// Package mysql 实现 room-service 的 MySQL 持久化边界。 package mysql import ( "context" "database/sql" "encoding/json" "errors" "sort" "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_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_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, 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_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), KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms) ) 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, 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_room (app_code, visible_region_id, room_id), KEY idx_room_region_pins_active (app_code, visible_region_id, status, 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`, `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`, `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)`, `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`, } 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.ensureRoomPasswordSchema(ctx); err != nil { return err } if err := r.ensureRoomAdminCloseSchema(ctx); err != nil { return err } return r.ensureOutboxRetrySchema(ctx) } 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) 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 } } hasIndex, err := r.indexExists(ctx, "room_outbox", "idx_room_outbox_retry") if err != nil { return err } if !hasIndex { if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms)`); 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 } // 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 } } 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 } // 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 WHERE app_code = ? AND status IN (?, ?) AND (status = ? OR next_retry_at_ms <= ?) ORDER BY created_at_ms ASC LIMIT ?`, appcode.FromContext(ctx), outbox.StatusPending, outbox.StatusRetryable, outbox.StatusPending, 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() rows, err := tx.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 WHERE app_code = ? AND ( status = ? OR (status = ? AND next_retry_at_ms <= ?) OR (status = ? AND lock_until_ms IS NOT NULL AND lock_until_ms <= ?) ) ORDER BY created_at_ms ASC LIMIT ? FOR UPDATE SKIP LOCKED`, appcode.FromContext(ctx), outbox.StatusPending, outbox.StatusRetryable, nowMS, outbox.StatusDelivering, nowMS, limit, ) if err != nil { _ = tx.Rollback() return nil, err } records := make([]outbox.Record, 0, limit) eventIDs := make([]string, 0, limit) 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 { _ = rows.Close() _ = tx.Rollback() return nil, err } if lockUntilValue.Valid { record.LockUntilMS = lockUntilValue.V } var envelope roomeventsv1.EventEnvelope if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil { _ = rows.Close() _ = tx.Rollback() return nil, err } record.Envelope = &envelope record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode) records = append(records, record) eventIDs = append(eventIDs, record.EventID) } if err := rows.Close(); err != nil { _ = tx.Rollback() return nil, err } if err := rows.Err(); err != nil { _ = tx.Rollback() return nil, err } for _, eventID := range eventIDs { 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.FromContext(ctx), 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 } // 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_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_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.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.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.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 } // 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.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.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.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_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_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_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) { pinnedExpr := "CASE WHEN p.room_id IS NULL THEN 0 ELSE 1 END" pinWeightExpr := "COALESCE(p.weight, 0)" pinExpiresExpr := "COALESCE(p.expires_at_ms, 0)" selectSQL := ` SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, 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, ` + pinWeightExpr + ` AS pin_weight, ` + pinExpiresExpr + ` AS pinned_until_ms FROM room_list_entries r LEFT JOIN room_region_pins p ON p.app_code = r.app_code AND p.visible_region_id = r.visible_region_id AND p.room_id = r.room_id AND p.status = ? AND p.expires_at_ms > ?` where := []string{"r.app_code = ?", "r.visible_region_id = ?", "r.status = ?"} args := []any{"active", query.NowMS, appcode.Normalize(query.AppCode), query.VisibleRegionID, "active"} if query.OwnerUserID > 0 { where = append(where, "r.owner_user_id = ?") args = append(args, query.OwnerUserID) } 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(pinnedExpr, pinWeightExpr, pinExpiresExpr, "r.created_at_ms")) args = append(args, roomListAfterCursorArgs(query.CursorPinned, query.CursorPinWeight, query.CursorPinnedUntilMS, query.CursorCreatedAtMS, query.CursorRoomID)...) } args = append(args, query.Limit) return selectSQL + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY " + pinnedExpr + " DESC, " + pinWeightExpr + " DESC, " + pinExpiresExpr + " DESC, r.created_at_ms DESC, r.room_id ASC\n\tLIMIT ?", args } if query.CursorRoomID != "" { where = append(where, roomListAfterCursorSQL(pinnedExpr, pinWeightExpr, pinExpiresExpr, "r.sort_score")) args = append(args, roomListAfterCursorArgs(query.CursorPinned, query.CursorPinWeight, query.CursorPinnedUntilMS, query.CursorSortScore, query.CursorRoomID)...) } args = append(args, query.Limit) return selectSQL + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY " + pinnedExpr + " DESC, " + pinWeightExpr + " DESC, " + pinExpiresExpr + " DESC, r.sort_score DESC, r.room_id ASC\n\tLIMIT ?", args } func roomListAfterCursorSQL(pinnedExpr string, pinWeightExpr string, pinExpiresExpr string, sortExpr string) string { // 置顶分区、权重、过期时间和原有排序键共同组成游标,避免区域置顶跨页时重复或跳过普通房间。 return "(" + pinnedExpr + " < ? OR (" + pinnedExpr + " = ? AND " + pinWeightExpr + " < ?) OR (" + pinnedExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " < ?) OR (" + pinnedExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " = ? AND " + sortExpr + " < ?) OR (" + pinnedExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " = ? AND " + sortExpr + " = ? AND r.room_id > ?))" } func roomListAfterCursorArgs(pinned bool, pinWeight int64, pinnedUntilMS int64, sortValue int64, roomID string) []any { pinnedValue := 0 if pinned { pinnedValue = 1 } return []any{ pinnedValue, pinnedValue, pinWeight, pinnedValue, pinWeight, pinnedUntilMS, pinnedValue, pinWeight, pinnedUntilMS, sortValue, pinnedValue, 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, 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), 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.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, 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.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 } rows, err := r.db.QueryContext(ctx, `SELECT app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, last_seen_at_ms, updated_at_ms FROM room_user_presence WHERE app_code = ? AND room_id = ? AND status = ? ORDER BY last_seen_at_ms DESC, user_id ASC 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.LastSeenAtMS, &presence.UpdatedAtMS, ); 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 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") }