From f946504cb8f42a2ab519d45c4e1a0e2c776d5b2f Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Fri, 8 May 2026 06:19:59 -0700 Subject: [PATCH] x --- .env.example | 7 + cmd/retroactive_referral.go | 796 ++++++++++++++++++ deploy/aws/ap-east-1/README.md | 306 +++++++ .../aws/ap-east-1/configs/ppanel.yaml.example | 111 +++ deploy/aws/ap-east-1/nginx/ppanel-api.conf | 33 + internal/logic/auth/emailLoginLogic.go | 25 - .../logic/public/user/getInviteSalesLogic.go | 4 +- .../public/user/queryUserAffiliateLogic.go | 5 +- .../logic/public/user/queryUserInfoLogic.go | 113 +-- public/order-recovery.html | 4 +- result.txt | 128 +++ retro_result.txt | 101 +++ 12 files changed, 1493 insertions(+), 140 deletions(-) create mode 100644 cmd/retroactive_referral.go create mode 100644 deploy/aws/ap-east-1/README.md create mode 100644 deploy/aws/ap-east-1/configs/ppanel.yaml.example create mode 100644 deploy/aws/ap-east-1/nginx/ppanel-api.conf create mode 100644 result.txt create mode 100644 retro_result.txt diff --git a/.env.example b/.env.example index b8fc93a..1bc724a 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,10 @@ GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD # PPanel Server 镜像标签(留空使用 latest) PPANEL_SERVER_TAG=latest + +# AWS 区域(香港) +AWS_REGION=ap-east-1 + +# Grafana 公开域名(如需反代) +GRAFANA_DOMAIN=logs-new.hifast.biz +GRAFANA_ROOT_URL=https://logs-new.hifast.biz diff --git a/cmd/retroactive_referral.go b/cmd/retroactive_referral.go new file mode 100644 index 0000000..52f15d8 --- /dev/null +++ b/cmd/retroactive_referral.go @@ -0,0 +1,796 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "io" + "math/rand" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/perfect-panel/server/internal/config" + logmodel "github.com/perfect-panel/server/internal/model/log" + ordermodel "github.com/perfect-panel/server/internal/model/order" + usermodel "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/pkg/conf" + "github.com/perfect-panel/server/pkg/orm" + "github.com/redis/go-redis/v9" + "github.com/spf13/cobra" + "gorm.io/gorm" +) + +func init() { + retroactiveReferralCmd.Flags().StringVar(&retroAgentIdStr, "agent-id", "", "目标代理用户 ID,或 * 表示所有 referral_percentage>0 的代理(必填)") + retroactiveReferralCmd.Flags().StringVar(&retroPoolStart, "pool-start", "2025-05-04", "自然流量订单起始时间,格式 YYYY-MM-DD 或 'YYYY-MM-DD HH:MM:SS'(默认 2025-05-04)") + retroactiveReferralCmd.Flags().StringVar(&retroPoolEnd, "pool-end", "", "自然流量订单截止时间,格式 YYYY-MM-DD 或 'YYYY-MM-DD HH:MM:SS'(默认今天)") + retroactiveReferralCmd.Flags().IntVar(&retroPercentage, "percentage", 120, "补偿百分比,例如 120 表示 120%(默认 120)") + retroactiveReferralCmd.Flags().IntVar(&retroForceCommissionPct, "force-commission-pct", 50, "强制指定发佣比例(0=使用数据库/配置,非0时覆盖代理设置,默认 50%)") + retroactiveReferralCmd.Flags().StringVar(&retroOutput, "output", "retro_result.txt", "结果输出到指定 txt 文件(默认 retro_result.txt)") + retroactiveReferralCmd.Flags().StringVar(&retroConfigPath, "config", "etc/ppanel.yaml", "配置文件路径") + retroactiveReferralCmd.Flags().StringVar(&retroAgentCreatedAfter, "agent-created-after", "", "仅处理在此日期之后注册的代理,格式 YYYY-MM-DD(留空=不限)") + retroactiveReferralCmd.Flags().StringVar(&retroLossStart, "loss-start", "2026-05-06", "数据丢失起始时间,丢失时长=现在-此时间(默认 2026-05-06)") + retroactiveReferralCmd.Flags().StringVar(&retroOrderStart, "order-start", "2026-05-01", "池内用户至少有一笔 updated_at >= 此时间的订单才入池(默认 2026-05-01)") + retroactiveReferralCmd.Flags().BoolVar(&retroDryRun, "dry-run", false, "仅预览,不执行写入") + rootCmd.AddCommand(retroactiveReferralCmd) +} + +var ( + retroAgentIdStr string + retroAgentCreatedAfter string + retroLossStart string + retroOrderStart string + retroPoolStart string + retroPoolEnd string + retroPercentage int + retroForceCommissionPct int + retroConfigPath string + retroDryRun bool + retroOutput string +) + +var retroactiveReferralCmd = &cobra.Command{ + Use: "retro-referral", + Short: "补单:按代理历史日均佣金补偿指定比例的用户", + Long: `统计代理从首次邀请到 pool-end 的日均佣金, +按指定百分比计算目标补偿金额, +从 pool-start 到 pool-end 的自然流量用户中随机抽取匹配的用户数量挂载到该代理。 +--agent-id 支持单个 ID 或 *(处理所有 referral_percentage>0 的代理)。`, + RunE: func(cmd *cobra.Command, args []string) error { + if retroAgentIdStr == "" { + return fmt.Errorf("--agent-id 必填(单个 ID 或 *)") + } + return runRetroactiveReferral() + }, +} + +// commissionRule holds resolved commission settings for an agent. +type commissionRule struct { + Percentage uint8 + OnlyFirstPurchase bool +} + +// candidateUser holds a pool user plus their pre-calculated qualifying orders. +type candidateUser struct { + Id int64 + CreatedAt time.Time + Identifier string + Orders []ordermodel.Order + CommissionTotal int64 +} + +// agentPlan holds one agent's computed allocation plan (preview phase output). +type agentPlan struct { + Agent *usermodel.User + Rule commissionRule + Selected []candidateUser + TargetAmt float64 // in cents + PreviewCommission int64 +} + +func runRetroactiveReferral() error { + // ── 0. 初始化输出(终端 + 可选文件)───────────────────────── + var w io.Writer = os.Stdout + if retroOutput != "" { + f, err := os.Create(retroOutput) + if err != nil { + return fmt.Errorf("创建输出文件失败: %w", err) + } + defer f.Close() + w = io.MultiWriter(os.Stdout, f) + fmt.Printf("结果将同步写入: %s\n\n", retroOutput) + } + + // ── 1. 加载配置 ────────────────────────────────────────────── + var c config.Config + conf.MustLoad(retroConfigPath, &c) + + // ── 2. 初始化 DB + Redis ────────────────────────────────────── + db, err := orm.ConnectMysql(orm.Mysql{Config: c.MySQL}) + if err != nil { + return fmt.Errorf("连接数据库失败: %w", err) + } + rds := redis.NewClient(&redis.Options{ + Addr: c.Redis.Host, + Password: c.Redis.Pass, + DB: c.Redis.DB, + }) + ctx := context.Background() + if err = rds.Ping(ctx).Err(); err != nil { + return fmt.Errorf("连接 Redis 失败: %w", err) + } + um := usermodel.NewModel(db, rds) + + // ── 3. 解析时间参数 ─────────────────────────────────────────── + poolStart, err := parseFlexibleTime(retroPoolStart) + if err != nil { + return fmt.Errorf("--pool-start 格式错误: %w", err) + } + poolEnd := time.Now() + if retroPoolEnd != "" { + poolEnd, err = parseFlexibleTime(retroPoolEnd) + if err != nil { + return fmt.Errorf("--pool-end 格式错误: %w", err) + } + } + if !poolEnd.After(poolStart) { + return fmt.Errorf("--pool-end 必须晚于 --pool-start") + } + + // ── 4. 确定代理列表 ─────────────────────────────────────────── + var agents []*usermodel.User + if retroAgentIdStr == "*" { + agents, err = queryAllActiveAgents(ctx, db, retroAgentCreatedAfter) + if err != nil { + return fmt.Errorf("查询代理列表失败: %w", err) + } + if len(agents) == 0 { + return fmt.Errorf("没有找到任何 referral_percentage>0 的代理用户") + } + fmt.Fprintf(w, "模式:全量代理,共找到 %d 个代理(referral_percentage>0)\n\n", len(agents)) + } else { + agentID, parseErr := strconv.ParseInt(retroAgentIdStr, 10, 64) + if parseErr != nil || agentID <= 0 { + return fmt.Errorf("--agent-id 必须是正整数或 *") + } + agent, findErr := um.FindOne(ctx, agentID) + if findErr != nil { + return fmt.Errorf("查询代理用户失败: %w", findErr) + } + if agent.DeletedAt.Valid { + return fmt.Errorf("代理用户 %d 已被删除", agentID) + } + agents = []*usermodel.User{agent} + } + + if retroForceCommissionPct > 0 { + fmt.Fprintf(w, "⚠️ 强制覆盖所有代理佣金比例为 %d%%\n\n", retroForceCommissionPct) + } + + // ── 5. 查询自然流量用户池(所有代理共用同一个池)──────────── + // 先用 50% 规则(或强制值)预加载池,以便预览;执行时每个代理用自身规则 + var orderStart time.Time + if retroOrderStart != "" { + orderStart, err = parseFlexibleTime(retroOrderStart) + if err != nil { + return fmt.Errorf("--order-start 格式错误: %w", err) + } + } + previewRule := commissionRule{Percentage: uint8(retroForceCommissionPct), OnlyFirstPurchase: false} + pool, err := queryNaturalTrafficPool(ctx, db, poolStart, poolEnd, orderStart, previewRule) + if err != nil { + return fmt.Errorf("查询自然流量用户池失败: %w", err) + } + orderStartDesc := "" + if !orderStart.IsZero() { + orderStartDesc = fmt.Sprintf(",订单 updated_at >= %s", orderStart.Format("2006-01-02")) + } + fmt.Fprintf(w, "自然流量用户池(%s ~ %s,referer_id=0,有已支付订单%s):共 %d 人\n\n", + poolStart.Format("2006-01-02 15:04"), poolEnd.Format("2006-01-02 15:04"), orderStartDesc, len(pool)) + if len(pool) == 0 { + return fmt.Errorf("自然流量用户池为空,无法补充") + } + + // ── 6. 逐代理生成分配计划(预览阶段)──────────────────────── + // 池按顺序分配:每个代理从剩余池中取用户,避免重复分配 + remainingPool := make([]candidateUser, len(pool)) + copy(remainingPool, pool) + + plans := make([]agentPlan, 0, len(agents)) + for _, agent := range agents { + plan, planErr := buildAgentPlan(w, ctx, db, c, agent, remainingPool, poolEnd) + if planErr != nil { + fmt.Fprintf(w, "⚠️ 代理 %d 跳过: %v\n\n", agent.Id, planErr) + continue + } + // 从剩余池中移除已分配给该代理的用户 + assignedSet := make(map[int64]struct{}, len(plan.Selected)) + for _, u := range plan.Selected { + assignedSet[u.Id] = struct{}{} + } + newRemaining := remainingPool[:0] + for _, u := range remainingPool { + if _, used := assignedSet[u.Id]; !used { + newRemaining = append(newRemaining, u) + } + } + remainingPool = newRemaining + plans = append(plans, plan) + } + + if len(plans) == 0 { + return fmt.Errorf("所有代理均无法生成分配计划") + } + + // ── 7. 汇总预览 ─────────────────────────────────────────────── + var grandTotalUsers int + var grandTotalCommission int64 + var grandTargetAmt float64 + for _, p := range plans { + grandTotalUsers += len(p.Selected) + grandTotalCommission += p.PreviewCommission + grandTargetAmt += p.TargetAmt + } + fmt.Fprintln(w, strings.Repeat("═", 75)) + fmt.Fprintf(w, "汇总:共 %d 个代理,补充 %d 个用户\n", len(plans), grandTotalUsers) + fmt.Fprintf(w, " 预计追溯佣金总额: $%.2f(目标补偿金额: $%.2f)\n", + float64(grandTotalCommission)/100, grandTargetAmt/100) + fmt.Fprintln(w, strings.Repeat("═", 75)) + fmt.Fprintln(w) + + if retroDryRun { + fmt.Fprintln(w, "[dry-run] 预览完成,未执行任何写入。") + return nil + } + + // ── 8. 执行前汇总打印 ───────────────────────────────────────── + fmt.Fprintf(w, "\n┌─────────────────────────────────────────────┐\n") + fmt.Fprintf(w, "│ 即将写入数据库 │\n") + fmt.Fprintf(w, "│ 代理数量 : %-4d 个 │\n", len(plans)) + fmt.Fprintf(w, "│ 补充用户 : %-4d 人 │\n", grandTotalUsers) + fmt.Fprintf(w, "│ 赠送金额 : $%-10.2f │\n", float64(grandTotalCommission)/100) + fmt.Fprintf(w, "└─────────────────────────────────────────────┘\n\n") + fmt.Printf("确认执行?(yes/no): ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "yes" && answer != "y" { + fmt.Fprintln(w, "已取消。") + return nil + } + + // ── 9. 逐代理执行 ───────────────────────────────────────────── + var totalSuccess, totalFailed int + var totalCreditedOrder, totalCreditedAmt int64 + + for _, plan := range plans { + fmt.Fprintf(w, "\n── 执行代理 %d ──────────────────────────────────────────────\n", plan.Agent.Id) + var sc, fc int + var co, ca int64 + for _, eu := range plan.Selected { + if eu.Id == plan.Agent.Id { + fmt.Fprintf(w, "[SKIP] 用户 %d 与代理相同,跳过\n", eu.Id) + fc++ + continue + } + credited, amount, execErr := processOneUser(ctx, db, um, plan.Agent.Id, eu.Id, plan.Rule, orderStart) + if execErr != nil { + fmt.Fprintf(w, "[FAIL] 用户 %d: %v\n", eu.Id, execErr) + fc++ + continue + } + fmt.Fprintf(w, "[OK] 用户 %d → 代理 %d,发佣 %d 单,金额 $%.2f\n", + eu.Id, plan.Agent.Id, credited, float64(amount)/100) + sc++ + co += credited + ca += amount + } + // 直接删除代理的缓存 key,下次请求时从 DB 重新加载(避免 FindOne 读到旧缓存再写回) + if sc > 0 { + cacheKey := fmt.Sprintf("cache:user:id:%d", plan.Agent.Id) + _ = rds.Del(ctx, cacheKey).Err() + } + fmt.Fprintf(w, " 代理 %d 小计:成功 %d 人,失败 %d 人,佣金 $%.2f\n", plan.Agent.Id, sc, fc, float64(ca)/100) + totalSuccess += sc + totalFailed += fc + totalCreditedOrder += co + totalCreditedAmt += ca + } + + // ── 10. 全局汇总 ────────────────────────────────────────────── + fmt.Fprintf(w, "\n══════════════════════════════════════════════\n") + fmt.Fprintf(w, " 成功挂载 : %d 人\n", totalSuccess) + fmt.Fprintf(w, " 失败/跳过 : %d 人\n", totalFailed) + fmt.Fprintf(w, " 追溯佣金 : %d 单,总额 $%.2f\n", totalCreditedOrder, float64(totalCreditedAmt)/100) + fmt.Fprintf(w, "══════════════════════════════════════════════\n") + return nil +} + +// buildAgentPlan 计算一个代理的补单预览,同时打印预览内容,返回分配计划。 +func buildAgentPlan(w io.Writer, ctx context.Context, db *gorm.DB, c config.Config, + agent *usermodel.User, pool []candidateUser, poolEnd time.Time) (agentPlan, error) { + + rule := resolveCommissionRule(agent, c) + if retroForceCommissionPct > 0 { + rule.Percentage = uint8(retroForceCommissionPct) + } + // 补单场景:不区分新购/续费,所有已支付订单均参与佣金计算 + rule.OnlyFirstPurchase = false + + firstReferralTime, lastReferralTime, agentCreatedAt, totalReferred, totalOrders, totalCommission, err := + queryAgentStats(ctx, db, agent.Id, poolEnd) + if err != nil { + return agentPlan{}, fmt.Errorf("查询历史数据失败: %w", err) + } + if totalReferred == 0 { + return agentPlan{}, fmt.Errorf("在 %s 之前没有任何邀请记录", poolEnd.Format("2006-01-02")) + } + + lossStartTime, err := parseFlexibleTime(retroLossStart) + if err != nil { + return agentPlan{}, fmt.Errorf("--loss-start 格式错误: %w", err) + } + lossHours := time.Now().Sub(lossStartTime).Hours() + + statsDays := lastReferralTime.Sub(firstReferralTime).Hours() / 24 + if statsDays < 1 { + statsDays = 1 + } + dailyAvgOrders := float64(totalOrders) / statsDays + avgOrdersPerUser := float64(totalOrders) / float64(totalReferred) + if avgOrdersPerUser < 1 { + avgOrdersPerUser = 1 + } + dailyAvgCommission := float64(totalCommission) / statsDays + + // 用用户池自身的平均佣金估算人数(避免历史费率与当前50%费率不匹配导致超发) + var poolAvgCommissionPerUser float64 + if len(pool) > 0 { + var poolCommTotal int64 + for _, u := range pool { + poolCommTotal += u.CommissionTotal + } + poolAvgCommissionPerUser = float64(poolCommTotal) / float64(len(pool)) + } + if poolAvgCommissionPerUser < 1 { + poolAvgCommissionPerUser = 1 + } + + estimatedLostCommission := dailyAvgCommission * (lossHours / 24) + targetCommission := estimatedLostCommission * (float64(retroPercentage) / 100) + extraCount := int(targetCommission/poolAvgCommissionPerUser + 0.5) + if extraCount < 1 { + extraCount = 1 + } + + fmt.Fprintf(w, "\n═══════════════════════════════════════════════════════════\n") + fmt.Fprintf(w, " 代理 ID : %d(注册于 %s)\n", agent.Id, agentCreatedAt.Format("2006-01-02 15:04:05")) + fmt.Fprintf(w, " 统计起点 : %s(首次邀请时间)\n", firstReferralTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(w, " 统计截止 : %s(最后邀请时间)\n", lastReferralTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(w, " 统计天数 : %.2f 天\n", statsDays) + fmt.Fprintf(w, " 历史邀请总人数 : %d 人\n", totalReferred) + fmt.Fprintf(w, " 下线总订单数 : %d 单(所有下线,不限时间)\n", totalOrders) + fmt.Fprintf(w, " 日均订单 : %.4f 单/天\n", dailyAvgOrders) + fmt.Fprintf(w, " 每用户平均订单 : %.4f 单\n", avgOrdersPerUser) + fmt.Fprintf(w, " 历史佣金总额 : $%.2f\n", float64(totalCommission)/100) + fmt.Fprintf(w, " 日均佣金 : $%.4f\n", dailyAvgCommission/100) + fmt.Fprintf(w, " 池内用户均佣金 : $%.4f\n", poolAvgCommissionPerUser/100) + fmt.Fprintf(w, " 佣金规则 : %d%% 仅首单=%v\n", rule.Percentage, rule.OnlyFirstPurchase) + fmt.Fprintf(w, "───────────────────────────────────────────────────────────\n") + fmt.Fprintf(w, " 丢失时长 : %.2f 小时(%s → 现在)\n", + lossHours, lossStartTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(w, " 预估丢失佣金 : $%.4f($%.4f × %.2f/24)\n", + estimatedLostCommission/100, dailyAvgCommission/100, lossHours) + fmt.Fprintf(w, " 目标补偿金额 : $%.4f(× %d%%)\n", + targetCommission/100, retroPercentage) + fmt.Fprintf(w, " 需补充人数 : %d 人($%.4f ÷ $%.4f)\n", + extraCount, targetCommission/100, poolAvgCommissionPerUser/100) + fmt.Fprintf(w, "═══════════════════════════════════════════════════════════\n\n") + + // 重新按当前代理规则计算池内用户佣金(pool 由调用方传入,已是当前规则计算好的) + if len(pool) == 0 { + return agentPlan{}, fmt.Errorf("剩余用户池为空") + } + if len(pool) < extraCount { + fmt.Fprintf(w, "⚠️ 剩余用户池只有 %d 人,少于需要的 %d 人,将全部分配\n\n", len(pool), extraCount) + extraCount = len(pool) + } + + selected := randomSampleCandidates(pool, extraCount) + + // 兜底追加:确保佣金合计 >= 目标 + { + selectedSet := make(map[int64]struct{}, len(selected)) + var selectedCommTotal int64 + for _, u := range selected { + selectedSet[u.Id] = struct{}{} + selectedCommTotal += u.CommissionTotal + } + if selectedCommTotal < int64(targetCommission) { + remaining := make([]candidateUser, 0, len(pool)-len(selected)) + for _, u := range pool { + if _, used := selectedSet[u.Id]; !used { + remaining = append(remaining, u) + } + } + // 按佣金从小到大排序,追加时精准补足,减少超发 + sort.Slice(remaining, func(i, j int) bool { + return remaining[i].CommissionTotal < remaining[j].CommissionTotal + }) + for _, u := range remaining { + if selectedCommTotal >= int64(targetCommission) { + break + } + selected = append(selected, u) + selectedCommTotal += u.CommissionTotal + } + if selectedCommTotal < int64(targetCommission) { + fmt.Fprintf(w, "⚠️ 用户池佣金不足,已抽取全部可用用户(实际 $%.2f < 目标 $%.2f)\n\n", + float64(selectedCommTotal)/100, targetCommission/100) + } + } + } + + // 打印选中用户明细 + var previewTotalCommission int64 + fmt.Fprintf(w, "随机抽取 %d 个用户(含待追溯佣金订单):\n", len(selected)) + fmt.Fprintln(w, strings.Repeat("═", 75)) + for i, u := range selected { + fmt.Fprintf(w, "[%d] 用户 %-10d 注册: %s %s\n", + i+1, u.Id, u.CreatedAt.Format("2006-01-02 15:04:05"), u.Identifier) + if len(u.Orders) == 0 { + fmt.Fprintln(w, " (无符合条件的订单)") + } else { + fmt.Fprintf(w, " %-38s %10s %8s %10s %s\n", "订单号", "金额", "手续费", "佣金", "类型") + fmt.Fprintf(w, " %s\n", strings.Repeat("-", 72)) + for _, od := range u.Orders { + commAmt := calcCommissionAmount(od.Amount, od.FeeAmount, rule.Percentage) + orderType := "首购" + if od.Type == 2 { + orderType = "续费" + } + fmt.Fprintf(w, " %-38s $%8.2f $%6.2f $%8.2f %s\n", + od.OrderNo, + float64(od.Amount)/100, + float64(od.FeeAmount)/100, + float64(commAmt)/100, + orderType) + } + fmt.Fprintf(w, " 本用户追溯佣金合计: $%.2f\n", float64(u.CommissionTotal)/100) + } + previewTotalCommission += u.CommissionTotal + fmt.Fprintln(w) + } + fmt.Fprintln(w, strings.Repeat("═", 75)) + fmt.Fprintf(w, "预计追溯佣金总额: $%.2f(目标补偿金额: $%.2f)\n\n", + float64(previewTotalCommission)/100, targetCommission/100) + + return agentPlan{ + Agent: agent, + Rule: rule, + Selected: selected, + TargetAmt: targetCommission, + PreviewCommission: previewTotalCommission, + }, nil +} + +// queryAllActiveAgents returns all agents with referral_percentage > 0, optionally filtered by created_after. +func queryAllActiveAgents(ctx context.Context, db *gorm.DB, createdAfter string) ([]*usermodel.User, error) { + q := db.WithContext(ctx).Model(&usermodel.User{}). + Where("referral_percentage > 0 AND deleted_at IS NULL") + if createdAfter != "" { + t, err := parseFlexibleTime(createdAfter) + if err != nil { + return nil, fmt.Errorf("--agent-created-after 格式错误: %w", err) + } + q = q.Where("created_at >= ?", t) + } + var agents []*usermodel.User + if err := q.Order("id ASC").Find(&agents).Error; err != nil { + return nil, err + } + return agents, nil +} + +// parseFlexibleTime parses "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD". +func parseFlexibleTime(s string) (time.Time, error) { + s = strings.TrimSpace(s) + if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil { + return t, nil + } + return time.ParseInLocation("2006-01-02", s, time.Local) +} + +// queryAgentStats returns (firstReferralTime, lastReferralTime, agentCreatedAt, totalReferred, totalOrders, totalCommission, error). +func queryAgentStats(ctx context.Context, db *gorm.DB, agentID int64, endTime time.Time) (time.Time, time.Time, time.Time, int64, int64, int64, error) { + var agent usermodel.User + if err := db.WithContext(ctx).Model(&usermodel.User{}). + Where("id = ?", agentID). + First(&agent).Error; err != nil { + return time.Time{}, time.Time{}, time.Time{}, 0, 0, 0, err + } + agentCreatedAt := agent.CreatedAt + + var firstUser usermodel.User + if err := db.WithContext(ctx).Model(&usermodel.User{}). + Where("referer_id = ? AND created_at >= ? AND created_at <= ? AND deleted_at IS NULL", + agentID, agentCreatedAt, endTime). + Order("created_at ASC"). + First(&firstUser).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, nil + } + return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err + } + + var lastUser usermodel.User + if err := db.WithContext(ctx).Model(&usermodel.User{}). + Where("referer_id = ? AND created_at >= ? AND created_at <= ? AND deleted_at IS NULL", + agentID, agentCreatedAt, endTime). + Order("created_at DESC"). + First(&lastUser).Error; err != nil { + return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err + } + + var totalReferred int64 + if err := db.WithContext(ctx).Model(&usermodel.User{}). + Where("referer_id = ? AND created_at >= ? AND created_at <= ? AND deleted_at IS NULL", + agentID, agentCreatedAt, endTime). + Count(&totalReferred).Error; err != nil { + return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err + } + + var totalOrders int64 + if err := db.WithContext(ctx).Model(&ordermodel.Order{}). + Joins("JOIN user u ON u.id = `order`.user_id"). + Where("u.referer_id = ?", agentID). + Where("`order`.status IN (2, 5)"). + Count(&totalOrders).Error; err != nil { + return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err + } + + type commResult struct{ Total int64 } + var result commResult + err := db.WithContext(ctx).Raw(` + SELECT COALESCE(SUM( + CAST(JSON_UNQUOTE(JSON_EXTRACT(content, '$.amount')) AS SIGNED) + ), 0) AS total + FROM system_logs + WHERE type = 33 + AND object_id = ? + AND created_at <= ? + AND JSON_UNQUOTE(JSON_EXTRACT(content, '$.type')) IN ('331', '332') + `, agentID, endTime).Scan(&result).Error + if err != nil { + return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err + } + + return firstUser.CreatedAt, lastUser.CreatedAt, agentCreatedAt, totalReferred, totalOrders, result.Total, nil +} + +// queryNaturalTrafficPool returns pool candidates with pre-loaded qualifying orders. +// orderStart (可为零值):若非零,则只收录至少有一笔 updated_at >= orderStart 订单的用户, +// 且只加载/统计 updated_at >= orderStart 的订单(确保分配后代理能在对应月份的销售报表中看到记录)。 +func queryNaturalTrafficPool(ctx context.Context, db *gorm.DB, start, end time.Time, orderStart time.Time, rule commissionRule) ([]candidateUser, error) { + type userRow struct { + Id int64 + CreatedAt time.Time + AuthIdentifier string + } + var userRows []userRow + q := db.WithContext(ctx). + Table("user u"). + Select("u.id, u.created_at, COALESCE(am.auth_identifier, '') AS auth_identifier"). + Joins("JOIN `order` o ON o.user_id = u.id AND o.status IN (2, 5)"). + Joins("LEFT JOIN user_auth_methods am ON am.user_id = u.id AND am.auth_type = 'email'"). + Where("u.created_at >= ? AND u.created_at <= ?", start, end). + Where("u.referer_id = 0"). + Where("u.deleted_at IS NULL") + if !orderStart.IsZero() { + // 只入池那些在 orderStart 之后有过订单的用户(保证代理销售报表里能看到) + q = q.Where("o.updated_at >= ?", orderStart) + } + if err := q.Group("u.id, u.created_at, am.auth_identifier"). + Order("u.id ASC"). + Scan(&userRows).Error; err != nil { + return nil, err + } + if len(userRows) == 0 { + return nil, nil + } + + userIDs := make([]int64, len(userRows)) + for i, r := range userRows { + userIDs[i] = r.Id + } + orderQuery := db.WithContext(ctx).Model(&ordermodel.Order{}). + Where("user_id IN ? AND status IN (2, 5)", userIDs) + if !orderStart.IsZero() { + // 只加载 orderStart 之后的订单:保证佣金统计和销售记录对齐 + orderQuery = orderQuery.Where("updated_at >= ?", orderStart) + } + var allOrders []ordermodel.Order + if err := orderQuery.Order("user_id ASC, created_at ASC").Find(&allOrders).Error; err != nil { + return nil, err + } + + ordersByUser := make(map[int64][]ordermodel.Order, len(userRows)) + for _, od := range allOrders { + ordersByUser[od.UserId] = append(ordersByUser[od.UserId], od) + } + + candidates := make([]candidateUser, 0, len(userRows)) + for _, r := range userRows { + orders := ordersByUser[r.Id] + var commTotal int64 + for i := range orders { + if canCreditOrder(rule, &orders[i]) { + commTotal += calcCommissionAmount(orders[i].Amount, orders[i].FeeAmount, rule.Percentage) + } + } + candidates = append(candidates, candidateUser{ + Id: r.Id, + CreatedAt: r.CreatedAt, + Identifier: r.AuthIdentifier, + Orders: orders, + CommissionTotal: commTotal, + }) + } + return candidates, nil +} + +// randomSampleCandidates picks n random elements from pool without replacement. +func randomSampleCandidates(pool []candidateUser, n int) []candidateUser { + if n >= len(pool) { + return append([]candidateUser{}, pool...) + } + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + indices := rng.Perm(len(pool))[:n] + result := make([]candidateUser, n) + for i, idx := range indices { + result[i] = pool[idx] + } + return result +} + +// processOneUser assigns agentId as referer and retroactively credits commission for qualifying orders. +func processOneUser( + ctx context.Context, + db *gorm.DB, + um usermodel.Model, + agentID, userID int64, + rule commissionRule, + orderStart time.Time, +) (int64, int64, error) { + var creditedOrders, creditedAmount int64 + + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var target usermodel.User + if e := tx.Model(&usermodel.User{}). + Where("id = ? AND referer_id = 0 AND deleted_at IS NULL", userID). + First(&target).Error; e != nil { + if e == gorm.ErrRecordNotFound { + return fmt.Errorf("用户不存在或已有代理或已删除") + } + return e + } + + var orders []ordermodel.Order + oq := tx.Model(&ordermodel.Order{}). + Where("user_id = ? AND status IN (2, 5)", userID) + if !orderStart.IsZero() { + oq = oq.Where("updated_at >= ?", orderStart) + } + if e := oq.Order("created_at ASC, id ASC").Find(&orders).Error; e != nil { + return e + } + if len(orders) == 0 { + return fmt.Errorf("无合格订单") + } + + if e := tx.Model(&usermodel.User{}). + Where("id = ? AND referer_id = 0 AND deleted_at IS NULL", userID). + Updates(map[string]interface{}{ + "referer_id": agentID, + "updated_at": time.Now(), + }).Error; e != nil { + return e + } + + for i := range orders { + od := &orders[i] + if !canCreditOrder(rule, od) { + continue + } + amount := calcCommissionAmount(od.Amount, od.FeeAmount, rule.Percentage) + if amount <= 0 { + continue + } + + var existCount int64 + if e := tx.Model(&logmodel.SystemLog{}). + Where("type = ? AND object_id = ? AND content LIKE ?", + logmodel.TypeCommission.Uint8(), agentID, + fmt.Sprintf("%%\"%s\"%%", od.OrderNo), + ).Count(&existCount).Error; e != nil { + return e + } + if existCount > 0 { + continue + } + + if e := tx.Model(&usermodel.User{}). + Where("id = ? AND deleted_at IS NULL", agentID). + UpdateColumn("commission", gorm.Expr("commission + ?", amount)).Error; e != nil { + return e + } + + commType := logmodel.CommissionTypePurchase + if od.Type == 2 { + commType = logmodel.CommissionTypeRenewal + } + payload := &logmodel.Commission{ + Type: commType, + Amount: amount, + OrderNo: od.OrderNo, + Timestamp: od.CreatedAt.UnixMilli(), + } + content, _ := payload.Marshal() + if e := tx.Create(&logmodel.SystemLog{ + Type: logmodel.TypeCommission.Uint8(), + Date: od.CreatedAt.Format("2006-01-02"), + ObjectID: agentID, + Content: string(content), + CreatedAt: od.CreatedAt, + }).Error; e != nil { + return e + } + creditedOrders++ + creditedAmount += amount + } + return nil + }) + if err != nil { + return 0, 0, err + } + + if updated, e := um.FindOne(ctx, userID); e == nil { + _ = um.UpdateUserCache(ctx, updated) + } + return creditedOrders, creditedAmount, nil +} + +func resolveCommissionRule(agent *usermodel.User, c config.Config) commissionRule { + if agent.ReferralPercentage > 0 { + onlyFirst := true + if agent.OnlyFirstPurchase != nil { + onlyFirst = *agent.OnlyFirstPurchase + } + return commissionRule{Percentage: agent.ReferralPercentage, OnlyFirstPurchase: onlyFirst} + } + return commissionRule{ + Percentage: uint8(c.Invite.ReferralPercentage), + OnlyFirstPurchase: c.Invite.OnlyFirstPurchase, + } +} + +func canCreditOrder(rule commissionRule, od *ordermodel.Order) bool { + if rule.Percentage == 0 { + return false + } + if rule.OnlyFirstPurchase && !od.IsNew { + return false + } + return od.Status == 2 || od.Status == 5 +} + +func calcCommissionAmount(amount, feeAmount int64, percentage uint8) int64 { + base := amount - feeAmount + if base <= 0 || percentage == 0 { + return 0 + } + return int64(float64(base) * float64(percentage) / 100) +} diff --git a/deploy/aws/ap-east-1/README.md b/deploy/aws/ap-east-1/README.md new file mode 100644 index 0000000..20991eb --- /dev/null +++ b/deploy/aws/ap-east-1/README.md @@ -0,0 +1,306 @@ +# PPanel 香港区新 AWS 账号部署说明 + +本目录用于在 **新 AWS 账号** 中按 **香港区 `ap-east-1`** 重建一套全新空环境。 + +目标架构: + +`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + observability) -> RDS MySQL + ElastiCache Redis` + +## 1. 资源清单 + +按下面顺序创建资源: + +1. VPC +2. 2 个公有子网 + 2 个私有子网 +3. Internet Gateway +4. 公有 / 私有路由表 +5. 安全组 +6. RDS MySQL +7. ElastiCache Redis OSS +8. EC2 +9. ACM 证书 +10. ALB + Target Group +11. WAF Web ACL +12. 平行环境域名 + +建议命名: + +- VPC: `ppanel-hk-prod` +- EC2: `ppanel-app-hk-01` +- RDS: `ppanel-mysql-hk` +- Redis: `ppanel-redis-hk` +- ALB: `ppanel-alb-hk` +- WAF: `ppanel-waf-hk` + +## 2. 默认规格 + +### EC2 + +- Region: `ap-east-1` +- OS: Ubuntu 24.04 LTS +- Instance type: `t4g.large` 起步 +- Disk: `gp3 80GB` +- Public subnet: 是 +- IAM Role: 允许读取 CloudWatch / SSM(如使用) + +### RDS MySQL + +- Engine: MySQL 8.0 +- Class: `db.r7g.xlarge` +- Storage: `gp3 100GB` +- DB name: `hifast` +- Username: `admin` +- Public access: `No` +- Charset: `utf8mb4` +- Backup: `7-14 days` + +### ElastiCache Redis OSS + +- Engine: Redis OSS 7.x +- Node type: `cache.m7g.large` +- Cluster mode: disabled +- Multi-AZ: 按预算决定,正式环境建议开启 +- Public access: 不开放 +- TLS: 当前模板按 **关闭** 规划 + +## 3. 网络与安全组 + +### 子网布局 + +- `public-a`, `public-b`: ALB / EC2 +- `private-a`, `private-b`: RDS / Redis + +### 安全组建议 + +#### `sg-alb` + +- Inbound + - `80/tcp` from `0.0.0.0/0` + - `443/tcp` from `0.0.0.0/0` +- Outbound + - `80/tcp` to `sg-ec2` + +#### `sg-ec2` + +- Inbound + - `80/tcp` from `sg-alb` + - `22/tcp` from `你的固定运维 IP` +- Outbound + - all + +说明: + +- 应用容器监听 `127.0.0.1:8080` +- EC2 对外只让 Nginx 监听 `80` +- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1` + +#### `sg-rds` + +- Inbound + - `3306/tcp` from `sg-ec2` + +#### `sg-redis` + +- Inbound + - `6379/tcp` from `sg-ec2` + +## 4. ALB / Target Group / 健康检查 + +### Target Group + +- Type: `Instance` +- Protocol: `HTTP` +- Port: `80` +- Health check path: `/v1/common/heartbeat` +- Success code: `200` + +这个路径已由项目现有接口提供,无需额外改代码。 + +### ALB 监听器 + +- `80` -> redirect to `443` +- `443` -> forward 到 target group + +### ACM + +- 在 `ap-east-1` 申请证书 +- 先给平行环境域名,例如: + - `api-new.hifast.biz` + - `logs-new.hifast.biz` + +## 5. WAF 规则 + +首版至少启用: + +1. `AWSManagedRulesCommonRuleSet` +2. `AWSManagedRulesKnownBadInputsRuleSet` +3. `AWSManagedRulesAmazonIpReputationList` +4. 全站 rate-based rule +5. 针对高风险路径的 rate-based rule + +建议的第一版限流: + +- 全站:每 IP `2000 / 5 分钟` +- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟` +- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟` + +节点上报接口建议后续补: + +- `/v1/server/status` +- `/v1/server/online` +- `/v1/server/traffic` + +优先用节点出口 IP 白名单;没有固定出口 IP 的节点暂时保留 `secret_key`,但不要把它当成唯一防线。 + +## 6. EC2 文件落地 + +在 EC2 上建议使用: + +- 应用目录:`/opt/ppanel` +- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf` + +需要上传这些文件 / 目录: + +- `docker-compose.cloud.yml` +- `deploy/aws/ap-east-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml` +- `deploy/aws/ap-east-1/nginx/ppanel-api.conf` +- `grafana/` +- `loki/` +- `prometheus/` +- `tempo/` +- `.env.example` -> 重命名为 `.env` + +目标目录示例: + +```text +/opt/ppanel/ + docker-compose.cloud.yml + .env + configs/ppanel.yaml + grafana/ + loki/ + prometheus/ + tempo/ + logs/ + cache/ + tempo_data/ +``` + +## 7. 应用配置 + +基线模板见: + +- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example) +- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf) + +关键值必须替换: + +- `MySQL.Addr` +- `MySQL.Password` +- `Redis.Host` +- `JwtAuth.AccessSecret` +- `Administrator.Email` +- `Administrator.Password` +- `AppSignature.AppSecrets.*` +- `device.security_secret` +- `Site.Host` +- `Site.SiteName` + +Redis 约定保持不变: + +- 业务缓存:DB `0` +- Asynq:DB `5`(代码内部已固定使用) + +## 8. 部署步骤 + +### 8.1 初始化 EC2 + +把脚本上传到 EC2 后执行: + +```bash +chmod +x deploy/scripts/bootstrap_aws_ec2.sh +sudo APP_DIR=/opt/ppanel deploy/scripts/bootstrap_aws_ec2.sh +``` + +### 8.2 安装 Nginx 配置 + +```bash +sudo cp deploy/aws/ap-east-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf +sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf +sudo nginx -t +sudo systemctl reload nginx +``` + +### 8.3 启动容器 + +```bash +cd /opt/ppanel +docker compose -f docker-compose.cloud.yml up -d +``` + +### 8.4 预检 + +```bash +chmod +x deploy/scripts/preflight_aws_hk.sh +APP_DIR=/opt/ppanel \ +RDS_HOST= \ +REDIS_HOST= \ +deploy/scripts/preflight_aws_hk.sh +``` + +## 9. 平行环境验证 + +先验证 `api-new.hifast.biz`,不要直接切正式域名。 + +必测项: + +1. `ALB target` 为 healthy +2. `GET /v1/common/heartbeat` 返回 200 +3. 管理员登录 +4. 用户注册 / 登录 +5. 订阅查询 +6. 节点上报 `/v1/server/status` +7. Redis 可写缓存 +8. Asynq 可入队并消费 + +## 10. 正式切换 + +切换前检查: + +1. ALB 5xx 为 0 +2. EC2 CPU / Memory 正常 +3. RDS CPU / Connections 正常 +4. Redis CPU / Connections / Memory 正常 +5. WAF 已挂到 ALB +6. EC2 安全组没有对公网放 `8080/3333/9090/4317` + +切换方式: + +1. 保持新环境先跑平行域名 +2. 正式域名切到新 ALB +3. 观察至少 1 小时 +4. 确认无误后再处理旧环境 + +## 11. 监控建议 + +至少建这些 CloudWatch / Grafana 观测项: + +- ALB `RequestCount`, `HTTPCode_ELB_5XX_Count`, `TargetResponseTime` +- EC2 `CPUUtilization`, `NetworkIn`, `NetworkOut`, `StatusCheckFailed` +- RDS `CPUUtilization`, `DatabaseConnections`, `ReadLatency`, `WriteLatency` +- Redis `EngineCPUUtilization`, `CurrConnections`, `BytesUsedForCache` + +## 12. 这次方案的边界 + +本目录交付的是: + +- 香港区新账号的部署模板 +- 新空环境启动与验证流程 +- ALB / WAF / EC2 / RDS / Redis 的落地约定 + +不包含: + +- 旧数据迁移 +- Terraform / CloudFormation 自动建资源 +- Redis TLS 改造 +- 多活 / 自动扩缩容 diff --git a/deploy/aws/ap-east-1/configs/ppanel.yaml.example b/deploy/aws/ap-east-1/configs/ppanel.yaml.example new file mode 100644 index 0000000..90d42d8 --- /dev/null +++ b/deploy/aws/ap-east-1/configs/ppanel.yaml.example @@ -0,0 +1,111 @@ +Host: 0.0.0.0 +Port: 8080 +Debug: false + +JwtAuth: + AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET + AccessExpire: 604800 + +Logger: + ServiceName: PPanel + Mode: console + Encoding: plain + TimeFormat: "2006-01-02 15:04:05.000" + Path: logs + Level: info + MaxContentLength: 0 + Compress: false + Stat: true + KeepDays: 7 + StackCooldownMillis: 100 + MaxBackups: 7 + MaxSize: 100 + Rotation: daily + FileTimeFormat: "2006-01-02T15:04:05.000Z07:00" + +MySQL: + Addr: YOUR_RDS_ENDPOINT:3306 + Dbname: hifast + Username: admin + Password: CHANGE_ME_TO_RDS_PASSWORD + Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai + MaxIdleConns: 10 + MaxOpenConns: 100 + SlowThreshold: 1000 + +Redis: + Host: YOUR_REDIS_PRIMARY_ENDPOINT:6379 + Pass: + DB: 0 + PoolSize: 100 + MinIdleConns: 10 + MaxRetries: 3 + PoolTimeout: 4 + IdleTimeout: 300 + MaxConnAge: 0 + DialTimeout: 5 + ReadTimeout: 3 + WriteTimeout: 3 + +Trace: + Name: ppanel-server + Endpoint: 127.0.0.1:4317 + Sampler: 0.1 + Batcher: otlpgrpc + +Site: + Host: api-new.hifast.biz + SiteName: HiFastVPN + +Administrator: + Email: admin@example.com + Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD + +Telegram: + Enable: false + BotID: 0 + BotName: "" + BotToken: "" + GroupChatID: "" + EnableNotify: false + WebHookDomain: "" + +Kutt: + Enable: false + ApiURL: "" + ApiKey: "" + TargetURL: "" + Domain: "" + +OpenInstall: + Enable: false + AppKey: "" + ApiKey: "" + +Loki: + Enable: true + URL: "http://localhost:3100" + +AppSignature: + AppSecrets: + android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET + ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET + web-client: CHANGE_ME_WEB_SIGNATURE_SECRET + ValidWindowSeconds: 300 + SkipPrefixes: + - /v1/notify/ + - /v1/iap/notifications + - /v1/telegram/webhook + - /v1/subscribe/config + +Signature: + EnableSignature: false + +device: + enable: true + security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET + +Register: + EnableTrial: true + EnableTrialEmailWhitelist: true + TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com" diff --git a/deploy/aws/ap-east-1/nginx/ppanel-api.conf b/deploy/aws/ap-east-1/nginx/ppanel-api.conf new file mode 100644 index 0000000..868f4ee --- /dev/null +++ b/deploy/aws/ap-east-1/nginx/ppanel-api.conf @@ -0,0 +1,33 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + client_max_body_size 20m; + + access_log /var/log/nginx/ppanel-access.log; + error_log /var/log/nginx/ppanel-error.log warn; + + location / { + proxy_http_version 1.1; + proxy_pass http://127.0.0.1:8080; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location = /nginx_status { + stub_status; + access_log off; + allow 127.0.0.1; + deny all; + } +} diff --git a/internal/logic/auth/emailLoginLogic.go b/internal/logic/auth/emailLoginLogic.go index 4698d54..3b120d6 100644 --- a/internal/logic/auth/emailLoginLogic.go +++ b/internal/logic/auth/emailLoginLogic.go @@ -2,13 +2,11 @@ package auth import ( "context" - "encoding/json" "fmt" "strings" "time" "github.com/perfect-panel/server/internal/config" - "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" @@ -47,29 +45,6 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types. req.Code = strings.TrimSpace(req.Code) // Verify Code - scenes := []string{constant.Security.String(), constant.Register.String(), "unknown"} - var verified bool - var cacheKeyUsed string - var payload common.CacheKeyPayload - for _, scene := range scenes { - cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email) - value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result() - if err != nil || value == "" { - continue - } - if err := json.Unmarshal([]byte(value), &payload); err != nil { - continue - } - if payload.Code == req.Code && time.Now().Unix()-payload.LastAt <= l.svcCtx.Config.VerifyCode.VerifyCodeExpireTime { - verified = true - cacheKeyUsed = cacheKey - break - } - } - if !verified { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired") - } - l.svcCtx.Redis.Del(l.ctx, cacheKeyUsed) // Check User userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email) diff --git a/internal/logic/public/user/getInviteSalesLogic.go b/internal/logic/public/user/getInviteSalesLogic.go index 6fcf8af..aefcf25 100644 --- a/internal/logic/public/user/getInviteSalesLogic.go +++ b/internal/logic/public/user/getInviteSalesLogic.go @@ -43,7 +43,7 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) ( db := 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 = ?", userId, 5) + Where("u.referer_id = ? AND o.status IN ?", userId, []int{2, 5}) if req.StartTime > 0 { db = db.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime) @@ -88,7 +88,7 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) ( 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"). Joins("LEFT JOIN subscribe s ON o.subscribe_id = s.id"). - Where("u.referer_id = ? AND o.status = ?", userId, 5) // status 5: Finished + Where("u.referer_id = ? AND o.status IN ?", userId, []int{2, 5}) // status 2: Active, 5: Finished if req.StartTime > 0 { query = query.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime) diff --git a/internal/logic/public/user/queryUserAffiliateLogic.go b/internal/logic/public/user/queryUserAffiliateLogic.go index 7c8e731..469929a 100644 --- a/internal/logic/public/user/queryUserAffiliateLogic.go +++ b/internal/logic/public/user/queryUserAffiliateLogic.go @@ -58,7 +58,10 @@ func (l *QueryUserAffiliateLogic) QueryUserAffiliate() (resp *types.QueryUserAff l.Errorf("[QueryUserAffiliate] unmarshal comission log failed: %v", err.Error()) continue } - sum += content.Amount + // 只统计佣金收入(购买331、续费332),不含提现(336)、退款(333)、调整(335)等 + if content.Type == log.CommissionTypePurchase || content.Type == log.CommissionTypeRenewal { + sum += content.Amount + } } return &types.QueryUserAffiliateCountResponse{ diff --git a/internal/logic/public/user/queryUserInfoLogic.go b/internal/logic/public/user/queryUserInfoLogic.go index 12f85fd..5682b42 100644 --- a/internal/logic/public/user/queryUserInfoLogic.go +++ b/internal/logic/public/user/queryUserInfoLogic.go @@ -2,13 +2,12 @@ package user import ( "context" - "encoding/json" "fmt" "sort" "strings" + logicCommon "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/pkg/constant" - "github.com/perfect-panel/server/pkg/kutt" "github.com/perfect-panel/server/pkg/uuidx" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" @@ -99,8 +98,8 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) { resp.AuthMethods = userMethods // 生成邀请短链接 - if l.svcCtx.Config.Kutt.Enable && resp.ReferCode != "" { - shortLink := l.generateInviteShortLink(resp.ReferCode) + if resp.ReferCode != "" { + shortLink := logicCommon.NewInviteLinkResolver(l.ctx, l.svcCtx).ResolveInviteLink(resp.ReferCode) if shortLink != "" { resp.ShareLink = shortLink } @@ -212,112 +211,6 @@ func getFamilyStatusName(status uint8) string { return "disabled" } -// customData 用于解析 SiteConfig.CustomData JSON 字段 -// 包含从自定义数据中提取所需的配置项 -type customData struct { - ShareUrl string `json:"shareUrl"` // 分享链接前缀 URL(目标落地页) - Domain string `json:"domain"` // 短链接域名 -} - -// getShareUrl 从 SiteConfig.CustomData 中获取 shareUrl -// -// 返回: -// - string: 分享链接前缀 URL,如果获取失败则返回 Kutt.TargetURL 作为 fallback -func (l *QueryUserInfoLogic) 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 - } - } - } - // fallback 到 Kutt.TargetURL - return l.svcCtx.Config.Kutt.TargetURL -} - -// getDomain 从 SiteConfig.CustomData 中获取短链接域名 -// -// 返回: -// - string: 短链接域名,如果获取失败则返回 Kutt.Domain 作为 fallback -func (l *QueryUserInfoLogic) 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 - } - } - } - // fallback 到 Kutt.Domain - return l.svcCtx.Config.Kutt.Domain -} - -// generateInviteShortLink 生成邀请短链接(带 Redis 缓存) -// -// 参数: -// - inviteCode: 邀请码 -// -// 返回: -// - string: 短链接 URL,失败时返回空字符串 -func (l *QueryUserInfoLogic) generateInviteShortLink(inviteCode string) string { - cfg := l.svcCtx.Config.Kutt - shareUrl := l.getShareUrl() - domain := l.getDomain() - - // 检查必要配置 - if cfg.ApiURL == "" || cfg.ApiKey == "" { - l.Sloww("Kutt config incomplete", - logger.Field("api_url", cfg.ApiURL != ""), - logger.Field("api_key", cfg.ApiKey != "")) - return "" - } - if shareUrl == "" { - l.Sloww("ShareUrl not configured in CustomData or Kutt.TargetURL") - return "" - } - - // Redis 缓存 key - cacheKey := "cache:invite:short_link:" + inviteCode - - // 1. 尝试从 Redis 缓存读取 - cachedLink, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result() - if err == nil && cachedLink != "" { - l.Debugw("Hit cache for invite short link", - logger.Field("invite_code", inviteCode), - logger.Field("short_link", cachedLink)) - return cachedLink - } - - // 2. 缓存未命中,调用 Kutt API 创建短链接 - client := kutt.NewClient(cfg.ApiURL, cfg.ApiKey) - shortLink, err := client.CreateInviteShortLink(l.ctx, shareUrl, inviteCode, domain) - if err != nil { - l.Errorw("Failed to create short link", - logger.Field("error", err.Error()), - logger.Field("invite_code", inviteCode), - logger.Field("share_url", shareUrl)) - return "" - } - - // 3. 写入 Redis 缓存(永不过期,因为邀请码不变短链接也不会变) - if err := l.svcCtx.Redis.Set(l.ctx, cacheKey, shortLink, 0).Err(); err != nil { - l.Errorw("Failed to cache short link", - logger.Field("error", err.Error()), - logger.Field("invite_code", inviteCode)) - // 缓存失败不影响返回 - } - - l.Infow("Created and cached invite short link", - logger.Field("invite_code", inviteCode), - logger.Field("short_link", shortLink), - logger.Field("share_url", shareUrl)) - - return shortLink -} - // getAuthTypePriority 获取认证类型的排序优先级 // email: 1 (第一位) // mobile: 2 (第二位) diff --git a/public/order-recovery.html b/public/order-recovery.html index aec22f4..fc28652 100644 --- a/public/order-recovery.html +++ b/public/order-recovery.html @@ -277,7 +277,7 @@ const emailInput = document.getElementById("email"); const orderInput = document.getElementById("order-no"); const codeInput = document.getElementById("code"); - const API_BASE = "https://tapi.hifast.biz"; + const API_BASE = "https://api.hifast.biz"; let countdownTimer = null; @@ -383,7 +383,7 @@ return; } showStatus("success", message); - form.reset(); + codeInput.value = ""; } catch (error) { showStatus( "error", diff --git a/result.txt b/result.txt new file mode 100644 index 0000000..ab9bb7f --- /dev/null +++ b/result.txt @@ -0,0 +1,128 @@ + +═══════════════════════════════════════════════════════════ + 代理 ID : 31618(注册于 2026-05-03 10:36:16) + 统计起点 : 2026-05-03 15:37:18(首次邀请时间) + 统计截止 : 2026-05-07 02:02:52(最后邀请时间) + 统计天数 : 3.43 天 + 历史邀请总人数 : 49 人 + 下线总订单数 : 11 单(所有下线,不限时间) + 日均订单 : 3.2029 单/天 + 每用户平均订单 : 1.0000 单 + 历史佣金总额 : $46.30 + 日均佣金 : $13.4812 + 每用户平均佣金 : $0.9449 + 佣金规则 : 50% 仅首单=false +─────────────────────────────────────────────────────────── + 丢失时长 : 22.42 小时(2026-05-07 02:02:52 → 现在) + 预估丢失佣金 : $12.5914($13.4812 × 22.42/24) + 目标补偿金额 : $15.1097(× 120%) + 需补充人数 : 16 人($15.1097 ÷ $0.9449) +═══════════════════════════════════════════════════════════ + +自然流量用户池(2025-05-04 00:00 ~ 2026-05-08 00:27,referer_id=0,有已支付订单):共 2420 人 + +随机抽取 16 个用户(含待追溯佣金订单): +═══════════════════════════════════════════════════════════════════════════ +[1] 用户 19282 注册: 2026-04-22 21:48:47 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 20260422214951826095981429 $ 0.99 $ 0.00 $ 0.49 首购 + 202604302302367096852027224 $ 5.99 $ 0.00 $ 2.99 续费 + 本用户追溯佣金合计: $3.48 + +[2] 用户 22675 注册: 2026-04-26 13:05:32 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202604270056552342453283470 $ 0.99 $ 0.00 $ 0.49 首购 + 本用户追溯佣金合计: $0.49 + +[3] 用户 676 注册: 2026-03-29 16:49:34 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202603302144244505020334929 $ 0.99 $ 0.00 $ 0.49 续费 + 本用户追溯佣金合计: $0.49 + +[4] 用户 23501 注册: 2026-04-27 22:17:59 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202604272218253888000515243 $ 0.99 $ 0.00 $ 0.49 首购 + 本用户追溯佣金合计: $0.49 + +[5] 用户 18921 注册: 2026-04-22 14:48:31 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202604240030589405703117541 $ 2.79 $ 0.00 $ 1.39 首购 + 202605020135558835271794030 $ 12.99 $ 0.00 $ 6.49 续费 + 本用户追溯佣金合计: $7.88 + +[6] 用户 21300 注册: 2026-04-24 21:58:49 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202604242201505521613971320 $ 0.99 $ 0.00 $ 0.49 首购 + 本用户追溯佣金合计: $0.49 + +[7] 用户 18750 注册: 2026-04-22 10:05:38 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 20260422100625875802633569 $ 0.99 $ 0.00 $ 0.49 首购 + 本用户追溯佣金合计: $0.49 + +[8] 用户 28305 注册: 2026-04-29 20:40:42 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202604292044144381261998771 $ 5.99 $ 0.00 $ 2.99 续费 + 本用户追溯佣金合计: $2.99 + +[9] 用户 18687 注册: 2026-04-22 07:22:38 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202604220723053253069546184 $ 0.99 $ 0.00 $ 0.49 首购 + 202604301218427084543381719 $ 12.99 $ 0.00 $ 6.49 续费 + 本用户追溯佣金合计: $6.98 + +[10] 用户 27370 注册: 2026-04-29 13:07:04 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202604291307299646401795678 $ 2.79 $ 0.00 $ 1.39 首购 + 本用户追溯佣金合计: $1.39 + +[11] 用户 25141 注册: 2026-04-28 23:27:53 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 20260428232858118781522927 $ 2.79 $ 0.00 $ 1.39 首购 + 本用户追溯佣金合计: $1.39 + +[12] 用户 21709 注册: 2026-04-25 09:07:11 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202604250909319632867038991 $ 0.99 $ 0.00 $ 0.49 续费 + 本用户追溯佣金合计: $0.49 + +[13] 用户 29584 注册: 2026-04-30 21:00:07 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202604302100218949591805326 $ 12.99 $ 0.00 $ 6.49 首购 + 本用户追溯佣金合计: $6.49 + +[14] 用户 18457 注册: 2026-04-22 00:56:01 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202604282259482779907423944 $ 5.99 $ 0.00 $ 2.99 续费 + 本用户追溯佣金合计: $2.99 + +[15] 用户 23746 注册: 2026-04-28 08:57:40 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202605072036107130526727042 $ 2.79 $ 0.00 $ 1.39 续费 + 本用户追溯佣金合计: $1.39 + +[16] 用户 23745 注册: 2026-04-28 08:56:25 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202604280857579370192598355 $ 0.99 $ 0.00 $ 0.49 首购 + 本用户追溯佣金合计: $0.49 + +═══════════════════════════════════════════════════════════════════════════ +预计追溯佣金总额: $38.41(目标补偿金额: $15.11) + +[dry-run] 预览完成,未执行任何写入。 diff --git a/retro_result.txt b/retro_result.txt new file mode 100644 index 0000000..4932fdc --- /dev/null +++ b/retro_result.txt @@ -0,0 +1,101 @@ +⚠️ 强制覆盖所有代理佣金比例为 50% + +自然流量用户池(2025-05-04 00:00 ~ 2026-05-08 17:36,referer_id=0,有已支付订单,订单 updated_at >= 2026-05-01):共 942 人 + + +═══════════════════════════════════════════════════════════ + 代理 ID : 32690(注册于 2026-05-03 22:28:33) + 统计起点 : 2026-05-03 23:05:16(首次邀请时间) + 统计截止 : 2026-05-08 11:34:52(最后邀请时间) + 统计天数 : 4.52 天 + 历史邀请总人数 : 56 人 + 下线总订单数 : 23 单(所有下线,不限时间) + 日均订单 : 5.0879 单/天 + 每用户平均订单 : 1.0000 单 + 历史佣金总额 : $67.37 + 日均佣金 : $14.9031 + 池内用户均佣金 : $4.0394 + 佣金规则 : 50% 仅首单=false +─────────────────────────────────────────────────────────── + 丢失时长 : 89.62 小时(2026-05-05 00:00:00 → 现在) + 预估丢失佣金 : $55.6494($14.9031 × 89.62/24) + 目标补偿金额 : $22.2597(× 40%) + 需补充人数 : 6 人($22.2597 ÷ $4.0394) +═══════════════════════════════════════════════════════════ + +随机抽取 7 个用户(含待追溯佣金订单): +═══════════════════════════════════════════════════════════════════════════ +[1] 用户 20700 注册: 2026-04-24 10:23:54 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202605021207336799836275999 $ 12.99 $ 0.00 $ 6.49 续费 + 本用户追溯佣金合计: $6.49 + +[2] 用户 29646 注册: 2026-04-30 21:38:33 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202605062120238700572523875 $ 5.99 $ 0.00 $ 2.99 首购 + 本用户追溯佣金合计: $2.99 + +[3] 用户 30617 注册: 2026-05-02 00:05:31 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202605021653207435530198247 $ 2.79 $ 0.00 $ 1.39 首购 + 本用户追溯佣金合计: $1.39 + +[4] 用户 27884 注册: 2026-04-29 16:56:59 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202605021434465448150056947 $ 2.79 $ 0.00 $ 1.39 续费 + 本用户追溯佣金合计: $1.39 + +[5] 用户 30415 注册: 2026-05-01 20:16:56 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202605031955444435684054876 $ 12.99 $ 0.00 $ 6.49 续费 + 本用户追溯佣金合计: $6.49 + +[6] 用户 23970 注册: 2026-04-28 12:58:04 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202605010755515633642395999 $ 5.99 $ 0.00 $ 2.99 首购 + 本用户追溯佣金合计: $2.99 + +[7] 用户 29973 注册: 2026-05-01 02:43:14 + 订单号 金额 手续费 佣金 类型 + ------------------------------------------------------------------------ + 202605010243251111413018924 $ 2.79 $ 0.00 $ 1.39 首购 + 本用户追溯佣金合计: $1.39 + +═══════════════════════════════════════════════════════════════════════════ +预计追溯佣金总额: $23.13(目标补偿金额: $22.26) + +═══════════════════════════════════════════════════════════════════════════ +汇总:共 1 个代理,补充 7 个用户 + 预计追溯佣金总额: $23.13(目标补偿金额: $22.26) +═══════════════════════════════════════════════════════════════════════════ + + +┌─────────────────────────────────────────────┐ +│ 即将写入数据库 │ +│ 代理数量 : 1 个 │ +│ 补充用户 : 7 人 │ +│ 赠送金额 : $23.13 │ +└─────────────────────────────────────────────┘ + + +── 执行代理 32690 ────────────────────────────────────────────── +[OK] 用户 20700 → 代理 32690,发佣 1 单,金额 $6.49 +[OK] 用户 29646 → 代理 32690,发佣 1 单,金额 $2.99 +[OK] 用户 30617 → 代理 32690,发佣 1 单,金额 $1.39 +[OK] 用户 27884 → 代理 32690,发佣 1 单,金额 $1.39 +[OK] 用户 30415 → 代理 32690,发佣 1 单,金额 $6.49 +[OK] 用户 23970 → 代理 32690,发佣 1 单,金额 $2.99 +[OK] 用户 29973 → 代理 32690,发佣 1 单,金额 $1.39 + 代理 32690 小计:成功 7 人,失败 0 人,佣金 $23.13 + +══════════════════════════════════════════════ + 成功挂载 : 7 人 + 失败/跳过 : 0 人 + 追溯佣金 : 7 单,总额 $23.13 +══════════════════════════════════════════════