1486 lines
57 KiB
Markdown
1486 lines
57 KiB
Markdown
# 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<String, dynamic>;
|
||
if (event['event_type'] != elem.desc) return;
|
||
if (event['room_id'] != currentRoomId) return;
|
||
|
||
final eventId = event['event_id'] as String? ?? '';
|
||
if (eventId.isEmpty || handledEventIds.contains(eventId)) return;
|
||
handledEventIds.add(eventId);
|
||
|
||
switch (event['event_type']) {
|
||
case 'room_background_changed':
|
||
applyBackgroundStateEvent(event);
|
||
break;
|
||
case 'room_decoration_changed':
|
||
applyDecorationStateEvent(event);
|
||
break;
|
||
case 'room_image_message':
|
||
mergeRoomImageByMessageId(event);
|
||
break;
|
||
}
|
||
}
|
||
```
|
||
|
||
腾讯云 IM 的 `Data` 和 `CloudCustomData` 内容相同;正常只消费一次 `customElem.data`。不要同时消费两份造成重复。
|
||
|
||
服务端群消息的腾讯 `Random` 已由 `event_id` 稳定派生,可收敛腾讯短窗口内的相同事件重试;超出腾讯去重窗口的 outbox 补偿仍可能再次到达,因此 Flutter 的 `event_id` 去重不可省略,图片再以 `message_id` 合并兜底。
|
||
|
||
### 12.3 刷新与恢复
|
||
|
||
| 时机 | HTTP 刷新 |
|
||
| --- | --- |
|
||
| 登录/切账号/冷启动/回前台 | `/vip/me` |
|
||
| 打开 VIP 中心 | 并发 `/vip/me` + `/vip/packages` |
|
||
| VIP 购买或体验卡成功 | 使用写响应更新;必要时再拉 `/vip/me` 和 `/vip/packages` |
|
||
| 用户资源佩戴成功 | 先用写响应更新背包,再拉 `/users/me/appearance`;已在房内时重拉 `/rooms/{room_id}/detail` 更新 `profiles[].mic_seat_animation` |
|
||
| 进入或重连房间 | `/rooms/{room_id}/detail` 初始化 `active_background`、`room_border`、`room_name_style` |
|
||
| 背景/装扮 IM 版本断层 | `/rooms/{room_id}/detail` |
|
||
| 装扮到 `expires_at_ms` | 本地立即隐藏,再拉详情确认 |
|
||
| 主页打开 | 先资料接口,再 `POST /users/{id}/visit`;按 `target_stats_hidden` 渲染 |
|
||
| 访客页打开/下拉刷新 | `/users/me/visitors` |
|
||
|
||
房间恢复请求为 `GET /api/v1/rooms/{room_id}/detail`;`room_id` 是必填 path 参数,无 query 和 body。成功响应 `data` 字段如下:
|
||
|
||
| 字段 | 含义 |
|
||
| --- | --- |
|
||
| `room` | 房间最终态,含可选 `active_background/room_border/room_name_style` |
|
||
| `viewer` / `permissions` / `is_followed` | 当前用户的房内身份、可操作权限与关注状态 |
|
||
| `seats` / `rank_top` / `online_count` / `profiles` | 麦位、首屏贡献榜、在线数和用户展示资料;`profiles` 可省略 |
|
||
| `im` / `rtc` | 腾讯 IM 群进入参数与 RTC 凭据状态 |
|
||
| `server_time_ms` | UTC Unix epoch milliseconds |
|
||
|
||
不要持久化“拥有某权益”的最终判断。磁盘缓存最多用于首帧占位;所有敏感写操作都必须接受服务端实时拒绝。
|
||
|
||
## 13. Flutter 模块执行方案
|
||
|
||
建议按五个边界落地:
|
||
|
||
1. `VipRepository`
|
||
- 解析 `/vip/me`、`/vip/packages`、资源列表和结构化 VIP 错误。
|
||
- 以 `(appCode, configVersion)` 隔离缓存,不能把 Fami 权益合并到 Lalu。
|
||
2. `AppearanceRepository`
|
||
- 解析当前/公开 appearance 以及房间 `profiles[]`中的同构资源外观。
|
||
- 只根据显式 `format` 选择渲染器;字段缺省就不渲染,不从 URL 后缀推断。
|
||
3. `RoomMediaRepository`
|
||
- 负责 MIME 检测、multipart 上传、保存/发送时原样回传 `RoomMedia`。
|
||
- 一个 pending action 保存 `commandId + roomId + localFile + uploadedMedia`,超时重试不重建 command。
|
||
4. `RoomStateStore`
|
||
- 保存 `roomVersion`、`activeBackground`、`roomBorder`、`roomNameStyle` 和用户资料 `profiles`。
|
||
- HTTP 快照全量替换,IM 状态事件按 version 增量更新。
|
||
5. `RoomCustomMessageRouter`
|
||
- 按 `Ext/Desc/event_type` 分发,维护 `event_id` LRU。
|
||
- 图片维护独立 `message_id` 索引,用于发送者乐观消息与 IM 回执合并。
|
||
|
||
推荐上线顺序:
|
||
|
||
1. 先发布兼容解析:允许 `resource`、`active_background`、`room_border`、`room_name_style` 缺省。
|
||
2. 接入结构化 `VIP_BENEFIT_REQUIRED` 和统一 `command_id` 管理。
|
||
3. 接入用户资源 equip 与 VIP 素材真实 URL。
|
||
4. 接入背景专用上传、保存、切换及 IM。
|
||
5. 接入装扮 HTTP/IM 与到期隐藏。
|
||
6. 接入主页隐私两种分支。
|
||
7. 最后接入房间图片乐观消息、outbox IM 去重和异常恢复。
|
||
|
||
## 14. 联调验收清单
|
||
|
||
- Fami `/vip/packages`:有真实资源时返回完整 `resource`,未配置时字段省略。
|
||
- Lalu:旧背景 `image_url` 请求仍可用,新 Flutter 也可走专用协议。
|
||
- 资源 equip:相同 command/body 重放成功;换 entitlement 返回 `IDEMPOTENCY_CONFLICT`。
|
||
- 麦位动效:资源列表、appearance 和房间 `profiles[].mic_seat_animation` 的顶层 `format` 一致;后台未配格式时三处都省略该字段,Flutter 不从 URL 推断。
|
||
- VIP 到期后尝试重新 equip 历史资源:返回带四个 data 字段的 `VIP_BENEFIT_REQUIRED`。
|
||
- 背景:JPEG/PNG/GIF/WebP 正常;错误 MIME、APNG、超大小、超尺寸分别命中预期 code。
|
||
- 背景 upload 成功但未 save:背景列表不增加;save 后未 set:当前背景不变化。
|
||
- set 成功:HTTP、详情快照和 `room_background_changed` 三处一致。
|
||
- 装扮:非房主、无 entitlement、资源类型错、VIP 到期都不能写入 Room Cell。
|
||
- 彩色房名:HTTP 数组能渲染;IM 的数组字符串能二次解析。
|
||
- 主页统计隐藏:`target_stats_hidden=true` 时 JSON 中没有 `target_stats`。
|
||
- 匿名访问:访客列表没有真实 `visitor_user_id`;关闭匿名后历史记录仍匿名。
|
||
- 同一用户公开后匿名:列表可出现公开和匿名两个独立聚合。
|
||
- 房间图片:不在房、ban、禁言、公屏关闭、无权益均被拒绝;不要求上麦。
|
||
- 图片 send HTTP 成功但 IM 重投:同一 `event_id/message_id` 只显示一条。
|
||
- IM 状态版本断层、App 回前台或重连:详情快照可恢复最终背景和装扮。
|