11 KiB
房间列表房主 VIP 与房间边框 Flutter 对接
本文档描述房间卡片的房主 VIP 等级、房间边框 URL,以及房主保存边框资源的接口。服务端不返回 VIP 房名色值;房间边框 URL 来自已授权的 room_border 资源。
接口范围
公共房间列表和 Mine 房间关系流返回相同的房间卡片结构:
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>
房间卡片字段位于:
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。
成功响应
{
"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
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。
保存房间边框
先从用户资源接口选择一个当前可用的房间边框:
GET /api/v1/users/me/resources?resource_type=room_border
Authorization: Bearer <access_token>
然后由房主保存到指定房间:
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。
成功响应:
{
"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 请求示例
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 展示规则
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。