vip相关

This commit is contained in:
zhx 2026-07-21 10:54:32 +08:00
parent f89aa37318
commit b4655c1326
28 changed files with 329 additions and 101 deletions

View File

@ -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"`

View File

@ -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;

View File

@ -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 = <String, VipBenefitHandler>{
"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 = <String, VipBenefitHandler>{
"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 = <String, VipBenefitHandler>{
- 当体验卡被佩戴时,不限制卡等级高低,它会覆盖付费 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 = <String, VipBenefitHandler>{
"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 强制;只阻止普通房主/管理员新增禁言 |

View File

@ -93,7 +93,7 @@ Fami 的 VIP1VIP9 初始全部为 `disabled`。30 天只是不产生零时长
- `anonymous_profile_visit_enabled`:匿名访问主页;
- `leaderboard_invisible_enabled`:榜单隐身。
无记录时 5 项默认为 `true`,首次修改才写行。偏好不授予权益:无 VIP 或对应等级没有权益时,即使开关为开也不会放行。用户可在无 VIP、VIP 过期或切换体验卡时保留偏好Fami 与 Lalu 互不影响。
无记录时,进房/上线通知默认为 `true`,隐藏个人数据/匿名访问/榜单隐身默认为 `false`,首次修改才写行。偏好不授予权益:无 VIP 或对应等级没有权益时,即使开关为开也不会放行。用户可在无 VIP、VIP 过期或切换体验卡时保留偏好Fami 与 Lalu 互不影响。
### 权益展示投影

View File

@ -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
}
}

View File

@ -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 <access_token>
"avatar_frame": {},
"profile_card": {},
"vehicle": {},
"chat_bubble": {},
"mic_seat_animation": {},
"badges": [],
"vip": {},

View File

@ -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

View File

@ -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 金币返现

View File

@ -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` 播放入场座驾。

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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;

View File

@ -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;

View File

@ -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)

View File

@ -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
}

View File

@ -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:

View File

@ -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"`
}

View File

@ -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,
})
}

View File

@ -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}

View File

@ -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) {

View File

@ -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{

View File

@ -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)

View File

@ -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;

View File

@ -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,
}
}

View File

@ -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)
}

View File

@ -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:

View File

@ -12,7 +12,8 @@ import (
)
// UpdateVipUserSettings 用一条 partial upsert 原子修改用户明确提交的开关。
// INSERT 分支给未提交字段使用默认 trueUPDATE 分支则保留原值,避免两个并发 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