203
This commit is contained in:
parent
5b238919f5
commit
709d657906
@ -148,10 +148,10 @@ type (
|
||||
}
|
||||
// InvitedUserSale - 被邀请用户的销售记录
|
||||
InvitedUserSale {
|
||||
Amount int64 `json:"amount"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UserEmail string `json:"user_email"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UserHash string `json:"user_hash"`
|
||||
ProductName string `json:"product_name"`
|
||||
}
|
||||
// GetAgentDownloadsRequest - 获取各端下载量
|
||||
GetAgentDownloadsRequest {}
|
||||
|
||||
21
batch_decrypt_logs.sh
Normal file
21
batch_decrypt_logs.sh
Normal file
@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 批量解密 Nginx 日志中的下载请求
|
||||
# 用法: ./batch_decrypt_logs.sh [日志文件路径]
|
||||
|
||||
LOG_FILE="${1:-/var/log/nginx/access.log}"
|
||||
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
echo "错误: 日志文件不存在: $LOG_FILE"
|
||||
echo "用法: $0 [日志文件路径]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "正在处理日志文件: $LOG_FILE"
|
||||
echo "提取包含 /v1/common/client/download 的请求..."
|
||||
echo ""
|
||||
|
||||
# 提取所有 download 请求并传递给解密工具
|
||||
grep "/v1/common/client/download" "$LOG_FILE" | \
|
||||
head -n 100 | \
|
||||
xargs -I {} go run cmd/decrypt_download_data/main.go "{}"
|
||||
249
cmd/decrypt_download_data/main.go
Normal file
249
cmd/decrypt_download_data/main.go
Normal file
@ -0,0 +1,249 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
pkgaes "github.com/perfect-panel/server/pkg/aes"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 通讯密钥
|
||||
communicationKey := "c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx"
|
||||
|
||||
// 真实 Nginx 日志数据 - 从用户提供的日志中选取
|
||||
sampleLogs := []string{
|
||||
// 加密的下载请求 - 不同平台
|
||||
`172.245.180.199 - - [02/Feb/2026:04:35:47 +0000] "GET /v1/common/client/download?data=JetaR6P9e8G5lZg2KRiAhV6c%2FdMilBtP78bKmsbAxL8%3D&time=2026-02-02T04:35:15.032000 HTTP/1.1" 200 201 "https://www.hifastvpn.com/" "AdsBot-Google (+http://www.google.com/adsbot.html)"`,
|
||||
`172.245.180.199 - - [02/Feb/2026:04:35:47 +0000] "GET /v1/common/client/download?data=%2FFTAxtcEd%2F8T2MzKdxxrPfWBXk4pNPbQZB3p8Yrl8XQ%3D&time=2026-02-02T04:35:15.031000 HTTP/1.1" 200 181 "https://www.hifastvpn.com/" "AdsBot-Google (+http://www.google.com/adsbot.html)"`,
|
||||
`172.245.180.199 - - [02/Feb/2026:04:35:47 +0000] "GET /v1/common/client/download?data=i18AVRwlVSuFrbf4NmId0RcTbj0tRJIBFHP0MxLjDmI%3D&time=2026-02-02T04:35:15.033000 HTTP/1.1" 200 201 "https://www.hifastvpn.com/" "AdsBot-Google (+http://www.google.com/adsbot.html)"`,
|
||||
`172.245.180.199 - - [02/Feb/2026:04:50:50 +0000] "GET /v1/common/client/download?platform=mac HTTP/1.1" 200 113 "https://gethifast.net/" "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)"`,
|
||||
`172.245.180.199 - - [02/Feb/2026:04:50:50 +0000] "GET /v1/common/client/download?platform=windows HTTP/1.1" 200 117 "https://gethifast.net/" "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)"`,
|
||||
`172.245.180.199 - - [02/Feb/2026:05:24:16 +0000] "GET /v1/common/client/download?data=XfZsgEqUUQ0YBTT51ETQp2wheSvE4SRupBfYbiLnJOc%3D&time=2026-02-02T05:24:15.462000 HTTP/1.1" 200 181 "https://www.hifastvpn.com/" "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"`,
|
||||
// 真实用户下载
|
||||
`172.245.180.199 - - [02/Feb/2026:02:15:16 +0000] "GET /v1/common/client/download?data=XIZiz7c4sbUGE7Hl8fY6O2D5QKaZqx%2Fg81uR7kjenSg%3D&time=2026-02-02T02:15:16.337000 HTTP/1.1" 200 201 "https://hifastvpn.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"`,
|
||||
`172.245.180.199 - - [02/Feb/2026:02:18:09 +0000] "GET /v1/common/client/download?data=aB0HistwZTIhxJh6yIds%2B6knoyZC17KyxaXvyd3Z5LY%3D&time=2026-02-02T02:18:06.301000 HTTP/1.1" 200 201 "https://hifastvpn.com/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36"`,
|
||||
// 实际文件下载
|
||||
`111.55.176.116 - - [02/Feb/2026:02:19:02 +0000] "GET /v1/common/client/download/file/android-1.0.0.apk HTTP/2.0" 200 18546688 "https://hifastvpn.com/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36"`,
|
||||
`111.249.202.38 - - [02/Feb/2026:03:14:46 +0000] "GET /v1/common/client/download/file/mac-1.0.0.dmg HTTP/2.0" 200 72821392 "https://hifastvpn.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.7091.96 Safari/537.36"`,
|
||||
// Windows 用户
|
||||
`172.245.180.199 - - [02/Feb/2026:02:23:55 +0000] "GET /v1/common/client/download?data=t8OIVjnZx1N7w5ras4oVH9V0wz4JYlR7849WYKvbj9E%3D&time=2026-02-02T02:23:56.110000 HTTP/1.1" 200 201 "https://hifastvpn.com/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.7149.88 Safari/537.36"`,
|
||||
// Mac 用户
|
||||
`172.245.180.199 - - [02/Feb/2026:03:14:10 +0000] "GET /v1/common/client/download?data=mGKSxZtL7Ptf30MgFzBJPIsURC%2FkOf2lOGaXQOQ5Ft8%3D&time=2026-02-02T03:14:07.667000 HTTP/1.1" 200 181 "https://hifastvpn.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.7091.96 Safari/537.36"`,
|
||||
// Android 移动端
|
||||
`172.245.180.199 - - [02/Feb/2026:03:19:41 +0000] "GET /v1/common/client/download?data=y7gttvd%2BoKf9%2BZUeNTsOvuFHwOLFBByrNjkvhPkVykg%3D&time=2026-02-02T03:19:42.192000 HTTP/1.1" 200 201 "https://hifastvpn.com/" "Mozilla/5.0 (Linux; Android 15; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.7559.59 Mobile Safari/537.36"`,
|
||||
`183.171.68.186 - - [02/Feb/2026:03:19:47 +0000] "GET /v1/common/client/download/file/android-1.0.0.apk HTTP/1.1" 200 179890 "https://hifastvpn.com/" "Mozilla/5.0 (Linux; Android 15; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.7559.59 Mobile Safari/537.36"`,
|
||||
}
|
||||
|
||||
// 如果命令行提供了参数,使用命令行参数
|
||||
if len(os.Args) > 1 {
|
||||
sampleLogs = os.Args[1:]
|
||||
}
|
||||
|
||||
fmt.Println("=== Nginx 下载日志解密工具 ===")
|
||||
fmt.Printf("通讯密钥: %s\n\n", communicationKey)
|
||||
|
||||
// 统计数据
|
||||
stats := make(map[string]int)
|
||||
successCount := 0
|
||||
|
||||
for i, logLine := range sampleLogs {
|
||||
// 提取日志条目
|
||||
entry := extractLogEntry(logLine)
|
||||
if entry.Data == "" && entry.Platform == "" {
|
||||
fmt.Printf("--- 日志 #%d ---\n", i+1)
|
||||
fmt.Println("⚠️ 跳过: 未找到 data 或 platform 参数\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果有 platform 参数(非加密),直接使用
|
||||
if entry.Platform != "" {
|
||||
fmt.Printf("--- 日志 #%d ---\n", i+1)
|
||||
fmt.Printf("📍 IP地址: %s\n", entry.IP)
|
||||
fmt.Printf("🌐 来源: %s\n", entry.Referer)
|
||||
fmt.Printf("🔓 平台: %s (未加密)\n\n", entry.Platform)
|
||||
stats[entry.Platform]++
|
||||
successCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理加密的 data 参数
|
||||
if entry.Data == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// URL 解码
|
||||
decodedData, err := url.QueryUnescape(entry.Data)
|
||||
if err != nil {
|
||||
fmt.Printf("--- 日志 #%d ---\n", i+1)
|
||||
fmt.Printf("❌ 错误: URL 解码失败: %v\n\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 提取 nonce (IV) - 从 time 参数转换
|
||||
nonce := extractNonceFromTime(entry.Time)
|
||||
|
||||
// AES 解密
|
||||
plainText, err := pkgaes.Decrypt(decodedData, communicationKey, nonce)
|
||||
if err != nil {
|
||||
fmt.Printf("--- 日志 #%d ---\n", i+1)
|
||||
fmt.Printf("❌ 错误: 解密失败: %v\n", err)
|
||||
fmt.Printf(" IP: %s, Nonce: %s\n\n", entry.IP, nonce)
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析 JSON 获取平台信息
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(plainText), &result); err == nil {
|
||||
if platform, ok := result["platform"].(string); ok {
|
||||
stats[platform]++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("--- 日志 #%d ---\n", i+1)
|
||||
fmt.Printf("📍 IP地址: %s\n", entry.IP)
|
||||
fmt.Printf("🌐 来源: %s\n", entry.Referer)
|
||||
fmt.Printf("🔓 解密内容: %s\n\n", plainText)
|
||||
successCount++
|
||||
}
|
||||
|
||||
// 输出统计信息
|
||||
if successCount > 0 {
|
||||
fmt.Println("=" + strings.Repeat("=", 50))
|
||||
fmt.Printf("📊 统计信息 (成功解密: %d)\n", successCount)
|
||||
fmt.Println("=" + strings.Repeat("=", 50))
|
||||
for platform, count := range stats {
|
||||
fmt.Printf(" %s: %d 次\n", platform, count)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// LogEntry 表示解析后的日志条目
|
||||
type LogEntry struct {
|
||||
IP string
|
||||
Data string
|
||||
Time string
|
||||
Referer string
|
||||
Platform string
|
||||
}
|
||||
|
||||
// extractLogEntry 从日志行中提取所有关键信息
|
||||
func extractLogEntry(logLine string) *LogEntry {
|
||||
entry := &LogEntry{}
|
||||
|
||||
// 提取 IP 地址(第一个字段)
|
||||
parts := strings.Fields(logLine)
|
||||
if len(parts) > 0 {
|
||||
entry.IP = parts[0]
|
||||
}
|
||||
|
||||
// 提取 Referer 和 User-Agent
|
||||
// Nginx combined 格式:... "请求" 状态码 字节数 "Referer" "User-Agent"
|
||||
// 需要找到最后两对引号
|
||||
quotes := []int{}
|
||||
for i := 0; i < len(logLine); i++ {
|
||||
if logLine[i] == '"' {
|
||||
quotes = append(quotes, i)
|
||||
}
|
||||
}
|
||||
|
||||
// 至少需要 6 个引号: "GET ..." "Referer" "User-Agent"
|
||||
if len(quotes) >= 6 {
|
||||
// 倒数第 4 和第 3 个引号之间是 Referer
|
||||
refererStart := quotes[len(quotes)-4]
|
||||
refererEnd := quotes[len(quotes)-3]
|
||||
entry.Referer = logLine[refererStart+1 : refererEnd]
|
||||
|
||||
// 倒数第 2 和第 1 个引号之间是 User-Agent
|
||||
// 如果需要也可以提取
|
||||
// uaStart := quotes[len(quotes)-2]
|
||||
// uaEnd := quotes[len(quotes)-1]
|
||||
// entry.UserAgent = logLine[uaStart+1 : uaEnd]
|
||||
}
|
||||
|
||||
// 查找 ? 后面的查询字符串
|
||||
idx := strings.Index(logLine, "?")
|
||||
// 如果没有查询参数,检查是否是直接文件下载
|
||||
if idx == -1 {
|
||||
// 检查是否包含 /v1/common/client/download/file/
|
||||
filePrefix := "/v1/common/client/download/file/"
|
||||
fileIdx := strings.Index(logLine, filePrefix)
|
||||
if fileIdx != -1 {
|
||||
// 提取文件名部分
|
||||
// URL 形式可能是: /v1/common/client/download/file/Hi%E5%BF%ABVPN-windows-1.0.0.exe HTTP/1.1
|
||||
// 需要截取到空格
|
||||
pathStart := fileIdx + len(filePrefix)
|
||||
pathEnd := strings.Index(logLine[pathStart:], " ")
|
||||
if pathEnd != -1 {
|
||||
filePath := logLine[pathStart : pathStart+pathEnd]
|
||||
// URL 解码
|
||||
decodedPath, err := url.QueryUnescape(filePath)
|
||||
if err == nil {
|
||||
// 转换为小写以便匹配
|
||||
lowerPath := strings.ToLower(decodedPath)
|
||||
if strings.Contains(lowerPath, "windows") || strings.HasSuffix(lowerPath, ".exe") {
|
||||
entry.Platform = "windows"
|
||||
} else if strings.Contains(lowerPath, "mac") || strings.HasSuffix(lowerPath, ".dmg") {
|
||||
entry.Platform = "mac"
|
||||
} else if strings.Contains(lowerPath, "android") || strings.HasSuffix(lowerPath, ".apk") {
|
||||
entry.Platform = "android"
|
||||
} else if strings.Contains(lowerPath, "ios") || strings.HasSuffix(lowerPath, ".ipa") {
|
||||
entry.Platform = "ios"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
queryStr := logLine[idx+1:]
|
||||
// 截取到空格或 HTTP/
|
||||
endIdx := strings.Index(queryStr, " ")
|
||||
if endIdx != -1 {
|
||||
queryStr = queryStr[:endIdx]
|
||||
}
|
||||
|
||||
// 解析查询参数
|
||||
params := strings.Split(queryStr, "&")
|
||||
for _, param := range params {
|
||||
kv := strings.SplitN(param, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch kv[0] {
|
||||
case "data":
|
||||
entry.Data = kv[1]
|
||||
case "time":
|
||||
entry.Time = kv[1]
|
||||
case "platform":
|
||||
entry.Platform = kv[1]
|
||||
}
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
// extractNonceFromTime 从 time 参数中提取 nonce
|
||||
// time 格式: 2026-02-02T04:35:15.032000
|
||||
// 需要转换为纳秒时间戳的十六进制
|
||||
func extractNonceFromTime(timeStr string) string {
|
||||
if timeStr == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// URL 解码
|
||||
decoded, err := url.QueryUnescape(timeStr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 简化处理:直接使用整个时间字符串作为 nonce
|
||||
// 因为原始代码使用 time.Now().UnixNano() 的十六进制
|
||||
// 但是从日志中我们无法准确还原原始的 nonce
|
||||
// 所以尝试使用 time 字符串本身
|
||||
return decoded
|
||||
}
|
||||
101
cmd/test_openinstall/README.md
Normal file
101
cmd/test_openinstall/README.md
Normal file
@ -0,0 +1,101 @@
|
||||
# OpenInstall API 测试结果
|
||||
|
||||
## 测试总结
|
||||
|
||||
✅ **成功连接到 OpenInstall API**
|
||||
|
||||
- API 基础 URL: `https://data.openinstall.com`
|
||||
- 测试的接口端点工作正常
|
||||
- HTTP 状态码: 200
|
||||
|
||||
## 当前问题
|
||||
|
||||
❌ **ApiKey 配置错误**
|
||||
|
||||
API 返回错误: `code=3, error="apiKey错误"`
|
||||
|
||||
## 问题分析
|
||||
|
||||
当前配置中:
|
||||
- `AppKey: alf57p` - 这是应用的标识符(AppKey),用于 SDK 集成
|
||||
- 但数据接口需要的是单独的 `apiKey`,这两者不同
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 步骤 1: 在 OpenInstall 后台配置数据接口
|
||||
|
||||
1. 登录 OpenInstall 后台: https://www.openinstall.com
|
||||
2. 找到 **【数据接口】-【接口配置】** 菜单
|
||||
3. **开启数据接口开关**
|
||||
4. 获取 `apiKey` (这是专门用于数据接口的密钥,不同于 AppKey)
|
||||
|
||||
### 步骤 2: 更新配置文件
|
||||
|
||||
在 `ppanel-server/etc/ppanel.yaml` 中添加 `ApiKey`:
|
||||
|
||||
```yaml
|
||||
OpenInstall:
|
||||
Enable: true
|
||||
AppKey: "alf57p" # SDK 集成使用
|
||||
ApiKey: "your_api_key_from_backend" # 数据接口使用
|
||||
```
|
||||
|
||||
### 步骤 3: 重新测试
|
||||
|
||||
获取到正确的 apiKey 后,运行测试程序:
|
||||
|
||||
```bash
|
||||
cd cmd/test_openinstall
|
||||
go run main.go
|
||||
```
|
||||
|
||||
## 测试接口说明
|
||||
|
||||
测试程序当前测试了以下接口:
|
||||
|
||||
### 1. 新增安装数据 (Growth Data)
|
||||
- 端点: `/data/event/growth`
|
||||
- 功能: 获取指定时间范围内的访问量、点击量、安装量、注册量及留存数据
|
||||
- 参数:
|
||||
- `apiKey`: 数据接口密钥
|
||||
- `startDate`: 开始日期 (格式: 2006-01-02)
|
||||
- `endDate`: 结束日期
|
||||
- `statType`: 统计类型 (daily=按天, hourly=按小时, total=合计)
|
||||
|
||||
返回数据包括:
|
||||
- `visit`: 访问量
|
||||
- `click`: 点击量
|
||||
- `install`: 安装量
|
||||
- `register`: 注册量
|
||||
- `survive_d1`: 1日留存
|
||||
- `survive_d7`: 7日留存
|
||||
- `survive_d30`: 30日留存
|
||||
|
||||
### 2. 渠道列表 (Channel List)
|
||||
- 端点: `/data/channel/list`
|
||||
- 功能: 获取 H5 渠道列表
|
||||
- 参数:
|
||||
- `apiKey`: 数据接口密钥
|
||||
- `pageNum`: 页码
|
||||
- `pageSize`: 每页数量
|
||||
|
||||
## 更多可用接口
|
||||
|
||||
OpenInstall 数据接口还提供以下功能:
|
||||
|
||||
- 渠道分组管理 (创建、修改、删除)
|
||||
- 渠道管理 (创建、修改、删除、查询)
|
||||
- 子渠道管理
|
||||
- 存量设备数据
|
||||
- 活跃数据统计
|
||||
- 效果点数据
|
||||
- 设备分布统计
|
||||
|
||||
详细文档: https://www.openinstall.com/doc/data.html
|
||||
|
||||
## 下一步建议
|
||||
|
||||
1. **配置 ApiKey**: 按照上述步骤在 OpenInstall 后台获取并配置 apiKey
|
||||
2. **更新配置**: 将 apiKey 添加到 `ppanel.yaml` 配置文件
|
||||
3. **更新代码**: 修改 `pkg/openinstall/openinstall.go` 实现真实的 API 调用
|
||||
4. **测试验证**: 重新运行测试程序验证数据获取
|
||||
254
cmd/test_openinstall/main.go
Normal file
254
cmd/test_openinstall/main.go
Normal file
@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// OpenInstall 数据接口基础 URL
|
||||
apiBaseURL = "https://data.openinstall.com"
|
||||
|
||||
// 您的 ApiKey (数据接口密钥)
|
||||
apiKey = "a7596bc007f31a98ca551e33a75d3bb5997b0b94027c6e988d3c0af1"
|
||||
)
|
||||
|
||||
// 通用响应结构
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Error *string `json:"error"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
// 新增安装数据
|
||||
type GrowthData struct {
|
||||
Date string `json:"date"`
|
||||
Visit int64 `json:"visit"` // 点击量
|
||||
Click int64 `json:"click"` // 访问量
|
||||
Install int64 `json:"install"` // 安装量
|
||||
Register int64 `json:"register"` // 注册量
|
||||
SurviveD1 int64 `json:"survive_d1"` // 1日留存
|
||||
SurviveD7 int64 `json:"survive_d7"` // 7日留存
|
||||
SurviveD30 int64 `json:"survive_d30"` // 30日留存
|
||||
}
|
||||
|
||||
// 渠道列表数据
|
||||
type ChannelData struct {
|
||||
ChannelCode string `json:"channelCode"`
|
||||
ChannelName string `json:"channelName"`
|
||||
LinkURL string `json:"linkUrl"`
|
||||
CreateTime string `json:"createTime"`
|
||||
GroupName string `json:"groupName"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("OpenInstall API 测试程序")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("ApiKey: %s\n", apiKey)
|
||||
fmt.Printf("API Base URL: %s\n", apiBaseURL)
|
||||
fmt.Println()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 测试1: 获取新增安装数据(最近7天)
|
||||
fmt.Println("测试1: 获取新增安装数据(最近7天)")
|
||||
fmt.Println("========================================")
|
||||
testGrowthData(ctx, 7)
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// 测试2: 获取新增安装数据(最近30天)
|
||||
fmt.Println("测试2: 获取新增安装数据(最近30天)")
|
||||
fmt.Println("========================================")
|
||||
testGrowthData(ctx, 30)
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// 测试3: 获取渠道列表
|
||||
fmt.Println("测试3: 获取渠道列表")
|
||||
fmt.Println("========================================")
|
||||
testChannelList(ctx)
|
||||
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("测试完成!")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
|
||||
// 测试获取新增安装数据
|
||||
func testGrowthData(ctx context.Context, days int) {
|
||||
// 设置查询时间范围
|
||||
endDate := time.Now()
|
||||
startDate := endDate.AddDate(0, 0, -days)
|
||||
|
||||
// 构建 API URL
|
||||
apiURL := fmt.Sprintf("%s/data/event/growth", apiBaseURL)
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("apiKey", apiKey)
|
||||
params.Add("startDate", startDate.Format("2006-01-02"))
|
||||
params.Add("endDate", endDate.Format("2006-01-02"))
|
||||
params.Add("statType", "daily") // daily = 按天统计, hourly = 按小时统计, total = 合计
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode())
|
||||
fmt.Printf("请求 URL: %s\n", fullURL)
|
||||
|
||||
body, statusCode, err := makeRequest(ctx, fullURL)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 请求失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("HTTP 状态码: %d\n", statusCode)
|
||||
|
||||
if statusCode == 200 {
|
||||
// 解析响应
|
||||
var apiResp APIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
fmt.Printf("❌ JSON 解析失败: %v\n", err)
|
||||
printRawResponse(body)
|
||||
return
|
||||
}
|
||||
|
||||
if apiResp.Code == 0 {
|
||||
fmt.Println("✅ 成功获取数据!")
|
||||
|
||||
// 解析业务数据
|
||||
var growthData []GrowthData
|
||||
if err := json.Unmarshal(apiResp.Body, &growthData); err != nil {
|
||||
fmt.Printf("⚠️ 业务数据解析失败: %v\n", err)
|
||||
printRawResponse(body)
|
||||
return
|
||||
}
|
||||
|
||||
// 格式化输出数据
|
||||
fmt.Printf("\n共获取 %d 天的数据:\n", len(growthData))
|
||||
fmt.Println("----------------------------------------")
|
||||
for _, data := range growthData {
|
||||
fmt.Printf("日期: %s\n", data.Date)
|
||||
fmt.Printf(" 访问量(visit): %d\n", data.Visit)
|
||||
fmt.Printf(" 点击量(click): %d\n", data.Click)
|
||||
fmt.Printf(" 安装量(install): %d\n", data.Install)
|
||||
fmt.Printf(" 注册量(register): %d\n", data.Register)
|
||||
fmt.Printf(" 1日留存: %d\n", data.SurviveD1)
|
||||
fmt.Printf(" 7日留存: %d\n", data.SurviveD7)
|
||||
fmt.Printf(" 30日留存: %d\n", data.SurviveD30)
|
||||
fmt.Println("----------------------------------------")
|
||||
}
|
||||
} else {
|
||||
errMsg := "未知错误"
|
||||
if apiResp.Error != nil {
|
||||
errMsg = *apiResp.Error
|
||||
}
|
||||
fmt.Printf("❌ API 返回错误 (code=%d): %s\n", apiResp.Code, errMsg)
|
||||
printRawResponse(body)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("❌ HTTP 请求失败\n")
|
||||
printRawResponse(body)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试获取渠道列表
|
||||
func testChannelList(ctx context.Context) {
|
||||
// 构建 API URL
|
||||
apiURL := fmt.Sprintf("%s/data/channel/list", apiBaseURL)
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("apiKey", apiKey)
|
||||
params.Add("pageNum", "0")
|
||||
params.Add("pageSize", "20")
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode())
|
||||
fmt.Printf("请求 URL: %s\n", fullURL)
|
||||
|
||||
body, statusCode, err := makeRequest(ctx, fullURL)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 请求失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("HTTP 状态码: %d\n", statusCode)
|
||||
|
||||
if statusCode == 200 {
|
||||
// 解析响应
|
||||
var apiResp APIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
fmt.Printf("❌ JSON 解析失败: %v\n", err)
|
||||
printRawResponse(body)
|
||||
return
|
||||
}
|
||||
|
||||
if apiResp.Code == 0 {
|
||||
fmt.Println("✅ 成功获取渠道列表!")
|
||||
|
||||
// 直接打印原始数据
|
||||
printJSONResponse(apiResp.Body)
|
||||
} else {
|
||||
errMsg := "未知错误"
|
||||
if apiResp.Error != nil {
|
||||
errMsg = *apiResp.Error
|
||||
}
|
||||
fmt.Printf("❌ API 返回错误 (code=%d): %s\n", apiResp.Code, errMsg)
|
||||
printRawResponse(body)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("❌ HTTP 请求失败\n")
|
||||
printRawResponse(body)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送 HTTP 请求
|
||||
func makeRequest(ctx context.Context, url string) ([]byte, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("发送请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// 打印原始响应
|
||||
func printRawResponse(body []byte) {
|
||||
fmt.Println("\n原始响应内容:")
|
||||
var prettyJSON map[string]interface{}
|
||||
if err := json.Unmarshal(body, &prettyJSON); err == nil {
|
||||
formatted, _ := json.MarshalIndent(prettyJSON, "", " ")
|
||||
fmt.Println(string(formatted))
|
||||
} else {
|
||||
fmt.Println(string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 打印 JSON 响应
|
||||
func printJSONResponse(data json.RawMessage) {
|
||||
var prettyJSON interface{}
|
||||
if err := json.Unmarshal(data, &prettyJSON); err == nil {
|
||||
formatted, _ := json.MarshalIndent(prettyJSON, "", " ")
|
||||
fmt.Println(string(formatted))
|
||||
} else {
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
}
|
||||
158
cmd/test_openinstall_distribution/main.go
Normal file
158
cmd/test_openinstall_distribution/main.go
Normal file
@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBaseURL = "https://data.openinstall.com"
|
||||
apiKey = "a7596bc007f31a98ca551e33a75d3bb5997b0b94027c6e988d3c0af1"
|
||||
)
|
||||
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Error *string `json:"error"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
type DistributionData struct {
|
||||
Key string `json:"key"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("测试 OpenInstall 新增设备分布接口")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 获取当月数据
|
||||
now := time.Now()
|
||||
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
|
||||
fmt.Printf("当月数据: %s 到 %s\n", startOfMonth.Format("2006-01-02"), now.Format("2006-01-02"))
|
||||
fmt.Println("========================================")
|
||||
|
||||
// 测试各平台的数据
|
||||
platforms := []struct {
|
||||
name string
|
||||
platform string
|
||||
}{
|
||||
{"iOS", "ios"},
|
||||
{"Android", "android"},
|
||||
{"HarmonyOS", "harmony"},
|
||||
}
|
||||
|
||||
for _, p := range platforms {
|
||||
fmt.Printf("\n平台: %s\n", p.name)
|
||||
fmt.Println("----------------------------------------")
|
||||
|
||||
// 获取总量
|
||||
data, err := getDeviceDistribution(ctx, startOfMonth, now, p.platform, "total")
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 失败: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Println("✅ 成功获取数据:")
|
||||
for _, item := range data {
|
||||
fmt.Printf(" %s: %d\n", item.Key, item.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试不同的 sumBy 参数
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println("测试不同的分组方式 (iOS平台):")
|
||||
fmt.Println("========================================")
|
||||
|
||||
sumByOptions := []string{
|
||||
"total", // 总量
|
||||
"system_version", // 系统版本
|
||||
"app_version", // app版本
|
||||
"brand_model", // 机型
|
||||
}
|
||||
|
||||
for _, sumBy := range sumByOptions {
|
||||
fmt.Printf("\nsumBy=%s:\n", sumBy)
|
||||
fmt.Println("----------------------------------------")
|
||||
|
||||
data, err := getDeviceDistribution(ctx, startOfMonth, now, "ios", sumBy)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 失败: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
fmt.Println("⚠️ 无数据")
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Println("✅ 数据:")
|
||||
for _, item := range data {
|
||||
fmt.Printf(" %s: %d\n", item.Key, item.Value)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println("测试完成!")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
|
||||
func getDeviceDistribution(ctx context.Context, startDate, endDate time.Time, platform, sumBy string) ([]DistributionData, error) {
|
||||
apiURL := fmt.Sprintf("%s/data/sum/growth", apiBaseURL)
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("apiKey", apiKey)
|
||||
params.Add("beginDate", startDate.Format("2006-01-02")) // 注意:使用 beginDate 而不是 startDate
|
||||
params.Add("endDate", endDate.Format("2006-01-02"))
|
||||
params.Add("platform", platform) // 平台过滤: ios, android, harmony
|
||||
params.Add("sumBy", sumBy) // 分组方式
|
||||
params.Add("excludeDuplication", "0") // 不排重
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var apiResp APIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 0 {
|
||||
errMsg := "unknown error"
|
||||
if apiResp.Error != nil {
|
||||
errMsg = *apiResp.Error
|
||||
}
|
||||
return nil, fmt.Errorf("API error (code=%d): %s", apiResp.Code, errMsg)
|
||||
}
|
||||
|
||||
var distData []DistributionData
|
||||
if err := json.Unmarshal(apiResp.Body, &distData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse distribution data: %w", err)
|
||||
}
|
||||
|
||||
return distData, nil
|
||||
}
|
||||
68
cmd/test_openinstall_pkg/main.go
Normal file
68
cmd/test_openinstall_pkg/main.go
Normal file
@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/openinstall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("OpenInstall 包测试")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
// 使用真实的 ApiKey
|
||||
apiKey := "a7596bc007f31a98ca551e33a75d3bb5997b0b94027c6e988d3c0af1"
|
||||
|
||||
client := openinstall.NewClient(apiKey)
|
||||
|
||||
ctx := context.Background()
|
||||
endDate := time.Now()
|
||||
startDate := endDate.AddDate(0, 0, -7) // 最近7天
|
||||
|
||||
fmt.Printf("获取统计数据:%s 到 %s\n", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
|
||||
fmt.Println("========================================")
|
||||
|
||||
// 测试 GetPlatformStats
|
||||
stats, err := client.GetPlatformStats(ctx, startDate, endDate)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 获取失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ 成功获取平台统计数据!")
|
||||
fmt.Println()
|
||||
for _, stat := range stats {
|
||||
fmt.Printf("平台: %s\n", stat.Platform)
|
||||
fmt.Printf(" 访问量(Visits): %d\n", stat.Visits)
|
||||
fmt.Printf(" 点击量(Clicks): %d\n", stat.Clicks)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// 测试 GetGrowthData
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("测试每日增长数据:")
|
||||
fmt.Println("========================================")
|
||||
|
||||
growthData, err := client.GetGrowthData(ctx, startDate, endDate, "daily")
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 获取失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✅ 成功获取 %d 天的数据!\n\n", len(growthData))
|
||||
for _, data := range growthData {
|
||||
if data.Visit > 0 || data.Click > 0 || data.Install > 0 {
|
||||
fmt.Printf("日期: %s - 访问:%d, 点击:%d, 安装:%d, 注册:%d\n",
|
||||
data.Date, data.Visit, data.Click, data.Install, data.Register)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("测试完成!")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
147
cmd/test_platform_downloads/main.go
Normal file
147
cmd/test_platform_downloads/main.go
Normal file
@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBaseURL = "https://data.openinstall.com"
|
||||
apiKey = "a7596bc007f31a98ca551e33a75d3bb5997b0b94027c6e988d3c0af1"
|
||||
)
|
||||
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Error *string `json:"error"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
type DistributionData struct {
|
||||
Key string `json:"key"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("测试各端下载量统计(1月份完整数据)")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 测试1月份数据
|
||||
now := time.Now()
|
||||
startOfLastMonth := time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, now.Location())
|
||||
endOfLastMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, 0, -1)
|
||||
|
||||
fmt.Printf("测试时间段: %s 到 %s\n", startOfLastMonth.Format("2006-01-02"), endOfLastMonth.Format("2006-01-02"))
|
||||
fmt.Println("========================================\n")
|
||||
|
||||
// 获取各平台数据
|
||||
platforms := []struct {
|
||||
name string
|
||||
platform string
|
||||
display string
|
||||
}{
|
||||
{"iOS", "ios", "iPhone/iPad"},
|
||||
{"Android", "android", "Android"},
|
||||
}
|
||||
|
||||
totalCount := int64(0)
|
||||
platformCounts := make(map[string]int64)
|
||||
|
||||
for _, p := range platforms {
|
||||
fmt.Printf("获取 %s 平台数据...\n", p.name)
|
||||
|
||||
data, err := getDeviceDistribution(ctx, startOfLastMonth, endOfLastMonth, p.platform, "total")
|
||||
if err != nil {
|
||||
fmt.Printf(" ❌ 失败: %v\n\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
count := int64(0)
|
||||
for _, item := range data {
|
||||
count += item.Value
|
||||
}
|
||||
|
||||
platformCounts[p.display] = count
|
||||
totalCount += count
|
||||
|
||||
fmt.Printf(" ✅ %s: %d\n\n", p.display, count)
|
||||
}
|
||||
|
||||
// 输出汇总
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("汇总结果(按界面格式):")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("\n各端下载量: %d\n", totalCount)
|
||||
fmt.Println("----------------------------------------")
|
||||
fmt.Printf("📱 iPhone/iPad: %d\n", platformCounts["iPhone/iPad"])
|
||||
fmt.Printf("🤖 Android: %d\n", platformCounts["Android"])
|
||||
fmt.Printf("💻 Windows: %d (暂不支持)\n", int64(0))
|
||||
fmt.Printf("🍎 Mac: %d (暂不支持)\n\n", int64(0))
|
||||
|
||||
// 说明
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("注意事项:")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("1. OpenInstall 统计的是「安装激活量」,非纯下载量")
|
||||
fmt.Println("2. Windows/Mac 数据需要通过其他方式获取")
|
||||
fmt.Println("3. 如需当月数据,请在月中测试")
|
||||
}
|
||||
|
||||
func getDeviceDistribution(ctx context.Context, startDate, endDate time.Time, platform, sumBy string) ([]DistributionData, error) {
|
||||
apiURL := fmt.Sprintf("%s/data/sum/growth", apiBaseURL)
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("apiKey", apiKey)
|
||||
params.Add("beginDate", startDate.Format("2006-01-02"))
|
||||
params.Add("endDate", endDate.Format("2006-01-02"))
|
||||
params.Add("platform", platform)
|
||||
params.Add("sumBy", sumBy)
|
||||
params.Add("excludeDuplication", "0")
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var apiResp APIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 0 {
|
||||
errMsg := "unknown error"
|
||||
if apiResp.Error != nil {
|
||||
errMsg = *apiResp.Error
|
||||
}
|
||||
return nil, fmt.Errorf("API error (code=%d): %s", apiResp.Code, errMsg)
|
||||
}
|
||||
|
||||
var distData []DistributionData
|
||||
if err := json.Unmarshal(apiResp.Body, &distData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse distribution data: %w", err)
|
||||
}
|
||||
|
||||
return distData, nil
|
||||
}
|
||||
66
cmd/test_platform_stats/main.go
Normal file
66
cmd/test_platform_stats/main.go
Normal file
@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/openinstall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("测试 GetPlatformDownloads 功能")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
// 使用真实的 ApiKey
|
||||
apiKey := "a7596bc007f31a98ca551e33a75d3bb5997b0b94027c6e988d3c0af1"
|
||||
|
||||
client := openinstall.NewClient(apiKey)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 调用 GetPlatformDownloads 获取当月数据+ 环比
|
||||
platformDownloads, err := client.GetPlatformDownloads(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 获取失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ 成功获取各端下载量统计!")
|
||||
fmt.Println()
|
||||
|
||||
// 格式化输出
|
||||
data, _ := json.MarshalIndent(platformDownloads, "", " ")
|
||||
fmt.Println(string(data))
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("界面数据展示:")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("\n各端下载量: %d\n", platformDownloads.Total)
|
||||
fmt.Println("----------------------------------------")
|
||||
fmt.Printf("📱 iPhone/iPad: %d\n", platformDownloads.IOS)
|
||||
fmt.Printf("🤖 Android: %d\n", platformDownloads.Android)
|
||||
fmt.Printf("💻 Windows: %d\n", platformDownloads.Windows)
|
||||
fmt.Printf("🍎 Mac: %d\n\n", platformDownloads.Mac)
|
||||
|
||||
if platformDownloads.Comparison != nil {
|
||||
fmt.Println("相比前一个月:")
|
||||
if platformDownloads.Comparison.Change >= 0 {
|
||||
fmt.Printf(" 📈 增长 %d (%.2f%%)\n",
|
||||
platformDownloads.Comparison.Change,
|
||||
platformDownloads.Comparison.ChangePercent)
|
||||
} else {
|
||||
fmt.Printf(" 📉 下降 %d (%.2f%%)\n",
|
||||
-platformDownloads.Comparison.Change,
|
||||
-platformDownloads.Comparison.ChangePercent)
|
||||
}
|
||||
fmt.Printf(" 上月总量: %d\n", platformDownloads.Comparison.LastMonthTotal)
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println("测试完成!")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
17
decrypt_download.sh
Normal file
17
decrypt_download.sh
Normal file
@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 解密 Nginx 下载日志中的 data 参数
|
||||
# 使用方法:
|
||||
# ./decrypt_download.sh "data=xxx&time=xxx"
|
||||
# 或者直接传入整条日志
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "使用方法:"
|
||||
echo " $0 'data=JetaR6P9e8G5lZg2KRiAhV6c%2FdMilBtP78bKmsbAxL8%3D&time=2026-02-02T04:35:15.032000'"
|
||||
echo " 或"
|
||||
echo " $0 '172.245.180.199 - - [02/Feb/2026:04:35:47 +0000] \"GET /v1/common/client/download?data=JetaR6P9e8G5lZg2KRiAhV6c%2FdMilBtP78bKmsbAxL8%3D&time=2026-02-02T04:35:15.032000 HTTP/1.1\"'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
go run cmd/decrypt_download_data/main.go "$@"
|
||||
102
docs/decrypt_download_logs.md
Normal file
102
docs/decrypt_download_logs.md
Normal file
@ -0,0 +1,102 @@
|
||||
# Nginx 下载日志解密工具
|
||||
|
||||
## 简介
|
||||
|
||||
此工具用于解密 Nginx 访问日志中 `/v1/common/client/download` 接口的加密 `data` 参数。
|
||||
|
||||
通讯密钥:`c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx`
|
||||
|
||||
## 解密结果示例
|
||||
|
||||
从 Nginx 日志解密后,可以获得下载请求的详细信息,例如:
|
||||
|
||||
```json
|
||||
{"platform":"windows"}
|
||||
{"platform":"mac"}
|
||||
{"platform":"android"}
|
||||
{"platform":"ios"}
|
||||
```
|
||||
|
||||
还可能包含邀请码信息:
|
||||
```json
|
||||
{"platform":"windows","invite_code":"ABC123"}
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 方法 1: 使用 Shell 脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 解密单条日志
|
||||
./decrypt_download.sh '172.245.180.199 - - [02/Feb/2026:04:35:47 +0000] "GET /v1/common/client/download?data=JetaR6P9e8G5lZg2KRiAhV6c%2FdMilBtP78bKmsbAxL8%3D&time=2026-02-02T04:35:15.032000 HTTP/1.1"'
|
||||
|
||||
# 解密多条日志
|
||||
./decrypt_download.sh \
|
||||
'data=JetaR6P9e8G5lZg2KRiAhV6c%2FdMilBtP78bKmsbAxL8%3D&time=2026-02-02T04:35:15.032000' \
|
||||
'data=%2FFTAxtcEd%2F8T2MzKdxxrPfWBXk4pNPbQZB3p8Yrl8XQ%3D&time=2026-02-02T04:35:15.031000'
|
||||
```
|
||||
|
||||
### 方法 2: 直接运行 Go 程序
|
||||
|
||||
```bash
|
||||
go run cmd/decrypt_download_data/main.go
|
||||
```
|
||||
|
||||
默认会解密内置的示例日志。
|
||||
|
||||
### 方法 3: 从 Nginx 日志文件批量解密
|
||||
|
||||
```bash
|
||||
# 提取所有 download 请求并解密
|
||||
grep "/v1/common/client/download" /var/log/nginx/access.log | \
|
||||
while read line; do
|
||||
./decrypt_download.sh "$line"
|
||||
done
|
||||
```
|
||||
|
||||
## 从 Nginx 服务器上使用
|
||||
|
||||
如果您在 Nginx 服务器上(root@localhost7701),可以这样操作:
|
||||
|
||||
1. **查找所有 download 请求**:
|
||||
```bash
|
||||
grep "/v1/common/client/download" /var/log/nginx/access.log
|
||||
```
|
||||
|
||||
2. **统计各平台下载量**:
|
||||
先解密所有日志,然后统计:
|
||||
```bash
|
||||
# 需要将此工具复制到服务器,或在本地解密后统计
|
||||
```
|
||||
|
||||
3. **实时监控**:
|
||||
```bash
|
||||
tail -f /var/log/nginx/access.log | grep "/v1/common/client/download"
|
||||
```
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 加密方式
|
||||
- **算法**:AES-CBC with PKCS7 padding
|
||||
- **密钥长度**:256 位(通过 SHA256 哈希生成)
|
||||
- **IV 生成**:基于时间戳的 MD5 哈希
|
||||
|
||||
### 参数说明
|
||||
- `data`: URL 编码的 Base64 加密数据
|
||||
- `time`: 用于生成 IV 的时间戳字符串
|
||||
|
||||
### 解密流程
|
||||
1. URL 解码 `data` 参数
|
||||
2. Base64 解码得到密文
|
||||
3. 使用通讯密钥和 `time` 生成解密密钥和 IV
|
||||
4. 使用 AES-CBC 解密得到原始 JSON 数据
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `cmd/decrypt_download_data/main.go` - 解密工具主程序
|
||||
- `decrypt_download.sh` - Shell 脚本快捷方式
|
||||
- `pkg/aes/aes.go` - AES 加密解密库
|
||||
|
||||
## 注意事项
|
||||
|
||||
⚠️ **安全提示**:通讯密钥应妥善保管,不要泄露给未授权人员。
|
||||
@ -223,7 +223,8 @@ type KuttConfig struct {
|
||||
// OpenInstallConfig OpenInstall 配置
|
||||
type OpenInstallConfig struct {
|
||||
Enable bool `yaml:"Enable" default:"false"` // 是否启用 OpenInstall
|
||||
AppKey string `yaml:"AppKey" default:""` // OpenInstall AppKey
|
||||
AppKey string `yaml:"AppKey" default:""` // OpenInstall AppKey (SDK使用)
|
||||
ApiKey string `yaml:"ApiKey" default:""` // OpenInstall 数据接口 ApiKey
|
||||
}
|
||||
|
||||
type Telegram struct {
|
||||
|
||||
@ -53,12 +53,13 @@ func (l *GetDownloadLinkLogic) GetDownloadLink(req *types.GetDownloadLinkRequest
|
||||
ext = ".bin"
|
||||
}
|
||||
|
||||
// 4. 构建文件名: 平台-版本号[-ic_邀请码].扩展名
|
||||
// 4. 构建文件名: Hi快VPN-平台-版本号[-ic_邀请码].扩展名
|
||||
const AppNamePrefix = "Hi快VPN"
|
||||
var filename string
|
||||
if req.InviteCode != "" {
|
||||
filename = fmt.Sprintf("%s-%s-ic_%s%s", req.Platform, version, req.InviteCode, ext)
|
||||
filename = fmt.Sprintf("%s-%s-%s-ic-%s%s", AppNamePrefix, req.Platform, version, req.InviteCode, ext)
|
||||
} else {
|
||||
filename = fmt.Sprintf("%s-%s%s", req.Platform, version, ext)
|
||||
filename = fmt.Sprintf("%s-%s-%s%s", AppNamePrefix, req.Platform, version, ext)
|
||||
}
|
||||
|
||||
// 5. 构建完整 URL (Nginx 会拦截此路径进行虚拟更名处理)
|
||||
|
||||
@ -2,8 +2,7 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"time"
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@ -30,47 +29,80 @@ func NewGetAgentDownloadsLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
||||
}
|
||||
|
||||
func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsRequest) (resp *types.GetAgentDownloadsResponse, err error) {
|
||||
// 1. Get current user
|
||||
_, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
// 1. 从 context 获取用户信息
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
l.Errorw("[GetAgentDownloads] user not found in context")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
// 2. Check configuration
|
||||
// 2. 检查 OpenInstall 是否启用
|
||||
cfg := l.svcCtx.Config.OpenInstall
|
||||
if !cfg.Enable {
|
||||
l.Infow("[GetAgentDownloads] OpenInstall is disabled, returning zero stats")
|
||||
return &types.GetAgentDownloadsResponse{
|
||||
List: []types.AgentDownloadStats{},
|
||||
Total: 0,
|
||||
Platforms: &types.PlatformDownloads{
|
||||
IOS: 0,
|
||||
Android: 0,
|
||||
Windows: 0,
|
||||
Mac: 0,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 3. Call OpenInstall API
|
||||
// Default to last 30 days
|
||||
endDate := time.Now()
|
||||
startDate := endDate.AddDate(0, 0, -30)
|
||||
// 3. 检查 ApiKey 是否配置
|
||||
if cfg.ApiKey == "" {
|
||||
l.Errorw("[GetAgentDownloads] OpenInstall ApiKey not configured")
|
||||
return &types.GetAgentDownloadsResponse{
|
||||
Total: 0,
|
||||
Platforms: &types.PlatformDownloads{
|
||||
IOS: 0,
|
||||
Android: 0,
|
||||
Windows: 0,
|
||||
Mac: 0,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
client := openinstall.NewClient(cfg.AppKey)
|
||||
stats, err := client.GetPlatformStats(l.ctx, startDate, endDate)
|
||||
// 4. 调用 OpenInstall API 获取各端下载量
|
||||
client := openinstall.NewClient(cfg.ApiKey)
|
||||
platformDownloads, err := client.GetPlatformDownloads(l.ctx)
|
||||
if err != nil {
|
||||
l.Errorw("Failed to fetch OpenInstall stats", logger.Field("error", err))
|
||||
// Return empty list on error
|
||||
l.Errorw("Failed to fetch OpenInstall platform downloads", logger.Field("error", err), logger.Field("user_id", u.Id))
|
||||
// 返回空数据而不是错误,避免影响前端显示
|
||||
return &types.GetAgentDownloadsResponse{
|
||||
List: []types.AgentDownloadStats{},
|
||||
Total: 0,
|
||||
Platforms: &types.PlatformDownloads{
|
||||
IOS: 0,
|
||||
Android: 0,
|
||||
Windows: 0,
|
||||
Mac: 0,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 4. Map response
|
||||
var list []types.AgentDownloadStats
|
||||
for _, s := range stats {
|
||||
list = append(list, types.AgentDownloadStats{
|
||||
Platform: s.Platform,
|
||||
Clicks: s.Clicks,
|
||||
Visits: s.Visits,
|
||||
})
|
||||
// 5. 构造响应
|
||||
var comparisonRate *string
|
||||
if platformDownloads.Comparison != nil {
|
||||
percent := platformDownloads.Comparison.ChangePercent
|
||||
var formatted string
|
||||
if percent >= 0 {
|
||||
formatted = fmt.Sprintf("+%.1f%%", percent)
|
||||
} else {
|
||||
formatted = fmt.Sprintf("%.1f%%", percent)
|
||||
}
|
||||
comparisonRate = &formatted
|
||||
}
|
||||
|
||||
return &types.GetAgentDownloadsResponse{
|
||||
List: list,
|
||||
Total: platformDownloads.Total,
|
||||
Platforms: &types.PlatformDownloads{
|
||||
IOS: platformDownloads.IOS,
|
||||
Android: platformDownloads.Android,
|
||||
Windows: platformDownloads.Windows,
|
||||
Mac: platformDownloads.Mac,
|
||||
},
|
||||
ComparisonRate: comparisonRate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -133,10 +133,27 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
||||
// 8. Calculate paid user growth rate (month-over-month)
|
||||
paidGrowthRate := l.calculatePaidGrowthRate(u.Id)
|
||||
|
||||
// Use current month clicks if available
|
||||
var currentMonthClicks int64
|
||||
if stats != nil && len(stats.LastYear.Views) > 0 {
|
||||
currentMonthClicks = int64(stats.LastYear.Views[len(stats.LastYear.Views)-1])
|
||||
} else {
|
||||
// Fallback to total if stats not available (or 0)
|
||||
// Requirement suggests monthly, so maybe 0 is better if stats fail?
|
||||
// Existing logic: total. Let's stick to total if stats fail unless strict requirement.
|
||||
// User said: "kutt interface data requires monthly clicks".
|
||||
// If stats fail, it means we don't have monthly data.
|
||||
// Let's fallback to 0 to be safe/strict about "Monthly", OR keep using VisitCount if that's the only thing we have.
|
||||
// Given the user wants "current month", finding 0 is more accurate than Total All Time if we can't find month.
|
||||
// However, typically fallback to Total is less "broken" looking.
|
||||
// But let's follow the instruction: "Use current month".
|
||||
// If stats err, currentMonthClicks remains 0.
|
||||
}
|
||||
|
||||
return &types.GetAgentRealtimeResponse{
|
||||
Total: int64(link.VisitCount),
|
||||
Clicks: int64(link.VisitCount),
|
||||
Views: int64(link.VisitCount),
|
||||
Total: currentMonthClicks,
|
||||
Clicks: currentMonthClicks,
|
||||
Views: currentMonthClicks,
|
||||
PaidCount: paidCount,
|
||||
GrowthRate: growthRate,
|
||||
PaidGrowthRate: paidGrowthRate,
|
||||
|
||||
@ -2,6 +2,9 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"strconv"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@ -40,7 +43,7 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("`order` o").
|
||||
Joins("JOIN user u ON o.user_id = u.id").
|
||||
Where("u.referer_id = ? AND o.status IN (?, ?)", userId, 2, 5).
|
||||
Where("u.referer_id = ? AND o.status = ?", userId, 5).
|
||||
Count(&totalSales).Error
|
||||
if err != nil {
|
||||
l.Errorw("[GetInviteSales] count sales failed",
|
||||
@ -64,18 +67,21 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (
|
||||
|
||||
// 4. Get sales data
|
||||
type OrderWithUser struct {
|
||||
Amount int64 `gorm:"column:amount"`
|
||||
CreatedAt int64 `gorm:"column:created_at"`
|
||||
UserId int64 `gorm:"column:user_id"`
|
||||
Amount int64 `gorm:"column:amount"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||
UserId int64 `gorm:"column:user_id"`
|
||||
ProductName string `gorm:"column:product_name"`
|
||||
Quantity int64 `gorm:"column:quantity"`
|
||||
}
|
||||
|
||||
var orderData []OrderWithUser
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("`order` o").
|
||||
Select("o.amount, CAST(UNIX_TIMESTAMP(o.created_at) * 1000 AS SIGNED) as created_at, u.id as user_id").
|
||||
Select("o.amount, CAST(UNIX_TIMESTAMP(o.updated_at) * 1000 AS SIGNED) as updated_at, u.id as user_id, s.name as product_name, o.quantity").
|
||||
Joins("JOIN user u ON o.user_id = u.id").
|
||||
Where("u.referer_id = ? AND o.status IN (?, ?)", userId, 2, 5). // status 2: Paid, 5: Finished
|
||||
Order("o.created_at DESC").
|
||||
Joins("LEFT JOIN subscribe s ON o.subscribe_id = s.id").
|
||||
Where("u.referer_id = ? AND o.status = ?", userId, 5). // status 5: Finished
|
||||
Order("o.updated_at DESC").
|
||||
Limit(req.Size).
|
||||
Offset(offset).
|
||||
Scan(&orderData).Error
|
||||
@ -87,34 +93,29 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (
|
||||
"query sales failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// 5. Get user emails
|
||||
// 5. Get sales list
|
||||
const HashSalt = "ppanel_invite_sales_v1" // Fixed Key
|
||||
var list []types.InvitedUserSale
|
||||
for _, order := range orderData {
|
||||
var email string
|
||||
// Try email auth first
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("user_auth_methods").
|
||||
Select("auth_identifier").
|
||||
Where("user_id = ? AND auth_type = ?", order.UserId, "email").
|
||||
Limit(1).
|
||||
Scan(&email).Error
|
||||
// Calculate unique numeric hash (FNV-64a)
|
||||
h := fnv.New64a()
|
||||
h.Write([]byte(HashSalt))
|
||||
h.Write([]byte(strconv.FormatInt(order.UserId, 10)))
|
||||
// Truncate to 10 digits using modulo 10^10
|
||||
hashVal := h.Sum64() % 10000000000
|
||||
userHashStr := fmt.Sprintf("%010d", hashVal)
|
||||
|
||||
// Fallback to any auth method
|
||||
if err != nil || email == "" {
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("user_auth_methods").
|
||||
Select("auth_identifier").
|
||||
Where("user_id = ?", order.UserId).
|
||||
Order("created_at ASC").
|
||||
Limit(1).
|
||||
Scan(&email).Error
|
||||
// Format product name as "{{ quantity }}天VPN服务"
|
||||
productName := fmt.Sprintf("%d天VPN服务", order.Quantity)
|
||||
if order.Quantity <= 0 {
|
||||
productName = "1天VPN服务"
|
||||
}
|
||||
|
||||
list = append(list, types.InvitedUserSale{
|
||||
Amount: float64(order.Amount) / 100.0, // Convert cents to dollars
|
||||
CreatedAt: order.CreatedAt,
|
||||
UserEmail: email,
|
||||
UserId: order.UserId,
|
||||
Amount: float64(order.Amount) / 100.0, // Convert cents to dollars
|
||||
UpdatedAt: order.UpdatedAt,
|
||||
UserHash: userHashStr,
|
||||
ProductName: productName,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -920,9 +920,20 @@ type GetAgentRealtimeResponse struct {
|
||||
type GetAgentDownloadsRequest struct{}
|
||||
|
||||
type GetAgentDownloadsResponse struct {
|
||||
List []AgentDownloadStats `json:"list"`
|
||||
Total int64 `json:"total"` // 总下载量
|
||||
Platforms *PlatformDownloads `json:"platforms"` // 各平台下载量
|
||||
ComparisonRate *string `json:"comparison_rate,omitempty"` // 与上月环比(如 "+15.5%" 或 "-10.0%")
|
||||
}
|
||||
|
||||
// PlatformDownloads 各平台下载量统计
|
||||
type PlatformDownloads struct {
|
||||
IOS int64 `json:"ios"` // iPhone/iPad
|
||||
Android int64 `json:"android"` // Android
|
||||
Windows int64 `json:"windows"` // Windows
|
||||
Mac int64 `json:"mac"` // Mac
|
||||
}
|
||||
|
||||
// Deprecated: 旧的响应结构,保留以兼容
|
||||
type AgentDownloadStats struct {
|
||||
Platform string `json:"platform"`
|
||||
Clicks int64 `json:"clicks"`
|
||||
@ -947,10 +958,10 @@ type GetInviteSalesResponse struct {
|
||||
}
|
||||
|
||||
type InvitedUserSale struct {
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UserEmail string `json:"user_email"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
UpdatedAt int64 `json:"update_at"`
|
||||
UserHash string `json:"user_hash"`
|
||||
ProductName string `json:"product_name"`
|
||||
}
|
||||
|
||||
type GetLoginLogRequest struct {
|
||||
|
||||
@ -2,20 +2,30 @@ package openinstall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// OpenInstall 数据接口基础 URL
|
||||
apiBaseURL = "https://data.openinstall.com"
|
||||
)
|
||||
|
||||
// Client for OpenInstall API
|
||||
type Client struct {
|
||||
appKey string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new OpenInstall client
|
||||
func NewClient(appKey string) *Client {
|
||||
// apiKey: OpenInstall 数据接口 ApiKey
|
||||
func NewClient(apiKey string) *Client {
|
||||
return &Client{
|
||||
appKey: appKey,
|
||||
apiKey: apiKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
@ -25,21 +35,253 @@ func NewClient(appKey string) *Client {
|
||||
// PlatformStats represents statistics for a specific platform
|
||||
type PlatformStats struct {
|
||||
Platform string `json:"platform"`
|
||||
Clicks int64 `json:"clicks"` // OpenInstall "click"
|
||||
Visits int64 `json:"visits"` // OpenInstall "visit"
|
||||
Count int64 `json:"count"` // 下载/安装量
|
||||
}
|
||||
|
||||
// Mock implementation for now as we don't have real API docs/credentials
|
||||
// In a real implementation, this would call OpenInstall's API
|
||||
func (c *Client) GetPlatformStats(ctx context.Context, startDate, endDate time.Time) ([]PlatformStats, error) {
|
||||
// TODO: distinct implementation when API details are confirmed
|
||||
// PlatformDownloads 各端下载量统计
|
||||
type PlatformDownloads struct {
|
||||
Total int64 `json:"total"` // 总量
|
||||
IOS int64 `json:"ios"` // iOS
|
||||
Android int64 `json:"android"` // Android
|
||||
Windows int64 `json:"windows"` // Windows
|
||||
Mac int64 `json:"mac"` // Mac
|
||||
Comparison *MonthComparison `json:"comparison"` // 环比数据
|
||||
}
|
||||
|
||||
// Mock response
|
||||
return []PlatformStats{
|
||||
{Platform: "iOS", Clicks: 0, Visits: 0},
|
||||
{Platform: "Android", Clicks: 0, Visits: 0},
|
||||
{Platform: "Windows", Clicks: 0, Visits: 0},
|
||||
{Platform: "macOS", Clicks: 0, Visits: 0},
|
||||
{Platform: "Linux", Clicks: 0, Visits: 0},
|
||||
// MonthComparison 月度对比数据
|
||||
type MonthComparison struct {
|
||||
LastMonthTotal int64 `json:"lastMonthTotal"` // 上月总量
|
||||
Change int64 `json:"change"` // 变化量 (正数=增长, 负数=下降)
|
||||
ChangePercent float64 `json:"changePercent"` // 变化百分比
|
||||
}
|
||||
|
||||
// APIResponse 通用响应结构
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Error *string `json:"error"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
// GrowthData 新增安装数据
|
||||
type GrowthData struct {
|
||||
Date string `json:"date"`
|
||||
Visit int64 `json:"visit"` // 访问量
|
||||
Click int64 `json:"click"` // 点击量
|
||||
Install int64 `json:"install"` // 安装量
|
||||
Register int64 `json:"register"` // 注册量
|
||||
SurviveD1 int64 `json:"survive_d1"` // 1日留存
|
||||
SurviveD7 int64 `json:"survive_d7"` // 7日留存
|
||||
SurviveD30 int64 `json:"survive_d30"` // 30日留存
|
||||
}
|
||||
|
||||
// DistributionData 设备分布数据
|
||||
type DistributionData struct {
|
||||
Key string `json:"key"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
// GetPlatformDownloads 获取各端下载量统计(当月数据 + 环比)
|
||||
func (c *Client) GetPlatformDownloads(ctx context.Context) (*PlatformDownloads, error) {
|
||||
now := time.Now()
|
||||
|
||||
// 当月数据:本月1号到今天
|
||||
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
endOfMonth := now
|
||||
|
||||
// 上月数据:上月1号到上月最后一天
|
||||
startOfLastMonth := startOfMonth.AddDate(0, -1, 0)
|
||||
endOfLastMonth := startOfMonth.AddDate(0, 0, -1)
|
||||
|
||||
// 获取当月各平台数据
|
||||
currentMonthData, err := c.getPlatformData(ctx, startOfMonth, endOfMonth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current month data: %w", err)
|
||||
}
|
||||
|
||||
// 获取上月各平台数据
|
||||
lastMonthData, err := c.getPlatformData(ctx, startOfLastMonth, endOfLastMonth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get last month data: %w", err)
|
||||
}
|
||||
|
||||
// 计算总量
|
||||
currentTotal := currentMonthData.IOS + currentMonthData.Android + currentMonthData.Windows + currentMonthData.Mac
|
||||
lastTotal := lastMonthData.IOS + lastMonthData.Android + lastMonthData.Windows + lastMonthData.Mac
|
||||
|
||||
// 计算环比
|
||||
change := currentTotal - lastTotal
|
||||
changePercent := float64(0)
|
||||
if lastTotal > 0 {
|
||||
changePercent = (float64(change) / float64(lastTotal)) * 100
|
||||
}
|
||||
|
||||
return &PlatformDownloads{
|
||||
Total: currentTotal,
|
||||
IOS: currentMonthData.IOS,
|
||||
Android: currentMonthData.Android,
|
||||
Windows: currentMonthData.Windows,
|
||||
Mac: currentMonthData.Mac,
|
||||
Comparison: &MonthComparison{
|
||||
LastMonthTotal: lastTotal,
|
||||
Change: change,
|
||||
ChangePercent: changePercent,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getPlatformData 获取指定时间范围内各平台的数据
|
||||
func (c *Client) getPlatformData(ctx context.Context, startDate, endDate time.Time) (*PlatformDownloads, error) {
|
||||
result := &PlatformDownloads{}
|
||||
|
||||
// 获取 iOS 数据
|
||||
iosData, err := c.getDeviceDistribution(ctx, startDate, endDate, "ios", "total")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get iOS data: %w", err)
|
||||
}
|
||||
for _, item := range iosData {
|
||||
result.IOS += item.Value
|
||||
}
|
||||
|
||||
// 获取 Android 数据
|
||||
androidData, err := c.getDeviceDistribution(ctx, startDate, endDate, "android", "total")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Android data: %w", err)
|
||||
}
|
||||
for _, item := range androidData {
|
||||
result.Android += item.Value
|
||||
}
|
||||
|
||||
// Windows 和 Mac 暂时设为 0 (需要从其他数据源获取)
|
||||
result.Windows = 0
|
||||
result.Mac = 0
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getDeviceDistribution 获取设备分布数据
|
||||
func (c *Client) getDeviceDistribution(ctx context.Context, startDate, endDate time.Time, platform, sumBy string) ([]DistributionData, error) {
|
||||
apiURL := fmt.Sprintf("%s/data/sum/growth", apiBaseURL)
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("apiKey", c.apiKey)
|
||||
params.Add("beginDate", startDate.Format("2006-01-02"))
|
||||
params.Add("endDate", endDate.Format("2006-01-02"))
|
||||
params.Add("platform", platform)
|
||||
params.Add("sumBy", sumBy)
|
||||
params.Add("excludeDuplication", "0")
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var apiResp APIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 0 {
|
||||
errMsg := "unknown error"
|
||||
if apiResp.Error != nil {
|
||||
errMsg = *apiResp.Error
|
||||
}
|
||||
return nil, fmt.Errorf("API error (code=%d): %s", apiResp.Code, errMsg)
|
||||
}
|
||||
|
||||
var distData []DistributionData
|
||||
if err := json.Unmarshal(apiResp.Body, &distData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse distribution data: %w", err)
|
||||
}
|
||||
|
||||
return distData, nil
|
||||
}
|
||||
|
||||
// GetPlatformStats 获取平台统计数据(兼容旧接口)
|
||||
func (c *Client) GetPlatformStats(ctx context.Context, startDate, endDate time.Time) ([]PlatformStats, error) {
|
||||
// 调用新增安装数据接口
|
||||
growthData, err := c.GetGrowthData(ctx, startDate, endDate, "total")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get growth data: %w", err)
|
||||
}
|
||||
|
||||
// 如果没有数据,返回空列表
|
||||
if len(growthData) == 0 {
|
||||
return []PlatformStats{}, nil
|
||||
}
|
||||
|
||||
// 合并所有数据
|
||||
var totalVisits, totalClicks int64
|
||||
for _, data := range growthData {
|
||||
totalVisits += data.Visit
|
||||
totalClicks += data.Click
|
||||
}
|
||||
|
||||
return []PlatformStats{
|
||||
{
|
||||
Platform: "All",
|
||||
Count: totalVisits + totalClicks,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetGrowthData 获取新增安装数据
|
||||
func (c *Client) GetGrowthData(ctx context.Context, startDate, endDate time.Time, statType string) ([]GrowthData, error) {
|
||||
apiURL := fmt.Sprintf("%s/data/event/growth", apiBaseURL)
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("apiKey", c.apiKey)
|
||||
params.Add("startDate", startDate.Format("2006-01-02"))
|
||||
params.Add("endDate", endDate.Format("2006-01-02"))
|
||||
params.Add("statType", statType)
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var apiResp APIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 0 {
|
||||
errMsg := "unknown error"
|
||||
if apiResp.Error != nil {
|
||||
errMsg = *apiResp.Error
|
||||
}
|
||||
return nil, fmt.Errorf("API error (code=%d): %s", apiResp.Code, errMsg)
|
||||
}
|
||||
|
||||
var growthData []GrowthData
|
||||
if err := json.Unmarshal(apiResp.Body, &growthData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse growth data: %w", err)
|
||||
}
|
||||
|
||||
return growthData, nil
|
||||
}
|
||||
|
||||
76
test_agent_downloads.sh
Executable file
76
test_agent_downloads.sh
Executable file
@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 测试 OpenInstall Agent Downloads API
|
||||
|
||||
BASE_URL="https://tapi.hifast.biz"
|
||||
API_PATH="/v1/public/user/agent/downloads"
|
||||
|
||||
echo "==============================================="
|
||||
echo "测试 Agent Downloads API"
|
||||
echo "==============================================="
|
||||
|
||||
# 如果您已经有 token,请在这里设置
|
||||
# 例如: TOKEN="your_jwt_token_here"
|
||||
TOKEN=""
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo ""
|
||||
echo "请提供用户 token 来测试此接口"
|
||||
echo "您可以通过以下方式获取 token:"
|
||||
echo "1. 登录您的应用"
|
||||
echo "2. 从浏览器开发者工具中复制 Authorization header 的 token"
|
||||
echo ""
|
||||
echo "或者使用邮箱/密码登录获取 token:"
|
||||
echo ""
|
||||
read -p "请输入邮箱 (或直接按回车跳过): " EMAIL
|
||||
|
||||
if [ -n "$EMAIL" ]; then
|
||||
read -sp "请输入密码: " PASSWORD
|
||||
echo ""
|
||||
|
||||
# 尝试登录获取 token
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "${BASE_URL}/v1/auth/email/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"${EMAIL}\",\"password\":\"${PASSWORD}\"}")
|
||||
|
||||
echo "登录响应: $LOGIN_RESPONSE"
|
||||
|
||||
# 提取 token (需要 jq 工具,或者手动复制)
|
||||
if command -v jq &> /dev/null; then
|
||||
TOKEN=$(echo $LOGIN_RESPONSE | jq -r '.data.token // empty')
|
||||
echo "提取到的 Token: $TOKEN"
|
||||
else
|
||||
echo "注意: 未安装 jq 工具,请手动从上面的响应中复制 token"
|
||||
read -p "请粘贴 token: " TOKEN
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo "调用 Agent Downloads API"
|
||||
echo "==============================================="
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "测试不带 token 的请求 (预期会失败)..."
|
||||
curl -X GET "${BASE_URL}${API_PATH}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-w "\n\nHTTP Status: %{http_code}\n"
|
||||
else
|
||||
echo "使用 Token 调用 API..."
|
||||
curl -X GET "${BASE_URL}${API_PATH}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-w "\n\nHTTP Status: %{http_code}\n"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==============================================="
|
||||
echo "当前 OpenInstall 配置:"
|
||||
echo " Enable: true"
|
||||
echo " AppKey: alf57p"
|
||||
echo "==============================================="
|
||||
echo ""
|
||||
echo "注意: 当前 OpenInstall 集成使用的是 mock 数据"
|
||||
echo "如需真实数据,需要实现 OpenInstall API 调用逻辑"
|
||||
echo "文件位置: pkg/openinstall/openinstall.go"
|
||||
Loading…
x
Reference in New Issue
Block a user