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