hyapp-server/docs/flutter对接/房间列表房主VIP等级Flutter对接.md
2026-07-20 21:37:27 +08:00

311 lines
11 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.

# 房间列表房主 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。