ip风控
This commit is contained in:
parent
ef3dacd1a3
commit
b1ffa08be0
@ -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)
|
||||
|
||||
471
services/room-service/internal/storage/mysql/admin_room.go
Normal file
471
services/room-service/internal/storage/mysql/admin_room.go
Normal 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
|
||||
}
|
||||
403
services/room-service/internal/storage/mysql/command_log.go
Normal file
403
services/room-service/internal/storage/mysql/command_log.go
Normal 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
|
||||
}
|
||||
365
services/room-service/internal/storage/mysql/outbox.go
Normal file
365
services/room-service/internal/storage/mysql/outbox.go
Normal 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)
|
||||
// 失败转入 retryable,worker 只有到 next_retry_at_ms 后才会重新抢占。
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE `+table+`
|
||||
SET status = ?, worker_id = '', lock_until_ms = NULL, retry_count = retry_count + 1, next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND event_id = ?`,
|
||||
outbox.StatusRetryable,
|
||||
nextRetryAtMS,
|
||||
lastErr,
|
||||
time.Now().UTC().UnixMilli(),
|
||||
appcode.FromContext(ctx),
|
||||
eventID,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkOutboxDead 把超过最大重试次数的事件转入 failed。
|
||||
func (r *Repository) MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error {
|
||||
return r.markOutboxDeadInTable(ctx, roomOutboxTable, eventID, lastErr)
|
||||
}
|
||||
|
||||
// MarkRobotOutboxDead 把超过最大重试次数的机器人展示事件转入 failed。
|
||||
func (r *Repository) MarkRobotOutboxDead(ctx context.Context, eventID string, lastErr string) error {
|
||||
return r.markOutboxDeadInTable(ctx, roomRobotOutboxTable, eventID, lastErr)
|
||||
}
|
||||
|
||||
func (r *Repository) markOutboxDeadInTable(ctx context.Context, table string, eventID string, lastErr string) error {
|
||||
table = roomOutboxTableName(table)
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE `+table+`
|
||||
SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND event_id = ?`,
|
||||
outbox.StatusFailed,
|
||||
lastErr,
|
||||
time.Now().UTC().UnixMilli(),
|
||||
appcode.FromContext(ctx),
|
||||
eventID,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func roomOutboxTableName(table string) string {
|
||||
switch table {
|
||||
case roomRobotOutboxTable:
|
||||
return roomRobotOutboxTable
|
||||
default:
|
||||
return roomOutboxTable
|
||||
}
|
||||
}
|
||||
|
||||
func roomOutboxIndexes(table string) (pending string, claim string) {
|
||||
if roomOutboxTableName(table) == roomRobotOutboxTable {
|
||||
return "idx_room_robot_outbox_pending", "idx_room_robot_outbox_claim"
|
||||
}
|
||||
return "idx_room_outbox_pending", "idx_room_outbox_claim"
|
||||
}
|
||||
196
services/room-service/internal/storage/mysql/presence.go
Normal file
196
services/room-service/internal/storage/mysql/presence.go
Normal 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
649
services/room-service/internal/storage/mysql/robot_room.go
Normal file
649
services/room-service/internal/storage/mysql/robot_room.go
Normal 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()
|
||||
}
|
||||
713
services/room-service/internal/storage/mysql/room_list.go
Normal file
713
services/room-service/internal/storage/mysql/room_list.go
Normal 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 投影用户当前房间读模型。
|
||||
171
services/room-service/internal/storage/mysql/room_meta.go
Normal file
171
services/room-service/internal/storage/mysql/room_meta.go
Normal 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
|
||||
}
|
||||
686
services/room-service/internal/storage/mysql/schema.go
Normal file
686
services/room-service/internal/storage/mysql/schema.go
Normal 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
|
||||
}
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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 的绑定。
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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, `
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user