# 房间踢人接口 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;服务端从 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 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 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 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 banUserIds; factory KickUserResult.fromJson(Map json) { final room = json['room'] as Map? ?? const {}; return KickUserResult( result: RoomCommandResult.fromJson( json['result'] as Map? ?? const {}, ), rtcKicked: json['rtc_kicked'] as bool? ?? false, rtcKickError: json['rtc_kick_error'] as String? ?? '', banUserIds: (room['ban_user_ids'] as List? ?? 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 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 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`。