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