2026-06-28 15:11:56 +08:00

295 lines
15 KiB
Go
Raw 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"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql"
_ "github.com/go-sql-driver/mysql"
)
type auditWindow struct {
startDay string
endDay string
startMS int64
endMS int64
}
type auditCheck struct {
key string
label string
source string
required bool
count int64
err error
}
func main() {
var (
appCode = flag.String("app", envDefault("APP_CODE", "lalu"), "app_code to audit")
statTZ = flag.String("timezone", envDefault("STAT_TZ", mysqlstorage.StatTZAsiaShanghai), "stat_tz to audit: UTC or Asia/Shanghai")
startDay = flag.String("start-day", envDefault("START_DAY", todayIn(mysqlstorage.StatTZAsiaShanghai)), "inclusive stat day in selected timezone, yyyy-mm-dd")
endDay = flag.String("end-day", envDefault("END_DAY", todayIn(mysqlstorage.StatTZAsiaShanghai)), "inclusive stat day in selected timezone, yyyy-mm-dd")
statisticsDSN = flag.String("statistics-dsn", firstNonEmptyEnv("STATISTICS_MYSQL_DSN", "MYSQL_DSN"), "statistics mysql dsn")
userDSN = flag.String("user-dsn", firstNonEmptyEnv("USER_MYSQL_DSN", "MYSQL_USER_DSN"), "user-service mysql dsn")
walletDSN = flag.String("wallet-dsn", firstNonEmptyEnv("WALLET_MYSQL_DSN", "MYSQL_WALLET_DSN"), "wallet-service mysql dsn")
roomDSN = flag.String("room-dsn", firstNonEmptyEnv("ROOM_MYSQL_DSN", "MYSQL_ROOM_DSN"), "room-service mysql dsn")
gameDSN = flag.String("game-dsn", firstNonEmptyEnv("GAME_MYSQL_DSN", "MYSQL_GAME_DSN"), "game-service mysql dsn")
)
flag.Parse()
statTZValue, loc, err := normalizeStatTZ(*statTZ)
if err != nil {
log.Fatal(err)
}
window, err := resolveWindow(*startDay, *endDay, loc)
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
dbs := map[string]*sql.DB{}
for name, dsn := range map[string]string{
"statistics": *statisticsDSN,
"user": *userDSN,
"wallet": *walletDSN,
"room": *roomDSN,
"game": *gameDSN,
} {
if strings.TrimSpace(dsn) == "" {
log.Fatalf("%s dsn is required", name)
}
db, err := openDB(ctx, dsn)
if err != nil {
log.Fatalf("open %s db: %v", name, err)
}
defer db.Close()
dbs[name] = db
}
sourceChecks := auditSources(ctx, dbs, *appCode, window)
statsChecks := auditStatistics(ctx, dbs["statistics"], *appCode, statTZValue, window)
failed := printChecks("owner outbox", sourceChecks)
failed += printChecks("statistics aggregate", statsChecks)
if failed > 0 {
log.Fatalf("databi real flow audit failed: %d required checks have no data", failed)
}
fmt.Printf("databi real flow audit passed app=%s stat_tz=%s days=%s..%s\n", *appCode, statTZValue, window.startDay, window.endDay)
}
func auditSources(ctx context.Context, dbs map[string]*sql.DB, app string, window auditWindow) []auditCheck {
return []auditCheck{
countCheck(ctx, dbs["user"], "new_users", "新增用户", "user_outbox.UserRegistered", true, `
SELECT COUNT(*) FROM user_outbox
WHERE app_code = ? AND event_type = 'UserRegistered' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["room"], "active_users", "活跃用户", "room_outbox.RoomUserJoined", true, `
SELECT COUNT(*) FROM room_outbox
WHERE app_code = ? AND event_type = 'RoomUserJoined' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["user"], "mic_online_ms", "麦上时长", "user_outbox.UserMicDailyStatsUpdated", true, `
SELECT COUNT(*) FROM user_outbox
WHERE app_code = ? AND event_type = 'UserMicDailyStatsUpdated' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["wallet"], "google_recharge_usd_minor", "谷歌充值", "wallet_outbox.WalletRechargeRecorded(recharge_type=google/google_play可本地模拟)", true, `
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')), JSON_UNQUOTE(JSON_EXTRACT(payload, '$.channel')), '')) IN ('google','google_play')`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["wallet"], "mifapay_recharge_usd_minor", "三方充值", "wallet_outbox.WalletRechargeRecorded(recharge_type=mifapay/v5pay可本地模拟)", true, `
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')), JSON_UNQUOTE(JSON_EXTRACT(payload, '$.channel')), '')) IN ('mifapay','v5pay')`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["wallet"], "coin_seller_stock_coin", "币商充值金币", "wallet_outbox.WalletCoinSellerStockPurchased", true, `
SELECT COUNT(*) FROM wallet_outbox
WHERE app_code = ? AND event_type = 'WalletCoinSellerStockPurchased' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["wallet"], "coin_seller_transfer_coin", "币商出货金币", "wallet_outbox.WalletRechargeRecorded(recharge_type=coin_seller_transfer)", true, `
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"], "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
AND created_at_ms >= ? AND created_at_ms < ?
AND LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.biz_type')), '')) IN ('gift_debit','direct_gift_debit','vip_purchase','resource_shop_purchase','red_packet_create','game_debit','wheel_draw_debit','cp_breakup_fee')`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["wallet"], "platform_grant_coin", "平台发放金币", "wallet_outbox.Wallet*RewardCredited", true, `
SELECT COUNT(*) FROM wallet_outbox
WHERE app_code = ? AND event_type IN ('WalletTaskRewardCredited','WalletLuckyGiftRewardCredited','WalletWheelRewardCredited','WalletRoomTurnoverRewardCredited','WalletInviteActivityRewardCredited','WalletAgencyOpeningRewardCredited')
AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["wallet"], "manual_grant_coin", "人工发放金币", "wallet_outbox.WalletBalanceChanged(biz_type=manual_credit)", true, `
SELECT COUNT(*) FROM wallet_outbox
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_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'
AND available_delta + frozen_delta > 0 AND created_at_ms >= ? AND created_at_ms < ?
AND LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.biz_type')), '')) = 'salary_transfer_to_coin_seller'`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["room"], "gift_coin_spent", "礼物/幸运礼物流水", "room_outbox.RoomGiftSent", true, `
SELECT COUNT(*) FROM room_outbox
WHERE app_code = ? AND event_type = 'RoomGiftSent' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["wallet"], "lucky_gift_payout", "幸运礼物返奖", "wallet_outbox.WalletLuckyGiftRewardCredited", true, `
SELECT COUNT(*) FROM wallet_outbox
WHERE app_code = ? AND event_type = 'WalletLuckyGiftRewardCredited' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
countCheck(ctx, dbs["game"], "game_turnover", "游戏流水/利润", "game_outbox.GameOrderSettled", true, `
SELECT COUNT(*) FROM game_outbox
WHERE app_code = ? AND event_type = 'GameOrderSettled' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
}
}
func auditStatistics(ctx context.Context, db *sql.DB, app string, statTZ string, window auditWindow) []auditCheck {
checks := []auditCheck{}
for _, spec := range []struct {
key string
label string
expr string
}{
{"new_users", "新增用户", "SUM(new_users)"},
{"active_users", "活跃用户", "SUM(active_users)"},
{"paid_users", "付费用户", "SUM(paid_users)"},
{"recharge_usd_minor", "总充值", "SUM(recharge_usd_minor)"},
{"coin_seller_recharge_usd_minor", "币商充值", "SUM(coin_seller_recharge_usd_minor)"},
{"coin_seller_stock_coin", "币商充值金币", "SUM(coin_seller_stock_coin)"},
{"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)"},
{"gift_coin_spent", "礼物流水", "SUM(gift_coin_spent)"},
{"lucky_gift_turnover", "幸运礼物流水", "SUM(lucky_gift_turnover)"},
{"lucky_gift_payout", "幸运礼物返奖", "SUM(lucky_gift_payout)"},
{"game_turnover", "游戏流水", "SUM(game_turnover)"},
{"game_payout", "游戏返奖", "SUM(game_payout)"},
} {
checks = append(checks, countCheck(ctx, db, spec.key, spec.label, "stat_app_day_country."+spec.key, true,
"SELECT COALESCE("+spec.expr+",0) FROM stat_app_day_country WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?",
app, statTZ, window.startDay, window.endDay))
}
checks = append(checks,
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%'`,
app, statTZ, window.startDay, window.endDay),
countCheck(ctx, db, "super_lucky_gift_payout", "超级幸运礼物返奖", "stat_lucky_gift_pool_day_country(pool_id LIKE %super_lucky%)", true, `
SELECT COALESCE(SUM(payout_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%'`,
app, statTZ, window.startDay, window.endDay),
)
return checks
}
func countCheck(ctx context.Context, db *sql.DB, key string, label string, source string, required bool, query string, args ...any) auditCheck {
check := auditCheck{key: key, label: label, source: source, required: required}
if db == nil {
check.err = fmt.Errorf("db is nil")
return check
}
err := db.QueryRowContext(ctx, query, args...).Scan(&check.count)
if err != nil {
check.err = err
}
return check
}
func printChecks(title string, checks []auditCheck) int {
fmt.Printf("\n%s\n", title)
failed := 0
for _, check := range checks {
status := "OK"
if check.err != nil || (check.required && check.count <= 0) {
status = "MISSING"
failed++
}
if check.err != nil {
fmt.Printf(" %-8s %-34s count=%d source=%s err=%v\n", status, check.label, check.count, check.source, check.err)
continue
}
fmt.Printf(" %-8s %-34s count=%d source=%s\n", status, check.label, check.count, check.source)
}
return failed
}
func normalizeStatTZ(value string) (string, *time.Location, error) {
switch strings.TrimSpace(value) {
case mysqlstorage.StatTZUTC:
return mysqlstorage.StatTZUTC, time.UTC, nil
case mysqlstorage.StatTZAsiaShanghai:
return mysqlstorage.StatTZAsiaShanghai, time.FixedZone(mysqlstorage.StatTZAsiaShanghai, 8*60*60), nil
default:
return "", nil, fmt.Errorf("unsupported stat_tz %q; only UTC and Asia/Shanghai are supported", value)
}
}
func resolveWindow(startDay string, endDay string, loc *time.Location) (auditWindow, error) {
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(startDay), loc)
if err != nil {
return auditWindow{}, fmt.Errorf("parse start-day: %w", err)
}
end, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(endDay), loc)
if err != nil {
return auditWindow{}, fmt.Errorf("parse end-day: %w", err)
}
if end.Before(start) {
return auditWindow{}, fmt.Errorf("end-day %s is before start-day %s", endDay, startDay)
}
return auditWindow{
startDay: start.Format("2006-01-02"),
endDay: end.Format("2006-01-02"),
startMS: start.UnixMilli(),
endMS: end.AddDate(0, 0, 1).UnixMilli(),
}, nil
}
func openDB(ctx context.Context, dsn string) (*sql.DB, error) {
db, err := sql.Open("mysql", strings.TrimSpace(dsn))
if err != nil {
return nil, err
}
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, err
}
return db, nil
}
func todayIn(statTZ string) string {
_, loc, err := normalizeStatTZ(statTZ)
if err != nil {
loc = time.FixedZone(mysqlstorage.StatTZAsiaShanghai, 8*60*60)
}
return time.Now().In(loc).Format("2006-01-02")
}
func envDefault(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func firstNonEmptyEnv(keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
}
return ""
}