hyapp-server/docs/语音房区域房间列表架构.md
2026-05-14 00:25:02 +08:00

497 lines
19 KiB
Markdown
Raw Permalink 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.

# 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` 生成 `<app_code>_<uuid>` 作为房间长 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` | 对外房间长 IDgateway 按 `<app_code>_<uuid>` 生成 |
| `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 故障不影响创建房间、进入房间和房间状态提交。