# Voice Room Region Room List Architecture

本文档定义 App 房间列表的区域化架构。默认 App 仍按用户区域隔离；Huwaa 首页默认读取全部区域，并允许客户端使用服务端目录下发的 active `region_id` 或 enabled `country_code` 做单一筛选。这些列表范围不改变现有 `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` | 按默认区域、All、指定区域或指定国家查询列表卡片 |
| HTTP 列表入口 | `gateway-service` | 鉴权后解析 App 策略，并用 active 区域目录校验客户筛选 |
| 实时进房校验 | `room-service` | 列表只负责发现，进入房间仍以 `JoinRoom` 为准 |

本阶段不做：

| Excluded | Reason |
| --- | --- |
| 推荐系统 | 首版先实现区域隔离和稳定排序，不做个性化推荐 |
| 非 Huwaa App 客户端提交 `region_id` | 这些 App 仍按用户国家投影的服务端区域隔离 |
| Huwaa 客户端提交任意区域/国家值 | 只接受 `/api/v1/rooms/filters` 中 active 区域和 enabled 国家的字段值 |
| IP 国家决定房间列表 | `country_by_ip` 只用于审计和风控辅助，不代表用户选择国家 |
| 把列表塞进 Room Cell | Room Cell 是单房间高频状态 owner，不承担多房间列表查询 |
| 创建者改国家自动迁移房间 | 房间可见区域是创建时确定的业务属性，避免直播中列表突然漂移 |

## Core Rule

非 Huwaa 房间列表默认按 `region_id` 隔离，而不是按 `country` 隔离。Huwaa All 会合并所有区域；普通房在置顶之后固定排成“本国家有人 → 其他国家/区域有人 → 本国家没人 → 其他国家/区域没人”。

例如：

| 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[&region_id|country_code]
  G->>U: GetUser(user_id)
  U-->>G: region_id
  G->>R: ListRooms(viewer_region_id, all/filter_region_id/country_code, 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` 的区域置顶和已选国家置顶。Huwaa All 会合并所有区域，但每个区域/国家置顶仍只能命中房间自身 `visible_region_id` 和房主国家，不会把某区置顶扩散到其他区域。`room_region_pins` 查询只使用 `status=active` 且 `expires_at_ms > now_ms` 的记录，排序优先级为：

1. 同一房间同时命中多种置顶时固定选 `global > region > country`，次级类型不能用更高 weight 反超。
2. 有效全区/区域置顶在国家置顶和普通房间之前。
3. 选定置顶类型后，房间按 `weight DESC, expires_at_ms DESC, room_id ASC`。
4. 普通房间继续按本国家/在线桶及 `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_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 按 `<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
GET /api/v1/rooms?tab=hot&region_id=2002
GET /api/v1/rooms?tab=hot&country_code=BD
```

响应继续使用 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`。
- Huwaa 不传 `region_id/country_code` 表示 All；两者互斥，且只能回传 `/api/v1/rooms/filters` 的字段值。
- 显式 `region_id` 写入不透明 cursor，不允许把 A 区游标复用到 B 区。
- 如果 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;
  string query = 7;
  string country_code = 8;
  string viewer_country_code = 9;
  bool all_visible_regions = 10;
  int64 filter_region_id = 11;
}

message RoomListItem {
  string room_id = 1;
  int64 owner_user_id = 2;
  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;
  string country_code = 16;
}

message ListRoomsResponse {
  repeated RoomListItem rooms = 1;
  string next_cursor = 2;
}
```

兼容规则：

- 新增 proto 字段只能追加，不能复用字段号。
- `visible_region_id` 使用 `int64`，`0` 表示 `GLOBAL`。
- 客户端原始 `country/country_code` 不能直传 room-service；gateway 先校验 enabled 国家与 active 区域映射，再传规范化的 `country_code` 给列表读模型。
- `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

房间列表采用最终一致。

允许：

- 列表在线人数短暂落后。
- 房间关闭后短时间仍出现在列表。
- 热度和排序秒级延迟。

不允许：

- 非 Huwaa/无 All 权限的用户看到其他区域专属列表。
- Huwaa 客户端传入不在 `/api/v1/rooms/filters` active 目录中的 `region_id`，或同时传 `region_id/country_code`。
- 列表查询直接访问 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 故障不影响创建房间、进入房间和房间状态提交。
