311 lines
11 KiB
Markdown
311 lines
11 KiB
Markdown
# 房间列表房主 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 <access_token>
|
||
```
|
||
|
||
房间卡片字段位于:
|
||
|
||
```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<String, dynamic> 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<String, dynamic>?)?['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 <access_token>
|
||
```
|
||
|
||
然后由房主保存到指定房间:
|
||
|
||
```http
|
||
POST /api/v1/rooms/{room_id}/border/save
|
||
Authorization: Bearer <access_token>
|
||
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<List<RoomListItem>> fetchRooms({
|
||
String tab = 'hot',
|
||
String? cursor,
|
||
}) async {
|
||
final response = await dio.get<Map<String, dynamic>>(
|
||
'/api/v1/rooms',
|
||
queryParameters: <String, dynamic>{
|
||
'tab': tab,
|
||
'limit': 20,
|
||
if (cursor != null && cursor.isNotEmpty) 'cursor': cursor,
|
||
},
|
||
);
|
||
|
||
final envelope = response.data ?? const <String, dynamic>{};
|
||
if (envelope['code'] != 'OK') {
|
||
throw StateError(envelope['message']?.toString() ?? 'room list failed');
|
||
}
|
||
|
||
final data = envelope['data'] as Map<String, dynamic>? ??
|
||
const <String, dynamic>{};
|
||
final rooms = data['rooms'] as List<dynamic>? ?? const <dynamic>[];
|
||
return rooms
|
||
.whereType<Map<String, dynamic>>()
|
||
.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。
|