diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index fea28b2e..ec36bc78 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -18288,7 +18288,7 @@ func (x *VipTrialCard) GetUpdatedAtMs() int64 { } // VipUserSettings 是用户按 App 隔离的可关闭 VIP 功能偏好。没有持久记录时 wallet -// 仍返回五个默认开启值,updated_at_ms=0;设置本身不授予任何 VIP 权益。 +// 默认开启进房/上线通知、关闭三项隐私能力,updated_at_ms=0;设置本身不授予任何 VIP 权益。 type VipUserSettings struct { state protoimpl.MessageState `protogen:"open.v1"` AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index 9ab3e548..399c0719 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -2119,7 +2119,7 @@ message VipTrialCard { } // VipUserSettings 是用户按 App 隔离的可关闭 VIP 功能偏好。没有持久记录时 wallet -// 仍返回五个默认开启值,updated_at_ms=0;设置本身不授予任何 VIP 权益。 +// 默认开启进房/上线通知、关闭三项隐私能力,updated_at_ms=0;设置本身不授予任何 VIP 权益。 message VipUserSettings { string app_code = 1; int64 user_id = 2; diff --git a/docs/Fami_VIP_Flutter对接方案.md b/docs/Fami_VIP_Flutter对接方案.md index 8e787fda..59e693cc 100644 --- a/docs/Fami_VIP_Flutter对接方案.md +++ b/docs/Fami_VIP_Flutter对接方案.md @@ -5,7 +5,7 @@ ## 0. 当前稳定契约 - `GET /vip/me`、`GET /vip/packages`、购买、佩戴体验卡、卸下体验卡的 `state` 完全同构,固定包含 `paid_vip`、`equipped_trial_card`、`effective_vip`、`effective_source`、`effective_benefits`、`evaluated_at_ms`、完整 `program_config`、完整 `user_settings`。写成功后整体替换 store,禁止局部合并。 -- `user_settings` 固定包含 5 项开关:进房通知、上线全服通知、隐藏个人数据、匿名访问主页、榜单隐身;均按 App 隔离、无记录默认开启。 +- `user_settings` 固定包含 5 项开关:进房通知、上线全服通知、隐藏个人数据、匿名访问主页、榜单隐身;均按 App 隔离,无记录时前两项通知默认开启、后三项隐私能力默认关闭。 - 权益富展示读取结构化 `benefits[].presentation`。它覆盖说明文案、多项静态/动态预览、用户头像合成方式和数值奖励;Flutter 不解析 `metadata_json` 生成 UI。 - VIP 购买后的余额读取带 `version` 的 `coin_balance`;`coin_balance_after` 仅兼容旧客户端。历史幂等回放若 `version=0`,必须重新请求钱包余额。 - 业务分支只判断稳定 `code`,不得解析 `message`。精确 JSON、`presentation` Schema 和错误码矩阵以 [新版 VIP 权限策略 Flutter 客户端对接文档](./新版VIP权限策略_Flutter客户端对接文档.md) 为唯一协议定义;本文后续较短 JSON 只解释场景,不定义裁剪响应。 @@ -340,9 +340,9 @@ final benefitHandlers = { "user_id": 10001, "room_entry_notice_enabled": true, "online_global_notice_enabled": true, - "hide_profile_data_enabled": true, - "anonymous_profile_visit_enabled": true, - "leaderboard_invisible_enabled": true, + "hide_profile_data_enabled": false, + "anonymous_profile_visit_enabled": false, + "leaderboard_invisible_enabled": false, "updated_at_ms": 0 } } @@ -468,9 +468,9 @@ final benefitHandlers = { "user_id": 10001, "room_entry_notice_enabled": true, "online_global_notice_enabled": true, - "hide_profile_data_enabled": true, - "anonymous_profile_visit_enabled": true, - "leaderboard_invisible_enabled": true, + "hide_profile_data_enabled": false, + "anonymous_profile_visit_enabled": false, + "leaderboard_invisible_enabled": false, "updated_at_ms": 0 } } @@ -492,7 +492,7 @@ final benefitHandlers = { - 当体验卡被佩戴时,不限制卡等级高低,它会覆盖付费 VIP 成为 `effective_vip`;卸下或过期后自然回落到仍有效的 `paid_vip`。 - 体验卡状态下,`daily_coin_rebate` 不会进入 `effective_benefits`。Flutter 不要只看卡等级猜测权益。 - 无 VIP 时,`effective_source="none"`、`effective_vip.active=false`、`effective_benefits=[]`。 -- `user_settings.updated_at_ms=0` 表示用户尚未修改,5 个开关使用默认开启值;设置在无 VIP 时也保留,但不会单独授权。 +- `user_settings.updated_at_ms=0` 表示用户尚未修改;进房/上线通知默认为 `true`,隐藏个人数据/匿名访问/榜单隐身默认为 `false`。设置在无 VIP 时也保留,但不会单独授权。 ## 8. 购买、续期和升级 @@ -580,9 +580,9 @@ final benefitHandlers = { "user_id": 10001, "room_entry_notice_enabled": true, "online_global_notice_enabled": true, - "hide_profile_data_enabled": true, - "anonymous_profile_visit_enabled": true, - "leaderboard_invisible_enabled": true, + "hide_profile_data_enabled": false, + "anonymous_profile_visit_enabled": false, + "leaderboard_invisible_enabled": false, "updated_at_ms": 0 } } @@ -784,9 +784,9 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不 "user_id": 10001, "room_entry_notice_enabled": true, "online_global_notice_enabled": true, - "hide_profile_data_enabled": true, - "anonymous_profile_visit_enabled": true, - "leaderboard_invisible_enabled": true, + "hide_profile_data_enabled": false, + "anonymous_profile_visit_enabled": false, + "leaderboard_invisible_enabled": false, "updated_at_ms": 0 } }, @@ -884,9 +884,9 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不 "user_id": 10001, "room_entry_notice_enabled": true, "online_global_notice_enabled": true, - "hide_profile_data_enabled": true, - "anonymous_profile_visit_enabled": true, - "leaderboard_invisible_enabled": true, + "hide_profile_data_enabled": false, + "anonymous_profile_visit_enabled": false, + "leaderboard_invisible_enabled": false, "updated_at_ms": 0 } }, @@ -1070,6 +1070,8 @@ Fami(`trial_card`)发一张 30 天体验卡到背包,不自动佩戴: `GET /api/v1/vip/me` 的 `data.state.user_settings` 与 GET 接口是同一份事实。VIP 页面已经拉取 `/vip/me` 时,不必再为展示开关多发一次请求。 +`GET /api/v1/users/me/overview` 也在 `data.vip.user_settings` 返回同一份完整设置,它复用聚合接口已有的 `GetMyVip` 回包,不会增加 wallet RPC。该字段只属于当前用户私有信息,查看他人的公开 profile 不返回。 + ### 参数 GET 无参数。PATCH 是局部更新,5 个字段至少传一个;显式 `false` 不能被当成未传: @@ -1134,7 +1136,7 @@ PATCH: } ``` -用户从未修改过时,5 项均默认为 `true`,`updated_at_ms=0`。PATCH 只改本次提交的字段,不会覆盖其他项。 +用户从未修改过时,进房/上线通知默认为 `true`,隐藏个人数据/匿名访问/榜单隐身默认为 `false`,`updated_at_ms=0`。PATCH 只改本次提交的字段,不会覆盖其他项。 ### 相关 IM @@ -1426,7 +1428,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有 | 同级续期 | 不在本地计算,以响应为准 | 已强制,从旧截止时间累加 | | 体验卡自由切换 | 按 `entitlement_id` 佩戴,每次直接替换全局 `state` | 已强制,不限等级,不作废旧卡,不改绝对截止时间 | | 体验卡权益 | 只渲染 `effective_benefits` 资格 | 已强制过滤 `trial_enabled=false`,金币返现不给体验卡 | -| VIP 功能开关 | 读 `state.user_settings`,用 PATCH 局部修改 | 已按 App 持久化,无记录默认 5 项开启,开关不单独授权 | +| VIP 功能开关 | 读 `state.user_settings`,用 PATCH 局部修改 | 已按 App 持久化,无记录时通知开、隐私关,开关不单独授权 | | 进房高亮通知 | 本人首屏读 Join HTTP `effective_vip`,其他成员消费当前房间 `room_user_joined` | 后端已同时校验权益与用户开关,并透传 Join HTTP 和当前房间 IM;不发全服 | | 防踢 | 房管操作返回 `VIP_ANTI_KICK` 时显示不可踢,不做本地预判 | 已在 room-service 强制;只阻止普通房主/管理员的 KickUser | | 防禁言 | 房管操作返回 `VIP_ANTI_MUTE` 时显示不可禁言,解禁始终可发起 | 已在 room-service 强制;只阻止普通房主/管理员新增禁言 | diff --git a/docs/VIP系统架构.md b/docs/VIP系统架构.md index 77254a7a..4cf6b9b5 100644 --- a/docs/VIP系统架构.md +++ b/docs/VIP系统架构.md @@ -93,7 +93,7 @@ Fami 的 VIP1–VIP9 初始全部为 `disabled`。30 天只是不产生零时长 - `anonymous_profile_visit_enabled`:匿名访问主页; - `leaderboard_invisible_enabled`:榜单隐身。 -无记录时 5 项默认为 `true`,首次修改才写行。偏好不授予权益:无 VIP 或对应等级没有权益时,即使开关为开也不会放行。用户可在无 VIP、VIP 过期或切换体验卡时保留偏好;Fami 与 Lalu 互不影响。 +无记录时,进房/上线通知默认为 `true`,隐藏个人数据/匿名访问/榜单隐身默认为 `false`,首次修改才写行。偏好不授予权益:无 VIP 或对应等级没有权益时,即使开关为开也不会放行。用户可在无 VIP、VIP 过期或切换体验卡时保留偏好;Fami 与 Lalu 互不影响。 ### 权益展示投影 diff --git a/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md b/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md index e23c2b29..d4502353 100644 --- a/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md +++ b/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md @@ -282,9 +282,9 @@ Authorization: Bearer {access_token} "user_id": 10001, "room_entry_notice_enabled": true, "online_global_notice_enabled": true, - "hide_profile_data_enabled": true, - "anonymous_profile_visit_enabled": true, - "leaderboard_invisible_enabled": true, + "hide_profile_data_enabled": false, + "anonymous_profile_visit_enabled": false, + "leaderboard_invisible_enabled": false, "updated_at_ms": 0 } } diff --git a/docs/flutter对接/进房接口Flutter对接.md b/docs/flutter对接/进房接口Flutter对接.md index c81572d9..71ce5365 100644 --- a/docs/flutter对接/进房接口Flutter对接.md +++ b/docs/flutter对接/进房接口Flutter对接.md @@ -191,6 +191,7 @@ Flutter 建议放在 body 里,避免调用层漏拼 query。 | `profiles[].avatar_frame` | 头像框后置拉。 | | `profiles[].profile_card` | 资料卡后置拉。 | | `profiles[].vehicle` | 座驾后置拉。 | +| `profiles[].chat_bubble` | 聊天气泡后置拉。 | | `profiles[].mic_seat_animation` | 麦位动画后置拉。 | | `profiles[].badges` | 等级/成就徽章后置拉。 | | `profiles[].vip` | VIP 展示后置拉。 | @@ -249,6 +250,7 @@ Authorization: Bearer "avatar_frame": {}, "profile_card": {}, "vehicle": {}, + "chat_bubble": {}, "mic_seat_animation": {}, "badges": [], "vip": {}, diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index 5c2b5cff..54ca480f 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -790,7 +790,7 @@ paths: - users summary: 我的页首屏聚合摘要 operationId: getMyOverview - description: 聚合当前用户资料、顶部计数、金币钱包卡片、VIP、Host/Agency/BD/币商身份和入口显隐。该接口不查消息红点、邀请码、钻石、美元余额、流水、成员或工资;钱包、VIP、计数和角色上游失败时按字段 `unavailable=true` 局部降级。 + description: 聚合当前用户资料、顶部计数、金币钱包卡片、VIP、Host/Agency/BD/币商身份和入口显隐。`data.vip.user_settings` 复用本次 VIP 状态查询,不另外请求 wallet,且只在当前用户私有接口返回。该接口不查消息红点、邀请码、钻石、美元余额、流水、成员或工资;钱包、VIP、计数和角色上游失败时按字段 `unavailable=true` 局部降级。 security: - BearerAuth: [] parameters: @@ -3782,6 +3782,8 @@ definitions: description: 钱包上游不可用时为 true。 MyOverviewVIPData: type: object + required: + - user_settings properties: level: type: integer @@ -3796,6 +3798,9 @@ definitions: expires_at_ms: type: integer format: int64 + user_settings: + $ref: "#/definitions/VIPUserSettingsData" + description: 当前 App 的完整 VIP 用户偏好;它不会出现在他人公开资料中。 unavailable: type: boolean description: VIP 上游不可用时为 true。 @@ -6045,6 +6050,9 @@ definitions: $ref: "#/definitions/AppearanceResourceData" vehicle: $ref: "#/definitions/AppearanceResourceData" + chat_bubble: + $ref: "#/definitions/AppearanceResourceData" + description: 当前聊天气泡佩戴快照;空对象表示未佩戴,供公屏按发送人资料渲染。 mic_seat_animation: $ref: "#/definitions/AppearanceResourceData" description: 当前麦位动效佩戴快照;空对象表示未佩戴,format 缺省表示后台没有配置受支持 renderer。 @@ -7281,14 +7289,24 @@ definitions: format: int64 room_entry_notice_enabled: type: boolean + default: true + description: 无持久化记录时默认开启。 online_global_notice_enabled: type: boolean + default: true + description: 无持久化记录时默认开启。 hide_profile_data_enabled: type: boolean + default: false + description: 无持久化记录时默认关闭。 anonymous_profile_visit_enabled: type: boolean + default: false + description: 无持久化记录时默认关闭。 leaderboard_invisible_enabled: type: boolean + default: false + description: 无持久化记录时默认关闭。 updated_at_ms: type: integer format: int64 diff --git a/docs/新版VIP权限策略_Flutter客户端对接文档.md b/docs/新版VIP权限策略_Flutter客户端对接文档.md index 959a54f1..caf0df09 100644 --- a/docs/新版VIP权限策略_Flutter客户端对接文档.md +++ b/docs/新版VIP权限策略_Flutter客户端对接文档.md @@ -291,9 +291,9 @@ class VipEntitlementState { "user_id": 10001, "room_entry_notice_enabled": true, "online_global_notice_enabled": true, - "hide_profile_data_enabled": true, - "anonymous_profile_visit_enabled": true, - "leaderboard_invisible_enabled": true, + "hide_profile_data_enabled": false, + "anonymous_profile_visit_enabled": false, + "leaderboard_invisible_enabled": false, "updated_at_ms": 0 } } @@ -416,9 +416,9 @@ class VipEntitlementState { "user_id": 10001, "room_entry_notice_enabled": true, "online_global_notice_enabled": true, - "hide_profile_data_enabled": true, - "anonymous_profile_visit_enabled": true, - "leaderboard_invisible_enabled": true, + "hide_profile_data_enabled": false, + "anonymous_profile_visit_enabled": false, + "leaderboard_invisible_enabled": false, "updated_at_ms": 0 } } @@ -542,9 +542,9 @@ class VipEntitlementState { "user_id": 10001, "room_entry_notice_enabled": true, "online_global_notice_enabled": true, - "hide_profile_data_enabled": true, - "anonymous_profile_visit_enabled": true, - "leaderboard_invisible_enabled": true, + "hide_profile_data_enabled": false, + "anonymous_profile_visit_enabled": false, + "leaderboard_invisible_enabled": false, "updated_at_ms": 0 } } @@ -728,9 +728,9 @@ class VipEntitlementState { "user_id": 10001, "room_entry_notice_enabled": true, "online_global_notice_enabled": true, - "hide_profile_data_enabled": true, - "anonymous_profile_visit_enabled": true, - "leaderboard_invisible_enabled": true, + "hide_profile_data_enabled": false, + "anonymous_profile_visit_enabled": false, + "leaderboard_invisible_enabled": false, "updated_at_ms": 0 } }, @@ -816,9 +816,9 @@ class VipEntitlementState { "user_id": 10001, "room_entry_notice_enabled": true, "online_global_notice_enabled": true, - "hide_profile_data_enabled": true, - "anonymous_profile_visit_enabled": true, - "leaderboard_invisible_enabled": true, + "hide_profile_data_enabled": false, + "anonymous_profile_visit_enabled": false, + "leaderboard_invisible_enabled": false, "updated_at_ms": 0 } }, @@ -910,7 +910,7 @@ class VipEntitlementState { } ``` -5 个字段都使用 PATCH 语义:不传表示保持原值,显式 `false` 表示关闭。没有持久记录时均默认 `true`;设置只限制用户主动启用对应效果,不会单独授予 VIP 权益。服务端执行时使用“当前有效权益 AND 用户设置”判定,Flutter 不能只看开关。 +5 个字段都使用 PATCH 语义:不传表示保持原值,显式 `false` 表示关闭。没有持久记录时,进房/上线通知默认 `true`,隐藏个人数据/匿名访问/榜单隐身默认 `false`;设置只限制用户主动启用对应效果,不会单独授予 VIP 权益。服务端执行时使用“当前有效权益 AND 用户设置”判定,Flutter 不能只看开关。 ## 8. 每日 VIP 金币返现 diff --git a/docs/用户装扮Flutter对接文档.md b/docs/用户装扮Flutter对接文档.md index ee8789f6..5995884e 100644 --- a/docs/用户装扮Flutter对接文档.md +++ b/docs/用户装扮Flutter对接文档.md @@ -371,6 +371,18 @@ GET /api/v1/users/room-display-profiles:batch?user_ids=42,43 "entitlement_id": "ent-vehicle", "expires_at_ms": 1999999999999 }, + "chat_bubble": { + "resource_id": 8103, + "resource_code": "bubble_gold", + "resource_type": "chat_bubble", + "name": "Gold Chat Bubble", + "asset_url": "https://cdn.example/chat-bubble.png", + "preview_url": "", + "animation_url": "", + "metadata_json": "{}", + "entitlement_id": "ent-chat-bubble", + "expires_at_ms": 1999999999999 + }, "mic_seat_animation": { "resource_id": 8106, "resource_code": "mic_seat_wave_gold", @@ -394,6 +406,7 @@ GET /api/v1/users/room-display-profiles:batch?user_ids=42,43 - 房间、公屏、在线列表展示头像框用 `avatar_frame`。 - 房间资料卡展示用 `profile_card`,从 `metadata_json.profile_card_layout` 解析 `content_top` 和 `content_height`。 - 入场座驾展示用 `vehicle`。 +- 公屏聊天气泡用 `chat_bubble`;未佩戴时固定返回空对象,普通文本消息按发送人 ID 批量补齐该字段。 - 麦位声波展示用 `mic_seat_animation`。 - 徽章条展示用 `badges`。 - 过期佩戴不会返回。 @@ -524,5 +537,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`、`badges`。 +6. 房间、公屏、在线用户资料优先使用 `room-display-profiles:batch` 的 `avatar_frame`、`profile_card`、`vehicle`、`chat_bubble`、`badges`。 7. 收到 `room_user_joined` IM 时,只按 `entry_vehicle` 播放入场座驾。 diff --git a/docs/语音房客户端接口流程.md b/docs/语音房客户端接口流程.md index ed53274a..436b5a5f 100644 --- a/docs/语音房客户端接口流程.md +++ b/docs/语音房客户端接口流程.md @@ -382,6 +382,7 @@ Response `data`: "metadata_json": "{\"profile_card_layout\":{\"source_height\":1680,\"content_top\":143,\"content_height\":1525}}" }, "vehicle": {}, + "chat_bubble": {}, "charm": 10 } ] @@ -391,7 +392,7 @@ Response `data`: Rules: - 该接口专门服务房间麦位、公屏和礼物动效展示;不要复用“我的页”或用户主页重接口。 -- 当前已接入用户基础资料,`vip/level/badges/avatar_frame/profile_card/vehicle/charm` 保持稳定字段;资料卡高度从 `profile_card.metadata_json.profile_card_layout` 解析。 +- 当前已接入用户基础资料,`vip/level/badges/avatar_frame/profile_card/vehicle/chat_bubble/charm` 保持稳定字段;资料卡高度从 `profile_card.metadata_json.profile_card_layout` 解析,公屏气泡按发送人资料里的 `chat_bubble` 渲染。 ### IM UserSig diff --git a/scripts/apply-local-mysql-initdb.sh b/scripts/apply-local-mysql-initdb.sh index f3833a6c..ae607b3c 100755 --- a/scripts/apply-local-mysql-initdb.sh +++ b/scripts/apply-local-mysql-initdb.sh @@ -42,6 +42,7 @@ INCREMENTAL_MIGRATION_FILES=( "services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql" "services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql" "services/wallet-service/deploy/mysql/migrations/010_fami_agency_host_income_share.sql" + "services/wallet-service/deploy/mysql/migrations/011_vip_user_settings_defaults.sql" ) # Keep MySQL as the only required dependency for this script. Business services diff --git a/scripts/mysql/062_vip_user_settings.sql b/scripts/mysql/062_vip_user_settings.sql index d4a72b3d..e982b005 100644 --- a/scripts/mysql/062_vip_user_settings.sql +++ b/scripts/mysql/062_vip_user_settings.sql @@ -2,16 +2,16 @@ SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; USE hyapp_wallet; --- 用户级通知开关只保存偏好,不改变 VIP 资格或 effective_benefits;历史用户不批量补行, --- 查询无记录时由 wallet-service 统一返回默认开启。 +-- 用户级开关只保存偏好,不改变 VIP 资格或 effective_benefits;历史用户不批量补行, +-- 查询无记录时由 wallet-service 统一返回“通知开、隐私关”的产品默认。 CREATE TABLE IF NOT EXISTS user_vip_settings ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', user_id BIGINT NOT NULL COMMENT '用户 ID', room_entry_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启进房通知', online_global_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启上线全服通知', - hide_profile_data_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启隐藏个人数据', - anonymous_profile_visit_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启匿名访问主页', - leaderboard_invisible_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启榜单隐身', + hide_profile_data_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否开启隐藏个人数据', + anonymous_profile_visit_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否开启匿名访问主页', + leaderboard_invisible_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否开启榜单隐身', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, user_id) diff --git a/scripts/mysql/067_vip_user_privacy_settings.sql b/scripts/mysql/067_vip_user_privacy_settings.sql index c14f343e..28ef8c95 100644 --- a/scripts/mysql/067_vip_user_privacy_settings.sql +++ b/scripts/mysql/067_vip_user_privacy_settings.sql @@ -7,7 +7,7 @@ USE hyapp_wallet; -- 显式 LOCK 子句,因此只保留算法硬约束,避免语法失败且禁止静默退化为 COPY/INPLACE。 SET @ddl := IF( (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_settings' AND COLUMN_NAME = 'hide_profile_data_enabled') = 0, - 'ALTER TABLE user_vip_settings ADD COLUMN hide_profile_data_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否开启隐藏个人数据'', ALGORITHM=INSTANT', + 'ALTER TABLE user_vip_settings ADD COLUMN hide_profile_data_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT ''是否开启隐藏个人数据'', ALGORITHM=INSTANT', 'SELECT 1' ); PREPARE stmt FROM @ddl; @@ -16,7 +16,7 @@ DEALLOCATE PREPARE stmt; SET @ddl := IF( (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_settings' AND COLUMN_NAME = 'anonymous_profile_visit_enabled') = 0, - 'ALTER TABLE user_vip_settings ADD COLUMN anonymous_profile_visit_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否开启匿名访问主页'', ALGORITHM=INSTANT', + 'ALTER TABLE user_vip_settings ADD COLUMN anonymous_profile_visit_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT ''是否开启匿名访问主页'', ALGORITHM=INSTANT', 'SELECT 1' ); PREPARE stmt FROM @ddl; @@ -25,7 +25,7 @@ DEALLOCATE PREPARE stmt; SET @ddl := IF( (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_vip_settings' AND COLUMN_NAME = 'leaderboard_invisible_enabled') = 0, - 'ALTER TABLE user_vip_settings ADD COLUMN leaderboard_invisible_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否开启榜单隐身'', ALGORITHM=INSTANT', + 'ALTER TABLE user_vip_settings ADD COLUMN leaderboard_invisible_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT ''是否开启榜单隐身'', ALGORITHM=INSTANT', 'SELECT 1' ); PREPARE stmt FROM @ddl; diff --git a/scripts/mysql/073_vip_user_settings_defaults.sql b/scripts/mysql/073_vip_user_settings_defaults.sql new file mode 100644 index 00000000..80bee9c0 --- /dev/null +++ b/scripts/mysql/073_vip_user_settings_defaults.sql @@ -0,0 +1,30 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_wallet; + +-- 067 已可能在生产执行,因此本迁移只修改后续 INSERT 使用的列默认, +-- 不 UPDATE 任何现有行。五列在一条 INSTANT DDL 中更新,只改数据字典, +-- 也不会把用户已明确选择的 true/false 重置成新产品默认。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_vip_settings' + AND COLUMN_NAME IN ( + 'room_entry_notice_enabled', + 'online_global_notice_enabled', + 'hide_profile_data_enabled', + 'anonymous_profile_visit_enabled', + 'leaderboard_invisible_enabled' + )) = 5, + 'ALTER TABLE user_vip_settings + ALTER COLUMN room_entry_notice_enabled SET DEFAULT TRUE, + ALTER COLUMN online_global_notice_enabled SET DEFAULT TRUE, + ALTER COLUMN hide_profile_data_enabled SET DEFAULT FALSE, + ALTER COLUMN anonymous_profile_visit_enabled SET DEFAULT FALSE, + ALTER COLUMN leaderboard_invisible_enabled SET DEFAULT FALSE, + ALGORITHM=INSTANT', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go b/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go index 3d2c9614..b00ac669 100644 --- a/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go +++ b/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go @@ -38,7 +38,14 @@ func TestGetMyOverviewComposesProfileWalletVIPStatsRolesAndEntries(t *testing.T) } walletClient := &fakeWalletClient{ valueSummaryResp: &walletv1.GetWalletValueSummaryResponse{Summary: &walletv1.WalletValueSummary{CoinAmount: 12000, UpdatedAtMs: 8000}}, - myVipResp: &walletv1.GetMyVipResponse{Vip: &walletv1.UserVip{UserId: 42, Level: 3, Name: "VIP3", Active: true, StartedAtMs: 9000, ExpiresAtMs: 19000}}, + myVipResp: &walletv1.GetMyVipResponse{ + Vip: &walletv1.UserVip{UserId: 42, Level: 3, Name: "VIP3", Active: true, StartedAtMs: 9000, ExpiresAtMs: 19000}, + State: &walletv1.VipState{UserSettings: &walletv1.VipUserSettings{ + AppCode: "lalu", UserId: 42, RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: false, + HideProfileDataEnabled: false, AnonymousProfileVisitEnabled: true, LeaderboardInvisibleEnabled: false, + UpdatedAtMs: 8500, + }}, + }, } hostClient := &fakeUserHostClient{ roleSummary: &userv1.UserRoleSummary{ @@ -104,6 +111,13 @@ func TestGetMyOverviewComposesProfileWalletVIPStatsRolesAndEntries(t *testing.T) if vip["level"] != float64(3) || vip["name"] != "VIP3" || vip["active"] != true { t.Fatalf("vip summary mismatch: %+v", vip) } + settings := vip["user_settings"].(map[string]any) + if settings["app_code"] != "lalu" || settings["user_id"] != float64(42) || + settings["room_entry_notice_enabled"] != true || settings["online_global_notice_enabled"] != false || + settings["hide_profile_data_enabled"] != false || settings["anonymous_profile_visit_enabled"] != true || + settings["leaderboard_invisible_enabled"] != false || settings["updated_at_ms"] != float64(8500) { + t.Fatalf("vip user settings mismatch: %+v", settings) + } if _, ok := data["invite"]; ok { t.Fatalf("my overview must not include invite: %+v", data["invite"]) } @@ -155,6 +169,14 @@ func TestGetMyOverviewProjectsOrdinaryUserEntriesAndZeroWallet(t *testing.T) { if roles["is_host"] != false || roles["is_manager"] != false || roles["is_bd"] != false || roles["is_coin_seller"] != false { t.Fatalf("ordinary roles mismatch: %+v", roles) } + vip := data["vip"].(map[string]any) + settings := vip["user_settings"].(map[string]any) + if settings["app_code"] != "lalu" || settings["user_id"] != float64(42) || + settings["room_entry_notice_enabled"] != true || settings["online_global_notice_enabled"] != true || + settings["hide_profile_data_enabled"] != false || settings["anonymous_profile_visit_enabled"] != false || + settings["leaderboard_invisible_enabled"] != false { + t.Fatalf("missing wallet state must use notice-on privacy-off defaults: %+v", settings) + } entries := data["entries"].([]any) if !overviewEntryVisible(entries, "apply_host") || overviewEntryVisible(entries, "host_center") { t.Fatalf("ordinary entries mismatch: %+v", entries) diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index a7570ce4..de1187c1 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -3719,7 +3719,7 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) } hasRoomDisplayResourceQuery := false for _, req := range walletClient.batchEquippedRequests { - if strings.Join(req.GetResourceTypes(), ",") == "avatar_frame,profile_card,vehicle,badge,mic_seat_animation" { + if strings.Join(req.GetResourceTypes(), ",") == "avatar_frame,profile_card,vehicle,chat_bubble,badge,mic_seat_animation" { hasRoomDisplayResourceQuery = true break } 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 38e42e2b..b943b9cb 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 ( roomAppearanceResourceAvatarFrame = "avatar_frame" roomAppearanceResourceProfileCard = "profile_card" roomAppearanceResourceVehicle = "vehicle" + roomAppearanceResourceChatBubble = "chat_bubble" roomAppearanceResourceBadge = "badge" roomAppearanceResourceMicSeatAnimation = "mic_seat_animation" @@ -2457,7 +2458,7 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64) // 房间资料是展示增强,失败时保留主房间数据,让客户端后续重试 batch 接口。 return nil } - // 房间资料高频读取,头像框/资料卡/座驾/当前佩戴徽章用 wallet 批量查; + // 房间资料高频读取,头像框/资料卡/座驾/聊天气泡/当前佩戴徽章用 wallet 批量查; // activity 的 badge 投影再补展示槽位,两个来源合并后客户端才能同时看到长短徽章。 appearanceMap := h.roomAppearanceResourceMap(request, userIDs) badgeMap := h.roomBadgeDisplayMap(request, userIDs) @@ -2490,6 +2491,7 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64) AvatarFrame: appearance.AvatarFrame, ProfileCard: appearance.ProfileCard, Vehicle: appearance.Vehicle, + ChatBubble: appearance.ChatBubble, MicSeatAnimation: appearance.MicSeatAnimation, Charm: 0, } @@ -2519,6 +2521,12 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64) profile.Vehicle = map[string]any{} profiles[userID] = profile } + if profiles[userID].ChatBubble == nil { + // 公屏普通文本只携带发送人 ID;空对象保持稳定 JSON 契约,观众端无需再区分 null/缺字段。 + profile := profiles[userID] + profile.ChatBubble = map[string]any{} + profiles[userID] = profile + } if profiles[userID].MicSeatAnimation == nil { profile := profiles[userID] profile.MicSeatAnimation = map[string]any{} @@ -2537,6 +2545,7 @@ type roomAppearanceResources struct { AvatarFrame map[string]any ProfileCard map[string]any Vehicle map[string]any + ChatBubble map[string]any MicSeatAnimation map[string]any Badges []map[string]any } @@ -2551,7 +2560,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, roomAppearanceResourceBadge, roomAppearanceResourceMicSeatAnimation}, + ResourceTypes: []string{roomAppearanceResourceAvatarFrame, roomAppearanceResourceProfileCard, roomAppearanceResourceVehicle, roomAppearanceResourceChatBubble, roomAppearanceResourceBadge, roomAppearanceResourceMicSeatAnimation}, }) if err != nil { // 装扮是展示增强,失败不影响房间主数据返回。 @@ -2572,6 +2581,8 @@ func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int current.ProfileCard = resource case roomAppearanceResourceVehicle: current.Vehicle = resource + case roomAppearanceResourceChatBubble: + current.ChatBubble = resource case roomAppearanceResourceBadge: current.Badges = append(current.Badges, roomBadgeItemFromEquippedResource(resource)) case roomAppearanceResourceMicSeatAnimation: 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 27cf8066..c3f7a66a 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -219,6 +219,7 @@ type roomDisplayProfileData struct { AvatarFrame map[string]any `json:"avatar_frame"` ProfileCard map[string]any `json:"profile_card"` Vehicle map[string]any `json:"vehicle"` + ChatBubble map[string]any `json:"chat_bubble"` MicSeatAnimation map[string]any `json:"mic_seat_animation"` Charm int64 `json:"charm"` } diff --git a/services/gateway-service/internal/transport/http/userapi/agency_center_handler.go b/services/gateway-service/internal/transport/http/userapi/agency_center_handler.go index fbbd7157..46397f5b 100644 --- a/services/gateway-service/internal/transport/http/userapi/agency_center_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/agency_center_handler.go @@ -28,6 +28,12 @@ type agencyCenterSalaryData struct { DisplayAmount float64 `json:"display_amount"` } +type agencyCenterDiamondProgressData struct { + CycleKey string `json:"cycle_key"` + TotalDiamonds int64 `json:"total_diamonds"` + ActiveHostCount int32 `json:"active_host_count"` +} + type agencyCenterUserData struct { UserID string `json:"user_id"` DisplayUserID string `json:"display_user_id"` @@ -120,24 +126,50 @@ func (h *Handler) getAgencyCenterPlatformPolicy(writer http.ResponseWriter, requ return } + now := time.Now().UTC() + cycleKey := now.Format("2006-01") // Agency Center 不是主播身份,不能复用 host-center 的 active host 校验;这里固定用当前 agency 的区域读取完整工资政策。 resp, err := h.walletClient.GetActiveHostSalaryPolicy(request.Context(), &walletv1.GetActiveHostSalaryPolicyRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), RegionId: agency.GetRegionId(), SettlementTriggerMode: "", - CycleKey: time.Now().UTC().Format("2006-01"), + CycleKey: cycleKey, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } - // H5 需要完整政策明细;hostCenterPolicyFromProto 会保留政策元数据和所有等级行,agency 端不计算主播等级进度。 + // Agency 卡片展示的是本工资周期内归属该 owner 的主播钻石合计。聚合依据入账时固化的 + // agency_owner_user_id 快照,既不会把 owner 的用户 COIN 钱包混进来,也不会因主播后来换公会改写历史归属。 + statsResp, err := h.walletClient.GetTeamHostSalaryStats(request.Context(), &walletv1.GetTeamHostSalaryStatsRequest{ + RequestId: httpkit.RequestIDFromContext(request.Context()), + AppCode: appcode.FromContext(request.Context()), + AgencyOwnerUserIds: []int64{agency.GetOwnerUserId()}, + CycleKeys: []string{cycleKey}, + NowMs: now.UnixMilli(), + }) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } + progress := agencyCenterDiamondProgressData{CycleKey: cycleKey} + for _, stat := range statsResp.GetStats() { + if stat.GetCycleKey() != cycleKey { + continue + } + progress.TotalDiamonds = stat.GetTotalDiamonds() + progress.ActiveHostCount = stat.GetActiveHostCount() + break + } + + // H5 需要完整政策明细和 Agency 周期合计;Agency 没有单主播等级,因此不返回 host-center 的 level_progress。 httpkit.WriteOK(writer, request, map[string]any{ "found": resp.GetFound(), "agency_region_id": agency.GetRegionId(), "policy": hostCenterPolicyFromProto(resp.GetPolicy()), + "progress": progress, }) } diff --git a/services/gateway-service/internal/transport/http/userapi/my_page_overview_handler.go b/services/gateway-service/internal/transport/http/userapi/my_page_overview_handler.go index eaec7270..741f8f9e 100644 --- a/services/gateway-service/internal/transport/http/userapi/my_page_overview_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/my_page_overview_handler.go @@ -67,12 +67,26 @@ type overviewEntryData struct { } type overviewVIPData struct { - Level int32 `json:"level"` - Name string `json:"name"` - Active bool `json:"active"` - StartedAtMS int64 `json:"started_at_ms"` - ExpiresAtMS int64 `json:"expires_at_ms"` - Unavailable bool `json:"unavailable,omitempty"` + Level int32 `json:"level"` + Name string `json:"name"` + Active bool `json:"active"` + StartedAtMS int64 `json:"started_at_ms"` + ExpiresAtMS int64 `json:"expires_at_ms"` + UserSettings overviewVIPUserSettingsData `json:"user_settings"` + Unavailable bool `json:"unavailable,omitempty"` +} + +// overviewVIPUserSettingsData 只出现在当前用户的私有聚合接口。不能把这些 wallet +// 偏好塞进公共 userProfileData,否则查看他人资料和批量用户读取会泄露私有设置。 +type overviewVIPUserSettingsData struct { + AppCode string `json:"app_code"` + UserID int64 `json:"user_id"` + RoomEntryNoticeEnabled bool `json:"room_entry_notice_enabled"` + OnlineGlobalNoticeEnabled bool `json:"online_global_notice_enabled"` + HideProfileDataEnabled bool `json:"hide_profile_data_enabled"` + AnonymousProfileVisitEnabled bool `json:"anonymous_profile_visit_enabled"` + LeaderboardInvisibleEnabled bool `json:"leaderboard_invisible_enabled"` + UpdatedAtMS int64 `json:"updated_at_ms"` } // getMyOverview 聚合 App 我的页首屏摘要;gateway 只做 RPC 编排和入口显隐规则,不查数据库。 @@ -154,7 +168,7 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques roles = overviewRolesData{Unavailable: true} } wallet := overviewWallet(walletResp, walletErr) - vip := overviewVIP(vipResp, vipErr) + vip := overviewVIP(vipResp, vipErr, appcode.FromContext(request.Context()), userID) profileStats := overviewStats(statsResp, statsErr) httpkit.WriteOK(writer, request, myOverviewData{ @@ -208,20 +222,51 @@ func overviewWallet(resp *walletv1.GetWalletValueSummaryResponse, err error) ove } } -func overviewVIP(resp *walletv1.GetMyVipResponse, err error) overviewVIPData { +func overviewVIP(resp *walletv1.GetMyVipResponse, err error, appCode string, userID int64) overviewVIPData { + settings := overviewVIPUserSettings(resp.GetState().GetUserSettings(), appCode, userID) if err != nil { - return overviewVIPData{Unavailable: true} + // 聚合接口保持稳定 JSON shape,但 unavailable=true 明确表示本次默认值 + // 不是已确认的持久化事实;客户端不应在降级时覆盖本地最后一次成功状态。 + return overviewVIPData{UserSettings: settings, Unavailable: true} } vip := resp.GetVip() return overviewVIPData{ - Level: vip.GetLevel(), - Name: vip.GetName(), - Active: vip.GetActive(), - StartedAtMS: vip.GetStartedAtMs(), - ExpiresAtMS: vip.GetExpiresAtMs(), + Level: vip.GetLevel(), + Name: vip.GetName(), + Active: vip.GetActive(), + StartedAtMS: vip.GetStartedAtMs(), + ExpiresAtMS: vip.GetExpiresAtMs(), + UserSettings: settings, } } +// overviewVIPUserSettings 复用当前 GetMyVip 回包,不增加 RPC。旧 wallet 实例缺少 +// user_settings 时与 wallet owner 的缺行语义保持一致:通知开启,三项隐私能力关闭。 +func overviewVIPUserSettings(settings *walletv1.VipUserSettings, appCode string, userID int64) overviewVIPUserSettingsData { + result := overviewVIPUserSettingsData{ + AppCode: appCode, UserID: userID, + RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true, + } + if settings == nil { + return result + } + result.AppCode = settings.GetAppCode() + if result.AppCode == "" { + result.AppCode = appCode + } + result.UserID = settings.GetUserId() + if result.UserID == 0 { + result.UserID = userID + } + result.RoomEntryNoticeEnabled = settings.GetRoomEntryNoticeEnabled() + result.OnlineGlobalNoticeEnabled = settings.GetOnlineGlobalNoticeEnabled() + result.HideProfileDataEnabled = settings.GetHideProfileDataEnabled() + result.AnonymousProfileVisitEnabled = settings.GetAnonymousProfileVisitEnabled() + result.LeaderboardInvisibleEnabled = settings.GetLeaderboardInvisibleEnabled() + result.UpdatedAtMS = settings.GetUpdatedAtMs() + return result +} + func overviewStats(resp *userv1.GetMyProfileStatsResponse, err error) overviewProfileStats { if err != nil { return overviewProfileStats{Unavailable: true} diff --git a/services/gateway-service/internal/transport/http/vip_handler_test.go b/services/gateway-service/internal/transport/http/vip_handler_test.go index a4b20dcd..c1119fc9 100644 --- a/services/gateway-service/internal/transport/http/vip_handler_test.go +++ b/services/gateway-service/internal/transport/http/vip_handler_test.go @@ -489,6 +489,13 @@ func TestGetMyVIPKeepsLegacyFieldsAndAddsEffectiveState(t *testing.T) { EffectiveBenefits []struct { BenefitCode string `json:"benefit_code"` } `json:"effective_benefits"` + UserSettings struct { + RoomEntryNoticeEnabled bool `json:"room_entry_notice_enabled"` + OnlineGlobalNoticeEnabled bool `json:"online_global_notice_enabled"` + HideProfileDataEnabled bool `json:"hide_profile_data_enabled"` + AnonymousProfileVisitEnabled bool `json:"anonymous_profile_visit_enabled"` + LeaderboardInvisibleEnabled bool `json:"leaderboard_invisible_enabled"` + } `json:"user_settings"` } `json:"state"` } `json:"data"` } @@ -507,6 +514,10 @@ func TestGetMyVIPKeepsLegacyFieldsAndAddsEffectiveState(t *testing.T) { if len(envelope.Data.State.EffectiveBenefits) != 1 || envelope.Data.State.EffectiveBenefits[0].BenefitCode != "room_entry_notice" { t.Fatalf("effective benefits mismatch: %+v", envelope.Data.State.EffectiveBenefits) } + settings := envelope.Data.State.UserSettings + if !settings.RoomEntryNoticeEnabled || !settings.OnlineGlobalNoticeEnabled || settings.HideProfileDataEnabled || settings.AnonymousProfileVisitEnabled || settings.LeaderboardInvisibleEnabled { + t.Fatalf("legacy wallet fallback must keep notice-on privacy-off defaults: %+v", settings) + } } func TestListVIPPackagesAddsProgramStateAndLevelBenefits(t *testing.T) { diff --git a/services/gateway-service/internal/transport/http/walletapi/vip_handler.go b/services/gateway-service/internal/transport/http/walletapi/vip_handler.go index 9d018556..934a64d3 100644 --- a/services/gateway-service/internal/transport/http/walletapi/vip_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/vip_handler.go @@ -505,8 +505,8 @@ func vipStateFromProto(state *walletv1.VipState) vipStateData { EffectiveBenefits: []vipBenefitData{}, UserSettings: vipUserSettingsData{ RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true, - HideProfileDataEnabled: true, AnonymousProfileVisitEnabled: true, - LeaderboardInvisibleEnabled: true, + HideProfileDataEnabled: false, AnonymousProfileVisitEnabled: false, + LeaderboardInvisibleEnabled: false, }, } } @@ -524,11 +524,12 @@ func vipStateFromProto(state *walletv1.VipState) vipStateData { func vipUserSettingsFromProto(settings *walletv1.VipUserSettings) vipUserSettingsData { if settings == nil { - // wallet 滚动升级期间旧实例没有 tag 8;与 wallet owner 的缺行语义一致,按默认开启兼容。 + // wallet 滚动升级期间旧实例没有 tag 8;这里必须与 wallet owner 的 + // 缺行语义一致,否则同一用户会因命中不同 gateway 实例而看到不同的隐私默认。 return vipUserSettingsData{ RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true, - HideProfileDataEnabled: true, AnonymousProfileVisitEnabled: true, - LeaderboardInvisibleEnabled: true, + HideProfileDataEnabled: false, AnonymousProfileVisitEnabled: false, + LeaderboardInvisibleEnabled: false, } } return vipUserSettingsData{ 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 96c6bcd7..56f265b5 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -1834,16 +1834,17 @@ CREATE TABLE IF NOT EXISTS user_vip_memberships ( KEY idx_user_vip_expiry (app_code, status, expires_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 VIP 成员关系表'; --- 用户偏好和 VIP 资格分离:没有记录时运行时默认两项开启;首次 PATCH 才物化。 +-- 用户偏好和 VIP 资格分离:没有记录时通知默认开启、三项隐私能力默认关闭; +-- 首次 PATCH 才物化,且未提交字段必须保留该默认。 -- 主键包含 app_code,确保同一 user_id 在 Fami/Lalu 可独立设置。 CREATE TABLE IF NOT EXISTS user_vip_settings ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', user_id BIGINT NOT NULL COMMENT '用户 ID', room_entry_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启进房通知', online_global_notice_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启上线全服通知', - hide_profile_data_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启隐藏个人数据', - anonymous_profile_visit_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启匿名访问主页', - leaderboard_invisible_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开启榜单隐身', + hide_profile_data_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否开启隐藏个人数据', + anonymous_profile_visit_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否开启匿名访问主页', + leaderboard_invisible_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否开启榜单隐身', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, user_id) diff --git a/services/wallet-service/deploy/mysql/migrations/011_vip_user_settings_defaults.sql b/services/wallet-service/deploy/mysql/migrations/011_vip_user_settings_defaults.sql new file mode 100644 index 00000000..09f4166a --- /dev/null +++ b/services/wallet-service/deploy/mysql/migrations/011_vip_user_settings_defaults.sql @@ -0,0 +1,29 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_wallet; + +-- 与顶层生产迁移 073 保持同一事实。旧迁移已可能物化用户选择, +-- 所以这里只用 INSTANT DDL 修改后续 INSERT 的列默认,绝不更新现有行。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_vip_settings' + AND COLUMN_NAME IN ( + 'room_entry_notice_enabled', + 'online_global_notice_enabled', + 'hide_profile_data_enabled', + 'anonymous_profile_visit_enabled', + 'leaderboard_invisible_enabled' + )) = 5, + 'ALTER TABLE user_vip_settings + ALTER COLUMN room_entry_notice_enabled SET DEFAULT TRUE, + ALTER COLUMN online_global_notice_enabled SET DEFAULT TRUE, + ALTER COLUMN hide_profile_data_enabled SET DEFAULT FALSE, + ALTER COLUMN anonymous_profile_visit_enabled SET DEFAULT FALSE, + ALTER COLUMN leaderboard_invisible_enabled SET DEFAULT FALSE, + ALGORITHM=INSTANT', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/services/wallet-service/internal/domain/ledger/vip.go b/services/wallet-service/internal/domain/ledger/vip.go index 9c7faf3b..8eba1bf0 100644 --- a/services/wallet-service/internal/domain/ledger/vip.go +++ b/services/wallet-service/internal/domain/ledger/vip.go @@ -273,13 +273,14 @@ type VipUserSettings struct { UpdatedAtMS int64 } -// DefaultVipUserSettings 返回尚未物化时的产品默认:五项能力均开启,UpdatedAtMS=0。 +// DefaultVipUserSettings 返回尚未物化时的产品默认:通知默认开启, +// 隐藏个人数据、匿名访问和榜单隐身默认关闭,UpdatedAtMS=0。 func DefaultVipUserSettings(appCode string, userID int64) VipUserSettings { return VipUserSettings{ AppCode: appCode, UserID: userID, RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true, - HideProfileDataEnabled: true, AnonymousProfileVisitEnabled: true, - LeaderboardInvisibleEnabled: true, + HideProfileDataEnabled: false, AnonymousProfileVisitEnabled: false, + LeaderboardInvisibleEnabled: false, } } diff --git a/services/wallet-service/internal/service/wallet/vip_user_settings_integration_test.go b/services/wallet-service/internal/service/wallet/vip_user_settings_integration_test.go index 62fb6c06..4a6b77c1 100644 --- a/services/wallet-service/internal/service/wallet/vip_user_settings_integration_test.go +++ b/services/wallet-service/internal/service/wallet/vip_user_settings_integration_test.go @@ -25,9 +25,9 @@ func TestVipUserSettingsDefaultPartialUpdateAndAppScope(t *testing.T) { t.Fatalf("get default VIP settings failed: %v", err) } if !defaultState.UserSettings.RoomEntryNoticeEnabled || !defaultState.UserSettings.OnlineGlobalNoticeEnabled || - !defaultState.UserSettings.HideProfileDataEnabled || !defaultState.UserSettings.AnonymousProfileVisitEnabled || - !defaultState.UserSettings.LeaderboardInvisibleEnabled || defaultState.UserSettings.UpdatedAtMS != 0 { - t.Fatalf("missing settings row must project default enabled: %+v", defaultState.UserSettings) + defaultState.UserSettings.HideProfileDataEnabled || defaultState.UserSettings.AnonymousProfileVisitEnabled || + defaultState.UserSettings.LeaderboardInvisibleEnabled || defaultState.UserSettings.UpdatedAtMS != 0 { + t.Fatalf("missing settings row must project notice-on privacy-off defaults: %+v", defaultState.UserSettings) } if got := repository.CountRows("user_vip_settings", "app_code = ? AND user_id = ?", "fami", paidUserID); got != 0 { t.Fatalf("read must not materialize default settings row, got %d", got) @@ -37,14 +37,16 @@ func TestVipUserSettingsDefaultPartialUpdateAndAppScope(t *testing.T) { settings, err := service.UpdateVipUserSettings(famiCtx, ledger.UpdateVipUserSettingsCommand{ AppCode: "fami", UserID: paidUserID, RoomEntryNoticeEnabled: &roomOff, }) - if err != nil || settings.RoomEntryNoticeEnabled || !settings.OnlineGlobalNoticeEnabled || settings.UpdatedAtMS <= 0 { + if err != nil || settings.RoomEntryNoticeEnabled || !settings.OnlineGlobalNoticeEnabled || + settings.HideProfileDataEnabled || settings.AnonymousProfileVisitEnabled || settings.LeaderboardInvisibleEnabled || + settings.UpdatedAtMS <= 0 { t.Fatalf("first partial settings update mismatch: settings=%+v err=%v", settings, err) } - hideProfileOff := false + anonymousVisitOn := true settings, err = service.UpdateVipUserSettings(famiCtx, ledger.UpdateVipUserSettingsCommand{ - AppCode: "fami", UserID: paidUserID, HideProfileDataEnabled: &hideProfileOff, + AppCode: "fami", UserID: paidUserID, AnonymousProfileVisitEnabled: &anonymousVisitOn, }) - if err != nil || settings.HideProfileDataEnabled || !settings.AnonymousProfileVisitEnabled || !settings.LeaderboardInvisibleEnabled { + if err != nil || settings.HideProfileDataEnabled || !settings.AnonymousProfileVisitEnabled || settings.LeaderboardInvisibleEnabled { t.Fatalf("privacy partial update must preserve other switches: settings=%+v err=%v", settings, err) } onlineOff := false @@ -56,8 +58,8 @@ func TestVipUserSettingsDefaultPartialUpdateAndAppScope(t *testing.T) { } laluState, err := service.GetVipState(laluCtx, paidUserID) if err != nil || !laluState.UserSettings.RoomEntryNoticeEnabled || !laluState.UserSettings.OnlineGlobalNoticeEnabled || - !laluState.UserSettings.HideProfileDataEnabled || !laluState.UserSettings.AnonymousProfileVisitEnabled || - !laluState.UserSettings.LeaderboardInvisibleEnabled || laluState.UserSettings.UpdatedAtMS != 0 { + laluState.UserSettings.HideProfileDataEnabled || laluState.UserSettings.AnonymousProfileVisitEnabled || + laluState.UserSettings.LeaderboardInvisibleEnabled || laluState.UserSettings.UpdatedAtMS != 0 { t.Fatalf("same user settings must remain App scoped: state=%+v err=%v", laluState.UserSettings, err) } @@ -84,7 +86,9 @@ func TestVipUserSettingsDefaultPartialUpdateAndAppScope(t *testing.T) { t.Fatalf("concurrent partial settings update failed: left=%v right=%v", leftErr, rightErr) } concurrentState, err := service.GetVipState(famiCtx, concurrentUserID) - if err != nil || concurrentState.UserSettings.RoomEntryNoticeEnabled || concurrentState.UserSettings.OnlineGlobalNoticeEnabled { + if err != nil || concurrentState.UserSettings.RoomEntryNoticeEnabled || concurrentState.UserSettings.OnlineGlobalNoticeEnabled || + concurrentState.UserSettings.HideProfileDataEnabled || concurrentState.UserSettings.AnonymousProfileVisitEnabled || + concurrentState.UserSettings.LeaderboardInvisibleEnabled { t.Fatalf("concurrent partial updates overwrote each other: settings=%+v err=%v", concurrentState.UserSettings, err) } diff --git a/services/wallet-service/internal/storage/mysql/vip_admin_repository.go b/services/wallet-service/internal/storage/mysql/vip_admin_repository.go index b282c445..c6245e40 100644 --- a/services/wallet-service/internal/storage/mysql/vip_admin_repository.go +++ b/services/wallet-service/internal/storage/mysql/vip_admin_repository.go @@ -423,14 +423,16 @@ func (r *Repository) validateAdminVipBenefit(ctx context.Context, tx *sql.Tx, pr } } } - // Fami tiered 的三项房间视觉权益是稳定跨服务契约,不能由后台把 code 改成 + // Fami tiered 的固定房间视觉权益是稳定跨服务契约,不能由后台把 code 改成 // function 或绑定到近似类型。disabled 可以暂不选素材,但一旦携带 resource_id, // 下方仍会校验资源 active 且类型精确匹配,避免脏绑定在之后切状态时直接生效。 expectedResourceType := "" if program.ProgramType == ledger.VipProgramTypeTieredPrivilegeV1 && appcode.FromContext(ctx) == "fami" { switch benefit.BenefitCode { case ledger.VipBenefitCodeMicSkin: - expectedResourceType = resourcedomain.TypeMicSeatAnimation + // 麦克风皮肤替换麦位中心图标;mic_seat_animation 留给 voice_wave 声波动效, + // 不能因两者都显示在麦位周围就复用同一目录类型。 + expectedResourceType = resourcedomain.TypeMicSeatIcon case ledger.VipBenefitCodeRoomBorder: expectedResourceType = resourcedomain.TypeRoomBorder case ledger.VipBenefitCodeColoredRoomName: diff --git a/services/wallet-service/internal/storage/mysql/vip_user_settings_repository.go b/services/wallet-service/internal/storage/mysql/vip_user_settings_repository.go index 07887e63..b73e831a 100644 --- a/services/wallet-service/internal/storage/mysql/vip_user_settings_repository.go +++ b/services/wallet-service/internal/storage/mysql/vip_user_settings_repository.go @@ -12,7 +12,8 @@ import ( ) // UpdateVipUserSettings 用一条 partial upsert 原子修改用户明确提交的开关。 -// INSERT 分支给未提交字段使用默认 true;UPDATE 分支则保留原值,避免两个并发 PATCH 互相覆盖。 +// INSERT 分支必须使用与无行读取完全相同的产品默认;UPDATE 分支则保留原值, +// 避免用户首次只改通知时意外开启三项隐私权益,也避免两个并发 PATCH 互相覆盖。 func (r *Repository) UpdateVipUserSettings(ctx context.Context, command ledger.UpdateVipUserSettingsCommand) (ledger.VipUserSettings, error) { if r == nil || r.db == nil { return ledger.VipUserSettings{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") @@ -41,17 +42,17 @@ func (r *Repository) UpdateVipUserSettings(ctx context.Context, command ledger.U if onlineProvided { onlineValue = *command.OnlineGlobalNoticeEnabled } - hideProfileValue := true + hideProfileValue := false hideProfileProvided := command.HideProfileDataEnabled != nil if hideProfileProvided { hideProfileValue = *command.HideProfileDataEnabled } - anonymousVisitValue := true + anonymousVisitValue := false anonymousVisitProvided := command.AnonymousProfileVisitEnabled != nil if anonymousVisitProvided { anonymousVisitValue = *command.AnonymousProfileVisitEnabled } - leaderboardInvisibleValue := true + leaderboardInvisibleValue := false leaderboardInvisibleProvided := command.LeaderboardInvisibleEnabled != nil if leaderboardInvisibleProvided { leaderboardInvisibleValue = *command.LeaderboardInvisibleEnabled @@ -87,7 +88,7 @@ func (r *Repository) UpdateVipUserSettings(ctx context.Context, command ledger.U return settings, nil } -// queryVipUserSettings 统一处理“无行即默认开启”。调用方可以传事务,从而让 VipState 的 +// queryVipUserSettings 统一处理“无行即产品默认”。调用方可以传事务,从而让 VipState 的 // program、会员、体验卡、benefits 和 settings 保持在同一 repeatable-read 快照。 func (r *Repository) queryVipUserSettings(ctx context.Context, querier interface { QueryRowContext(context.Context, string, ...any) *sql.Row