hyapp-server/docs/flutter对接/房间踢人接口Flutter对接.md
2026-05-14 00:25:02 +08:00

323 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 房间踢人接口 Flutter 对接
本文描述 Flutter App 对接房间踢人能力的 HTTP 接口,以及踢人后腾讯 IM 和腾讯 RTC 的客户端处理方式。踢人事实由 `room-service` Room Cell 提交;客户端不能只靠本地 UI 状态判断用户是否仍在房间。
## 接口地址
`POST /api/v1/rooms/user/kick`
`POST /api/v1/rooms/user/unban`
本地开发地址:
```text
http://127.0.0.1:13000
```
## 请求方法
| 方法 | 接口 | 说明 |
| --- | --- | --- |
| `POST` | `/api/v1/rooms/user/kick` | 踢出房间用户,并写入房间 ban 集合。 |
| `POST` | `/api/v1/rooms/user/unban` | 解除房间 ban允许用户后续重新 JoinRoom。 |
## 请求头
| Header | 必填 | 模拟值 | 说明 |
| --- | --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>` | 登录 access token服务端从 token 读取当前用户 ID。 |
| `X-App-Code` | 否 | `lalu` | App 编码;登录态接口最终以 token 内的 `app_code` 为准。 |
## 踢出房间用户
房主可以踢出房间内用户。当前后端权限矩阵也允许房管管理普通用户;端上如果只开放房主入口,后端仍兼容。
请求体:
| 字段 | 必填 | 类型 | 模拟值 | 说明 |
| --- | --- | --- | --- | --- |
| `room_id` | 是 | string | `room_10001` | 房间 ID。 |
| `command_id` | 是 | string | `cmd_kick_room_10001_20001_1710000000` | 房间命令幂等键;同一次踢人重试必须复用。 |
| `target_user_id` | 是 | int64 | `20001` | 被踢用户长 ID。 |
请求示例:
```http
POST /api/v1/rooms/user/kick
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"room_id": "room_10001",
"command_id": "cmd_kick_room_10001_20001_1710000000",
"target_user_id": 20001
}
```
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"result": {
"applied": true,
"room_version": 18,
"server_time_ms": 1710000000456
},
"room": {
"room_id": "room_10001",
"status": "active",
"ban_user_ids": [20001],
"online_users": [],
"mic_seats": [],
"version": 18
},
"rtc_kicked": true,
"rtc_kick_error": ""
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `data.result.applied` | bool | 本次命令是否产生状态变更;重复踢已 ban 且无残留状态的用户可能为 `false`。 |
| `data.room.ban_user_ids` | int64[] | 当前房间 ban 用户集合;被踢用户会进入该集合。 |
| `data.room.online_users` | array | 当前业务 presence被踢用户会被移除。 |
| `data.room.mic_seats` | array | 如果被踢用户在麦上,对应麦位会被释放。 |
| `data.rtc_kicked` | bool | 服务端是否已调用 TRTC `RemoveUserByStrRoomId` 移除目标用户。 |
| `data.rtc_kick_error` | string | TRTC 服务端踢人失败原因;踢人事实已生效,客户端仍按被踢处理。 |
## 解除房间 ban
解除 ban 只恢复用户后续重新进房资格,不恢复历史 presence、麦位、管理员身份或 RTC 连接。
请求示例:
```http
POST /api/v1/rooms/user/unban
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"room_id": "room_10001",
"command_id": "cmd_unban_room_10001_20001_1710000100",
"target_user_id": 20001
}
```
成功响应字段和踢人一致,`room.ban_user_ids` 中不再包含目标用户。
## IM 和 RTC 交互
踢人成功后有三层效果,客户端都要按幂等方式处理:
| 层 | 服务端行为 | Flutter 处理 |
| --- | --- | --- |
| room-service | 移除目标 presence、释放麦位、写入 ban后续 JoinRoom、RTC token、IM 进群和发言守卫都会拒绝目标用户。 | 被踢用户所有房间接口如果返回 `PERMISSION_DENIED`,立即退出房间页并清理房间态。 |
| 腾讯 IM 群 | room-service outbox worker 先调用 IM REST 把目标用户移出房间群,再发布 `room_user_kicked` 系统消息给房间内其他用户。 | 普通成员收到 `room_user_kicked` 后刷新在线列表和麦位;被踢用户要监听 IM 群被移除或退群回调作为主要退出信号。 |
| 腾讯 IM 单聊 | notice-service 监听 `room_outbox.RoomUserKicked`,给被踢用户发送 C2C `room_notice` 私有通知。 | 被踢用户收到 `room_notice` 时执行同一套本地退出逻辑;按 `event_id` 去重,避免和群消息或接口 403 重复弹窗。 |
| 腾讯 RTC | room-service 在踢人事实提交后尽力调用 TRTC `RemoveUserByStrRoomId`。 | 被踢用户收到 RTC 退房或连接断开回调时调用同一套本地退出逻辑;如果先收到 IM 回调,也要主动 `exitRoom`。 |
客户端退出房间的本地动作必须可重复调用:
1. 停止本地音频上行。
2. 调用 TRTC `exitRoom`
3. 退出或清理当前房间 IM 群会话。
4. 停止房间 heartbeat。
5. 清空当前房间页状态,回到房间列表或 Mine 页面。
6. 弹出被踢提示。
被踢用户不一定能收到房间群里的 `room_user_kicked` 自定义消息,因为服务端会优先把他移出 IM 群。端上必须同时处理 notice-service 的 C2C `room_notice`、IM SDK 的群移除回调、RTC 退房回调和后续接口 403。
## IM 自定义消息
房间内其他用户会收到群 `TIMCustomElem`,核心字段如下:
```json
{
"event_id": "evt_abc",
"room_id": "room_10001",
"event_type": "room_user_kicked",
"actor_user_id": 10001,
"target_user_id": 20001,
"room_version": 18,
"attributes": {}
}
```
处理规则:
| 字段 | 说明 |
| --- | --- |
| `event_type` | 固定 `room_user_kicked`。 |
| `actor_user_id` | 操作者用户 ID系统驱逐时可能为 `0`。 |
| `target_user_id` | 被踢用户 ID。 |
| `room_version` | 房间版本;本地已有更高版本时丢弃旧消息。 |
如果 `target_user_id` 是当前登录用户,直接执行本地退出房间动作;如果不是当前用户,只刷新在线列表、麦位和管理面 UI。
被踢用户会收到 notice-service 发送的 C2C `TIMCustomElem``Ext=room_notice`,核心字段如下:
```json
{
"event_id": "evt_abc",
"event_type": "room_user_kicked",
"source_event_type": "RoomUserKicked",
"app_code": "lalu",
"room_id": "room_10001",
"room_version": 18,
"actor_user_id": 10001,
"target_user_id": 20001,
"occurred_at_ms": 1710000000456,
"source_created_at_ms": 1710000000456
}
```
C2C 通知和房间群消息使用同一个 `event_id`。Flutter 侧把 `room_user_kicked`、IM 群移除、RTC 退房和接口 403 都归一到同一个 `handleKickedFromRoom(roomId, eventId)` 流程;同一个 `event_id` 或同一个房间的短时间重复退出信号只处理一次。
## 错误处理
失败返回仍使用统一 envelope
```json
{
"code": "PERMISSION_DENIED",
"message": "permission denied",
"request_id": "req_abc"
}
```
| HTTP 状态码 | `code` | 典型场景 | 处理方式 |
| --- | --- | --- | --- |
| `400` | `INVALID_ARGUMENT` | `room_id``command_id``target_user_id` 缺失;操作者踢自己。 | 客户端修正参数,不自动重试。 |
| `401` | `UNAUTHORIZED` | token 缺失、无效、过期或会话失效。 | 重新登录。 |
| `403` | `PROFILE_REQUIRED` | 用户资料未完成。 | 跳转资料补全流程。 |
| `403` | `PERMISSION_DENIED` | 非管理角色踢人;房管踢房主;操作者不在房间。 | 隐藏入口或提示无权限。 |
| `404` | `NOT_FOUND` | 房间不存在。 | 刷新列表或退出房间页。 |
| `409` | `ROOM_CLOSED` | 房间已关闭。 | 刷新列表或退出房间页。 |
| `409` | `CONFLICT` | 同一个 `command_id` 被不同请求体复用。 | 为新的用户动作生成新的 `command_id`。 |
| `502` | `UPSTREAM_ERROR` | 内部服务暂不可用。 | 可提示稍后重试。 |
幂等规则:
| 场景 | 返回 |
| --- | --- |
| 同一个 `command_id` 重试同一次踢人 | 返回同一命令结果,不重复变更房间。 |
| 同一个 `command_id` 换另一个 `target_user_id` | `409 CONFLICT`。 |
| 重复踢已 ban 且无残留状态的用户 | `200 OK``applied=false`。 |
| 解封未 ban 用户 | `200 OK``applied=false`。 |
## Flutter 解析示例
```dart
class RoomCommandResult {
RoomCommandResult({
required this.applied,
required this.roomVersion,
required this.serverTimeMs,
});
final bool applied;
final int roomVersion;
final int serverTimeMs;
factory RoomCommandResult.fromJson(Map<String, dynamic> json) {
return RoomCommandResult(
applied: json['applied'] as bool? ?? false,
roomVersion: (json['room_version'] as num?)?.toInt() ?? 0,
serverTimeMs: (json['server_time_ms'] as num?)?.toInt() ?? 0,
);
}
}
class KickUserResult {
KickUserResult({
required this.result,
required this.rtcKicked,
required this.rtcKickError,
required this.banUserIds,
});
final RoomCommandResult result;
final bool rtcKicked;
final String rtcKickError;
final List<int> banUserIds;
factory KickUserResult.fromJson(Map<String, dynamic> json) {
final room = json['room'] as Map<String, dynamic>? ?? const {};
return KickUserResult(
result: RoomCommandResult.fromJson(
json['result'] as Map<String, dynamic>? ?? const {},
),
rtcKicked: json['rtc_kicked'] as bool? ?? false,
rtcKickError: json['rtc_kick_error'] as String? ?? '',
banUserIds: (room['ban_user_ids'] as List<dynamic>? ?? const [])
.map((value) => (value as num).toInt())
.toList(),
);
}
}
class RoomKickedEvent {
RoomKickedEvent({
required this.roomId,
required this.actorUserId,
required this.targetUserId,
required this.roomVersion,
});
final String roomId;
final int actorUserId;
final int targetUserId;
final int roomVersion;
bool isSelf(int currentUserId) => targetUserId == currentUserId;
factory RoomKickedEvent.fromJson(Map<String, dynamic> json) {
return RoomKickedEvent(
roomId: json['room_id'] as String? ?? '',
actorUserId: (json['actor_user_id'] as num?)?.toInt() ?? 0,
targetUserId: (json['target_user_id'] as num?)?.toInt() ?? 0,
roomVersion: (json['room_version'] as num?)?.toInt() ?? 0,
);
}
}
class RoomKickNotice {
RoomKickNotice({
required this.eventId,
required this.roomId,
required this.actorUserId,
required this.targetUserId,
required this.roomVersion,
});
final String eventId;
final String roomId;
final int actorUserId;
final int targetUserId;
final int roomVersion;
factory RoomKickNotice.fromJson(Map<String, dynamic> json) {
return RoomKickNotice(
eventId: json['event_id'] as String? ?? '',
roomId: json['room_id'] as String? ?? '',
actorUserId: (json['actor_user_id'] as num?)?.toInt() ?? 0,
targetUserId: (json['target_user_id'] as num?)?.toInt() ?? 0,
roomVersion: (json['room_version'] as num?)?.toInt() ?? 0,
);
}
}
```
调用 HTTP 接口时先解析外层 envelope只有 `code == "OK"` 时读取 `data`;其他 `code` 按登录态、参数错误、权限错误或服务错误处理,并把 `request_id` 写入客户端日志。
客户端生成 `command_id` 时按一次用户动作生成一次,同一次 HTTP 重试复用原 `command_id`;用户再次点击踢人时生成新的 `command_id`