57 KiB
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
线上: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
成功:
{
"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 购买、体验卡、返现和上线通知接口保持不变:
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 套餐接口
GET /api/v1/vip/packages
Authorization: Bearer {access_token}
无 query 和 body 参数。代表响应:
{
"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 是可选字段,只有同时满足以下条件才出现:
resource_id > 0;- 资源属于当前 App;
- 资源
status=active; - 资源的
resource_type与 benefit 精确一致。
条件不满足时字段会被完全省略,不会返回空对象。Flutter 展示素材入口应使用:
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 状态
GET /api/v1/vip/me
返回根层兼容字段、program_config 和完整 state。授权只看:
data.state.effective_vip
data.state.effective_benefits
data.state.user_settings
不要使用根层 data.level 重新推导权益。登录、切账号、冷启动、回前台、购买/体验卡切换成功以及收到 VIP 状态类 IM 后重新拉取。
4.3 VIP 设置
GET /api/v1/vip/settings
PATCH /api/v1/vip/settings
PATCH 是局部更新,至少提交一个字段;显式 false 不能当成未提交:
{
"hide_profile_data_enabled": true,
"anonymous_profile_visit_enabled": true
}
开关只是用户偏好,不能单独授权。执行主页访问、房间行为或上线通知时,后端会把当前有效 benefit 与对应开关合并判定。
5. 用户资源查询与幂等佩戴
5.1 查询背包资源
GET /api/v1/users/me/resources?resource_type=mic_seat_animation
resource_type 可省略。房间装扮可分别查询 room_border、room_name_style,但不能调用通用 equip;它们必须走第 7 节 Room Cell 接口。
{
"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 佩戴用户级资源
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 佩戴后的外观与房间投影
当前用户佩戴态:
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 为准:
{
"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 专用上传
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。
成功响应:
{
"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 保存素材
POST /api/v1/rooms/backgrounds/save
Content-Type: application/json
Fami 请求:
{
"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 为准。
{
"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 查询素材
GET /api/v1/rooms/fami_room_1001/backgrounds
{
"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 切换当前背景
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 数字或数字字符串。代表响应:
{
"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 中都是字符串:
{
"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. 房间边框与彩色房名
先从背包查询对应资源:
GET /api/v1/users/me/resources?resource_type=room_border
GET /api/v1/users/me/resources?resource_type=room_name_style
然后由房主应用到具体房间:
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。成功响应:
{
"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 相同 |
{
"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 记录主页访问
POST /api/v1/users/{target_user_id}/visit
Authorization: Bearer {access_token}
无 body。公开响应:
{
"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 权益和同名开关当前生效时:
{
"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 必须用显式布尔值控制统计区:
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 查询我的访客
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 条。
{
"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 专用上传
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 秒 |
成功响应与背景上传同构,但:
{
"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 发送图片
POST /api/v1/rooms/fami_room_1001/messages/image
Content-Type: application/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。成功响应:
{
"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 相同 |
{
"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/1.1 403 Forbidden
{
"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 内:
{
"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 路由顺序
- 从
V2TimMessage.customElem读取extension/ext、desc、data;SDK 字段名因封装不同,值必须与上表完全匹配。 Ext=room_system_message时解析DataJSON。- 验证
data.event_type == Desc、data.room_id == currentRoomId。 - 用进程内有界 LRU/TTL 集合按
event_id去重;建议每个房间保留最近 1000 个或 30 分钟。 - 背景/装扮是状态事件:再按
room_version排序,版本断层时拉详情。 - 图片是内容事件:按
message_id再去重,不用本地较新的 room version 丢弃旧图片。
伪代码:
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 模块执行方案
建议按五个边界落地:
VipRepository- 解析
/vip/me、/vip/packages、资源列表和结构化 VIP 错误。 - 以
(appCode, configVersion)隔离缓存,不能把 Fami 权益合并到 Lalu。
- 解析
AppearanceRepository- 解析当前/公开 appearance 以及房间
profiles[]中的同构资源外观。 - 只根据显式
format选择渲染器;字段缺省就不渲染,不从 URL 后缀推断。
- 解析当前/公开 appearance 以及房间
RoomMediaRepository- 负责 MIME 检测、multipart 上传、保存/发送时原样回传
RoomMedia。 - 一个 pending action 保存
commandId + roomId + localFile + uploadedMedia,超时重试不重建 command。
- 负责 MIME 检测、multipart 上传、保存/发送时原样回传
RoomStateStore- 保存
roomVersion、activeBackground、roomBorder、roomNameStyle和用户资料profiles。 - HTTP 快照全量替换,IM 状态事件按 version 增量更新。
- 保存
RoomCustomMessageRouter- 按
Ext/Desc/event_type分发,维护event_idLRU。 - 图片维护独立
message_id索引,用于发送者乐观消息与 IM 回执合并。
- 按
推荐上线顺序:
- 先发布兼容解析:允许
resource、active_background、room_border、room_name_style缺省。 - 接入结构化
VIP_BENEFIT_REQUIRED和统一command_id管理。 - 接入用户资源 equip 与 VIP 素材真实 URL。
- 接入背景专用上传、保存、切换及 IM。
- 接入装扮 HTTP/IM 与到期隐藏。
- 接入主页隐私两种分支。
- 最后接入房间图片乐观消息、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 回前台或重连:详情快照可恢复最终背景和装扮。