1880 lines
83 KiB
Go
1880 lines
83 KiB
Go
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"
|
||
SourceH5 = "h5"
|
||
)
|
||
|
||
// 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_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_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,
|
||
coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, coin_seller_stock_coin 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,
|
||
coin_total BIGINT NOT NULL DEFAULT 0, consumed_coin BIGINT NOT NULL DEFAULT 0,
|
||
output_coin BIGINT NOT NULL DEFAULT 0, platform_grant_coin BIGINT NOT NULL DEFAULT 0, manual_grant_coin BIGINT NOT NULL DEFAULT 0,
|
||
salary_usd_minor BIGINT NOT NULL DEFAULT 0, salary_transfer_coin BIGINT NOT NULL DEFAULT 0,
|
||
mic_online_ms BIGINT NOT NULL DEFAULT 0, mic_online_users 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_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_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_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, 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, 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_user_dimension (
|
||
app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
|
||
country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0,
|
||
updated_at_ms BIGINT NOT NULL,
|
||
PRIMARY KEY (app_code, user_id),
|
||
KEY idx_stat_user_dimension_country (app_code, country_id),
|
||
KEY idx_stat_user_dimension_region (app_code, region_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||
`CREATE TABLE IF NOT EXISTS stat_user_day_mic (
|
||
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, mic_online_ms BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
|
||
PRIMARY KEY (app_code, stat_tz, stat_day, user_id),
|
||
KEY idx_stat_user_day_mic_country (app_code, stat_tz, stat_day, country_id),
|
||
KEY idx_stat_user_day_mic_region (app_code, stat_tz, stat_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_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_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_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_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_platform_grant_coin_events (
|
||
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL,
|
||
event_id VARCHAR(160) NOT NULL, event_type VARCHAR(64) NOT NULL,
|
||
user_id BIGINT NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0,
|
||
amount BIGINT NOT NULL DEFAULT 0, occurred_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL,
|
||
PRIMARY KEY (app_code, stat_tz, event_id),
|
||
KEY idx_stat_platform_grant_country (app_code, stat_tz, stat_day, country_id, region_id, amount),
|
||
KEY idx_stat_platform_grant_user (app_code, stat_tz, stat_day, country_id, user_id, occurred_at_ms)
|
||
) 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_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_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_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_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_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_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_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_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_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_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_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_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_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,
|
||
failed_matches BIGINT NOT NULL DEFAULT 0, human_matches BIGINT NOT NULL DEFAULT 0,
|
||
robot_matches BIGINT NOT NULL DEFAULT 0, human_participants BIGINT NOT NULL DEFAULT 0,
|
||
robot_participants BIGINT NOT NULL DEFAULT 0, winner_count BIGINT NOT NULL DEFAULT 0,
|
||
loser_count BIGINT NOT NULL DEFAULT 0, draw_count BIGINT NOT NULL DEFAULT 0,
|
||
user_stake_coin BIGINT NOT NULL DEFAULT 0, robot_stake_coin BIGINT NOT NULL DEFAULT 0,
|
||
payout_coin BIGINT NOT NULL DEFAULT 0, refund_coin BIGINT NOT NULL DEFAULT 0,
|
||
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_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_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_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_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_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_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_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 {
|
||
if _, err := r.db.ExecContext(ctx, statement); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, table := range []string{
|
||
"stat_app_day_country",
|
||
"stat_user_day_activity",
|
||
"stat_user_registration",
|
||
"stat_user_day_mic",
|
||
"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`,
|
||
`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_app_day_country ADD COLUMN coin_seller_stock_coin BIGINT NOT NULL DEFAULT 0 AFTER coin_seller_recharge_usd_minor`,
|
||
`ALTER TABLE stat_app_day_country ADD COLUMN coin_total BIGINT NOT NULL DEFAULT 0 AFTER coin_seller_transfer_coin`,
|
||
`ALTER TABLE stat_app_day_country ADD COLUMN consumed_coin BIGINT NOT NULL DEFAULT 0 AFTER coin_total`,
|
||
`ALTER TABLE stat_app_day_country ADD COLUMN output_coin BIGINT NOT NULL DEFAULT 0 AFTER consumed_coin`,
|
||
`ALTER TABLE stat_app_day_country ADD COLUMN platform_grant_coin BIGINT NOT NULL DEFAULT 0 AFTER output_coin`,
|
||
`ALTER TABLE stat_app_day_country ADD COLUMN manual_grant_coin BIGINT NOT NULL DEFAULT 0 AFTER platform_grant_coin`,
|
||
`ALTER TABLE stat_app_day_country ADD COLUMN salary_usd_minor BIGINT NOT NULL DEFAULT 0 AFTER manual_grant_coin`,
|
||
`ALTER TABLE stat_app_day_country ADD COLUMN salary_transfer_coin BIGINT NOT NULL DEFAULT 0 AFTER salary_usd_minor`,
|
||
`ALTER TABLE stat_app_day_country ADD COLUMN mic_online_ms BIGINT NOT NULL DEFAULT 0 AFTER salary_transfer_coin`,
|
||
`ALTER TABLE stat_app_day_country ADD COLUMN mic_online_users BIGINT NOT NULL DEFAULT 0 AFTER mic_online_ms`,
|
||
`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_user_day_mic 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_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_user_day_mic ADD KEY idx_stat_user_day_mic_region (app_code, stat_tz, stat_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) {
|
||
return err
|
||
}
|
||
}
|
||
for table, columns := range map[string][]string{
|
||
"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_user_dimension": {"app_code", "user_id"},
|
||
"stat_user_day_mic": {"app_code", "stat_tz", "stat_day", "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_platform_grant_coin_events": {"app_code", "stat_tz", "event_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_user_dimension", "idx_stat_user_dimension_country", []string{"app_code", "country_id"}},
|
||
{"stat_user_dimension", "idx_stat_user_dimension_region", []string{"app_code", "region_id"}},
|
||
{"stat_user_day_mic", "idx_stat_user_day_mic_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}},
|
||
{"stat_user_day_mic", "idx_stat_user_day_mic_region", []string{"app_code", "stat_tz", "stat_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_platform_grant_coin_events", "idx_stat_platform_grant_country", []string{"app_code", "stat_tz", "stat_day", "country_id", "region_id", "amount"}},
|
||
{"stat_platform_grant_coin_events", "idx_stat_platform_grant_user", []string{"app_code", "stat_tz", "stat_day", "country_id", "user_id", "occurred_at_ms"}},
|
||
{"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
|
||
}
|
||
|
||
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 (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
|
||
}
|
||
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 UserRegionChangedEvent 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
|
||
RoomID string
|
||
SenderUserID int64
|
||
CountryID int64
|
||
VisibleRegionID int64
|
||
GiftValue int64
|
||
CoinSpent int64
|
||
PoolID string
|
||
GiftTypeCode string
|
||
RealRoomRobot bool
|
||
OccurredAtMS int64
|
||
}
|
||
|
||
type LuckyGiftRewardEvent struct {
|
||
AppCode string
|
||
EventID string
|
||
EventType string
|
||
UserID int64
|
||
CountryID int64
|
||
RegionID int64
|
||
PoolID string
|
||
Amount int64
|
||
OccurredAtMS int64
|
||
}
|
||
|
||
type PlatformGrantCoinEvent struct {
|
||
AppCode string
|
||
EventID string
|
||
EventType string
|
||
UserID int64
|
||
CountryID int64
|
||
RegionID int64
|
||
Amount int64
|
||
OccurredAtMS int64
|
||
}
|
||
|
||
type CoinOutputEvent struct {
|
||
AppCode string
|
||
EventID string
|
||
EventType string
|
||
UserID int64
|
||
CountryID int64
|
||
RegionID int64
|
||
Amount int64
|
||
OccurredAtMS int64
|
||
}
|
||
|
||
type WalletBalanceChangedEvent struct {
|
||
AppCode string
|
||
EventID string
|
||
EventType string
|
||
UserID int64
|
||
DimensionUserID int64
|
||
AssetType string
|
||
BizType string
|
||
CommandID string
|
||
AvailableDelta int64
|
||
FrozenDelta int64
|
||
CountryID int64
|
||
RegionID int64
|
||
OccurredAtMS int64
|
||
}
|
||
|
||
type UserMicDailyStatsUpdatedEvent struct {
|
||
AppCode string
|
||
EventID string
|
||
UserID int64
|
||
StatDate string
|
||
MicOnlineDeltaMS 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
|
||
}
|
||
|
||
type SelfGameH5Event struct {
|
||
AppCode string
|
||
EventID string
|
||
EventName string
|
||
GameID string
|
||
Page string
|
||
SessionID string
|
||
MatchID string
|
||
UserID int64
|
||
CountryID int64
|
||
RegionID int64
|
||
Language string
|
||
ClientVersion string
|
||
H5Version string
|
||
EntrySource string
|
||
DurationMS int64
|
||
Success bool
|
||
ErrorCode string
|
||
OccurredAtMS int64
|
||
}
|
||
|
||
type SelfGameMatchParticipantEvent struct {
|
||
UserID int64
|
||
ParticipantType string
|
||
StakeCoin int64
|
||
DicePoints []int64
|
||
RPSGesture string
|
||
Result string
|
||
PayoutCoin int64
|
||
NetWinCoin int64
|
||
}
|
||
|
||
type SelfGameMatchEvent struct {
|
||
AppCode string
|
||
EventID string
|
||
EventType string
|
||
MatchID string
|
||
GameID string
|
||
PlatformCode string
|
||
RoomID string
|
||
CountryID int64
|
||
RegionID int64
|
||
StakeCoin int64
|
||
Status string
|
||
Result string
|
||
MatchMode string
|
||
ForcedResult string
|
||
UserParticipants int64
|
||
RobotParticipants int64
|
||
WinnerCount int64
|
||
LoserCount int64
|
||
DrawCount int64
|
||
UserStakeCoin int64
|
||
RobotStakeCoin int64
|
||
PayoutCoin int64
|
||
RefundCoin int64
|
||
PlatformProfitCoin int64
|
||
PoolDeltaCoin int64
|
||
WaitMS int64
|
||
DurationMS int64
|
||
Participants []SelfGameMatchParticipantEvent
|
||
OccurredAtMS int64
|
||
}
|
||
|
||
type SelfGamePoolAdjustmentEvent struct {
|
||
AppCode string
|
||
EventID string
|
||
GameID string
|
||
Direction string
|
||
Reason string
|
||
AmountCoin int64
|
||
BalanceAfter 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 {
|
||
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||
if err := upsertUserDimension(ctx, tx, event.AppCode, event.UserID, 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
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func (r *Repository) ConsumeUserRegionChanged(ctx context.Context, event UserRegionChangedEvent) error {
|
||
return r.withEvent(ctx, SourceUser, event.EventID, "UserRegionChanged", func(tx *sql.Tx, nowMS int64) error {
|
||
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||
return upsertUserDimension(ctx, tx, event.AppCode, event.UserID, countryID, regionID, nowMS)
|
||
})
|
||
}
|
||
|
||
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 {
|
||
countryID, regionID := normalizeDimension(event.TargetCountryID, event.TargetRegionID)
|
||
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", "v5pay":
|
||
mifapayUSD = event.USDMinor
|
||
case "usdt_trc20":
|
||
// USDT-TRC20 是站外 H5 充值渠道;当前大盘只单列 MiFaPay/Google/币商转账,
|
||
// 因此只进入总充值和付费用户,不误归到币商美元充值桶。
|
||
case "coin_seller_transfer", "coin_seller":
|
||
coinSellerTransferCoin = event.CoinAmount
|
||
default:
|
||
coinSellerUSD = event.USDMinor
|
||
}
|
||
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 {
|
||
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||
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 + ?,
|
||
coin_seller_stock_coin = coin_seller_stock_coin + ?,
|
||
updated_at_ms = ?
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||
`, event.USDMinor, event.USDMinor, event.CoinAmount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||
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 {
|
||
countryID, regionID := normalizeDimension(event.CountryID, event.VisibleRegionID)
|
||
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
|
||
}
|
||
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_app_day_country
|
||
SET gift_coin_spent = gift_coin_spent + ?, lucky_gift_turnover = lucky_gift_turnover + ?,
|
||
lucky_gift_payers = lucky_gift_payers + ?,
|
||
consumed_coin = consumed_coin + ?,
|
||
updated_at_ms = ?
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||
`, event.CoinSpent, luckyTurnover, luckyPayers, event.CoinSpent, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func realRoomRobotGiftCoins(event RoomGiftEvent) (int64, int64, int64) {
|
||
coin := event.CoinSpent
|
||
if coin <= 0 {
|
||
coin = event.GiftValue
|
||
}
|
||
switch realRoomRobotGiftKind(event) {
|
||
case "super_lucky":
|
||
return 0, 0, coin
|
||
case "lucky":
|
||
return 0, coin, 0
|
||
default:
|
||
return coin, 0, 0
|
||
}
|
||
}
|
||
|
||
func realRoomRobotGiftKind(event RoomGiftEvent) string {
|
||
giftType := strings.ToLower(strings.TrimSpace(event.GiftTypeCode))
|
||
poolID := strings.ToLower(strings.TrimSpace(event.PoolID))
|
||
switch {
|
||
case giftType == "super_lucky" || strings.Contains(poolID, "super_lucky"):
|
||
return "super_lucky"
|
||
case giftType == "lucky" || poolID != "":
|
||
return "lucky"
|
||
default:
|
||
return "normal"
|
||
}
|
||
}
|
||
|
||
func (r *Repository) ConsumeLuckyGiftReward(ctx context.Context, event LuckyGiftRewardEvent) error {
|
||
eventType := strings.TrimSpace(event.EventType)
|
||
if eventType == "" {
|
||
eventType = "WalletLuckyGiftRewardCredited"
|
||
}
|
||
return r.withEvent(ctx, SourceWallet, event.EventID, eventType, func(tx *sql.Tx, nowMS int64) error {
|
||
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||
amount := normalizeID(event.Amount)
|
||
poolID := strings.TrimSpace(event.PoolID)
|
||
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 = ?
|
||
`, amount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID, poolID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE stat_app_day_country
|
||
SET lucky_gift_payout = lucky_gift_payout + ?,
|
||
output_coin = output_coin + ?,
|
||
updated_at_ms = ?
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||
`, amount, amount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func (r *Repository) ConsumePlatformGrantCoin(ctx context.Context, event PlatformGrantCoinEvent) error {
|
||
eventType := strings.TrimSpace(event.EventType)
|
||
if eventType == "" {
|
||
eventType = "WalletPlatformGrantCoinCredited"
|
||
}
|
||
return r.withEvent(ctx, SourceWallet, event.EventID, eventType, func(tx *sql.Tx, nowMS int64) error {
|
||
countryID, regionID, err := r.dimensionForUser(ctx, tx, event.AppCode, event.UserID, event.CountryID, event.RegionID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
amount := normalizeID(event.Amount)
|
||
if amount == 0 {
|
||
return nil
|
||
}
|
||
platformGrant, outputCoin := int64(0), int64(0)
|
||
switch {
|
||
case isOutputCoinRewardEventType(eventType):
|
||
outputCoin = amount
|
||
default:
|
||
platformGrant = amount
|
||
}
|
||
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 _, err := tx.ExecContext(ctx, `
|
||
UPDATE stat_app_day_country
|
||
SET platform_grant_coin = platform_grant_coin + ?,
|
||
output_coin = output_coin + ?,
|
||
updated_at_ms = ?
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||
`, platformGrant, outputCoin, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||
return err
|
||
}
|
||
if platformGrant > 0 {
|
||
// 保存事件级来源,后续 Databi 下钻按国家 -> 用户 -> 每条奖励来源分页查询,不需要扫 owner service 账务表。
|
||
if err := insertPlatformGrantCoinEvent(ctx, tx, event.AppCode, scope.tz, scope.day, event.EventID, eventType, event.UserID, countryID, regionID, platformGrant, event.OccurredAtMS, nowMS); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func (r *Repository) ConsumeCoinOutput(ctx context.Context, event CoinOutputEvent) error {
|
||
eventType := strings.TrimSpace(event.EventType)
|
||
if eventType == "" {
|
||
eventType = "WalletCoinOutputCredited"
|
||
}
|
||
return r.withEvent(ctx, SourceWallet, event.EventID, eventType, func(tx *sql.Tx, nowMS int64) error {
|
||
countryID, regionID, err := r.dimensionForUser(ctx, tx, event.AppCode, event.UserID, event.CountryID, event.RegionID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
amount := normalizeID(event.Amount)
|
||
if amount == 0 {
|
||
return 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
|
||
}
|
||
// 产出金币只写统计读模型总量;来源明细仍由各 owner 的 outbox 和幂等表保留,查询层不回扫钱包流水。
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE stat_app_day_country
|
||
SET output_coin = output_coin + ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||
`, amount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func (r *Repository) ConsumeWalletBalanceChanged(ctx context.Context, event WalletBalanceChangedEvent) error {
|
||
return r.withEvent(ctx, SourceWallet, event.EventID, "WalletBalanceChanged", func(tx *sql.Tx, nowMS int64) error {
|
||
dimensionUserID := event.UserID
|
||
if event.DimensionUserID > 0 {
|
||
dimensionUserID = event.DimensionUserID
|
||
}
|
||
countryID, regionID, err := r.dimensionForUser(ctx, tx, event.AppCode, dimensionUserID, event.CountryID, event.RegionID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
totalDelta := event.AvailableDelta + event.FrozenDelta
|
||
coinTotal, consumed, outputCoin, platformGrant, manualGrant, salaryUSD, salaryTransferCoin := int64(0), int64(0), int64(0), int64(0), int64(0), int64(0), int64(0)
|
||
assetType := strings.ToUpper(strings.TrimSpace(event.AssetType))
|
||
bizType := strings.ToLower(strings.TrimSpace(event.BizType))
|
||
if assetType == "COIN" {
|
||
coinTotal = totalDelta
|
||
if totalDelta < 0 && isConsumedCoinBizType(bizType) {
|
||
consumed = -totalDelta
|
||
}
|
||
if totalDelta > 0 && isOutputCoinBizType(bizType) {
|
||
outputCoin = totalDelta
|
||
}
|
||
if totalDelta > 0 && bizType == "resource_grant" && isPlatformGrantCommandID(event.CommandID) {
|
||
platformGrant = totalDelta
|
||
}
|
||
if totalDelta > 0 && bizType == "manual_credit" {
|
||
manualGrant = totalDelta
|
||
}
|
||
}
|
||
if isHostAgencySalaryAssetType(assetType) {
|
||
// 工资总和是主播/代理工资钱包余额快照,不是结算流水。这里写 available+frozen 的净变化,
|
||
// 冻结动作本身一正一负不改变总额,入账/出账/兑换才会改变当天余额快照。
|
||
salaryUSD = totalDelta
|
||
}
|
||
if totalDelta > 0 && bizType == "salary_transfer_to_coin_seller" && assetType == "COIN_SELLER_COIN" {
|
||
salaryTransferCoin = totalDelta
|
||
}
|
||
if coinTotal == 0 && consumed == 0 && outputCoin == 0 && platformGrant == 0 && manualGrant == 0 && salaryUSD == 0 && salaryTransferCoin == 0 {
|
||
return 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 _, err := tx.ExecContext(ctx, `
|
||
UPDATE stat_app_day_country
|
||
SET coin_total = coin_total + ?,
|
||
consumed_coin = consumed_coin + ?,
|
||
output_coin = output_coin + ?,
|
||
platform_grant_coin = platform_grant_coin + ?,
|
||
manual_grant_coin = manual_grant_coin + ?,
|
||
salary_usd_minor = salary_usd_minor + ?,
|
||
salary_transfer_coin = salary_transfer_coin + ?,
|
||
updated_at_ms = ?
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||
`, coinTotal, consumed, outputCoin, platformGrant, manualGrant, salaryUSD, salaryTransferCoin, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func (r *Repository) ConsumeUserMicDailyStatsUpdated(ctx context.Context, event UserMicDailyStatsUpdatedEvent) error {
|
||
return r.withEvent(ctx, SourceUser, event.EventID, "UserMicDailyStatsUpdated", func(tx *sql.Tx, nowMS int64) error {
|
||
if event.MicOnlineDeltaMS == 0 || strings.TrimSpace(event.StatDate) == "" {
|
||
return nil
|
||
}
|
||
countryID, regionID, err := r.dimensionForUser(ctx, tx, event.AppCode, event.UserID, 0, 0)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, scope := range statDateScopesFromContext(ctx, event.StatDate) {
|
||
if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil {
|
||
return err
|
||
}
|
||
before, err := micOnlineBefore(ctx, tx, event.AppCode, scope.tz, scope.day, event.UserID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
after := before + event.MicOnlineDeltaMS
|
||
if after < 0 {
|
||
after = 0
|
||
}
|
||
userDelta := int64(0)
|
||
if before <= 0 && after > 0 {
|
||
userDelta = 1
|
||
} else if before > 0 && after <= 0 {
|
||
userDelta = -1
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `
|
||
INSERT INTO stat_user_day_mic (app_code, stat_tz, stat_day, country_id, region_id, user_id, mic_online_ms, updated_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
country_id = VALUES(country_id),
|
||
region_id = VALUES(region_id),
|
||
mic_online_ms = VALUES(mic_online_ms),
|
||
updated_at_ms = VALUES(updated_at_ms)
|
||
`, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID, event.UserID, after, nowMS); err != nil {
|
||
return err
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE stat_app_day_country
|
||
SET mic_online_ms = mic_online_ms + ?,
|
||
mic_online_users = GREATEST(mic_online_users + ?, 0),
|
||
updated_at_ms = ?
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||
`, after-before, userDelta, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||
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)
|
||
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 {
|
||
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||
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
|
||
}
|
||
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 + ?,
|
||
consumed_coin = consumed_coin + ?,
|
||
output_coin = output_coin + ?,
|
||
updated_at_ms = ?
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||
`, turnover, payout, refund, playerDelta, turnover, payout, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func (r *Repository) ConsumeSelfGameH5Event(ctx context.Context, event SelfGameH5Event) error {
|
||
eventName := normalizeShortText(event.EventName)
|
||
if eventName == "" {
|
||
eventName = "unknown"
|
||
}
|
||
return r.withEvent(ctx, SourceH5, event.EventID, eventName, func(tx *sql.Tx, nowMS int64) error {
|
||
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||
gameID := normalizeShortText(event.GameID)
|
||
if gameID == "" {
|
||
gameID = "unknown"
|
||
}
|
||
language := normalizeShortText(event.Language)
|
||
clientVersion := normalizeShortText(event.ClientVersion)
|
||
h5Version := normalizeShortText(event.H5Version)
|
||
entrySource := normalizeShortText(event.EntrySource)
|
||
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
|
||
}
|
||
}
|
||
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 {
|
||
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||
gameID := normalizeShortText(event.GameID)
|
||
if gameID == "" {
|
||
gameID = "unknown"
|
||
}
|
||
stakeCoin := normalizeID(event.StakeCoin)
|
||
created, matched, settled, canceled, failed := selfGameMatchCounters(event.EventType)
|
||
humanMatch, robotMatch := int64(0), int64(0)
|
||
humanParticipants, robotParticipants := int64(0), int64(0)
|
||
userStake, robotStake, payout, refund, platformProfit, poolDelta := int64(0), int64(0), int64(0), int64(0), int64(0), int64(0)
|
||
winners, losers, draws := int64(0), int64(0), int64(0)
|
||
forcedLose, waitMS, durationMS := int64(0), int64(0), int64(0)
|
||
if settled > 0 {
|
||
if event.UserParticipants > 0 {
|
||
humanMatch = 1
|
||
}
|
||
if event.RobotParticipants > 0 {
|
||
robotMatch = 1
|
||
}
|
||
humanParticipants = normalizeID(event.UserParticipants)
|
||
robotParticipants = normalizeID(event.RobotParticipants)
|
||
winners = normalizeID(event.WinnerCount)
|
||
losers = normalizeID(event.LoserCount)
|
||
draws = normalizeID(event.DrawCount)
|
||
userStake = normalizeID(event.UserStakeCoin)
|
||
robotStake = normalizeID(event.RobotStakeCoin)
|
||
payout = normalizeID(event.PayoutCoin)
|
||
refund = normalizeID(event.RefundCoin)
|
||
platformProfit = event.PlatformProfitCoin
|
||
poolDelta = event.PoolDeltaCoin
|
||
waitMS = normalizeID(event.WaitMS)
|
||
durationMS = normalizeID(event.DurationMS)
|
||
if strings.EqualFold(strings.TrimSpace(event.ForcedResult), "player_lose") {
|
||
forcedLose = 1
|
||
}
|
||
}
|
||
if canceled > 0 || failed > 0 || matched > 0 {
|
||
waitMS = normalizeID(event.WaitMS)
|
||
durationMS = normalizeID(event.DurationMS)
|
||
}
|
||
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, 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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
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 {
|
||
gameID := normalizeShortText(event.GameID)
|
||
if gameID == "" {
|
||
gameID = "unknown"
|
||
}
|
||
direction := normalizeShortText(event.Direction)
|
||
reason := normalizeShortText(event.Reason)
|
||
inCoin, outCoin := int64(0), int64(0)
|
||
switch direction {
|
||
case "in":
|
||
inCoin = normalizeID(event.AmountCoin)
|
||
case "out":
|
||
outCoin = normalizeID(event.AmountCoin)
|
||
}
|
||
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, statTZ string, day string, countryID int64, regionID int64, gameID string, stakeCoin int64, nowMS int64) error {
|
||
participantType := normalizeShortText(participant.ParticipantType)
|
||
if participantType == "" {
|
||
participantType = "user"
|
||
}
|
||
result := normalizeShortText(participant.Result)
|
||
gesture := normalizeShortText(participant.RPSGesture)
|
||
points := participant.DicePoints
|
||
if len(points) == 0 {
|
||
points = []int64{0}
|
||
}
|
||
for _, point := range points {
|
||
dicePoint := int64(0)
|
||
if point > 0 {
|
||
dicePoint = point
|
||
}
|
||
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, `
|
||
UPDATE stat_self_game_participant_day
|
||
SET participant_count = participant_count + 1,
|
||
payout_coin = payout_coin + ?,
|
||
net_win_coin = net_win_coin + ?,
|
||
updated_at_ms = ?
|
||
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), 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, statTZ, day, countryID, regionID, gameID, participant.UserID, nowMS); err != nil {
|
||
return err
|
||
}
|
||
win, lose, draw := int64(0), int64(0), int64(0)
|
||
switch result {
|
||
case "win":
|
||
win = 1
|
||
case "lose":
|
||
lose = 1
|
||
case "draw":
|
||
draw = 1
|
||
}
|
||
_, err := tx.ExecContext(ctx, `
|
||
UPDATE stat_self_game_user_day
|
||
SET match_count = match_count + 1,
|
||
win_count = win_count + ?,
|
||
lose_count = lose_count + ?,
|
||
draw_count = draw_count + ?,
|
||
stake_coin = stake_coin + ?,
|
||
payout_coin = payout_coin + ?,
|
||
net_win_coin = net_win_coin + ?,
|
||
updated_at_ms = ?
|
||
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
|
||
}
|
||
|
||
func selfGameMatchCounters(eventType string) (created, matched, settled, canceled, failed int64) {
|
||
switch strings.TrimSpace(eventType) {
|
||
case "SelfGameMatchCreated":
|
||
created = 1
|
||
case "SelfGameMatchReady":
|
||
matched = 1
|
||
case "SelfGameMatchSettled":
|
||
settled = 1
|
||
case "SelfGameMatchCanceled":
|
||
canceled = 1
|
||
case "SelfGameMatchFailed":
|
||
failed = 1
|
||
}
|
||
return created, matched, settled, canceled, failed
|
||
}
|
||
|
||
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 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, 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_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
|
||
}
|
||
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_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||
`, nowMS, appcode.Normalize(app), normalizeStatTZ(statTZ), day, countryID, regionID)
|
||
return err
|
||
}
|
||
|
||
func upsertUserDimension(ctx context.Context, tx *sql.Tx, app string, userID int64, countryID int64, regionID int64, nowMS int64) error {
|
||
if userID <= 0 {
|
||
return nil
|
||
}
|
||
_, err := tx.ExecContext(ctx, `
|
||
INSERT INTO stat_user_dimension (app_code, user_id, country_id, region_id, updated_at_ms)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
country_id = CASE WHEN VALUES(country_id) > 0 THEN VALUES(country_id) ELSE country_id END,
|
||
region_id = CASE WHEN VALUES(region_id) > 0 THEN VALUES(region_id) ELSE region_id END,
|
||
updated_at_ms = VALUES(updated_at_ms)
|
||
`, appcode.Normalize(app), userID, normalizeID(countryID), normalizeID(regionID), nowMS)
|
||
return err
|
||
}
|
||
|
||
func (r *Repository) dimensionForUser(ctx context.Context, tx *sql.Tx, app string, userID int64, countryID int64, regionID int64) (int64, int64, error) {
|
||
countryID, regionID = normalizeDimension(countryID, regionID)
|
||
if (countryID > 0 && regionID > 0) || userID <= 0 {
|
||
return countryID, regionID, nil
|
||
}
|
||
var storedCountryID, storedRegionID int64
|
||
err := tx.QueryRowContext(ctx, `
|
||
SELECT country_id, region_id
|
||
FROM stat_user_dimension
|
||
WHERE app_code = ? AND user_id = ?
|
||
`, appcode.Normalize(app), userID).Scan(&storedCountryID, &storedRegionID)
|
||
if err != nil && err != sql.ErrNoRows {
|
||
return 0, 0, err
|
||
}
|
||
if countryID == 0 {
|
||
countryID = storedCountryID
|
||
}
|
||
if regionID == 0 {
|
||
regionID = storedRegionID
|
||
}
|
||
countryID, regionID = normalizeDimension(countryID, regionID)
|
||
return countryID, regionID, nil
|
||
}
|
||
|
||
func isConsumedCoinBizType(value string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "direct_gift_debit", "red_packet_create", "wheel_draw_debit":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isOutputCoinBizType(value string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "red_packet_claim", "red_packet_refund":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isHostAgencySalaryAssetType(value string) bool {
|
||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||
case "HOST_SALARY_USD", "AGENCY_SALARY_USD":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isOutputCoinRewardEventType(value string) bool {
|
||
switch strings.TrimSpace(value) {
|
||
case "WalletWheelRewardCredited":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isPlatformGrantCommandID(value string) bool {
|
||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||
if normalized == "" {
|
||
return false
|
||
}
|
||
switch {
|
||
case strings.HasPrefix(normalized, "weekly_star:"):
|
||
return true
|
||
case strings.HasPrefix(normalized, "cp_weekly_rank:"):
|
||
return true
|
||
case strings.HasPrefix(normalized, "room_turnover_reward:"):
|
||
return true
|
||
case strings.HasPrefix(normalized, "wcheckin_"):
|
||
return true
|
||
case strings.HasPrefix(normalized, "cmd_room_rocket_grant_"):
|
||
return true
|
||
case strings.HasPrefix(normalized, "wtask_"), strings.HasPrefix(normalized, "wfr_"), strings.HasPrefix(normalized, "wcr_"), strings.HasPrefix(normalized, "wreg_"):
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func statDateScopesFromContext(ctx context.Context, statDate string) []statDayScope {
|
||
day := strings.TrimSpace(statDate)
|
||
if ctx != nil {
|
||
if statTZ, ok := ctx.Value(statTZScopeContextKey{}).(string); ok {
|
||
return []statDayScope{{tz: normalizeStatTZ(statTZ), day: day}}
|
||
}
|
||
}
|
||
return []statDayScope{{tz: StatTZUTC, day: day}, {tz: StatTZAsiaShanghai, day: day}}
|
||
}
|
||
|
||
func micOnlineBefore(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, userID int64) (int64, error) {
|
||
var before int64
|
||
err := tx.QueryRowContext(ctx, `
|
||
SELECT mic_online_ms
|
||
FROM stat_user_day_mic
|
||
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND user_id = ?
|
||
FOR UPDATE
|
||
`, appcode.Normalize(app), normalizeStatTZ(statTZ), day, userID).Scan(&before)
|
||
if err == sql.ErrNoRows {
|
||
return 0, nil
|
||
}
|
||
return before, err
|
||
}
|
||
|
||
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_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), normalizeStatTZ(statTZ), day, countryID, regionID, nowMS)
|
||
return err
|
||
}
|
||
|
||
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_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), normalizeStatTZ(statTZ), day, countryID, regionID, platformCode, gameID, nowMS)
|
||
return err
|
||
}
|
||
|
||
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_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), normalizeStatTZ(statTZ), day, countryID, regionID, strings.TrimSpace(poolID), nowMS)
|
||
return err
|
||
}
|
||
|
||
func insertPlatformGrantCoinEvent(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, eventID string, eventType string, userID int64, countryID int64, regionID int64, amount int64, occurredAtMS int64, nowMS int64) error {
|
||
eventID = strings.TrimSpace(eventID)
|
||
eventType = strings.TrimSpace(eventType)
|
||
amount = normalizeID(amount)
|
||
if eventID == "" || userID <= 0 || amount <= 0 {
|
||
return nil
|
||
}
|
||
if eventType == "" {
|
||
eventType = "WalletPlatformGrantCoinCredited"
|
||
}
|
||
// withEvent 已经保证同一个钱包事件只聚合一次;这里仍用 INSERT IGNORE,让补偿消费或历史修复遇到旧明细时不回滚当天汇总事务。
|
||
_, err := tx.ExecContext(ctx, `
|
||
INSERT IGNORE INTO stat_platform_grant_coin_events (
|
||
app_code, stat_tz, stat_day, event_id, event_type, user_id, country_id, region_id,
|
||
amount, occurred_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, appcode.Normalize(app), normalizeStatTZ(statTZ), day, eventID, eventType, userID, countryID, regionID, amount, occurredAtMS, nowMS)
|
||
return err
|
||
}
|
||
|
||
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_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), normalizeStatTZ(statTZ), day, countryID, regionID, gameID, eventName, language, clientVersion, h5Version, entrySource, nowMS)
|
||
return err
|
||
}
|
||
|
||
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_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), normalizeStatTZ(statTZ), day, countryID, regionID, gameID, stakeCoin, nowMS)
|
||
return err
|
||
}
|
||
|
||
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_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), 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
|
||
}
|
||
|
||
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 statDayIn(ms, StatTZUTC)
|
||
}
|
||
|
||
func normalizeID(id int64) int64 {
|
||
if id < 0 {
|
||
return 0
|
||
}
|
||
return id
|
||
}
|
||
|
||
func normalizeShortText(value string) string {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
|
||
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 ""
|
||
}
|