# 房间列表房主 VIP 与房间边框 Flutter 对接 本文档描述房间卡片的房主 VIP 等级、房间边框 URL,以及房主保存边框资源的接口。服务端不返回 VIP 房名色值;房间边框 URL 来自已授权的 `room_border` 资源。 ## 接口范围 公共房间列表和 Mine 房间关系流返回相同的房间卡片结构: ```http GET /api/v1/rooms?tab=hot&limit=20 GET /api/v1/rooms?tab=new&limit=20 GET /api/v1/rooms/feeds?tab=visited&limit=20 GET /api/v1/rooms/feeds?tab=friend&limit=20 GET /api/v1/rooms/feeds?tab=following&limit=20 GET /api/v1/rooms/feeds?tab=followed&limit=20 Authorization: Bearer ``` 房间卡片字段位于: ```text data.rooms[*].owner_vip_level data.rooms[*].room_border_url ``` 列表不需要 Flutter 针对每个房主调用 `/api/v1/vip/me`、资源或房间详情接口。房主选择边框时使用本文后面的专用保存接口。 ## 公共房间列表参数 | 参数 | 必填 | 说明 | | --- | --- | --- | | `tab` | 否 | `hot` 或 `new`,不传默认 `hot`。 | | `cursor` | 否 | 服务端返回的不透明分页游标;下一页原样回传。 | | `limit` | 否 | 默认 20,最大 50。 | | `q` | 否 | 房间标题或用户 ID 搜索词;新客户端使用 `q`。 | | `region_id` | 否 | 仅开放跨区域筛选的 App 使用;与 `country_code` 互斥。 | | `country_code` | 否 | 大写国家码;与 `region_id` 互斥。 | `region_id`、`country_code` 的可选值必须来自 `GET /api/v1/rooms/filters`,不要由客户端自行拼接。切换 tab、区域、国家或搜索词时必须清空旧 `cursor`。 ## 成功响应 ```json { "code": "OK", "message": "ok", "request_id": "req_room_list_01", "data": { "rooms": [ { "room_id": "fami_room_1001", "im_group_id": "fami_room_1001", "owner_user_id": "10001", "owner_vip_level": 7, "room_border_url": "https://cdn.example.com/room-border/gold.webp", "title": "Nina Room", "cover_url": "https://cdn.example.com/room/1001.png", "mode": "voice", "status": "active", "locked": false, "heat": 9820, "online_count": 18, "seat_count": 10, "occupied_seat_count": 4, "visible_region_id": 1001, "app_code": "fami", "room_short_id": "1001", "country_code": "AE", "country_flag": "🇦🇪" }, { "room_id": "fami_room_1002", "im_group_id": "fami_room_1002", "owner_user_id": "10002", "owner_vip_level": 0, "room_border_url": "", "title": "Music Room", "mode": "voice", "status": "active", "locked": true, "heat": 1200, "online_count": 3, "seat_count": 10, "occupied_seat_count": 1 } ], "next_cursor": "eyJ0YWIiOiJob3QiLC4uLn0" } } ``` ## `owner_vip_level` 语义 | 值 | Flutter 行为 | | --- | --- | | `0` | 房主当前没有有效 VIP,使用普通房间名称样式。 | | `> 0` | 房主当前有效 VIP 等级,使用当前 App 对应等级的 VIP 房间名称样式。 | | 字段缺失或 `null` | 仅用于兼容旧环境、旧缓存或 Mock 数据,按 `0` 处理。 | 边界要求: - 该值是房主的当前有效 VIP 等级,只用于房间卡片展示。 - 服务端已经处理 VIP 到期;Flutter 不需要计算到期时间。 - 接口不返回 `text_colors`、十六进制色值、渐变角度或 VIP 到期时间。 - Flutter 不要根据 `owner_user_id`、房间热度或本地购买记录推断 VIP。 - 不要把该展示字段用于权限判断;购买、装扮、踢人保护等权限仍以对应后端接口结果为准。 - 不要硬编码最大等级。不同 App 的 VIP 等级数量可以不同,未知正等级应走产品约定的默认 VIP 样式兜底。 房间卡片还可能返回独立资源装扮字段 `room_name_style`。它属于用户主动佩戴的资源装扮,与本次 `owner_vip_level` 驱动的默认 VIP 房名展示不是同一个契约;Flutter 不要用 `room_name_style` 是否存在来判断房主有没有 VIP。 ## `room_border_url` 语义 | 值 | Flutter 行为 | | --- | --- | | 非空 URL | 渲染当前房间边框。 | | 空字符串、字段缺失或 `null` | 当前没有有效房间边框,不渲染。 | - 字段类型固定为字符串,是当前有效 `room_border.asset_url` 的扁平值。 - 服务端已经过滤到期、撤销和无效资源;Flutter 仍应在收到 `room_decoration_changed` 或刷新列表后覆盖旧值。 - 列表查询直接读取现有房间装扮投影,不增加 wallet 查询,也不逐房回查资源。 - 兼容旧服务时可以回退读取 `room_border.asset_url`,但新代码应优先使用 `room_border_url`。 - URL 只用于展示,不能作为资源所有权或 VIP 权限判断依据。 ## Dart Model ```dart class RoomListItem { const RoomListItem({ required this.roomId, required this.imGroupId, required this.ownerUserId, required this.ownerVipLevel, required this.roomBorderUrl, required this.title, required this.locked, }); final String roomId; final String imGroupId; final String ownerUserId; final int ownerVipLevel; final String roomBorderUrl; final String title; final bool locked; bool get hasOwnerVip => ownerVipLevel > 0; bool get hasRoomBorder => roomBorderUrl.isNotEmpty; factory RoomListItem.fromJson(Map json) { return RoomListItem( roomId: json['room_id']?.toString() ?? '', imGroupId: json['im_group_id']?.toString() ?? '', ownerUserId: json['owner_user_id']?.toString() ?? '', ownerVipLevel: _readInt(json['owner_vip_level']), roomBorderUrl: json['room_border_url']?.toString() ?? ((json['room_border'] as Map?)?['asset_url'] ?.toString() ?? ''), title: json['title']?.toString() ?? '', locked: json['locked'] == true, ); } static int _readInt(dynamic value) { if (value is num) return value.toInt(); return int.tryParse(value?.toString() ?? '') ?? 0; } } ``` 用户 ID、房间 ID 和 IM 群 ID 按字符串保存,不要转成 Dart `int`。 ## 保存房间边框 先从用户资源接口选择一个当前可用的房间边框: ```http GET /api/v1/users/me/resources?resource_type=room_border Authorization: Bearer ``` 然后由房主保存到指定房间: ```http POST /api/v1/rooms/{room_id}/border/save Authorization: Bearer Content-Type: application/json { "command_id": "room_border_550e8400-e29b-41d4-a716-446655440000", "resource_id": "5201", "entitlement_id": "ent_room_border_abc" } ``` | 字段 | 必填 | 说明 | | --- | --- | --- | | `command_id` | 是 | 幂等键,最大 128 字节;同一次保存重试必须复用。 | | `resource_id` | 是 | 从用户资源接口取得,支持 JSON 数字或数字字符串。 | | `entitlement_id` | 否 | 建议回传选中的资源实例 ID;省略时服务端选择当前有效实例。 | 客户端不提交 `room_border_url`。服务端会校验房主身份、房间状态、VIP benefit、资源类型、资源状态和 entitlement,再把资源的权威 `asset_url` 保存进 Room Cell。 成功响应: ```json { "code": "OK", "message": "ok", "request_id": "req_save_room_border", "data": { "result": { "applied": true, "room_version": 21, "server_time_ms": 1784515200000 }, "room": { "room_id": "fami_room_1001", "room_border_url": "https://cdn.example.com/room-border/gold.webp", "version": 21 }, "room_border_url": "https://cdn.example.com/room-border/gold.webp", "resource": { "resource_id": 5201, "resource_type": "room_border", "asset_url": "https://cdn.example.com/room-border/gold.webp", "entitlement_id": "ent_room_border_abc" }, "server_time_ms": 1784515200000 } } ``` 保存成功后立即使用 `data.room_border_url` 更新当前设备。其他房间用户通过 `room_decoration_changed` 更新:当 `decoration_type=room_border` 且 `resource_id>0` 时优先使用 `room_border_url`,兼容读取 `asset_url`;当 `resource_id=0` 时清空边框。 ## Dio 请求示例 ```dart Future> fetchRooms({ String tab = 'hot', String? cursor, }) async { final response = await dio.get>( '/api/v1/rooms', queryParameters: { 'tab': tab, 'limit': 20, if (cursor != null && cursor.isNotEmpty) 'cursor': cursor, }, ); final envelope = response.data ?? const {}; if (envelope['code'] != 'OK') { throw StateError(envelope['message']?.toString() ?? 'room list failed'); } final data = envelope['data'] as Map? ?? const {}; final rooms = data['rooms'] as List? ?? const []; return rooms .whereType>() .map(RoomListItem.fromJson) .toList(growable: false); } ``` ## UI 展示规则 ```dart final TextStyle roomNameStyle = room.ownerVipLevel > 0 ? vipRoomNameStyleResolver.resolve(room.ownerVipLevel) : defaultRoomNameStyle; Text( room.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: roomNameStyle, ); ``` `vipRoomNameStyleResolver` 使用当前 App 已有的 VIP 设计 token。本文档不定义具体颜色,后端也不会通过本字段下发颜色。 ## 刷新与一致性 - VIP 购买、体验卡切换或房主重新进房后,服务端异步刷新房间列表读模型。 - Flutter 继续使用现有的下拉刷新、切换 tab、回前台刷新和分页流程,不新增逐房轮询。 - 同一个房间已经显示在页面上时,以后续房间列表请求返回的新 `owner_vip_level` 覆盖本地旧值。 - `owner_vip_level` 从正数变成 `0` 时,立即恢复普通房名样式。 - `room_border_url` 变化时替换边框;变成空字符串时立即移除边框。 ## 错误处理 | HTTP | `code` | Flutter 处理 | | --- | --- | --- | | 400 | `INVALID_ARGUMENT` | 参数或筛选组合错误;清空无效 cursor 后按当前筛选重新请求。 | | 401 | `UNAUTHORIZED` | 刷新登录态或跳转登录。 | | 403 | `PERMISSION_DENIED` | 按现有资料完整度或权限流程处理。 | | 502 | `UPSTREAM_ERROR` | 保留当前列表,提供重试;不要逐房查询 VIP 兜底。 | ## Flutter 验收清单 - `owner_vip_level=0` 使用普通房名样式。 - `owner_vip_level>0` 使用对应等级的 VIP 房名样式。 - 字段缺失、`null` 或无法解析时按 `0` 兼容,房间卡片仍正常展示。 - 翻页、搜索、公共列表和 Mine 关系流使用同一字段解析逻辑。 - 不请求每个房主的 VIP 详情,不在客户端拼接色值接口。 - VIP 等级变化后,下一次列表刷新能覆盖当前卡片样式。 - 非空 `room_border_url` 能渲染房间边框,空值不占位、不发起空 URL 请求。 - 保存边框只提交资源身份,成功后使用服务端返回 URL;客户端不允许保存任意外部 URL。