diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index d1010c8..2d622fb 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -13,7 +13,7 @@ on: env: # Docker镜像仓库 - REPO: ${{ vars.REPO || 'registry.kxsw.us/ario-server' }} + REPO: ${{ vars.REPO || 'registry.kxsw.us/vpn-server' }} # SSH连接信息 SSH_HOST: ${{ vars.SSH_HOST }} SSH_PORT: ${{ vars.SSH_PORT }} @@ -23,15 +23,15 @@ env: TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0 TG_CHAT_ID: "-4940243803" # Go构建变量 - SERVICE: ario - SERVICE_STYLE: ario + SERVICE: vpn + SERVICE_STYLE: vpn VERSION: ${{ github.sha }} BUILDTIME: ${{ github.event.head_commit.timestamp }} GOARCH: amd64 jobs: build: - runs-on: ario-server + runs-on: vpn-server container: image: node:20 strategy: diff --git a/docker-compose.cloud.yml b/docker-compose.cloud.yml index 7653f40..554955d 100644 --- a/docker-compose.cloud.yml +++ b/docker-compose.cloud.yml @@ -10,7 +10,7 @@ services: # 1. 业务后端 (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 restart: always volumes: @@ -22,7 +22,7 @@ services: - OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317 - OTEL_SERVICE_NAME=ppanel-server - OTEL_TRACES_EXPORTER=otlp - - OTEL_METRICS_EXPORTER=none # 指标由 tempo 抓取,不使用 OTLP + - OTEL_METRICS_EXPORTER=prometheus # 指标由 tempo 抓取,不使用 OTLP network_mode: host ulimits: nproc: 65535 @@ -43,14 +43,24 @@ services: # 14. Tempo (链路追踪存储 - 替代/增强 Jaeger) # ---------------------------------------------------- tempo: - image: grafana/tempo:latest + image: grafana/tempo:2.4.1 container_name: ppanel-tempo + user: root restart: always - command: [ "-config.file=/etc/tempo.yaml" ] + command: + - "-config.file=/etc/tempo.yaml" + - "-target=all" volumes: - - ./tempo/tempo-config.yaml:/etc/tempo.yaml - - tempo_data:/var/tempo - network_mode: host + - ./tempo/tempo-config.yaml:/etc/tempo.yaml # - tempo_data:/var/tempo + - ./tempo_data:/var/tempo # 改为映射到当前目录,确保数据彻底干净 + + ports: + - "3200:3200" + - "4317:4317" + - "4318:4318" + - "9095:9095" + networks: + - ppanel_net logging: driver: "json-file" options: @@ -133,6 +143,8 @@ services: - ./loki/loki-config.yaml:/etc/loki/local-config.yaml - loki_data:/loki command: -config.file=/etc/loki/local-config.yaml + ports: + - "3100:3100" networks: - ppanel_net logging: @@ -154,6 +166,8 @@ services: - /var/run/docker.sock:/var/run/docker.sock # 采集当前目录下的 logs 文件夹 - ./logs:/var/log/ppanel-server:ro + # 采集 Nginx 访问日志(用于追踪邀请码来源) + - /var/log/nginx:/var/log/nginx:ro command: -config.file=/etc/promtail/config.yaml networks: - ppanel_net @@ -177,7 +191,7 @@ services: environment: - GF_SECURITY_ADMIN_PASSWORD=admin - GF_USERS_ALLOW_SIGN_UP=false - - GF_FEATURE_TOGGLES_ENABLE=appObservability + - GF_FEATURE_TOGGLES_ENABLE=appObservability #- GF_INSTALL_PLUGINS=redis-datasource volumes: - grafana_data:/var/lib/grafana - ./grafana/provisioning:/etc/grafana/provisioning @@ -211,6 +225,8 @@ services: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--web.enable-lifecycle' + - '--web.enable-remote-write-receiver' + networks: - ppanel_net logging: diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index d695399..0000000 --- a/docker-compose.yaml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index c2b3225..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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 diff --git a/internal/logic/public/user/getAgentDownloadsLogic.go b/internal/logic/public/user/getAgentDownloadsLogic.go index 5d89542..e3811a7 100644 --- a/internal/logic/public/user/getAgentDownloadsLogic.go +++ b/internal/logic/public/user/getAgentDownloadsLogic.go @@ -2,15 +2,12 @@ package user import ( "context" - "fmt" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/constant" "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/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 获取用户代理下载统计数据 -// 结合 OpenInstall (iOS/Android) 和 Loki (Windows/Mac) 数据源 +// 基于用户邀请码查询被邀请用户的设备UA来统计各平台安装量 func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsRequest) (resp *types.GetAgentDownloadsResponse, err error) { // 1. 从 context 获取用户信息 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") } - // 初始化响应数据 - var iosCount, androidCount, windowsCount, macCount int64 - var comparisonRate *string + // 2. 通过数据库查询各平台设备安装量 + // 基于 user_device 表的 user_agent 字段判断平台 + // 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 数据 - openInstallCfg := l.svcCtx.Config.OpenInstall - if openInstallCfg.Enable && openInstallCfg.ApiKey != "" { - client := openinstall.NewClient(openInstallCfg.ApiKey) - platformDownloads, err := client.GetPlatformDownloads(l.ctx, u.ReferCode) - if err != nil { - 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 - } - } + if err != nil { + l.Errorw("[GetAgentDownloads] query platform stats failed", + logger.Field("error", err.Error()), + logger.Field("user_id", u.Id)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), + "query platform stats failed: %v", err.Error()) } - // 3. 从 Loki 获取 Windows/Mac 数据(基于用户邀请码) - 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("Failed to fetch Loki stats", logger.Field("error", err), logger.Field("user_id", u.Id), logger.Field("refer_code", u.ReferCode)) - // 不返回错误,继续使用已有数据 - } 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)) - } - } + l.Infow("[GetAgentDownloads] platform stats fetched successfully", + logger.Field("user_id", u.Id), + logger.Field("refer_code", u.ReferCode), + logger.Field("android", stats.Android), + logger.Field("ios", stats.IOS), + logger.Field("mac", stats.Mac), + logger.Field("windows", stats.Windows), + logger.Field("total", stats.Total)) - // 4. 计算总量 - total := iosCount + androidCount + windowsCount + macCount - - // 5. 构造响应 + // 3. 构造响应 return &types.GetAgentDownloadsResponse{ - Total: total, + Total: stats.Total, Platforms: &types.PlatformDownloads{ - IOS: iosCount, - Android: androidCount, - Windows: windowsCount, - Mac: macCount, + IOS: stats.IOS, + Android: stats.Android, + Windows: stats.Windows, + Mac: stats.Mac, }, - ComparisonRate: comparisonRate, + ComparisonRate: nil, // 不再计算环比 }, nil } diff --git a/internal/logic/public/user/getAgentRealtimeLogic.go b/internal/logic/public/user/getAgentRealtimeLogic.go index 2e4f077..47c8b2a 100644 --- a/internal/logic/public/user/getAgentRealtimeLogic.go +++ b/internal/logic/public/user/getAgentRealtimeLogic.go @@ -2,7 +2,6 @@ package user import ( "context" - "encoding/json" "fmt" "time" @@ -10,8 +9,8 @@ import ( "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "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/loki" "github.com/perfect-panel/server/pkg/xerr" "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") } - // 2. Check if Kutt is enabled - cfg := l.svcCtx.Config.Kutt - 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 views, lastMonthViews int64 + var installs 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 { - 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("user_id", u.Id)) - // Don't fail the whole request, just return 0 for paid count 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) - // 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: currentMonthClicks, - Clicks: currentMonthClicks, - Views: currentMonthClicks, + Total: views, + Clicks: views, + Views: views, + Installs: installs, PaidCount: paidCount, GrowthRate: growthRate, PaidGrowthRate: paidGrowthRate, }, 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 计算付费用户的环比增长率 func (l *GetAgentRealtimeLogic) calculatePaidGrowthRate(userId int64) string { db := l.svcCtx.DB diff --git a/internal/logic/public/user/queryUserSubscribeLogic.go b/internal/logic/public/user/queryUserSubscribeLogic.go index 757f6fc..0a00899 100644 --- a/internal/logic/public/user/queryUserSubscribeLogic.go +++ b/internal/logic/public/user/queryUserSubscribeLogic.go @@ -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) resp.List = append(resp.List, sub) } diff --git a/internal/model/order/model.go b/internal/model/order/model.go index d98e361..c2779d0 100644 --- a/internal/model/order/model.go +++ b/internal/model/order/model.go @@ -249,7 +249,7 @@ func (m *customOrderModel) IsUserEligibleForNewOrder(ctx context.Context, userID var count int64 err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error { 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 }) return count == 0, err diff --git a/internal/types/types.go b/internal/types/types.go index 45123d2..1d23303 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -912,6 +912,7 @@ type GetAgentRealtimeResponse struct { Total int64 `json:"total"` // 访问总人数 Clicks int64 `json:"clicks"` // 点击量 Views int64 `json:"views"` // 浏览量 + Installs int64 `json:"installs"` // 安装量(被邀请注册用户数) PaidCount int64 `json:"paid_count"` // 付费数量 GrowthRate string `json:"growth_rate"` // 访问量环比增长率 PaidGrowthRate string `json:"paid_growth_rate"` // 付费用户环比增长率 @@ -2788,6 +2789,7 @@ type UserSubscribe struct { Upload int64 `json:"upload"` Token string `json:"token"` Status uint8 `json:"status"` + IsGift bool `json:"is_gift"` // 是否为赠送订单(amount=0) CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` }