594 lines
16 KiB
Markdown
594 lines
16 KiB
Markdown
# 语音房房间用户列表后端技术架构设计
|
||
|
||
## 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-service,room-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/...
|
||
```
|