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

## 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/...
```
