// Package mysql 实现 room-service 的 MySQL 持久化边界。 package mysql import ( "context" "database/sql" "encoding/json" "errors" "sort" "strconv" "strings" "time" mysqlerr "github.com/go-sql-driver/mysql" "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/outbox" roomservice "hyapp/services/room-service/internal/room/service" ) // Repository 把 room-service 的权威状态持久化到 MySQL。 type Repository struct { // db 是标准库连接池,所有 repository 方法都通过它访问 MySQL。 db *sql.DB } // Open 建立 MySQL 连接池,并验证数据库可用。 func Open(ctx context.Context, dsn string, maxOpenConns int, maxIdleConns int) (*Repository, error) { // sql.Open 只校验 driver 和 DSN 形态,真实连通性由后续 PingContext 验证。 db, err := sql.Open("mysql", dsn) if err != nil { return nil, err } if maxOpenConns > 0 { // 只在配置显式给出正数时覆盖标准库默认值。 db.SetMaxOpenConns(maxOpenConns) } if maxIdleConns > 0 { // 空闲连接数需要小于等于最大连接数,由配置层负责合理设置。 db.SetMaxIdleConns(maxIdleConns) } // 长连接定期轮换,避免云数据库或网络层回收导致长期坏连接驻留。 db.SetConnMaxLifetime(30 * time.Minute) if err := db.PingContext(ctx); err != nil { // 启动探测失败时必须关闭连接池,避免返回半可用 repository。 _ = db.Close() return nil, err } return &Repository{db: db}, nil } // Close 关闭底层 MySQL 连接池。 func (r *Repository) Close() error { // App.Close 调用该方法释放所有空闲连接,并阻止后续新查询。 return r.db.Close() } // Ping 验证 MySQL 连接池当前仍能在调用方 deadline 内拿到可用连接。 func (r *Repository) Ping(ctx context.Context) error { // ready 探针通过该方法确认 command log/snapshot/outbox 的持久化来源可用。 return r.db.PingContext(ctx) } // 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, 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_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`, `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`, } 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.ensureRoomUserPresenceSchema(ctx); err != nil { return err } if err := r.ensureRoomPinSchema(ctx); err != nil { return err } return r.ensureOutboxRetrySchema(ctx) } 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 } // SaveRoomMeta 插入房间元数据。 func (r *Repository) SaveRoomMeta(ctx context.Context, meta roomservice.RoomMeta) error { // rooms 表只保存恢复初始状态需要的元数据,实时态以 snapshot/command log 为准。 appCode := normalizedRecordAppCode(ctx, meta.AppCode) nowMS := time.Now().UTC().UnixMilli() _, err := r.db.ExecContext(ctx, `INSERT INTO rooms (app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appCode, meta.RoomID, meta.RoomShortID, meta.OwnerUserID, meta.SeatCount, meta.Mode, meta.Status, meta.RoomPasswordHash, meta.VisibleRegionID, nowMS, nowMS, ) return mapRoomMetaDuplicateError(err) } // GetRoomMeta 查询房间元数据。 func (r *Repository) GetRoomMeta(ctx context.Context, roomID string) (roomservice.RoomMeta, bool, error) { // meta 是无内存 Cell 恢复的入口;不存在时上层返回 room not found。 row := r.db.QueryRowContext(ctx, `SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id FROM rooms WHERE app_code = ? AND room_id = ?`, appcode.FromContext(ctx), roomID, ) var meta roomservice.RoomMeta if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil { if errors.Is(err, sql.ErrNoRows) { // 未创建房间不是数据库错误,用 exists=false 表达。 return roomservice.RoomMeta{}, false, nil } return roomservice.RoomMeta{}, false, err } return meta, true, nil } // GetRoomMetaByOwner 查询用户已经创建的房间。 func (r *Repository) GetRoomMetaByOwner(ctx context.Context, ownerUserID int64) (roomservice.RoomMeta, bool, error) { // rooms.owner_user_id 有唯一约束,查询最多返回一条;该读路径用于 CreateRoom 前置失败。 row := r.db.QueryRowContext(ctx, `SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id FROM rooms WHERE app_code = ? AND owner_user_id = ? LIMIT 1`, appcode.FromContext(ctx), ownerUserID, ) var meta roomservice.RoomMeta if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil { if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomMeta{}, false, nil } return roomservice.RoomMeta{}, false, err } return meta, true, nil } // SaveRoomBackground 保存房间背景图素材;相同房间相同 URL 按幂等返回原记录。 func (r *Repository) SaveRoomBackground(ctx context.Context, background roomservice.RoomBackgroundImage) (roomservice.RoomBackgroundImage, error) { appCode := normalizedRecordAppCode(ctx, background.AppCode) roomID := strings.TrimSpace(background.RoomID) imageURL := strings.TrimSpace(background.ImageURL) nowMS := background.CreatedAtMS if nowMS <= 0 { nowMS = time.Now().UTC().UnixMilli() } _, err := r.db.ExecContext(ctx, ` INSERT INTO room_background_images ( app_code, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms) `, appCode, roomID, imageURL, background.CreatedByUserID, nowMS, nowMS) if err != nil { return roomservice.RoomBackgroundImage{}, err } row := r.db.QueryRowContext(ctx, ` SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms FROM room_background_images WHERE app_code = ? AND room_id = ? AND image_url = ? LIMIT 1 `, appCode, roomID, imageURL) return scanRoomBackground(row) } // GetRoomBackground 精确读取一个房间背景图素材。 func (r *Repository) GetRoomBackground(ctx context.Context, roomID string, backgroundID int64) (roomservice.RoomBackgroundImage, bool, error) { row := r.db.QueryRowContext(ctx, ` SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms FROM room_background_images WHERE app_code = ? AND room_id = ? AND background_id = ? LIMIT 1 `, appcode.FromContext(ctx), strings.TrimSpace(roomID), backgroundID) background, err := scanRoomBackground(row) if err != nil { if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomBackgroundImage{}, false, nil } return roomservice.RoomBackgroundImage{}, false, err } return background, true, nil } // ListRoomBackgrounds 返回某个房间保存过的背景图素材,按最近保存优先。 func (r *Repository) ListRoomBackgrounds(ctx context.Context, roomID string) ([]roomservice.RoomBackgroundImage, error) { rows, err := r.db.QueryContext(ctx, ` SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms FROM room_background_images WHERE app_code = ? AND room_id = ? ORDER BY created_at_ms DESC, background_id DESC `, appcode.FromContext(ctx), strings.TrimSpace(roomID)) if err != nil { return nil, err } defer rows.Close() backgrounds := []roomservice.RoomBackgroundImage{} for rows.Next() { background, err := scanRoomBackground(rows) if err != nil { return nil, err } backgrounds = append(backgrounds, background) } return backgrounds, rows.Err() } func scanRoomBackground(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomBackgroundImage, error) { var background roomservice.RoomBackgroundImage err := scanner.Scan( &background.AppCode, &background.BackgroundID, &background.RoomID, &background.ImageURL, &background.CreatedByUserID, &background.CreatedAtMS, &background.UpdatedAtMS, ) return background, err } // GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。 func (r *Repository) GetCommand(ctx context.Context, roomID string, commandID string) (roomservice.CommandRecord, bool, error) { // 幂等检查只看 command log,只有成功提交过的命令才算 seen。 row := r.db.QueryRowContext(ctx, `SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms FROM room_command_log WHERE app_code = ? AND room_id = ? AND command_id = ? LIMIT 1`, appcode.FromContext(ctx), roomID, commandID, ) var record roomservice.CommandRecord if err := row.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil { if errors.Is(err, sql.ErrNoRows) { // 没有命令日志表示本命令尚未成功提交。 return roomservice.CommandRecord{}, false, nil } return roomservice.CommandRecord{}, false, err } return record, true, nil } // SaveCommand 追加一条成功命令日志。 func (r *Repository) SaveCommand(ctx context.Context, record roomservice.CommandRecord) error { // ON DUPLICATE KEY 保证重复命令写入不会破坏已提交记录。 appCode := normalizedRecordAppCode(ctx, record.AppCode) _, err := r.db.ExecContext(ctx, `INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE command_id = command_id`, appCode, record.RoomID, record.RoomVersion, record.CommandID, record.CommandType, record.OwnerNodeID, record.LeaseToken, record.Payload, record.Replayable, record.CreatedAtMS, ) return err } // SaveMutation 在同一事务中提交命令日志、outbox 和可选房间状态。 func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.MutationCommit) error { appCode := normalizedRecordAppCode(ctx, commit.Command.AppCode) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } if _, err := tx.ExecContext(ctx, `INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE command_id = command_id`, appCode, commit.Command.RoomID, commit.Command.RoomVersion, commit.Command.CommandID, commit.Command.CommandType, commit.Command.OwnerNodeID, commit.Command.LeaseToken, commit.Command.Payload, commit.Command.Replayable, commit.Command.CreatedAtMS, ); err != nil { _ = tx.Rollback() return err } for _, record := range commit.OutboxRecords { record.AppCode = normalizedRecordAppCode(ctx, record.AppCode) if record.Envelope != nil { record.Envelope.AppCode = record.AppCode } envelopeBytes, err := proto.Marshal(record.Envelope) if err != nil { _ = tx.Rollback() return err } if _, err := tx.ExecContext(ctx, `INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE event_id = event_id`, record.AppCode, record.EventID, record.EventType, record.RoomID, record.Status, envelopeBytes, record.CreatedAtMS, record.RetryCount, record.NextRetryAtMS, nullableString(record.LastError), record.CreatedAtMS, ); err != nil { _ = tx.Rollback() return err } } if strings.TrimSpace(commit.RoomStatus) != "" { if _, err := tx.ExecContext(ctx, `UPDATE rooms SET status = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, commit.RoomStatus, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } if _, err := tx.ExecContext(ctx, `UPDATE room_list_entries SET status = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, commit.RoomStatus, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } if commit.RoomSeatCount != nil { if _, err := tx.ExecContext(ctx, `UPDATE rooms SET seat_count = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, *commit.RoomSeatCount, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } if _, err := tx.ExecContext(ctx, `UPDATE room_list_entries SET seat_count = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, *commit.RoomSeatCount, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } if commit.RoomPasswordHash != nil { locked := strings.TrimSpace(*commit.RoomPasswordHash) != "" if _, err := tx.ExecContext(ctx, `UPDATE rooms SET room_password_hash = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, *commit.RoomPasswordHash, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } if _, err := tx.ExecContext(ctx, `UPDATE room_list_entries SET locked = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, locked, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } if commit.RoomMode != nil { if _, err := tx.ExecContext(ctx, `UPDATE rooms SET mode = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, *commit.RoomMode, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } if commit.VisibleRegionID != nil { if _, err := tx.ExecContext(ctx, `UPDATE rooms SET visible_region_id = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, *commit.VisibleRegionID, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } if commit.CloseInfo != nil { info := commit.CloseInfo reason, adminName, closedAt := info.Reason, info.AdminName, info.ClosedAtMS adminID := info.AdminID if info.ClearOnOpen { reason, adminName, closedAt, adminID = "", "", 0, 0 } for _, table := range []string{"rooms", "room_list_entries"} { if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET close_reason = ?, closed_by_admin_id = ?, closed_by_admin_name = ?, closed_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, reason, adminID, adminName, closedAt, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } } for _, stat := range commit.RoomUserGiftStats { if stat.UserID <= 0 || stat.GiftValue <= 0 { continue } statAppCode := normalizedRecordAppCode(ctx, stat.AppCode) statRoomID := strings.TrimSpace(stat.RoomID) if statRoomID == "" { statRoomID = commit.Command.RoomID } if _, err := tx.ExecContext(ctx, `INSERT INTO room_user_gift_stats ( app_code, room_id, user_id, gift_value, last_gift_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE gift_value = gift_value + VALUES(gift_value), last_gift_at_ms = VALUES(last_gift_at_ms), updated_at_ms = VALUES(updated_at_ms)`, statAppCode, statRoomID, stat.UserID, stat.GiftValue, stat.LastGiftAtMS, commit.Command.CreatedAtMS, commit.Command.CreatedAtMS, ); err != nil { _ = tx.Rollback() return err } } return tx.Commit() } // GetRoomSeatConfig 读取当前 App 的房间座位数配置。 func (r *Repository) GetRoomSeatConfig(ctx context.Context) (roomservice.RoomSeatConfig, bool, error) { row := r.db.QueryRowContext(ctx, `SELECT app_code, allowed_seat_counts, default_seat_count, updated_at_ms FROM room_seat_configs WHERE app_code = ?`, appcode.FromContext(ctx), ) var config roomservice.RoomSeatConfig var rawAllowed string if err := row.Scan(&config.AppCode, &rawAllowed, &config.DefaultSeatCount, &config.UpdatedAtMS); err != nil { if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomSeatConfig{}, false, nil } return roomservice.RoomSeatConfig{}, false, err } if err := json.Unmarshal([]byte(rawAllowed), &config.AllowedSeatCounts); err != nil { return roomservice.RoomSeatConfig{}, false, err } return config, true, nil } // UpsertRoomSeatConfig 写入当前 App 的房间座位数配置。 func (r *Repository) UpsertRoomSeatConfig(ctx context.Context, config roomservice.RoomSeatConfig) error { appCode := normalizedRecordAppCode(ctx, config.AppCode) rawAllowed, err := json.Marshal(config.AllowedSeatCounts) if err != nil { return err } nowMS := config.UpdatedAtMS if nowMS <= 0 { nowMS = time.Now().UTC().UnixMilli() } _, err = r.db.ExecContext(ctx, `INSERT INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE allowed_seat_counts = VALUES(allowed_seat_counts), default_seat_count = VALUES(default_seat_count), updated_at_ms = VALUES(updated_at_ms)`, appCode, string(rawAllowed), config.DefaultSeatCount, nowMS, nowMS, ) return err } // GetRoomRocketConfig 读取当前 App 的语音房火箭配置。 func (r *Repository) GetRoomRocketConfig(ctx context.Context) (roomservice.RoomRocketConfig, bool, error) { row := r.db.QueryRowContext(ctx, `SELECT 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 FROM room_rocket_configs WHERE app_code = ?`, appcode.FromContext(ctx), ) var config roomservice.RoomRocketConfig var enabled int var broadcastEnabled int var rawLevels string var rawRules string if err := row.Scan( &config.AppCode, &enabled, &config.ConfigVersion, &config.FuelSource, &config.LaunchDelayMS, &broadcastEnabled, &config.BroadcastScope, &config.BroadcastDelayMS, &config.RewardStackPolicy, &rawLevels, &rawRules, &config.UpdatedByAdminID, &config.CreatedAtMS, &config.UpdatedAtMS, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomRocketConfig{}, false, nil } return roomservice.RoomRocketConfig{}, false, err } config.Enabled = enabled == 1 config.BroadcastEnabled = broadcastEnabled == 1 if err := json.Unmarshal([]byte(rawLevels), &config.Levels); err != nil { return roomservice.RoomRocketConfig{}, false, err } if err := json.Unmarshal([]byte(rawRules), &config.GiftFuelRules); err != nil { return roomservice.RoomRocketConfig{}, false, err } return config, true, nil } // UpsertRoomRocketConfig 写入当前 App 的语音房火箭配置。 func (r *Repository) UpsertRoomRocketConfig(ctx context.Context, config roomservice.RoomRocketConfig) error { appCode := normalizedRecordAppCode(ctx, config.AppCode) rawLevels, err := json.Marshal(config.Levels) if err != nil { return err } rawRules, err := json.Marshal(config.GiftFuelRules) if err != nil { return err } nowMS := config.UpdatedAtMS if nowMS <= 0 { nowMS = time.Now().UTC().UnixMilli() } createdAtMS := config.CreatedAtMS if createdAtMS <= 0 { createdAtMS = nowMS } _, err = r.db.ExecContext(ctx, `INSERT 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), config_version = VALUES(config_version), fuel_source = VALUES(fuel_source), launch_delay_ms = VALUES(launch_delay_ms), broadcast_enabled = VALUES(broadcast_enabled), broadcast_scope = VALUES(broadcast_scope), broadcast_delay_ms = VALUES(broadcast_delay_ms), reward_stack_policy = VALUES(reward_stack_policy), levels_json = VALUES(levels_json), gift_fuel_rules_json = VALUES(gift_fuel_rules_json), updated_by_admin_id = VALUES(updated_by_admin_id), updated_at_ms = VALUES(updated_at_ms)`, appCode, boolToInt(config.Enabled), config.ConfigVersion, config.FuelSource, config.LaunchDelayMS, boolToInt(config.BroadcastEnabled), config.BroadcastScope, config.BroadcastDelayMS, config.RewardStackPolicy, string(rawLevels), string(rawRules), config.UpdatedByAdminID, createdAtMS, nowMS, ) return err } // ListCommandsAfter 返回快照版本之后的可回放命令日志。 func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) { // 恢复必须按 room_version 再按自增 id 排序,确保同版本异常数据也有稳定顺序。 rows, err := r.db.QueryContext(ctx, `SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms FROM room_command_log WHERE app_code = ? AND room_id = ? AND room_version > ? ORDER BY room_version ASC, id ASC`, appcode.FromContext(ctx), roomID, roomVersion, ) if err != nil { return nil, err } defer rows.Close() records := make([]roomservice.CommandRecord, 0) for rows.Next() { // payload 保持原始字节,领域层根据 command_type 反序列化。 var record roomservice.CommandRecord if err := rows.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil { return nil, err } records = append(records, record) } if err := rows.Err(); err != nil { // rows.Err 捕获迭代过程中发生的底层连接或扫描错误。 return nil, err } return records, nil } // SaveSnapshot 保存房间最新快照。 func (r *Repository) SaveSnapshot(ctx context.Context, snapshot roomservice.SnapshotRecord) error { // 快照按 room_id 保留最新一条,版本更旧的写入不会覆盖较新的恢复来源。 appCode := normalizedRecordAppCode(ctx, snapshot.AppCode) _, err := r.db.ExecContext(ctx, `INSERT INTO room_snapshots (app_code, room_id, room_version, payload, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE room_version = IF(VALUES(room_version) >= room_version, VALUES(room_version), room_version), payload = IF(VALUES(room_version) >= room_version, VALUES(payload), payload), created_at_ms = IF(VALUES(room_version) >= room_version, VALUES(created_at_ms), created_at_ms), updated_at_ms = IF(VALUES(room_version) >= room_version, VALUES(updated_at_ms), updated_at_ms)`, appCode, snapshot.RoomID, snapshot.RoomVersion, snapshot.Payload, snapshot.CreatedAtMS, snapshot.CreatedAtMS, ) return err } // GetLatestSnapshot 读取房间最新快照。 func (r *Repository) GetLatestSnapshot(ctx context.Context, roomID string) (roomservice.SnapshotRecord, bool, error) { // room_snapshots 主键是 room_id,因此查询结果最多一条。 row := r.db.QueryRowContext(ctx, `SELECT app_code, room_id, room_version, payload, created_at_ms FROM room_snapshots WHERE app_code = ? AND room_id = ?`, appcode.FromContext(ctx), roomID, ) var snapshot roomservice.SnapshotRecord if err := row.Scan(&snapshot.AppCode, &snapshot.RoomID, &snapshot.RoomVersion, &snapshot.Payload, &snapshot.CreatedAtMS); err != nil { if errors.Is(err, sql.ErrNoRows) { // 没有快照时上层会从 RoomMeta 和 command log 从头恢复。 return roomservice.SnapshotRecord{}, false, nil } return roomservice.SnapshotRecord{}, false, err } return snapshot, true, nil } // SaveOutbox 追加待投递 outbox 事件。 func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) error { if len(records) == 0 { // 没有事件时保持无副作用,方便领域层统一调用。 return nil } // 多条事件用一个事务写入,保证同一命令产生的事件不会部分入库。 tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } for _, record := range records { record.AppCode = normalizedRecordAppCode(ctx, record.AppCode) if record.Envelope != nil { record.Envelope.AppCode = record.AppCode } // envelope 是 protobuf 信封,存储层不解析具体事件 body。 envelopeBytes, err := proto.Marshal(record.Envelope) if err != nil { _ = tx.Rollback() return err } if _, err := tx.ExecContext(ctx, `INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE event_id = event_id`, record.AppCode, record.EventID, record.EventType, record.RoomID, record.Status, envelopeBytes, record.CreatedAtMS, record.RetryCount, record.NextRetryAtMS, nullableString(record.LastError), record.CreatedAtMS, ); err != nil { // 任一事件失败就回滚整批,避免 GiftSent/HeatChanged/RankChanged 部分缺失。 _ = tx.Rollback() return err } } // commit 成功后事件才对 outbox worker 可见。 return tx.Commit() } // ListPendingOutbox 返回待投递事件。 func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error) { if limit <= 0 { // 默认批量上限避免调用方传 0 导致全表扫描。 limit = 100 } // 按创建时间顺序扫描 pending,尽量保持事件投递顺序。 nowMS := time.Now().UTC().UnixMilli() rows, err := r.db.QueryContext(ctx, `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '') FROM room_outbox FORCE INDEX (idx_room_outbox_pending) WHERE app_code = ? AND status IN (?, ?) AND next_retry_at_ms <= ? ORDER BY created_at_ms ASC LIMIT ?`, appcode.FromContext(ctx), outbox.StatusPending, outbox.StatusRetryable, nowMS, limit, ) if err != nil { return nil, err } defer rows.Close() records := make([]outbox.Record, 0, limit) for rows.Next() { // 每条 outbox 记录恢复 envelope,发布器只消费 protobuf 信封。 var envelopeBytes []byte var record outbox.Record var lockUntil sql.Null[int64] if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil { return nil, err } if lockUntil.Valid { record.LockUntilMS = lockUntil.V } var envelope roomeventsv1.EventEnvelope if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil { // envelope 损坏说明 outbox 数据不可投递,需要暴露错误而不是跳过。 return nil, err } record.Envelope = &envelope record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode) records = append(records, record) } if err := rows.Err(); err != nil { return nil, err } return records, nil } // ClaimPendingOutbox 抢占一批待投递事件,避免多 worker 同时投递同一批。 func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]outbox.Record, error) { if limit <= 0 { limit = 100 } if strings.TrimSpace(workerID) == "" { workerID = "room-outbox-worker" } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return nil, err } nowMS := time.Now().UTC().UnixMilli() appCode := appcode.FromContext(ctx) records := make([]outbox.Record, 0, limit) claimBranches := []struct { query string args []any }{ { query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '') FROM room_outbox FORCE INDEX (idx_room_outbox_pending) WHERE app_code = ? AND status = ? AND next_retry_at_ms <= ? AND lock_until_ms IS NULL ORDER BY created_at_ms ASC, event_id ASC LIMIT ? FOR UPDATE SKIP LOCKED`, args: []any{appCode, outbox.StatusPending, nowMS}, }, { query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '') FROM room_outbox FORCE INDEX (idx_room_outbox_pending) WHERE app_code = ? AND status = ? AND next_retry_at_ms <= ? AND lock_until_ms IS NULL ORDER BY created_at_ms ASC, event_id ASC LIMIT ? FOR UPDATE SKIP LOCKED`, args: []any{appCode, outbox.StatusRetryable, nowMS}, }, { query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '') FROM room_outbox FORCE INDEX (idx_room_outbox_claim) WHERE app_code = ? AND status = ? AND lock_until_ms <= ? ORDER BY created_at_ms ASC, event_id ASC LIMIT ? FOR UPDATE SKIP LOCKED`, args: []any{appCode, outbox.StatusDelivering, nowMS}, }, } for _, branch := range claimBranches { remaining := limit - len(records) if remaining <= 0 { break } args := append(append([]any{}, branch.args...), remaining) branchRecords, err := r.queryRoomOutboxRecords(ctx, tx, branch.query, args...) if err != nil { _ = tx.Rollback() return nil, err } records = append(records, branchRecords...) } for _, record := range records { if _, err := tx.ExecContext(ctx, `UPDATE room_outbox SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ? WHERE app_code = ? AND event_id = ?`, outbox.StatusDelivering, workerID, lockUntilMS, nowMS, appCode, record.EventID, ); err != nil { _ = tx.Rollback() return nil, err } } if err := tx.Commit(); err != nil { return nil, err } for index := range records { records[index].Status = outbox.StatusDelivering records[index].WorkerID = workerID records[index].LockUntilMS = lockUntilMS } return records, nil } func (r *Repository) queryRoomOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]outbox.Record, error) { rows, err := tx.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() records := make([]outbox.Record, 0) for rows.Next() { var envelopeBytes []byte var record outbox.Record var lockUntilValue sql.Null[int64] if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntilValue, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil { return nil, err } if lockUntilValue.Valid { record.LockUntilMS = lockUntilValue.V } var envelope roomeventsv1.EventEnvelope if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil { return nil, err } record.Envelope = &envelope record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode) records = append(records, record) } if err := rows.Err(); err != nil { return nil, err } return records, nil } // MarkOutboxDelivered 标记事件投递成功。 func (r *Repository) MarkOutboxDelivered(ctx context.Context, eventID string) error { // 成功后清空 last_error,后续扫描不再返回该事件。 _, err := r.db.ExecContext(ctx, `UPDATE room_outbox SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = NULL, updated_at_ms = ? WHERE app_code = ? AND event_id = ?`, outbox.StatusDelivered, time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID, ) return err } // MarkOutboxRetryable 标记事件投递失败并写入下一次重试时间。 func (r *Repository) MarkOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error { // 失败转入 retryable,worker 只有到 next_retry_at_ms 后才会重新抢占。 _, err := r.db.ExecContext(ctx, `UPDATE room_outbox SET status = ?, worker_id = '', lock_until_ms = NULL, retry_count = retry_count + 1, next_retry_at_ms = ?, last_error = ?, updated_at_ms = ? WHERE app_code = ? AND event_id = ?`, outbox.StatusRetryable, nextRetryAtMS, lastErr, time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID, ) return err } // MarkOutboxDead 把超过最大重试次数的事件转入 failed。 func (r *Repository) MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error { _, err := r.db.ExecContext(ctx, `UPDATE room_outbox SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = ?, updated_at_ms = ? WHERE app_code = ? AND event_id = ?`, outbox.StatusFailed, lastErr, time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID, ) return err } // UpsertRoomListEntry 写入房间列表读模型。 func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice.RoomListEntry) error { // created_at_ms 首次插入后保持不变,避免 Join/Leave/SendGift 等更新把 new tab 顺序洗掉。 appCode := normalizedRecordAppCode(ctx, entry.AppCode) _, err := r.db.ExecContext(ctx, `INSERT INTO room_list_entries ( app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked, heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE room_short_id = VALUES(room_short_id), visible_region_id = VALUES(visible_region_id), owner_country_code = VALUES(owner_country_code), owner_user_id = VALUES(owner_user_id), title = VALUES(title), cover_url = VALUES(cover_url), mode = VALUES(mode), status = VALUES(status), locked = VALUES(locked), heat = VALUES(heat), online_count = VALUES(online_count), seat_count = VALUES(seat_count), occupied_seat_count = VALUES(occupied_seat_count), sort_score = VALUES(sort_score), updated_at_ms = VALUES(updated_at_ms)`, appCode, entry.RoomID, entry.RoomShortID, entry.VisibleRegionID, entry.OwnerCountryCode, entry.OwnerUserID, entry.Title, entry.CoverURL, entry.Mode, entry.Status, entry.Locked, entry.Heat, entry.OnlineCount, entry.SeatCount, entry.OccupiedSeatCount, entry.SortScore, entry.CreatedAtMS, entry.UpdatedAtMS, ) return err } // ListRoomListEntries 读取公共发现列表读模型,并按查询区域把全区置顶、区域置顶、本区房和外区房排成稳定分页序列。 func (r *Repository) ListRoomListEntries(ctx context.Context, query roomservice.RoomListQuery) ([]roomservice.RoomListEntry, error) { if query.Limit <= 0 { query.Limit = 20 } query.AppCode = normalizedRecordAppCode(ctx, query.AppCode) if query.NowMS <= 0 { query.NowMS = time.Now().UTC().UnixMilli() } sqlText, args := buildRoomListQuerySQL(query) rows, err := r.db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, err } defer rows.Close() entries := make([]roomservice.RoomListEntry, 0, query.Limit) for rows.Next() { var entry roomservice.RoomListEntry var pinned int64 if err := rows.Scan( &entry.AppCode, &entry.RoomID, &entry.RoomShortID, &entry.VisibleRegionID, &entry.OwnerCountryCode, &entry.OwnerUserID, &entry.Title, &entry.CoverURL, &entry.Mode, &entry.Status, &entry.Locked, &entry.Heat, &entry.OnlineCount, &entry.SeatCount, &entry.OccupiedSeatCount, &entry.SortScore, &entry.CreatedAtMS, &entry.UpdatedAtMS, &pinned, &entry.PinListRank, &entry.PinWeight, &entry.PinnedUntilMS, ); err != nil { return nil, err } entry.IsPinned = pinned > 0 entries = append(entries, entry) } if err := rows.Err(); err != nil { return nil, err } return entries, nil } // ListRoomListEntriesByIDs 按榜单已命中的 room_id 批量读取列表卡片。 func (r *Repository) ListRoomListEntriesByIDs(ctx context.Context, query roomservice.RoomListEntriesByIDQuery) (map[string]roomservice.RoomListEntry, error) { appCode := normalizedRecordAppCode(ctx, query.AppCode) roomIDs := uniqueRoomListIDs(query.RoomIDs) result := make(map[string]roomservice.RoomListEntry, len(roomIDs)) if len(roomIDs) == 0 { return result, nil } placeholders := strings.TrimRight(strings.Repeat("?,", len(roomIDs)), ",") args := make([]any, 0, len(roomIDs)+1) args = append(args, appCode) for _, roomID := range roomIDs { args = append(args, roomID) } rows, err := r.db.QueryContext(ctx, ` SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked, heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms FROM room_list_entries WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var entry roomservice.RoomListEntry if err := rows.Scan( &entry.AppCode, &entry.RoomID, &entry.RoomShortID, &entry.VisibleRegionID, &entry.OwnerCountryCode, &entry.OwnerUserID, &entry.Title, &entry.CoverURL, &entry.Mode, &entry.Status, &entry.Locked, &entry.Heat, &entry.OnlineCount, &entry.SeatCount, &entry.OccupiedSeatCount, &entry.SortScore, &entry.CreatedAtMS, &entry.UpdatedAtMS, ); err != nil { return nil, err } result[entry.RoomID] = entry } if err := rows.Err(); err != nil { return nil, err } return result, nil } func uniqueRoomListIDs(values []string) []string { seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out } // UpsertRoomUserFeedEntry 写入用户房间关系流索引。 func (r *Repository) UpsertRoomUserFeedEntry(ctx context.Context, entry roomservice.RoomUserFeedEntry) error { // feed 只保存用户到房间的索引;房间卡片字段仍以 room_list_entries 为准,避免复制不一致。 appCode := normalizedRecordAppCode(ctx, entry.AppCode) _, err := r.db.ExecContext(ctx, `INSERT INTO room_user_feed_entries (app_code, user_id, feed_type, room_id, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`, appCode, entry.UserID, entry.FeedType, entry.RoomID, entry.CreatedAtMS, entry.UpdatedAtMS, ) return err } // ListRoomUserFeedEntries 按用户关系流读取房间卡片。 func (r *Repository) ListRoomUserFeedEntries(ctx context.Context, query roomservice.RoomUserFeedQuery) ([]roomservice.RoomListEntry, error) { if query.Limit <= 0 { query.Limit = 20 } query.AppCode = normalizedRecordAppCode(ctx, query.AppCode) sqlText, args := buildRoomUserFeedQuerySQL(query) rows, err := r.db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, err } defer rows.Close() entries := make([]roomservice.RoomListEntry, 0, query.Limit) for rows.Next() { var entry roomservice.RoomListEntry if err := rows.Scan( &entry.AppCode, &entry.RoomID, &entry.RoomShortID, &entry.VisibleRegionID, &entry.OwnerCountryCode, &entry.OwnerUserID, &entry.Title, &entry.CoverURL, &entry.Mode, &entry.Status, &entry.Locked, &entry.Heat, &entry.OnlineCount, &entry.SeatCount, &entry.OccupiedSeatCount, &entry.SortScore, &entry.CreatedAtMS, &entry.UpdatedAtMS, ); err != nil { return nil, err } entries = append(entries, entry) } if err := rows.Err(); err != nil { return nil, err } return entries, nil } // FollowRoom 建立或恢复用户对房间的关注关系。 func (r *Repository) FollowRoom(ctx context.Context, userID int64, roomID string, nowMS int64) (roomservice.RoomFollowRecord, error) { appCode := appcode.FromContext(ctx) if nowMS <= 0 { nowMS = time.Now().UTC().UnixMilli() } if _, err := r.db.ExecContext(ctx, `INSERT INTO room_follows (app_code, user_id, room_id, status, followed_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE followed_at_ms = IF(status = ?, followed_at_ms, VALUES(followed_at_ms)), status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`, appCode, userID, roomID, roomFollowStatusActiveSQL, nowMS, nowMS, roomFollowStatusActiveSQL, ); err != nil { return roomservice.RoomFollowRecord{}, err } record, _, err := r.getRoomFollowRecord(ctx, userID, roomID) return record, err } // UnfollowRoom 取消用户对房间的关注关系;没有现存关系时不写入新行。 func (r *Repository) UnfollowRoom(ctx context.Context, userID int64, roomID string, nowMS int64) error { if nowMS <= 0 { nowMS = time.Now().UTC().UnixMilli() } _, err := r.db.ExecContext(ctx, `UPDATE room_follows SET status = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND room_id = ? AND status = ?`, roomFollowStatusCancelledSQL, nowMS, appcode.FromContext(ctx), userID, roomID, roomFollowStatusActiveSQL, ) return err } // IsRoomFollowed 查询用户是否正在关注指定房间。 func (r *Repository) IsRoomFollowed(ctx context.Context, userID int64, roomID string) (bool, error) { record, exists, err := r.getRoomFollowRecord(ctx, userID, roomID) if err != nil || !exists { return false, err } return record.Status == roomFollowStatusActiveSQL, nil } func (r *Repository) getRoomFollowRecord(ctx context.Context, userID int64, roomID string) (roomservice.RoomFollowRecord, bool, error) { row := r.db.QueryRowContext(ctx, `SELECT app_code, user_id, room_id, status, followed_at_ms, updated_at_ms FROM room_follows WHERE app_code = ? AND user_id = ? AND room_id = ? LIMIT 1`, appcode.FromContext(ctx), userID, roomID, ) var record roomservice.RoomFollowRecord if err := row.Scan(&record.AppCode, &record.UserID, &record.RoomID, &record.Status, &record.FollowedAtMS, &record.UpdatedAtMS); err != nil { if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomFollowRecord{}, false, nil } return roomservice.RoomFollowRecord{}, false, err } return record, true, nil } // ListRoomFollowEntries 读取当前用户关注的 active 房间卡片。 func (r *Repository) ListRoomFollowEntries(ctx context.Context, query roomservice.RoomFollowQuery) ([]roomservice.RoomListEntry, error) { if query.Limit <= 0 { query.Limit = 20 } query.AppCode = normalizedRecordAppCode(ctx, query.AppCode) sqlText, args := buildRoomFollowQuerySQL(query) rows, err := r.db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, err } defer rows.Close() entries := make([]roomservice.RoomListEntry, 0, query.Limit) for rows.Next() { var entry roomservice.RoomListEntry if err := rows.Scan( &entry.AppCode, &entry.RoomID, &entry.RoomShortID, &entry.VisibleRegionID, &entry.OwnerCountryCode, &entry.OwnerUserID, &entry.Title, &entry.CoverURL, &entry.Mode, &entry.Status, &entry.Locked, &entry.Heat, &entry.OnlineCount, &entry.SeatCount, &entry.OccupiedSeatCount, &entry.SortScore, &entry.CreatedAtMS, &entry.UpdatedAtMS, ); err != nil { return nil, err } entries = append(entries, entry) } if err := rows.Err(); err != nil { return nil, err } return entries, nil } // ListRoomRelatedFeedEntries 根据 user-service 给出的好友/关注用户查询 active 房间卡片。 func (r *Repository) ListRoomRelatedFeedEntries(ctx context.Context, query roomservice.RoomRelatedFeedQuery) ([]roomservice.RoomListEntry, error) { if query.Limit <= 0 { query.Limit = 20 } query.AppCode = normalizedRecordAppCode(ctx, query.AppCode) relations := normalizeRoomFeedRelations(query.RelatedUsers) if len(relations) == 0 { return []roomservice.RoomListEntry{}, nil } sqlText, args := buildRoomRelatedFeedQuerySQL(query, relations) rows, err := r.db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, err } defer rows.Close() entries := make([]roomservice.RoomListEntry, 0, query.Limit) for rows.Next() { var entry roomservice.RoomListEntry if err := rows.Scan( &entry.AppCode, &entry.RoomID, &entry.RoomShortID, &entry.VisibleRegionID, &entry.OwnerCountryCode, &entry.OwnerUserID, &entry.Title, &entry.CoverURL, &entry.Mode, &entry.Status, &entry.Locked, &entry.Heat, &entry.OnlineCount, &entry.SeatCount, &entry.OccupiedSeatCount, &entry.SortScore, &entry.CreatedAtMS, &entry.UpdatedAtMS, ); err != nil { return nil, err } entry.FeedSubjectUserID, entry.UpdatedAtMS = relatedFeedSortKey(entry, relations) if entry.FeedSubjectUserID == 0 || !roomRelatedFeedAfterCursor(entry, query.CursorUpdatedAtMS, query.CursorSubjectUserID, query.CursorRoomID) { continue } entries = append(entries, entry) } if err := rows.Err(); err != nil { return nil, err } sort.SliceStable(entries, func(i, j int) bool { if entries[i].UpdatedAtMS != entries[j].UpdatedAtMS { return entries[i].UpdatedAtMS > entries[j].UpdatedAtMS } if entries[i].FeedSubjectUserID != entries[j].FeedSubjectUserID { return entries[i].FeedSubjectUserID > entries[j].FeedSubjectUserID } return entries[i].RoomID < entries[j].RoomID }) if len(entries) > query.Limit { entries = entries[:query.Limit] } return entries, nil } const roomListSelectColumns = ` SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked, heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms FROM room_list_entries` const roomUserFeedSelectColumns = ` SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked, r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.updated_at_ms FROM room_user_feed_entries f JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id` const roomFollowSelectColumns = ` SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked, r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.followed_at_ms FROM room_follows f JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id` func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) { regionID := query.VisibleRegionID if regionID < 0 { regionID = 0 } regionLiteral := strconv.FormatInt(regionID, 10) pinnedExpr := "CASE WHEN gp.room_id IS NULL AND rp.room_id IS NULL THEN 0 ELSE 1 END" newPinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 1 WHEN r.visible_region_id = " + regionLiteral + " THEN 2 ELSE 3 END" hotPinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 1 WHEN r.visible_region_id = " + regionLiteral + " AND r.online_count > 0 THEN 2 WHEN r.visible_region_id <> " + regionLiteral + " AND r.online_count > 0 THEN 3 WHEN r.visible_region_id = " + regionLiteral + " THEN 4 ELSE 5 END" pinRankExpr := hotPinRankExpr if query.Tab == "new" { pinRankExpr = newPinRankExpr } pinWeightExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.weight WHEN rp.room_id IS NOT NULL THEN rp.weight ELSE 0 END" pinExpiresExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.expires_at_ms WHEN rp.room_id IS NOT NULL THEN rp.expires_at_ms ELSE 0 END" selectSQL := ` SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked, r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, r.updated_at_ms, ` + pinnedExpr + ` AS is_pinned, ` + pinRankExpr + ` AS pin_list_rank, ` + pinWeightExpr + ` AS pin_weight, ` + pinExpiresExpr + ` AS pinned_until_ms FROM room_list_entries r LEFT JOIN room_region_pins gp ON gp.app_code = r.app_code AND gp.pin_type = 'global' AND gp.visible_region_id = 0 AND gp.room_id = r.room_id AND gp.status = ? AND gp.pinned_at_ms <= ? AND gp.expires_at_ms > ? LEFT JOIN room_region_pins rp ON rp.app_code = r.app_code AND rp.pin_type = 'region' AND rp.visible_region_id = ? AND rp.room_id = r.room_id AND rp.status = ? AND rp.pinned_at_ms <= ? AND rp.expires_at_ms > ?` where := []string{"r.app_code = ?", "r.status = ?"} args := []any{"active", query.NowMS, query.NowMS, regionID, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active"} if query.OwnerUserID > 0 { where = append(where, "r.owner_user_id = ?") args = append(args, query.OwnerUserID) } if strings.TrimSpace(query.CountryCode) != "" { where = append(where, "r.owner_country_code = ?") args = append(args, strings.ToUpper(strings.TrimSpace(query.CountryCode))) } if strings.TrimSpace(query.Query) != "" { where = append(where, "(r.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\')") like := "%" + escapeRoomListLike(query.Query) + "%" args = append(args, like, like, like) } if query.Tab == "new" { if query.CursorRoomID != "" { where = append(where, roomListAfterCursorSQL(pinRankExpr, pinWeightExpr, pinExpiresExpr, "r.created_at_ms")) args = append(args, roomListAfterCursorArgs(query.CursorPinListRank, query.CursorPinWeight, query.CursorPinnedUntilMS, query.CursorCreatedAtMS, query.CursorRoomID)...) } args = append(args, query.Limit) return selectSQL + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY " + pinRankExpr + " ASC, " + pinWeightExpr + " DESC, " + pinExpiresExpr + " DESC, r.created_at_ms DESC, r.room_id ASC\n\tLIMIT ?", args } if query.CursorRoomID != "" { where = append(where, roomListAfterCursorSQL(pinRankExpr, pinWeightExpr, pinExpiresExpr, "r.sort_score")) args = append(args, roomListAfterCursorArgs(query.CursorPinListRank, query.CursorPinWeight, query.CursorPinnedUntilMS, query.CursorSortScore, query.CursorRoomID)...) } args = append(args, query.Limit) return selectSQL + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY " + pinRankExpr + " ASC, " + pinWeightExpr + " DESC, " + pinExpiresExpr + " DESC, r.sort_score DESC, r.room_id ASC\n\tLIMIT ?", args } func (r *Repository) AdminListRooms(ctx context.Context, query roomservice.AdminRoomListQuery) ([]roomservice.AdminRoomListEntry, int64, error) { if query.Page <= 0 { query.Page = 1 } if query.PageSize <= 0 { query.PageSize = 20 } if query.PageSize > 100 { query.PageSize = 100 } where, args := adminRoomWhere(ctx, query) var total int64 if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_list_entries rle `+where, args...).Scan(&total); err != nil { return nil, 0, err } rows, err := r.db.QueryContext(ctx, adminRoomSelectSQL()+where+` ORDER BY `+adminRoomOrderBy(query)+` LIMIT ? OFFSET ?`, append(args, query.PageSize, (query.Page-1)*query.PageSize)...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]roomservice.AdminRoomListEntry, 0, query.PageSize) for rows.Next() { item, err := scanAdminRoom(rows) if err != nil { return nil, 0, err } items = append(items, item) } if err := rows.Err(); err != nil { return nil, 0, err } return items, total, nil } func (r *Repository) AdminGetRoom(ctx context.Context, roomID string) (roomservice.AdminRoomListEntry, bool, error) { row := r.db.QueryRowContext(ctx, adminRoomSelectSQL()+` WHERE rle.app_code = ? AND rle.room_id = ?`, appcode.FromContext(ctx), strings.TrimSpace(roomID)) item, err := scanAdminRoom(row) if errors.Is(err, sql.ErrNoRows) { return roomservice.AdminRoomListEntry{}, false, nil } if err != nil { return roomservice.AdminRoomListEntry{}, false, err } return item, true, nil } func adminRoomSelectSQL() string { return ` SELECT rle.room_id, rle.room_short_id, rle.visible_region_id, rle.owner_user_id, rle.title, rle.cover_url, rle.mode, rle.status, rle.close_reason, rle.closed_by_admin_id, rle.closed_by_admin_name, rle.closed_at_ms, rle.heat, rle.online_count, rle.seat_count, rle.occupied_seat_count, rle.sort_score, rle.created_at_ms, rle.updated_at_ms FROM room_list_entries rle ` } func adminRoomWhere(ctx context.Context, query roomservice.AdminRoomListQuery) (string, []any) { where := []string{"rle.app_code = ?"} app := appcode.Normalize(query.AppCode) if app == "" { app = appcode.FromContext(ctx) } args := []any{app} switch strings.TrimSpace(query.Status) { case "closed": where = append(where, "rle.status <> ?") args = append(args, "active") case "": default: where = append(where, "rle.status = ?") args = append(args, strings.TrimSpace(query.Status)) } if query.RegionID > 0 { where = append(where, "rle.visible_region_id = ?") args = append(args, query.RegionID) } if query.OwnerUserID > 0 { where = append(where, "rle.owner_user_id = ?") args = append(args, query.OwnerUserID) } if strings.TrimSpace(query.Keyword) != "" { like := "%" + strings.ReplaceAll(strings.TrimSpace(query.Keyword), "%", "\\%") + "%" where = append(where, "(rle.room_id LIKE ? ESCAPE '\\\\' OR rle.room_short_id LIKE ? ESCAPE '\\\\' OR rle.title LIKE ? ESCAPE '\\\\' OR CAST(rle.owner_user_id AS CHAR) LIKE ? ESCAPE '\\\\')") args = append(args, like, like, like, like) } return " WHERE " + strings.Join(where, " AND "), args } func adminRoomOrderBy(query roomservice.AdminRoomListQuery) string { direction := "DESC" if strings.EqualFold(strings.TrimSpace(query.SortDirection), "asc") { direction = "ASC" } switch strings.TrimSpace(query.SortBy) { case "room_contribution", "heat": return "rle.heat " + direction + ", rle.created_at_ms DESC, rle.room_id DESC" default: return "rle.created_at_ms DESC, rle.room_id DESC" } } func scanAdminRoom(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomListEntry, error) { var item roomservice.AdminRoomListEntry err := scanner.Scan( &item.RoomID, &item.RoomShortID, &item.VisibleRegionID, &item.OwnerUserID, &item.Title, &item.CoverURL, &item.Mode, &item.Status, &item.CloseReason, &item.ClosedByAdminID, &item.ClosedByAdminName, &item.ClosedAtMS, &item.Heat, &item.OnlineCount, &item.SeatCount, &item.OccupiedSeatCount, &item.SortScore, &item.CreatedAtMS, &item.UpdatedAtMS, ) return item, err } func (r *Repository) AdminListRoomPins(ctx context.Context, query roomservice.AdminRoomPinQuery) ([]roomservice.AdminRoomPinEntry, int64, error) { if query.Page <= 0 { query.Page = 1 } if query.PageSize <= 0 { query.PageSize = 20 } if query.PageSize > 100 { query.PageSize = 100 } where, args := adminRoomPinWhere(ctx, query) var total int64 if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) `+where, args...).Scan(&total); err != nil { return nil, 0, err } rows, err := r.db.QueryContext(ctx, adminRoomPinSelectSQL()+where+` ORDER BY p.weight DESC, p.expires_at_ms DESC, p.id DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, (query.Page-1)*query.PageSize)...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]roomservice.AdminRoomPinEntry, 0, query.PageSize) for rows.Next() { item, err := scanAdminRoomPin(rows) if err != nil { return nil, 0, err } items = append(items, item) } return items, total, rows.Err() } func (r *Repository) AdminCreateRoomPin(ctx context.Context, input roomservice.AdminCreateRoomPinInput) (roomservice.AdminRoomPinEntry, bool, error) { app := appcode.FromContext(ctx) room, exists, err := r.AdminGetRoom(ctx, input.RoomID) if err != nil || !exists { return roomservice.AdminRoomPinEntry{}, false, err } if room.Status != "active" { return roomservice.AdminRoomPinEntry{}, false, nil } pinType := strings.TrimSpace(input.PinType) if pinType == "" { pinType = "region" } visibleRegionID := room.VisibleRegionID if pinType == "global" { visibleRegionID = 0 } pinnedAtMS := input.PinnedAtMS if pinnedAtMS <= 0 { pinnedAtMS = input.NowMS } expiresAtMS := input.ExpiresAtMS if expiresAtMS <= 0 { expiresAtMS = pinnedAtMS + input.DurationDays*24*60*60*1000 } if _, err := r.db.ExecContext(ctx, ` INSERT INTO room_region_pins ( app_code, visible_region_id, pin_type, room_id, weight, status, pinned_at_ms, expires_at_ms, cancelled_at_ms, created_by_admin_id, cancelled_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, 0, ?, ?) ON DUPLICATE KEY UPDATE weight = VALUES(weight), status = VALUES(status), pinned_at_ms = VALUES(pinned_at_ms), expires_at_ms = VALUES(expires_at_ms), cancelled_at_ms = 0, created_by_admin_id = VALUES(created_by_admin_id), cancelled_by_admin_id = 0, created_at_ms = VALUES(created_at_ms), updated_at_ms = VALUES(updated_at_ms) `, app, visibleRegionID, pinType, room.RoomID, input.Weight, pinnedAtMS, expiresAtMS, input.AdminID, input.NowMS, input.NowMS); err != nil { return roomservice.AdminRoomPinEntry{}, false, err } return r.adminGetRoomPin(ctx, `WHERE p.app_code = ? AND p.pin_type = ? AND p.visible_region_id = ? AND p.room_id = ?`, app, pinType, visibleRegionID, room.RoomID) } func (r *Repository) AdminCancelRoomPin(ctx context.Context, pinID int64, adminID uint64, nowMS int64) (roomservice.AdminRoomPinEntry, bool, error) { app := appcode.FromContext(ctx) if _, err := r.db.ExecContext(ctx, ` UPDATE room_region_pins SET status = 'cancelled', cancelled_at_ms = ?, cancelled_by_admin_id = ?, updated_at_ms = ? WHERE app_code = ? AND id = ? AND status <> 'cancelled' `, nowMS, adminID, nowMS, app, pinID); err != nil { return roomservice.AdminRoomPinEntry{}, false, err } return r.adminGetRoomPin(ctx, `WHERE p.app_code = ? AND p.id = ?`, app, pinID) } func (r *Repository) adminGetRoomPin(ctx context.Context, where string, args ...any) (roomservice.AdminRoomPinEntry, bool, error) { item, err := scanAdminRoomPin(r.db.QueryRowContext(ctx, adminRoomPinSelectSQL()+where+` LIMIT 1`, args...)) if errors.Is(err, sql.ErrNoRows) { return roomservice.AdminRoomPinEntry{}, false, nil } if err != nil { return roomservice.AdminRoomPinEntry{}, false, err } return item, true, nil } func adminRoomPinSelectSQL() string { return ` SELECT p.id, p.visible_region_id, p.pin_type, p.room_id, p.weight, p.status, p.pinned_at_ms, p.expires_at_ms, p.cancelled_at_ms, p.created_by_admin_id, p.cancelled_by_admin_id, p.created_at_ms, p.updated_at_ms, r.room_short_id, r.visible_region_id, r.title, r.cover_url, r.owner_user_id, r.status FROM room_region_pins p JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id ` } func adminRoomPinWhere(ctx context.Context, query roomservice.AdminRoomPinQuery) (string, []any) { nowMS := query.NowMS if nowMS <= 0 { nowMS = time.Now().UTC().UnixMilli() } where := []string{"p.app_code = ?"} args := []any{appcode.FromContext(ctx)} switch strings.TrimSpace(query.Status) { case "active": where = append(where, "p.status = ?", "p.expires_at_ms > ?") args = append(args, "active", nowMS) case "expired": where = append(where, "p.status = ?", "p.expires_at_ms <= ?") args = append(args, "active", nowMS) case "cancelled": where = append(where, "p.status = ?") args = append(args, "cancelled") } if query.RegionID > 0 { where = append(where, "p.visible_region_id = ?") args = append(args, query.RegionID) } if strings.TrimSpace(query.PinType) != "" { where = append(where, "p.pin_type = ?") args = append(args, strings.TrimSpace(query.PinType)) } if strings.TrimSpace(query.Keyword) != "" { like := "%" + strings.ReplaceAll(strings.TrimSpace(query.Keyword), "%", "\\%") + "%" where = append(where, "(p.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\' OR CAST(r.owner_user_id AS CHAR) LIKE ? ESCAPE '\\\\')") args = append(args, like, like, like, like) } return ` FROM room_region_pins p JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id WHERE ` + strings.Join(where, " AND "), args } func scanAdminRoomPin(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomPinEntry, error) { var item roomservice.AdminRoomPinEntry err := scanner.Scan(&item.ID, &item.VisibleRegionID, &item.PinType, &item.RoomID, &item.Weight, &item.Status, &item.PinnedAtMS, &item.ExpiresAtMS, &item.CancelledAtMS, &item.CreatedByAdminID, &item.CancelledByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.RoomShortID, &item.RoomVisibleRegionID, &item.Title, &item.CoverURL, &item.OwnerUserID, &item.RoomStatus) return item, err } func roomListAfterCursorSQL(pinRankExpr string, pinWeightExpr string, pinExpiresExpr string, sortExpr string) string { // 排序桶、置顶权重、过期时间和 tab 排序键共同组成游标,避免跨全区置顶/区域置顶/普通房分区翻页时重复或跳过。 return "(" + pinRankExpr + " > ? OR (" + pinRankExpr + " = ? AND " + pinWeightExpr + " < ?) OR (" + pinRankExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " < ?) OR (" + pinRankExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " = ? AND " + sortExpr + " < ?) OR (" + pinRankExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " = ? AND " + sortExpr + " = ? AND r.room_id > ?))" } func roomListAfterCursorArgs(pinListRank int64, pinWeight int64, pinnedUntilMS int64, sortValue int64, roomID string) []any { return []any{ pinListRank, pinListRank, pinWeight, pinListRank, pinWeight, pinnedUntilMS, pinListRank, pinWeight, pinnedUntilMS, sortValue, pinListRank, pinWeight, pinnedUntilMS, sortValue, roomID, } } func buildRoomUserFeedQuerySQL(query roomservice.RoomUserFeedQuery) (string, []any) { where := []string{"f.app_code = ?", "f.user_id = ?", "f.feed_type = ?", "r.visible_region_id = ?", "r.status = ?"} args := []any{appcode.Normalize(query.AppCode), query.UserID, query.FeedType, query.VisibleRegionID, "active"} if strings.TrimSpace(query.Query) != "" { where = append(where, "(r.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\')") like := "%" + escapeRoomListLike(query.Query) + "%" args = append(args, like, like, like) } if query.CursorRoomID != "" { where = append(where, "(f.updated_at_ms < ? OR (f.updated_at_ms = ? AND f.room_id > ?))") args = append(args, query.CursorUpdatedAtMS, query.CursorUpdatedAtMS, query.CursorRoomID) } args = append(args, query.Limit) return roomUserFeedSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY f.updated_at_ms DESC, f.room_id ASC\n\tLIMIT ?", args } func buildRoomFollowQuerySQL(query roomservice.RoomFollowQuery) (string, []any) { where := []string{"f.app_code = ?", "f.user_id = ?", "f.status = ?", "r.visible_region_id = ?", "r.status = ?"} args := []any{appcode.Normalize(query.AppCode), query.UserID, roomFollowStatusActiveSQL, query.VisibleRegionID, "active"} if strings.TrimSpace(query.Query) != "" { where = append(where, "(r.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\')") like := "%" + escapeRoomListLike(query.Query) + "%" args = append(args, like, like, like) } if query.CursorRoomID != "" { where = append(where, "(f.followed_at_ms < ? OR (f.followed_at_ms = ? AND f.room_id > ?))") args = append(args, query.CursorFollowedAtMS, query.CursorFollowedAtMS, query.CursorRoomID) } args = append(args, query.Limit) return roomFollowSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY f.followed_at_ms DESC, f.room_id ASC\n\tLIMIT ?", args } func buildRoomRelatedFeedQuerySQL(query roomservice.RoomRelatedFeedQuery, relations map[int64]int64) (string, []any) { where := []string{"app_code = ?", "visible_region_id = ?", "status = ?"} args := []any{appcode.Normalize(query.AppCode), query.VisibleRegionID, "active"} placeholders := make([]string, 0, len(relations)) relatedIDs := make([]any, 0, len(relations)) for userID := range relations { placeholders = append(placeholders, "?") relatedIDs = append(relatedIDs, userID) } inClause := strings.Join(placeholders, ",") where = append(where, "owner_user_id IN ("+inClause+")") args = append(args, relatedIDs...) if strings.TrimSpace(query.Query) != "" { where = append(where, "(room_id LIKE ? ESCAPE '\\\\' OR room_short_id LIKE ? ESCAPE '\\\\' OR title LIKE ? ESCAPE '\\\\')") like := "%" + escapeRoomListLike(query.Query) + "%" args = append(args, like, like, like) } return roomListSelectColumns + "\n\tWHERE " + strings.Join(where, " AND "), args } func normalizeRoomFeedRelations(items []roomservice.RoomFeedRelatedUser) map[int64]int64 { relations := make(map[int64]int64, len(items)) for _, item := range items { if item.UserID <= 0 || item.RelationUpdatedAtMS <= 0 { continue } if item.RelationUpdatedAtMS > relations[item.UserID] { relations[item.UserID] = item.RelationUpdatedAtMS } } return relations } func relatedFeedSortKey(entry roomservice.RoomListEntry, relations map[int64]int64) (int64, int64) { bestUserID, bestUpdatedAtMS := int64(0), int64(0) choose := func(userID int64) { updatedAtMS := relations[userID] if updatedAtMS == 0 { return } if updatedAtMS > bestUpdatedAtMS || (updatedAtMS == bestUpdatedAtMS && userID > bestUserID) { bestUserID = userID bestUpdatedAtMS = updatedAtMS } } choose(entry.OwnerUserID) return bestUserID, bestUpdatedAtMS } func roomRelatedFeedAfterCursor(entry roomservice.RoomListEntry, cursorUpdatedAtMS int64, cursorSubjectUserID int64, cursorRoomID string) bool { if cursorUpdatedAtMS <= 0 || cursorSubjectUserID <= 0 || cursorRoomID == "" { return true } if entry.UpdatedAtMS != cursorUpdatedAtMS { return entry.UpdatedAtMS < cursorUpdatedAtMS } if entry.FeedSubjectUserID != cursorSubjectUserID { return entry.FeedSubjectUserID < cursorSubjectUserID } return entry.RoomID > cursorRoomID } // ProjectRoomPresence 用最新 RoomSnapshot 投影用户当前房间读模型。 func (r *Repository) ProjectRoomPresence(ctx context.Context, snapshot roomservice.RoomPresenceSnapshot) error { appCode := normalizedRecordAppCode(ctx, snapshot.AppCode) if strings.TrimSpace(snapshot.RoomID) == "" { return nil } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } // 先把该房间上一版 active presence 全部置为 left,再把本次快照里的在线用户 upsert 回 active。 // 这样 Join/Leave/Kick/Close 的投影逻辑统一,避免按命令类型维护多份分支。 if _, err := tx.ExecContext(ctx, `UPDATE room_user_presence SET status = ?, publish_state = '', mic_session_id = '', room_version = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ? AND status = ?`, roomPresenceStatusLeftSQL, snapshot.RoomVersion, snapshot.UpdatedAtMS, appCode, snapshot.RoomID, roomPresenceStatusActiveSQL, ); err != nil { _ = tx.Rollback() return err } for _, user := range snapshot.Users { if user.UserID <= 0 { continue } if _, err := tx.ExecContext(ctx, `INSERT INTO room_user_presence ( app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, joined_at_ms, last_seen_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE room_id = VALUES(room_id), role = VALUES(role), publish_state = VALUES(publish_state), mic_session_id = VALUES(mic_session_id), room_version = VALUES(room_version), status = VALUES(status), joined_at_ms = VALUES(joined_at_ms), last_seen_at_ms = VALUES(last_seen_at_ms), updated_at_ms = VALUES(updated_at_ms)`, appCode, user.UserID, snapshot.RoomID, user.Role, user.PublishState, user.MicSessionID, snapshot.RoomVersion, roomPresenceStatusActiveSQL, user.JoinedAtMS, user.LastSeenAtMS, snapshot.UpdatedAtMS, ); err != nil { _ = tx.Rollback() return err } } return tx.Commit() } // GetCurrentRoomPresence 查询用户当前 active 房间,不扫描发现页列表。 func (r *Repository) GetCurrentRoomPresence(ctx context.Context, userID int64) (roomservice.RoomPresence, bool, error) { row := r.db.QueryRowContext(ctx, `SELECT app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, joined_at_ms, last_seen_at_ms, updated_at_ms FROM room_user_presence WHERE app_code = ? AND user_id = ? AND status = ? LIMIT 1`, appcode.FromContext(ctx), userID, roomPresenceStatusActiveSQL, ) var presence roomservice.RoomPresence if err := row.Scan( &presence.AppCode, &presence.UserID, &presence.RoomID, &presence.Role, &presence.PublishState, &presence.MicSessionID, &presence.RoomVersion, &presence.Status, &presence.JoinedAtMS, &presence.LastSeenAtMS, &presence.UpdatedAtMS, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomPresence{}, false, nil } return roomservice.RoomPresence{}, false, err } return presence, true, nil } // ListRoomOnlineUsers 分页查询某个房间当前 active presence。 func (r *Repository) ListRoomOnlineUsers(ctx context.Context, query roomservice.RoomOnlineUserQuery) (roomservice.RoomOnlineUserPage, error) { page := query.Page if page <= 0 { page = 1 } pageSize := query.PageSize if pageSize <= 0 { pageSize = 50 } offset := (page - 1) * pageSize appCode := normalizedRecordAppCode(ctx, query.AppCode) var total int64 if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_user_presence WHERE app_code = ? AND room_id = ? AND status = ?`, appCode, query.RoomID, roomPresenceStatusActiveSQL, ).Scan(&total); err != nil { return roomservice.RoomOnlineUserPage{}, err } orderBy := "p.last_seen_at_ms DESC, p.user_id ASC" if strings.EqualFold(strings.TrimSpace(query.Sort), "gift_value") { orderBy = "COALESCE(g.gift_value, 0) DESC, p.last_seen_at_ms DESC, p.user_id ASC" } rows, err := r.db.QueryContext(ctx, `SELECT p.app_code, p.user_id, p.room_id, p.role, p.publish_state, p.mic_session_id, p.room_version, p.status, p.joined_at_ms, p.last_seen_at_ms, p.updated_at_ms, COALESCE(g.gift_value, 0) FROM room_user_presence p LEFT JOIN room_user_gift_stats g ON g.app_code = p.app_code AND g.room_id = p.room_id AND g.user_id = p.user_id WHERE p.app_code = ? AND p.room_id = ? AND p.status = ? ORDER BY `+orderBy+` LIMIT ? OFFSET ?`, appCode, query.RoomID, roomPresenceStatusActiveSQL, pageSize, offset, ) if err != nil { return roomservice.RoomOnlineUserPage{}, err } defer rows.Close() users := make([]roomservice.RoomPresence, 0, pageSize) for rows.Next() { var presence roomservice.RoomPresence if err := rows.Scan( &presence.AppCode, &presence.UserID, &presence.RoomID, &presence.Role, &presence.PublishState, &presence.MicSessionID, &presence.RoomVersion, &presence.Status, &presence.JoinedAtMS, &presence.LastSeenAtMS, &presence.UpdatedAtMS, &presence.GiftValue, ); err != nil { return roomservice.RoomOnlineUserPage{}, err } users = append(users, presence) } if err := rows.Err(); err != nil { return roomservice.RoomOnlineUserPage{}, err } return roomservice.RoomOnlineUserPage{ Users: users, Total: total, Page: page, PageSize: pageSize, }, nil } const ( roomPresenceStatusActiveSQL = "active" roomPresenceStatusLeftSQL = "left" roomFollowStatusActiveSQL = "active" roomFollowStatusCancelledSQL = "cancelled" ) func escapeRoomListLike(value string) string { var builder strings.Builder for _, r := range value { if r == '\\' || r == '%' || r == '_' { builder.WriteRune('\\') } builder.WriteRune(r) } return builder.String() } func nullableString(value string) sql.Null[string] { // 空错误写 NULL,便于 SQL 查询区分没有错误和空字符串。 return sql.Null[string]{ V: value, Valid: value != "", } } func normalizedRecordAppCode(ctx context.Context, value string) string { value = strings.TrimSpace(value) if value == "" { return appcode.FromContext(ctx) } return appcode.Normalize(value) } func boolToInt(value bool) int { if value { return 1 } return 0 } func mapRoomMetaDuplicateError(err error) error { if err == nil { return nil } var mysqlErr *mysqlerr.MySQLError if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1062 { return err } message := mysqlErr.Message if strings.Contains(message, "uk_rooms_owner_user") || strings.Contains(message, "owner_user_id") { return xerr.New(xerr.Conflict, "owner already has room") } return xerr.New(xerr.Conflict, "room already exists") }