20 KiB
房间踢人接口 Flutter 对接
本文描述 Flutter App 对接房间踢人能力的 HTTP 接口,以及踢人后腾讯 IM 和腾讯 RTC 的客户端处理方式。踢人事实由 room-service Room Cell 提交;客户端不能只靠本地 UI 状态判断用户是否仍在房间。
接口地址
POST /api/v1/rooms/user/kick
GET /api/v1/rooms/{room_id}/banned-users
POST /api/v1/rooms/user/unban
本地开发地址:
http://127.0.0.1:13000
请求方法
| 方法 | 接口 | 说明 |
|---|---|---|
POST |
/api/v1/rooms/user/kick |
踢出房间用户,并写入房间 ban 集合。 |
GET |
/api/v1/rooms/{room_id}/banned-users |
查询房间维度当前仍有效的踢人列表,也就是房间黑名单列表。 |
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。 |
duration_ms |
否 | int64 | 600000 |
禁止重新进房的毫秒时长;0 或不传表示永久,直到房间 owner 调用解封。 |
请求示例:
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,
"duration_ms": 600000
}
成功响应:
{
"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],
"ban_states": [
{
"user_id": 20001,
"actor_user_id": 10001,
"created_at_ms": 1710000000456,
"expires_at_ms": 1710000600456
}
],
"online_users": [],
"mic_seats": [],
"version": 18
},
"rtc_kicked": true,
"rtc_kick_error": ""
}
}
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
data.result.applied |
bool | 本次命令是否产生状态变更;同一个 command_id 重试返回同一命令结果,新的 command_id 再踢同一用户会刷新 ban 状态。 |
data.room.ban_user_ids |
int64[] | 当前房间 ban 用户集合;被踢用户会进入该集合。 |
data.room.ban_states |
array | 当前房间 ban 的时间边界;expires_at_ms=0 表示永久,非 0 表示该 UTC 毫秒后可重新进房。 |
data.room.online_users |
array | 当前业务 presence;被踢用户会被移除。 |
data.room.mic_seats |
array | 如果被踢用户在麦上,对应麦位会被释放。 |
data.rtc_kicked |
bool | 服务端是否已调用 TRTC RemoveUserByStrRoomId 移除目标用户。 |
data.rtc_kick_error |
string | TRTC 服务端踢人失败原因;踢人事实已生效,客户端仍按被踢处理。 |
房间踢人列表
该接口用于房主或房管查看当前房间仍有效的踢人列表。列表只返回当前仍会阻止用户进房的记录;限时踢人已过期后不再返回。被解除的用户也不再返回。
请求示例:
GET /api/v1/rooms/room_10001/banned-users?page=1&page_size=20
Authorization: Bearer <access_token>
X-App-Code: lalu
路径参数:
| 字段 | 必填 | 类型 | 模拟值 | 说明 |
|---|---|---|---|---|
room_id |
是 | string | room_10001 |
房间 ID。 |
Query 参数:
| 字段 | 必填 | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
page |
否 | int32 | 1 |
页码,从 1 开始。 |
page_size |
否 | int32 | 20 |
每页数量;建议 App 使用 20,后端最大值按实现收敛。 |
成功响应:
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"room_id": "room_10001",
"items": [
{
"user_id": "20001",
"display_user_id": "90020001",
"username": "Alice",
"avatar": "https://example.com/avatar.png",
"country": "US",
"country_name": "United States",
"country_display_name": "United States",
"country_flag": "🇺🇸",
"actor_user_id": "10001",
"created_at_ms": 1710000000456,
"unban_at_ms": 1710000600456,
"remaining_ms": 599000,
"permanent": false
}
],
"total": 1,
"page": 1,
"page_size": 20,
"server_time_ms": 1710000001456
}
}
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
data.room_id |
string | 当前查询的房间 ID。 |
data.items[].user_id |
string | 被踢用户长 ID;Flutter 侧按字符串保存,避免大整数精度问题。 |
data.items[].display_user_id |
string | 用户展示 ID;可能为空,端上需要兜底展示 user_id。 |
data.items[].username |
string | 用户昵称;可能为空,端上可兜底展示 display_user_id 或 user_id。 |
data.items[].avatar |
string | 用户头像 URL;可能为空,端上展示默认头像。 |
data.items[].country |
string | 用户国家二位码,例如 US。 |
data.items[].country_name |
string | 用户国家标准名。 |
data.items[].country_display_name |
string | App 展示优先使用的国家名。 |
data.items[].country_flag |
string | 国家国旗 emoji;为空时端上可按 country 本地兜底。 |
data.items[].actor_user_id |
string | 执行踢人的用户 ID;系统驱逐时可能为 "0"。 |
data.items[].created_at_ms |
int64 | 踢人状态写入时间,UTC epoch milliseconds。 |
data.items[].unban_at_ms |
int64 | 自动解封时间;0 表示永久,需要手动解除。 |
data.items[].remaining_ms |
int64 | 距离自动解除的剩余毫秒;永久踢人为 0。 |
data.items[].permanent |
bool | 是否永久禁止进房。 |
data.total |
int64 | 当前房间有效黑名单总数。 |
data.server_time_ms |
int64 | 服务端当前 UTC 毫秒;倒计时 UI 以它校正本地时间。 |
解除房间 ban
解除 ban 只恢复用户后续重新进房资格,不恢复历史 presence、麦位、管理员身份或 RTC 连接。
请求体:
| 字段 | 必填 | 类型 | 模拟值 | 说明 |
|---|---|---|---|---|
room_id |
是 | string | room_10001 |
房间 ID。 |
command_id |
是 | string | cmd_unban_room_10001_20001_1710000100 |
房间命令幂等键;同一次解除操作重试必须复用。 |
target_user_id |
是 | int64 | 20001 |
要解除踢人状态的用户长 ID。 |
请求示例:
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
}
成功响应:
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"result": {
"applied": true,
"room_version": 19,
"server_time_ms": 1710000100456
},
"room": {
"room_id": "room_10001",
"status": "active",
"ban_user_ids": [],
"ban_states": [],
"version": 19
}
}
}
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
data.result.applied |
bool | 是否实际删除了 ban 状态;目标用户本来不在黑名单时为 false。 |
data.room.ban_user_ids |
int64[] | 当前房间仍有效的 ban 用户集合;成功解除后不再包含目标用户。 |
data.room.ban_states |
array | 当前房间仍有效的 ban 状态;成功解除后不再包含目标用户对应状态。 |
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。 |
新的 command_id 再次踢同一用户 |
200 OK,刷新 ban 状态和 duration_ms。 |
| 解封未 ban 用户 | 200 OK,applied=false。 |
Flutter 解析示例
int parseIntLike(dynamic value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(value?.toString() ?? '') ?? 0;
}
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,
required this.banStates,
});
final RoomCommandResult result;
final bool rtcKicked;
final String rtcKickError;
final List<int> banUserIds;
final List<RoomBanState> banStates;
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(parseIntLike)
.toList(),
banStates: (room['ban_states'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(RoomBanState.fromJson)
.toList(),
);
}
}
class RoomBanState {
RoomBanState({
required this.userId,
required this.actorUserId,
required this.createdAtMs,
required this.expiresAtMs,
});
final int userId;
final int actorUserId;
final int createdAtMs;
final int expiresAtMs;
bool get permanent => expiresAtMs == 0;
factory RoomBanState.fromJson(Map<String, dynamic> json) {
return RoomBanState(
userId: parseIntLike(json['user_id']),
actorUserId: parseIntLike(json['actor_user_id']),
createdAtMs: parseIntLike(json['created_at_ms']),
expiresAtMs: parseIntLike(json['expires_at_ms']),
);
}
}
class RoomBannedUsersResult {
RoomBannedUsersResult({
required this.roomId,
required this.items,
required this.total,
required this.page,
required this.pageSize,
required this.serverTimeMs,
});
final String roomId;
final List<RoomBannedUserItem> items;
final int total;
final int page;
final int pageSize;
final int serverTimeMs;
factory RoomBannedUsersResult.fromJson(Map<String, dynamic> json) {
return RoomBannedUsersResult(
roomId: json['room_id'] as String? ?? '',
items: (json['items'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(RoomBannedUserItem.fromJson)
.toList(),
total: parseIntLike(json['total']),
page: parseIntLike(json['page']),
pageSize: parseIntLike(json['page_size']),
serverTimeMs: parseIntLike(json['server_time_ms']),
);
}
}
class RoomBannedUserItem {
RoomBannedUserItem({
required this.userId,
required this.displayUserId,
required this.username,
required this.avatar,
required this.country,
required this.countryName,
required this.countryDisplayName,
required this.countryFlag,
required this.actorUserId,
required this.createdAtMs,
required this.unbanAtMs,
required this.remainingMs,
required this.permanent,
});
final int userId;
final String displayUserId;
final String username;
final String avatar;
final String country;
final String countryName;
final String countryDisplayName;
final String countryFlag;
final int actorUserId;
final int createdAtMs;
final int unbanAtMs;
final int remainingMs;
final bool permanent;
factory RoomBannedUserItem.fromJson(Map<String, dynamic> json) {
return RoomBannedUserItem(
userId: parseIntLike(json['user_id']),
displayUserId: json['display_user_id'] as String? ?? '',
username: json['username'] as String? ?? '',
avatar: json['avatar'] as String? ?? '',
country: json['country'] as String? ?? '',
countryName: json['country_name'] as String? ?? '',
countryDisplayName: json['country_display_name'] as String? ?? '',
countryFlag: json['country_flag'] as String? ?? '',
actorUserId: parseIntLike(json['actor_user_id']),
createdAtMs: parseIntLike(json['created_at_ms']),
unbanAtMs: parseIntLike(json['unban_at_ms']),
remainingMs: parseIntLike(json['remaining_ms']),
permanent: json['permanent'] as bool? ?? false,
);
}
}
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。