# 创建房间与 IM 入群 Flutter 对接

本文描述 Flutter App 从创建语音房到加入腾讯云 IM 房间群的调用顺序。当前服务端已改为：`CreateRoom` 返回前同步创建腾讯 IM 群；创建成功后，`data.room.im_group_id` 对应的腾讯 IM 群已经存在。

## 接口地址
涉及接口：

| 方法 | 接口 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/im/usersig` | 获取腾讯 IM SDK 登录票据。 |
| `POST` | `/api/v1/rooms/create` | 创建房间；成功前同步创建腾讯 IM 群。 |
| `POST` | `/api/v1/rooms/join` | 建立 room-service 业务 presence；成功后才调用腾讯 IM `joinGroup`。 |

## 请求头

| Header | 必填 | 示例 | 说明 |
| --- | --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>` | 登录 access token；服务端从 token 读取当前用户 ID。 |
| `X-App-Code` | 否 | `lalu` | App 编码；登录态接口最终以 token 内的 `app_code` 为准。 |
| `Content-Type` | POST 必填 | `application/json` | JSON 请求体。 |

## 正确调用顺序

```text
1. App 登录后获取 access_token
2. GET /api/v1/im/usersig
3. 腾讯 IM SDK login(user_id, user_sig)
4. POST /api/v1/rooms/create
5. POST /api/v1/rooms/join
6. 腾讯 IM SDK joinGroup(join_response.data.im.group_id)
7. 成功后进入房间页并启动 heartbeat / RTC
```

关键规则：

| 规则 | 说明 |
| --- | --- |
| `CreateRoom` 成功代表 IM 群已创建 | 服务端同步调用腾讯 IM `create_group`，失败时创建接口直接失败，不落房间。 |
| `CreateRoom` 后不要直接 `joinGroup` | 腾讯 IM 回调守卫依赖 room-service presence；必须先 `JoinRoom`。 |
| `im_group_id` 当前等于 `room_id` | 客户端仍然必须使用接口返回字段，不能硬编码映射规则。 |
| `joinGroup` 成功或“已在群内”都按成功处理 | 页面重进、SDK 重连或重复 join 时可能出现已在群内。 |
| `command_id` 同一次用户动作重试必须复用 | 创建房间和进房分别使用不同 `command_id`。 |

## 获取 IM UserSig

请求：

```http
GET /api/v1/im/usersig
Authorization: Bearer <access_token>
X-App-Code: lalu
```

成功响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_abc",
  "data": {
    "sdk_app_id": 20036101,
    "user_id": "10001",
    "user_sig": "xxx",
    "expire_at_ms": 1778007200000
  }
}
```

客户端行为：

- `user_id` 是腾讯 IM identifier，直接传给腾讯 IM SDK。
- `user_sig` 按 `expire_at_ms` 缓存；过期前可以复用。
- `UNAUTHORIZED` 或 `PROFILE_REQUIRED` 时不进入房间流程。

## 创建房间

请求：

```http
POST /api/v1/rooms/create
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json

{
  "command_id": "cmd_create_room_10001_1778000000000",
  "seat_count": 10,
  "mode": "voice",
  "room_name": "Live Room",
  "room_avatar": "https://cdn.example/room.png",
  "room_description": "welcome"
}
```

请求字段：

| 字段 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| `command_id` | 是 | string | 创建房间动作幂等键；同一次点击超时重试必须复用。 |
| `seat_count` | 否 | int | 不传或传 `0` 使用后台默认麦位数；正数必须命中后台启用座位数。 |
| `mode` | 是 | string | 当前传 `voice`。 |
| `room_name` | 是 | string | 房间标题，最长 128 个字符。 |
| `room_avatar` | 是 | string | 房间封面 URL；不传或空字符串会返回 `INVALID_ARGUMENT`。 |
| `room_description` | 否 | string | 房间简介，最长 512 个字符。 |

成功响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_abc",
  "data": {
    "result": {
      "applied": true,
      "room_version": 1,
      "server_time_ms": 1778000000123
    },
    "room": {
      "room_id": "lalu_abc123",
      "im_group_id": "lalu_abc123",
      "room_short_id": "10001",
      "title": "Live Room",
      "cover_url": "https://cdn.example/room.png",
      "description": "welcome",
      "owner_user_id": "10001",
      "mode": "voice",
      "status": "active",
      "chat_enabled": true,
      "locked": false,
      "heat": 0,
      "online_count": 1,
      "seat_count": 10,
      "occupied_seat_count": 0,
      "visible_region_id": 1001,
      "version": 1
    },
    "seats": [
      {
        "seat_no": 1,
        "locked": false
      }
    ],
    "im": {
      "group_id": "lalu_abc123",
      "need_join_group": true
    },
    "server_time_ms": 1778000000123
  }
}
```

字段说明：

| 字段 | 说明 |
| --- | --- |
| `data.room.room_id` | 房间 ID；后续 JoinRoom、heartbeat、RTC 都使用它。 |
| `data.room.im_group_id` | 腾讯 IM 房间群 ID；创建成功时群已存在。 |
| `data.im.group_id` | 同一个 IM 群 ID；客户端最终以 JoinRoom 响应里的 `data.im.group_id` 入群。 |
| `data.result.applied` | `true` 表示本次创建写入；同一 `command_id` 重试可能为 `false`。 |

创建失败处理：

| code | HTTP | 客户端处理 |
| --- | --- | --- |
| `INVALID_ARGUMENT` | 400 | 检查必填字段、座位数、标题长度。 |
| `UNAUTHORIZED` | 401 | 回登录。 |
| `PROFILE_REQUIRED` | 403 | 引导补全资料。 |
| `CONFLICT` | 409 | 当前用户已经有房间；调用 `/api/v1/rooms/me` 恢复已有房间。 |
| `UPSTREAM_ERROR` | 502 | 可能是腾讯 IM 建群失败或 room-service 不可用；不要进入房间页，可提示稍后重试。 |

## JoinRoom 后加入 IM 群

创建房间成功后，房主仍然要调用 `JoinRoom`。原因是 IM 入群回调会检查 room-service presence；只创建房间不能替代进房动作。

请求：

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

{
  "room_id": "lalu_abc123",
  "command_id": "cmd_join_room_lalu_abc123_10001_1778000001000",
  "role": "audience"
}
```

成功响应只展示 IM 相关字段：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_def",
  "data": {
    "room": {
      "room_id": "lalu_abc123",
      "im_group_id": "lalu_abc123",
      "version": 2
    },
    "viewer": {
      "user_id": "10001",
      "role": "owner",
      "joined_at_ms": 1778000001000,
      "last_seen_at_ms": 1778000001000
    },
    "im": {
      "group_id": "lalu_abc123",
      "need_join_group": true
    },
    "rtc": {
      "need_token": true,
      "available": true
    },
    "server_time_ms": 1778000001000
  }
}
```

客户端行为：

| 条件 | 行为 |
| --- | --- |
| `data.im.need_join_group=true` | 调腾讯 IM SDK `joinGroup(data.im.group_id)`。 |
| `joinGroup` 成功 | 开始监听房间群 `TIMCustomElem`，继续 RTC 进房。 |
| `joinGroup` 返回已在群内 | 按成功处理。 |
| `joinGroup` 返回群不存在 | 视为异常；当前服务端 CreateRoom 已同步建群，记录 `request_id` 和 `group_id` 上报。 |
| `joinGroup` 返回被拒绝/无权限 | 重新调用 `JoinRoom`；仍失败则退出房间页。 |

## Flutter 示例

下面示例只展示调用边界，项目内可替换为现有 Dio 封装、错误码枚举和腾讯 IM SDK 适配层。

```dart
class VoiceRoomApi {
  VoiceRoomApi(this._dio);

  final Dio _dio;

  Future<TencentIMUserSig> fetchIMUserSig() async {
    final resp = await _dio.get('/api/v1/im/usersig');
    final data = resp.data['data'] as Map<String, dynamic>;
    return TencentIMUserSig.fromJson(data);
  }

  Future<CreateRoomResult> createRoom({
    required String commandId,
    required String roomName,
    String mode = 'voice',
    int seatCount = 0,
    String roomAvatar = '',
    String roomDescription = '',
  }) async {
    final resp = await _dio.post(
      '/api/v1/rooms/create',
      data: {
        'command_id': commandId,
        'seat_count': seatCount,
        'mode': mode,
        'room_name': roomName,
        'room_avatar': roomAvatar,
        'room_description': roomDescription,
      },
    );
    return CreateRoomResult.fromJson(resp.data['data'] as Map<String, dynamic>);
  }

  Future<JoinRoomResult> joinRoom({
    required String roomId,
    required String commandId,
    String role = 'audience',
    String password = '',
  }) async {
    final resp = await _dio.post(
      '/api/v1/rooms/join',
      data: {
        'room_id': roomId,
        'command_id': commandId,
        'role': role,
        if (password.isNotEmpty) 'password': password,
      },
    );
    return JoinRoomResult.fromJson(resp.data['data'] as Map<String, dynamic>);
  }
}
```

IM 登录和入群：

```dart
class VoiceRoomIMBridge {
  Future<void> ensureLoggedIn(TencentIMUserSig sig) async {
    // 根据项目使用的腾讯云 IM Flutter SDK 版本适配。
    // 常见入参是 sdkAppID、userID、userSig。
    await TencentIMSDK.login(
      sdkAppId: sig.sdkAppId,
      userId: sig.userId,
      userSig: sig.userSig,
    );
  }

  Future<void> joinRoomGroup(String groupId) async {
    try {
      await TencentIMSDK.joinGroup(groupId: groupId);
    } on TencentIMException catch (error) {
      if (_isAlreadyInGroup(error)) {
        return;
      }
      throw error;
    }
  }

  bool _isAlreadyInGroup(TencentIMException error) {
    // 按项目实际 SDK 错误码收敛，例如已在群内、重复入群等。
    return error.code == TencentIMErrors.alreadyInGroup;
  }
}
```

创建并进入自己房间：

```dart
Future<void> createAndEnterRoom({
  required VoiceRoomApi api,
  required VoiceRoomIMBridge im,
  required String roomName,
}) async {
  final sig = await api.fetchIMUserSig();
  await im.ensureLoggedIn(sig);

  final create = await api.createRoom(
    commandId: newCommandId('create_room'),
    roomName: roomName,
  );

  final roomId = create.room.roomId;

  final join = await api.joinRoom(
    roomId: roomId,
    commandId: newCommandId('join_room_$roomId'),
  );

  if (join.im.needJoinGroup) {
    await im.joinRoomGroup(join.im.groupId);
  }

  // join.rtc.available=true 时继续 enterRoom；无论是否进 RTC，业务 presence 已经建立。
  openVoiceRoomPage(join);
}
```

`command_id` 生成建议：

```dart
String newCommandId(String action) {
  final now = DateTime.now().millisecondsSinceEpoch;
  final nonce = randomBase32(8);
  return 'cmd_${action}_${now}_$nonce';
}
```

同一次点击因网络超时重试时，必须复用原 `command_id`；用户重新点击创建或进房时再生成新的 `command_id`。

## 状态机建议

| 状态 | 入口 | 成功下一步 | 失败处理 |
| --- | --- | --- | --- |
| `imLogin` | `/im/usersig` + IM `login` | `createRoom` | 回登录或提示 IM 初始化失败。 |
| `createRoom` | `/rooms/create` | `joinRoom` | 不进入房间页；`CONFLICT` 时查询我的房间。 |
| `joinRoom` | `/rooms/join` | `joinIMGroup` | 不调用 `joinGroup`；展示进房失败。 |
| `joinIMGroup` | IM SDK `joinGroup` | `enterRTC` / 打开房间页 | 已在群内按成功；其他错误退出房间页或重试 JoinRoom。 |
| `inRoom` | 房间页 active | heartbeat loop | heartbeat 失败按重连策略处理。 |

## IM 消息监听

房间系统消息通过腾讯 IM 群 `TIMCustomElem` 下发：

| 字段 | 说明 |
| --- | --- |
| `MsgType` | `TIMCustomElem` |
| `Ext` | `room_system_message` |
| `Data` | JSON 字符串，包含 `event_id`、`room_id`、`event_type`、`room_version` 等。 |
| `CloudCustomData` | 与 `Data` 相同，便于漫游和排障。 |

客户端处理规则：

- 按 `event_id` 去重。
- 按 `room_version` 丢弃旧事件。
- 收到无法解析或版本断层时，调用 `GET /api/v1/rooms/snapshot?room_id={room_id}` 拉最新快照。

## 常见错误处理

| 场景 | 客户端动作 |
| --- | --- |
| `/rooms/create` 返回 `UPSTREAM_ERROR` | 不调用 JoinRoom，不进入房间页；提示稍后重试。 |
| `/rooms/create` 返回 `CONFLICT` | 用户已有房间，调用 `/api/v1/rooms/me` 恢复。 |
| `/rooms/join` 返回 `PERMISSION_DENIED` | 可能被 ban 或锁房密码错误；不调用 `joinGroup`。 |
| IM `joinGroup` 群不存在 | 当前不应出现；记录 `request_id`、`room_id`、`group_id` 上报。 |
| IM 登录票据过期 | 重新调用 `/api/v1/im/usersig` 并 `login`。 |
