# 语音房房间用户列表后端技术架构设计 ## 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 通知客户端刷新。 ## 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` | 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/... ```