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 "" }