# Tencent RTC Token And UID Mapping Development Guide

本文档定义 Phase 2 “腾讯 RTC 语音接入”中，`gateway-service` 如何签发腾讯 RTC 进房凭证，以及系统内部 `user_id`、`room_id` 如何映射到腾讯 RTC SDK 参数。目标是让用户在 `JoinRoom` 成功后可以进入真实语音频道，同时不把 RTC 在线态塞进 `room-service`。

## Scope

本阶段只做：

| Capability | Owner | Required Result |
| --- | --- | --- |
| RTC UserSig 签发 | `gateway-service` | 已鉴权且已进房用户可以拿到腾讯 RTC 所需 `SDKAppID/UserID/UserSig` |
| UID 映射 | `gateway-service` | 内部不可变 `user_id` 稳定映射为 RTC `UserID` 字符串 |
| 房间 ID 映射 | `gateway-service` + `room-service` | 内部 `room_id` 稳定映射为 RTC `strRoomId` |
| 进房权限 | `gateway-service` + `room-service` | 只有 room-service presence 中的用户才能拿 RTC token |
| 客户端进频道 | Client | 客户端使用返回参数调用腾讯 RTC SDK 进入语音房 |

本阶段不做：

| Not Included | Reason |
| --- | --- |
| 自研 RTC 网关 | 音频链路由腾讯 RTC SDK 和腾讯云承载 |
| RTC 在线态持久化 | RTC 连接态属于腾讯 RTC，不进入 Room Cell 核心状态 |
| 多端同时进同一 RTC 房 | 腾讯 RTC 对同一 `UserID` 同房多端有冲突约束，首版只支持单用户单端进同一 RTC 房 |
| 音频流审核/旁路直播 | 需要独立产品和合规设计 |
| 私有进房权限 `privateMapKey` | 首版不启用房间级腾讯侧权限；只适用于普通开放语音房，私密、付费或强权限房必须先补 `privateMapKey` |

## Official Constraints

需要遵守的腾讯 RTC 规则：

| Rule | Design Impact |
| --- | --- |
| UserSig 是腾讯 RTC/Chat 的用户鉴权凭证，生产环境应由业务服务端实时生成，不能把 SecretKey 放在客户端 | `gateway-service` 签发，客户端永远拿不到 `secret_key` |
| RTC SDK 进房需要 `SDKAppID + UserID + UserSig` | HTTP 响应必须返回这三项 |
| `UserID` 允许字母、数字、下划线、连字符等有限字符集合 | 内部 int64 `user_id` 转十进制字符串，天然满足限制 |
| 同一 `UserID` 不能在同一 TRTC 房间用两个设备同时进房 | 首版不做多端同房；后续要多端时必须新增 `rtc_user_id` 映射表和端标识 |
| TRTC 同时支持数字 `roomId` 和字符串 `strRoomId`，二者不能混用 | 本项目内部 `room_id` 是字符串，统一使用 `strRoomId`，客户端不要传数字 `roomId` |
| UserSig 只校验 `SDKAppID + UserID` 是否有权使用 TRTC 服务，不绑定具体房间 | `VerifyRoomPresence` 只能控制本服务是否签发 token；没有 `privateMapKey` 时腾讯 RTC 后端不会阻止同一 UserSig 进入其他 `strRoomId` |
| `privateMapKey` 才是 TRTC 房间级权限凭证 | 首版不开启应用级房间权限控制；一旦控制台开启，客户端必须传 `privateMapKey`，否则无法进房 |

参考官方文档：

- [Secure authentication with UserSig](https://trtc.io/document/34385?menulabel=serverapis&product=chat)
- [Entering a Room](https://trtc.io/document/47645)
- [TRTCParams](https://trtc.io/document/72275)
- [Advanced Permission Control](https://trtc.io/document/35157)

## Ownership

| Domain | Owner | Rule |
| --- | --- | --- |
| HTTP RTC token API | `gateway-service` | 只做鉴权、参数校验、room guard 查询和 UserSig 签发 |
| 房间业务 presence | `room-service` | 仍然是用户是否属于房间的事实来源 |
| RTC 音频连接 | 腾讯 RTC | 持有真实音频连接、音频流、频道内在线状态 |
| 用户身份 | `user-service` | `user_id` 发号和用户状态，不进入 RTC 高频链路 |
| 客户端音频操作 | Client | 根据 token 进房、切换麦克风、处理 RTC SDK 回调 |

边界：

- `gateway-service` 不能接受客户端自报 `user_id` 或 `rtc_user_id`。
- `room-service` 不签发 RTC token，不保存 RTC socket 或音频流状态。
- `room-service.VerifyRoomPresence` 是签发 RTC token 前的业务守卫。
- 腾讯 RTC `UserID` 不是展示号，不能使用 `display_user_id`、昵称、手机号或 provider subject。
- 首版 `VerifyRoomPresence + UserSig` 是业务入口守卫，不是腾讯 RTC 房间级强制鉴权。已拿到有效 UserSig 的客户端理论上可以在 TTL 内请求进入其他 `strRoomId`，因此首版只面向普通开放语音房。
- 私密房、付费房、活动专属房、合规要求强隔离的房型不能复用首版 token 方案，必须先新增 `privateMapKey` 签发、客户端传参、验收测试和回滚策略。

## UID Mapping

首版映射规则：

```text
internal user_id int64 -> strconv.FormatInt(user_id, 10) -> rtc_user_id / UserID
```

示例：

| Internal | Tencent RTC |
| --- | --- |
| `user_id=918274650129384448` | `UserID="918274650129384448"` |
| `user_id=10001` | `UserID="10001"` |

规则：

- 映射必须稳定、可重复、无数据库查询。
- `rtc_user_id` 必须等于腾讯 IM identifier，方便客户端和排障把 IM 用户、RTC 用户、房间业务用户对齐。
- `display_user_id` 只用于展示、搜索、登录名，不能参与 RTC 身份。
- access token 中的展示号快照不能作为 RTC 身份。
- 如果未来要支持同一用户多端同时进入同一 RTC 房，不能简单追加设备后缀；必须新增显式映射，例如 `rtc_user_sessions(user_id, device_id, rtc_user_id, room_id, expires_at_ms)`，并重新设计 IM/RTC/房间事件中的身份还原规则。

## Room ID Mapping

首版映射规则：

```text
internal room_id string -> RTC strRoomId
RTC numeric roomId -> always 0 or omitted
```

响应字段建议同时返回业务名和 RTC 名，避免客户端混用：

```json
{
  "room_id": "room_1001",
  "rtc_room_id": "room_1001",
  "rtc_room_id_type": "string",
  "str_room_id": "room_1001"
}
```

规则：

- 客户端进腾讯 RTC 房时只使用 `strRoomId`。
- 客户端不能把 `"123"` 转成数字 `123`；这在 TRTC 中是两个不同房间。
- 新建房间时的 `room_id` 必须满足 RTC 字符串房间 ID 限制；校验要前移到 `CreateRoom` HTTP/gRPC 入口或 room-service 领域层，不能创建出业务可用但永远拿不到 RTC token 的房间。
- `/api/v1/rtc/token` 仍要重复校验 `room_id`，作为内部调用和入口绕过场景的 fail-closed 保护；非法房间 ID 返回 `INVALID_ARGUMENT`。
- 为同时满足腾讯 IM 群组和 RTC 房间限制，`room_id` 格式收敛到：`^[A-Za-z0-9_-]{1,48}$`。如果产品需要更丰富字符，必须同时确认 IM group id、RTC `strRoomId`、OpenAPI 和客户端 SDK 都接受。

## External HTTP API

RTC token gateway 入口：

```text
POST /api/v1/rtc/token
Authorization: Bearer <access_token>
```

Request:

```json
{
  "room_id": "room_1001"
}
```

Response `data`:

```json
{
  "sdk_app_id": 1400000000,
  "user_id": "10001",
  "rtc_user_id": "10001",
  "user_sig": "usersig_xxx",
  "expire_at_ms": 1777000000000,
  "room_id": "room_1001",
  "rtc_room_id": "room_1001",
  "rtc_room_id_type": "string",
  "str_room_id": "room_1001",
  "app_scene": "voice_chat_room",
  "role": "audience"
}
```

字段规则：

- `user_id` 保留腾讯 SDK 常用字段名，值是字符串形式内部长 `user_id`。
- `rtc_user_id` 是同一个值，用于客户端代码显式区分业务 `user_id` 和 RTC `UserID`。
- `user_sig` 是腾讯 RTC UserSig，不是腾讯 SecretKey。
- `expire_at_ms` 让客户端在 SDK 报 UserSig 过期前主动刷新。
- `role` 是当前推荐入房角色：不在麦上为 `audience`，已在麦上或房主/主持需要发流时为 `anchor`。VoiceChatRoom 场景下 `audience` 不能发布音频流，客户端只有在 `MicUp` 成功后才能切到 `anchor` 并开始采集。
- 首版 token 响应可以先返回 `audience`；后续如果 gateway 要按麦位返回 `anchor/audience`，必须从 room-service guard 或只读快照获取麦位事实，不能在 gateway 缓存房间状态。

错误映射：

| Case | HTTP | Code | Rule |
| --- | ---: | --- | --- |
| 未登录 | 401 | `UNAUTHORIZED` | middleware 拦截 |
| `room_id` 为空或不符合 RTC 限制 | 400 | `INVALID_ARGUMENT` | gateway 本地校验 |
| 不在房间 | 403 | `PERMISSION_DENIED` | `VerifyRoomPresence.present=false` |
| 房间不存在 | 404 | `NOT_FOUND` | guard reason 为 `room_not_found` |
| 用户被踢/封禁 | 403 | `PERMISSION_DENIED` | guard reason 为 `user_banned` |
| RTC 配置缺失 | 500 | `INTERNAL_ERROR` | fail-closed，不返回假 token |
| room-service 不可用 | 502 | `UPSTREAM_ERROR` | gateway 不能自判 presence |

显式房间心跳入口：

```text
POST /api/v1/rooms/heartbeat
Authorization: Bearer <access_token>
```

Request:

```json
{
  "room_id": "room_1001",
  "command_id": "cmd-heartbeat-1700000000"
}
```

规则：

- 心跳只刷新已经存在的 room-service presence 的 `last_seen_at_ms`。
- 用户不在房间时返回 `NOT_FOUND`，不能通过心跳重新进入房间。
- 客户端应按小于 `room_presence_stale_after` 的间隔发送心跳；HTTP 重试同一次心跳必须复用同一个 `command_id`。
- 主动离房仍然必须调用 `/api/v1/rooms/leave`；心跳停止只代表后台 stale worker 最终清理。

腾讯 RTC 服务端事件回调入口：

```text
POST /api/v1/tencent-rtc/callback?app_code=default
Header: SdkAppId: <sdk_app_id>
Header: Sign: base64(hmacsha256(callback_sign_key, raw_body))
```

消费事件：

| Tencent Event | Internal Event | Room Cell Effect |
| --- | --- | --- |
| `EventGroupId=2, EventType=203` | `audio_started` | 当前麦位为 `pending_publish` 时确认成 `publishing` |
| `EventGroupId=2, EventType=204` | `audio_stopped` | 释放当前用户麦位 |
| `EventGroupId=1, EventType=104` | `room_exited` | 释放当前用户麦位，不移除业务 presence |

回调边界：

- gateway 只负责验签、校验 `SdkAppId`、解析 Tencent 字段和派生稳定 `command_id`。
- 房间状态变更只调用 room-service `ApplyRTCEvent`，仍进入 Room Cell、command log 和 outbox。
- 未知事件在验签成功后返回 `{"code":0}` 并忽略，避免腾讯新增事件导致无意义重试。
- room-service 暂时不可用时返回非 200，让腾讯 RTC 按平台策略重试。
- `room_exited` 只代表 RTC 媒体频道退出，用户仍可能停留在房间页重连；业务 presence 只能由 `LeaveRoom`、踢人、关房或 stale worker 清理。
- 如果一个旧 `room_exited` 回调晚于新的业务心跳到达，room-service 会保留较新的业务 presence 和新麦位会话，避免旧回调清掉重连用户。

## Internal Flow

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

  C->>G: POST /api/v1/rooms/join
  G->>R: JoinRoom(actor_user_id, room_id)
  R-->>G: RoomSnapshot
  G-->>C: JoinRoom envelope

  C->>G: POST /api/v1/rtc/token(room_id)
  G->>G: verify JWT, derive user_id
  G->>R: VerifyRoomPresence(room_id, user_id)
  R-->>G: present=true, room_version
  G->>G: map user_id -> rtc_user_id
  G->>G: map room_id -> strRoomId
  G->>G: generate UserSig
  G-->>C: SDKAppID/UserID/UserSig/strRoomId

  C->>T: enterRoom(SDKAppID, UserID, UserSig, strRoomId)
  T-->>C: RTC room entered

  loop heartbeat before stale timeout
    C->>G: POST /api/v1/rooms/heartbeat(room_id)
    G->>R: RoomHeartbeat(actor_user_id, room_id)
    R-->>G: refreshed last_seen_at_ms
  end

  T->>G: RTC callback 203/204/104 with Sign
  G->>G: verify HMAC-SHA256 over raw body
  G->>R: ApplyRTCEvent(audio_started/audio_stopped/room_exited)
  R-->>G: RoomSnapshot
  G-->>T: {"code":0}
```

签发顺序：

```text
validate HTTP JSON
extract user_id from access token
validate room_id format
call room-service.VerifyRoomPresence(room_id, user_id)
map user_id to rtc_user_id string
generate RTC UserSig from tencent_rtc config
return token response
```

## Gateway Implementation

### Config

新增配置块：

```yaml
tencent_rtc:
  enabled: true
  sdk_app_id: 1400000000
  secret_key: "TENCENT_RTC_SECRET_KEY"
  user_sig_ttl: "2h"
  room_id_type: "string"
  app_scene: "voice_chat_room"
  callback_url: "https://api.example.com/api/v1/tencent-rtc/callback?app_code=default"
  callback_sign_key: "TENCENT_RTC_CALLBACK_SIGN_KEY"
```

配置规则：

- `secret_key` 只能存在服务端配置或密钥系统，不能下发客户端。
- `callback_sign_key` 只用于腾讯 RTC 回调验签，不要复用 UserSig `secret_key`。
- `tencent_rtc` 与 `tencent_im` 分开配置，避免误把 IM 应用和 RTC 应用混用。
- 如果腾讯控制台实际使用同一个应用承载 Chat/RTC，可以让两块配置填同一组 `sdk_app_id/secret_key`，但仍必须显式配置。
- `user_sig_ttl` 不宜过长；建议 2h 到 24h，具体按客户端刷新能力和泄露窗口决定。
- `room_id_type` 首版固定 `string`，其他值启动失败。

### Package Boundary

当前 `pkg/tencentim.GenerateUserSig` 已实现 Tencent UserSig v2 算法。RTC 接入时不要让 gateway 的 RTC handler 直接依赖 `pkg/tencentim` 语义。

推荐做法：

```text
pkg/tencentsig/        通用 UserSig 算法
pkg/tencentim/         IM REST 和 IM UserSig wrapper
pkg/tencentrtc/        RTC token response 和 UserSig wrapper
```

迁移规则：

- 先把通用签名算法抽到 `pkg/tencentsig`。
- 保持现有 `/api/v1/im/usersig` 行为不变。
- 新增 `pkg/tencentrtc.GenerateUserSig` 或 `GenerateToken` 包装通用算法。
- 单测用固定时间验证 IM 和 RTC 生成结果稳定，避免重构破坏现有 IM 登录。

### Handler

新增：

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

路由：

```go
mux.Handle(apiV1Prefix+"/rtc/token", apiHandler(jwtVerifier, h.issueTencentRTCToken))
```

handler 不写房间状态，只做：

- decode `room_id`
- 从 context 取 authenticated `user_id`
- 校验 `room_id`
- 调 `roomGuardClient.VerifyRoomPresence`
- 根据 guard response 映射 HTTP envelope
- 签发 UserSig
- 返回 token data

handler 装配规则：

- `services/gateway-service/internal/config.Config.TencentRTC` 必须从 `app.New` 传入 HTTP handler，不能只停留在 config 包。
- `NewHandlerWithConfig` 或后续构造器必须同时接收 `TencentIMConfig` 和 `TencentRTCConfig`，并保持 IM usersig 行为不变。
- handler 签发前校验 `tencent_rtc.enabled=true`、`sdk_app_id>0`、`secret_key` 非空、`user_sig_ttl>0`、`room_id_type=string`、`app_scene=voice_chat_room`；任一不满足都 fail-closed。
- 配置错误返回 `INTERNAL_ERROR`，依赖 room-service 失败返回 `UPSTREAM_ERROR`，两者不能混用。

### Room Guard Client

复用现有 `RoomGuardClient.VerifyRoomPresence`。

不新增 room-service RPC，除非后续需要更细的 RTC 进房策略，例如：

- 只有上麦用户能以 anchor 进房。
- 房间关闭 RTC。
- 房间分区域或分线路。
- 多端设备踢旧逻辑。

## Room-Service Changes

room-service 不保存 RTC socket 或在线态，但必须保存“业务占麦后是否完成 RTC 发流确认”的麦位状态，避免把 `MicUp` 当成音频已经发布成功。

当前麦位发流状态：

| State | Meaning | Transition |
| --- | --- | --- |
| empty / `idle` | 空麦或没有发流会话 | `MicUp` 后进入 `pending_publish` |
| `pending_publish` | 业务麦位占用成功，等待客户端或 RTC webhook 确认音频已发布 | `ConfirmMicPublishing` 后进入 `publishing`；超时自动 `MicDown(reason=publish_timeout)` |
| `publishing` | 当前 `mic_session_id` 已确认发布音频 | `MicDown`、`LeaveRoom`、`KickUser` 后清空 |

需要确认的稳定行为：

- `VerifyRoomPresence` 能区分 `room_not_found/not_in_room/user_banned`。
- `JoinRoom` 重连刷新 `last_seen_at_ms`。
- `LeaveRoom` 后 `VerifyRoomPresence` 返回 false。
- Kick 后 `VerifyRoomPresence` 返回 `user_banned`。
- `MicUp` 返回 `mic_session_id` 和 `publish_deadline_ms`，并在 `SeatState` 写入 `pending_publish`。
- `ConfirmMicPublishing(mic_session_id, room_version, event_time_ms)` 只确认当前麦位会话。
- pending 超过 `publish_deadline_ms` 未确认时，room-service 自动下麦并在事件 reason 中写 `publish_timeout`。

可选增强：

- `RoomSnapshot.room_ext` 后续可加入 `rtc_enabled=true`，但首版不需要。
- 如果要根据麦位返回 `role=anchor/audience`，gateway 可以从 room-service 增加只读查询或扩展 guard response；不要让 gateway 自己缓存麦位状态。
- 后续接 RTC webhook 时，webhook payload 必须带 `mic_session_id`、`room_version`、`event_time_ms`；room-service 只接受当前 session 且事件时间不旧于已接受事件的数据，旧 audience/leave 事件不能清掉新上麦状态。

## Client Flow

标准进房：

```text
1. Login / RefreshToken
2. POST /api/v1/rooms/join
3. POST /api/v1/im/usersig
4. IM SDK login + join group
5. POST /api/v1/rtc/token
6. RTC SDK enterRoom with SDKAppID/UserID/UserSig/strRoomId
7. Use JoinRoom RoomSnapshot as business UI state
```

上麦发布音频：

```text
1. POST /api/v1/rooms/mic/up
2. Client receives mic_session_id, publish_deadline_ms, and RoomSnapshot with publish_state=pending_publish
3. RTC SDK switchRole(anchor)
4. RTC SDK startLocalAudio
5. Client receives local publish success callback
6. POST /api/v1/rooms/mic/publishing/confirm with room_id, mic_session_id, room_version, event_time_ms, source=client
7. RoomSnapshot seat publish_state becomes publishing
8. If step 6 is not completed before publish_deadline_ms, room-service automatically MicDown(reason=publish_timeout)
```

重连：

```text
1. Refresh access token if needed
2. POST /api/v1/rooms/join to refresh business presence
3. If RTC UserSig expired or SDK requires re-enter, POST /api/v1/rtc/token
4. RTC SDK re-enter same strRoomId
5. Client reconciles UI with latest RoomSnapshot
```

离房：

```text
1. Client stops local audio if publishing
2. Client exits RTC room
3. Client exits IM group if product requires
4. POST /api/v1/rooms/leave
5. room-service presence removed
6. Later /api/v1/rtc/token for same room is denied
```

服务端原则：

- RTC 断线不立即等于业务离房。
- 主动 `LeaveRoom` 才是明确离房。
- 超过 presence stale timeout 后，业务 presence 被清理，新的 RTC token 会被拒绝。
- 客户端收到 Kick 系统消息后必须 `stopLocalAudio`、退出 RTC 房并退出 IM 群；服务端后续 token 签发也会拒绝。
- 下麦成功后客户端必须 `stopLocalAudio`，并在 VoiceChatRoom 场景调用 `switchRole(audience)` 或按 SDK 推荐方式停止发布。
- 客户端只能把 `publishing` 当成“服务端已接受音频发布确认”；`pending_publish` 只能展示为“正在连接麦克风/等待发流确认”。
- 客户端重复确认同一 SDK 成功回调时必须复用同一个 `command_id`；如果没有复用，room-service 仍会用 `mic_session_id + room_version + event_time_ms` 防止旧事件覆盖新会话。

## Security Rules

- UserSig 必须服务端生成。
- SecretKey 不进客户端、不进日志、不进错误响应。
- token endpoint 必须要求 access token。
- token endpoint 必须先查 `VerifyRoomPresence`，不能只要登录就签 RTC token。
- `room_id`、`user_id`、`rtc_user_id`、`request_id` 可以记录日志；`user_sig` 和 `secret_key` 不能记录。
- UserSig 过期只影响 RTC SDK 鉴权，不应改变房间业务 presence。
- 如果 `tencent_rtc.enabled=false` 或配置不完整，接口 fail-closed。
- UserSig 不是房间级权限凭证。首版普通房接受“签发入口校验 + 短 TTL + 客户端退出协议”的边界；需要腾讯侧强制房间准入时必须启用 `privateMapKey`。
- 不启用 `privateMapKey` 时，不要把 `/api/v1/rtc/token` 文案、产品策略或风控策略描述成“腾讯 RTC 后端已强制禁止跨房间进入”。

## Observability

日志字段：

| Field | Meaning |
| --- | --- |
| `request_id` | gateway HTTP 追踪 ID |
| `user_id` | 内部长 ID |
| `rtc_user_id` | 腾讯 RTC UserID |
| `room_id` | 内部房间 ID |
| `str_room_id` | RTC 字符串房间 ID |
| `room_version` | guard 返回的房间版本 |
| `mic_session_id` | 单次上麦发流会话 ID |
| `publish_state` | pending_publish / publishing |
| `publish_deadline_ms` | 发流确认截止时间 |
| `publish_event_time_ms` | 客户端 SDK 或 RTC webhook 事件时间 |
| `result` | issued / denied / config_error / upstream_error |
| `reason` | guard 或配置失败原因 |

指标建议：

```text
gateway_rtc_token_issued_total
gateway_rtc_token_denied_total
gateway_rtc_token_config_error_total
gateway_rtc_token_upstream_error_total
gateway_rtc_token_latency_ms
room_mic_publish_confirmed_total
room_mic_publish_timeout_total
room_mic_publish_stale_event_ignored_total
```

腾讯 RTC 外部进房成功率由客户端 SDK 上报或日志采集，不放进 gateway readiness。

## Implementation Order

1. 新增 `docs/腾讯RTC票据UID映射开发.md` 并确认产品边界。
2. 抽出通用 Tencent UserSig 算法到 `pkg/tencentsig`，保持 IM usersig 测试通过。
3. 新增 `pkg/tencentrtc`，定义 RTC token config/result 和 `GenerateToken`。
4. 扩展 gateway config：`tencent_rtc`。
5. 三份 gateway config 增加 `tencent_rtc` 示例。
6. 把 `config.TencentRTC` 从 `app.New` 装配到 HTTP handler，并补 handler 构造器单测。
7. 新增 gateway `/api/v1/rtc/token` handler 和 route。
8. handler 调 `RoomGuardClient.VerifyRoomPresence`。
9. 在 `CreateRoom` 入口或 room-service 领域层补统一 `room_id` 格式校验，并保持 `/api/v1/rtc/token` 二次校验。
10. 扩展 room proto：`SeatState.publish_state/mic_session_id/publish_deadline_ms`、`MicUpResponse`、`ConfirmMicPublishing`。
11. room-service 在 `MicUp` 写入 `pending_publish`，确认后写 `publishing`，超时自动 `MicDown(reason=publish_timeout)`。
12. gateway 增加 `/api/v1/rooms/mic/publishing/confirm` 并透传 request_id 到 room-service。
13. 更新 `docs/openapi/gateway.swagger.yaml` 和 `docs/openapi/room.swagger.yaml`。
14. 增加 gateway transport、room-service command、stale event 和 timeout 单测。
15. 跑 Phase 2 最小手工联调：JoinRoom 后签 RTC token，客户端用 `strRoomId` 进入语音房，再完成 MicUp -> ConfirmMicPublishing -> MicDown。

## Acceptance Tests

必须覆盖：

| Test | Expected Result |
| --- | --- |
| 已登录且已进房用户签发 RTC token | 返回 `sdk_app_id/user_id/rtc_user_id/user_sig/str_room_id/expire_at_ms` |
| 未登录用户请求 | HTTP 401 envelope |
| 未 JoinRoom 用户请求 | HTTP 403 envelope，不返回 UserSig |
| 房间不存在 | HTTP 404 envelope |
| 被踢用户请求 | HTTP 403 envelope |
| RTC 配置缺失 | HTTP 500 envelope，不返回假 UserSig |
| `room_id` 为空或格式非法 | HTTP 400 envelope |
| CreateRoom 使用不符合 RTC/IM 限制的 `room_id` | HTTP 400 envelope，房间不创建 |
| UserID 映射 | `user_id=10001` 时 `rtc_user_id == "10001"` |
| request_id 透传 | response envelope 和 guard RPC 使用同一个 `request_id` |
| `tencent_rtc.enabled=false` | HTTP 500 envelope，不返回 UserSig |
| `room_id_type` 非 `string` | handler fail-closed，不返回 UserSig |
| IM UserSig 不回归 | 现有 `/api/v1/im/usersig` 测试仍通过 |
| MicUp 后未确认发布 | 麦位进入 `pending_publish`，响应返回 `mic_session_id/publish_deadline_ms` |
| ConfirmMicPublishing 当前 session | 麦位进入 `publishing` |
| ConfirmMicPublishing 旧 session/旧版本/旧 event_time | 不覆盖当前麦位状态 |
| publish deadline 超时 | 自动 `MicDown(reason=publish_timeout)` 并释放麦位 |

建议补充手工验证：

```text
Login
  -> CreateRoom
  -> JoinRoom
  -> Get IM UserSig
  -> IM SDK login and join group
  -> Get RTC token
  -> RTC SDK enterRoom(strRoomId=room_id, userId=rtc_user_id)
  -> MicUp
  -> SeatState.publish_state=pending_publish, client receives mic_session_id
  -> RTC SDK switchRole(anchor)
  -> Client starts publishing audio
  -> ConfirmMicPublishing(mic_session_id, room_version, event_time_ms)
  -> SeatState.publish_state=publishing
  -> Retry stale ConfirmMicPublishing with old mic_session_id/event_time_ms and verify no state rollback
  -> MicDown
  -> Client stops publishing audio and switches to audience
  -> MicUp again but do not confirm
  -> publish_deadline_ms expires and room-service auto MicDown(reason=publish_timeout)
  -> LeaveRoom
  -> RTC token denied for same room
```

## Verification Commands

涉及代码实现后至少运行：

```bash
go test ./pkg/tencentim
go test ./pkg/tencentsig
go test ./pkg/tencentrtc
go test ./services/gateway-service/internal/config
go test ./services/gateway-service/internal/transport/http
go test ./...
```

涉及配置后运行：

```bash
docker compose config
```

涉及 protobuf 时运行：

```bash
make proto
go test ./...
```

## Done Definition

完成标准：

```text
Authenticated user
  -> JoinRoom succeeds
  -> /api/v1/rtc/token checks room-service presence
  -> gateway maps internal user_id to RTC UserID
  -> gateway maps room_id to RTC strRoomId
  -> gateway signs RTC UserSig on server
  -> client enters Tencent RTC room
  -> MicUp creates pending_publish mic_session
  -> client confirms publishing with mic_session_id, room_version, event_time_ms
  -> stale RTC/client events cannot clear a newer mic_session
  -> unconfirmed pending_publish session auto MicDowns after deadline
  -> LeaveRoom/Kick/stale cleanup makes later RTC token request fail
```

只要这条链路稳定，后续麦位上行、角色切换、音频审核和多端 RTC 策略都可以在不破坏 Room Cell 状态边界的前提下继续迭代。
