2026-06-15 23:53:30 +08:00

3000 lines
104 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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`,
`CREATE TABLE IF NOT EXISTS room_robot_rooms (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
title VARCHAR(128) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '',
visible_region_id BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
owner_robot_user_id BIGINT NOT NULL,
robot_user_ids_json JSON NOT NULL,
gift_ids_json JSON NOT NULL,
lucky_gift_ids_json JSON NOT NULL,
normal_gift_interval_ms BIGINT NOT NULL,
lucky_combo_min BIGINT NOT NULL,
lucky_combo_max BIGINT NOT NULL,
lucky_pause_min_ms BIGINT NOT NULL,
lucky_pause_max_ms BIGINT NOT NULL,
created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id),
KEY idx_room_robot_rooms_status (app_code, status, updated_at_ms, room_id),
KEY idx_room_robot_rooms_owner (app_code, owner_robot_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`INSERT IGNORE INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms)
VALUES ('lalu', '[10,15,20,25,30]', 15, 0, 0)`,
`INSERT IGNORE INTO room_rocket_configs (
app_code,
enabled,
config_version,
fuel_source,
launch_delay_ms,
broadcast_enabled,
broadcast_scope,
broadcast_delay_ms,
reward_stack_policy,
levels_json,
gift_fuel_rules_json,
updated_by_admin_id,
created_at_ms,
updated_at_ms
) VALUES (
'lalu',
0,
1,
'heat_value',
30000,
1,
'region',
0,
'allow_stack',
JSON_ARRAY(
JSON_OBJECT('level', 1, 'fuelThreshold', 1000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
JSON_OBJECT('level', 2, 'fuelThreshold', 3000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
JSON_OBJECT('level', 3, 'fuelThreshold', 6000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
JSON_OBJECT('level', 4, 'fuelThreshold', 10000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
JSON_OBJECT('level', 5, 'fuelThreshold', 15000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY())
),
JSON_ARRAY(),
0,
0,
0
)`,
`ALTER TABLE rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_list_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_user_presence MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_snapshots MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_command_log MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_outbox MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_user_feed_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_follows MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_region_pins MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_seat_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_rocket_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_robot_rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
}
for _, statement := range statements {
// 每条 DDL 独立执行,失败时返回具体 SQL 错误给启动流程。
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
if err := r.ensureRoomListLockSchema(ctx); err != nil {
return err
}
if err := r.ensureRoomListCountrySchema(ctx); err != nil {
return err
}
if err := r.ensureRoomPasswordSchema(ctx); err != nil {
return err
}
if err := r.ensureRoomAdminCloseSchema(ctx); err != nil {
return err
}
if err := r.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
}
// ListRobotRooms 读取后台机器人房间列表;机器人房间配置归 room-service 所有,后台不直连房间库。
func (r *Repository) ListRobotRooms(ctx context.Context, query roomservice.RobotRoomListQuery) ([]roomservice.RobotRoomConfig, int64, error) {
appCode := normalizedRecordAppCode(ctx, query.AppCode)
page := query.Page
if page <= 0 {
page = 1
}
pageSize := query.PageSize
if pageSize <= 0 || pageSize > 100 {
pageSize = 50
}
status := strings.TrimSpace(query.Status)
args := []any{appCode}
where := `WHERE app_code = ?`
if status != "" {
where += ` AND status = ?`
args = append(args, status)
}
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_robot_rooms `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, pageSize, (page-1)*pageSize)
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status,
owner_robot_user_id, robot_user_ids_json, gift_ids_json, lucky_gift_ids_json,
normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms,
created_by_admin_id, created_at_ms, updated_at_ms
FROM room_robot_rooms `+where+`
ORDER BY updated_at_ms DESC, room_id DESC
LIMIT ? OFFSET ?`,
args...,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items, err := scanRobotRoomConfigs(rows)
return items, total, err
}
// GetRobotRoom 精确读取一个机器人房间配置。
func (r *Repository) GetRobotRoom(ctx context.Context, roomID string) (roomservice.RobotRoomConfig, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status,
owner_robot_user_id, robot_user_ids_json, gift_ids_json, lucky_gift_ids_json,
normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms,
created_by_admin_id, created_at_ms, updated_at_ms
FROM room_robot_rooms
WHERE app_code = ? AND room_id = ?`,
appcode.FromContext(ctx),
strings.TrimSpace(roomID),
)
item, err := scanRobotRoomConfig(row)
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RobotRoomConfig{}, false, nil
}
return item, err == nil, err
}
// CreateRobotRoomConfig 写入机器人房间配置,并用事务锁定候选账号避免两个 active 机器人房复用同一批账号。
func (r *Repository) CreateRobotRoomConfig(ctx context.Context, input roomservice.CreateRobotRoomConfigInput) (roomservice.RobotRoomConfig, error) {
config := input.Config
appCode := normalizedRecordAppCode(ctx, config.AppCode)
nowMS := input.NowMS
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
config.AppCode = appCode
config.CreatedAtMS = firstPositiveInt64(config.CreatedAtMS, nowMS)
config.UpdatedAtMS = firstPositiveInt64(config.UpdatedAtMS, nowMS)
if config.Status == "" {
config.Status = "active"
}
rawRobots, rawGifts, rawLuckyGifts, err := robotRoomJSON(config)
if err != nil {
return roomservice.RobotRoomConfig{}, err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return roomservice.RobotRoomConfig{}, err
}
defer func() { _ = tx.Rollback() }()
occupied, err := occupiedRobotUserIDsTx(ctx, tx, appCode, config.RobotUserIDs)
if err != nil {
return roomservice.RobotRoomConfig{}, err
}
if len(occupied) > 0 {
return roomservice.RobotRoomConfig{}, xerr.New(xerr.Conflict, "robot user already assigned to active robot room")
}
_, err = tx.ExecContext(ctx,
`INSERT INTO room_robot_rooms (
app_code, room_id, room_short_id, title, cover_url, visible_region_id, status,
owner_robot_user_id, robot_user_ids_json, gift_ids_json, lucky_gift_ids_json,
normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms,
created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
config.AppCode,
config.RoomID,
config.RoomShortID,
config.Title,
config.CoverURL,
config.VisibleRegionID,
config.Status,
config.OwnerRobotUserID,
string(rawRobots),
string(rawGifts),
string(rawLuckyGifts),
config.GiftRule.NormalGiftIntervalMS,
config.GiftRule.LuckyComboMin,
config.GiftRule.LuckyComboMax,
config.GiftRule.LuckyPauseMinMS,
config.GiftRule.LuckyPauseMaxMS,
config.CreatedByAdminID,
config.CreatedAtMS,
config.UpdatedAtMS,
)
if err != nil {
return roomservice.RobotRoomConfig{}, err
}
if err := tx.Commit(); err != nil {
return roomservice.RobotRoomConfig{}, err
}
return config, nil
}
// UpdateRobotRoomStatus 只切换机器人房间运行态;停止后账号会从“已占用”集合释放。
func (r *Repository) UpdateRobotRoomStatus(ctx context.Context, roomID string, status string, nowMS int64) (roomservice.RobotRoomConfig, bool, error) {
status = strings.TrimSpace(status)
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
res, err := r.db.ExecContext(ctx,
`UPDATE room_robot_rooms
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
status,
nowMS,
appcode.FromContext(ctx),
strings.TrimSpace(roomID),
)
if err != nil {
return roomservice.RobotRoomConfig{}, false, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return roomservice.RobotRoomConfig{}, false, nil
}
item, exists, err := r.GetRobotRoom(ctx, roomID)
return item, exists, err
}
// OccupiedRobotUserIDs 返回仍被 active 机器人房占用的机器人账号集合。
func (r *Repository) OccupiedRobotUserIDs(ctx context.Context, userIDs []int64) (map[int64]bool, error) {
return occupiedRobotUserIDsQuery(ctx, r.db, appcode.FromContext(ctx), userIDs, false)
}
// ListActiveRobotRooms 为 room-service 重启恢复运行时循环提供当前 active 配置。
func (r *Repository) ListActiveRobotRooms(ctx context.Context) ([]roomservice.RobotRoomConfig, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status,
owner_robot_user_id, robot_user_ids_json, gift_ids_json, lucky_gift_ids_json,
normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms,
created_by_admin_id, created_at_ms, updated_at_ms
FROM room_robot_rooms
WHERE status = 'active'
ORDER BY app_code ASC, updated_at_ms DESC, room_id DESC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRobotRoomConfigs(rows)
}
type robotRoomScanner interface {
Scan(dest ...any) error
}
func scanRobotRoomConfigs(rows *sql.Rows) ([]roomservice.RobotRoomConfig, error) {
items := make([]roomservice.RobotRoomConfig, 0)
for rows.Next() {
item, err := scanRobotRoomConfig(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func scanRobotRoomConfig(row robotRoomScanner) (roomservice.RobotRoomConfig, error) {
var item roomservice.RobotRoomConfig
var rawRobots string
var rawGifts string
var rawLuckyGifts string
if err := row.Scan(
&item.AppCode,
&item.RoomID,
&item.RoomShortID,
&item.Title,
&item.CoverURL,
&item.VisibleRegionID,
&item.Status,
&item.OwnerRobotUserID,
&rawRobots,
&rawGifts,
&rawLuckyGifts,
&item.GiftRule.NormalGiftIntervalMS,
&item.GiftRule.LuckyComboMin,
&item.GiftRule.LuckyComboMax,
&item.GiftRule.LuckyPauseMinMS,
&item.GiftRule.LuckyPauseMaxMS,
&item.CreatedByAdminID,
&item.CreatedAtMS,
&item.UpdatedAtMS,
); err != nil {
return roomservice.RobotRoomConfig{}, err
}
if err := json.Unmarshal([]byte(rawRobots), &item.RobotUserIDs); err != nil {
return roomservice.RobotRoomConfig{}, err
}
if err := json.Unmarshal([]byte(rawGifts), &item.GiftRule.GiftIDs); err != nil {
return roomservice.RobotRoomConfig{}, err
}
if err := json.Unmarshal([]byte(rawLuckyGifts), &item.GiftRule.LuckyGiftIDs); err != nil {
return roomservice.RobotRoomConfig{}, err
}
return item, nil
}
func robotRoomJSON(config roomservice.RobotRoomConfig) ([]byte, []byte, []byte, error) {
rawRobots, err := json.Marshal(config.RobotUserIDs)
if err != nil {
return nil, nil, nil, err
}
rawGifts, err := json.Marshal(config.GiftRule.GiftIDs)
if err != nil {
return nil, nil, nil, err
}
rawLuckyGifts, err := json.Marshal(config.GiftRule.LuckyGiftIDs)
if err != nil {
return nil, nil, nil, err
}
return rawRobots, rawGifts, rawLuckyGifts, nil
}
func occupiedRobotUserIDsTx(ctx context.Context, tx *sql.Tx, appCode string, userIDs []int64) (map[int64]bool, error) {
return occupiedRobotUserIDsQuery(ctx, tx, appCode, userIDs, true)
}
type dbQuerier interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}
func occupiedRobotUserIDsQuery(ctx context.Context, db dbQuerier, appCode string, userIDs []int64, lock bool) (map[int64]bool, error) {
ids := uniquePositiveInt64(userIDs)
occupied := make(map[int64]bool, len(ids))
if len(ids) == 0 {
return occupied, nil
}
query := `SELECT robot_user_ids_json FROM room_robot_rooms WHERE app_code = ? AND status = 'active'`
if lock {
query += ` FOR UPDATE`
}
rows, err := db.QueryContext(ctx, query, appcode.Normalize(appCode))
if err != nil {
return nil, err
}
defer rows.Close()
wanted := make(map[int64]bool, len(ids))
for _, id := range ids {
wanted[id] = true
}
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return nil, err
}
var roomRobotIDs []int64
if err := json.Unmarshal([]byte(raw), &roomRobotIDs); err != nil {
return nil, err
}
for _, id := range roomRobotIDs {
if wanted[id] {
occupied[id] = true
}
}
}
return occupied, rows.Err()
}
// ListCommandsAfter 返回快照版本之后的可回放命令日志。
func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) {
// 恢复必须按 room_version 再按自增 id 排序,确保同版本异常数据也有稳定顺序。
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms
FROM room_command_log
WHERE app_code = ? AND room_id = ? AND room_version > ?
ORDER BY room_version ASC, id ASC`,
appcode.FromContext(ctx),
roomID,
roomVersion,
)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]roomservice.CommandRecord, 0)
for rows.Next() {
// payload 保持原始字节,领域层根据 command_type 反序列化。
var record roomservice.CommandRecord
if err := rows.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil {
return nil, err
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
// rows.Err 捕获迭代过程中发生的底层连接或扫描错误。
return nil, err
}
return records, nil
}
// SaveSnapshot 保存房间最新快照。
func (r *Repository) SaveSnapshot(ctx context.Context, snapshot roomservice.SnapshotRecord) error {
// 快照按 room_id 保留最新一条,版本更旧的写入不会覆盖较新的恢复来源。
appCode := normalizedRecordAppCode(ctx, snapshot.AppCode)
_, err := r.db.ExecContext(ctx,
`INSERT INTO room_snapshots (app_code, room_id, room_version, payload, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
room_version = IF(VALUES(room_version) >= room_version, VALUES(room_version), room_version),
payload = IF(VALUES(room_version) >= room_version, VALUES(payload), payload),
created_at_ms = IF(VALUES(room_version) >= room_version, VALUES(created_at_ms), created_at_ms),
updated_at_ms = IF(VALUES(room_version) >= room_version, VALUES(updated_at_ms), updated_at_ms)`,
appCode,
snapshot.RoomID,
snapshot.RoomVersion,
snapshot.Payload,
snapshot.CreatedAtMS,
snapshot.CreatedAtMS,
)
return err
}
// GetLatestSnapshot 读取房间最新快照。
func (r *Repository) GetLatestSnapshot(ctx context.Context, roomID string) (roomservice.SnapshotRecord, bool, error) {
// room_snapshots 主键是 room_id因此查询结果最多一条。
row := r.db.QueryRowContext(ctx,
`SELECT app_code, room_id, room_version, payload, created_at_ms
FROM room_snapshots
WHERE app_code = ? AND room_id = ?`,
appcode.FromContext(ctx),
roomID,
)
var snapshot roomservice.SnapshotRecord
if err := row.Scan(&snapshot.AppCode, &snapshot.RoomID, &snapshot.RoomVersion, &snapshot.Payload, &snapshot.CreatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 没有快照时上层会从 RoomMeta 和 command log 从头恢复。
return roomservice.SnapshotRecord{}, false, nil
}
return roomservice.SnapshotRecord{}, false, err
}
return snapshot, true, nil
}
// SaveOutbox 追加待投递 outbox 事件。
func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) error {
if len(records) == 0 {
// 没有事件时保持无副作用,方便领域层统一调用。
return nil
}
// 多条事件用一个事务写入,保证同一命令产生的事件不会部分入库。
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
for _, record := range records {
record.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
if record.Envelope != nil {
record.Envelope.AppCode = record.AppCode
}
// envelope 是 protobuf 信封,存储层不解析具体事件 body。
envelopeBytes, err := proto.Marshal(record.Envelope)
if err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE event_id = event_id`,
record.AppCode,
record.EventID,
record.EventType,
record.RoomID,
record.Status,
envelopeBytes,
record.CreatedAtMS,
record.RetryCount,
record.NextRetryAtMS,
nullableString(record.LastError),
record.CreatedAtMS,
); err != nil {
// 任一事件失败就回滚整批,避免 GiftSent/HeatChanged/RankChanged 部分缺失。
_ = tx.Rollback()
return err
}
}
// commit 成功后事件才对 outbox worker 可见。
return tx.Commit()
}
// ListPendingOutbox 返回待投递事件。
func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error) {
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 {
// 失败转入 retryableworker 只有到 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 firstPositiveInt64(values ...int64) int64 {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func uniquePositiveInt64(values []int64) []int64 {
seen := make(map[int64]bool, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
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")
}