// 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(*) `+adminRoomPinFromSQL()+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.app_code, 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 ` + adminRoomPinFromSQL() } func adminRoomPinFromSQL() string { 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 ` } 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 ` 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.AppCode, &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 }