2660 lines
93 KiB
Go
2660 lines
93 KiB
Go
// Package mysql 实现 room-service 的 MySQL 持久化边界。
|
||
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
mysqlerr "github.com/go-sql-driver/mysql"
|
||
"google.golang.org/protobuf/proto"
|
||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
"hyapp/services/room-service/internal/room/outbox"
|
||
roomservice "hyapp/services/room-service/internal/room/service"
|
||
)
|
||
|
||
// Repository 把 room-service 的权威状态持久化到 MySQL。
|
||
type Repository struct {
|
||
// db 是标准库连接池,所有 repository 方法都通过它访问 MySQL。
|
||
db *sql.DB
|
||
}
|
||
|
||
// Open 建立 MySQL 连接池,并验证数据库可用。
|
||
func Open(ctx context.Context, dsn string, maxOpenConns int, maxIdleConns int) (*Repository, error) {
|
||
// sql.Open 只校验 driver 和 DSN 形态,真实连通性由后续 PingContext 验证。
|
||
db, err := sql.Open("mysql", dsn)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if maxOpenConns > 0 {
|
||
// 只在配置显式给出正数时覆盖标准库默认值。
|
||
db.SetMaxOpenConns(maxOpenConns)
|
||
}
|
||
|
||
if maxIdleConns > 0 {
|
||
// 空闲连接数需要小于等于最大连接数,由配置层负责合理设置。
|
||
db.SetMaxIdleConns(maxIdleConns)
|
||
}
|
||
|
||
// 长连接定期轮换,避免云数据库或网络层回收导致长期坏连接驻留。
|
||
db.SetConnMaxLifetime(30 * time.Minute)
|
||
|
||
if err := db.PingContext(ctx); err != nil {
|
||
// 启动探测失败时必须关闭连接池,避免返回半可用 repository。
|
||
_ = db.Close()
|
||
return nil, err
|
||
}
|
||
|
||
return &Repository{db: db}, nil
|
||
}
|
||
|
||
// Close 关闭底层 MySQL 连接池。
|
||
func (r *Repository) Close() error {
|
||
// App.Close 调用该方法释放所有空闲连接,并阻止后续新查询。
|
||
return r.db.Close()
|
||
}
|
||
|
||
// Ping 验证 MySQL 连接池当前仍能在调用方 deadline 内拿到可用连接。
|
||
func (r *Repository) Ping(ctx context.Context) error {
|
||
// ready 探针通过该方法确认 command log/snapshot/outbox 的持久化来源可用。
|
||
return r.db.PingContext(ctx)
|
||
}
|
||
|
||
// Migrate 创建 room-service 首版需要的 MySQL 表。
|
||
func (r *Repository) Migrate(ctx context.Context) error {
|
||
// 这里保持与 deploy/mysql/initdb 的表结构一致,服务本地启动可自动建表。
|
||
statements := []string{
|
||
`CREATE TABLE IF NOT EXISTS rooms (
|
||
app_code VARCHAR(32) NOT NULL,
|
||
room_id VARCHAR(64) NOT NULL,
|
||
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
|
||
owner_user_id BIGINT NOT NULL,
|
||
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,
|
||
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_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_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`,
|
||
`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`,
|
||
}
|
||
|
||
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.ensureRoomUserPresenceSchema(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureRoomPinSchema(ctx); err != nil {
|
||
return err
|
||
}
|
||
|
||
return r.ensureOutboxRetrySchema(ctx)
|
||
}
|
||
|
||
func (r *Repository) ensureRoomPinSchema(ctx context.Context) error {
|
||
hasColumn, err := r.columnExists(ctx, "room_region_pins", "pin_type")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !hasColumn {
|
||
// pin_type 把“全区置顶”和“区域置顶”的作用域写进同一 owner 表;历史数据默认都是区域置顶。
|
||
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD COLUMN pin_type VARCHAR(32) NOT NULL DEFAULT 'region' AFTER visible_region_id`); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
hasScopeUnique, err := r.indexExists(ctx, "room_region_pins", "uk_room_region_pins_scope")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !hasScopeUnique {
|
||
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD UNIQUE KEY uk_room_region_pins_scope (app_code, pin_type, visible_region_id, room_id)`); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
hasLegacyUnique, err := r.indexExists(ctx, "room_region_pins", "uk_room_region_pins_room")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if hasLegacyUnique {
|
||
// 新唯一键允许 region_id=0 的区域置顶和全区置顶并存;旧唯一键会错误地把两者互斥。
|
||
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins DROP INDEX uk_room_region_pins_room`); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
hasActiveScope, err := r.indexExists(ctx, "room_region_pins", "idx_room_region_pins_active_scope")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !hasActiveScope {
|
||
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD INDEX idx_room_region_pins_active_scope (app_code, pin_type, visible_region_id, status, pinned_at_ms, expires_at_ms, weight DESC, room_id)`); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Repository) ensureRoomUserPresenceSchema(ctx context.Context) error {
|
||
hasColumn, err := r.columnExists(ctx, "room_user_presence", "joined_at_ms")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !hasColumn {
|
||
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_user_presence ADD COLUMN joined_at_ms BIGINT NOT NULL DEFAULT 0 AFTER status`); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Repository) ensureRoomPasswordSchema(ctx context.Context) error {
|
||
hasColumn, err := r.columnExists(ctx, "rooms", "room_password_hash")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !hasColumn {
|
||
// rooms 是最新快照恢复密码哈希的持久来源;列表只使用 locked 布尔投影。
|
||
if _, err := r.db.ExecContext(ctx, `ALTER TABLE rooms ADD COLUMN room_password_hash VARCHAR(128) NOT NULL DEFAULT '' AFTER status`); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Repository) ensureRoomListLockSchema(ctx context.Context) error {
|
||
hasColumn, err := r.columnExists(ctx, "room_list_entries", "locked")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !hasColumn {
|
||
// locked 是列表/搜索需要的纯投影字段,历史行默认 false,后续房间状态变更会刷新为真实值。
|
||
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD COLUMN locked BOOLEAN NOT NULL DEFAULT FALSE AFTER status`); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Repository) ensureRoomListCountrySchema(ctx context.Context) error {
|
||
hasColumn, err := r.columnExists(ctx, "room_list_entries", "owner_country_code")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !hasColumn {
|
||
// owner_country_code 是房主国家快照,只服务发现页国家筛选;历史行先为空,后续房间投影刷新会补真实值。
|
||
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD COLUMN owner_country_code VARCHAR(3) NOT NULL DEFAULT '' AFTER visible_region_id`); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
indexes := []struct {
|
||
name string
|
||
statement string
|
||
}{
|
||
{
|
||
name: "idx_room_list_region_country_hot",
|
||
statement: `ALTER TABLE room_list_entries ADD INDEX idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id)`,
|
||
},
|
||
{
|
||
name: "idx_room_list_region_country_new",
|
||
statement: `ALTER TABLE room_list_entries ADD INDEX idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id)`,
|
||
},
|
||
}
|
||
for _, item := range indexes {
|
||
hasIndex, err := r.indexExists(ctx, "room_list_entries", item.name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !hasIndex {
|
||
if _, err := r.db.ExecContext(ctx, item.statement); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Repository) ensureRoomAdminCloseSchema(ctx context.Context) error {
|
||
columns := []struct {
|
||
table string
|
||
column string
|
||
statement string
|
||
}{
|
||
{"rooms", "close_reason", `ALTER TABLE rooms ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER room_password_hash`},
|
||
{"rooms", "closed_by_admin_id", `ALTER TABLE rooms ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`},
|
||
{"rooms", "closed_by_admin_name", `ALTER TABLE rooms ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`},
|
||
{"rooms", "closed_at_ms", `ALTER TABLE rooms ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`},
|
||
{"room_list_entries", "close_reason", `ALTER TABLE room_list_entries ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER locked`},
|
||
{"room_list_entries", "closed_by_admin_id", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`},
|
||
{"room_list_entries", "closed_by_admin_name", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`},
|
||
{"room_list_entries", "closed_at_ms", `ALTER TABLE room_list_entries ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`},
|
||
}
|
||
for _, item := range columns {
|
||
hasColumn, err := r.columnExists(ctx, item.table, item.column)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !hasColumn {
|
||
if _, err := r.db.ExecContext(ctx, item.statement); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Repository) ensureOutboxRetrySchema(ctx context.Context) error {
|
||
hasColumn, err := r.columnExists(ctx, "room_outbox", "next_retry_at_ms")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !hasColumn {
|
||
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_outbox ADD COLUMN next_retry_at_ms BIGINT NOT NULL DEFAULT 0 AFTER retry_count`); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
indexes := []struct {
|
||
name string
|
||
statement string
|
||
}{
|
||
{"idx_room_outbox_pending", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id)`},
|
||
{"idx_room_outbox_retry", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms, event_id)`},
|
||
{"idx_room_outbox_claim", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id)`},
|
||
{"idx_room_outbox_event_created", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id)`},
|
||
{"idx_room_outbox_retention", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retention (app_code, status, updated_at_ms, event_id)`},
|
||
}
|
||
for _, index := range indexes {
|
||
hasIndex, err := r.indexExists(ctx, "room_outbox", index.name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !hasIndex {
|
||
if _, err := r.db.ExecContext(ctx, index.statement); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Repository) columnExists(ctx context.Context, tableName string, columnName string) (bool, error) {
|
||
var exists int
|
||
err := r.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM information_schema.COLUMNS
|
||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
|
||
`, tableName, columnName).Scan(&exists)
|
||
return exists > 0, err
|
||
}
|
||
|
||
func (r *Repository) indexExists(ctx context.Context, tableName string, indexName string) (bool, error) {
|
||
var exists int
|
||
err := r.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM information_schema.STATISTICS
|
||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
|
||
`, tableName, indexName).Scan(&exists)
|
||
return exists > 0, err
|
||
}
|
||
|
||
// SaveRoomMeta 插入房间元数据。
|
||
func (r *Repository) SaveRoomMeta(ctx context.Context, meta roomservice.RoomMeta) error {
|
||
// rooms 表只保存恢复初始状态需要的元数据,实时态以 snapshot/command log 为准。
|
||
appCode := normalizedRecordAppCode(ctx, meta.AppCode)
|
||
nowMS := time.Now().UTC().UnixMilli()
|
||
_, err := r.db.ExecContext(ctx,
|
||
`INSERT INTO rooms (app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id, created_at_ms, updated_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
appCode,
|
||
meta.RoomID,
|
||
meta.RoomShortID,
|
||
meta.OwnerUserID,
|
||
meta.SeatCount,
|
||
meta.Mode,
|
||
meta.Status,
|
||
meta.RoomPasswordHash,
|
||
meta.VisibleRegionID,
|
||
nowMS,
|
||
nowMS,
|
||
)
|
||
|
||
return mapRoomMetaDuplicateError(err)
|
||
}
|
||
|
||
// GetRoomMeta 查询房间元数据。
|
||
func (r *Repository) GetRoomMeta(ctx context.Context, roomID string) (roomservice.RoomMeta, bool, error) {
|
||
// meta 是无内存 Cell 恢复的入口;不存在时上层返回 room not found。
|
||
row := r.db.QueryRowContext(ctx,
|
||
`SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id
|
||
FROM rooms
|
||
WHERE app_code = ? AND room_id = ?`,
|
||
appcode.FromContext(ctx),
|
||
roomID,
|
||
)
|
||
|
||
var meta roomservice.RoomMeta
|
||
if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
// 未创建房间不是数据库错误,用 exists=false 表达。
|
||
return roomservice.RoomMeta{}, false, nil
|
||
}
|
||
|
||
return roomservice.RoomMeta{}, false, err
|
||
}
|
||
|
||
return meta, true, nil
|
||
}
|
||
|
||
// GetRoomMetaByOwner 查询用户已经创建的房间。
|
||
func (r *Repository) GetRoomMetaByOwner(ctx context.Context, ownerUserID int64) (roomservice.RoomMeta, bool, error) {
|
||
// rooms.owner_user_id 有唯一约束,查询最多返回一条;该读路径用于 CreateRoom 前置失败。
|
||
row := r.db.QueryRowContext(ctx,
|
||
`SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id
|
||
FROM rooms
|
||
WHERE app_code = ? AND owner_user_id = ?
|
||
LIMIT 1`,
|
||
appcode.FromContext(ctx),
|
||
ownerUserID,
|
||
)
|
||
|
||
var meta roomservice.RoomMeta
|
||
if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return roomservice.RoomMeta{}, false, nil
|
||
}
|
||
|
||
return roomservice.RoomMeta{}, false, err
|
||
}
|
||
|
||
return meta, true, nil
|
||
}
|
||
|
||
// SaveRoomBackground 保存房间背景图素材;相同房间相同 URL 按幂等返回原记录。
|
||
func (r *Repository) SaveRoomBackground(ctx context.Context, background roomservice.RoomBackgroundImage) (roomservice.RoomBackgroundImage, error) {
|
||
appCode := normalizedRecordAppCode(ctx, background.AppCode)
|
||
roomID := strings.TrimSpace(background.RoomID)
|
||
imageURL := strings.TrimSpace(background.ImageURL)
|
||
nowMS := background.CreatedAtMS
|
||
if nowMS <= 0 {
|
||
nowMS = time.Now().UTC().UnixMilli()
|
||
}
|
||
|
||
_, err := r.db.ExecContext(ctx, `
|
||
INSERT INTO room_background_images (
|
||
app_code, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)
|
||
`, appCode, roomID, imageURL, background.CreatedByUserID, nowMS, nowMS)
|
||
if err != nil {
|
||
return roomservice.RoomBackgroundImage{}, err
|
||
}
|
||
|
||
row := r.db.QueryRowContext(ctx, `
|
||
SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
|
||
FROM room_background_images
|
||
WHERE app_code = ? AND room_id = ? AND image_url = ?
|
||
LIMIT 1
|
||
`, appCode, roomID, imageURL)
|
||
return scanRoomBackground(row)
|
||
}
|
||
|
||
// GetRoomBackground 精确读取一个房间背景图素材。
|
||
func (r *Repository) GetRoomBackground(ctx context.Context, roomID string, backgroundID int64) (roomservice.RoomBackgroundImage, bool, error) {
|
||
row := r.db.QueryRowContext(ctx, `
|
||
SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
|
||
FROM room_background_images
|
||
WHERE app_code = ? AND room_id = ? AND background_id = ?
|
||
LIMIT 1
|
||
`, appcode.FromContext(ctx), strings.TrimSpace(roomID), backgroundID)
|
||
|
||
background, err := scanRoomBackground(row)
|
||
if err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return roomservice.RoomBackgroundImage{}, false, nil
|
||
}
|
||
return roomservice.RoomBackgroundImage{}, false, err
|
||
}
|
||
return background, true, nil
|
||
}
|
||
|
||
// ListRoomBackgrounds 返回某个房间保存过的背景图素材,按最近保存优先。
|
||
func (r *Repository) ListRoomBackgrounds(ctx context.Context, roomID string) ([]roomservice.RoomBackgroundImage, error) {
|
||
rows, err := r.db.QueryContext(ctx, `
|
||
SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
|
||
FROM room_background_images
|
||
WHERE app_code = ? AND room_id = ?
|
||
ORDER BY created_at_ms DESC, background_id DESC
|
||
`, appcode.FromContext(ctx), strings.TrimSpace(roomID))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
backgrounds := []roomservice.RoomBackgroundImage{}
|
||
for rows.Next() {
|
||
background, err := scanRoomBackground(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
backgrounds = append(backgrounds, background)
|
||
}
|
||
return backgrounds, rows.Err()
|
||
}
|
||
|
||
func scanRoomBackground(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomBackgroundImage, error) {
|
||
var background roomservice.RoomBackgroundImage
|
||
err := scanner.Scan(
|
||
&background.AppCode,
|
||
&background.BackgroundID,
|
||
&background.RoomID,
|
||
&background.ImageURL,
|
||
&background.CreatedByUserID,
|
||
&background.CreatedAtMS,
|
||
&background.UpdatedAtMS,
|
||
)
|
||
return background, err
|
||
}
|
||
|
||
// GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。
|
||
func (r *Repository) GetCommand(ctx context.Context, roomID string, commandID string) (roomservice.CommandRecord, bool, error) {
|
||
// 幂等检查只看 command log,只有成功提交过的命令才算 seen。
|
||
row := r.db.QueryRowContext(ctx,
|
||
`SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms
|
||
FROM room_command_log
|
||
WHERE app_code = ? AND room_id = ? AND command_id = ?
|
||
LIMIT 1`,
|
||
appcode.FromContext(ctx),
|
||
roomID,
|
||
commandID,
|
||
)
|
||
|
||
var record roomservice.CommandRecord
|
||
if err := row.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
// 没有命令日志表示本命令尚未成功提交。
|
||
return roomservice.CommandRecord{}, false, nil
|
||
}
|
||
|
||
return roomservice.CommandRecord{}, false, err
|
||
}
|
||
|
||
return record, true, nil
|
||
}
|
||
|
||
// SaveCommand 追加一条成功命令日志。
|
||
func (r *Repository) SaveCommand(ctx context.Context, record roomservice.CommandRecord) error {
|
||
// ON DUPLICATE KEY 保证重复命令写入不会破坏已提交记录。
|
||
appCode := normalizedRecordAppCode(ctx, record.AppCode)
|
||
_, err := r.db.ExecContext(ctx,
|
||
`INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE command_id = command_id`,
|
||
appCode,
|
||
record.RoomID,
|
||
record.RoomVersion,
|
||
record.CommandID,
|
||
record.CommandType,
|
||
record.OwnerNodeID,
|
||
record.LeaseToken,
|
||
record.Payload,
|
||
record.Replayable,
|
||
record.CreatedAtMS,
|
||
)
|
||
|
||
return err
|
||
}
|
||
|
||
// SaveMutation 在同一事务中提交命令日志、outbox 和可选房间状态。
|
||
func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.MutationCommit) error {
|
||
appCode := normalizedRecordAppCode(ctx, commit.Command.AppCode)
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE command_id = command_id`,
|
||
appCode,
|
||
commit.Command.RoomID,
|
||
commit.Command.RoomVersion,
|
||
commit.Command.CommandID,
|
||
commit.Command.CommandType,
|
||
commit.Command.OwnerNodeID,
|
||
commit.Command.LeaseToken,
|
||
commit.Command.Payload,
|
||
commit.Command.Replayable,
|
||
commit.Command.CreatedAtMS,
|
||
); err != nil {
|
||
_ = tx.Rollback()
|
||
return err
|
||
}
|
||
|
||
for _, record := range commit.OutboxRecords {
|
||
record.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
|
||
if record.Envelope != nil {
|
||
record.Envelope.AppCode = record.AppCode
|
||
}
|
||
envelopeBytes, err := proto.Marshal(record.Envelope)
|
||
if err != nil {
|
||
_ = tx.Rollback()
|
||
return err
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_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 {
|
||
_ = 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
|
||
}
|
||
}
|
||
|
||
return tx.Commit()
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// ListCommandsAfter 返回快照版本之后的可回放命令日志。
|
||
func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) {
|
||
// 恢复必须按 room_version 再按自增 id 排序,确保同版本异常数据也有稳定顺序。
|
||
rows, err := r.db.QueryContext(ctx,
|
||
`SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms
|
||
FROM room_command_log
|
||
WHERE app_code = ? AND room_id = ? AND room_version > ?
|
||
ORDER BY room_version ASC, id ASC`,
|
||
appcode.FromContext(ctx),
|
||
roomID,
|
||
roomVersion,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
records := make([]roomservice.CommandRecord, 0)
|
||
for rows.Next() {
|
||
// payload 保持原始字节,领域层根据 command_type 反序列化。
|
||
var record roomservice.CommandRecord
|
||
if err := rows.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
records = append(records, record)
|
||
}
|
||
|
||
if err := rows.Err(); err != nil {
|
||
// rows.Err 捕获迭代过程中发生的底层连接或扫描错误。
|
||
return nil, err
|
||
}
|
||
|
||
return records, nil
|
||
}
|
||
|
||
// SaveSnapshot 保存房间最新快照。
|
||
func (r *Repository) SaveSnapshot(ctx context.Context, snapshot roomservice.SnapshotRecord) error {
|
||
// 快照按 room_id 保留最新一条,版本更旧的写入不会覆盖较新的恢复来源。
|
||
appCode := normalizedRecordAppCode(ctx, snapshot.AppCode)
|
||
_, err := r.db.ExecContext(ctx,
|
||
`INSERT INTO room_snapshots (app_code, room_id, room_version, payload, created_at_ms, updated_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
room_version = IF(VALUES(room_version) >= room_version, VALUES(room_version), room_version),
|
||
payload = IF(VALUES(room_version) >= room_version, VALUES(payload), payload),
|
||
created_at_ms = IF(VALUES(room_version) >= room_version, VALUES(created_at_ms), created_at_ms),
|
||
updated_at_ms = IF(VALUES(room_version) >= room_version, VALUES(updated_at_ms), updated_at_ms)`,
|
||
appCode,
|
||
snapshot.RoomID,
|
||
snapshot.RoomVersion,
|
||
snapshot.Payload,
|
||
snapshot.CreatedAtMS,
|
||
snapshot.CreatedAtMS,
|
||
)
|
||
|
||
return err
|
||
}
|
||
|
||
// GetLatestSnapshot 读取房间最新快照。
|
||
func (r *Repository) GetLatestSnapshot(ctx context.Context, roomID string) (roomservice.SnapshotRecord, bool, error) {
|
||
// room_snapshots 主键是 room_id,因此查询结果最多一条。
|
||
row := r.db.QueryRowContext(ctx,
|
||
`SELECT app_code, room_id, room_version, payload, created_at_ms
|
||
FROM room_snapshots
|
||
WHERE app_code = ? AND room_id = ?`,
|
||
appcode.FromContext(ctx),
|
||
roomID,
|
||
)
|
||
|
||
var snapshot roomservice.SnapshotRecord
|
||
if err := row.Scan(&snapshot.AppCode, &snapshot.RoomID, &snapshot.RoomVersion, &snapshot.Payload, &snapshot.CreatedAtMS); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
// 没有快照时上层会从 RoomMeta 和 command log 从头恢复。
|
||
return roomservice.SnapshotRecord{}, false, nil
|
||
}
|
||
|
||
return roomservice.SnapshotRecord{}, false, err
|
||
}
|
||
|
||
return snapshot, true, nil
|
||
}
|
||
|
||
// SaveOutbox 追加待投递 outbox 事件。
|
||
func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) error {
|
||
if len(records) == 0 {
|
||
// 没有事件时保持无副作用,方便领域层统一调用。
|
||
return nil
|
||
}
|
||
|
||
// 多条事件用一个事务写入,保证同一命令产生的事件不会部分入库。
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
for _, record := range records {
|
||
record.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
|
||
if record.Envelope != nil {
|
||
record.Envelope.AppCode = record.AppCode
|
||
}
|
||
// envelope 是 protobuf 信封,存储层不解析具体事件 body。
|
||
envelopeBytes, err := proto.Marshal(record.Envelope)
|
||
if err != nil {
|
||
_ = tx.Rollback()
|
||
return err
|
||
}
|
||
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE event_id = event_id`,
|
||
record.AppCode,
|
||
record.EventID,
|
||
record.EventType,
|
||
record.RoomID,
|
||
record.Status,
|
||
envelopeBytes,
|
||
record.CreatedAtMS,
|
||
record.RetryCount,
|
||
record.NextRetryAtMS,
|
||
nullableString(record.LastError),
|
||
record.CreatedAtMS,
|
||
); err != nil {
|
||
// 任一事件失败就回滚整批,避免 GiftSent/HeatChanged/RankChanged 部分缺失。
|
||
_ = tx.Rollback()
|
||
return err
|
||
}
|
||
}
|
||
|
||
// commit 成功后事件才对 outbox worker 可见。
|
||
return tx.Commit()
|
||
}
|
||
|
||
// ListPendingOutbox 返回待投递事件。
|
||
func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error) {
|
||
if limit <= 0 {
|
||
// 默认批量上限避免调用方传 0 导致全表扫描。
|
||
limit = 100
|
||
}
|
||
|
||
// 按创建时间顺序扫描 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 room_outbox FORCE INDEX (idx_room_outbox_pending)
|
||
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) {
|
||
if limit <= 0 {
|
||
limit = 100
|
||
}
|
||
if strings.TrimSpace(workerID) == "" {
|
||
workerID = "room-outbox-worker"
|
||
}
|
||
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
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 room_outbox FORCE INDEX (idx_room_outbox_pending)
|
||
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 room_outbox FORCE INDEX (idx_room_outbox_pending)
|
||
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 room_outbox FORCE INDEX (idx_room_outbox_claim)
|
||
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 room_outbox
|
||
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 {
|
||
// 成功后清空 last_error,后续扫描不再返回该事件。
|
||
_, err := r.db.ExecContext(ctx,
|
||
`UPDATE room_outbox
|
||
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 {
|
||
// 失败转入 retryable,worker 只有到 next_retry_at_ms 后才会重新抢占。
|
||
_, err := r.db.ExecContext(ctx,
|
||
`UPDATE room_outbox
|
||
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 {
|
||
_, err := r.db.ExecContext(ctx,
|
||
`UPDATE room_outbox
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
regionLiteral := strconv.FormatInt(regionID, 10)
|
||
pinnedExpr := "CASE WHEN gp.room_id IS NULL AND rp.room_id IS NULL THEN 0 ELSE 1 END"
|
||
newPinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 1 WHEN r.visible_region_id = " + regionLiteral + " THEN 2 ELSE 3 END"
|
||
hotPinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 1 WHEN r.visible_region_id = " + regionLiteral + " AND r.online_count > 0 THEN 2 WHEN r.visible_region_id <> " + regionLiteral + " AND r.online_count > 0 THEN 3 WHEN r.visible_region_id = " + regionLiteral + " THEN 4 ELSE 5 END"
|
||
pinRankExpr := hotPinRankExpr
|
||
if query.Tab == "new" {
|
||
pinRankExpr = newPinRankExpr
|
||
}
|
||
pinWeightExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.weight WHEN rp.room_id IS NOT NULL THEN rp.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 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'
|
||
AND rp.visible_region_id = ?
|
||
AND rp.room_id = r.room_id
|
||
AND rp.status = ?
|
||
AND rp.pinned_at_ms <= ?
|
||
AND rp.expires_at_ms > ?`
|
||
where := []string{"r.app_code = ?", "r.status = ?"}
|
||
args := []any{"active", query.NowMS, query.NowMS, regionID, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active"}
|
||
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 (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
|
||
}
|
||
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
|
||
}
|
||
rows, err := r.db.QueryContext(ctx, adminRoomSelectSQL()+where+` ORDER BY `+adminRoomOrderBy(query)+` 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) (roomservice.AdminRoomListEntry, bool, error) {
|
||
row := r.db.QueryRowContext(ctx, adminRoomSelectSQL()+` 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() 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,
|
||
rle.heat, 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) string {
|
||
direction := "DESC"
|
||
if strings.EqualFold(strings.TrimSpace(query.SortDirection), "asc") {
|
||
direction = "ASC"
|
||
}
|
||
switch strings.TrimSpace(query.SortBy) {
|
||
case "room_contribution", "heat":
|
||
return "rle.heat " + direction + ", rle.created_at_ms DESC, rle.room_id DESC"
|
||
default:
|
||
return "rle.created_at_ms DESC, rle.room_id DESC"
|
||
}
|
||
}
|
||
|
||
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)
|
||
if err != nil || !exists {
|
||
return roomservice.AdminRoomPinEntry{}, false, err
|
||
}
|
||
if room.Status != "active" {
|
||
return roomservice.AdminRoomPinEntry{}, false, nil
|
||
}
|
||
pinType := strings.TrimSpace(input.PinType)
|
||
if pinType == "" {
|
||
pinType = "region"
|
||
}
|
||
visibleRegionID := room.VisibleRegionID
|
||
if pinType == "global" {
|
||
visibleRegionID = 0
|
||
}
|
||
pinnedAtMS := input.PinnedAtMS
|
||
if pinnedAtMS <= 0 {
|
||
pinnedAtMS = input.NowMS
|
||
}
|
||
expiresAtMS := input.ExpiresAtMS
|
||
if expiresAtMS <= 0 {
|
||
expiresAtMS = pinnedAtMS + input.DurationDays*24*60*60*1000
|
||
}
|
||
if _, err := r.db.ExecContext(ctx, `
|
||
INSERT INTO room_region_pins (
|
||
app_code, visible_region_id, pin_type, room_id, weight, status, pinned_at_ms, expires_at_ms,
|
||
cancelled_at_ms, created_by_admin_id, cancelled_by_admin_id, created_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, 0, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
weight = VALUES(weight), status = VALUES(status), pinned_at_ms = VALUES(pinned_at_ms),
|
||
expires_at_ms = VALUES(expires_at_ms), cancelled_at_ms = 0,
|
||
created_by_admin_id = VALUES(created_by_admin_id), cancelled_by_admin_id = 0,
|
||
created_at_ms = VALUES(created_at_ms), updated_at_ms = VALUES(updated_at_ms)
|
||
`, app, visibleRegionID, pinType, room.RoomID, input.Weight, pinnedAtMS, expiresAtMS, input.AdminID, input.NowMS, input.NowMS); err != nil {
|
||
return roomservice.AdminRoomPinEntry{}, false, err
|
||
}
|
||
return r.adminGetRoomPin(ctx, `WHERE p.app_code = ? AND p.pin_type = ? AND p.visible_region_id = ? AND p.room_id = ?`, app, pinType, visibleRegionID, room.RoomID)
|
||
}
|
||
|
||
func (r *Repository) AdminCancelRoomPin(ctx context.Context, pinID int64, adminID uint64, nowMS int64) (roomservice.AdminRoomPinEntry, bool, error) {
|
||
app := appcode.FromContext(ctx)
|
||
if _, err := r.db.ExecContext(ctx, `
|
||
UPDATE room_region_pins
|
||
SET status = 'cancelled', cancelled_at_ms = ?, cancelled_by_admin_id = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND id = ? AND status <> 'cancelled'
|
||
`, nowMS, adminID, nowMS, app, pinID); err != nil {
|
||
return roomservice.AdminRoomPinEntry{}, false, err
|
||
}
|
||
return r.adminGetRoomPin(ctx, `WHERE p.app_code = ? AND p.id = ?`, app, pinID)
|
||
}
|
||
|
||
func (r *Repository) adminGetRoomPin(ctx context.Context, where string, args ...any) (roomservice.AdminRoomPinEntry, bool, error) {
|
||
item, err := scanAdminRoomPin(r.db.QueryRowContext(ctx, adminRoomPinSelectSQL()+where+` LIMIT 1`, args...))
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return roomservice.AdminRoomPinEntry{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return roomservice.AdminRoomPinEntry{}, false, err
|
||
}
|
||
return item, true, nil
|
||
}
|
||
|
||
func adminRoomPinSelectSQL() string {
|
||
return `
|
||
SELECT p.id, p.visible_region_id, p.pin_type, p.room_id, p.weight, p.status,
|
||
p.pinned_at_ms, p.expires_at_ms, p.cancelled_at_ms,
|
||
p.created_by_admin_id, p.cancelled_by_admin_id, p.created_at_ms, p.updated_at_ms,
|
||
r.room_short_id, r.visible_region_id, r.title, r.cover_url, r.owner_user_id, r.status
|
||
FROM room_region_pins p
|
||
JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id `
|
||
}
|
||
|
||
func adminRoomPinWhere(ctx context.Context, query roomservice.AdminRoomPinQuery) (string, []any) {
|
||
nowMS := query.NowMS
|
||
if nowMS <= 0 {
|
||
nowMS = time.Now().UTC().UnixMilli()
|
||
}
|
||
where := []string{"p.app_code = ?"}
|
||
args := []any{appcode.FromContext(ctx)}
|
||
switch strings.TrimSpace(query.Status) {
|
||
case "active":
|
||
where = append(where, "p.status = ?", "p.expires_at_ms > ?")
|
||
args = append(args, "active", nowMS)
|
||
case "expired":
|
||
where = append(where, "p.status = ?", "p.expires_at_ms <= ?")
|
||
args = append(args, "active", nowMS)
|
||
case "cancelled":
|
||
where = append(where, "p.status = ?")
|
||
args = append(args, "cancelled")
|
||
}
|
||
if query.RegionID > 0 {
|
||
where = append(where, "p.visible_region_id = ?")
|
||
args = append(args, query.RegionID)
|
||
}
|
||
if strings.TrimSpace(query.PinType) != "" {
|
||
where = append(where, "p.pin_type = ?")
|
||
args = append(args, strings.TrimSpace(query.PinType))
|
||
}
|
||
if strings.TrimSpace(query.Keyword) != "" {
|
||
like := "%" + strings.ReplaceAll(strings.TrimSpace(query.Keyword), "%", "\\%") + "%"
|
||
where = append(where, "(p.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\' OR CAST(r.owner_user_id AS CHAR) LIKE ? ESCAPE '\\\\')")
|
||
args = append(args, like, like, like, like)
|
||
}
|
||
return ` FROM room_region_pins p JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id WHERE ` + strings.Join(where, " AND "), args
|
||
}
|
||
|
||
func scanAdminRoomPin(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomPinEntry, error) {
|
||
var item roomservice.AdminRoomPinEntry
|
||
err := scanner.Scan(&item.ID, &item.VisibleRegionID, &item.PinType, &item.RoomID, &item.Weight, &item.Status, &item.PinnedAtMS, &item.ExpiresAtMS, &item.CancelledAtMS, &item.CreatedByAdminID, &item.CancelledByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.RoomShortID, &item.RoomVisibleRegionID, &item.Title, &item.CoverURL, &item.OwnerUserID, &item.RoomStatus)
|
||
return item, err
|
||
}
|
||
|
||
func roomListAfterCursorSQL(pinRankExpr string, pinWeightExpr string, pinExpiresExpr string, sortExpr string) string {
|
||
// 排序桶、置顶权重、过期时间和 tab 排序键共同组成游标,避免跨全区置顶/区域置顶/普通房分区翻页时重复或跳过。
|
||
return "(" +
|
||
pinRankExpr + " > ? OR (" +
|
||
pinRankExpr + " = ? AND " + pinWeightExpr + " < ?) OR (" +
|
||
pinRankExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " < ?) OR (" +
|
||
pinRankExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " = ? AND " + sortExpr + " < ?) OR (" +
|
||
pinRankExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " = ? AND " + sortExpr + " = ? AND r.room_id > ?))"
|
||
}
|
||
|
||
func roomListAfterCursorArgs(pinListRank int64, pinWeight int64, pinnedUntilMS int64, sortValue int64, roomID string) []any {
|
||
return []any{
|
||
pinListRank,
|
||
pinListRank, pinWeight,
|
||
pinListRank, pinWeight, pinnedUntilMS,
|
||
pinListRank, pinWeight, pinnedUntilMS, sortValue,
|
||
pinListRank, pinWeight, pinnedUntilMS, sortValue, roomID,
|
||
}
|
||
}
|
||
|
||
func buildRoomUserFeedQuerySQL(query roomservice.RoomUserFeedQuery) (string, []any) {
|
||
where := []string{"f.app_code = ?", "f.user_id = ?", "f.feed_type = ?", "r.visible_region_id = ?", "r.status = ?"}
|
||
args := []any{appcode.Normalize(query.AppCode), query.UserID, query.FeedType, query.VisibleRegionID, "active"}
|
||
if strings.TrimSpace(query.Query) != "" {
|
||
where = append(where, "(r.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\')")
|
||
like := "%" + escapeRoomListLike(query.Query) + "%"
|
||
args = append(args, like, like, like)
|
||
}
|
||
if query.CursorRoomID != "" {
|
||
where = append(where, "(f.updated_at_ms < ? OR (f.updated_at_ms = ? AND f.room_id > ?))")
|
||
args = append(args, query.CursorUpdatedAtMS, query.CursorUpdatedAtMS, query.CursorRoomID)
|
||
}
|
||
args = append(args, query.Limit)
|
||
return roomUserFeedSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY f.updated_at_ms DESC, f.room_id ASC\n\tLIMIT ?", args
|
||
}
|
||
|
||
func buildRoomFollowQuerySQL(query roomservice.RoomFollowQuery) (string, []any) {
|
||
where := []string{"f.app_code = ?", "f.user_id = ?", "f.status = ?", "r.visible_region_id = ?", "r.status = ?"}
|
||
args := []any{appcode.Normalize(query.AppCode), query.UserID, roomFollowStatusActiveSQL, query.VisibleRegionID, "active"}
|
||
if strings.TrimSpace(query.Query) != "" {
|
||
where = append(where, "(r.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\')")
|
||
like := "%" + escapeRoomListLike(query.Query) + "%"
|
||
args = append(args, like, like, like)
|
||
}
|
||
if query.CursorRoomID != "" {
|
||
where = append(where, "(f.followed_at_ms < ? OR (f.followed_at_ms = ? AND f.room_id > ?))")
|
||
args = append(args, query.CursorFollowedAtMS, query.CursorFollowedAtMS, query.CursorRoomID)
|
||
}
|
||
args = append(args, query.Limit)
|
||
return roomFollowSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY f.followed_at_ms DESC, f.room_id ASC\n\tLIMIT ?", args
|
||
}
|
||
|
||
func buildRoomRelatedFeedQuerySQL(query roomservice.RoomRelatedFeedQuery, relations map[int64]int64) (string, []any) {
|
||
where := []string{"app_code = ?", "visible_region_id = ?", "status = ?"}
|
||
args := []any{appcode.Normalize(query.AppCode), query.VisibleRegionID, "active"}
|
||
|
||
placeholders := make([]string, 0, len(relations))
|
||
relatedIDs := make([]any, 0, len(relations))
|
||
for userID := range relations {
|
||
placeholders = append(placeholders, "?")
|
||
relatedIDs = append(relatedIDs, userID)
|
||
}
|
||
inClause := strings.Join(placeholders, ",")
|
||
where = append(where, "owner_user_id IN ("+inClause+")")
|
||
args = append(args, relatedIDs...)
|
||
|
||
if strings.TrimSpace(query.Query) != "" {
|
||
where = append(where, "(room_id LIKE ? ESCAPE '\\\\' OR room_short_id LIKE ? ESCAPE '\\\\' OR title LIKE ? ESCAPE '\\\\')")
|
||
like := "%" + escapeRoomListLike(query.Query) + "%"
|
||
args = append(args, like, like, like)
|
||
}
|
||
|
||
return roomListSelectColumns + "\n\tWHERE " + strings.Join(where, " AND "), args
|
||
}
|
||
|
||
func normalizeRoomFeedRelations(items []roomservice.RoomFeedRelatedUser) map[int64]int64 {
|
||
relations := make(map[int64]int64, len(items))
|
||
for _, item := range items {
|
||
if item.UserID <= 0 || item.RelationUpdatedAtMS <= 0 {
|
||
continue
|
||
}
|
||
if item.RelationUpdatedAtMS > relations[item.UserID] {
|
||
relations[item.UserID] = item.RelationUpdatedAtMS
|
||
}
|
||
}
|
||
return relations
|
||
}
|
||
|
||
func relatedFeedSortKey(entry roomservice.RoomListEntry, relations map[int64]int64) (int64, int64) {
|
||
bestUserID, bestUpdatedAtMS := int64(0), int64(0)
|
||
choose := func(userID int64) {
|
||
updatedAtMS := relations[userID]
|
||
if updatedAtMS == 0 {
|
||
return
|
||
}
|
||
if updatedAtMS > bestUpdatedAtMS || (updatedAtMS == bestUpdatedAtMS && userID > bestUserID) {
|
||
bestUserID = userID
|
||
bestUpdatedAtMS = updatedAtMS
|
||
}
|
||
}
|
||
choose(entry.OwnerUserID)
|
||
return bestUserID, bestUpdatedAtMS
|
||
}
|
||
|
||
func roomRelatedFeedAfterCursor(entry roomservice.RoomListEntry, cursorUpdatedAtMS int64, cursorSubjectUserID int64, cursorRoomID string) bool {
|
||
if cursorUpdatedAtMS <= 0 || cursorSubjectUserID <= 0 || cursorRoomID == "" {
|
||
return true
|
||
}
|
||
if entry.UpdatedAtMS != cursorUpdatedAtMS {
|
||
return entry.UpdatedAtMS < cursorUpdatedAtMS
|
||
}
|
||
if entry.FeedSubjectUserID != cursorSubjectUserID {
|
||
return entry.FeedSubjectUserID < cursorSubjectUserID
|
||
}
|
||
return entry.RoomID > cursorRoomID
|
||
}
|
||
|
||
// ProjectRoomPresence 用最新 RoomSnapshot 投影用户当前房间读模型。
|
||
func (r *Repository) ProjectRoomPresence(ctx context.Context, snapshot roomservice.RoomPresenceSnapshot) error {
|
||
appCode := normalizedRecordAppCode(ctx, snapshot.AppCode)
|
||
if strings.TrimSpace(snapshot.RoomID) == "" {
|
||
return nil
|
||
}
|
||
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 先把该房间上一版 active presence 全部置为 left,再把本次快照里的在线用户 upsert 回 active。
|
||
// 这样 Join/Leave/Kick/Close 的投影逻辑统一,避免按命令类型维护多份分支。
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE room_user_presence
|
||
SET status = ?, publish_state = '', mic_session_id = '', room_version = ?, updated_at_ms = ?
|
||
WHERE app_code = ? AND room_id = ? AND status = ?`,
|
||
roomPresenceStatusLeftSQL,
|
||
snapshot.RoomVersion,
|
||
snapshot.UpdatedAtMS,
|
||
appCode,
|
||
snapshot.RoomID,
|
||
roomPresenceStatusActiveSQL,
|
||
); err != nil {
|
||
_ = tx.Rollback()
|
||
return err
|
||
}
|
||
|
||
for _, user := range snapshot.Users {
|
||
if user.UserID <= 0 {
|
||
continue
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO room_user_presence (
|
||
app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, joined_at_ms, last_seen_at_ms, updated_at_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
room_id = VALUES(room_id),
|
||
role = VALUES(role),
|
||
publish_state = VALUES(publish_state),
|
||
mic_session_id = VALUES(mic_session_id),
|
||
room_version = VALUES(room_version),
|
||
status = VALUES(status),
|
||
joined_at_ms = VALUES(joined_at_ms),
|
||
last_seen_at_ms = VALUES(last_seen_at_ms),
|
||
updated_at_ms = VALUES(updated_at_ms)`,
|
||
appCode,
|
||
user.UserID,
|
||
snapshot.RoomID,
|
||
user.Role,
|
||
user.PublishState,
|
||
user.MicSessionID,
|
||
snapshot.RoomVersion,
|
||
roomPresenceStatusActiveSQL,
|
||
user.JoinedAtMS,
|
||
user.LastSeenAtMS,
|
||
snapshot.UpdatedAtMS,
|
||
); err != nil {
|
||
_ = tx.Rollback()
|
||
return err
|
||
}
|
||
}
|
||
|
||
return tx.Commit()
|
||
}
|
||
|
||
// GetCurrentRoomPresence 查询用户当前 active 房间,不扫描发现页列表。
|
||
func (r *Repository) GetCurrentRoomPresence(ctx context.Context, userID int64) (roomservice.RoomPresence, bool, error) {
|
||
row := r.db.QueryRowContext(ctx,
|
||
`SELECT app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, joined_at_ms, last_seen_at_ms, updated_at_ms
|
||
FROM room_user_presence
|
||
WHERE app_code = ? AND user_id = ? AND status = ?
|
||
LIMIT 1`,
|
||
appcode.FromContext(ctx),
|
||
userID,
|
||
roomPresenceStatusActiveSQL,
|
||
)
|
||
|
||
var presence roomservice.RoomPresence
|
||
if err := row.Scan(
|
||
&presence.AppCode,
|
||
&presence.UserID,
|
||
&presence.RoomID,
|
||
&presence.Role,
|
||
&presence.PublishState,
|
||
&presence.MicSessionID,
|
||
&presence.RoomVersion,
|
||
&presence.Status,
|
||
&presence.JoinedAtMS,
|
||
&presence.LastSeenAtMS,
|
||
&presence.UpdatedAtMS,
|
||
); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return roomservice.RoomPresence{}, false, nil
|
||
}
|
||
return roomservice.RoomPresence{}, false, err
|
||
}
|
||
|
||
return presence, true, nil
|
||
}
|
||
|
||
// ListRoomOnlineUsers 分页查询某个房间当前 active presence。
|
||
func (r *Repository) ListRoomOnlineUsers(ctx context.Context, query roomservice.RoomOnlineUserQuery) (roomservice.RoomOnlineUserPage, error) {
|
||
page := query.Page
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
pageSize := query.PageSize
|
||
if pageSize <= 0 {
|
||
pageSize = 50
|
||
}
|
||
offset := (page - 1) * pageSize
|
||
appCode := normalizedRecordAppCode(ctx, query.AppCode)
|
||
|
||
var total int64
|
||
if err := r.db.QueryRowContext(ctx,
|
||
`SELECT COUNT(*)
|
||
FROM room_user_presence
|
||
WHERE app_code = ? AND room_id = ? AND status = ?`,
|
||
appCode,
|
||
query.RoomID,
|
||
roomPresenceStatusActiveSQL,
|
||
).Scan(&total); err != nil {
|
||
return roomservice.RoomOnlineUserPage{}, err
|
||
}
|
||
|
||
orderBy := "p.last_seen_at_ms DESC, p.user_id ASC"
|
||
if strings.EqualFold(strings.TrimSpace(query.Sort), "gift_value") {
|
||
orderBy = "COALESCE(g.gift_value, 0) DESC, p.last_seen_at_ms DESC, p.user_id ASC"
|
||
}
|
||
|
||
rows, err := r.db.QueryContext(ctx,
|
||
`SELECT p.app_code, p.user_id, p.room_id, p.role, p.publish_state, p.mic_session_id, p.room_version, p.status,
|
||
p.joined_at_ms, p.last_seen_at_ms, p.updated_at_ms, COALESCE(g.gift_value, 0)
|
||
FROM room_user_presence p
|
||
LEFT JOIN room_user_gift_stats g
|
||
ON g.app_code = p.app_code AND g.room_id = p.room_id AND g.user_id = p.user_id
|
||
WHERE p.app_code = ? AND p.room_id = ? AND p.status = ?
|
||
ORDER BY `+orderBy+`
|
||
LIMIT ? OFFSET ?`,
|
||
appCode,
|
||
query.RoomID,
|
||
roomPresenceStatusActiveSQL,
|
||
pageSize,
|
||
offset,
|
||
)
|
||
if err != nil {
|
||
return roomservice.RoomOnlineUserPage{}, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
users := make([]roomservice.RoomPresence, 0, pageSize)
|
||
for rows.Next() {
|
||
var presence roomservice.RoomPresence
|
||
if err := rows.Scan(
|
||
&presence.AppCode,
|
||
&presence.UserID,
|
||
&presence.RoomID,
|
||
&presence.Role,
|
||
&presence.PublishState,
|
||
&presence.MicSessionID,
|
||
&presence.RoomVersion,
|
||
&presence.Status,
|
||
&presence.JoinedAtMS,
|
||
&presence.LastSeenAtMS,
|
||
&presence.UpdatedAtMS,
|
||
&presence.GiftValue,
|
||
); err != nil {
|
||
return roomservice.RoomOnlineUserPage{}, err
|
||
}
|
||
users = append(users, presence)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return roomservice.RoomOnlineUserPage{}, err
|
||
}
|
||
|
||
return roomservice.RoomOnlineUserPage{
|
||
Users: users,
|
||
Total: total,
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
}, nil
|
||
}
|
||
|
||
const (
|
||
roomPresenceStatusActiveSQL = "active"
|
||
roomPresenceStatusLeftSQL = "left"
|
||
roomFollowStatusActiveSQL = "active"
|
||
roomFollowStatusCancelledSQL = "cancelled"
|
||
)
|
||
|
||
func escapeRoomListLike(value string) string {
|
||
var builder strings.Builder
|
||
for _, r := range value {
|
||
if r == '\\' || r == '%' || r == '_' {
|
||
builder.WriteRune('\\')
|
||
}
|
||
builder.WriteRune(r)
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
func nullableString(value string) sql.Null[string] {
|
||
// 空错误写 NULL,便于 SQL 查询区分没有错误和空字符串。
|
||
return sql.Null[string]{
|
||
V: value,
|
||
Valid: value != "",
|
||
}
|
||
}
|
||
|
||
func normalizedRecordAppCode(ctx context.Context, value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return appcode.FromContext(ctx)
|
||
}
|
||
|
||
return appcode.Normalize(value)
|
||
}
|
||
|
||
func boolToInt(value bool) int {
|
||
if value {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func mapRoomMetaDuplicateError(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
|
||
var mysqlErr *mysqlerr.MySQLError
|
||
if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1062 {
|
||
return err
|
||
}
|
||
|
||
message := mysqlErr.Message
|
||
if strings.Contains(message, "uk_rooms_owner_user") || strings.Contains(message, "owner_user_id") {
|
||
return xerr.New(xerr.Conflict, "owner already has room")
|
||
}
|
||
|
||
return xerr.New(xerr.Conflict, "room already exists")
|
||
}
|