11 KiB
房间踢人接口 Flutter 对接
本文描述 Flutter App 对接房间踢人能力的 HTTP 接口,以及踢人后腾讯 IM 和腾讯 RTC 的客户端处理方式。踢人事实由 room-service Room Cell 提交;客户端不能只靠本地 UI 状态判断用户是否仍在房间。
接口地址
POST /api/v1/rooms/user/kick
POST /api/v1/rooms/user/unban
本地开发地址:
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。 |
请求示例:
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
}
成功响应:
{
"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 连接。
请求示例:
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。 |
客户端退出房间的本地动作必须可重复调用:
- 停止本地音频上行。
- 调用 TRTC
exitRoom。 - 退出或清理当前房间 IM 群会话。
- 停止房间 heartbeat。
- 清空当前房间页状态,回到房间列表或 Mine 页面。
- 弹出被踢提示。
被踢用户不一定能收到房间群里的 room_user_kicked 自定义消息,因为服务端会优先把他移出 IM 群。端上必须同时处理 notice-service 的 C2C room_notice、IM SDK 的群移除回调、RTC 退房回调和后续接口 403。
IM 自定义消息
房间内其他用户会收到群 TIMCustomElem,核心字段如下:
{
"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,核心字段如下:
{
"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:
{
"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 解析示例
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。