# 创建房间与 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;服务端从 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 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 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 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 fetchIMUserSig() async { final resp = await _dio.get('/api/v1/im/usersig'); final data = resp.data['data'] as Map; return TencentIMUserSig.fromJson(data); } Future 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); } Future 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); } } ``` IM 登录和入群: ```dart class VoiceRoomIMBridge { Future ensureLoggedIn(TencentIMUserSig sig) async { // 根据项目使用的腾讯云 IM Flutter SDK 版本适配。 // 常见入参是 sdkAppID、userID、userSig。 await TencentIMSDK.login( sdkAppId: sig.sdkAppId, userId: sig.userId, userSig: sig.userSig, ); } Future 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 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`。 |