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

11 KiB
Raw Blame History

房间列表房主 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 hotnew,不传默认 hot
cursor 服务端返回的不透明分页游标;下一页原样回传。
limit 默认 20最大 50。
q 房间标题或用户 ID 搜索词;新客户端使用 q
region_id 仅开放跨区域筛选的 App 使用;与 country_code 互斥。
country_code 大写国家码;与 region_id 互斥。

region_idcountry_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_borderresource_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。