hyapp-server/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md
2026-07-17 16:55:22 +08:00

1486 lines
57 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.

# 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_animation` 走用户资源佩戴;`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_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}/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_animation",
"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_animation",
"name": "VIP5 Mic",
"status": "active",
"grantable": false,
"grant_strategy": "new_entitlement",
"usage_scopes": ["room_mic"],
"asset_url": "https://cdn.example.com/vip/v5-mic.svga",
"preview_url": "https://cdn.example.com/vip/v5-mic.png",
"animation_url": "https://cdn.example.com/vip/v5-mic.svga",
"format": "svga",
"metadata_json": "{\"format\":\"svga\"}",
"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_animation",
"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_animation",
"name": "VIP5 Mic",
"status": "active",
"grantable": false,
"grant_strategy": "new_entitlement",
"usage_scopes": ["room_mic"],
"asset_url": "https://cdn.example.com/vip/v5-mic.svga",
"preview_url": "https://cdn.example.com/vip/v5-mic.png",
"animation_url": "https://cdn.example.com/vip/v5-mic.svga",
"format": "svga",
"metadata_json": "{\"format\":\"svga\"}",
"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": true,
"anonymous_profile_visit_enabled": true,
"leaderboard_invisible_enabled": true,
"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_animation``room_border` 资源时必须在 `metadata_json.format` 中选择 `png/jpeg/webp/gif/svga/pag/mp4` 之一。
当前映射:
| benefit_code | resource_type | 应用位置 |
| --- | --- | --- |
| `mic_skin` | `mic_seat_animation` | 用户资源 equip 后由 appearance/房间资料投影 |
| `room_border` | `room_border` | 房间 decoration apply |
| `colored_room_name` | `room_name_style` | 房间 decoration apply`decoration_type=colored_room_name` |
本版本初始数据只有 Fami VIP4VIP8 的 `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_animation
```
`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_animation",
"name": "VIP5 Mic",
"status": "active",
"grantable": false,
"grant_strategy": "new_entitlement",
"usage_scopes": ["room_mic"],
"asset_url": "https://cdn.example.com/vip/v5-mic.svga",
"preview_url": "https://cdn.example.com/vip/v5-mic.png",
"animation_url": "https://cdn.example.com/vip/v5-mic.svga",
"format": "svga",
"metadata_json": "{\"format\":\"svga\"}",
"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 发腾讯云 IMwallet 只发布内部 `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_animation` 同构。下例只展开麦位动效相关字段,其他 appearance 字段以 OpenAPI 为准:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_appearance",
"data": {
"user_id": "10001",
"mic_seat_animation": {
"resource_id": 5105,
"resource_code": "fami_vip5_mic",
"resource_type": "mic_seat_animation",
"name": "VIP5 Mic",
"asset_url": "https://cdn.example.com/vip/v5-mic.svga",
"preview_url": "https://cdn.example.com/vip/v5-mic.png",
"animation_url": "https://cdn.example.com/vip/v5-mic.svga",
"format": "svga",
"metadata_json": "{\"format\":\"svga\"}",
"entitlement_id": "ent_abc",
"expires_at_ms": 1786592000000
},
"resources": [
{
"resource_id": 5105,
"resource_code": "fami_vip5_mic",
"resource_type": "mic_seat_animation",
"name": "VIP5 Mic",
"asset_url": "https://cdn.example.com/vip/v5-mic.svga",
"preview_url": "https://cdn.example.com/vip/v5-mic.png",
"animation_url": "https://cdn.example.com/vip/v5-mic.svga",
"format": "svga",
"metadata_json": "{\"format\":\"svga\"}",
"entitlement_id": "ent_abc",
"expires_at_ms": 1786592000000
}
],
"server_time_ms": 1784265600000
}
}
```
没有佩戴时 `mic_seat_animation={}`。已佩戴但后台没有显式格式时,该对象仍存在但 `format` 字段省略Flutter 不渲染且不从 URL 推断。房间 join/detail 等响应的 `data.profiles[].mic_seat_animation` 与此对象同构,其他用户的麦位效果必须直接使用该快照中的 `format + animation_url/asset_url`
## 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/thumbnailHTTP 中空值会省略 `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
```
然后由房主应用到具体房间:
```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``room_name_style`;字段不存在表示当前无有效装扮。到达 `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` 是结构化样式而不是媒体 rendererHTTP 不返回 `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_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 和房间 `profiles[].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 回前台或重连:详情快照可恢复最终背景和装扮。