This commit is contained in:
parent
28ada42ae5
commit
f518b8b1eb
@ -13,7 +13,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
# Docker镜像仓库
|
# Docker镜像仓库
|
||||||
REPO: ${{ vars.REPO || 'registry.kxsw.us/ario-server' }}
|
REPO: ${{ vars.REPO || 'registry.kxsw.us/vpn-server' }}
|
||||||
# SSH连接信息
|
# SSH连接信息
|
||||||
SSH_HOST: ${{ vars.SSH_HOST }}
|
SSH_HOST: ${{ vars.SSH_HOST }}
|
||||||
SSH_PORT: ${{ vars.SSH_PORT }}
|
SSH_PORT: ${{ vars.SSH_PORT }}
|
||||||
@ -23,15 +23,15 @@ env:
|
|||||||
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
|
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
|
||||||
TG_CHAT_ID: "-4940243803"
|
TG_CHAT_ID: "-4940243803"
|
||||||
# Go构建变量
|
# Go构建变量
|
||||||
SERVICE: ario
|
SERVICE: vpn
|
||||||
SERVICE_STYLE: ario
|
SERVICE_STYLE: vpn
|
||||||
VERSION: ${{ github.sha }}
|
VERSION: ${{ github.sha }}
|
||||||
BUILDTIME: ${{ github.event.head_commit.timestamp }}
|
BUILDTIME: ${{ github.event.head_commit.timestamp }}
|
||||||
GOARCH: amd64
|
GOARCH: amd64
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ario-server
|
runs-on: vpn-server
|
||||||
container:
|
container:
|
||||||
image: node:20
|
image: node:20
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
@ -10,7 +10,7 @@ services:
|
|||||||
# 1. 业务后端 (PPanel Server)
|
# 1. 业务后端 (PPanel Server)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
ppanel-server:
|
ppanel-server:
|
||||||
image: registry.kxsw.us/ppanel/new-server:v1.0.2
|
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:-latest}
|
||||||
container_name: ppanel-server
|
container_name: ppanel-server
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
@ -22,7 +22,7 @@ services:
|
|||||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317
|
- OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317
|
||||||
- OTEL_SERVICE_NAME=ppanel-server
|
- OTEL_SERVICE_NAME=ppanel-server
|
||||||
- OTEL_TRACES_EXPORTER=otlp
|
- OTEL_TRACES_EXPORTER=otlp
|
||||||
- OTEL_METRICS_EXPORTER=none # 指标由 tempo 抓取,不使用 OTLP
|
- OTEL_METRICS_EXPORTER=prometheus # 指标由 tempo 抓取,不使用 OTLP
|
||||||
network_mode: host
|
network_mode: host
|
||||||
ulimits:
|
ulimits:
|
||||||
nproc: 65535
|
nproc: 65535
|
||||||
@ -43,14 +43,24 @@ services:
|
|||||||
# 14. Tempo (链路追踪存储 - 替代/增强 Jaeger)
|
# 14. Tempo (链路追踪存储 - 替代/增强 Jaeger)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
tempo:
|
tempo:
|
||||||
image: grafana/tempo:latest
|
image: grafana/tempo:2.4.1
|
||||||
container_name: ppanel-tempo
|
container_name: ppanel-tempo
|
||||||
|
user: root
|
||||||
restart: always
|
restart: always
|
||||||
command: [ "-config.file=/etc/tempo.yaml" ]
|
command:
|
||||||
|
- "-config.file=/etc/tempo.yaml"
|
||||||
|
- "-target=all"
|
||||||
volumes:
|
volumes:
|
||||||
- ./tempo/tempo-config.yaml:/etc/tempo.yaml
|
- ./tempo/tempo-config.yaml:/etc/tempo.yaml # - tempo_data:/var/tempo
|
||||||
- tempo_data:/var/tempo
|
- ./tempo_data:/var/tempo # 改为映射到当前目录,确保数据彻底干净
|
||||||
network_mode: host
|
|
||||||
|
ports:
|
||||||
|
- "3200:3200"
|
||||||
|
- "4317:4317"
|
||||||
|
- "4318:4318"
|
||||||
|
- "9095:9095"
|
||||||
|
networks:
|
||||||
|
- ppanel_net
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
@ -133,6 +143,8 @@ services:
|
|||||||
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
|
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
|
||||||
- loki_data:/loki
|
- loki_data:/loki
|
||||||
command: -config.file=/etc/loki/local-config.yaml
|
command: -config.file=/etc/loki/local-config.yaml
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
networks:
|
networks:
|
||||||
- ppanel_net
|
- ppanel_net
|
||||||
logging:
|
logging:
|
||||||
@ -154,6 +166,8 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
# 采集当前目录下的 logs 文件夹
|
# 采集当前目录下的 logs 文件夹
|
||||||
- ./logs:/var/log/ppanel-server:ro
|
- ./logs:/var/log/ppanel-server:ro
|
||||||
|
# 采集 Nginx 访问日志(用于追踪邀请码来源)
|
||||||
|
- /var/log/nginx:/var/log/nginx:ro
|
||||||
command: -config.file=/etc/promtail/config.yaml
|
command: -config.file=/etc/promtail/config.yaml
|
||||||
networks:
|
networks:
|
||||||
- ppanel_net
|
- ppanel_net
|
||||||
@ -177,7 +191,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
- GF_FEATURE_TOGGLES_ENABLE=appObservability
|
- GF_FEATURE_TOGGLES_ENABLE=appObservability #- GF_INSTALL_PLUGINS=redis-datasource
|
||||||
volumes:
|
volumes:
|
||||||
- grafana_data:/var/lib/grafana
|
- grafana_data:/var/lib/grafana
|
||||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||||
@ -211,6 +225,8 @@ services:
|
|||||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
- '--storage.tsdb.path=/prometheus'
|
- '--storage.tsdb.path=/prometheus'
|
||||||
- '--web.enable-lifecycle'
|
- '--web.enable-lifecycle'
|
||||||
|
- '--web.enable-remote-write-receiver'
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- ppanel_net
|
- ppanel_net
|
||||||
logging:
|
logging:
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
|
||||||
jaeger:
|
|
||||||
image: jaegertracing/all-in-one:latest
|
|
||||||
container_name: jaeger
|
|
||||||
ports:
|
|
||||||
- "16686:16686"
|
|
||||||
- "4317:4317"
|
|
||||||
- "4318:4318"
|
|
||||||
environment:
|
|
||||||
# - SPAN_STORAGE_TYPE=elasticsearch
|
|
||||||
# - ES_SERVER_URLS=http://elasticsearch:9200
|
|
||||||
- LOG_LEVEL=debug
|
|
||||||
- COLLECTOR_OTLP_ENABLED=true
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '0.8'
|
|
||||||
memory: 500M
|
|
||||||
reservations:
|
|
||||||
cpus: '0.05'
|
|
||||||
memory: 200M
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
|
||||||
ppanel:
|
|
||||||
container_name: ppanel-server
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- ./etc/ppanel.yaml:/app/etc/ppanel.yaml
|
|
||||||
restart: always
|
|
||||||
@ -2,15 +2,12 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
"github.com/perfect-panel/server/pkg/loki"
|
|
||||||
"github.com/perfect-panel/server/pkg/openinstall"
|
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
@ -30,8 +27,17 @@ func NewGetAgentDownloadsLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlatformStats 各平台设备统计结果
|
||||||
|
type PlatformStats struct {
|
||||||
|
Android int64 `gorm:"column:android"`
|
||||||
|
IOS int64 `gorm:"column:ios"`
|
||||||
|
Mac int64 `gorm:"column:mac"`
|
||||||
|
Windows int64 `gorm:"column:windows"`
|
||||||
|
Total int64 `gorm:"column:total"`
|
||||||
|
}
|
||||||
|
|
||||||
// GetAgentDownloads 获取用户代理下载统计数据
|
// GetAgentDownloads 获取用户代理下载统计数据
|
||||||
// 结合 OpenInstall (iOS/Android) 和 Loki (Windows/Mac) 数据源
|
// 基于用户邀请码查询被邀请用户的设备UA来统计各平台安装量
|
||||||
func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsRequest) (resp *types.GetAgentDownloadsResponse, err error) {
|
func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsRequest) (resp *types.GetAgentDownloadsResponse, err error) {
|
||||||
// 1. 从 context 获取用户信息
|
// 1. 从 context 获取用户信息
|
||||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||||
@ -40,69 +46,49 @@ func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsR
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化响应数据
|
// 2. 通过数据库查询各平台设备安装量
|
||||||
var iosCount, androidCount, windowsCount, macCount int64
|
// 基于 user_device 表的 user_agent 字段判断平台
|
||||||
var comparisonRate *string
|
// UA格式: HiVPN/1.0.0 (平台; 设备; 版本) Flutter
|
||||||
|
var stats PlatformStats
|
||||||
|
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Table("user u").
|
||||||
|
Select(`
|
||||||
|
SUM(CASE WHEN d.user_agent LIKE '%(Android;%' THEN 1 ELSE 0 END) AS android,
|
||||||
|
SUM(CASE WHEN d.user_agent LIKE '%(iOS;%' THEN 1 ELSE 0 END) AS ios,
|
||||||
|
SUM(CASE WHEN d.user_agent LIKE '%(macOS;%' THEN 1 ELSE 0 END) AS mac,
|
||||||
|
SUM(CASE WHEN d.user_agent LIKE '%(Windows;%' THEN 1 ELSE 0 END) AS windows,
|
||||||
|
COUNT(*) AS total
|
||||||
|
`).
|
||||||
|
Joins("JOIN user_device d ON u.id = d.user_id").
|
||||||
|
Where("u.referer_id = ?", u.Id).
|
||||||
|
Scan(&stats).Error
|
||||||
|
|
||||||
// 2. 从 OpenInstall 获取 iOS/Android 数据
|
if err != nil {
|
||||||
openInstallCfg := l.svcCtx.Config.OpenInstall
|
l.Errorw("[GetAgentDownloads] query platform stats failed",
|
||||||
if openInstallCfg.Enable && openInstallCfg.ApiKey != "" {
|
logger.Field("error", err.Error()),
|
||||||
client := openinstall.NewClient(openInstallCfg.ApiKey)
|
logger.Field("user_id", u.Id))
|
||||||
platformDownloads, err := client.GetPlatformDownloads(l.ctx, u.ReferCode)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
|
||||||
if err != nil {
|
"query platform stats failed: %v", err.Error())
|
||||||
l.Errorw("Failed to fetch OpenInstall platform downloads", logger.Field("error", err), logger.Field("user_id", u.Id))
|
|
||||||
// 不返回错误,继续处理其他数据源
|
|
||||||
} else {
|
|
||||||
iosCount = platformDownloads.IOS
|
|
||||||
androidCount = platformDownloads.Android
|
|
||||||
// OpenInstall 的 Windows/Mac 数据可能为空,后面用 Loki 补充
|
|
||||||
|
|
||||||
// 计算环比
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 从 Loki 获取 Windows/Mac 数据(基于用户邀请码)
|
l.Infow("[GetAgentDownloads] platform stats fetched successfully",
|
||||||
lokiCfg := l.svcCtx.Config.Loki
|
logger.Field("user_id", u.Id),
|
||||||
if lokiCfg.Enable && lokiCfg.URL != "" && u.ReferCode != "" {
|
logger.Field("refer_code", u.ReferCode),
|
||||||
lokiClient := loki.NewClient(lokiCfg.URL)
|
logger.Field("android", stats.Android),
|
||||||
lokiStats, err := lokiClient.GetInviteCodeStats(l.ctx, u.ReferCode, 30)
|
logger.Field("ios", stats.IOS),
|
||||||
if err != nil {
|
logger.Field("mac", stats.Mac),
|
||||||
l.Errorw("Failed to fetch Loki stats", logger.Field("error", err), logger.Field("user_id", u.Id), logger.Field("refer_code", u.ReferCode))
|
logger.Field("windows", stats.Windows),
|
||||||
// 不返回错误,继续使用已有数据
|
logger.Field("total", stats.Total))
|
||||||
} else {
|
|
||||||
// 使用 Loki 的 Windows/Mac 数据
|
|
||||||
windowsCount = lokiStats.WindowsClicks
|
|
||||||
macCount = lokiStats.MacClicks
|
|
||||||
l.Infow("Fetched Loki stats successfully",
|
|
||||||
logger.Field("user_id", u.Id),
|
|
||||||
logger.Field("refer_code", u.ReferCode),
|
|
||||||
logger.Field("windows", windowsCount),
|
|
||||||
logger.Field("mac", macCount))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 计算总量
|
// 3. 构造响应
|
||||||
total := iosCount + androidCount + windowsCount + macCount
|
|
||||||
|
|
||||||
// 5. 构造响应
|
|
||||||
return &types.GetAgentDownloadsResponse{
|
return &types.GetAgentDownloadsResponse{
|
||||||
Total: total,
|
Total: stats.Total,
|
||||||
Platforms: &types.PlatformDownloads{
|
Platforms: &types.PlatformDownloads{
|
||||||
IOS: iosCount,
|
IOS: stats.IOS,
|
||||||
Android: androidCount,
|
Android: stats.Android,
|
||||||
Windows: windowsCount,
|
Windows: stats.Windows,
|
||||||
Mac: macCount,
|
Mac: stats.Mac,
|
||||||
},
|
},
|
||||||
ComparisonRate: comparisonRate,
|
ComparisonRate: nil, // 不再计算环比
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -10,8 +9,8 @@ import (
|
|||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/kutt"
|
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/loki"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
@ -38,154 +37,75 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check if Kutt is enabled
|
var views, lastMonthViews int64
|
||||||
cfg := l.svcCtx.Config.Kutt
|
var installs int64
|
||||||
if !cfg.Enable || cfg.ApiKey == "" || cfg.ApiURL == "" {
|
|
||||||
l.Infow("Kutt service not enabled or configured",
|
|
||||||
logger.Field("enable", cfg.Enable),
|
|
||||||
logger.Field("has_key", cfg.ApiKey != ""),
|
|
||||||
logger.Field("has_url", cfg.ApiURL != ""))
|
|
||||||
return &types.GetAgentRealtimeResponse{
|
|
||||||
Total: 0,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Get share URL and domain
|
|
||||||
shareUrl := l.getShareUrl()
|
|
||||||
domain := l.getDomain()
|
|
||||||
|
|
||||||
if shareUrl == "" {
|
|
||||||
l.Errorw("ShareUrl not configured for Kutt integration")
|
|
||||||
return &types.GetAgentRealtimeResponse{
|
|
||||||
Total: 0,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Construct target URL
|
|
||||||
// Format: https://gethifast.net/?ic=INVITECODE
|
|
||||||
inviteCode := u.ReferCode
|
|
||||||
if inviteCode == "" {
|
|
||||||
l.Errorw("User has no refer code", logger.Field("user_id", u.Id))
|
|
||||||
return &types.GetAgentRealtimeResponse{
|
|
||||||
Total: 0,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
target := fmt.Sprintf("%s?ic=%s", shareUrl, inviteCode)
|
|
||||||
|
|
||||||
// 5. Call Kutt API to get link stats
|
|
||||||
// We use Reuse=true to get the existing link details including stats
|
|
||||||
client := kutt.NewClient(cfg.ApiURL, cfg.ApiKey)
|
|
||||||
kuttReq := &kutt.CreateLinkRequest{
|
|
||||||
Target: target,
|
|
||||||
Description: fmt.Sprintf("Invite link for code: %s", inviteCode),
|
|
||||||
Reuse: true,
|
|
||||||
Domain: domain,
|
|
||||||
}
|
|
||||||
|
|
||||||
link, err := client.CreateShortLink(l.ctx, kuttReq)
|
|
||||||
if err != nil {
|
|
||||||
l.Errorw("Failed to create/fetch Kutt link",
|
|
||||||
logger.Field("error", err.Error()),
|
|
||||||
logger.Field("user_id", u.Id),
|
|
||||||
logger.Field("target", target))
|
|
||||||
// Return 0 on error, don't block the UI
|
|
||||||
return &types.GetAgentRealtimeResponse{
|
|
||||||
Total: 0,
|
|
||||||
GrowthRate: "0%",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Get detailed stats for growth rate calculation
|
|
||||||
stats, err := client.GetLinkStats(l.ctx, link.ID)
|
|
||||||
var growthRate string
|
|
||||||
if err != nil {
|
|
||||||
l.Errorw("Failed to fetch Kutt detailed stats, using basic count only",
|
|
||||||
logger.Field("error", err.Error()),
|
|
||||||
logger.Field("link_id", link.ID))
|
|
||||||
growthRate = "N/A"
|
|
||||||
} else {
|
|
||||||
// Calculate month-over-month growth rate
|
|
||||||
// lastYear.views is an array of 12 months, last element is current month, second-to-last is previous month
|
|
||||||
growthRate = calculateGrowthRate(stats.LastYear.Views)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Get paid user count
|
|
||||||
var paidCount int64
|
var paidCount int64
|
||||||
db := l.svcCtx.DB
|
|
||||||
// Count users invited by me who have paid orders
|
|
||||||
// Sub-query to get user IDs invited by current user
|
|
||||||
// Count distinct users who have paid orders (Status 2=Paid, 5=Finished)
|
|
||||||
err = db.Table("order").
|
|
||||||
Joins("LEFT JOIN user ON user.id = order.user_id").
|
|
||||||
Where("user.referer_id = ? AND order.status IN ?", u.Id, []int{2, 5}).
|
|
||||||
Distinct("order.user_id").
|
|
||||||
Count(&paidCount).Error
|
|
||||||
|
|
||||||
|
// 2. 从 Loki 获取 views(nginx 访问日志)
|
||||||
|
lokiCfg := l.svcCtx.Config.Loki
|
||||||
|
if lokiCfg.Enable && lokiCfg.URL != "" && u.ReferCode != "" {
|
||||||
|
lokiClient := loki.NewClient(lokiCfg.URL)
|
||||||
|
lokiStats, err := lokiClient.GetInviteCodeStats(l.ctx, u.ReferCode, 30)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[GetAgentRealtime] Failed to fetch Loki stats",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("user_id", u.Id),
|
||||||
|
logger.Field("refer_code", u.ReferCode))
|
||||||
|
// 不返回错误,继续使用已有数据
|
||||||
|
} else {
|
||||||
|
views = lokiStats.MacClicks + lokiStats.WindowsClicks
|
||||||
|
lastMonthViews = lokiStats.LastMonthMac + lokiStats.LastMonthWindows
|
||||||
|
l.Infow("[GetAgentRealtime] Fetched Loki stats successfully",
|
||||||
|
logger.Field("user_id", u.Id),
|
||||||
|
logger.Field("refer_code", u.ReferCode),
|
||||||
|
logger.Field("views", views),
|
||||||
|
logger.Field("last_month_views", lastMonthViews))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 从数据库获取安装量(被邀请注册用户数)
|
||||||
|
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&user.User{}).
|
||||||
|
Where("referer_id = ?", u.Id).
|
||||||
|
Count(&installs).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("Failed to count paid users",
|
l.Errorw("[GetAgentRealtime] Failed to count installs",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("user_id", u.Id))
|
||||||
|
installs = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 获取付费用户数
|
||||||
|
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Table("`order`").
|
||||||
|
Joins("LEFT JOIN user ON user.id = `order`.user_id").
|
||||||
|
Where("user.referer_id = ? AND `order`.status IN ?", u.Id, []int{2, 5}).
|
||||||
|
Distinct("`order`.user_id").
|
||||||
|
Count(&paidCount).Error
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[GetAgentRealtime] Failed to count paid users",
|
||||||
logger.Field("error", err.Error()),
|
logger.Field("error", err.Error()),
|
||||||
logger.Field("user_id", u.Id))
|
logger.Field("user_id", u.Id))
|
||||||
// Don't fail the whole request, just return 0 for paid count
|
|
||||||
paidCount = 0
|
paidCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Calculate paid user growth rate (month-over-month)
|
// 5. 计算环比增长率
|
||||||
|
growthRate := calculateGrowthRate([]int{int(lastMonthViews), int(views)})
|
||||||
|
|
||||||
|
// 6. 计算付费用户环比增长率
|
||||||
paidGrowthRate := l.calculatePaidGrowthRate(u.Id)
|
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{
|
return &types.GetAgentRealtimeResponse{
|
||||||
Total: currentMonthClicks,
|
Total: views,
|
||||||
Clicks: currentMonthClicks,
|
Clicks: views,
|
||||||
Views: currentMonthClicks,
|
Views: views,
|
||||||
|
Installs: installs,
|
||||||
PaidCount: paidCount,
|
PaidCount: paidCount,
|
||||||
GrowthRate: growthRate,
|
GrowthRate: growthRate,
|
||||||
PaidGrowthRate: paidGrowthRate,
|
PaidGrowthRate: paidGrowthRate,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *GetAgentRealtimeLogic) getShareUrl() string {
|
|
||||||
siteConfig := l.svcCtx.Config.Site
|
|
||||||
if siteConfig.CustomData != "" {
|
|
||||||
var data customData
|
|
||||||
if err := json.Unmarshal([]byte(siteConfig.CustomData), &data); err == nil {
|
|
||||||
if data.ShareUrl != "" {
|
|
||||||
return data.ShareUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return l.svcCtx.Config.Kutt.TargetURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *GetAgentRealtimeLogic) getDomain() string {
|
|
||||||
siteConfig := l.svcCtx.Config.Site
|
|
||||||
if siteConfig.CustomData != "" {
|
|
||||||
var data customData
|
|
||||||
if err := json.Unmarshal([]byte(siteConfig.CustomData), &data); err == nil {
|
|
||||||
if data.Domain != "" {
|
|
||||||
return data.Domain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return l.svcCtx.Config.Kutt.Domain
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculatePaidGrowthRate 计算付费用户的环比增长率
|
// calculatePaidGrowthRate 计算付费用户的环比增长率
|
||||||
func (l *GetAgentRealtimeLogic) calculatePaidGrowthRate(userId int64) string {
|
func (l *GetAgentRealtimeLogic) calculatePaidGrowthRate(userId int64) string {
|
||||||
db := l.svcCtx.DB
|
db := l.svcCtx.DB
|
||||||
|
|||||||
@ -60,6 +60,14 @@ func (l *QueryUserSubscribeLogic) QueryUserSubscribe() (resp *types.QueryUserSub
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询订单金额,判断是否为赠送订单(amount=0)
|
||||||
|
if item.OrderId > 0 {
|
||||||
|
orderInfo, err := l.svcCtx.OrderModel.FindOne(l.ctx, item.OrderId)
|
||||||
|
if err == nil && orderInfo != nil {
|
||||||
|
sub.IsGift = orderInfo.Amount == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sub.ResetTime = calculateNextResetTime(&sub)
|
sub.ResetTime = calculateNextResetTime(&sub)
|
||||||
resp.List = append(resp.List, sub)
|
resp.List = append(resp.List, sub)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -249,7 +249,7 @@ func (m *customOrderModel) IsUserEligibleForNewOrder(ctx context.Context, userID
|
|||||||
var count int64
|
var count int64
|
||||||
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
|
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
|
||||||
return conn.Model(&Order{}).
|
return conn.Model(&Order{}).
|
||||||
Where("user_id = ? AND status IN ?", userID, []int64{2, 5}).
|
Where("user_id = ? AND status IN ? AND amount > 0", userID, []int64{2, 5}).
|
||||||
Count(&count).Error
|
Count(&count).Error
|
||||||
})
|
})
|
||||||
return count == 0, err
|
return count == 0, err
|
||||||
|
|||||||
@ -912,6 +912,7 @@ type GetAgentRealtimeResponse struct {
|
|||||||
Total int64 `json:"total"` // 访问总人数
|
Total int64 `json:"total"` // 访问总人数
|
||||||
Clicks int64 `json:"clicks"` // 点击量
|
Clicks int64 `json:"clicks"` // 点击量
|
||||||
Views int64 `json:"views"` // 浏览量
|
Views int64 `json:"views"` // 浏览量
|
||||||
|
Installs int64 `json:"installs"` // 安装量(被邀请注册用户数)
|
||||||
PaidCount int64 `json:"paid_count"` // 付费数量
|
PaidCount int64 `json:"paid_count"` // 付费数量
|
||||||
GrowthRate string `json:"growth_rate"` // 访问量环比增长率
|
GrowthRate string `json:"growth_rate"` // 访问量环比增长率
|
||||||
PaidGrowthRate string `json:"paid_growth_rate"` // 付费用户环比增长率
|
PaidGrowthRate string `json:"paid_growth_rate"` // 付费用户环比增长率
|
||||||
@ -2788,6 +2789,7 @@ type UserSubscribe struct {
|
|||||||
Upload int64 `json:"upload"`
|
Upload int64 `json:"upload"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Status uint8 `json:"status"`
|
Status uint8 `json:"status"`
|
||||||
|
IsGift bool `json:"is_gift"` // 是否为赠送订单(amount=0)
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user