package mysql import ( "context" "database/sql" "encoding/json" "errors" "strings" "time" mysqlerr "github.com/go-sql-driver/mysql" "hyapp/pkg/appcode" "hyapp/pkg/xerr" ) const ( SourceUser = "user" SourceWallet = "wallet" SourceRoom = "room" SourceGame = "game" ) // Repository owns the statistics read model. Online queries must use aggregate // tables; the optional activity connection only reads lucky-gift day snapshots, // never the high-cardinality draw fact table. type Repository struct { db *sql.DB activityDB *sql.DB } func Open(ctx context.Context, dsn string, activityDSNs ...string) (*Repository, error) { if strings.TrimSpace(dsn) == "" { return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required") } db, err := sql.Open("mysql", dsn) if err != nil { return nil, err } if err := db.PingContext(ctx); err != nil { _ = db.Close() return nil, err } var activityDB *sql.DB if len(activityDSNs) > 0 && strings.TrimSpace(activityDSNs[0]) != "" { activityDB, err = sql.Open("mysql", strings.TrimSpace(activityDSNs[0])) if err != nil { _ = db.Close() return nil, err } if err := activityDB.PingContext(ctx); err != nil { _ = activityDB.Close() _ = db.Close() return nil, err } } repo := &Repository{db: db, activityDB: activityDB} if err := repo.Migrate(ctx); err != nil { if activityDB != nil { _ = activityDB.Close() } _ = db.Close() return nil, err } return repo, nil } func (r *Repository) Close() error { if r == nil { return nil } var err error if r.activityDB != nil { err = r.activityDB.Close() } if r.db != nil { if dbErr := r.db.Close(); err == nil { err = dbErr } } return err } func (r *Repository) Ping(ctx context.Context) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } if err := r.db.PingContext(ctx); err != nil { return err } if r.activityDB != nil { return r.activityDB.PingContext(ctx) } return nil } func (r *Repository) Migrate(ctx context.Context) error { statements := []string{ `CREATE TABLE IF NOT EXISTS statistics_event_consumption ( app_code VARCHAR(32) NOT NULL, source VARCHAR(32) NOT NULL, event_id VARCHAR(160) NOT NULL, 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) ) 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, 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, coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, mifapay_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, coin_seller_transfer_coin BIGINT NOT NULL DEFAULT 0, gift_coin_spent BIGINT NOT NULL DEFAULT 0, 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) ) 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, 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) ) 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, 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) ) 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, 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) ) 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, 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) ) 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, 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) ) 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, 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) ) 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, 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, } for _, statement := range statements { if _, err := r.db.ExecContext(ctx, statement); err != nil { 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`, `ALTER TABLE stat_app_day_country ADD COLUMN lucky_gift_payout BIGINT NOT NULL DEFAULT 0 AFTER lucky_gift_turnover`, `ALTER TABLE stat_app_day_country ADD COLUMN coin_seller_transfer_coin BIGINT NOT NULL DEFAULT 0 AFTER google_recharge_usd_minor`, `ALTER TABLE stat_game_day_country ADD COLUMN refund_coin BIGINT NOT NULL DEFAULT 0 AFTER payout_coin`, `ALTER TABLE stat_app_day_country ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`, `ALTER TABLE stat_user_day_activity ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`, `ALTER TABLE stat_user_registration ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`, `ALTER TABLE stat_recharge_day_payers ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`, `ALTER TABLE stat_lucky_gift_day_payers ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`, `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)`, } for _, statement := range alterStatements { if _, err := r.db.ExecContext(ctx, statement); err != nil && !isDuplicateSchemaError(err) { return err } } 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"}, } { if err := r.ensurePrimaryKeyColumns(ctx, table, columns); err != nil { return err } } return nil } func (r *Repository) ensurePrimaryKeyColumns(ctx context.Context, table string, expected []string) error { rows, err := r.db.QueryContext(ctx, ` SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY' ORDER BY ORDINAL_POSITION`, table) if err != nil { return err } defer rows.Close() actual := make([]string, 0, len(expected)) 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 sameStringSlice(actual, expected) { return nil } // 统计聚合表必须同时以国家和区域做主键;否则区域筛选和国家筛选会写入同一行,导致阿富汗这类历史错位国家继续污染榜单。 _, err = r.db.ExecContext(ctx, "ALTER TABLE "+table+" DROP PRIMARY KEY, ADD PRIMARY KEY ("+strings.Join(expected, ", ")+")") return err } func sameStringSlice(left, right []string) bool { if len(left) != len(right) { return false } for index := range left { if left[index] != right[index] { return false } } return true } type UserRegisteredEvent struct { AppCode string EventID string UserID int64 CountryID int64 RegionID int64 OccurredAtMS int64 } type RechargeEvent struct { AppCode string EventID string EventType string UserID int64 USDMinor int64 CoinAmount int64 RechargeSequence int64 TargetCountryID int64 TargetRegionID int64 RechargeType string OccurredAtMS int64 } type CoinSellerStockEvent struct { AppCode string EventID string SellerUserID int64 USDMinor int64 CoinAmount int64 CountryID int64 RegionID int64 OccurredAtMS int64 } type RoomGiftEvent struct { AppCode string EventID string SenderUserID int64 CountryID int64 VisibleRegionID int64 GiftValue int64 CoinSpent int64 PoolID string OccurredAtMS int64 } type LuckyGiftRewardEvent struct { AppCode string EventID string EventType string UserID int64 CountryID int64 RegionID int64 PoolID string Amount int64 OccurredAtMS int64 } type UserActiveEvent struct { AppCode string EventID string EventType string UserID int64 CountryID int64 RegionID int64 OccurredAtMS int64 } type GameOrderEvent struct { AppCode string EventID string UserID int64 CountryID int64 RegionID int64 PlatformCode string GameID string OpType string CoinAmount int64 OccurredAtMS int64 } 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 } if _, err := tx.ExecContext(ctx, ` 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); err != nil { return err } _, err := tx.ExecContext(ctx, ` UPDATE stat_app_day_country SET new_users = new_users + 1, updated_at_ms = ? WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? `, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID) return err }) } 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 } coinSellerUSD, mifapayUSD, googleUSD, coinSellerTransferCoin := int64(0), int64(0), int64(0), int64(0) switch strings.ToLower(strings.TrimSpace(event.RechargeType)) { case "google", "google_play": googleUSD = event.USDMinor case "mifapay": mifapayUSD = event.USDMinor case "coin_seller_transfer", "coin_seller": coinSellerTransferCoin = event.CoinAmount 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 }) } 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 } // 币商进货是后台 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 }) } 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 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 { 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 } 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 { 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 }) } 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 { 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 { 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 }) } 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) }) } 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": turnover = event.CoinAmount case "credit", "payout": payout = event.CoinAmount 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 { 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 }) } func (r *Repository) withEvent(ctx context.Context, source string, eventID string, eventType string, apply func(tx *sql.Tx, nowMS int64) error) error { var lastErr error for attempt := 0; attempt < 5; attempt++ { err := r.withEventOnce(ctx, source, eventID, eventType, apply) if err == nil { return nil } if !isRetryableMySQLError(err) { return err } lastErr = err timer := time.NewTimer(time.Duration(attempt+1) * 50 * time.Millisecond) select { case <-ctx.Done(): timer.Stop() return ctx.Err() case <-timer.C: } } return lastErr } func (r *Repository) withEventOnce(ctx context.Context, source string, eventID string, eventType string, apply func(tx *sql.Tx, nowMS int64) error) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } app := appcode.FromContext(ctx) nowMS := time.Now().UTC().UnixMilli() tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() result, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO statistics_event_consumption (app_code, source, event_id, event_type, status, consumed_at_ms, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, 'processing', ?, ?, ?) `, app, source, eventID, eventType, nowMS, nowMS, nowMS) if err != nil { return err } affected, _ := result.RowsAffected() if affected == 0 { return tx.Commit() } if err := apply(tx, nowMS); err != nil { return err } if _, err := tx.ExecContext(ctx, ` UPDATE statistics_event_consumption SET status = 'done', consumed_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND source = ? AND event_id = ? `, nowMS, nowMS, app, source, eventID); err != nil { return err } return tx.Commit() } func isRetryableMySQLError(err error) bool { var mysqlErr *mysqlerr.MySQLError if !errors.As(err, &mysqlErr) { return false } return mysqlErr.Number == 1213 || mysqlErr.Number == 1205 } func isDuplicateSchemaError(err error) bool { var mysqlErr *mysqlerr.MySQLError return errors.As(err, &mysqlErr) && (mysqlErr.Number == 1060 || mysqlErr.Number == 1061) } 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 { 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) if err != nil { return err } if affected == 0 { return nil } _, 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) return err } func ensureAppDay(ctx context.Context, tx *sql.Tx, app 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) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms) `, appcode.Normalize(app), day, countryID, regionID, strings.TrimSpace(poolID), nowMS) return err } func insertUnique(ctx context.Context, tx *sql.Tx, query string, args ...any) (int64, error) { result, err := tx.ExecContext(ctx, query, args...) if err != nil { return 0, err } affected, err := result.RowsAffected() if err != nil { return 0, err } return affected, nil } func statDay(ms int64) string { return time.UnixMilli(ms).UTC().Format("2006-01-02") } func normalizeID(id int64) int64 { if id < 0 { return 0 } return id } func normalizeDimension(countryID int64, regionID int64) (int64, int64) { return normalizeID(countryID), normalizeID(regionID) } func DecodeJSON(payload string) map[string]any { decoded := map[string]any{} _ = json.Unmarshal([]byte(payload), &decoded) return decoded } func Int64(payload map[string]any, key string) int64 { switch value := payload[key].(type) { case float64: return int64(value) case int64: return value case json.Number: v, _ := value.Int64() return v default: return 0 } } func String(payload map[string]any, key string) string { if value, ok := payload[key].(string); ok { return strings.TrimSpace(value) } return "" }