package mysql import ( "context" "database/sql" "errors" "fmt" "math" "math/big" "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" ) type hostSalaryCandidate struct { // UserID 与 CycleKey 唯一定位主播当月工资周期账户;后续所有结算进度都挂在这组键上。 UserID int64 CycleKey string RegionID int64 AgencyOwnerUserID int64 TotalDiamonds int64 GiftDiamondTotal int64 } type hostSalaryProgress struct { // 这些 settled_* 字段保存“已发累计权益”,结算时只对比当前等级累计值来补差额,天然防重复发放。 SettledLevelNo int SettledHostSalaryUSDMinor int64 SettledHostCoinReward int64 SettledAgencySalaryUSDMinor int64 LastSettledTotalDiamonds int64 ResidualUSDMinor int64 MonthEndClearedAtMS int64 LastPolicyID uint64 Version int64 } type hostSalarySettlementMetadata struct { // metadata 是审计快照:钱包交易、outbox、结算记录共享同一份口径,方便后续从任一链路追账。 AppCode string `json:"app_code"` SettlementType string `json:"settlement_type"` TriggerMode string `json:"trigger_mode"` SettlementRole string `json:"settlement_role"` HostUserID int64 `json:"host_user_id"` AgencyOwnerUserID int64 `json:"agency_owner_user_id"` CycleKey string `json:"cycle_key"` RegionID int64 `json:"region_id"` PolicyID uint64 `json:"policy_id"` PolicyName string `json:"policy_name"` LevelNo int `json:"level_no"` RequiredDiamonds int64 `json:"required_diamonds"` TotalDiamonds int64 `json:"total_diamonds"` HostSalaryUSDMinorDelta int64 `json:"host_salary_usd_minor_delta"` HostCoinRewardDelta int64 `json:"host_coin_reward_delta"` AgencySalaryUSDMinorDelta int64 `json:"agency_salary_usd_minor_delta"` ResidualUSDMinorDelta int64 `json:"residual_usd_minor_delta"` HostUSDBalanceAfter int64 `json:"host_usd_balance_after"` HostCoinBalanceAfter int64 `json:"host_coin_balance_after"` AgencyUSDBalanceAfter int64 `json:"agency_usd_balance_after"` HostSalaryAssetType string `json:"host_salary_asset_type"` AgencySalaryAssetType string `json:"agency_salary_asset_type"` SettledLevelNo int `json:"settled_level_no"` SettledHostSalaryUSDMinor int64 `json:"settled_host_salary_usd_minor"` SettledHostCoinReward int64 `json:"settled_host_coin_reward"` SettledAgencySalaryUSDMinor int64 `json:"settled_agency_salary_usd_minor"` ResidualUSDMinor int64 `json:"residual_usd_minor"` MonthEndClearedAtMS int64 `json:"month_end_cleared_at_ms"` ProcessedAtMS int64 `json:"processed_at_ms"` } type hostSalaryPolicyQuerier interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) } // ProcessHostSalarySettlementBatch 按周期账户扫描并结算主播美元、主播金币奖励和代理美元工资。 // 每个主播单独开事务,避免某个主播配置异常拖垮整个批次,也让 cron-service 可按 has_more 继续拉下一页。 func (r *Repository) ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) { if r == nil || r.db == nil { return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command.SettlementType = normalizeHostSalarySettlementType(command.SettlementType) if command.SettlementType == "" { return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "settlement_type is invalid") } command.TriggerMode = normalizeHostSalarySettlementTriggerMode(command.TriggerMode) if command.TriggerMode == "" { return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "trigger_mode is invalid") } command.SettlementRole = normalizeHostSalarySettlementRole(command.SettlementRole) if command.SettlementRole == "" { return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "settlement_role is invalid") } if command.NowMs <= 0 { return ledger.HostSalarySettlementBatchResult{}, xerr.New(xerr.InvalidArgument, "now_ms is required") } if command.BatchSize <= 0 { command.BatchSize = 100 } limit := command.BatchSize + 1 candidates, err := r.listHostSalaryCandidates(ctx, command, limit) if err != nil { return ledger.HostSalarySettlementBatchResult{}, err } hasMore := len(candidates) > command.BatchSize if hasMore { candidates = candidates[:command.BatchSize] } result := ledger.HostSalarySettlementBatchResult{ClaimedCount: len(candidates), HasMore: hasMore} var firstErr error for _, candidate := range candidates { processed, err := r.processHostSalaryCandidate(ctx, command, candidate) result.ProcessedCount++ if err != nil { if firstErr == nil { firstErr = err } result.FailureCount++ continue } if processed { result.SuccessCount++ } } if result.ClaimedCount > 0 && result.SuccessCount == 0 && firstErr != nil { return result, firstErr } return result, nil } func (r *Repository) listHostSalaryCandidates(ctx context.Context, command ledger.HostSalarySettlementBatchCommand, limit int) ([]hostSalaryCandidate, error) { policyModeClause := "" userFilterClause := "" args := []any{appcode.FromContext(ctx), command.CycleKey} var policyModeArgs []any if command.SettlementType != ledger.HostSalarySettlementTypeMonthEnd { // 日结和半月结必须精确匹配政策配置的结算模式;月底清算复用有效政策处理剩余钻石和周期关闭。 policyModeClause = "AND p.settlement_mode = ?" policyModeArgs = append(policyModeArgs, command.SettlementType) } userIDs := uniquePositiveInt64(command.UserIDs) if len(userIDs) > 0 { // 后台手动结算只允许处理运营勾选的主播;过滤下推到候选 SQL,避免锁定无关周期账户。 userFilterClause = "AND a.user_id IN (" + placeholders(len(userIDs)) + ")" for _, id := range userIDs { args = append(args, id) } } args = append(args, command.NowMs, command.NowMs, command.TriggerMode) args = append(args, policyModeArgs...) // 日结/半月结只扫描“本周期钻石比上次结算更多”的主播,避免 cron 重跑时反复锁定无新增收益的账户。 // 月底批次必须扫描全部未关闭周期账户,因为它还负责剩余钻石折美元和周期逻辑关闭。 progressClause := "AND a.total_diamonds > COALESCE(progress.last_settled_total_diamonds, 0)" if command.SettlementType == ledger.HostSalarySettlementTypeMonthEnd { progressClause = "" } args = append(args, limit) query := fmt.Sprintf(` SELECT a.user_id, a.cycle_key, a.region_id, a.agency_owner_user_id, a.total_diamonds, a.gift_diamond_total FROM host_period_diamond_accounts a LEFT JOIN host_salary_settlement_progress progress ON progress.app_code = a.app_code AND progress.user_id = a.user_id AND progress.cycle_key = a.cycle_key WHERE a.app_code = ? AND a.cycle_key = ? AND a.total_diamonds > 0 AND COALESCE(progress.month_end_cleared_at_ms, 0) = 0 %s %s AND EXISTS ( SELECT 1 FROM host_agency_salary_policies p WHERE p.app_code = a.app_code AND p.region_id = a.region_id AND p.status = 'active' AND p.effective_from_ms <= ? AND (p.effective_to_ms = 0 OR p.effective_to_ms > ?) AND p.settlement_trigger_mode = ? %s ) ORDER BY a.updated_at_ms ASC, a.user_id ASC LIMIT ?`, progressClause, userFilterClause, policyModeClause) rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() candidates := make([]hostSalaryCandidate, 0, limit) for rows.Next() { var candidate hostSalaryCandidate if err := rows.Scan(&candidate.UserID, &candidate.CycleKey, &candidate.RegionID, &candidate.AgencyOwnerUserID, &candidate.TotalDiamonds, &candidate.GiftDiamondTotal); err != nil { return nil, err } candidates = append(candidates, candidate) } return candidates, rows.Err() } func (r *Repository) processHostSalaryCandidate(ctx context.Context, command ledger.HostSalarySettlementBatchCommand, selected hostSalaryCandidate) (bool, error) { // 账务、进度和 outbox 必须在同一个主播级事务中提交;批次层只负责继续处理下一位主播。 tx, err := r.db.BeginTx(ctx, nil) if err != nil { return false, err } defer func() { _ = tx.Rollback() }() account, exists, err := r.lockHostSalaryCandidate(ctx, tx, selected.UserID, selected.CycleKey) if err != nil || !exists { return false, err } progress, err := r.lockHostSalaryProgress(ctx, tx, account.UserID, account.CycleKey, command.NowMs) if err != nil { return false, err } if progress.MonthEndClearedAtMS > 0 { return true, tx.Commit() } // 非月底批次不关闭周期,若同一周期已结算到当前钻石数,则本次只作为幂等空跑通过。 if command.SettlementType != ledger.HostSalarySettlementTypeMonthEnd && account.TotalDiamonds <= progress.LastSettledTotalDiamonds { return true, tx.Commit() } // 政策按周期账户里的区域快照解析;该快照由同周期最近一次主播收礼写入。 policy, ok, err := r.resolveHostSalaryPolicy(ctx, tx, account.RegionID, command.SettlementType, command.TriggerMode, command.NowMs) if err != nil || !ok { return false, err } shouldSettleHost := command.SettlementRole == "all" || command.SettlementRole == "host" shouldSettleAgency := command.SettlementRole == "all" || command.SettlementRole == "agency" // 工资表是累计值,因此每次只发“目标累计值 - 已发累计值”;这对应产品规则里的 2 级只补 2 级减 1 级。 level := highestHostSalaryLevel(policy.Levels, account.TotalDiamonds) residualTarget := progress.ResidualUSDMinor residualDelta := int64(0) if command.SettlementType == ledger.HostSalarySettlementTypeMonthEnd { // 月底不物理清零历史钻石,改为把剩余钻石折算入账并写 month_end_cleared_at_ms,保留审计和重放依据。 residualDiamonds := residualDiamondRemainder(account.TotalDiamonds, level) residualTarget, err = usdMinorFromDiamondRate(residualDiamonds, policy.ResidualDiamondToUSDRate) if err != nil { return false, err } residualDelta = positiveDelta(residualTarget, progress.ResidualUSDMinor) } if !shouldSettleHost { // Agency 人工结算只处理代理工资钱包;月底剩余钻石折美元仍属于 Host 工资钱包,不能在 Agency 行触发。 residualDelta = 0 } hostSalaryDelta := int64(0) hostCoinDelta := int64(0) if shouldSettleHost { hostSalaryDelta = positiveDelta(level.HostSalaryUSDMinor, progress.SettledHostSalaryUSDMinor) hostCoinDelta = positiveDelta(level.HostCoinReward, progress.SettledHostCoinReward) } agencyEntitlementDelta := int64(0) if shouldSettleAgency { agencyEntitlementDelta = positiveDelta(level.AgencySalaryUSDMinor, progress.SettledAgencySalaryUSDMinor) } agencyPaidDelta := agencyEntitlementDelta if account.AgencyOwnerUserID <= 0 { // 没有代理收款人时只把该等级代理权益标记为已处理,不在未来组织绑定后补发历史月份工资。 agencyPaidDelta = 0 } nextProgress := progress nextProgress.LastPolicyID = policy.PolicyID if shouldSettleHost && (hostSalaryDelta > 0 || hostCoinDelta > 0 || residualDelta > 0) { nextProgress.SettledLevelNo = maxInt(nextProgress.SettledLevelNo, level.LevelNo) nextProgress.SettledHostSalaryUSDMinor = maxInt64(nextProgress.SettledHostSalaryUSDMinor, level.HostSalaryUSDMinor) nextProgress.SettledHostCoinReward = maxInt64(nextProgress.SettledHostCoinReward, level.HostCoinReward) nextProgress.ResidualUSDMinor = maxInt64(nextProgress.ResidualUSDMinor, residualTarget) } if shouldSettleAgency && agencyEntitlementDelta > 0 { nextProgress.SettledLevelNo = maxInt(nextProgress.SettledLevelNo, level.LevelNo) nextProgress.SettledAgencySalaryUSDMinor = maxInt64(nextProgress.SettledAgencySalaryUSDMinor, level.AgencySalaryUSDMinor) } if account.AgencyOwnerUserID <= 0 { // 当前周期没有代理收款人时,代理权益视为已处理,避免后续绑定代理后追发旧周期代理工资。 nextProgress.SettledAgencySalaryUSDMinor = maxInt64(nextProgress.SettledAgencySalaryUSDMinor, level.AgencySalaryUSDMinor) } if hostSalaryProgressCoversLevel(nextProgress, level, account, command.SettlementType, residualTarget) { nextProgress.SettledLevelNo = maxInt(nextProgress.SettledLevelNo, level.LevelNo) nextProgress.LastSettledTotalDiamonds = account.TotalDiamonds if command.SettlementType == ledger.HostSalarySettlementTypeMonthEnd { nextProgress.MonthEndClearedAtMS = command.NowMs } } paidHostUSDDelta, err := checkedAdd(hostSalaryDelta, residualDelta) if err != nil { return false, err } metadata := hostSalarySettlementMetadata{ AppCode: appcode.FromContext(ctx), SettlementType: command.SettlementType, TriggerMode: command.TriggerMode, SettlementRole: command.SettlementRole, HostUserID: account.UserID, AgencyOwnerUserID: account.AgencyOwnerUserID, CycleKey: account.CycleKey, RegionID: account.RegionID, PolicyID: policy.PolicyID, PolicyName: policy.Name, LevelNo: level.LevelNo, RequiredDiamonds: level.RequiredDiamonds, TotalDiamonds: account.TotalDiamonds, HostSalaryUSDMinorDelta: hostSalaryDelta, HostCoinRewardDelta: hostCoinDelta, AgencySalaryUSDMinorDelta: agencyPaidDelta, ResidualUSDMinorDelta: residualDelta, HostSalaryAssetType: ledger.AssetHostSalaryUSD, AgencySalaryAssetType: ledger.AssetAgencySalaryUSD, SettledLevelNo: nextProgress.SettledLevelNo, SettledHostSalaryUSDMinor: nextProgress.SettledHostSalaryUSDMinor, SettledHostCoinReward: nextProgress.SettledHostCoinReward, SettledAgencySalaryUSDMinor: nextProgress.SettledAgencySalaryUSDMinor, ResidualUSDMinor: nextProgress.ResidualUSDMinor, MonthEndClearedAtMS: nextProgress.MonthEndClearedAtMS, ProcessedAtMS: command.NowMs, } if paidHostUSDDelta > 0 || hostCoinDelta > 0 || agencyPaidDelta > 0 { // settlement commandID 带上已结算等级和当前钻石,确保同一增量重复执行会命中交易幂等键。 commandID := hostSalarySettlementCommandID(command, account, policy.PolicyID, nextProgress) transactionID := transactionID(appcode.FromContext(ctx), commandID) if err := r.insertTransaction(ctx, tx, transactionID, commandID, bizTypeHostSalarySettlement, hostSalarySettlementRequestHash(metadata), fmt.Sprintf("%s:%d", account.CycleKey, account.UserID), metadata, command.NowMs); err != nil { return false, err } events, err := r.applyHostSalaryCredits(ctx, tx, transactionID, commandID, metadata, paidHostUSDDelta, hostCoinDelta, agencyPaidDelta, command.NowMs) if err != nil { return false, err } if err := r.insertWalletOutbox(ctx, tx, events); err != nil { return false, err } if err := r.insertHostSalarySettlementRecord(ctx, tx, transactionID, commandID, metadata, command.NowMs); err != nil { return false, err } } if err := r.updateHostSalaryProgress(ctx, tx, account.UserID, account.CycleKey, nextProgress, command.NowMs); err != nil { return false, err } if err := tx.Commit(); err != nil { return false, err } return true, nil } func (r *Repository) lockHostSalaryCandidate(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string) (hostSalaryCandidate, bool, error) { // 锁定周期账户后再读取总钻石,防止送礼入账与结算批次并发时结算到半更新状态。 row := tx.QueryRowContext(ctx, ` SELECT user_id, cycle_key, region_id, agency_owner_user_id, total_diamonds, gift_diamond_total FROM host_period_diamond_accounts WHERE app_code = ? AND user_id = ? AND cycle_key = ? FOR UPDATE`, appcode.FromContext(ctx), userID, cycleKey) var account hostSalaryCandidate if err := row.Scan(&account.UserID, &account.CycleKey, &account.RegionID, &account.AgencyOwnerUserID, &account.TotalDiamonds, &account.GiftDiamondTotal); err != nil { if errors.Is(err, sql.ErrNoRows) { return hostSalaryCandidate{}, false, nil } return hostSalaryCandidate{}, false, err } return account, true, nil } func (r *Repository) lockHostSalaryProgress(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string, nowMs int64) (hostSalaryProgress, error) { // INSERT IGNORE 让第一次结算自动创建进度行;随后 FOR UPDATE 串行化同主播同周期的所有结算请求。 if _, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO host_salary_settlement_progress ( app_code, user_id, cycle_key, settled_level_no, settled_host_salary_usd_minor, settled_host_coin_reward, settled_agency_salary_usd_minor, last_settled_total_diamonds, residual_usd_minor, month_end_cleared_at_ms, last_policy_id, version, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 1, ?, ?)`, appcode.FromContext(ctx), userID, cycleKey, nowMs, nowMs, ); err != nil { return hostSalaryProgress{}, err } row := tx.QueryRowContext(ctx, ` SELECT settled_level_no, settled_host_salary_usd_minor, settled_host_coin_reward, settled_agency_salary_usd_minor, last_settled_total_diamonds, residual_usd_minor, month_end_cleared_at_ms, last_policy_id, version FROM host_salary_settlement_progress WHERE app_code = ? AND user_id = ? AND cycle_key = ? FOR UPDATE`, appcode.FromContext(ctx), userID, cycleKey) var progress hostSalaryProgress if err := row.Scan(&progress.SettledLevelNo, &progress.SettledHostSalaryUSDMinor, &progress.SettledHostCoinReward, &progress.SettledAgencySalaryUSDMinor, &progress.LastSettledTotalDiamonds, &progress.ResidualUSDMinor, &progress.MonthEndClearedAtMS, &progress.LastPolicyID, &progress.Version); err != nil { return hostSalaryProgress{}, err } return progress, nil } // GetActiveHostSalaryPolicy 返回当前 app、区域和触发模式下正在生效的主播工资政策;这是 H5 展示平台政策的只读入口。 func (r *Repository) GetActiveHostSalaryPolicy(ctx context.Context, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) { if r == nil || r.db == nil { return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } return r.queryActiveHostSalaryPolicy(ctx, r.db, regionID, settlementMode, triggerMode, nowMs) } // GetHostSalaryProgress 读取主播工资周期钻石账户;没有任何收礼时返回空累计,前端据此展示距离首档还差多少钻石。 func (r *Repository) GetHostSalaryProgress(ctx context.Context, userID int64, cycleKey string) (ledger.HostSalaryProgress, error) { if r == nil || r.db == nil { return ledger.HostSalaryProgress{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } progress := ledger.HostSalaryProgress{ HostUserID: userID, CycleKey: cycleKey, } row := r.db.QueryRowContext(ctx, ` SELECT user_id, cycle_key, region_id, agency_owner_user_id, total_diamonds, gift_diamond_total, updated_at_ms FROM host_period_diamond_accounts WHERE app_code = ? AND user_id = ? AND cycle_key = ?`, appcode.FromContext(ctx), userID, cycleKey) if err := row.Scan(&progress.HostUserID, &progress.CycleKey, &progress.RegionID, &progress.AgencyOwnerUserID, &progress.TotalDiamonds, &progress.GiftDiamondTotal, &progress.UpdatedAtMS); err != nil { if errors.Is(err, sql.ErrNoRows) { return progress, nil } return ledger.HostSalaryProgress{}, err } return progress, nil } func (r *Repository) resolveHostSalaryPolicy(ctx context.Context, tx *sql.Tx, regionID int64, settlementType string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) { settlementMode := "" if settlementType != ledger.HostSalarySettlementTypeMonthEnd { settlementMode = settlementType } return r.queryActiveHostSalaryPolicy(ctx, tx, regionID, settlementMode, triggerMode, nowMs) } func (r *Repository) queryActiveHostSalaryPolicy(ctx context.Context, q hostSalaryPolicyQuerier, regionID int64, settlementMode string, triggerMode string, nowMs int64) (ledger.HostSalaryPolicy, bool, error) { modeClause := "" triggerClause := "" args := []any{appcode.FromContext(ctx), regionID, nowMs, nowMs} if strings.TrimSpace(triggerMode) != "" { // H5 展示不传 trigger 时 automatic/manual 都可展示;结算任务传 trigger 时仍精确匹配。 triggerClause = "AND settlement_trigger_mode = ?" args = append(args, triggerMode) } if strings.TrimSpace(settlementMode) != "" { // SQL 里 trigger 条件排在 mode 前面,所以参数也按 trigger -> mode 追加;否则 manual half-month 会被查成 trigger=half_month。 modeClause = "AND settlement_mode = ?" args = append(args, settlementMode) } query := fmt.Sprintf(` SELECT policy_id, name, region_id, status, settlement_mode, settlement_trigger_mode, CAST(gift_coin_to_diamond_ratio AS CHAR), CAST(residual_diamond_to_usd_rate AS CHAR), effective_from_ms, effective_to_ms FROM host_agency_salary_policies WHERE app_code = ? AND region_id = ? AND status = 'active' AND effective_from_ms <= ? AND (effective_to_ms = 0 OR effective_to_ms > ?) %s %s ORDER BY effective_from_ms DESC, policy_id DESC LIMIT 1`, triggerClause, modeClause) var policy ledger.HostSalaryPolicy err := q.QueryRowContext(ctx, query, args...).Scan(&policy.PolicyID, &policy.Name, &policy.RegionID, &policy.Status, &policy.SettlementMode, &policy.SettlementTriggerMode, &policy.GiftCoinToDiamondRatio, &policy.ResidualDiamondToUSDRate, &policy.EffectiveFromMs, &policy.EffectiveToMs) if err != nil { if errors.Is(err, sql.ErrNoRows) { return ledger.HostSalaryPolicy{}, false, nil } return ledger.HostSalaryPolicy{}, false, err } levels, err := r.listHostSalaryPolicyLevels(ctx, q, policy.PolicyID) if err != nil { return ledger.HostSalaryPolicy{}, false, err } policy.Levels = levels return policy, true, nil } func (r *Repository) listHostSalaryPolicyLevels(ctx context.Context, q hostSalaryPolicyQuerier, policyID uint64) ([]ledger.HostSalaryPolicyLevel, error) { // 等级必须按 required_diamonds 升序返回,highestHostSalaryLevel 依赖这个顺序找到最高已达档位。 rows, err := q.QueryContext(ctx, ` SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward, agency_salary_usd_minor, status, sort_order FROM host_agency_salary_policy_levels WHERE app_code = ? AND policy_id = ? AND status = 'active' ORDER BY required_diamonds ASC, level_no ASC`, appcode.FromContext(ctx), policyID) if err != nil { return nil, err } defer rows.Close() var levels []ledger.HostSalaryPolicyLevel for rows.Next() { var level ledger.HostSalaryPolicyLevel if err := rows.Scan(&level.LevelNo, &level.RequiredDiamonds, &level.HostSalaryUSDMinor, &level.HostCoinReward, &level.AgencySalaryUSDMinor, &level.Status, &level.SortOrder); err != nil { return nil, err } levels = append(levels, level) } return levels, rows.Err() } func (r *Repository) applyHostSalaryCredits(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata hostSalarySettlementMetadata, hostUSDDelta int64, hostCoinDelta int64, agencyUSDDelta int64, nowMs int64) ([]walletOutboxEvent, error) { // 主播美元、主播金币、代理美元共用同一 transaction_id,确保账务上可视为一次工资结算。 events := make([]walletOutboxEvent, 0, 3) if hostUSDDelta > 0 { // 主播工资和月底剩余钻石折美元只进入主播工资钱包,不混入代理或团队工资钱包。 balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.HostUserID, ledger.AssetHostSalaryUSD, hostUSDDelta, 0, nowMs) if err != nil { return nil, err } metadata.HostUSDBalanceAfter = balanceAfter events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetHostSalaryUSD, hostUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs, bizTypeHostSalarySettlement)) } if hostCoinDelta > 0 { balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.HostUserID, ledger.AssetCoin, hostCoinDelta, 0, nowMs) if err != nil { return nil, err } metadata.HostCoinBalanceAfter = balanceAfter events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetCoin, hostCoinDelta, 0, balanceAfter, 0, version, metadata, nowMs, bizTypeHostSalarySettlement)) } if agencyUSDDelta > 0 { // 代理工资进入代理工资钱包,同一用户若同时是主播也不会和主播工资钱包合并。 balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.AgencyOwnerUserID, ledger.AssetAgencySalaryUSD, agencyUSDDelta, metadata.HostUserID, nowMs) if err != nil { return nil, err } metadata.AgencyUSDBalanceAfter = balanceAfter events = append(events, balanceChangedEvent(transactionID, commandID, metadata.AgencyOwnerUserID, ledger.AssetAgencySalaryUSD, agencyUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs, bizTypeHostSalarySettlement)) } return events, nil } func (r *Repository) creditSettlementAsset(ctx context.Context, tx *sql.Tx, transactionID string, userID int64, assetType string, amount int64, counterpartyUserID int64, nowMs int64) (int64, int64, error) { // 工资入账只增加可用余额,不走冻结;counterparty 仅用于代理工资反查来源主播。 account, err := r.lockAccount(ctx, tx, userID, assetType, true, nowMs) if err != nil { return 0, 0, err } if err := r.applyAccountDelta(ctx, tx, account, amount, 0, nowMs); err != nil { return 0, 0, err } after := account.AvailableAmount + amount if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: userID, AssetType: assetType, AvailableDelta: amount, FrozenDelta: 0, AvailableAfter: after, FrozenAfter: account.FrozenAmount, CounterpartyUserID: counterpartyUserID, CreatedAtMS: nowMs, }); err != nil { return 0, 0, err } return after, account.Version + 1, nil } func (r *Repository) insertHostSalarySettlementRecord(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata hostSalarySettlementMetadata, nowMs int64) error { // 结算记录保存每次实际发放的增量;BD 结算后续从这里汇总主播工资和剩余钻石折美元。 _, err := tx.ExecContext(ctx, ` INSERT INTO host_salary_settlement_records ( app_code, settlement_id, command_id, transaction_id, settlement_type, trigger_mode, settlement_role, user_id, agency_owner_user_id, cycle_key, policy_id, level_no, total_diamonds, host_salary_usd_minor_delta, host_coin_reward_delta, agency_salary_usd_minor_delta, residual_usd_minor_delta, status, reason, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', '', ?)`, appcode.FromContext(ctx), hostSalarySettlementID(metadata.AppCode, commandID), commandID, transactionID, metadata.SettlementType, metadata.TriggerMode, metadata.SettlementRole, metadata.HostUserID, metadata.AgencyOwnerUserID, metadata.CycleKey, metadata.PolicyID, metadata.LevelNo, metadata.TotalDiamonds, metadata.HostSalaryUSDMinorDelta, metadata.HostCoinRewardDelta, metadata.AgencySalaryUSDMinorDelta, metadata.ResidualUSDMinorDelta, nowMs, ) return err } func (r *Repository) updateHostSalaryProgress(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string, progress hostSalaryProgress, nowMs int64) error { // version 乐观锁补充 FOR UPDATE 之外的防线:异常重入不会悄悄覆盖已推进的进度。 result, err := tx.ExecContext(ctx, ` UPDATE host_salary_settlement_progress SET settled_level_no = ?, settled_host_salary_usd_minor = ?, settled_host_coin_reward = ?, settled_agency_salary_usd_minor = ?, last_settled_total_diamonds = ?, residual_usd_minor = ?, month_end_cleared_at_ms = ?, last_policy_id = ?, version = version + 1, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND cycle_key = ? AND version = ?`, progress.SettledLevelNo, progress.SettledHostSalaryUSDMinor, progress.SettledHostCoinReward, progress.SettledAgencySalaryUSDMinor, progress.LastSettledTotalDiamonds, progress.ResidualUSDMinor, progress.MonthEndClearedAtMS, progress.LastPolicyID, nowMs, appcode.FromContext(ctx), userID, cycleKey, progress.Version, ) if err != nil { return err } rows, err := result.RowsAffected() if err != nil { return err } if rows != 1 { return xerr.New(xerr.LedgerConflict, "host salary settlement progress version conflict") } return nil } func highestHostSalaryLevel(levels []ledger.HostSalaryPolicyLevel, totalDiamonds int64) ledger.HostSalaryPolicyLevel { // 未达到任何等级时返回零值,后续 delta 都为 0;仍可在月底用于只折算剩余钻石。 var selected ledger.HostSalaryPolicyLevel for _, level := range levels { if level.RequiredDiamonds <= totalDiamonds { selected = level } } return selected } func residualDiamondRemainder(totalDiamonds int64, level ledger.HostSalaryPolicyLevel) int64 { // 剩余钻石只计算最高已达等级以上的部分,避免把已获得等级工资的门槛钻石重复折美元。 if level.LevelNo <= 0 || totalDiamonds <= level.RequiredDiamonds { return 0 } return totalDiamonds - level.RequiredDiamonds } func usdMinorFromDiamondRate(diamonds int64, rate string) (int64, error) { // 使用 big.Rat 做精确十进制换算,避免 residual_diamond_to_usd_rate 走 float 后出现分位误差。 if diamonds <= 0 || strings.TrimSpace(rate) == "" { return 0, nil } rat, ok := new(big.Rat).SetString(strings.TrimSpace(rate)) if !ok || rat.Sign() < 0 { return 0, xerr.New(xerr.InvalidArgument, "residual diamond rate is invalid") } value := new(big.Rat).Mul(new(big.Rat).SetInt64(diamonds), rat) value.Mul(value, big.NewRat(100, 1)) result := new(big.Int).Quo(value.Num(), value.Denom()) if !result.IsInt64() || result.Int64() > math.MaxInt64 { return 0, xerr.New(xerr.InvalidArgument, "residual diamond amount overflow") } return result.Int64(), nil } func normalizeHostSalarySettlementType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case ledger.HostSalarySettlementModeDaily: return ledger.HostSalarySettlementModeDaily case ledger.HostSalarySettlementModeHalfMonth: return ledger.HostSalarySettlementModeHalfMonth case ledger.HostSalarySettlementTypeMonthEnd: return ledger.HostSalarySettlementTypeMonthEnd default: return "" } } func normalizeHostSalarySettlementTriggerMode(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "", ledger.HostSalarySettlementTriggerAutomatic: // 空值按自动处理,保证已有 cron 调用不需要同步改所有入参。 return ledger.HostSalarySettlementTriggerAutomatic case ledger.HostSalarySettlementTriggerManual: return ledger.HostSalarySettlementTriggerManual default: return "" } } func normalizeHostSalarySettlementRole(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "", "all": // 空值按完整结算处理,保证已有定时任务继续同时处理主播和代理权益。 return "all" case "host": return "host" case "agency": return "agency" default: return "" } } func uniquePositiveInt64(values []int64) []int64 { seen := map[int64]struct{}{} out := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out } func hostSalarySettlementCommandID(command ledger.HostSalarySettlementBatchCommand, account hostSalaryCandidate, policyID uint64, progress hostSalaryProgress) string { // commandID 包含触发方式和政策,避免 automatic/manual 共存或政策重发时把不同来源的结算误判为同一交易。 return fmt.Sprintf("host_salary:%s:%s:%s:%s:%d:%d:%d:%d", command.SettlementType, command.TriggerMode, command.SettlementRole, account.CycleKey, account.UserID, policyID, progress.SettledLevelNo, account.TotalDiamonds) } func hostSalarySettlementID(appCode string, commandID string) string { // settlement_id 对外稳定且不暴露原始 command_id,方便后台查账和防重复记录。 return fmt.Sprintf("hsset_%s_%s", appcode.Normalize(appCode), stableHash(commandID)) } func hostSalarySettlementRequestHash(metadata hostSalarySettlementMetadata) string { // 请求哈希覆盖金额、等级和周期口径;同 commandID 但 payload 改变会被交易层识别为冲突。 return stableHash(fmt.Sprintf("host_salary|%s|%s|%s|%s|%d|%d|%s|%d|%d|%d|%d|%d|%d|%d", appcode.Normalize(metadata.AppCode), metadata.SettlementType, metadata.TriggerMode, metadata.SettlementRole, metadata.HostUserID, metadata.AgencyOwnerUserID, metadata.CycleKey, metadata.PolicyID, metadata.LevelNo, metadata.TotalDiamonds, metadata.HostSalaryUSDMinorDelta, metadata.HostCoinRewardDelta, metadata.AgencySalaryUSDMinorDelta, metadata.ResidualUSDMinorDelta, )) } func hostSalaryProgressCoversLevel(progress hostSalaryProgress, level ledger.HostSalaryPolicyLevel, account hostSalaryCandidate, settlementType string, residualTarget int64) bool { hostCovered := progress.SettledHostSalaryUSDMinor >= level.HostSalaryUSDMinor && progress.SettledHostCoinReward >= level.HostCoinReward agencyCovered := progress.SettledAgencySalaryUSDMinor >= level.AgencySalaryUSDMinor || account.AgencyOwnerUserID <= 0 residualCovered := settlementType != ledger.HostSalarySettlementTypeMonthEnd || progress.ResidualUSDMinor >= residualTarget return hostCovered && agencyCovered && residualCovered } func positiveDelta(target int64, current int64) int64 { if target <= current { return 0 } return target - current } func maxInt(left int, right int) int { if right > left { return right } return left } func maxInt64(left int64, right int64) int64 { if right > left { return right } return left }