// Package mysql 实现 room-service 的 MySQL 持久化边界。 package mysql import ( "context" "database/sql" "errors" "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, host_user_id BIGINT NOT NULL, seat_count INT NOT NULL, mode VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, visible_region_id BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), 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, host_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, 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_snapshots ( app_code VARCHAR(32) NOT NULL, room_id VARCHAR(64) NOT NULL, room_version BIGINT NOT NULL, payload LONGBLOB NOT NULL, created_at TIMESTAMP(3) NOT NULL, updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), 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 TIMESTAMP(3) 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 TIMESTAMP(3) NULL, envelope LONGBLOB NOT NULL, created_at TIMESTAMP(3) NOT NULL, retry_count INT NOT NULL DEFAULT 0, last_error TEXT NULL, updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY (app_code, event_id), KEY idx_room_outbox_pending (app_code, status, created_at), KEY idx_room_outbox_claim (app_code, status, lock_until, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `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_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`, } for _, statement := range statements { // 每条 DDL 独立执行,失败时返回具体 SQL 错误给启动流程。 if _, err := r.db.ExecContext(ctx, statement); err != nil { return err } } return nil } // SaveRoomMeta 插入房间元数据。 func (r *Repository) SaveRoomMeta(ctx context.Context, meta roomservice.RoomMeta) error { // rooms 表只保存恢复初始状态需要的元数据,实时态以 snapshot/command log 为准。 appCode := normalizedRecordAppCode(ctx, meta.AppCode) _, err := r.db.ExecContext(ctx, `INSERT INTO rooms (app_code, room_id, room_short_id, owner_user_id, host_user_id, seat_count, mode, status, visible_region_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, appCode, meta.RoomID, meta.RoomShortID, meta.OwnerUserID, meta.HostUserID, meta.SeatCount, meta.Mode, meta.Status, meta.VisibleRegionID, ) 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, host_user_id, seat_count, mode, status, 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.HostUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &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, host_user_id, seat_count, mode, status, 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.HostUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &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 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.CreatedAt); 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) 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.CreatedAt, ) 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) 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.CreatedAt, ); 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, retry_count, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE event_id = event_id`, record.AppCode, record.EventID, record.EventType, record.RoomID, record.Status, envelopeBytes, record.CreatedAt, record.RetryCount, nullableString(record.LastError), ); err != nil { _ = tx.Rollback() return err } } if strings.TrimSpace(commit.RoomStatus) != "" { if _, err := tx.ExecContext(ctx, `UPDATE rooms SET status = ? WHERE app_code = ? AND room_id = ?`, commit.RoomStatus, 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.CreatedAt.UnixMilli(), appCode, commit.Command.RoomID, ); err != nil { _ = tx.Rollback() return err } } return tx.Commit() } // 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 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.CreatedAt); 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) 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 = IF(VALUES(room_version) >= room_version, VALUES(created_at), created_at)`, appCode, snapshot.RoomID, snapshot.RoomVersion, snapshot.Payload, snapshot.CreatedAt, ) 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 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.CreatedAt); 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, retry_count, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE event_id = event_id`, record.AppCode, record.EventID, record.EventType, record.RoomID, record.Status, envelopeBytes, record.CreatedAt, record.RetryCount, nullableString(record.LastError), ); 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,尽量保持事件投递顺序。 rows, err := r.db.QueryContext(ctx, `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until, envelope, created_at, retry_count, COALESCE(last_error, '') FROM room_outbox WHERE app_code = ? AND status IN (?, ?) ORDER BY created_at ASC LIMIT ?`, appcode.FromContext(ctx), outbox.StatusPending, outbox.StatusRetryable, 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.NullTime if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAt, &record.RetryCount, &record.LastError); err != nil { return nil, err } if lockUntil.Valid { record.LockUntilMS = lockUntil.Time.UnixMilli() } 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, lockUntil time.Time) ([]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 } now := time.Now().UTC() rows, err := tx.QueryContext(ctx, `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until, envelope, created_at, retry_count, COALESCE(last_error, '') FROM room_outbox WHERE app_code = ? AND ( status IN (?, ?) OR (status = ? AND lock_until IS NOT NULL AND lock_until <= ?) ) ORDER BY created_at ASC LIMIT ? FOR UPDATE SKIP LOCKED`, appcode.FromContext(ctx), outbox.StatusPending, outbox.StatusRetryable, outbox.StatusDelivering, now, 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.NullTime if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntilValue, &envelopeBytes, &record.CreatedAt, &record.RetryCount, &record.LastError); err != nil { _ = rows.Close() _ = tx.Rollback() return nil, err } if lockUntilValue.Valid { record.LockUntilMS = lockUntilValue.Time.UnixMilli() } 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 = ? WHERE app_code = ? AND event_id = ?`, outbox.StatusDelivering, workerID, lockUntil, 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 = lockUntil.UnixMilli() } 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 = NULL, last_error = NULL WHERE app_code = ? AND event_id = ?`, outbox.StatusDelivered, appcode.FromContext(ctx), eventID, ) return err } // MarkOutboxFailed 标记事件投递失败并累计重试次数。 func (r *Repository) MarkOutboxFailed(ctx context.Context, eventID string, lastErr string) error { // 失败转入 retryable,worker 下一轮可重新抢占。 _, err := r.db.ExecContext(ctx, `UPDATE room_outbox SET status = ?, worker_id = '', lock_until = NULL, retry_count = retry_count + 1, last_error = ? WHERE app_code = ? AND event_id = ?`, outbox.StatusRetryable, lastErr, 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, host_user_id, title, cover_url, mode, status, 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), host_user_id = VALUES(host_user_id), title = VALUES(title), cover_url = VALUES(cover_url), mode = VALUES(mode), status = VALUES(status), 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.HostUserID, entry.Title, entry.CoverURL, entry.Mode, entry.Status, 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) 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 if err := rows.Scan( &entry.AppCode, &entry.RoomID, &entry.RoomShortID, &entry.VisibleRegionID, &entry.OwnerUserID, &entry.HostUserID, &entry.Title, &entry.CoverURL, &entry.Mode, &entry.Status, &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 } const roomListSelectColumns = ` SELECT app_code, room_id, room_short_id, visible_region_id, owner_user_id, host_user_id, title, cover_url, mode, status, heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms FROM room_list_entries` func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) { where := []string{"app_code = ?", "visible_region_id = ?", "status = ?"} args := []any{appcode.Normalize(query.AppCode), query.VisibleRegionID, "active"} if query.OwnerUserID > 0 { where = append(where, "owner_user_id = ?") args = append(args, query.OwnerUserID) } 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) } if query.Tab == "new" || query.Tab == "me" { if query.CursorRoomID != "" { where = append(where, "(created_at_ms < ? OR (created_at_ms = ? AND room_id > ?))") args = append(args, query.CursorCreatedAtMS, query.CursorCreatedAtMS, query.CursorRoomID) } args = append(args, query.Limit) return roomListSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY created_at_ms DESC, room_id ASC\n\tLIMIT ?", args } if query.CursorRoomID != "" { where = append(where, "(sort_score < ? OR (sort_score = ? AND room_id > ?))") args = append(args, query.CursorSortScore, query.CursorSortScore, query.CursorRoomID) } args = append(args, query.Limit) return roomListSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY sort_score DESC, room_id ASC\n\tLIMIT ?", args } 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.NullString { // 空错误写 NULL,便于 SQL 查询区分没有错误和空字符串。 return sql.NullString{ String: 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") }