hyapp-server/docs/flutter对接/房间踢人接口Flutter对接.md
2026-05-29 11:59:57 +08:00

578 lines
20 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`
`GET /api/v1/rooms/{room_id}/banned-users`
`POST /api/v1/rooms/user/unban`
本地开发地址:
```text
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 调用解封。 |
请求示例:
```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,
"duration_ms": 600000
}
```
成功响应:
```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],
"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 服务端踢人失败原因;踢人事实已生效,客户端仍按被踢处理。 |
## 房间踢人列表
该接口用于房主或房管查看当前房间仍有效的踢人列表。列表只返回当前仍会阻止用户进房的记录;限时踢人已过期后不再返回。被解除的用户也不再返回。
请求示例:
```http
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后端最大值按实现收敛。 |
成功响应:
```json
{
"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 | 被踢用户长 IDFlutter 侧按字符串保存,避免大整数精度问题。 |
| `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。 |
请求示例:
```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
}
```
成功响应:
```json
{
"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`。 |
客户端退出房间的本地动作必须可重复调用:
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`。 |
| 新的 `command_id` 再次踢同一用户 | `200 OK`,刷新 ban 状态和 `duration_ms`。 |
| 解封未 ban 用户 | `200 OK``applied=false`。 |
## Flutter 解析示例
```dart
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`