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

57 KiB
Raw Blame History

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_coderesource_code 拼接素材 URL。只有接口实际返回的 benefits[].resource、背包 resource 或房间最终快照可以渲染。
  • 用户级装扮与房间级装扮分开:mic_seat_animation 走用户资源佩戴;room_borderroom_name_style 必须走 Room Cell 的房间装扮接口。
  • 房间背景和房间图片只支持 JPEG、PNG、GIF、WebP。首版不支持 APNG、SVG、视频或外部 URL。
  • 房间状态以 HTTP 快照恢复,腾讯云 IM 只负责实时增量。不能把 IM 是否到达当成权益或持久状态事实。

五阶段对应关系:

阶段 Flutter 可用结果
1. 契约与错误 VIP 套餐内嵌真实 resource;资源装备有 command_idVIP_BENEFIT_REQUIRED 有结构化 data
2. 资源映射与复验 mic_skin -> mic_seat_animationroom_border -> room_bordercolored_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_idfile
X-App-Code 通常否 由 App 包和登录态决定;不得在同一 token 下任意切换 Fami/Lalu

客户端不提交 app_codeuser_idactor_user_idrequest_id。这些值由 gateway 从登录态和请求上下文注入。

2.3 Envelope

成功:

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

失败通常省略 data;需要客户端执行动作的错误会返回安全结构化 data。业务分支只判断 code,不要解析 message。日志至少记录 path、coderequest_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 是可选字段,只有同时满足以下条件才出现:

  1. resource_id > 0
  2. 资源属于当前 App
  3. 资源 status=active
  4. 资源的 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=disabledresource_id=0,响应不返回 resource;不能先启用权益再让客户端使用占位素材。

resource.format 是客户端选择素材渲染器的唯一格式依据。后端优先取 metadata_json.format,缺省时兼容取 metadata_json.animation_format,并返回小写值;两者都没有时顶层 format 字段省略Flutter 应当作“格式未配置/当前不支持”,不得根据 asset_urlpreview_urlanimation_url 后缀推断。后台创建 mic_seat_animationroom_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 applydecoration_type=colored_room_name

本版本初始数据只有 Fami VIP4VIP8 的 mic_skin 已绑定可核实资源;room_bordercolored_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_borderroom_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 发腾讯云 IMwallet 只发布内部 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 <= 2160height <= 3840
总像素 最大 8,294,400
动画 仅 GIF/WebP最多 120 帧,时长 (0, 15000]ms
静态图元数据 frame_count=1animated=falseduration_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/thumbnailHTTP 中空值会省略 poster_urlthumbnail_urlIM 为保持固定媒体属性会返回这两个 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_urlroom_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_urlselected_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_bordercolored_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#RRGGBBAAstops 若提供,数量必须与颜色一致、范围 [0,1] 且升序。Flutter 直接渲染响应字段,不再次解析 metadata_json 作为权限事实。

房间列表、feed、详情和命令响应会直接返回最终 room_borderroom_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_colorsstops 是“JSON 数组的字符串”HTTP 中才是数组。Flutter IM parser 需要再做一次 jsonDecoderoom_name_style 是结构化样式而不是媒体 rendererHTTP 不返回 formatIM 的通用 attributes 中 format 为空字符串客户端直接使用颜色、stops 和角度。

当后台禁用/改绑资源、entitlement 撤销、VIP 或等级配置失效时wallet outbox 会驱动 Room Cell 清除已失效装扮。清除仍使用 room_decoration_changeddecoration_type 保留原槽位,resource_id 固定为字符串 "0",其他资源 attributes 为空值。Flutter 收到后必须立即清空对应的 room_borderroom_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=truetarget_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 默认为 1page_size 默认为 20显式传值时两者都必须大于 0pagepage_size 都不能超过 100。超限时返回 HTTP 400 + INVALID_ARGUMENTFlutter 应修正本地分页参数,不要当作空页或自动重试。这个边界把公开与匿名两个分支的单次合并窗口固定在最多各 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 <= 4096height <= 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_idapp_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_backgroundsave_room_backgroundset_room_background;其他动作包括 equip_resourceapply_room_decorationupload_room_imagesend_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_LARGEUNSUPPORTED_MEDIA_TYPEINVALID_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_messageroom_decoration_changed 这两个 custom event不要再等待 wallet/VIP 的第二条消息确认同一次房间动作。

12.2 Flutter 路由顺序

  1. V2TimMessage.customElem 读取 extension/extdescdataSDK 字段名因封装不同,值必须与上表完全匹配。
  2. Ext=room_system_message 时解析 Data JSON。
  3. 验证 data.event_type == Descdata.room_id == currentRoomId
  4. 用进程内有界 LRU/TTL 集合按 event_id 去重;建议每个房间保留最近 1000 个或 30 分钟。
  5. 背景/装扮是状态事件:再按 room_version 排序,版本断层时拉详情。
  6. 图片是内容事件:按 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 的 DataCloudCustomData 内容相同;正常只消费一次 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_backgroundroom_borderroom_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}/detailroom_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
    • 保存 roomVersionactiveBackgroundroomBorderroomNameStyle 和用户资料 profiles
    • HTTP 快照全量替换IM 状态事件按 version 增量更新。
  5. RoomCustomMessageRouter
    • Ext/Desc/event_type 分发,维护 event_id LRU。
    • 图片维护独立 message_id 索引,用于发送者乐观消息与 IM 回执合并。

推荐上线顺序:

  1. 先发布兼容解析:允许 resourceactive_backgroundroom_borderroom_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 回前台或重连:详情快照可恢复最终背景和装扮。