# 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 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_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 发腾讯云 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_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/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 ``` 然后由房主应用到具体房间: ```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` 是结构化样式而不是媒体 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; 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 回前台或重连:详情快照可恢复最终背景和装扮。