# Fami VIP 五阶段 Flutter 接口对接

本文是本次五阶段后端补齐后的 Flutter 执行契约，覆盖 VIP 素材、资源佩戴、房间背景、房间装扮、主页隐私、房间图片消息和腾讯云 IM。字段以 gateway 当前 HTTP DTO 与 room-service 当前 IM 映射为准。

## 1. 适用边界

- Fami 当前 VIP 体系是 `tiered_privilege_v1`。资格只能读取服务端返回的 `state.effective_benefits`，执行写操作时仍由后端实时复验。
- Lalu 当前保持 `legacy_timed`。旧版房间背景 `image_url` 请求和不带资源佩戴 `command_id` 的客户端仍兼容；新 Flutter 应统一使用本文新协议。
- Flutter 不按 VIP 等级、`benefit_code` 或 `resource_code` 拼接素材 URL。只有接口实际返回的 `benefits[].resource`、背包 `resource` 或房间最终快照可以渲染。
- 用户级装扮与房间级装扮分开：`mic_seat_icon`（麦位中心图标）和 `mic_seat_animation`（Voice Wave）分别走独立的用户资源佩戴槽；`room_border` 和 `room_name_style` 必须走 Room Cell 的房间装扮接口。
- 房间背景和房间图片只支持 JPEG、PNG、GIF、WebP。首版不支持 APNG、SVG、视频或外部 URL。
- 房间状态以 HTTP 快照恢复，腾讯云 IM 只负责实时增量。不能把 IM 是否到达当成权益或持久状态事实。

五阶段对应关系：

| 阶段 | Flutter 可用结果 |
| --- | --- |
| 1. 契约与错误 | VIP 套餐内嵌真实 `resource`；资源装备有 `command_id`；`VIP_BENEFIT_REQUIRED` 有结构化 `data` |
| 2. 资源映射与复验 | `mic_skin -> mic_seat_icon`、`voice_wave -> mic_seat_animation`、`room_border -> room_border`、`colored_room_name -> room_name_style`；写操作复验当前权益和 entitlement |
| 3. 背景与装扮 | 房间专用上传、完整媒体元数据、背景保存/切换、房间装扮 Room Cell 快照与 IM |
| 4. 主页隐私 | 目标统计隐藏显式返回；匿名访问在写入时固化，访客列表不泄露真实 ID |
| 5. 房间图片 | 专用上传、发言与权益门禁、command log、durable outbox、腾讯云 IM 图片事件 |

## 2. 通用 HTTP 约定

### 2.1 Base URL

```text
线上：https://api.global-interaction.com
本地：http://127.0.0.1:13000
```

所有下文路径都拼接在同一 gateway Base URL 后。Flutter 从当前环境配置取得 host，不在业务代码写死域名。

### 2.2 Headers

| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization: Bearer {access_token}` | 是 | 当前登录用户 token |
| `Content-Type: application/json` | JSON 写接口 | 请求体为 JSON |
| `Content-Type: multipart/form-data` | 上传接口 | 包含 `command_id` 和 `file` |
| `X-App-Code` | 通常否 | 由 App 包和登录态决定；不得在同一 token 下任意切换 Fami/Lalu |

客户端不提交 `app_code`、`user_id`、`actor_user_id`、`request_id`。这些值由 gateway 从登录态和请求上下文注入。

### 2.3 Envelope

成功：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {}
}
```

失败通常省略 `data`；需要客户端执行动作的错误会返回安全结构化 `data`。业务分支只判断 `code`，不要解析 `message`。日志至少记录 path、`code` 和 `request_id`，不能记录 token。

### 2.4 `command_id`

- 最大 128 字节，建议 UUID v4 或 `功能前缀 + UUID`。
- 它是业务幂等键，`request_id` 只是链路追踪，不能替代。
- 同一次用户动作超时、断网或 5xx 重试必须复用同一个 `command_id` 和完全相同的业务 payload。
- 同键同 payload 返回原结果；同键换 payload 返回 `IDEMPOTENCY_CONFLICT`。
- 用户重新选择素材、重新点击形成新的业务动作时生成新键。

建议动作边界：

| 动作 | command_id 使用范围 |
| --- | --- |
| 上传并保存一个房间背景 | upload 与 save 共用 A |
| 把已保存背景设为当前背景 | set 单独使用 B |
| 应用一个房间装扮 | apply 使用 C |
| 上传并发送一条房间图片 | upload 与 send 共用 D |
| 佩戴一个用户资源 | equip 使用 E |

## 3. 接口总览

| 功能 | Method | Path |
| --- | --- | --- |
| 当前 VIP 最终态 | `GET` | `/api/v1/vip/me` |
| VIP 套餐与真实素材 | `GET` | `/api/v1/vip/packages` |
| VIP 功能开关 | `GET/PATCH` | `/api/v1/vip/settings` |
| 查询我的资源 | `GET` | `/api/v1/users/me/resources?resource_type={type}` |
| 佩戴用户级资源 | `POST` | `/api/v1/users/me/resources/{resource_id}/equip` |
| 查询我已佩戴外观 | `GET` | `/api/v1/users/me/appearance` |
| 查询其他用户已佩戴外观 | `GET` | `/api/v1/users/by-id/{user_id}/appearance` |
| 上传房间背景 | `POST multipart` | `/api/v1/rooms/{room_id}/backgrounds/upload` |
| 保存房间背景素材 | `POST` | `/api/v1/rooms/backgrounds/save` |
| 查询房间背景素材 | `GET` | `/api/v1/rooms/{room_id}/backgrounds` |
| 切换当前背景 | `POST` | `/api/v1/rooms/backgrounds/set` |
| 保存当前房间边框 | `POST` | `/api/v1/rooms/{room_id}/border/save` |
| 应用房间装扮 | `POST` | `/api/v1/rooms/{room_id}/decorations/apply` |
| 记录主页访问 | `POST` | `/api/v1/users/{user_id}/visit` |
| 查询访问我的用户 | `GET` | `/api/v1/users/me/visitors?page=1&page_size=20` |
| 上传房间图片 | `POST multipart` | `/api/v1/rooms/{room_id}/messages/images/upload` |
| 发送房间图片 | `POST` | `/api/v1/rooms/{room_id}/messages/image` |
| 断线后恢复房间最终状态 | `GET` | `/api/v1/rooms/{room_id}/detail` |

已有 VIP 购买、体验卡、返现和上线通知接口保持不变：

```text
POST   /api/v1/vip/purchase
POST   /api/v1/vip/trial-cards/{entitlement_id}/equip
DELETE /api/v1/vip/trial-cards/equipped
GET    /api/v1/vip/coin-rebates/current
POST   /api/v1/vip/coin-rebates/statuses
POST   /api/v1/vip/coin-rebates/{rebate_id}/claim
POST   /api/v1/vip/online-notice
```

## 4. VIP 套餐、最终态与真实资源

### 4.1 套餐接口

```http
GET /api/v1/vip/packages
Authorization: Bearer {access_token}
```

无 query 和 body 参数。代表响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_packages",
  "data": {
    "current_vip": {
      "level": 5,
      "name": "VIP5",
      "active": true,
      "started_at_ms": 1784000000000,
      "expires_at_ms": 1786592000000,
      "program_type": "tiered_privilege_v1",
      "config_version": 12
    },
    "packages": [
      {
        "level": 5,
        "name": "VIP5",
        "status": "active",
        "price_coin": 5000,
        "duration_ms": 2592000000,
        "reward_resource_group_id": 0,
        "reward_items": [],
        "can_purchase": true,
        "sort_order": 50,
        "recharge_gate_required": false,
        "required_recharge_coin_amount": 0,
        "user_recharge_coin_amount": 0,
        "purchase_locked_reason": "",
        "benefits": [
          {
            "benefit_code": "mic_skin",
            "name": "VIP麦位皮肤",
            "benefit_type": "decoration",
            "unlock_level": 5,
            "status": "active",
            "trial_enabled": true,
            "resource_id": 5105,
            "resource_type": "mic_seat_icon",
            "execution_scope": "room",
            "auto_equip": false,
            "sort_order": 50,
            "metadata_json": "",
            "created_at_ms": 1784000000000,
            "updated_at_ms": 1784000000000,
            "presentation": {
              "description": "VIP麦位中心图标",
              "preview_items": [],
              "numeric_reward": null
            },
            "resource": {
              "resource_id": 5105,
              "resource_code": "fami_vip5_mic",
              "resource_type": "mic_seat_icon",
              "name": "VIP5 Mic",
              "status": "active",
              "grantable": false,
              "grant_strategy": "new_entitlement",
              "usage_scopes": ["room_mic"],
              "asset_url": "https://cdn.example.com/vip/v5-mic.png",
              "preview_url": "https://cdn.example.com/vip/v5-mic.png",
              "animation_url": "",
              "format": "png",
              "metadata_json": "{\"format\":\"png\"}",
              "sort_order": 50,
              "created_at_ms": 1784000000000,
              "updated_at_ms": 1784000000000
            }
          }
        ],
        "config_version": 12
      }
    ],
    "program_config": {
      "app_code": "fami",
      "program_type": "tiered_privilege_v1",
      "level_count": 9,
      "same_level_expiry_policy": "extend_remaining",
      "upgrade_expiry_policy": "replace_from_now",
      "downgrade_purchase_policy": "reject",
      "benefit_inheritance_policy": "target_only",
      "grant_mode": "trial_card",
      "trial_card_enabled": true,
      "status": "active",
      "config_version": 12
    },
    "state": {
      "paid_vip": {
        "level": 5,
        "name": "VIP5",
        "active": true,
        "started_at_ms": 1784000000000,
        "expires_at_ms": 1786592000000,
        "program_type": "tiered_privilege_v1",
        "config_version": 12
      },
      "equipped_trial_card": null,
      "effective_vip": {
        "level": 5,
        "name": "VIP5",
        "active": true,
        "started_at_ms": 1784000000000,
        "expires_at_ms": 1786592000000,
        "program_type": "tiered_privilege_v1",
        "config_version": 12
      },
      "effective_source": "paid",
      "effective_benefits": [
        {
          "benefit_code": "mic_skin",
          "name": "VIP麦位皮肤",
          "benefit_type": "decoration",
          "unlock_level": 5,
          "status": "active",
          "trial_enabled": true,
          "resource_id": 5105,
          "resource_type": "mic_seat_icon",
          "execution_scope": "room",
          "auto_equip": false,
          "sort_order": 50,
          "metadata_json": "",
          "created_at_ms": 1784000000000,
          "updated_at_ms": 1784000000000,
          "presentation": {
            "description": "VIP麦位中心图标",
            "preview_items": [],
            "numeric_reward": null
          },
          "resource": {
            "resource_id": 5105,
            "resource_code": "fami_vip5_mic",
            "resource_type": "mic_seat_icon",
            "name": "VIP5 Mic",
            "status": "active",
            "grantable": false,
            "grant_strategy": "new_entitlement",
            "usage_scopes": ["room_mic"],
            "asset_url": "https://cdn.example.com/vip/v5-mic.png",
            "preview_url": "https://cdn.example.com/vip/v5-mic.png",
            "animation_url": "",
            "format": "png",
            "metadata_json": "{\"format\":\"png\"}",
            "sort_order": 50,
            "created_at_ms": 1784000000000,
            "updated_at_ms": 1784000000000
          }
        }
      ],
      "evaluated_at_ms": 1784265600000,
      "program_config": {
        "app_code": "fami",
        "program_type": "tiered_privilege_v1",
        "config_version": 12
      },
      "user_settings": {
        "app_code": "fami",
        "user_id": 10001,
        "room_entry_notice_enabled": true,
        "online_global_notice_enabled": true,
        "hide_profile_data_enabled": false,
        "anonymous_profile_visit_enabled": false,
        "leaderboard_invisible_enabled": false,
        "updated_at_ms": 0
      }
    }
  }
}
```

`benefits[].resource` 是可选字段，只有同时满足以下条件才出现：

1. `resource_id > 0`；
2. 资源属于当前 App；
3. 资源 `status=active`；
4. 资源的 `resource_type` 与 benefit 精确一致。

条件不满足时字段会被完全省略，不会返回空对象。Flutter 展示素材入口应使用：

```dart
final canRenderMaterial = benefit.status == 'active' &&
    benefit.resource != null &&
    benefit.resource!.status == 'active' &&
    benefit.resource!.resourceType == benefit.resourceType;
```

后台配置顺序是“先创建同 App 的真实 active resource，再把该 resource 绑定到 benefit”。真实素材不存在时，该 benefit 必须保持 `status=disabled`、`resource_id=0`，响应不返回 `resource`；不能先启用权益再让客户端使用占位素材。

`resource.format` 是客户端选择素材渲染器的唯一格式依据。后端优先取 `metadata_json.format`，缺省时兼容取 `metadata_json.animation_format`，并返回小写值；两者都没有时顶层 `format` 字段省略，Flutter 应当作“格式未配置/当前不支持”，不得根据 `asset_url`、`preview_url` 或 `animation_url` 后缀推断。后台创建 `mic_seat_icon`、`mic_seat_animation` 或 `room_border` 资源时，应在 `metadata_json.format` 中选择与真实素材一致的 `png/jpeg/webp/gif/svga/pag/mp4`。

当前映射：

| benefit_code | resource_type | 应用位置 |
| --- | --- | --- |
| `mic_skin` | `mic_seat_icon` | 用户资源 equip 后由 appearance/房间资料投影到麦位中心图标 |
| `voice_wave` | `mic_seat_animation` | 用户资源 equip 后由 appearance/房间资料投影到 Voice Wave |
| `room_border` | `room_border` | 房间 decoration apply |
| `colored_room_name` | `room_name_style` | 房间 decoration apply，`decoration_type=colored_room_name` |

本版本初始数据只有 Fami VIP4–VIP8 的 `mic_skin` 已绑定可核实资源；`room_border`、`colored_room_name` 和 VIP9 mic 素材当前为 disabled/无 `resource`。Flutter 必须隐藏素材操作，等待后台配置真实资源，不能放占位 URL。

### 4.2 当前 VIP 状态

```http
GET /api/v1/vip/me
```

返回根层兼容字段、`program_config` 和完整 `state`。授权只看：

```text
data.state.effective_vip
data.state.effective_benefits
data.state.user_settings
```

不要使用根层 `data.level` 重新推导权益。登录、切账号、冷启动、回前台、购买/体验卡切换成功以及收到 VIP 状态类 IM 后重新拉取。

### 4.3 VIP 设置

```http
GET /api/v1/vip/settings
PATCH /api/v1/vip/settings
```

PATCH 是局部更新，至少提交一个字段；显式 `false` 不能当成未提交：

```json
{
  "hide_profile_data_enabled": true,
  "anonymous_profile_visit_enabled": true
}
```

开关只是用户偏好，不能单独授权。执行主页访问、房间行为或上线通知时，后端会把当前有效 benefit 与对应开关合并判定。

## 5. 用户资源查询与幂等佩戴

### 5.1 查询背包资源

```http
GET /api/v1/users/me/resources?resource_type=mic_seat_icon
```

`resource_type` 可省略。房间装扮可分别查询 `room_border`、`room_name_style`，但不能调用通用 equip；它们必须走第 7 节 Room Cell 接口。

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_resources",
  "data": {
    "items": [
      {
        "entitlement_id": "ent_abc",
        "resource_id": 5105,
        "resource": {
          "resource_id": 5105,
          "resource_code": "fami_vip5_mic",
          "resource_type": "mic_seat_icon",
          "name": "VIP5 Mic",
          "status": "active",
          "grantable": false,
          "grant_strategy": "new_entitlement",
          "usage_scopes": ["room_mic"],
          "asset_url": "https://cdn.example.com/vip/v5-mic.png",
          "preview_url": "https://cdn.example.com/vip/v5-mic.png",
          "animation_url": "",
          "format": "png",
          "metadata_json": "{\"format\":\"png\"}",
          "sort_order": 50,
          "created_at_ms": 1784000000000,
          "updated_at_ms": 1784000000000
        },
        "status": "active",
        "quantity": 1,
        "remaining_quantity": 1,
        "effective_at_ms": 1784000000000,
        "expires_at_ms": 1786592000000,
        "source_grant_id": "grant_abc",
        "created_at_ms": 1784000000000,
        "updated_at_ms": 1784000000000,
        "equipped": false
      }
    ],
    "total": 1
  }
}
```

### 5.2 佩戴用户级资源

```http
POST /api/v1/users/me/resources/5105/equip
Content-Type: application/json

{
  "command_id": "equip_550e8400-e29b-41d4-a716-446655440000",
  "entitlement_id": "ent_abc"
}
```

| 参数 | 必填 | 说明 |
| --- | --- | --- |
| path `resource_id` | 是 | 目标资源 ID |
| `command_id` | Fami 是 | 同一佩戴动作重试复用，最大 128 字节 |
| `entitlement_id` | 否 | 指定背包实例；省略时由服务端选择当前有效实例 |

成功 `data` 就是一条完整背包资源对象，与上一节 `items[]` 同构，且 `equipped=true`。

服务端在写入前重新检查：entitlement 仍 active、未过期、有剩余数量、资源仍 active，以及 VIP 绑定资源的对应 benefit 仍有效。用户 VIP 到期后，即使历史 entitlement 仍存在，也不能重新佩戴该 VIP 资源。

资源装备不会直接给 Flutter 发腾讯云 IM；wallet 只发布内部 `UserResourceChanged`。用户级外观通过后续 `/users/me/appearance`、房间资料投影或重新进房刷新。

### 5.3 佩戴后的外观与房间投影

当前用户佩戴态：

```http
GET /api/v1/users/me/appearance
Authorization: Bearer {access_token}
```

查询其他用户的公开外观使用 `GET /api/v1/users/by-id/{user_id}/appearance`。两个接口的 `data.mic_seat_icon`、`data.mic_seat_animation` 分别同构。下例同时展开麦位中心图标与 Voice Wave，其他 appearance 字段以 OpenAPI 为准：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_appearance",
  "data": {
    "user_id": "10001",
    "mic_seat_icon": {
      "resource_id": 5105,
      "resource_code": "fami_vip5_mic",
      "resource_type": "mic_seat_icon",
      "name": "VIP5 Mic",
      "asset_url": "https://cdn.example.com/vip/v5-mic.png",
      "preview_url": "https://cdn.example.com/vip/v5-mic.png",
      "animation_url": "",
      "format": "png",
      "metadata_json": "{\"format\":\"png\"}",
      "entitlement_id": "ent_abc",
      "expires_at_ms": 1786592000000
    },
    "mic_seat_animation": {
      "resource_id": 5103,
      "resource_code": "fami_vip3_voice_wave",
      "resource_type": "mic_seat_animation",
      "name": "VIP Voice Wave",
      "asset_url": "https://cdn.example.com/vip/voice-wave.png",
      "preview_url": "https://cdn.example.com/vip/voice-wave.png",
      "animation_url": "https://cdn.example.com/vip/voice-wave.svga",
      "format": "svga",
      "metadata_json": "{\"format\":\"svga\"}",
      "entitlement_id": "ent_wave",
      "expires_at_ms": 1786592000000
    },
    "resources": [
      {
        "resource_id": 5105,
        "resource_code": "fami_vip5_mic",
        "resource_type": "mic_seat_icon",
        "name": "VIP5 Mic",
        "asset_url": "https://cdn.example.com/vip/v5-mic.png",
        "preview_url": "https://cdn.example.com/vip/v5-mic.png",
        "animation_url": "",
        "format": "png",
        "metadata_json": "{\"format\":\"png\"}",
        "entitlement_id": "ent_abc",
        "expires_at_ms": 1786592000000
      },
      {
        "resource_id": 5103,
        "resource_code": "fami_vip3_voice_wave",
        "resource_type": "mic_seat_animation",
        "name": "VIP Voice Wave",
        "asset_url": "https://cdn.example.com/vip/voice-wave.png",
        "preview_url": "https://cdn.example.com/vip/voice-wave.png",
        "animation_url": "https://cdn.example.com/vip/voice-wave.svga",
        "format": "svga",
        "metadata_json": "{\"format\":\"svga\"}",
        "entitlement_id": "ent_wave",
        "expires_at_ms": 1786592000000
      }
    ],
    "server_time_ms": 1784265600000
  }
}
```

没有佩戴时对应字段固定为 `{}`。房间 join/detail 等响应的 `data.profiles[].mic_seat_icon`、`data.profiles[].mic_seat_animation` 与 appearance 对象同构。Flutter 用 `mic_seat_icon.asset_url` 替换有用户麦位的默认中心图标，同时继续按 `mic_seat_animation.format + animation_url/asset_url` 渲染 Voice Wave；任一字段为空都不能清除或覆盖另一个字段。

## 6. 房间背景：上传、保存、切换

### 6.1 专用上传

```http
POST /api/v1/rooms/{room_id}/backgrounds/upload
Authorization: Bearer {access_token}
Content-Type: multipart/form-data

command_id={本次上传并保存动作的幂等键}
file={binary}
```

服务端同时校验本地文件事实、房主身份、房间 active 和 `custom_room_background` 权益，再写对象存储。

| 约束 | 房间背景 |
| --- | --- |
| 格式 | JPEG、PNG、GIF、WebP；文件签名、multipart MIME、文件扩展名必须一致 |
| 文件大小 | 最大 12 MiB |
| 宽高 | `width <= 2160`、`height <= 3840` |
| 总像素 | 最大 8,294,400 |
| 动画 | 仅 GIF/WebP；最多 120 帧，时长 `(0, 15000]ms` |
| 静态图元数据 | `frame_count=1`、`animated=false`、`duration_ms=0` |

Flutter multipart 必须给 part 设置正确 MIME，例如 JPEG 使用 `image/jpeg`。不要把所有文件统一发成 `application/octet-stream`。

成功响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_bg_upload",
  "data": {
    "purpose": "room_background",
    "room_id": "fami_room_1001",
    "command_id": "bg_550e8400-e29b-41d4-a716-446655440000",
    "room_version": 17,
    "media": {
      "url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
      "object_key": "room-media/fami/backgrounds/10001/fami_room_1001/x.png",
      "content_type": "image/png",
      "format": "png",
      "size_bytes": 345678,
      "width": 1080,
      "height": 1920,
      "animated": false,
      "frame_count": 1,
      "duration_ms": 0,
      "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
      "status": "active"
    },
    "server_time_ms": 1784265600000
  }
}
```

`room_version` 是上传授权时看到的版本，不表示背景已保存或切换。Flutter 必须把整个 `media` 原样保留，不能只保存 URL，也不能改 `object_key`、尺寸、哈希或状态。

上传超时重试必须复用同一 `command_id` 和同一文件字节；换文件属于新动作，必须生成新 command。若后续 save 使用同一 command 提交不同媒体，返回 409 `IDEMPOTENCY_CONFLICT`。

当前后端不生成 poster/thumbnail，HTTP 中空值会省略 `poster_url`、`thumbnail_url`；IM 为保持固定媒体属性会返回这两个 key，当前值为空字符串。Flutter 不得自行拼接缩略图 URL。

### 6.2 保存素材

```http
POST /api/v1/rooms/backgrounds/save
Content-Type: application/json
```

Fami 请求：

```json
{
  "room_id": "fami_room_1001",
  "command_id": "bg_550e8400-e29b-41d4-a716-446655440000",
  "media": {
    "url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
    "object_key": "room-media/fami/backgrounds/10001/fami_room_1001/x.png",
    "content_type": "image/png",
    "format": "png",
    "size_bytes": 345678,
    "width": 1080,
    "height": 1920,
    "animated": false,
    "frame_count": 1,
    "duration_ms": 0,
    "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
    "status": "active"
  }
}
```

`image_url` 可省略，gateway 会从 `media.url` 补齐。Lalu 旧请求仍可只传 `room_id + image_url`。历史 Lalu 背景的列表/当前态和 IM 事件可能只有 `image_url` 或 `room_background_url`，没有嵌套 `media` 及其属性；Flutter 需兼容这种缺省，Fami 新链路则以完整 `media` 为准。

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_bg_save",
  "data": {
    "background": {
      "background_id": 101,
      "room_id": "fami_room_1001",
      "image_url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
      "created_by_user_id": "10001",
      "created_at_ms": 1784265600100,
      "updated_at_ms": 1784265600100,
      "command_id": "bg_550e8400-e29b-41d4-a716-446655440000",
      "media": {
        "url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
        "object_key": "room-media/fami/backgrounds/10001/fami_room_1001/x.png",
        "content_type": "image/png",
        "format": "png",
        "size_bytes": 345678,
        "width": 1080,
        "height": 1920,
        "animated": false,
        "frame_count": 1,
        "duration_ms": 0,
        "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
        "status": "active"
      }
    },
    "server_time_ms": 1784265600100
  }
}
```

保存只写房间素材列表，不改变 Room Cell，不发 IM。

### 6.3 查询素材

```http
GET /api/v1/rooms/fami_room_1001/backgrounds
```

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_bg_list",
  "data": {
    "backgrounds": [
      {
        "background_id": 101,
        "room_id": "fami_room_1001",
        "image_url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
        "created_by_user_id": "10001",
        "created_at_ms": 1784265600100,
        "updated_at_ms": 1784265600100,
        "command_id": "bg_550e8400-e29b-41d4-a716-446655440000",
        "media": {
          "url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
          "object_key": "room-media/fami/backgrounds/10001/fami_room_1001/x.png",
          "content_type": "image/png",
          "format": "png",
          "size_bytes": 345678,
          "width": 1080,
          "height": 1920,
          "animated": false,
          "frame_count": 1,
          "duration_ms": 0,
          "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
          "status": "active"
        }
      }
    ],
    "selected_background_url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
    "selected_background": {
      "background_id": 101,
      "room_id": "fami_room_1001",
      "image_url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
      "created_by_user_id": "10001",
      "created_at_ms": 1784265600100,
      "updated_at_ms": 1784265600100,
      "command_id": "bg_550e8400-e29b-41d4-a716-446655440000",
      "media": {
        "url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
        "object_key": "room-media/fami/backgrounds/10001/fami_room_1001/x.png",
        "content_type": "image/png",
        "format": "png",
        "size_bytes": 345678,
        "width": 1080,
        "height": 1920,
        "animated": false,
        "frame_count": 1,
        "duration_ms": 0,
        "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
        "status": "active"
      }
    },
    "server_time_ms": 1784265600200
  }
}
```

未设置背景时 `selected_background_url` 和 `selected_background` 会省略。

### 6.4 切换当前背景

```http
POST /api/v1/rooms/backgrounds/set
Content-Type: application/json

{
  "room_id": "fami_room_1001",
  "command_id": "bg_set_550e8400-e29b-41d4-a716-446655440001",
  "background_id": "101"
}
```

`background_id` 接受 JSON 数字或数字字符串。代表响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_bg_set",
  "data": {
    "result": {
      "applied": true,
      "room_version": 18,
      "server_time_ms": 1784265600300
    },
    "room": {
      "room_id": "fami_room_1001",
      "im_group_id": "fami_room_1001",
      "title": "Fami Room",
      "background_url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
      "owner_user_id": "10001",
      "status": "active",
      "chat_enabled": true,
      "locked": false,
      "version": 18,
      "active_background": {
        "background_id": 101,
        "room_id": "fami_room_1001",
        "image_url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
        "created_by_user_id": "10001",
        "created_at_ms": 1784265600100,
        "updated_at_ms": 1784265600100,
        "command_id": "bg_550e8400-e29b-41d4-a716-446655440000",
        "media": {
          "url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
          "object_key": "room-media/fami/backgrounds/10001/fami_room_1001/x.png",
          "content_type": "image/png",
          "format": "png",
          "size_bytes": 345678,
          "width": 1080,
          "height": 1920,
          "animated": false,
          "frame_count": 1,
          "duration_ms": 0,
          "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
          "status": "active"
        }
      }
    },
    "background": {
      "background_id": 101,
      "room_id": "fami_room_1001",
      "image_url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
      "created_by_user_id": "10001",
      "created_at_ms": 1784265600100,
      "updated_at_ms": 1784265600100,
      "command_id": "bg_550e8400-e29b-41d4-a716-446655440000",
      "media": {
        "url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
        "object_key": "room-media/fami/backgrounds/10001/fami_room_1001/x.png",
        "content_type": "image/png",
        "format": "png",
        "size_bytes": 345678,
        "width": 1080,
        "height": 1920,
        "animated": false,
        "frame_count": 1,
        "duration_ms": 0,
        "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
        "status": "active"
      }
    },
    "server_time_ms": 1784265600300
  }
}
```

### 6.5 背景 IM

腾讯云原始 `TIMCustomElem`：

| 字段 | 精确值 |
| --- | --- |
| `Ext` | `room_system_message` |
| `Desc` | `room_background_changed` |
| `Data` | 下方 JSON 的 UTF-8 字符串 |
| `CloudCustomData` | 与 `Data` 完全相同 |

`Data` 同时保留 `attributes`，并把所有 attributes 平铺到顶层。媒体属性在 IM 中都是字符串：

```json
{
  "event_id": "evt_xxx",
  "room_id": "fami_room_1001",
  "event_type": "room_background_changed",
  "actor_user_id": 10001,
  "room_version": 18,
  "attributes": {
    "background_id": "101",
    "room_background_url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
    "url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
    "object_key": "room-media/fami/backgrounds/10001/fami_room_1001/x.png",
    "content_type": "image/png",
    "format": "png",
    "size_bytes": "345678",
    "width": "1080",
    "height": "1920",
    "animated": "false",
    "frame_count": "1",
    "duration_ms": "0",
    "poster_url": "",
    "thumbnail_url": "",
    "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
  },
  "background_id": "101",
  "room_background_url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
  "url": "https://cdn.example.com/room-media/fami/backgrounds/10001/fami_room_1001/x.png",
  "object_key": "room-media/fami/backgrounds/10001/fami_room_1001/x.png",
  "content_type": "image/png",
  "format": "png",
  "size_bytes": "345678",
  "width": "1080",
  "height": "1920",
  "animated": "false",
  "frame_count": "1",
  "duration_ms": "0",
  "poster_url": "",
  "thumbnail_url": "",
  "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
```

状态处理：同房间、`event_id` 未处理且 `room_version >= localRoomVersion` 才应用；如果版本跳跃大于 1，先拉 `/rooms/{room_id}/detail`。断线重进始终使用详情中的 `room.active_background` 恢复，不依赖历史 IM。

## 7. 房间边框与彩色房名

先从背包查询对应资源：

```http
GET /api/v1/users/me/resources?resource_type=room_border
GET /api/v1/users/me/resources?resource_type=room_name_style
```

Fami 当前 `benefit_inheritance_policy=target_only`，高等级不会自动继承低等级套餐绑定的房间边框。只有当前 `state.effective_benefits` 中存在 active `room_border`，且背包查询返回对应有效 `items[].entitlement_id` 时，Flutter 才开放“应用”按钮并发送保存请求。若 `items=[]`，这是当前套餐未配置/未发放权益，不是保存接口失败；客户端必须保持锁态且不得用其他等级资源 ID 或空 `entitlement_id` 调 `/border/save`，运营需先在当前目标套餐绑定并发放真实 `room_border` 资源。

只保存房间边框时优先使用专用入口 `POST /api/v1/rooms/{room_id}/border/save`，请求只传 `command_id/resource_id/entitlement_id`；服务端返回字符串 `room_border_url`。通用 `/decorations/apply` 继续用于彩色房名及兼容调用。

然后由房主应用到具体房间：

```http
POST /api/v1/rooms/fami_room_1001/decorations/apply
Content-Type: application/json

{
  "command_id": "room_decor_550e8400-e29b-41d4-a716-446655440002",
  "decoration_type": "colored_room_name",
  "resource_id": "5205",
  "entitlement_id": "ent_room_name_abc"
}
```

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `command_id` | 是 | 最大 128 字节，重试复用 |
| `decoration_type` | 是 | `room_border` 或 `colored_room_name` |
| `resource_id` | 是 | JSON 数字或数字字符串 |
| `entitlement_id` | 否 | 建议明确传实例；省略时服务端选择当前有效实例 |

`colored_room_name` 的资源目录类型固定为 `room_name_style`。成功响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_decor_apply",
  "data": {
    "result": {
      "applied": true,
      "room_version": 19,
      "server_time_ms": 1784265600400
    },
    "room": {
      "room_id": "fami_room_1001",
      "im_group_id": "fami_room_1001",
      "title": "Fami Room",
      "owner_user_id": "10001",
      "status": "active",
      "chat_enabled": true,
      "locked": false,
      "version": 19,
      "room_name_style": {
        "resource_id": 5205,
        "resource_code": "fami_vip_room_name_gold",
        "resource_type": "room_name_style",
        "name": "Gold Room Name",
        "asset_url": "https://cdn.example.com/vip/room-name.json",
        "preview_url": "https://cdn.example.com/vip/room-name.png",
        "metadata_json": "{\"text_colors\":[\"#FFD76A\",\"#FF8A00\"],\"stops\":[0,1],\"angle_degrees\":90}",
        "entitlement_id": "ent_room_name_abc",
        "text_colors": ["#FFD76A", "#FF8A00"],
        "stops": [0, 1],
        "angle_degrees": 90,
        "expires_at_ms": 1786592000000
      }
    },
    "decoration_type": "colored_room_name",
    "resource": {
      "resource_id": 5205,
      "resource_code": "fami_vip_room_name_gold",
      "resource_type": "room_name_style",
      "name": "Gold Room Name",
      "asset_url": "https://cdn.example.com/vip/room-name.json",
      "preview_url": "https://cdn.example.com/vip/room-name.png",
      "metadata_json": "{\"text_colors\":[\"#FFD76A\",\"#FF8A00\"],\"stops\":[0,1],\"angle_degrees\":90}",
      "entitlement_id": "ent_room_name_abc",
      "text_colors": ["#FFD76A", "#FF8A00"],
      "stops": [0, 1],
      "angle_degrees": 90,
      "expires_at_ms": 1786592000000
    },
    "server_time_ms": 1784265600400
  }
}
```

服务端必须同时验证：当前用户是房主、房间 active、VIP benefit 精确绑定同一 `resource_id/resource_type`、entitlement 有效、资源 active。`expires_at_ms` 是 VIP 与 entitlement 到期时间的较早者。

彩色房名 `text_colors` 最多 8 个，颜色只接受 `#RRGGBB` 或 `#RRGGBBAA`；`stops` 若提供，数量必须与颜色一致、范围 `[0,1]` 且升序。Flutter 直接渲染响应字段，不再次解析 `metadata_json` 作为权限事实。

房间列表、feed、详情和命令响应会直接返回字符串 `room_border_url` 以及结构化 `room_border`、`room_name_style`；`room_border_url` 为空表示无有效边框。到达 `expires_at_ms` 时客户端立即隐藏，并刷新房间详情。

### 7.1 装扮 IM

| TIMCustomElem 字段 | 精确值 |
| --- | --- |
| `Ext` | `room_system_message` |
| `Desc` | `room_decoration_changed` |
| `Data` | 下方 JSON 字符串 |
| `CloudCustomData` | 与 `Data` 相同 |

```json
{
  "event_id": "evt_xxx",
  "room_id": "fami_room_1001",
  "event_type": "room_decoration_changed",
  "actor_user_id": 10001,
  "room_version": 19,
  "attributes": {
    "decoration_type": "colored_room_name",
    "resource_id": "5205",
    "resource_code": "fami_vip_room_name_gold",
    "resource_type": "room_name_style",
    "name": "Gold Room Name",
    "asset_url": "https://cdn.example.com/vip/room-name.json",
    "preview_url": "https://cdn.example.com/vip/room-name.png",
    "animation_url": "",
    "format": "",
    "metadata_json": "{\"text_colors\":[\"#FFD76A\",\"#FF8A00\"],\"stops\":[0,1],\"angle_degrees\":90}",
    "text_colors": "[\"#FFD76A\",\"#FF8A00\"]",
    "stops": "[0,1]",
    "angle_degrees": "90",
    "entitlement_id": "ent_room_name_abc",
    "expires_at_ms": "1786592000000"
  },
  "decoration_type": "colored_room_name",
  "resource_id": "5205",
  "resource_code": "fami_vip_room_name_gold",
  "resource_type": "room_name_style",
  "name": "Gold Room Name",
  "asset_url": "https://cdn.example.com/vip/room-name.json",
  "preview_url": "https://cdn.example.com/vip/room-name.png",
  "animation_url": "",
  "format": "",
  "metadata_json": "{\"text_colors\":[\"#FFD76A\",\"#FF8A00\"],\"stops\":[0,1],\"angle_degrees\":90}",
  "text_colors": "[\"#FFD76A\",\"#FF8A00\"]",
  "stops": "[0,1]",
  "angle_degrees": "90",
  "entitlement_id": "ent_room_name_abc",
  "expires_at_ms": "1786592000000"
}
```

注意：IM 内 `text_colors` 和 `stops` 是“JSON 数组的字符串”，HTTP 中才是数组。Flutter IM parser 需要再做一次 `jsonDecode`。`room_name_style` 是结构化样式而不是媒体 renderer，HTTP 不返回 `format`，IM 的通用 attributes 中 `format` 为空字符串；客户端直接使用颜色、stops 和角度。

当后台禁用/改绑资源、entitlement 撤销、VIP 或等级配置失效时，wallet outbox 会驱动 Room Cell 清除已失效装扮。清除仍使用 `room_decoration_changed`：`decoration_type` 保留原槽位，`resource_id` 固定为字符串 `"0"`，其他资源 attributes 为空值。Flutter 收到后必须立即清空对应的 `room_border` 或 `room_name_style` 槽位，不能尝试查询资源 ID 0。状态事件统一按 `event_id + room_version` 去重和排序；纯自然到期不保证恰好有 IM，仍要按 `expires_at_ms` 本地到点隐藏并拉详情校准。

## 8. 主页数据隐藏与匿名访问

### 8.1 记录主页访问

```http
POST /api/v1/users/{target_user_id}/visit
Authorization: Bearer {access_token}
```

无 body。公开响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_visit_public",
  "data": {
    "recorded": true,
    "target_stats_hidden": false,
    "target_stats": {
      "visitors_count": 20,
      "following_count": 8,
      "friends_count": 5,
      "updated_at_ms": 1784265600500
    },
    "target_profile": {
      "user_id": "20002",
      "display_user_id": "888002",
      "username": "Nina",
      "avatar": "https://cdn.example.com/avatar.png"
    }
  }
}
```

目标用户的 `hide_profile_data` 权益和同名开关当前生效时：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_visit_hidden",
  "data": {
    "recorded": true,
    "target_stats_hidden": true,
    "target_profile": {
      "user_id": "20002",
      "display_user_id": "888002",
      "username": "Nina",
      "avatar": "https://cdn.example.com/avatar.png"
    }
  }
}
```

`target_stats_hidden=true` 时 `target_stats` 字段完全不存在，不会返回 `null` 或伪造的 0。Flutter 必须用显式布尔值控制统计区：

```dart
if (data.targetStatsHidden) {
  hideProfileStats();
} else {
  renderProfileStats(data.targetStats!);
}
```

`recorded=true` 表示首次形成该公开/匿名聚合项；同一身份重复访问会累计次数并更新时间，可能返回 `recorded=false`，不表示请求失败。

匿名与公开身份在“本次写入时”决定：

- 访问者当时 `anonymous_profile_visit` 权益和开关都生效，写匿名表并固定随机 `anonymous_visit_id`。
- 以后关闭开关或 VIP 到期，历史匿名记录仍保持匿名。
- 同一真实用户先公开访问、后匿名访问，会形成两个独立展示聚合；历史公开记录不会改写，匿名记录也不会借公开记录泄露身份。
- 匿名权益查询失败时本次访问不落库并返回上游错误；目标统计隐藏查询失败时按隐藏处理，避免泄漏。
- 当前 App 访问链路不设好友或管理员白名单；除本人访问本身会直接被拒绝外，其他查看者一律以 `target_stats_hidden` 为最终结论。

### 8.2 查询我的访客

```http
GET /api/v1/users/me/visitors?page=1&page_size=20
```

`page` 默认为 1，`page_size` 默认为 20，显式传值时两者都必须大于 0，且 `page` 和 `page_size` 都不能超过 100。超限时返回 HTTP 400 + `INVALID_ARGUMENT`，Flutter 应修正本地分页参数，不要当作空页或自动重试。这个边界把公开与匿名两个分支的单次合并窗口固定在最多各 10,000 条。

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_visitors",
  "data": {
    "records": [
      {
        "visitor_user_id": "30003",
        "visitor_identity_hidden": false,
        "target_user_id": "20002",
        "visit_count": 4,
        "last_visited_at_ms": 1784265600600
      },
      {
        "anonymous_visit_id": "anon_0123456789abcdef0123456789abcdef",
        "visitor_identity_hidden": true,
        "target_user_id": "20002",
        "visit_count": 2,
        "last_visited_at_ms": 1784265500000
      }
    ],
    "total": 2
  }
}
```

解析规则：

| `visitor_identity_hidden` | 必须存在 | 必须不存在 | UI |
| --- | --- | --- | --- |
| `false` | `visitor_user_id` | `anonymous_visit_id` | 可按 user_id 拉取公开资料 |
| `true` | `anonymous_visit_id` | `visitor_user_id` | 展示匿名头像/名称；不能发起资料查询 |

`anonymous_visit_id` 是随机、目标用户作用域内稳定的展示 ID；不同被访问者看到的值不同，不能跨用户关联。主页访问和列表不发送 IM。

## 9. 房间图片消息

### 9.1 专用上传

```http
POST /api/v1/rooms/{room_id}/messages/images/upload
Authorization: Bearer {access_token}
Content-Type: multipart/form-data

command_id={本次上传并发送动作的幂等键}
file={binary}
```

上传前服务端校验：房间 active、当前用户仍在房、未被 ban、未被禁言、公屏开启，以及 `room_image_message` 当前生效。用户不要求在麦位上。

| 约束 | 房间图片 |
| --- | --- |
| 格式 | JPEG、PNG、GIF、WebP；签名、MIME、扩展名一致 |
| 文件大小 | 最大 8 MiB |
| 宽高 | `width <= 4096`、`height <= 4096` |
| 总像素 | 最大 16,777,216 |
| 动画 | 仅 GIF/WebP；最多 120 帧，最长 15 秒 |

成功响应与背景上传同构，但：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_image_upload",
  "data": {
    "purpose": "room_image",
    "room_id": "fami_room_1001",
    "command_id": "room_image_550e8400-e29b-41d4-a716-446655440003",
    "room_version": 19,
    "media": {
      "url": "https://cdn.example.com/room-media/fami/images/10001/fami_room_1001/y.webp",
      "object_key": "room-media/fami/images/10001/fami_room_1001/y.webp",
      "content_type": "image/webp",
      "format": "webp",
      "size_bytes": 456789,
      "width": 800,
      "height": 800,
      "animated": true,
      "frame_count": 12,
      "duration_ms": 2400,
      "sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
      "status": "active"
    },
    "server_time_ms": 1784265600700
  }
}
```

图片上传超时同样只能用原 `command_id + 同一文件` 重试；重新选图生成新 command。当前 `poster_url/thumbnail_url` 不生成，HTTP 为空时省略，IM 中以空字符串出现。

### 9.2 发送图片

```http
POST /api/v1/rooms/fami_room_1001/messages/image
Content-Type: application/json
```

```json
{
  "command_id": "room_image_550e8400-e29b-41d4-a716-446655440003",
  "image": {
    "url": "https://cdn.example.com/room-media/fami/images/10001/fami_room_1001/y.webp",
    "object_key": "room-media/fami/images/10001/fami_room_1001/y.webp",
    "content_type": "image/webp",
    "format": "webp",
    "size_bytes": 456789,
    "width": 800,
    "height": 800,
    "animated": true,
    "frame_count": 12,
    "duration_ms": 2400,
    "sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
    "status": "active"
  }
}
```

必须原样使用上传响应的整个 `media`。成功响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_image_send",
  "data": {
    "result": {
      "applied": true,
      "room_version": 20,
      "server_time_ms": 1784265600800
    },
    "accepted": true,
    "message_id": "room_msg_0123456789abcdef0123456789abcdef",
    "room_id": "fami_room_1001",
    "sender_user_id": "10001",
    "server_time_ms": 1784265600800,
    "image": {
      "url": "https://cdn.example.com/room-media/fami/images/10001/fami_room_1001/y.webp",
      "object_key": "room-media/fami/images/10001/fami_room_1001/y.webp",
      "content_type": "image/webp",
      "format": "webp",
      "size_bytes": 456789,
      "width": 800,
      "height": 800,
      "animated": true,
      "frame_count": 12,
      "duration_ms": 2400,
      "sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
      "status": "active"
    }
  }
}
```

`accepted=true` 表示 command log 与 durable outbox 已提交，不表示腾讯云 IM 已到达每个在线客户端。`message_id` 由 `app_code + room_id + command_id` 稳定生成；相同请求重放仍返回同一个值。

服务端对同一 command 只提交一条逻辑图片消息和一条 outbox 事实；补偿投递或 SDK 漫游仍可能让客户端观察到重复到达，所以 Flutter 用 `event_id` 去重，并以 `message_id` 作为跨投递/乐观消息合并兜底。

发送者可用 HTTP 响应先插入一条 `sending/pending` UI 消息，收到 IM 后按 `message_id` 合并成已确认状态，不能再插入第二条。

### 9.3 图片 IM

| TIMCustomElem 字段 | 精确值 |
| --- | --- |
| `Ext` | `room_system_message` |
| `Desc` | `room_image_message` |
| `Data` | 下方 JSON 字符串 |
| `CloudCustomData` | 与 `Data` 相同 |

```json
{
  "event_id": "evt_xxx",
  "room_id": "fami_room_1001",
  "event_type": "room_image_message",
  "actor_user_id": 10001,
  "room_version": 20,
  "attributes": {
    "message_id": "room_msg_0123456789abcdef0123456789abcdef",
    "command_id": "room_image_550e8400-e29b-41d4-a716-446655440003",
    "sender_user_id": "10001",
    "image_url": "https://cdn.example.com/room-media/fami/images/10001/fami_room_1001/y.webp",
    "image_object_key": "room-media/fami/images/10001/fami_room_1001/y.webp",
    "image_content_type": "image/webp",
    "image_format": "webp",
    "image_size_bytes": "456789",
    "image_width": "800",
    "image_height": "800",
    "image_animated": "true",
    "image_frame_count": "12",
    "image_duration_ms": "2400",
    "image_poster_url": "",
    "image_thumbnail_url": "",
    "image_sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
  },
  "message_id": "room_msg_0123456789abcdef0123456789abcdef",
  "command_id": "room_image_550e8400-e29b-41d4-a716-446655440003",
  "sender_user_id": "10001",
  "image_url": "https://cdn.example.com/room-media/fami/images/10001/fami_room_1001/y.webp",
  "image_object_key": "room-media/fami/images/10001/fami_room_1001/y.webp",
  "image_content_type": "image/webp",
  "image_format": "webp",
  "image_size_bytes": "456789",
  "image_width": "800",
  "image_height": "800",
  "image_animated": "true",
  "image_frame_count": "12",
  "image_duration_ms": "2400",
  "image_poster_url": "",
  "image_thumbnail_url": "",
  "image_sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
}
```

所有 `attributes`/平铺媒体值都是字符串。图片消息是内容流，不能因为 `room_version < localRoomVersion` 就丢弃；先按 `event_id` 去除 outbox 重投，再按 `message_id` 合并发送者的乐观消息或 SDK 重复消息。

## 10. `VIP_BENEFIT_REQUIRED` 结构化错误

资源佩戴、背景上传/保存/切换、房间装扮、房间图片上传/发送和上线通知都会在执行前复验权益。失败统一为：

```http
HTTP/1.1 403 Forbidden
```

```json
{
  "code": "VIP_BENEFIT_REQUIRED",
  "message": "vip benefit is required",
  "request_id": "req_denied",
  "data": {
    "benefit_code": "room_image_message",
    "required_level": 3,
    "current_effective_level": 2,
    "action": "send_room_image"
  }
}
```

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `benefit_code` | string | 本次缺失的精确权益 |
| `required_level` | int | 当前 App 首个显式配置该权益的 active 等级；`0` 表示当前未配置 |
| `current_effective_level` | int | 付费 VIP 与体验卡合并后的当前有效等级 |
| `action` | string | 被拒绝的后端动作。背景链路依次为 `upload_room_background`、`save_room_background`、`set_room_background`；其他动作包括 `equip_resource`、`apply_room_decoration`、`upload_room_image`、`send_room_image` |

Flutter 处理：关闭 loading，保留用户尚未提交的本地选择，刷新 `/api/v1/vip/me` 和必要的 `/api/v1/vip/packages`，显示升级/续费入口。不要只修改本地等级后自动重试。

## 11. 其他错误矩阵

| HTTP | code | 场景 | Flutter 动作 |
| --- | --- | --- | --- |
| 400 | `INVALID_JSON` | JSON 无法解析 | 修正客户端序列化，不重试同一 body |
| 400 | `INVALID_ARGUMENT` | command、ID 或媒体元数据不完整 | 停止重试，重新构造请求 |
| 400 | `INVALID_MEDIA_DIMENSIONS` | 宽高、像素、帧数或动画时长越界 | 提示重新选择或在客户端压缩 |
| 401 | `UNAUTHORIZED` / token 类错误 | 登录态失效 | 走统一刷新 token/登录流程 |
| 403 | `PROFILE_REQUIRED` | 资料未完成 | 先完成资料 |
| 403 | `PERMISSION_DENIED` | 非房主、不在房、被 ban/禁言、对象不归属当前用户/房间 | 关闭对应操作并刷新房间状态 |
| 403 | `VIP_BENEFIT_REQUIRED` | 权益、开关或等级不满足 | 按第 10 节刷新 VIP |
| 404 | `NOT_FOUND` | 房间、背景、resource 或 entitlement 不存在 | 刷新对应列表 |
| 409 | `IDEMPOTENCY_CONFLICT` | 同 command_id 换了 payload | 客户端 bug；新动作生成新 ID，旧动作仍用旧 body |
| 409 | `ROOM_CLOSED` | 房间已关闭 | 退出房间并停止上传/发送 |
| 409 | `CONFLICT` | 记录匿名访问时的极低概率展示 ID 碰撞；事务已回滚 | 发起一次新的访问请求，让服务端重新生成随机 ID；不得回退为公开记录或在客户端构造匿名 ID |
| 409 | `CONFLICT` | 公屏关闭或当前状态不允许 | 刷新详情，不自动换 command 重试 |
| 413 | `FILE_TOO_LARGE` | 背景超过 12MiB、房间图片超过 8MiB | 客户端压缩或重选 |
| 415 | `UNSUPPORTED_MEDIA_TYPE` | 签名、MIME、扩展名不一致，或 APNG/其他格式 | 转码为受支持格式 |
| 500 | `INTERNAL_ERROR` | 服务端持久化或未分类内部错误 | 保留 `request_id` 上报；不得在本地伪造访问成功或隐私结论 |
| 502 | `UPSTREAM_ERROR` | owner service、wallet 或对象存储暂不可用 | 保留当前 command 与 payload，退避重试 |
| 503 | `UPSTREAM_ERROR` | 上传或房间 VIP 依赖未就绪 | 保留当前 command 与 payload，退避重试 |

`FILE_TOO_LARGE`、`UNSUPPORTED_MEDIA_TYPE`、`INVALID_MEDIA_DIMENSIONS` 是上传专用 code。不要把所有失败都显示成“VIP不足”。

## 12. 腾讯云 IM 统一路由与去重

room-service 调腾讯云 REST 时使用以下原始形态；`CloudCustomData` 是消息级字段，不在 `TIMCustomElem.MsgContent` 内：

```json
{
  "CloudCustomData": "{与 Data 相同的 JSON 字符串}",
  "MsgBody": [
    {
      "MsgType": "TIMCustomElem",
      "MsgContent": {
        "Data": "{业务 JSON 字符串}",
        "Desc": "room_image_message",
        "Ext": "room_system_message",
        "Sound": ""
      }
    }
  ]
}
```

Flutter SDK 中通常对应 `customElem.data/desc/extension`；具体 Dart 包字段名可以不同，但字节内容和值不能改写。

### 12.1 相关消息表

| 场景 | 会话 | Ext | Desc |
| --- | --- | --- | --- |
| VIP 激活 | C2C | `vip_notice` | `VipActivated` |
| VIP 进房展示 | 当前房间群 | `room_system_message` | `room_user_joined` |
| VIP 上线全服通知 | 全局播报群 | `im_broadcast` | `vip_online_notice` |
| 房间背景切换 | 当前房间群 | `room_system_message` | `room_background_changed` |
| 房间装扮切换 | 当前房间群 | `room_system_message` | `room_decoration_changed` |
| 房间图片 | 当前房间群 | `room_system_message` | `room_image_message` |

用户资源 equip 没有客户端 IM。主页隐私写入也没有 IM。
房间图片和房间装扮的客户端实时入口分别只有 `room_image_message`、`room_decoration_changed` 这两个 custom event；不要再等待 wallet/VIP 的第二条消息确认同一次房间动作。

### 12.2 Flutter 路由顺序

1. 从 `V2TimMessage.customElem` 读取 `extension/ext`、`desc`、`data`；SDK 字段名因封装不同，值必须与上表完全匹配。
2. `Ext=room_system_message` 时解析 `Data` JSON。
3. 验证 `data.event_type == Desc`、`data.room_id == currentRoomId`。
4. 用进程内有界 LRU/TTL 集合按 `event_id` 去重；建议每个房间保留最近 1000 个或 30 分钟。
5. 背景/装扮是状态事件：再按 `room_version` 排序，版本断层时拉详情。
6. 图片是内容事件：按 `message_id` 再去重，不用本地较新的 room version 丢弃旧图片。

伪代码：

```dart
void onCustomMessage(CustomElem elem) {
  if (elem.extension != 'room_system_message') return;
  final event = jsonDecode(utf8.decode(elem.data)) as Map<String, dynamic>;
  if (event['event_type'] != elem.desc) return;
  if (event['room_id'] != currentRoomId) return;

  final eventId = event['event_id'] as String? ?? '';
  if (eventId.isEmpty || handledEventIds.contains(eventId)) return;
  handledEventIds.add(eventId);

  switch (event['event_type']) {
    case 'room_background_changed':
      applyBackgroundStateEvent(event);
      break;
    case 'room_decoration_changed':
      applyDecorationStateEvent(event);
      break;
    case 'room_image_message':
      mergeRoomImageByMessageId(event);
      break;
  }
}
```

腾讯云 IM 的 `Data` 和 `CloudCustomData` 内容相同；正常只消费一次 `customElem.data`。不要同时消费两份造成重复。

服务端群消息的腾讯 `Random` 已由 `event_id` 稳定派生，可收敛腾讯短窗口内的相同事件重试；超出腾讯去重窗口的 outbox 补偿仍可能再次到达，因此 Flutter 的 `event_id` 去重不可省略，图片再以 `message_id` 合并兜底。

### 12.3 刷新与恢复

| 时机 | HTTP 刷新 |
| --- | --- |
| 登录/切账号/冷启动/回前台 | `/vip/me` |
| 打开 VIP 中心 | 并发 `/vip/me` + `/vip/packages` |
| VIP 购买或体验卡成功 | 使用写响应更新；必要时再拉 `/vip/me` 和 `/vip/packages` |
| 用户资源佩戴成功 | 先用写响应更新背包，再拉 `/users/me/appearance`；已在房内时重拉 `/rooms/{room_id}/detail`，分别更新 `profiles[].mic_seat_icon` 与 `profiles[].mic_seat_animation` |
| 进入或重连房间 | `/rooms/{room_id}/detail` 初始化 `active_background`、`room_border`、`room_name_style` |
| 背景/装扮 IM 版本断层 | `/rooms/{room_id}/detail` |
| 装扮到 `expires_at_ms` | 本地立即隐藏，再拉详情确认 |
| 主页打开 | 先资料接口，再 `POST /users/{id}/visit`；按 `target_stats_hidden` 渲染 |
| 访客页打开/下拉刷新 | `/users/me/visitors` |

房间恢复请求为 `GET /api/v1/rooms/{room_id}/detail`；`room_id` 是必填 path 参数，无 query 和 body。成功响应 `data` 字段如下：

| 字段 | 含义 |
| --- | --- |
| `room` | 房间最终态，含可选 `active_background/room_border/room_name_style` |
| `viewer` / `permissions` / `is_followed` | 当前用户的房内身份、可操作权限与关注状态 |
| `seats` / `rank_top` / `online_count` / `profiles` | 麦位、首屏贡献榜、在线数和用户展示资料；`profiles` 可省略 |
| `im` / `rtc` | 腾讯 IM 群进入参数与 RTC 凭据状态 |
| `server_time_ms` | UTC Unix epoch milliseconds |

不要持久化“拥有某权益”的最终判断。磁盘缓存最多用于首帧占位；所有敏感写操作都必须接受服务端实时拒绝。

## 13. Flutter 模块执行方案

建议按五个边界落地：

1. `VipRepository`
   - 解析 `/vip/me`、`/vip/packages`、资源列表和结构化 VIP 错误。
   - 以 `(appCode, configVersion)` 隔离缓存，不能把 Fami 权益合并到 Lalu。
2. `AppearanceRepository`
   - 解析当前/公开 appearance 以及房间 `profiles[]`中的同构资源外观。
   - 只根据显式 `format` 选择渲染器；字段缺省就不渲染，不从 URL 后缀推断。
3. `RoomMediaRepository`
   - 负责 MIME 检测、multipart 上传、保存/发送时原样回传 `RoomMedia`。
   - 一个 pending action 保存 `commandId + roomId + localFile + uploadedMedia`，超时重试不重建 command。
4. `RoomStateStore`
   - 保存 `roomVersion`、`activeBackground`、`roomBorder`、`roomNameStyle` 和用户资料 `profiles`。
   - HTTP 快照全量替换，IM 状态事件按 version 增量更新。
5. `RoomCustomMessageRouter`
   - 按 `Ext/Desc/event_type` 分发，维护 `event_id` LRU。
   - 图片维护独立 `message_id` 索引，用于发送者乐观消息与 IM 回执合并。

推荐上线顺序：

1. 先发布兼容解析：允许 `resource`、`active_background`、`room_border`、`room_name_style` 缺省。
2. 接入结构化 `VIP_BENEFIT_REQUIRED` 和统一 `command_id` 管理。
3. 接入用户资源 equip 与 VIP 素材真实 URL。
4. 接入背景专用上传、保存、切换及 IM。
5. 接入装扮 HTTP/IM 与到期隐藏。
6. 接入主页隐私两种分支。
7. 最后接入房间图片乐观消息、outbox IM 去重和异常恢复。

## 14. 联调验收清单

- Fami `/vip/packages`：有真实资源时返回完整 `resource`，未配置时字段省略。
- Lalu：旧背景 `image_url` 请求仍可用，新 Flutter 也可走专用协议。
- 资源 equip：相同 command/body 重放成功；换 entitlement 返回 `IDEMPOTENCY_CONFLICT`。
- 麦位图标与动效：资源列表、appearance 和房间资料分别使用 `mic_seat_icon`、`mic_seat_animation`；两个槽位独立，顶层 `format` 与各自资源一致，Flutter 不从 URL 推断，也不互相覆盖。
- VIP 到期后尝试重新 equip 历史资源：返回带四个 data 字段的 `VIP_BENEFIT_REQUIRED`。
- 背景：JPEG/PNG/GIF/WebP 正常；错误 MIME、APNG、超大小、超尺寸分别命中预期 code。
- 背景 upload 成功但未 save：背景列表不增加；save 后未 set：当前背景不变化。
- set 成功：HTTP、详情快照和 `room_background_changed` 三处一致。
- 装扮：非房主、无 entitlement、资源类型错、VIP 到期都不能写入 Room Cell。
- 彩色房名：HTTP 数组能渲染；IM 的数组字符串能二次解析。
- 主页统计隐藏：`target_stats_hidden=true` 时 JSON 中没有 `target_stats`。
- 匿名访问：访客列表没有真实 `visitor_user_id`；关闭匿名后历史记录仍匿名。
- 同一用户公开后匿名：列表可出现公开和匿名两个独立聚合。
- 房间图片：不在房、ban、禁言、公屏关闭、无权益均被拒绝；不要求上麦。
- 图片 send HTTP 成功但 IM 重投：同一 `event_id/message_id` 只显示一条。
- IM 状态版本断层、App 回前台或重连：详情快照可恢复最终背景和装扮。
