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