This commit is contained in:
zhx 2026-06-22 13:59:15 +08:00
parent ef3dacd1a3
commit b1ffa08be0
15 changed files with 3895 additions and 3568 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
// 失败转入 retryableworker 只有到 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"
}

View File

@ -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
}

File diff suppressed because it is too large Load Diff

View File

@ -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()
}

View File

@ -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 投影用户当前房间读模型。

View File

@ -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
}

View File

@ -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
}

View File

@ -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 == "" {

View File

@ -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 的绑定。

View File

@ -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)

View File

@ -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, `

View File

@ -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)