2026-07-09 13:02:49 +08:00

158 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}