package mysql import ( "context" "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" ) // GetTeamHostSalaryStats 按 Agency 收款人集合聚合主播工资统计,经理中心团队工资卡片使用。 // 预收入口径与结算保持同一套档位数学:当前累计钻石命中的档位累计工资 + 超出档位的剩余钻石按月底费率折美元; // 再与已结算累计(含已入账剩余折算)取大,保证已 month_end 关闭的周期回读即真实发放值,进行中周期是即时估算。 // 这是只读聚合,不加锁不推进任何结算进度。 func (r *Repository) GetTeamHostSalaryStats(ctx context.Context, query ledger.TeamHostSalaryStatsQuery) ([]ledger.TeamHostSalaryCycleStat, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } owners := uniquePositiveInt64(query.AgencyOwnerUserIDs) stats := make([]ledger.TeamHostSalaryCycleStat, 0, len(query.CycleKeys)) // 政策按区域跨周期复用同一份解析结果;估算统一用“当前生效政策”,不回放历史政策版本。 policyByRegion := map[int64]*ledger.HostSalaryPolicy{} for _, cycleKey := range query.CycleKeys { cycleKey = strings.TrimSpace(cycleKey) if cycleKey == "" { return nil, xerr.New(xerr.InvalidArgument, "cycle_key is required") } stat := ledger.TeamHostSalaryCycleStat{CycleKey: cycleKey} if len(owners) == 0 { // 经理团队还没有任何 Agency 时返回零值统计,调用方不需要为空团队做特殊分支。 stats = append(stats, stat) continue } if err := r.aggregateTeamHostSalaryCycle(ctx, owners, cycleKey, query.NowMs, policyByRegion, &stat); err != nil { return nil, err } stats = append(stats, stat) } return stats, nil } // teamHostSalaryRow 是单主播周期账户与结算进度的联合投影,先整批读出再做档位估算。 type teamHostSalaryRow struct { RegionID int64 TotalDiamonds int64 SettledHostUSDMinor int64 SettledResidualUSDMinor int64 MonthEndClearedAtMS int64 } func (r *Repository) aggregateTeamHostSalaryCycle(ctx context.Context, owners []int64, cycleKey string, nowMs int64, policyByRegion map[int64]*ledger.HostSalaryPolicy, stat *ledger.TeamHostSalaryCycleStat) error { // 先把整个结果集读完再解析政策:政策查询和主查询共用连接池,游标未关时嵌套发查询 // 在池被打满的场景下会互相等待造成饥饿;行结构很小,整批驻留内存成本可忽略。 hostRows, err := r.listTeamHostSalaryRows(ctx, owners, cycleKey) if err != nil { return err } for _, row := range hostRows { // 已结算累计包含月底剩余钻石折美元,两者都入了主播工资钱包,属于同一“主播工资”口径。 settledTotal, err := checkedAdd(row.SettledHostUSDMinor, row.SettledResidualUSDMinor) if err != nil { return err } estimated := settledTotal // month_end 已清算的周期以真实发放为准,不再用“当前政策”重估: // 政策在周期关闭后上调时,重估会显示从未发放过的金额,误导经理对账。 if row.MonthEndClearedAtMS == 0 { policy, err := r.resolveTeamSalaryPolicy(ctx, row.RegionID, nowMs, policyByRegion) if err != nil { return err } if policy != nil { // 与结算同一套档位数学:最高已达档位累计工资 + 档位之上的剩余钻石按月底费率折美元。 level := highestHostSalaryLevel(policy.Levels, row.TotalDiamonds) residualEstimate, err := usdMinorFromDiamondRate(residualDiamondRemainder(row.TotalDiamonds, level), policy.ResidualDiamondToUSDRate) if err != nil { return err } levelTotal, err := checkedAdd(level.HostSalaryUSDMinor, residualEstimate) if err != nil { return err } // 取大兜底政策回调:进行中周期的预收入永远不小于已经真实入账的工资。 estimated = maxInt64(settledTotal, levelTotal) } } stat.ActiveHostCount++ stat.TotalDiamonds, err = checkedAdd(stat.TotalDiamonds, row.TotalDiamonds) if err != nil { return err } stat.SettledHostSalaryUSDMinor, err = checkedAdd(stat.SettledHostSalaryUSDMinor, settledTotal) if err != nil { return err } stat.EstimatedHostSalaryUSDMinor, err = checkedAdd(stat.EstimatedHostSalaryUSDMinor, estimated) if err != nil { return err } } return nil } func (r *Repository) listTeamHostSalaryRows(ctx context.Context, owners []int64, cycleKey string) ([]teamHostSalaryRow, error) { args := make([]any, 0, len(owners)+2) args = append(args, appcode.FromContext(ctx), cycleKey) for _, owner := range owners { args = append(args, owner) } // 周期账户是收礼实时累计的事实表;LEFT JOIN 进度表拿到已结算累计和周期关闭标记,未结算过的主播按 0 处理。 rows, err := r.db.QueryContext(ctx, ` SELECT a.region_id, a.total_diamonds, COALESCE(p.settled_host_salary_usd_minor, 0), COALESCE(p.residual_usd_minor, 0), COALESCE(p.month_end_cleared_at_ms, 0) FROM host_period_diamond_accounts a LEFT JOIN host_salary_settlement_progress p ON p.app_code = a.app_code AND p.user_id = a.user_id AND p.cycle_key = a.cycle_key WHERE a.app_code = ? AND a.cycle_key = ? AND a.total_diamonds > 0 AND a.agency_owner_user_id IN (`+placeholders(len(owners))+`)`, args...) if err != nil { return nil, err } defer rows.Close() hostRows := make([]teamHostSalaryRow, 0, 256) for rows.Next() { var row teamHostSalaryRow if err := rows.Scan(&row.RegionID, &row.TotalDiamonds, &row.SettledHostUSDMinor, &row.SettledResidualUSDMinor, &row.MonthEndClearedAtMS); err != nil { return nil, err } hostRows = append(hostRows, row) } return hostRows, rows.Err() } func (r *Repository) resolveTeamSalaryPolicy(ctx context.Context, regionID int64, nowMs int64, policyByRegion map[int64]*ledger.HostSalaryPolicy) (*ledger.HostSalaryPolicy, error) { if cached, ok := policyByRegion[regionID]; ok { return cached, nil } // 展示估算不区分结算模式和触发方式,取区域当前最新生效政策,与 H5 平台政策展示口径一致。 policy, found, err := r.queryActiveHostSalaryPolicy(ctx, r.db, regionID, "", "", nowMs) if err != nil { return nil, err } if !found { // 区域没有生效政策时记 nil,主播预收入退化为已结算累计,不做档位估算。 policyByRegion[regionID] = nil return nil, nil } policyByRegion[regionID] = &policy return &policy, nil }