1492 lines
56 KiB
Go
1492 lines
56 KiB
Go
package teamsalarysettlement
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"math/big"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
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)
|
||
// 待结算列表复用结算候选计算,保证运营看到的金额与真正点击结算时的金额口径一致。
|
||
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)
|
||
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
|
||
}
|
||
|
||
// 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 owner;BD 归属必须回 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 profile,RegionID 始终来自用户当前区域,调用方必须显式传 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 key,BD/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
|
||
}
|