hyapp-server/docs/语音房区域房间列表架构.md

19 KiB
Raw Permalink Blame History

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

同一区域内的国家看到相同列表;不同区域看到不同列表。

用户无区域归属时进入默认列表桶:

region_id = 0
region_code = GLOBAL

GLOBAL 不是地理区域只是兜底列表桶。后续如果产品要求“未选国家不能看列表”gateway 可以在入口直接拒绝,不需要改 room-service 列表模型。

Service Boundary

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-serviceroom-service 时必须透传同一个 RequestMeta.app_coderoom-service 的房间、列表、快照、命令日志和 outbox 查询都必须带 app_code
  • Room Cell 不保存区域列表,不扫描全局房间。
  • 腾讯云 IM 只负责实时消息,不参与房间列表排序和区域可见性。

Request Flow

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}

列表只是发现页。用户点击房间后仍然走:

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=activeexpires_at_ms > now_ms 的记录,排序优先级为:

  1. 有效区域置顶在普通房间之前。
  2. 置顶房间按 weight DESC, expires_at_ms DESC, room_id ASC
  3. 普通房间继续按 hotsort_score DESCnewcreated_at_ms DESC

置顶不进入 Room Cell、command log 或 snapshot它是房间列表读模型的运营控制面。房间改区域后旧区域置顶不会自动跨区域生效后台需要在新区域重新建立置顶。

Room Region Ownership

CreateRoom

创建房间时绑定 visible_region_id

推荐链路:

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_idgateway 按当前 app_code 生成 <app_code>_<uuid> 作为房间长 ID。
  • room_short_id 首版直接等于创建者当前 display_user_id,用于搜索、分享和展示。
  • 同一个登录用户只能作为 owner 创建一个房间room-service 按 RequestMeta.actor_user_id 判定 owner不能通过更换 room_id 创建第二个房间。
  • 客户端必须提交 room_nameroom_avatarroom_description 只进入 RoomExt
  • visible_region_id 来自创建者当前 users.region_id
  • 创建者没有区域时写 0,进入 GLOBAL 列表桶。
  • 房间创建成功后,创建者改国家不自动修改房间区域。
  • 用户 users.region_id 允许变化的来源见 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 是房间恢复和基础元数据表。建议增加:

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

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

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。
  • JoinRoomLeaveRoomSendGift、麦位变化更新 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 计算:

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

GET /api/v1/rooms?tab=hot&cursor=&limit=20
GET /api/v1/rooms?tab=new&cursor=&limit=20

响应继续使用 gateway envelope

{
  "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

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 使用 int640 表示 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_countsort_score
LeaveRoom 移除 presence、释放麦位 更新 online_countoccupied_seat_count
MicUp 占用麦位 更新 occupied_seat_count
MicDown 释放麦位 更新 occupied_seat_count
TransferRoomHost 修改 host 更新 host_user_id
SendGift 更新 heat、rank 更新 heatsort_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 成为唯一数据源。

点击列表进入房间时必须再次校验:

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/listroom/query 包,封装列表 repository 和 service。
  6. gateway 新增 GET /api/v1/rooms,先查 user-service再调 room-service。

Phase 2: Projection Updates

  1. CreateRoom 插入列表投影。
  2. JoinRoomLeaveRoom 更新在线人数。
  3. MicUpMicDown 更新麦位占用。
  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
SGMY 属于同一区域 两个用户看到相同列表
SGSA 属于不同区域 两个用户看到不同列表
用户无区域 进入 GLOBAL 列表
客户端传伪造区域 gateway 忽略客户端区域,只用 user-service 返回值
创建房间 rooms.visible_region_idroom_list_entries
房主改国家 已创建房间列表区域不变
Join/Leave 列表在线人数最终更新
区域置顶 置顶房间排在同区域普通房间前,翻页不重复
SendGift 热度和排序分更新
Redis miss MySQL 回源成功
列表房间已关闭 JoinRoom 拒绝,客户端刷新

Acceptance Criteria

  • App 房间列表只展示当前用户 region_id 对应房间。
  • 同一区域内不同国家用户看到同一套列表。
  • 不同区域用户不会看到彼此区域房间。
  • 房间列表查询不访问 Room Cell 内存集合。
  • Room Cell 状态变更仍只走命令流水线。
  • MySQL 可独立恢复完整房间列表读模型。
  • Redis 故障不影响创建房间、进入房间和房间状态提交。