From 13f07d800fe1514beb48c10098df9251f910afd2 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 25 Jun 2026 14:31:15 +0800 Subject: [PATCH] feat(statistics): add stat_tz dashboard aggregation --- .../internal/modules/dashboard/handler.go | 2 + .../internal/modules/dashboard/service.go | 8 + .../cmd/backfill-last7/main.go | 124 ++- .../cmd/replay-stat-tz/main.go | 414 ++++++++ .../mysql/initdb/001_statistics_service.sql | 103 +- .../statistics-service/internal/app/app.go | 142 +-- .../internal/app/query_http.go | 2 + .../internal/storage/mysql/query.go | 212 +++-- .../internal/storage/mysql/query_test.go | 106 ++- .../internal/storage/mysql/repository.go | 895 ++++++++++-------- .../internal/storage/mysql/stat_tz.go | 68 ++ 11 files changed, 1441 insertions(+), 635 deletions(-) create mode 100644 services/statistics-service/cmd/replay-stat-tz/main.go create mode 100644 services/statistics-service/internal/storage/mysql/stat_tz.go diff --git a/server/admin/internal/modules/dashboard/handler.go b/server/admin/internal/modules/dashboard/handler.go index e08bf91c..dab9e969 100644 --- a/server/admin/internal/modules/dashboard/handler.go +++ b/server/admin/internal/modules/dashboard/handler.go @@ -31,6 +31,7 @@ func (h *Handler) DashboardOverview(c *gin.Context) { func (h *Handler) StatisticsOverview(c *gin.Context) { overview, err := h.service.StatisticsOverview(c.Request.Context(), StatisticsQuery{ AppCode: c.DefaultQuery("app_code", "lalu"), + StatTZ: c.Query("stat_tz"), StartMS: parseInt64(c.Query("start_ms")), EndMS: parseInt64(c.Query("end_ms")), SeriesStartMS: parseInt64(c.Query("series_start_ms")), @@ -48,6 +49,7 @@ func (h *Handler) StatisticsOverview(c *gin.Context) { func (h *Handler) SelfGameStatisticsOverview(c *gin.Context) { overview, err := h.service.SelfGameStatisticsOverview(c.Request.Context(), SelfGameStatisticsQuery{ AppCode: c.DefaultQuery("app_code", "lalu"), + StatTZ: c.Query("stat_tz"), RequestID: middleware.CurrentRequestID(c), StartMS: parseInt64(c.Query("start_ms")), EndMS: parseInt64(c.Query("end_ms")), diff --git a/server/admin/internal/modules/dashboard/service.go b/server/admin/internal/modules/dashboard/service.go index 5b680325..e28ac8a6 100644 --- a/server/admin/internal/modules/dashboard/service.go +++ b/server/admin/internal/modules/dashboard/service.go @@ -23,6 +23,7 @@ type DashboardService struct { type StatisticsQuery struct { AppCode string + StatTZ string StartMS int64 EndMS int64 SeriesStartMS int64 @@ -33,6 +34,7 @@ type StatisticsQuery struct { type SelfGameStatisticsQuery struct { AppCode string + StatTZ string RequestID string StartMS int64 EndMS int64 @@ -57,6 +59,9 @@ func (s *DashboardService) Overview() (*repository.DashboardOverview, error) { func (s *DashboardService) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) { values := url.Values{} values.Set("app_code", query.AppCode) + if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" { + values.Set("stat_tz", statTZ) + } if query.StartMS > 0 { values.Set("start_ms", strconv.FormatInt(query.StartMS, 10)) } @@ -81,6 +86,9 @@ func (s *DashboardService) StatisticsOverview(ctx context.Context, query Statist func (s *DashboardService) SelfGameStatisticsOverview(ctx context.Context, query SelfGameStatisticsQuery) (map[string]any, error) { values := url.Values{} values.Set("app_code", query.AppCode) + if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" { + values.Set("stat_tz", statTZ) + } if query.StartMS > 0 { values.Set("start_ms", strconv.FormatInt(query.StartMS, 10)) } diff --git a/services/statistics-service/cmd/backfill-last7/main.go b/services/statistics-service/cmd/backfill-last7/main.go index cd9614f7..bf8b88aa 100644 --- a/services/statistics-service/cmd/backfill-last7/main.go +++ b/services/statistics-service/cmd/backfill-last7/main.go @@ -33,6 +33,7 @@ type appDayAgg struct { rechargeUSDMinor int64 newUserRechargeUSDMinor int64 coinSellerRechargeUSDMinor int64 + mifaPayRechargeUSDMinor int64 googleRechargeUSDMinor int64 coinSellerTransferCoin int64 } @@ -90,6 +91,10 @@ func main() { if err != nil { log.Fatalf("load timezone: %v", err) } + statTZ, err := normalizeBackfillStatTZ(loc.String()) + if err != nil { + log.Fatal(err) + } startDay, endDay, windowStartMS, windowEndMS, err := resolveWindow(*startFlag, *endFlag, loc) if err != nil { log.Fatal(err) @@ -116,7 +121,7 @@ func main() { if err := collectRegistrationUsers(ctx, userDB, state, app, windowStartMS, windowEndMS); err != nil { log.Fatalf("collect registrations: %v", err) } - if err := collectActiveUserIDs(ctx, statsDB, state, app, startDay, endDay); err != nil { + if err := collectActiveUserIDs(ctx, statsDB, state, app, statTZ, startDay, endDay); err != nil { log.Fatalf("collect active ids: %v", err) } if err := collectRechargeUserIDs(ctx, walletDB, state, app, windowStartMS, windowEndMS); err != nil { @@ -128,7 +133,7 @@ func main() { if err := rebuildRegistrations(ctx, userDB, state, app, windowStartMS, windowEndMS, loc); err != nil { log.Fatalf("rebuild registrations: %v", err) } - if err := rebuildActive(ctx, statsDB, state, app, startDay, endDay); err != nil { + if err := rebuildActive(ctx, statsDB, state, app, statTZ, startDay, endDay); err != nil { log.Fatalf("rebuild active: %v", err) } if err := rebuildWalletRecharge(ctx, walletDB, state, app, windowStartMS, windowEndMS, loc); err != nil { @@ -138,7 +143,7 @@ func main() { log.Fatalf("rebuild coin seller stock: %v", err) } - if err := stageAndMaybeApply(ctx, statsDB, state, app, startDay, endDay, *applyFlag); err != nil { + if err := stageAndMaybeApply(ctx, statsDB, state, app, statTZ, startDay, endDay, *applyFlag); err != nil { log.Fatalf("stage/apply: %v", err) } mode := "dry-run" @@ -146,7 +151,18 @@ func main() { mode = "applied" } fmt.Printf("%s app=%s timezone=%s window=%s..%s app_day_rows=%d registrations=%d active=%d payers=%d\n", - mode, app, loc.String(), startDay, endDay, len(state.appDays), len(state.registrations), len(state.activeByKey), len(state.payersByKey)) + mode, app, statTZ, startDay, endDay, len(state.appDays), len(state.registrations), len(state.activeByKey), len(state.payersByKey)) +} + +func normalizeBackfillStatTZ(value string) (string, error) { + switch strings.TrimSpace(value) { + case "UTC": + return "UTC", nil + case "Asia/Shanghai": + return "Asia/Shanghai", nil + default: + return "", fmt.Errorf("unsupported stat_tz %q; only UTC and Asia/Shanghai are queryable", value) + } } func envDefault(key string, fallback string) string { @@ -250,12 +266,12 @@ func collectRegistrationUsers(ctx context.Context, db *sql.DB, state *rebuildSta return rows.Err() } -func collectActiveUserIDs(ctx context.Context, db *sql.DB, state *rebuildState, app, startDay, endDay string) error { +func collectActiveUserIDs(ctx context.Context, db *sql.DB, state *rebuildState, app, statTZ, startDay, endDay string) error { rows, err := db.QueryContext(ctx, ` SELECT DISTINCT user_id FROM stat_user_day_activity - WHERE app_code = ? AND stat_day BETWEEN ? AND ?`, - app, startDay, endDay) + WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`, + app, statTZ, startDay, endDay) if err != nil { return err } @@ -370,14 +386,14 @@ func rebuildRegistrations(ctx context.Context, db *sql.DB, state *rebuildState, return rows.Err() } -func rebuildActive(ctx context.Context, db *sql.DB, state *rebuildState, app, startDay, endDay string) error { - // 房间/游戏活跃已经被实时消费进 stat_user_day_activity;离线回填在注册活跃基础上重刷这张表的国家/区域快照。 +func rebuildActive(ctx context.Context, db *sql.DB, state *rebuildState, app, statTZ, startDay, endDay string) error { + // 房间/游戏活跃已经被实时消费进对应 stat_tz 的 stat_user_day_activity;回填只读取目标时区,避免用 UTC 活跃去污染中国自然日。 rows, err := db.QueryContext(ctx, ` SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), user_id, MIN(first_active_at_ms) FROM stat_user_day_activity - WHERE app_code = ? AND stat_day BETWEEN ? AND ? + WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? GROUP BY stat_day, user_id`, - app, startDay, endDay) + app, statTZ, startDay, endDay) if err != nil { return err } @@ -426,6 +442,9 @@ func rebuildWalletRecharge(ctx context.Context, db *sql.DB, state *rebuildState, case "google_play_recharge": agg.rechargeUSDMinor += usdMinor agg.googleRechargeUSDMinor += usdMinor + case "mifapay", "v5pay": + agg.rechargeUSDMinor += usdMinor + agg.mifaPayRechargeUSDMinor += usdMinor default: if usdMinor > 0 { agg.rechargeUSDMinor += usdMinor @@ -507,7 +526,7 @@ func userDayKey(day string, userID int64) string { return fmt.Sprintf("%s:%d", day, userID) } -func stageAndMaybeApply(ctx context.Context, db *sql.DB, state *rebuildState, app, startDay, endDay string, apply bool) error { +func stageAndMaybeApply(ctx context.Context, db *sql.DB, state *rebuildState, app, statTZ, startDay, endDay string, apply bool) error { conn, err := db.Conn(ctx) if err != nil { return err @@ -521,7 +540,7 @@ func stageAndMaybeApply(ctx context.Context, db *sql.DB, state *rebuildState, ap if err := createTempTables(ctx, tx); err != nil { return err } - if err := insertStageRows(ctx, tx, state, app); err != nil { + if err := insertStageRows(ctx, tx, state, app, statTZ); err != nil { return err } if err := validateStage(ctx, tx); err != nil { @@ -531,60 +550,61 @@ func stageAndMaybeApply(ctx context.Context, db *sql.DB, state *rebuildState, ap return tx.Rollback() } nowMS := time.Now().UTC().UnixMilli() - // 本 job 只覆盖能从当前源表确定的近 7 天列;礼物、游戏、MifaPay 等不在本次源表范围内的指标保留原值,避免修国家维度时误删其他业务数据。 + // 本 job 只覆盖能从当前源表确定的列;礼物和游戏等不在本次源表范围内的指标保留原值,且只替换目标 stat_tz,避免重跑中国日时覆盖 UTC 读模型。 if _, err := tx.ExecContext(ctx, ` UPDATE stat_app_day_country SET new_users = 0, active_users = 0, paid_users = 0, recharge_usd_minor = 0, new_user_recharge_usd_minor = 0, coin_seller_recharge_usd_minor = 0, - google_recharge_usd_minor = 0, coin_seller_transfer_coin = 0, + mifapay_recharge_usd_minor = 0, google_recharge_usd_minor = 0, coin_seller_transfer_coin = 0, updated_at_ms = ? - WHERE app_code = ? AND stat_day BETWEEN ? AND ?`, - nowMS, app, startDay, endDay); err != nil { + WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`, + nowMS, app, statTZ, startDay, endDay); err != nil { return err } for _, statement := range []string{ - `DELETE FROM stat_user_registration WHERE app_code = ? AND registered_day BETWEEN ? AND ?`, - `DELETE FROM stat_user_day_activity WHERE app_code = ? AND stat_day BETWEEN ? AND ?`, - `DELETE FROM stat_recharge_day_payers WHERE app_code = ? AND stat_day BETWEEN ? AND ?`, + `DELETE FROM stat_user_registration WHERE app_code = ? AND stat_tz = ? AND registered_day BETWEEN ? AND ?`, + `DELETE FROM stat_user_day_activity WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`, + `DELETE FROM stat_recharge_day_payers WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`, } { - if _, err := tx.ExecContext(ctx, statement, app, startDay, endDay); err != nil { + if _, err := tx.ExecContext(ctx, statement, app, statTZ, startDay, endDay); err != nil { return err } } if _, err := tx.ExecContext(ctx, ` INSERT INTO stat_app_day_country ( - app_code, stat_day, country_id, region_id, new_users, active_users, paid_users, + app_code, stat_tz, stat_day, country_id, region_id, new_users, active_users, paid_users, recharge_usd_minor, new_user_recharge_usd_minor, coin_seller_recharge_usd_minor, - google_recharge_usd_minor, coin_seller_transfer_coin, updated_at_ms + mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin, updated_at_ms ) - SELECT app_code, stat_day, country_id, region_id, new_users, active_users, paid_users, + SELECT app_code, stat_tz, stat_day, country_id, region_id, new_users, active_users, paid_users, recharge_usd_minor, new_user_recharge_usd_minor, coin_seller_recharge_usd_minor, - google_recharge_usd_minor, coin_seller_transfer_coin, ? + mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin, ? FROM tmp_stat_backfill_app_day ON DUPLICATE KEY UPDATE new_users = VALUES(new_users), active_users = VALUES(active_users), paid_users = VALUES(paid_users), recharge_usd_minor = VALUES(recharge_usd_minor), new_user_recharge_usd_minor = VALUES(new_user_recharge_usd_minor), coin_seller_recharge_usd_minor = VALUES(coin_seller_recharge_usd_minor), + mifapay_recharge_usd_minor = VALUES(mifapay_recharge_usd_minor), google_recharge_usd_minor = VALUES(google_recharge_usd_minor), coin_seller_transfer_coin = VALUES(coin_seller_transfer_coin), updated_at_ms = VALUES(updated_at_ms)`, nowMS); err != nil { return err } if _, err := tx.ExecContext(ctx, ` - INSERT INTO stat_user_registration (app_code, user_id, registered_day, country_id, region_id, registered_at_ms) - SELECT app_code, user_id, registered_day, country_id, region_id, registered_at_ms FROM tmp_stat_backfill_registration`); err != nil { + INSERT INTO stat_user_registration (app_code, stat_tz, user_id, registered_day, country_id, region_id, registered_at_ms) + SELECT app_code, stat_tz, user_id, registered_day, country_id, region_id, registered_at_ms FROM tmp_stat_backfill_registration`); err != nil { return err } if _, err := tx.ExecContext(ctx, ` - INSERT INTO stat_user_day_activity (app_code, stat_day, country_id, region_id, user_id, first_active_at_ms) - SELECT app_code, stat_day, country_id, region_id, user_id, first_active_at_ms FROM tmp_stat_backfill_active`); err != nil { + INSERT INTO stat_user_day_activity (app_code, stat_tz, stat_day, country_id, region_id, user_id, first_active_at_ms) + SELECT app_code, stat_tz, stat_day, country_id, region_id, user_id, first_active_at_ms FROM tmp_stat_backfill_active`); err != nil { return err } if _, err := tx.ExecContext(ctx, ` - INSERT INTO stat_recharge_day_payers (app_code, stat_day, country_id, region_id, user_id, first_paid_at_ms) - SELECT app_code, stat_day, country_id, region_id, user_id, first_paid_at_ms FROM tmp_stat_backfill_payers`); err != nil { + INSERT INTO stat_recharge_day_payers (app_code, stat_tz, stat_day, country_id, region_id, user_id, first_paid_at_ms) + SELECT app_code, stat_tz, stat_day, country_id, region_id, user_id, first_paid_at_ms FROM tmp_stat_backfill_payers`); err != nil { return err } return tx.Commit() @@ -593,27 +613,27 @@ func stageAndMaybeApply(ctx context.Context, db *sql.DB, state *rebuildState, ap func createTempTables(ctx context.Context, tx *sql.Tx) error { statements := []string{ `CREATE TEMPORARY TABLE tmp_stat_backfill_app_day ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL, region_id BIGINT NOT NULL, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL, region_id BIGINT NOT NULL, new_users BIGINT NOT NULL, active_users BIGINT NOT NULL, paid_users BIGINT NOT NULL, recharge_usd_minor BIGINT NOT NULL, new_user_recharge_usd_minor BIGINT NOT NULL, - coin_seller_recharge_usd_minor BIGINT NOT NULL, google_recharge_usd_minor BIGINT NOT NULL, + coin_seller_recharge_usd_minor BIGINT NOT NULL, mifapay_recharge_usd_minor BIGINT NOT NULL, google_recharge_usd_minor BIGINT NOT NULL, coin_seller_transfer_coin BIGINT NOT NULL, - PRIMARY KEY(app_code, stat_day, country_id, region_id) + PRIMARY KEY(app_code, stat_tz, stat_day, country_id, region_id) )`, `CREATE TEMPORARY TABLE tmp_stat_backfill_registration ( - app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, registered_day DATE NOT NULL, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, registered_day DATE NOT NULL, country_id BIGINT NOT NULL, region_id BIGINT NOT NULL, registered_at_ms BIGINT NOT NULL, - PRIMARY KEY(app_code, user_id) + PRIMARY KEY(app_code, stat_tz, user_id) )`, `CREATE TEMPORARY TABLE tmp_stat_backfill_active ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL, region_id BIGINT NOT NULL, user_id BIGINT NOT NULL, first_active_at_ms BIGINT NOT NULL, - PRIMARY KEY(app_code, stat_day, user_id) + PRIMARY KEY(app_code, stat_tz, stat_day, user_id) )`, `CREATE TEMPORARY TABLE tmp_stat_backfill_payers ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL, region_id BIGINT NOT NULL, user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL, - PRIMARY KEY(app_code, stat_day, user_id) + PRIMARY KEY(app_code, stat_tz, stat_day, user_id) )`, } for _, statement := range statements { @@ -624,38 +644,38 @@ func createTempTables(ctx context.Context, tx *sql.Tx) error { return nil } -func insertStageRows(ctx context.Context, tx *sql.Tx, state *rebuildState, app string) error { +func insertStageRows(ctx context.Context, tx *sql.Tx, state *rebuildState, app string, statTZ string) error { for key, agg := range state.appDays { if _, err := tx.ExecContext(ctx, ` INSERT INTO tmp_stat_backfill_app_day ( - app_code, stat_day, country_id, region_id, new_users, active_users, paid_users, + app_code, stat_tz, stat_day, country_id, region_id, new_users, active_users, paid_users, recharge_usd_minor, new_user_recharge_usd_minor, coin_seller_recharge_usd_minor, - google_recharge_usd_minor, coin_seller_transfer_coin - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - app, key.day, key.countryID, key.regionID, agg.newUsers, agg.activeUsers, agg.paidUsers, + mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + app, statTZ, key.day, key.countryID, key.regionID, agg.newUsers, agg.activeUsers, agg.paidUsers, agg.rechargeUSDMinor, agg.newUserRechargeUSDMinor, agg.coinSellerRechargeUSDMinor, - agg.googleRechargeUSDMinor, agg.coinSellerTransferCoin); err != nil { + agg.mifaPayRechargeUSDMinor, agg.googleRechargeUSDMinor, agg.coinSellerTransferCoin); err != nil { return err } } for _, row := range state.registrations { if _, err := tx.ExecContext(ctx, ` - INSERT INTO tmp_stat_backfill_registration (app_code, user_id, registered_day, country_id, region_id, registered_at_ms) - VALUES (?, ?, ?, ?, ?, ?)`, app, row.userID, row.registeredDay, row.dim.countryID, row.dim.regionID, row.registeredAtMS); err != nil { + INSERT INTO tmp_stat_backfill_registration (app_code, stat_tz, user_id, registered_day, country_id, region_id, registered_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?)`, app, statTZ, row.userID, row.registeredDay, row.dim.countryID, row.dim.regionID, row.registeredAtMS); err != nil { return err } } for _, row := range state.activeByKey { if _, err := tx.ExecContext(ctx, ` - INSERT INTO tmp_stat_backfill_active (app_code, stat_day, country_id, region_id, user_id, first_active_at_ms) - VALUES (?, ?, ?, ?, ?, ?)`, app, row.day, row.dim.countryID, row.dim.regionID, row.userID, row.firstMS); err != nil { + INSERT INTO tmp_stat_backfill_active (app_code, stat_tz, stat_day, country_id, region_id, user_id, first_active_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?)`, app, statTZ, row.day, row.dim.countryID, row.dim.regionID, row.userID, row.firstMS); err != nil { return err } } for _, row := range state.payersByKey { if _, err := tx.ExecContext(ctx, ` - INSERT INTO tmp_stat_backfill_payers (app_code, stat_day, country_id, region_id, user_id, first_paid_at_ms) - VALUES (?, ?, ?, ?, ?, ?)`, app, row.day, row.dim.countryID, row.dim.regionID, row.userID, row.firstMS); err != nil { + INSERT INTO tmp_stat_backfill_payers (app_code, stat_tz, stat_day, country_id, region_id, user_id, first_paid_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?)`, app, statTZ, row.day, row.dim.countryID, row.dim.regionID, row.userID, row.firstMS); err != nil { return err } } diff --git a/services/statistics-service/cmd/replay-stat-tz/main.go b/services/statistics-service/cmd/replay-stat-tz/main.go new file mode 100644 index 00000000..7b972116 --- /dev/null +++ b/services/statistics-service/cmd/replay-stat-tz/main.go @@ -0,0 +1,414 @@ +package main + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "flag" + "fmt" + "log" + "os" + "strings" + "time" + + "google.golang.org/protobuf/proto" + roomeventsv1 "hyapp.local/api/proto/events/room/v1" + + "hyapp/pkg/gamemq" + "hyapp/pkg/roommq" + "hyapp/pkg/usermq" + "hyapp/pkg/walletmq" + statapp "hyapp/services/statistics-service/internal/app" + mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql" + + _ "github.com/go-sql-driver/mysql" +) + +const replayEventIDMaxLen = 160 + +type replayWindow struct { + startDay string + endDay string + startMS int64 + endMS int64 +} + +type replaySummary struct { + userRows int64 + walletRows int64 + roomRows int64 + gameRows int64 +} + +func main() { + var ( + appCode = flag.String("app", envDefault("APP_CODE", "lalu"), "app_code to replay") + statTZ = flag.String("timezone", envDefault("STAT_TZ", mysqlstorage.StatTZAsiaShanghai), "stat_tz to rebuild: UTC or Asia/Shanghai") + startDay = flag.String("start-day", "", "inclusive stat day in selected timezone, yyyy-mm-dd") + endDay = flag.String("end-day", "", "inclusive stat day in selected timezone, yyyy-mm-dd") + apply = flag.Bool("apply", false, "apply mutations; dry-run only counts source rows by default") + 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 := normalizeReplayStatTZ(*statTZ) + if err != nil { + log.Fatal(err) + } + window, err := resolveReplayWindow(*startDay, *endDay, loc) + if err != nil { + log.Fatal(err) + } + if *apply && strings.TrimSpace(*statisticsDSN) == "" { + log.Fatal("statistics-dsn is required when apply is true") + } + if *apply && (strings.TrimSpace(*userDSN) == "" || strings.TrimSpace(*walletDSN) == "" || strings.TrimSpace(*roomDSN) == "" || strings.TrimSpace(*gameDSN) == "") { + log.Fatal("apply requires user-dsn, wallet-dsn, room-dsn and game-dsn so the rebuilt stat_tz window is complete for owner outbox facts") + } + + ctx := context.Background() + dbs := map[string]*sql.DB{} + for name, dsn := range map[string]string{"user": *userDSN, "wallet": *walletDSN, "room": *roomDSN, "game": *gameDSN} { + if strings.TrimSpace(dsn) == "" { + continue + } + db, err := openDB(ctx, dsn) + if err != nil { + log.Fatalf("open %s db: %v", name, err) + } + defer db.Close() + dbs[name] = db + } + + summary, err := countSources(ctx, dbs, *appCode, window) + if err != nil { + log.Fatalf("count sources: %v", err) + } + if !*apply { + fmt.Printf("dry-run app=%s stat_tz=%s days=%s..%s source_rows user=%d wallet=%d room=%d game=%d\n", + *appCode, statTZValue, window.startDay, window.endDay, summary.userRows, summary.walletRows, summary.roomRows, summary.gameRows) + return + } + statsDB, err := openDB(ctx, *statisticsDSN) + if err != nil { + log.Fatalf("open statistics db: %v", err) + } + defer statsDB.Close() + repo, err := mysqlstorage.Open(ctx, *statisticsDSN) + if err != nil { + log.Fatalf("open statistics repository: %v", err) + } + defer repo.Close() + if err := resetReplayTarget(ctx, statsDB, *appCode, statTZValue, window); err != nil { + log.Fatalf("reset target: %v", err) + } + scopeCtx := mysqlstorage.WithStatTZScope(ctx, statTZValue) + if err := replayUser(ctx, scopeCtx, repo, dbs["user"], *appCode, statTZValue, window); err != nil { + log.Fatalf("replay user outbox: %v", err) + } + if err := replayWallet(ctx, scopeCtx, repo, dbs["wallet"], *appCode, statTZValue, window); err != nil { + log.Fatalf("replay wallet outbox: %v", err) + } + if err := replayRoom(ctx, scopeCtx, repo, dbs["room"], *appCode, statTZValue, window); err != nil { + log.Fatalf("replay room outbox: %v", err) + } + if err := replayGame(ctx, scopeCtx, repo, dbs["game"], *appCode, statTZValue, window); err != nil { + log.Fatalf("replay game outbox: %v", err) + } + fmt.Printf("applied app=%s stat_tz=%s days=%s..%s source_rows user=%d wallet=%d room=%d game=%d\n", + *appCode, statTZValue, window.startDay, window.endDay, summary.userRows, summary.walletRows, summary.roomRows, summary.gameRows) +} + +func normalizeReplayStatTZ(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 resolveReplayWindow(startDay string, endDay string, loc *time.Location) (replayWindow, error) { + now := time.Now().In(loc) + if strings.TrimSpace(startDay) == "" || strings.TrimSpace(endDay) == "" { + // 默认重跑“最近 30 个已完成自然日”,避开当天实时消费者仍在写入造成 replay 后缀和原 event_id 同时入账。 + yesterday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -1) + start := yesterday.AddDate(0, 0, -29) + startDay = start.Format("2006-01-02") + endDay = yesterday.Format("2006-01-02") + } + start, err := time.ParseInLocation("2006-01-02", startDay, loc) + if err != nil { + return replayWindow{}, fmt.Errorf("parse start-day: %w", err) + } + end, err := time.ParseInLocation("2006-01-02", endDay, loc) + if err != nil { + return replayWindow{}, fmt.Errorf("parse end-day: %w", err) + } + if end.Before(start) { + return replayWindow{}, fmt.Errorf("end-day %s is before start-day %s", endDay, startDay) + } + return replayWindow{ + 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 countSources(ctx context.Context, dbs map[string]*sql.DB, app string, window replayWindow) (replaySummary, error) { + var out replaySummary + var err error + if db := dbs["user"]; db != nil { + out.userRows, err = countOutboxRows(ctx, db, "user_outbox", app, []string{"UserRegistered"}, window) + if err != nil { + return replaySummary{}, err + } + } + if db := dbs["wallet"]; db != nil { + out.walletRows, err = countOutboxRows(ctx, db, "wallet_outbox", app, []string{"WalletRechargeRecorded", "WalletCoinSellerStockPurchased", "WalletLuckyGiftRewardCredited"}, window) + if err != nil { + return replaySummary{}, err + } + } + if db := dbs["room"]; db != nil { + out.roomRows, err = countOutboxRows(ctx, db, "room_outbox", app, []string{"RoomGiftSent", "RoomUserJoined"}, window) + if err != nil { + return replaySummary{}, err + } + } + if db := dbs["game"]; db != nil { + out.gameRows, err = countOutboxRows(ctx, db, "game_outbox", app, gameReplayEventTypes(), window) + if err != nil { + return replaySummary{}, err + } + } + return out, nil +} + +func countOutboxRows(ctx context.Context, db *sql.DB, table string, app string, eventTypes []string, window replayWindow) (int64, error) { + query, args := outboxWindowQuery("COUNT(*)", table, app, eventTypes, window, "") + row := db.QueryRowContext(ctx, query, args...) + var count int64 + return count, row.Scan(&count) +} + +func resetReplayTarget(ctx context.Context, db *sql.DB, app string, statTZ string, window replayWindow) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + // 目标表只删除当前 stat_tz 的可重建 owner-outbox 聚合;H5 上报没有 owner outbox,不在这里删除,避免抹掉部署后实时进入的 H5 事件。 + for _, spec := range []struct { + table string + day string + }{ + {"stat_app_day_country", "stat_day"}, + {"stat_user_day_activity", "stat_day"}, + {"stat_user_registration", "registered_day"}, + {"stat_recharge_day_payers", "stat_day"}, + {"stat_lucky_gift_day_payers", "stat_day"}, + {"stat_lucky_gift_pool_day_country", "stat_day"}, + {"stat_real_room_robot_gift_day_rooms", "stat_day"}, + {"stat_game_day_country", "stat_day"}, + {"stat_game_day_players", "stat_day"}, + {"stat_self_game_match_day", "stat_day"}, + {"stat_self_game_pool_day", "stat_day"}, + {"stat_self_game_participant_day", "stat_day"}, + {"stat_self_game_user_day", "stat_day"}, + } { + if _, err := tx.ExecContext(ctx, "DELETE FROM "+spec.table+" WHERE app_code = ? AND stat_tz = ? AND "+spec.day+" BETWEEN ? AND ?", app, statTZ, window.startDay, window.endDay); err != nil { + return err + } + } + token := replayToken(statTZ) + for _, source := range []string{mysqlstorage.SourceUser, mysqlstorage.SourceWallet, mysqlstorage.SourceRoom, mysqlstorage.SourceGame} { + if _, err := tx.ExecContext(ctx, ` + DELETE FROM statistics_event_consumption + WHERE app_code = ? AND source = ? AND (event_id LIKE ? OR event_id LIKE ?)`, + app, source, "%:stat_tz:"+token, "replay:"+token+":%"); err != nil { + return err + } + } + return tx.Commit() +} + +func replayUser(ctx context.Context, scopeCtx context.Context, repo *mysqlstorage.Repository, db *sql.DB, app string, statTZ string, window replayWindow) error { + rows, err := db.QueryContext(ctx, ` + SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms + FROM user_outbox + WHERE app_code = ? AND event_type = 'UserRegistered' AND created_at_ms >= ? AND created_at_ms < ? + ORDER BY created_at_ms ASC, event_id ASC`, app, window.startMS, window.endMS) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var msg usermq.UserOutboxMessage + if err := rows.Scan(&msg.AppCode, &msg.EventID, &msg.EventType, &msg.AggregateType, &msg.AggregateID, &msg.PayloadJSON, &msg.OccurredAtMS); err != nil { + return err + } + msg.EventID = replayEventID(msg.EventID, statTZ) + body, err := usermq.EncodeUserOutboxMessage(msg) + if err != nil { + return err + } + if err := statapp.ConsumeUserOutboxBody(scopeCtx, repo, body); err != nil { + return err + } + } + return rows.Err() +} + +func replayWallet(ctx context.Context, scopeCtx context.Context, repo *mysqlstorage.Repository, db *sql.DB, app string, statTZ string, window replayWindow) error { + query, args := outboxWindowQuery(` + app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, + available_delta, frozen_delta, CAST(payload AS CHAR), created_at_ms`, "wallet_outbox", app, + []string{"WalletRechargeRecorded", "WalletCoinSellerStockPurchased", "WalletLuckyGiftRewardCredited"}, window, "ORDER BY created_at_ms ASC, event_id ASC") + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var msg walletmq.WalletOutboxMessage + if err := rows.Scan(&msg.AppCode, &msg.EventID, &msg.EventType, &msg.TransactionID, &msg.CommandID, &msg.UserID, &msg.AssetType, &msg.AvailableDelta, &msg.FrozenDelta, &msg.PayloadJSON, &msg.OccurredAtMS); err != nil { + return err + } + msg.EventID = replayEventID(msg.EventID, statTZ) + body, err := walletmq.EncodeWalletOutboxMessage(msg) + if err != nil { + return err + } + if err := statapp.ConsumeWalletOutboxBody(scopeCtx, repo, body); err != nil { + return err + } + } + return rows.Err() +} + +func replayRoom(ctx context.Context, scopeCtx context.Context, repo *mysqlstorage.Repository, db *sql.DB, app string, statTZ string, window replayWindow) error { + query, args := outboxWindowQuery("envelope", "room_outbox", app, []string{"RoomGiftSent", "RoomUserJoined"}, window, "ORDER BY created_at_ms ASC, event_id ASC") + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return err + } + var envelope roomeventsv1.EventEnvelope + if err := proto.Unmarshal(raw, &envelope); err != nil { + return err + } + envelope.EventId = replayEventID(envelope.GetEventId(), statTZ) + body, err := roommq.EncodeRoomOutboxMessage(&envelope) + if err != nil { + return err + } + if err := statapp.ConsumeRoomOutboxBody(scopeCtx, repo, body); err != nil { + return err + } + } + return rows.Err() +} + +func replayGame(ctx context.Context, scopeCtx context.Context, repo *mysqlstorage.Repository, db *sql.DB, app string, statTZ string, window replayWindow) error { + query, args := outboxWindowQuery(` + app_code, event_id, event_type, order_id, user_id, platform_code, game_id, + op_type, coin_amount, CAST(payload_json AS CHAR), created_at_ms`, "game_outbox", app, gameReplayEventTypes(), window, "ORDER BY created_at_ms ASC, event_id ASC") + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var msg gamemq.GameOutboxMessage + if err := rows.Scan(&msg.AppCode, &msg.EventID, &msg.EventType, &msg.OrderID, &msg.UserID, &msg.PlatformCode, &msg.GameID, &msg.OpType, &msg.CoinAmount, &msg.PayloadJSON, &msg.OccurredAtMS); err != nil { + return err + } + msg.EventID = replayEventID(msg.EventID, statTZ) + body, err := gamemq.EncodeGameOutboxMessage(msg) + if err != nil { + return err + } + if err := statapp.ConsumeGameOutboxBody(scopeCtx, repo, body); err != nil { + return err + } + } + return rows.Err() +} + +func outboxWindowQuery(selectExpr string, table string, app string, eventTypes []string, window replayWindow, suffix string) (string, []any) { + placeholders := strings.TrimRight(strings.Repeat("?,", len(eventTypes)), ",") + args := []any{app} + for _, eventType := range eventTypes { + args = append(args, eventType) + } + args = append(args, window.startMS, window.endMS) + return fmt.Sprintf("SELECT %s FROM %s WHERE app_code = ? AND event_type IN (%s) AND created_at_ms >= ? AND created_at_ms < ? %s", selectExpr, table, placeholders, suffix), args +} + +func gameReplayEventTypes() []string { + return []string{ + gamemq.EventTypeGameOrderSettled, + gamemq.EventTypeSelfGameMatchCreated, + gamemq.EventTypeSelfGameMatchReady, + gamemq.EventTypeSelfGameMatchSettled, + gamemq.EventTypeSelfGameMatchCanceled, + gamemq.EventTypeSelfGameMatchFailed, + gamemq.EventTypeSelfGamePoolAdjusted, + } +} + +func replayEventID(eventID string, statTZ string) string { + token := replayToken(statTZ) + suffixed := eventID + ":stat_tz:" + token + if len(suffixed) <= replayEventIDMaxLen { + return suffixed + } + sum := sha256.Sum256([]byte(eventID + "|" + statTZ)) + return "replay:" + token + ":" + hex.EncodeToString(sum[:])[:40] +} + +func replayToken(statTZ string) string { + return strings.NewReplacer("/", "_", ":", "_").Replace(strings.ToLower(strings.TrimSpace(statTZ))) +} + +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 "" +} diff --git a/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql b/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql index 4f48c0e2..d8000b4f 100644 --- a/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql +++ b/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql @@ -14,12 +14,13 @@ CREATE TABLE IF NOT EXISTS statistics_event_consumption ( created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, source, event_id), - KEY idx_statistics_event_type (app_code, source, event_type, consumed_at_ms) + KEY idx_event_consumption_type (app_code, source, event_type, consumed_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统计事件消费幂等表'; CREATE TABLE IF NOT EXISTS stat_app_day_country ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码', - stat_day DATE NOT NULL COMMENT 'UTC 统计日', + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', + stat_day DATE NOT NULL COMMENT '统计日,随 stat_tz 切换', country_id BIGINT NOT NULL DEFAULT 0 COMMENT '国家维度 ID,0 表示未知或全局', region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域维度 ID,0 表示未知或全局', new_users BIGINT NOT NULL DEFAULT 0 COMMENT '新增用户数', @@ -40,75 +41,81 @@ CREATE TABLE IF NOT EXISTS stat_app_day_country ( game_payout BIGINT NOT NULL DEFAULT 0 COMMENT '游戏返奖', game_refund BIGINT NOT NULL DEFAULT 0 COMMENT '游戏退款', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code, stat_day, country_id, region_id), - KEY idx_stat_app_day_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id), + KEY idx_stat_app_day_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='App 国家日统计聚合表'; CREATE TABLE IF NOT EXISTS stat_user_day_activity ( app_code VARCHAR(32) NOT NULL, + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, user_id BIGINT NOT NULL, first_active_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, user_id), - KEY idx_stat_user_day_country (app_code, stat_day, country_id), - KEY idx_stat_user_day_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, user_id), + KEY idx_stat_user_day_country (app_code, stat_tz, stat_day, country_id), + KEY idx_stat_user_day_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户日活跃去重表'; CREATE TABLE IF NOT EXISTS stat_user_registration ( app_code VARCHAR(32) NOT NULL, + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', user_id BIGINT NOT NULL, registered_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, registered_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, user_id), - KEY idx_stat_user_registration_day (app_code, registered_day, country_id), - KEY idx_stat_user_registration_region (app_code, registered_day, region_id) + PRIMARY KEY (app_code, stat_tz, user_id), + KEY idx_stat_user_registration_day (app_code, stat_tz, registered_day, country_id), + KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户注册日留存 cohort 表'; CREATE TABLE IF NOT EXISTS stat_recharge_day_payers ( app_code VARCHAR(32) NOT NULL, + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, user_id), - KEY idx_stat_recharge_payer_country (app_code, stat_day, country_id), - KEY idx_stat_recharge_payer_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, user_id), + KEY idx_stat_recharge_payer_country (app_code, stat_tz, stat_day, country_id), + KEY idx_stat_recharge_payer_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='充值日付费用户去重表'; CREATE TABLE IF NOT EXISTS stat_lucky_gift_day_payers ( app_code VARCHAR(32) NOT NULL, + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, user_id), - KEY idx_stat_lucky_payer_country (app_code, stat_day, country_id), - KEY idx_stat_lucky_payer_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, user_id), + KEY idx_stat_lucky_payer_country (app_code, stat_tz, stat_day, country_id), + KEY idx_stat_lucky_payer_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物日付费去重表'; CREATE TABLE IF NOT EXISTS stat_lucky_gift_pool_day_country ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码', - stat_day DATE NOT NULL COMMENT 'UTC 统计日', + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', + stat_day DATE NOT NULL COMMENT '统计日,随 stat_tz 切换', country_id BIGINT NOT NULL DEFAULT 0 COMMENT '国家维度 ID,0 表示未知或全局', region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域维度 ID,0 表示未知或全局', pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', turnover_coin BIGINT NOT NULL DEFAULT 0 COMMENT '奖池幸运礼物流水金币', payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '奖池幸运礼物返奖金币', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code, stat_day, country_id, region_id, pool_id), - KEY idx_stat_lucky_pool_overview (app_code, stat_day, pool_id), - KEY idx_stat_lucky_pool_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id, pool_id), + KEY idx_stat_lucky_pool_overview (app_code, stat_tz, stat_day, pool_id), + KEY idx_stat_lucky_pool_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物奖池国家日统计聚合表'; CREATE TABLE IF NOT EXISTS stat_real_room_robot_gift_day_rooms ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码', - stat_day DATE NOT NULL COMMENT 'UTC 统计日', + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', + stat_day DATE NOT NULL COMMENT '统计日,随 stat_tz 切换', country_id BIGINT NOT NULL DEFAULT 0 COMMENT '国家维度 ID,0 表示未知或全局', region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域维度 ID,0 表示未知或全局', room_id VARCHAR(96) NOT NULL COMMENT '真人房间 ID', @@ -116,13 +123,14 @@ CREATE TABLE IF NOT EXISTS stat_real_room_robot_gift_day_rooms ( lucky_gift_coin BIGINT NOT NULL DEFAULT 0 COMMENT '机器人在真人房送出的幸运礼物金币价值', super_lucky_gift_coin BIGINT NOT NULL DEFAULT 0 COMMENT '机器人在真人房送出的超级幸运礼物金币价值', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code, stat_day, country_id, region_id, room_id), - KEY idx_stat_real_room_robot_gift_region (app_code, stat_day, region_id), - KEY idx_stat_real_room_robot_gift_country (app_code, stat_day, country_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id, room_id), + KEY idx_stat_real_room_robot_gift_region (app_code, stat_tz, stat_day, region_id), + KEY idx_stat_real_room_robot_gift_country (app_code, stat_tz, stat_day, country_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真人房机器人礼物房间日统计表'; CREATE TABLE IF NOT EXISTS stat_game_day_country ( app_code VARCHAR(32) NOT NULL, + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, @@ -133,12 +141,13 @@ CREATE TABLE IF NOT EXISTS stat_game_day_country ( refund_coin BIGINT NOT NULL DEFAULT 0, player_count BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, region_id, platform_code, game_id), - KEY idx_stat_game_day_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id, platform_code, game_id), + KEY idx_stat_game_day_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏国家日统计聚合表'; CREATE TABLE IF NOT EXISTS stat_game_day_players ( app_code VARCHAR(32) NOT NULL, + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, @@ -146,12 +155,13 @@ CREATE TABLE IF NOT EXISTS stat_game_day_players ( game_id VARCHAR(96) NOT NULL, user_id BIGINT NOT NULL, first_played_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, platform_code, game_id, user_id), - KEY idx_stat_game_player_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, platform_code, game_id, user_id), + KEY idx_stat_game_player_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏日参与用户去重表'; CREATE TABLE IF NOT EXISTS stat_self_game_h5_event_day ( app_code VARCHAR(32) NOT NULL, + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, @@ -167,24 +177,26 @@ CREATE TABLE IF NOT EXISTS stat_self_game_h5_event_day ( error_count BIGINT NOT NULL DEFAULT 0, duration_ms_sum BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, region_id, game_id, event_name, language, client_version, h5_version, entry_source), - KEY idx_stat_self_game_h5_day (app_code, stat_day, game_id, event_name), - KEY idx_stat_self_game_h5_region (app_code, stat_day, region_id, game_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id, game_id, event_name, language, client_version, h5_version, entry_source), + KEY idx_stat_self_game_h5_event_name (app_code, stat_tz, stat_day, game_id, event_name), + KEY idx_stat_self_game_h5_users_day (app_code, stat_tz, stat_day, region_id, game_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏 H5 事件日统计表'; CREATE TABLE IF NOT EXISTS stat_self_game_h5_event_users ( app_code VARCHAR(32) NOT NULL, + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', stat_day DATE NOT NULL, game_id VARCHAR(96) NOT NULL, event_name VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, first_seen_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, game_id, event_name, user_id), - KEY idx_stat_self_game_h5_users_day (app_code, stat_day, game_id, event_name) + PRIMARY KEY (app_code, stat_tz, stat_day, game_id, event_name, user_id), + KEY idx_stat_self_game_h5_users_day (app_code, stat_tz, stat_day, game_id, event_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏 H5 事件用户去重表'; CREATE TABLE IF NOT EXISTS stat_self_game_match_day ( app_code VARCHAR(32) NOT NULL, + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, @@ -212,13 +224,14 @@ CREATE TABLE IF NOT EXISTS stat_self_game_match_day ( wait_ms_sum BIGINT NOT NULL DEFAULT 0, duration_ms_sum BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, region_id, game_id, stake_coin), - KEY idx_stat_self_game_match_day (app_code, stat_day, game_id), - KEY idx_stat_self_game_match_region (app_code, stat_day, region_id, game_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id, game_id, stake_coin), + KEY idx_stat_self_game_match_game (app_code, stat_tz, stat_day, game_id), + KEY idx_stat_self_game_match_region (app_code, stat_tz, stat_day, region_id, game_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏对局日统计表'; CREATE TABLE IF NOT EXISTS stat_self_game_pool_day ( app_code VARCHAR(32) NOT NULL, + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', stat_day DATE NOT NULL, game_id VARCHAR(96) NOT NULL, direction VARCHAR(16) NOT NULL DEFAULT '', @@ -228,12 +241,13 @@ CREATE TABLE IF NOT EXISTS stat_self_game_pool_day ( out_coin BIGINT NOT NULL DEFAULT 0, balance_after_coin BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, game_id, direction, reason), - KEY idx_stat_self_game_pool_day (app_code, stat_day, game_id) + PRIMARY KEY (app_code, stat_tz, stat_day, game_id, direction, reason), + KEY idx_stat_self_game_pool_game (app_code, stat_tz, stat_day, game_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏奖池日统计表'; CREATE TABLE IF NOT EXISTS stat_self_game_participant_day ( app_code VARCHAR(32) NOT NULL, + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, @@ -247,13 +261,14 @@ CREATE TABLE IF NOT EXISTS stat_self_game_participant_day ( payout_coin BIGINT NOT NULL DEFAULT 0, net_win_coin BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, region_id, game_id, stake_coin, participant_type, result, rps_gesture, dice_point), - KEY idx_stat_self_game_participant_day (app_code, stat_day, game_id), - KEY idx_stat_self_game_participant_region (app_code, stat_day, region_id, game_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id, game_id, stake_coin, participant_type, result, rps_gesture, dice_point), + KEY idx_stat_self_game_participant_game (app_code, stat_tz, stat_day, game_id), + KEY idx_stat_self_game_participant_region (app_code, stat_tz, stat_day, region_id, game_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏参与者结果分布日统计表'; CREATE TABLE IF NOT EXISTS stat_self_game_user_day ( app_code VARCHAR(32) NOT NULL, + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, @@ -268,7 +283,7 @@ CREATE TABLE IF NOT EXISTS stat_self_game_user_day ( payout_coin BIGINT NOT NULL DEFAULT 0, net_win_coin BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, game_id, user_id), - KEY idx_stat_self_game_user_win (app_code, stat_day, game_id, net_win_coin), - KEY idx_stat_self_game_user_region (app_code, stat_day, region_id, game_id) + PRIMARY KEY (app_code, stat_tz, stat_day, game_id, user_id), + KEY idx_stat_self_game_user_win (app_code, stat_tz, stat_day, game_id, net_win_coin), + KEY idx_stat_self_game_user_region (app_code, stat_tz, stat_day, region_id, game_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏用户风险日统计表'; diff --git a/services/statistics-service/internal/app/app.go b/services/statistics-service/internal/app/app.go index f7b33bec..7b136665 100644 --- a/services/statistics-service/internal/app/app.go +++ b/services/statistics-service/internal/app/app.go @@ -141,81 +141,24 @@ func newConsumers(cfg config.Config, repo *mysqlstorage.Repository) ([]*rocketmq return nil } if err := add(cfg.RocketMQ.UserOutbox, usermq.TagUserOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { - event, ok, err := userRegisteredEvent(message.Body) - if err != nil || !ok { - return err - } - return repo.ConsumeUserRegistered(appcode.WithContext(ctx, event.AppCode), event) + return ConsumeUserOutboxBody(ctx, repo, message.Body) }); err != nil { return nil, err } if err := add(cfg.RocketMQ.WalletOutbox, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { - event, ok, err := rechargeEvent(message.Body) - if err != nil { - return err - } - if ok { - return repo.ConsumeRecharge(appcode.WithContext(ctx, event.AppCode), event) - } - stock, ok, err := coinSellerStockEvent(message.Body) - if err != nil { - return err - } - if ok { - return repo.ConsumeCoinSellerStock(appcode.WithContext(ctx, stock.AppCode), stock) - } - reward, ok, err := luckyGiftRewardEvent(message.Body) - if err != nil || !ok { - return err - } - return repo.ConsumeLuckyGiftReward(appcode.WithContext(ctx, reward.AppCode), reward) + return ConsumeWalletOutboxBody(ctx, repo, message.Body) }); err != nil { shutdownConsumers(consumers) return nil, err } if err := add(cfg.RocketMQ.RoomOutbox, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { - if gift, ok, err := roomGiftEvent(message.Body); err != nil || ok { - if err != nil || !ok { - return err - } - if err := repo.ConsumeRoomGift(appcode.WithContext(ctx, gift.AppCode), gift); err != nil { - return err - } - return repo.ConsumeUserActive(appcode.WithContext(ctx, gift.AppCode), mysqlstorage.UserActiveEvent{ - AppCode: gift.AppCode, - EventID: gift.EventID + ":active", - EventType: "RoomGiftSentActive", - UserID: gift.SenderUserID, - CountryID: gift.CountryID, - RegionID: gift.VisibleRegionID, - OccurredAtMS: gift.OccurredAtMS, - }) - } - active, ok, err := roomJoinActiveEvent(message.Body) - if err != nil || !ok { - return err - } - return repo.ConsumeUserActive(appcode.WithContext(ctx, active.AppCode), active) + return ConsumeRoomOutboxBody(ctx, repo, message.Body) }); err != nil { shutdownConsumers(consumers) return nil, err } if err := add(cfg.RocketMQ.GameOutbox, gamemq.TagGameOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { - if event, ok, err := gameOrderEvent(message.Body); err != nil || ok { - if err != nil || !ok { - return err - } - return repo.ConsumeGameOrder(appcode.WithContext(ctx, event.AppCode), event) - } - match, ok, err := selfGameMatchEvent(message.Body) - if err != nil || !ok { - pool, poolOK, poolErr := selfGamePoolAdjustmentEvent(message.Body) - if poolErr != nil || !poolOK { - return poolErr - } - return repo.ConsumeSelfGamePoolAdjustment(appcode.WithContext(ctx, pool.AppCode), pool) - } - return repo.ConsumeSelfGameMatch(appcode.WithContext(ctx, match.AppCode), match) + return ConsumeGameOutboxBody(ctx, repo, message.Body) }); err != nil { shutdownConsumers(consumers) return nil, err @@ -223,6 +166,83 @@ func newConsumers(cfg config.Config, repo *mysqlstorage.Repository) ([]*rocketmq return consumers, nil } +// ConsumeUserOutboxBody 是 user outbox 的唯一统计投影入口;实时 MQ 和离线回灌共用它,避免同一 payload 出现两套解析口径。 +func ConsumeUserOutboxBody(ctx context.Context, repo *mysqlstorage.Repository, body []byte) error { + event, ok, err := userRegisteredEvent(body) + if err != nil || !ok { + return err + } + return repo.ConsumeUserRegistered(appcode.WithContext(ctx, event.AppCode), event) +} + +// ConsumeWalletOutboxBody 按钱包事实类型分派充值、币商补货和幸运礼物返奖;未知事件只提交消费位点,不写统计读模型。 +func ConsumeWalletOutboxBody(ctx context.Context, repo *mysqlstorage.Repository, body []byte) error { + event, ok, err := rechargeEvent(body) + if err != nil { + return err + } + if ok { + return repo.ConsumeRecharge(appcode.WithContext(ctx, event.AppCode), event) + } + stock, ok, err := coinSellerStockEvent(body) + if err != nil { + return err + } + if ok { + return repo.ConsumeCoinSellerStock(appcode.WithContext(ctx, stock.AppCode), stock) + } + reward, ok, err := luckyGiftRewardEvent(body) + if err != nil || !ok { + return err + } + return repo.ConsumeLuckyGiftReward(appcode.WithContext(ctx, reward.AppCode), reward) +} + +// ConsumeRoomOutboxBody 只投影房间统计需要的礼物和真人进房事件;礼物事实额外产生 sender 当日活跃,保持实时和回灌一致。 +func ConsumeRoomOutboxBody(ctx context.Context, repo *mysqlstorage.Repository, body []byte) error { + if gift, ok, err := roomGiftEvent(body); err != nil || ok { + if err != nil || !ok { + return err + } + if err := repo.ConsumeRoomGift(appcode.WithContext(ctx, gift.AppCode), gift); err != nil { + return err + } + return repo.ConsumeUserActive(appcode.WithContext(ctx, gift.AppCode), mysqlstorage.UserActiveEvent{ + AppCode: gift.AppCode, + EventID: gift.EventID + ":active", + EventType: "RoomGiftSentActive", + UserID: gift.SenderUserID, + CountryID: gift.CountryID, + RegionID: gift.VisibleRegionID, + OccurredAtMS: gift.OccurredAtMS, + }) + } + active, ok, err := roomJoinActiveEvent(body) + if err != nil || !ok { + return err + } + return repo.ConsumeUserActive(appcode.WithContext(ctx, active.AppCode), active) +} + +// ConsumeGameOutboxBody 同时承接厂商游戏订单和自研游戏局级事实;回灌复用它可保证利润、奖池和参与者分布口径一致。 +func ConsumeGameOutboxBody(ctx context.Context, repo *mysqlstorage.Repository, body []byte) error { + if event, ok, err := gameOrderEvent(body); err != nil || ok { + if err != nil || !ok { + return err + } + return repo.ConsumeGameOrder(appcode.WithContext(ctx, event.AppCode), event) + } + match, ok, err := selfGameMatchEvent(body) + if err != nil || !ok { + pool, poolOK, poolErr := selfGamePoolAdjustmentEvent(body) + if poolErr != nil || !poolOK { + return poolErr + } + return repo.ConsumeSelfGamePoolAdjustment(appcode.WithContext(ctx, pool.AppCode), pool) + } + return repo.ConsumeSelfGameMatch(appcode.WithContext(ctx, match.AppCode), match) +} + func userRegisteredEvent(body []byte) (mysqlstorage.UserRegisteredEvent, bool, error) { message, err := usermq.DecodeUserOutboxMessage(body) if err != nil { diff --git a/services/statistics-service/internal/app/query_http.go b/services/statistics-service/internal/app/query_http.go index 716be60e..99a39602 100644 --- a/services/statistics-service/internal/app/query_http.go +++ b/services/statistics-service/internal/app/query_http.go @@ -69,6 +69,7 @@ func (s *queryHTTPServer) overview(w http.ResponseWriter, r *http.Request) { } overview, err := s.repo.QueryOverview(r.Context(), mysqlstorage.OverviewQuery{ AppCode: query.Get("app_code"), + StatTZ: query.Get("stat_tz"), StartMS: startMS, EndMS: endMS, SeriesStartMS: parseInt64(query.Get("series_start_ms")), @@ -169,6 +170,7 @@ func (s *queryHTTPServer) selfGameOverview(w http.ResponseWriter, r *http.Reques } overview, err := s.repo.QuerySelfGameOverview(r.Context(), mysqlstorage.SelfGameOverviewQuery{ AppCode: query.Get("app_code"), + StatTZ: query.Get("stat_tz"), StartMS: startMS, EndMS: endMS, GameID: query.Get("game_id"), diff --git a/services/statistics-service/internal/storage/mysql/query.go b/services/statistics-service/internal/storage/mysql/query.go index 88da5337..eda99d18 100644 --- a/services/statistics-service/internal/storage/mysql/query.go +++ b/services/statistics-service/internal/storage/mysql/query.go @@ -13,6 +13,7 @@ import ( type OverviewQuery struct { AppCode string + StatTZ string StartMS int64 EndMS int64 SeriesStartMS int64 @@ -23,6 +24,7 @@ type OverviewQuery struct { type Overview struct { AppCode string `json:"app_code"` + StatTZ string `json:"stat_tz"` StartMS int64 `json:"start_ms"` EndMS int64 `json:"end_ms"` CountryID int64 `json:"country_id"` @@ -164,6 +166,7 @@ type CountryBreakdown struct { type SelfGameOverviewQuery struct { AppCode string + StatTZ string StartMS int64 EndMS int64 GameID string @@ -173,6 +176,7 @@ type SelfGameOverviewQuery struct { type SelfGameOverview struct { AppCode string `json:"app_code"` + StatTZ string `json:"stat_tz"` StartMS int64 `json:"start_ms"` EndMS int64 `json:"end_ms"` GameID string `json:"game_id"` @@ -320,27 +324,28 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov if app == "" { app = "lalu" } - startDay := statDay(query.StartMS) - endDay := statDay(query.EndMS - 1) + statTZ := normalizeStatTZ(query.StatTZ) + startDay := statDayIn(query.StartMS, statTZ) + endDay := statDayIn(query.EndMS-1, statTZ) previousStartDay, previousEndDay := previousStatDayRange(startDay, endDay) - overview, err := r.queryAppDayOverviewTotals(ctx, app, startDay, endDay, query.CountryID, query.RegionID) + overview, err := r.queryAppDayOverviewTotals(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } - previous, err := r.queryAppDayOverviewTotals(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID) + previous, err := r.queryAppDayOverviewTotals(ctx, app, statTZ, previousStartDay, previousEndDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } - overview.AppCode, overview.StartMS, overview.EndMS, overview.CountryID = app, query.StartMS, query.EndMS, query.CountryID - previous.AppCode, previous.CountryID = app, query.CountryID + overview.AppCode, overview.StatTZ, overview.StartMS, overview.EndMS, overview.CountryID = app, statTZ, query.StartMS, query.EndMS, query.CountryID + previous.AppCode, previous.StatTZ, previous.CountryID = app, statTZ, query.CountryID // 新增付费用户来自日充值用户去重表和注册表的交集;只读聚合小表,避免 Databi 页面回扫钱包或订单明细。 - if overview.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil { + if overview.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID); err != nil { return Overview{}, err } - if previous.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil { + if previous.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, statTZ, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil { return Overview{}, err } - if robotGifts, err := r.queryRealRoomRobotGiftOverview(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil { + if robotGifts, err := r.queryRealRoomRobotGiftOverview(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID); err != nil { return Overview{}, err } else { overview.RealRoomRobotNormalGiftCoin = robotGifts.RealRoomRobotNormalGiftCoin @@ -351,39 +356,41 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov overview.RealRoomRobotAvgRoomGiftCoin = robotGifts.RealRoomRobotAvgRoomGiftCoin } // Pool 明细和顶部汇总来自同一个统计窗口;Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。 - pools, err := r.queryLuckyGiftPools(ctx, app, startDay, endDay, query.CountryID, query.RegionID) + pools, err := r.queryLuckyGiftPools(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.LuckyGiftPools = pools - // 幸运礼物流水和返奖以 activity-service 的抽奖事实增量日汇总为准;这里仅读小表,避免 Databi 页面扫描 lucky_draw_records。 - if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil { - return Overview{}, err - } else if ok { - overview.LuckyGiftPools = activityPools - overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools) - } - // 上一周期也使用相同的日聚合口径,保证“与昨日相比/前周期相比”不额外查询明细表。 - if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil { - return Overview{}, err - } else if ok { - previous.LuckyGiftTurnover, previous.LuckyGiftPayout = sumLuckyGiftPools(activityPools) + if statTZ == StatTZUTC { + // activity-service 的 lucky_draw_pool_day_stats 仍是 UTC 日表;只有 UTC 查询能拿它覆盖旧统计行,北京日必须使用本服务 stat_tz=Asia/Shanghai 的读模型。 + if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil { + return Overview{}, err + } else if ok { + overview.LuckyGiftPools = activityPools + overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools) + } + // 上一周期也使用相同的日聚合口径,保证“与昨日相比/前周期相比”不额外查询明细表。 + if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil { + return Overview{}, err + } else if ok { + previous.LuckyGiftTurnover, previous.LuckyGiftPayout = sumLuckyGiftPools(activityPools) + } } applyOverviewDerivedMetrics(&overview) applyOverviewDerivedMetrics(&previous) overview.applyDeltaRates(previous) - retention, err := r.queryRetention(ctx, app, startDay, endDay, query.CountryID, query.RegionID) + retention, err := r.queryRetention(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.Retention = retention - ranking, err := r.queryGameRanking(ctx, app, startDay, endDay, query.CountryID, query.RegionID) + ranking, err := r.queryGameRanking(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } overview.GameRanking = ranking if query.CountryID <= 0 { - breakdown, err := r.queryCountryBreakdown(ctx, app, startDay, endDay, query.RegionID) + breakdown, err := r.queryCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID) if err != nil { return Overview{}, err } @@ -391,11 +398,11 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov } seriesStartDay, seriesEndDay := startDay, endDay if query.SeriesStartMS > 0 && query.SeriesEndMS > query.SeriesStartMS { - seriesStartDay = statDay(query.SeriesStartMS) - seriesEndDay = statDay(query.SeriesEndMS - 1) + seriesStartDay = statDayIn(query.SeriesStartMS, statTZ) + seriesEndDay = statDayIn(query.SeriesEndMS-1, statTZ) } // daily_series 服务卡片内 sparkline 和弹窗折线图;一条 GROUP BY stat_day 查询返回全部指标,避免前端逐日请求。 - dailySeries, err := r.queryDailySeries(ctx, app, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID) + dailySeries, err := r.queryDailySeries(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID) if err != nil { return Overview{}, err } @@ -409,49 +416,51 @@ func (r *Repository) QuerySelfGameOverview(ctx context.Context, query SelfGameOv if app == "" { app = "lalu" } - startDay := statDay(startMS) - endDay := statDay(endMS - 1) + statTZ := normalizeStatTZ(query.StatTZ) + startDay := statDayIn(startMS, statTZ) + endDay := statDayIn(endMS-1, statTZ) gameID := normalizeShortText(query.GameID) overview := SelfGameOverview{ AppCode: app, + StatTZ: statTZ, StartMS: startMS, EndMS: endMS, GameID: gameID, CountryID: query.CountryID, RegionID: query.RegionID, } - events, err := r.querySelfGameEvents(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID) + events, err := r.querySelfGameEvents(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID) if err != nil { return SelfGameOverview{}, err } overview.Events = events overview.Funnel, overview.Conversion = buildSelfGameFunnel(events) - totals, err := r.querySelfGameMatchTotals(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID) + totals, err := r.querySelfGameMatchTotals(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID) if err != nil { return SelfGameOverview{}, err } overview.MatchTotals = totals - stakes, err := r.querySelfGameStakeBreakdown(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID) + stakes, err := r.querySelfGameStakeBreakdown(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID) if err != nil { return SelfGameOverview{}, err } overview.StakeBreakdown = stakes - pools, err := r.querySelfGamePools(ctx, app, startDay, endDay, gameID) + pools, err := r.querySelfGamePools(ctx, app, statTZ, startDay, endDay, gameID) if err != nil { return SelfGameOverview{}, err } overview.PoolStats = pools - participants, err := r.querySelfGameParticipantDistribution(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID) + participants, err := r.querySelfGameParticipantDistribution(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID) if err != nil { return SelfGameOverview{}, err } overview.ParticipantDistribution = participants - risk, err := r.querySelfGameUserRisk(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID) + risk, err := r.querySelfGameUserRisk(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID) if err != nil { return SelfGameOverview{}, err } overview.UserRisk = risk - retention, err := r.querySelfGameRetention(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID) + retention, err := r.querySelfGameRetention(ctx, app, statTZ, startDay, endDay, gameID, query.CountryID, query.RegionID) if err != nil { return SelfGameOverview{}, err } @@ -460,8 +469,8 @@ func (r *Repository) QuerySelfGameOverview(ctx context.Context, query SelfGameOv return overview, nil } -func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (Overview, error) { - args := []any{app, startDay, endDay} +func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Overview, error) { + args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} filter := "" if countryID > 0 { filter += " AND country_id = ?" @@ -481,7 +490,7 @@ func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, startDa COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0), COALESCE(SUM(game_players),0) FROM stat_app_day_country - WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter, args...) + WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter, args...) var overview Overview if err := row.Scan(&overview.NewUsers, &overview.ActiveUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.CoinSellerTransferCoin, &overview.GiftCoinSpent, &overview.LuckyGiftTurnover, &overview.LuckyGiftPayout, &overview.LuckyGiftPayers, &overview.GameTurnover, &overview.GamePayout, &overview.GameRefund, &overview.GamePlayers); err != nil { return Overview{}, err @@ -489,8 +498,8 @@ func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, startDa return overview, nil } -func (r *Repository) queryNewPaidUsers(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (int64, error) { - args := []any{app, startDay, endDay, startDay, endDay} +func (r *Repository) queryNewPaidUsers(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (int64, error) { + args := []any{app, normalizeStatTZ(statTZ), startDay, endDay, normalizeStatTZ(statTZ), startDay, endDay} filter := "" if countryID > 0 { filter += " AND p.country_id = ?" @@ -504,9 +513,9 @@ func (r *Repository) queryNewPaidUsers(ctx context.Context, app, startDay, endDa row := r.db.QueryRowContext(ctx, ` SELECT COUNT(DISTINCT p.user_id) FROM stat_recharge_day_payers p - JOIN stat_user_registration r ON r.app_code = p.app_code AND r.user_id = p.user_id - WHERE p.app_code = ? AND p.stat_day BETWEEN ? AND ? - AND r.registered_day BETWEEN ? AND ?`+filter, args...) + JOIN stat_user_registration r ON r.app_code = p.app_code AND r.stat_tz = p.stat_tz AND r.user_id = p.user_id + WHERE p.app_code = ? AND p.stat_tz = ? AND p.stat_day BETWEEN ? AND ? + AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter, args...) var count int64 if err := row.Scan(&count); err != nil && err != sql.ErrNoRows { return 0, err @@ -514,8 +523,8 @@ func (r *Repository) queryNewPaidUsers(ctx context.Context, app, startDay, endDa return count, nil } -func (r *Repository) queryRealRoomRobotGiftOverview(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (Overview, error) { - args := []any{app, startDay, endDay} +func (r *Repository) queryRealRoomRobotGiftOverview(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Overview, error) { + args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} filter := "" if countryID > 0 { filter += " AND country_id = ?" @@ -531,7 +540,7 @@ func (r *Repository) queryRealRoomRobotGiftOverview(ctx context.Context, app, st COALESCE(SUM(super_lucky_gift_coin),0), COUNT(*) FROM stat_real_room_robot_gift_day_rooms - WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter, args...) + WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter, args...) var overview Overview if err := row.Scan(&overview.RealRoomRobotNormalGiftCoin, &overview.RealRoomRobotLuckyGiftCoin, &overview.RealRoomRobotSuperGiftCoin, &overview.RealRoomRobotGiftRoomCount); err != nil { return Overview{}, err @@ -541,8 +550,8 @@ func (r *Repository) queryRealRoomRobotGiftOverview(ctx context.Context, app, st return overview, nil } -func (r *Repository) queryDailySeries(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]DailySeriesItem, error) { - args := []any{app, startDay, endDay} +func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]DailySeriesItem, error) { + args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} filter := "" if countryID > 0 { filter += " AND country_id = ?" @@ -562,7 +571,7 @@ func (r *Repository) queryDailySeries(ctx context.Context, app, startDay, endDay COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0) FROM stat_app_day_country - WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter+` + WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+` GROUP BY stat_day ORDER BY stat_day ASC`, args...) if err != nil { @@ -584,23 +593,25 @@ func (r *Repository) queryDailySeries(ctx context.Context, app, startDay, endDay if err := rows.Err(); err != nil { return nil, err } - newPaidUsersByDay, err := r.queryDailyNewPaidUsers(ctx, app, startDay, endDay, countryID, regionID) + newPaidUsersByDay, err := r.queryDailyNewPaidUsers(ctx, app, statTZ, startDay, endDay, countryID, regionID) if err != nil { return nil, err } for index := range out { out[index].NewPaidUsers = newPaidUsersByDay[out[index].StatDay] } - if activityDaily, ok, err := r.queryLuckyGiftDailyFromActivityStats(ctx, app, startDay, endDay, countryID, regionID); err != nil { - return nil, err - } else if ok { - out = mergeActivityLuckyGiftDaily(out, activityDaily) + if normalizeStatTZ(statTZ) == StatTZUTC { + if activityDaily, ok, err := r.queryLuckyGiftDailyFromActivityStats(ctx, app, startDay, endDay, countryID, regionID); err != nil { + return nil, err + } else if ok { + out = mergeActivityLuckyGiftDaily(out, activityDaily) + } } return out, nil } -func (r *Repository) queryDailyNewPaidUsers(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (map[string]int64, error) { - args := []any{app, startDay, endDay, startDay, endDay} +func (r *Repository) queryDailyNewPaidUsers(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (map[string]int64, error) { + args := []any{app, normalizeStatTZ(statTZ), startDay, endDay, normalizeStatTZ(statTZ), startDay, endDay} filter := "" if countryID > 0 { filter += " AND p.country_id = ?" @@ -613,9 +624,9 @@ func (r *Repository) queryDailyNewPaidUsers(ctx context.Context, app, startDay, rows, err := r.db.QueryContext(ctx, ` SELECT DATE_FORMAT(p.stat_day, '%Y-%m-%d'), COUNT(DISTINCT p.user_id) FROM stat_recharge_day_payers p - JOIN stat_user_registration r ON r.app_code = p.app_code AND r.user_id = p.user_id - WHERE p.app_code = ? AND p.stat_day BETWEEN ? AND ? - AND r.registered_day BETWEEN ? AND ?`+filter+` + JOIN stat_user_registration r ON r.app_code = p.app_code AND r.stat_tz = p.stat_tz AND r.user_id = p.user_id + WHERE p.app_code = ? AND p.stat_tz = ? AND p.stat_day BETWEEN ? AND ? + AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter+` GROUP BY p.stat_day`, args...) if err != nil { return nil, err @@ -686,9 +697,9 @@ func (overview *Overview) applyDeltaRates(previous Overview) { overview.GameProfitDeltaRate = deltaRate(overview.GameProfit, previous.GameProfit) } -func (r *Repository) queryRetention(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (Retention, error) { +func (r *Repository) queryRetention(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Retention, error) { filter := "" - args := []any{app, startDay, endDay} + args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} if countryID > 0 { filter = " AND r.country_id = ?" args = append(args, countryID) @@ -699,11 +710,11 @@ func (r *Repository) queryRetention(ctx context.Context, app, startDay, endDay s } row := r.db.QueryRowContext(ctx, ` SELECT COUNT(*), - COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 1 DAY))),0), - COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 7 DAY))),0), - COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 30 DAY))),0) + COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.stat_tz=r.stat_tz AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 1 DAY))),0), + COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.stat_tz=r.stat_tz AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 7 DAY))),0), + COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.stat_tz=r.stat_tz AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 30 DAY))),0) FROM stat_user_registration r - WHERE r.app_code = ? AND r.registered_day BETWEEN ? AND ?`+filter, args...) + WHERE r.app_code = ? AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter, args...) var out Retention if err := row.Scan(&out.RegisteredUsers, &out.Day1Users, &out.Day7Users, &out.Day30Users); err != nil && err != sql.ErrNoRows { return Retention{}, err @@ -714,8 +725,8 @@ func (r *Repository) queryRetention(ctx context.Context, app, startDay, endDay s return out, nil } -func (r *Repository) queryGameRanking(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]GameRank, error) { - args := []any{app, startDay, endDay} +func (r *Repository) queryGameRanking(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]GameRank, error) { + args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} filter := "" if countryID > 0 { filter = " AND country_id = ?" @@ -729,7 +740,7 @@ func (r *Repository) queryGameRanking(ctx context.Context, app, startDay, endDay SELECT platform_code, game_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(refund_coin),0), COALESCE(SUM(player_count),0) FROM stat_game_day_country - WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter+` + WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+` GROUP BY platform_code, game_id ORDER BY COALESCE(SUM(turnover_coin),0) DESC LIMIT 50`, args...) @@ -750,8 +761,8 @@ func (r *Repository) queryGameRanking(ctx context.Context, app, startDay, endDay return out, rows.Err() } -func (r *Repository) queryCountryBreakdown(ctx context.Context, app, startDay, endDay string, regionID int64) ([]CountryBreakdown, error) { - args := []any{app, startDay, endDay} +func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) ([]CountryBreakdown, error) { + args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} filter := "" if regionID > 0 { filter = " AND region_id = ?" @@ -768,7 +779,7 @@ func (r *Repository) queryCountryBreakdown(ctx context.Context, app, startDay, e COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0), COALESCE(SUM(game_players),0) FROM stat_app_day_country - WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter+` + WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+` GROUP BY country_id ORDER BY COALESCE(SUM(recharge_usd_minor),0) DESC, COALESCE(SUM(active_users),0) DESC, country_id ASC LIMIT 500`, args...) @@ -805,8 +816,8 @@ func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) { item.LuckyGiftProfitRate = ratio(item.LuckyGiftProfit, item.LuckyGiftTurnover) } -func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, error) { - args := []any{app, startDay, endDay} +func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, error) { + args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} filter := "" if countryID > 0 { filter = " AND country_id = ?" @@ -819,7 +830,7 @@ func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, startDay, end rows, err := r.db.QueryContext(ctx, ` SELECT pool_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0) FROM stat_lucky_gift_pool_day_country - WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter+` + WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+` GROUP BY pool_id ORDER BY COALESCE(SUM(turnover_coin),0) DESC, pool_id ASC`, args...) if err != nil { @@ -955,8 +966,8 @@ func sumLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) { return turnover, payout } -func (r *Repository) querySelfGameEvents(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameEventStat, error) { - filter, args := selfGameFilter(app, startDay, endDay, gameID, countryID, regionID) +func (r *Repository) querySelfGameEvents(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameEventStat, error) { + filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID) rows, err := r.db.QueryContext(ctx, ` SELECT game_id, event_name, COALESCE(SUM(event_count),0), COALESCE(SUM(user_count),0), COALESCE(SUM(success_count),0), COALESCE(SUM(error_count),0), COALESCE(SUM(duration_ms_sum),0) @@ -980,8 +991,8 @@ func (r *Repository) querySelfGameEvents(ctx context.Context, app, startDay, end return out, rows.Err() } -func (r *Repository) querySelfGameMatchTotals(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameMatchTotals, error) { - filter, args := selfGameFilter(app, startDay, endDay, gameID, countryID, regionID) +func (r *Repository) querySelfGameMatchTotals(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameMatchTotals, error) { + filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID) row := r.db.QueryRowContext(ctx, ` SELECT COALESCE(SUM(created_matches),0), COALESCE(SUM(matched_matches),0), COALESCE(SUM(settled_matches),0), COALESCE(SUM(canceled_matches),0), COALESCE(SUM(failed_matches),0), COALESCE(SUM(human_matches),0), @@ -1000,8 +1011,8 @@ func (r *Repository) querySelfGameMatchTotals(ctx context.Context, app, startDay return totals, nil } -func (r *Repository) querySelfGameStakeBreakdown(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameStakeStat, error) { - filter, args := selfGameFilter(app, startDay, endDay, gameID, countryID, regionID) +func (r *Repository) querySelfGameStakeBreakdown(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameStakeStat, error) { + filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID) rows, err := r.db.QueryContext(ctx, ` SELECT game_id, stake_coin, COALESCE(SUM(created_matches),0), COALESCE(SUM(matched_matches),0), COALESCE(SUM(settled_matches),0), COALESCE(SUM(canceled_matches),0), COALESCE(SUM(failed_matches),0), @@ -1030,15 +1041,16 @@ func (r *Repository) querySelfGameStakeBreakdown(ctx context.Context, app, start return out, rows.Err() } -func (r *Repository) querySelfGamePools(ctx context.Context, app, startDay, endDay, gameID string) ([]SelfGamePoolStat, error) { - filter := "app_code = ? AND stat_day BETWEEN ? AND ?" - args := []any{app, startDay, endDay} +func (r *Repository) querySelfGamePools(ctx context.Context, app, statTZ, startDay, endDay, gameID string) ([]SelfGamePoolStat, error) { + statTZ = normalizeStatTZ(statTZ) + filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?" + args := []any{app, statTZ, startDay, endDay} if gameID != "" { filter += " AND game_id = ?" args = append(args, gameID) } latestArgs := append([]any{}, args...) - latestArgs = append(latestArgs, app) + latestArgs = append(latestArgs, app, statTZ) latestArgs = append(latestArgs, args...) rows, err := r.db.QueryContext(ctx, ` SELECT agg.game_id, agg.direction, agg.reason, agg.transaction_count, @@ -1050,7 +1062,7 @@ func (r *Repository) querySelfGamePools(ctx context.Context, app, startDay, endD WHERE `+filter+` GROUP BY game_id, direction, reason ) agg - LEFT JOIN stat_self_game_pool_day latest ON latest.app_code = ? AND latest.game_id = agg.game_id + LEFT JOIN stat_self_game_pool_day latest ON latest.app_code = ? AND latest.stat_tz = ? AND latest.game_id = agg.game_id AND latest.direction = agg.direction AND latest.reason = agg.reason AND latest.stat_day = ( SELECT p2.stat_day @@ -1075,8 +1087,8 @@ func (r *Repository) querySelfGamePools(ctx context.Context, app, startDay, endD return out, rows.Err() } -func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameParticipantStat, error) { - filter, args := selfGameFilter(app, startDay, endDay, gameID, countryID, regionID) +func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameParticipantStat, error) { + filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID) rows, err := r.db.QueryContext(ctx, ` SELECT game_id, stake_coin, participant_type, result, rps_gesture, dice_point, COALESCE(SUM(participant_count),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(net_win_coin),0) @@ -1121,8 +1133,8 @@ func selfGameParticipantRateKey(item SelfGameParticipantStat) string { return item.GameID + "\x00" + strconv.FormatInt(item.StakeCoin, 10) + "\x00" + item.ParticipantType + "\x00" + playKey } -func (r *Repository) querySelfGameUserRisk(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameUserRiskStat, error) { - filter, args := selfGameFilter(app, startDay, endDay, gameID, countryID, regionID) +func (r *Repository) querySelfGameUserRisk(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameUserRiskStat, error) { + filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID) rows, err := r.db.QueryContext(ctx, ` SELECT game_id, user_id, COALESCE(SUM(match_count),0), COALESCE(SUM(win_count),0), COALESCE(SUM(lose_count),0), COALESCE(SUM(draw_count),0), COALESCE(SUM(cancel_count),0), @@ -1149,9 +1161,9 @@ func (r *Repository) querySelfGameUserRisk(ctx context.Context, app, startDay, e return out, rows.Err() } -func (r *Repository) querySelfGameRetention(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameRetention, error) { - cohortFilter, cohortArgs := selfGameUserDayFilter(app, startDay, endDay, gameID, countryID, regionID) - returnFilter, returnArgs := selfGameUserDayFilter(app, addStatDays(startDay, 1), addStatDays(endDay, 7), gameID, countryID, regionID) +func (r *Repository) querySelfGameRetention(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameRetention, error) { + cohortFilter, cohortArgs := selfGameUserDayFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID) + returnFilter, returnArgs := selfGameUserDayFilter(app, statTZ, addStatDays(startDay, 1), addStatDays(endDay, 7), gameID, countryID, regionID) args := make([]any, 0, len(cohortArgs)+len(returnArgs)*2) args = append(args, cohortArgs...) args = append(args, returnArgs...) @@ -1184,9 +1196,9 @@ func (r *Repository) querySelfGameRetention(ctx context.Context, app, startDay, return retention, nil } -func selfGameUserDayFilter(app, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) { - filter := "app_code = ? AND match_count > 0" - args := []any{app} +func selfGameUserDayFilter(app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) { + filter := "app_code = ? AND stat_tz = ? AND match_count > 0" + args := []any{app, normalizeStatTZ(statTZ)} if startDay != "" && endDay != "" { filter += " AND stat_day BETWEEN ? AND ?" args = append(args, startDay, endDay) @@ -1206,9 +1218,9 @@ func selfGameUserDayFilter(app, startDay, endDay, gameID string, countryID int64 return filter, args } -func selfGameFilter(app, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) { - filter := "app_code = ? AND stat_day BETWEEN ? AND ?" - args := []any{app, startDay, endDay} +func selfGameFilter(app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) { + filter := "app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?" + args := []any{app, normalizeStatTZ(statTZ), startDay, endDay} if gameID != "" { filter += " AND game_id = ?" args = append(args, gameID) diff --git a/services/statistics-service/internal/storage/mysql/query_test.go b/services/statistics-service/internal/storage/mysql/query_test.go index d8774e0f..4157d89f 100644 --- a/services/statistics-service/internal/storage/mysql/query_test.go +++ b/services/statistics-service/internal/storage/mysql/query_test.go @@ -99,7 +99,7 @@ func TestConsumeUserRegisteredCountsOnlyNewRegistration(t *testing.T) { if err := repository.db.QueryRowContext(ctx, ` SELECT new_users, active_users FROM stat_app_day_country - WHERE app_code = 'lalu' AND stat_day = '2026-06-22' AND country_id = 15 AND region_id = 10 + WHERE app_code = 'lalu' AND stat_tz = 'UTC' AND stat_day = '2026-06-22' AND country_id = 15 AND region_id = 10 `).Scan(&newUsers, &activeUsers); err != nil { t.Fatalf("query app day country: %v", err) } @@ -115,6 +115,7 @@ func TestConsumeUserRegisteredCountsOnlyNewRegistration(t *testing.T) { SELECT COUNT(*) FROM stat_user_day_activity WHERE app_code = 'lalu' + AND stat_tz = 'UTC' AND stat_day = '2026-06-22' AND country_id = 15 AND region_id = 10 @@ -127,6 +128,109 @@ func TestConsumeUserRegisteredCountsOnlyNewRegistration(t *testing.T) { } } +func TestConsumeRechargeWritesUTCAndChinaStatDays(t *testing.T) { + ctx := context.Background() + _, file, _, _ := runtime.Caller(0) + statsSchema := mysqlschema.New(t, mysqlschema.Config{ + InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"), + DatabasePrefix: "hy_stats_stat_tz_test", + }) + repository, err := Open(ctx, statsSchema.DSN) + if err != nil { + t.Fatalf("open repository: %v", err) + } + t.Cleanup(func() { _ = repository.Close() }) + + occurredAt := time.Date(2026, 6, 23, 16, 30, 0, 0, time.UTC).UnixMilli() + if err := repository.ConsumeRecharge(ctx, RechargeEvent{ + AppCode: "lalu", + EventID: "recharge:tz-boundary:1", + EventType: "WalletRechargeRecorded", + UserID: 9001, + TargetCountryID: 63, + TargetRegionID: 210, + USDMinor: 80_000, + RechargeType: "mifapay", + OccurredAtMS: occurredAt, + }); err != nil { + t.Fatalf("consume recharge: %v", err) + } + + utcStart := time.Date(2026, 6, 23, 0, 0, 0, 0, time.UTC).UnixMilli() + utcOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: utcStart, EndMS: utcStart + int64(24*time.Hour/time.Millisecond), CountryID: 63}) + if err != nil { + t.Fatalf("query utc overview: %v", err) + } + if utcOverview.StatTZ != StatTZUTC || utcOverview.RechargeUSDMinor != 80_000 || utcOverview.MifaPayRechargeUSDMinor != 80_000 { + t.Fatalf("UTC stat day mismatch: %+v", utcOverview) + } + + beijingStart := time.Date(2026, 6, 23, 16, 0, 0, 0, time.UTC).UnixMilli() + chinaOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StatTZ: StatTZAsiaShanghai, StartMS: beijingStart, EndMS: beijingStart + int64(24*time.Hour/time.Millisecond), CountryID: 63}) + if err != nil { + t.Fatalf("query china overview: %v", err) + } + if chinaOverview.StatTZ != StatTZAsiaShanghai || chinaOverview.RechargeUSDMinor != 80_000 || len(chinaOverview.DailySeries) != 1 || chinaOverview.DailySeries[0].StatDay != "2026-06-24" { + t.Fatalf("China stat day mismatch: %+v", chinaOverview) + } + + nextUTCStart := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC).UnixMilli() + nextUTCOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: nextUTCStart, EndMS: nextUTCStart + int64(24*time.Hour/time.Millisecond), CountryID: 63}) + if err != nil { + t.Fatalf("query next utc overview: %v", err) + } + if nextUTCOverview.RechargeUSDMinor != 0 { + t.Fatalf("UTC 2026-06-24 must not include previous UTC day recharge: %+v", nextUTCOverview) + } +} + +func TestConsumeRechargeWithStatTZScopeDoesNotTouchUTC(t *testing.T) { + ctx := context.Background() + _, file, _, _ := runtime.Caller(0) + statsSchema := mysqlschema.New(t, mysqlschema.Config{ + InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"), + DatabasePrefix: "hy_stats_stat_tz_scope_test", + }) + repository, err := Open(ctx, statsSchema.DSN) + if err != nil { + t.Fatalf("open repository: %v", err) + } + t.Cleanup(func() { _ = repository.Close() }) + + occurredAt := time.Date(2026, 6, 23, 16, 30, 0, 0, time.UTC).UnixMilli() + if err := repository.ConsumeRecharge(WithStatTZScope(ctx, StatTZAsiaShanghai), RechargeEvent{ + AppCode: "lalu", + EventID: "recharge:tz-scope:1", + EventType: "WalletRechargeRecorded", + UserID: 9002, + TargetCountryID: 63, + TargetRegionID: 210, + USDMinor: 90_000, + RechargeType: "mifapay", + OccurredAtMS: occurredAt, + }); err != nil { + t.Fatalf("consume scoped recharge: %v", err) + } + + utcStart := time.Date(2026, 6, 23, 0, 0, 0, 0, time.UTC).UnixMilli() + utcOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: utcStart, EndMS: utcStart + int64(24*time.Hour/time.Millisecond), CountryID: 63}) + if err != nil { + t.Fatalf("query utc overview: %v", err) + } + if utcOverview.RechargeUSDMinor != 0 { + t.Fatalf("scoped replay must not mutate UTC rows: %+v", utcOverview) + } + + beijingStart := time.Date(2026, 6, 23, 16, 0, 0, 0, time.UTC).UnixMilli() + chinaOverview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StatTZ: StatTZAsiaShanghai, StartMS: beijingStart, EndMS: beijingStart + int64(24*time.Hour/time.Millisecond), CountryID: 63}) + if err != nil { + t.Fatalf("query china overview: %v", err) + } + if chinaOverview.RechargeUSDMinor != 90_000 || chinaOverview.StatTZ != StatTZAsiaShanghai { + t.Fatalf("scoped replay must write China stat_tz only: %+v", chinaOverview) + } +} + func TestQueryOverviewReturnsPreviousPeriodDeltaRates(t *testing.T) { ctx := context.Background() _, file, _, _ := runtime.Caller(0) diff --git a/services/statistics-service/internal/storage/mysql/repository.go b/services/statistics-service/internal/storage/mysql/repository.go index 91fac3ef..20ce10dc 100644 --- a/services/statistics-service/internal/storage/mysql/repository.go +++ b/services/statistics-service/internal/storage/mysql/repository.go @@ -101,10 +101,10 @@ func (r *Repository) Migrate(ctx context.Context) error { event_type VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, consumed_at_ms BIGINT NOT NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, source, event_id), - KEY idx_statistics_event_type (app_code, source, event_type, consumed_at_ms) + KEY idx_event_consumption_type (app_code, source, event_type, consumed_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_app_day_country ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, new_users BIGINT NOT NULL DEFAULT 0, active_users BIGINT NOT NULL DEFAULT 0, paid_users BIGINT NOT NULL DEFAULT 0, recharge_usd_minor BIGINT NOT NULL DEFAULT 0, new_user_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, @@ -114,95 +114,95 @@ func (r *Repository) Migrate(ctx context.Context) error { lucky_gift_turnover BIGINT NOT NULL DEFAULT 0, lucky_gift_payout BIGINT NOT NULL DEFAULT 0, lucky_gift_payers BIGINT NOT NULL DEFAULT 0, game_turnover BIGINT NOT NULL DEFAULT 0, game_players BIGINT NOT NULL DEFAULT 0, game_payout BIGINT NOT NULL DEFAULT 0, game_refund BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, region_id), - KEY idx_stat_app_day_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id), + KEY idx_stat_app_day_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_user_day_activity ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, user_id BIGINT NOT NULL, first_active_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, user_id), - KEY idx_stat_user_day_country (app_code, stat_day, country_id), - KEY idx_stat_user_day_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, user_id), + KEY idx_stat_user_day_country (app_code, stat_tz, stat_day, country_id), + KEY idx_stat_user_day_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_user_registration ( - app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, registered_day DATE NOT NULL, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', user_id BIGINT NOT NULL, registered_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, registered_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, user_id), KEY idx_stat_user_registration_day (app_code, registered_day, country_id), - KEY idx_stat_user_registration_region (app_code, registered_day, region_id) + PRIMARY KEY (app_code, stat_tz, user_id), KEY idx_stat_user_registration_day (app_code, stat_tz, registered_day, country_id), + KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_recharge_day_payers ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, user_id), KEY idx_stat_recharge_payer_country (app_code, stat_day, country_id), - KEY idx_stat_recharge_payer_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, user_id), KEY idx_stat_recharge_payer_country (app_code, stat_tz, stat_day, country_id), + KEY idx_stat_recharge_payer_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_lucky_gift_day_payers ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, user_id), - KEY idx_stat_lucky_payer_country (app_code, stat_day, country_id), - KEY idx_stat_lucky_payer_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, user_id), + KEY idx_stat_lucky_payer_country (app_code, stat_tz, stat_day, country_id), + KEY idx_stat_lucky_payer_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_lucky_gift_pool_day_country ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, pool_id VARCHAR(96) NOT NULL, turnover_coin BIGINT NOT NULL DEFAULT 0, payout_coin BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, region_id, pool_id), - KEY idx_stat_lucky_pool_overview (app_code, stat_day, pool_id), - KEY idx_stat_lucky_pool_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id, pool_id), + KEY idx_stat_lucky_pool_overview (app_code, stat_tz, stat_day, pool_id), + KEY idx_stat_lucky_pool_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_real_room_robot_gift_day_rooms ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, room_id VARCHAR(96) NOT NULL, normal_gift_coin BIGINT NOT NULL DEFAULT 0, lucky_gift_coin BIGINT NOT NULL DEFAULT 0, super_lucky_gift_coin BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, region_id, room_id), - KEY idx_stat_real_room_robot_gift_region (app_code, stat_day, region_id), - KEY idx_stat_real_room_robot_gift_country (app_code, stat_day, country_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id, room_id), + KEY idx_stat_real_room_robot_gift_region (app_code, stat_tz, stat_day, region_id), + KEY idx_stat_real_room_robot_gift_country (app_code, stat_tz, stat_day, country_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_game_day_country ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, platform_code VARCHAR(64) NOT NULL DEFAULT '', game_id VARCHAR(96) NOT NULL, turnover_coin BIGINT NOT NULL DEFAULT 0, payout_coin BIGINT NOT NULL DEFAULT 0, refund_coin BIGINT NOT NULL DEFAULT 0, player_count BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, region_id, platform_code, game_id), - KEY idx_stat_game_day_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id, platform_code, game_id), + KEY idx_stat_game_day_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_game_day_players ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, platform_code VARCHAR(64) NOT NULL DEFAULT '', game_id VARCHAR(96) NOT NULL, user_id BIGINT NOT NULL, first_played_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, platform_code, game_id, user_id), - KEY idx_stat_game_player_region (app_code, stat_day, region_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, platform_code, game_id, user_id), + KEY idx_stat_game_player_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_self_game_h5_event_day ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, game_id VARCHAR(96) NOT NULL, event_name VARCHAR(64) NOT NULL, language VARCHAR(16) NOT NULL DEFAULT '', client_version VARCHAR(64) NOT NULL DEFAULT '', h5_version VARCHAR(64) NOT NULL DEFAULT '', entry_source VARCHAR(64) NOT NULL DEFAULT '', event_count BIGINT NOT NULL DEFAULT 0, user_count BIGINT NOT NULL DEFAULT 0, success_count BIGINT NOT NULL DEFAULT 0, error_count BIGINT NOT NULL DEFAULT 0, duration_ms_sum BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, region_id, game_id, event_name, language, client_version, h5_version, entry_source), - KEY idx_stat_self_game_h5_day (app_code, stat_day, game_id, event_name), - KEY idx_stat_self_game_h5_region (app_code, stat_day, region_id, game_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id, game_id, event_name, language, client_version, h5_version, entry_source), + KEY idx_stat_self_game_h5_event_name (app_code, stat_tz, stat_day, game_id, event_name), + KEY idx_stat_self_game_h5_day (app_code, stat_tz, stat_day, region_id, game_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_self_game_h5_event_users ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, game_id VARCHAR(96) NOT NULL, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, game_id VARCHAR(96) NOT NULL, event_name VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, first_seen_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, game_id, event_name, user_id), - KEY idx_stat_self_game_h5_users_day (app_code, stat_day, game_id, event_name) + PRIMARY KEY (app_code, stat_tz, stat_day, game_id, event_name, user_id), + KEY idx_stat_self_game_h5_users_day (app_code, stat_tz, stat_day, game_id, event_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_self_game_match_day ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, game_id VARCHAR(96) NOT NULL, stake_coin BIGINT NOT NULL DEFAULT 0, created_matches BIGINT NOT NULL DEFAULT 0, matched_matches BIGINT NOT NULL DEFAULT 0, settled_matches BIGINT NOT NULL DEFAULT 0, canceled_matches BIGINT NOT NULL DEFAULT 0, @@ -215,41 +215,41 @@ func (r *Repository) Migrate(ctx context.Context) error { platform_profit_coin BIGINT NOT NULL DEFAULT 0, pool_delta_coin BIGINT NOT NULL DEFAULT 0, forced_player_lose_matches BIGINT NOT NULL DEFAULT 0, wait_ms_sum BIGINT NOT NULL DEFAULT 0, duration_ms_sum BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, region_id, game_id, stake_coin), - KEY idx_stat_self_game_match_day (app_code, stat_day, game_id), - KEY idx_stat_self_game_match_region (app_code, stat_day, region_id, game_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id, game_id, stake_coin), + KEY idx_stat_self_game_match_game (app_code, stat_tz, stat_day, game_id), + KEY idx_stat_self_game_match_region (app_code, stat_tz, stat_day, region_id, game_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_self_game_pool_day ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, game_id VARCHAR(96) NOT NULL, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, game_id VARCHAR(96) NOT NULL, direction VARCHAR(16) NOT NULL DEFAULT '', reason VARCHAR(64) NOT NULL DEFAULT '', transaction_count BIGINT NOT NULL DEFAULT 0, in_coin BIGINT NOT NULL DEFAULT 0, out_coin BIGINT NOT NULL DEFAULT 0, balance_after_coin BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, game_id, direction, reason), - KEY idx_stat_self_game_pool_day (app_code, stat_day, game_id) + PRIMARY KEY (app_code, stat_tz, stat_day, game_id, direction, reason), + KEY idx_stat_self_game_pool_game (app_code, stat_tz, stat_day, game_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_self_game_participant_day ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, game_id VARCHAR(96) NOT NULL, stake_coin BIGINT NOT NULL DEFAULT 0, participant_type VARCHAR(32) NOT NULL DEFAULT 'user', result VARCHAR(32) NOT NULL DEFAULT '', rps_gesture VARCHAR(32) NOT NULL DEFAULT '', dice_point INT NOT NULL DEFAULT 0, participant_count BIGINT NOT NULL DEFAULT 0, payout_coin BIGINT NOT NULL DEFAULT 0, net_win_coin BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, country_id, region_id, game_id, stake_coin, participant_type, result, rps_gesture, dice_point), - KEY idx_stat_self_game_participant_day (app_code, stat_day, game_id), - KEY idx_stat_self_game_participant_region (app_code, stat_day, region_id, game_id) + PRIMARY KEY (app_code, stat_tz, stat_day, country_id, region_id, game_id, stake_coin, participant_type, result, rps_gesture, dice_point), + KEY idx_stat_self_game_participant_game (app_code, stat_tz, stat_day, game_id), + KEY idx_stat_self_game_participant_region (app_code, stat_tz, stat_day, region_id, game_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_self_game_user_day ( - app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, game_id VARCHAR(96) NOT NULL, user_id BIGINT NOT NULL, match_count BIGINT NOT NULL DEFAULT 0, win_count BIGINT NOT NULL DEFAULT 0, lose_count BIGINT NOT NULL DEFAULT 0, draw_count BIGINT NOT NULL DEFAULT 0, cancel_count BIGINT NOT NULL DEFAULT 0, stake_coin BIGINT NOT NULL DEFAULT 0, payout_coin BIGINT NOT NULL DEFAULT 0, net_win_coin BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_day, game_id, user_id), - KEY idx_stat_self_game_user_win (app_code, stat_day, game_id, net_win_coin), - KEY idx_stat_self_game_user_region (app_code, stat_day, region_id, game_id) + PRIMARY KEY (app_code, stat_tz, stat_day, game_id, user_id), + KEY idx_stat_self_game_user_win (app_code, stat_tz, stat_day, game_id, net_win_coin), + KEY idx_stat_self_game_user_region (app_code, stat_tz, stat_day, region_id, game_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, } for _, statement := range statements { @@ -257,6 +257,28 @@ func (r *Repository) Migrate(ctx context.Context) error { return err } } + for _, table := range []string{ + "stat_app_day_country", + "stat_user_day_activity", + "stat_user_registration", + "stat_recharge_day_payers", + "stat_lucky_gift_day_payers", + "stat_lucky_gift_pool_day_country", + "stat_real_room_robot_gift_day_rooms", + "stat_game_day_country", + "stat_game_day_players", + "stat_self_game_h5_event_day", + "stat_self_game_h5_event_users", + "stat_self_game_match_day", + "stat_self_game_pool_day", + "stat_self_game_participant_day", + "stat_self_game_user_day", + } { + // stat_tz 默认补成 UTC,旧表升级后历史行仍保持原 UTC 口径;新增的 Asia/Shanghai 行由实时消费和回灌任务单独写入。 + if _, err := r.db.ExecContext(ctx, "ALTER TABLE "+table+" ADD COLUMN stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' AFTER app_code"); err != nil && !isDuplicateSchemaError(err) { + return err + } + } alterStatements := []string{ `ALTER TABLE stat_app_day_country ADD COLUMN paid_users BIGINT NOT NULL DEFAULT 0 AFTER active_users`, `ALTER TABLE stat_app_day_country ADD COLUMN game_refund BIGINT NOT NULL DEFAULT 0 AFTER game_payout`, @@ -271,14 +293,14 @@ func (r *Repository) Migrate(ctx context.Context) error { `ALTER TABLE stat_lucky_gift_pool_day_country ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`, `ALTER TABLE stat_game_day_country ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`, `ALTER TABLE stat_game_day_players ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`, - `ALTER TABLE stat_app_day_country ADD KEY idx_stat_app_day_region (app_code, stat_day, region_id)`, - `ALTER TABLE stat_user_day_activity ADD KEY idx_stat_user_day_region (app_code, stat_day, region_id)`, - `ALTER TABLE stat_user_registration ADD KEY idx_stat_user_registration_region (app_code, registered_day, region_id)`, - `ALTER TABLE stat_recharge_day_payers ADD KEY idx_stat_recharge_payer_region (app_code, stat_day, region_id)`, - `ALTER TABLE stat_lucky_gift_day_payers ADD KEY idx_stat_lucky_payer_region (app_code, stat_day, region_id)`, - `ALTER TABLE stat_lucky_gift_pool_day_country ADD KEY idx_stat_lucky_pool_region (app_code, stat_day, region_id)`, - `ALTER TABLE stat_game_day_country ADD KEY idx_stat_game_day_region (app_code, stat_day, region_id)`, - `ALTER TABLE stat_game_day_players ADD KEY idx_stat_game_player_region (app_code, stat_day, region_id)`, + `ALTER TABLE stat_app_day_country ADD KEY idx_stat_app_day_region (app_code, stat_tz, stat_day, region_id)`, + `ALTER TABLE stat_user_day_activity ADD KEY idx_stat_user_day_region (app_code, stat_tz, stat_day, region_id)`, + `ALTER TABLE stat_user_registration ADD KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id)`, + `ALTER TABLE stat_recharge_day_payers ADD KEY idx_stat_recharge_payer_region (app_code, stat_tz, stat_day, region_id)`, + `ALTER TABLE stat_lucky_gift_day_payers ADD KEY idx_stat_lucky_payer_region (app_code, stat_tz, stat_day, region_id)`, + `ALTER TABLE stat_lucky_gift_pool_day_country ADD KEY idx_stat_lucky_pool_region (app_code, stat_tz, stat_day, region_id)`, + `ALTER TABLE stat_game_day_country ADD KEY idx_stat_game_day_region (app_code, stat_tz, stat_day, region_id)`, + `ALTER TABLE stat_game_day_players ADD KEY idx_stat_game_player_region (app_code, stat_tz, stat_day, region_id)`, } for _, statement := range alterStatements { if _, err := r.db.ExecContext(ctx, statement); err != nil && !isDuplicateSchemaError(err) { @@ -286,14 +308,61 @@ func (r *Repository) Migrate(ctx context.Context) error { } } for table, columns := range map[string][]string{ - "stat_app_day_country": {"app_code", "stat_day", "country_id", "region_id"}, - "stat_lucky_gift_pool_day_country": {"app_code", "stat_day", "country_id", "region_id", "pool_id"}, - "stat_game_day_country": {"app_code", "stat_day", "country_id", "region_id", "platform_code", "game_id"}, + "stat_app_day_country": {"app_code", "stat_tz", "stat_day", "country_id", "region_id"}, + "stat_user_day_activity": {"app_code", "stat_tz", "stat_day", "user_id"}, + "stat_user_registration": {"app_code", "stat_tz", "user_id"}, + "stat_recharge_day_payers": {"app_code", "stat_tz", "stat_day", "user_id"}, + "stat_lucky_gift_day_payers": {"app_code", "stat_tz", "stat_day", "user_id"}, + "stat_lucky_gift_pool_day_country": {"app_code", "stat_tz", "stat_day", "country_id", "region_id", "pool_id"}, + "stat_real_room_robot_gift_day_rooms": {"app_code", "stat_tz", "stat_day", "country_id", "region_id", "room_id"}, + "stat_game_day_country": {"app_code", "stat_tz", "stat_day", "country_id", "region_id", "platform_code", "game_id"}, + "stat_game_day_players": {"app_code", "stat_tz", "stat_day", "country_id", "platform_code", "game_id", "user_id"}, + "stat_self_game_h5_event_day": {"app_code", "stat_tz", "stat_day", "country_id", "region_id", "game_id", "event_name", "language", "client_version", "h5_version", "entry_source"}, + "stat_self_game_h5_event_users": {"app_code", "stat_tz", "stat_day", "game_id", "event_name", "user_id"}, + "stat_self_game_match_day": {"app_code", "stat_tz", "stat_day", "country_id", "region_id", "game_id", "stake_coin"}, + "stat_self_game_pool_day": {"app_code", "stat_tz", "stat_day", "game_id", "direction", "reason"}, + "stat_self_game_participant_day": {"app_code", "stat_tz", "stat_day", "country_id", "region_id", "game_id", "stake_coin", "participant_type", "result", "rps_gesture", "dice_point"}, + "stat_self_game_user_day": {"app_code", "stat_tz", "stat_day", "game_id", "user_id"}, } { if err := r.ensurePrimaryKeyColumns(ctx, table, columns); err != nil { return err } } + for _, index := range []struct { + table string + name string + columns []string + }{ + {"stat_app_day_country", "idx_stat_app_day_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}}, + {"stat_user_day_activity", "idx_stat_user_day_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}}, + {"stat_user_day_activity", "idx_stat_user_day_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}}, + {"stat_user_registration", "idx_stat_user_registration_day", []string{"app_code", "stat_tz", "registered_day", "country_id"}}, + {"stat_user_registration", "idx_stat_user_registration_region", []string{"app_code", "stat_tz", "registered_day", "region_id"}}, + {"stat_recharge_day_payers", "idx_stat_recharge_payer_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}}, + {"stat_recharge_day_payers", "idx_stat_recharge_payer_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}}, + {"stat_lucky_gift_day_payers", "idx_stat_lucky_payer_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}}, + {"stat_lucky_gift_day_payers", "idx_stat_lucky_payer_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}}, + {"stat_lucky_gift_pool_day_country", "idx_stat_lucky_pool_overview", []string{"app_code", "stat_tz", "stat_day", "pool_id"}}, + {"stat_lucky_gift_pool_day_country", "idx_stat_lucky_pool_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}}, + {"stat_real_room_robot_gift_day_rooms", "idx_stat_real_room_robot_gift_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}}, + {"stat_real_room_robot_gift_day_rooms", "idx_stat_real_room_robot_gift_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}}, + {"stat_game_day_country", "idx_stat_game_day_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}}, + {"stat_game_day_players", "idx_stat_game_player_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}}, + {"stat_self_game_h5_event_day", "idx_stat_self_game_h5_event_name", []string{"app_code", "stat_tz", "stat_day", "game_id", "event_name"}}, + {"stat_self_game_h5_event_day", "idx_stat_self_game_h5_day", []string{"app_code", "stat_tz", "stat_day", "region_id", "game_id"}}, + {"stat_self_game_h5_event_users", "idx_stat_self_game_h5_users_day", []string{"app_code", "stat_tz", "stat_day", "game_id", "event_name"}}, + {"stat_self_game_match_day", "idx_stat_self_game_match_game", []string{"app_code", "stat_tz", "stat_day", "game_id"}}, + {"stat_self_game_match_day", "idx_stat_self_game_match_region", []string{"app_code", "stat_tz", "stat_day", "region_id", "game_id"}}, + {"stat_self_game_pool_day", "idx_stat_self_game_pool_game", []string{"app_code", "stat_tz", "stat_day", "game_id"}}, + {"stat_self_game_participant_day", "idx_stat_self_game_participant_game", []string{"app_code", "stat_tz", "stat_day", "game_id"}}, + {"stat_self_game_participant_day", "idx_stat_self_game_participant_region", []string{"app_code", "stat_tz", "stat_day", "region_id", "game_id"}}, + {"stat_self_game_user_day", "idx_stat_self_game_user_win", []string{"app_code", "stat_tz", "stat_day", "game_id", "net_win_coin"}}, + {"stat_self_game_user_day", "idx_stat_self_game_user_region", []string{"app_code", "stat_tz", "stat_day", "region_id", "game_id"}}, + } { + if err := r.ensureIndexColumns(ctx, index.table, index.name, index.columns); err != nil { + return err + } + } return nil } @@ -327,6 +396,40 @@ func (r *Repository) ensurePrimaryKeyColumns(ctx context.Context, table string, return err } +func (r *Repository) ensureIndexColumns(ctx context.Context, table string, index string, expected []string) error { + rows, err := r.db.QueryContext(ctx, ` + SELECT COLUMN_NAME + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? + ORDER BY SEQ_IN_INDEX`, table, index) + if err != nil { + return err + } + defer rows.Close() + actual := []string{} + for rows.Next() { + var column string + if err := rows.Scan(&column); err != nil { + return err + } + actual = append(actual, column) + } + if err := rows.Err(); err != nil { + return err + } + if strings.Join(actual, ",") == strings.Join(expected, ",") { + return nil + } + // 老库里多数二级索引已经同名存在,但列序缺少 stat_tz;这里按信息_schema 的实际结构修复,保证北京日查询不会退化成跨时区扫描。 + if len(actual) > 0 { + if _, err := r.db.ExecContext(ctx, "ALTER TABLE "+table+" DROP INDEX "+index); err != nil && !isMissingIndexError(err) { + return err + } + } + _, err = r.db.ExecContext(ctx, "ALTER TABLE "+table+" ADD KEY "+index+" ("+strings.Join(expected, ", ")+")") + return err +} + func sameStringSlice(left, right []string) bool { if len(left) != len(right) { return false @@ -500,50 +603,43 @@ type SelfGamePoolAdjustmentEvent struct { func (r *Repository) ConsumeUserRegistered(ctx context.Context, event UserRegisteredEvent) error { return r.withEvent(ctx, SourceUser, event.EventID, "UserRegistered", func(tx *sql.Tx, nowMS int64) error { - day := statDay(event.OccurredAtMS) countryID, regionID := normalizeDimension(event.CountryID, event.RegionID) - if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil { - return err + for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) { + if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil { + return err + } + inserted, err := insertUnique(ctx, tx, ` + INSERT IGNORE INTO stat_user_registration (app_code, stat_tz, user_id, registered_day, country_id, region_id, registered_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, appcode.Normalize(event.AppCode), scope.tz, event.UserID, scope.day, countryID, regionID, event.OccurredAtMS) + if err != nil { + return err + } + // MQ 重投和补偿任务可能带来新的 event_id,但同一用户在同一统计时区的注册 cohort 只能入库一次; + // new_users 必须按 INSERT IGNORE 的真实插入行数累加,避免回放把国家新增人数重复放大。 + if _, err = tx.ExecContext(ctx, ` + UPDATE stat_app_day_country + SET new_users = new_users + ?, updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? + `, inserted, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { + return err + } + if inserted == 0 { + continue + } + // 产品口径要求“完成注册即产生当日活跃”:注册完成本身已经证明用户打开并完成 App 主流程; + // 这里复用活跃唯一表按 stat_tz+user+day 收敛,UTC 和中国日互不覆盖。 + if err := applyActive(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, event.UserID, event.OccurredAtMS, nowMS); err != nil { + return err + } } - inserted, err := insertUnique(ctx, tx, ` - INSERT IGNORE INTO stat_user_registration (app_code, user_id, registered_day, country_id, region_id, registered_at_ms) - VALUES (?, ?, ?, ?, ?, ?) - `, appcode.Normalize(event.AppCode), event.UserID, day, countryID, regionID, event.OccurredAtMS) - if err != nil { - return err - } - // MQ 重投和补偿任务可能带来新的 event_id,但同一用户注册 cohort 只能入库一次; - // new_users 必须按 INSERT IGNORE 的真实插入行数累加,避免回放把国家新增人数重复放大。 - if _, err = tx.ExecContext(ctx, ` - UPDATE stat_app_day_country - SET new_users = new_users + ?, updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? - `, inserted, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID); err != nil { - return err - } - if inserted == 0 { - return nil - } - // 产品口径要求“完成注册即产生当日活跃”:注册完成本身已经证明用户打开并完成 App 主流程; - // 这里复用活跃唯一表收敛,若同日已有进房或游戏事件,不会重复增加 active_users。 - return applyActive(ctx, tx, event.AppCode, day, countryID, regionID, event.UserID, event.OccurredAtMS, nowMS) + return nil }) } func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) error { return r.withEvent(ctx, SourceWallet, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error { - day := statDay(event.OccurredAtMS) countryID, regionID := normalizeDimension(event.TargetCountryID, event.TargetRegionID) - if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil { - return err - } - paidUsers, err := insertUnique(ctx, tx, ` - INSERT IGNORE INTO stat_recharge_day_payers (app_code, stat_day, country_id, region_id, user_id, first_paid_at_ms) - VALUES (?, ?, ?, ?, ?, ?) - `, appcode.Normalize(event.AppCode), day, countryID, regionID, event.UserID, event.OccurredAtMS) - if err != nil { - return err - } newUserUSD := int64(0) if event.RechargeSequence == 1 { newUserUSD = event.USDMinor @@ -562,95 +658,117 @@ func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) e default: coinSellerUSD = event.USDMinor } - _, err = tx.ExecContext(ctx, ` - UPDATE stat_app_day_country - SET recharge_usd_minor = recharge_usd_minor + ?, - new_user_recharge_usd_minor = new_user_recharge_usd_minor + ?, - coin_seller_recharge_usd_minor = coin_seller_recharge_usd_minor + ?, - mifapay_recharge_usd_minor = mifapay_recharge_usd_minor + ?, - google_recharge_usd_minor = google_recharge_usd_minor + ?, - coin_seller_transfer_coin = coin_seller_transfer_coin + ?, - paid_users = paid_users + ?, - updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? - `, event.USDMinor, newUserUSD, coinSellerUSD, mifapayUSD, googleUSD, coinSellerTransferCoin, paidUsers, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID) - return err + for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) { + if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil { + return err + } + paidUsers, err := insertUnique(ctx, tx, ` + INSERT IGNORE INTO stat_recharge_day_payers (app_code, stat_tz, stat_day, country_id, region_id, user_id, first_paid_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID, event.UserID, event.OccurredAtMS) + if err != nil { + return err + } + if _, err = tx.ExecContext(ctx, ` + UPDATE stat_app_day_country + SET recharge_usd_minor = recharge_usd_minor + ?, + new_user_recharge_usd_minor = new_user_recharge_usd_minor + ?, + coin_seller_recharge_usd_minor = coin_seller_recharge_usd_minor + ?, + mifapay_recharge_usd_minor = mifapay_recharge_usd_minor + ?, + google_recharge_usd_minor = google_recharge_usd_minor + ?, + coin_seller_transfer_coin = coin_seller_transfer_coin + ?, + paid_users = paid_users + ?, + updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? + `, event.USDMinor, newUserUSD, coinSellerUSD, mifapayUSD, googleUSD, coinSellerTransferCoin, paidUsers, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { + return err + } + } + return nil }) } func (r *Repository) ConsumeCoinSellerStock(ctx context.Context, event CoinSellerStockEvent) error { return r.withEvent(ctx, SourceWallet, event.EventID, "WalletCoinSellerStockPurchased", func(tx *sql.Tx, nowMS int64) error { - day := statDay(event.OccurredAtMS) countryID, regionID := normalizeDimension(event.CountryID, event.RegionID) - if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil { - return err + for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) { + if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil { + return err + } + // 币商进货是后台 USDT 入金事实,只进入总充值和币商充值列;它不是普通用户到账,所以不增加用户 USDT 首充和付费用户。 + if _, err := tx.ExecContext(ctx, ` + UPDATE stat_app_day_country + SET recharge_usd_minor = recharge_usd_minor + ?, + coin_seller_recharge_usd_minor = coin_seller_recharge_usd_minor + ?, + updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? + `, event.USDMinor, event.USDMinor, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { + return err + } } - // 币商进货是后台 USDT 入金事实,只进入总充值和币商充值列;它不是普通用户到账,所以不增加用户 USDT 首充和付费用户。 - _, err := tx.ExecContext(ctx, ` - UPDATE stat_app_day_country - SET recharge_usd_minor = recharge_usd_minor + ?, - coin_seller_recharge_usd_minor = coin_seller_recharge_usd_minor + ?, - updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? - `, event.USDMinor, event.USDMinor, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID) - return err + return nil }) } func (r *Repository) ConsumeRoomGift(ctx context.Context, event RoomGiftEvent) error { return r.withEvent(ctx, SourceRoom, event.EventID, "RoomGiftSent", func(tx *sql.Tx, nowMS int64) error { - day := statDay(event.OccurredAtMS) countryID, regionID := normalizeDimension(event.CountryID, event.VisibleRegionID) - if event.RealRoomRobot { - normalCoin, luckyCoin, superLuckyCoin := realRoomRobotGiftCoins(event) - // 真人房机器人礼物单独进 Databi 投放统计,不混入普通用户付费/幸运礼物流水,避免运营指标被机器行为放大。 - _, err := tx.ExecContext(ctx, ` - INSERT INTO stat_real_room_robot_gift_day_rooms ( - app_code, stat_day, country_id, region_id, room_id, - normal_gift_coin, lucky_gift_coin, super_lucky_gift_coin, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - normal_gift_coin = normal_gift_coin + VALUES(normal_gift_coin), - lucky_gift_coin = lucky_gift_coin + VALUES(lucky_gift_coin), - super_lucky_gift_coin = super_lucky_gift_coin + VALUES(super_lucky_gift_coin), - updated_at_ms = VALUES(updated_at_ms) - `, appcode.Normalize(event.AppCode), day, countryID, regionID, strings.TrimSpace(event.RoomID), normalCoin, luckyCoin, superLuckyCoin, nowMS) - return err - } - if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil { - return err - } - luckyTurnover, luckyPayers := int64(0), int64(0) - poolID := strings.TrimSpace(event.PoolID) - if poolID != "" { - luckyTurnover = event.CoinSpent - affected, err := insertUnique(ctx, tx, ` - INSERT IGNORE INTO stat_lucky_gift_day_payers (app_code, stat_day, country_id, region_id, user_id, first_paid_at_ms) - VALUES (?, ?, ?, ?, ?, ?) - `, appcode.Normalize(event.AppCode), day, countryID, regionID, event.SenderUserID, event.OccurredAtMS) - if err != nil { + for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) { + if event.RealRoomRobot { + normalCoin, luckyCoin, superLuckyCoin := realRoomRobotGiftCoins(event) + // 真人房机器人礼物单独进 Databi 投放统计,不混入普通用户付费/幸运礼物流水;双时区行各自承接同一事实,查询时由 stat_tz 选择展示口径。 + if _, err := tx.ExecContext(ctx, ` + INSERT INTO stat_real_room_robot_gift_day_rooms ( + app_code, stat_tz, stat_day, country_id, region_id, room_id, + normal_gift_coin, lucky_gift_coin, super_lucky_gift_coin, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + normal_gift_coin = normal_gift_coin + VALUES(normal_gift_coin), + lucky_gift_coin = lucky_gift_coin + VALUES(lucky_gift_coin), + super_lucky_gift_coin = super_lucky_gift_coin + VALUES(super_lucky_gift_coin), + updated_at_ms = VALUES(updated_at_ms) + `, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID, strings.TrimSpace(event.RoomID), normalCoin, luckyCoin, superLuckyCoin, nowMS); err != nil { + return err + } + continue + } + if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil { return err } - luckyPayers = affected - // Pool 明细是 Databi 点击下钻的真实口径:每笔带 pool_id 的幸运礼物送礼先按 UTC 日、国家和奖池累加流水,返奖事件稍后用相同维度补齐 payout。 - if err := ensureLuckyGiftPoolDay(ctx, tx, event.AppCode, day, countryID, regionID, poolID, nowMS); err != nil { - return err + luckyTurnover, luckyPayers := int64(0), int64(0) + poolID := strings.TrimSpace(event.PoolID) + if poolID != "" { + luckyTurnover = event.CoinSpent + affected, err := insertUnique(ctx, tx, ` + INSERT IGNORE INTO stat_lucky_gift_day_payers (app_code, stat_tz, stat_day, country_id, region_id, user_id, first_paid_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID, event.SenderUserID, event.OccurredAtMS) + if err != nil { + return err + } + luckyPayers = affected + // Pool 明细是 Databi 点击下钻的真实口径:送礼和返奖都用 stat_tz+stat_day+国家+奖池对齐,避免北京时间查询误用 UTC 行。 + if err := ensureLuckyGiftPoolDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, poolID, nowMS); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE stat_lucky_gift_pool_day_country + SET turnover_coin = turnover_coin + ?, updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? AND pool_id = ? + `, luckyTurnover, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID, poolID); err != nil { + return err + } } if _, err := tx.ExecContext(ctx, ` - UPDATE stat_lucky_gift_pool_day_country - SET turnover_coin = turnover_coin + ?, updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? AND pool_id = ? - `, luckyTurnover, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID, poolID); err != nil { + UPDATE stat_app_day_country + SET gift_coin_spent = gift_coin_spent + ?, lucky_gift_turnover = lucky_gift_turnover + ?, + lucky_gift_payers = lucky_gift_payers + ?, updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? + `, event.CoinSpent, luckyTurnover, luckyPayers, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { return err } } - _, err := tx.ExecContext(ctx, ` - UPDATE stat_app_day_country - SET gift_coin_spent = gift_coin_spent + ?, lucky_gift_turnover = lucky_gift_turnover + ?, - lucky_gift_payers = lucky_gift_payers + ?, updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? - `, event.CoinSpent, luckyTurnover, luckyPayers, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID) - return err + return nil }) } @@ -684,51 +802,52 @@ func realRoomRobotGiftKind(event RoomGiftEvent) string { func (r *Repository) ConsumeLuckyGiftReward(ctx context.Context, event LuckyGiftRewardEvent) error { return r.withEvent(ctx, SourceWallet, event.EventID, "WalletLuckyGiftRewardCredited", func(tx *sql.Tx, nowMS int64) error { - day := statDay(event.OccurredAtMS) countryID, regionID := normalizeDimension(event.CountryID, event.RegionID) - if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil { - return err - } poolID := strings.TrimSpace(event.PoolID) - if poolID != "" { - // 钱包返奖事件带回 pool_id 后,按同一 UTC 日和国家写入奖池返奖;利润不落表,查询时用 turnover-payout 实时计算,避免双写漂移。 - if err := ensureLuckyGiftPoolDay(ctx, tx, event.AppCode, day, countryID, regionID, poolID, nowMS); err != nil { + for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) { + if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil { return err } + if poolID != "" { + // 钱包返奖事件带回 pool_id 后,按同一统计时区和国家写入奖池返奖;利润不落表,查询时用 turnover-payout 实时计算,避免双写漂移。 + if err := ensureLuckyGiftPoolDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, poolID, nowMS); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE stat_lucky_gift_pool_day_country + SET payout_coin = payout_coin + ?, updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? AND pool_id = ? + `, event.Amount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID, poolID); err != nil { + return err + } + } if _, err := tx.ExecContext(ctx, ` - UPDATE stat_lucky_gift_pool_day_country - SET payout_coin = payout_coin + ?, updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? AND pool_id = ? - `, event.Amount, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID, poolID); err != nil { + UPDATE stat_app_day_country + SET lucky_gift_payout = lucky_gift_payout + ?, updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? + `, event.Amount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { return err } } - _, err := tx.ExecContext(ctx, ` - UPDATE stat_app_day_country - SET lucky_gift_payout = lucky_gift_payout + ?, updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? - `, event.Amount, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID) - return err + return nil }) } func (r *Repository) ConsumeUserActive(ctx context.Context, event UserActiveEvent) error { return r.withEvent(ctx, SourceRoom, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error { countryID, regionID := normalizeDimension(event.CountryID, event.RegionID) - return applyActive(ctx, tx, event.AppCode, statDay(event.OccurredAtMS), countryID, regionID, event.UserID, event.OccurredAtMS, nowMS) + for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) { + if err := applyActive(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, event.UserID, event.OccurredAtMS, nowMS); err != nil { + return err + } + } + return nil }) } func (r *Repository) ConsumeGameOrder(ctx context.Context, event GameOrderEvent) error { return r.withEvent(ctx, SourceGame, event.EventID, "GameOrderSettled", func(tx *sql.Tx, nowMS int64) error { - day := statDay(event.OccurredAtMS) countryID, regionID := normalizeDimension(event.CountryID, event.RegionID) - if err := applyActive(ctx, tx, event.AppCode, day, countryID, regionID, event.UserID, event.OccurredAtMS, nowMS); err != nil { - return err - } - if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil { - return err - } turnover, payout, refund := int64(0), int64(0), int64(0) switch strings.ToLower(strings.TrimSpace(event.OpType)) { case "debit", "bet": @@ -738,35 +857,45 @@ func (r *Repository) ConsumeGameOrder(ctx context.Context, event GameOrderEvent) case "refund", "reverse": refund = event.CoinAmount } - playerDelta := int64(0) - if turnover > 0 { - affected, err := insertUnique(ctx, tx, ` - INSERT IGNORE INTO stat_game_day_players (app_code, stat_day, country_id, region_id, platform_code, game_id, user_id, first_played_at_ms) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, appcode.Normalize(event.AppCode), day, countryID, regionID, event.PlatformCode, event.GameID, event.UserID, event.OccurredAtMS) - if err != nil { + for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) { + if err := applyActive(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, event.UserID, event.OccurredAtMS, nowMS); err != nil { + return err + } + if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil { + return err + } + playerDelta := int64(0) + if turnover > 0 { + affected, err := insertUnique(ctx, tx, ` + INSERT IGNORE INTO stat_game_day_players (app_code, stat_tz, stat_day, country_id, region_id, platform_code, game_id, user_id, first_played_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID, event.PlatformCode, event.GameID, event.UserID, event.OccurredAtMS) + if err != nil { + return err + } + playerDelta = affected + } + if err := ensureGameDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, event.PlatformCode, event.GameID, nowMS); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE stat_game_day_country + SET turnover_coin = turnover_coin + ?, payout_coin = payout_coin + ?, refund_coin = refund_coin + ?, + player_count = player_count + ?, updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? AND platform_code = ? AND game_id = ? + `, turnover, payout, refund, playerDelta, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID, event.PlatformCode, event.GameID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE stat_app_day_country + SET game_turnover = game_turnover + ?, game_payout = game_payout + ?, game_refund = game_refund + ?, + game_players = game_players + ?, updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? + `, turnover, payout, refund, playerDelta, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { return err } - playerDelta = affected } - if err := ensureGameDay(ctx, tx, event.AppCode, day, countryID, regionID, event.PlatformCode, event.GameID, nowMS); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, ` - UPDATE stat_game_day_country - SET turnover_coin = turnover_coin + ?, payout_coin = payout_coin + ?, refund_coin = refund_coin + ?, - player_count = player_count + ?, updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? AND platform_code = ? AND game_id = ? - `, turnover, payout, refund, playerDelta, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID, event.PlatformCode, event.GameID); err != nil { - return err - } - _, err := tx.ExecContext(ctx, ` - UPDATE stat_app_day_country - SET game_turnover = game_turnover + ?, game_payout = game_payout + ?, game_refund = game_refund + ?, - game_players = game_players + ?, updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? - `, turnover, payout, refund, playerDelta, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID) - return err + return nil }) } @@ -776,7 +905,6 @@ func (r *Repository) ConsumeSelfGameH5Event(ctx context.Context, event SelfGameH eventName = "unknown" } return r.withEvent(ctx, SourceH5, event.EventID, eventName, func(tx *sql.Tx, nowMS int64) error { - day := statDay(event.OccurredAtMS) countryID, regionID := normalizeDimension(event.CountryID, event.RegionID) gameID := normalizeShortText(event.GameID) if gameID == "" { @@ -786,56 +914,56 @@ func (r *Repository) ConsumeSelfGameH5Event(ctx context.Context, event SelfGameH clientVersion := normalizeShortText(event.ClientVersion) h5Version := normalizeShortText(event.H5Version) entrySource := normalizeShortText(event.EntrySource) - if err := ensureSelfGameH5EventDay(ctx, tx, event.AppCode, day, countryID, regionID, gameID, eventName, language, clientVersion, h5Version, entrySource, nowMS); err != nil { - return err - } - userDelta := int64(0) - if event.UserID > 0 { - affected, err := insertUnique(ctx, tx, ` - INSERT IGNORE INTO stat_self_game_h5_event_users (app_code, stat_day, game_id, event_name, user_id, first_seen_at_ms) - VALUES (?, ?, ?, ?, ?, ?) - `, appcode.Normalize(event.AppCode), day, gameID, eventName, event.UserID, event.OccurredAtMS) - if err != nil { + for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) { + if err := ensureSelfGameH5EventDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, gameID, eventName, language, clientVersion, h5Version, entrySource, nowMS); err != nil { + return err + } + userDelta := int64(0) + if event.UserID > 0 { + affected, err := insertUnique(ctx, tx, ` + INSERT IGNORE INTO stat_self_game_h5_event_users (app_code, stat_tz, stat_day, game_id, event_name, user_id, first_seen_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, appcode.Normalize(event.AppCode), scope.tz, scope.day, gameID, eventName, event.UserID, event.OccurredAtMS) + if err != nil { + return err + } + userDelta = affected + } + successDelta, errorDelta := int64(0), int64(0) + if event.Success { + successDelta = 1 + } + if normalizeShortText(event.ErrorCode) != "" { + errorDelta = 1 + } + // H5 事件按 event_id 做幂等;PV 用事件次数,UV 用同一 stat_tz 日、玩法和事件的用户去重,接口耗时只累加客户端实际传入的 duration。 + if _, err := tx.ExecContext(ctx, ` + UPDATE stat_self_game_h5_event_day + SET event_count = event_count + 1, + user_count = user_count + ?, + success_count = success_count + ?, + error_count = error_count + ?, + duration_ms_sum = duration_ms_sum + ?, + updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? + AND game_id = ? AND event_name = ? AND language = ? AND client_version = ? + AND h5_version = ? AND entry_source = ? + `, userDelta, successDelta, errorDelta, normalizeID(event.DurationMS), nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID, gameID, eventName, language, clientVersion, h5Version, entrySource); err != nil { return err } - userDelta = affected } - successDelta, errorDelta := int64(0), int64(0) - if event.Success { - successDelta = 1 - } - if normalizeShortText(event.ErrorCode) != "" { - errorDelta = 1 - } - // H5 事件按 event_id 做幂等;PV 用事件次数,UV 用同日同玩法同事件的用户去重,接口耗时只累加客户端实际传入的 duration。 - _, err := tx.ExecContext(ctx, ` - UPDATE stat_self_game_h5_event_day - SET event_count = event_count + 1, - user_count = user_count + ?, - success_count = success_count + ?, - error_count = error_count + ?, - duration_ms_sum = duration_ms_sum + ?, - updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? - AND game_id = ? AND event_name = ? AND language = ? AND client_version = ? - AND h5_version = ? AND entry_source = ? - `, userDelta, successDelta, errorDelta, normalizeID(event.DurationMS), nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID, gameID, eventName, language, clientVersion, h5Version, entrySource) - return err + return nil }) } func (r *Repository) ConsumeSelfGameMatch(ctx context.Context, event SelfGameMatchEvent) error { return r.withEvent(ctx, SourceGame, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error { - day := statDay(event.OccurredAtMS) countryID, regionID := normalizeDimension(event.CountryID, event.RegionID) gameID := normalizeShortText(event.GameID) if gameID == "" { gameID = "unknown" } stakeCoin := normalizeID(event.StakeCoin) - if err := ensureSelfGameMatchDay(ctx, tx, event.AppCode, day, countryID, regionID, gameID, stakeCoin, nowMS); err != nil { - return err - } created, matched, settled, canceled, failed := selfGameMatchCounters(event.EventType) humanMatch, robotMatch := int64(0), int64(0) humanParticipants, robotParticipants := int64(0), int64(0) @@ -870,57 +998,62 @@ func (r *Repository) ConsumeSelfGameMatch(ctx context.Context, event SelfGameMat waitMS = normalizeID(event.WaitMS) durationMS = normalizeID(event.DurationMS) } - // 对局状态事件只按 match 级事实累加一次;明细用户维度只在结算/取消时落表,避免创建和 ready 阶段重复计算下注与输赢。 - if _, err := tx.ExecContext(ctx, ` - UPDATE stat_self_game_match_day - SET created_matches = created_matches + ?, - matched_matches = matched_matches + ?, - settled_matches = settled_matches + ?, - canceled_matches = canceled_matches + ?, - failed_matches = failed_matches + ?, - human_matches = human_matches + ?, - robot_matches = robot_matches + ?, - human_participants = human_participants + ?, - robot_participants = robot_participants + ?, - winner_count = winner_count + ?, - loser_count = loser_count + ?, - draw_count = draw_count + ?, - user_stake_coin = user_stake_coin + ?, - robot_stake_coin = robot_stake_coin + ?, - payout_coin = payout_coin + ?, - refund_coin = refund_coin + ?, - platform_profit_coin = platform_profit_coin + ?, - pool_delta_coin = pool_delta_coin + ?, - forced_player_lose_matches = forced_player_lose_matches + ?, - wait_ms_sum = wait_ms_sum + ?, - duration_ms_sum = duration_ms_sum + ?, - updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? - AND game_id = ? AND stake_coin = ? - `, created, matched, settled, canceled, failed, humanMatch, robotMatch, humanParticipants, robotParticipants, winners, losers, draws, userStake, robotStake, payout, refund, platformProfit, poolDelta, forcedLose, waitMS, durationMS, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID, gameID, stakeCoin); err != nil { - return err - } - if settled > 0 { - for _, participant := range event.Participants { - if err := consumeSelfGameSettledParticipant(ctx, tx, event, participant, day, countryID, regionID, gameID, stakeCoin, nowMS); err != nil { - return err + for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) { + if err := ensureSelfGameMatchDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, gameID, stakeCoin, nowMS); err != nil { + return err + } + // 对局状态事件只按 match 级事实累加一次;明细用户维度只在结算/取消时落表,UTC 和中国日各自按 stat_tz 独立聚合。 + if _, err := tx.ExecContext(ctx, ` + UPDATE stat_self_game_match_day + SET created_matches = created_matches + ?, + matched_matches = matched_matches + ?, + settled_matches = settled_matches + ?, + canceled_matches = canceled_matches + ?, + failed_matches = failed_matches + ?, + human_matches = human_matches + ?, + robot_matches = robot_matches + ?, + human_participants = human_participants + ?, + robot_participants = robot_participants + ?, + winner_count = winner_count + ?, + loser_count = loser_count + ?, + draw_count = draw_count + ?, + user_stake_coin = user_stake_coin + ?, + robot_stake_coin = robot_stake_coin + ?, + payout_coin = payout_coin + ?, + refund_coin = refund_coin + ?, + platform_profit_coin = platform_profit_coin + ?, + pool_delta_coin = pool_delta_coin + ?, + forced_player_lose_matches = forced_player_lose_matches + ?, + wait_ms_sum = wait_ms_sum + ?, + duration_ms_sum = duration_ms_sum + ?, + updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? + AND game_id = ? AND stake_coin = ? + `, created, matched, settled, canceled, failed, humanMatch, robotMatch, humanParticipants, robotParticipants, winners, losers, draws, userStake, robotStake, payout, refund, platformProfit, poolDelta, forcedLose, waitMS, durationMS, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID, gameID, stakeCoin); err != nil { + return err + } + if settled > 0 { + for _, participant := range event.Participants { + if err := consumeSelfGameSettledParticipant(ctx, tx, event, participant, scope.tz, scope.day, countryID, regionID, gameID, stakeCoin, nowMS); err != nil { + return err + } } } - } - if canceled > 0 { - for _, participant := range event.Participants { - if participant.UserID <= 0 || !strings.EqualFold(strings.TrimSpace(participant.ParticipantType), "user") { - continue - } - if err := ensureSelfGameUserDay(ctx, tx, event.AppCode, day, countryID, regionID, gameID, participant.UserID, nowMS); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, ` - UPDATE stat_self_game_user_day - SET cancel_count = cancel_count + 1, updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND game_id = ? AND user_id = ? - `, nowMS, appcode.Normalize(event.AppCode), day, gameID, participant.UserID); err != nil { - return err + if canceled > 0 { + for _, participant := range event.Participants { + if participant.UserID <= 0 || !strings.EqualFold(strings.TrimSpace(participant.ParticipantType), "user") { + continue + } + if err := ensureSelfGameUserDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, gameID, participant.UserID, nowMS); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE stat_self_game_user_day + SET cancel_count = cancel_count + 1, updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND game_id = ? AND user_id = ? + `, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, gameID, participant.UserID); err != nil { + return err + } } } } @@ -930,16 +1063,12 @@ func (r *Repository) ConsumeSelfGameMatch(ctx context.Context, event SelfGameMat func (r *Repository) ConsumeSelfGamePoolAdjustment(ctx context.Context, event SelfGamePoolAdjustmentEvent) error { return r.withEvent(ctx, SourceGame, event.EventID, "SelfGamePoolAdjusted", func(tx *sql.Tx, nowMS int64) error { - day := statDay(event.OccurredAtMS) gameID := normalizeShortText(event.GameID) if gameID == "" { gameID = "unknown" } direction := normalizeShortText(event.Direction) reason := normalizeShortText(event.Reason) - if err := ensureSelfGamePoolDay(ctx, tx, event.AppCode, day, gameID, direction, reason, nowMS); err != nil { - return err - } inCoin, outCoin := int64(0), int64(0) switch direction { case "in": @@ -947,20 +1076,27 @@ func (r *Repository) ConsumeSelfGamePoolAdjustment(ctx context.Context, event Se case "out": outCoin = normalizeID(event.AmountCoin) } - _, err := tx.ExecContext(ctx, ` - UPDATE stat_self_game_pool_day - SET transaction_count = transaction_count + 1, - in_coin = in_coin + ?, - out_coin = out_coin + ?, - balance_after_coin = ?, - updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND game_id = ? AND direction = ? AND reason = ? - `, inCoin, outCoin, event.BalanceAfter, nowMS, appcode.Normalize(event.AppCode), day, gameID, direction, reason) - return err + for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) { + if err := ensureSelfGamePoolDay(ctx, tx, event.AppCode, scope.tz, scope.day, gameID, direction, reason, nowMS); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE stat_self_game_pool_day + SET transaction_count = transaction_count + 1, + in_coin = in_coin + ?, + out_coin = out_coin + ?, + balance_after_coin = ?, + updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND game_id = ? AND direction = ? AND reason = ? + `, inCoin, outCoin, event.BalanceAfter, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, gameID, direction, reason); err != nil { + return err + } + } + return nil }) } -func consumeSelfGameSettledParticipant(ctx context.Context, tx *sql.Tx, event SelfGameMatchEvent, participant SelfGameMatchParticipantEvent, day string, countryID int64, regionID int64, gameID string, stakeCoin int64, nowMS int64) error { +func consumeSelfGameSettledParticipant(ctx context.Context, tx *sql.Tx, event SelfGameMatchEvent, participant SelfGameMatchParticipantEvent, statTZ string, day string, countryID int64, regionID int64, gameID string, stakeCoin int64, nowMS int64) error { participantType := normalizeShortText(participant.ParticipantType) if participantType == "" { participantType = "user" @@ -976,7 +1112,7 @@ func consumeSelfGameSettledParticipant(ctx context.Context, tx *sql.Tx, event Se if point > 0 { dicePoint = point } - if err := ensureSelfGameParticipantDay(ctx, tx, event.AppCode, day, countryID, regionID, gameID, stakeCoin, participantType, result, gesture, dicePoint, nowMS); err != nil { + if err := ensureSelfGameParticipantDay(ctx, tx, event.AppCode, statTZ, day, countryID, regionID, gameID, stakeCoin, participantType, result, gesture, dicePoint, nowMS); err != nil { return err } if _, err := tx.ExecContext(ctx, ` @@ -985,17 +1121,17 @@ func consumeSelfGameSettledParticipant(ctx context.Context, tx *sql.Tx, event Se payout_coin = payout_coin + ?, net_win_coin = net_win_coin + ?, updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? AND game_id = ? AND stake_coin = ? AND participant_type = ? AND result = ? AND rps_gesture = ? AND dice_point = ? - `, normalizeID(participant.PayoutCoin), participant.NetWinCoin, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID, gameID, stakeCoin, participantType, result, gesture, dicePoint); err != nil { + `, normalizeID(participant.PayoutCoin), participant.NetWinCoin, nowMS, appcode.Normalize(event.AppCode), normalizeStatTZ(statTZ), day, countryID, regionID, gameID, stakeCoin, participantType, result, gesture, dicePoint); err != nil { return err } } if participant.UserID <= 0 || participantType != "user" { return nil } - if err := ensureSelfGameUserDay(ctx, tx, event.AppCode, day, countryID, regionID, gameID, participant.UserID, nowMS); err != nil { + if err := ensureSelfGameUserDay(ctx, tx, event.AppCode, statTZ, day, countryID, regionID, gameID, participant.UserID, nowMS); err != nil { return err } win, lose, draw := int64(0), int64(0), int64(0) @@ -1017,8 +1153,8 @@ func consumeSelfGameSettledParticipant(ctx context.Context, tx *sql.Tx, event Se payout_coin = payout_coin + ?, net_win_coin = net_win_coin + ?, updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND game_id = ? AND user_id = ? - `, win, lose, draw, normalizeID(participant.StakeCoin), normalizeID(participant.PayoutCoin), participant.NetWinCoin, nowMS, appcode.Normalize(event.AppCode), day, gameID, participant.UserID) + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND game_id = ? AND user_id = ? + `, win, lose, draw, normalizeID(participant.StakeCoin), normalizeID(participant.PayoutCoin), participant.NetWinCoin, nowMS, appcode.Normalize(event.AppCode), normalizeStatTZ(statTZ), day, gameID, participant.UserID) return err } @@ -1108,19 +1244,24 @@ func isDuplicateSchemaError(err error) bool { return errors.As(err, &mysqlErr) && (mysqlErr.Number == 1060 || mysqlErr.Number == 1061) } +func isMissingIndexError(err error) bool { + var mysqlErr *mysqlerr.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1091 +} + func isMissingTableError(err error) bool { var mysqlErr *mysqlerr.MySQLError return errors.As(err, &mysqlErr) && mysqlErr.Number == 1146 } -func applyActive(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, userID int64, occurredAtMS int64, nowMS int64) error { - if err := ensureAppDay(ctx, tx, app, day, countryID, regionID, nowMS); err != nil { +func applyActive(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, countryID int64, regionID int64, userID int64, occurredAtMS int64, nowMS int64) error { + if err := ensureAppDay(ctx, tx, app, statTZ, day, countryID, regionID, nowMS); err != nil { return err } affected, err := insertUnique(ctx, tx, ` - INSERT IGNORE INTO stat_user_day_activity (app_code, stat_day, country_id, region_id, user_id, first_active_at_ms) - VALUES (?, ?, ?, ?, ?, ?) - `, appcode.Normalize(app), day, countryID, regionID, userID, occurredAtMS) + INSERT IGNORE INTO stat_user_day_activity (app_code, stat_tz, stat_day, country_id, region_id, user_id, first_active_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, appcode.Normalize(app), normalizeStatTZ(statTZ), day, countryID, regionID, userID, occurredAtMS) if err != nil { return err } @@ -1130,88 +1271,88 @@ func applyActive(ctx context.Context, tx *sql.Tx, app string, day string, countr _, err = tx.ExecContext(ctx, ` UPDATE stat_app_day_country SET active_users = active_users + 1, updated_at_ms = ? - WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? - `, nowMS, appcode.Normalize(app), day, countryID, regionID) + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? + `, nowMS, appcode.Normalize(app), normalizeStatTZ(statTZ), day, countryID, regionID) return err } -func ensureAppDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, nowMS int64) error { +func ensureAppDay(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, countryID int64, regionID int64, nowMS int64) error { _, err := tx.ExecContext(ctx, ` - INSERT INTO stat_app_day_country (app_code, stat_day, country_id, region_id, updated_at_ms) - VALUES (?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - updated_at_ms = VALUES(updated_at_ms) - `, appcode.Normalize(app), day, countryID, regionID, nowMS) - return err -} - -func ensureGameDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, platformCode string, gameID string, nowMS int64) error { - _, err := tx.ExecContext(ctx, ` - INSERT INTO stat_game_day_country (app_code, stat_day, country_id, region_id, platform_code, game_id, updated_at_ms) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - updated_at_ms = VALUES(updated_at_ms) - `, appcode.Normalize(app), day, countryID, regionID, platformCode, gameID, nowMS) - return err -} - -func ensureLuckyGiftPoolDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, poolID string, nowMS int64) error { - _, err := tx.ExecContext(ctx, ` - INSERT INTO stat_lucky_gift_pool_day_country (app_code, stat_day, country_id, region_id, pool_id, updated_at_ms) + INSERT INTO stat_app_day_country (app_code, stat_tz, stat_day, country_id, region_id, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms) - `, appcode.Normalize(app), day, countryID, regionID, strings.TrimSpace(poolID), nowMS) + `, appcode.Normalize(app), normalizeStatTZ(statTZ), day, countryID, regionID, nowMS) return err } -func ensureSelfGameH5EventDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, gameID string, eventName string, language string, clientVersion string, h5Version string, entrySource string, nowMS int64) error { +func ensureGameDay(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, countryID int64, regionID int64, platformCode string, gameID string, nowMS int64) error { _, err := tx.ExecContext(ctx, ` - INSERT INTO stat_self_game_h5_event_day (app_code, stat_day, country_id, region_id, game_id, event_name, language, client_version, h5_version, entry_source, updated_at_ms) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO stat_game_day_country (app_code, stat_tz, stat_day, country_id, region_id, platform_code, game_id, updated_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms) - `, appcode.Normalize(app), day, countryID, regionID, gameID, eventName, language, clientVersion, h5Version, entrySource, nowMS) + `, appcode.Normalize(app), normalizeStatTZ(statTZ), day, countryID, regionID, platformCode, gameID, nowMS) return err } -func ensureSelfGameMatchDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, gameID string, stakeCoin int64, nowMS int64) error { +func ensureLuckyGiftPoolDay(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, countryID int64, regionID int64, poolID string, nowMS int64) error { _, err := tx.ExecContext(ctx, ` - INSERT INTO stat_self_game_match_day (app_code, stat_day, country_id, region_id, game_id, stake_coin, updated_at_ms) + INSERT INTO stat_lucky_gift_pool_day_country (app_code, stat_tz, stat_day, country_id, region_id, pool_id, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms) - `, appcode.Normalize(app), day, countryID, regionID, gameID, stakeCoin, nowMS) + `, appcode.Normalize(app), normalizeStatTZ(statTZ), day, countryID, regionID, strings.TrimSpace(poolID), nowMS) return err } -func ensureSelfGamePoolDay(ctx context.Context, tx *sql.Tx, app string, day string, gameID string, direction string, reason string, nowMS int64) error { +func ensureSelfGameH5EventDay(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, countryID int64, regionID int64, gameID string, eventName string, language string, clientVersion string, h5Version string, entrySource string, nowMS int64) error { _, err := tx.ExecContext(ctx, ` - INSERT INTO stat_self_game_pool_day (app_code, stat_day, game_id, direction, reason, updated_at_ms) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO stat_self_game_h5_event_day (app_code, stat_tz, stat_day, country_id, region_id, game_id, event_name, language, client_version, h5_version, entry_source, updated_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms) - `, appcode.Normalize(app), day, gameID, direction, reason, nowMS) + `, appcode.Normalize(app), normalizeStatTZ(statTZ), day, countryID, regionID, gameID, eventName, language, clientVersion, h5Version, entrySource, nowMS) return err } -func ensureSelfGameParticipantDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, gameID string, stakeCoin int64, participantType string, result string, gesture string, dicePoint int64, nowMS int64) error { +func ensureSelfGameMatchDay(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, countryID int64, regionID int64, gameID string, stakeCoin int64, nowMS int64) error { _, err := tx.ExecContext(ctx, ` - INSERT INTO stat_self_game_participant_day (app_code, stat_day, country_id, region_id, game_id, stake_coin, participant_type, result, rps_gesture, dice_point, updated_at_ms) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO stat_self_game_match_day (app_code, stat_tz, stat_day, country_id, region_id, game_id, stake_coin, updated_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms) - `, appcode.Normalize(app), day, countryID, regionID, gameID, stakeCoin, participantType, result, gesture, dicePoint, nowMS) + `, appcode.Normalize(app), normalizeStatTZ(statTZ), day, countryID, regionID, gameID, stakeCoin, nowMS) return err } -func ensureSelfGameUserDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, gameID string, userID int64, nowMS int64) error { +func ensureSelfGamePoolDay(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, gameID string, direction string, reason string, nowMS int64) error { _, err := tx.ExecContext(ctx, ` - INSERT INTO stat_self_game_user_day (app_code, stat_day, country_id, region_id, game_id, user_id, updated_at_ms) + INSERT INTO stat_self_game_pool_day (app_code, stat_tz, stat_day, game_id, direction, reason, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms) - `, appcode.Normalize(app), day, countryID, regionID, gameID, userID, nowMS) + `, appcode.Normalize(app), normalizeStatTZ(statTZ), day, gameID, direction, reason, nowMS) + return err +} + +func ensureSelfGameParticipantDay(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, countryID int64, regionID int64, gameID string, stakeCoin int64, participantType string, result string, gesture string, dicePoint int64, nowMS int64) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO stat_self_game_participant_day (app_code, stat_tz, stat_day, country_id, region_id, game_id, stake_coin, participant_type, result, rps_gesture, dice_point, updated_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + updated_at_ms = VALUES(updated_at_ms) + `, appcode.Normalize(app), normalizeStatTZ(statTZ), day, countryID, regionID, gameID, stakeCoin, participantType, result, gesture, dicePoint, nowMS) + return err +} + +func ensureSelfGameUserDay(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, countryID int64, regionID int64, gameID string, userID int64, nowMS int64) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO stat_self_game_user_day (app_code, stat_tz, stat_day, country_id, region_id, game_id, user_id, updated_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + updated_at_ms = VALUES(updated_at_ms) + `, appcode.Normalize(app), normalizeStatTZ(statTZ), day, countryID, regionID, gameID, userID, nowMS) return err } @@ -1228,7 +1369,7 @@ func insertUnique(ctx context.Context, tx *sql.Tx, query string, args ...any) (i } func statDay(ms int64) string { - return time.UnixMilli(ms).UTC().Format("2006-01-02") + return statDayIn(ms, StatTZUTC) } func normalizeID(id int64) int64 { diff --git a/services/statistics-service/internal/storage/mysql/stat_tz.go b/services/statistics-service/internal/storage/mysql/stat_tz.go new file mode 100644 index 00000000..90357d29 --- /dev/null +++ b/services/statistics-service/internal/storage/mysql/stat_tz.go @@ -0,0 +1,68 @@ +package mysql + +import ( + "context" + "strings" + "time" +) + +const ( + // StatTZUTC 是历史默认口径,保留它可以让旧接口、旧报表和跨服务 UTC 统计规则继续稳定工作。 + StatTZUTC = "UTC" + // StatTZAsiaShanghai 是 Databi 需要的中国自然日口径;中国当前无夏令时,固定 +08:00 能避免容器缺 tzdata 时切日失败。 + StatTZAsiaShanghai = "Asia/Shanghai" +) + +var asiaShanghaiLocation = time.FixedZone(StatTZAsiaShanghai, 8*60*60) + +type statDayScope struct { + tz string + day string +} + +type statTZScopeContextKey struct{} + +// WithStatTZScope 限制当前聚合只写一个 stat_tz;实时消费不使用它,回灌任务用它避免重放历史事件时污染 UTC 口径。 +func WithStatTZScope(ctx context.Context, statTZ string) context.Context { + return context.WithValue(ctx, statTZScopeContextKey{}, normalizeStatTZ(statTZ)) +} + +func normalizeStatTZ(value string) string { + switch strings.TrimSpace(value) { + case StatTZAsiaShanghai: + return StatTZAsiaShanghai + case StatTZUTC, "": + return StatTZUTC + default: + return StatTZUTC + } +} + +func statLocation(statTZ string) *time.Location { + if normalizeStatTZ(statTZ) == StatTZAsiaShanghai { + return asiaShanghaiLocation + } + return time.UTC +} + +func statDayIn(ms int64, statTZ string) string { + return time.UnixMilli(ms).In(statLocation(statTZ)).Format("2006-01-02") +} + +func statDayScopes(ms int64) []statDayScope { + // 同一条业务事实必须一次事务内写出两份读模型:UTC 行维持历史聚合,Asia/Shanghai 行给中国自然日大屏使用。 + return []statDayScope{ + {tz: StatTZUTC, day: statDayIn(ms, StatTZUTC)}, + {tz: StatTZAsiaShanghai, day: statDayIn(ms, StatTZAsiaShanghai)}, + } +} + +func statDayScopesFromContext(ctx context.Context, ms int64) []statDayScope { + if ctx != nil { + if statTZ, ok := ctx.Value(statTZScopeContextKey{}).(string); ok { + statTZ = normalizeStatTZ(statTZ) + return []statDayScope{{tz: statTZ, day: statDayIn(ms, statTZ)}} + } + } + return statDayScopes(ms) +}