158 lines
6.5 KiB
Go
158 lines
6.5 KiB
Go
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
|
||
}
|