# Voice Room Client API Flow

本文定义用户进入语音房后的客户端接口调用顺序、触发条件和服务边界。本文只描述 App 侧要调用的 gateway HTTP 接口、腾讯云 IM/RTC SDK 动作，以及不能由客户端直接调用的内部守卫接口。

## Scope

| Area | Owner | Rule |
| --- | --- | --- |
| HTTP 入口 | `gateway-service` | 鉴权、profile gate、协议转换、腾讯 IM/RTC token 签发 |
| 房间业务状态 | `room-service` | Room Cell、presence、麦位、房管、snapshot、outbox、房间关注关系 |
| 房间长连接和公屏 | 腾讯云 IM SDK | 客户端登录、房间群、公屏、系统消息接收 |
| 真实音频频道 | 腾讯云 RTC SDK | 进 RTC 房、切换 audience/anchor、发音频 |
| 用户主数据 | `user-service` | 登录、资料完成状态、用户区域、push token |

关键边界：

- 客户端进入语音房必须先 `JoinRoom` 写入 room-service 业务 presence，再进 IM 群和 RTC 房。
- `room-service` 不保存客户端 socket，也不保存腾讯云 RTC 连接态。
- 腾讯云 IM 群成员关系不是业务 presence；是否允许进群、发言、签 RTC token 仍以 room-service 守卫为准。
- gateway 只使用 access token 中的 `user_id` 作为 actor，客户端不能自报 `actor_user_id`、`owner_user_id`、`host_user_id`。
- 所有 `/api/v1` 业务响应使用 `{code,message,request_id,data}` envelope。

## Minimal Enter Room Flow

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

  C->>G: GET /api/v1/im/usersig
  G-->>C: IM user_sig
  C->>IM: login(user_id, user_sig)

  C->>G: GET /api/v1/rooms/me
  G->>R: GetMyRoom(owner_user_id from token)
  R-->>G: owner room card from rooms + snapshot
  G-->>C: has_room + room card + im_group_id

  C->>G: GET /api/v1/rooms/feeds?tab=visited|friend|following|followed
  opt tab=friend|following
    G->>U: ListFriends/ListFollowing(cursor)
    U-->>G: related user ids + relation times
  end
  G->>R: ListRoomFeeds(viewer region + related users)
  R-->>G: room cards
  G-->>C: room cards + im_group_id

  C->>G: POST /api/v1/rooms/join
  G->>R: JoinRoom(actor_user_id from token)
  R-->>G: room snapshot + user presence
  G-->>C: room initial data + rtc token

  par realtime bootstrap
    C->>IM: joinGroup(im_group_id)
  and audio bootstrap
    C->>RTC: enterRoom(strRoomId from join.rtc.token, audience)
  end

  C->>G: GET /api/v1/rooms/snapshot
  G-->>C: optional active-room resync

  C->>G: GET /api/v1/rooms/{room_id}/detail
  G-->>C: render-ready room detail package + is_followed

  opt user toggles room follow
    C->>G: POST|DELETE /api/v1/rooms/{room_id}/follow
    G->>R: FollowRoom|UnfollowRoom(user_id from token)
    R-->>G: current follow state
    G-->>C: is_followed
  end

  loop while room page is active
    C->>G: POST /api/v1/rooms/heartbeat
  end
```

### Required Order

| Step | Interface | Required | Reason |
| --- | --- | --- | --- |
| 1 | `GET /api/v1/im/usersig` | yes | 用户级 IM 登录票据，可在 App 启动后预取并按 TTL 复用 |
| 2 | Tencent IM SDK `login` | yes | 客户端先建立 IM 身份，房间群仍等 JoinRoom 成功后再进 |
| 3 | `GET /api/v1/rooms/me` | yes for Mine top card | 直接读取 owner 房间权威元数据，不依赖发现页投影 |
| 4 | `GET /api/v1/rooms/feeds?tab=visited\|friend\|following\|followed` | yes for Mine lists | Mine 列表 tab；`visited` 由 JoinRoom 成功写入，`friend/following` 由 gateway 先查 user-service 关系，`followed` 直接读取 room-service 的房间关注关系 |
| 5 | `POST /api/v1/rooms/join` | yes | 建立业务 presence，并返回首屏房间数据和内嵌 RTC token |
| 6 | Tencent IM SDK `joinGroup(im_group_id)` | yes | 必须在 JoinRoom 成功后执行，IM 回调守卫会校验 room-service presence |
| 7 | Tencent RTC SDK `enterRoom` | yes for voice room audio | 使用 JoinRoom 响应里的 `rtc.token` 进入真实音频频道旁听 |
| 8 | `POST /api/v1/rooms/heartbeat` | yes | 刷新业务 presence，避免 stale timeout |
| 9 | `GET /api/v1/rooms/{room_id}/detail` | optional after join/restore | 详情页聚合包；返回 `is_followed`，用于关注按钮初始状态 |
| 10 | `POST /api/v1/rooms/{room_id}/follow` / `DELETE /api/v1/rooms/{room_id}/follow` | user action | 建立或取消当前用户对房间的关注关系；gateway 只使用 token user_id |
| 11 | `GET /api/v1/rooms/{room_id}/gift-panel` | lazy on gift button | 打开礼物面板时再拉，不放进房间首屏 |
| 12 | `GET /api/v1/rooms/{room_id}/online-users` | lazy on online count | 点击右上角在线人数时分页拉取 |

如果 JoinRoom 响应里的 `rtc.available=false`，客户端可以先展示房间页并重试 `POST /api/v1/rtc/token`；RTC 失败不能回滚已经成功的业务 presence。

### Mine Feed Cache Boundary

当前 `friend/following/followed` 不强制加 Redis。原因是关注、取关、同意好友和删除好友都要求列表立即反映关系事实；`friend/following` 直接读 user-service MySQL 关系表，`followed` 直接读 room-service 的 `room_follows`，再查 active 房间卡片，正确性更直接。

后续可以加 Redis 的位置：

- `hot/new` 公共列表：room-service 用 Redis ZSET 缓存排序 room_id，miss 后回源 `room_list_entries`。
- 房间卡片短 TTL 缓存：key 可按 `app_code + region_id + room_id`，但 MySQL `room_list_entries` 仍是事实来源。
- `friend/following` 关系 ID 短 TTL 缓存：只能放在 gateway 或 user-service 读优化层，`follow/unfollow/accept/delete` 成功后必须主动失效。
- `followed` 房间 ID 短 TTL 缓存：只能放在 room-service 读优化层，`FollowRoom/UnfollowRoom` 成功后必须主动失效。
- `visited` room_id 列表缓存：可以由 JoinRoom 成功后刷新，Redis 写失败不能影响 JoinRoom。

不建议首版缓存 `friend/following/followed` 完整房间卡片列表。这个列表同时依赖关系事实、room-service 房间 active 状态、区域过滤和搜索条件，失效面较大；先保持同步查询，等 QPS 或延迟数据证明需要缓存再加。

## Core Interfaces

### Room List Cards

```http
GET /api/v1/rooms?tab=hot&query=100001
GET /api/v1/rooms/feeds?tab=followed
Authorization: Bearer <access_token>
```

Rules:

- 公共发现列表、房间搜索和 Mine 页房间流都返回同一套房间卡片字段。
- `locked=true` 表示 JoinRoom 需要 `password`；列表和搜索只返回锁房标识，不返回明文或哈希。
- `im_group_id` 只代表腾讯 IM 目标群，不能绕过 JoinRoom 的锁房、ban 和 presence 校验。

### Join Room

```http
POST /api/v1/rooms/join
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request:

```json
{
  "room_id": "lalu_xxx",
  "command_id": "cmd_join_<room_id>_<user_id>_<nonce>",
  "role": "audience",
  "password": "1234"
}
```

Response `data`:

```json
{
  "result": {
    "applied": true,
    "room_version": 2,
    "server_time_ms": 1778000000000
  },
  "room": {
    "room_id": "lalu_xxx",
    "im_group_id": "lalu_xxx",
    "room_short_id": "100001",
    "title": "Live Room",
    "cover_url": "https://cdn.example/room.png",
    "description": "welcome",
    "owner_user_id": "10001",
    "host_user_id": "10001",
    "mode": "voice",
    "status": "active",
    "chat_enabled": true,
    "locked": true,
    "heat": 1000,
    "online_count": 26,
    "seat_count": 15,
    "occupied_seat_count": 2,
    "version": 2
  },
  "viewer": {
    "user_id": "10001",
    "role": "audience",
    "joined_at_ms": 1778000000000,
    "last_seen_at_ms": 1778000000000
  },
  "seats": [
    {
      "seat_no": 1,
      "user_id": "10002",
      "locked": false,
      "publish_state": "publishing",
      "mic_session_id": "mic_xxx"
    }
  ],
  "contribution_rank": [
    {
      "user_id": "10002",
      "score": 100,
      "gift_value": 100
    }
  ],
  "profiles": [
    {
      "user_id": "10002",
      "display_user_id": "200002",
      "username": "host",
      "avatar": "https://cdn.example/avatar.png"
    }
  ],
  "im": {
    "group_id": "lalu_xxx",
    "need_join_group": true
  },
  "rtc": {
    "need_token": true,
    "available": true,
    "token": {
      "sdk_app_id": 20036101,
      "user_id": "10001",
      "rtc_user_id": "10001",
      "user_sig": "xxx",
      "expire_at_ms": 1778007200000,
      "room_id": "lalu_xxx",
      "rtc_room_id": "lalu_xxx",
      "rtc_room_id_type": "string",
      "str_room_id": "lalu_xxx",
      "app_scene": "voice_chat_room",
      "role": "audience"
    }
  },
  "server_time_ms": 1778000000000
}
```

Rules:

- `room_id` 必须来自房间列表、分享或恢复接口，不能由客户端拼业务含义。
- `command_id` 由客户端按用户动作生成，用于房间命令幂等；重试同一次进房必须复用同一个值。
- `role` 只作为展示默认值，不能通过自报 `admin/host/owner` 获得权限。
- 带锁房间的新进入用户必须传正确 `password`；密码错误返回 403，不创建 presence，不签发 RTC token。
- 房主不需要密码进入自己的房间；已在房内的用户不会因为房主重新设置密码被挤出。
- `im.group_id` 当前等于 `room_id`，但客户端必须使用服务端返回值，不能硬编码映射规则。
- JoinRoom 成功后才能调用腾讯 IM SDK `joinGroup`；列表里的 `im_group_id` 只用于准备，不代表入群授权。
- JoinRoom 会尝试内嵌签发 RTC token；签发失败时返回 `rtc.available=false`，客户端可以继续展示房间并单独重试 `/api/v1/rtc/token`。

### Room Detail

```http
GET /api/v1/rooms/{room_id}/detail
Authorization: Bearer <access_token>
```

Response `data`:

```json
{
  "room": {},
  "viewer": {},
  "seats": [],
  "rank_top": [],
  "online_count": 30,
  "is_followed": true,
  "permissions": {},
  "profiles": [],
  "im": {},
  "rtc": {}
}
```

Rules:

- `detail` 只读 Room Cell/snapshot，不刷新 heartbeat，不隐式进房。
- viewer 必须仍在 room-service presence 内；未进房、被踢或被 ban 返回 `PERMISSION_DENIED`。
- `is_followed` 来自 room-service 的 `room_follows` 关系表，只表达当前 token 用户是否关注该房间，不写入 Room Cell 快照。
- `permissions.can_close_room` 表示设计稿右上角电源按钮可用，但当前语义是“退出房间”，不是关闭房间生命周期。
- `profiles` 只返回首屏需要的 owner、host、viewer、麦位用户和榜单 top 用户。完整在线用户资料走在线用户分页或 `users/room-display-profiles:batch`。

### Follow Room

```http
POST /api/v1/rooms/{room_id}/follow
DELETE /api/v1/rooms/{room_id}/follow
Authorization: Bearer <access_token>
```

Response `data`:

```json
{
  "room_id": "lalu_xxx",
  "is_followed": true,
  "followed_at_ms": 1700000000123,
  "server_time_ms": 1700000000123
}
```

Rules:

- POST 重复关注保持幂等；已 active 的关注不会刷新 `followed_at_ms`，避免列表排序被重复点击打乱。
- DELETE 未关注或历史关系已取消时按幂等成功返回 `is_followed=false`。
- 关注列表走 `GET /api/v1/rooms/feeds?tab=followed`，只返回当前仍 active 且命中 viewer 区域桶的房间卡片。

### Online Users

```http
GET /api/v1/rooms/{room_id}/online-users?page=1&page_size=50
Authorization: Bearer <access_token>
```

Response `data`:

```json
{
  "items": [
    {
      "user_id": "10002",
      "role": "audience",
      "last_seen_at_ms": 1778000000000,
      "profile": {}
    }
  ],
  "total": 30,
  "page": 1,
  "page_size": 50,
  "server_time_ms": 1778000000000
}
```

Rules:

- 该接口读 `room_user_presence` 分页投影，不拉完整 `RoomSnapshot` 做列表分页。
- `page_size` 默认 50，服务端最大 100。
- 用户展示资料失败不影响在线列表主数据，客户端可用 `GET /api/v1/users/room-display-profiles:batch` 补偿。

### Gift Panel

```http
GET /api/v1/rooms/{room_id}/gift-panel
Authorization: Bearer <access_token>
```

Response `data`:

```json
{
  "coin_balance": 1123123,
  "recipients": [],
  "tabs": [],
  "gifts": [],
  "quantity_presets": [1, 9, 99, 999]
}
```

Rules:

- 打开礼物面板时再请求，不放进 `rooms/detail`，避免房间首屏每次都查钱包和礼物大列表。
- `gifts` 来自 wallet-service 礼物配置，按房间 `visible_region_id` 过滤。
- `recipients` 首版包含麦上用户；超过一个麦上用户时附带 `target_type=all_mic` 占位。
- `coin_balance` 只查 `COIN` 余额；余额不足时送礼接口仍以 wallet-service 扣费结果为准。

### Room Display Profiles

```http
GET /api/v1/users/room-display-profiles:batch?user_ids=10001,10002
Authorization: Bearer <access_token>
```

Response `data`:

```json
{
  "profiles": [
    {
      "user_id": "10002",
      "username": "Jack",
      "avatar": "https://cdn.example/avatar.png",
      "display_user_id": "123456",
      "vip": {},
      "level": {},
      "badges": [],
      "avatar_frame": {},
      "profile_card": {
        "resource_id": 8104,
        "animation_url": "https://cdn.example/profile-card.mp4",
        "metadata_json": "{\"profile_card_layout\":{\"source_height\":1680,\"content_top\":143,\"content_height\":1525}}"
      },
      "vehicle": {},
      "charm": 10
    }
  ]
}
```

Rules:

- 该接口专门服务房间麦位、公屏和礼物动效展示；不要复用“我的页”或用户主页重接口。
- 当前已接入用户基础资料，`vip/level/badges/avatar_frame/profile_card/vehicle/charm` 保持稳定字段；资料卡高度从 `profile_card.metadata_json.profile_card_layout` 解析。

### IM UserSig

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

Response `data`:

```json
{
  "sdk_app_id": 20036101,
  "user_id": "10001",
  "user_sig": "xxx",
  "expire_at_ms": 1778086400000,
  "join_groups": [
    {
      "group_id": "hy_lalu_bc_g_v2",
      "type": "global_broadcast"
    },
    {
      "group_id": "hy_lalu_bc_r_v2_1001",
      "type": "region_broadcast",
      "region_id": 1001
    }
  ]
}
```

Rules:

- 当前 gateway 路由是 `GET /api/v1/im/usersig`。
- `user_id` 是内部不可变长 `user_id` 的十进制字符串，必须直接作为腾讯云 IM identifier。
- UserSig 只用于腾讯云 IM SDK 登录，不用于本仓库鉴权。
- 未完成资料用户会被 profile gate 拒绝。

Client SDK action:

```text
Tencent IM SDK login(user_id, user_sig)
Tencent IM SDK joinGroup(join_response.im.group_id)
```

### RTC Token

`POST /api/v1/rtc/token` 是 JoinRoom 内嵌签发失败、token 过期或 RTC 重连时的补偿入口；首次进房应优先使用 JoinRoom 响应里的 `rtc.token`。

```http
POST /api/v1/rtc/token
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request:

```json
{
  "room_id": "lalu_xxx"
}
```

Response `data`:

```json
{
  "sdk_app_id": 20036101,
  "user_id": "10001",
  "rtc_user_id": "10001",
  "user_sig": "xxx",
  "expire_at_ms": 1778007200000,
  "room_id": "lalu_xxx",
  "rtc_room_id": "lalu_xxx",
  "rtc_room_id_type": "string",
  "str_room_id": "lalu_xxx",
  "app_scene": "voice_chat_room",
  "role": "audience"
}
```

Rules:

- gateway 会先调用 room-service `VerifyRoomPresence`，用户不在房间、被踢、被 ban 时不会签发 RTC token。
- 当前只支持 TRTC `strRoomId`，客户端不能把 `room_id` 转为数字 `roomId`。
- 当前 token 返回 `role=audience`；上麦成功后由客户端切 `anchor` 并确认发流。
- token 过期时重新调用该接口，不使用 refresh token。

Client SDK action:

```text
Tencent RTC SDK enterRoom(strRoomId=resp.str_room_id, userId=resp.rtc_user_id, role=audience)
```

### Update Room Profile

房主在房间内修改房间资料和座位数时调用：

```http
POST /api/v1/rooms/profile/update
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request:

```json
{
  "room_id": "lalu_xxx",
  "command_id": "cmd_profile_<room_id>_<user_id>_<nonce>",
  "room_name": "Live Room",
  "room_avatar": "https://cdn.example/room.png",
  "room_description": "welcome",
  "seat_count": 20
}
```

Rules:

- 只有当前在房间内的 owner 可以执行。
- `room_name`、`room_avatar`、`room_description`、`seat_count` 都是可选字段；客户端只传本次要修改的字段。
- `seat_count` 只能传后台已启用的 `10/15/20/25/30` 子集，当前默认配置全部启用。
- 创建房间可以省略 `seat_count` 使用后台默认值；修改资料时不能传 `0`。
- 缩小座位数时，被删除的高编号麦位必须全部空闲且未锁，否则返回 `conflict`，客户端需要先处理麦位再重试。
- 成功响应返回最新 `room` 和 `seats`，客户端直接刷新房间标题、头像、简介和麦位列表。
- 成功后房间系统消息事件名为 `room_profile_updated`。

### Room Backgrounds

房间背景图是房间维度素材；保存素材不立即改变当前房间背景，设置背景会写入 Room Cell 快照，`GET /api/v1/rooms/{room_id}/detail` 的 `data.room.background_url` 返回当前生效背景。

```http
POST /api/v1/rooms/backgrounds/save
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request:

```json
{
  "room_id": "lalu_xxx",
  "image_url": "https://cdn.example/room-bg.png"
}
```

```http
GET /api/v1/rooms/{room_id}/backgrounds
Authorization: Bearer <access_token>
```

Response `data.backgrounds[]` 返回 `background_id`、`image_url`、创建人和时间；`data.selected_background_url` 是当前生效背景。

```http
POST /api/v1/rooms/backgrounds/set
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request:

```json
{
  "room_id": "lalu_xxx",
  "command_id": "cmd_background_<room_id>_<user_id>_<nonce>",
  "background_id": "101"
}
```

Rules:

- 只有房主可以保存、查询和设置自己房间的背景图。
- `image_url` 应先通过上传接口拿到对象存储 URL，再传给保存接口。
- 设置成功后返回最新 `room.background_url`，并通过 `room_background_changed` 房间系统消息同步；消息 `attributes` 包含 `background_id` 和 `room_background_url`。

### Heartbeat

```http
POST /api/v1/rooms/heartbeat
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request:

```json
{
  "room_id": "lalu_xxx",
  "command_id": "cmd_heartbeat_<room_id>_<user_id>_<tick>"
}
```

Rules:

- 只刷新已经存在的业务 presence，不能替代 JoinRoom。
- 用户已经离房、被踢或房间关闭时，客户端应停止心跳并清理本地房间态。
- 心跳间隔由客户端配置控制，但必须小于 room-service 的 presence stale 窗口。

### Leave Room

```http
POST /api/v1/rooms/leave
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request:

```json
{
  "room_id": "lalu_xxx",
  "command_id": "cmd_leave_<room_id>_<user_id>_<nonce>"
}
```

Rules:

- 主动关闭房间页、切换账号、被客户端确认退出时调用。
- 如果用户在麦上，LeaveRoom 会释放麦位。
- LeaveRoom 后客户端必须停止 heartbeat、退出 RTC 房、退出或忽略 IM 房间群消息。

## Mic Flow

### Mic Up

```http
POST /api/v1/rooms/mic/up
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request:

```json
{
  "room_id": "lalu_xxx",
  "command_id": "cmd_mic_up_<room_id>_<user_id>_<nonce>",
  "seat_no": 1
}
```

Response `data`:

```json
{
  "result": {},
  "seat_no": 1,
  "mic_session_id": "mic_xxx",
  "publish_deadline_ms": 1778000015000,
  "room": {}
}
```

Client actions after success:

1. 保存 `mic_session_id`、`room.version` 和 `publish_deadline_ms`。
2. 确保已有 RTC token；没有或过期则调用 `POST /api/v1/rtc/token`。
3. RTC SDK 切换 `anchor`。
4. RTC SDK 调 `startLocalAudio`。
5. SDK 本地发流成功后调用 `POST /api/v1/rooms/mic/publishing/confirm`。

Rules:

- `MicUp` 成功只代表业务麦位占用成功，不代表 RTC 已经发布音频成功。
- 服务端状态先进入 `pending_publish`。
- 超过 `publish_deadline_ms` 未确认，room-service 会自动 MicDown，reason 为 `publish_timeout`。

### Confirm Mic Publishing

```http
POST /api/v1/rooms/mic/publishing/confirm
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request:

```json
{
  "room_id": "lalu_xxx",
  "command_id": "cmd_confirm_publish_<room_id>_<user_id>_<nonce>",
  "target_user_id": 0,
  "mic_session_id": "mic_xxx",
  "room_version": 12,
  "event_time_ms": 1778000001000,
  "source": "client"
}
```

Rules:

- `target_user_id=0` 表示确认当前 actor；RTC webhook 路径才需要显式 target。
- `mic_session_id` 必须等于当前麦位会话。
- `room_version` 和 `event_time_ms` 用于丢弃旧事件，避免旧 audience/leave 事件清理新会话。
- 成功后麦位 `publish_state=publishing`。

### Mic Heartbeat

地址：

```http
POST /api/v1/rooms/mic/heartbeat
Authorization: Bearer <access_token>
Content-Type: application/json
```

参数：

```json
{
  "room_id": "lalu_xxx",
  "command_id": "cmd_mic_heartbeat_<room_id>_<user_id>_<nonce>",
  "target_user_id": 0,
  "mic_session_id": "mic_xxx"
}
```

返回值：

```json
{
  "result": {},
  "seat_no": 1,
  "mic_heartbeat_at_ms": 1778000005000,
  "room": {}
}
```

相关 IM：

- 无独立 IM。该接口只刷新当前 `publishing` 麦位会话的服务端心跳时间和房间 presence。
- `target_user_id=0` 表示当前登录用户自己。
- `mic_session_id` 必须等于当前麦位会话。
- `pending_publish` 不能调用该接口开始计时，必须先调用 `POST /api/v1/rooms/mic/publishing/confirm`。
- 重试同一次心跳复用同一个 `command_id`；下一次心跳必须换新的 `command_id`。

### Mic Down

```http
POST /api/v1/rooms/mic/down
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request:

```json
{
  "room_id": "lalu_xxx",
  "command_id": "cmd_mic_down_<room_id>_<user_id>_<nonce>",
  "target_user_id": 0,
  "reason": "user_action"
}
```

Rules:

- 普通用户下自己麦时 `target_user_id` 可传 0 或自己 user_id。
- 房管下别人麦时传目标用户 `target_user_id`。
- 客户端收到成功响应或房间系统消息后必须停止本地音频并切回 `audience`。

### Change Mic Seat

```http
POST /api/v1/rooms/mic/change
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request:

```json
{
  "room_id": "lalu_xxx",
  "command_id": "cmd_change_mic_<room_id>_<actor_user_id>_<nonce>",
  "target_user_id": 10002,
  "seat_no": 2
}
```

Rules:

- 换麦位只更新业务麦位和 UI，不需要重新进入 RTC。
- 如果 target 当前在发流，客户端继续保持 RTC 上行。

## Room Management APIs

这些接口只允许有权限的 owner/host/admin 调用；gateway 不判断权限，最终由 room-service Room Cell 判断。

| Interface | Body | Result |
| --- | --- | --- |
| `POST /api/v1/rooms/mic/lock` | `room_id, command_id, seat_no, locked` | 锁/解锁麦位 |
| `POST /api/v1/rooms/mic/mute` | `room_id, command_id, target_user_id, muted` | 修改麦克风静音状态；该状态会同步给房间其他用户 |
| `POST /api/v1/rooms/chat/enabled` | `room_id, command_id, enabled` | 开/关公屏，影响发言守卫 |
| `POST /api/v1/rooms/password` | `room_id, command_id, locked, password` | 房主设置或清空入房密码；成功后房间快照、列表和搜索的 `locked` 同步刷新 |
| `POST /api/v1/rooms/admin/set` | `room_id, command_id, target_user_id, is_admin` | 设置或取消房管 |
| `POST /api/v1/rooms/host/transfer` | `room_id, command_id, target_user_id` | 转移主持人 |
| `POST /api/v1/rooms/user/mute` | `room_id, command_id, target_user_id, muted, reason` | 禁言或解禁 |
| `POST /api/v1/rooms/user/kick` | `room_id, command_id, target_user_id, reason` | 踢人并 ban，释放 presence 和麦位 |
| `POST /api/v1/rooms/user/unban` | `room_id, command_id, target_user_id, reason` | 解除房间 ban |
| `POST /api/v1/rooms/close` | `room_id, command_id` | 设计稿电源按钮：当前用户退出房间，不关闭房间 |

Management event handling:

- 管理动作成功后，room-service 写 command log、snapshot 和 outbox。
- 踢人成功后 room-service 会移除业务 presence、释放麦位、写入房间 ban，并通过 room-service 房间 IM bridge 补偿移出腾讯 IM 房间群。
- 被踢本人私有通知由 notice-service 监听 `room_outbox.RoomUserKicked` 后发送 C2C `room_notice`；notice-service 不负责建群、踢群成员或房间群系统消息。
- 踢人成功后 room-service 会尽力调用 TRTC `RemoveUserByStrRoomId` 移出 RTC 房间；失败不回滚踢人事实，客户端仍必须按 IM/RTC 回调和后续 403 兜底清理。
- 客户端应通过腾讯云 IM 房间系统消息、notice-service C2C 私有通知、IM 群成员移除回调和 RTC 退房回调更新 UI。
- 如果本地 UI 状态和系统消息冲突，以最新 room snapshot 和 `room_version` 为准。

## Gift And Interaction

```http
POST /api/v1/rooms/gift/send
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request:

```json
{
  "room_id": "lalu_xxx",
  "command_id": "cmd_send_gift_<room_id>_<user_id>_<nonce>",
  "target_type": "user",
  "target_user_ids": [10002],
  "gift_id": "rose",
  "gift_count": 1
}
```

Rules:

- `SendGift` 必须先由 wallet-service 扣费成功，再落房间表现。
- 旧客户端的 `target_user_id` 仍兼容；新客户端统一传 `target_type + target_user_ids`。
- 当前已支持 `target_type=user`；`all_mic/all_room/couple` 先保留协议字段，等账务拆单和房间表现策略补齐后开放。
- 房间表现、热度、本地榜通过 room snapshot 和房间系统消息同步。
- 公屏文本消息不走 gateway HTTP；客户端使用腾讯云 IM 群消息。

## Restore And Reconnect

### Current Room

```http
GET /api/v1/rooms/current
Authorization: Bearer <access_token>
```

Response `data`:

```json
{
  "has_current_room": true,
  "room_id": "lalu_xxx",
  "room_version": 12,
  "role": "audience",
  "mic_session_id": "",
  "publish_state": "",
  "need_join_im_group": true,
  "need_rtc_token": false,
  "server_time_ms": 1778000000000
}
```

Client restore rules:

| Response | Client Action |
| --- | --- |
| `has_current_room=false` | 清理本地房间态，不展示恢复入口 |
| `need_join_im_group=true` | 调 `GET /api/v1/im/usersig`，重新登录 IM 并 join group |
| `need_rtc_token=true` | 调 `POST /api/v1/rtc/token`，重新进入 RTC |
| `publish_state=pending_publish` | 重新执行 SDK 发流并确认当前 `mic_session_id` |
| `publish_state=publishing` | 确认本地 RTC 上行是否仍存在；不确定时重新发流并确认 |

`rooms/current` 是只读恢复探测，不会隐式 JoinRoom，不刷新 heartbeat，也不改变 Room Cell。

### Reconnect Order

1. access token 有效时先调 `GET /api/v1/rooms/current`。
2. 如果 `has_current_room=false`，回房间列表或主界面。
3. 如果 `has_current_room=true`，先恢复 IM，再恢复 RTC。
4. 如果用户原本在麦上，必须使用返回的 `mic_session_id` 继续确认或等待服务端超时下麦。
5. 恢复成功后恢复 `POST /api/v1/rooms/heartbeat` 定时器。

## Server Callback Boundaries

客户端不要直接调用这些接口或内部 RPC：

| Interface | Caller | Purpose |
| --- | --- | --- |
| `POST /api/v1/tencent-im/callback` | Tencent IM | 入群、发言等服务端回调入口 |
| `POST /api/v1/tencent-rtc/callback` | Tencent RTC | RTC 音频事件回调入口 |
| `room-service.VerifyRoomPresence` | gateway / callback | 判断用户是否仍在房间、是否被 ban |
| `room-service.CheckSpeakPermission` | gateway / callback | 判断用户是否允许发公屏 |

客户端只消费这些回调产生的房间系统消息和最新 snapshot，不直接操作内部守卫。

## Error Handling

| Error | Meaning | Client Action |
| --- | --- | --- |
| `UNAUTHORIZED` / `AUTH_REQUIRED` | access token 无效 | refresh 成功后重试；refresh 失败回登录 |
| `PROFILE_REQUIRED` | 资料未完成 | 跳资料补全页，不能进入房间 |
| `NOT_FOUND` | 房间或用户事实不存在 | 清本地目标房间，回列表 |
| `ROOM_CLOSED` | 房间已关闭 | 清房间态，回列表 |
| `PERMISSION_DENIED` | 被踢、被 ban、无权限 | 停止对应操作，按系统消息更新 UI |
| `CONFLICT` | 麦位占用、命令冲突、状态冲突 | 拉最新 snapshot 或等待房间系统消息修正 |
| `UPSTREAM_ERROR` | 内部服务或腾讯云依赖异常 | 保留本地登录态，展示重试 |

不要因为普通网络失败或 5xx 清空登录态。只有 refresh 明确失败、session revoked 或用户主动 logout 才清 token。

## Room Snapshot Sync

当前客户端拿完整房间状态的来源：

- `JoinRoom` 响应里的 room snapshot。
- `GET /api/v1/rooms/{room_id}/detail` 的首屏聚合包。
- 房间命令响应里的 room snapshot。
- 腾讯云 IM 房间系统消息。
- `rooms/current` 的恢复摘要。
- `GET /api/v1/rooms/snapshot?room_id=<room_id>` 主动同步接口。

```http
GET /api/v1/rooms/snapshot?room_id=<room_id>
```

该接口只读 Room Cell/snapshot，不刷新 presence，不隐式 JoinRoom，不替代 heartbeat。gateway 使用 access token user_id 作为 viewer，room-service 要求 viewer 仍在该房间；未进房、被踢或被 ban 的用户不能读取完整在线用户和麦位快照。
