修正工资总和余额快照口径

This commit is contained in:
zhx 2026-06-30 14:58:48 +08:00
parent ff51573613
commit 38c674e3eb
7 changed files with 96 additions and 87 deletions

View File

@ -36,7 +36,7 @@
| `consume_output_delta` | 消耗产出差额 | `consumed_coin - output_coin`。 |
| `platform_grant_coin` | 平台发放金币 | 排行榜奖励、活动奖励、签到/任务奖励、火箭奖励中的 COIN 金币发放;包括对应 `Wallet*RewardCredited``resource_grant` COIN 入账不含人工发放、装扮价值、lucky 返奖、转盘产出。 |
| `manual_grant_coin` | 人工发放金币 | `WalletBalanceChanged biz_type=manual_credit` 的普通 COIN 正向发放。 |
| `salary_usd_minor` | 工资总和 | 主播和代理工资钱包结算成功金额,单位是 USD minor。 |
| `salary_usd_minor` | 工资总和(主播/代理) | 主播和代理工资钱包 `HOST_SALARY_USD + AGENCY_SALARY_USD``available+frozen` 余额总和;历史日取当天 23:59:59.999 前最后一次余额,今日由补数任务按当前已有分录刷新;多日总览只取结束日快照,日序列展示每天快照;不包含 BD/Admin 工资和 residual。 |
| `salary_transfer_coin` | 工资兑换金币 | 工资转给币商时币商 `COIN_SELLER_COIN` 入账金币数量,维度按发起转账用户。 |
| `mic_online_ms` | 麦上总时长 | `user_mic_daily_stats.mic_online_ms` 按日、国家、区域聚合。 |
| `mic_online_users` | 麦上用户数 | 当日 `mic_online_ms > 0` 的去重用户数。 |
@ -79,8 +79,8 @@
| 字段 | 口径 |
| --- | --- |
| `retention` | `registered_users` 为注册 cohort`day1/day7/day30_users` 为注册后第 1/7/30 天有活跃的用户rate 为对应用户数除以注册用户数。 |
| `daily_series` | 按日返回本页主要字段;`coin_total` 是每天的用户金币数快照,其它流水字段按当天累加。 |
| `country_breakdown` | 按国家返回本页主要字段;`coin_total` 是查询结束日该国家用户金币数快照,其它字段按查询周期累加。 |
| `daily_series` | 按日返回本页主要字段;`coin_total` `salary_usd_minor` 是每天余额快照,其它流水字段按当天累加。 |
| `country_breakdown` | 按国家返回本页主要字段;`coin_total` `salary_usd_minor` 是查询结束日该国家余额快照,其它字段按查询周期累加。 |
| `daily_country_breakdown` | 按日 + 国家返回主要字段,用于国家弹窗明细。 |
| `game_ranking` | 按 `platform_code/game_id` 聚合游戏流水、返奖、退款、玩家数、利润和利润率。 |
| `lucky_gift_pools` | 按 `pool_id` 聚合 lucky 礼物流水、返奖、利润、返奖率和利润率。 |

View File

@ -170,8 +170,8 @@ func main() {
if err := rebuildAppDayCoinMetricsFromStatistics(ctx, statsDB, state, app, statTZ, startDay, endDay); err != nil {
log.Fatalf("rebuild app day coin metrics from statistics: %v", err)
}
if err := rebuildSalary(ctx, walletDB, state, app, windowStartMS, windowEndMS, loc); err != nil {
log.Fatalf("rebuild salary: %v", err)
if err := rebuildSalaryTotals(ctx, walletDB, state, app, startDay, endDay, windowEndMS, loc); err != nil {
log.Fatalf("rebuild salary totals: %v", err)
}
if err := rebuildMicStats(ctx, userDB, state, app, startDay, endDay); err != nil {
log.Fatalf("rebuild mic stats: %v", err)
@ -370,25 +370,9 @@ func collectReportMetricUserIDs(ctx context.Context, walletDB *sql.DB, userDB *s
query: `
SELECT DISTINCT user_id
FROM wallet_accounts
WHERE app_code = ? AND asset_type = 'COIN' AND available_amount + frozen_amount <> 0`,
WHERE app_code = ? AND asset_type IN ('COIN', 'HOST_SALARY_USD', 'AGENCY_SALARY_USD') AND available_amount + frozen_amount <> 0`,
args: []any{app},
},
{
db: walletDB,
query: `
SELECT DISTINCT user_id
FROM host_salary_settlement_records
WHERE app_code = ? AND status = 'succeeded' AND created_at_ms < ?`,
args: []any{app, windowEndMS},
},
{
db: walletDB,
query: `
SELECT DISTINCT agency_owner_user_id
FROM host_salary_settlement_records
WHERE app_code = ? AND status = 'succeeded' AND agency_owner_user_id > 0 AND created_at_ms < ?`,
args: []any{app, windowEndMS},
},
{
db: userDB,
query: `
@ -675,54 +659,82 @@ func rebuildAppDayCoinMetricsFromStatistics(ctx context.Context, db *sql.DB, sta
return rows.Err()
}
type coinBalanceEntry struct {
type walletBalanceEntry struct {
userID int64
assetType string
balance int64
createdAtMS int64
}
func rebuildCoinTotals(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string, windowEndMS int64, loc *time.Location) error {
return rebuildWalletBalanceTotals(ctx, db, state, app, startDay, endDay, windowEndMS, loc, []string{"COIN"}, func(agg *appDayAgg, balance int64) {
agg.coinTotal += balance
})
}
func rebuildSalaryTotals(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string, windowEndMS int64, loc *time.Location) error {
return rebuildWalletBalanceTotals(ctx, db, state, app, startDay, endDay, windowEndMS, loc, []string{"HOST_SALARY_USD", "AGENCY_SALARY_USD"}, func(agg *appDayAgg, balance int64) {
agg.salaryUSDMinor += balance
})
}
func rebuildWalletBalanceTotals(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string, windowEndMS int64, loc *time.Location, assetTypes []string, applyBalance func(*appDayAgg, int64)) error {
dayEnds, err := dayEndPoints(startDay, endDay, loc)
if err != nil {
return err
}
if len(assetTypes) == 0 {
return nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(assetTypes)), ",")
args := []any{app}
for _, assetType := range assetTypes {
args = append(args, strings.ToUpper(strings.TrimSpace(assetType)))
}
args = append(args, windowEndMS)
rows, err := db.QueryContext(ctx, `
SELECT e.user_id, e.available_after + e.frozen_after AS balance_after, e.created_at_ms
SELECT e.user_id, e.asset_type, e.available_after + e.frozen_after AS balance_after, e.created_at_ms
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.transaction_id = e.transaction_id
WHERE wt.app_code = ? AND e.asset_type = 'COIN' AND e.created_at_ms < ?
WHERE wt.app_code = ? AND e.asset_type IN (`+placeholders+`) AND e.created_at_ms < ?
ORDER BY e.user_id ASC, e.created_at_ms ASC, e.entry_id ASC`,
app, windowEndMS)
args...)
if err != nil {
return err
}
defer rows.Close()
var currentUserID int64
entries := []coinBalanceEntry{}
entries := []walletBalanceEntry{}
flush := func() {
if currentUserID <= 0 || len(entries) == 0 {
return
}
dim := state.userDims[currentUserID]
index := 0
balance := int64(0)
hasBalance := false
balancesByAsset := map[string]int64{}
for _, point := range dayEnds {
for index < len(entries) && entries[index].createdAtMS < point.endMS {
balance = entries[index].balance
hasBalance = true
balancesByAsset[entries[index].assetType] = entries[index].balance
index++
}
if hasBalance {
state.ensureAgg(point.day, dim).coinTotal += balance
if len(balancesByAsset) == 0 {
continue
}
balanceTotal := int64(0)
for _, balance := range balancesByAsset {
balanceTotal += balance
}
// 日终余额口径取该用户在当天结束前每个目标资产的最后一条账后余额;工资用户可能同时拥有主播和代理资产,
// 因此先按资产覆盖最后余额再求和,避免同一用户多资产互相覆盖。
applyBalance(state.ensureAgg(point.day, dim), balanceTotal)
}
}
for rows.Next() {
var entry coinBalanceEntry
if err := rows.Scan(&entry.userID, &entry.balance, &entry.createdAtMS); err != nil {
var entry walletBalanceEntry
if err := rows.Scan(&entry.userID, &entry.assetType, &entry.balance, &entry.createdAtMS); err != nil {
return err
}
entry.assetType = strings.ToUpper(strings.TrimSpace(entry.assetType))
if currentUserID != 0 && entry.userID != currentUserID {
flush()
entries = entries[:0]
@ -737,32 +749,6 @@ func rebuildCoinTotals(ctx context.Context, db *sql.DB, state *rebuildState, app
return nil
}
func rebuildSalary(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS int64, endMS int64, loc *time.Location) error {
rows, err := db.QueryContext(ctx, `
SELECT user_id, agency_owner_user_id, host_salary_usd_minor_delta, agency_salary_usd_minor_delta, created_at_ms
FROM host_salary_settlement_records
WHERE app_code = ? AND status = 'succeeded' AND created_at_ms >= ? AND created_at_ms < ?`,
app, startMS, endMS)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var hostUserID, agencyUserID, hostSalary, agencySalary, createdAtMS int64
if err := rows.Scan(&hostUserID, &agencyUserID, &hostSalary, &agencySalary, &createdAtMS); err != nil {
return err
}
day := dayFromMS(createdAtMS, loc)
if hostSalary > 0 {
state.ensureAgg(day, state.userDims[hostUserID]).salaryUSDMinor += hostSalary
}
if agencySalary > 0 && agencyUserID > 0 {
state.ensureAgg(day, state.userDims[agencyUserID]).salaryUSDMinor += agencySalary
}
}
return rows.Err()
}
func rebuildMicStats(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string) error {
rows, err := db.QueryContext(ctx, `
SELECT DATE_FORMAT(stat_date, '%Y-%m-%d'), user_id, mic_online_ms

View File

@ -109,9 +109,10 @@ func auditSources(ctx context.Context, dbs map[string]*sql.DB, app string, windo
SELECT COUNT(*) FROM wallet_outbox
WHERE app_code = ? AND event_type = 'WalletRechargeRecorded' AND created_at_ms >= ? AND created_at_ms < ?
AND LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.recharge_type')), '')) IN ('coin_seller_transfer','coin_seller')`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["wallet"], "coin_total", "金币数量总和", "wallet_outbox.WalletBalanceChanged(asset=COIN)", true, `
SELECT COUNT(*) FROM wallet_outbox
WHERE app_code = ? AND event_type = 'WalletBalanceChanged' AND asset_type = 'COIN' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["wallet"], "coin_total", "用户金币数", "wallet_entries(asset=COIN)", true, `
SELECT COUNT(*) FROM wallet_entries e
JOIN wallet_transactions wt ON wt.transaction_id = e.transaction_id
WHERE wt.app_code = ? AND e.asset_type = 'COIN' AND e.created_at_ms < ?`, app, window.endMS),
countCheck(ctx, dbs["wallet"], "consumed_coin", "消耗金币", "wallet_outbox.WalletBalanceChanged(COIN消费biz_type)", true, `
SELECT COUNT(*) FROM wallet_outbox
WHERE app_code = ? AND event_type = 'WalletBalanceChanged' AND asset_type = 'COIN' AND available_delta + frozen_delta < 0
@ -126,11 +127,10 @@ func auditSources(ctx context.Context, dbs map[string]*sql.DB, app string, windo
WHERE app_code = ? AND event_type = 'WalletBalanceChanged' AND asset_type = 'COIN' AND available_delta + frozen_delta > 0
AND created_at_ms >= ? AND created_at_ms < ?
AND LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.biz_type')), '')) = 'manual_credit'`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["wallet"], "salary_usd_minor", "工资总和", "wallet_outbox.WalletBalanceChanged(biz_type=host_salary_settlement)", true, `
SELECT COUNT(*) FROM wallet_outbox
WHERE app_code = ? AND event_type = 'WalletBalanceChanged' AND asset_type IN ('HOST_SALARY_USD','AGENCY_SALARY_USD')
AND available_delta + frozen_delta > 0 AND created_at_ms >= ? AND created_at_ms < ?
AND LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.biz_type')), '')) = 'host_salary_settlement'`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["wallet"], "salary_usd_minor", "工资总和(主播/代理)", "wallet_entries(asset=HOST_SALARY_USD/AGENCY_SALARY_USD)", true, `
SELECT COUNT(*) FROM wallet_entries e
JOIN wallet_transactions wt ON wt.transaction_id = e.transaction_id
WHERE wt.app_code = ? AND e.asset_type IN ('HOST_SALARY_USD','AGENCY_SALARY_USD') AND e.created_at_ms < ?`, app, window.endMS),
countCheck(ctx, dbs["wallet"], "salary_transfer_coin", "工资兑换金币", "wallet_outbox.WalletBalanceChanged(biz_type=salary_transfer_to_coin_seller)", true, `
SELECT COUNT(*) FROM wallet_outbox
WHERE app_code = ? AND event_type = 'WalletBalanceChanged' AND asset_type = 'COIN_SELLER_COIN'
@ -164,11 +164,9 @@ func auditStatistics(ctx context.Context, db *sql.DB, app string, statTZ string,
{"coin_seller_transfer_coin", "币商出货金币", "SUM(coin_seller_transfer_coin)"},
{"google_recharge_usd_minor", "谷歌充值", "SUM(google_recharge_usd_minor)"},
{"mifapay_recharge_usd_minor", "三方充值", "SUM(mifapay_recharge_usd_minor)"},
{"coin_total", "金币数量总和", "SUM(coin_total)"},
{"consumed_coin", "消耗金币", "SUM(consumed_coin)"},
{"platform_grant_coin", "平台发放金币", "SUM(platform_grant_coin)"},
{"manual_grant_coin", "人工发放金币", "SUM(manual_grant_coin)"},
{"salary_usd_minor", "工资总和", "SUM(salary_usd_minor)"},
{"salary_transfer_coin", "工资兑换金币", "SUM(salary_transfer_coin)"},
{"mic_online_ms", "麦上总时长", "SUM(mic_online_ms)"},
{"mic_online_users", "平均麦上时间用户数", "SUM(mic_online_users)"},
@ -183,6 +181,16 @@ func auditStatistics(ctx context.Context, db *sql.DB, app string, statTZ string,
app, statTZ, window.startDay, window.endDay))
}
checks = append(checks,
countCheck(ctx, db, "coin_total", "用户金币数", "stat_app_day_country.coin_total(end day)", true, `
SELECT COALESCE(SUM(CASE WHEN stat_day = ? THEN coin_total ELSE 0 END),0)
FROM stat_app_day_country
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
window.endDay, app, statTZ, window.startDay, window.endDay),
countCheck(ctx, db, "salary_usd_minor", "工资总和(主播/代理)", "stat_app_day_country.salary_usd_minor(end day)", true, `
SELECT COALESCE(SUM(CASE WHEN stat_day = ? THEN salary_usd_minor ELSE 0 END),0)
FROM stat_app_day_country
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
window.endDay, app, statTZ, window.startDay, window.endDay),
countCheck(ctx, db, "super_lucky_gift_turnover", "超级幸运礼物流水", "stat_lucky_gift_pool_day_country(pool_id LIKE %super_lucky%)", true, `
SELECT COALESCE(SUM(turnover_coin),0) FROM stat_lucky_gift_pool_day_country
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND LOWER(pool_id) LIKE '%super_lucky%'`,

View File

@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS stat_app_day_country (
output_coin BIGINT NOT NULL DEFAULT 0 COMMENT '游戏、收礼、lucky、转盘和红包领取退回产出金币',
platform_grant_coin BIGINT NOT NULL DEFAULT 0 COMMENT '排行榜、活动、签到和火箭奖励金币发放',
manual_grant_coin BIGINT NOT NULL DEFAULT 0 COMMENT '后台人工发放金币',
salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '主播和代理工资 USD 最小单位',
salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '主播和代理工资钱包余额快照 USD 最小单位',
salary_transfer_coin BIGINT NOT NULL DEFAULT 0 COMMENT '工资转币商入账金币数量',
mic_online_ms BIGINT NOT NULL DEFAULT 0 COMMENT '麦上总时长毫秒',
mic_online_users BIGINT NOT NULL DEFAULT 0 COMMENT '麦上用户数',

View File

@ -682,7 +682,7 @@ func (r *Repository) QueryPlatformGrantCoinRecords(ctx context.Context, query Pl
}
func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Overview, error) {
args := []any{endDay, app, normalizeStatTZ(statTZ), startDay, endDay}
args := []any{endDay, endDay, app, normalizeStatTZ(statTZ), startDay, endDay}
filter := ""
if countryID > 0 {
filter += " AND country_id = ?"
@ -699,7 +699,7 @@ func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, statTZ,
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
COALESCE(SUM(CASE WHEN stat_day = ? THEN coin_total ELSE 0 END),0),
COALESCE(SUM(consumed_coin),0), COALESCE(SUM(output_coin),0), COALESCE(SUM(platform_grant_coin),0),
COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0),
COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(CASE WHEN stat_day = ? THEN salary_usd_minor ELSE 0 END),0), COALESCE(SUM(salary_transfer_coin),0),
COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
COALESCE(SUM(gift_coin_spent),0),
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
@ -1002,7 +1002,7 @@ func (r *Repository) queryGameRanking(ctx context.Context, app, statTZ, startDay
}
func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) ([]CountryBreakdown, error) {
args := []any{endDay, app, normalizeStatTZ(statTZ), startDay, endDay}
args := []any{endDay, endDay, app, normalizeStatTZ(statTZ), startDay, endDay}
filter := ""
if regionID > 0 {
filter = " AND region_id = ?"
@ -1016,7 +1016,7 @@ func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, sta
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
COALESCE(SUM(CASE WHEN stat_day = ? THEN coin_total ELSE 0 END),0),
COALESCE(SUM(consumed_coin),0), COALESCE(SUM(output_coin),0), COALESCE(SUM(platform_grant_coin),0),
COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0),
COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(CASE WHEN stat_day = ? THEN salary_usd_minor ELSE 0 END),0), COALESCE(SUM(salary_transfer_coin),0),
COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
COALESCE(SUM(gift_coin_spent),0),
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
@ -1425,7 +1425,7 @@ func reportMetricSources() []ReportMetricSource {
{Key: "consume_output_ratio", Label: "消耗产出比", Status: "available", Source: "stat_app_day_country", Definition: "比例=产出金币/消耗金币,差额=消耗金币-产出金币。"},
{Key: "platform_grant_coin", Label: "平台发放金币", Status: "available", Source: "Wallet*RewardCredited / WalletBalanceChanged biz_type=resource_grant", Definition: "排行榜奖励、活动奖励、签到/任务奖励和火箭奖励中的金币发放resource_grant 只统计可识别命令前缀的 COIN 入账不包含人工发放、装扮价值、lucky 返奖和转盘产出。"},
{Key: "manual_grant_coin", Label: "人工发放金币", Status: "available", Source: "WalletBalanceChanged biz_type=manual_credit", Definition: "后台人工调账向普通 COIN 钱包正向发放的金币。"},
{Key: "salary_usd_minor", Label: "工资总和(主播/代理)", Status: "available", Source: "host_salary_settlement_records / WalletBalanceChanged", Definition: "成功结算的主播工资和代理工资 USD minor 合计,不包含 residual、BD/Admin 工资。"},
{Key: "salary_usd_minor", Label: "工资总和(主播/代理)", Status: "available", Source: "wallet_entries / WalletBalanceChanged", Definition: "主播和代理工资钱包 HOST_SALARY_USD+AGENCY_SALARY_USD 的 available+frozen 余额总和;多日总览只取结束日余额快照,日序列展示每日日终快照,不包含 residual、BD/Admin 工资。"},
{Key: "salary_transfer_coin", Label: "工资兑换金币", Status: "available", Source: "WalletBalanceChanged biz_type=salary_transfer_to_coin_seller / wallet_transactions", Definition: "用户工资钱包转给币商时,币商 COIN_SELLER_COIN 入账的金币数量;国家/区域按发起转账用户归属。"},
{Key: "avg_mic_online_ms", Label: "平均麦上时间", Status: "available", Source: "user_mic_daily_stats", Definition: "麦上总时长 / 当日麦上用户数,只统计 mic_online_ms > 0 的用户。"},
{Key: "mic_online_ms", Label: "麦上总时长", Status: "available", Source: "user_mic_daily_stats.mic_online_ms", Definition: "用户每日麦上时长按国家/区域聚合后的毫秒总和。"},

View File

@ -157,6 +157,7 @@ func TestConsumeReportMetricsAggregatesWalletSalaryGrantAndMic(t *testing.T) {
{AppCode: "lalu", EventID: "balance:coin:checkin-resource", UserID: 7001, AssetType: "COIN", BizType: "resource_grant", CommandID: "wcheckin_claim_1", AvailableDelta: 66, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "balance:coin:manual", UserID: 7001, AssetType: "COIN", BizType: "manual_credit", AvailableDelta: 70, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "balance:salary:host", UserID: 7001, AssetType: "HOST_SALARY_USD", BizType: "host_salary_settlement", AvailableDelta: 123, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "balance:salary:transfer-debit", UserID: 7001, AssetType: "HOST_SALARY_USD", BizType: "salary_transfer_to_coin_seller", AvailableDelta: -23, OccurredAtMS: occurredAt},
{AppCode: "lalu", EventID: "balance:salary:coin-seller", UserID: 9001, DimensionUserID: 7001, AssetType: "COIN_SELLER_COIN", BizType: "salary_transfer_to_coin_seller", AvailableDelta: 444, OccurredAtMS: occurredAt},
} {
if err := repository.ConsumeWalletBalanceChanged(ctx, event); err != nil {
@ -188,7 +189,7 @@ func TestConsumeReportMetricsAggregatesWalletSalaryGrantAndMic(t *testing.T) {
if err != nil {
t.Fatalf("query overview: %v", err)
}
if overview.CoinTotal != 986 || overview.ConsumedCoin != 200 || overview.OutputCoin != 90 || overview.ConsumeOutputDelta != 110 || overview.ConsumeOutputRatio != 0.45 || overview.PlatformGrantCoin != 99 || overview.ManualGrantCoin != 70 || overview.SalaryUSDMinor != 123 || overview.SalaryTransferCoin != 444 {
if overview.CoinTotal != 986 || overview.ConsumedCoin != 200 || overview.OutputCoin != 90 || overview.ConsumeOutputDelta != 110 || overview.ConsumeOutputRatio != 0.45 || overview.PlatformGrantCoin != 99 || overview.ManualGrantCoin != 70 || overview.SalaryUSDMinor != 100 || overview.SalaryTransferCoin != 444 {
t.Fatalf("report wallet metrics mismatch: %+v", overview)
}
if overview.MicOnlineMS != 120_000 || overview.MicOnlineUsers != 2 || overview.AvgMicOnlineMS != 60_000 {
@ -206,7 +207,7 @@ func TestConsumeReportMetricsAggregatesWalletSalaryGrantAndMic(t *testing.T) {
}
}
func TestQueryOverviewUsesEndDayCoinSnapshotForRange(t *testing.T) {
func TestQueryOverviewUsesEndDayBalanceSnapshotsForRange(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
@ -221,10 +222,10 @@ func TestQueryOverviewUsesEndDayCoinSnapshotForRange(t *testing.T) {
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_app_day_country (
app_code, stat_day, country_id, region_id, coin_total, consumed_coin, output_coin, updated_at_ms
app_code, stat_day, country_id, region_id, coin_total, salary_usd_minor, consumed_coin, output_coin, updated_at_ms
) VALUES
('lalu', '2026-06-24', 15, 10, 100, 7, 3, 1),
('lalu', '2026-06-25', 15, 10, 250, 11, 4, 1)`); err != nil {
('lalu', '2026-06-24', 15, 10, 100, 12, 7, 3, 1),
('lalu', '2026-06-25', 15, 10, 250, 34, 11, 4, 1)`); err != nil {
t.Fatalf("seed coin snapshots: %v", err)
}
@ -233,11 +234,14 @@ func TestQueryOverviewUsesEndDayCoinSnapshotForRange(t *testing.T) {
if err != nil {
t.Fatalf("query overview: %v", err)
}
if overview.CoinTotal != 250 || overview.ConsumedCoin != 18 || overview.OutputCoin != 7 {
t.Fatalf("overview must use end-day coin snapshot and range flow sums: %+v", overview)
if overview.CoinTotal != 250 || overview.SalaryUSDMinor != 34 || overview.ConsumedCoin != 18 || overview.OutputCoin != 7 {
t.Fatalf("overview must use end-day balance snapshots and range flow sums: %+v", overview)
}
if len(overview.DailySeries) != 2 || overview.DailySeries[0].CoinTotal != 100 || overview.DailySeries[1].CoinTotal != 250 {
t.Fatalf("daily series must keep each day's coin snapshot: %+v", overview.DailySeries)
if len(overview.DailySeries) != 2 || overview.DailySeries[0].CoinTotal != 100 || overview.DailySeries[0].SalaryUSDMinor != 12 || overview.DailySeries[1].CoinTotal != 250 || overview.DailySeries[1].SalaryUSDMinor != 34 {
t.Fatalf("daily series must keep each day's balance snapshots: %+v", overview.DailySeries)
}
if len(overview.CountryBreakdown) != 1 || overview.CountryBreakdown[0].CoinTotal != 250 || overview.CountryBreakdown[0].SalaryUSDMinor != 34 {
t.Fatalf("country breakdown must use end-day balance snapshots: %+v", overview.CountryBreakdown)
}
}

View File

@ -1066,7 +1066,9 @@ func (r *Repository) ConsumeWalletBalanceChanged(ctx context.Context, event Wall
manualGrant = totalDelta
}
}
if totalDelta > 0 && bizType == "host_salary_settlement" && (assetType == "HOST_SALARY_USD" || assetType == "AGENCY_SALARY_USD") {
if isHostAgencySalaryAssetType(assetType) {
// 工资总和是主播/代理工资钱包余额快照,不是结算流水。这里写 available+frozen 的净变化,
// 冻结动作本身一正一负不改变总额,入账/出账/兑换才会改变当天余额快照。
salaryUSD = totalDelta
}
if totalDelta > 0 && bizType == "salary_transfer_to_coin_seller" && assetType == "COIN_SELLER_COIN" {
@ -1653,6 +1655,15 @@ func isOutputCoinBizType(value string) bool {
}
}
func isHostAgencySalaryAssetType(value string) bool {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "HOST_SALARY_USD", "AGENCY_SALARY_USD":
return true
default:
return false
}
}
func isOutputCoinRewardEventType(value string) bool {
switch strings.TrimSpace(value) {
case "WalletWheelRewardCredited":