增加麦克风皮肤的装扮
This commit is contained in:
parent
070b4f179a
commit
838a76f0cc
@ -154,6 +154,10 @@ final benefitHandlers = <String, VipBenefitHandler>{
|
||||
|
||||
展示等级详情时直接循环该套餐的 `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
|
||||
|
||||
- `/vip/packages` 和 `/vip/me` 返回当前登录态对应 App 的配置,Flutter 不传 `app_code`。
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
- 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 的房间装扮接口。
|
||||
- 用户级装扮与房间级装扮分开:`mic_seat_icon`(麦位中心图标)和 `mic_seat_animation`(Voice Wave)分别走独立的用户资源佩戴槽;`room_border` 和 `room_name_style` 必须走 Room Cell 的房间装扮接口。
|
||||
- 房间背景和房间图片只支持 JPEG、PNG、GIF、WebP。首版不支持 APNG、SVG、视频或外部 URL。
|
||||
- 房间状态以 HTTP 快照恢复,腾讯云 IM 只负责实时增量。不能把 IM 是否到达当成权益或持久状态事实。
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
| 阶段 | 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 |
|
||||
| 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 |
|
||||
| 4. 主页隐私 | 目标统计隐藏显式返回;匿名访问在写入时固化,访客列表不泄露真实 ID |
|
||||
| 5. 房间图片 | 专用上传、发言与权益门禁、command log、durable outbox、腾讯云 IM 图片事件 |
|
||||
@ -161,7 +161,7 @@ Authorization: Bearer {access_token}
|
||||
"status": "active",
|
||||
"trial_enabled": true,
|
||||
"resource_id": 5105,
|
||||
"resource_type": "mic_seat_animation",
|
||||
"resource_type": "mic_seat_icon",
|
||||
"execution_scope": "room",
|
||||
"auto_equip": false,
|
||||
"sort_order": 50,
|
||||
@ -169,24 +169,24 @@ Authorization: Bearer {access_token}
|
||||
"created_at_ms": 1784000000000,
|
||||
"updated_at_ms": 1784000000000,
|
||||
"presentation": {
|
||||
"description": "VIP麦位动态皮肤",
|
||||
"description": "VIP麦位中心图标",
|
||||
"preview_items": [],
|
||||
"numeric_reward": null
|
||||
},
|
||||
"resource": {
|
||||
"resource_id": 5105,
|
||||
"resource_code": "fami_vip5_mic",
|
||||
"resource_type": "mic_seat_animation",
|
||||
"resource_type": "mic_seat_icon",
|
||||
"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",
|
||||
"asset_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",
|
||||
"format": "svga",
|
||||
"metadata_json": "{\"format\":\"svga\"}",
|
||||
"animation_url": "",
|
||||
"format": "png",
|
||||
"metadata_json": "{\"format\":\"png\"}",
|
||||
"sort_order": 50,
|
||||
"created_at_ms": 1784000000000,
|
||||
"updated_at_ms": 1784000000000
|
||||
@ -239,7 +239,7 @@ Authorization: Bearer {access_token}
|
||||
"status": "active",
|
||||
"trial_enabled": true,
|
||||
"resource_id": 5105,
|
||||
"resource_type": "mic_seat_animation",
|
||||
"resource_type": "mic_seat_icon",
|
||||
"execution_scope": "room",
|
||||
"auto_equip": false,
|
||||
"sort_order": 50,
|
||||
@ -247,24 +247,24 @@ Authorization: Bearer {access_token}
|
||||
"created_at_ms": 1784000000000,
|
||||
"updated_at_ms": 1784000000000,
|
||||
"presentation": {
|
||||
"description": "VIP麦位动态皮肤",
|
||||
"description": "VIP麦位中心图标",
|
||||
"preview_items": [],
|
||||
"numeric_reward": null
|
||||
},
|
||||
"resource": {
|
||||
"resource_id": 5105,
|
||||
"resource_code": "fami_vip5_mic",
|
||||
"resource_type": "mic_seat_animation",
|
||||
"resource_type": "mic_seat_icon",
|
||||
"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",
|
||||
"asset_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",
|
||||
"format": "svga",
|
||||
"metadata_json": "{\"format\":\"svga\"}",
|
||||
"animation_url": "",
|
||||
"format": "png",
|
||||
"metadata_json": "{\"format\":\"png\"}",
|
||||
"sort_order": 50,
|
||||
"created_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`;不能先启用权益再让客户端使用占位素材。
|
||||
|
||||
`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 | 应用位置 |
|
||||
| --- | --- | --- |
|
||||
| `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 |
|
||||
| `colored_room_name` | `room_name_style` | 房间 decoration apply,`decoration_type=colored_room_name` |
|
||||
|
||||
@ -361,7 +362,7 @@ PATCH 是局部更新,至少提交一个字段;显式 `false` 不能当成
|
||||
### 5.1 查询背包资源
|
||||
|
||||
```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 接口。
|
||||
@ -379,17 +380,17 @@ GET /api/v1/users/me/resources?resource_type=mic_seat_animation
|
||||
"resource": {
|
||||
"resource_id": 5105,
|
||||
"resource_code": "fami_vip5_mic",
|
||||
"resource_type": "mic_seat_animation",
|
||||
"resource_type": "mic_seat_icon",
|
||||
"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",
|
||||
"asset_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",
|
||||
"format": "svga",
|
||||
"metadata_json": "{\"format\":\"svga\"}",
|
||||
"animation_url": "",
|
||||
"format": "png",
|
||||
"metadata_json": "{\"format\":\"png\"}",
|
||||
"sort_order": 50,
|
||||
"created_at_ms": 1784000000000,
|
||||
"updated_at_ms": 1784000000000
|
||||
@ -443,7 +444,7 @@ 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 为准:
|
||||
查询其他用户的公开外观使用 `GET /api/v1/users/by-id/{user_id}/appearance`。两个接口的 `data.mic_seat_icon`、`data.mic_seat_animation` 分别同构。下例同时展开麦位中心图标与 Voice Wave,其他 appearance 字段以 OpenAPI 为准:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -452,31 +453,57 @@ Authorization: Bearer {access_token}
|
||||
"request_id": "req_appearance",
|
||||
"data": {
|
||||
"user_id": "10001",
|
||||
"mic_seat_animation": {
|
||||
"mic_seat_icon": {
|
||||
"resource_id": 5105,
|
||||
"resource_code": "fami_vip5_mic",
|
||||
"resource_type": "mic_seat_animation",
|
||||
"resource_type": "mic_seat_icon",
|
||||
"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",
|
||||
"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",
|
||||
"metadata_json": "{\"format\":\"svga\"}",
|
||||
"entitlement_id": "ent_abc",
|
||||
"entitlement_id": "ent_wave",
|
||||
"expires_at_ms": 1786592000000
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"resource_id": 5105,
|
||||
"resource_code": "fami_vip5_mic",
|
||||
"resource_type": "mic_seat_animation",
|
||||
"resource_type": "mic_seat_icon",
|
||||
"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",
|
||||
"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",
|
||||
"metadata_json": "{\"format\":\"svga\"}",
|
||||
"entitlement_id": "ent_abc",
|
||||
"entitlement_id": "ent_wave",
|
||||
"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. 房间背景:上传、保存、切换
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
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` 继续用于彩色房名及兼容调用。
|
||||
|
||||
然后由房主应用到具体房间:
|
||||
@ -1419,7 +1448,7 @@ void onCustomMessage(CustomElem elem) {
|
||||
| 登录/切账号/冷启动/回前台 | `/vip/me` |
|
||||
| 打开 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` |
|
||||
| 背景/装扮 IM 版本断层 | `/rooms/{room_id}/detail` |
|
||||
| 装扮到 `expires_at_ms` | 本地立即隐藏,再拉详情确认 |
|
||||
@ -1473,7 +1502,7 @@ void onCustomMessage(CustomElem elem) {
|
||||
- Fami `/vip/packages`:有真实资源时返回完整 `resource`,未配置时字段省略。
|
||||
- Lalu:旧背景 `image_url` 请求仍可用,新 Flutter 也可走专用协议。
|
||||
- 资源 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`。
|
||||
- 背景:JPEG/PNG/GIF/WebP 正常;错误 MIME、APNG、超大小、超尺寸分别命中预期 code。
|
||||
- 背景 upload 成功但未 save:背景列表不增加;save 后未 set:当前背景不变化。
|
||||
|
||||
@ -109,6 +109,7 @@ X-App-Code: lalu
|
||||
"avatar_frame": {},
|
||||
"profile_card": {},
|
||||
"vehicle": {},
|
||||
"mic_seat_icon": {},
|
||||
"mic_seat_animation": {},
|
||||
"charm": 0
|
||||
}
|
||||
@ -137,6 +138,7 @@ X-App-Code: lalu
|
||||
"avatar_frame": {},
|
||||
"profile_card": {},
|
||||
"vehicle": {},
|
||||
"mic_seat_icon": {},
|
||||
"mic_seat_animation": {},
|
||||
"charm": 0
|
||||
}
|
||||
@ -161,6 +163,7 @@ X-App-Code: lalu
|
||||
"avatar_frame": {},
|
||||
"profile_card": {},
|
||||
"vehicle": {},
|
||||
"mic_seat_icon": {},
|
||||
"mic_seat_animation": {},
|
||||
"charm": 0
|
||||
},
|
||||
@ -182,6 +185,7 @@ X-App-Code: lalu
|
||||
"avatar_frame": {},
|
||||
"profile_card": {},
|
||||
"vehicle": {},
|
||||
"mic_seat_icon": {},
|
||||
"mic_seat_animation": {},
|
||||
"charm": 0
|
||||
}
|
||||
@ -238,7 +242,8 @@ X-App-Code: lalu
|
||||
| `avatar_frame` | object | 当前佩戴头像框。 |
|
||||
| `profile_card` | object | 当前佩戴资料卡。 |
|
||||
| `vehicle` | object | 当前佩戴座驾。 |
|
||||
| `mic_seat_animation` | object | 当前佩戴麦位动画。 |
|
||||
| `mic_seat_icon` | object | 当前佩戴麦位中心图标。 |
|
||||
| `mic_seat_animation` | object | 当前佩戴 Voice Wave;与中心图标独立。 |
|
||||
| `charm` | int64 | 魅力值。 |
|
||||
|
||||
## 错误码
|
||||
|
||||
@ -81,7 +81,7 @@ Query:
|
||||
| `badge` | 徽章 | 是 |
|
||||
| `gift` | 礼物权益 | 否 |
|
||||
| `floating_screen` | 飘屏 | 否 |
|
||||
| `mic_seat_icon` | 麦位图标 | 否 |
|
||||
| `mic_seat_icon` | 麦位中心图标 | 是 |
|
||||
| `mic_seat_animation` | 麦位动画 | 是 |
|
||||
| `emoji_pack` | 表情包 | 否 |
|
||||
|
||||
@ -219,14 +219,16 @@ Body:
|
||||
|
||||
| 规则 | 说明 |
|
||||
| --- | --- |
|
||||
| 可佩戴类型 | `avatar_frame`、`profile_card`、`vehicle`、`chat_bubble`、`badge`、`mic_seat_animation`。 |
|
||||
| 不可佩戴类型 | `gift`、`floating_screen`、`mic_seat_icon`、`emoji_pack`。 |
|
||||
| 可佩戴类型 | `avatar_frame`、`profile_card`、`vehicle`、`chat_bubble`、`badge`、`mic_seat_icon`、`mic_seat_animation`。 |
|
||||
| 不可佩戴类型 | `gift`、`floating_screen`、`emoji_pack`。 |
|
||||
| 同类替换 | 同一个 `resource_type` 只有一个当前佩戴项,新佩戴会替换旧佩戴。 |
|
||||
| 权益校验 | 必须是当前登录用户自己的有效权益;过期、禁用、剩余数量为 0 都不能佩戴。 |
|
||||
| 发放默认佩戴 | `badge`、`mic_seat_animation` 发放成功后默认佩戴。 |
|
||||
| 徽章展示 | `badge` 是拥有即佩戴的多装备资源;资料页多徽章展示仍使用 `/api/v1/badges/me` 和 `/api/v1/badges/display`。 |
|
||||
| 资料卡背景 | `profile_card` 只用于装扮资料卡;个人主页背景图使用用户资料字段 `profile_bg_img`,不走背包佩戴。 |
|
||||
|
||||
`mic_seat_icon` 和 `mic_seat_animation` 的 `resource_type` 不同,因此分别单选、分别取消。前者只替换麦位中心图标,后者只控制 Voice Wave;佩戴或取消任一类型都不能修改另一类型的装备状态。
|
||||
|
||||
常见错误:
|
||||
|
||||
| HTTP | `code` | 场景 |
|
||||
|
||||
@ -1162,7 +1162,8 @@ paths:
|
||||
summary: 查询当前用户已佩戴外观资源
|
||||
operationId: getMyAppearance
|
||||
description: >-
|
||||
返回 wallet 已过滤过期状态后的当前佩戴快照。mic_seat_animation.format 只来自资源 metadata_json 的显式
|
||||
返回 wallet 已过滤过期状态后的当前佩戴快照。mic_seat_icon 与 mic_seat_animation 是独立装备槽,
|
||||
前者替换麦位中心图标,后者保留 Voice Wave。format 只来自资源 metadata_json 的显式
|
||||
format/animation_format;字段缺省表示格式未配置,客户端不得从素材 URL 后缀推断。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
@ -1727,7 +1728,7 @@ paths:
|
||||
- rooms
|
||||
summary: 加入房间
|
||||
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:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
@ -4658,7 +4659,7 @@ definitions:
|
||||
type: string
|
||||
resource_type:
|
||||
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:
|
||||
type: string
|
||||
asset_url:
|
||||
@ -4690,6 +4691,7 @@ definitions:
|
||||
- avatar_frame
|
||||
- profile_card
|
||||
- vehicle
|
||||
- mic_seat_icon
|
||||
- mic_seat_animation
|
||||
- level
|
||||
- level_badges
|
||||
@ -4707,8 +4709,12 @@ definitions:
|
||||
$ref: "#/definitions/AppearanceResourceData"
|
||||
vehicle:
|
||||
$ref: "#/definitions/AppearanceResourceData"
|
||||
mic_seat_icon:
|
||||
$ref: "#/definitions/AppearanceResourceData"
|
||||
description: 当前麦位中心图标佩戴快照;空对象表示未佩戴,不影响 mic_seat_animation Voice Wave。
|
||||
mic_seat_animation:
|
||||
$ref: "#/definitions/AppearanceResourceData"
|
||||
description: 当前 Voice Wave 佩戴快照;空对象表示未佩戴,不影响 mic_seat_icon 中心图标。
|
||||
level:
|
||||
type: object
|
||||
level_badges:
|
||||
@ -6189,9 +6195,12 @@ definitions:
|
||||
chat_bubble:
|
||||
$ref: "#/definitions/AppearanceResourceData"
|
||||
description: 当前聊天气泡佩戴快照;空对象表示未佩戴,供公屏按发送人资料渲染。
|
||||
mic_seat_icon:
|
||||
$ref: "#/definitions/AppearanceResourceData"
|
||||
description: 当前麦位中心图标佩戴快照;空对象表示未佩戴,必须与 Voice Wave 分层渲染。
|
||||
mic_seat_animation:
|
||||
$ref: "#/definitions/AppearanceResourceData"
|
||||
description: 当前麦位动效佩戴快照;空对象表示未佩戴,format 缺省表示后台没有配置受支持 renderer。
|
||||
description: 当前 Voice Wave 佩戴快照;空对象表示未佩戴,format 缺省表示后台没有配置受支持 renderer,不得覆盖 mic_seat_icon。
|
||||
charm:
|
||||
type: integer
|
||||
format: int64
|
||||
|
||||
@ -1242,6 +1242,7 @@ class VipEntitlementState {
|
||||
"avatar_frame": {},
|
||||
"profile_card": {},
|
||||
"vehicle": {},
|
||||
"mic_seat_icon": {},
|
||||
"mic_seat_animation": {},
|
||||
"equipped_badges": [],
|
||||
"badges": [],
|
||||
|
||||
@ -62,6 +62,18 @@ GET /api/v1/users/me/appearance
|
||||
"entitlement_id": "ent-vehicle",
|
||||
"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": {
|
||||
"resource_id": 8106,
|
||||
"resource_code": "mic_seat_wave_gold",
|
||||
@ -107,6 +119,18 @@ GET /api/v1/users/me/appearance
|
||||
"server_time_ms": 1778256000000
|
||||
},
|
||||
"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_code": "mic_seat_wave_gold",
|
||||
@ -132,6 +156,7 @@ GET /api/v1/users/me/appearance
|
||||
| avatar_frame | 当前佩戴头像框,未佩戴时为 `{}` |
|
||||
| profile_card | 当前佩戴背景卡,未佩戴时为 `{}` |
|
||||
| vehicle | 当前佩戴入场座驾,未佩戴时为 `{}` |
|
||||
| mic_seat_icon | 当前佩戴麦位中心图标,未佩戴时为 `{}` |
|
||||
| mic_seat_animation | 当前佩戴麦位声波,未佩戴时为 `{}` |
|
||||
| equipped_badges | wallet 当前佩戴的 badge 权益 |
|
||||
| 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。
|
||||
- 客户端渲染动画优先使用 `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` 发放成功后服务端默认佩戴。
|
||||
- 过期资源不能佩戴。
|
||||
- 不传 `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",
|
||||
"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": {
|
||||
"resource_id": 8106,
|
||||
"resource_code": "mic_seat_wave_gold",
|
||||
@ -408,7 +446,9 @@ GET /api/v1/users/room-display-profiles:batch?user_ids=42,43
|
||||
- 入场座驾展示用 `vehicle`。
|
||||
- 公屏聊天气泡用 `chat_bubble`;未佩戴时固定返回空对象,普通文本消息按发送人 ID 批量补齐该字段。
|
||||
- VIP 装扮只在当前有效 VIP 权限覆盖对应等级时视为已佩戴;VIP 失效后背包可保留历史权益,但 `equipped=false`,客户端不得用资源列表兜底继续渲染。
|
||||
- 麦位中心图标展示用 `mic_seat_icon`;在有用户的麦位用其 `asset_url` 替换默认中心图标,未佩戴时使用客户端默认麦位图标。
|
||||
- 麦位声波展示用 `mic_seat_animation`。
|
||||
- 同一资料同时返回两者时必须叠加渲染:`mic_seat_icon` 管中心图标,`mic_seat_animation` 管 Voice Wave;不能因为图标存在而隐藏声波。
|
||||
- 徽章条展示用 `badges`。
|
||||
- 过期佩戴不会返回。
|
||||
|
||||
@ -478,8 +518,8 @@ Body:
|
||||
|
||||
- Flutter 不需要传入场座驾。
|
||||
- 服务端会在进房时自动读取当前佩戴的 `vehicle`,写入房间 IM。
|
||||
- `join` 返回里的 `profiles` 是基础资料。
|
||||
- 房间增强资料用 `/users/room-display-profiles:batch`,或使用房间详情、在线用户列表里内嵌的 `profile` 字段。
|
||||
- 完整模式下 `join` 返回里的 `profiles` 与 `/users/room-display-profiles:batch` 使用同一聚合结构,直接包含 `mic_seat_icon` 和 `mic_seat_animation`;lite 模式不保证返回增强装扮。
|
||||
- 后续增量用户用 `/users/room-display-profiles:batch`,或使用房间详情、在线用户列表里内嵌的 `profile` 字段补齐。
|
||||
|
||||
## 7. 相关 IM
|
||||
|
||||
@ -538,5 +578,5 @@ Flutter 处理:
|
||||
3. 点击佩戴调 `POST /api/v1/users/me/resources/{resource_id}/equip`,成功后刷新 `/appearance`。
|
||||
4. 点击取消佩戴调 `DELETE /api/v1/users/me/resources/{resource_type}/unequip`,成功后刷新 `/appearance`。
|
||||
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` 播放入场座驾。
|
||||
|
||||
@ -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`。
|
||||
- `badge`、`mic_seat_animation` 发放成功后默认写入当前佩戴。
|
||||
- `user-service.users.avatar` 仍然只是头像 URL,不承载头像框。
|
||||
|
||||
@ -69,13 +69,13 @@ SELECT b.app_code,
|
||||
FROM vip_level_benefits AS b
|
||||
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;
|
||||
-- disabled 可以无素材,但若已有 resource_id 也不能保留错误类型或下架资源。
|
||||
UPDATE vip_level_benefits
|
||||
SET benefit_type = 'decoration',
|
||||
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 'colored_room_name' THEN 'room_name_style'
|
||||
END,
|
||||
@ -85,7 +85,7 @@ WHERE app_code = 'fami'
|
||||
AND (
|
||||
benefit_type <> 'decoration'
|
||||
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 'colored_room_name' THEN 'room_name_style'
|
||||
END
|
||||
@ -99,7 +99,7 @@ LEFT JOIN resources AS resource
|
||||
AND resource.resource_id = benefit.resource_id
|
||||
AND resource.status = 'active'
|
||||
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 'colored_room_name' THEN 'room_name_style'
|
||||
END
|
||||
@ -112,7 +112,7 @@ WHERE benefit.app_code = 'fami'
|
||||
|
||||
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 防止运营新增候选后误绑;
|
||||
-- VIP9、room_border、room_name_style 当前没有真实目录资源,必须保持未配置,不能伪造 ID/URL。
|
||||
UPDATE vip_level_benefits AS benefit
|
||||
@ -126,7 +126,7 @@ JOIN (
|
||||
JOIN resources AS resource
|
||||
ON resource.app_code = group_item.app_code
|
||||
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'
|
||||
WHERE resource_group.app_code = 'fami'
|
||||
AND resource_group.status = 'active'
|
||||
@ -136,7 +136,7 @@ JOIN (
|
||||
) AS mapping
|
||||
ON mapping.group_code = CONCAT('VIP', benefit.level)
|
||||
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
|
||||
WHERE benefit.app_code = 'fami'
|
||||
AND benefit.level BETWEEN 4 AND 8
|
||||
@ -146,14 +146,14 @@ WHERE benefit.app_code = 'fami'
|
||||
|
||||
SET @vip_mic_skin_bind_count := ROW_COUNT();
|
||||
|
||||
-- 只给“已经被 active mic_skin 绑定”的真实麦座动画补显式 format:先信任后台已有的
|
||||
-- 只给“已经被 active mic_skin 绑定”的真实麦位中心图标补显式 format:先信任后台已有的
|
||||
-- 合法 animation_format,否则只从实际 animation/asset URL 的明确后缀推导。这个更新
|
||||
-- 通过权益配置小表圈定 resource_id,再按 resources 主键点查,不会扫描或修改未绑定资源。
|
||||
UPDATE vip_level_benefits AS benefit
|
||||
JOIN resources AS resource FORCE INDEX (PRIMARY)
|
||||
ON resource.resource_id = benefit.resource_id
|
||||
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'
|
||||
SET resource.metadata_json = JSON_SET(
|
||||
COALESCE(resource.metadata_json, JSON_OBJECT()),
|
||||
@ -178,7 +178,7 @@ SET resource.metadata_json = JSON_SET(
|
||||
WHERE benefit.app_code = 'fami'
|
||||
AND benefit.benefit_code = 'mic_skin'
|
||||
AND benefit.status = 'active'
|
||||
AND benefit.resource_type = 'mic_seat_animation'
|
||||
AND benefit.resource_type = 'mic_seat_icon'
|
||||
AND benefit.resource_id > 0
|
||||
AND (TRIM(resource.asset_url) <> '' OR TRIM(resource.animation_url) <> '')
|
||||
AND TRIM(resource.preview_url) <> ''
|
||||
@ -195,7 +195,7 @@ WHERE benefit.app_code = 'fami'
|
||||
|
||||
SET @vip_mic_format_backfill_count := ROW_COUNT();
|
||||
|
||||
-- active 必须能联结到同 App、active、精确类型的真实资源。麦座动画和房间边框还必须
|
||||
-- active 必须能联结到同 App、active、精确类型的真实资源。麦位中心图标和房间边框还必须
|
||||
-- 同时具备素材、preview、合法 format,且可选 animation_format 一旦配置就必须一致。
|
||||
-- 缺少真实配置时只禁用权益,不创建占位资源,也不猜测素材 URL。
|
||||
UPDATE vip_level_benefits AS benefit
|
||||
@ -212,7 +212,7 @@ WHERE benefit.app_code = 'fami'
|
||||
AND (
|
||||
resource.resource_id IS NULL
|
||||
OR (
|
||||
benefit.resource_type IN ('mic_seat_animation', 'room_border')
|
||||
benefit.resource_type IN ('mic_seat_icon', 'room_border')
|
||||
AND (
|
||||
(TRIM(resource.asset_url) = '' AND TRIM(resource.animation_url) = '')
|
||||
OR TRIM(resource.preview_url) = ''
|
||||
|
||||
@ -3647,6 +3647,15 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
|
||||
GrantStrategy: "set_active_flag",
|
||||
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{
|
||||
ResourceId: 8105,
|
||||
ResourceCode: "badge_tile_gold",
|
||||
@ -3678,6 +3687,14 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
|
||||
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",
|
||||
@ -3754,13 +3771,13 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
|
||||
}
|
||||
hasRoomDisplayResourceQuery := false
|
||||
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
|
||||
break
|
||||
}
|
||||
}
|
||||
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)
|
||||
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`) {
|
||||
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)
|
||||
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)
|
||||
@ -9037,6 +9058,15 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
|
||||
GrantStrategy: "set_active_flag",
|
||||
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{
|
||||
batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{
|
||||
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-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-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},
|
||||
},
|
||||
}},
|
||||
@ -9096,6 +9127,11 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
} `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 {
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
AnimationURL string `json:"animation_url"`
|
||||
@ -9122,24 +9158,27 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
|
||||
if walletClient.lastBatchEquipped == nil || walletClient.lastBatchEquipped.GetUserIds()[0] != 42 {
|
||||
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" {
|
||||
t.Fatalf("appearance should query mic_seat_animation with wearable resources: %+v", walletClient.lastBatchEquipped.GetResourceTypes())
|
||||
if strings.Join(walletClient.lastBatchEquipped.GetResourceTypes(), ",") != "avatar_frame,profile_card,vehicle,badge,mic_seat_icon,mic_seat_animation" {
|
||||
t.Fatalf("appearance should query independent mic icon and Voice Wave slots: %+v", walletClient.lastBatchEquipped.GetResourceTypes())
|
||||
}
|
||||
if achievementClient.lastBadges == nil || achievementClient.lastBadges.GetUserId() != 42 {
|
||||
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)
|
||||
}
|
||||
hasMicSeatResource := false
|
||||
hasMicSeatIconResource := false
|
||||
hasMicSeatAnimationResource := false
|
||||
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" {
|
||||
hasMicSeatResource = true
|
||||
break
|
||||
hasMicSeatAnimationResource = true
|
||||
}
|
||||
}
|
||||
if !hasMicSeatResource {
|
||||
t.Fatalf("appearance resources must include equipped mic_seat_animation: %+v", envelope.Data.Resources)
|
||||
if !hasMicSeatIconResource || !hasMicSeatAnimationResource {
|
||||
t.Fatalf("appearance resources must include independent mic icon and Voice Wave equipment: %+v", envelope.Data.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -306,6 +306,7 @@ func roomGiftCoreProfilesFromUsers(userIDs []int64, users map[int64]*userv1.User
|
||||
AvatarFrame: map[string]any{},
|
||||
ProfileCard: map[string]any{},
|
||||
Vehicle: map[string]any{},
|
||||
MicSeatIcon: map[string]any{},
|
||||
MicSeatAnimation: map[string]any{},
|
||||
Charm: 0,
|
||||
}
|
||||
|
||||
@ -37,6 +37,7 @@ const (
|
||||
roomAppearanceResourceVehicle = "vehicle"
|
||||
roomAppearanceResourceChatBubble = "chat_bubble"
|
||||
roomAppearanceResourceBadge = "badge"
|
||||
roomAppearanceResourceMicSeatIcon = "mic_seat_icon"
|
||||
roomAppearanceResourceMicSeatAnimation = "mic_seat_animation"
|
||||
|
||||
// 机器人房标记来自 room-service RoomExt;gateway 只用它决定首屏资料是否补机器人头像昵称。
|
||||
@ -2491,8 +2492,9 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
|
||||
// 房间资料是展示增强,失败时保留主房间数据,让客户端后续重试 batch 接口。
|
||||
return nil
|
||||
}
|
||||
// 房间资料高频读取,头像框/资料卡/座驾/聊天气泡/当前佩戴徽章用 wallet 批量查;
|
||||
// activity 的 badge 投影再补展示槽位,两个来源合并后客户端才能同时看到长短徽章。
|
||||
// 房间资料高频读取,头像框/资料卡/座驾/聊天气泡/麦位图标/Voice Wave/当前佩戴徽章
|
||||
// 用 wallet 一次批量查;activity 的 badge 投影再补展示槽位,两个来源合并后客户端才能
|
||||
// 同时看到长短徽章,且麦位两层视觉不会被任一来源覆盖。
|
||||
appearanceMap := h.roomAppearanceResourceMap(request, userIDs)
|
||||
badgeMap := h.roomBadgeDisplayMap(request, userIDs)
|
||||
levelMap := h.roomLevelDisplayProfileMap(request, userIDs)
|
||||
@ -2525,6 +2527,7 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
|
||||
ProfileCard: appearance.ProfileCard,
|
||||
Vehicle: appearance.Vehicle,
|
||||
ChatBubble: appearance.ChatBubble,
|
||||
MicSeatIcon: appearance.MicSeatIcon,
|
||||
MicSeatAnimation: appearance.MicSeatAnimation,
|
||||
Charm: 0,
|
||||
}
|
||||
@ -2565,6 +2568,12 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
|
||||
profile.MicSeatAnimation = map[string]any{}
|
||||
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 {
|
||||
profile := profiles[userID]
|
||||
profile.ProfileCard = map[string]any{}
|
||||
@ -2579,6 +2588,7 @@ type roomAppearanceResources struct {
|
||||
ProfileCard map[string]any
|
||||
Vehicle map[string]any
|
||||
ChatBubble map[string]any
|
||||
MicSeatIcon map[string]any
|
||||
MicSeatAnimation 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()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
UserIds: userIDs,
|
||||
ResourceTypes: []string{roomAppearanceResourceAvatarFrame, roomAppearanceResourceProfileCard, roomAppearanceResourceVehicle, roomAppearanceResourceChatBubble, roomAppearanceResourceBadge, roomAppearanceResourceMicSeatAnimation},
|
||||
ResourceTypes: []string{roomAppearanceResourceAvatarFrame, roomAppearanceResourceProfileCard, roomAppearanceResourceVehicle, roomAppearanceResourceChatBubble, roomAppearanceResourceBadge, roomAppearanceResourceMicSeatIcon, roomAppearanceResourceMicSeatAnimation},
|
||||
})
|
||||
if err != nil {
|
||||
// 装扮是展示增强,失败不影响房间主数据返回。
|
||||
@ -2618,6 +2628,8 @@ func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int
|
||||
current.ChatBubble = resource
|
||||
case roomAppearanceResourceBadge:
|
||||
current.Badges = append(current.Badges, roomBadgeItemFromEquippedResource(resource))
|
||||
case roomAppearanceResourceMicSeatIcon:
|
||||
current.MicSeatIcon = resource
|
||||
case roomAppearanceResourceMicSeatAnimation:
|
||||
current.MicSeatAnimation = resource
|
||||
}
|
||||
|
||||
@ -220,6 +220,7 @@ type roomDisplayProfileData struct {
|
||||
ProfileCard map[string]any `json:"profile_card"`
|
||||
Vehicle map[string]any `json:"vehicle"`
|
||||
ChatBubble map[string]any `json:"chat_bubble"`
|
||||
MicSeatIcon map[string]any `json:"mic_seat_icon"`
|
||||
MicSeatAnimation map[string]any `json:"mic_seat_animation"`
|
||||
Charm int64 `json:"charm"`
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ const (
|
||||
appearanceResourceProfileCard = "profile_card"
|
||||
appearanceResourceVehicle = "vehicle"
|
||||
appearanceResourceBadge = "badge"
|
||||
appearanceResourceMicSeatIcon = "mic_seat_icon"
|
||||
appearanceResourceMicSeatAnimation = "mic_seat_animation"
|
||||
)
|
||||
|
||||
@ -32,6 +33,7 @@ type appearanceData struct {
|
||||
AvatarFrame map[string]any `json:"avatar_frame"`
|
||||
ProfileCard map[string]any `json:"profile_card"`
|
||||
Vehicle map[string]any `json:"vehicle"`
|
||||
MicSeatIcon map[string]any `json:"mic_seat_icon"`
|
||||
MicSeatAnimation map[string]any `json:"mic_seat_animation"`
|
||||
Level map[string]any `json:"level"`
|
||||
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{},
|
||||
ProfileCard: map[string]any{},
|
||||
Vehicle: map[string]any{},
|
||||
MicSeatIcon: map[string]any{},
|
||||
MicSeatAnimation: map[string]any{},
|
||||
Level: defaultAppearanceLevelDisplay(),
|
||||
LevelBadges: map[string]any{},
|
||||
@ -98,10 +101,11 @@ func (h *Handler) appearanceForUser(request *http.Request, userID int64) (appear
|
||||
// 1. wallet 内部会过滤过期佩戴;
|
||||
// 2. 后续扩展批量 appearance 时可以复用同一 RPC,不引入 N+1。
|
||||
equipped, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
UserIds: []int64{userID},
|
||||
ResourceTypes: []string{appearanceResourceAvatarFrame, appearanceResourceProfileCard, appearanceResourceVehicle, appearanceResourceBadge, appearanceResourceMicSeatAnimation},
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
UserIds: []int64{userID},
|
||||
// 图标和声波同时查询并分别投影,客户端替换麦位中心图标时不会覆盖 Voice Wave。
|
||||
ResourceTypes: []string{appearanceResourceAvatarFrame, appearanceResourceProfileCard, appearanceResourceVehicle, appearanceResourceBadge, appearanceResourceMicSeatIcon, appearanceResourceMicSeatAnimation},
|
||||
})
|
||||
if err != nil {
|
||||
return data, err
|
||||
@ -124,6 +128,8 @@ func (h *Handler) appearanceForUser(request *http.Request, userID int64) (appear
|
||||
data.Vehicle = resource
|
||||
case appearanceResourceBadge:
|
||||
data.EquippedBadges = append(data.EquippedBadges, resource)
|
||||
case appearanceResourceMicSeatIcon:
|
||||
data.MicSeatIcon = resource
|
||||
case appearanceResourceMicSeatAnimation:
|
||||
data.MicSeatAnimation = resource
|
||||
}
|
||||
|
||||
@ -2351,7 +2351,7 @@ SELECT
|
||||
CASE WHEN benefits.benefit_code = 'daily_coin_rebate' THEN 'disabled' ELSE 'active' END,
|
||||
benefits.trial_enabled, 0,
|
||||
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 'colored_room_name' THEN 'room_name_style'
|
||||
ELSE ''
|
||||
@ -2460,7 +2460,7 @@ SET @vip_resource_contract_now_ms := CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 100
|
||||
UPDATE vip_level_benefits
|
||||
SET benefit_type = 'decoration',
|
||||
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 'colored_room_name' THEN 'room_name_style'
|
||||
END,
|
||||
@ -2470,7 +2470,7 @@ WHERE app_code = 'fami'
|
||||
AND (
|
||||
benefit_type <> 'decoration'
|
||||
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 'colored_room_name' THEN 'room_name_style'
|
||||
END
|
||||
@ -2482,7 +2482,7 @@ LEFT JOIN resources AS resource
|
||||
AND resource.resource_id = benefit.resource_id
|
||||
AND resource.status = 'active'
|
||||
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 'colored_room_name' THEN 'room_name_style'
|
||||
END
|
||||
@ -2493,7 +2493,7 @@ WHERE benefit.app_code = 'fami'
|
||||
AND benefit.resource_id > 0
|
||||
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,不生成占位素材。
|
||||
UPDATE vip_level_benefits AS benefit
|
||||
JOIN (
|
||||
@ -2506,7 +2506,7 @@ JOIN (
|
||||
JOIN resources AS resource
|
||||
ON resource.app_code = group_item.app_code
|
||||
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'
|
||||
WHERE resource_group.app_code = 'fami'
|
||||
AND resource_group.status = 'active'
|
||||
@ -2516,7 +2516,7 @@ JOIN (
|
||||
) AS mapping
|
||||
ON mapping.group_code = CONCAT('VIP', benefit.level)
|
||||
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
|
||||
WHERE benefit.app_code = 'fami'
|
||||
AND benefit.level BETWEEN 4 AND 8
|
||||
@ -2524,13 +2524,13 @@ WHERE benefit.app_code = 'fami'
|
||||
AND benefit.status = 'active'
|
||||
AND benefit.resource_id = 0;
|
||||
|
||||
-- 只给 active mic_skin 已绑定的真实麦座动画补 format;优先采用合法 animation_format,
|
||||
-- 只给 active mic_skin 已绑定的真实麦位中心图标补 format;优先采用合法 animation_format,
|
||||
-- 否则只从实际 animation/asset URL 的明确后缀推导,jpg 统一写成 jpeg。
|
||||
UPDATE vip_level_benefits AS benefit
|
||||
JOIN resources AS resource FORCE INDEX (PRIMARY)
|
||||
ON resource.resource_id = benefit.resource_id
|
||||
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'
|
||||
SET resource.metadata_json = JSON_SET(
|
||||
COALESCE(resource.metadata_json, JSON_OBJECT()),
|
||||
@ -2555,7 +2555,7 @@ SET resource.metadata_json = JSON_SET(
|
||||
WHERE benefit.app_code = 'fami'
|
||||
AND benefit.benefit_code = 'mic_skin'
|
||||
AND benefit.status = 'active'
|
||||
AND benefit.resource_type = 'mic_seat_animation'
|
||||
AND benefit.resource_type = 'mic_seat_icon'
|
||||
AND benefit.resource_id > 0
|
||||
AND (TRIM(resource.asset_url) <> '' OR TRIM(resource.animation_url) <> '')
|
||||
AND TRIM(resource.preview_url) <> ''
|
||||
@ -2570,7 +2570,7 @@ WHERE benefit.app_code = 'fami'
|
||||
REGEXP '\\.(png|jpeg|jpg|webp|gif|svga|pag|mp4)$'
|
||||
);
|
||||
|
||||
-- active 装扮必须能联结到同 App、active、精确类型的真实资源。麦座动画和房间边框还
|
||||
-- active 装扮必须能联结到同 App、active、精确类型的真实资源。麦位中心图标和房间边框还
|
||||
-- 必须具备素材、preview、合法 format,并保持可选 animation_format 与 format 一致。
|
||||
UPDATE vip_level_benefits AS benefit
|
||||
LEFT JOIN resources AS resource
|
||||
@ -2586,7 +2586,7 @@ WHERE benefit.app_code = 'fami'
|
||||
AND (
|
||||
resource.resource_id IS NULL
|
||||
OR (
|
||||
benefit.resource_type IN ('mic_seat_animation', 'room_border')
|
||||
benefit.resource_type IN ('mic_seat_icon', 'room_border')
|
||||
AND (
|
||||
(TRIM(resource.asset_url) = '' AND TRIM(resource.animation_url) = '')
|
||||
OR TRIM(resource.preview_url) = ''
|
||||
|
||||
@ -71,7 +71,7 @@ JOIN vip_program_configs AS p ON p.app_code = b.app_code;
|
||||
UPDATE vip_level_benefits
|
||||
SET benefit_type = 'decoration',
|
||||
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 'colored_room_name' THEN 'room_name_style'
|
||||
END,
|
||||
@ -81,7 +81,7 @@ WHERE app_code = 'fami'
|
||||
AND (
|
||||
benefit_type <> 'decoration'
|
||||
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 'colored_room_name' THEN 'room_name_style'
|
||||
END
|
||||
@ -95,7 +95,7 @@ LEFT JOIN resources AS resource
|
||||
AND resource.resource_id = benefit.resource_id
|
||||
AND resource.status = 'active'
|
||||
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 'colored_room_name' THEN 'room_name_style'
|
||||
END
|
||||
@ -108,7 +108,7 @@ WHERE benefit.app_code = 'fami'
|
||||
|
||||
SET @vip_invalid_resource_clear_count := ROW_COUNT();
|
||||
|
||||
-- 只绑定资源组中唯一的 active 麦位动画。缺少真实目录资源的 VIP9、房间边框和房名样式保持未配置。
|
||||
-- 只绑定资源组中唯一的 active 麦位中心图标。缺少真实目录资源的 VIP9、房间边框和房名样式保持未配置。
|
||||
UPDATE vip_level_benefits AS benefit
|
||||
JOIN (
|
||||
SELECT resource_group.group_code, MIN(resource.resource_id) AS resource_id
|
||||
@ -120,7 +120,7 @@ JOIN (
|
||||
JOIN resources AS resource
|
||||
ON resource.app_code = group_item.app_code
|
||||
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'
|
||||
WHERE resource_group.app_code = 'fami'
|
||||
AND resource_group.status = 'active'
|
||||
@ -130,7 +130,7 @@ JOIN (
|
||||
) AS mapping
|
||||
ON mapping.group_code = CONCAT('VIP', benefit.level)
|
||||
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
|
||||
WHERE benefit.app_code = 'fami'
|
||||
AND benefit.level BETWEEN 4 AND 8
|
||||
@ -140,13 +140,13 @@ WHERE benefit.app_code = 'fami'
|
||||
|
||||
SET @vip_mic_skin_bind_count := ROW_COUNT();
|
||||
|
||||
-- 只处理 active mic_skin 已绑定的真实麦座动画:合法 animation_format 优先;缺省时仅从
|
||||
-- 只处理 active mic_skin 已绑定的真实麦位中心图标:合法 animation_format 优先;缺省时仅从
|
||||
-- animation/asset URL 的明确后缀推导 format。权益矩阵很小,资源表始终按主键点查。
|
||||
UPDATE vip_level_benefits AS benefit
|
||||
JOIN resources AS resource FORCE INDEX (PRIMARY)
|
||||
ON resource.resource_id = benefit.resource_id
|
||||
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'
|
||||
SET resource.metadata_json = JSON_SET(
|
||||
COALESCE(resource.metadata_json, JSON_OBJECT()),
|
||||
@ -171,7 +171,7 @@ SET resource.metadata_json = JSON_SET(
|
||||
WHERE benefit.app_code = 'fami'
|
||||
AND benefit.benefit_code = 'mic_skin'
|
||||
AND benefit.status = 'active'
|
||||
AND benefit.resource_type = 'mic_seat_animation'
|
||||
AND benefit.resource_type = 'mic_seat_icon'
|
||||
AND benefit.resource_id > 0
|
||||
AND (TRIM(resource.asset_url) <> '' OR TRIM(resource.animation_url) <> '')
|
||||
AND TRIM(resource.preview_url) <> ''
|
||||
@ -202,7 +202,7 @@ WHERE benefit.app_code = 'fami'
|
||||
AND (
|
||||
resource.resource_id IS NULL
|
||||
OR (
|
||||
benefit.resource_type IN ('mic_seat_animation', 'room_border')
|
||||
benefit.resource_type IN ('mic_seat_icon', 'room_border')
|
||||
AND (
|
||||
(TRIM(resource.asset_url) = '' AND TRIM(resource.animation_url) = '')
|
||||
OR TRIM(resource.preview_url) = ''
|
||||
|
||||
@ -6745,8 +6745,9 @@ func TestRevokeResourceGrantRejectsUnsupportedRecords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantMicSeatAnimationAutoEquips 验证麦位声波资源在发放事务内默认佩戴。
|
||||
func TestGrantMicSeatAnimationAutoEquips(t *testing.T) {
|
||||
// TestMicSeatIconAndAnimationUseIndependentEquipmentSlots 验证 Voice Wave 默认佩戴后,
|
||||
// 再佩戴麦位中心图标不会覆盖声波;两个 resource_type 必须各保留一个当前装备。
|
||||
func TestMicSeatIconAndAnimationUseIndependentEquipmentSlots(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
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 {
|
||||
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 锁定道具商店购买的原子语义。
|
||||
|
||||
@ -1021,9 +1021,10 @@ func (r *Repository) pruneExpiredUserResourceEquipment(ctx context.Context, user
|
||||
|
||||
func isEquipableResourceType(resourceType string) bool {
|
||||
switch resourcedomain.NormalizeResourceType(resourceType) {
|
||||
// 飘窗与头像框等用户装扮保持同一资源类型单选槽,切换时事务会替换同类型旧装备;
|
||||
// badge 的多选和 VIP 体验卡的专用状态机仍由各自分支处理。
|
||||
case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge, resourcedomain.TypeFloatingScreen, resourcedomain.TypeMicSeatAnimation, resourcedomain.TypeVIPTrialCard:
|
||||
// 麦位中心图标和声波动效必须使用两个独立的 resource_type 单选槽;用户切换 mic_seat_icon
|
||||
// 时只替换旧图标,不能删除 mic_seat_animation。badge 的多选和 VIP 体验卡的专用状态机
|
||||
// 仍由各自分支处理。
|
||||
case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge, resourcedomain.TypeFloatingScreen, resourcedomain.TypeMicSeatIcon, resourcedomain.TypeMicSeatAnimation, resourcedomain.TypeVIPTrialCard:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
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) {
|
||||
t.Fatalf("%s resources must use the equipment slot just like other profile decorations", resourceType)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user