404 lines
12 KiB
Markdown
404 lines
12 KiB
Markdown
# 创建房间与 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`。 |
|