增加麦克风皮肤的装扮

This commit is contained in:
zhx 2026-07-22 14:24:04 +08:00
parent 070b4f179a
commit 838a76f0cc
19 changed files with 311 additions and 110 deletions

View File

@ -154,6 +154,10 @@ final benefitHandlers = <String, VipBenefitHandler>{
展示等级详情时直接循环该套餐的 `benefits`,可按 `benefit_type``execution_scope` 分组;不要把上表写进 Flutter。高等级是否包含低等级权益由后台下发的完整集合决定。 展示等级详情时直接循环该套餐的 `benefits`,可按 `benefit_type``execution_scope` 分组;不要把上表写进 Flutter。高等级是否包含低等级权益由后台下发的完整集合决定。
麦位相关的两个权益不得合并:`mic_skin` 必须绑定 `resource_type=mic_seat_icon`,只替换麦位中心图标;`voice_wave` 使用 `resource_type=mic_seat_animation`,只渲染声波动效。用户装备后,`/api/v1/appearance`、完整模式 `JoinRoom.profiles``/api/v1/users/room-display-profiles:batch` 会同时返回独立的 `mic_seat_icon``mic_seat_animation` 字段Flutter 应分层叠加,不能用任一字段覆盖另一字段。
房间边框遵循 `target_only`:当前有效套餐未绑定/未发放 `room_border` 时,`GET /api/v1/users/me/resources?resource_type=room_border` 返回 `items=[]`。Flutter 此时保持锁态,不发送 `/api/v1/rooms/{room_id}/border/save`;只有拿到当前有效 `entitlement_id` 后才开放应用,不能用低等级套餐资源兜底。
### 3.2 Lalu 与其他 App ### 3.2 Lalu 与其他 App
- `/vip/packages``/vip/me` 返回当前登录态对应 App 的配置Flutter 不传 `app_code` - `/vip/packages``/vip/me` 返回当前登录态对应 App 的配置Flutter 不传 `app_code`

View File

@ -7,7 +7,7 @@
- Fami 当前 VIP 体系是 `tiered_privilege_v1`。资格只能读取服务端返回的 `state.effective_benefits`,执行写操作时仍由后端实时复验。 - Fami 当前 VIP 体系是 `tiered_privilege_v1`。资格只能读取服务端返回的 `state.effective_benefits`,执行写操作时仍由后端实时复验。
- Lalu 当前保持 `legacy_timed`。旧版房间背景 `image_url` 请求和不带资源佩戴 `command_id` 的客户端仍兼容;新 Flutter 应统一使用本文新协议。 - Lalu 当前保持 `legacy_timed`。旧版房间背景 `image_url` 请求和不带资源佩戴 `command_id` 的客户端仍兼容;新 Flutter 应统一使用本文新协议。
- Flutter 不按 VIP 等级、`benefit_code``resource_code` 拼接素材 URL。只有接口实际返回的 `benefits[].resource`、背包 `resource` 或房间最终快照可以渲染。 - Flutter 不按 VIP 等级、`benefit_code``resource_code` 拼接素材 URL。只有接口实际返回的 `benefits[].resource`、背包 `resource` 或房间最终快照可以渲染。
- 用户级装扮与房间级装扮分开:`mic_seat_animation` 走用户资源佩戴;`room_border``room_name_style` 必须走 Room Cell 的房间装扮接口。 - 用户级装扮与房间级装扮分开:`mic_seat_icon`(麦位中心图标)和 `mic_seat_animation`Voice Wave分别独立的用户资源佩戴`room_border``room_name_style` 必须走 Room Cell 的房间装扮接口。
- 房间背景和房间图片只支持 JPEG、PNG、GIF、WebP。首版不支持 APNG、SVG、视频或外部 URL。 - 房间背景和房间图片只支持 JPEG、PNG、GIF、WebP。首版不支持 APNG、SVG、视频或外部 URL。
- 房间状态以 HTTP 快照恢复,腾讯云 IM 只负责实时增量。不能把 IM 是否到达当成权益或持久状态事实。 - 房间状态以 HTTP 快照恢复,腾讯云 IM 只负责实时增量。不能把 IM 是否到达当成权益或持久状态事实。
@ -16,7 +16,7 @@
| 阶段 | Flutter 可用结果 | | 阶段 | Flutter 可用结果 |
| --- | --- | | --- | --- |
| 1. 契约与错误 | VIP 套餐内嵌真实 `resource`;资源装备有 `command_id``VIP_BENEFIT_REQUIRED` 有结构化 `data` | | 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 | | 2. 资源映射与复验 | `mic_skin -> mic_seat_icon`、`voice_wave -> mic_seat_animation`、`room_border -> room_border``colored_room_name -> room_name_style`;写操作复验当前权益和 entitlement |
| 3. 背景与装扮 | 房间专用上传、完整媒体元数据、背景保存/切换、房间装扮 Room Cell 快照与 IM | | 3. 背景与装扮 | 房间专用上传、完整媒体元数据、背景保存/切换、房间装扮 Room Cell 快照与 IM |
| 4. 主页隐私 | 目标统计隐藏显式返回;匿名访问在写入时固化,访客列表不泄露真实 ID | | 4. 主页隐私 | 目标统计隐藏显式返回;匿名访问在写入时固化,访客列表不泄露真实 ID |
| 5. 房间图片 | 专用上传、发言与权益门禁、command log、durable outbox、腾讯云 IM 图片事件 | | 5. 房间图片 | 专用上传、发言与权益门禁、command log、durable outbox、腾讯云 IM 图片事件 |
@ -161,7 +161,7 @@ Authorization: Bearer {access_token}
"status": "active", "status": "active",
"trial_enabled": true, "trial_enabled": true,
"resource_id": 5105, "resource_id": 5105,
"resource_type": "mic_seat_animation", "resource_type": "mic_seat_icon",
"execution_scope": "room", "execution_scope": "room",
"auto_equip": false, "auto_equip": false,
"sort_order": 50, "sort_order": 50,
@ -169,24 +169,24 @@ Authorization: Bearer {access_token}
"created_at_ms": 1784000000000, "created_at_ms": 1784000000000,
"updated_at_ms": 1784000000000, "updated_at_ms": 1784000000000,
"presentation": { "presentation": {
"description": "VIP麦位动态皮肤", "description": "VIP麦位中心图标",
"preview_items": [], "preview_items": [],
"numeric_reward": null "numeric_reward": null
}, },
"resource": { "resource": {
"resource_id": 5105, "resource_id": 5105,
"resource_code": "fami_vip5_mic", "resource_code": "fami_vip5_mic",
"resource_type": "mic_seat_animation", "resource_type": "mic_seat_icon",
"name": "VIP5 Mic", "name": "VIP5 Mic",
"status": "active", "status": "active",
"grantable": false, "grantable": false,
"grant_strategy": "new_entitlement", "grant_strategy": "new_entitlement",
"usage_scopes": ["room_mic"], "usage_scopes": ["room_mic"],
"asset_url": "https://cdn.example.com/vip/v5-mic.svga", "asset_url": "https://cdn.example.com/vip/v5-mic.png",
"preview_url": "https://cdn.example.com/vip/v5-mic.png", "preview_url": "https://cdn.example.com/vip/v5-mic.png",
"animation_url": "https://cdn.example.com/vip/v5-mic.svga", "animation_url": "",
"format": "svga", "format": "png",
"metadata_json": "{\"format\":\"svga\"}", "metadata_json": "{\"format\":\"png\"}",
"sort_order": 50, "sort_order": 50,
"created_at_ms": 1784000000000, "created_at_ms": 1784000000000,
"updated_at_ms": 1784000000000 "updated_at_ms": 1784000000000
@ -239,7 +239,7 @@ Authorization: Bearer {access_token}
"status": "active", "status": "active",
"trial_enabled": true, "trial_enabled": true,
"resource_id": 5105, "resource_id": 5105,
"resource_type": "mic_seat_animation", "resource_type": "mic_seat_icon",
"execution_scope": "room", "execution_scope": "room",
"auto_equip": false, "auto_equip": false,
"sort_order": 50, "sort_order": 50,
@ -247,24 +247,24 @@ Authorization: Bearer {access_token}
"created_at_ms": 1784000000000, "created_at_ms": 1784000000000,
"updated_at_ms": 1784000000000, "updated_at_ms": 1784000000000,
"presentation": { "presentation": {
"description": "VIP麦位动态皮肤", "description": "VIP麦位中心图标",
"preview_items": [], "preview_items": [],
"numeric_reward": null "numeric_reward": null
}, },
"resource": { "resource": {
"resource_id": 5105, "resource_id": 5105,
"resource_code": "fami_vip5_mic", "resource_code": "fami_vip5_mic",
"resource_type": "mic_seat_animation", "resource_type": "mic_seat_icon",
"name": "VIP5 Mic", "name": "VIP5 Mic",
"status": "active", "status": "active",
"grantable": false, "grantable": false,
"grant_strategy": "new_entitlement", "grant_strategy": "new_entitlement",
"usage_scopes": ["room_mic"], "usage_scopes": ["room_mic"],
"asset_url": "https://cdn.example.com/vip/v5-mic.svga", "asset_url": "https://cdn.example.com/vip/v5-mic.png",
"preview_url": "https://cdn.example.com/vip/v5-mic.png", "preview_url": "https://cdn.example.com/vip/v5-mic.png",
"animation_url": "https://cdn.example.com/vip/v5-mic.svga", "animation_url": "",
"format": "svga", "format": "png",
"metadata_json": "{\"format\":\"svga\"}", "metadata_json": "{\"format\":\"png\"}",
"sort_order": 50, "sort_order": 50,
"created_at_ms": 1784000000000, "created_at_ms": 1784000000000,
"updated_at_ms": 1784000000000 "updated_at_ms": 1784000000000
@ -310,13 +310,14 @@ final canRenderMaterial = benefit.status == 'active' &&
后台配置顺序是“先创建同 App 的真实 active resource再把该 resource 绑定到 benefit”。真实素材不存在时该 benefit 必须保持 `status=disabled``resource_id=0`,响应不返回 `resource`;不能先启用权益再让客户端使用占位素材。 后台配置顺序是“先创建同 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` 之一 `resource.format` 是客户端选择素材渲染器的唯一格式依据。后端优先取 `metadata_json.format`,缺省时兼容取 `metadata_json.animation_format`,并返回小写值;两者都没有时顶层 `format` 字段省略Flutter 应当作“格式未配置/当前不支持”,不得根据 `asset_url``preview_url``animation_url` 后缀推断。后台创建 `mic_seat_icon`、`mic_seat_animation` 或 `room_border` 资源时,应在 `metadata_json.format` 中选择与真实素材一致的 `png/jpeg/webp/gif/svga/pag/mp4`
当前映射: 当前映射:
| benefit_code | resource_type | 应用位置 | | benefit_code | resource_type | 应用位置 |
| --- | --- | --- | | --- | --- | --- |
| `mic_skin` | `mic_seat_animation` | 用户资源 equip 后由 appearance/房间资料投影 | | `mic_skin` | `mic_seat_icon` | 用户资源 equip 后由 appearance/房间资料投影到麦位中心图标 |
| `voice_wave` | `mic_seat_animation` | 用户资源 equip 后由 appearance/房间资料投影到 Voice Wave |
| `room_border` | `room_border` | 房间 decoration apply | | `room_border` | `room_border` | 房间 decoration apply |
| `colored_room_name` | `room_name_style` | 房间 decoration apply`decoration_type=colored_room_name` | | `colored_room_name` | `room_name_style` | 房间 decoration apply`decoration_type=colored_room_name` |
@ -361,7 +362,7 @@ PATCH 是局部更新,至少提交一个字段;显式 `false` 不能当成
### 5.1 查询背包资源 ### 5.1 查询背包资源
```http ```http
GET /api/v1/users/me/resources?resource_type=mic_seat_animation GET /api/v1/users/me/resources?resource_type=mic_seat_icon
``` ```
`resource_type` 可省略。房间装扮可分别查询 `room_border``room_name_style`,但不能调用通用 equip它们必须走第 7 节 Room Cell 接口。 `resource_type` 可省略。房间装扮可分别查询 `room_border``room_name_style`,但不能调用通用 equip它们必须走第 7 节 Room Cell 接口。
@ -379,17 +380,17 @@ GET /api/v1/users/me/resources?resource_type=mic_seat_animation
"resource": { "resource": {
"resource_id": 5105, "resource_id": 5105,
"resource_code": "fami_vip5_mic", "resource_code": "fami_vip5_mic",
"resource_type": "mic_seat_animation", "resource_type": "mic_seat_icon",
"name": "VIP5 Mic", "name": "VIP5 Mic",
"status": "active", "status": "active",
"grantable": false, "grantable": false,
"grant_strategy": "new_entitlement", "grant_strategy": "new_entitlement",
"usage_scopes": ["room_mic"], "usage_scopes": ["room_mic"],
"asset_url": "https://cdn.example.com/vip/v5-mic.svga", "asset_url": "https://cdn.example.com/vip/v5-mic.png",
"preview_url": "https://cdn.example.com/vip/v5-mic.png", "preview_url": "https://cdn.example.com/vip/v5-mic.png",
"animation_url": "https://cdn.example.com/vip/v5-mic.svga", "animation_url": "",
"format": "svga", "format": "png",
"metadata_json": "{\"format\":\"svga\"}", "metadata_json": "{\"format\":\"png\"}",
"sort_order": 50, "sort_order": 50,
"created_at_ms": 1784000000000, "created_at_ms": 1784000000000,
"updated_at_ms": 1784000000000 "updated_at_ms": 1784000000000
@ -443,7 +444,7 @@ GET /api/v1/users/me/appearance
Authorization: Bearer {access_token} Authorization: Bearer {access_token}
``` ```
查询其他用户的公开外观使用 `GET /api/v1/users/by-id/{user_id}/appearance`。两个接口的 `data.mic_seat_animation` 同构。下例只展开麦位动效相关字段,其他 appearance 字段以 OpenAPI 为准: 查询其他用户的公开外观使用 `GET /api/v1/users/by-id/{user_id}/appearance`。两个接口的 `data.mic_seat_icon`、`data.mic_seat_animation` 分别同构。下例同时展开麦位中心图标与 Voice Wave,其他 appearance 字段以 OpenAPI 为准:
```json ```json
{ {
@ -452,31 +453,57 @@ Authorization: Bearer {access_token}
"request_id": "req_appearance", "request_id": "req_appearance",
"data": { "data": {
"user_id": "10001", "user_id": "10001",
"mic_seat_animation": { "mic_seat_icon": {
"resource_id": 5105, "resource_id": 5105,
"resource_code": "fami_vip5_mic", "resource_code": "fami_vip5_mic",
"resource_type": "mic_seat_animation", "resource_type": "mic_seat_icon",
"name": "VIP5 Mic", "name": "VIP5 Mic",
"asset_url": "https://cdn.example.com/vip/v5-mic.svga", "asset_url": "https://cdn.example.com/vip/v5-mic.png",
"preview_url": "https://cdn.example.com/vip/v5-mic.png", "preview_url": "https://cdn.example.com/vip/v5-mic.png",
"animation_url": "https://cdn.example.com/vip/v5-mic.svga", "animation_url": "",
"format": "png",
"metadata_json": "{\"format\":\"png\"}",
"entitlement_id": "ent_abc",
"expires_at_ms": 1786592000000
},
"mic_seat_animation": {
"resource_id": 5103,
"resource_code": "fami_vip3_voice_wave",
"resource_type": "mic_seat_animation",
"name": "VIP Voice Wave",
"asset_url": "https://cdn.example.com/vip/voice-wave.png",
"preview_url": "https://cdn.example.com/vip/voice-wave.png",
"animation_url": "https://cdn.example.com/vip/voice-wave.svga",
"format": "svga", "format": "svga",
"metadata_json": "{\"format\":\"svga\"}", "metadata_json": "{\"format\":\"svga\"}",
"entitlement_id": "ent_abc", "entitlement_id": "ent_wave",
"expires_at_ms": 1786592000000 "expires_at_ms": 1786592000000
}, },
"resources": [ "resources": [
{ {
"resource_id": 5105, "resource_id": 5105,
"resource_code": "fami_vip5_mic", "resource_code": "fami_vip5_mic",
"resource_type": "mic_seat_animation", "resource_type": "mic_seat_icon",
"name": "VIP5 Mic", "name": "VIP5 Mic",
"asset_url": "https://cdn.example.com/vip/v5-mic.svga", "asset_url": "https://cdn.example.com/vip/v5-mic.png",
"preview_url": "https://cdn.example.com/vip/v5-mic.png", "preview_url": "https://cdn.example.com/vip/v5-mic.png",
"animation_url": "https://cdn.example.com/vip/v5-mic.svga", "animation_url": "",
"format": "png",
"metadata_json": "{\"format\":\"png\"}",
"entitlement_id": "ent_abc",
"expires_at_ms": 1786592000000
},
{
"resource_id": 5103,
"resource_code": "fami_vip3_voice_wave",
"resource_type": "mic_seat_animation",
"name": "VIP Voice Wave",
"asset_url": "https://cdn.example.com/vip/voice-wave.png",
"preview_url": "https://cdn.example.com/vip/voice-wave.png",
"animation_url": "https://cdn.example.com/vip/voice-wave.svga",
"format": "svga", "format": "svga",
"metadata_json": "{\"format\":\"svga\"}", "metadata_json": "{\"format\":\"svga\"}",
"entitlement_id": "ent_abc", "entitlement_id": "ent_wave",
"expires_at_ms": 1786592000000 "expires_at_ms": 1786592000000
} }
], ],
@ -485,7 +512,7 @@ Authorization: Bearer {access_token}
} }
``` ```
没有佩戴时 `mic_seat_animation={}`。已佩戴但后台没有显式格式时,该对象仍存在但 `format` 字段省略Flutter 不渲染且不从 URL 推断。房间 join/detail 等响应的 `data.profiles[].mic_seat_animation` 与此对象同构,其他用户的麦位效果必须直接使用该快照中的 `format + animation_url/asset_url` 没有佩戴时对应字段固定为 `{}`。房间 join/detail 等响应的 `data.profiles[].mic_seat_icon`、`data.profiles[].mic_seat_animation` 与 appearance 对象同构。Flutter 用 `mic_seat_icon.asset_url` 替换有用户麦位的默认中心图标,同时继续按 `mic_seat_animation.format + animation_url/asset_url` 渲染 Voice Wave任一字段为空都不能清除或覆盖另一个字段
## 6. 房间背景:上传、保存、切换 ## 6. 房间背景:上传、保存、切换
@ -840,6 +867,8 @@ GET /api/v1/users/me/resources?resource_type=room_border
GET /api/v1/users/me/resources?resource_type=room_name_style GET /api/v1/users/me/resources?resource_type=room_name_style
``` ```
Fami 当前 `benefit_inheritance_policy=target_only`,高等级不会自动继承低等级套餐绑定的房间边框。只有当前 `state.effective_benefits` 中存在 active `room_border`,且背包查询返回对应有效 `items[].entitlement_id`Flutter 才开放“应用”按钮并发送保存请求。若 `items=[]`,这是当前套餐未配置/未发放权益,不是保存接口失败;客户端必须保持锁态且不得用其他等级资源 ID 或空 `entitlement_id``/border/save`,运营需先在当前目标套餐绑定并发放真实 `room_border` 资源。
只保存房间边框时优先使用专用入口 `POST /api/v1/rooms/{room_id}/border/save`,请求只传 `command_id/resource_id/entitlement_id`;服务端返回字符串 `room_border_url`。通用 `/decorations/apply` 继续用于彩色房名及兼容调用。 只保存房间边框时优先使用专用入口 `POST /api/v1/rooms/{room_id}/border/save`,请求只传 `command_id/resource_id/entitlement_id`;服务端返回字符串 `room_border_url`。通用 `/decorations/apply` 继续用于彩色房名及兼容调用。
然后由房主应用到具体房间: 然后由房主应用到具体房间:
@ -1419,7 +1448,7 @@ void onCustomMessage(CustomElem elem) {
| 登录/切账号/冷启动/回前台 | `/vip/me` | | 登录/切账号/冷启动/回前台 | `/vip/me` |
| 打开 VIP 中心 | 并发 `/vip/me` + `/vip/packages` | | 打开 VIP 中心 | 并发 `/vip/me` + `/vip/packages` |
| VIP 购买或体验卡成功 | 使用写响应更新;必要时再拉 `/vip/me``/vip/packages` | | VIP 购买或体验卡成功 | 使用写响应更新;必要时再拉 `/vip/me``/vip/packages` |
| 用户资源佩戴成功 | 先用写响应更新背包,再拉 `/users/me/appearance`;已在房内时重拉 `/rooms/{room_id}/detail` 更新 `profiles[].mic_seat_animation` | | 用户资源佩戴成功 | 先用写响应更新背包,再拉 `/users/me/appearance`;已在房内时重拉 `/rooms/{room_id}/detail`,分别更新 `profiles[].mic_seat_icon` `profiles[].mic_seat_animation` |
| 进入或重连房间 | `/rooms/{room_id}/detail` 初始化 `active_background``room_border``room_name_style` | | 进入或重连房间 | `/rooms/{room_id}/detail` 初始化 `active_background``room_border``room_name_style` |
| 背景/装扮 IM 版本断层 | `/rooms/{room_id}/detail` | | 背景/装扮 IM 版本断层 | `/rooms/{room_id}/detail` |
| 装扮到 `expires_at_ms` | 本地立即隐藏,再拉详情确认 | | 装扮到 `expires_at_ms` | 本地立即隐藏,再拉详情确认 |
@ -1473,7 +1502,7 @@ void onCustomMessage(CustomElem elem) {
- Fami `/vip/packages`:有真实资源时返回完整 `resource`,未配置时字段省略。 - Fami `/vip/packages`:有真实资源时返回完整 `resource`,未配置时字段省略。
- Lalu旧背景 `image_url` 请求仍可用,新 Flutter 也可走专用协议。 - Lalu旧背景 `image_url` 请求仍可用,新 Flutter 也可走专用协议。
- 资源 equip相同 command/body 重放成功;换 entitlement 返回 `IDEMPOTENCY_CONFLICT` - 资源 equip相同 command/body 重放成功;换 entitlement 返回 `IDEMPOTENCY_CONFLICT`
- 麦位动效资源列表、appearance 和房间 `profiles[].mic_seat_animation` 的顶层 `format` 一致后台未配格式时三处都省略该字段Flutter 不从 URL 推断 - 麦位图标与动效资源列表、appearance 和房间资料分别使用 `mic_seat_icon``mic_seat_animation`;两个槽位独立,顶层 `format` 与各自资源一致Flutter 不从 URL 推断,也不互相覆盖
- VIP 到期后尝试重新 equip 历史资源:返回带四个 data 字段的 `VIP_BENEFIT_REQUIRED` - VIP 到期后尝试重新 equip 历史资源:返回带四个 data 字段的 `VIP_BENEFIT_REQUIRED`
- 背景JPEG/PNG/GIF/WebP 正常;错误 MIME、APNG、超大小、超尺寸分别命中预期 code。 - 背景JPEG/PNG/GIF/WebP 正常;错误 MIME、APNG、超大小、超尺寸分别命中预期 code。
- 背景 upload 成功但未 save背景列表不增加save 后未 set当前背景不变化。 - 背景 upload 成功但未 save背景列表不增加save 后未 set当前背景不变化。

View File

@ -109,6 +109,7 @@ X-App-Code: lalu
"avatar_frame": {}, "avatar_frame": {},
"profile_card": {}, "profile_card": {},
"vehicle": {}, "vehicle": {},
"mic_seat_icon": {},
"mic_seat_animation": {}, "mic_seat_animation": {},
"charm": 0 "charm": 0
} }
@ -137,6 +138,7 @@ X-App-Code: lalu
"avatar_frame": {}, "avatar_frame": {},
"profile_card": {}, "profile_card": {},
"vehicle": {}, "vehicle": {},
"mic_seat_icon": {},
"mic_seat_animation": {}, "mic_seat_animation": {},
"charm": 0 "charm": 0
} }
@ -161,6 +163,7 @@ X-App-Code: lalu
"avatar_frame": {}, "avatar_frame": {},
"profile_card": {}, "profile_card": {},
"vehicle": {}, "vehicle": {},
"mic_seat_icon": {},
"mic_seat_animation": {}, "mic_seat_animation": {},
"charm": 0 "charm": 0
}, },
@ -182,6 +185,7 @@ X-App-Code: lalu
"avatar_frame": {}, "avatar_frame": {},
"profile_card": {}, "profile_card": {},
"vehicle": {}, "vehicle": {},
"mic_seat_icon": {},
"mic_seat_animation": {}, "mic_seat_animation": {},
"charm": 0 "charm": 0
} }
@ -238,7 +242,8 @@ X-App-Code: lalu
| `avatar_frame` | object | 当前佩戴头像框。 | | `avatar_frame` | object | 当前佩戴头像框。 |
| `profile_card` | object | 当前佩戴资料卡。 | | `profile_card` | object | 当前佩戴资料卡。 |
| `vehicle` | object | 当前佩戴座驾。 | | `vehicle` | object | 当前佩戴座驾。 |
| `mic_seat_animation` | object | 当前佩戴麦位动画。 | | `mic_seat_icon` | object | 当前佩戴麦位中心图标。 |
| `mic_seat_animation` | object | 当前佩戴 Voice Wave与中心图标独立。 |
| `charm` | int64 | 魅力值。 | | `charm` | int64 | 魅力值。 |
## 错误码 ## 错误码

View File

@ -81,7 +81,7 @@ Query
| `badge` | 徽章 | 是 | | `badge` | 徽章 | 是 |
| `gift` | 礼物权益 | 否 | | `gift` | 礼物权益 | 否 |
| `floating_screen` | 飘屏 | 否 | | `floating_screen` | 飘屏 | 否 |
| `mic_seat_icon` | 麦位图标 | 否 | | `mic_seat_icon` | 麦位中心图标 | 是 |
| `mic_seat_animation` | 麦位动画 | 是 | | `mic_seat_animation` | 麦位动画 | 是 |
| `emoji_pack` | 表情包 | 否 | | `emoji_pack` | 表情包 | 否 |
@ -219,14 +219,16 @@ Body
| 规则 | 说明 | | 规则 | 说明 |
| --- | --- | | --- | --- |
| 可佩戴类型 | `avatar_frame``profile_card``vehicle``chat_bubble``badge``mic_seat_animation`。 | | 可佩戴类型 | `avatar_frame``profile_card``vehicle``chat_bubble``badge``mic_seat_icon`、`mic_seat_animation`。 |
| 不可佩戴类型 | `gift``floating_screen``mic_seat_icon`、`emoji_pack`。 | | 不可佩戴类型 | `gift``floating_screen``emoji_pack`。 |
| 同类替换 | 同一个 `resource_type` 只有一个当前佩戴项,新佩戴会替换旧佩戴。 | | 同类替换 | 同一个 `resource_type` 只有一个当前佩戴项,新佩戴会替换旧佩戴。 |
| 权益校验 | 必须是当前登录用户自己的有效权益;过期、禁用、剩余数量为 0 都不能佩戴。 | | 权益校验 | 必须是当前登录用户自己的有效权益;过期、禁用、剩余数量为 0 都不能佩戴。 |
| 发放默认佩戴 | `badge``mic_seat_animation` 发放成功后默认佩戴。 | | 发放默认佩戴 | `badge``mic_seat_animation` 发放成功后默认佩戴。 |
| 徽章展示 | `badge` 是拥有即佩戴的多装备资源;资料页多徽章展示仍使用 `/api/v1/badges/me``/api/v1/badges/display`。 | | 徽章展示 | `badge` 是拥有即佩戴的多装备资源;资料页多徽章展示仍使用 `/api/v1/badges/me``/api/v1/badges/display`。 |
| 资料卡背景 | `profile_card` 只用于装扮资料卡;个人主页背景图使用用户资料字段 `profile_bg_img`,不走背包佩戴。 | | 资料卡背景 | `profile_card` 只用于装扮资料卡;个人主页背景图使用用户资料字段 `profile_bg_img`,不走背包佩戴。 |
`mic_seat_icon``mic_seat_animation``resource_type` 不同,因此分别单选、分别取消。前者只替换麦位中心图标,后者只控制 Voice Wave佩戴或取消任一类型都不能修改另一类型的装备状态。
常见错误: 常见错误:
| HTTP | `code` | 场景 | | HTTP | `code` | 场景 |

View File

@ -1162,7 +1162,8 @@ paths:
summary: 查询当前用户已佩戴外观资源 summary: 查询当前用户已佩戴外观资源
operationId: getMyAppearance operationId: getMyAppearance
description: >- description: >-
返回 wallet 已过滤过期状态后的当前佩戴快照。mic_seat_animation.format 只来自资源 metadata_json 的显式 返回 wallet 已过滤过期状态后的当前佩戴快照。mic_seat_icon 与 mic_seat_animation 是独立装备槽,
前者替换麦位中心图标,后者保留 Voice Wave。format 只来自资源 metadata_json 的显式
format/animation_format字段缺省表示格式未配置客户端不得从素材 URL 后缀推断。 format/animation_format字段缺省表示格式未配置客户端不得从素材 URL 后缀推断。
security: security:
- BearerAuth: [] - BearerAuth: []
@ -1727,7 +1728,7 @@ paths:
- rooms - rooms
summary: 加入房间 summary: 加入房间
operationId: joinRoom operationId: joinRoom
description: JoinRoom 是业务 presence、IM 入群和 RTC 进房的权限分界点。成功后响应返回首屏房间数据、`im.group_id` 和内嵌 `rtc.token`RTC 签发失败时仍返回房间数据,并设置 `rtc.available=false`。 description: JoinRoom 是业务 presence、IM 入群和 RTC 进房的权限分界点。成功后响应返回首屏房间数据、`im.group_id` 和内嵌 `rtc.token`完整模式的 `profiles` 同时投影独立的 `mic_seat_icon` 与 `mic_seat_animation`。RTC 签发失败时仍返回房间数据,并设置 `rtc.available=false`。
security: security:
- BearerAuth: [] - BearerAuth: []
parameters: parameters:
@ -4658,7 +4659,7 @@ definitions:
type: string type: string
resource_type: resource_type:
type: string type: string
enum: [avatar_frame, profile_card, vehicle, badge, mic_seat_animation] enum: [avatar_frame, profile_card, vehicle, badge, mic_seat_icon, mic_seat_animation]
name: name:
type: string type: string
asset_url: asset_url:
@ -4690,6 +4691,7 @@ definitions:
- avatar_frame - avatar_frame
- profile_card - profile_card
- vehicle - vehicle
- mic_seat_icon
- mic_seat_animation - mic_seat_animation
- level - level
- level_badges - level_badges
@ -4707,8 +4709,12 @@ definitions:
$ref: "#/definitions/AppearanceResourceData" $ref: "#/definitions/AppearanceResourceData"
vehicle: vehicle:
$ref: "#/definitions/AppearanceResourceData" $ref: "#/definitions/AppearanceResourceData"
mic_seat_icon:
$ref: "#/definitions/AppearanceResourceData"
description: 当前麦位中心图标佩戴快照;空对象表示未佩戴,不影响 mic_seat_animation Voice Wave。
mic_seat_animation: mic_seat_animation:
$ref: "#/definitions/AppearanceResourceData" $ref: "#/definitions/AppearanceResourceData"
description: 当前 Voice Wave 佩戴快照;空对象表示未佩戴,不影响 mic_seat_icon 中心图标。
level: level:
type: object type: object
level_badges: level_badges:
@ -6189,9 +6195,12 @@ definitions:
chat_bubble: chat_bubble:
$ref: "#/definitions/AppearanceResourceData" $ref: "#/definitions/AppearanceResourceData"
description: 当前聊天气泡佩戴快照;空对象表示未佩戴,供公屏按发送人资料渲染。 description: 当前聊天气泡佩戴快照;空对象表示未佩戴,供公屏按发送人资料渲染。
mic_seat_icon:
$ref: "#/definitions/AppearanceResourceData"
description: 当前麦位中心图标佩戴快照;空对象表示未佩戴,必须与 Voice Wave 分层渲染。
mic_seat_animation: mic_seat_animation:
$ref: "#/definitions/AppearanceResourceData" $ref: "#/definitions/AppearanceResourceData"
description: 当前麦位动效佩戴快照空对象表示未佩戴format 缺省表示后台没有配置受支持 renderer。 description: 当前 Voice Wave 佩戴快照空对象表示未佩戴format 缺省表示后台没有配置受支持 renderer,不得覆盖 mic_seat_icon
charm: charm:
type: integer type: integer
format: int64 format: int64

View File

@ -1242,6 +1242,7 @@ class VipEntitlementState {
"avatar_frame": {}, "avatar_frame": {},
"profile_card": {}, "profile_card": {},
"vehicle": {}, "vehicle": {},
"mic_seat_icon": {},
"mic_seat_animation": {}, "mic_seat_animation": {},
"equipped_badges": [], "equipped_badges": [],
"badges": [], "badges": [],

View File

@ -62,6 +62,18 @@ GET /api/v1/users/me/appearance
"entitlement_id": "ent-vehicle", "entitlement_id": "ent-vehicle",
"expires_at_ms": 1999999999999 "expires_at_ms": 1999999999999
}, },
"mic_seat_icon": {
"resource_id": 8107,
"resource_code": "mic_seat_icon_gold",
"resource_type": "mic_seat_icon",
"name": "Gold Mic Icon",
"asset_url": "https://cdn.example/mic-seat-icon.png",
"preview_url": "",
"animation_url": "",
"metadata_json": "{}",
"entitlement_id": "ent-mic-seat-icon",
"expires_at_ms": 1999999999999
},
"mic_seat_animation": { "mic_seat_animation": {
"resource_id": 8106, "resource_id": 8106,
"resource_code": "mic_seat_wave_gold", "resource_code": "mic_seat_wave_gold",
@ -107,6 +119,18 @@ GET /api/v1/users/me/appearance
"server_time_ms": 1778256000000 "server_time_ms": 1778256000000
}, },
"resources": [ "resources": [
{
"resource_id": 8107,
"resource_code": "mic_seat_icon_gold",
"resource_type": "mic_seat_icon",
"name": "Gold Mic Icon",
"asset_url": "https://cdn.example/mic-seat-icon.png",
"preview_url": "",
"animation_url": "",
"metadata_json": "{}",
"entitlement_id": "ent-mic-seat-icon",
"expires_at_ms": 1999999999999
},
{ {
"resource_id": 8106, "resource_id": 8106,
"resource_code": "mic_seat_wave_gold", "resource_code": "mic_seat_wave_gold",
@ -132,6 +156,7 @@ GET /api/v1/users/me/appearance
| avatar_frame | 当前佩戴头像框,未佩戴时为 `{}` | | avatar_frame | 当前佩戴头像框,未佩戴时为 `{}` |
| profile_card | 当前佩戴背景卡,未佩戴时为 `{}` | | profile_card | 当前佩戴背景卡,未佩戴时为 `{}` |
| vehicle | 当前佩戴入场座驾,未佩戴时为 `{}` | | vehicle | 当前佩戴入场座驾,未佩戴时为 `{}` |
| mic_seat_icon | 当前佩戴麦位中心图标,未佩戴时为 `{}` |
| mic_seat_animation | 当前佩戴麦位声波,未佩戴时为 `{}` | | mic_seat_animation | 当前佩戴麦位声波,未佩戴时为 `{}` |
| equipped_badges | wallet 当前佩戴的 badge 权益 | | equipped_badges | wallet 当前佩戴的 badge 权益 |
| badges | 房间、公屏等高频展示用徽章,取 `strip_badges` | | badges | 房间、公屏等高频展示用徽章,取 `strip_badges` |
@ -142,7 +167,8 @@ GET /api/v1/users/me/appearance
说明: 说明:
- 过期佩戴不会返回。 - 过期佩戴不会返回。
- 头像框、背景卡、入场座驾、麦位声波来自 wallet 当前佩戴。 - 头像框、背景卡、入场座驾、麦位中心图标和麦位声波来自 wallet 当前佩戴。
- `mic_seat_icon``mic_seat_animation` 是两个独立装备槽Flutter 只能用前者替换麦位中心图标,后者继续渲染 Voice Wave二者不能互相覆盖或兜底。
- 徽章展示配置来自 activity。 - 徽章展示配置来自 activity。
- 客户端渲染动画优先使用 `animation_url`,没有动画时用 `asset_url``preview_url` 兜底。 - 客户端渲染动画优先使用 `animation_url`,没有动画时用 `asset_url``preview_url` 兜底。
@ -158,7 +184,7 @@ GET /api/v1/users/me/resources
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| resource_type | string | 否 | `avatar_frame` / `profile_card` / `vehicle` / `badge` / `chat_bubble` | | resource_type | string | 否 | `avatar_frame` / `profile_card` / `vehicle` / `badge` / `chat_bubble` / `mic_seat_icon` / `mic_seat_animation` |
示例: 示例:
@ -261,7 +287,7 @@ Body 参数:
说明: 说明:
- 可佩戴类型:`avatar_frame``profile_card``vehicle``chat_bubble``badge``mic_seat_animation`。 - 可佩戴类型:`avatar_frame``profile_card``vehicle``chat_bubble``badge``mic_seat_icon`、`mic_seat_animation`。
- `badge``mic_seat_animation` 发放成功后服务端默认佩戴。 - `badge``mic_seat_animation` 发放成功后服务端默认佩戴。
- 过期资源不能佩戴。 - 过期资源不能佩戴。
- 不传 `entitlement_id` 时,服务端会选择当前有效权益。 - 不传 `entitlement_id` 时,服务端会选择当前有效权益。
@ -278,7 +304,7 @@ DELETE /api/v1/users/me/resources/{resource_type}/unequip
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| resource_type | string | 是 | `avatar_frame` / `profile_card` / `vehicle` / `chat_bubble` / `badge` | | resource_type | string | 是 | `avatar_frame` / `profile_card` / `vehicle` / `chat_bubble` / `badge` / `mic_seat_icon` / `mic_seat_animation` |
返回值: 返回值:
@ -383,6 +409,18 @@ GET /api/v1/users/room-display-profiles:batch?user_ids=42,43
"entitlement_id": "ent-chat-bubble", "entitlement_id": "ent-chat-bubble",
"expires_at_ms": 1999999999999 "expires_at_ms": 1999999999999
}, },
"mic_seat_icon": {
"resource_id": 8107,
"resource_code": "mic_seat_icon_gold",
"resource_type": "mic_seat_icon",
"name": "Gold Mic Icon",
"asset_url": "https://cdn.example/mic-seat-icon.png",
"preview_url": "",
"animation_url": "",
"metadata_json": "{}",
"entitlement_id": "ent-mic-seat-icon",
"expires_at_ms": 1999999999999
},
"mic_seat_animation": { "mic_seat_animation": {
"resource_id": 8106, "resource_id": 8106,
"resource_code": "mic_seat_wave_gold", "resource_code": "mic_seat_wave_gold",
@ -408,7 +446,9 @@ GET /api/v1/users/room-display-profiles:batch?user_ids=42,43
- 入场座驾展示用 `vehicle` - 入场座驾展示用 `vehicle`
- 公屏聊天气泡用 `chat_bubble`;未佩戴时固定返回空对象,普通文本消息按发送人 ID 批量补齐该字段。 - 公屏聊天气泡用 `chat_bubble`;未佩戴时固定返回空对象,普通文本消息按发送人 ID 批量补齐该字段。
- VIP 装扮只在当前有效 VIP 权限覆盖对应等级时视为已佩戴VIP 失效后背包可保留历史权益,但 `equipped=false`,客户端不得用资源列表兜底继续渲染。 - VIP 装扮只在当前有效 VIP 权限覆盖对应等级时视为已佩戴VIP 失效后背包可保留历史权益,但 `equipped=false`,客户端不得用资源列表兜底继续渲染。
- 麦位中心图标展示用 `mic_seat_icon`;在有用户的麦位用其 `asset_url` 替换默认中心图标,未佩戴时使用客户端默认麦位图标。
- 麦位声波展示用 `mic_seat_animation` - 麦位声波展示用 `mic_seat_animation`
- 同一资料同时返回两者时必须叠加渲染:`mic_seat_icon` 管中心图标,`mic_seat_animation` 管 Voice Wave不能因为图标存在而隐藏声波。
- 徽章条展示用 `badges` - 徽章条展示用 `badges`
- 过期佩戴不会返回。 - 过期佩戴不会返回。
@ -478,8 +518,8 @@ Body
- Flutter 不需要传入场座驾。 - Flutter 不需要传入场座驾。
- 服务端会在进房时自动读取当前佩戴的 `vehicle`,写入房间 IM。 - 服务端会在进房时自动读取当前佩戴的 `vehicle`,写入房间 IM。
- `join` 返回里的 `profiles` 是基础资料 - 完整模式下 `join` 返回里的 `profiles``/users/room-display-profiles:batch` 使用同一聚合结构,直接包含 `mic_seat_icon``mic_seat_animation`lite 模式不保证返回增强装扮
- 房间增强资料`/users/room-display-profiles:batch`,或使用房间详情、在线用户列表里内嵌的 `profile` 字段。 - 后续增量用户`/users/room-display-profiles:batch`,或使用房间详情、在线用户列表里内嵌的 `profile` 字段补齐
## 7. 相关 IM ## 7. 相关 IM
@ -538,5 +578,5 @@ Flutter 处理:
3. 点击佩戴调 `POST /api/v1/users/me/resources/{resource_id}/equip`,成功后刷新 `/appearance` 3. 点击佩戴调 `POST /api/v1/users/me/resources/{resource_id}/equip`,成功后刷新 `/appearance`
4. 点击取消佩戴调 `DELETE /api/v1/users/me/resources/{resource_type}/unequip`,成功后刷新 `/appearance` 4. 点击取消佩戴调 `DELETE /api/v1/users/me/resources/{resource_type}/unequip`,成功后刷新 `/appearance`
5. 个人主页信息卡背景使用个人资料返回的 `profile_bg_img`;该字段是用户资料字段,和 `profile_card` 装扮资源无关。 5. 个人主页信息卡背景使用个人资料返回的 `profile_bg_img`;该字段是用户资料字段,和 `profile_card` 装扮资源无关。
6. 房间、公屏、在线用户资料优先使用 `room-display-profiles:batch``avatar_frame``profile_card``vehicle``chat_bubble``badges`。 6. 房间、公屏、在线用户资料优先使用 `room-display-profiles:batch``avatar_frame``profile_card``vehicle``chat_bubble``mic_seat_icon`、`mic_seat_animation``badges`。
7. 收到 `room_user_joined` IM 时,只按 `entry_vehicle` 播放入场座驾。 7. 收到 `room_user_joined` IM 时,只按 `entry_vehicle` 播放入场座驾。

View File

@ -426,7 +426,7 @@ POST /api/v1/users/me/resources/{resource_id}/equip
装备资源的边界: 装备资源的边界:
- `equip` 只适用于头像框、资料卡、座驾、气泡、徽章、麦位声波等可装备资源。 - `equip` 只适用于头像框、资料卡、座驾、气泡、徽章、麦位中心图标、麦位声波等可装备资源`mic_seat_icon``mic_seat_animation` 是两个独立单选槽
- `floating_screen``gift``coin` 不能走 `equip`;金币只落钱包账本,礼物和飘屏后续通过使用命令扣减 `remaining_quantity` - `floating_screen``gift``coin` 不能走 `equip`;金币只落钱包账本,礼物和飘屏后续通过使用命令扣减 `remaining_quantity`
- `badge``mic_seat_animation` 发放成功后默认写入当前佩戴。 - `badge``mic_seat_animation` 发放成功后默认写入当前佩戴。
- `user-service.users.avatar` 仍然只是头像 URL不承载头像框。 - `user-service.users.avatar` 仍然只是头像 URL不承载头像框。

View File

@ -69,13 +69,13 @@ SELECT b.app_code,
FROM vip_level_benefits AS b FROM vip_level_benefits AS b
JOIN vip_program_configs AS p ON p.app_code = b.app_code; JOIN vip_program_configs AS p ON p.app_code = b.app_code;
-- tiered Fami 的房间视觉权益使用稳定映射mic_skin -> mic_seat_animation、 -- tiered Fami 的房间视觉权益使用稳定映射mic_skin -> mic_seat_icon、
-- room_border -> room_border、colored_room_name -> room_name_style。三项都属于 decoration -- room_border -> room_border、colored_room_name -> room_name_style。三项都属于 decoration
-- disabled 可以无素材,但若已有 resource_id 也不能保留错误类型或下架资源。 -- disabled 可以无素材,但若已有 resource_id 也不能保留错误类型或下架资源。
UPDATE vip_level_benefits UPDATE vip_level_benefits
SET benefit_type = 'decoration', SET benefit_type = 'decoration',
resource_type = CASE benefit_code resource_type = CASE benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation' WHEN 'mic_skin' THEN 'mic_seat_icon'
WHEN 'room_border' THEN 'room_border' WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style' WHEN 'colored_room_name' THEN 'room_name_style'
END, END,
@ -85,7 +85,7 @@ WHERE app_code = 'fami'
AND ( AND (
benefit_type <> 'decoration' benefit_type <> 'decoration'
OR resource_type <> CASE benefit_code OR resource_type <> CASE benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation' WHEN 'mic_skin' THEN 'mic_seat_icon'
WHEN 'room_border' THEN 'room_border' WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style' WHEN 'colored_room_name' THEN 'room_name_style'
END END
@ -99,7 +99,7 @@ LEFT JOIN resources AS resource
AND resource.resource_id = benefit.resource_id AND resource.resource_id = benefit.resource_id
AND resource.status = 'active' AND resource.status = 'active'
AND resource.resource_type = CASE benefit.benefit_code AND resource.resource_type = CASE benefit.benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation' WHEN 'mic_skin' THEN 'mic_seat_icon'
WHEN 'room_border' THEN 'room_border' WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style' WHEN 'colored_room_name' THEN 'room_name_style'
END END
@ -112,7 +112,7 @@ WHERE benefit.app_code = 'fami'
SET @vip_invalid_resource_clear_count := ROW_COUNT(); SET @vip_invalid_resource_clear_count := ROW_COUNT();
-- 本地现有 Fami VIP4..VIP8 资源组各自只有一个可核实的 active mic_seat_animation。 -- 本地现有 Fami VIP4..VIP8 资源组各自只有一个可核实的 active mic_seat_icon。
-- 这里按稳定 group_code + 类型解析 ID并用 HAVING COUNT(DISTINCT)=1 防止运营新增候选后误绑; -- 这里按稳定 group_code + 类型解析 ID并用 HAVING COUNT(DISTINCT)=1 防止运营新增候选后误绑;
-- VIP9、room_border、room_name_style 当前没有真实目录资源,必须保持未配置,不能伪造 ID/URL。 -- VIP9、room_border、room_name_style 当前没有真实目录资源,必须保持未配置,不能伪造 ID/URL。
UPDATE vip_level_benefits AS benefit UPDATE vip_level_benefits AS benefit
@ -126,7 +126,7 @@ JOIN (
JOIN resources AS resource JOIN resources AS resource
ON resource.app_code = group_item.app_code ON resource.app_code = group_item.app_code
AND resource.resource_id = group_item.resource_id AND resource.resource_id = group_item.resource_id
AND resource.resource_type = 'mic_seat_animation' AND resource.resource_type = 'mic_seat_icon'
AND resource.status = 'active' AND resource.status = 'active'
WHERE resource_group.app_code = 'fami' WHERE resource_group.app_code = 'fami'
AND resource_group.status = 'active' AND resource_group.status = 'active'
@ -136,7 +136,7 @@ JOIN (
) AS mapping ) AS mapping
ON mapping.group_code = CONCAT('VIP', benefit.level) ON mapping.group_code = CONCAT('VIP', benefit.level)
SET benefit.resource_id = mapping.resource_id, SET benefit.resource_id = mapping.resource_id,
benefit.resource_type = 'mic_seat_animation', benefit.resource_type = 'mic_seat_icon',
benefit.updated_at_ms = @vip_resource_contract_now_ms benefit.updated_at_ms = @vip_resource_contract_now_ms
WHERE benefit.app_code = 'fami' WHERE benefit.app_code = 'fami'
AND benefit.level BETWEEN 4 AND 8 AND benefit.level BETWEEN 4 AND 8
@ -146,14 +146,14 @@ WHERE benefit.app_code = 'fami'
SET @vip_mic_skin_bind_count := ROW_COUNT(); SET @vip_mic_skin_bind_count := ROW_COUNT();
-- 只给“已经被 active mic_skin 绑定”的真实麦座动画补显式 format先信任后台已有的 -- 只给“已经被 active mic_skin 绑定”的真实麦位中心图标补显式 format先信任后台已有的
-- 合法 animation_format否则只从实际 animation/asset URL 的明确后缀推导。这个更新 -- 合法 animation_format否则只从实际 animation/asset URL 的明确后缀推导。这个更新
-- 通过权益配置小表圈定 resource_id再按 resources 主键点查,不会扫描或修改未绑定资源。 -- 通过权益配置小表圈定 resource_id再按 resources 主键点查,不会扫描或修改未绑定资源。
UPDATE vip_level_benefits AS benefit UPDATE vip_level_benefits AS benefit
JOIN resources AS resource FORCE INDEX (PRIMARY) JOIN resources AS resource FORCE INDEX (PRIMARY)
ON resource.resource_id = benefit.resource_id ON resource.resource_id = benefit.resource_id
AND resource.app_code = benefit.app_code AND resource.app_code = benefit.app_code
AND resource.resource_type = 'mic_seat_animation' AND resource.resource_type = 'mic_seat_icon'
AND resource.status = 'active' AND resource.status = 'active'
SET resource.metadata_json = JSON_SET( SET resource.metadata_json = JSON_SET(
COALESCE(resource.metadata_json, JSON_OBJECT()), COALESCE(resource.metadata_json, JSON_OBJECT()),
@ -178,7 +178,7 @@ SET resource.metadata_json = JSON_SET(
WHERE benefit.app_code = 'fami' WHERE benefit.app_code = 'fami'
AND benefit.benefit_code = 'mic_skin' AND benefit.benefit_code = 'mic_skin'
AND benefit.status = 'active' AND benefit.status = 'active'
AND benefit.resource_type = 'mic_seat_animation' AND benefit.resource_type = 'mic_seat_icon'
AND benefit.resource_id > 0 AND benefit.resource_id > 0
AND (TRIM(resource.asset_url) <> '' OR TRIM(resource.animation_url) <> '') AND (TRIM(resource.asset_url) <> '' OR TRIM(resource.animation_url) <> '')
AND TRIM(resource.preview_url) <> '' AND TRIM(resource.preview_url) <> ''
@ -195,7 +195,7 @@ WHERE benefit.app_code = 'fami'
SET @vip_mic_format_backfill_count := ROW_COUNT(); SET @vip_mic_format_backfill_count := ROW_COUNT();
-- active 必须能联结到同 App、active、精确类型的真实资源。麦座动画和房间边框还必须 -- active 必须能联结到同 App、active、精确类型的真实资源。麦位中心图标和房间边框还必须
-- 同时具备素材、preview、合法 format且可选 animation_format 一旦配置就必须一致。 -- 同时具备素材、preview、合法 format且可选 animation_format 一旦配置就必须一致。
-- 缺少真实配置时只禁用权益,不创建占位资源,也不猜测素材 URL。 -- 缺少真实配置时只禁用权益,不创建占位资源,也不猜测素材 URL。
UPDATE vip_level_benefits AS benefit UPDATE vip_level_benefits AS benefit
@ -212,7 +212,7 @@ WHERE benefit.app_code = 'fami'
AND ( AND (
resource.resource_id IS NULL resource.resource_id IS NULL
OR ( OR (
benefit.resource_type IN ('mic_seat_animation', 'room_border') benefit.resource_type IN ('mic_seat_icon', 'room_border')
AND ( AND (
(TRIM(resource.asset_url) = '' AND TRIM(resource.animation_url) = '') (TRIM(resource.asset_url) = '' AND TRIM(resource.animation_url) = '')
OR TRIM(resource.preview_url) = '' OR TRIM(resource.preview_url) = ''

View File

@ -3647,6 +3647,15 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
GrantStrategy: "set_active_flag", GrantStrategy: "set_active_flag",
Status: "active", Status: "active",
} }
micSeatIconResource := &walletv1.Resource{
ResourceId: 8107,
ResourceCode: "mic_seat_icon_gold",
ResourceType: "mic_seat_icon",
Name: "Gold Mic Icon",
AssetUrl: "https://cdn.example/mic-seat-icon.png",
GrantStrategy: "set_active_flag",
Status: "active",
}
shortBadgeResource := &walletv1.Resource{ shortBadgeResource := &walletv1.Resource{
ResourceId: 8105, ResourceId: 8105,
ResourceCode: "badge_tile_gold", ResourceCode: "badge_tile_gold",
@ -3678,6 +3687,14 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
Equipped: true, Equipped: true,
ExpiresAtMs: 1999999999999, ExpiresAtMs: 1999999999999,
}, },
{
UserId: 42,
EntitlementId: "ent-mic-seat-icon",
ResourceId: 8107,
Resource: micSeatIconResource,
Equipped: true,
ExpiresAtMs: 1999999999999,
},
{ {
UserId: 42, UserId: 42,
EntitlementId: "ent-mic-seat-wave", EntitlementId: "ent-mic-seat-wave",
@ -3754,13 +3771,13 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
} }
hasRoomDisplayResourceQuery := false hasRoomDisplayResourceQuery := false
for _, req := range walletClient.batchEquippedRequests { for _, req := range walletClient.batchEquippedRequests {
if strings.Join(req.GetResourceTypes(), ",") == "avatar_frame,profile_card,vehicle,chat_bubble,badge,mic_seat_animation" { if strings.Join(req.GetResourceTypes(), ",") == "avatar_frame,profile_card,vehicle,chat_bubble,badge,mic_seat_icon,mic_seat_animation" {
hasRoomDisplayResourceQuery = true hasRoomDisplayResourceQuery = true
break break
} }
} }
if !hasRoomDisplayResourceQuery { if !hasRoomDisplayResourceQuery {
t.Fatalf("join response must batch room wearable resources including mic_seat_animation: %+v", walletClient.batchEquippedRequests) t.Fatalf("join response must batch independent mic_seat_icon and mic_seat_animation resources: %+v", walletClient.batchEquippedRequests)
} }
profiles, profilesOK := data["profiles"].([]any) profiles, profilesOK := data["profiles"].([]any)
if !profilesOK || len(profiles) == 0 { if !profilesOK || len(profiles) == 0 {
@ -3780,6 +3797,10 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
if !profileCardOK || profileCard["resource_id"] != float64(8104) || !strings.Contains(metadataJSON, `"content_top":143`) { if !profileCardOK || profileCard["resource_id"] != float64(8104) || !strings.Contains(metadataJSON, `"content_top":143`) {
t.Fatalf("join profile must include profile_card metadata_json: %+v", currentProfile) t.Fatalf("join profile must include profile_card metadata_json: %+v", currentProfile)
} }
micSeatIcon, micSeatIconOK := currentProfile["mic_seat_icon"].(map[string]any)
if !micSeatIconOK || micSeatIcon["resource_id"] != float64(8107) || micSeatIcon["asset_url"] != "https://cdn.example/mic-seat-icon.png" {
t.Fatalf("join profile must include mic_seat_icon independently from Voice Wave: %+v", currentProfile)
}
micSeatAnimation, micSeatAnimationOK := currentProfile["mic_seat_animation"].(map[string]any) micSeatAnimation, micSeatAnimationOK := currentProfile["mic_seat_animation"].(map[string]any)
if !micSeatAnimationOK || micSeatAnimation["resource_id"] != float64(8106) || micSeatAnimation["animation_url"] != "https://cdn.example/mic-seat.svga" { if !micSeatAnimationOK || micSeatAnimation["resource_id"] != float64(8106) || micSeatAnimation["animation_url"] != "https://cdn.example/mic-seat.svga" {
t.Fatalf("join profile must include mic_seat_animation resource: %+v", currentProfile) t.Fatalf("join profile must include mic_seat_animation resource: %+v", currentProfile)
@ -9037,6 +9058,15 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
GrantStrategy: "set_active_flag", GrantStrategy: "set_active_flag",
Status: "active", Status: "active",
} }
micSeatIconResource := &walletv1.Resource{
ResourceId: 8107,
ResourceCode: "mic_seat_icon_gold",
ResourceType: "mic_seat_icon",
Name: "Gold Mic Icon",
AssetUrl: "https://cdn.example/mic-seat-icon.png",
GrantStrategy: "set_active_flag",
Status: "active",
}
walletClient := &fakeWalletClient{ walletClient := &fakeWalletClient{
batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{ batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{
Users: []*walletv1.UserEquippedResources{{ Users: []*walletv1.UserEquippedResources{{
@ -9046,6 +9076,7 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
{UserId: 42, EntitlementId: "ent-vehicle", ResourceId: 8102, Resource: vehicleResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-vehicle", ResourceId: 8102, Resource: vehicleResource, Equipped: true, ExpiresAtMs: 1999999999999},
{UserId: 42, EntitlementId: "ent-profile-card", ResourceId: 8104, Resource: profileCardResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-profile-card", ResourceId: 8104, Resource: profileCardResource, Equipped: true, ExpiresAtMs: 1999999999999},
{UserId: 42, EntitlementId: "ent-badge", ResourceId: 8103, Resource: badgeResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-badge", ResourceId: 8103, Resource: badgeResource, Equipped: true, ExpiresAtMs: 1999999999999},
{UserId: 42, EntitlementId: "ent-mic-seat-icon", ResourceId: 8107, Resource: micSeatIconResource, Equipped: true, ExpiresAtMs: 1999999999999},
{UserId: 42, EntitlementId: "ent-mic-seat-wave", ResourceId: 8106, Resource: micSeatResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-mic-seat-wave", ResourceId: 8106, Resource: micSeatResource, Equipped: true, ExpiresAtMs: 1999999999999},
}, },
}}, }},
@ -9096,6 +9127,11 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
ResourceID int64 `json:"resource_id"` ResourceID int64 `json:"resource_id"`
MetadataJSON string `json:"metadata_json"` MetadataJSON string `json:"metadata_json"`
} `json:"profile_card"` } `json:"profile_card"`
MicSeatIcon struct {
ResourceID int64 `json:"resource_id"`
AssetURL string `json:"asset_url"`
EntitlementID string `json:"entitlement_id"`
} `json:"mic_seat_icon"`
MicSeatAnimation struct { MicSeatAnimation struct {
ResourceID int64 `json:"resource_id"` ResourceID int64 `json:"resource_id"`
AnimationURL string `json:"animation_url"` AnimationURL string `json:"animation_url"`
@ -9122,24 +9158,27 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
if walletClient.lastBatchEquipped == nil || walletClient.lastBatchEquipped.GetUserIds()[0] != 42 { if walletClient.lastBatchEquipped == nil || walletClient.lastBatchEquipped.GetUserIds()[0] != 42 {
t.Fatalf("appearance should batch query equipped resources: %+v", walletClient.lastBatchEquipped) t.Fatalf("appearance should batch query equipped resources: %+v", walletClient.lastBatchEquipped)
} }
if strings.Join(walletClient.lastBatchEquipped.GetResourceTypes(), ",") != "avatar_frame,profile_card,vehicle,badge,mic_seat_animation" { if strings.Join(walletClient.lastBatchEquipped.GetResourceTypes(), ",") != "avatar_frame,profile_card,vehicle,badge,mic_seat_icon,mic_seat_animation" {
t.Fatalf("appearance should query mic_seat_animation with wearable resources: %+v", walletClient.lastBatchEquipped.GetResourceTypes()) t.Fatalf("appearance should query independent mic icon and Voice Wave slots: %+v", walletClient.lastBatchEquipped.GetResourceTypes())
} }
if achievementClient.lastBadges == nil || achievementClient.lastBadges.GetUserId() != 42 { if achievementClient.lastBadges == nil || achievementClient.lastBadges.GetUserId() != 42 {
t.Fatalf("appearance should query displayed badges: %+v", achievementClient.lastBadges) t.Fatalf("appearance should query displayed badges: %+v", achievementClient.lastBadges)
} }
if envelope.Code != httpkit.CodeOK || envelope.Data.UserID != "42" || envelope.Data.AvatarFrame.AnimationURL != "https://cdn.example/frame.svga" || envelope.Data.Vehicle.ResourceID != 8102 || envelope.Data.ProfileCard.ResourceID != 8104 || !strings.Contains(envelope.Data.ProfileCard.MetadataJSON, `"content_top":117`) || envelope.Data.MicSeatAnimation.ResourceID != 8106 || envelope.Data.MicSeatAnimation.AnimationURL != "https://cdn.example/mic-seat.svga" || envelope.Data.MicSeatAnimation.EntitlementID != "ent-mic-seat-wave" || len(envelope.Data.EquippedBadges) != 1 || len(envelope.Data.Badges) != 1 || envelope.Data.Badges[0].Resource.AssetURL != "https://cdn.example/badge.png" { if envelope.Code != httpkit.CodeOK || envelope.Data.UserID != "42" || envelope.Data.AvatarFrame.AnimationURL != "https://cdn.example/frame.svga" || envelope.Data.Vehicle.ResourceID != 8102 || envelope.Data.ProfileCard.ResourceID != 8104 || !strings.Contains(envelope.Data.ProfileCard.MetadataJSON, `"content_top":117`) || envelope.Data.MicSeatIcon.ResourceID != 8107 || envelope.Data.MicSeatIcon.AssetURL != "https://cdn.example/mic-seat-icon.png" || envelope.Data.MicSeatIcon.EntitlementID != "ent-mic-seat-icon" || envelope.Data.MicSeatAnimation.ResourceID != 8106 || envelope.Data.MicSeatAnimation.AnimationURL != "https://cdn.example/mic-seat.svga" || envelope.Data.MicSeatAnimation.EntitlementID != "ent-mic-seat-wave" || len(envelope.Data.EquippedBadges) != 1 || len(envelope.Data.Badges) != 1 || envelope.Data.Badges[0].Resource.AssetURL != "https://cdn.example/badge.png" {
t.Fatalf("appearance response mismatch: %+v", envelope) t.Fatalf("appearance response mismatch: %+v", envelope)
} }
hasMicSeatResource := false hasMicSeatIconResource := false
hasMicSeatAnimationResource := false
for _, resource := range envelope.Data.Resources { for _, resource := range envelope.Data.Resources {
if resource.ResourceID == 8107 && resource.ResourceType == "mic_seat_icon" {
hasMicSeatIconResource = true
}
if resource.ResourceID == 8106 && resource.ResourceType == "mic_seat_animation" { if resource.ResourceID == 8106 && resource.ResourceType == "mic_seat_animation" {
hasMicSeatResource = true hasMicSeatAnimationResource = true
break
} }
} }
if !hasMicSeatResource { if !hasMicSeatIconResource || !hasMicSeatAnimationResource {
t.Fatalf("appearance resources must include equipped mic_seat_animation: %+v", envelope.Data.Resources) t.Fatalf("appearance resources must include independent mic icon and Voice Wave equipment: %+v", envelope.Data.Resources)
} }
} }

View File

@ -306,6 +306,7 @@ func roomGiftCoreProfilesFromUsers(userIDs []int64, users map[int64]*userv1.User
AvatarFrame: map[string]any{}, AvatarFrame: map[string]any{},
ProfileCard: map[string]any{}, ProfileCard: map[string]any{},
Vehicle: map[string]any{}, Vehicle: map[string]any{},
MicSeatIcon: map[string]any{},
MicSeatAnimation: map[string]any{}, MicSeatAnimation: map[string]any{},
Charm: 0, Charm: 0,
} }

View File

@ -37,6 +37,7 @@ const (
roomAppearanceResourceVehicle = "vehicle" roomAppearanceResourceVehicle = "vehicle"
roomAppearanceResourceChatBubble = "chat_bubble" roomAppearanceResourceChatBubble = "chat_bubble"
roomAppearanceResourceBadge = "badge" roomAppearanceResourceBadge = "badge"
roomAppearanceResourceMicSeatIcon = "mic_seat_icon"
roomAppearanceResourceMicSeatAnimation = "mic_seat_animation" roomAppearanceResourceMicSeatAnimation = "mic_seat_animation"
// 机器人房标记来自 room-service RoomExtgateway 只用它决定首屏资料是否补机器人头像昵称。 // 机器人房标记来自 room-service RoomExtgateway 只用它决定首屏资料是否补机器人头像昵称。
@ -2491,8 +2492,9 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
// 房间资料是展示增强,失败时保留主房间数据,让客户端后续重试 batch 接口。 // 房间资料是展示增强,失败时保留主房间数据,让客户端后续重试 batch 接口。
return nil return nil
} }
// 房间资料高频读取,头像框/资料卡/座驾/聊天气泡/当前佩戴徽章用 wallet 批量查; // 房间资料高频读取,头像框/资料卡/座驾/聊天气泡/麦位图标/Voice Wave/当前佩戴徽章
// activity 的 badge 投影再补展示槽位,两个来源合并后客户端才能同时看到长短徽章。 // 用 wallet 一次批量查activity 的 badge 投影再补展示槽位,两个来源合并后客户端才能
// 同时看到长短徽章,且麦位两层视觉不会被任一来源覆盖。
appearanceMap := h.roomAppearanceResourceMap(request, userIDs) appearanceMap := h.roomAppearanceResourceMap(request, userIDs)
badgeMap := h.roomBadgeDisplayMap(request, userIDs) badgeMap := h.roomBadgeDisplayMap(request, userIDs)
levelMap := h.roomLevelDisplayProfileMap(request, userIDs) levelMap := h.roomLevelDisplayProfileMap(request, userIDs)
@ -2525,6 +2527,7 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
ProfileCard: appearance.ProfileCard, ProfileCard: appearance.ProfileCard,
Vehicle: appearance.Vehicle, Vehicle: appearance.Vehicle,
ChatBubble: appearance.ChatBubble, ChatBubble: appearance.ChatBubble,
MicSeatIcon: appearance.MicSeatIcon,
MicSeatAnimation: appearance.MicSeatAnimation, MicSeatAnimation: appearance.MicSeatAnimation,
Charm: 0, Charm: 0,
} }
@ -2565,6 +2568,12 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
profile.MicSeatAnimation = map[string]any{} profile.MicSeatAnimation = map[string]any{}
profiles[userID] = profile profiles[userID] = profile
} }
if profiles[userID].MicSeatIcon == nil {
// icon 与 animation 是两个稳定槽位;任一缺失都返回空对象,客户端不能互相兜底覆盖。
profile := profiles[userID]
profile.MicSeatIcon = map[string]any{}
profiles[userID] = profile
}
if profiles[userID].ProfileCard == nil { if profiles[userID].ProfileCard == nil {
profile := profiles[userID] profile := profiles[userID]
profile.ProfileCard = map[string]any{} profile.ProfileCard = map[string]any{}
@ -2579,6 +2588,7 @@ type roomAppearanceResources struct {
ProfileCard map[string]any ProfileCard map[string]any
Vehicle map[string]any Vehicle map[string]any
ChatBubble map[string]any ChatBubble map[string]any
MicSeatIcon map[string]any
MicSeatAnimation map[string]any MicSeatAnimation map[string]any
Badges []map[string]any Badges []map[string]any
} }
@ -2593,7 +2603,7 @@ func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int
RequestId: httpkit.RequestIDFromContext(request.Context()), RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
UserIds: userIDs, UserIds: userIDs,
ResourceTypes: []string{roomAppearanceResourceAvatarFrame, roomAppearanceResourceProfileCard, roomAppearanceResourceVehicle, roomAppearanceResourceChatBubble, roomAppearanceResourceBadge, roomAppearanceResourceMicSeatAnimation}, ResourceTypes: []string{roomAppearanceResourceAvatarFrame, roomAppearanceResourceProfileCard, roomAppearanceResourceVehicle, roomAppearanceResourceChatBubble, roomAppearanceResourceBadge, roomAppearanceResourceMicSeatIcon, roomAppearanceResourceMicSeatAnimation},
}) })
if err != nil { if err != nil {
// 装扮是展示增强,失败不影响房间主数据返回。 // 装扮是展示增强,失败不影响房间主数据返回。
@ -2618,6 +2628,8 @@ func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int
current.ChatBubble = resource current.ChatBubble = resource
case roomAppearanceResourceBadge: case roomAppearanceResourceBadge:
current.Badges = append(current.Badges, roomBadgeItemFromEquippedResource(resource)) current.Badges = append(current.Badges, roomBadgeItemFromEquippedResource(resource))
case roomAppearanceResourceMicSeatIcon:
current.MicSeatIcon = resource
case roomAppearanceResourceMicSeatAnimation: case roomAppearanceResourceMicSeatAnimation:
current.MicSeatAnimation = resource current.MicSeatAnimation = resource
} }

View File

@ -220,6 +220,7 @@ type roomDisplayProfileData struct {
ProfileCard map[string]any `json:"profile_card"` ProfileCard map[string]any `json:"profile_card"`
Vehicle map[string]any `json:"vehicle"` Vehicle map[string]any `json:"vehicle"`
ChatBubble map[string]any `json:"chat_bubble"` ChatBubble map[string]any `json:"chat_bubble"`
MicSeatIcon map[string]any `json:"mic_seat_icon"`
MicSeatAnimation map[string]any `json:"mic_seat_animation"` MicSeatAnimation map[string]any `json:"mic_seat_animation"`
Charm int64 `json:"charm"` Charm int64 `json:"charm"`
} }

View File

@ -22,6 +22,7 @@ const (
appearanceResourceProfileCard = "profile_card" appearanceResourceProfileCard = "profile_card"
appearanceResourceVehicle = "vehicle" appearanceResourceVehicle = "vehicle"
appearanceResourceBadge = "badge" appearanceResourceBadge = "badge"
appearanceResourceMicSeatIcon = "mic_seat_icon"
appearanceResourceMicSeatAnimation = "mic_seat_animation" appearanceResourceMicSeatAnimation = "mic_seat_animation"
) )
@ -32,6 +33,7 @@ type appearanceData struct {
AvatarFrame map[string]any `json:"avatar_frame"` AvatarFrame map[string]any `json:"avatar_frame"`
ProfileCard map[string]any `json:"profile_card"` ProfileCard map[string]any `json:"profile_card"`
Vehicle map[string]any `json:"vehicle"` Vehicle map[string]any `json:"vehicle"`
MicSeatIcon map[string]any `json:"mic_seat_icon"`
MicSeatAnimation map[string]any `json:"mic_seat_animation"` MicSeatAnimation map[string]any `json:"mic_seat_animation"`
Level map[string]any `json:"level"` Level map[string]any `json:"level"`
LevelBadges map[string]any `json:"level_badges"` LevelBadges map[string]any `json:"level_badges"`
@ -87,6 +89,7 @@ func (h *Handler) appearanceForUser(request *http.Request, userID int64) (appear
AvatarFrame: map[string]any{}, AvatarFrame: map[string]any{},
ProfileCard: map[string]any{}, ProfileCard: map[string]any{},
Vehicle: map[string]any{}, Vehicle: map[string]any{},
MicSeatIcon: map[string]any{},
MicSeatAnimation: map[string]any{}, MicSeatAnimation: map[string]any{},
Level: defaultAppearanceLevelDisplay(), Level: defaultAppearanceLevelDisplay(),
LevelBadges: map[string]any{}, LevelBadges: map[string]any{},
@ -98,10 +101,11 @@ func (h *Handler) appearanceForUser(request *http.Request, userID int64) (appear
// 1. wallet 内部会过滤过期佩戴; // 1. wallet 内部会过滤过期佩戴;
// 2. 后续扩展批量 appearance 时可以复用同一 RPC不引入 N+1。 // 2. 后续扩展批量 appearance 时可以复用同一 RPC不引入 N+1。
equipped, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{ equipped, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()), RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
UserIds: []int64{userID}, UserIds: []int64{userID},
ResourceTypes: []string{appearanceResourceAvatarFrame, appearanceResourceProfileCard, appearanceResourceVehicle, appearanceResourceBadge, appearanceResourceMicSeatAnimation}, // 图标和声波同时查询并分别投影,客户端替换麦位中心图标时不会覆盖 Voice Wave。
ResourceTypes: []string{appearanceResourceAvatarFrame, appearanceResourceProfileCard, appearanceResourceVehicle, appearanceResourceBadge, appearanceResourceMicSeatIcon, appearanceResourceMicSeatAnimation},
}) })
if err != nil { if err != nil {
return data, err return data, err
@ -124,6 +128,8 @@ func (h *Handler) appearanceForUser(request *http.Request, userID int64) (appear
data.Vehicle = resource data.Vehicle = resource
case appearanceResourceBadge: case appearanceResourceBadge:
data.EquippedBadges = append(data.EquippedBadges, resource) data.EquippedBadges = append(data.EquippedBadges, resource)
case appearanceResourceMicSeatIcon:
data.MicSeatIcon = resource
case appearanceResourceMicSeatAnimation: case appearanceResourceMicSeatAnimation:
data.MicSeatAnimation = resource data.MicSeatAnimation = resource
} }

View File

@ -2351,7 +2351,7 @@ SELECT
CASE WHEN benefits.benefit_code = 'daily_coin_rebate' THEN 'disabled' ELSE 'active' END, CASE WHEN benefits.benefit_code = 'daily_coin_rebate' THEN 'disabled' ELSE 'active' END,
benefits.trial_enabled, 0, benefits.trial_enabled, 0,
CASE benefits.benefit_code CASE benefits.benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation' WHEN 'mic_skin' THEN 'mic_seat_icon'
WHEN 'room_border' THEN 'room_border' WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style' WHEN 'colored_room_name' THEN 'room_name_style'
ELSE '' ELSE ''
@ -2460,7 +2460,7 @@ SET @vip_resource_contract_now_ms := CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 100
UPDATE vip_level_benefits UPDATE vip_level_benefits
SET benefit_type = 'decoration', SET benefit_type = 'decoration',
resource_type = CASE benefit_code resource_type = CASE benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation' WHEN 'mic_skin' THEN 'mic_seat_icon'
WHEN 'room_border' THEN 'room_border' WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style' WHEN 'colored_room_name' THEN 'room_name_style'
END, END,
@ -2470,7 +2470,7 @@ WHERE app_code = 'fami'
AND ( AND (
benefit_type <> 'decoration' benefit_type <> 'decoration'
OR resource_type <> CASE benefit_code OR resource_type <> CASE benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation' WHEN 'mic_skin' THEN 'mic_seat_icon'
WHEN 'room_border' THEN 'room_border' WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style' WHEN 'colored_room_name' THEN 'room_name_style'
END END
@ -2482,7 +2482,7 @@ LEFT JOIN resources AS resource
AND resource.resource_id = benefit.resource_id AND resource.resource_id = benefit.resource_id
AND resource.status = 'active' AND resource.status = 'active'
AND resource.resource_type = CASE benefit.benefit_code AND resource.resource_type = CASE benefit.benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation' WHEN 'mic_skin' THEN 'mic_seat_icon'
WHEN 'room_border' THEN 'room_border' WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style' WHEN 'colored_room_name' THEN 'room_name_style'
END END
@ -2493,7 +2493,7 @@ WHERE benefit.app_code = 'fami'
AND benefit.resource_id > 0 AND benefit.resource_id > 0
AND resource.resource_id IS NULL; AND resource.resource_id IS NULL;
-- 只按已经存在的 active Fami VIP4..VIP8 资源组唯一匹配麦位动画HAVING=1 防止多候选误绑。 -- 只按已经存在的 active Fami VIP4..VIP8 资源组唯一匹配麦位中心图标HAVING=1 防止多候选误绑。
-- VIP9、room_border、room_name_style 没有可核实目录资源时继续保持 resource_id=0不生成占位素材。 -- VIP9、room_border、room_name_style 没有可核实目录资源时继续保持 resource_id=0不生成占位素材。
UPDATE vip_level_benefits AS benefit UPDATE vip_level_benefits AS benefit
JOIN ( JOIN (
@ -2506,7 +2506,7 @@ JOIN (
JOIN resources AS resource JOIN resources AS resource
ON resource.app_code = group_item.app_code ON resource.app_code = group_item.app_code
AND resource.resource_id = group_item.resource_id AND resource.resource_id = group_item.resource_id
AND resource.resource_type = 'mic_seat_animation' AND resource.resource_type = 'mic_seat_icon'
AND resource.status = 'active' AND resource.status = 'active'
WHERE resource_group.app_code = 'fami' WHERE resource_group.app_code = 'fami'
AND resource_group.status = 'active' AND resource_group.status = 'active'
@ -2516,7 +2516,7 @@ JOIN (
) AS mapping ) AS mapping
ON mapping.group_code = CONCAT('VIP', benefit.level) ON mapping.group_code = CONCAT('VIP', benefit.level)
SET benefit.resource_id = mapping.resource_id, SET benefit.resource_id = mapping.resource_id,
benefit.resource_type = 'mic_seat_animation', benefit.resource_type = 'mic_seat_icon',
benefit.updated_at_ms = @vip_resource_contract_now_ms benefit.updated_at_ms = @vip_resource_contract_now_ms
WHERE benefit.app_code = 'fami' WHERE benefit.app_code = 'fami'
AND benefit.level BETWEEN 4 AND 8 AND benefit.level BETWEEN 4 AND 8
@ -2524,13 +2524,13 @@ WHERE benefit.app_code = 'fami'
AND benefit.status = 'active' AND benefit.status = 'active'
AND benefit.resource_id = 0; AND benefit.resource_id = 0;
-- 只给 active mic_skin 已绑定的真实麦座动画补 format优先采用合法 animation_format -- 只给 active mic_skin 已绑定的真实麦位中心图标补 format优先采用合法 animation_format
-- 否则只从实际 animation/asset URL 的明确后缀推导jpg 统一写成 jpeg。 -- 否则只从实际 animation/asset URL 的明确后缀推导jpg 统一写成 jpeg。
UPDATE vip_level_benefits AS benefit UPDATE vip_level_benefits AS benefit
JOIN resources AS resource FORCE INDEX (PRIMARY) JOIN resources AS resource FORCE INDEX (PRIMARY)
ON resource.resource_id = benefit.resource_id ON resource.resource_id = benefit.resource_id
AND resource.app_code = benefit.app_code AND resource.app_code = benefit.app_code
AND resource.resource_type = 'mic_seat_animation' AND resource.resource_type = 'mic_seat_icon'
AND resource.status = 'active' AND resource.status = 'active'
SET resource.metadata_json = JSON_SET( SET resource.metadata_json = JSON_SET(
COALESCE(resource.metadata_json, JSON_OBJECT()), COALESCE(resource.metadata_json, JSON_OBJECT()),
@ -2555,7 +2555,7 @@ SET resource.metadata_json = JSON_SET(
WHERE benefit.app_code = 'fami' WHERE benefit.app_code = 'fami'
AND benefit.benefit_code = 'mic_skin' AND benefit.benefit_code = 'mic_skin'
AND benefit.status = 'active' AND benefit.status = 'active'
AND benefit.resource_type = 'mic_seat_animation' AND benefit.resource_type = 'mic_seat_icon'
AND benefit.resource_id > 0 AND benefit.resource_id > 0
AND (TRIM(resource.asset_url) <> '' OR TRIM(resource.animation_url) <> '') AND (TRIM(resource.asset_url) <> '' OR TRIM(resource.animation_url) <> '')
AND TRIM(resource.preview_url) <> '' AND TRIM(resource.preview_url) <> ''
@ -2570,7 +2570,7 @@ WHERE benefit.app_code = 'fami'
REGEXP '\\.(png|jpeg|jpg|webp|gif|svga|pag|mp4)$' REGEXP '\\.(png|jpeg|jpg|webp|gif|svga|pag|mp4)$'
); );
-- active 装扮必须能联结到同 App、active、精确类型的真实资源。麦座动画和房间边框还 -- active 装扮必须能联结到同 App、active、精确类型的真实资源。麦位中心图标和房间边框还
-- 必须具备素材、preview、合法 format并保持可选 animation_format 与 format 一致。 -- 必须具备素材、preview、合法 format并保持可选 animation_format 与 format 一致。
UPDATE vip_level_benefits AS benefit UPDATE vip_level_benefits AS benefit
LEFT JOIN resources AS resource LEFT JOIN resources AS resource
@ -2586,7 +2586,7 @@ WHERE benefit.app_code = 'fami'
AND ( AND (
resource.resource_id IS NULL resource.resource_id IS NULL
OR ( OR (
benefit.resource_type IN ('mic_seat_animation', 'room_border') benefit.resource_type IN ('mic_seat_icon', 'room_border')
AND ( AND (
(TRIM(resource.asset_url) = '' AND TRIM(resource.animation_url) = '') (TRIM(resource.asset_url) = '' AND TRIM(resource.animation_url) = '')
OR TRIM(resource.preview_url) = '' OR TRIM(resource.preview_url) = ''

View File

@ -71,7 +71,7 @@ JOIN vip_program_configs AS p ON p.app_code = b.app_code;
UPDATE vip_level_benefits UPDATE vip_level_benefits
SET benefit_type = 'decoration', SET benefit_type = 'decoration',
resource_type = CASE benefit_code resource_type = CASE benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation' WHEN 'mic_skin' THEN 'mic_seat_icon'
WHEN 'room_border' THEN 'room_border' WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style' WHEN 'colored_room_name' THEN 'room_name_style'
END, END,
@ -81,7 +81,7 @@ WHERE app_code = 'fami'
AND ( AND (
benefit_type <> 'decoration' benefit_type <> 'decoration'
OR resource_type <> CASE benefit_code OR resource_type <> CASE benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation' WHEN 'mic_skin' THEN 'mic_seat_icon'
WHEN 'room_border' THEN 'room_border' WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style' WHEN 'colored_room_name' THEN 'room_name_style'
END END
@ -95,7 +95,7 @@ LEFT JOIN resources AS resource
AND resource.resource_id = benefit.resource_id AND resource.resource_id = benefit.resource_id
AND resource.status = 'active' AND resource.status = 'active'
AND resource.resource_type = CASE benefit.benefit_code AND resource.resource_type = CASE benefit.benefit_code
WHEN 'mic_skin' THEN 'mic_seat_animation' WHEN 'mic_skin' THEN 'mic_seat_icon'
WHEN 'room_border' THEN 'room_border' WHEN 'room_border' THEN 'room_border'
WHEN 'colored_room_name' THEN 'room_name_style' WHEN 'colored_room_name' THEN 'room_name_style'
END END
@ -108,7 +108,7 @@ WHERE benefit.app_code = 'fami'
SET @vip_invalid_resource_clear_count := ROW_COUNT(); SET @vip_invalid_resource_clear_count := ROW_COUNT();
-- 只绑定资源组中唯一的 active 麦位动画。缺少真实目录资源的 VIP9、房间边框和房名样式保持未配置。 -- 只绑定资源组中唯一的 active 麦位中心图标。缺少真实目录资源的 VIP9、房间边框和房名样式保持未配置。
UPDATE vip_level_benefits AS benefit UPDATE vip_level_benefits AS benefit
JOIN ( JOIN (
SELECT resource_group.group_code, MIN(resource.resource_id) AS resource_id SELECT resource_group.group_code, MIN(resource.resource_id) AS resource_id
@ -120,7 +120,7 @@ JOIN (
JOIN resources AS resource JOIN resources AS resource
ON resource.app_code = group_item.app_code ON resource.app_code = group_item.app_code
AND resource.resource_id = group_item.resource_id AND resource.resource_id = group_item.resource_id
AND resource.resource_type = 'mic_seat_animation' AND resource.resource_type = 'mic_seat_icon'
AND resource.status = 'active' AND resource.status = 'active'
WHERE resource_group.app_code = 'fami' WHERE resource_group.app_code = 'fami'
AND resource_group.status = 'active' AND resource_group.status = 'active'
@ -130,7 +130,7 @@ JOIN (
) AS mapping ) AS mapping
ON mapping.group_code = CONCAT('VIP', benefit.level) ON mapping.group_code = CONCAT('VIP', benefit.level)
SET benefit.resource_id = mapping.resource_id, SET benefit.resource_id = mapping.resource_id,
benefit.resource_type = 'mic_seat_animation', benefit.resource_type = 'mic_seat_icon',
benefit.updated_at_ms = @vip_resource_contract_now_ms benefit.updated_at_ms = @vip_resource_contract_now_ms
WHERE benefit.app_code = 'fami' WHERE benefit.app_code = 'fami'
AND benefit.level BETWEEN 4 AND 8 AND benefit.level BETWEEN 4 AND 8
@ -140,13 +140,13 @@ WHERE benefit.app_code = 'fami'
SET @vip_mic_skin_bind_count := ROW_COUNT(); SET @vip_mic_skin_bind_count := ROW_COUNT();
-- 只处理 active mic_skin 已绑定的真实麦座动画:合法 animation_format 优先;缺省时仅从 -- 只处理 active mic_skin 已绑定的真实麦位中心图标:合法 animation_format 优先;缺省时仅从
-- animation/asset URL 的明确后缀推导 format。权益矩阵很小资源表始终按主键点查。 -- animation/asset URL 的明确后缀推导 format。权益矩阵很小资源表始终按主键点查。
UPDATE vip_level_benefits AS benefit UPDATE vip_level_benefits AS benefit
JOIN resources AS resource FORCE INDEX (PRIMARY) JOIN resources AS resource FORCE INDEX (PRIMARY)
ON resource.resource_id = benefit.resource_id ON resource.resource_id = benefit.resource_id
AND resource.app_code = benefit.app_code AND resource.app_code = benefit.app_code
AND resource.resource_type = 'mic_seat_animation' AND resource.resource_type = 'mic_seat_icon'
AND resource.status = 'active' AND resource.status = 'active'
SET resource.metadata_json = JSON_SET( SET resource.metadata_json = JSON_SET(
COALESCE(resource.metadata_json, JSON_OBJECT()), COALESCE(resource.metadata_json, JSON_OBJECT()),
@ -171,7 +171,7 @@ SET resource.metadata_json = JSON_SET(
WHERE benefit.app_code = 'fami' WHERE benefit.app_code = 'fami'
AND benefit.benefit_code = 'mic_skin' AND benefit.benefit_code = 'mic_skin'
AND benefit.status = 'active' AND benefit.status = 'active'
AND benefit.resource_type = 'mic_seat_animation' AND benefit.resource_type = 'mic_seat_icon'
AND benefit.resource_id > 0 AND benefit.resource_id > 0
AND (TRIM(resource.asset_url) <> '' OR TRIM(resource.animation_url) <> '') AND (TRIM(resource.asset_url) <> '' OR TRIM(resource.animation_url) <> '')
AND TRIM(resource.preview_url) <> '' AND TRIM(resource.preview_url) <> ''
@ -202,7 +202,7 @@ WHERE benefit.app_code = 'fami'
AND ( AND (
resource.resource_id IS NULL resource.resource_id IS NULL
OR ( OR (
benefit.resource_type IN ('mic_seat_animation', 'room_border') benefit.resource_type IN ('mic_seat_icon', 'room_border')
AND ( AND (
(TRIM(resource.asset_url) = '' AND TRIM(resource.animation_url) = '') (TRIM(resource.asset_url) = '' AND TRIM(resource.animation_url) = '')
OR TRIM(resource.preview_url) = '' OR TRIM(resource.preview_url) = ''

View File

@ -6745,8 +6745,9 @@ func TestRevokeResourceGrantRejectsUnsupportedRecords(t *testing.T) {
} }
} }
// TestGrantMicSeatAnimationAutoEquips 验证麦位声波资源在发放事务内默认佩戴。 // TestMicSeatIconAndAnimationUseIndependentEquipmentSlots 验证 Voice Wave 默认佩戴后,
func TestGrantMicSeatAnimationAutoEquips(t *testing.T) { // 再佩戴麦位中心图标不会覆盖声波;两个 resource_type 必须各保留一个当前装备。
func TestMicSeatIconAndAnimationUseIndependentEquipmentSlots(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository) svc := walletservice.New(repository)
ctx := context.Background() ctx := context.Background()
@ -6836,6 +6837,56 @@ func TestGrantMicSeatAnimationAutoEquips(t *testing.T) {
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(42003), resourcedomain.TypeMicSeatAnimation); got != 1 { if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(42003), resourcedomain.TypeMicSeatAnimation); got != 1 {
t.Fatalf("mic seat animation equipment should keep one active row, got %d", got) t.Fatalf("mic seat animation equipment should keep one active row, got %d", got)
} }
micIcon, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "mic_icon_gold",
ResourceType: resourcedomain.TypeMicSeatIcon,
Name: "Gold Mic Icon",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"mic_seat"},
AssetURL: "https://cdn.example/mic-icon.png",
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create mic seat icon resource failed: %v", err)
}
iconGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-grant-mic-icon-gold",
TargetUserID: 42003,
ResourceID: micIcon.ResourceID,
Quantity: 1,
Reason: "test independent mic icon equipment",
OperatorUserID: 90001,
GrantSource: resourcedomain.GrantSourceAdmin,
})
if err != nil {
t.Fatalf("grant mic seat icon resource failed: %v", err)
}
if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
CommandID: "cmd-equip-mic-icon-gold",
UserID: 42003,
ResourceID: micIcon.ResourceID,
EntitlementID: iconGrant.Items[0].EntitlementID,
}); err != nil {
t.Fatalf("equip mic seat icon resource failed: %v", err)
}
independentEquipment, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
UserIDs: []int64{42003},
ResourceTypes: []string{resourcedomain.TypeMicSeatIcon, resourcedomain.TypeMicSeatAnimation},
})
if err != nil {
t.Fatalf("batch get independent mic equipment failed: %v", err)
}
if len(independentEquipment) != 1 || len(independentEquipment[0].Resources) != 2 {
t.Fatalf("mic icon equipment must preserve Voice Wave equipment: %+v", independentEquipment)
}
for _, resourceType := range []string{resourcedomain.TypeMicSeatIcon, resourcedomain.TypeMicSeatAnimation} {
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(42003), resourceType); got != 1 {
t.Fatalf("%s must keep its own single-select equipment row, got %d", resourceType, got)
}
}
} }
// TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement 锁定道具商店购买的原子语义。 // TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement 锁定道具商店购买的原子语义。

View File

@ -1021,9 +1021,10 @@ func (r *Repository) pruneExpiredUserResourceEquipment(ctx context.Context, user
func isEquipableResourceType(resourceType string) bool { func isEquipableResourceType(resourceType string) bool {
switch resourcedomain.NormalizeResourceType(resourceType) { switch resourcedomain.NormalizeResourceType(resourceType) {
// 飘窗与头像框等用户装扮保持同一资源类型单选槽,切换时事务会替换同类型旧装备; // 麦位中心图标和声波动效必须使用两个独立的 resource_type 单选槽;用户切换 mic_seat_icon
// badge 的多选和 VIP 体验卡的专用状态机仍由各自分支处理。 // 时只替换旧图标,不能删除 mic_seat_animation。badge 的多选和 VIP 体验卡的专用状态机
case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge, resourcedomain.TypeFloatingScreen, resourcedomain.TypeMicSeatAnimation, resourcedomain.TypeVIPTrialCard: // 仍由各自分支处理。
case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge, resourcedomain.TypeFloatingScreen, resourcedomain.TypeMicSeatIcon, resourcedomain.TypeMicSeatAnimation, resourcedomain.TypeVIPTrialCard:
return true return true
default: default:
return false return false

View File

@ -7,7 +7,7 @@ import (
) )
func TestDecorativeResourceTypesAreEquipable(t *testing.T) { func TestDecorativeResourceTypesAreEquipable(t *testing.T) {
for _, resourceType := range []string{resourcedomain.TypeProfileCard, resourcedomain.TypeMicSeatAnimation} { for _, resourceType := range []string{resourcedomain.TypeProfileCard, resourcedomain.TypeMicSeatIcon, resourcedomain.TypeMicSeatAnimation} {
if !isEquipableResourceType(resourceType) { if !isEquipableResourceType(resourceType) {
t.Fatalf("%s resources must use the equipment slot just like other profile decorations", resourceType) t.Fatalf("%s resources must use the equipment slot just like other profile decorations", resourceType)
} }