hyapp-server/docs/语音房房间用户列表后端技术架构设计.md
2026-05-27 18:50:40 +08:00

594 lines
16 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.

# 语音房房间用户列表后端技术架构设计
## 1. 背景
当前 `room-service` 已有基础能力:
- `GET /api/v1/rooms/{room_id}/online-users` 查询在线用户。
- `POST /api/v1/rooms/admin/set` 设置或取消管理员。
- `RoomAdminChanged` outbox 事件和腾讯云 IM `room_admin_changed` 消息。
- `RoomGiftSent` outbox 事件和腾讯云 IM `room_gift_sent` 消息。
现有在线用户列表只返回 presence 信息,不包含:
- 用户在当前房间内累计送出的礼物价值。
- 用户针对当前房间的管理身份:`normal``admin`
## 2. 目标
本次后端开发目标:
1. 在线用户列表返回所有当前在房间内的用户。
2. 每个用户返回当前房间内累计送礼价值 `gift_value`
3. 每个用户返回是否为房主 `is_owner`
4. 每个用户返回当前房间管理员身份 `room_role`,只允许 `normal``admin`
5. 复用同一个接口完成设置管理员和取消管理员。
6. 管理员变更和送礼成功继续通过 IM 通知客户端刷新。
7. 在线用户列表聚合用户性别 `gender` 和年龄 `age`,但不暴露生日 `birth`
## 3. 非目标
本次不做:
- 历史房间贡献榜。
- 日榜、周榜、月榜。
- 离线预设管理员。
- 平台后台管理员权限。
- 客户端上报价格参与礼物价值计算。
- 使用 `GiftRank` 作为完整用户礼物价值数据源。
## 4. 现有代码基础
### 4.1 Proto
文件:
```text
api/proto/room/v1/room.proto
```
现有 RPC
```proto
rpc ListRoomOnlineUsers(ListRoomOnlineUsersRequest) returns (ListRoomOnlineUsersResponse);
rpc SetRoomAdmin(SetRoomAdminRequest) returns (SetRoomAdminResponse);
```
现有 `RoomUser.role` 已用于 presence 角色,不能改成 `normal/admin`
### 4.2 房间状态
文件:
```text
services/room-service/internal/room/state/state.go
```
当前 `RoomState` 已包含:
| 字段 | 说明 |
| --- | --- |
| `OnlineUsers` | 当前房间业务在线用户 |
| `AdminUsers` | 当前房间管理员集合 |
| `GiftRank` | 房间礼物榜 Top N |
注意:`GiftRank``rank_limit` 限制,不能覆盖所有用户。
### 4.3 设置管理员
文件:
```text
services/room-service/internal/room/service/management.go
```
`SetRoomAdmin` 需要满足以下行为:
- 只有房主可操作。
- 目标用户必须在当前房间内。
- 房主不是管理员,不允许对房主执行设置或取消管理员。
- 管理员之间不可互相操作。
- 重复设置同一状态是 no-op。
- 状态变化时生成 `RoomAdminChanged` outbox 事件。
- IM 事件类型为 `room_admin_changed`
这部分服务逻辑可以复用,主要补 HTTP DTO 和文档约束。
### 4.4 送礼事件
文件:
```text
services/room-service/internal/room/service/gift.go
```
当前 `SendGift` 成功后:
- 使用 wallet-service 结算结果。
- 使用 `heatValue` 增加房间热度。
- 使用 `heatValue` 更新本地礼物榜。
- 生成 `RoomGiftSent``RoomHeatChanged``RoomRankChanged` outbox 事件。
- 对客户端只投递 `room_gift_sent` IM。
新增 `gift_value` 必须使用同一个 `heatValue` 口径。
## 5. 数据模型设计
### 5.1 在线来源
在线用户来源保持不变:
```text
room_user_presence
```
判断条件:
```text
app_code = ?
room_id = ?
status = active
```
### 5.2 礼物价值统计表
新增完整房间用户送礼统计表:
```sql
CREATE TABLE room_user_gift_stats (
app_code VARCHAR(64) NOT NULL,
room_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
gift_value BIGINT NOT NULL DEFAULT 0,
last_gift_at_ms BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id, user_id),
KEY idx_room_user_gift_stats_room_value (
app_code,
room_id,
gift_value DESC,
last_gift_at_ms DESC,
user_id
)
);
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `app_code` | 应用编码 |
| `room_id` | 房间 ID |
| `user_id` | 送礼用户 ID |
| `gift_value` | 当前房间生命周期内累计送礼价值 |
| `last_gift_at_ms` | 最近一次成功送礼时间 |
| `created_at_ms` | 创建时间 |
| `updated_at_ms` | 更新时间 |
### 5.3 为什么不复用 `GiftRank`
`GiftRank` 是榜单结构,不是完整统计结构:
-`rank_limit` 限制,默认只保留 Top N。
- 不包含送礼价值为 `0` 的用户。
- 在线用户超过 Top N 时无法返回完整 `gift_value`
所以在线用户列表必须读取完整统计表,并且对无统计记录的在线用户返回 `gift_value = 0`
## 6. Proto 设计
新增在线用户展示结构,不改原 `RoomUser` 语义:
```proto
message RoomOnlineUser {
int64 user_id = 1;
string role = 2;
string room_role = 3;
int64 gift_value = 4;
int64 joined_at_ms = 5;
int64 last_seen_at_ms = 6;
bool is_owner = 7;
}
```
扩展响应:
```proto
message ListRoomOnlineUsersResponse {
repeated RoomUser users = 1;
int64 total = 2;
int32 page = 3;
int32 page_size = 4;
int64 server_time_ms = 5;
repeated RoomOnlineUser items = 6;
}
```
兼容策略:
- `users = 1` 保留旧客户端和旧 gateway 兼容。
- 新 gateway 优先使用 `items = 6`
- 不把 `RoomUser.role` 改成 `normal/admin`
## 7. 查询链路设计
### 7.1 HTTP 链路
```text
gateway-service
-> room-service ListRoomOnlineUsers
-> user-service/profile 聚合用户展示资料
-> HTTP 返回 items
```
HTTP 返回字段需要新增:
| 字段 | 来源 |
| --- | --- |
| `is_owner` | `user_id == owner_user_id` |
| `room_role` | room-service 基于管理员集合派生 |
| `gift_value` | room-service 查询统计表 |
| `joined_at_ms` | room_user_presence |
| `last_seen_at_ms` | room_user_presence |
| `profile.user_id` | user-service `BatchGetUsers` |
| `profile.username` | user-service `BatchGetUsers` |
| `profile.avatar` | user-service `BatchGetUsers` |
| `profile.display_user_id` | user-service `BatchGetUsers` |
| `profile.gender` | user-service `BatchGetUsers``gender` |
| `profile.age` | gateway 根据 user-service `birth``yyyy-mm-dd` 计算 |
| `profile.country` | user-service `BatchGetUsers``country` |
| `profile.country_name` | user-service `BatchGetUsers``country_name` |
| `profile.country_display_name` | user-service `BatchGetUsers``country_display_name` |
| `profile.country_flag` | gateway 根据 ISO Alpha-2 国家码生成 |
用户资料边界:
- `gender``birth` 属于 user-serviceroom-service 不存储、不缓存。
- `country``country_name``country_display_name` 属于 user-service 国家主数据聚合结果room-service 不存储、不缓存。
- HTTP 只返回 `age`,不返回 `birth`
- `birth` 为空、格式非法、未来日期或超过合理年龄上限时,`age` 返回 `0`
- 年龄计算以 gateway 当前 UTC 日期为准,避免客户端时区差异导致展示不一致。
- `country_flag` 不依赖 user proto 新字段,由 gateway 使用两位国家码生成;国家码为空或非法时返回空字符串。
### 7.2 room-service 查询流程
1. 校验 `room_id``viewer_user_id`、分页参数。
2. 校验调用方当前在该房间内。
3. 读取当前房间 snapshot拿到 `owner_user_id``admin_user_ids`
4. 查询 `room_user_presence` 当前在线用户分页。
5. 左连接 `room_user_gift_stats` 获取 `gift_value`
6. 服务层派生每个用户的 `is_owner``room_role`
7. 返回 `items` 和兼容字段 `users`
### 7.3 `is_owner` 和 `room_role` 派生规则
```text
if user_id == owner_user_id:
is_owner = true
else:
is_owner = false
if user_id in admin_user_ids:
room_role = "admin"
else:
room_role = "normal"
```
房主不是管理员,所以不能因为 `is_owner = true` 就返回 `room_role = admin`
不建议把 `room_role` 冗余写入 `room_user_presence`,因为管理员身份属于房间状态,不属于 presence 状态。
如果当前代码为了兼容权限把房主写入或恢复进 `AdminUsers`,在线列表展示和管理员操作必须做过滤:
- 展示时 `user_id == owner_user_id` 固定按 `is_owner = true` 返回,`room_role` 不因房主身份变成 `admin`
- 设置或取消管理员时,`target_user_id == owner_user_id` 直接拒绝。
- 管理员集合里的 owner 不能对外表现为管理员。
### 7.4 排序
第一版建议保持现有在线排序,避免影响旧客户端:
```sql
ORDER BY last_seen_at_ms DESC, user_id ASC
```
如果要支持 `sort=gift_value`
```sql
ORDER BY gift_value DESC, last_seen_at_ms DESC, user_id ASC
```
分页总数仍以当前在线用户数为准。
## 8. 写入链路设计
### 8.1 送礼成功累计 `gift_value`
`SendGift` 钱包扣款成功并准备提交房间状态时,使用结算后的 `heatValue` 累加:
```sql
INSERT INTO room_user_gift_stats (
app_code,
room_id,
user_id,
gift_value,
last_gift_at_ms,
created_at_ms,
updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
gift_value = gift_value + VALUES(gift_value),
last_gift_at_ms = VALUES(last_gift_at_ms),
updated_at_ms = VALUES(updated_at_ms);
```
写入边界:
- 只在送礼成功后写入。
- 使用 `heatValue`,不能使用客户端价格。
- 统计送礼用户 `actor_user_id`,不是收礼用户。
- 必须和现有命令幂等边界一致,重复命令不能重复累计。
### 8.2 幂等要求
已有房间命令链路通过 `command_id` 判断命令是否已应用。
新增统计写入必须满足:
- 命令第一次成功应用时累计一次。
- 同一 `command_id` 重试时不再次累计。
- recovery replay 不能重复写统计表。
推荐做法:
- 将统计写入纳入保存 mutation 的同一事务或同一 applied 分支。
- 已 applied 的命令直接返回已有结果,不进入统计累加。
### 8.3 历史数据补偿
如果上线前已有房间内送礼数据,需要决定是否补偿:
- 不补偿:上线后新送礼才有 `gift_value`,旧房间可能显示不完整。
- 补偿:从已有 `RoomGiftSent` outbox 或命令日志回放,按 `sender_user_id``gift_value` 聚合写入 `room_user_gift_stats`
产品如果要求老房间准确,必须执行补偿。
## 9. 管理员接口设计
### 9.1 现有 HTTP 接口
```http
POST /api/v1/rooms/admin/set
```
请求:
```json
{
"room_id": "room_10001",
"command_id": "cmd-10001",
"target_user_id": "10002",
"enabled": true
}
```
### 9.2 服务边界
保持现有 `SetRoomAdmin` 逻辑:
- 只有房主可设置或取消。
- 目标用户必须当前在房间内。
- 房主不是管理员,不能对房主执行设置或取消管理员。
- 管理员之间不可互相操作。
- 重复设置同一状态不生成重复 outbox 和 IM。
- 成功变更时写入 `RoomAdminChanged` outbox。
### 9.3 Gateway DTO
建议不要直接透出完整 `RoomSnapshot`,新增稳定 HTTP DTO
```json
{
"result": {
"applied": true,
"room_version": 12
},
"target_user_id": "10002",
"room_role": "admin",
"server_time_ms": 1760000006000
}
```
## 10. IM 设计
### 10.1 投递形态
room-service 通过 outbox worker 向腾讯云 IM 投递 `TIMCustomElem`
| Tencent 字段 | 值 |
| --- | --- |
| `MsgType` | `TIMCustomElem` |
| `MsgContent.Ext` | `room_system_message` |
| `MsgContent.Desc` | 客户端事件类型,例如 `room_admin_changed` |
| `MsgContent.Data` | JSON 序列化后的 `RoomEvent` |
| `CloudCustomData` | 同一份 JSON用于服务端透传和排查 |
### 10.2 管理员变更
outbox 事件:
```text
RoomAdminChanged
```
客户端事件:
```text
room_admin_changed
```
payload
```json
{
"event_id": "evt-admin-10001",
"room_id": "room_10001",
"event_type": "room_admin_changed",
"actor_user_id": 10001,
"target_user_id": 10002,
"room_version": 12,
"attributes": {
"enabled": "true"
}
}
```
字段来源:
| 字段 | 来源 |
| --- | --- |
| `event_id` | outbox event id |
| `actor_user_id` | `RoomAdminChanged.actor_user_id` |
| `target_user_id` | `RoomAdminChanged.target_user_id` |
| `attributes.enabled` | `RoomAdminChanged.enabled` 转字符串 |
### 10.3 送礼成功
outbox 事件:
```text
RoomGiftSent
```
客户端事件:
```text
room_gift_sent
```
payload
```json
{
"event_id": "evt-gift-10001",
"room_id": "room_10001",
"event_type": "room_gift_sent",
"actor_user_id": 10002,
"target_user_id": 10003,
"gift_value": 5200,
"room_heat": 999999,
"room_version": 13,
"attributes": {
"gift_id": "rose",
"pool_id": "",
"gift_count": "10",
"billing_receipt_id": "receipt-10001",
"coin_spent": "5200",
"gift_point_added": "5200",
"price_version": "v1"
}
}
```
字段来源:
| 字段 | 来源 |
| --- | --- |
| `actor_user_id` | 送礼用户 |
| `target_user_id` | 收礼用户 |
| `gift_value` | 本次 `heatValue` |
| `room_heat` | 送礼后的房间热度 |
| `attributes.billing_receipt_id` | wallet-service 结算流水 |
### 10.4 不新增列表专用 IM
用户列表接口本身不新增 IM。
客户端刷新依赖已有事件:
| 变化 | IM |
| --- | --- |
| 进房 | `room_user_joined` |
| 离房 | `room_user_left` |
| 管理员变化 | `room_admin_changed` |
| 送礼成功 | `room_gift_sent` |
## 11. 错误边界
| 场景 | gRPC code | HTTP code |
| --- | --- | --- |
| `room_id` 为空 | `INVALID_ARGUMENT` | `400` |
| `viewer_user_id` 为空 | `INVALID_ARGUMENT` | `400` |
| 调用方不在房间内 | `PERMISSION_DENIED` | `403` |
| 房间不存在 | `NOT_FOUND` | `404` |
| 非房主设置管理员 | `PERMISSION_DENIED` | `403` |
| 目标用户不在房间 | `NOT_FOUND` | `404` |
| 操作目标是房主 | `FAILED_PRECONDITION` | `409` |
| `page_size` 超过上限 | 正常截断 | `200` |
## 12. 开发步骤
1. 新增 MySQL migration`room_user_gift_stats`
2. 修改 `api/proto/room/v1/room.proto`,新增 `RoomOnlineUser``ListRoomOnlineUsersResponse.items`
3. 重新生成 proto 代码。
4. 扩展 room repository
- 送礼统计累加方法。
- 在线用户列表查询左连接礼物统计。
5. 修改 `SendGift` 成功提交路径,累计 `gift_value`
6. 修改 `ListRoomOnlineUsers` service
- 校验 viewer 在房间内。
- 读取 snapshot 派生 `is_owner``room_role`
- 返回 `items`
7. 修改 gateway 在线用户 DTO
- 增加 `is_owner`
- 增加 `room_role`
- 增加 `gift_value`
- 增加 `joined_at_ms`
8. 修改 gateway 管理员设置响应 DTO避免直接暴露完整 snapshot。
9. 补充单元测试和 HTTP handler 测试。
## 13. 测试清单
### 13.1 在线用户列表
- 普通用户返回 `room_role = normal`
- 房主返回 `is_owner = true`
- 房主不是管理员时返回 `room_role = normal`
- 被设置管理员的用户返回 `room_role = admin`
- 未送礼用户返回 `gift_value = 0`
- 已送礼用户返回累计 `gift_value`
- 在线用户超过 `rank_limit` 时,非 Top N 用户仍有正确 `gift_value`
- 不在房间内的用户查询失败。
### 13.2 管理员设置
- 房主设置管理员成功。
- 房主取消管理员成功。
- 非房主设置失败。
- 目标用户不在房间时失败。
- 对房主执行设置或取消管理员失败。
- 管理员之间互相设置或取消管理员失败。
- 重复设置同一状态不重复发送 `RoomAdminChanged`
### 13.3 送礼统计
- 成功送礼累计一次。
- 钱包扣款失败不累计。
- 同一 `command_id` 重试不重复累计。
- recovery replay 不重复累计。
### 13.4 IM
- 管理员变更投递 `room_admin_changed`
- `attributes.enabled = "true"` 表示设置管理员。
- `attributes.enabled = "false"` 表示取消管理员。
- 送礼成功投递 `room_gift_sent`
- `room_gift_sent.gift_value` 与统计表累加值口径一致。
建议执行:
```bash
go test ./services/room-service/... ./services/gateway-service/...
```