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

20 KiB
Raw Permalink Blame History

房间踢人接口 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 被踢用户长 IDFlutter 侧按字符串保存,避免大整数精度问题。
data.items[].display_user_id string 用户展示 ID可能为空端上需要兜底展示 user_id
data.items[].username string 用户昵称;可能为空,端上可兜底展示 display_user_iduser_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

客户端退出房间的本地动作必须可重复调用:

  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,核心字段如下:

{
  "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 TIMCustomElemExt=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_idcommand_idtarget_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 OKapplied=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