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

16 KiB
Raw Permalink Blame History

语音房房间用户列表后端技术架构设计

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 信息,不包含:

  • 用户在当前房间内累计送出的礼物价值。
  • 用户针对当前房间的管理身份:normaladmin

2. 目标

本次后端开发目标:

  1. 在线用户列表返回所有当前在房间内的用户。
  2. 每个用户返回当前房间内累计送礼价值 gift_value
  3. 每个用户返回是否为房主 is_owner
  4. 每个用户返回当前房间管理员身份 room_role,只允许 normaladmin
  5. 复用同一个接口完成设置管理员和取消管理员。
  6. 管理员变更和送礼成功继续通过 IM 通知客户端刷新。
  7. 在线用户列表聚合用户性别 gender 和年龄 age,但不暴露生日 birth

3. 非目标

本次不做:

  • 历史房间贡献榜。
  • 日榜、周榜、月榜。
  • 离线预设管理员。
  • 平台后台管理员权限。
  • 客户端上报价格参与礼物价值计算。
  • 使用 GiftRank 作为完整用户礼物价值数据源。

4. 现有代码基础

4.1 Proto

文件:

api/proto/room/v1/room.proto

现有 RPC

rpc ListRoomOnlineUsers(ListRoomOnlineUsersRequest) returns (ListRoomOnlineUsersResponse);
rpc SetRoomAdmin(SetRoomAdminRequest) returns (SetRoomAdminResponse);

现有 RoomUser.role 已用于 presence 角色,不能改成 normal/admin

4.2 房间状态

文件:

services/room-service/internal/room/state/state.go

当前 RoomState 已包含:

字段 说明
OnlineUsers 当前房间业务在线用户
AdminUsers 当前房间管理员集合
GiftRank 房间礼物榜 Top N

注意:GiftRankrank_limit 限制,不能覆盖所有用户。

4.3 设置管理员

文件:

services/room-service/internal/room/service/management.go

SetRoomAdmin 需要满足以下行为:

  • 只有房主可操作。
  • 目标用户必须在当前房间内。
  • 房主不是管理员,不允许对房主执行设置或取消管理员。
  • 管理员之间不可互相操作。
  • 重复设置同一状态是 no-op。
  • 状态变化时生成 RoomAdminChanged outbox 事件。
  • IM 事件类型为 room_admin_changed

这部分服务逻辑可以复用,主要补 HTTP DTO 和文档约束。

4.4 送礼事件

文件:

services/room-service/internal/room/service/gift.go

当前 SendGift 成功后:

  • 使用 wallet-service 结算结果。
  • 使用 heatValue 增加房间热度。
  • 使用 heatValue 更新本地礼物榜。
  • 生成 RoomGiftSentRoomHeatChangedRoomRankChanged outbox 事件。
  • 对客户端只投递 room_gift_sent IM。

新增 gift_value 必须使用同一个 heatValue 口径。

5. 数据模型设计

5.1 在线来源

在线用户来源保持不变:

room_user_presence

判断条件:

app_code = ?
room_id = ?
status = active

5.2 礼物价值统计表

新增完整房间用户送礼统计表:

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 语义:

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;
}

扩展响应:

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 链路

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 BatchGetUsersgender
profile.age gateway 根据 user-service birthyyyy-mm-dd 计算
profile.country user-service BatchGetUserscountry
profile.country_name user-service BatchGetUserscountry_name
profile.country_display_name user-service BatchGetUserscountry_display_name
profile.country_flag gateway 根据 ISO Alpha-2 国家码生成

用户资料边界:

  • genderbirth 属于 user-serviceroom-service 不存储、不缓存。
  • countrycountry_namecountry_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_idviewer_user_id、分页参数。
  2. 校验调用方当前在该房间内。
  3. 读取当前房间 snapshot拿到 owner_user_idadmin_user_ids
  4. 查询 room_user_presence 当前在线用户分页。
  5. 左连接 room_user_gift_stats 获取 gift_value
  6. 服务层派生每个用户的 is_ownerroom_role
  7. 返回 items 和兼容字段 users

7.3 is_ownerroom_role 派生规则

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 排序

第一版建议保持现有在线排序,避免影响旧客户端:

ORDER BY last_seen_at_ms DESC, user_id ASC

如果要支持 sort=gift_value

ORDER BY gift_value DESC, last_seen_at_ms DESC, user_id ASC

分页总数仍以当前在线用户数为准。

8. 写入链路设计

8.1 送礼成功累计 gift_value

SendGift 钱包扣款成功并准备提交房间状态时,使用结算后的 heatValue 累加:

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_idgift_value 聚合写入 room_user_gift_stats

产品如果要求老房间准确,必须执行补偿。

9. 管理员接口设计

9.1 现有 HTTP 接口

POST /api/v1/rooms/admin/set

请求:

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

{
  "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 事件:

RoomAdminChanged

客户端事件:

room_admin_changed

payload

{
  "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 事件:

RoomGiftSent

客户端事件:

room_gift_sent

payload

{
  "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 migrationroom_user_gift_stats
  2. 修改 api/proto/room/v1/room.proto,新增 RoomOnlineUserListRoomOnlineUsersResponse.items
  3. 重新生成 proto 代码。
  4. 扩展 room repository
    • 送礼统计累加方法。
    • 在线用户列表查询左连接礼物统计。
  5. 修改 SendGift 成功提交路径,累计 gift_value
  6. 修改 ListRoomOnlineUsers service
    • 校验 viewer 在房间内。
    • 读取 snapshot 派生 is_ownerroom_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 与统计表累加值口径一致。

建议执行:

go test ./services/room-service/... ./services/gateway-service/...