hyapp-server/docs/Fami_VIP_Flutter对接方案.md
2026-07-14 17:12:39 +08:00

1281 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 可配置 VIP 权限体系 Flutter 对接方案Fami / Lalu
本文定义 Flutter 与当前已落地 VIP 后端的对接契约。客户端不要重复实现 VIP 状态机,也不要按 App、VIP 等级或最低解锁等级推导权限。界面和文案统一使用“VIP”不再使用“SVIP”。
## 1. 通用约定
- 地址前缀:`/api/v1`,实际 host 按环境配置。
- 所有接口需携带 `Authorization: Bearer <access_token>``app_code``user_id` 从登录态解析Flutter 不在 body 中传。
- 所有成功响应使用统一外壳:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
```
- `request_id` 由后端生成,只用于追踪,不是幂等键。购买幂等键是 `command_id`
- 所有 `*_at_ms` / `duration_ms` 都是 Unix epoch milliseconds。业务切日使用 UTCFlutter 只在展示层转本地时间。
- 客户端展示当前身份只读 `state.effective_vip`,权益资格只读 `state.effective_benefits`。不要根据等级、`unlock_level` 或 App 名自行继承权益;进房/上线通知还要结合 `state.user_settings`
## 2. 客户端权限控制方案
### 2.1 结论
客户端只把“VIP 等级”用于套餐名称、价格、有效期和等级对比展示;所有功能权限一律使用稳定的 `benefit_code` 判断:
```text
后台等级配置 -> wallet 计算当前 effective_benefits -> Flutter 权益快照 -> 页面预判
-> owner service 最终鉴权
```
权限分三层,三层职责不能混用:
| 层级 | 负责内容 | 是否是最终权限依据 |
|---|---|---|
| VIP 配置与 wallet 快照 | 按当前 App、付费 VIP/体验卡、开关和配置版本计算 `effective_benefits` | 是,提供资格事实 |
| Flutter 权益中心 | 控制入口显隐、锁态、升级引导、装扮渲染,减少无效请求 | 否,只是体验层预判 |
| room/user/wallet/notice 等 owner service | 在写操作发生时重新校验权益、业务状态和平台权限 | 是,接口结果永远优先 |
禁止以下实现:
- `vipLevel >= 8` 就允许进房通知,或 `vipLevel >= 9` 就认为不可踢。
- 根据 `unlock_level` 在客户端累计低等级权益。
- `if (appCode == 'fami')` 写一套权限分支、`lalu` 再写另一套。
- 用本地缓存阻止房主发起踢人/禁言,或用缓存绕过服务端拒绝。
- 收到 VIP IM 后直接拼装权限,不再查询 `/vip/me`
成熟会员/订阅产品通常采用“服务端维护 entitlement当前资格客户端缓存当前资格并做 UI 预判敏感能力由服务端再次鉴权”的模式。Google Play 官方建议在安全后端管理购买状态并在授予权益前验证购买Apple 的 StoreKit 也提供 current entitlements并给出服务端判定服务资格的模型。这里沿用同一边界但资格来源是本项目的 wallet VIP 状态,而不是让 Flutter 直接解释订单:
- [Google Play在安全后端管理购买生命周期与权益](https://developer.android.com/google/play/billing/backend?hl=en)
- [Google Play授予权益前验证购买](https://developer.android.com/google/play/billing/security?hl=en)
- [Apple StoreKitcurrentEntitlements](https://developer.apple.com/documentation/storekit/transaction/currententitlements)
- [Apple StoreKit服务端判定 entitlement](https://developer.apple.com/documentation/StoreKit/determining-service-entitlement-on-the-server)
### 2.2 Flutter 统一权益中心
建议建立一个按登录 App 隔离的 `VipEntitlementStore`。下面是数据结构示意,不要求照搬类名:
```dart
class VipEntitlementSnapshot {
final VipProgramConfig program;
final VipIdentity effectiveVip;
final String effectiveSource; // paid / trial / none
final Map<String, VipBenefit> benefitsByCode;
final VipUserSettings settings;
final int evaluatedAtMs;
bool has(String code) {
return program.status == 'active' &&
effectiveVip.active &&
benefitsByCode[code]?.status == 'active';
}
bool allowsRoomEntryNotice() {
return has('room_entry_notice') && settings.roomEntryNoticeEnabled;
}
bool allowsOnlineGlobalNotice() {
return has('online_global_notice') &&
settings.onlineGlobalNoticeEnabled;
}
}
```
`benefitsByCode` 只能由 `/vip/me.data.state.effective_benefits` 建立。客户端的已知权益注册表只描述“这个 code 由哪个模块渲染/调用”,不能配置“最低 VIP 等级”:
```dart
final benefitHandlers = <String, VipBenefitHandler>{
'custom_room_background': roomBackgroundHandler,
'daily_coin_rebate': coinRebateHandler,
'room_entry_notice': roomEntryNoticeHandler,
'online_global_notice': onlineNoticeHandler,
};
```
遇到服务端新下发、旧客户端不认识的 `benefit_code` 时:保留在快照中、上报一次兼容性埋点,但不自动开放未知功能、不崩溃。这样后台可先配置新权益,旧版本客户端会安全忽略。
### 2.3 页面与写操作的统一决策
| 场景 | Flutter 预处理 | 最终处理 |
|---|---|---|
| VIP 中心等级对比 | 使用 `packages[].benefits` 展示每个套餐当前完整权益 | 仅展示,不计算用户权限 |
| 功能入口 | `store.has(code)` 决定正常态/锁态和升级引导 | 点击后的写接口再次鉴权 |
| 装扮权益 | 同时检查权益和服务端资源字段;素材为空时使用无图兜底 | 保存/佩戴仍调用资源 owner 接口 |
| 进房通知 | 本人的设置页读 store房间内展示读 Join/房间 IM 快照 | room-service 在 Join 时校验 |
| 防踢、防禁言 | 不根据目标用户本地 VIP 快照禁用管理按钮 | 正常调用房管接口,处理 `PERMISSION_DENIED` |
| 金币返现 | `daily_coin_rebate` 可用于显示入口,金额/状态不在本地推导 | current/statuses/claim 接口决定资格和入账 |
| 上线全服通知 | 权益和开关满足时发起 `/vip/online-notice` | activity/notice 链路异步投递 |
对于锁定入口,“升级到哪一级”也不能使用本地最低等级表。应按 `sort_order` 遍历当前 `/vip/packages`,找到第一个 `status=active``can_purchase=true``benefits` 包含目标 code 的套餐;找不到时只显示“暂未开放”,不要给出错误等级承诺。
## 3. 每一级 VIP 如何展示和控制
### 3.1 Fami P1 初始权益矩阵
下表是初始化配置中的“本级新增权益”,用于产品和客户端理解页面分组,不是客户端授权表。后台保存的是每一级完整权益集合,运营调整后必须以 `packages[level].benefits` 为准。
| 等级 | 初始化新增权益 |
|---|---|
| VIP1 | `vip_badge_identity` VIP标识、`vip_medal` VIP勋章、`avatar_frame` VIP头像框、`vip_title` VIP称号 |
| VIP2 | `chat_bubble` VIP聊天气泡、`vip_gift` VIP礼物、`entry_effect` VIP进场通知、`visitor_history` 查看访客 |
| VIP3 | `voice_wave` VIP声波纹、`profile_card` VIP资料卡皮肤、`custom_room_background` 自定义房间背景、`room_image_message` 房间发图 |
| VIP4 | `animated_avatar` 动态头像、`animated_room_cover` 动态房间封面、`mic_skin` 麦克风皮肤、`room_border` 房间边框、`vehicle` VIP座驾 |
| VIP5 | `colored_room_name` VIP彩色房间名称、`colored_nickname` VIP彩色昵称、`colored_id` VIP彩色ID、`gift_tray_skin` 送礼托盘皮肤 |
| VIP6 | `hide_profile_data` 隐藏个人数据、`custom_avatar_frame` 定制头框、`leaderboard_invisible` 榜单隐身、`daily_coin_rebate` 金币返现 |
| VIP7 | `custom_profile_card` 定制资料卡、`anonymous_profile_visit` 匿名访问主页、`custom_gift` 定制礼物 |
| VIP8 | `room_entry_notice` 进房高亮通知、`custom_pretty_id` 定制靓号、`custom_vehicle` 定制座驾 |
| VIP9 | `anti_kick` 防踢、`anti_mute` 防禁言、`online_global_notice` 上线全服通知 |
展示等级详情时直接循环该套餐的 `benefits`,可按 `benefit_type``execution_scope` 分组;不要把上表写进 Flutter。高等级是否包含低等级权益由后台下发的完整集合决定。
### 3.2 Lalu 与其他 App
- `/vip/packages``/vip/me` 返回当前登录态对应 App 的配置Flutter 不传 `app_code`
- Lalu 当前是 `legacy_timed`Fami 当前是 `tiered_privilege_v1`;客户端只展示 `program_config`,不按该值重写购买状态机。
- 等级数量使用 `program_config.level_count`,可购买等级使用 `packages` 实际数组。不能假设所有 App 都有 VIP1VIP9。
- 套餐是否可买使用 `can_purchase`;购买后的等级、期限、权益和当前展示身份使用购买响应中的 `state`
## 4. 权益快照生命周期与缓存
### 4.1 刷新时机
| 事件 | 客户端动作 |
|---|---|
| 登录成功、切换账号或切换 App | 清空旧 App store调用 `/vip/me` |
| App 冷启动、回前台 | 调 `/vip/me` 校准过期、后台停用和体验卡回落 |
| 进入 VIP 中心 | 并发调用 `/vip/packages``/vip/me` |
| 购买、佩戴体验卡、卸下体验卡成功 | 立即用响应 `state` 原子替换 store |
| 收到 `vip_notice` | 用 `event_id` 去重,然后调用 `/vip/me`;若版本变更再刷新 packages |
| `state.program_config.config_version` 与套餐版本不一致 | 用户权限先以 state 为准,重新请求 packages不能跨版本合并权益数组 |
| 进入房间 | 本人首屏读 Join 响应;其他人的进房效果读当前房间 IM不查询对方 `/vip/me` |
内存快照是当前会话的主缓存。磁盘缓存只可用于会员徽章等首帧占位,必须立刻后台校准;防踢、防禁言、返现、发送图片等敏感功能不能在离线状态仅凭磁盘快照授权。
客户端不要用手机定时器直接删除权益。可以用 `expires_at_ms` 做倒计时和提前显示过期态,但恢复联网或执行写操作时,最终结果仍以服务端为准。
### 4.2 `config_version` 规则
- `/vip/me.state.program_config.config_version`:当前用户权益快照所依据的版本。
- `/vip/packages.program_config.config_version` 和各 package 的 `config_version`:套餐列表所依据的版本。
- 收到更高版本的购买响应或 `vip_notice.config_version` 时,刷新 `/vip/me``/vip/packages`
- 版本不一致时,绝不能把旧 packages 的权益补进新 state也不能把新 packages 当作用户已拥有权益。
## 5. 当前接口与消息总览
### 5.1 HTTP
| 目的 | 方法与地址 | 客户端状态影响 |
|---|---|---|
| 套餐与每级完整权益 | `GET /api/v1/vip/packages` | 更新套餐目录,不直接授权 |
| 当前 VIP 与生效权益 | `GET /api/v1/vip/me` | 原子替换 VIP store |
| 购买/续期/升级 | `POST /api/v1/vip/purchase` | 成功后使用响应 `state` |
| 体验卡背包 | `GET /api/v1/users/me/resources?resource_type=vip_trial_card` | 只更新卡列表 |
| 佩戴体验卡 | `POST /api/v1/vip/trial-cards/{entitlement_id}/equip` | 成功后使用响应 `state` |
| 卸下体验卡 | `DELETE /api/v1/vip/trial-cards/equipped` | 成功后使用响应 `state` |
| VIP 用户开关 | `GET/PATCH /api/v1/vip/settings` | 更新 `user_settings`,不改变权益集 |
| 金币返现 | `/api/v1/vip/coin-rebates/*` | 使用服务端记录和余额回执 |
| 上线全服通知 | `POST /api/v1/vip/online-notice` | 只创建异步播报事件 |
### 5.2 腾讯云 IM 与 inbox
| 事件 | 通道与标识 | 客户端处理 |
|---|---|---|
| 购买/直接激活 VIP | C2C `TIMCustomElem``Ext=vip_notice``Desc=VipActivated` | `event_id` 去重,刷新 `/vip/me`,不能直接授权 |
| 后台发 Fami 体验卡 | 当前无专用 C2C | 进入背包时刷新资源列表,不假装自动佩戴 |
| 用户进入房间 | 当前房间群 `TIMCustomElem``Ext=room_system_message``Desc=room_user_joined` | 使用事件内 VIP 快照渲染本次进房效果 |
| VIP 上线全服通知 | 全局播报群 `TIMCustomElem``Ext=im_broadcast``Desc=vip_online_notice` | `event_id` 去重并渲染事件快照 |
| VIP 金币返现 | 后端 system inbox`action_type=vip_coin_rebate_claim` | 解析 `action_param` 后调用 statuses 恢复真实状态 |
当前没有“任意 VIP 权限变化”的通用 IM。客户端必须依靠上述刷新生命周期校准不能把 IM 是否到达当作权益是否生效的依据。
## 6. VIP 套餐
### 接口地址
`GET /api/v1/vip/packages`
### 参数
无 query/body 参数。
### 返回值
以下 JSON 仅保留对接所需的代表字段;后端会按本节字段表返回完整对象。
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_01",
"data": {
"current_vip": {
"level": 3,
"name": "VIP3",
"active": true,
"started_at_ms": 1780000000000,
"expires_at_ms": 1782592000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
},
"packages": [
{
"level": 4,
"name": "VIP4",
"status": "active",
"price_coin": 4000,
"duration_ms": 2592000000,
"reward_resource_group_id": 0,
"reward_items": [],
"can_purchase": true,
"sort_order": 40,
"recharge_gate_required": false,
"required_recharge_coin_amount": 0,
"user_recharge_coin_amount": 0,
"purchase_locked_reason": "",
"benefits": [
{
"benefit_code": "custom_room_background",
"name": "自定义房间背景",
"benefit_type": "function",
"unlock_level": 3,
"status": "active",
"trial_enabled": true,
"resource_id": 0,
"resource_type": "",
"execution_scope": "room",
"auto_equip": false,
"sort_order": 110,
"metadata_json": "",
"created_at_ms": 0,
"updated_at_ms": 0
}
],
"config_version": 7
}
],
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7
},
"state": {
"paid_vip": {
"level": 3,
"name": "VIP3",
"active": true,
"expires_at_ms": 1782592000000
},
"equipped_trial_card": null,
"effective_vip": {
"level": 3,
"name": "VIP3",
"active": true,
"expires_at_ms": 1782592000000
},
"effective_source": "paid",
"effective_benefits": [
{
"benefit_code": "custom_room_background",
"status": "active"
}
],
"evaluated_at_ms": 1780000000000,
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"config_version": 7
},
"user_settings": {
"app_code": "fami",
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": true,
"updated_at_ms": 0
}
}
}
}
```
`packages` 只返回后台已启用的等级。Flutter 必须动态渲染,不得硬编码 9 个套餐。Fami 初始 VIP1VIP9 均为 `disabled`,运营配置价格并启用前,`packages` 为空是正常结果。
### 相关 IM
无。
### 场景处理
- 购买按钮以 `can_purchase` 为准;`false` 时可用 `purchase_locked_reason` 决定禁用态。
- `benefits` 已是该等级的完整集合。`unlock_level` 仅是配置来源信息,不参与 Flutter 权益计算。
- `current_vip` 是兼容字段,新代码优先使用 `state.effective_vip`
## 7. 当前 VIP 状态
### 接口地址
`GET /api/v1/vip/me`
### 参数
无 query/body 参数。
### 返回值
以下为结构示例,`state``program_config` 实际返回完整字段。
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_02",
"data": {
"level": 8,
"name": "VIP8",
"active": true,
"started_at_ms": 1780100000000,
"expires_at_ms": 1782692000000,
"program_type": "tiered_privilege_v1",
"config_version": 7,
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"level_count": 9,
"same_level_expiry_policy": "extend_remaining",
"upgrade_expiry_policy": "replace_from_now",
"downgrade_purchase_policy": "reject",
"benefit_inheritance_policy": "target_only",
"grant_mode": "trial_card",
"trial_card_enabled": true,
"status": "active",
"config_version": 7
},
"state": {
"paid_vip": {
"level": 3,
"name": "VIP3",
"active": true,
"started_at_ms": 1780000000000,
"expires_at_ms": 1782592000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
},
"equipped_trial_card": {
"trial_card_id": "vip_trial_card_xxx",
"entitlement_id": "ent_xxx",
"resource_id": 108,
"user_id": 10001,
"level": 8,
"name": "VIP8",
"status": "active",
"equipped": true,
"duration_ms": 2592000000,
"effective_at_ms": 1780100000000,
"expires_at_ms": 1782692000000,
"remaining_duration_ms": 2591000000,
"grant_source": "admin_grant",
"source_grant_id": "grant_xxx",
"created_at_ms": 1780100000000,
"updated_at_ms": 1780100000000
},
"effective_vip": {
"level": 8,
"name": "VIP8",
"active": true,
"started_at_ms": 1780100000000,
"expires_at_ms": 1782692000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
},
"effective_source": "trial",
"effective_benefits": [
{
"benefit_code": "room_entry_notice",
"name": "进房高亮通知",
"benefit_type": "function",
"unlock_level": 8,
"status": "active",
"trial_enabled": true,
"resource_id": 0,
"resource_type": "",
"execution_scope": "room",
"auto_equip": false,
"sort_order": 290,
"metadata_json": ""
}
],
"evaluated_at_ms": 1780101000000,
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"config_version": 7
},
"user_settings": {
"app_code": "fami",
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": true,
"updated_at_ms": 0
}
}
}
}
```
`data.level/name/active/...` 是兼容旧版的最终生效 VIP 扁平投影,新 Flutter 代码应读 `data.state`
### 相关 IM
本接口自身不发 IM。收到购买/发放的 `vip_notice` 后,应调用本接口刷新最终状态。
### 场景处理
- `effective_source` 取值为 `paid` / `trial` / `none`
- `paid_vip` 是付费会员事实;`equipped_trial_card` 是当前佩戴卡;`effective_vip` 是页面和房间展示的唯一真值。
- 后台关闭 `trial_card_enabled` 时,`equipped_trial_card` 原始背包事实仍返回,但 `effective_source` 会立即回落为 `paid`Flutter 不得继续沿用旧体验卡权限缓存。
- 当体验卡被佩戴时,不限制卡等级高低,它会覆盖付费 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` 表示用户尚未修改,两个开关使用默认开启值;设置在无 VIP 时也保留,但不会单独授权。
## 8. 购买、续期和升级
### 接口地址
`POST /api/v1/vip/purchase`
### 参数
```json
{
"command_id": "vip_purchase_10001_4_1780100000000",
"level": 4
}
```
| 字段 | 说明 |
|---|---|
| `command_id` | 必填,同一次购买的网络重试必须复用同一值;新的购买动作必须生成新值。 |
| `level` | 必填,目标 VIP 等级,必须来自 `packages`。 |
### 返回值
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_03",
"data": {
"order_id": "vip_order_xxx",
"transaction_id": "tx_xxx",
"vip": {
"level": 4,
"name": "VIP4",
"active": true,
"started_at_ms": 1780100000000,
"expires_at_ms": 1782692000000,
"program_type": "tiered_privilege_v1",
"config_version": 7
},
"coin_spent": 4000,
"coin_balance_after": 6000,
"reward_items": [],
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"upgrade_expiry_policy": "replace_from_now",
"config_version": 7
},
"state": {
"effective_source": "paid",
"paid_vip": {
"level": 4,
"name": "VIP4",
"active": true,
"expires_at_ms": 1782692000000
},
"equipped_trial_card": null,
"effective_vip": {
"level": 4,
"name": "VIP4",
"active": true,
"expires_at_ms": 1782692000000
},
"effective_benefits": [
{
"benefit_code": "custom_room_background",
"status": "active"
}
],
"evaluated_at_ms": 1780100000000,
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"config_version": 7
}
}
}
}
```
### 相关 IM
购买或后台直接激活成功后notice-service 向该用户发送 C2C `TIMCustomElem`
```json
{
"Ext": "vip_notice",
"Desc": "VipActivated",
"Data": "{\"event_type\":\"VipActivated\",\"event_id\":\"evt_xxx\",\"app_code\":\"fami\",\"transaction_id\":\"tx_xxx\",\"command_id\":\"vip_purchase_10001_4_1780100000000\",\"user_id\":\"10001\",\"asset_type\":\"VIP\",\"available_delta\":0,\"frozen_delta\":0,\"source\":\"vip_purchase\",\"action\":\"upgrade\",\"previous_level\":3,\"previous_expires_at_ms\":1781396000000,\"level\":4,\"vip_name\":\"VIP4\",\"started_at_ms\":1780100000000,\"expires_at_ms\":1782692000000,\"program_type\":\"tiered_privilege_v1\",\"config_version\":7,\"expiry_policy\":\"replace_from_now\",\"reward_resource_group_id\":0,\"resource_grant_id\":\"\",\"reward_items\":[],\"created_at_ms\":1780100000000,\"source_created_at_ms\":1780100000000}"
}
```
Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不要用 IM payload 自行拼出最终权益。
### 场景处理
- Fami 无有效会员时:新截止时间 = 购买时间 + `duration_ms`
- Fami 同级续期:新截止时间 = 旧 `expires_at_ms` + `duration_ms`,保留剩余时间。
- Fami 高级升级:新截止时间 = 购买时间 + `duration_ms`,旧低等级剩余时间丢弃。例如 VIP3 剩 15 天购买 VIP4 30 天,结果是 VIP4 30 天。
- 购买更低等级由后端拒绝,错误 `VIP_DOWNGRADE_NOT_ALLOWED`
- 余额不足返回 `INSUFFICIENT_BALANCE`;等级未启用返回 `VIP_LEVEL_DISABLED`
- 如果购买时仍佩戴体验卡,购买结果会写入 `paid_vip`,但当前展示仍可能是 `effective_source="trial"`。购买成功页也必须以返回的 `state.effective_vip` 为准。
## 9. 体验卡背包列表
### 接口地址
`GET /api/v1/users/me/resources?resource_type=vip_trial_card`
### 参数
| 参数 | 位置 | 值 |
|---|---|---|
| `resource_type` | query | 固定 `vip_trial_card` |
### 返回值
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_04",
"data": {
"items": [
{
"entitlement_id": "ent_card_a",
"resource_id": 101,
"resource": {
"resource_id": 101,
"resource_code": "vip_trial_card_3",
"resource_type": "vip_trial_card",
"name": "VIP3体验卡",
"status": "active",
"grantable": true,
"grant_strategy": "new_entitlement",
"usage_scopes": [],
"asset_url": "",
"preview_url": "",
"animation_url": "",
"metadata_json": "{\"vip_level\":3}",
"sort_order": 30
},
"status": "active",
"quantity": 1,
"remaining_quantity": 1,
"effective_at_ms": 1780100000000,
"expires_at_ms": 1781309600000,
"source_grant_id": "grant_a",
"created_at_ms": 1780100000000,
"updated_at_ms": 1780100000000,
"equipped": false
}
],
"total": 1
}
}
```
`metadata_json` 是 JSON 字符串,如需读取 `vip_level` 必须再解析一次;但当前生效等级仍以 `/vip/me` 为准。
### 相关 IM
无。后台发卡目前不会自动佩戴,也没有已定义的体验卡 C2C 消息。
### 场景处理
- 体验卡从发放时的 `effective_at_ms` 开始绝对计时,不是首次佩戴时才开始。
- 切换、卸下都不修改任何卡的 `expires_at_ms`,也不删除旧卡。
- 列表仅返回当前有效的背包实例。卡过期后会自然从 active 列表消失。
- 预置卡素材初始为空Flutter 需提供无图兜底,不得拼接本地固定素材 URL。
## 10. 佩戴体验卡
### 接口地址
`POST /api/v1/vip/trial-cards/{entitlement_id}/equip`
### 参数
- path `entitlement_id`:必须使用背包列表的实例 ID不能使用 `resource_id`
- 无 body。
### 返回值
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_05",
"data": {
"trial_card": {
"trial_card_id": "vip_trial_card_b",
"entitlement_id": "ent_card_b",
"resource_id": 102,
"user_id": 10001,
"level": 4,
"name": "VIP4",
"status": "active",
"equipped": true,
"duration_ms": 1728000000,
"effective_at_ms": 1780200000000,
"expires_at_ms": 1781928000000,
"remaining_duration_ms": 1700000000,
"grant_source": "admin_grant",
"source_grant_id": "grant_b",
"created_at_ms": 1780200000000,
"updated_at_ms": 1780200000000
},
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"trial_card_enabled": true
},
"state": {
"effective_source": "trial",
"effective_vip": {
"level": 4,
"name": "VIP4",
"active": true,
"expires_at_ms": 1781928000000
},
"effective_benefits": []
},
"server_time_ms": 1780228000000
}
}
```
### 相关 IM
无。
### 场景处理
- 任意等级可自由切换,不比较新旧卡高低。
- 切换 A 到 B 后B 成为 `equipped=true`A 仍在背包中A/B 的绝对截止时间均不变。
- 必须直接用返回的 `state` 更新全局 VIP不要等待 IM。
- 倒计时使用 `expires_at_ms - server_time_ms`,不要在每次切换时重置为 `duration_ms`
- Flutter 的 VIP 流程固定调用本接口,不调用通用 `/users/me/resources/{resource_id}/equip`。后端虽兼容通用背包装备并会内部转交同一 VIP 状态机,但通用响应没有完整 `state`;历史通用背包组件若触发成功,必须立即补拉 `GET /vip/me`
## 11. 卸下体验卡
### 接口地址
`DELETE /api/v1/vip/trial-cards/equipped`
### 参数
无 path/query/body 参数。
### 返回值
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_06",
"data": {
"unequipped": true,
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1"
},
"state": {
"effective_source": "paid",
"paid_vip": {
"level": 3,
"name": "VIP3",
"active": true
},
"equipped_trial_card": null,
"effective_vip": {
"level": 3,
"name": "VIP3",
"active": true
},
"effective_benefits": []
},
"server_time_ms": 1780229000000
}
}
```
### 相关 IM
无。
### 场景处理
- Flutter 的 VIP 流程固定调用本接口,不调用通用 `/users/me/resources/vip_trial_card/unequip`。后端对历史通用背包调用同样会转交专用状态机,但通用响应不包含回落后的完整 `state`
- `unequipped=true` 表示本次确实解除了佩戴;重复调用可返回 `false`,仍按成功处理。
- 卸下只删除佩戴关系,不删卡、不暂停卡、不修改卡的截止时间。
- 卸下后优先回落到有效 `paid_vip`;没有付费 VIP 则 `effective_source="none"`
## 12. 经理中心发放 VIP
经理中心页面如果复用 Flutter仍调用原地址不需要按 Fami/Lalu 写分支。gateway 会读取当前 App 的 `program_config.grant_mode`Lalu 调直接会员发放Fami 调体验卡专用发放。
### 接口地址
`POST /api/v1/manager-center/vip-grants`
### 参数
```json
{
"command_id": "mgr_vip_10001_1780300000000",
"target_user_id": "10001",
"level": 5,
"reason": "manager_center"
}
```
- `command_id` 必填且最长 128 字节;重试必须复用同一个值和同一业务参数。
- 经理中心现有权限仍只允许 VIP1VIP5目标用户国家与经理权限校验保持不变。
### 返回值
Lalu`direct_membership`)直接激活会员:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_08a",
"data": {
"grant_mode": "direct_membership",
"transaction_id": "tx_xxx",
"level": 5,
"active": true,
"expires_at_ms": 1782892000000,
"server_time_ms": 1780300000000
}
}
```
Fami`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_08b",
"data": {
"grant_mode": "trial_card",
"transaction_id": "",
"trial_card_id": "vip_card_xxx",
"entitlement_id": "ent_xxx",
"source_grant_id": "grant_xxx",
"level": 5,
"active": false,
"card_status": "active",
"equipped": false,
"expires_at_ms": 1782892000000,
"server_time_ms": 1780300000000
}
}
```
### 相关 IM
无。Fami 发卡成功后,被发放用户下次进入背包或收到后续资源刷新信号时重新拉取资源列表;当前接口不会替用户佩戴。
### 场景处理
- 只根据响应 `grant_mode` 展示“VIP 已生效”或“体验卡已发到背包”,不能仅按 `level` 判断。
- `trial_card` 返回的 `active=false` 表示没有直接激活会员;卡片本身状态看 `card_status`
- 配置恰好在 gateway 查询后被后台切换时wallet 会拒绝旧模式写入Flutter 展示接口错误并允许使用同一 `command_id` 重试。
## 13. 进房通知 IM
### 接口地址
进房仍使用现有 `POST /api/v1/rooms/join`。VIP 展示不新增 Flutter HTTP 请求。
### 参数
沿用现有进房参数:
```json
{
"room_id": "room_1001",
"command_id": "join_10001_room_1001_1780300000000",
"role": "audience",
"password": "",
"response_mode": "full"
}
```
### 返回值
`response_mode=full``response_mode=lite` 均会在进房成功响应的 `data` 中返回当前用户的进房 VIP 快照:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_join_xxx",
"data": {
"result": {
"applied": true,
"room_version": 18,
"server_time_ms": 1780300000000
},
"room": {},
"viewer": {},
"seats": [],
"profiles": [],
"im": {
"group_id": "room_1001",
"need_join_group": true
},
"rtc": {},
"effective_vip": {
"program_type": "tiered_privilege_v1",
"level": 8,
"name": "VIP8",
"room_entry_notice_enabled": true
},
"server_time_ms": 1780300000000
}
}
```
`effective_vip` 是当前进房用户在本次 Join 时的轻量快照,可用于本人首屏。完整会员状态和权益集仍以 `/vip/me.state` 为准。
### 相关 IM
进房成功后只向**当前房间的腾讯 IM 群**发送 `TIMCustomElem`,不发全服群:
```json
{
"Ext": "room_system_message",
"Desc": "room_user_joined",
"Data": "{\"event_id\":\"evt_join_xxx\",\"room_id\":\"room_1001\",\"event_type\":\"room_user_joined\",\"actor_user_id\":10001,\"target_user_id\":10001,\"room_version\":18,\"role\":\"audience\",\"nickname\":\"Nina\",\"avatar\":\"https://cdn.example/avatar.png\",\"display_user_id\":\"100042\",\"pretty_display_user_id\":\"888042\",\"vip_program_type\":\"tiered_privilege_v1\",\"effective_vip_level\":\"8\",\"effective_vip_name\":\"VIP8\",\"room_entry_notice_enabled\":\"true\",\"attributes\":{\"role\":\"audience\",\"nickname\":\"Nina\",\"avatar\":\"https://cdn.example/avatar.png\",\"display_user_id\":\"100042\",\"pretty_display_user_id\":\"888042\",\"vip_program_type\":\"tiered_privilege_v1\",\"effective_vip_level\":\"8\",\"effective_vip_name\":\"VIP8\",\"room_entry_notice_enabled\":\"true\"}}"
}
```
`effective_vip_level``room_entry_notice_enabled` 在 Data 中是字符串Flutter 需做兼容解析。
### 场景处理
- 当前用户进房成功后,可直接用 HTTP `data.effective_vip` 初始化本人进房效果;其他成员的进房展示仍以房间 IM 为准。
- 仅当 `event_type == "room_user_joined"``room_entry_notice_enabled == "true"` 时显示 VIP 进房高亮效果。
- 消息所在 IM GroupID 就是当前 `room_id`;不要转发到 App 全局播报群。
-`event_id` 去重。重连/重复 Join 仍可收到进房展示事件,不要以本地 presence 猜测是否展示。
- 进房 VIP 查询失败时后端会降级为普通进房,不阻断进房主链路。
## 14. VIP 通知开关
### 接口地址
- `GET /api/v1/vip/settings`
- `PATCH /api/v1/vip/settings`
`GET /api/v1/vip/me``data.state.user_settings` 与 GET 接口是同一份事实。VIP 页面已经拉取 `/vip/me` 时,不必再为展示开关多发一次请求。
### 参数
GET 无参数。PATCH 是局部更新,两个字段至少传一个;显式 `false` 不能被当成未传:
```json
{
"room_entry_notice_enabled": false
}
```
```json
{
"online_global_notice_enabled": true
}
```
Flutter 不传 `app_code``user_id`,后端只从登录态解析。空对象 `{}` 返回 `INVALID_ARGUMENT`
### 返回值
GET
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_settings_get",
"data": {
"settings": {
"app_code": "fami",
"user_id": 10001,
"room_entry_notice_enabled": true,
"online_global_notice_enabled": false,
"updated_at_ms": 1780300000000
},
"evaluated_at_ms": 1780300001000
}
}
```
PATCH
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_settings_patch",
"data": {
"settings": {
"app_code": "fami",
"user_id": 10001,
"room_entry_notice_enabled": false,
"online_global_notice_enabled": false,
"updated_at_ms": 1780300010000
},
"server_time_ms": 1780300010000
}
}
```
用户从未修改过时,两项默认为 `true``updated_at_ms=0`。PATCH 只改本次提交的字段,不会覆盖另一项。
### 相关 IM
修改开关本身不发 IM。进房开关会在下一次 Join 时影响当前房间 `room_user_joined`;上线开关会在触发 `/vip/online-notice` 时由服务端再校验。
### 场景处理
- 开关是用户偏好,不是权益;无 VIP 时也可保存,但不会产生进房或上线播报。
- 进房展示必须同时满足 `effective_benefits``room_entry_notice``room_entry_notice_enabled=true`
- 上线展示必须同时满足 `online_global_notice` 权益和开关;最终以触发接口的服务端判定为准。
- 设置按 App 隔离Fami 的修改不影响 Lalu。
## 15. 每日 VIP 金币返现
### 接口地址
- `GET /api/v1/vip/coin-rebates/current`:查当前 UTC 日资格。
- `POST /api/v1/vip/coin-rebates/statuses`:批量恢复系统消息中的真实状态,或分页查历史。
- `POST /api/v1/vip/coin-rebates/{rebate_id}/claim`:手动领取。
- `GET /api/v1/messages?section=system&page_size=20&page_token=`查“VIP金币返现”系统消息。
### 参数
current 无 query/body。statuses 有两种用法:
```json
{
"rebate_ids": ["vip_rebate_xxx", "vip_rebate_yyy"],
"page": 1,
"page_size": 20
}
```
`rebate_ids` 最多 100 个,后端只返回当前登录用户拥有的记录。传空数组或不传时使用 `page/page_size` 查历史,`page_size` 最大 100。
claim
```json
{
"command_id": "vip_rebate_claim_10001_20260710"
}
```
`rebate_id` 只来自 current/statuses/系统消息。Flutter 不传金额、VIP 等级、`app_code``user_id`
### 返回值
current 已生成资格:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_rebate_current",
"data": {
"found": true,
"rebate": {
"rebate_id": "vip_rebate_xxx",
"user_id": "10001",
"task_day": "2026-07-10",
"vip_level": 6,
"vip_name": "VIP6",
"coin_amount": 100,
"status": "claimable",
"available_at_ms": 1783641600000,
"expires_at_ms": 1783728000000,
"config_version": 8,
"claimed_at_ms": 0,
"wallet_transaction_id": "",
"created_at_ms": 1783641605000,
"updated_at_ms": 1783641605000
},
"server_time_ms": 1783641610000
}
}
```
无资格或 cron 尚未生成时也是成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_rebate_none",
"data": {
"found": false,
"rebate": null,
"server_time_ms": 1783641610000
}
}
```
statuses
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_rebate_statuses",
"data": {
"rebates": [
{
"rebate_id": "vip_rebate_xxx",
"user_id": "10001",
"task_day": "2026-07-10",
"vip_level": 6,
"vip_name": "VIP6",
"coin_amount": 100,
"status": "claimable",
"available_at_ms": 1783641600000,
"expires_at_ms": 1783728000000,
"config_version": 8,
"claimed_at_ms": 0,
"wallet_transaction_id": "",
"created_at_ms": 1783641605000,
"updated_at_ms": 1783641605000
}
],
"total": 1,
"server_time_ms": 1783641610000
}
}
```
claim
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_rebate_claim",
"data": {
"rebate": {
"rebate_id": "vip_rebate_xxx",
"user_id": "10001",
"task_day": "2026-07-10",
"vip_level": 6,
"vip_name": "VIP6",
"coin_amount": 100,
"status": "claimed",
"available_at_ms": 1783641600000,
"expires_at_ms": 1783728000000,
"config_version": 8,
"claimed_at_ms": 1783641700000,
"wallet_transaction_id": "wallet_tx_xxx",
"created_at_ms": 1783641605000,
"updated_at_ms": 1783641700000
},
"transaction_id": "wallet_tx_xxx",
"coin_balance": {
"asset_type": "COIN",
"available_amount": 20100,
"frozen_amount": 0,
"version": 18
},
"server_time_ms": 1783641700000
}
}
```
`status` 只处理 `claimable` / `claimed` / `expired``expired` 是 wallet 根据当前 UTC 时间的投影,不要仅凭手机时钟自行改状态。
### 相关 IM
返现可领取通知进入后端 system inbox不新增腾讯云 `TIMCustomElem``GET /messages?section=system` 的条目示例:
```json
{
"message_id": "msg_vip_rebate_xxx",
"title": "VIP金币返现",
"summary": "尊敬的VIP用户平台今日返现100金币请及时领取。",
"body": "尊敬的VIP用户平台今日返现100金币请及时领取。",
"icon_url": "",
"image_url": "",
"action_type": "vip_coin_rebate_claim",
"action_param": "{\"coin_amount\":100,\"expires_at_ms\":1783728000000,\"rebate_id\":\"vip_rebate_xxx\",\"task_day\":\"2026-07-10\"}",
"read": false,
"sent_at_ms": 1783641600000
}
```
Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有 `rebate_id` 合并调 statuses用后端状态刷新“去领取/已领取/已过期”。
### 场景处理
- 资格按 UTC 0 点时的有效付费 VIP 还原0 点后才购买或升级的状态从次日参与,体验卡不参与。
- 领取窗口是 `[UTC 当日0点,UTC 次日0点)`。到次日0点后服务端返回 `VIP_COIN_REBATE_EXPIRED`,不再入账。
- 金额取当日首个成功 cron run 固化的后台配置;同一 run 不会因运营中途改价而分裂。
- 领取按钮要防连点,网络重试复用同一 `command_id`。同一返现换新 `command_id` 重复提交也只返回原成功回执,不会重复加币。
- 领取成功后用 `data.coin_balance` 替换 COIN 余额快照,不在 Flutter 本地做 `balance += coin_amount`
- 记录不存在或不属于当前用户时返回 `VIP_COIN_REBATE_NOT_FOUND`;复用一个 `command_id` 改投另一笔返现时返回 `IDEMPOTENCY_CONFLICT`
## 16. 上线全服通知
### 接口地址
`POST /api/v1/vip/online-notice`
调用前先完成 `GET /api/v1/im/usersig` 的 IM 登录,并按 `join_groups` 加入 `type=global_broadcast` 的全局播报群。Flutter 只请求后端发送,不向全局群自行发消息。
### 参数
```json
{
"command_id": "vip_online_550e8400-e29b-41d4-a716-446655440000_2026-07-10"
}
```
`command_id` 最长 128用“进程启动唯一 ID + UTC 日期”生成。同一进程当日网络重试必须复用同一值。
### 返回值
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_online_notice",
"data": {
"event_id": "vip_online_notice:10001:sha256_digest",
"group_id": "hy_fami_bc_g_v2",
"status": "pending",
"created": true,
"evaluated_at_ms": 1783641610000
}
}
```
`status=pending` 表示已持久化到 activity 播报 outbox腾讯云 IM 由 worker 异步投递。相同 `command_id` 重试返回同一 `event_id``created=false` 是幂等命中,不是失败。
### 相关 IM
全局播报群下发 `TIMCustomElem`
| 字段 | 值 |
|---|---|
| `Ext` | `im_broadcast` |
| `Desc` | `vip_online_notice` |
| `Data` | 下方 JSON 字符串 |
| `CloudCustomData` | 与 `Data` 相同的 JSON 字符串 |
```json
{
"event_id": "vip_online_notice:10001:sha256_digest",
"broadcast_type": "vip_online_notice",
"scope": "global",
"app_code": "fami",
"user_id": 10001,
"sender_user_id": 10001,
"nickname": "Nina",
"avatar": "https://cdn.example/avatar.png",
"avatar_frame_url": "https://cdn.example/frame.png",
"avatar_frame_resource_id": 108,
"avatar_frame_code": "frame_vip9",
"avatar_frame_asset_url": "https://cdn.example/frame.png",
"avatar_frame_preview_url": "https://cdn.example/frame-preview.png",
"avatar_frame_animation_url": "https://cdn.example/frame-animation.svga",
"display_user_id": "888042",
"raw_display_user_id": "100042",
"pretty_display_user_id": "888042",
"vip_program_type": "tiered_privilege_v1",
"vip_level": 9,
"vip_name": "VIP9",
"message": "欢迎进入Fami祝你有美好的一天",
"action": {
"type": "none"
},
"sent_at_ms": 1783641610500
}
```
`display_user_id` 优先美号,无美号时回落 `raw_display_user_id``avatar_frame_url` 是兼容字段,固定等于 `avatar_frame_asset_url`;无当前佩戴头像框时 ID 为 `0`、URL/code 为空字符串。用户资料、VIP 等级和头像框都由服务端取当前事实Flutter 不能用请求伪造。用 `event_id` 做会话内去重。
### 场景处理
- 在进程内保存 `process_boot_id`、当前 UTC 日和该日 `command_id`,不写 SharedPreferences/数据库/磁盘文件。
- 同进程同 UTC 日首次进入 App`/vip/me` 中具有 `online_global_notice` 资格、`online_global_notice_enabled=true` 时触发。成功后当日不再产生新 command。
- 超时/5xx 可用同一 `command_id` 重试;不能在同进程同日换 command 规避幂等。
- 杀掉进程后内存清空,新进程产生新 `process_boot_id``command_id`,同日可再播一次,这是已确认产品规则。
- 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `PERMISSION_DENIED`Flutter 不做本地飘屏,可重拉 `/vip/me`
- 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。
## 17. 权益场景和完成边界
| 场景 | Flutter 处理 | 当前后端状态 |
|---|---|---|
| App 维度 VIP 体系 | 始终渲染 `program_config` 和接口返回的动态等级,不按 Fami/Lalu 写死业务分支 | 已强制。Fami 为 `tiered_privilege_v1`Lalu 为 `legacy_timed` |
| Fami 高级升级 | 直接展示购买响应的新截止时间 | 已强制,旧低级剩余时间丢弃 |
| 同级续期 | 不在本地计算,以响应为准 | 已强制,从旧截止时间累加 |
| 体验卡自由切换 | 按 `entitlement_id` 佩戴,每次直接替换全局 `state` | 已强制,不限等级,不作废旧卡,不改绝对截止时间 |
| 体验卡权益 | 只渲染 `effective_benefits` 资格 | 已强制过滤 `trial_enabled=false`,金币返现不给体验卡 |
| VIP 通知开关 | 读 `state.user_settings`,用 PATCH 局部修改 | 已按 App 持久化,无记录默认两项开启,开关不单独授权 |
| 进房高亮通知 | 本人首屏读 Join HTTP `effective_vip`,其他成员消费当前房间 `room_user_joined` | 后端已同时校验权益与用户开关,并透传 Join HTTP 和当前房间 IM不发全服 |
| 防踢 | 房管操作返回 `PERMISSION_DENIED` 时显示不可踢,不做本地预判 | 已在 room-service 强制;只阻止普通房主/管理员的 KickUser |
| 防禁言 | 房管操作返回 `PERMISSION_DENIED` 时显示不可禁言,解禁始终可发起 | 已在 room-service 强制;只阻止普通房主/管理员新增禁言 |
| 平台封禁/风控/超级管理 | Flutter 不作 VIP 免责提示 | 独立 `SystemEvictUser` 治理链路会绕过防踢VIP 不能绕过平台治理 |
| 自定义房间背景 | 按现有房间背景页面调用 | `tiered_privilege_v1` 已由 room-service 强制校验 `custom_room_background` |
| 金币返现 | 消费 system inbox批量恢复状态手动领取后使用响应余额 | 已完成 UTC 日资格、异步系统消息、24 小时窗口和 wallet 原子入账;体验卡不参与 |
| 上线全服通知 | 按进程内 UTC 日规则请求后端,消费全局播报群 `vip_online_notice` | 已完成权益/开关校验、服务端资料与头像框、activity outbox 和腾讯云 IM服务端不做日级去重 |
| 其他 VIP 装扮/功能 | 可根据 `effective_benefits` 展示锁定/解锁态;有素材时使用后端 URL | 权益矩阵已配置,但不等于各 owner 均已强制。Fami 初始 `resource_id=0`、素材为空,未绑定 owner/资源前不能宣称已实现 |
## 18. Flutter 建议状态流
1. 进 VIP 页并发请求 `/vip/packages``/vip/me`,套餐用前者,用户生效状态用后者。
2. 购买成功、佩戴成功、卸下成功时,立即用响应中的 `state` 替换全局 VIP store。
3. App 冷启动、登录成功、回前台以及收到 `vip_notice` 后调 `/vip/me` 校准;同时更新 `user_settings`
4. 背包页用 `vip_trial_card` 列表展示每张独立卡;当前佩戴态以 `/vip/me.state.equipped_trial_card.entitlement_id` 为准。
5. 所有房间、个人页和消息展示都使用 `effective_vip`,不要用 `paid_vip` 覆盖体验卡展示。
6. system inbox 遇到 `action_type=vip_coin_rebate_claim` 时,批量用 statuses 恢复真实状态;领取成功后替换余额与返现状态。
7. IM 登录并加入全局播报群后,按进程内 UTC 日规则触发 `/vip/online-notice`;收到 `vip_online_notice` 时用 `event_id` 去重。
8. 修改通知开关成功后,用 PATCH 响应替换本地 `user_settings`,不修改 `effective_benefits`