hyapp-server/docs/voice-room-region-room-list-architecture.md
2026-04-30 02:30:32 +08:00

443 lines
14 KiB
Markdown
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.

# Voice Room Region Room List Architecture
本文档定义 App 房间列表的区域化架构。目标是让同一区域内的国家看到同一套房间列表,不同区域看到不同列表,同时不破坏现有 `Room Cell` 单房间状态 owner 模型。
## Scope
| Capability | Owner | Required Result |
| --- | --- | --- |
| 国家和区域主数据 | `user-service` | 国家归属区域由服务端配置和计算 |
| 用户区域归属 | `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
```
边界规则:
- `user-service` 是国家和区域归属的唯一 owner。
- `gateway-service` 只做鉴权、用户区域解析和协议转换。
- `room-service` 是房间列表和房间状态的 owner。
- `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 or Redis ZSET
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 守卫决定。
## 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
G->>U: GetUser(actor_user_id)
U-->>G: region_id
G->>R: CreateRoom(visible_region_id=region_id)
R->>R: Create Room Cell + room_list_entries
R-->>G: RoomSnapshot
G-->>C: room
```
约束:
- 客户端不能提交 `visible_region_id`
- `visible_region_id` 来自创建者当前 `users.region_id`
- 创建者没有区域时写 `0`,进入 `GLOBAL` 列表桶。
- 房间创建成功后,创建者改国家不自动修改房间区域。
- 管理端迁移房间区域必须走独立命令,例如 `ChangeRoomVisibleRegion`,并写审计。
### Why Not Dynamic By Owner Region
不建议每次列表查询都按房主当前 `region_id` 过滤:
- 房主改国家会导致直播中房间突然从原区域消失。
- 列表查询会被迫 join user-service 或用户表,破坏 room-service 的读模型独立性。
- 历史房间的区域口径会随用户资料变化而漂移,不利于运营审计。
所以房间列表区域应该是房间属性,不是房主资料的实时派生字段。
## Data Model
### `rooms`
`rooms` 是房间恢复和基础元数据表。建议增加:
```sql
ALTER TABLE rooms
ADD COLUMN visible_region_id BIGINT NOT NULL DEFAULT 0 AFTER status,
ADD KEY idx_rooms_region_status (visible_region_id, status);
```
字段语义:
| Field | Rule |
| --- | --- |
| `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 (
room_id VARCHAR(64) NOT NULL PRIMARY KEY,
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,
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,
KEY idx_room_list_region_hot (visible_region_id, status, sort_score DESC, room_id),
KEY idx_room_list_region_new (visible_region_id, status, created_at_ms DESC, room_id),
KEY idx_room_list_owner (owner_user_id, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
读模型字段只保存列表卡片需要的内容,不保存完整房间状态:
| Field | Source | Update Trigger |
| --- | --- | --- |
| `room_id` | CreateRoom | 创建房间 |
| `visible_region_id` | creator `region_id` | 创建房间或管理端迁移 |
| `owner_user_id` | RoomMeta | 创建房间 |
| `host_user_id` | RoomState | 创建房间、TransferRoomHost |
| `mode` | RoomMeta/RoomState | 创建房间或模式变更 |
| `status` | RoomState | 创建、关闭、封禁、恢复 |
| `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": [],
"next_cursor": ""
}
}
```
请求规则:
- `tab` 默认 `hot`
- `limit` 默认 `20`,最大 `50`
- `cursor` 使用不透明字符串,客户端不能解析或拼装。
- gateway 从 access token 得到 `user_id`,再查 user-service 获取 `region_id`
- 如果 user-service 查询失败gateway 应返回错误,不应该用 IP 国家临时兜底。
### 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;
}
message ListRoomsResponse {
repeated RoomListItem rooms = 1;
string next_cursor = 2;
}
```
兼容规则:
- 新增 proto 字段只能追加,不能复用字段号。
- `visible_region_id` 使用 `int64``0` 表示 `GLOBAL`
- 不要把 `country` 传给 room-service 列表接口;国家到区域的映射不属于 room-service。
## 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 内存。
## 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` 拒绝,客户端刷新列表 |
| 区域配置变更 | 影响新用户和用户重算;已有房间不自动迁移 |
## Implementation Plan
### Phase 1: MySQL Read Model
1. `room.proto` 新增 `RoomQueryService.ListRooms` 和列表消息。
2. `CreateRoomRequest` 增加 `visible_region_id`,由 gateway 填充。
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. 支持按区域隐藏、封禁或置顶房间。
## 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 故障不影响创建房间、进入房间和房间状态提交。