From b1ffa08be01827ae2a1d0bba0932bbe9ab59a3e1 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 22 Jun 2026 13:59:15 +0800 Subject: [PATCH] =?UTF-8?q?ip=E9=A3=8E=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/room/service/repository.go | 62 +- .../internal/storage/mysql/admin_room.go | 471 +++ .../internal/storage/mysql/command_log.go | 403 ++ .../internal/storage/mysql/outbox.go | 365 ++ .../internal/storage/mysql/presence.go | 196 + .../internal/storage/mysql/repository.go | 3557 ----------------- .../internal/storage/mysql/robot_room.go | 649 +++ .../internal/storage/mysql/room_list.go | 713 ++++ .../internal/storage/mysql/room_meta.go | 171 + .../internal/storage/mysql/schema.go | 686 ++++ .../internal/service/auth/risk_worker.go | 31 +- .../internal/service/auth/service.go | 2 + .../internal/service/auth/service_test.go | 76 + .../internal/storage/mysql/auth/sessions.go | 76 + .../internal/testutil/mysqltest/mysqltest.go | 5 + 15 files changed, 3895 insertions(+), 3568 deletions(-) create mode 100644 services/room-service/internal/storage/mysql/admin_room.go create mode 100644 services/room-service/internal/storage/mysql/command_log.go create mode 100644 services/room-service/internal/storage/mysql/outbox.go create mode 100644 services/room-service/internal/storage/mysql/presence.go create mode 100644 services/room-service/internal/storage/mysql/robot_room.go create mode 100644 services/room-service/internal/storage/mysql/room_list.go create mode 100644 services/room-service/internal/storage/mysql/room_meta.go create mode 100644 services/room-service/internal/storage/mysql/schema.go diff --git a/services/room-service/internal/room/service/repository.go b/services/room-service/internal/room/service/repository.go index ac69c978..e278c36b 100644 --- a/services/room-service/internal/room/service/repository.go +++ b/services/room-service/internal/room/service/repository.go @@ -609,8 +609,24 @@ type RoomRelatedFeedQuery struct { CursorRoomID string } -// Repository 聚合 room-service 在首版需要的全部持久化读写。 +// Repository 聚合 room-service 当前运行需要的全部持久化能力。 +// +// 总接口继续保留给 Service 装配使用,子接口按变化原因拆开,后续单测可以只依赖 +// CommandStore、RoomListStore 或 OutboxStore 这类窄接口,避免为一个用例 mock 整个仓储面。 type Repository interface { + RoomMetaStore + CommandStore + SnapshotStore + RoomConfigStore + OutboxStore + RoomListStore + PresenceStore + AdminRoomStore + RobotRoomStore +} + +// RoomMetaStore 保存房间基础元数据和房主背景素材;它是 Room Cell 恢复前的轻量入口。 +type RoomMetaStore interface { // SaveRoomMeta 保存房间基础元数据,创建房间时必须先建立它。 SaveRoomMeta(ctx context.Context, meta RoomMeta) error // GetRoomMeta 读取房间基础元数据,恢复无内存 Cell 的房间依赖它。 @@ -623,12 +639,30 @@ type Repository interface { GetRoomBackground(ctx context.Context, roomID string, backgroundID int64) (RoomBackgroundImage, bool, error) // ListRoomBackgrounds 按房间读取背景图素材列表,不读取 Room Cell 状态。 ListRoomBackgrounds(ctx context.Context, roomID string) ([]RoomBackgroundImage, error) +} + +// CommandStore 保存命令幂等记录和一次 Room Cell mutation 的事务提交边界。 +type CommandStore interface { // GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。 GetCommand(ctx context.Context, roomID string, commandID string) (CommandRecord, bool, error) // SaveCommand 追加成功命令日志,关键命令恢复必须依赖它。 SaveCommand(ctx context.Context, record CommandRecord) error // SaveMutation 在同一事务中提交命令日志、outbox 事件和可选房间生命周期状态。 SaveMutation(ctx context.Context, commit MutationCommit) error + // ListCommandsAfter 读取快照版本之后的命令,恢复时按版本顺序回放。 + ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]CommandRecord, error) +} + +// SnapshotStore 保存和读取最新快照;它只负责降低恢复成本,不替代 command log。 +type SnapshotStore interface { + // SaveSnapshot 保存最新房间快照,允许覆盖较低版本号快照但不能倒退。 + SaveSnapshot(ctx context.Context, snapshot SnapshotRecord) error + // GetLatestSnapshot 读取当前最新快照,恢复时优先使用它减少回放成本。 + GetLatestSnapshot(ctx context.Context, roomID string) (SnapshotRecord, bool, error) +} + +// RoomConfigStore 管理后台低频配置;读取失败由调用方决定是否使用内置默认值。 +type RoomConfigStore interface { // GetRoomSeatConfig 读取后台配置的房间座位数规则;不存在时 service 层使用内置默认值。 GetRoomSeatConfig(ctx context.Context) (RoomSeatConfig, bool, error) // UpsertRoomSeatConfig 写入房间座位数配置,供本地验证和后台配置共享同一张表。 @@ -637,12 +671,10 @@ type Repository interface { GetRoomRocketConfig(ctx context.Context) (RoomRocketConfig, bool, error) // UpsertRoomRocketConfig 写入语音房火箭配置,主要供集成测试和本地验证复用生产表结构。 UpsertRoomRocketConfig(ctx context.Context, config RoomRocketConfig) error - // ListCommandsAfter 读取快照版本之后的命令,恢复时按版本顺序回放。 - ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]CommandRecord, error) - // SaveSnapshot 保存最新房间快照,允许覆盖较低版本号快照但不能倒退。 - SaveSnapshot(ctx context.Context, snapshot SnapshotRecord) error - // GetLatestSnapshot 读取当前最新快照,恢复时优先使用它减少回放成本。 - GetLatestSnapshot(ctx context.Context, roomID string) (SnapshotRecord, bool, error) +} + +// OutboxStore 管理 room_outbox 和 robot outbox 的补偿投递生命周期。 +type OutboxStore interface { // SaveOutbox 写入房间外事件,必须和成功命令保持同一提交语义。 SaveOutbox(ctx context.Context, records []outbox.Record) error // ListPendingOutbox 扫描待补偿投递事件。 @@ -665,6 +697,10 @@ type Repository interface { MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error // MarkRobotOutboxDead 把超过最大重试次数的机器人展示事件转入 failed。 MarkRobotOutboxDead(ctx context.Context, eventID string, lastErr string) error +} + +// RoomListStore 管理发现页、Mine 页和关系房间流读模型;它不读取 Room Cell 内存。 +type RoomListStore interface { // UpsertRoomListEntry 写入或更新房间列表读模型;失败不应破坏 Room Cell 已提交状态。 UpsertRoomListEntry(ctx context.Context, entry RoomListEntry) error // ListRoomListEntries 按区域和 tab 查询房间列表,不访问 Room Cell 内存。 @@ -685,17 +721,29 @@ type Repository interface { ListRoomFollowEntries(ctx context.Context, query RoomFollowQuery) ([]RoomListEntry, error) // ListRoomRelatedFeedEntries 根据 user-service 关系事实查询 active 房间卡片,不持久化关系状态。 ListRoomRelatedFeedEntries(ctx context.Context, query RoomRelatedFeedQuery) ([]RoomListEntry, error) +} + +// PresenceStore 管理当前房间 presence 投影;权威状态仍然来自 Room Cell 快照。 +type PresenceStore interface { // ProjectRoomPresence 用最新快照刷新用户当前房间读模型;失败不应破坏 Room Cell 已提交状态。 ProjectRoomPresence(ctx context.Context, snapshot RoomPresenceSnapshot) error // GetCurrentRoomPresence 查询用户当前 active 房间 presence,不扫描房间列表或快照。 GetCurrentRoomPresence(ctx context.Context, userID int64) (RoomPresence, bool, error) // ListRoomOnlineUsers 查询房间 active 在线用户分页,不读取完整 RoomSnapshot。 ListRoomOnlineUsers(ctx context.Context, query RoomOnlineUserQuery) (RoomOnlineUserPage, error) +} + +// AdminRoomStore 服务后台房间列表、详情和置顶管理;后台仍通过 room-service 访问 owner 数据。 +type AdminRoomStore interface { AdminListRooms(ctx context.Context, query AdminRoomListQuery) ([]AdminRoomListEntry, int64, error) AdminGetRoom(ctx context.Context, roomID string, nowMS int64) (AdminRoomListEntry, bool, error) AdminListRoomPins(ctx context.Context, query AdminRoomPinQuery) ([]AdminRoomPinEntry, int64, error) AdminCreateRoomPin(ctx context.Context, input AdminCreateRoomPinInput) (AdminRoomPinEntry, bool, error) AdminCancelRoomPin(ctx context.Context, pinID int64, adminID uint64, nowMS int64) (AdminRoomPinEntry, bool, error) +} + +// RobotRoomStore 管理机器人房间配置、账号占用和真人房补机器人候选查询。 +type RobotRoomStore interface { ListRobotRooms(ctx context.Context, query RobotRoomListQuery) ([]RobotRoomConfig, int64, error) GetRobotRoom(ctx context.Context, roomID string) (RobotRoomConfig, bool, error) CreateRobotRoomConfig(ctx context.Context, input CreateRobotRoomConfigInput) (RobotRoomConfig, error) diff --git a/services/room-service/internal/storage/mysql/admin_room.go b/services/room-service/internal/storage/mysql/admin_room.go new file mode 100644 index 00000000..dba67dd7 --- /dev/null +++ b/services/room-service/internal/storage/mysql/admin_room.go @@ -0,0 +1,471 @@ +// Package mysql 实现 room-service 的 MySQL 持久化边界。 +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "hyapp/pkg/appcode" + roomservice "hyapp/services/room-service/internal/room/service" +) + +// 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 +} + +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 + } + if query.NowMS <= 0 { + query.NowMS = time.Now().UTC().UnixMilli() + } + 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 + } + contributionExpr := adminRoomContributionExpr(query.NowMS) + rows, err := r.db.QueryContext(ctx, adminRoomSelectSQL(contributionExpr)+where+` ORDER BY `+adminRoomOrderBy(query, contributionExpr)+` 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, nowMS int64) (roomservice.AdminRoomListEntry, bool, error) { + contributionExpr := adminRoomContributionExpr(nowMS) + row := r.db.QueryRowContext(ctx, adminRoomSelectSQL(contributionExpr)+` 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(contributionExpr string) 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, + ` + contributionExpr + ` AS room_contribution, 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, contributionExpr string) string { + direction := "DESC" + if strings.EqualFold(strings.TrimSpace(query.SortDirection), "asc") { + direction = "ASC" + } + switch strings.TrimSpace(query.SortBy) { + case "room_contribution", "heat": + return contributionExpr + " " + direction + ", rle.created_at_ms DESC, rle.room_id DESC" + default: + return "rle.created_at_ms DESC, rle.room_id DESC" + } +} + +func adminRoomContributionExpr(nowMS int64) string { + weekStartMS := roomContributionWeekStartMS(nowMS) + return "CASE WHEN rle.contribution_week_start_ms = " + strconv.FormatInt(weekStartMS, 10) + " THEN rle.weekly_contribution ELSE 0 END" +} + +func roomContributionWeekStartMS(nowMS int64) int64 { + if nowMS <= 0 { + nowMS = time.Now().UTC().UnixMilli() + } + dayStart := time.UnixMilli(nowMS).UTC() + dayStart = time.Date(dayStart.Year(), dayStart.Month(), dayStart.Day(), 0, 0, 0, 0, time.UTC) + // 房间贡献榜按 UTC 周一 01:00 切周,给 00:00 后的结算和榜单消费留出缓冲窗口。 + cutoff := dayStart.Add(time.Hour) + if time.UnixMilli(nowMS).UTC().Before(cutoff) { + dayStart = dayStart.AddDate(0, 0, -1) + } + weekday := int(dayStart.Weekday()) + if weekday == 0 { + weekday = 7 + } + return dayStart.AddDate(0, 0, 1-weekday).Add(time.Hour).UnixMilli() +} + +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, input.NowMS) + 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 +} diff --git a/services/room-service/internal/storage/mysql/command_log.go b/services/room-service/internal/storage/mysql/command_log.go new file mode 100644 index 00000000..cf16cd16 --- /dev/null +++ b/services/room-service/internal/storage/mysql/command_log.go @@ -0,0 +1,403 @@ +// Package mysql 实现 room-service 的 MySQL 持久化边界。 +package mysql + +import ( + "context" + "database/sql" + "errors" + "strings" + + "google.golang.org/protobuf/proto" + "hyapp/pkg/appcode" + "hyapp/services/room-service/internal/room/outbox" + roomservice "hyapp/services/room-service/internal/room/service" +) + +// 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 + } + + if err := r.insertRoomOutboxRecords(ctx, tx, roomOutboxTable, commit.OutboxRecords); err != nil { + // 任一事件失败就回滚整批,避免 GiftSent/HeatChanged/RankChanged 部分缺失。 + _ = tx.Rollback() + return err + } + if err := r.insertRoomOutboxRecords(ctx, tx, roomRobotOutboxTable, commit.RobotOutboxRecords); err != nil { + // robot outbox 也和命令日志同事务提交,避免机器人礼物状态变了但客户端展示事件丢失。 + _ = 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 + } + } + if weekly := commit.RoomWeeklyContribution; weekly != nil && weekly.GiftValue > 0 { + weeklyAppCode := normalizedRecordAppCode(ctx, weekly.AppCode) + weeklyRoomID := strings.TrimSpace(weekly.RoomID) + if weeklyRoomID == "" { + weeklyRoomID = commit.Command.RoomID + } + occurredMS := weekly.OccurredMS + if occurredMS <= 0 { + occurredMS = commit.Command.CreatedAtMS + } + weekStartMS := roomContributionWeekStartMS(occurredMS) + if _, err := tx.ExecContext(ctx, + `UPDATE room_list_entries + SET weekly_contribution = CASE + WHEN contribution_week_start_ms = ? THEN weekly_contribution + ? + ELSE ? + END, + contribution_week_start_ms = ?, + updated_at_ms = ? + WHERE app_code = ? AND room_id = ?`, + weekStartMS, + weekly.GiftValue, + weekly.GiftValue, + weekStartMS, + commit.Command.CreatedAtMS, + weeklyAppCode, + weeklyRoomID, + ); err != nil { + _ = tx.Rollback() + return err + } + } + + return tx.Commit() +} + +func (r *Repository) insertRoomOutboxRecords(ctx context.Context, tx *sql.Tx, table string, records []outbox.Record) error { + table = roomOutboxTableName(table) + if len(records) == 0 { + return nil + } + + for _, record := range records { + record.AppCode = normalizedRecordAppCode(ctx, record.AppCode) + if record.Envelope != nil { + record.Envelope.AppCode = record.AppCode + } + envelopeBytes, err := proto.Marshal(record.Envelope) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO `+table+` (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 { + return err + } + } + + return nil +} + +// 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 +} diff --git a/services/room-service/internal/storage/mysql/outbox.go b/services/room-service/internal/storage/mysql/outbox.go new file mode 100644 index 00000000..acb371b5 --- /dev/null +++ b/services/room-service/internal/storage/mysql/outbox.go @@ -0,0 +1,365 @@ +// Package mysql 实现 room-service 的 MySQL 持久化边界。 +package mysql + +import ( + "context" + "database/sql" + "strings" + "time" + + "google.golang.org/protobuf/proto" + roomeventsv1 "hyapp.local/api/proto/events/room/v1" + "hyapp/pkg/appcode" + "hyapp/services/room-service/internal/room/outbox" +) + +// 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) { + return r.listPendingOutboxFromTable(ctx, roomOutboxTable, limit) +} + +// ListPendingRobotOutbox 返回机器人房间待投递展示事件。 +func (r *Repository) ListPendingRobotOutbox(ctx context.Context, limit int) ([]outbox.Record, error) { + return r.listPendingOutboxFromTable(ctx, roomRobotOutboxTable, limit) +} + +func (r *Repository) listPendingOutboxFromTable(ctx context.Context, table string, limit int) ([]outbox.Record, error) { + if limit <= 0 { + // 默认批量上限避免调用方传 0 导致全表扫描。 + limit = 100 + } + table = roomOutboxTableName(table) + pendingIndex, _ := roomOutboxIndexes(table) + + // 按创建时间顺序扫描 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 `+table+` FORCE INDEX (`+pendingIndex+`) + 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) { + return r.claimPendingOutboxFromTable(ctx, roomOutboxTable, workerID, limit, lockUntilMS) +} + +// ClaimPendingRobotOutbox 抢占机器人房间展示事件,避免机器人流量占用主 room_outbox worker。 +func (r *Repository) ClaimPendingRobotOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]outbox.Record, error) { + return r.claimPendingOutboxFromTable(ctx, roomRobotOutboxTable, workerID, limit, lockUntilMS) +} + +func (r *Repository) claimPendingOutboxFromTable(ctx context.Context, table string, workerID string, limit int, lockUntilMS int64) ([]outbox.Record, error) { + if limit <= 0 { + limit = 100 + } + if strings.TrimSpace(workerID) == "" { + workerID = "room-outbox-worker" + } + table = roomOutboxTableName(table) + pendingIndex, claimIndex := roomOutboxIndexes(table) + + 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 ` + table + ` FORCE INDEX (` + pendingIndex + `) + 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 ` + table + ` FORCE INDEX (` + pendingIndex + `) + 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 ` + table + ` FORCE INDEX (` + claimIndex + `) + 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 `+table+` + 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 { + return r.markOutboxDeliveredInTable(ctx, roomOutboxTable, eventID) +} + +// MarkRobotOutboxDelivered 标记机器人展示事件已经投递成功。 +func (r *Repository) MarkRobotOutboxDelivered(ctx context.Context, eventID string) error { + return r.markOutboxDeliveredInTable(ctx, roomRobotOutboxTable, eventID) +} + +func (r *Repository) markOutboxDeliveredInTable(ctx context.Context, table string, eventID string) error { + table = roomOutboxTableName(table) + // 成功后清空 last_error,后续扫描不再返回该事件。 + _, err := r.db.ExecContext(ctx, + `UPDATE `+table+` + 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 { + return r.markOutboxRetryableInTable(ctx, roomOutboxTable, eventID, lastErr, nextRetryAtMS) +} + +// MarkRobotOutboxRetryable 记录机器人展示事件投递失败,并写入独立重试退避。 +func (r *Repository) MarkRobotOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error { + return r.markOutboxRetryableInTable(ctx, roomRobotOutboxTable, eventID, lastErr, nextRetryAtMS) +} + +func (r *Repository) markOutboxRetryableInTable(ctx context.Context, table string, eventID string, lastErr string, nextRetryAtMS int64) error { + table = roomOutboxTableName(table) + // 失败转入 retryable,worker 只有到 next_retry_at_ms 后才会重新抢占。 + _, err := r.db.ExecContext(ctx, + `UPDATE `+table+` + 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 { + return r.markOutboxDeadInTable(ctx, roomOutboxTable, eventID, lastErr) +} + +// MarkRobotOutboxDead 把超过最大重试次数的机器人展示事件转入 failed。 +func (r *Repository) MarkRobotOutboxDead(ctx context.Context, eventID string, lastErr string) error { + return r.markOutboxDeadInTable(ctx, roomRobotOutboxTable, eventID, lastErr) +} + +func (r *Repository) markOutboxDeadInTable(ctx context.Context, table string, eventID string, lastErr string) error { + table = roomOutboxTableName(table) + _, err := r.db.ExecContext(ctx, + `UPDATE `+table+` + 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 +} + +func roomOutboxTableName(table string) string { + switch table { + case roomRobotOutboxTable: + return roomRobotOutboxTable + default: + return roomOutboxTable + } +} + +func roomOutboxIndexes(table string) (pending string, claim string) { + if roomOutboxTableName(table) == roomRobotOutboxTable { + return "idx_room_robot_outbox_pending", "idx_room_robot_outbox_claim" + } + return "idx_room_outbox_pending", "idx_room_outbox_claim" +} diff --git a/services/room-service/internal/storage/mysql/presence.go b/services/room-service/internal/storage/mysql/presence.go new file mode 100644 index 00000000..5be77e7c --- /dev/null +++ b/services/room-service/internal/storage/mysql/presence.go @@ -0,0 +1,196 @@ +// Package mysql 实现 room-service 的 MySQL 持久化边界。 +package mysql + +import ( + "context" + "database/sql" + "errors" + "strings" + + "hyapp/pkg/appcode" + roomservice "hyapp/services/room-service/internal/room/service" +) + +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 +} diff --git a/services/room-service/internal/storage/mysql/repository.go b/services/room-service/internal/storage/mysql/repository.go index 028df57e..3f3ea5e8 100644 --- a/services/room-service/internal/storage/mysql/repository.go +++ b/services/room-service/internal/storage/mysql/repository.go @@ -4,20 +4,14 @@ 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" ) const ( @@ -73,3557 +67,6 @@ func (r *Repository) Ping(ctx context.Context) error { 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, - weekly_contribution BIGINT NOT NULL DEFAULT 0, - contribution_week_start_ms BIGINT NOT NULL DEFAULT 0, - online_count INT NOT NULL DEFAULT 0, - seat_count INT NOT NULL DEFAULT 0, - occupied_seat_count INT NOT NULL DEFAULT 0, - sort_score BIGINT NOT NULL DEFAULT 0, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, room_id), - KEY idx_room_list_short_id (app_code, room_short_id), - KEY idx_room_list_region_hot (app_code, visible_region_id, status, sort_score DESC, room_id), - KEY idx_room_list_region_new (app_code, visible_region_id, status, created_at_ms DESC, room_id), - KEY idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id), - KEY idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id), - KEY idx_room_list_weekly_contribution (app_code, status, visible_region_id, weekly_contribution DESC, created_at_ms DESC, room_id), - KEY idx_room_list_owner (app_code, owner_user_id, updated_at_ms) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_user_presence ( - app_code VARCHAR(32) NOT NULL, - user_id BIGINT NOT NULL, - room_id VARCHAR(64) NOT NULL, - role VARCHAR(32) NOT NULL, - publish_state VARCHAR(32) NOT NULL DEFAULT '', - mic_session_id VARCHAR(128) NOT NULL DEFAULT '', - room_version BIGINT NOT NULL, - status VARCHAR(32) NOT NULL, - joined_at_ms BIGINT NOT NULL DEFAULT 0, - last_seen_at_ms BIGINT NOT NULL DEFAULT 0, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, user_id), - KEY idx_room_user_presence_room (app_code, room_id, status), - KEY idx_room_user_presence_updated (app_code, status, updated_at_ms) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_user_gift_stats ( - app_code VARCHAR(32) NOT NULL, - room_id VARCHAR(64) NOT NULL, - user_id BIGINT NOT NULL, - gift_value BIGINT NOT NULL DEFAULT 0, - last_gift_at_ms BIGINT NOT NULL DEFAULT 0, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, room_id, user_id), - KEY idx_room_user_gift_stats_room_value (app_code, room_id, gift_value DESC, last_gift_at_ms DESC, user_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_background_images ( - app_code VARCHAR(32) NOT NULL, - background_id BIGINT NOT NULL AUTO_INCREMENT, - room_id VARCHAR(64) NOT NULL, - image_url VARCHAR(256) NOT NULL, - created_by_user_id BIGINT NOT NULL, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (background_id), - UNIQUE KEY uk_room_background_url (app_code, room_id, image_url), - KEY idx_room_background_list (app_code, room_id, created_at_ms DESC, background_id DESC) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_snapshots ( - app_code VARCHAR(32) NOT NULL, - room_id VARCHAR(64) NOT NULL, - room_version BIGINT NOT NULL, - payload LONGBLOB NOT NULL, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, room_id), - INDEX idx_room_snapshots_version (app_code, room_id, room_version) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_command_log ( - app_code VARCHAR(32) NOT NULL, - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - room_id VARCHAR(64) NOT NULL, - room_version BIGINT NOT NULL, - command_id VARCHAR(128) NOT NULL, - command_type VARCHAR(64) NOT NULL, - owner_node_id VARCHAR(128) NOT NULL DEFAULT '', - lease_token VARCHAR(128) NOT NULL DEFAULT '', - payload LONGBLOB NOT NULL, - replayable BOOLEAN NOT NULL, - created_at_ms BIGINT NOT NULL, - UNIQUE KEY uk_room_command (app_code, room_id, command_id), - KEY idx_room_command_replay (app_code, room_id, room_version) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_outbox ( - app_code VARCHAR(32) NOT NULL, - event_id VARCHAR(128) NOT NULL, - event_type VARCHAR(64) NOT NULL, - room_id VARCHAR(64) NOT NULL, - status VARCHAR(32) NOT NULL, - worker_id VARCHAR(128) NOT NULL DEFAULT '', - lock_until_ms BIGINT NULL, - envelope LONGBLOB NOT NULL, - created_at_ms BIGINT NOT NULL, - retry_count INT NOT NULL DEFAULT 0, - next_retry_at_ms BIGINT NOT NULL DEFAULT 0, - last_error TEXT NULL, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, event_id), - KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id), - KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id), - KEY idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id), - KEY idx_room_outbox_retention (app_code, status, updated_at_ms, event_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_robot_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_robot_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id), - KEY idx_room_robot_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id), - KEY idx_room_robot_outbox_event_created (app_code, event_type, created_at_ms, event_id), - KEY idx_room_robot_outbox_retention (app_code, status, updated_at_ms, event_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_user_feed_entries ( - app_code VARCHAR(32) NOT NULL, - user_id BIGINT NOT NULL, - feed_type VARCHAR(32) NOT NULL, - room_id VARCHAR(64) NOT NULL, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, user_id, feed_type, room_id), - KEY idx_room_user_feed_list (app_code, user_id, feed_type, updated_at_ms DESC, room_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_follows ( - app_code VARCHAR(32) NOT NULL, - user_id BIGINT NOT NULL, - room_id VARCHAR(64) NOT NULL, - status VARCHAR(32) NOT NULL, - followed_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, user_id, room_id), - KEY idx_room_follows_user_list (app_code, user_id, status, followed_at_ms DESC, room_id), - KEY idx_room_follows_room (app_code, room_id, status) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_region_pins ( - id BIGINT NOT NULL AUTO_INCREMENT, - app_code VARCHAR(32) NOT NULL, - visible_region_id BIGINT NOT NULL, - pin_type VARCHAR(32) NOT NULL DEFAULT 'region', - room_id VARCHAR(64) NOT NULL, - weight BIGINT NOT NULL DEFAULT 0, - status VARCHAR(32) NOT NULL, - pinned_at_ms BIGINT NOT NULL, - expires_at_ms BIGINT NOT NULL, - cancelled_at_ms BIGINT NOT NULL DEFAULT 0, - created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - cancelled_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_room_region_pins_scope (app_code, pin_type, visible_region_id, room_id), - KEY idx_room_region_pins_active_scope (app_code, pin_type, visible_region_id, status, pinned_at_ms, expires_at_ms, weight DESC, room_id), - KEY idx_room_region_pins_room (app_code, room_id, status) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间置顶表'`, - `CREATE TABLE IF NOT EXISTS room_seat_configs ( - app_code VARCHAR(32) NOT NULL, - allowed_seat_counts VARCHAR(128) NOT NULL, - default_seat_count INT NOT NULL, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_rocket_configs ( - app_code VARCHAR(32) NOT NULL, - enabled TINYINT(1) NOT NULL DEFAULT 0, - config_version BIGINT NOT NULL DEFAULT 1, - fuel_source VARCHAR(32) NOT NULL, - launch_delay_ms BIGINT NOT NULL, - broadcast_enabled TINYINT(1) NOT NULL DEFAULT 1, - broadcast_scope VARCHAR(32) NOT NULL, - broadcast_delay_ms BIGINT NOT NULL DEFAULT 0, - reward_stack_policy VARCHAR(32) NOT NULL, - levels_json JSON NOT NULL, - gift_fuel_rules_json JSON NOT NULL, - updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code), - KEY idx_room_rocket_enabled (app_code, enabled) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_robot_rooms ( - app_code VARCHAR(32) NOT NULL, - room_id VARCHAR(64) NOT NULL, - room_short_id VARCHAR(32) NOT NULL DEFAULT '', - title VARCHAR(128) NOT NULL DEFAULT '', - cover_url VARCHAR(512) NOT NULL DEFAULT '', - visible_region_id BIGINT NOT NULL DEFAULT 0, - status VARCHAR(32) NOT NULL, - owner_robot_user_id BIGINT NOT NULL, - robot_user_ids_json JSON NOT NULL, - active_robot_count INT NOT NULL DEFAULT 0, - seat_count INT NOT NULL DEFAULT 10, - gift_ids_json JSON NOT NULL, - lucky_gift_ids_json JSON NOT NULL, - normal_gift_interval_ms BIGINT NOT NULL, - lucky_combo_min BIGINT NOT NULL, - lucky_combo_max BIGINT NOT NULL, - lucky_pause_min_ms BIGINT NOT NULL, - lucky_pause_max_ms BIGINT NOT NULL, - robot_stay_min_ms BIGINT NOT NULL DEFAULT 180000, - robot_stay_max_ms BIGINT NOT NULL DEFAULT 600000, - robot_replace_min_ms BIGINT NOT NULL DEFAULT 0, - robot_replace_max_ms BIGINT NOT NULL DEFAULT 60000, - max_gift_senders BIGINT NOT NULL DEFAULT 1, - created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, room_id), - KEY idx_room_robot_rooms_status (app_code, status, updated_at_ms, room_id), - KEY idx_room_robot_rooms_owner (app_code, owner_robot_user_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `CREATE TABLE IF NOT EXISTS room_human_robot_configs ( - app_code VARCHAR(32) NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT FALSE, - candidate_room_max_online INT NOT NULL DEFAULT 5, - room_full_stop_online INT NOT NULL DEFAULT 10, - room_target_min_online INT NOT NULL DEFAULT 8, - room_target_max_online INT NOT NULL DEFAULT 10, - robot_stay_min_ms BIGINT NOT NULL DEFAULT 60000, - robot_stay_max_ms BIGINT NOT NULL DEFAULT 600000, - robot_replace_min_ms BIGINT NOT NULL DEFAULT 60000, - robot_replace_max_ms BIGINT NOT NULL DEFAULT 180000, - normal_gift_interval_ms BIGINT NOT NULL DEFAULT 10000, - normal_gift_interval_min_ms BIGINT NOT NULL DEFAULT 1000, - normal_gift_interval_max_ms BIGINT NOT NULL DEFAULT 20000, - gift_ids_json JSON NOT NULL, - lucky_gift_ids_json JSON NOT NULL, - super_lucky_gift_ids_json JSON NOT NULL, - lucky_combo_min BIGINT NOT NULL DEFAULT 1, - lucky_combo_max BIGINT NOT NULL DEFAULT 3, - lucky_pause_min_ms BIGINT NOT NULL DEFAULT 5000, - lucky_pause_max_ms BIGINT NOT NULL DEFAULT 20000, - max_gift_senders BIGINT NOT NULL DEFAULT 1, - country_pools_json JSON NOT NULL, - allowed_owner_ids_json JSON NOT NULL, - country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE, - country_rules_json JSON NOT NULL, - updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code), - KEY idx_room_human_robot_enabled (app_code, enabled) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, - `INSERT IGNORE INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms) - VALUES ('lalu', '[10,15,20,25,30]', 15, 0, 0)`, - `INSERT IGNORE INTO room_rocket_configs ( - app_code, - enabled, - config_version, - fuel_source, - launch_delay_ms, - broadcast_enabled, - broadcast_scope, - broadcast_delay_ms, - reward_stack_policy, - levels_json, - gift_fuel_rules_json, - updated_by_admin_id, - created_at_ms, - updated_at_ms - ) VALUES ( - 'lalu', - 0, - 1, - 'heat_value', - 30000, - 1, - 'region', - 0, - 'allow_stack', - JSON_ARRAY( - JSON_OBJECT('level', 1, 'fuelThreshold', 1000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), - JSON_OBJECT('level', 2, 'fuelThreshold', 3000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), - JSON_OBJECT('level', 3, 'fuelThreshold', 6000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), - JSON_OBJECT('level', 4, 'fuelThreshold', 10000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), - JSON_OBJECT('level', 5, 'fuelThreshold', 15000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()) - ), - JSON_ARRAY(), - 0, - 0, - 0 - )`, - `ALTER TABLE rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, - `ALTER TABLE room_list_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, - `ALTER TABLE room_user_presence MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, - `ALTER TABLE room_snapshots MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, - `ALTER TABLE room_command_log MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, - `ALTER TABLE room_outbox MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, - `ALTER TABLE room_user_feed_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, - `ALTER TABLE room_follows MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, - `ALTER TABLE room_region_pins MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, - `ALTER TABLE room_seat_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, - `ALTER TABLE room_rocket_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, - `ALTER TABLE room_robot_rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, - } - - for _, statement := range statements { - // 每条 DDL 独立执行,失败时返回具体 SQL 错误给启动流程。 - if _, err := r.db.ExecContext(ctx, statement); err != nil { - return err - } - } - - if err := r.ensureRoomListLockSchema(ctx); err != nil { - return err - } - if err := r.ensureRoomListCountrySchema(ctx); err != nil { - return err - } - if err := r.ensureRoomPasswordSchema(ctx); err != nil { - return err - } - if err := r.ensureRoomAdminCloseSchema(ctx); err != nil { - return err - } - if err := r.ensureRoomWeeklyContributionSchema(ctx); err != nil { - return err - } - if err := r.ensureRoomUserPresenceSchema(ctx); err != nil { - return err - } - if err := r.ensureRoomPinSchema(ctx); err != nil { - return err - } - if err := r.ensureRobotRoomBehaviorSchema(ctx); err != nil { - return err - } - if err := r.ensureHumanRobotConfigBehaviorSchema(ctx); err != nil { - return err - } - - return r.ensureOutboxRetrySchema(ctx) -} - -func (r *Repository) ensureHumanRobotConfigBehaviorSchema(ctx context.Context) error { - columns := []struct { - name string - statement string - }{ - {name: "normal_gift_interval_min_ms", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN normal_gift_interval_min_ms BIGINT NOT NULL DEFAULT 1000 AFTER normal_gift_interval_ms`}, - {name: "normal_gift_interval_max_ms", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN normal_gift_interval_max_ms BIGINT NOT NULL DEFAULT 20000 AFTER normal_gift_interval_min_ms`}, - {name: "room_target_min_online", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN room_target_min_online INT NOT NULL DEFAULT 8 AFTER room_full_stop_online`}, - {name: "room_target_max_online", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN room_target_max_online INT NOT NULL DEFAULT 10 AFTER room_target_min_online`}, - {name: "allowed_owner_ids_json", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN allowed_owner_ids_json JSON NULL AFTER country_pools_json`}, - {name: "country_limit_enabled", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER allowed_owner_ids_json`}, - {name: "country_rules_json", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN country_rules_json JSON NULL AFTER country_limit_enabled`}, - } - for _, column := range columns { - exists, err := r.columnExists(ctx, "room_human_robot_configs", column.name) - if err != nil { - return err - } - if exists { - continue - } - if _, err := r.db.ExecContext(ctx, column.statement); err != nil { - return err - } - } - return nil -} - -func (r *Repository) ensureRobotRoomBehaviorSchema(ctx context.Context) error { - columns := []struct { - name string - statement string - }{ - {name: "active_robot_count", statement: `ALTER TABLE room_robot_rooms ADD COLUMN active_robot_count INT NOT NULL DEFAULT 0 AFTER robot_user_ids_json`}, - {name: "seat_count", statement: `ALTER TABLE room_robot_rooms ADD COLUMN seat_count INT NOT NULL DEFAULT 10 AFTER active_robot_count`}, - {name: "robot_stay_min_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_stay_min_ms BIGINT NOT NULL DEFAULT 180000 AFTER lucky_pause_max_ms`}, - {name: "robot_stay_max_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_stay_max_ms BIGINT NOT NULL DEFAULT 600000 AFTER robot_stay_min_ms`}, - {name: "robot_replace_min_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_replace_min_ms BIGINT NOT NULL DEFAULT 0 AFTER robot_stay_max_ms`}, - {name: "robot_replace_max_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_replace_max_ms BIGINT NOT NULL DEFAULT 60000 AFTER robot_replace_min_ms`}, - {name: "max_gift_senders", statement: `ALTER TABLE room_robot_rooms ADD COLUMN max_gift_senders BIGINT NOT NULL DEFAULT 1 AFTER robot_replace_max_ms`}, - } - for _, column := range columns { - exists, err := r.columnExists(ctx, "room_robot_rooms", column.name) - if err != nil { - return err - } - if exists { - continue - } - if _, err := r.db.ExecContext(ctx, column.statement); err != nil { - return err - } - } - return nil -} - -func (r *Repository) ensureRoomWeeklyContributionSchema(ctx context.Context) error { - columns := []struct { - column string - statement string - }{ - { - column: "weekly_contribution", - statement: `ALTER TABLE room_list_entries ADD COLUMN weekly_contribution BIGINT NOT NULL DEFAULT 0 AFTER heat`, - }, - { - column: "contribution_week_start_ms", - statement: `ALTER TABLE room_list_entries ADD COLUMN contribution_week_start_ms BIGINT NOT NULL DEFAULT 0 AFTER weekly_contribution`, - }, - } - for _, item := range columns { - hasColumn, err := r.columnExists(ctx, "room_list_entries", item.column) - if err != nil { - return err - } - if !hasColumn { - if _, err := r.db.ExecContext(ctx, item.statement); err != nil { - return err - } - } - } - hasIndex, err := r.indexExists(ctx, "room_list_entries", "idx_room_list_weekly_contribution") - if err != nil { - return err - } - if !hasIndex { - // 后台按房间贡献排序时只读当前 UTC 周贡献;索引用于收敛 active/region 过滤后的排序成本。 - if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD INDEX idx_room_list_weekly_contribution (app_code, status, visible_region_id, weekly_contribution DESC, created_at_ms DESC, room_id)`); err != nil { - return err - } - } - return nil -} - -func (r *Repository) ensureRoomPinSchema(ctx context.Context) error { - hasColumn, err := r.columnExists(ctx, "room_region_pins", "pin_type") - if err != nil { - return err - } - if !hasColumn { - // pin_type 把“全区置顶”和“区域置顶”的作用域写进同一 owner 表;历史数据默认都是区域置顶。 - if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD COLUMN pin_type VARCHAR(32) NOT NULL DEFAULT 'region' AFTER visible_region_id`); err != nil { - return err - } - } - hasScopeUnique, err := r.indexExists(ctx, "room_region_pins", "uk_room_region_pins_scope") - if err != nil { - return err - } - if !hasScopeUnique { - if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD UNIQUE KEY uk_room_region_pins_scope (app_code, pin_type, visible_region_id, room_id)`); err != nil { - return err - } - } - hasLegacyUnique, err := r.indexExists(ctx, "room_region_pins", "uk_room_region_pins_room") - if err != nil { - return err - } - if hasLegacyUnique { - // 新唯一键允许 region_id=0 的区域置顶和全区置顶并存;旧唯一键会错误地把两者互斥。 - if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins DROP INDEX uk_room_region_pins_room`); err != nil { - return err - } - } - hasActiveScope, err := r.indexExists(ctx, "room_region_pins", "idx_room_region_pins_active_scope") - if err != nil { - return err - } - if !hasActiveScope { - if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD INDEX idx_room_region_pins_active_scope (app_code, pin_type, visible_region_id, status, pinned_at_ms, expires_at_ms, weight DESC, room_id)`); err != nil { - return err - } - } - return nil -} - -func (r *Repository) ensureRoomUserPresenceSchema(ctx context.Context) error { - hasColumn, err := r.columnExists(ctx, "room_user_presence", "joined_at_ms") - if err != nil { - return err - } - if !hasColumn { - if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_user_presence ADD COLUMN joined_at_ms BIGINT NOT NULL DEFAULT 0 AFTER status`); err != nil { - return err - } - } - return nil -} - -func (r *Repository) ensureRoomPasswordSchema(ctx context.Context) error { - hasColumn, err := r.columnExists(ctx, "rooms", "room_password_hash") - if err != nil { - return err - } - if !hasColumn { - // rooms 是最新快照恢复密码哈希的持久来源;列表只使用 locked 布尔投影。 - if _, err := r.db.ExecContext(ctx, `ALTER TABLE rooms ADD COLUMN room_password_hash VARCHAR(128) NOT NULL DEFAULT '' AFTER status`); err != nil { - return err - } - } - return nil -} - -func (r *Repository) ensureRoomListLockSchema(ctx context.Context) error { - hasColumn, err := r.columnExists(ctx, "room_list_entries", "locked") - if err != nil { - return err - } - if !hasColumn { - // locked 是列表/搜索需要的纯投影字段,历史行默认 false,后续房间状态变更会刷新为真实值。 - if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD COLUMN locked BOOLEAN NOT NULL DEFAULT FALSE AFTER status`); err != nil { - return err - } - } - return nil -} - -func (r *Repository) ensureRoomListCountrySchema(ctx context.Context) error { - hasColumn, err := r.columnExists(ctx, "room_list_entries", "owner_country_code") - if err != nil { - return err - } - if !hasColumn { - // owner_country_code 是房主国家快照,只服务发现页国家筛选;历史行先为空,后续房间投影刷新会补真实值。 - if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD COLUMN owner_country_code VARCHAR(3) NOT NULL DEFAULT '' AFTER visible_region_id`); err != nil { - return err - } - } - indexes := []struct { - name string - statement string - }{ - { - name: "idx_room_list_region_country_hot", - statement: `ALTER TABLE room_list_entries ADD INDEX idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id)`, - }, - { - name: "idx_room_list_region_country_new", - statement: `ALTER TABLE room_list_entries ADD INDEX idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id)`, - }, - } - for _, item := range indexes { - hasIndex, err := r.indexExists(ctx, "room_list_entries", item.name) - if err != nil { - return err - } - if !hasIndex { - if _, err := r.db.ExecContext(ctx, item.statement); err != nil { - return err - } - } - } - return nil -} - -func (r *Repository) ensureRoomAdminCloseSchema(ctx context.Context) error { - columns := []struct { - table string - column string - statement string - }{ - {"rooms", "close_reason", `ALTER TABLE rooms ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER room_password_hash`}, - {"rooms", "closed_by_admin_id", `ALTER TABLE rooms ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`}, - {"rooms", "closed_by_admin_name", `ALTER TABLE rooms ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`}, - {"rooms", "closed_at_ms", `ALTER TABLE rooms ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`}, - {"room_list_entries", "close_reason", `ALTER TABLE room_list_entries ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER locked`}, - {"room_list_entries", "closed_by_admin_id", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`}, - {"room_list_entries", "closed_by_admin_name", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`}, - {"room_list_entries", "closed_at_ms", `ALTER TABLE room_list_entries ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`}, - } - for _, item := range columns { - hasColumn, err := r.columnExists(ctx, item.table, item.column) - if err != nil { - return err - } - if !hasColumn { - if _, err := r.db.ExecContext(ctx, item.statement); err != nil { - return err - } - } - } - return nil -} - -func (r *Repository) ensureOutboxRetrySchema(ctx context.Context) error { - hasColumn, err := r.columnExists(ctx, "room_outbox", "next_retry_at_ms") - if err != nil { - return err - } - if !hasColumn { - if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_outbox ADD COLUMN next_retry_at_ms BIGINT NOT NULL DEFAULT 0 AFTER retry_count`); err != nil { - return err - } - } - indexes := []struct { - name string - statement string - }{ - {"idx_room_outbox_pending", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id)`}, - {"idx_room_outbox_retry", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms, event_id)`}, - {"idx_room_outbox_claim", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id)`}, - {"idx_room_outbox_event_created", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id)`}, - {"idx_room_outbox_retention", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retention (app_code, status, updated_at_ms, event_id)`}, - } - for _, index := range indexes { - hasIndex, err := r.indexExists(ctx, "room_outbox", index.name) - if err != nil { - return err - } - if !hasIndex { - if _, err := r.db.ExecContext(ctx, index.statement); err != nil { - return err - } - } - } - return nil -} - -func (r *Repository) columnExists(ctx context.Context, tableName string, columnName string) (bool, error) { - var exists int - err := r.db.QueryRowContext(ctx, ` - SELECT COUNT(*) - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? - `, tableName, columnName).Scan(&exists) - return exists > 0, err -} - -func (r *Repository) indexExists(ctx context.Context, tableName string, indexName string) (bool, error) { - var exists int - err := r.db.QueryRowContext(ctx, ` - SELECT COUNT(*) - FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? - `, tableName, indexName).Scan(&exists) - return exists > 0, err -} - -// 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 - } - - if err := r.insertRoomOutboxRecords(ctx, tx, roomOutboxTable, commit.OutboxRecords); err != nil { - // 任一事件失败就回滚整批,避免 GiftSent/HeatChanged/RankChanged 部分缺失。 - _ = tx.Rollback() - return err - } - if err := r.insertRoomOutboxRecords(ctx, tx, roomRobotOutboxTable, commit.RobotOutboxRecords); err != nil { - // robot outbox 也和命令日志同事务提交,避免机器人礼物状态变了但客户端展示事件丢失。 - _ = 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 - } - } - if weekly := commit.RoomWeeklyContribution; weekly != nil && weekly.GiftValue > 0 { - weeklyAppCode := normalizedRecordAppCode(ctx, weekly.AppCode) - weeklyRoomID := strings.TrimSpace(weekly.RoomID) - if weeklyRoomID == "" { - weeklyRoomID = commit.Command.RoomID - } - occurredMS := weekly.OccurredMS - if occurredMS <= 0 { - occurredMS = commit.Command.CreatedAtMS - } - weekStartMS := roomContributionWeekStartMS(occurredMS) - if _, err := tx.ExecContext(ctx, - `UPDATE room_list_entries - SET weekly_contribution = CASE - WHEN contribution_week_start_ms = ? THEN weekly_contribution + ? - ELSE ? - END, - contribution_week_start_ms = ?, - updated_at_ms = ? - WHERE app_code = ? AND room_id = ?`, - weekStartMS, - weekly.GiftValue, - weekly.GiftValue, - weekStartMS, - commit.Command.CreatedAtMS, - weeklyAppCode, - weeklyRoomID, - ); err != nil { - _ = tx.Rollback() - return err - } - } - - return tx.Commit() -} - -func (r *Repository) insertRoomOutboxRecords(ctx context.Context, tx *sql.Tx, table string, records []outbox.Record) error { - table = roomOutboxTableName(table) - if len(records) == 0 { - return nil - } - - for _, record := range records { - record.AppCode = normalizedRecordAppCode(ctx, record.AppCode) - if record.Envelope != nil { - record.Envelope.AppCode = record.AppCode - } - envelopeBytes, err := proto.Marshal(record.Envelope) - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, - `INSERT INTO `+table+` (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 { - return err - } - } - - return nil -} - -// 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 -} - -// ListRobotRooms 读取后台机器人房间列表;机器人房间配置归 room-service 所有,后台不直连房间库。 -func (r *Repository) ListRobotRooms(ctx context.Context, query roomservice.RobotRoomListQuery) ([]roomservice.RobotRoomConfig, int64, error) { - appCode := normalizedRecordAppCode(ctx, query.AppCode) - page := query.Page - if page <= 0 { - page = 1 - } - pageSize := query.PageSize - if pageSize <= 0 || pageSize > 100 { - pageSize = 50 - } - status := strings.TrimSpace(query.Status) - args := []any{appCode} - where := `WHERE app_code = ?` - if status != "" { - where += ` AND status = ?` - args = append(args, status) - } - var total int64 - if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_robot_rooms `+where, args...).Scan(&total); err != nil { - return nil, 0, err - } - args = append(args, pageSize, (page-1)*pageSize) - rows, err := r.db.QueryContext(ctx, - `SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status, - owner_robot_user_id, robot_user_ids_json, active_robot_count, seat_count, gift_ids_json, lucky_gift_ids_json, - normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, - robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, max_gift_senders, - created_by_admin_id, created_at_ms, updated_at_ms - FROM room_robot_rooms `+where+` - ORDER BY updated_at_ms DESC, room_id DESC - LIMIT ? OFFSET ?`, - args..., - ) - if err != nil { - return nil, 0, err - } - defer rows.Close() - items, err := scanRobotRoomConfigs(rows) - return items, total, err -} - -// GetRobotRoom 精确读取一个机器人房间配置。 -func (r *Repository) GetRobotRoom(ctx context.Context, roomID string) (roomservice.RobotRoomConfig, bool, error) { - row := r.db.QueryRowContext(ctx, - `SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status, - owner_robot_user_id, robot_user_ids_json, active_robot_count, seat_count, gift_ids_json, lucky_gift_ids_json, - normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, - robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, max_gift_senders, - created_by_admin_id, created_at_ms, updated_at_ms - FROM room_robot_rooms - WHERE app_code = ? AND room_id = ?`, - appcode.FromContext(ctx), - strings.TrimSpace(roomID), - ) - item, err := scanRobotRoomConfig(row) - if errors.Is(err, sql.ErrNoRows) { - return roomservice.RobotRoomConfig{}, false, nil - } - return item, err == nil, err -} - -// CreateRobotRoomConfig 写入机器人房间配置,并用事务锁定候选账号避免两个 active 机器人房复用同一批账号。 -func (r *Repository) CreateRobotRoomConfig(ctx context.Context, input roomservice.CreateRobotRoomConfigInput) (roomservice.RobotRoomConfig, error) { - config := input.Config - appCode := normalizedRecordAppCode(ctx, config.AppCode) - nowMS := input.NowMS - if nowMS <= 0 { - nowMS = time.Now().UTC().UnixMilli() - } - config.AppCode = appCode - config.CreatedAtMS = firstPositiveInt64(config.CreatedAtMS, nowMS) - config.UpdatedAtMS = firstPositiveInt64(config.UpdatedAtMS, nowMS) - if config.Status == "" { - config.Status = "active" - } - rawRobots, rawGifts, rawLuckyGifts, err := robotRoomJSON(config) - if err != nil { - return roomservice.RobotRoomConfig{}, err - } - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return roomservice.RobotRoomConfig{}, err - } - defer func() { _ = tx.Rollback() }() - occupied, err := occupiedRobotUserIDsTx(ctx, tx, appCode, config.RobotUserIDs) - if err != nil { - return roomservice.RobotRoomConfig{}, err - } - if len(occupied) > 0 { - return roomservice.RobotRoomConfig{}, xerr.New(xerr.Conflict, "robot user already assigned to active robot room") - } - _, err = tx.ExecContext(ctx, - `INSERT INTO room_robot_rooms ( - app_code, room_id, room_short_id, title, cover_url, visible_region_id, status, - owner_robot_user_id, robot_user_ids_json, active_robot_count, seat_count, gift_ids_json, lucky_gift_ids_json, - normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, - robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, max_gift_senders, - created_by_admin_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - config.AppCode, - config.RoomID, - config.RoomShortID, - config.Title, - config.CoverURL, - config.VisibleRegionID, - config.Status, - config.OwnerRobotUserID, - string(rawRobots), - config.ActiveRobotCount, - config.SeatCount, - string(rawGifts), - string(rawLuckyGifts), - config.GiftRule.NormalGiftIntervalMS, - config.GiftRule.LuckyComboMin, - config.GiftRule.LuckyComboMax, - config.GiftRule.LuckyPauseMinMS, - config.GiftRule.LuckyPauseMaxMS, - config.GiftRule.RobotStayMinMS, - config.GiftRule.RobotStayMaxMS, - config.GiftRule.RobotReplaceMinMS, - config.GiftRule.RobotReplaceMaxMS, - config.GiftRule.MaxGiftSenders, - config.CreatedByAdminID, - config.CreatedAtMS, - config.UpdatedAtMS, - ) - if err != nil { - return roomservice.RobotRoomConfig{}, err - } - if err := tx.Commit(); err != nil { - return roomservice.RobotRoomConfig{}, err - } - return config, nil -} - -// UpdateRobotRoomStatus 只切换机器人房间运行态;停止后账号会从“已占用”集合释放。 -func (r *Repository) UpdateRobotRoomStatus(ctx context.Context, roomID string, status string, nowMS int64) (roomservice.RobotRoomConfig, bool, error) { - status = strings.TrimSpace(status) - if nowMS <= 0 { - nowMS = time.Now().UTC().UnixMilli() - } - res, err := r.db.ExecContext(ctx, - `UPDATE room_robot_rooms - SET status = ?, updated_at_ms = ? - WHERE app_code = ? AND room_id = ?`, - status, - nowMS, - appcode.FromContext(ctx), - strings.TrimSpace(roomID), - ) - if err != nil { - return roomservice.RobotRoomConfig{}, false, err - } - affected, _ := res.RowsAffected() - if affected == 0 { - return roomservice.RobotRoomConfig{}, false, nil - } - item, exists, err := r.GetRobotRoom(ctx, roomID) - return item, exists, err -} - -// OccupiedRobotUserIDs 返回仍被 active 机器人房占用的机器人账号集合。 -func (r *Repository) OccupiedRobotUserIDs(ctx context.Context, userIDs []int64) (map[int64]bool, error) { - return occupiedRobotUserIDsQuery(ctx, r.db, appcode.FromContext(ctx), userIDs, false) -} - -// ListActiveRobotRooms 为 room-service 重启恢复运行时循环提供当前 active 配置。 -func (r *Repository) ListActiveRobotRooms(ctx context.Context) ([]roomservice.RobotRoomConfig, error) { - rows, err := r.db.QueryContext(ctx, - `SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status, - owner_robot_user_id, robot_user_ids_json, active_robot_count, seat_count, gift_ids_json, lucky_gift_ids_json, - normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, - robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, max_gift_senders, - created_by_admin_id, created_at_ms, updated_at_ms - FROM room_robot_rooms - WHERE status = 'active' - ORDER BY app_code ASC, updated_at_ms DESC, room_id DESC`, - ) - if err != nil { - return nil, err - } - defer rows.Close() - return scanRobotRoomConfigs(rows) -} - -// GetHumanRoomRobotConfig 读取真人房间机器人行为配置;没有配置时由 service 层补默认值。 -func (r *Repository) GetHumanRoomRobotConfig(ctx context.Context) (roomservice.HumanRoomRobotConfig, bool, error) { - row := r.db.QueryRowContext(ctx, - `SELECT app_code, enabled, candidate_room_max_online, room_full_stop_online, room_target_min_online, room_target_max_online, - robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, - normal_gift_interval_ms, normal_gift_interval_min_ms, normal_gift_interval_max_ms, - gift_ids_json, lucky_gift_ids_json, super_lucky_gift_ids_json, - lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, max_gift_senders, - country_pools_json, COALESCE(allowed_owner_ids_json, JSON_ARRAY()), - country_limit_enabled, COALESCE(country_rules_json, JSON_ARRAY()), - updated_by_admin_id, created_at_ms, updated_at_ms - FROM room_human_robot_configs - WHERE app_code = ?`, - appcode.FromContext(ctx), - ) - config, err := scanHumanRoomRobotConfig(row) - if errors.Is(err, sql.ErrNoRows) { - return roomservice.HumanRoomRobotConfig{}, false, nil - } - return config, err == nil, err -} - -// UpsertHumanRoomRobotConfig 保存真人房间机器人行为配置;运行时扫描每轮读取最新配置。 -func (r *Repository) UpsertHumanRoomRobotConfig(ctx context.Context, config roomservice.HumanRoomRobotConfig) (roomservice.HumanRoomRobotConfig, error) { - appCode := normalizedRecordAppCode(ctx, config.AppCode) - nowMS := config.UpdatedAtMS - if nowMS <= 0 { - nowMS = time.Now().UTC().UnixMilli() - } - config.AppCode = appCode - config.CreatedAtMS = firstPositiveInt64(config.CreatedAtMS, nowMS) - config.UpdatedAtMS = nowMS - rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, rawCountryRules, err := humanRoomRobotConfigJSON(config) - if err != nil { - return roomservice.HumanRoomRobotConfig{}, err - } - _, err = r.db.ExecContext(ctx, - `INSERT INTO room_human_robot_configs ( - app_code, enabled, candidate_room_max_online, room_full_stop_online, room_target_min_online, room_target_max_online, - robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, - normal_gift_interval_ms, normal_gift_interval_min_ms, normal_gift_interval_max_ms, - gift_ids_json, lucky_gift_ids_json, super_lucky_gift_ids_json, - lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, max_gift_senders, - country_pools_json, allowed_owner_ids_json, country_limit_enabled, country_rules_json, - updated_by_admin_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - enabled = VALUES(enabled), - candidate_room_max_online = VALUES(candidate_room_max_online), - room_full_stop_online = VALUES(room_full_stop_online), - room_target_min_online = VALUES(room_target_min_online), - room_target_max_online = VALUES(room_target_max_online), - robot_stay_min_ms = VALUES(robot_stay_min_ms), - robot_stay_max_ms = VALUES(robot_stay_max_ms), - robot_replace_min_ms = VALUES(robot_replace_min_ms), - robot_replace_max_ms = VALUES(robot_replace_max_ms), - normal_gift_interval_ms = VALUES(normal_gift_interval_ms), - normal_gift_interval_min_ms = VALUES(normal_gift_interval_min_ms), - normal_gift_interval_max_ms = VALUES(normal_gift_interval_max_ms), - gift_ids_json = VALUES(gift_ids_json), - lucky_gift_ids_json = VALUES(lucky_gift_ids_json), - super_lucky_gift_ids_json = VALUES(super_lucky_gift_ids_json), - lucky_combo_min = VALUES(lucky_combo_min), - lucky_combo_max = VALUES(lucky_combo_max), - lucky_pause_min_ms = VALUES(lucky_pause_min_ms), - lucky_pause_max_ms = VALUES(lucky_pause_max_ms), - max_gift_senders = VALUES(max_gift_senders), - country_pools_json = VALUES(country_pools_json), - allowed_owner_ids_json = VALUES(allowed_owner_ids_json), - country_limit_enabled = VALUES(country_limit_enabled), - country_rules_json = VALUES(country_rules_json), - updated_by_admin_id = VALUES(updated_by_admin_id), - updated_at_ms = VALUES(updated_at_ms)`, - config.AppCode, - boolToInt(config.Enabled), - config.CandidateRoomMaxOnline, - config.RoomFullStopOnline, - config.RoomTargetMinOnline, - config.RoomTargetMaxOnline, - config.RobotStayMinMS, - config.RobotStayMaxMS, - config.RobotReplaceMinMS, - config.RobotReplaceMaxMS, - config.NormalGiftIntervalMS, - config.NormalGiftIntervalMinMS, - config.NormalGiftIntervalMaxMS, - string(rawGifts), - string(rawLucky), - string(rawSuperLucky), - config.LuckyComboMin, - config.LuckyComboMax, - config.LuckyPauseMinMS, - config.LuckyPauseMaxMS, - config.MaxGiftSenders, - string(rawPools), - string(rawAllowedOwners), - boolToInt(config.CountryLimitEnabled), - string(rawCountryRules), - config.UpdatedByAdminID, - config.CreatedAtMS, - config.UpdatedAtMS, - ) - if err != nil { - return roomservice.HumanRoomRobotConfig{}, err - } - saved, _, err := r.GetHumanRoomRobotConfig(ctx) - return saved, err -} - -// ListHumanRobotCandidateRooms 找出同国家低人数真人房;allowedOwnerIDs 为空时保持全量扫描,非空时只命中房主短号或内部 owner_user_id。 -func (r *Repository) ListHumanRobotCandidateRooms(ctx context.Context, appCode string, countryCode string, maxOnline int32, fullStopOnline int32, limit int, allowedOwnerIDs []string) ([]roomservice.RoomListEntry, error) { - appCode = normalizedRecordAppCode(ctx, appCode) - countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) - if maxOnline <= 0 { - maxOnline = 5 - } - if fullStopOnline <= 0 { - fullStopOnline = 10 - } - if maxOnline > fullStopOnline { - maxOnline = fullStopOnline - } - if limit <= 0 || limit > 100 { - limit = 50 - } - shortIDs, ownerUserIDs := splitHumanRobotAllowedOwnerIDs(allowedOwnerIDs) - where := []string{ - "app_code = ?", - "status = 'active'", - "owner_country_code = ?", - "online_count < ?", - "online_count < ?", - "room_id NOT LIKE 'robot\\_%'", - } - args := []any{appCode, countryCode, maxOnline, fullStopOnline} - if len(shortIDs) > 0 { - ownerWhere := make([]string, 0, 2) - shortPlaceholders := strings.TrimRight(strings.Repeat("?,", len(shortIDs)), ",") - ownerWhere = append(ownerWhere, "room_short_id IN ("+shortPlaceholders+")") - for _, id := range shortIDs { - args = append(args, id) - } - if len(ownerUserIDs) > 0 { - userPlaceholders := strings.TrimRight(strings.Repeat("?,", len(ownerUserIDs)), ",") - ownerWhere = append(ownerWhere, "owner_user_id IN ("+userPlaceholders+")") - for _, id := range ownerUserIDs { - args = append(args, id) - } - } - where = append(where, "("+strings.Join(ownerWhere, " OR ")+")") - } - args = append(args, limit) - query := `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 ` + strings.Join(where, "\n AND ") + ` - ORDER BY online_count ASC, updated_at_ms DESC, room_id DESC - LIMIT ?` - rows, err := r.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - items := make([]roomservice.RoomListEntry, 0, 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 - } - items = append(items, entry) - } - return items, rows.Err() -} - -type humanRoomRobotConfigScanner interface { - Scan(dest ...any) error -} - -func scanHumanRoomRobotConfig(row humanRoomRobotConfigScanner) (roomservice.HumanRoomRobotConfig, error) { - var config roomservice.HumanRoomRobotConfig - var enabled int - var rawGifts string - var rawLucky string - var rawSuperLucky string - var rawPools string - var rawAllowedOwners string - var countryLimitEnabled int - var rawCountryRules string - if err := row.Scan( - &config.AppCode, - &enabled, - &config.CandidateRoomMaxOnline, - &config.RoomFullStopOnline, - &config.RoomTargetMinOnline, - &config.RoomTargetMaxOnline, - &config.RobotStayMinMS, - &config.RobotStayMaxMS, - &config.RobotReplaceMinMS, - &config.RobotReplaceMaxMS, - &config.NormalGiftIntervalMS, - &config.NormalGiftIntervalMinMS, - &config.NormalGiftIntervalMaxMS, - &rawGifts, - &rawLucky, - &rawSuperLucky, - &config.LuckyComboMin, - &config.LuckyComboMax, - &config.LuckyPauseMinMS, - &config.LuckyPauseMaxMS, - &config.MaxGiftSenders, - &rawPools, - &rawAllowedOwners, - &countryLimitEnabled, - &rawCountryRules, - &config.UpdatedByAdminID, - &config.CreatedAtMS, - &config.UpdatedAtMS, - ); err != nil { - return roomservice.HumanRoomRobotConfig{}, err - } - config.Enabled = enabled == 1 - if err := json.Unmarshal([]byte(rawGifts), &config.GiftIDs); err != nil { - return roomservice.HumanRoomRobotConfig{}, err - } - if err := json.Unmarshal([]byte(rawLucky), &config.LuckyGiftIDs); err != nil { - return roomservice.HumanRoomRobotConfig{}, err - } - if err := json.Unmarshal([]byte(rawSuperLucky), &config.SuperLuckyGiftIDs); err != nil { - return roomservice.HumanRoomRobotConfig{}, err - } - if err := json.Unmarshal([]byte(rawPools), &config.CountryPools); err != nil { - return roomservice.HumanRoomRobotConfig{}, err - } - if err := json.Unmarshal([]byte(rawAllowedOwners), &config.AllowedOwnerIDs); err != nil { - return roomservice.HumanRoomRobotConfig{}, err - } - config.CountryLimitEnabled = countryLimitEnabled == 1 - if err := json.Unmarshal([]byte(rawCountryRules), &config.CountryRules); err != nil { - return roomservice.HumanRoomRobotConfig{}, err - } - return config, nil -} - -func humanRoomRobotConfigJSON(config roomservice.HumanRoomRobotConfig) ([]byte, []byte, []byte, []byte, []byte, []byte, error) { - rawGifts, err := json.Marshal(config.GiftIDs) - if err != nil { - return nil, nil, nil, nil, nil, nil, err - } - rawLucky, err := json.Marshal(config.LuckyGiftIDs) - if err != nil { - return nil, nil, nil, nil, nil, nil, err - } - rawSuperLucky, err := json.Marshal(config.SuperLuckyGiftIDs) - if err != nil { - return nil, nil, nil, nil, nil, nil, err - } - rawPools, err := json.Marshal(config.CountryPools) - if err != nil { - return nil, nil, nil, nil, nil, nil, err - } - rawAllowedOwners, err := json.Marshal(config.AllowedOwnerIDs) - if err != nil { - return nil, nil, nil, nil, nil, nil, err - } - rawCountryRules, err := json.Marshal(config.CountryRules) - if err != nil { - return nil, nil, nil, nil, nil, nil, err - } - return rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, rawCountryRules, nil -} - -func splitHumanRobotAllowedOwnerIDs(values []string) ([]string, []int64) { - seenShortIDs := make(map[string]bool, len(values)) - seenUserIDs := make(map[int64]bool, len(values)) - shortIDs := make([]string, 0, len(values)) - userIDs := make([]int64, 0, len(values)) - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" || seenShortIDs[value] { - continue - } - seenShortIDs[value] = true - shortIDs = append(shortIDs, value) - if strings.ContainsAny(value, ".eE") { - continue - } - userID, err := strconv.ParseInt(value, 10, 64) - if err != nil || userID <= 0 || seenUserIDs[userID] { - continue - } - seenUserIDs[userID] = true - userIDs = append(userIDs, userID) - } - return shortIDs, userIDs -} - -type robotRoomScanner interface { - Scan(dest ...any) error -} - -func scanRobotRoomConfigs(rows *sql.Rows) ([]roomservice.RobotRoomConfig, error) { - items := make([]roomservice.RobotRoomConfig, 0) - for rows.Next() { - item, err := scanRobotRoomConfig(rows) - if err != nil { - return nil, err - } - items = append(items, item) - } - return items, rows.Err() -} - -func scanRobotRoomConfig(row robotRoomScanner) (roomservice.RobotRoomConfig, error) { - var item roomservice.RobotRoomConfig - var rawRobots string - var rawGifts string - var rawLuckyGifts string - if err := row.Scan( - &item.AppCode, - &item.RoomID, - &item.RoomShortID, - &item.Title, - &item.CoverURL, - &item.VisibleRegionID, - &item.Status, - &item.OwnerRobotUserID, - &rawRobots, - &item.ActiveRobotCount, - &item.SeatCount, - &rawGifts, - &rawLuckyGifts, - &item.GiftRule.NormalGiftIntervalMS, - &item.GiftRule.LuckyComboMin, - &item.GiftRule.LuckyComboMax, - &item.GiftRule.LuckyPauseMinMS, - &item.GiftRule.LuckyPauseMaxMS, - &item.GiftRule.RobotStayMinMS, - &item.GiftRule.RobotStayMaxMS, - &item.GiftRule.RobotReplaceMinMS, - &item.GiftRule.RobotReplaceMaxMS, - &item.GiftRule.MaxGiftSenders, - &item.CreatedByAdminID, - &item.CreatedAtMS, - &item.UpdatedAtMS, - ); err != nil { - return roomservice.RobotRoomConfig{}, err - } - if err := json.Unmarshal([]byte(rawRobots), &item.RobotUserIDs); err != nil { - return roomservice.RobotRoomConfig{}, err - } - if err := json.Unmarshal([]byte(rawGifts), &item.GiftRule.GiftIDs); err != nil { - return roomservice.RobotRoomConfig{}, err - } - if err := json.Unmarshal([]byte(rawLuckyGifts), &item.GiftRule.LuckyGiftIDs); err != nil { - return roomservice.RobotRoomConfig{}, err - } - if item.ActiveRobotCount <= 0 { - item.ActiveRobotCount = int32(len(item.RobotUserIDs)) - } - if item.SeatCount <= 0 { - item.SeatCount = 10 - } - item.GiftRule = roomservice.WithRobotRoomGiftRuleStorageDefaults(item.GiftRule) - return item, nil -} - -func robotRoomJSON(config roomservice.RobotRoomConfig) ([]byte, []byte, []byte, error) { - rawRobots, err := json.Marshal(config.RobotUserIDs) - if err != nil { - return nil, nil, nil, err - } - rawGifts, err := json.Marshal(config.GiftRule.GiftIDs) - if err != nil { - return nil, nil, nil, err - } - rawLuckyGifts, err := json.Marshal(config.GiftRule.LuckyGiftIDs) - if err != nil { - return nil, nil, nil, err - } - return rawRobots, rawGifts, rawLuckyGifts, nil -} - -func occupiedRobotUserIDsTx(ctx context.Context, tx *sql.Tx, appCode string, userIDs []int64) (map[int64]bool, error) { - return occupiedRobotUserIDsQuery(ctx, tx, appCode, userIDs, true) -} - -type dbQuerier interface { - QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) -} - -func occupiedRobotUserIDsQuery(ctx context.Context, db dbQuerier, appCode string, userIDs []int64, lock bool) (map[int64]bool, error) { - ids := uniquePositiveInt64(userIDs) - occupied := make(map[int64]bool, len(ids)) - if len(ids) == 0 { - return occupied, nil - } - query := `SELECT robot_user_ids_json FROM room_robot_rooms WHERE app_code = ? AND status = 'active'` - if lock { - query += ` FOR UPDATE` - } - rows, err := db.QueryContext(ctx, query, appcode.Normalize(appCode)) - if err != nil { - return nil, err - } - defer rows.Close() - wanted := make(map[int64]bool, len(ids)) - for _, id := range ids { - wanted[id] = true - } - for rows.Next() { - var raw string - if err := rows.Scan(&raw); err != nil { - return nil, err - } - var roomRobotIDs []int64 - if err := json.Unmarshal([]byte(raw), &roomRobotIDs); err != nil { - return nil, err - } - for _, id := range roomRobotIDs { - if wanted[id] { - occupied[id] = true - } - } - } - return occupied, rows.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) { - return r.listPendingOutboxFromTable(ctx, roomOutboxTable, limit) -} - -// ListPendingRobotOutbox 返回机器人房间待投递展示事件。 -func (r *Repository) ListPendingRobotOutbox(ctx context.Context, limit int) ([]outbox.Record, error) { - return r.listPendingOutboxFromTable(ctx, roomRobotOutboxTable, limit) -} - -func (r *Repository) listPendingOutboxFromTable(ctx context.Context, table string, limit int) ([]outbox.Record, error) { - if limit <= 0 { - // 默认批量上限避免调用方传 0 导致全表扫描。 - limit = 100 - } - table = roomOutboxTableName(table) - pendingIndex, _ := roomOutboxIndexes(table) - - // 按创建时间顺序扫描 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 `+table+` FORCE INDEX (`+pendingIndex+`) - 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) { - return r.claimPendingOutboxFromTable(ctx, roomOutboxTable, workerID, limit, lockUntilMS) -} - -// ClaimPendingRobotOutbox 抢占机器人房间展示事件,避免机器人流量占用主 room_outbox worker。 -func (r *Repository) ClaimPendingRobotOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]outbox.Record, error) { - return r.claimPendingOutboxFromTable(ctx, roomRobotOutboxTable, workerID, limit, lockUntilMS) -} - -func (r *Repository) claimPendingOutboxFromTable(ctx context.Context, table string, workerID string, limit int, lockUntilMS int64) ([]outbox.Record, error) { - if limit <= 0 { - limit = 100 - } - if strings.TrimSpace(workerID) == "" { - workerID = "room-outbox-worker" - } - table = roomOutboxTableName(table) - pendingIndex, claimIndex := roomOutboxIndexes(table) - - 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 ` + table + ` FORCE INDEX (` + pendingIndex + `) - 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 ` + table + ` FORCE INDEX (` + pendingIndex + `) - 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 ` + table + ` FORCE INDEX (` + claimIndex + `) - 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 `+table+` - 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 { - return r.markOutboxDeliveredInTable(ctx, roomOutboxTable, eventID) -} - -// MarkRobotOutboxDelivered 标记机器人展示事件已经投递成功。 -func (r *Repository) MarkRobotOutboxDelivered(ctx context.Context, eventID string) error { - return r.markOutboxDeliveredInTable(ctx, roomRobotOutboxTable, eventID) -} - -func (r *Repository) markOutboxDeliveredInTable(ctx context.Context, table string, eventID string) error { - table = roomOutboxTableName(table) - // 成功后清空 last_error,后续扫描不再返回该事件。 - _, err := r.db.ExecContext(ctx, - `UPDATE `+table+` - 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 { - return r.markOutboxRetryableInTable(ctx, roomOutboxTable, eventID, lastErr, nextRetryAtMS) -} - -// MarkRobotOutboxRetryable 记录机器人展示事件投递失败,并写入独立重试退避。 -func (r *Repository) MarkRobotOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error { - return r.markOutboxRetryableInTable(ctx, roomRobotOutboxTable, eventID, lastErr, nextRetryAtMS) -} - -func (r *Repository) markOutboxRetryableInTable(ctx context.Context, table string, eventID string, lastErr string, nextRetryAtMS int64) error { - table = roomOutboxTableName(table) - // 失败转入 retryable,worker 只有到 next_retry_at_ms 后才会重新抢占。 - _, err := r.db.ExecContext(ctx, - `UPDATE `+table+` - 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 { - return r.markOutboxDeadInTable(ctx, roomOutboxTable, eventID, lastErr) -} - -// MarkRobotOutboxDead 把超过最大重试次数的机器人展示事件转入 failed。 -func (r *Repository) MarkRobotOutboxDead(ctx context.Context, eventID string, lastErr string) error { - return r.markOutboxDeadInTable(ctx, roomRobotOutboxTable, eventID, lastErr) -} - -func (r *Repository) markOutboxDeadInTable(ctx context.Context, table string, eventID string, lastErr string) error { - table = roomOutboxTableName(table) - _, err := r.db.ExecContext(ctx, - `UPDATE `+table+` - 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 -} - -func roomOutboxTableName(table string) string { - switch table { - case roomRobotOutboxTable: - return roomRobotOutboxTable - default: - return roomOutboxTable - } -} - -func roomOutboxIndexes(table string) (pending string, claim string) { - if roomOutboxTableName(table) == roomRobotOutboxTable { - return "idx_room_robot_outbox_pending", "idx_room_robot_outbox_claim" - } - return "idx_room_outbox_pending", "idx_room_outbox_claim" -} - -// 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 - } - viewerCountryCode := normalizeRoomListSQLCountryCode(query.ViewerCountryCode) - filterCountryCode := normalizeRoomListSQLCountryCode(query.CountryCode) - sortCountryCode := viewerCountryCode - if filterCountryCode != "" { - // 国家 tab 已经把结果集收敛到一个国家,国家置顶和国家优先桶都应按该 tab 国家解释。 - sortCountryCode = filterCountryCode - } - normalRankExpr := "CASE WHEN r.online_count > 0 THEN 2 ELSE 4 END" - if sortCountryCode != "" { - countryLiteral := roomListSQLStringLiteral(sortCountryCode) - normalRankExpr = "CASE WHEN r.owner_country_code = " + countryLiteral + " AND r.online_count > 0 THEN 2 WHEN r.owner_country_code <> " + countryLiteral + " AND r.online_count > 0 THEN 3 WHEN r.owner_country_code = " + countryLiteral + " THEN 4 ELSE 5 END" - } - regionPinScopeSQL := "AND rp.visible_region_id = ?" - countryPinScopeSQL := "AND cp.visible_region_id = ?" - if query.AllVisibleRegions { - // 白名单用户查看全区列表时,区域/国家置顶按房间自身区域命中,不能再固定到 viewer 区域。 - regionPinScopeSQL = "AND rp.visible_region_id = r.visible_region_id" - countryPinScopeSQL = "AND cp.visible_region_id = r.visible_region_id" - } - pinnedExpr := "CASE WHEN gp.room_id IS NULL AND rp.room_id IS NULL AND cp.room_id IS NULL THEN 0 ELSE 1 END" - pinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 0 WHEN cp.room_id IS NOT NULL THEN 1 ELSE " + normalRankExpr + " END" - pinWeightExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.weight WHEN rp.room_id IS NOT NULL THEN rp.weight WHEN cp.room_id IS NOT NULL THEN cp.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 WHEN cp.room_id IS NOT NULL THEN cp.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' - ` + regionPinScopeSQL + ` - AND rp.room_id = r.room_id - AND rp.status = ? - AND rp.pinned_at_ms <= ? - AND rp.expires_at_ms > ? - LEFT JOIN room_region_pins cp - ON cp.app_code = r.app_code - AND cp.pin_type = 'country' - ` + countryPinScopeSQL + ` - AND cp.room_id = r.room_id - AND r.owner_country_code = ? - AND cp.status = ? - AND cp.pinned_at_ms <= ? - AND cp.expires_at_ms > ?` - where := []string{"r.app_code = ?", "r.status = ?"} - args := []any{"active", query.NowMS, query.NowMS} - if !query.AllVisibleRegions { - args = append(args, regionID) - } - args = append(args, "active", query.NowMS, query.NowMS) - if !query.AllVisibleRegions { - args = append(args, regionID) - } - args = append(args, sortCountryCode, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active") - if !query.AllVisibleRegions { - // 普通发现页必须利用区域索引隔离结果;白名单模式只保留 app/status/可选国家/关键词条件。 - where = append(where, "r.visible_region_id = ?") - args = append(args, regionID) - } - 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 normalizeRoomListSQLCountryCode(countryCode string) string { - countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) - if len(countryCode) < 2 || len(countryCode) > 3 { - return "" - } - for _, ch := range countryCode { - if ch < 'A' || ch > 'Z' { - return "" - } - } - return countryCode -} - -func roomListSQLStringLiteral(value string) string { - return "'" + strings.ReplaceAll(value, "'", "''") + "'" -} - -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 - } - if query.NowMS <= 0 { - query.NowMS = time.Now().UTC().UnixMilli() - } - 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 - } - contributionExpr := adminRoomContributionExpr(query.NowMS) - rows, err := r.db.QueryContext(ctx, adminRoomSelectSQL(contributionExpr)+where+` ORDER BY `+adminRoomOrderBy(query, contributionExpr)+` 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, nowMS int64) (roomservice.AdminRoomListEntry, bool, error) { - contributionExpr := adminRoomContributionExpr(nowMS) - row := r.db.QueryRowContext(ctx, adminRoomSelectSQL(contributionExpr)+` 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(contributionExpr string) 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, - ` + contributionExpr + ` AS room_contribution, 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, contributionExpr string) string { - direction := "DESC" - if strings.EqualFold(strings.TrimSpace(query.SortDirection), "asc") { - direction = "ASC" - } - switch strings.TrimSpace(query.SortBy) { - case "room_contribution", "heat": - return contributionExpr + " " + direction + ", rle.created_at_ms DESC, rle.room_id DESC" - default: - return "rle.created_at_ms DESC, rle.room_id DESC" - } -} - -func adminRoomContributionExpr(nowMS int64) string { - weekStartMS := roomContributionWeekStartMS(nowMS) - return "CASE WHEN rle.contribution_week_start_ms = " + strconv.FormatInt(weekStartMS, 10) + " THEN rle.weekly_contribution ELSE 0 END" -} - -func roomContributionWeekStartMS(nowMS int64) int64 { - if nowMS <= 0 { - nowMS = time.Now().UTC().UnixMilli() - } - dayStart := time.UnixMilli(nowMS).UTC() - dayStart = time.Date(dayStart.Year(), dayStart.Month(), dayStart.Day(), 0, 0, 0, 0, time.UTC) - // 房间贡献榜按 UTC 周一 01:00 切周,给 00:00 后的结算和榜单消费留出缓冲窗口。 - cutoff := dayStart.Add(time.Hour) - if time.UnixMilli(nowMS).UTC().Before(cutoff) { - dayStart = dayStart.AddDate(0, 0, -1) - } - weekday := int(dayStart.Weekday()) - if weekday == 0 { - weekday = 7 - } - return dayStart.AddDate(0, 0, 1-weekday).Add(time.Hour).UnixMilli() -} - -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, input.NowMS) - 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" diff --git a/services/room-service/internal/storage/mysql/robot_room.go b/services/room-service/internal/storage/mysql/robot_room.go new file mode 100644 index 00000000..6b440d76 --- /dev/null +++ b/services/room-service/internal/storage/mysql/robot_room.go @@ -0,0 +1,649 @@ +// Package mysql 实现 room-service 的 MySQL 持久化边界。 +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + roomservice "hyapp/services/room-service/internal/room/service" +) + +// ListRobotRooms 读取后台机器人房间列表;机器人房间配置归 room-service 所有,后台不直连房间库。 +func (r *Repository) ListRobotRooms(ctx context.Context, query roomservice.RobotRoomListQuery) ([]roomservice.RobotRoomConfig, int64, error) { + appCode := normalizedRecordAppCode(ctx, query.AppCode) + page := query.Page + if page <= 0 { + page = 1 + } + pageSize := query.PageSize + if pageSize <= 0 || pageSize > 100 { + pageSize = 50 + } + status := strings.TrimSpace(query.Status) + args := []any{appCode} + where := `WHERE app_code = ?` + if status != "" { + where += ` AND status = ?` + args = append(args, status) + } + var total int64 + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_robot_rooms `+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + args = append(args, pageSize, (page-1)*pageSize) + rows, err := r.db.QueryContext(ctx, + `SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status, + owner_robot_user_id, robot_user_ids_json, active_robot_count, seat_count, gift_ids_json, lucky_gift_ids_json, + normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, + robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, max_gift_senders, + created_by_admin_id, created_at_ms, updated_at_ms + FROM room_robot_rooms `+where+` + ORDER BY updated_at_ms DESC, room_id DESC + LIMIT ? OFFSET ?`, + args..., + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + items, err := scanRobotRoomConfigs(rows) + return items, total, err +} + +// GetRobotRoom 精确读取一个机器人房间配置。 +func (r *Repository) GetRobotRoom(ctx context.Context, roomID string) (roomservice.RobotRoomConfig, bool, error) { + row := r.db.QueryRowContext(ctx, + `SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status, + owner_robot_user_id, robot_user_ids_json, active_robot_count, seat_count, gift_ids_json, lucky_gift_ids_json, + normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, + robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, max_gift_senders, + created_by_admin_id, created_at_ms, updated_at_ms + FROM room_robot_rooms + WHERE app_code = ? AND room_id = ?`, + appcode.FromContext(ctx), + strings.TrimSpace(roomID), + ) + item, err := scanRobotRoomConfig(row) + if errors.Is(err, sql.ErrNoRows) { + return roomservice.RobotRoomConfig{}, false, nil + } + return item, err == nil, err +} + +// CreateRobotRoomConfig 写入机器人房间配置,并用事务锁定候选账号避免两个 active 机器人房复用同一批账号。 +func (r *Repository) CreateRobotRoomConfig(ctx context.Context, input roomservice.CreateRobotRoomConfigInput) (roomservice.RobotRoomConfig, error) { + config := input.Config + appCode := normalizedRecordAppCode(ctx, config.AppCode) + nowMS := input.NowMS + if nowMS <= 0 { + nowMS = time.Now().UTC().UnixMilli() + } + config.AppCode = appCode + config.CreatedAtMS = firstPositiveInt64(config.CreatedAtMS, nowMS) + config.UpdatedAtMS = firstPositiveInt64(config.UpdatedAtMS, nowMS) + if config.Status == "" { + config.Status = "active" + } + rawRobots, rawGifts, rawLuckyGifts, err := robotRoomJSON(config) + if err != nil { + return roomservice.RobotRoomConfig{}, err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return roomservice.RobotRoomConfig{}, err + } + defer func() { _ = tx.Rollback() }() + occupied, err := occupiedRobotUserIDsTx(ctx, tx, appCode, config.RobotUserIDs) + if err != nil { + return roomservice.RobotRoomConfig{}, err + } + if len(occupied) > 0 { + return roomservice.RobotRoomConfig{}, xerr.New(xerr.Conflict, "robot user already assigned to active robot room") + } + _, err = tx.ExecContext(ctx, + `INSERT INTO room_robot_rooms ( + app_code, room_id, room_short_id, title, cover_url, visible_region_id, status, + owner_robot_user_id, robot_user_ids_json, active_robot_count, seat_count, gift_ids_json, lucky_gift_ids_json, + normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, + robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, max_gift_senders, + created_by_admin_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + config.AppCode, + config.RoomID, + config.RoomShortID, + config.Title, + config.CoverURL, + config.VisibleRegionID, + config.Status, + config.OwnerRobotUserID, + string(rawRobots), + config.ActiveRobotCount, + config.SeatCount, + string(rawGifts), + string(rawLuckyGifts), + config.GiftRule.NormalGiftIntervalMS, + config.GiftRule.LuckyComboMin, + config.GiftRule.LuckyComboMax, + config.GiftRule.LuckyPauseMinMS, + config.GiftRule.LuckyPauseMaxMS, + config.GiftRule.RobotStayMinMS, + config.GiftRule.RobotStayMaxMS, + config.GiftRule.RobotReplaceMinMS, + config.GiftRule.RobotReplaceMaxMS, + config.GiftRule.MaxGiftSenders, + config.CreatedByAdminID, + config.CreatedAtMS, + config.UpdatedAtMS, + ) + if err != nil { + return roomservice.RobotRoomConfig{}, err + } + if err := tx.Commit(); err != nil { + return roomservice.RobotRoomConfig{}, err + } + return config, nil +} + +// UpdateRobotRoomStatus 只切换机器人房间运行态;停止后账号会从“已占用”集合释放。 +func (r *Repository) UpdateRobotRoomStatus(ctx context.Context, roomID string, status string, nowMS int64) (roomservice.RobotRoomConfig, bool, error) { + status = strings.TrimSpace(status) + if nowMS <= 0 { + nowMS = time.Now().UTC().UnixMilli() + } + res, err := r.db.ExecContext(ctx, + `UPDATE room_robot_rooms + SET status = ?, updated_at_ms = ? + WHERE app_code = ? AND room_id = ?`, + status, + nowMS, + appcode.FromContext(ctx), + strings.TrimSpace(roomID), + ) + if err != nil { + return roomservice.RobotRoomConfig{}, false, err + } + affected, _ := res.RowsAffected() + if affected == 0 { + return roomservice.RobotRoomConfig{}, false, nil + } + item, exists, err := r.GetRobotRoom(ctx, roomID) + return item, exists, err +} + +// OccupiedRobotUserIDs 返回仍被 active 机器人房占用的机器人账号集合。 +func (r *Repository) OccupiedRobotUserIDs(ctx context.Context, userIDs []int64) (map[int64]bool, error) { + return occupiedRobotUserIDsQuery(ctx, r.db, appcode.FromContext(ctx), userIDs, false) +} + +// ListActiveRobotRooms 为 room-service 重启恢复运行时循环提供当前 active 配置。 +func (r *Repository) ListActiveRobotRooms(ctx context.Context) ([]roomservice.RobotRoomConfig, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status, + owner_robot_user_id, robot_user_ids_json, active_robot_count, seat_count, gift_ids_json, lucky_gift_ids_json, + normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, + robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, max_gift_senders, + created_by_admin_id, created_at_ms, updated_at_ms + FROM room_robot_rooms + WHERE status = 'active' + ORDER BY app_code ASC, updated_at_ms DESC, room_id DESC`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanRobotRoomConfigs(rows) +} + +// GetHumanRoomRobotConfig 读取真人房间机器人行为配置;没有配置时由 service 层补默认值。 +func (r *Repository) GetHumanRoomRobotConfig(ctx context.Context) (roomservice.HumanRoomRobotConfig, bool, error) { + row := r.db.QueryRowContext(ctx, + `SELECT app_code, enabled, candidate_room_max_online, room_full_stop_online, room_target_min_online, room_target_max_online, + robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, + normal_gift_interval_ms, normal_gift_interval_min_ms, normal_gift_interval_max_ms, + gift_ids_json, lucky_gift_ids_json, super_lucky_gift_ids_json, + lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, max_gift_senders, + country_pools_json, COALESCE(allowed_owner_ids_json, JSON_ARRAY()), + country_limit_enabled, COALESCE(country_rules_json, JSON_ARRAY()), + updated_by_admin_id, created_at_ms, updated_at_ms + FROM room_human_robot_configs + WHERE app_code = ?`, + appcode.FromContext(ctx), + ) + config, err := scanHumanRoomRobotConfig(row) + if errors.Is(err, sql.ErrNoRows) { + return roomservice.HumanRoomRobotConfig{}, false, nil + } + return config, err == nil, err +} + +// UpsertHumanRoomRobotConfig 保存真人房间机器人行为配置;运行时扫描每轮读取最新配置。 +func (r *Repository) UpsertHumanRoomRobotConfig(ctx context.Context, config roomservice.HumanRoomRobotConfig) (roomservice.HumanRoomRobotConfig, error) { + appCode := normalizedRecordAppCode(ctx, config.AppCode) + nowMS := config.UpdatedAtMS + if nowMS <= 0 { + nowMS = time.Now().UTC().UnixMilli() + } + config.AppCode = appCode + config.CreatedAtMS = firstPositiveInt64(config.CreatedAtMS, nowMS) + config.UpdatedAtMS = nowMS + rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, rawCountryRules, err := humanRoomRobotConfigJSON(config) + if err != nil { + return roomservice.HumanRoomRobotConfig{}, err + } + _, err = r.db.ExecContext(ctx, + `INSERT INTO room_human_robot_configs ( + app_code, enabled, candidate_room_max_online, room_full_stop_online, room_target_min_online, room_target_max_online, + robot_stay_min_ms, robot_stay_max_ms, robot_replace_min_ms, robot_replace_max_ms, + normal_gift_interval_ms, normal_gift_interval_min_ms, normal_gift_interval_max_ms, + gift_ids_json, lucky_gift_ids_json, super_lucky_gift_ids_json, + lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms, max_gift_senders, + country_pools_json, allowed_owner_ids_json, country_limit_enabled, country_rules_json, + updated_by_admin_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + enabled = VALUES(enabled), + candidate_room_max_online = VALUES(candidate_room_max_online), + room_full_stop_online = VALUES(room_full_stop_online), + room_target_min_online = VALUES(room_target_min_online), + room_target_max_online = VALUES(room_target_max_online), + robot_stay_min_ms = VALUES(robot_stay_min_ms), + robot_stay_max_ms = VALUES(robot_stay_max_ms), + robot_replace_min_ms = VALUES(robot_replace_min_ms), + robot_replace_max_ms = VALUES(robot_replace_max_ms), + normal_gift_interval_ms = VALUES(normal_gift_interval_ms), + normal_gift_interval_min_ms = VALUES(normal_gift_interval_min_ms), + normal_gift_interval_max_ms = VALUES(normal_gift_interval_max_ms), + gift_ids_json = VALUES(gift_ids_json), + lucky_gift_ids_json = VALUES(lucky_gift_ids_json), + super_lucky_gift_ids_json = VALUES(super_lucky_gift_ids_json), + lucky_combo_min = VALUES(lucky_combo_min), + lucky_combo_max = VALUES(lucky_combo_max), + lucky_pause_min_ms = VALUES(lucky_pause_min_ms), + lucky_pause_max_ms = VALUES(lucky_pause_max_ms), + max_gift_senders = VALUES(max_gift_senders), + country_pools_json = VALUES(country_pools_json), + allowed_owner_ids_json = VALUES(allowed_owner_ids_json), + country_limit_enabled = VALUES(country_limit_enabled), + country_rules_json = VALUES(country_rules_json), + updated_by_admin_id = VALUES(updated_by_admin_id), + updated_at_ms = VALUES(updated_at_ms)`, + config.AppCode, + boolToInt(config.Enabled), + config.CandidateRoomMaxOnline, + config.RoomFullStopOnline, + config.RoomTargetMinOnline, + config.RoomTargetMaxOnline, + config.RobotStayMinMS, + config.RobotStayMaxMS, + config.RobotReplaceMinMS, + config.RobotReplaceMaxMS, + config.NormalGiftIntervalMS, + config.NormalGiftIntervalMinMS, + config.NormalGiftIntervalMaxMS, + string(rawGifts), + string(rawLucky), + string(rawSuperLucky), + config.LuckyComboMin, + config.LuckyComboMax, + config.LuckyPauseMinMS, + config.LuckyPauseMaxMS, + config.MaxGiftSenders, + string(rawPools), + string(rawAllowedOwners), + boolToInt(config.CountryLimitEnabled), + string(rawCountryRules), + config.UpdatedByAdminID, + config.CreatedAtMS, + config.UpdatedAtMS, + ) + if err != nil { + return roomservice.HumanRoomRobotConfig{}, err + } + saved, _, err := r.GetHumanRoomRobotConfig(ctx) + return saved, err +} + +// ListHumanRobotCandidateRooms 找出同国家低人数真人房;allowedOwnerIDs 为空时保持全量扫描,非空时只命中房主短号或内部 owner_user_id。 +func (r *Repository) ListHumanRobotCandidateRooms(ctx context.Context, appCode string, countryCode string, maxOnline int32, fullStopOnline int32, limit int, allowedOwnerIDs []string) ([]roomservice.RoomListEntry, error) { + appCode = normalizedRecordAppCode(ctx, appCode) + countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) + if maxOnline <= 0 { + maxOnline = 5 + } + if fullStopOnline <= 0 { + fullStopOnline = 10 + } + if maxOnline > fullStopOnline { + maxOnline = fullStopOnline + } + if limit <= 0 || limit > 100 { + limit = 50 + } + shortIDs, ownerUserIDs := splitHumanRobotAllowedOwnerIDs(allowedOwnerIDs) + where := []string{ + "app_code = ?", + "status = 'active'", + "owner_country_code = ?", + "online_count < ?", + "online_count < ?", + "room_id NOT LIKE 'robot\\_%'", + } + args := []any{appCode, countryCode, maxOnline, fullStopOnline} + if len(shortIDs) > 0 { + ownerWhere := make([]string, 0, 2) + shortPlaceholders := strings.TrimRight(strings.Repeat("?,", len(shortIDs)), ",") + ownerWhere = append(ownerWhere, "room_short_id IN ("+shortPlaceholders+")") + for _, id := range shortIDs { + args = append(args, id) + } + if len(ownerUserIDs) > 0 { + userPlaceholders := strings.TrimRight(strings.Repeat("?,", len(ownerUserIDs)), ",") + ownerWhere = append(ownerWhere, "owner_user_id IN ("+userPlaceholders+")") + for _, id := range ownerUserIDs { + args = append(args, id) + } + } + where = append(where, "("+strings.Join(ownerWhere, " OR ")+")") + } + args = append(args, limit) + query := `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 ` + strings.Join(where, "\n AND ") + ` + ORDER BY online_count ASC, updated_at_ms DESC, room_id DESC + LIMIT ?` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]roomservice.RoomListEntry, 0, 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 + } + items = append(items, entry) + } + return items, rows.Err() +} + +type humanRoomRobotConfigScanner interface { + Scan(dest ...any) error +} + +func scanHumanRoomRobotConfig(row humanRoomRobotConfigScanner) (roomservice.HumanRoomRobotConfig, error) { + var config roomservice.HumanRoomRobotConfig + var enabled int + var rawGifts string + var rawLucky string + var rawSuperLucky string + var rawPools string + var rawAllowedOwners string + var countryLimitEnabled int + var rawCountryRules string + if err := row.Scan( + &config.AppCode, + &enabled, + &config.CandidateRoomMaxOnline, + &config.RoomFullStopOnline, + &config.RoomTargetMinOnline, + &config.RoomTargetMaxOnline, + &config.RobotStayMinMS, + &config.RobotStayMaxMS, + &config.RobotReplaceMinMS, + &config.RobotReplaceMaxMS, + &config.NormalGiftIntervalMS, + &config.NormalGiftIntervalMinMS, + &config.NormalGiftIntervalMaxMS, + &rawGifts, + &rawLucky, + &rawSuperLucky, + &config.LuckyComboMin, + &config.LuckyComboMax, + &config.LuckyPauseMinMS, + &config.LuckyPauseMaxMS, + &config.MaxGiftSenders, + &rawPools, + &rawAllowedOwners, + &countryLimitEnabled, + &rawCountryRules, + &config.UpdatedByAdminID, + &config.CreatedAtMS, + &config.UpdatedAtMS, + ); err != nil { + return roomservice.HumanRoomRobotConfig{}, err + } + config.Enabled = enabled == 1 + if err := json.Unmarshal([]byte(rawGifts), &config.GiftIDs); err != nil { + return roomservice.HumanRoomRobotConfig{}, err + } + if err := json.Unmarshal([]byte(rawLucky), &config.LuckyGiftIDs); err != nil { + return roomservice.HumanRoomRobotConfig{}, err + } + if err := json.Unmarshal([]byte(rawSuperLucky), &config.SuperLuckyGiftIDs); err != nil { + return roomservice.HumanRoomRobotConfig{}, err + } + if err := json.Unmarshal([]byte(rawPools), &config.CountryPools); err != nil { + return roomservice.HumanRoomRobotConfig{}, err + } + if err := json.Unmarshal([]byte(rawAllowedOwners), &config.AllowedOwnerIDs); err != nil { + return roomservice.HumanRoomRobotConfig{}, err + } + config.CountryLimitEnabled = countryLimitEnabled == 1 + if err := json.Unmarshal([]byte(rawCountryRules), &config.CountryRules); err != nil { + return roomservice.HumanRoomRobotConfig{}, err + } + return config, nil +} + +func humanRoomRobotConfigJSON(config roomservice.HumanRoomRobotConfig) ([]byte, []byte, []byte, []byte, []byte, []byte, error) { + rawGifts, err := json.Marshal(config.GiftIDs) + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + rawLucky, err := json.Marshal(config.LuckyGiftIDs) + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + rawSuperLucky, err := json.Marshal(config.SuperLuckyGiftIDs) + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + rawPools, err := json.Marshal(config.CountryPools) + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + rawAllowedOwners, err := json.Marshal(config.AllowedOwnerIDs) + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + rawCountryRules, err := json.Marshal(config.CountryRules) + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + return rawGifts, rawLucky, rawSuperLucky, rawPools, rawAllowedOwners, rawCountryRules, nil +} + +func splitHumanRobotAllowedOwnerIDs(values []string) ([]string, []int64) { + seenShortIDs := make(map[string]bool, len(values)) + seenUserIDs := make(map[int64]bool, len(values)) + shortIDs := make([]string, 0, len(values)) + userIDs := make([]int64, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seenShortIDs[value] { + continue + } + seenShortIDs[value] = true + shortIDs = append(shortIDs, value) + if strings.ContainsAny(value, ".eE") { + continue + } + userID, err := strconv.ParseInt(value, 10, 64) + if err != nil || userID <= 0 || seenUserIDs[userID] { + continue + } + seenUserIDs[userID] = true + userIDs = append(userIDs, userID) + } + return shortIDs, userIDs +} + +type robotRoomScanner interface { + Scan(dest ...any) error +} + +func scanRobotRoomConfigs(rows *sql.Rows) ([]roomservice.RobotRoomConfig, error) { + items := make([]roomservice.RobotRoomConfig, 0) + for rows.Next() { + item, err := scanRobotRoomConfig(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func scanRobotRoomConfig(row robotRoomScanner) (roomservice.RobotRoomConfig, error) { + var item roomservice.RobotRoomConfig + var rawRobots string + var rawGifts string + var rawLuckyGifts string + if err := row.Scan( + &item.AppCode, + &item.RoomID, + &item.RoomShortID, + &item.Title, + &item.CoverURL, + &item.VisibleRegionID, + &item.Status, + &item.OwnerRobotUserID, + &rawRobots, + &item.ActiveRobotCount, + &item.SeatCount, + &rawGifts, + &rawLuckyGifts, + &item.GiftRule.NormalGiftIntervalMS, + &item.GiftRule.LuckyComboMin, + &item.GiftRule.LuckyComboMax, + &item.GiftRule.LuckyPauseMinMS, + &item.GiftRule.LuckyPauseMaxMS, + &item.GiftRule.RobotStayMinMS, + &item.GiftRule.RobotStayMaxMS, + &item.GiftRule.RobotReplaceMinMS, + &item.GiftRule.RobotReplaceMaxMS, + &item.GiftRule.MaxGiftSenders, + &item.CreatedByAdminID, + &item.CreatedAtMS, + &item.UpdatedAtMS, + ); err != nil { + return roomservice.RobotRoomConfig{}, err + } + if err := json.Unmarshal([]byte(rawRobots), &item.RobotUserIDs); err != nil { + return roomservice.RobotRoomConfig{}, err + } + if err := json.Unmarshal([]byte(rawGifts), &item.GiftRule.GiftIDs); err != nil { + return roomservice.RobotRoomConfig{}, err + } + if err := json.Unmarshal([]byte(rawLuckyGifts), &item.GiftRule.LuckyGiftIDs); err != nil { + return roomservice.RobotRoomConfig{}, err + } + if item.ActiveRobotCount <= 0 { + item.ActiveRobotCount = int32(len(item.RobotUserIDs)) + } + if item.SeatCount <= 0 { + item.SeatCount = 10 + } + item.GiftRule = roomservice.WithRobotRoomGiftRuleStorageDefaults(item.GiftRule) + return item, nil +} + +func robotRoomJSON(config roomservice.RobotRoomConfig) ([]byte, []byte, []byte, error) { + rawRobots, err := json.Marshal(config.RobotUserIDs) + if err != nil { + return nil, nil, nil, err + } + rawGifts, err := json.Marshal(config.GiftRule.GiftIDs) + if err != nil { + return nil, nil, nil, err + } + rawLuckyGifts, err := json.Marshal(config.GiftRule.LuckyGiftIDs) + if err != nil { + return nil, nil, nil, err + } + return rawRobots, rawGifts, rawLuckyGifts, nil +} + +func occupiedRobotUserIDsTx(ctx context.Context, tx *sql.Tx, appCode string, userIDs []int64) (map[int64]bool, error) { + return occupiedRobotUserIDsQuery(ctx, tx, appCode, userIDs, true) +} + +type dbQuerier interface { + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) +} + +func occupiedRobotUserIDsQuery(ctx context.Context, db dbQuerier, appCode string, userIDs []int64, lock bool) (map[int64]bool, error) { + ids := uniquePositiveInt64(userIDs) + occupied := make(map[int64]bool, len(ids)) + if len(ids) == 0 { + return occupied, nil + } + query := `SELECT robot_user_ids_json FROM room_robot_rooms WHERE app_code = ? AND status = 'active'` + if lock { + query += ` FOR UPDATE` + } + rows, err := db.QueryContext(ctx, query, appcode.Normalize(appCode)) + if err != nil { + return nil, err + } + defer rows.Close() + wanted := make(map[int64]bool, len(ids)) + for _, id := range ids { + wanted[id] = true + } + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + return nil, err + } + var roomRobotIDs []int64 + if err := json.Unmarshal([]byte(raw), &roomRobotIDs); err != nil { + return nil, err + } + for _, id := range roomRobotIDs { + if wanted[id] { + occupied[id] = true + } + } + } + return occupied, rows.Err() +} diff --git a/services/room-service/internal/storage/mysql/room_list.go b/services/room-service/internal/storage/mysql/room_list.go new file mode 100644 index 00000000..a0c4442d --- /dev/null +++ b/services/room-service/internal/storage/mysql/room_list.go @@ -0,0 +1,713 @@ +// Package mysql 实现 room-service 的 MySQL 持久化边界。 +package mysql + +import ( + "context" + "database/sql" + "errors" + "sort" + "strings" + "time" + + "hyapp/pkg/appcode" + roomservice "hyapp/services/room-service/internal/room/service" +) + +// 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 + } + viewerCountryCode := normalizeRoomListSQLCountryCode(query.ViewerCountryCode) + filterCountryCode := normalizeRoomListSQLCountryCode(query.CountryCode) + sortCountryCode := viewerCountryCode + if filterCountryCode != "" { + // 国家 tab 已经把结果集收敛到一个国家,国家置顶和国家优先桶都应按该 tab 国家解释。 + sortCountryCode = filterCountryCode + } + normalRankExpr := "CASE WHEN r.online_count > 0 THEN 2 ELSE 4 END" + if sortCountryCode != "" { + countryLiteral := roomListSQLStringLiteral(sortCountryCode) + normalRankExpr = "CASE WHEN r.owner_country_code = " + countryLiteral + " AND r.online_count > 0 THEN 2 WHEN r.owner_country_code <> " + countryLiteral + " AND r.online_count > 0 THEN 3 WHEN r.owner_country_code = " + countryLiteral + " THEN 4 ELSE 5 END" + } + regionPinScopeSQL := "AND rp.visible_region_id = ?" + countryPinScopeSQL := "AND cp.visible_region_id = ?" + if query.AllVisibleRegions { + // 白名单用户查看全区列表时,区域/国家置顶按房间自身区域命中,不能再固定到 viewer 区域。 + regionPinScopeSQL = "AND rp.visible_region_id = r.visible_region_id" + countryPinScopeSQL = "AND cp.visible_region_id = r.visible_region_id" + } + pinnedExpr := "CASE WHEN gp.room_id IS NULL AND rp.room_id IS NULL AND cp.room_id IS NULL THEN 0 ELSE 1 END" + pinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 0 WHEN cp.room_id IS NOT NULL THEN 1 ELSE " + normalRankExpr + " END" + pinWeightExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.weight WHEN rp.room_id IS NOT NULL THEN rp.weight WHEN cp.room_id IS NOT NULL THEN cp.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 WHEN cp.room_id IS NOT NULL THEN cp.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' + ` + regionPinScopeSQL + ` + AND rp.room_id = r.room_id + AND rp.status = ? + AND rp.pinned_at_ms <= ? + AND rp.expires_at_ms > ? + LEFT JOIN room_region_pins cp + ON cp.app_code = r.app_code + AND cp.pin_type = 'country' + ` + countryPinScopeSQL + ` + AND cp.room_id = r.room_id + AND r.owner_country_code = ? + AND cp.status = ? + AND cp.pinned_at_ms <= ? + AND cp.expires_at_ms > ?` + where := []string{"r.app_code = ?", "r.status = ?"} + args := []any{"active", query.NowMS, query.NowMS} + if !query.AllVisibleRegions { + args = append(args, regionID) + } + args = append(args, "active", query.NowMS, query.NowMS) + if !query.AllVisibleRegions { + args = append(args, regionID) + } + args = append(args, sortCountryCode, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active") + if !query.AllVisibleRegions { + // 普通发现页必须利用区域索引隔离结果;白名单模式只保留 app/status/可选国家/关键词条件。 + where = append(where, "r.visible_region_id = ?") + args = append(args, regionID) + } + 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 normalizeRoomListSQLCountryCode(countryCode string) string { + countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) + if len(countryCode) < 2 || len(countryCode) > 3 { + return "" + } + for _, ch := range countryCode { + if ch < 'A' || ch > 'Z' { + return "" + } + } + return countryCode +} + +func roomListSQLStringLiteral(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +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 投影用户当前房间读模型。 diff --git a/services/room-service/internal/storage/mysql/room_meta.go b/services/room-service/internal/storage/mysql/room_meta.go new file mode 100644 index 00000000..0cfc175f --- /dev/null +++ b/services/room-service/internal/storage/mysql/room_meta.go @@ -0,0 +1,171 @@ +// Package mysql 实现 room-service 的 MySQL 持久化边界。 +package mysql + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" + + "hyapp/pkg/appcode" + roomservice "hyapp/services/room-service/internal/room/service" +) + +// 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 +} diff --git a/services/room-service/internal/storage/mysql/schema.go b/services/room-service/internal/storage/mysql/schema.go new file mode 100644 index 00000000..90b8d2f1 --- /dev/null +++ b/services/room-service/internal/storage/mysql/schema.go @@ -0,0 +1,686 @@ +// Package mysql 实现 room-service 的 MySQL 持久化边界。 +package mysql + +import "context" + +// Migrate 创建 room-service 首版需要的 MySQL 表。 +func (r *Repository) Migrate(ctx context.Context) error { + // 这里保持与 deploy/mysql/initdb 的表结构一致,服务本地启动可自动建表。 + statements := []string{ + `CREATE TABLE IF NOT EXISTS rooms ( + app_code VARCHAR(32) NOT NULL, + room_id VARCHAR(64) NOT NULL, + room_short_id VARCHAR(32) NOT NULL DEFAULT '', + owner_user_id BIGINT NOT NULL, + seat_count INT NOT NULL, + mode VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + room_password_hash VARCHAR(128) NOT NULL DEFAULT '', + close_reason VARCHAR(512) NOT NULL DEFAULT '', + closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '', + closed_at_ms BIGINT NOT NULL DEFAULT 0, + visible_region_id BIGINT NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, room_id), + UNIQUE KEY uk_rooms_short_id (app_code, room_short_id), + UNIQUE KEY uk_rooms_owner_user (app_code, owner_user_id), + KEY idx_rooms_region_status (app_code, visible_region_id, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_list_entries ( + app_code VARCHAR(32) NOT NULL, + room_id VARCHAR(64) NOT NULL, + room_short_id VARCHAR(32) NOT NULL DEFAULT '', + visible_region_id BIGINT NOT NULL DEFAULT 0, + owner_country_code VARCHAR(3) NOT NULL DEFAULT '', + owner_user_id BIGINT NOT NULL, + title VARCHAR(128) NOT NULL DEFAULT '', + cover_url VARCHAR(512) NOT NULL DEFAULT '', + mode VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + locked BOOLEAN NOT NULL DEFAULT FALSE, + close_reason VARCHAR(512) NOT NULL DEFAULT '', + closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '', + closed_at_ms BIGINT NOT NULL DEFAULT 0, + heat BIGINT NOT NULL DEFAULT 0, + weekly_contribution BIGINT NOT NULL DEFAULT 0, + contribution_week_start_ms BIGINT NOT NULL DEFAULT 0, + online_count INT NOT NULL DEFAULT 0, + seat_count INT NOT NULL DEFAULT 0, + occupied_seat_count INT NOT NULL DEFAULT 0, + sort_score BIGINT NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, room_id), + KEY idx_room_list_short_id (app_code, room_short_id), + KEY idx_room_list_region_hot (app_code, visible_region_id, status, sort_score DESC, room_id), + KEY idx_room_list_region_new (app_code, visible_region_id, status, created_at_ms DESC, room_id), + KEY idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id), + KEY idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id), + KEY idx_room_list_weekly_contribution (app_code, status, visible_region_id, weekly_contribution DESC, created_at_ms DESC, room_id), + KEY idx_room_list_owner (app_code, owner_user_id, updated_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_user_presence ( + app_code VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + room_id VARCHAR(64) NOT NULL, + role VARCHAR(32) NOT NULL, + publish_state VARCHAR(32) NOT NULL DEFAULT '', + mic_session_id VARCHAR(128) NOT NULL DEFAULT '', + room_version BIGINT NOT NULL, + status VARCHAR(32) NOT NULL, + joined_at_ms BIGINT NOT NULL DEFAULT 0, + last_seen_at_ms BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, user_id), + KEY idx_room_user_presence_room (app_code, room_id, status), + KEY idx_room_user_presence_updated (app_code, status, updated_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_user_gift_stats ( + app_code VARCHAR(32) NOT NULL, + room_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + gift_value BIGINT NOT NULL DEFAULT 0, + last_gift_at_ms BIGINT NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, room_id, user_id), + KEY idx_room_user_gift_stats_room_value (app_code, room_id, gift_value DESC, last_gift_at_ms DESC, user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_background_images ( + app_code VARCHAR(32) NOT NULL, + background_id BIGINT NOT NULL AUTO_INCREMENT, + room_id VARCHAR(64) NOT NULL, + image_url VARCHAR(256) NOT NULL, + created_by_user_id BIGINT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (background_id), + UNIQUE KEY uk_room_background_url (app_code, room_id, image_url), + KEY idx_room_background_list (app_code, room_id, created_at_ms DESC, background_id DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_snapshots ( + app_code VARCHAR(32) NOT NULL, + room_id VARCHAR(64) NOT NULL, + room_version BIGINT NOT NULL, + payload LONGBLOB NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, room_id), + INDEX idx_room_snapshots_version (app_code, room_id, room_version) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_command_log ( + app_code VARCHAR(32) NOT NULL, + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + room_id VARCHAR(64) NOT NULL, + room_version BIGINT NOT NULL, + command_id VARCHAR(128) NOT NULL, + command_type VARCHAR(64) NOT NULL, + owner_node_id VARCHAR(128) NOT NULL DEFAULT '', + lease_token VARCHAR(128) NOT NULL DEFAULT '', + payload LONGBLOB NOT NULL, + replayable BOOLEAN NOT NULL, + created_at_ms BIGINT NOT NULL, + UNIQUE KEY uk_room_command (app_code, room_id, command_id), + KEY idx_room_command_replay (app_code, room_id, room_version) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_outbox ( + app_code VARCHAR(32) NOT NULL, + event_id VARCHAR(128) NOT NULL, + event_type VARCHAR(64) NOT NULL, + room_id VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + worker_id VARCHAR(128) NOT NULL DEFAULT '', + lock_until_ms BIGINT NULL, + envelope LONGBLOB NOT NULL, + created_at_ms BIGINT NOT NULL, + retry_count INT NOT NULL DEFAULT 0, + next_retry_at_ms BIGINT NOT NULL DEFAULT 0, + last_error TEXT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, event_id), + KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id), + KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id), + KEY idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id), + KEY idx_room_outbox_retention (app_code, status, updated_at_ms, event_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_robot_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_robot_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id), + KEY idx_room_robot_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id), + KEY idx_room_robot_outbox_event_created (app_code, event_type, created_at_ms, event_id), + KEY idx_room_robot_outbox_retention (app_code, status, updated_at_ms, event_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_user_feed_entries ( + app_code VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + feed_type VARCHAR(32) NOT NULL, + room_id VARCHAR(64) NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, user_id, feed_type, room_id), + KEY idx_room_user_feed_list (app_code, user_id, feed_type, updated_at_ms DESC, room_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_follows ( + app_code VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + room_id VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + followed_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, user_id, room_id), + KEY idx_room_follows_user_list (app_code, user_id, status, followed_at_ms DESC, room_id), + KEY idx_room_follows_room (app_code, room_id, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_region_pins ( + id BIGINT NOT NULL AUTO_INCREMENT, + app_code VARCHAR(32) NOT NULL, + visible_region_id BIGINT NOT NULL, + pin_type VARCHAR(32) NOT NULL DEFAULT 'region', + room_id VARCHAR(64) NOT NULL, + weight BIGINT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL, + pinned_at_ms BIGINT NOT NULL, + expires_at_ms BIGINT NOT NULL, + cancelled_at_ms BIGINT NOT NULL DEFAULT 0, + created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + cancelled_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_room_region_pins_scope (app_code, pin_type, visible_region_id, room_id), + KEY idx_room_region_pins_active_scope (app_code, pin_type, visible_region_id, status, pinned_at_ms, expires_at_ms, weight DESC, room_id), + KEY idx_room_region_pins_room (app_code, room_id, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间置顶表'`, + `CREATE TABLE IF NOT EXISTS room_seat_configs ( + app_code VARCHAR(32) NOT NULL, + allowed_seat_counts VARCHAR(128) NOT NULL, + default_seat_count INT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_rocket_configs ( + app_code VARCHAR(32) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 0, + config_version BIGINT NOT NULL DEFAULT 1, + fuel_source VARCHAR(32) NOT NULL, + launch_delay_ms BIGINT NOT NULL, + broadcast_enabled TINYINT(1) NOT NULL DEFAULT 1, + broadcast_scope VARCHAR(32) NOT NULL, + broadcast_delay_ms BIGINT NOT NULL DEFAULT 0, + reward_stack_policy VARCHAR(32) NOT NULL, + levels_json JSON NOT NULL, + gift_fuel_rules_json JSON NOT NULL, + updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code), + KEY idx_room_rocket_enabled (app_code, enabled) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_robot_rooms ( + app_code VARCHAR(32) NOT NULL, + room_id VARCHAR(64) NOT NULL, + room_short_id VARCHAR(32) NOT NULL DEFAULT '', + title VARCHAR(128) NOT NULL DEFAULT '', + cover_url VARCHAR(512) NOT NULL DEFAULT '', + visible_region_id BIGINT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL, + owner_robot_user_id BIGINT NOT NULL, + robot_user_ids_json JSON NOT NULL, + active_robot_count INT NOT NULL DEFAULT 0, + seat_count INT NOT NULL DEFAULT 10, + gift_ids_json JSON NOT NULL, + lucky_gift_ids_json JSON NOT NULL, + normal_gift_interval_ms BIGINT NOT NULL, + lucky_combo_min BIGINT NOT NULL, + lucky_combo_max BIGINT NOT NULL, + lucky_pause_min_ms BIGINT NOT NULL, + lucky_pause_max_ms BIGINT NOT NULL, + robot_stay_min_ms BIGINT NOT NULL DEFAULT 180000, + robot_stay_max_ms BIGINT NOT NULL DEFAULT 600000, + robot_replace_min_ms BIGINT NOT NULL DEFAULT 0, + robot_replace_max_ms BIGINT NOT NULL DEFAULT 60000, + max_gift_senders BIGINT NOT NULL DEFAULT 1, + created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, room_id), + KEY idx_room_robot_rooms_status (app_code, status, updated_at_ms, room_id), + KEY idx_room_robot_rooms_owner (app_code, owner_robot_user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_human_robot_configs ( + app_code VARCHAR(32) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + candidate_room_max_online INT NOT NULL DEFAULT 5, + room_full_stop_online INT NOT NULL DEFAULT 10, + room_target_min_online INT NOT NULL DEFAULT 8, + room_target_max_online INT NOT NULL DEFAULT 10, + robot_stay_min_ms BIGINT NOT NULL DEFAULT 60000, + robot_stay_max_ms BIGINT NOT NULL DEFAULT 600000, + robot_replace_min_ms BIGINT NOT NULL DEFAULT 60000, + robot_replace_max_ms BIGINT NOT NULL DEFAULT 180000, + normal_gift_interval_ms BIGINT NOT NULL DEFAULT 10000, + normal_gift_interval_min_ms BIGINT NOT NULL DEFAULT 1000, + normal_gift_interval_max_ms BIGINT NOT NULL DEFAULT 20000, + gift_ids_json JSON NOT NULL, + lucky_gift_ids_json JSON NOT NULL, + super_lucky_gift_ids_json JSON NOT NULL, + lucky_combo_min BIGINT NOT NULL DEFAULT 1, + lucky_combo_max BIGINT NOT NULL DEFAULT 3, + lucky_pause_min_ms BIGINT NOT NULL DEFAULT 5000, + lucky_pause_max_ms BIGINT NOT NULL DEFAULT 20000, + max_gift_senders BIGINT NOT NULL DEFAULT 1, + country_pools_json JSON NOT NULL, + allowed_owner_ids_json JSON NOT NULL, + country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE, + country_rules_json JSON NOT NULL, + updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code), + KEY idx_room_human_robot_enabled (app_code, enabled) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `INSERT IGNORE INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms) + VALUES ('lalu', '[10,15,20,25,30]', 15, 0, 0)`, + `INSERT IGNORE INTO room_rocket_configs ( + app_code, + enabled, + config_version, + fuel_source, + launch_delay_ms, + broadcast_enabled, + broadcast_scope, + broadcast_delay_ms, + reward_stack_policy, + levels_json, + gift_fuel_rules_json, + updated_by_admin_id, + created_at_ms, + updated_at_ms + ) VALUES ( + 'lalu', + 0, + 1, + 'heat_value', + 30000, + 1, + 'region', + 0, + 'allow_stack', + JSON_ARRAY( + JSON_OBJECT('level', 1, 'fuelThreshold', 1000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 2, 'fuelThreshold', 3000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 3, 'fuelThreshold', 6000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 4, 'fuelThreshold', 10000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 5, 'fuelThreshold', 15000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()) + ), + JSON_ARRAY(), + 0, + 0, + 0 + )`, + `ALTER TABLE rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + `ALTER TABLE room_list_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + `ALTER TABLE room_user_presence MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + `ALTER TABLE room_snapshots MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + `ALTER TABLE room_command_log MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + `ALTER TABLE room_outbox MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + `ALTER TABLE room_user_feed_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + `ALTER TABLE room_follows MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + `ALTER TABLE room_region_pins MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + `ALTER TABLE room_seat_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + `ALTER TABLE room_rocket_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + `ALTER TABLE room_robot_rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + } + + for _, statement := range statements { + // 每条 DDL 独立执行,失败时返回具体 SQL 错误给启动流程。 + if _, err := r.db.ExecContext(ctx, statement); err != nil { + return err + } + } + + if err := r.ensureRoomListLockSchema(ctx); err != nil { + return err + } + if err := r.ensureRoomListCountrySchema(ctx); err != nil { + return err + } + if err := r.ensureRoomPasswordSchema(ctx); err != nil { + return err + } + if err := r.ensureRoomAdminCloseSchema(ctx); err != nil { + return err + } + if err := r.ensureRoomWeeklyContributionSchema(ctx); err != nil { + return err + } + if err := r.ensureRoomUserPresenceSchema(ctx); err != nil { + return err + } + if err := r.ensureRoomPinSchema(ctx); err != nil { + return err + } + if err := r.ensureRobotRoomBehaviorSchema(ctx); err != nil { + return err + } + if err := r.ensureHumanRobotConfigBehaviorSchema(ctx); err != nil { + return err + } + + return r.ensureOutboxRetrySchema(ctx) +} + +func (r *Repository) ensureHumanRobotConfigBehaviorSchema(ctx context.Context) error { + columns := []struct { + name string + statement string + }{ + {name: "normal_gift_interval_min_ms", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN normal_gift_interval_min_ms BIGINT NOT NULL DEFAULT 1000 AFTER normal_gift_interval_ms`}, + {name: "normal_gift_interval_max_ms", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN normal_gift_interval_max_ms BIGINT NOT NULL DEFAULT 20000 AFTER normal_gift_interval_min_ms`}, + {name: "room_target_min_online", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN room_target_min_online INT NOT NULL DEFAULT 8 AFTER room_full_stop_online`}, + {name: "room_target_max_online", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN room_target_max_online INT NOT NULL DEFAULT 10 AFTER room_target_min_online`}, + {name: "allowed_owner_ids_json", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN allowed_owner_ids_json JSON NULL AFTER country_pools_json`}, + {name: "country_limit_enabled", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN country_limit_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER allowed_owner_ids_json`}, + {name: "country_rules_json", statement: `ALTER TABLE room_human_robot_configs ADD COLUMN country_rules_json JSON NULL AFTER country_limit_enabled`}, + } + for _, column := range columns { + exists, err := r.columnExists(ctx, "room_human_robot_configs", column.name) + if err != nil { + return err + } + if exists { + continue + } + if _, err := r.db.ExecContext(ctx, column.statement); err != nil { + return err + } + } + return nil +} + +func (r *Repository) ensureRobotRoomBehaviorSchema(ctx context.Context) error { + columns := []struct { + name string + statement string + }{ + {name: "active_robot_count", statement: `ALTER TABLE room_robot_rooms ADD COLUMN active_robot_count INT NOT NULL DEFAULT 0 AFTER robot_user_ids_json`}, + {name: "seat_count", statement: `ALTER TABLE room_robot_rooms ADD COLUMN seat_count INT NOT NULL DEFAULT 10 AFTER active_robot_count`}, + {name: "robot_stay_min_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_stay_min_ms BIGINT NOT NULL DEFAULT 180000 AFTER lucky_pause_max_ms`}, + {name: "robot_stay_max_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_stay_max_ms BIGINT NOT NULL DEFAULT 600000 AFTER robot_stay_min_ms`}, + {name: "robot_replace_min_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_replace_min_ms BIGINT NOT NULL DEFAULT 0 AFTER robot_stay_max_ms`}, + {name: "robot_replace_max_ms", statement: `ALTER TABLE room_robot_rooms ADD COLUMN robot_replace_max_ms BIGINT NOT NULL DEFAULT 60000 AFTER robot_replace_min_ms`}, + {name: "max_gift_senders", statement: `ALTER TABLE room_robot_rooms ADD COLUMN max_gift_senders BIGINT NOT NULL DEFAULT 1 AFTER robot_replace_max_ms`}, + } + for _, column := range columns { + exists, err := r.columnExists(ctx, "room_robot_rooms", column.name) + if err != nil { + return err + } + if exists { + continue + } + if _, err := r.db.ExecContext(ctx, column.statement); err != nil { + return err + } + } + return nil +} + +func (r *Repository) ensureRoomWeeklyContributionSchema(ctx context.Context) error { + columns := []struct { + column string + statement string + }{ + { + column: "weekly_contribution", + statement: `ALTER TABLE room_list_entries ADD COLUMN weekly_contribution BIGINT NOT NULL DEFAULT 0 AFTER heat`, + }, + { + column: "contribution_week_start_ms", + statement: `ALTER TABLE room_list_entries ADD COLUMN contribution_week_start_ms BIGINT NOT NULL DEFAULT 0 AFTER weekly_contribution`, + }, + } + for _, item := range columns { + hasColumn, err := r.columnExists(ctx, "room_list_entries", item.column) + if err != nil { + return err + } + if !hasColumn { + if _, err := r.db.ExecContext(ctx, item.statement); err != nil { + return err + } + } + } + hasIndex, err := r.indexExists(ctx, "room_list_entries", "idx_room_list_weekly_contribution") + if err != nil { + return err + } + if !hasIndex { + // 后台按房间贡献排序时只读当前 UTC 周贡献;索引用于收敛 active/region 过滤后的排序成本。 + if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD INDEX idx_room_list_weekly_contribution (app_code, status, visible_region_id, weekly_contribution DESC, created_at_ms DESC, room_id)`); err != nil { + return err + } + } + return nil +} + +func (r *Repository) ensureRoomPinSchema(ctx context.Context) error { + hasColumn, err := r.columnExists(ctx, "room_region_pins", "pin_type") + if err != nil { + return err + } + if !hasColumn { + // pin_type 把“全区置顶”和“区域置顶”的作用域写进同一 owner 表;历史数据默认都是区域置顶。 + if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD COLUMN pin_type VARCHAR(32) NOT NULL DEFAULT 'region' AFTER visible_region_id`); err != nil { + return err + } + } + hasScopeUnique, err := r.indexExists(ctx, "room_region_pins", "uk_room_region_pins_scope") + if err != nil { + return err + } + if !hasScopeUnique { + if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD UNIQUE KEY uk_room_region_pins_scope (app_code, pin_type, visible_region_id, room_id)`); err != nil { + return err + } + } + hasLegacyUnique, err := r.indexExists(ctx, "room_region_pins", "uk_room_region_pins_room") + if err != nil { + return err + } + if hasLegacyUnique { + // 新唯一键允许 region_id=0 的区域置顶和全区置顶并存;旧唯一键会错误地把两者互斥。 + if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins DROP INDEX uk_room_region_pins_room`); err != nil { + return err + } + } + hasActiveScope, err := r.indexExists(ctx, "room_region_pins", "idx_room_region_pins_active_scope") + if err != nil { + return err + } + if !hasActiveScope { + if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD INDEX idx_room_region_pins_active_scope (app_code, pin_type, visible_region_id, status, pinned_at_ms, expires_at_ms, weight DESC, room_id)`); err != nil { + return err + } + } + return nil +} + +func (r *Repository) ensureRoomUserPresenceSchema(ctx context.Context) error { + hasColumn, err := r.columnExists(ctx, "room_user_presence", "joined_at_ms") + if err != nil { + return err + } + if !hasColumn { + if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_user_presence ADD COLUMN joined_at_ms BIGINT NOT NULL DEFAULT 0 AFTER status`); err != nil { + return err + } + } + return nil +} + +func (r *Repository) ensureRoomPasswordSchema(ctx context.Context) error { + hasColumn, err := r.columnExists(ctx, "rooms", "room_password_hash") + if err != nil { + return err + } + if !hasColumn { + // rooms 是最新快照恢复密码哈希的持久来源;列表只使用 locked 布尔投影。 + if _, err := r.db.ExecContext(ctx, `ALTER TABLE rooms ADD COLUMN room_password_hash VARCHAR(128) NOT NULL DEFAULT '' AFTER status`); err != nil { + return err + } + } + return nil +} + +func (r *Repository) ensureRoomListLockSchema(ctx context.Context) error { + hasColumn, err := r.columnExists(ctx, "room_list_entries", "locked") + if err != nil { + return err + } + if !hasColumn { + // locked 是列表/搜索需要的纯投影字段,历史行默认 false,后续房间状态变更会刷新为真实值。 + if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD COLUMN locked BOOLEAN NOT NULL DEFAULT FALSE AFTER status`); err != nil { + return err + } + } + return nil +} + +func (r *Repository) ensureRoomListCountrySchema(ctx context.Context) error { + hasColumn, err := r.columnExists(ctx, "room_list_entries", "owner_country_code") + if err != nil { + return err + } + if !hasColumn { + // owner_country_code 是房主国家快照,只服务发现页国家筛选;历史行先为空,后续房间投影刷新会补真实值。 + if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD COLUMN owner_country_code VARCHAR(3) NOT NULL DEFAULT '' AFTER visible_region_id`); err != nil { + return err + } + } + indexes := []struct { + name string + statement string + }{ + { + name: "idx_room_list_region_country_hot", + statement: `ALTER TABLE room_list_entries ADD INDEX idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id)`, + }, + { + name: "idx_room_list_region_country_new", + statement: `ALTER TABLE room_list_entries ADD INDEX idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id)`, + }, + } + for _, item := range indexes { + hasIndex, err := r.indexExists(ctx, "room_list_entries", item.name) + if err != nil { + return err + } + if !hasIndex { + if _, err := r.db.ExecContext(ctx, item.statement); err != nil { + return err + } + } + } + return nil +} + +func (r *Repository) ensureRoomAdminCloseSchema(ctx context.Context) error { + columns := []struct { + table string + column string + statement string + }{ + {"rooms", "close_reason", `ALTER TABLE rooms ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER room_password_hash`}, + {"rooms", "closed_by_admin_id", `ALTER TABLE rooms ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`}, + {"rooms", "closed_by_admin_name", `ALTER TABLE rooms ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`}, + {"rooms", "closed_at_ms", `ALTER TABLE rooms ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`}, + {"room_list_entries", "close_reason", `ALTER TABLE room_list_entries ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER locked`}, + {"room_list_entries", "closed_by_admin_id", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`}, + {"room_list_entries", "closed_by_admin_name", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`}, + {"room_list_entries", "closed_at_ms", `ALTER TABLE room_list_entries ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`}, + } + for _, item := range columns { + hasColumn, err := r.columnExists(ctx, item.table, item.column) + if err != nil { + return err + } + if !hasColumn { + if _, err := r.db.ExecContext(ctx, item.statement); err != nil { + return err + } + } + } + return nil +} + +func (r *Repository) ensureOutboxRetrySchema(ctx context.Context) error { + hasColumn, err := r.columnExists(ctx, "room_outbox", "next_retry_at_ms") + if err != nil { + return err + } + if !hasColumn { + if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_outbox ADD COLUMN next_retry_at_ms BIGINT NOT NULL DEFAULT 0 AFTER retry_count`); err != nil { + return err + } + } + indexes := []struct { + name string + statement string + }{ + {"idx_room_outbox_pending", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id)`}, + {"idx_room_outbox_retry", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms, event_id)`}, + {"idx_room_outbox_claim", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id)`}, + {"idx_room_outbox_event_created", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id)`}, + {"idx_room_outbox_retention", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retention (app_code, status, updated_at_ms, event_id)`}, + } + for _, index := range indexes { + hasIndex, err := r.indexExists(ctx, "room_outbox", index.name) + if err != nil { + return err + } + if !hasIndex { + if _, err := r.db.ExecContext(ctx, index.statement); err != nil { + return err + } + } + } + return nil +} + +func (r *Repository) columnExists(ctx context.Context, tableName string, columnName string) (bool, error) { + var exists int + err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? + `, tableName, columnName).Scan(&exists) + return exists > 0, err +} + +func (r *Repository) indexExists(ctx context.Context, tableName string, indexName string) (bool, error) { + var exists int + err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? + `, tableName, indexName).Scan(&exists) + return exists > 0, err +} diff --git a/services/user-service/internal/service/auth/risk_worker.go b/services/user-service/internal/service/auth/risk_worker.go index d92c882b..5961deae 100644 --- a/services/user-service/internal/service/auth/risk_worker.go +++ b/services/user-service/internal/service/auth/risk_worker.go @@ -221,23 +221,46 @@ func (s *Service) applyLoginIPRiskDecision(ctx context.Context, job authdomain.L if decision.Decision != authdomain.LoginIPRiskDecisionBlocked { return nil } - revoked, err := s.authRepository.RevokeSessionWithReason(ctx, job.SessionID, nowMs, revokeReasonGeoPolicyBlocked, job.RequestID, "risk_worker") + // 风控任务异步执行时,客户端可能已经在任务完成前用 refresh token 轮换出新 session。 + // 因此不能只撤 job.session_id;必须沿源 session 的 user_id + device_id 撤销同一登录链路后续 active session,防止新 access token 继续通过 gateway。 + riskSessionIDs, err := s.authRepository.RevokeUserDeviceSessionsForRisk(ctx, job.SessionID, job.UserID, nowMs, revokeReasonGeoPolicyBlocked, job.RequestID, "risk_worker") if err != nil { return err } + denylistSessionIDs := uniqueRiskSessionIDs(append([]string{job.SessionID}, riskSessionIDs...)) if s.ipDecisionCache != nil { - if err := s.ipDecisionCache.SetRevokedSession(ctx, job.AppCode, job.SessionID, revokeReasonGeoPolicyBlocked, s.loginRiskPolicy.DenylistTTL); err != nil { - logx.Warn(ctx, "revoked_session_denylist_write_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.String("error", err.Error())) + for _, sessionID := range denylistSessionIDs { + // DB 吊销负责 refresh 续期失败,Redis denylist 负责让已经签发出去的 access token 立刻在 gateway 失效。 + if err := s.ipDecisionCache.SetRevokedSession(ctx, job.AppCode, sessionID, revokeReasonGeoPolicyBlocked, s.loginRiskPolicy.DenylistTTL); err != nil { + logx.Warn(ctx, "revoked_session_denylist_write_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", sessionID), slog.String("error", err.Error())) + } } } if err := s.authRepository.MarkLoginAuditBlocked(ctx, job.RequestID, job.UserID, job.CountryCode, riskBlockReasonIPAsync); err != nil { logx.Warn(ctx, "login_audit_mark_blocked_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.String("error", err.Error())) } - logx.Info(ctx, "login_session_revoked_by_risk", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.Int64("user_id", job.UserID), slog.String("reason", revokeReasonGeoPolicyBlocked), slog.String("country", job.CountryCode), slog.Bool("revoked", revoked)) + logx.Info(ctx, "login_session_revoked_by_risk", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("session_id", job.SessionID), slog.Int64("user_id", job.UserID), slog.String("reason", revokeReasonGeoPolicyBlocked), slog.String("country", job.CountryCode), slog.Int("risk_session_count", len(riskSessionIDs)), slog.Int("denylist_session_count", len(denylistSessionIDs))) _ = attempts return nil } +func uniqueRiskSessionIDs(sessionIDs []string) []string { + seen := make(map[string]struct{}, len(sessionIDs)) + unique := make([]string, 0, len(sessionIDs)) + for _, sessionID := range sessionIDs { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + continue + } + if _, ok := seen[sessionID]; ok { + continue + } + seen[sessionID] = struct{}{} + unique = append(unique, sessionID) + } + return unique +} + func trimRiskFailure(value string) string { value = strings.TrimSpace(value) if value == "" { diff --git a/services/user-service/internal/service/auth/service.go b/services/user-service/internal/service/auth/service.go index c497794f..23537705 100644 --- a/services/user-service/internal/service/auth/service.go +++ b/services/user-service/internal/service/auth/service.go @@ -56,6 +56,8 @@ type AuthRepository interface { RevokeSession(ctx context.Context, sessionID string, refreshTokenHash string, revokedAtMs int64) (bool, error) // RevokeSessionWithReason 按 session_id 幂等吊销 session,并持久化撤销来源。 RevokeSessionWithReason(ctx context.Context, sessionID string, revokedAtMs int64, reason string, requestID string, revokedBy string) (bool, error) + // RevokeUserDeviceSessionsForRisk 按风险源 session 定位同一设备后续 session,吊销 active session,并返回需要写入 denylist 的 session_id。 + RevokeUserDeviceSessionsForRisk(ctx context.Context, sourceSessionID string, userID int64, revokedAtMs int64, reason string, requestID string, revokedBy string) ([]string, error) // UpdateSessionHeartbeat 刷新当前 App 登录会话心跳,必须校验 session 仍属于当前用户且未过期未吊销。 UpdateSessionHeartbeat(ctx context.Context, sessionID string, userID int64, heartbeatAtMs int64, requestID string) (authdomain.Session, error) // FindThirdPartyIdentity 查找 provider + subject 的绑定。 diff --git a/services/user-service/internal/service/auth/service_test.go b/services/user-service/internal/service/auth/service_test.go index 865b99ca..60a18a8d 100644 --- a/services/user-service/internal/service/auth/service_test.go +++ b/services/user-service/internal/service/auth/service_test.go @@ -552,6 +552,82 @@ func TestLoginIPRiskWorkerRevokesBlockedCountrySession(t *testing.T) { } } +func TestLoginIPRiskWorkerRevokesRotatedSameDeviceSessions(t *testing.T) { + // 复现线上竞态:登录后风控任务尚未完成,客户端连续 refresh 轮换 session。 + // blocked 决策落地时必须沿源 session 的同设备登录链路清掉后续 session,否则最新 access/refresh 仍能继续使用。 + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "CN") + seedCountry(t, repository, "SG") + now := time.UnixMilli(1000) + cache := newMemoryDecisionCache() + authSvc := newAuthService(repository, &now, []int64{900023}, []string{"100023"}, + authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{ + Enabled: true, + UnsupportedCountries: []string{"CN"}, + BlockedTTL: time.Hour, + AllowedTTL: time.Hour, + UnknownTTL: time.Minute, + ProviderTimeout: 50 * time.Millisecond, + ProviderNames: []string{"edge_country"}, + DenylistTTL: time.Hour, + }), + authservice.WithIPDecisionCache(cache), + authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders([]string{"edge_country"})), + ) + + token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-rotated", thirdPartyRegistration("ios"), authservice.Meta{ + AppCode: "lalu", + RequestID: "req-risk-rotated", + ClientIP: "203.0.113.79", + CountryByIP: "CN", + Platform: "ios", + }) + if err != nil { + t.Fatalf("LoginThirdParty failed: %v", err) + } + + now = time.UnixMilli(1200) + refreshedOnce, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-before-risk-1"}) + if err != nil { + t.Fatalf("first RefreshToken before risk failed: %v", err) + } + now = time.UnixMilli(1400) + refreshedTwice, err := authSvc.RefreshToken(ctx, refreshedOnce.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-before-risk-2"}) + if err != nil { + t.Fatalf("second RefreshToken before risk failed: %v", err) + } + + now = time.UnixMilli(2000) + processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-rotated-test", LockTTL: time.Second, BatchSize: 10}) + if err != nil || processed != 1 { + t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err) + } + if _, err := authSvc.RefreshToken(ctx, refreshedTwice.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-after-risk"}); !xerr.IsCode(err, xerr.SessionRevoked) { + t.Fatalf("latest rotated refresh session must be revoked by risk, got %v", err) + } + if _, err := authSvc.IssueAccessTokenForSession(ctx, refreshedTwice.UserID, refreshedTwice.SessionID, authservice.Meta{AppCode: "lalu", RequestID: "req-resign-after-risk"}); !xerr.IsCode(err, xerr.SessionRevoked) { + t.Fatalf("latest rotated access session must be revoked by risk, got %v", err) + } + + for _, sessionID := range []string{token.SessionID, refreshedOnce.SessionID, refreshedTwice.SessionID} { + if got := cache.revoked["lalu:"+sessionID]; got != "GEO_POLICY_BLOCKED" { + t.Fatalf("session %s denylist mismatch: %q", sessionID, got) + } + } + var latestReason string + if err := repository.RawDB().QueryRowContext(ctx, ` + SELECT revoked_reason + FROM auth_sessions + WHERE app_code = 'lalu' AND session_id = ? + `, refreshedTwice.SessionID).Scan(&latestReason); err != nil { + t.Fatalf("query latest session failed: %v", err) + } + if latestReason != "GEO_POLICY_BLOCKED" { + t.Fatalf("latest session revoke reason mismatch: %s", latestReason) + } +} + func TestLoginIPRiskWorkerUsesHTTPGeoProviderFallback(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) diff --git a/services/user-service/internal/storage/mysql/auth/sessions.go b/services/user-service/internal/storage/mysql/auth/sessions.go index 7b7d32ec..d7b0e8fc 100644 --- a/services/user-service/internal/storage/mysql/auth/sessions.go +++ b/services/user-service/internal/storage/mysql/auth/sessions.go @@ -124,6 +124,82 @@ func (r *Repository) RevokeSessionWithReason(ctx context.Context, sessionID stri return r.revokeSessionByID(ctx, sessionID, revokedAtMs, reason, requestID, revokedBy) } +func (r *Repository) RevokeUserDeviceSessionsForRisk(ctx context.Context, sourceSessionID string, userID int64, revokedAtMs int64, reason string, requestID string, revokedBy string) ([]string, error) { + // IP 风控是异步执行;客户端可能已经用源 session 的 refresh token 轮换出了新 session。 + // 这里先锁定源 session,拿到设备和创建时间,再圈定同一用户、同一设备、同一登录链路之后产生的未过期 session,避免误伤同账号其他设备。 + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + + var deviceID string + var sourceUserID int64 + var sourceCreatedAtMs int64 + err = tx.QueryRowContext(ctx, ` + SELECT user_id, device_id, created_at_ms + FROM auth_sessions + WHERE app_code = ? AND session_id = ? + FOR UPDATE + `, appcode.FromContext(ctx), sourceSessionID).Scan(&sourceUserID, &deviceID, &sourceCreatedAtMs) + if err == sql.ErrNoRows { + // 风控任务来自登录成功后的持久 session;若源 session 已被异常清理,按空结果幂等结束。 + return nil, tx.Commit() + } + if err != nil { + return nil, err + } + if sourceUserID != userID { + // job.user_id 和 session.user_id 不一致说明数据链路被污染,不能扩大吊销范围。 + return nil, xerr.New(xerr.SessionRevoked, "risk session owner mismatch") + } + + rows, err := tx.QueryContext(ctx, ` + SELECT session_id + FROM auth_sessions + WHERE app_code = ? + AND user_id = ? + AND device_id = ? + AND created_at_ms >= ? + AND expires_at_ms > ? + FOR UPDATE + `, appcode.FromContext(ctx), userID, deviceID, sourceCreatedAtMs, revokedAtMs) + if err != nil { + return nil, err + } + var sessionIDs []string + for rows.Next() { + var sessionID string + if err := rows.Scan(&sessionID); err != nil { + _ = rows.Close() + return nil, err + } + sessionIDs = append(sessionIDs, sessionID) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(sessionIDs) == 0 { + return sessionIDs, tx.Commit() + } + + for _, sessionID := range sessionIDs { + // 已经被 refresh 轮换的 session 不覆盖原撤销原因,但仍返回给上层写 denylist,避免旧 access token 继续通过 gateway。 + if _, err := tx.ExecContext(ctx, ` + UPDATE auth_sessions + SET revoked_at_ms = ?, revoked_reason = ?, revoked_request_id = ?, revoked_by = ?, updated_at_ms = ? + WHERE app_code = ? AND session_id = ? AND revoked_at_ms IS NULL + `, revokedAtMs, reason, requestID, revokedBy, revokedAtMs, appcode.FromContext(ctx), sessionID); err != nil { + return nil, err + } + } + + return sessionIDs, tx.Commit() +} + func (r *Repository) UpdateSessionHeartbeat(ctx context.Context, sessionID string, userID int64, heartbeatAtMs int64, requestID string) (authdomain.Session, error) { // App 心跳绑定服务端 session,不能只按 user_id 宽泛刷新,避免旧 token 或跨设备请求续住错误会话。 result, err := r.db.ExecContext(ctx, ` diff --git a/services/user-service/internal/testutil/mysqltest/mysqltest.go b/services/user-service/internal/testutil/mysqltest/mysqltest.go index 186f8391..bddca121 100644 --- a/services/user-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/user-service/internal/testutil/mysqltest/mysqltest.go @@ -339,6 +339,11 @@ func (r *Repository) RevokeSessionWithReason(ctx context.Context, sessionID stri return r.Repository.AuthRepository().RevokeSessionWithReason(ctx, sessionID, revokedAtMs, reason, requestID, revokedBy) } +// RevokeUserDeviceSessionsForRisk 让测试 wrapper 继续满足认证接口。 +func (r *Repository) RevokeUserDeviceSessionsForRisk(ctx context.Context, sourceSessionID string, userID int64, revokedAtMs int64, reason string, requestID string, revokedBy string) ([]string, error) { + return r.Repository.AuthRepository().RevokeUserDeviceSessionsForRisk(ctx, sourceSessionID, userID, revokedAtMs, reason, requestID, revokedBy) +} + // UpdateSessionHeartbeat 让测试 wrapper 继续满足认证接口。 func (r *Repository) UpdateSessionHeartbeat(ctx context.Context, sessionID string, userID int64, heartbeatAtMs int64, requestID string) (authdomain.Session, error) { return r.Repository.AuthRepository().UpdateSessionHeartbeat(ctx, sessionID, userID, heartbeatAtMs, requestID)