package storage import ( "context" "errors" "fmt" driverMysql "github.com/go-sql-driver/mysql" ) func (r *MySQLRepository) EnsureSchema(ctx context.Context) error { if err := execAll(ctx, r.db, []string{ `CREATE TABLE IF NOT EXISTS dashboard_cdc_checkpoint ( service_name varchar(64) NOT NULL, binlog_file varchar(255) NOT NULL, binlog_pos int unsigned NOT NULL, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (service_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS dashboard_cdc_event_dedup ( event_key varchar(255) NOT NULL, source varchar(128) NOT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (event_key), KEY idx_dashboard_cdc_event_dedup_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS dashboard_cdc_user_country ( user_id bigint NOT NULL, sys_origin varchar(50) NOT NULL, country_code varchar(30) NOT NULL, country_name varchar(100) NOT NULL, is_deleted tinyint(1) NOT NULL DEFAULT 0, user_created_at datetime NULL, user_updated_at datetime NULL, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (user_id), KEY idx_dashboard_cdc_user_country_origin_country (sys_origin, country_code), KEY idx_dashboard_cdc_user_country_origin_created (sys_origin, user_created_at, country_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, createDailyMetricTable, createDailyUserMetricTable, createPeriodMetricTable, createPeriodUserMetricTable, createGamePeriodMetricTable, createGamePeriodUserMetricTable, createGameEventFactTable, createRechargeDetailTable, }); err != nil { return err } return r.ensureMetricColumns(ctx) } const createDailyMetricTable = `CREATE TABLE IF NOT EXISTS country_dashboard_daily_metric ( id bigint NOT NULL AUTO_INCREMENT, stat_date date NOT NULL, date_number int NOT NULL, sys_origin varchar(50) NOT NULL, country_code varchar(64) NOT NULL, country_name varchar(128) NOT NULL, country_new_user bigint NOT NULL DEFAULT 0, new_user_recharge decimal(20,2) NOT NULL DEFAULT 0, official_recharge decimal(20,2) NOT NULL DEFAULT 0, mifapay_recharge decimal(20,2) NOT NULL DEFAULT 0, google_recharge decimal(20,2) NOT NULL DEFAULT 0, dealer_recharge decimal(20,2) NOT NULL DEFAULT 0, user_recharge decimal(20,2) NOT NULL DEFAULT 0, new_dealer_user_recharge decimal(20,2) NOT NULL DEFAULT 0, salary_exchange decimal(20,2) NOT NULL DEFAULT 0, salary_transfer decimal(20,2) NOT NULL DEFAULT 0, gift_consume decimal(20,2) NOT NULL DEFAULT 0, lucky_gift_total_flow decimal(20,2) NOT NULL DEFAULT 0, lucky_gift_user bigint NOT NULL DEFAULT 0, lucky_gift_payout decimal(20,2) NOT NULL DEFAULT 0, game_total_flow decimal(20,2) NOT NULL DEFAULT 0, daily_recharge_user bigint NOT NULL DEFAULT 0, game_user bigint NOT NULL DEFAULT 0, game_payout decimal(20,2) NOT NULL DEFAULT 0, refreshed_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_daily_origin_country (stat_date, sys_origin, country_code), KEY idx_daily_origin_date (sys_origin, stat_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` const createDailyUserMetricTable = `CREATE TABLE IF NOT EXISTS country_dashboard_daily_user_metric ( id bigint NOT NULL AUTO_INCREMENT, stat_date date NOT NULL, sys_origin varchar(50) NOT NULL, country_code varchar(64) NOT NULL, country_name varchar(128) NOT NULL, user_id bigint NOT NULL, metric_type varchar(32) NOT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_daily_user_metric (stat_date, sys_origin, country_code, user_id, metric_type), KEY idx_daily_user_metric (sys_origin, user_id, metric_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` const createPeriodMetricTable = `CREATE TABLE IF NOT EXISTS country_dashboard_period_metric ( id bigint NOT NULL AUTO_INCREMENT, period_type varchar(16) NOT NULL, period_key varchar(32) NOT NULL, stat_timezone varchar(64) NOT NULL DEFAULT 'Asia/Riyadh', period_name varchar(64) NOT NULL, period_start_date date NULL, period_end_date date NULL, sys_origin varchar(50) NOT NULL, country_code varchar(64) NOT NULL, country_name varchar(128) NOT NULL, country_new_user bigint NOT NULL DEFAULT 0, new_user_recharge decimal(20,2) NOT NULL DEFAULT 0, official_recharge decimal(20,2) NOT NULL DEFAULT 0, mifapay_recharge decimal(20,2) NOT NULL DEFAULT 0, google_recharge decimal(20,2) NOT NULL DEFAULT 0, dealer_recharge decimal(20,2) NOT NULL DEFAULT 0, user_recharge decimal(20,2) NOT NULL DEFAULT 0, new_dealer_user_recharge decimal(20,2) NOT NULL DEFAULT 0, salary_exchange decimal(20,2) NOT NULL DEFAULT 0, salary_transfer decimal(20,2) NOT NULL DEFAULT 0, gift_consume decimal(20,2) NOT NULL DEFAULT 0, lucky_gift_total_flow decimal(20,2) NOT NULL DEFAULT 0, lucky_gift_user bigint NOT NULL DEFAULT 0, lucky_gift_payout decimal(20,2) NOT NULL DEFAULT 0, game_total_flow decimal(20,2) NOT NULL DEFAULT 0, daily_recharge_user bigint NOT NULL DEFAULT 0, game_user bigint NOT NULL DEFAULT 0, game_payout decimal(20,2) NOT NULL DEFAULT 0, refreshed_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_period_origin_country (period_type, period_key, stat_timezone, sys_origin, country_code), KEY idx_origin_period (sys_origin, stat_timezone, period_type, period_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` func (r *MySQLRepository) ensureMetricColumns(ctx context.Context) error { if err := r.addColumnIfMissing(ctx, "country_dashboard_daily_metric", "salary_transfer", "ALTER TABLE country_dashboard_daily_metric ADD COLUMN salary_transfer decimal(20,2) NOT NULL DEFAULT 0 AFTER salary_exchange"); err != nil { return err } if err := r.addColumnIfMissing(ctx, "country_dashboard_period_metric", "salary_transfer", "ALTER TABLE country_dashboard_period_metric ADD COLUMN salary_transfer decimal(20,2) NOT NULL DEFAULT 0 AFTER salary_exchange"); err != nil { return err } if err := r.addColumnIfMissing(ctx, "country_dashboard_daily_metric", "user_recharge", "ALTER TABLE country_dashboard_daily_metric ADD COLUMN user_recharge decimal(20,2) NOT NULL DEFAULT 0 AFTER dealer_recharge"); err != nil { return err } if err := r.addColumnIfMissing(ctx, "country_dashboard_period_metric", "user_recharge", "ALTER TABLE country_dashboard_period_metric ADD COLUMN user_recharge decimal(20,2) NOT NULL DEFAULT 0 AFTER dealer_recharge"); err != nil { return err } if err := r.addColumnIfMissing(ctx, "country_dashboard_daily_metric", "new_dealer_user_recharge", "ALTER TABLE country_dashboard_daily_metric ADD COLUMN new_dealer_user_recharge decimal(20,2) NOT NULL DEFAULT 0 AFTER user_recharge"); err != nil { return err } if err := r.addColumnIfMissing(ctx, "country_dashboard_period_metric", "new_dealer_user_recharge", "ALTER TABLE country_dashboard_period_metric ADD COLUMN new_dealer_user_recharge decimal(20,2) NOT NULL DEFAULT 0 AFTER user_recharge"); err != nil { return err } if err := r.addColumnIfMissing(ctx, "country_dashboard_daily_metric", "daily_recharge_user", "ALTER TABLE country_dashboard_daily_metric ADD COLUMN daily_recharge_user bigint NOT NULL DEFAULT 0 AFTER game_total_flow"); err != nil { return err } if err := r.addColumnIfMissing(ctx, "country_dashboard_period_metric", "daily_recharge_user", "ALTER TABLE country_dashboard_period_metric ADD COLUMN daily_recharge_user bigint NOT NULL DEFAULT 0 AFTER game_total_flow"); err != nil { return err } return r.addIndexIfMissing(ctx, "dashboard_cdc_user_country", "idx_dashboard_cdc_user_country_origin_created", "ALTER TABLE dashboard_cdc_user_country ADD INDEX idx_dashboard_cdc_user_country_origin_created (sys_origin, user_created_at, country_code)") } func (r *MySQLRepository) addColumnIfMissing(ctx context.Context, table string, column string, ddl string) error { var exists int err := r.db.QueryRowContext(ctx, ` SELECT COUNT(1) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? `, table, column).Scan(&exists) if err != nil { return err } if exists > 0 { return nil } if _, err := r.db.ExecContext(ctx, ddl); err != nil && !isDuplicateColumnError(err) { return err } return nil } func (r *MySQLRepository) addIndexIfMissing(ctx context.Context, table string, indexName string, ddl string) error { var exists int err := r.db.QueryRowContext(ctx, ` SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? `, table, indexName).Scan(&exists) if err != nil { return err } if exists > 0 { return nil } if _, err := r.db.ExecContext(ctx, ddl); err != nil && !isDuplicateKeyError(err) { return err } return nil } func isDuplicateColumnError(err error) bool { var mysqlErr *driverMysql.MySQLError if err == nil { return false } if errors.As(err, &mysqlErr) { return mysqlErr.Number == 1060 } return fmt.Sprint(err) == "Error 1060 (42S21): Duplicate column name 'salary_transfer'" } func isDuplicateKeyError(err error) bool { var mysqlErr *driverMysql.MySQLError if err == nil { return false } if errors.As(err, &mysqlErr) { return mysqlErr.Number == 1061 } return false } const createPeriodUserMetricTable = `CREATE TABLE IF NOT EXISTS country_dashboard_period_user_metric ( id bigint NOT NULL AUTO_INCREMENT, period_type varchar(16) NOT NULL, period_key varchar(32) NOT NULL, stat_timezone varchar(64) NOT NULL DEFAULT 'Asia/Riyadh', period_name varchar(64) NOT NULL, period_start_date date NULL, period_end_date date NULL, sys_origin varchar(50) NOT NULL, country_code varchar(64) NOT NULL, country_name varchar(128) NOT NULL, user_id bigint NOT NULL, metric_type varchar(32) NOT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_period_user_metric (period_type, period_key, stat_timezone, sys_origin, country_code, user_id, metric_type), KEY idx_origin_user_metric (sys_origin, stat_timezone, user_id, metric_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` const createGamePeriodMetricTable = `CREATE TABLE IF NOT EXISTS country_dashboard_game_period_metric ( id bigint NOT NULL AUTO_INCREMENT, period_type varchar(16) NOT NULL, period_key varchar(32) NOT NULL, stat_timezone varchar(64) NOT NULL DEFAULT 'Asia/Riyadh', period_name varchar(64) NOT NULL, period_start_date date NULL, period_end_date date NULL, sys_origin varchar(50) NOT NULL, country_code varchar(64) NOT NULL, country_name varchar(128) NOT NULL, game_provider varchar(32) NOT NULL, game_id varchar(128) NOT NULL, game_name varchar(256) NOT NULL, consume_amount decimal(20,2) NOT NULL DEFAULT 0, payout_amount decimal(20,2) NOT NULL DEFAULT 0, profit_amount decimal(20,2) NOT NULL DEFAULT 0, user_count bigint NOT NULL DEFAULT 0, order_count bigint NOT NULL DEFAULT 0, refreshed_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_game_period (sys_origin, stat_timezone, period_type, period_key, country_code, game_provider, game_id), KEY idx_game_period_game (sys_origin, stat_timezone, period_type, period_key, game_provider, game_id, country_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` const createGamePeriodUserMetricTable = `CREATE TABLE IF NOT EXISTS country_dashboard_game_period_user_metric ( id bigint NOT NULL AUTO_INCREMENT, period_type varchar(16) NOT NULL, period_key varchar(32) NOT NULL, stat_timezone varchar(64) NOT NULL DEFAULT 'Asia/Riyadh', period_name varchar(64) NOT NULL, period_start_date date NULL, period_end_date date NULL, sys_origin varchar(50) NOT NULL, country_code varchar(64) NOT NULL, country_name varchar(128) NOT NULL, game_provider varchar(32) NOT NULL, game_id varchar(128) NOT NULL, user_id bigint NOT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_game_period_user (period_type, period_key, stat_timezone, sys_origin, country_code, game_provider, game_id, user_id), KEY idx_game_user (sys_origin, stat_timezone, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` const createGameEventFactTable = `CREATE TABLE IF NOT EXISTS dashboard_game_event_fact ( fact_key varchar(255) NOT NULL, source_schema varchar(64) NOT NULL, source_table varchar(128) NOT NULL, source_pk varchar(128) NOT NULL, source_action varchar(16) NOT NULL, sys_origin varchar(50) NOT NULL, stat_date date NOT NULL, event_time datetime NOT NULL, user_id bigint NOT NULL, country_code varchar(64) NOT NULL, country_name varchar(128) NOT NULL, game_provider varchar(32) NOT NULL, game_id varchar(128) NOT NULL, game_name varchar(256) NOT NULL, consume_amount decimal(20,2) NOT NULL DEFAULT 0, payout_amount decimal(20,2) NOT NULL DEFAULT 0, profit_amount decimal(20,2) NOT NULL DEFAULT 0, order_count bigint NOT NULL DEFAULT 0, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (fact_key), KEY idx_game_fact_date_origin_country (stat_date, sys_origin, country_code), KEY idx_game_fact_date_game (stat_date, sys_origin, game_provider, game_id), KEY idx_game_fact_user (stat_date, sys_origin, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` const createRechargeDetailTable = `CREATE TABLE IF NOT EXISTS country_dashboard_recharge_detail ( id bigint NOT NULL AUTO_INCREMENT, source_type varchar(32) NOT NULL, record_id varchar(128) NOT NULL, source_channel varchar(64) NOT NULL DEFAULT '', sys_origin varchar(50) NOT NULL, user_id bigint NOT NULL, country_code varchar(64) NOT NULL DEFAULT 'UNKNOWN', country_name varchar(128) NOT NULL DEFAULT 'UNKNOWN', amount decimal(20,2) NOT NULL DEFAULT 0, coin_quantity decimal(20,2) NULL, remark varchar(512) NOT NULL DEFAULT '', create_time datetime NOT NULL, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_recharge_source_record (source_type, record_id), KEY idx_recharge_query (sys_origin, create_time, country_code), KEY idx_recharge_user (user_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`