2026-06-28 15:11:56 +08:00

784 lines
34 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"
"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
}