2026-06-29 14:26:06 +08:00

1523 lines
57 KiB
Go
Raw Permalink 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 teamsalarysettlement
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"math/big"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/modules/shared"
)
const (
// maxPageSize 限制后台列表单次拉取规模,避免待结算计算在页面误传大 page_size 时拖垮三个库的聚合查询。
maxPageSize = 100
policyTypeBD = "bd"
policyTypeAdmin = "admin"
triggerAutomatic = "automatic"
triggerManual = "manual"
// BD/Admin 工资钱包资产类型必须与 wallet-service ledger 常量保持一致admin-server 是独立 module不能直接复用根 module 常量。
assetBDSalaryUSD = "BD_SALARY_USD"
assetAdminSalaryUSD = "ADMIN_SALARY_USD"
bizTypeTeamSettlement = "team_salary_settlement"
statusSucceeded = "succeeded"
)
// autoSettlementResult 是自动任务内部的聚合结果;它不直接暴露给前端,只用于日志和测试断言。
type autoSettlementResult struct {
Due bool `json:"due"`
CycleKey string `json:"cycle_key"`
Results map[string]settleResultDTO `json:"results"`
}
// Service 聚合 admin/user/wallet 三个库:政策在 admin组织关系在 user结算进度与钱包流水在 wallet。
type Service struct {
adminDB *sql.DB
walletDB *sql.DB
userDB *sql.DB
}
// teamPolicy 是结算时使用的政策快照;工资按等级累计值发差值,因此必须携带完整等级表。
type teamPolicy struct {
PolicyID uint64
PolicyType string
Name string
RegionID int64
TriggerMode string
Levels []teamPolicyLevel
}
// teamPolicyLevel 中金额统一转为美分整数,避免 decimal 字符串在计算差值时出现浮点误差。
type teamPolicyLevel struct {
LevelNo int
ThresholdMinor int64
SalaryMinor int64
}
// teamProgress 是幂等核心:同一用户同一周期只记录“已发到哪个累计工资”,重复结算自然得到 0 差值。
type teamProgress struct {
SettledLevelNo int
SettledSalaryUSDMinor int64
LastSettledIncomeUSDMinor int64
LastPolicyID uint64
MonthClosedAtMS int64
Version int64
}
// teamCandidate 是待结算候选人,已经完成收入聚合、区域归属、政策匹配和差值计算。
type teamCandidate struct {
PolicyType string
UserID int64
CycleKey string
RegionID int64
RegionName string
Policy teamPolicy
Level teamPolicyLevel
IncomeUSDMinor int64
Progress teamProgress
SalaryUSDMinorDelta int64
}
// settlementMetadata 被同时写入钱包交易和 outbox后续查账可以从任一钱包流水反查政策、周期、档位和操作者。
type settlementMetadata struct {
AppCode string `json:"app_code"`
PolicyType string `json:"policy_type"`
TriggerMode string `json:"trigger_mode"`
UserID int64 `json:"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"`
IncomeUSDMinor int64 `json:"income_usd_minor"`
SalaryUSDMinorDelta int64 `json:"salary_usd_minor_delta"`
SalaryAssetType string `json:"salary_asset_type"`
SettledLevelNo int `json:"settled_level_no"`
SettledSalaryUSDMinor int64 `json:"settled_salary_usd_minor"`
ProcessedAtMS int64 `json:"processed_at_ms"`
OperatorAdminID int64 `json:"operator_admin_id"`
}
// userProfile 只用于后台展示,结算主链路永远以 user_id 为准,避免昵称或短 ID 变更影响账务。
type userProfile struct {
UserID int64
DisplayUserID string
Username string
Avatar string
}
// NewService 注入三个数据库连接调用方负责连接生命周期Service 只做业务编排。
func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Service {
return &Service{adminDB: adminDB, walletDB: walletDB, userDB: userDB}
}
// ListPending 计算当前筛选条件下“现在结算会产生正向工资差值”的 BD/Admin 列表。
func (s *Service) ListPending(ctx context.Context, appCode string, req pendingQuery) ([]pendingDTO, int64, error) {
if err := s.ensureReady(ctx); err != nil {
return nil, 0, err
}
req = normalizePendingQuery(req)
var matched bool
var err error
req.UserID, matched, err = s.resolveFilterUserID(ctx, appCode, req.UserID, req.UserFilter)
if err != nil {
return nil, 0, err
}
if !matched {
return []pendingDTO{}, 0, nil
}
// 待结算列表复用结算候选计算,保证运营看到的金额与真正点击结算时的金额口径一致。
candidates, err := s.collectCandidates(ctx, strings.TrimSpace(appCode), req.PolicyType, req.TriggerMode, req.CycleKey, req.RegionID, req.CountryID, req.CountryCode, req.UserID, time.Now().UTC().UnixMilli())
if err != nil {
return nil, 0, err
}
// 待结算列表只展示有正向差额的用户;未达档或已结清用户留在记录/进度表里,避免运营批量操作时出现 0 金额行。
pending := make([]teamCandidate, 0, len(candidates))
for _, candidate := range candidates {
if candidate.SalaryUSDMinorDelta > 0 {
pending = append(pending, candidate)
}
}
sortTeamCandidates(pending)
total := int64(len(pending))
pageItems := paginateCandidates(pending, req.Page, req.PageSize)
// 用户信息只按分页后的结果加载,避免大区域待结算列表一次性扫全量用户资料。
profiles, err := s.userProfiles(ctx, appCode, candidateUserIDs(pageItems))
if err != nil {
return nil, 0, err
}
items := make([]pendingDTO, 0, len(pageItems))
for _, item := range pageItems {
items = append(items, pendingDTOFromCandidate(item, profiles[item.UserID]))
}
return items, total, nil
}
// ListRecords 查询 BD/Admin 结算记录;国家筛选会先转成区域集合,再过滤 wallet 记录里的 region_id。
func (s *Service) ListRecords(ctx context.Context, appCode string, req recordQuery) ([]recordDTO, int64, error) {
if err := s.ensureReady(ctx); err != nil {
return nil, 0, err
}
req = normalizeRecordQuery(req)
var matched bool
var err error
req.UserID, matched, err = s.resolveFilterUserID(ctx, appCode, req.UserID, req.UserFilter)
if err != nil {
return nil, 0, err
}
if !matched {
return []recordDTO{}, 0, nil
}
whereSQL, args, err := s.recordWhere(ctx, strings.TrimSpace(appCode), req)
if err != nil {
return nil, 0, err
}
var total int64
if err := s.walletDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM team_salary_settlement_records `+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.walletDB.QueryContext(ctx, `
SELECT settlement_id, command_id, transaction_id, policy_type, trigger_mode, user_id,
cycle_key, region_id, policy_id, level_no, income_usd_minor, salary_usd_minor_delta,
status, reason, created_at_ms
FROM team_salary_settlement_records
`+whereSQL+`
ORDER BY created_at_ms DESC, settlement_id DESC
LIMIT ? OFFSET ?`,
append(args, req.PageSize, offset(req.Page, req.PageSize))...,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]recordDTO, 0, req.PageSize)
userIDs := make([]int64, 0, req.PageSize)
for rows.Next() {
var item recordDTO
var userID int64
if err := rows.Scan(&item.SettlementID, &item.CommandID, &item.TransactionID, &item.PolicyType, &item.TriggerMode, &userID, &item.CycleKey, &item.RegionID, &item.PolicyID, &item.LevelNo, &item.IncomeUSDMinor, &item.SalaryUSDMinorDelta, &item.Status, &item.Reason, &item.CreatedAtMS); err != nil {
return nil, 0, err
}
item.UserID = strconv.FormatInt(userID, 10)
item.User = userDTO{UserID: item.UserID}
item.IncomeUSD = formatUSDMinor(item.IncomeUSDMinor)
item.SalaryUSDDelta = formatUSDMinor(item.SalaryUSDMinorDelta)
items = append(items, item)
userIDs = append(userIDs, userID)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
profiles, err := s.userProfiles(ctx, appCode, userIDs)
if err != nil {
return nil, 0, err
}
for i := range items {
if id, err := strconv.ParseInt(items[i].UserID, 10, 64); err == nil {
items[i].User = userDTOFromProfile(items[i].UserID, profiles[id])
}
}
return items, total, nil
}
func (s *Service) resolveFilterUserID(ctx context.Context, appCode string, currentUserID int64, filter shared.UserIdentityFilter) (int64, bool, error) {
if currentUserID > 0 || filter.IsEmpty() {
return currentUserID, true, nil
}
userID, matched, err := shared.ResolveUserIdentityFilter(ctx, s.userDB, strings.TrimSpace(appCode), filter, time.Now().UnixMilli())
if err != nil || !matched {
return 0, matched, err
}
return userID, true, nil
}
// Settle 执行手动或自动结算;它只关心“触发方式”和“用户集合”,差值与幂等统一落在 settleCandidate。
func (s *Service) Settle(ctx context.Context, appCode string, operatorAdminID int64, req settleRequest) (settleResultDTO, error) {
if err := s.ensureReady(ctx); err != nil {
return settleResultDTO{}, err
}
req.PolicyType = normalizePolicyType(req.PolicyType)
req.TriggerMode = normalizeTriggerMode(req.TriggerMode)
if req.TriggerMode == "" {
req.TriggerMode = triggerManual
}
if req.CycleKey == "" {
req.CycleKey = previousMonthCycleKey(time.Now().UTC())
}
userIDs := settlementUserIDValues(req.UserIDs)
userFilter := int64(0)
if len(userIDs) == 1 {
userFilter = userIDs[0]
}
// 单用户结算在候选聚合阶段下推 userFilter批量结算先算区域候选再做白名单过滤。
candidates, err := s.collectCandidates(ctx, strings.TrimSpace(appCode), req.PolicyType, req.TriggerMode, req.CycleKey, req.RegionID, req.CountryID, req.CountryCode, userFilter, time.Now().UTC().UnixMilli())
if err != nil {
return settleResultDTO{}, err
}
if len(userIDs) > 0 {
allowed := map[int64]struct{}{}
for _, id := range userIDs {
if id > 0 {
allowed[id] = struct{}{}
}
}
// 前端多选只允许结算勾选用户;这里二次过滤,避免客户端篡改国家/区域后误结不在选择范围内的人。
filtered := candidates[:0]
for _, candidate := range candidates {
if _, ok := allowed[candidate.UserID]; ok {
filtered = append(filtered, candidate)
}
}
candidates = filtered
}
sortTeamCandidates(candidates)
result := settleResultDTO{PolicyType: req.PolicyType, CycleKey: req.CycleKey, RequestedCount: len(userIDs), Records: []recordDTO{}}
profiles, _ := s.userProfiles(ctx, appCode, candidateUserIDs(candidates))
for _, candidate := range candidates {
result.ProcessedCount++
// 单个候选失败不阻塞同批其他用户,后台返回 failure_count 供运营重试定位。
record, settled, err := s.settleCandidate(ctx, strings.TrimSpace(appCode), operatorAdminID, req.TriggerMode, candidate)
if err != nil {
result.FailureCount++
continue
}
if !settled {
result.SkippedCount++
continue
}
if id, err := strconv.ParseInt(record.UserID, 10, 64); err == nil {
record.User = userDTOFromProfile(record.UserID, profiles[id])
}
result.SuccessCount++
result.Records = append(result.Records, record)
}
return result, nil
}
// RunAutomaticDue 是自动任务入口:只在每月 5 日结上一个完整自然月,并固定先结 BD 再结 Admin。
func (s *Service) RunAutomaticDue(ctx context.Context, appCode string, now time.Time) (autoSettlementResult, error) {
utcNow := now.UTC()
result := autoSettlementResult{
Due: utcNow.Day() == 5,
CycleKey: previousMonthCycleKey(utcNow),
Results: map[string]settleResultDTO{},
}
if !result.Due {
return result, nil
}
// BD 必须先跑Admin 的收入基数来自同一周期内 BD 已成功入账的工资记录。
bdResult, err := s.Settle(ctx, appCode, 0, settleRequest{
PolicyType: policyTypeBD,
TriggerMode: triggerAutomatic,
CycleKey: result.CycleKey,
})
if err != nil {
return result, err
}
result.Results[policyTypeBD] = bdResult
adminResult, err := s.Settle(ctx, appCode, 0, settleRequest{
PolicyType: policyTypeAdmin,
TriggerMode: triggerAutomatic,
CycleKey: result.CycleKey,
})
if err != nil {
return result, err
}
result.Results[policyTypeAdmin] = adminResult
return result, nil
}
// collectCandidates 汇总 BD 和 Admin 两类候选policyType 为空时用于记录页或内部全量扫描。
func (s *Service) collectCandidates(ctx context.Context, appCode string, policyType string, triggerMode string, cycleKey string, regionID int64, countryID int64, countryCode string, userID int64, nowMS int64) ([]teamCandidate, error) {
policyType = normalizePolicyType(policyType)
triggerMode = normalizeTriggerMode(triggerMode)
if cycleKey == "" {
cycleKey = previousMonthCycleKey(time.Now().UTC())
}
// 国家筛选在 user 库维护,结算记录只存 region_id因此先把国家解析为一组可用区域。
regionFilter, err := s.regionFilter(ctx, appCode, regionID, countryID, countryCode)
if err != nil {
return nil, err
}
var candidates []teamCandidate
if policyType == "" || policyType == policyTypeBD {
items, err := s.collectBDCandidates(ctx, appCode, triggerMode, cycleKey, regionFilter, userID, nowMS)
if err != nil {
return nil, err
}
candidates = append(candidates, items...)
}
if policyType == "" || policyType == policyTypeAdmin {
items, err := s.collectAdminCandidates(ctx, appCode, triggerMode, cycleKey, regionFilter, userID, nowMS)
if err != nil {
return nil, err
}
candidates = append(candidates, items...)
}
return candidates, nil
}
// collectBDCandidates 以 Agency 为桥接,把 Host/Agency 已成功结算的主播收入汇总到其上级 BD。
func (s *Service) collectBDCandidates(ctx context.Context, appCode string, triggerMode string, cycleKey string, regions map[int64]struct{}, userID int64, nowMS int64) ([]teamCandidate, error) {
agencyIncome, err := s.hostIncomeByAgencyOwner(ctx, appCode, cycleKey)
if err != nil || len(agencyIncome) == 0 {
return nil, err
}
// host_salary_settlement_records 只知道 agency ownerBD 归属必须回 user 库查 active agency 当前关系。
agencies, err := s.activeAgenciesByOwner(ctx, appCode, keysInt64(agencyIncome))
if err != nil {
return nil, err
}
incomeByBDRegion := map[string]int64{}
for ownerID, income := range agencyIncome {
agency, ok := agencies[ownerID]
if !ok || agency.ParentBDUserID <= 0 || income <= 0 {
continue
}
// 区域来自 Agency 拥有者 users 当前区域,不从主播记录或 Agency 历史快照推导。
if len(regions) > 0 {
if _, ok := regions[agency.RegionID]; !ok {
continue
}
}
if userID > 0 && agency.ParentBDUserID != userID {
continue
}
incomeByBDRegion[teamCandidateKey(agency.ParentBDUserID, agency.RegionID)] += income
}
return s.candidatesFromIncome(ctx, appCode, policyTypeBD, triggerMode, cycleKey, incomeByBDRegion, nowMS)
}
// collectAdminCandidates 以已发 BD 工资作为 Admin 收入基数,再通过 BD profile 找到上级 Admin。
func (s *Service) collectAdminCandidates(ctx context.Context, appCode string, triggerMode string, cycleKey string, regions map[int64]struct{}, userID int64, nowMS int64) ([]teamCandidate, error) {
bdIncome, err := s.bdSalaryIncomeByUser(ctx, appCode, cycleKey)
if err != nil || len(bdIncome) == 0 {
return nil, err
}
// Admin 政策定义为“下属 BD 的 BD Salary 合计”,因此只有已成功入账的 BD 记录才进入这里。
bdProfiles, err := s.bdProfilesByUser(ctx, appCode, keysInt64(bdIncome), "bd")
if err != nil {
return nil, err
}
leaderIDs := make([]int64, 0, len(bdProfiles))
for _, profile := range bdProfiles {
if profile.ParentLeaderUserID > 0 {
leaderIDs = append(leaderIDs, profile.ParentLeaderUserID)
}
}
leaders, err := s.bdProfilesByUser(ctx, appCode, uniquePositiveUserIDs(leaderIDs), "bd_leader")
if err != nil {
return nil, err
}
incomeByAdminRegion := map[string]int64{}
for bdUserID, income := range bdIncome {
profile, ok := bdProfiles[bdUserID]
if !ok || profile.ParentLeaderUserID <= 0 || income <= 0 {
continue
}
leader, ok := leaders[profile.ParentLeaderUserID]
if !ok {
continue
}
// Admin 的适用区域以 BD Leader 用户当前区域为准,不再读取 bd_leader_profiles 的历史 region_id。
if len(regions) > 0 {
if _, ok := regions[leader.RegionID]; !ok {
continue
}
}
if userID > 0 && leader.UserID != userID {
continue
}
incomeByAdminRegion[teamCandidateKey(leader.UserID, leader.RegionID)] += income
}
return s.candidatesFromIncome(ctx, appCode, policyTypeAdmin, triggerMode, cycleKey, incomeByAdminRegion, nowMS)
}
// candidatesFromIncome 将“用户+区域+收入基数”转换为可结算候选,集中处理政策匹配、等级选择和差值计算。
func (s *Service) candidatesFromIncome(ctx context.Context, appCode string, policyType string, triggerMode string, cycleKey string, incomeByUserRegion map[string]int64, nowMS int64) ([]teamCandidate, error) {
if len(incomeByUserRegion) == 0 {
return nil, nil
}
// 区域名称只是展示字段;读取失败不影响结算主流程,主键 region_id 仍然完整。
regionNames, _ := s.regionNames(ctx, appCode)
out := make([]teamCandidate, 0, len(incomeByUserRegion))
for key, income := range incomeByUserRegion {
userID, regionID := parseTeamCandidateKey(key)
// 自动/手动政策同一区域可共存triggerMode 必须参与政策解析,否则会把人工复核政策提前发放。
policy, ok, err := s.resolvePolicy(ctx, appCode, policyType, regionID, triggerMode, nowMS)
if err != nil || !ok {
if err != nil {
return nil, err
}
continue
}
progress, err := s.loadProgress(ctx, appCode, policyType, userID, cycleKey)
if err != nil {
return nil, err
}
level := highestTeamLevel(policy.Levels, income)
// 等级薪资是“累计应得”,这里先算一次待结差值供页面展示,真正发放时还会在事务锁内重算。
delta := positiveDelta(level.SalaryMinor, progress.SettledSalaryUSDMinor)
out = append(out, teamCandidate{
PolicyType: policyType,
UserID: userID,
CycleKey: cycleKey,
RegionID: regionID,
RegionName: regionNames[regionID],
Policy: policy,
Level: level,
IncomeUSDMinor: income,
Progress: progress,
SalaryUSDMinorDelta: delta,
})
}
return out, nil
}
// settleCandidate 在一个 wallet 事务内完成进度锁定、差值重算、钱包入账、流水和结算记录写入。
func (s *Service) settleCandidate(ctx context.Context, appCode string, operatorAdminID int64, triggerMode string, candidate teamCandidate) (recordDTO, bool, error) {
nowMS := time.Now().UTC().UnixMilli()
tx, err := s.walletDB.BeginTx(ctx, nil)
if err != nil {
return recordDTO{}, false, err
}
defer func() { _ = tx.Rollback() }()
progress, err := s.lockProgress(ctx, tx, appCode, candidate, nowMS)
if err != nil {
return recordDTO{}, false, err
}
// 结算表里的等级工资是累计值;每次只发“当前应得累计 - 已发累计”,因此重复提交同一批用户不会二次入账。
salaryDelta := positiveDelta(candidate.Level.SalaryMinor, progress.SettledSalaryUSDMinor)
if salaryDelta <= 0 {
// 未升档但收入基数增加时只刷新 last_settled_income减少后续待结算列表反复出现 0 金额候选。
if candidate.IncomeUSDMinor > progress.LastSettledIncomeUSDMinor {
progress.LastSettledIncomeUSDMinor = candidate.IncomeUSDMinor
if err := s.updateProgress(ctx, tx, appCode, candidate, progress, nowMS); err != nil {
return recordDTO{}, false, err
}
}
if err := tx.Commit(); err != nil {
return recordDTO{}, false, err
}
return recordDTO{}, false, nil
}
// nextProgress 使用 max确保历史已发累计不会因政策调整或候选回退被写小。
nextProgress := teamProgress{
SettledLevelNo: maxInt(progress.SettledLevelNo, candidate.Level.LevelNo),
SettledSalaryUSDMinor: maxInt64(progress.SettledSalaryUSDMinor, candidate.Level.SalaryMinor),
LastSettledIncomeUSDMinor: maxInt64(progress.LastSettledIncomeUSDMinor, candidate.IncomeUSDMinor),
LastPolicyID: candidate.Policy.PolicyID,
MonthClosedAtMS: nowMS,
Version: progress.Version,
}
salaryAssetType := teamSalaryAssetType(candidate.PolicyType)
if salaryAssetType == "" {
return recordDTO{}, false, fmt.Errorf("team salary asset type is invalid: %s", candidate.PolicyType)
}
metadata := settlementMetadata{
AppCode: appCode,
PolicyType: candidate.PolicyType,
TriggerMode: triggerMode,
UserID: candidate.UserID,
CycleKey: candidate.CycleKey,
RegionID: candidate.RegionID,
PolicyID: candidate.Policy.PolicyID,
PolicyName: candidate.Policy.Name,
LevelNo: candidate.Level.LevelNo,
IncomeUSDMinor: candidate.IncomeUSDMinor,
SalaryUSDMinorDelta: salaryDelta,
SalaryAssetType: salaryAssetType,
SettledLevelNo: nextProgress.SettledLevelNo,
SettledSalaryUSDMinor: nextProgress.SettledSalaryUSDMinor,
ProcessedAtMS: nowMS,
OperatorAdminID: operatorAdminID,
}
// commandID 带上目标等级和收入基数;同一进度重复提交会命中 progress 差值为 0异常重试也会被唯一键兜住。
commandID := teamSalaryCommandID(candidate, triggerMode, nextProgress)
transactionID := teamSalaryTransactionID(appCode, commandID)
if err := s.insertWalletTransaction(ctx, tx, appCode, transactionID, commandID, metadata, nowMS); err != nil {
return recordDTO{}, false, err
}
balanceAfter, version, err := s.creditWallet(ctx, tx, appCode, transactionID, candidate.UserID, metadata.SalaryAssetType, salaryDelta, nowMS)
if err != nil {
return recordDTO{}, false, err
}
if err := s.insertWalletOutbox(ctx, tx, appCode, transactionID, commandID, candidate.UserID, metadata.SalaryAssetType, salaryDelta, balanceAfter, version, metadata, nowMS); err != nil {
return recordDTO{}, false, err
}
if err := s.insertRecord(ctx, tx, appCode, transactionID, commandID, metadata, nowMS); err != nil {
return recordDTO{}, false, err
}
if err := s.updateProgress(ctx, tx, appCode, candidate, nextProgress, nowMS); err != nil {
return recordDTO{}, false, err
}
if err := tx.Commit(); err != nil {
return recordDTO{}, false, err
}
return recordDTO{
SettlementID: teamSalarySettlementID(appCode, commandID),
CommandID: commandID,
TransactionID: transactionID,
PolicyType: candidate.PolicyType,
TriggerMode: triggerMode,
UserID: strconv.FormatInt(candidate.UserID, 10),
User: userDTO{UserID: strconv.FormatInt(candidate.UserID, 10)},
CycleKey: candidate.CycleKey,
RegionID: candidate.RegionID,
PolicyID: candidate.Policy.PolicyID,
LevelNo: int32(candidate.Level.LevelNo),
IncomeUSDMinor: candidate.IncomeUSDMinor,
IncomeUSD: formatUSDMinor(candidate.IncomeUSDMinor),
SalaryUSDMinorDelta: salaryDelta,
SalaryUSDDelta: formatUSDMinor(salaryDelta),
Status: statusSucceeded,
CreatedAtMS: nowMS,
}, true, nil
}
// hostIncomeByAgencyOwner 读取 Host/Agency 已成功结算记录BD 只基于主播工资和月底剩余折美元,不计 Agency 自己工资。
func (s *Service) hostIncomeByAgencyOwner(ctx context.Context, appCode string, cycleKey string) (map[int64]int64, error) {
rows, err := s.walletDB.QueryContext(ctx, `
SELECT agency_owner_user_id, SUM(host_salary_usd_minor_delta + residual_usd_minor_delta)
FROM host_salary_settlement_records
WHERE app_code = ? AND cycle_key = ? AND status = 'succeeded' AND agency_owner_user_id > 0
GROUP BY agency_owner_user_id`, appCode, cycleKey)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]int64{}
for rows.Next() {
var ownerID int64
var income sql.NullInt64
if err := rows.Scan(&ownerID, &income); err != nil {
return nil, err
}
if ownerID > 0 && income.Int64 > 0 {
out[ownerID] = income.Int64
}
}
return out, rows.Err()
}
// bdSalaryIncomeByUser 读取同周期已成功发放的 BD 工资Admin 只按真实入账 BD Salary 计算。
func (s *Service) bdSalaryIncomeByUser(ctx context.Context, appCode string, cycleKey string) (map[int64]int64, error) {
rows, err := s.walletDB.QueryContext(ctx, `
SELECT user_id, SUM(salary_usd_minor_delta)
FROM team_salary_settlement_records
WHERE app_code = ? AND policy_type = 'bd' AND cycle_key = ? AND status = 'succeeded'
GROUP BY user_id`, appCode, cycleKey)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]int64{}
for rows.Next() {
var userID int64
var income sql.NullInt64
if err := rows.Scan(&userID, &income); err != nil {
return nil, err
}
if userID > 0 && income.Int64 > 0 {
out[userID] = income.Int64
}
}
return out, rows.Err()
}
// agencyInfo 是 BD 汇总所需的最小组织关系视图。
type agencyInfo struct {
OwnerUserID int64
ParentBDUserID int64
RegionID int64
}
// activeAgenciesByOwner 只使用 active agency并从 owner users 行读取当前区域,避免历史快照继续影响 BD 工资。
func (s *Service) activeAgenciesByOwner(ctx context.Context, appCode string, ownerIDs []int64) (map[int64]agencyInfo, error) {
ownerIDs = uniquePositiveUserIDs(ownerIDs)
if len(ownerIDs) == 0 {
return map[int64]agencyInfo{}, nil
}
placeholders := placeholders(len(ownerIDs))
args := make([]any, 0, len(ownerIDs)+1)
args = append(args, appCode)
for _, id := range ownerIDs {
args = append(args, id)
}
rows, err := s.userDB.QueryContext(ctx, `
SELECT a.owner_user_id, a.parent_bd_user_id, COALESCE(u.region_id, 0)
FROM agencies a
INNER JOIN users u
ON u.app_code = a.app_code AND u.user_id = a.owner_user_id
WHERE a.app_code = ? AND a.status = 'active' AND a.owner_user_id IN (`+placeholders+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]agencyInfo{}
for rows.Next() {
var item agencyInfo
if err := rows.Scan(&item.OwnerUserID, &item.ParentBDUserID, &item.RegionID); err != nil {
return nil, err
}
out[item.OwnerUserID] = item
}
return out, rows.Err()
}
// bdProfile 是 Admin 汇总所需的最小 BD/BD Leader 组织关系视图。
type bdProfile struct {
UserID int64
Role string
RegionID int64
ParentLeaderUserID int64
}
// bdProfilesByUser 按角色取 active profileRegionID 始终来自用户当前区域,调用方必须显式传 role。
func (s *Service) bdProfilesByUser(ctx context.Context, appCode string, userIDs []int64, role string) (map[int64]bdProfile, error) {
userIDs = uniquePositiveUserIDs(userIDs)
if len(userIDs) == 0 {
return map[int64]bdProfile{}, nil
}
placeholders := placeholders(len(userIDs))
args := make([]any, 0, len(userIDs)+1)
args = append(args, appCode)
for _, id := range userIDs {
args = append(args, id)
}
query := `
SELECT bp.user_id, bp.role, COALESCE(u.region_id, 0), COALESCE(bp.parent_leader_user_id, 0)
FROM bd_profiles bp
INNER JOIN users u
ON u.app_code = bp.app_code AND u.user_id = bp.user_id
WHERE bp.app_code = ? AND bp.role = 'bd' AND bp.status = 'active' AND bp.user_id IN (` + placeholders + `)`
if role == "bd_leader" {
query = `
SELECT bl.user_id, 'bd_leader', COALESCE(u.region_id, 0), 0
FROM bd_leader_profiles bl
INNER JOIN users u
ON u.app_code = bl.app_code AND u.user_id = bl.user_id
WHERE bl.app_code = ? AND bl.status = 'active' AND bl.user_id IN (` + placeholders + `)`
}
rows, err := s.userDB.QueryContext(ctx, `
`+query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]bdProfile{}
for rows.Next() {
var item bdProfile
if err := rows.Scan(&item.UserID, &item.Role, &item.RegionID, &item.ParentLeaderUserID); err != nil {
return nil, err
}
out[item.UserID] = item
}
return out, rows.Err()
}
// resolvePolicy 按 app、类型、区域、触发方式和生效时间解析当前唯一有效政策。
func (s *Service) resolvePolicy(ctx context.Context, appCode string, policyType string, regionID int64, triggerMode string, nowMS int64) (teamPolicy, bool, error) {
conditions := []string{"app_code = ?", "policy_type = ?", "region_id = ?", "status = 'active'", "effective_from_ms <= ?", "(effective_to_ms = 0 OR effective_to_ms > ?)"}
args := []any{appCode, policyType, regionID, nowMS, nowMS}
if triggerMode != "" {
conditions = append(conditions, "settlement_trigger_mode = ?")
args = append(args, triggerMode)
}
row := s.adminDB.QueryRowContext(ctx, `
SELECT id, policy_type, name, region_id, settlement_trigger_mode
FROM admin_team_salary_policies
WHERE `+strings.Join(conditions, " AND ")+`
ORDER BY effective_from_ms DESC, id DESC
LIMIT 1`, args...)
var policy teamPolicy
if err := row.Scan(&policy.PolicyID, &policy.PolicyType, &policy.Name, &policy.RegionID, &policy.TriggerMode); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 没有政策不是系统错误;该区域用户本轮没有可结算候选。
return teamPolicy{}, false, nil
}
return teamPolicy{}, false, err
}
levels, err := s.policyLevels(ctx, policy.PolicyID)
if err != nil {
return teamPolicy{}, false, err
}
policy.Levels = levels
return policy, true, nil
}
// policyLevels 读取并转换政策等级排序按门槛递增highestTeamLevel 可以线性选择最高达标档。
func (s *Service) policyLevels(ctx context.Context, policyID uint64) ([]teamPolicyLevel, error) {
rows, err := s.adminDB.QueryContext(ctx, `
SELECT level_no, CAST(threshold_usd AS CHAR), CAST(salary_usd AS CHAR)
FROM admin_team_salary_policy_levels
WHERE policy_id = ? AND status = 'active'
ORDER BY threshold_usd ASC, level_no ASC`, policyID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []teamPolicyLevel
for rows.Next() {
var level teamPolicyLevel
var threshold string
var salary string
if err := rows.Scan(&level.LevelNo, &threshold, &salary); err != nil {
return nil, err
}
thresholdMinor, err := usdStringToMinor(threshold)
if err != nil {
return nil, err
}
salaryMinor, err := usdStringToMinor(salary)
if err != nil {
return nil, err
}
level.ThresholdMinor = thresholdMinor
level.SalaryMinor = salaryMinor
out = append(out, level)
}
return out, rows.Err()
}
// loadProgress 为列表展示读取当前进度;没有进度代表本周期尚未发过工资。
func (s *Service) loadProgress(ctx context.Context, appCode string, policyType string, userID int64, cycleKey string) (teamProgress, error) {
var progress teamProgress
err := s.walletDB.QueryRowContext(ctx, `
SELECT settled_level_no, settled_salary_usd_minor, last_settled_income_usd_minor,
last_policy_id, month_closed_at_ms, version
FROM team_salary_settlement_progress
WHERE app_code = ? AND policy_type = ? AND user_id = ? AND cycle_key = ?`,
appCode, policyType, userID, cycleKey,
).Scan(&progress.SettledLevelNo, &progress.SettledSalaryUSDMinor, &progress.LastSettledIncomeUSDMinor, &progress.LastPolicyID, &progress.MonthClosedAtMS, &progress.Version)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return teamProgress{Version: 1}, nil
}
return teamProgress{}, err
}
return progress, nil
}
// lockProgress 先 INSERT IGNORE 再 SELECT FOR UPDATE保证并发结算同一用户周期时只有一个事务能重算差值。
func (s *Service) lockProgress(ctx context.Context, tx *sql.Tx, appCode string, candidate teamCandidate, nowMS int64) (teamProgress, error) {
if _, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO team_salary_settlement_progress (
app_code, policy_type, user_id, cycle_key, region_id, settled_level_no,
settled_salary_usd_minor, last_settled_income_usd_minor, last_policy_id,
month_closed_at_ms, version, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 1, ?, ?)`,
appCode, candidate.PolicyType, candidate.UserID, candidate.CycleKey, candidate.RegionID, nowMS, nowMS,
); err != nil {
return teamProgress{}, err
}
var progress teamProgress
if err := tx.QueryRowContext(ctx, `
SELECT settled_level_no, settled_salary_usd_minor, last_settled_income_usd_minor,
last_policy_id, month_closed_at_ms, version
FROM team_salary_settlement_progress
WHERE app_code = ? AND policy_type = ? AND user_id = ? AND cycle_key = ?
FOR UPDATE`, appCode, candidate.PolicyType, candidate.UserID, candidate.CycleKey,
).Scan(&progress.SettledLevelNo, &progress.SettledSalaryUSDMinor, &progress.LastSettledIncomeUSDMinor, &progress.LastPolicyID, &progress.MonthClosedAtMS, &progress.Version); err != nil {
return teamProgress{}, err
}
return progress, nil
}
// updateProgress 使用 version 乐观校验,防止事务外或异常并发修改覆盖已提交进度。
func (s *Service) updateProgress(ctx context.Context, tx *sql.Tx, appCode string, candidate teamCandidate, progress teamProgress, nowMS int64) error {
result, err := tx.ExecContext(ctx, `
UPDATE team_salary_settlement_progress
SET region_id = ?,
settled_level_no = ?,
settled_salary_usd_minor = ?,
last_settled_income_usd_minor = ?,
last_policy_id = ?,
month_closed_at_ms = ?,
version = version + 1,
updated_at_ms = ?
WHERE app_code = ? AND policy_type = ? AND user_id = ? AND cycle_key = ? AND version = ?`,
candidate.RegionID,
progress.SettledLevelNo,
progress.SettledSalaryUSDMinor,
progress.LastSettledIncomeUSDMinor,
progress.LastPolicyID,
progress.MonthClosedAtMS,
nowMS,
appCode,
candidate.PolicyType,
candidate.UserID,
candidate.CycleKey,
progress.Version,
)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows != 1 {
return fmt.Errorf("team salary settlement progress version conflict")
}
return nil
}
// insertWalletTransaction 写钱包交易主表command_id 是业务幂等键。
func (s *Service) insertWalletTransaction(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, commandID string, metadata settlementMetadata, nowMS int64) error {
payload, err := json.Marshal(metadata)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO wallet_transactions (
app_code, transaction_id, command_id, biz_type, status, request_hash,
external_ref, metadata_json, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?)`,
appCode, transactionID, commandID, bizTypeTeamSettlement, stableHash(string(payload)), metadata.CycleKey, string(payload), nowMS, nowMS,
)
return err
}
// creditWallet 锁定对应角色工资钱包并追加 wallet_entries使余额、流水和版本在同一事务内一致。
func (s *Service) creditWallet(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, userID int64, assetType string, amount int64, nowMS int64) (int64, int64, error) {
if _, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO wallet_accounts (
app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 0, 0, 1, ?, ?)`, appCode, userID, assetType, nowMS, nowMS); err != nil {
return 0, 0, err
}
var available int64
var frozen int64
var version int64
if err := tx.QueryRowContext(ctx, `
SELECT available_amount, frozen_amount, version
FROM wallet_accounts
WHERE app_code = ? AND user_id = ? AND asset_type = ?
FOR UPDATE`, appCode, userID, assetType,
).Scan(&available, &frozen, &version); err != nil {
return 0, 0, err
}
after := available + amount
if after < 0 {
return 0, 0, fmt.Errorf("team salary credit overflow")
}
// 工资只会正向入账version 条件仍保留,和其它钱包写入路径保持并发语义一致。
if _, err := tx.ExecContext(ctx, `
UPDATE wallet_accounts
SET available_amount = ?, version = version + 1, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND asset_type = ? AND version = ?`,
after, nowMS, appCode, userID, assetType, version,
); err != nil {
return 0, 0, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO wallet_entries (
app_code, transaction_id, user_id, asset_type, available_delta, frozen_delta,
available_after, frozen_after, counterparty_user_id, room_id, created_at_ms
) VALUES (?, ?, ?, ?, ?, 0, ?, ?, 0, '', ?)`,
appCode, transactionID, userID, assetType, amount, after, frozen, nowMS,
); err != nil {
return 0, 0, err
}
return after, version + 1, nil
}
// insertWalletOutbox 投递余额变更事件,供后续 MQ/通知链路异步消费。
func (s *Service) insertWalletOutbox(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, commandID string, userID int64, assetType string, amount int64, balanceAfter int64, version int64, metadata settlementMetadata, nowMS int64) error {
payload, err := json.Marshal(map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"user_id": userID,
"asset_type": assetType,
"available_delta": amount,
"available_after": balanceAfter,
"version": version,
"metadata": metadata,
"created_at_ms": nowMS,
})
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO wallet_outbox (
app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, payload, status, created_at_ms, updated_at_ms
) VALUES (?, ?, 'WalletBalanceChanged', ?, ?, ?, ?, ?, 0, ?, 'pending', ?, ?)`,
appCode, transactionID+":"+assetType, transactionID, commandID, userID, assetType, amount, string(payload), nowMS, nowMS,
)
return err
}
// insertRecord 写业务结算记录,是后台查询 settlement_id 的权威来源。
func (s *Service) insertRecord(ctx context.Context, tx *sql.Tx, appCode string, transactionID string, commandID string, metadata settlementMetadata, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO team_salary_settlement_records (
app_code, settlement_id, command_id, transaction_id, policy_type, trigger_mode,
user_id, cycle_key, region_id, policy_id, level_no, income_usd_minor,
salary_usd_minor_delta, status, reason, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', '', ?)`,
appCode,
teamSalarySettlementID(appCode, commandID),
commandID,
transactionID,
metadata.PolicyType,
metadata.TriggerMode,
metadata.UserID,
metadata.CycleKey,
metadata.RegionID,
metadata.PolicyID,
metadata.LevelNo,
metadata.IncomeUSDMinor,
metadata.SalaryUSDMinorDelta,
nowMS,
)
return err
}
// recordWhere 统一构造记录查询条件,避免列表 API 和后续导出 API 出现筛选口径差异。
func (s *Service) recordWhere(ctx context.Context, appCode string, req recordQuery) (string, []any, error) {
conditions := []string{"app_code = ?"}
args := []any{appCode}
if req.PolicyType != "" {
conditions = append(conditions, "policy_type = ?")
args = append(args, req.PolicyType)
}
if req.TriggerMode != "" {
conditions = append(conditions, "trigger_mode = ?")
args = append(args, req.TriggerMode)
}
if req.CycleKey != "" {
conditions = append(conditions, "cycle_key = ?")
args = append(args, req.CycleKey)
}
if req.Status != "" {
conditions = append(conditions, "status = ?")
args = append(args, req.Status)
}
if req.UserID > 0 {
conditions = append(conditions, "user_id = ?")
args = append(args, req.UserID)
}
if req.PolicyID > 0 {
conditions = append(conditions, "policy_id = ?")
args = append(args, req.PolicyID)
}
if req.RegionID > 0 {
conditions = append(conditions, "region_id = ?")
args = append(args, req.RegionID)
} else if req.CountryID > 0 || req.CountryCode != "" {
regions, err := s.regionFilter(ctx, appCode, 0, req.CountryID, req.CountryCode)
if err != nil {
return "", nil, err
}
if len(regions) == 0 {
conditions = append(conditions, "region_id = -1")
} else {
ids := keysInt64FromSet(regions)
conditions = append(conditions, "region_id IN ("+placeholders(len(ids))+")")
for _, id := range ids {
args = append(args, id)
}
}
}
if req.StartAtMS > 0 {
conditions = append(conditions, "created_at_ms >= ?")
args = append(args, req.StartAtMS)
}
if req.EndAtMS > 0 {
conditions = append(conditions, "created_at_ms < ?")
args = append(args, req.EndAtMS)
}
return "WHERE " + strings.Join(conditions, " AND "), args, nil
}
// regionFilter 把区域、国家 ID、国家代码归一为 region_id 集合region_id 直传时优先级最高。
func (s *Service) regionFilter(ctx context.Context, appCode string, regionID int64, countryID int64, countryCode string) (map[int64]struct{}, error) {
if regionID > 0 {
return map[int64]struct{}{regionID: {}}, nil
}
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryID > 0 && countryCode == "" {
if err := s.userDB.QueryRowContext(ctx, `SELECT country_code FROM countries WHERE app_code = ? AND country_id = ?`, appCode, countryID).Scan(&countryCode); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return map[int64]struct{}{}, nil
}
return nil, err
}
}
if countryCode == "" {
return nil, nil
}
rows, err := s.userDB.QueryContext(ctx, `
SELECT region_id
FROM region_countries
WHERE app_code = ? AND country_code = ? AND status = 'active'`, appCode, countryCode)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]struct{}{}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out[id] = struct{}{}
}
return out, rows.Err()
}
// regionNames 批量读取区域名称,只服务后台展示,不参与任何金额计算。
func (s *Service) regionNames(ctx context.Context, appCode string) (map[int64]string, error) {
rows, err := s.userDB.QueryContext(ctx, `SELECT region_id, name FROM regions WHERE app_code = ?`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]string{}
for rows.Next() {
var id int64
var name string
if err := rows.Scan(&id, &name); err != nil {
return nil, err
}
out[id] = name
}
return out, rows.Err()
}
// userProfiles 批量读取用户展示信息;缺失用户资料时上层会保留 user_id。
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
userIDs = uniquePositiveUserIDs(userIDs)
if len(userIDs) == 0 {
return map[int64]userProfile{}, nil
}
args := make([]any, 0, len(userIDs)+1)
args = append(args, appCode)
for _, id := range userIDs {
args = append(args, id)
}
rows, err := s.userDB.QueryContext(ctx, `
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]userProfile{}
for rows.Next() {
var item userProfile
if err := rows.Scan(&item.UserID, &item.DisplayUserID, &item.Username, &item.Avatar); err != nil {
return nil, err
}
out[item.UserID] = item
}
return out, rows.Err()
}
// ensureReady 做运行时自检并补齐 wallet 运行表;本地测试可以直接跑,线上仍以标准迁移为准。
func (s *Service) ensureReady(ctx context.Context) error {
if s == nil || s.adminDB == nil || s.walletDB == nil || s.userDB == nil {
return fmt.Errorf("team salary settlement dependencies are not configured")
}
// 运行表属于 wallet 账本库admin-server 在本地和测试环境按需补表,线上仍应由标准 DDL 发布流程先执行 initdb/migration。
for _, ddl := range teamSalaryRuntimeDDL() {
if _, err := s.walletDB.ExecContext(ctx, ddl); err != nil {
return err
}
}
return nil
}
// teamSalaryRuntimeDDL 返回 wallet 侧团队工资运行表 DDL保持与 wallet-service initdb 的结构一致。
func teamSalaryRuntimeDDL() []string {
return []string{
`CREATE TABLE IF NOT EXISTS team_salary_settlement_progress (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
policy_type VARCHAR(24) NOT NULL,
user_id BIGINT NOT NULL,
cycle_key VARCHAR(16) NOT NULL,
region_id BIGINT NOT NULL,
settled_level_no INT NOT NULL DEFAULT 0,
settled_salary_usd_minor BIGINT NOT NULL DEFAULT 0,
last_settled_income_usd_minor BIGINT NOT NULL DEFAULT 0,
last_policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
month_closed_at_ms BIGINT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 1,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, policy_type, user_id, cycle_key),
KEY idx_team_salary_progress_cycle (app_code, policy_type, cycle_key, region_id, month_closed_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS team_salary_settlement_records (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
settlement_id VARCHAR(128) NOT NULL,
command_id VARCHAR(128) NOT NULL,
transaction_id VARCHAR(96) NOT NULL,
policy_type VARCHAR(24) NOT NULL,
trigger_mode VARCHAR(24) NOT NULL DEFAULT 'manual',
user_id BIGINT NOT NULL,
cycle_key VARCHAR(16) NOT NULL,
region_id BIGINT NOT NULL,
policy_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
level_no INT NOT NULL DEFAULT 0,
income_usd_minor BIGINT NOT NULL DEFAULT 0,
salary_usd_minor_delta BIGINT NOT NULL DEFAULT 0,
status VARCHAR(24) NOT NULL DEFAULT 'succeeded',
reason VARCHAR(255) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, settlement_id),
UNIQUE KEY uk_team_salary_settlement_command (app_code, command_id),
KEY idx_team_salary_records_user_cycle (app_code, policy_type, user_id, cycle_key, created_at_ms),
KEY idx_team_salary_records_cycle (app_code, policy_type, cycle_key, region_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
}
}
// pendingDTOFromCandidate 将内部候选转成前端 DTO同时保留已结进度方便运营判断为什么只发差值。
func pendingDTOFromCandidate(item teamCandidate, profile userProfile) pendingDTO {
userID := strconv.FormatInt(item.UserID, 10)
return pendingDTO{
PolicyType: item.PolicyType,
UserID: userID,
User: userDTOFromProfile(userID, profile),
CycleKey: item.CycleKey,
RegionID: item.RegionID,
RegionName: item.RegionName,
PolicyID: item.Policy.PolicyID,
PolicyName: item.Policy.Name,
LevelNo: int32(item.Level.LevelNo),
IncomeUSDMinor: item.IncomeUSDMinor,
IncomeUSD: formatUSDMinor(item.IncomeUSDMinor),
SalaryUSDMinorTarget: item.Level.SalaryMinor,
SalaryUSDTarget: formatUSDMinor(item.Level.SalaryMinor),
SalaryUSDMinorDelta: item.SalaryUSDMinorDelta,
SalaryUSDDelta: formatUSDMinor(item.SalaryUSDMinorDelta),
SettledLevelNo: int32(item.Progress.SettledLevelNo),
SettledSalaryUSDMinor: item.Progress.SettledSalaryUSDMinor,
SettledSalaryUSD: formatUSDMinor(item.Progress.SettledSalaryUSDMinor),
LastSettledIncomeUSDMinor: item.Progress.LastSettledIncomeUSDMinor,
LastSettledIncomeUSD: formatUSDMinor(item.Progress.LastSettledIncomeUSDMinor),
MonthClosedAtMS: item.Progress.MonthClosedAtMS,
}
}
// normalizePendingQuery 收敛列表参数默认值;默认看上一个完整月,符合 BD/Admin 次月 5 日结算口径。
func normalizePendingQuery(req pendingQuery) pendingQuery {
req.PolicyType = normalizePolicyType(req.PolicyType)
req.TriggerMode = normalizeTriggerMode(req.TriggerMode)
req.CycleKey = strings.TrimSpace(req.CycleKey)
req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode))
if req.CycleKey == "" {
req.CycleKey = previousMonthCycleKey(time.Now().UTC())
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 50
}
if req.PageSize > maxPageSize {
req.PageSize = maxPageSize
}
return req
}
// normalizeRecordQuery 收敛记录查询参数,非法枚举由 handler 提前报错,这里只做兜底。
func normalizeRecordQuery(req recordQuery) recordQuery {
req.PolicyType = normalizePolicyType(req.PolicyType)
req.TriggerMode = normalizeTriggerMode(req.TriggerMode)
req.Status = normalizeStatus(req.Status)
req.CycleKey = strings.TrimSpace(req.CycleKey)
req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode))
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 50
}
if req.PageSize > maxPageSize {
req.PageSize = maxPageSize
}
return req
}
// normalizePolicyType 只允许 bd/admin/all未知值归空让 handler 能统一判断是否非法。
func normalizePolicyType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
return ""
case policyTypeBD:
return policyTypeBD
case policyTypeAdmin:
return policyTypeAdmin
default:
return ""
}
}
// normalizeTriggerMode 只允许 automatic/manual/all防止手写字符串绕过政策触发方式匹配。
func normalizeTriggerMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
return ""
case triggerAutomatic:
return triggerAutomatic
case triggerManual:
return triggerManual
default:
return ""
}
}
func teamSalaryAssetType(policyType string) string {
// 用户可以同时拥有 BD/Admin 等多重身份,工资资产必须按本次结算角色拆分,不能混入其它身份钱包。
switch normalizePolicyType(policyType) {
case policyTypeBD:
return assetBDSalaryUSD
case policyTypeAdmin:
return assetAdminSalaryUSD
default:
return ""
}
}
// normalizeStatus 只允许记录表定义的状态值,避免前端筛选拼接任意 SQL 条件。
func normalizeStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
return ""
case "succeeded", "skipped", "failed":
return strings.ToLower(strings.TrimSpace(value))
default:
return ""
}
}
// highestTeamLevel 按收入基数找最高已达档;未达任何档时返回零值,后续差值自然为 0。
func highestTeamLevel(levels []teamPolicyLevel, incomeMinor int64) teamPolicyLevel {
var selected teamPolicyLevel
for _, level := range levels {
if level.ThresholdMinor <= incomeMinor {
selected = level
}
}
return selected
}
// usdStringToMinor 把 DECIMAL 字符串转成美分整数,所有工资差值都用整数计算。
func usdStringToMinor(value string) (int64, error) {
rat, ok := new(big.Rat).SetString(strings.TrimSpace(value))
if !ok || rat.Sign() < 0 {
return 0, fmt.Errorf("invalid usd amount: %s", value)
}
rat.Mul(rat, big.NewRat(100, 1))
minor := new(big.Int).Quo(rat.Num(), rat.Denom())
if !minor.IsInt64() || minor.Int64() > math.MaxInt64 {
return 0, fmt.Errorf("usd amount overflow: %s", value)
}
return minor.Int64(), nil
}
// previousMonthCycleKey 以 UTC 自然月为结算周期,和 wallet Host 周期键保持同一口径。
func previousMonthCycleKey(now time.Time) string {
return time.Date(now.UTC().Year(), now.UTC().Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -1, 0).Format("2006-01")
}
// teamSalaryCommandID 是团队工资业务幂等 ID编码维度覆盖角色、触发方式、周期、用户、等级和收入基数。
func teamSalaryCommandID(candidate teamCandidate, triggerMode string, progress teamProgress) string {
return fmt.Sprintf("team_salary:%s:%s:%s:%d:%d:%d", candidate.PolicyType, triggerMode, candidate.CycleKey, candidate.UserID, progress.SettledLevelNo, candidate.IncomeUSDMinor)
}
// teamSalarySettlementID 从 commandID 派生稳定记录 ID方便重复请求定位到同一业务动作。
func teamSalarySettlementID(appCode string, commandID string) string {
return fmt.Sprintf("tss_%s_%s", strings.TrimSpace(appCode), stableHash(commandID))
}
// teamSalaryTransactionID 从 commandID 派生 wallet 交易 ID保证记录和钱包交易可双向关联。
func teamSalaryTransactionID(appCode string, commandID string) string {
return fmt.Sprintf("wtx_%s_%s", strings.TrimSpace(appCode), stableHash(commandID))
}
// stableHash 使用 SHA-256 压缩长幂等键,避免 MySQL varchar 索引过长。
func stableHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
// teamCandidateKey 把用户和区域组合成 map keyBD/Admin 同一用户跨区域时必须分别匹配政策。
func teamCandidateKey(userID int64, regionID int64) string {
return strconv.FormatInt(userID, 10) + ":" + strconv.FormatInt(regionID, 10)
}
// parseTeamCandidateKey 反解 teamCandidateKey异常 key 返回零值,后续不会命中有效政策。
func parseTeamCandidateKey(key string) (int64, int64) {
parts := strings.Split(key, ":")
if len(parts) != 2 {
return 0, 0
}
userID, _ := strconv.ParseInt(parts[0], 10, 64)
regionID, _ := strconv.ParseInt(parts[1], 10, 64)
return userID, regionID
}
// sortTeamCandidates 让后台列表稳定:先角色、区域,再按收入从高到低,最后按用户 ID。
func sortTeamCandidates(items []teamCandidate) {
sort.Slice(items, func(i, j int) bool {
if items[i].PolicyType != items[j].PolicyType {
return items[i].PolicyType < items[j].PolicyType
}
if items[i].RegionID != items[j].RegionID {
return items[i].RegionID < items[j].RegionID
}
if items[i].IncomeUSDMinor != items[j].IncomeUSDMinor {
return items[i].IncomeUSDMinor > items[j].IncomeUSDMinor
}
return items[i].UserID < items[j].UserID
})
}
// paginateCandidates 在内存候选上分页;候选已被区域/国家/角色过滤,避免 SQL 里重复复杂聚合分页。
func paginateCandidates(items []teamCandidate, page int, pageSize int) []teamCandidate {
start := offset(page, pageSize)
if start >= len(items) {
return []teamCandidate{}
}
end := start + pageSize
if end > len(items) {
end = len(items)
}
return items[start:end]
}
// candidateUserIDs 提取候选用户 ID用于一次性加载展示资料。
func candidateUserIDs(items []teamCandidate) []int64 {
out := make([]int64, 0, len(items))
for _, item := range items {
out = append(out, item.UserID)
}
return out
}
// keysInt64 将 map key 稳定排序,保证 SQL IN 参数顺序和测试输出稳定。
func keysInt64(values map[int64]int64) []int64 {
out := make([]int64, 0, len(values))
for key := range values {
out = append(out, key)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
// keysInt64FromSet 将 set key 稳定排序,主要用于国家转区域后的记录查询条件。
func keysInt64FromSet(values map[int64]struct{}) []int64 {
out := make([]int64, 0, len(values))
for key := range values {
out = append(out, key)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
// uniquePositiveUserIDs 去重并过滤无效用户 ID避免动态 IN () 和重复 profile 查询。
func uniquePositiveUserIDs(ids []int64) []int64 {
seen := map[int64]struct{}{}
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
// userDTOFromProfile 在资料缺失时仍返回 fallback user_id保证结算记录永远可识别收款用户。
func userDTOFromProfile(fallbackID string, profile userProfile) userDTO {
if profile.UserID <= 0 {
return userDTO{UserID: fallbackID}
}
return userDTO{
UserID: strconv.FormatInt(profile.UserID, 10),
DisplayUserID: profile.DisplayUserID,
Username: profile.Username,
Avatar: profile.Avatar,
}
}
// formatUSDMinor 只负责后端 DTO 展示格式,账务计算不使用格式化字符串。
func formatUSDMinor(value int64) string {
sign := ""
if value < 0 {
sign = "-"
value = -value
}
whole := value / 100
fraction := value % 100
if fraction == 0 {
return sign + strconv.FormatInt(whole, 10)
}
if fraction < 10 {
return sign + strconv.FormatInt(whole, 10) + ".0" + strconv.FormatInt(fraction, 10)
}
return sign + strconv.FormatInt(whole, 10) + "." + strconv.FormatInt(fraction, 10)
}
// placeholders 生成固定数量的 SQL 占位符,调用方已保证 n > 0。
func placeholders(n int) string {
return strings.TrimRight(strings.Repeat("?,", n), ",")
}
// offset 把页码转换为 LIMIT offset第一页和异常页码都回到 0。
func offset(page int, pageSize int) int {
if page <= 1 {
return 0
}
return (page - 1) * pageSize
}
// positiveDelta 是所有差值发薪的最终公式:目标累计 <= 已发累计时不再发钱。
func positiveDelta(target int64, current int64) int64 {
if target <= current {
return 0
}
return target - current
}
// maxInt 防止进度等级被旧候选覆盖回退。
func maxInt(left int, right int) int {
if right > left {
return right
}
return left
}
// maxInt64 防止已发金额或收入基数被旧候选覆盖回退。
func maxInt64(left int64, right int64) int64 {
if right > left {
return right
}
return left
}