# 房间锁房接口 Flutter 对接 本文描述 Flutter App 对接房间锁房能力的 HTTP 接口。房间密码由 `room-service` 只保存 bcrypt 哈希;客户端只提交明文用于本次设置或进房校验,任何列表、搜索、详情、房间消息和响应体都不会返回明文或哈希。 ## 接口地址 `POST /api/v1/rooms/password` `POST /api/v1/rooms/join` `GET /api/v1/rooms` `GET /api/v1/rooms/feeds` 本地开发地址: ```text http://127.0.0.1:13000 ``` ## 请求方法 | 方法 | 接口 | 说明 | | --- | --- | --- | | `POST` | `/api/v1/rooms/password` | 房主设置或清空房间入房密码。 | | `POST` | `/api/v1/rooms/join` | 进入房间;带锁房间需要传 `password`。 | | `GET` | `/api/v1/rooms` | 公共房间列表和搜索;房间卡片返回 `locked`。 | | `GET` | `/api/v1/rooms/feeds` | Mine 页房间流;房间卡片返回 `locked`。 | ## 请求头 | Header | 必填 | 模拟值 | 说明 | | --- | --- | --- | --- | | `Authorization` | 是 | `Bearer ` | 登录 access token;服务端从 token 读取当前用户 ID。 | | `X-App-Code` | 否 | `lalu` | App 编码;登录态接口最终以 token 内的 `app_code` 为准。 | ## 设置或清空房间密码 只有房主可以调用。房管、主持人和普通观众调用会返回 `PERMISSION_DENIED`。 请求体: | 字段 | 必填 | 类型 | 模拟值 | 说明 | | --- | --- | --- | --- | --- | | `room_id` | 是 | string | `room_10001` | 房间 ID。 | | `command_id` | 是 | string | `cmd_room_password_room_10001_1710000000` | 房间命令幂等键;同一次点击重试必须复用。 | | `locked` | 是 | bool | `true` | `true` 表示设置密码,`false` 表示清空密码。 | | `password` | `locked=true` 时必填 | string | `1234` | 入房密码;服务端 trim 后最长 64 个字符。`locked=false` 时忽略。 | 设置密码请求示例: ```http POST /api/v1/rooms/password Authorization: Bearer X-App-Code: lalu Content-Type: application/json { "room_id": "room_10001", "command_id": "cmd_room_password_room_10001_1710000000", "locked": true, "password": "1234" } ``` 清空密码请求示例: ```http POST /api/v1/rooms/password Authorization: Bearer X-App-Code: lalu Content-Type: application/json { "room_id": "room_10001", "command_id": "cmd_room_password_room_10001_1710000100", "locked": false } ``` 成功响应: ```json { "code": "OK", "message": "ok", "request_id": "req_abc", "data": { "result": { "applied": true, "room_version": 12, "server_time_ms": 1710000000456 }, "room": { "room_id": "room_10001", "im_group_id": "room_10001", "room_short_id": "100001", "title": "Live Room", "cover_url": "https://cdn.example/room.png", "owner_user_id": "10001", "mode": "voice", "status": "active", "chat_enabled": true, "locked": true, "heat": 1000, "online_count": 12, "seat_count": 15, "occupied_seat_count": 2, "version": 12 }, "seats": [], "server_time_ms": 1710000000456 } } ``` 字段说明: | 字段 | 类型 | 说明 | | --- | --- | --- | | `data.result.applied` | bool | 本次命令是否产生状态变更;重复提交相同命令可能为 `false`。 | | `data.result.room_version` | int64 | 命令完成后房间版本。 | | `data.room.locked` | bool | 最新锁房状态;`true` 表示后续新用户进房需要密码。 | | `data.room.version` | int64 | 最新房间快照版本;客户端本地房间状态以更大版本覆盖旧状态。 | | `data.seats` | array | 当前座位列表;锁房接口成功后可直接复用该房间快照刷新页面。 | ## 带密码进入房间 客户端进入房间前先看房间卡片或详情里的 `locked`: | `locked` | 客户端行为 | | --- | --- | | `false` | 直接调用 JoinRoom,`password` 可不传或传空字符串。 | | `true` | 先弹出密码输入,再调用 JoinRoom 并传 `password`。 | 请求体: | 字段 | 必填 | 类型 | 模拟值 | 说明 | | --- | --- | --- | --- | --- | | `room_id` | 是 | string | `room_10001` | 房间 ID。 | | `command_id` | 是 | string | `cmd_join_room_10001_20001_1710000000` | 进房命令幂等键;同一次进房重试必须复用。 | | `role` | 否 | string | `audience` | 当前固定传 `audience`。 | | `password` | 锁房时必填 | string | `1234` | 只用于本次进房校验,服务端不保存明文。 | 请求示例: ```http POST /api/v1/rooms/join Authorization: Bearer X-App-Code: lalu Content-Type: application/json { "room_id": "room_10001", "command_id": "cmd_join_room_10001_20001_1710000000", "role": "audience", "password": "1234" } ``` 成功响应只展示锁房相关字段: ```json { "code": "OK", "message": "ok", "request_id": "req_abc", "data": { "result": { "applied": true, "room_version": 13, "server_time_ms": 1710000001456 }, "room": { "room_id": "room_10001", "im_group_id": "room_10001", "locked": true, "version": 13 }, "viewer": { "user_id": "20001", "role": "audience", "joined_at_ms": 1710000001456, "last_seen_at_ms": 1710000001456 }, "im": { "group_id": "room_10001", "need_join_group": true }, "rtc": { "need_token": true, "available": true }, "server_time_ms": 1710000001456 } } ``` 进房规则: | 场景 | 结果 | | --- | --- | | 房间未锁 | 不校验 `password`。 | | 房间已锁且密码正确 | JoinRoom 成功,客户端再调用腾讯 IM `joinGroup` 和 RTC `enterRoom`。 | | 房间已锁且密码错误或缺失 | 返回 `403 PERMISSION_DENIED`,不会创建 presence,不会返回 RTC token。 | | 房主进入自己的房间 | 不需要密码。 | | 用户已经在房间内 | 再次 JoinRoom 按幂等恢复当前房间态,不会因为后续锁房被踢出。 | ## 房间列表和搜索 locked 字段 公共列表和搜索使用同一个接口: ```http GET /api/v1/rooms?tab=hot&query=100001 Authorization: Bearer X-App-Code: lalu ``` Mine 页房间流: ```http GET /api/v1/rooms/feeds?tab=followed Authorization: Bearer X-App-Code: lalu ``` 列表响应示例: ```json { "code": "OK", "message": "ok", "request_id": "req_abc", "data": { "rooms": [ { "room_id": "room_10001", "im_group_id": "room_10001", "owner_user_id": "10001", "title": "Live Room", "cover_url": "https://cdn.example/room.png", "mode": "voice", "status": "active", "locked": true, "heat": 1000, "online_count": 12, "seat_count": 15, "occupied_seat_count": 2, "visible_region_id": 1, "app_code": "lalu", "room_short_id": "100001" } ], "next_cursor": "" } } ``` 字段说明: | 字段 | 类型 | 说明 | | --- | --- | --- | | `rooms[].locked` | bool | 房间是否需要入房密码;搜索结果和普通列表一致。 | | `rooms[].im_group_id` | string | 腾讯 IM 群 ID;只用于 JoinRoom 成功后的 `joinGroup`,不能绕过 JoinRoom。 | | `next_cursor` | string | 下一页游标;为空表示没有下一页。 | ## 错误处理 失败返回仍使用统一 envelope: ```json { "code": "PERMISSION_DENIED", "message": "permission denied", "request_id": "req_abc" } ``` | HTTP 状态码 | `code` | 典型场景 | 处理方式 | | --- | --- | --- | --- | | `400` | `INVALID_ARGUMENT` | `room_id`、`command_id` 缺失;设置锁房时 `password` 为空或超过 64 字符。 | 客户端修正参数,不自动重试。 | | `401` | `UNAUTHORIZED` | token 缺失、无效、过期或会话失效。 | 重新登录。 | | `403` | `PROFILE_REQUIRED` | 用户资料未完成。 | 跳转资料补全流程。 | | `403` | `PERMISSION_DENIED` | 非房主设置密码;进锁房密码错误;用户被 ban。 | 设置密码时隐藏入口或提示无权限;进房时提示密码错误。 | | `404` | `NOT_FOUND` | 房间不存在。 | 刷新列表或退出房间页。 | | `409` | `ROOM_CLOSED` | 房间已关闭。 | 刷新列表或退出房间页。 | | `409` | `CONFLICT` | 同一个 `command_id` 被不同请求体复用。 | 为新的用户动作生成新的 `command_id`。 | | `502` | `UPSTREAM_ERROR` | 内部服务暂不可用。 | 可提示稍后重试。 | 幂等规则: | 场景 | 返回 | | --- | --- | | 同一个 `command_id` 重试同一次设置密码 | 返回同一命令结果,不重复变更房间。 | | 同一个 `command_id` 换另一个密码 | `409 CONFLICT`。 | | 重复清空已解锁房间 | `200 OK`,`data.room.locked=false`。 | | 同一个用户重复 JoinRoom | 返回当前房间首屏数据,不重复创建 presence。 | ## 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 RoomCard { RoomCard({ required this.roomId, required this.imGroupId, required this.title, required this.locked, required this.onlineCount, }); final String roomId; final String imGroupId; final String title; final bool locked; final int onlineCount; factory RoomCard.fromJson(Map json) { return RoomCard( roomId: json['room_id'] as String? ?? '', imGroupId: json['im_group_id'] as String? ?? '', title: json['title'] as String? ?? '', locked: json['locked'] as bool? ?? false, onlineCount: (json['online_count'] as num?)?.toInt() ?? 0, ); } } class RoomLockResult { RoomLockResult({ required this.result, required this.room, required this.serverTimeMs, }); final RoomCommandResult result; final RoomCard room; final int serverTimeMs; factory RoomLockResult.fromJson(Map json) { return RoomLockResult( result: RoomCommandResult.fromJson( json['result'] as Map? ?? const {}, ), room: RoomCard.fromJson( json['room'] as Map? ?? const {}, ), serverTimeMs: (json['server_time_ms'] as num?)?.toInt() ?? 0, ); } } ``` 调用时先解析外层 envelope:只有 `code == "OK"` 时读取 `data`;其他 `code` 按登录态、参数错误、权限错误或服务错误处理,并把 `request_id` 写入客户端日志。 客户端生成 `command_id` 时按一次用户动作生成一次,例如: ```dart String newRoomCommandId(String action, String roomId, String userId) { return 'cmd_${action}_${roomId}_${userId}_${DateTime.now().millisecondsSinceEpoch}'; } ``` 同一次 HTTP 重试复用原 `command_id`;用户重新输入密码或再次点击设置按钮时生成新的 `command_id`。