// Package mysql 实现 room-service 的 MySQL 持久化边界。 package mysql import "context" // Migrate 创建 room-service 首版需要的 MySQL 表。 func (r *Repository) Migrate(ctx context.Context) error { // 这里保持与 deploy/mysql/initdb 的表结构一致,服务本地启动可自动建表。 statements := []string{ `CREATE TABLE IF NOT EXISTS rooms ( app_code VARCHAR(32) NOT NULL, room_id VARCHAR(64) NOT NULL, room_short_id VARCHAR(32) NOT NULL DEFAULT '', owner_user_id BIGINT NOT NULL, seat_count INT NOT NULL, mode VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, room_password_hash VARCHAR(128) NOT NULL DEFAULT '', close_reason VARCHAR(512) NOT NULL DEFAULT '', closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '', closed_at_ms BIGINT NOT NULL DEFAULT 0, visible_region_id BIGINT NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, room_id), UNIQUE KEY uk_rooms_short_id (app_code, room_short_id), UNIQUE KEY uk_rooms_owner_user (app_code, owner_user_id), KEY idx_rooms_region_status (app_code, visible_region_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_list_entries ( app_code VARCHAR(32) NOT NULL, room_id VARCHAR(64) NOT NULL, room_short_id VARCHAR(32) NOT NULL DEFAULT '', visible_region_id BIGINT NOT NULL DEFAULT 0, owner_country_code VARCHAR(3) NOT NULL DEFAULT '', owner_user_id BIGINT NOT NULL, title VARCHAR(128) NOT NULL DEFAULT '', cover_url VARCHAR(512) NOT NULL DEFAULT '', mode VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, locked BOOLEAN NOT NULL DEFAULT FALSE, close_reason VARCHAR(512) NOT NULL DEFAULT '', closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '', closed_at_ms BIGINT NOT NULL DEFAULT 0, heat BIGINT NOT NULL DEFAULT 0, weekly_contribution BIGINT NOT NULL DEFAULT 0, contribution_week_start_ms BIGINT NOT NULL DEFAULT 0, online_count INT NOT NULL DEFAULT 0, seat_count INT NOT NULL DEFAULT 0, occupied_seat_count INT NOT NULL DEFAULT 0, sort_score BIGINT NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, room_id), KEY idx_room_list_short_id (app_code, room_short_id), KEY idx_room_list_region_hot (app_code, visible_region_id, status, sort_score DESC, room_id), KEY idx_room_list_region_new (app_code, visible_region_id, status, created_at_ms DESC, room_id), KEY idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id), KEY idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id), KEY idx_room_list_weekly_contribution (app_code, status, visible_region_id, weekly_contribution DESC, created_at_ms DESC, room_id), KEY idx_room_list_owner (app_code, owner_user_id, updated_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_user_presence ( app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, room_id VARCHAR(64) NOT NULL, role VARCHAR(32) NOT NULL, publish_state VARCHAR(32) NOT NULL DEFAULT '', mic_session_id VARCHAR(128) NOT NULL DEFAULT '', room_version BIGINT NOT NULL, status VARCHAR(32) NOT NULL, joined_at_ms BIGINT NOT NULL DEFAULT 0, last_seen_at_ms BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, user_id), KEY idx_room_user_presence_room (app_code, room_id, status), KEY idx_room_user_presence_updated (app_code, status, updated_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_user_gift_stats ( app_code VARCHAR(32) NOT NULL, room_id VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, gift_value BIGINT NOT NULL DEFAULT 0, last_gift_at_ms BIGINT NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, room_id, user_id), KEY idx_room_user_gift_stats_room_value (app_code, room_id, gift_value DESC, last_gift_at_ms DESC, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_background_images ( app_code VARCHAR(32) NOT NULL, background_id BIGINT NOT NULL AUTO_INCREMENT, room_id VARCHAR(64) NOT NULL, image_url VARCHAR(256) NOT NULL, created_by_user_id BIGINT NOT NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (background_id), UNIQUE KEY uk_room_background_url (app_code, room_id, image_url), KEY idx_room_background_list (app_code, room_id, created_at_ms DESC, background_id DESC) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_snapshots ( app_code VARCHAR(32) NOT NULL, room_id VARCHAR(64) NOT NULL, room_version BIGINT NOT NULL, payload LONGBLOB NOT NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, room_id), INDEX idx_room_snapshots_version (app_code, room_id, room_version) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_command_log ( app_code VARCHAR(32) NOT NULL, id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, room_id VARCHAR(64) NOT NULL, room_version BIGINT NOT NULL, command_id VARCHAR(128) NOT NULL, command_type VARCHAR(64) NOT NULL, owner_node_id VARCHAR(128) NOT NULL DEFAULT '', lease_token VARCHAR(128) NOT NULL DEFAULT '', payload LONGBLOB NOT NULL, replayable BOOLEAN NOT NULL, created_at_ms BIGINT NOT NULL, UNIQUE KEY uk_room_command (app_code, room_id, command_id), KEY idx_room_command_replay (app_code, room_id, room_version) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_outbox ( app_code VARCHAR(32) NOT NULL, event_id VARCHAR(128) NOT NULL, event_type VARCHAR(64) NOT NULL, room_id VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, worker_id VARCHAR(128) NOT NULL DEFAULT '', lock_until_ms BIGINT NULL, envelope LONGBLOB NOT NULL, created_at_ms BIGINT NOT NULL, retry_count INT NOT NULL DEFAULT 0, next_retry_at_ms BIGINT NOT NULL DEFAULT 0, last_error TEXT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, event_id), KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id), KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id), KEY idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id), KEY idx_room_outbox_retention (app_code, status, updated_at_ms, event_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_user_feed_entries ( app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, feed_type VARCHAR(32) NOT NULL, room_id VARCHAR(64) NOT NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, user_id, feed_type, room_id), KEY idx_room_user_feed_list (app_code, user_id, feed_type, updated_at_ms DESC, room_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_follows ( app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, room_id VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, followed_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, user_id, room_id), KEY idx_room_follows_user_list (app_code, user_id, status, followed_at_ms DESC, room_id), KEY idx_room_follows_room (app_code, room_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_region_pins ( id BIGINT NOT NULL AUTO_INCREMENT, app_code VARCHAR(32) NOT NULL, visible_region_id BIGINT NOT NULL, pin_type VARCHAR(32) NOT NULL DEFAULT 'region', room_id VARCHAR(64) NOT NULL, weight BIGINT NOT NULL DEFAULT 0, status VARCHAR(32) NOT NULL, pinned_at_ms BIGINT NOT NULL, expires_at_ms BIGINT NOT NULL, cancelled_at_ms BIGINT NOT NULL DEFAULT 0, created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, cancelled_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_room_region_pins_scope (app_code, pin_type, visible_region_id, room_id), KEY idx_room_region_pins_active_scope (app_code, pin_type, visible_region_id, status, pinned_at_ms, expires_at_ms, weight DESC, room_id), KEY idx_room_region_pins_room (app_code, room_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间置顶表'`, `CREATE TABLE IF NOT EXISTS room_seat_configs ( app_code VARCHAR(32) NOT NULL, allowed_seat_counts VARCHAR(128) NOT NULL, default_seat_count INT NOT NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_rocket_configs ( app_code VARCHAR(32) NOT NULL, enabled TINYINT(1) NOT NULL DEFAULT 0, config_version BIGINT NOT NULL DEFAULT 1, fuel_source VARCHAR(32) NOT NULL, launch_delay_ms BIGINT NOT NULL, broadcast_enabled TINYINT(1) NOT NULL DEFAULT 1, broadcast_scope VARCHAR(32) NOT NULL, broadcast_delay_ms BIGINT NOT NULL DEFAULT 0, reward_stack_policy VARCHAR(32) NOT NULL, levels_json JSON NOT NULL, gift_fuel_rules_json JSON NOT NULL, updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code), KEY idx_room_rocket_enabled (app_code, enabled) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_robot_rooms ( app_code VARCHAR(32) NOT NULL, room_id VARCHAR(64) NOT NULL, room_short_id VARCHAR(32) NOT NULL DEFAULT '', title VARCHAR(128) NOT NULL DEFAULT '', cover_url VARCHAR(512) NOT NULL DEFAULT '', visible_region_id BIGINT NOT NULL DEFAULT 0, status VARCHAR(32) NOT NULL, owner_robot_user_id BIGINT NOT NULL, robot_user_ids_json JSON NOT NULL, active_robot_count INT NOT NULL DEFAULT 0, seat_count INT NOT NULL DEFAULT 10, gift_ids_json JSON NOT NULL, lucky_gift_ids_json JSON NOT NULL, normal_gift_interval_ms BIGINT NOT NULL, lucky_combo_min BIGINT NOT NULL, lucky_combo_max BIGINT NOT NULL, lucky_pause_min_ms BIGINT NOT NULL, lucky_pause_max_ms BIGINT NOT NULL, robot_stay_min_ms BIGINT NOT NULL DEFAULT 180000, robot_stay_max_ms BIGINT NOT NULL DEFAULT 600000, robot_replace_min_ms BIGINT NOT NULL DEFAULT 0, robot_replace_max_ms BIGINT NOT NULL DEFAULT 60000, max_gift_senders BIGINT NOT NULL DEFAULT 1, created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, room_id), KEY idx_room_robot_rooms_status (app_code, status, updated_at_ms, room_id), KEY idx_room_robot_rooms_owner (app_code, owner_robot_user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_human_robot_configs ( app_code VARCHAR(32) NOT NULL, enabled BOOLEAN NOT NULL DEFAULT FALSE, candidate_room_max_online INT NOT NULL DEFAULT 5, room_full_stop_online INT NOT NULL DEFAULT 10, room_target_min_online INT NOT NULL DEFAULT 8, room_target_max_online INT NOT NULL DEFAULT 10, robot_stay_min_ms BIGINT NOT NULL DEFAULT 60000, robot_stay_max_ms BIGINT NOT NULL DEFAULT 600000, robot_replace_min_ms BIGINT NOT NULL DEFAULT 60000, robot_replace_max_ms BIGINT NOT NULL DEFAULT 180000, normal_gift_interval_ms BIGINT NOT NULL DEFAULT 10000, normal_gift_interval_min_ms BIGINT NOT NULL DEFAULT 1000, normal_gift_interval_max_ms BIGINT NOT NULL DEFAULT 20000, gift_ids_json JSON NOT NULL, lucky_gift_ids_json JSON NOT NULL, super_lucky_gift_ids_json JSON NOT NULL, lucky_combo_min BIGINT NOT NULL DEFAULT 1, lucky_combo_max BIGINT NOT NULL DEFAULT 3, lucky_pause_min_ms BIGINT NOT NULL DEFAULT 5000, lucky_pause_max_ms BIGINT NOT NULL DEFAULT 20000, max_gift_senders BIGINT NOT NULL DEFAULT 1, country_pools_json JSON NOT NULL, allowed_owner_ids_json JSON NOT NULL, country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE, country_rules_json JSON NOT NULL, updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code), KEY idx_room_human_robot_enabled (app_code, enabled) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `INSERT IGNORE INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms) VALUES ('lalu', '[10,15,20,25,30]', 15, 0, 0)`, `INSERT IGNORE INTO room_rocket_configs ( app_code, enabled, config_version, fuel_source, launch_delay_ms, broadcast_enabled, broadcast_scope, broadcast_delay_ms, reward_stack_policy, levels_json, gift_fuel_rules_json, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES ( 'lalu', 0, 1, 'heat_value', 30000, 1, 'region', 0, 'allow_stack', JSON_ARRAY( JSON_OBJECT('level', 1, 'fuelThreshold', 1000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), JSON_OBJECT('level', 2, 'fuelThreshold', 3000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), JSON_OBJECT('level', 3, 'fuelThreshold', 6000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), JSON_OBJECT('level', 4, 'fuelThreshold', 10000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), JSON_OBJECT('level', 5, 'fuelThreshold', 15000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()) ), JSON_ARRAY(), 0, 0, 0 )`, `ALTER TABLE rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_list_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_user_presence MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_snapshots MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_command_log MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_outbox MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_user_feed_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_follows MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_region_pins MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_seat_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_rocket_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_robot_rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, } for _, statement := range statements { // 每条 DDL 独立执行,失败时返回具体 SQL 错误给启动流程。 if _, err := r.db.ExecContext(ctx, statement); err != nil { return err } } if err := r.ensureRoomListLockSchema(ctx); err != nil { return err } if err := r.ensureRoomListCountrySchema(ctx); err != nil { return err } if err := r.ensureRoomPasswordSchema(ctx); err != nil { return err } if err := r.ensureRoomAdminCloseSchema(ctx); err != nil { return err } if err := r.ensureRoomWeeklyContributionSchema(ctx); err != nil { return err } if err := r.ensureRoomUserPresenceSchema(ctx); err != nil { return err } if err := r.ensureRoomPinSchema(ctx); err != nil { return err } if err := r.ensureRobotRoomBehaviorSchema(ctx); err != nil { return err } if err := r.ensureHumanRobotConfigBehaviorSchema(ctx); err != nil { return err } return r.ensureOutboxRetrySchema(ctx) } func (r *Repository) ensureHumanRobotConfigBehaviorSchema(ctx context.Context) error { columns := []struct { name string statement string }{ {name: "normal_gift_interval_min_ms", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN normal_gift_interval_min_ms BIGINT NOT NULL DEFAULT 1000 AFTER normal_gift_interval_ms`}, {name: "normal_gift_interval_max_ms", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN normal_gift_interval_max_ms BIGINT NOT NULL DEFAULT 20000 AFTER normal_gift_interval_min_ms`}, {name: "room_target_min_online", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN room_target_min_online INT NOT NULL DEFAULT 8 AFTER room_full_stop_online`}, {name: "room_target_max_online", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN room_target_max_online INT NOT NULL DEFAULT 10 AFTER room_target_min_online`}, {name: "allowed_owner_ids_json", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN allowed_owner_ids_json JSON NULL AFTER country_pools_json`}, {name: "country_limit_enabled", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER allowed_owner_ids_json`}, {name: "country_rules_json", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN country_rules_json JSON NULL AFTER country_limit_enabled`}, } for _, column := range columns { exists, err := r.columnExists(ctx, "room_human_robot_configs", column.name) if err != nil { return err } if exists { continue } if _, err := r.db.ExecContext(ctx, column.statement); err != nil { return err } } return nil } func (r *Repository) ensureRobotRoomBehaviorSchema(ctx context.Context) error { columns := []struct { name string statement string }{ {name: "active_robot_count", statement: `ALTER TABLE room_robot_rooms ADD COLUMN active_robot_count INT NOT NULL DEFAULT 0 AFTER robot_user_ids_json`}, {name: "seat_count", statement: `ALTER TABLE room_robot_rooms ADD COLUMN seat_count INT NOT NULL DEFAULT 10 AFTER active_robot_count`}, {name: "robot_stay_min_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_stay_min_ms BIGINT NOT NULL DEFAULT 180000 AFTER lucky_pause_max_ms`}, {name: "robot_stay_max_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_stay_max_ms BIGINT NOT NULL DEFAULT 600000 AFTER robot_stay_min_ms`}, {name: "robot_replace_min_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_replace_min_ms BIGINT NOT NULL DEFAULT 0 AFTER robot_stay_max_ms`}, {name: "robot_replace_max_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_replace_max_ms BIGINT NOT NULL DEFAULT 60000 AFTER robot_replace_min_ms`}, {name: "max_gift_senders", statement: `ALTER TABLE room_robot_rooms ADD COLUMN max_gift_senders BIGINT NOT NULL DEFAULT 1 AFTER robot_replace_max_ms`}, } for _, column := range columns { exists, err := r.columnExists(ctx, "room_robot_rooms", column.name) if err != nil { return err } if exists { continue } if _, err := r.db.ExecContext(ctx, column.statement); err != nil { return err } } return nil } func (r *Repository) ensureRoomWeeklyContributionSchema(ctx context.Context) error { columns := []struct { column string statement string }{ { column: "weekly_contribution", statement: `ALTER TABLE room_list_entries ADD COLUMN weekly_contribution BIGINT NOT NULL DEFAULT 0 AFTER heat`, }, { column: "contribution_week_start_ms", statement: `ALTER TABLE room_list_entries ADD COLUMN contribution_week_start_ms BIGINT NOT NULL DEFAULT 0 AFTER weekly_contribution`, }, } for _, item := range columns { hasColumn, err := r.columnExists(ctx, "room_list_entries", item.column) if err != nil { return err } if !hasColumn { if _, err := r.db.ExecContext(ctx, item.statement); err != nil { return err } } } hasIndex, err := r.indexExists(ctx, "room_list_entries", "idx_room_list_weekly_contribution") if err != nil { return err } if !hasIndex { // 后台按房间贡献排序时只读当前 UTC 周贡献;索引用于收敛 active/region 过滤后的排序成本。 if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD INDEX idx_room_list_weekly_contribution (app_code, status, visible_region_id, weekly_contribution DESC, created_at_ms DESC, room_id)`); err != nil { return err } } return nil } func (r *Repository) ensureRoomPinSchema(ctx context.Context) error { hasColumn, err := r.columnExists(ctx, "room_region_pins", "pin_type") if err != nil { return err } if !hasColumn { // pin_type 把“全区置顶”和“区域置顶”的作用域写进同一 owner 表;历史数据默认都是区域置顶。 if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD COLUMN pin_type VARCHAR(32) NOT NULL DEFAULT 'region' AFTER visible_region_id`); err != nil { return err } } hasScopeUnique, err := r.indexExists(ctx, "room_region_pins", "uk_room_region_pins_scope") if err != nil { return err } if !hasScopeUnique { if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD UNIQUE KEY uk_room_region_pins_scope (app_code, pin_type, visible_region_id, room_id)`); err != nil { return err } } hasLegacyUnique, err := r.indexExists(ctx, "room_region_pins", "uk_room_region_pins_room") if err != nil { return err } if hasLegacyUnique { // 新唯一键允许 region_id=0 的区域置顶和全区置顶并存;旧唯一键会错误地把两者互斥。 if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins DROP INDEX uk_room_region_pins_room`); err != nil { return err } } hasActiveScope, err := r.indexExists(ctx, "room_region_pins", "idx_room_region_pins_active_scope") if err != nil { return err } if !hasActiveScope { if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD INDEX idx_room_region_pins_active_scope (app_code, pin_type, visible_region_id, status, pinned_at_ms, expires_at_ms, weight DESC, room_id)`); err != nil { return err } } return nil } func (r *Repository) ensureRoomUserPresenceSchema(ctx context.Context) error { hasColumn, err := r.columnExists(ctx, "room_user_presence", "joined_at_ms") if err != nil { return err } if !hasColumn { if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_user_presence ADD COLUMN joined_at_ms BIGINT NOT NULL DEFAULT 0 AFTER status`); err != nil { return err } } return nil } func (r *Repository) ensureRoomPasswordSchema(ctx context.Context) error { hasColumn, err := r.columnExists(ctx, "rooms", "room_password_hash") if err != nil { return err } if !hasColumn { // rooms 是最新快照恢复密码哈希的持久来源;列表只使用 locked 布尔投影。 if _, err := r.db.ExecContext(ctx, `ALTER TABLE rooms ADD COLUMN room_password_hash VARCHAR(128) NOT NULL DEFAULT '' AFTER status`); err != nil { return err } } return nil } func (r *Repository) ensureRoomListLockSchema(ctx context.Context) error { hasColumn, err := r.columnExists(ctx, "room_list_entries", "locked") if err != nil { return err } if !hasColumn { // locked 是列表/搜索需要的纯投影字段,历史行默认 false,后续房间状态变更会刷新为真实值。 if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD COLUMN locked BOOLEAN NOT NULL DEFAULT FALSE AFTER status`); err != nil { return err } } return nil } func (r *Repository) ensureRoomListCountrySchema(ctx context.Context) error { hasColumn, err := r.columnExists(ctx, "room_list_entries", "owner_country_code") if err != nil { return err } if !hasColumn { // owner_country_code 是房主国家快照,只服务发现页国家筛选;历史行先为空,后续房间投影刷新会补真实值。 if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD COLUMN owner_country_code VARCHAR(3) NOT NULL DEFAULT '' AFTER visible_region_id`); err != nil { return err } } indexes := []struct { name string statement string }{ { name: "idx_room_list_region_country_hot", statement: `ALTER TABLE room_list_entries ADD INDEX idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id)`, }, { name: "idx_room_list_region_country_new", statement: `ALTER TABLE room_list_entries ADD INDEX idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id)`, }, } for _, item := range indexes { hasIndex, err := r.indexExists(ctx, "room_list_entries", item.name) if err != nil { return err } if !hasIndex { if _, err := r.db.ExecContext(ctx, item.statement); err != nil { return err } } } return nil } func (r *Repository) ensureRoomAdminCloseSchema(ctx context.Context) error { columns := []struct { table string column string statement string }{ {"rooms", "close_reason", `ALTER TABLE rooms ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER room_password_hash`}, {"rooms", "closed_by_admin_id", `ALTER TABLE rooms ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`}, {"rooms", "closed_by_admin_name", `ALTER TABLE rooms ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`}, {"rooms", "closed_at_ms", `ALTER TABLE rooms ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`}, {"room_list_entries", "close_reason", `ALTER TABLE room_list_entries ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER locked`}, {"room_list_entries", "closed_by_admin_id", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`}, {"room_list_entries", "closed_by_admin_name", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`}, {"room_list_entries", "closed_at_ms", `ALTER TABLE room_list_entries ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`}, } for _, item := range columns { hasColumn, err := r.columnExists(ctx, item.table, item.column) if err != nil { return err } if !hasColumn { if _, err := r.db.ExecContext(ctx, item.statement); err != nil { return err } } } return nil } func (r *Repository) ensureOutboxRetrySchema(ctx context.Context) error { hasColumn, err := r.columnExists(ctx, "room_outbox", "next_retry_at_ms") if err != nil { return err } if !hasColumn { if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_outbox ADD COLUMN next_retry_at_ms BIGINT NOT NULL DEFAULT 0 AFTER retry_count`); err != nil { return err } } indexes := []struct { name string statement string }{ {"idx_room_outbox_pending", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id)`}, {"idx_room_outbox_retry", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms, event_id)`}, {"idx_room_outbox_claim", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id)`}, {"idx_room_outbox_event_created", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id)`}, {"idx_room_outbox_retention", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retention (app_code, status, updated_at_ms, event_id)`}, } for _, index := range indexes { hasIndex, err := r.indexExists(ctx, "room_outbox", index.name) if err != nil { return err } if !hasIndex { if _, err := r.db.ExecContext(ctx, index.statement); err != nil { return err } } } return nil } func (r *Repository) columnExists(ctx context.Context, tableName string, columnName string) (bool, error) { var exists int err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? `, tableName, columnName).Scan(&exists) return exists > 0, err } func (r *Repository) indexExists(ctx context.Context, tableName string, indexName string) (bool, error) { var exists int err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? `, tableName, indexName).Scan(&exists) return exists > 0, err }