2026-07-04 00:09:06 +08:00

386 lines
16 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 main
import (
"context"
"database/sql"
"fmt"
"strings"
"testing"
"time"
"hyapp/internal/testutil/mysqlschema"
)
// 这些测试验证钱包扫描的性能改写与旧实现口径逐字段一致:
// rebuildWalletBalanceTotals 的「期初快照 + 窗口回放」必须等价于旧的全历史回放,
// rebuildWalletReportMetrics 的「按天分段 + 批量取交易」必须等价于旧的整窗 JOIN。
// 基准实现是改写前代码的逐字拷贝,不要随新实现同步修改。
const (
testApp = "lalu"
testStartDay = "2026-06-29"
testEndDay = "2026-07-01"
)
func newWalletSchema(t *testing.T) *mysqlschema.Schema {
t.Helper()
return mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1),
"..", "..", "..", "wallet-service", "deploy", "mysql", "initdb", "001_wallet_service.sql"),
DatabasePrefix: "hy_test_backfill",
})
}
func testWindow(t *testing.T) (loc *time.Location, windowStartMS int64, windowEndMS int64) {
t.Helper()
loc = time.UTC
start, err := parseDay(testStartDay, loc)
if err != nil {
t.Fatal(err)
}
end, err := parseDay(testEndDay, loc)
if err != nil {
t.Fatal(err)
}
return loc, start.UTC().UnixMilli(), end.AddDate(0, 0, 1).UTC().UnixMilli()
}
type seedEntry struct {
entryID int64
app string
txID string
userID int64
assetType string
availableDelta int64
frozenDelta int64
availableAfter int64
frozenAfter int64
counterparty int64
createdAtMS int64
}
type seedTransaction struct {
app string
txID string
commandID string
bizType string
metadataJSON sql.NullString
}
func seedWalletData(t *testing.T, db *sql.DB, transactions []seedTransaction, entries []seedEntry) {
t.Helper()
ctx := context.Background()
for _, tx := range transactions {
if _, err := db.ExecContext(ctx, `
INSERT INTO wallet_transactions (app_code, transaction_id, command_id, biz_type, status, request_hash, metadata_json, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, 'committed', ?, ?, 1, 1)`,
tx.app, tx.txID, tx.commandID, tx.bizType, "hash_"+tx.txID, tx.metadataJSON); err != nil {
t.Fatalf("seed transaction %s: %v", tx.txID, err)
}
}
for _, entry := range entries {
if _, err := db.ExecContext(ctx, `
INSERT INTO wallet_entries (entry_id, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`,
entry.entryID, entry.app, entry.txID, entry.userID, entry.assetType,
entry.availableDelta, entry.frozenDelta, entry.availableAfter, entry.frozenAfter,
entry.counterparty, entry.createdAtMS); err != nil {
t.Fatalf("seed entry %d: %v", entry.entryID, err)
}
}
}
func newTestState() *rebuildState {
state := &rebuildState{
appDays: map[appDayKey]*appDayAgg{},
registeredByUserDay: map[string]struct{}{},
payersByKey: map[string]payerRow{},
activeByKey: map[string]activeRow{},
micByKey: map[string]micRow{},
userIDs: map[int64]struct{}{},
userDims: map[int64]countryRegion{},
knownUsers: map[int64]struct{}{},
}
// 一部分用户带维度,一部分故意缺失(走零值维度分支)。
for userID := int64(101); userID <= 110; userID++ {
state.userDims[userID] = countryRegion{countryID: userID % 5, regionID: userID % 3}
}
state.userDims[202] = countryRegion{countryID: 9, regionID: 2}
return state
}
func requireEqualAppDays(t *testing.T, got map[appDayKey]*appDayAgg, want map[appDayKey]*appDayAgg) {
t.Helper()
for key := range want {
if got[key] == nil {
t.Errorf("missing agg for %+v (reference has %+v)", key, *want[key])
}
}
for key, gotAgg := range got {
wantAgg := want[key]
if wantAgg == nil {
t.Errorf("unexpected agg for %+v: %+v", key, *gotAgg)
continue
}
if *gotAgg != *wantAgg {
t.Errorf("agg mismatch for %+v:\n got=%+v\nwant=%+v", key, *gotAgg, *wantAgg)
}
}
}
// referenceRebuildWalletBalanceTotals 是优化前的全历史回放实现,逐字拷贝作为口径基准。
func referenceRebuildWalletBalanceTotals(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.asset_type, e.available_after + e.frozen_after AS balance_after, e.created_at_ms
FROM wallet_entries e
WHERE e.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`,
args...)
if err != nil {
return err
}
defer rows.Close()
var currentUserID int64
entries := []walletBalanceEntry{}
flush := func() {
if currentUserID <= 0 || len(entries) == 0 {
return
}
dim := state.userDims[currentUserID]
index := 0
balancesByAsset := map[string]int64{}
for _, point := range dayEnds {
for index < len(entries) && entries[index].createdAtMS < point.endMS {
balancesByAsset[entries[index].assetType] = entries[index].balance
index++
}
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 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]
}
currentUserID = entry.userID
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return err
}
flush()
return nil
}
// referenceRebuildWalletReportMetrics 是优化前的整窗 JOIN 实现,逐字拷贝作为口径基准。
func referenceRebuildWalletReportMetrics(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string, startMS int64, endMS int64, loc *time.Location) error {
if err := referenceRebuildWalletBalanceTotals(ctx, db, state, app, startDay, endDay, endMS, loc, []string{"COIN"}, func(agg *appDayAgg, balance int64) {
agg.coinTotal += balance
}); err != nil {
return err
}
rows, err := db.QueryContext(ctx, `
SELECT e.user_id, e.counterparty_user_id, e.asset_type, e.available_delta + e.frozen_delta AS amount_delta,
wt.biz_type, COALESCE(JSON_UNQUOTE(JSON_EXTRACT(wt.metadata_json, '$.command_id')), wt.command_id), e.created_at_ms
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
WHERE e.app_code = ? AND e.asset_type IN ('COIN', 'COIN_SELLER_COIN') AND e.created_at_ms >= ? AND e.created_at_ms < ?`,
app, startMS, endMS)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var userID, counterpartyUserID, amountDelta, createdAtMS int64
var assetType, bizType, commandID string
if err := rows.Scan(&userID, &counterpartyUserID, &assetType, &amountDelta, &bizType, &commandID, &createdAtMS); err != nil {
return err
}
day := dayFromMS(createdAtMS, loc)
normalizedBiz := strings.ToLower(strings.TrimSpace(bizType))
normalizedAsset := strings.ToUpper(strings.TrimSpace(assetType))
if amountDelta > 0 && normalizedBiz == "salary_transfer_to_coin_seller" && normalizedAsset == "COIN_SELLER_COIN" {
dimensionUserID := userID
if counterpartyUserID > 0 {
dimensionUserID = counterpartyUserID
}
agg := state.ensureAgg(day, state.userDims[dimensionUserID])
agg.salaryTransferCoin += amountDelta
continue
}
if normalizedAsset != "COIN" {
continue
}
dim := state.userDims[userID]
agg := state.ensureAgg(day, dim)
switch {
case amountDelta < 0 && isConsumedCoinBizType(normalizedBiz):
agg.consumedCoin += -amountDelta
case amountDelta > 0 && isOutputCoinBizType(normalizedBiz):
agg.outputCoin += amountDelta
case amountDelta > 0 && normalizedBiz == "manual_credit":
agg.manualGrantCoin += amountDelta
case amountDelta > 0 && normalizedBiz == "resource_grant" && isPlatformGrantCommandID(commandID):
agg.platformGrantCoin += amountDelta
case amountDelta > 0 && isPlatformGrantBizType(normalizedBiz):
agg.platformGrantCoin += amountDelta
}
}
return rows.Err()
}
func jsonMeta(value string) sql.NullString {
return sql.NullString{String: value, Valid: true}
}
// seedBalanceAndReportFixture 铺一份同时覆盖余额回放和报表归类所有分支的数据。
func seedBalanceAndReportFixture(t *testing.T, db *sql.DB, windowStartMS int64, windowEndMS int64) {
t.Helper()
day1 := windowStartMS // 2026-06-29 00:00 UTC
day2 := day1 + 24*3600*1000 // 2026-06-30 00:00 UTC
day3 := day2 + 24*3600*1000 // 2026-07-01 00:00 UTC
pre := windowStartMS - 10*24*3600*1000 // 窗口前 10 天
transactions := []seedTransaction{
{testApp, "tx_pre_1", "cmd_pre_1", "recharge_credit", sql.NullString{}},
{testApp, "tx_pre_2", "cmd_pre_2", "recharge_credit", sql.NullString{}},
{testApp, "tx_pre_3", "cmd_pre_3", "recharge_credit", sql.NullString{}},
{testApp, "tx_pre_4", "cmd_pre_4", "recharge_credit", sql.NullString{}},
{testApp, "tx_pre_5", "cmd_pre_5", "recharge_credit", sql.NullString{}},
{testApp, "tx_gift_neg", "cmd_gift_neg", "direct_gift_debit", sql.NullString{}},
{testApp, "tx_gift_pos", "cmd_gift_pos", "gift_debit", sql.NullString{}},
{testApp, "tx_gift_direct_pos", "cmd_gift_direct_pos", "direct_gift_debit", sql.NullString{}},
{testApp, "tx_manual", "cmd_manual", "manual_credit", sql.NullString{}},
{testApp, "tx_grant_meta", "cmd_grant_meta", "resource_grant", jsonMeta(`{"command_id":"weekly_star:abc"}`)},
{testApp, "tx_grant_nometa", "wtask_grant_2", "resource_grant", jsonMeta(`{"other":1}`)},
{testApp, "tx_grant_other", "cmd_grant_other", "resource_grant", sql.NullString{}},
{testApp, "tx_grant_case", "cp_weekly_rank:z", "Resource_Grant", sql.NullString{}},
{testApp, "tx_grant_leading", "cmd_not_platform", " resource_grant", jsonMeta(`{"command_id":"weekly_star:lead"}`)},
{testApp, "tx_task", "cmd_task", "task_reward", sql.NullString{}},
{testApp, "tx_redpacket", "cmd_redpacket", "red_packet_create", sql.NullString{}},
{testApp, "tx_wheel", "cmd_wheel", "wheel_draw_debit", sql.NullString{}},
{testApp, "tx_claim", "cmd_claim", "red_packet_claim", sql.NullString{}},
{testApp, "tx_salary_transfer", "cmd_salary_transfer", "salary_transfer_to_coin_seller", jsonMeta(`{"command_id":"ignored"}`)},
{testApp, "tx_seller_manual", "cmd_seller_manual", "manual_credit", sql.NullString{}},
{testApp, "tx_lower", "cmd_lower", "manual_credit", sql.NullString{}},
{testApp, "tx_boundary", "cmd_boundary", "manual_credit", sql.NullString{}},
// 故意不给 tx_missing 建交易主行INNER JOIN 语义下该分录不参与口径。
{"other", "tx_other_app", "cmd_other_app", "manual_credit", sql.NullString{}},
}
entries := []seedEntry{
// user 101只有窗口前分录期初快照兜底路径日终余额恒为 500。
{1, testApp, "tx_pre_1", 101, "COIN", 300, 0, 300, 0, 0, pre},
{2, testApp, "tx_pre_2", 101, "COIN", 200, 0, 400, 100, 0, pre + 1000},
// user 104窗口前同一毫秒两条分录entry_id 大者(余额 777才是口径上的期初。
{3, testApp, "tx_pre_3", 104, "COIN", 100, 0, 111, 0, 0, pre + 5000},
{4, testApp, "tx_pre_4", 104, "COIN", 100, 0, 777, 0, 0, pre + 5000},
// user 102窗口前期初 900day2 内变为 250。
{5, testApp, "tx_pre_5", 102, "COIN", 900, 0, 900, 0, 0, pre + 9000},
{6, testApp, "tx_gift_neg", 102, "COIN", -100, 0, 250, 0, 0, day2 + 3600_000},
// user 103只有窗口内分录day3 起才计入。
{7, testApp, "tx_gift_pos", 103, "COIN", 80, 0, 80, 0, 0, day3 + 7200_000},
// user 105COIN 与 HOST_SALARY_USD 双资产。
{8, testApp, "tx_manual", 105, "COIN", 50, 0, 50, 0, 0, day1 + 1000},
{9, testApp, "tx_grant_meta", 105, "HOST_SALARY_USD", 30, 0, 30, 0, 0, pre + 100},
// user 106负余额也按原样累计。
{10, testApp, "tx_grant_nometa", 106, "COIN", 30, 0, -20, 0, 0, day1 + 2000},
// user 107AGENCY_SALARY_USD 只在窗口内。
{11, testApp, "tx_grant_other", 107, "AGENCY_SALARY_USD", 12, 0, 12, 0, 0, day2 + 100},
// user 108HOST_SALARY_USD 只有期初。
{12, testApp, "tx_task", 108, "HOST_SALARY_USD", 40, 0, 40, 0, 0, pre + 200},
// 报表归类分支(均在窗口内,含各 biz_type 与正负号组合)。
{13, testApp, "tx_grant_case", 105, "COIN", 5, 0, 55, 0, 0, day2 + 5000},
{14, testApp, "tx_redpacket", 102, "COIN", -40, 0, 210, 0, 0, day2 + 6000},
{15, testApp, "tx_wheel", 102, "COIN", -60, 0, 150, 0, 0, day3 + 1000},
{16, testApp, "tx_claim", 103, "COIN", 25, 0, 105, 0, 0, day3 + 8000},
{17, testApp, "tx_salary_transfer", 201, "COIN_SELLER_COIN", 500, 0, 500, 0, 202, day2 + 7000},
{18, testApp, "tx_seller_manual", 201, "COIN_SELLER_COIN", 10, 0, 510, 0, 0, day2 + 8000},
{19, testApp, "tx_missing", 109, "COIN", 33, 0, 33, 0, 0, day2 + 9000},
// 小写 asset_typeci 排序规则下新旧实现都会命中并归一化成大写。
{20, testApp, "tx_lower", 110, "coin", 15, 0, 15, 0, 0, day1 + 3000},
// 边界:恰好等于窗口起点与 day2 起点的分录。
{21, testApp, "tx_boundary", 106, "COIN", 6, 0, -14, 0, 0, day2},
{22, testApp, "tx_grant_meta", 104, "COIN", 30, 0, 807, 0, 0, windowStartMS},
// 其他 app 的分录必须被过滤。
{23, "other", "tx_other_app", 101, "COIN", 999, 0, 999, 0, 0, day1 + 100},
// 正向 direct_gift_debit 归产出(新旧一致)。
{24, testApp, "tx_gift_direct_pos", 103, "COIN", 10, 0, 90, 0, 0, day3 + 7500},
// 前导空格 biz_typeGo 侧 TrimSpace 归到 resource_grantSQL 侧 TRIM 门必须同样命中 metadata command_id。
{25, testApp, "tx_grant_leading", 105, "COIN", 7, 0, 62, 0, 0, day2 + 9500},
}
seedWalletData(t, db, transactions, entries)
}
func TestRebuildWalletBalanceTotalsMatchesFullReplay(t *testing.T) {
schema := newWalletSchema(t)
ctx := context.Background()
loc, windowStartMS, windowEndMS := testWindow(t)
seedBalanceAndReportFixture(t, schema.DB, windowStartMS, windowEndMS)
// 多资产切片没有生产调用方,但要覆盖占位符拼装的通用分支。
for _, assets := range [][]string{{"COIN"}, {"HOST_SALARY_USD"}, {"AGENCY_SALARY_USD"}, {"HOST_SALARY_USD", "AGENCY_SALARY_USD"}} {
newState := newTestState()
refState := newTestState()
apply := func(agg *appDayAgg, balance int64) { agg.coinTotal += balance }
if err := rebuildWalletBalanceTotals(ctx, schema.DB, newState, testApp, testStartDay, testEndDay, windowStartMS, windowEndMS, loc, assets, apply); err != nil {
t.Fatalf("new impl (%v): %v", assets, err)
}
if err := referenceRebuildWalletBalanceTotals(ctx, schema.DB, refState, testApp, testStartDay, testEndDay, windowEndMS, loc, assets, apply); err != nil {
t.Fatalf("reference impl (%v): %v", assets, err)
}
t.Run(fmt.Sprintf("assets_%s", strings.Join(assets, "_")), func(t *testing.T) {
requireEqualAppDays(t, newState.appDays, refState.appDays)
})
}
}
func TestRebuildWalletReportMetricsMatchesJoinQuery(t *testing.T) {
schema := newWalletSchema(t)
ctx := context.Background()
loc, windowStartMS, windowEndMS := testWindow(t)
seedBalanceAndReportFixture(t, schema.DB, windowStartMS, windowEndMS)
newState := newTestState()
refState := newTestState()
if err := rebuildWalletReportMetrics(ctx, schema.DB, newState, testApp, testStartDay, testEndDay, windowStartMS, windowEndMS, loc); err != nil {
t.Fatalf("new impl: %v", err)
}
if err := referenceRebuildWalletReportMetrics(ctx, schema.DB, refState, testApp, testStartDay, testEndDay, windowStartMS, windowEndMS, loc); err != nil {
t.Fatalf("reference impl: %v", err)
}
requireEqualAppDays(t, newState.appDays, refState.appDays)
// 抽查一个绝对值防止两套实现同时错成一样user 202维度 9/2在 day2 收到 500 工资转账。
key := appDayKey{day: "2026-06-30", countryID: 9, regionID: 2}
agg := newState.appDays[key]
if agg == nil || agg.salaryTransferCoin != 500 {
t.Errorf("expected salaryTransferCoin=500 for %+v, got %+v", key, agg)
}
}