# Voice Room Region Room List Architecture 本文档定义 App 房间列表的区域化架构。目标是让同一区域内的国家看到同一套房间列表,不同区域看到不同列表,同时不破坏现有 `Room Cell` 单房间状态 owner 模型。 本文的区域隔离始终发生在单个 `app_code` 内。不同 App 即使使用相同国家、区域和 `room_id`,房间列表也必须互相不可见。 ## Scope | Capability | Owner | Required Result | | --- | --- | --- | | 国家和区域主数据存储 | user DB | 国家归属区域由服务端配置和计算 | | 后台国家/区域管理 | `hyapp-admin-server` | 创建国家、启用/禁用国家、创建区域、启用/禁用区域属于后台管理后端能力 | | App 国家/区域解析 | `user-service` | App 注册、改国家和用户投影只读 enabled 国家与 active 区域映射 | | 用户区域归属 | `user-service` | 注册和改国家后写入 `users.region_id` | | 房间可见区域 | `room-service` | 房间创建时绑定 `visible_region_id` | | 房间列表读模型 | `room-service` | 按 `visible_region_id` 查询列表卡片 | | HTTP 列表入口 | `gateway-service` | 鉴权后解析用户区域并调用 room-service | | 实时进房校验 | `room-service` | 列表只负责发现,进入房间仍以 `JoinRoom` 为准 | 本阶段不做: | Excluded | Reason | | --- | --- | | 推荐系统 | 首版先实现区域隔离和稳定排序,不做个性化推荐 | | 跨区域混排 | 产品语义要求不同区域列表隔离 | | 客户端提交 `region_id` | 区域必须由服务端按用户国家计算,避免伪造 | | IP 国家决定房间列表 | `country_by_ip` 只用于审计和风控辅助,不代表用户选择国家 | | 把列表塞进 Room Cell | Room Cell 是单房间高频状态 owner,不承担多房间列表查询 | | 创建者改国家自动迁移房间 | 房间可见区域是创建时确定的业务属性,避免直播中列表突然漂移 | ## Core Rule 房间列表按 `region_id` 隔离,而不是按 `country` 隔离。 例如: | Country | Region | Visible List | | --- | --- | --- | | `SG` | `SEA` | `room:list:region:SEA` | | `MY` | `SEA` | `room:list:region:SEA` | | `SA` | `MENA` | `room:list:region:MENA` | 同一区域内的国家看到相同列表;不同区域看到不同列表。 用户无区域归属时进入默认列表桶: ```text region_id = 0 region_code = GLOBAL ``` `GLOBAL` 不是地理区域,只是兜底列表桶。后续如果产品要求“未选国家不能看列表”,gateway 可以在入口直接拒绝,不需要改 room-service 列表模型。 ## Service Boundary ```mermaid flowchart LR C["Client"] --> G["gateway-service"] G --> U["user-service"] G --> R["room-service"] R --> DB["MySQL"] R --> Redis["Redis"] R --> T["Tencent Cloud IM"] U -. "country -> region_id" .-> G G -. "ListRooms(region_id)" .-> R R -. "room list projection" .-> DB R -. "hot/new sorted cache" .-> Redis R -. "system room events" .-> T ``` 边界规则: - `hyapp-admin-server` 是后台创建/启停国家和区域的 owner,不为这些后台动作新增 user-service RPC。 - `user-service` 是 App 侧国家校验、区域解析和用户 `region_id` 投影的 owner。 - `gateway-service` 只做鉴权、用户区域解析和协议转换。 - `room-service` 是房间列表和房间状态的 owner。 - gateway 调 `user-service` 和 `room-service` 时必须透传同一个 `RequestMeta.app_code`;room-service 的房间、列表、快照、命令日志和 outbox 查询都必须带 `app_code`。 - `Room Cell` 不保存区域列表,不扫描全局房间。 - 腾讯云 IM 只负责实时消息,不参与房间列表排序和区域可见性。 ## Request Flow ```mermaid sequenceDiagram participant C as Client participant G as gateway-service participant U as user-service participant R as room-service participant S as MySQL/Redis C->>G: GET /api/v1/rooms?tab=hot&limit=20 G->>U: GetUser(user_id) U-->>G: region_id G->>R: ListRooms(region_id, tab, cursor, limit) R->>S: Read room_list_entries + active room_region_pins S-->>R: RoomListItem[] R-->>G: ListRoomsResponse G-->>C: {code,message,request_id,data} ``` 列表只是发现页。用户点击房间后仍然走: ```text gateway -> room-service.JoinRoom -> Tencent IM join group callback guard ``` 因此列表允许秒级最终一致;真正能否进入房间由 `JoinRoom` 和 IM 守卫决定。 区域置顶只影响当前 `visible_region_id` 的公共发现列表排序。`room_region_pins` 保存 `app_code + visible_region_id + room_id` 的运营置顶关系;查询时只 join `status=active` 且 `expires_at_ms > now_ms` 的记录,排序优先级为: 1. 有效区域置顶在普通房间之前。 2. 置顶房间按 `weight DESC, expires_at_ms DESC, room_id ASC`。 3. 普通房间继续按 `hot` 的 `sort_score DESC` 或 `new` 的 `created_at_ms DESC`。 置顶不进入 Room Cell、command log 或 snapshot;它是房间列表读模型的运营控制面。房间改区域后,旧区域置顶不会自动跨区域生效,后台需要在新区域重新建立置顶。 ## Room Region Ownership ### CreateRoom 创建房间时绑定 `visible_region_id`。 推荐链路: ```mermaid sequenceDiagram participant C as Client participant G as gateway-service participant U as user-service participant R as room-service C->>G: POST /api/v1/rooms/create(room_name, room_avatar?, room_description?) G->>U: GetUser(actor_user_id) U-->>G: region_id, display_user_id G->>G: generate room_id = app_code + uuid G->>R: CreateRoom(room_id, room_short_id=display_user_id, visible_region_id=region_id, room profile) R->>R: Create Room Cell + room_list_entries R-->>G: RoomSnapshot G-->>C: room ``` 约束: - 客户端不能提交 `visible_region_id`。 - 客户端不能提交 `room_id`;gateway 按当前 `app_code` 生成 `_` 作为房间长 ID。 - `room_short_id` 首版直接等于创建者当前 `display_user_id`,用于搜索、分享和展示。 - 同一个登录用户只能作为 owner 创建一个房间;room-service 按 `RequestMeta.actor_user_id` 判定 owner,不能通过更换 `room_id` 创建第二个房间。 - 客户端必须提交 `room_name`;`room_avatar` 为空时由 room-service 写默认系统头像,`room_description` 只进入 `RoomExt`。 - `visible_region_id` 来自创建者当前 `users.region_id`。 - 创建者没有区域时写 `0`,进入 `GLOBAL` 列表桶。 - 房间创建成功后,创建者改国家不自动修改房间区域。 - 用户 `users.region_id` 允许变化的来源见 [User Region Change Sources](./用户区域与国家开发.md#user-region-change-sources)。无论是用户改国家、区域映射 rebuild,还是后台创建 BD Leader 导致用户区域变化,都不自动迁移已创建房间的 `visible_region_id`。 - 管理端迁移房间区域必须走独立命令,例如 `ChangeRoomVisibleRegion`,并写审计。 ### Why Not Dynamic By Owner Region 不建议每次列表查询都按房主当前 `region_id` 过滤: - 房主改国家会导致直播中房间突然从原区域消失。 - 列表查询会被迫 join user-service 或用户表,破坏 room-service 的读模型独立性。 - 历史房间的区域口径会随用户资料变化而漂移,不利于运营审计。 所以房间列表区域应该是房间属性,不是房主资料的实时派生字段。 ## Data Model ### `rooms` `rooms` 是房间恢复和基础元数据表。建议增加: ```sql ALTER TABLE rooms ADD COLUMN room_short_id VARCHAR(32) NOT NULL DEFAULT '' AFTER room_id, ADD COLUMN visible_region_id BIGINT NOT NULL DEFAULT 0 AFTER status, ADD UNIQUE KEY uk_rooms_short_id (app_code, room_short_id), ADD KEY idx_rooms_region_status (visible_region_id, status); ``` 字段语义: | Field | Rule | | --- | --- | | `room_id` | 对外房间长 ID,gateway 按 `_` 生成 | | `room_short_id` | 房间短 ID,首版等于创建者当前 `display_user_id` | | `visible_region_id` | 房间列表可见区域,`0` 表示 `GLOBAL` | | `status` | 房间生命周期状态,列表只展示 active/live 类状态 | ### `room_list_entries` `room_list_entries` 是房间列表读模型,避免列表查询反序列化 `room_snapshots`。 ```sql CREATE TABLE IF NOT EXISTS room_list_entries ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', room_id VARCHAR(64) NOT NULL, room_short_id VARCHAR(32) NOT NULL DEFAULT '', visible_region_id BIGINT NOT NULL DEFAULT 0, owner_user_id BIGINT NOT NULL, host_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, 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_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_owner (app_code, owner_user_id, updated_at_ms), KEY idx_room_list_short_id (app_code, room_short_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` 读模型字段只保存列表卡片需要的内容,不保存完整房间状态: | Field | Source | Update Trigger | | --- | --- | --- | | `room_id` | CreateRoom | 创建房间 | | `room_short_id` | owner `display_user_id` | 创建房间 | | `visible_region_id` | creator `region_id` | 创建房间或管理端迁移 | | `owner_user_id` | RoomMeta | 创建房间 | | `host_user_id` | RoomState | 创建房间、TransferRoomHost | | `title` | `RoomState.RoomExt["title"]` | 创建房间 | | `cover_url` | `RoomState.RoomExt["cover_url"]` | 创建房间 | | `mode` | RoomMeta/RoomState | 创建房间或模式变更 | | `status` | RoomState | 创建、关闭、封禁、恢复 | | `locked` | `RoomState.RoomPasswordHash != ""` | SetRoomPassword | | `heat` | RoomState | SendGift、热度刷新 | | `online_count` | RoomState presence | JoinRoom、LeaveRoom、stale cleanup | | `occupied_seat_count` | RoomState mic seats | MicUp、MicDown、ChangeMicSeat | | `sort_score` | room-service ranking policy | 任意影响排序的事件 | ## Redis Cache MySQL 是事实来源,Redis 是列表排序和卡片缓存。 推荐 key: ```text room:list:{visible_region_id}:hot -> ZSET(room_id, sort_score) room:list:{visible_region_id}:new -> ZSET(room_id, created_at_ms) room:card:{room_id} -> HASH/JSON RoomListItem ``` 缓存规则: - `CreateRoom` 写 MySQL 后写 Redis。 - `JoinRoom`、`LeaveRoom`、`SendGift`、麦位变化更新 MySQL 投影,再更新 Redis。 - Redis 写失败不回滚 Room Cell 命令。 - Redis miss 时从 MySQL 回源并修复缓存。 - Redis 中的列表成员必须定期清理非 active 房间,避免关闭房间长期残留。 首版可以先只做 MySQL 查询,等列表 QPS 上来再加 Redis ZSET。不要一开始为了缓存牺牲正确性。 ## Sort Policy 首版只支持两个 tab: | Tab | Sort | Use Case | | --- | --- | --- | | `hot` | `sort_score DESC, room_id ASC` | 默认热门列表 | | `new` | `created_at_ms DESC, room_id ASC` | 最新开播列表 | `sort_score` 建议由 room-service 计算: ```text sort_score = heat * 1000 + online_count * 100 + occupied_seat_count * 10 + freshness_boost ``` 约束: - 排序公式只影响列表展示,不进入 RoomState。 - 排序公式调整不需要回放 Room Cell command log。 - 需要大规模重排时使用后台 rebuild job 更新 `room_list_entries.sort_score`。 ## API Contract ### HTTP ```text GET /api/v1/rooms?tab=hot&cursor=&limit=20 GET /api/v1/rooms?tab=new&cursor=&limit=20 ``` 响应继续使用 gateway envelope: ```json { "code": "OK", "message": "ok", "request_id": "req_xxx", "data": { "rooms": [ { "room_id": "lalu_room_xxx", "im_group_id": "lalu_room_xxx", "title": "Live Room", "cover_url": "https://cdn.example/room.png", "mode": "voice", "status": "active", "heat": 1000, "online_count": 26, "seat_count": 15, "occupied_seat_count": 2 } ], "next_cursor": "" } } ``` 请求规则: - `tab` 默认 `hot`。 - `limit` 默认 `20`,最大 `50`。 - `cursor` 使用不透明字符串,客户端不能解析或拼装。 - gateway 从 access token 得到 `user_id`,再查 user-service 获取 `region_id`。 - 如果 user-service 查询失败,gateway 应返回错误,不应该用 IP 国家临时兜底。 - `im_group_id` 当前等于 `room_id`,只用于客户端准备腾讯 IM 房间群;真正入群必须等 `JoinRoom` 成功后由 IM 回调守卫校验 presence。 ### gRPC 建议新增查询服务,不混入 `RoomCommandService`: ```proto service RoomQueryService { rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse); } message ListRoomsRequest { RequestMeta meta = 1; int64 viewer_user_id = 2; int64 visible_region_id = 3; string tab = 4; string cursor = 5; int32 limit = 6; } message RoomListItem { string room_id = 1; int64 owner_user_id = 2; int64 host_user_id = 3; string title = 4; string cover_url = 5; string mode = 6; string status = 7; int64 heat = 8; int32 online_count = 9; int32 seat_count = 10; int32 occupied_seat_count = 11; int64 visible_region_id = 12; string app_code = 13; string room_short_id = 14; bool locked = 15; } message ListRoomsResponse { repeated RoomListItem rooms = 1; string next_cursor = 2; } ``` 兼容规则: - 新增 proto 字段只能追加,不能复用字段号。 - `visible_region_id` 使用 `int64`,`0` 表示 `GLOBAL`。 - 不要把 `country` 传给 room-service 列表接口;国家到区域的映射不属于 room-service。 - `app_code` 只从 `RequestMeta` 来;客户端不能在列表 query/body 里自报另一个 App。 ## Write Path Updates 房间命令成功后要同步更新读模型。读模型失败不能破坏 Room Cell 已提交状态,但必须可补偿。 | Command | Room Cell | List Projection | | --- | --- | --- | | `CreateRoom` | 创建状态、快照、outbox | 插入 `room_list_entries` | | `JoinRoom` | 增加或刷新 presence | 更新 `online_count`、`sort_score` | | `LeaveRoom` | 移除 presence、释放麦位 | 更新 `online_count`、`occupied_seat_count` | | `MicUp` | 占用麦位 | 更新 `occupied_seat_count` | | `MicDown` | 释放麦位 | 更新 `occupied_seat_count` | | `TransferRoomHost` | 修改 host | 更新 `host_user_id` | | `SendGift` | 更新 heat、rank | 更新 `heat`、`sort_score` | | `CloseRoom` | 修改状态 | 从列表隐藏或标记非 active | 推荐实现: - Room Cell 命令仍然是状态变更入口。 - 命令成功提交后调用 `RoomListProjector` 更新 MySQL 投影。 - 投影更新失败时写 `room_outbox` 或独立 `room_projection_tasks` 等待补偿。 - 列表查询只读 `room_list_entries`,不直接读取 Room Cell 内存。 - 公共列表和带 `query` 的搜索列表都从同一投影读取 `locked`;它只表达是否需要入房密码,不携带密码明文或哈希。 ## Consistency Model 房间列表采用最终一致。 允许: - 列表在线人数短暂落后。 - 房间关闭后短时间仍出现在列表。 - 热度和排序秒级延迟。 不允许: - 用户看到其他区域专属列表。 - 客户端通过伪造 `region_id` 越权获取列表。 - 列表查询直接访问 Room Cell 全量内存。 - Redis 成为唯一数据源。 点击列表进入房间时必须再次校验: ```text JoinRoom(room_id, user_id) ``` 如果房间已关闭、用户被封禁或区域策略后续收紧,`JoinRoom` 返回拒绝,客户端刷新列表。 ## Failure Handling | Failure | Handling | | --- | --- | | user-service GetUser 失败 | gateway 返回错误,不用 IP 国家兜底 | | 用户 `region_id` 为空 | 使用 `GLOBAL` 列表桶,除非产品要求强制选国家 | | room-service Redis miss | 回源 MySQL 并修复缓存 | | Redis 写失败 | 不回滚房间命令,记录日志或补偿任务 | | MySQL 投影写失败 | 房间命令已提交时必须进入补偿队列 | | 列表出现关闭房间 | `JoinRoom` 拒绝,客户端刷新列表 | | 用户区域变化 | 包括用户改国家、区域配置 rebuild、后台创建 BD Leader 迁移用户区域;已有房间不自动迁移 | | 区域配置变更 | 影响新用户和用户重算;已有房间不自动迁移 | ## Implementation Plan ### Phase 1: MySQL Read Model 1. `room.proto` 新增 `RoomQueryService.ListRooms` 和列表消息。 2. `CreateRoomRequest` 包含 `visible_region_id` 和房间展示资料;区域由 gateway 填充,`room_name` 由客户端必填。 3. `rooms` 增加 `visible_region_id`。 4. 新增 `room_list_entries` 表。 5. room-service 新增 `room/list` 或 `room/query` 包,封装列表 repository 和 service。 6. gateway 新增 `GET /api/v1/rooms`,先查 user-service,再调 room-service。 ### Phase 2: Projection Updates 1. `CreateRoom` 插入列表投影。 2. `JoinRoom`、`LeaveRoom` 更新在线人数。 3. `MicUp`、`MicDown` 更新麦位占用。 4. `SendGift` 更新热度和排序分。 5. 增加投影补偿任务,避免命令成功但列表长期不更新。 ### Phase 3: Redis Acceleration 1. 为 `hot/new` 增加 Redis ZSET。 2. 列表查询优先读 Redis room_id,再批量读 `room:card`。 3. Redis miss 或卡片缺失时回源 MySQL。 4. 增加后台 reconcile job,把 MySQL 投影定期修复到 Redis。 ### Phase 4: Admin Controls 1. 新增管理端房间区域迁移命令。 2. 新增区域迁移审计表。 3. 支持按区域隐藏、封禁或置顶房间;置顶由 `room_region_pins` 按区域独立维护。 ## Tests 必须覆盖: | Case | Expected | | --- | --- | | `SG` 和 `MY` 属于同一区域 | 两个用户看到相同列表 | | `SG` 和 `SA` 属于不同区域 | 两个用户看到不同列表 | | 用户无区域 | 进入 `GLOBAL` 列表 | | 客户端传伪造区域 | gateway 忽略客户端区域,只用 user-service 返回值 | | 创建房间 | 写 `rooms.visible_region_id` 和 `room_list_entries` | | 房主改国家 | 已创建房间列表区域不变 | | Join/Leave | 列表在线人数最终更新 | | 区域置顶 | 置顶房间排在同区域普通房间前,翻页不重复 | | SendGift | 热度和排序分更新 | | Redis miss | MySQL 回源成功 | | 列表房间已关闭 | `JoinRoom` 拒绝,客户端刷新 | ## Acceptance Criteria - App 房间列表只展示当前用户 `region_id` 对应房间。 - 同一区域内不同国家用户看到同一套列表。 - 不同区域用户不会看到彼此区域房间。 - 房间列表查询不访问 Room Cell 内存集合。 - Room Cell 状态变更仍只走命令流水线。 - MySQL 可独立恢复完整房间列表读模型。 - Redis 故障不影响创建房间、进入房间和房间状态提交。