# Voice Room Basic Loop Implementation

本文档定义语音房 v1 基础闭环的实现顺序和验收边界。目标不是做完整语音房，而是先让“登录用户创建房间、进入房间、登录腾讯云 IM、加入房间群、接收房间系统消息、离房、重连恢复”成为可测闭环。

Room Cell 的完整状态机见：

```text
docs/房间Cell状态机.md
```

## Scope

本阶段必须打通：

| Capability | Owner | Required Result |
| --- | --- | --- |
| 用户身份 | `user-service` | gateway 能从 access token 得到稳定 `user_id` |
| HTTP 命令入口 | `gateway-service` | 所有房间命令走 `/api/v1` envelope |
| 房间业务态 | `room-service` | Room Cell 持有 presence、麦位、禁言、踢人、快照和恢复 |
| 腾讯云 IM 登录票据 | `gateway-service` | `/api/v1/im/usersig` 返回 `sdk_app_id/user_id/user_sig/expire_at_ms` |
| 腾讯云 IM 群组 | `room-service` | `RoomCreated` outbox 由 worker 异步确保 `room_id` 对应群组存在 |
| 房间系统消息 | `room-service` | 房间事件只写 outbox，腾讯云 IM 由 outbox worker 异步投递 |
| 实时权限守卫 | `gateway-service` + `room-service` | 腾讯云 IM 入群前和群内发言前必须回查房间业务态 |
| 腾讯云 RTC 进房票据 | `gateway-service` | `JoinRoom` 成功后在响应中内嵌 RTC token；`/api/v1/rtc/token` 只做补偿和刷新 |

本阶段不做：

| Excluded | Reason |
| --- | --- |
| 自研 WebSocket | 实时连接和消息通道由腾讯云 IM 承担 |
| 全量 IM 历史库 | 群历史优先使用腾讯云 IM 能力 |
| RTC 录制/审核 | 只先接入进房听音频所需的 token 和回调，不处理录制与审核 |
| 复杂单聊会话 | 先保证房间基础闭环 |
| 热门房多活 | 当前仍是单房间单活 + Redis lease 接管 |

## Required Config

`gateway-service` 负责客户端 UserSig 和腾讯云 IM 回调入口：

```yaml
tencent_im:
  sdk_app_id: 20036101
  secret_key: "TENCENT_IM_SECRET_KEY"
  user_sig_ttl: "24h"
  callback_url: "https://api.example.com/api/v1/tencent-im/callback"
  callback_auth_token: "TENCENT_IM_CALLBACK_AUTH_TOKEN"
```

`room-service` 负责服务端 REST API 建群和系统消息：

```yaml
tencent_im:
  enabled: true
  sdk_app_id: 20036101
  secret_key: "TENCENT_IM_SECRET_KEY"
  admin_identifier: "900100100"
  admin_user_sig_ttl: "24h"
  endpoint: "adminapisgp.im.qcloud.com"
  group_type: "ChatRoom"
  request_timeout: "5s"
```

配置规则：

- `sdk_app_id`、`secret_key` 必须在 gateway 和 room-service 中保持一致。
- `endpoint` 按腾讯云 IM 应用区域填写；新加坡使用 `adminapisgp.im.qcloud.com`。
- `secret_key` 和 `callback_auth_token` 在线上必须走私有配置源；本地调试配置只用于本地联调。
- 本地 Docker 联调时必须让 gateway 和 room-service 使用同一套腾讯云 IM 配置，否则 UserSig、建群和回调守卫会出现跨应用不一致。

## Target Flow

```mermaid
sequenceDiagram
  participant C as Client
  participant G as gateway-service
  participant U as user-service
  participant R as room-service
  participant T as Tencent Cloud IM
  participant RTC as Tencent Cloud RTC

  C->>G: Login / RefreshToken
  G->>U: gRPC auth
  U-->>G: access token with user_id
  G-->>C: token envelope

  C->>G: POST /api/v1/rooms/create(room_name, room_avatar?, room_description?)
  G->>R: CreateRoom(actor_user_id, room profile)
  R->>R: command log + snapshot + outbox
  R-->>G: RoomSnapshot
  G-->>C: room envelope
  R-->>T: outbox worker create_group(room_id)

  C->>G: GET /api/v1/im/usersig
  G-->>C: SDKAppID + user_id + UserSig
  C->>T: SDK login(user_id, user_sig)

  C->>G: POST /api/v1/rooms/join
  G->>R: JoinRoom(actor_user_id)
  R->>R: update Room Cell presence
  R-->>G: RoomSnapshot
  G-->>C: initial room envelope + im_group_id + RTC token
  R-->>T: outbox worker send_group_msg(room_user_joined)

  C->>T: joinGroup(room_id)
  T->>G: CallbackBeforeApplyJoinGroup
  G->>R: VerifyRoomPresence(room_id, user_id)
  R-->>G: present=true
  G-->>T: allow

  C->>RTC: enterRoom(strRoomId from JoinRoom rtc.token)

  C->>T: send group message
  T->>G: CallbackBeforeSendMsg
  G->>R: CheckSpeakPermission(room_id, user_id)
  R-->>G: allowed=true
  G-->>T: allow
```

## Gateway Implementation

### 1. UserSig

已存在入口：

```text
GET /api/v1/im/usersig
Authorization: Bearer <access_token>
```

实现约束：

- `user_id` 必须来自 access token，不接受客户端传入。
- 腾讯云 IM identifier 使用内部长 `user_id` 的十进制字符串。
- `secret_key` 缺失时必须 fail-closed，返回统一 envelope 错误，不能返回假票据。
- `request_id` 只做链路追踪，不作为 UserSig 幂等键。

### 2. Callback Endpoint

新增入口：

```text
POST /api/v1/tencent-im/callback
```

该入口不使用客户端 JWT。它只接受腾讯云 IM 服务端回调，并按 `CallbackCommand` 分发。

必须支持的回调：

| CallbackCommand | Purpose | Internal RPC |
| --- | --- | --- |
| `Group.CallbackBeforeApplyJoinGroup` | 入群前确认用户业务上在房间内 | `VerifyRoomPresence` |
| `Group.CallbackBeforeSendMsg` | 群内发言前确认未被禁言、未被踢、房间允许聊天 | `CheckSpeakPermission` |

回调处理规则：

- 必须校验回调来源。启用腾讯云 IM 回调鉴权后，使用 `callback_auth_token` 或控制台约定的鉴权参数校验。
- `CallbackCommand` 不在白名单内时返回允许，但要记录日志，避免未知回调阻断腾讯云 IM。
- `GroupId` 映射为 `room_id`。
- `Operator_Account` 或 `From_Account` 映射为内部长 `user_id`，只能接受十进制字符串。
- 解析失败时拒绝该次入群或发言，避免绕过业务守卫。

建议结构：

```text
services/gateway-service/internal/transport/http/
  tencent_im_callback.go

services/gateway-service/internal/tencentim/
  callback.go
  verifier.go
  command.go
```

gateway 不保存房间状态，只做回调鉴权、字段解析、调用 room guard RPC 和返回腾讯云 IM 需要的响应。

## Room-Service Implementation

### 1. CreateRoom

`CreateRoom` 不等待腾讯云 IM 建群。它只提交房间权威状态和 `RoomCreated` outbox；建群由 outbox worker 异步完成。
同一个 `actor_user_id` 只能作为 owner 创建一个房间；加入、主持或管理别人的房间不受该 owner 限制影响。

主链路顺序：

```text
validate request
acquire Redis lease
check room meta
check owner has no room
initialize RoomState
write room meta + command log + snapshot + outbox
install Room Cell
return snapshot
```

如果后续 `EnsureRoomGroup` 失败：

- `CreateRoom` 已经成功，不回滚房间状态。
- outbox worker 按指数退避重试，达到上限进入 `failed`。
- 客户端 IM SDK 加群可能短暂遇到 group-not-ready，需要按产品可接受窗口重试。
- 失败原因不应该泄露腾讯云密钥、UserSig 或完整公网响应。

### 2. JoinRoom

`JoinRoom` 只修改 room-service 业务 presence，不创建腾讯云 IM 连接。

幂等规则：

- 用户已在房间内时刷新 `last_seen_at_ms`。
- 不重复增加在线人数。
- 不重复广播进房系统消息，除非产品明确要求“重新进房提示”。

当前实现如果重复 JoinRoom 直接返回快照，需要补齐 `last_seen_at_ms` 刷新，否则断线重连无法把 presence 延长。

### 3. LeaveRoom

`LeaveRoom` 只移除业务 presence。

要求：

- 用户不在房间内时幂等返回，不报错。
- 用户离房后 `VerifyRoomPresence` 必须返回 `present=false`。
- 如果用户在麦上，离房必须同步释放麦位。
- 离房事件写 outbox，腾讯云 IM 系统消息由 worker 异步投递。

### 4. Guard RPC

`VerifyRoomPresence` 是入群前守卫。

返回原因：

| Reason | Meaning |
| --- | --- |
| `room_not_found` | 房间不存在或不可恢复 |
| `not_in_room` | 用户没有业务 presence |
| `user_banned` | 用户被踢或封禁 |

`CheckSpeakPermission` 是发言前守卫。

返回原因：

| Reason | Meaning |
| --- | --- |
| `room_not_found` | 房间不存在或不可恢复 |
| `not_in_room` | 用户没有业务 presence |
| `user_banned` | 用户被踢或封禁 |
| `user_muted` | 用户被禁言 |
| `chat_disabled` | 房间关闭公屏 |

守卫 RPC 不写状态，不发 outbox，只读取当前 Room Cell 或恢复后的 snapshot 状态。

## Tencent IM Message Contract

room-service 发给腾讯云 IM 群的房间系统消息使用 `TIMCustomElem`。给单个用户的私有通知不从这个 bridge 发，统一由 notice-service 读取 owner outbox 后走腾讯云 IM C2C。

客户端只依赖以下稳定字段：

```json
{
  "event_id": "evt_xxx",
  "room_id": "room_1001",
  "event_type": "room_user_joined",
  "actor_user_id": 10001,
  "target_user_id": 10001,
  "seat_no": 1,
  "gift_value": 20,
  "room_heat": 100,
  "room_version": 7,
  "attributes": {
    "reason": "stale_timeout"
  }
}
```

字段规则：

- `event_id` 是客户端去重键。
- `room_version` 是房间状态排序参考，不替代腾讯云 IM 消息序列。
- `event_type` 使用 snake_case，不能直接暴露 protobuf 事件名。
- 系统事件字段按当前 OpenAPI/proto 契约输出；新增字段必须同步 gateway、room-service 和客户端测试。

首版系统事件：

| Event Type | Source |
| --- | --- |
| `room_user_joined` | `RoomUserJoined` |
| `room_user_left` | `RoomUserLeft` |
| `room_mic_changed` | `RoomMicChanged` |
| `room_user_muted` | `RoomUserMuted` |
| `room_user_kicked` | `RoomUserKicked` |
| `room_gift_sent` | `RoomGiftSent` |

`room_user_kicked` 还会由 notice-service 读取同一条 `RoomUserKicked` 事实，给被踢用户发送 C2C `room_notice`。room-service 仍负责把目标用户移出腾讯 IM 群；notice-service 不处理建群、退群或群成员权限。

## Recovery Rules

节点重启或接管时：

1. 新节点获得 Redis lease。
2. 从 MySQL 读取最新 snapshot。
3. 回放 snapshot version 之后的 command log。
4. 安装 Room Cell。
5. 客户端通过 `JoinRoom` 幂等刷新 presence。
6. 客户端重新获取 UserSig 或复用未过期 UserSig 登录腾讯云 IM。
7. 客户端重新加入房间群，并用最新 `RoomSnapshot` 覆盖本地状态。

恢复不做：

- 不恢复本地 socket。
- 不重新扣费。
- 不重复调用腾讯云 IM 发送历史系统消息。
- 不把 Redis 当恢复来源。

## Implementation Order

按这个顺序实现，避免先做复杂能力导致闭环不可测：

1. **锁定配置**：确认 gateway 和 room-service 的 `sdk_app_id`、`secret_key`、新加坡 endpoint、管理员账号一致。
2. **补 gateway 回调 handler**：实现 `/api/v1/tencent-im/callback`，支持入群前和发言前回调。
3. **补 room guard client**：gateway 内部 client 增加 `RoomGuardService` 调用。
4. **补 JoinRoom 重连刷新**：重复 JoinRoom 刷新 `last_seen_at_ms`，但不重复发进房系统消息。
5. **补 LeaveRoom 麦位释放**：离房时如果在麦上，释放麦位并产生一致的房间事件。
6. **补 presence stale worker**：按 `last_seen_at_ms` 清理超时 presence，写 `RoomUserLeft`。
7. **补集成测试**：覆盖 UserSig、CreateRoom outbox 建群、JoinRoom、回调守卫、LeaveRoom、重连恢复。

## Acceptance Tests

必须有自动化测试覆盖：

| Test | Expected Result |
| --- | --- |
| UserSig 签发 | 已登录用户拿到 `sdk_app_id/user_id/user_sig`，匿名用户失败 |
| UserSig 配置缺失 | 返回统一错误，不返回假票据 |
| CreateRoom 后建群失败 | 房间状态已提交，outbox 重试；超限后进入 `failed` |
| CreateRoom 成功 | MySQL meta/snapshot/command/outbox 都存在 |
| JoinRoom 成功 | presence 增加，返回 RoomSnapshot |
| 重复 JoinRoom | 刷新 `last_seen_at_ms`，不重复创建 presence |
| 入群前回调允许 | 已进房且未被 ban 的用户返回 allow |
| 入群前回调拒绝 | 未进房或被踢用户返回 deny |
| 发言前回调允许 | 已进房、未禁言、房间聊天开启 |
| 发言前回调拒绝 | 被禁言、被踢、未进房或房间聊天关闭 |
| LeaveRoom 后发言 | `CheckSpeakPermission` 返回 `not_in_room` |
| room-service 接管恢复 | snapshot + command log 恢复后守卫 RPC 继续正确 |
| outbox 投递失败 | 房间状态已提交，outbox 进入 `retryable` 或最终 `failed` |

## Operational Checks

上线前必须人工确认：

- 腾讯云 IM 控制台应用区域是新加坡，REST endpoint 使用 `adminapisgp.im.qcloud.com`。
- 腾讯云 IM 控制台已配置回调 URL，且 URL 指向 gateway 公网入口。
- 回调鉴权 token 和 gateway 配置一致。
- 已启用 `Group.CallbackBeforeApplyJoinGroup` 和 `Group.CallbackBeforeSendMsg`。
- gateway 和 room-service 的 `SDKAppID`、`SecretKey` 一致。
- 线上配置没有把真实 secret 写入仓库。
- room outbox 积压、腾讯云 IM REST 错误率、UserSig 签发失败率都有监控。

## Done Definition

基础闭环完成的标准：

```text
User Login
  -> CreateRoom
  -> JoinRoom
  -> Get UserSig
  -> Tencent IM SDK Login
  -> Join Tencent IM Group
  -> Callback VerifyRoomPresence allow
  -> Send Room Chat
  -> Callback CheckSpeakPermission allow
  -> Mic/Gift/Kick/Mute system event delivered
  -> LeaveRoom
  -> Callback guard denies further speak/join
  -> Reconnect via JoinRoom + UserSig + IM group join
```

只要这条链路稳定，后续 RTC、礼物、活动、房间推荐都可以在不破坏边界的前提下继续迭代。
