This commit is contained in:
shanshanzhong 2026-02-03 04:40:23 -08:00
parent 5b238919f5
commit 709d657906
19 changed files with 1649 additions and 85 deletions

View File

@ -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
View 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 "{}"

View 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
}

View 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. **测试验证**: 重新运行测试程序验证数据获取

View 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))
}
}

View 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
}

View 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("========================================")
}

View 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
}

View 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
View 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 "$@"

View 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 加密解密库
## 注意事项
⚠️ **安全提示**:通讯密钥应妥善保管,不要泄露给未授权人员。

View File

@ -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 {

View File

@ -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 会拦截此路径进行虚拟更名处理)

View File

@ -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
}

View File

@ -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,

View File

@ -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,
})
}

View File

@ -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 {

View File

@ -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
View 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"