From 838a76f0ccab9dc5b57e4467b0836d11fbc84576 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 22 Jul 2026 14:24:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=BA=A6=E5=85=8B=E9=A3=8E?= =?UTF-8?q?=E7=9A=AE=E8=82=A4=E7=9A=84=E8=A3=85=E6=89=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Fami_VIP_Flutter对接方案.md | 4 + .../Fami_VIP五阶段Flutter接口对接.md | 103 +++++++++++------- docs/flutter对接/房内贡献榜Flutter对接.md | 7 +- docs/flutter对接/背包接口Flutter对接.md | 8 +- docs/openapi/gateway.swagger.yaml | 17 ++- docs/新版VIP权限策略_Flutter客户端对接文档.md | 1 + docs/用户装扮Flutter对接文档.md | 54 +++++++-- docs/资源组与礼物架构.md | 2 +- .../069_vip_resource_equipment_contract.sql | 24 ++-- .../internal/transport/http/response_test.go | 59 ++++++++-- .../http/roomapi/gift_panel_pipeline.go | 1 + .../transport/http/roomapi/room_handler.go | 18 ++- .../transport/http/roomapi/room_view.go | 1 + .../http/userapi/appearance_handler.go | 14 ++- .../mysql/initdb/001_wallet_service.sql | 24 ++-- .../003_vip_resource_equipment_contract.sql | 20 ++-- .../internal/service/wallet/service_test.go | 55 +++++++++- .../mysql/resource_entitlement_repository.go | 7 +- .../storage/mysql/resource_repository_test.go | 2 +- 19 files changed, 311 insertions(+), 110 deletions(-) diff --git a/docs/Fami_VIP_Flutter对接方案.md b/docs/Fami_VIP_Flutter对接方案.md index 59e693cc..50d4f842 100644 --- a/docs/Fami_VIP_Flutter对接方案.md +++ b/docs/Fami_VIP_Flutter对接方案.md @@ -154,6 +154,10 @@ final benefitHandlers = { 展示等级详情时直接循环该套餐的 `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`。 diff --git a/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md b/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md index d4502353..48981c14 100644 --- a/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md +++ b/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md @@ -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:当前背景不变化。 diff --git a/docs/flutter对接/房内贡献榜Flutter对接.md b/docs/flutter对接/房内贡献榜Flutter对接.md index 8ea83c3f..062ec200 100644 --- a/docs/flutter对接/房内贡献榜Flutter对接.md +++ b/docs/flutter对接/房内贡献榜Flutter对接.md @@ -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 | 魅力值。 | ## 错误码 diff --git a/docs/flutter对接/背包接口Flutter对接.md b/docs/flutter对接/背包接口Flutter对接.md index 6e6124d6..2e8f8612 100644 --- a/docs/flutter对接/背包接口Flutter对接.md +++ b/docs/flutter对接/背包接口Flutter对接.md @@ -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` | 场景 | diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index df39f435..99e24380 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -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 diff --git a/docs/新版VIP权限策略_Flutter客户端对接文档.md b/docs/新版VIP权限策略_Flutter客户端对接文档.md index caf0df09..e49243c8 100644 --- a/docs/新版VIP权限策略_Flutter客户端对接文档.md +++ b/docs/新版VIP权限策略_Flutter客户端对接文档.md @@ -1242,6 +1242,7 @@ class VipEntitlementState { "avatar_frame": {}, "profile_card": {}, "vehicle": {}, + "mic_seat_icon": {}, "mic_seat_animation": {}, "equipped_badges": [], "badges": [], diff --git a/docs/用户装扮Flutter对接文档.md b/docs/用户装扮Flutter对接文档.md index b583af29..091ed15d 100644 --- a/docs/用户装扮Flutter对接文档.md +++ b/docs/用户装扮Flutter对接文档.md @@ -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` 播放入场座驾。 diff --git a/docs/资源组与礼物架构.md b/docs/资源组与礼物架构.md index 4a1b9a6e..8f4132f2 100644 --- a/docs/资源组与礼物架构.md +++ b/docs/资源组与礼物架构.md @@ -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,不承载头像框。 diff --git a/scripts/mysql/069_vip_resource_equipment_contract.sql b/scripts/mysql/069_vip_resource_equipment_contract.sql index 1c43c7b7..a6590583 100644 --- a/scripts/mysql/069_vip_resource_equipment_contract.sql +++ b/scripts/mysql/069_vip_resource_equipment_contract.sql @@ -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) = '' diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index ee2ec2a7..c750f6f6 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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) } } diff --git a/services/gateway-service/internal/transport/http/roomapi/gift_panel_pipeline.go b/services/gateway-service/internal/transport/http/roomapi/gift_panel_pipeline.go index dd821330..f0690c6f 100644 --- a/services/gateway-service/internal/transport/http/roomapi/gift_panel_pipeline.go +++ b/services/gateway-service/internal/transport/http/roomapi/gift_panel_pipeline.go @@ -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, } diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index d2dd7370..df1fcb8d 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -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 } diff --git a/services/gateway-service/internal/transport/http/roomapi/room_view.go b/services/gateway-service/internal/transport/http/roomapi/room_view.go index fce5cf2d..01c154ed 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -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"` } diff --git a/services/gateway-service/internal/transport/http/userapi/appearance_handler.go b/services/gateway-service/internal/transport/http/userapi/appearance_handler.go index 87b24c26..5226b303 100644 --- a/services/gateway-service/internal/transport/http/userapi/appearance_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/appearance_handler.go @@ -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 } diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 403906cc..0134e9fd 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -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) = '' diff --git a/services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql b/services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql index 66b3c669..14038c0d 100644 --- a/services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql +++ b/services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql @@ -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) = '' diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index cb1066a9..8692e6fa 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -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 锁定道具商店购买的原子语义。 diff --git a/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go b/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go index e762bf41..1cf68a23 100644 --- a/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go @@ -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 diff --git a/services/wallet-service/internal/storage/mysql/resource_repository_test.go b/services/wallet-service/internal/storage/mysql/resource_repository_test.go index 8a981b45..1373eebc 100644 --- a/services/wallet-service/internal/storage/mysql/resource_repository_test.go +++ b/services/wallet-service/internal/storage/mysql/resource_repository_test.go @@ -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) }