183 lines
12 KiB
Markdown
183 lines
12 KiB
Markdown
# 可配置 VIP 系统
|
||
|
||
## 目标与边界
|
||
|
||
VIP 是 `wallet-service` 按 `app_code` 隔离的会员与权益域。每个 App 通过一份 `vip_program_configs` 选择等级数量、购买有效期策略、后台发放模式和体验卡开关;gateway、room、admin 和客户端只能消费这份显式配置,不得通过 `app_code` 写第二套状态机。
|
||
|
||
- `wallet-service`:会员、体验卡、用户功能开关、权益矩阵、购买扣费、每日返现、资源发放、历史和 outbox 的 owner。
|
||
- `gateway-service`:App HTTP 鉴权、协议转换和钱包 gRPC 编排。
|
||
- `room-service`:只执行房间权限并通过腾讯云 IM 投递当前房间事件,不能拥有会员状态。
|
||
- `server/admin`:配置 program、等级和每级完整权益,发放真实 VIP 或体验卡。
|
||
- Flutter:展示后端状态、操作背包佩戴并处理客户端生命周期效果;不参与购买规则计算。
|
||
|
||
全链路业务时间使用 UTC epoch milliseconds。每日结算或刷新按 UTC 0 点切日;用户本地时间只用于展示。
|
||
|
||
## 当前 App 策略
|
||
|
||
| 配置 | Lalu | Fami |
|
||
| --- | --- | --- |
|
||
| `program_type` | `legacy_timed` | `tiered_privilege_v1` |
|
||
| `level_count` | 10 | 9 |
|
||
| 同级购买 | 从当前截止时间续期 | 从当前截止时间续期 |
|
||
| 高级升级 | 保留剩余时间后追加新时长 | 立即替换为高级,从购买时刻重新计时,旧等级剩余时间丢弃 |
|
||
| 低级购买 | 拒绝 | 拒绝 |
|
||
| 后台发放 | `direct_membership`,直接写真实 VIP | `trial_card`,发到背包后由用户佩戴 |
|
||
| 体验卡 | 关闭 | 开启 |
|
||
| 权益集合 | 每级显式保存 | 每级显式保存完整集合,不按 `unlock_level` 自动继承 |
|
||
|
||
Fami 的 VIP1–VIP9 初始全部为 `disabled`。30 天只是不产生零时长脏配置的占位值;价格和资源组保持 0,运营完成配置并启用前不可购买或发卡。
|
||
|
||
## 数据模型
|
||
|
||
### App 级 program
|
||
|
||
`vip_program_configs` 是行为规则的唯一来源:
|
||
|
||
- `program_type`
|
||
- `level_count`
|
||
- `same_level_expiry_policy`
|
||
- `upgrade_expiry_policy`
|
||
- `downgrade_purchase_policy`
|
||
- `benefit_inheritance_policy`,当前固定 `target_only`
|
||
- `grant_mode`
|
||
- `trial_card_enabled`
|
||
- `status`
|
||
- `config_version`
|
||
|
||
后台修改 program、等级或权益时递增 `config_version`。会员、订单和体验卡保留创建时的 program/version 快照,当前权限判断使用最新有效 program 和等级显式权益集合。
|
||
|
||
### 等级和权益
|
||
|
||
- `vip_levels`:等级名称、状态、金币价格、时长、可选资源组和排序。
|
||
- `vip_level_benefits`:以 `(app_code, level, benefit_code)` 保存该等级最终完整权益集合。
|
||
|
||
`unlock_level` 只记录 P1 初始化来源,运行时和后台保存都不能据此给高等级自动补权益。纯功能权益的 `resource_id=0`;装扮权益可以绑定同 App 的实际资源。
|
||
|
||
### 付费会员
|
||
|
||
`user_vip_memberships` 保存用户付费会员事实。有效条件是 `level > 0 && expires_at_ms > now_ms`;不依赖异步过期任务才能失效。
|
||
|
||
购买事务同时完成:
|
||
|
||
1. 锁定 program、目标等级和当前会员;
|
||
2. 根据 program 计算有效期,拒绝降级;
|
||
3. 锁定 COIN 账户并扣费;
|
||
4. 发放目标资源组;
|
||
5. 更新会员,写订单、历史和 wallet outbox;
|
||
6. 一次事务提交。
|
||
|
||
`command_id` 是购买幂等键,`request_id` 仅用于链路追踪。
|
||
|
||
`vip_command_locks` 在任何会员、账户或资源业务锁之前串行化 `(app_code, command_id)`。购买、直接会员赠送和体验卡发放共享该守卫空间;同命令并发重试会等待原事务提交后读取稳定回执,三种业务也不能交叉复用同一命令号。
|
||
|
||
### 体验卡
|
||
|
||
体验卡使用三层事实:
|
||
|
||
- `resources(resource_type=vip_trial_card)`:卡面与等级元数据;
|
||
- `user_resource_entitlements`:背包实例和绝对有效期;
|
||
- `user_resource_equipment`:当前单选佩戴指针;
|
||
- `user_vip_trial_cards`:等级、来源、时长和 program/version 快照。
|
||
|
||
发卡时即写入 `effective_at_ms` 和 `expires_at_ms`,但不自动佩戴。切换卡片只替换 `user_resource_equipment` 指针,不修改、删除、暂停或延长任一卡片。
|
||
|
||
例如 A=14 天、B=20 天、C=30 天:佩戴 A 后立刻切 B,B 生效,A 仍保留原绝对到期时间;之后只要 A 未过期即可重新佩戴。体验卡等级不受当前付费 VIP 或上一张体验卡等级限制。
|
||
|
||
### 用户功能开关
|
||
|
||
`user_vip_settings` 按 `(app_code,user_id)` 保存两个用户偏好:
|
||
|
||
- `room_entry_notice_enabled`:进房 VIP 高亮通知;
|
||
- `online_global_notice_enabled`:上线 VIP 全服飘屏。
|
||
|
||
无记录时两项默认为 `true`,首次修改才写行。偏好不授予权益:无 VIP 或对应等级没有权益时,即使开关为开也不会放行。用户可在无 VIP、VIP 过期或切换体验卡时保留偏好;Fami 与 Lalu 互不影响。
|
||
|
||
### 每日金币返现
|
||
|
||
`daily_coin_rebate` 的 `metadata_json.coin_amount` 是该等级每日可领金币正整数。只有启用的付费 VIP 参与,体验卡即使历史配置误开 `trial_enabled` 也会被 wallet 二次排除。
|
||
|
||
每个 App 每个 UTC 日期只有一个 `vip_daily_coin_rebate_runs`:
|
||
|
||
1. 首个成功批次固化当时的 `config_version` 和等级金额矩阵;后续分页不重读新配置。
|
||
2. wallet 根据会员历史还原 UTC 0 点时的付费 VIP;0 点后才购买或升级的状态从次日起参与。
|
||
3. 生成 `vip_daily_coin_rebates` 资格,领取窗口固定为 `[UTC 当日0点,UTC 次日0点)`。
|
||
4. 同事务写 `VipDailyCoinRebateAvailable` outbox;activity-service 幂等投影为“VIP金币返现”系统消息。
|
||
5. 用户手动领取时,返现行锁、COIN 余额、交易、分录和 `WalletBalanceChanged` outbox 在一个事务内提交。
|
||
|
||
“日切资格”严格按 UTC 0 点;“金额配置”是当日首个成功 run 的快照,不伪造系统尚未保存的 0 点历史配置。正常情况下 cron 每分钟触发;如零点后 wallet/cron 长时间不可用,恢复前的后台金额修改会进入当日快照。运营若要保证修改次日生效,应在当日 run 已创建后修改。
|
||
|
||
## 最终生效状态
|
||
|
||
所有调用方统一消费 `VipState`:
|
||
|
||
- `paid_vip`:真实购买会员;
|
||
- `equipped_trial_card`:当前有效且已佩戴的体验卡;
|
||
- `effective_vip`:最终展示和权限使用的 VIP;
|
||
- `effective_source`:`paid`、`trial` 或 `none`;
|
||
- `effective_benefits`:最终身份拥有的权益资格;可关闭的通知类权益还要与 `user_settings` 做 AND;
|
||
- `program_config`:当前 App 规则;
|
||
- `user_settings`:当前 App 下的进房/上线通知偏好。
|
||
|
||
program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡有效并已佩戴时覆盖展示;否则使用有效付费会员;二者都无效则为 `none`。关闭体验卡开关只停止它参与最终权限合并,背包和佩戴原始事实仍保留;关闭整个 program 时付费与卡片原始事实仍可查询,但最终身份为 `none`、不下发权益。
|
||
|
||
钱包只读取 `effective_vip.level` 对应的显式权益行。来源为 `trial` 时,再移除 `trial_enabled=false` 的权益;当前 `daily_coin_rebate` 不对体验卡开放。owner service 必须按 `benefit_code` 判断,不能改回 `level >= N`;进房和上线通知的执行方必须使用 wallet 已合并用户开关的结果。
|
||
|
||
## P1 权益编码
|
||
|
||
| 等级 | 本级新增权益 |
|
||
| --- | --- |
|
||
| VIP1 | `vip_badge_identity`、`vip_medal`、`avatar_frame`、`vip_title` |
|
||
| VIP2 | `chat_bubble`、`vip_gift`、`entry_effect`、`visitor_history` |
|
||
| VIP3 | `voice_wave`、`profile_card`、`custom_room_background`、`room_image_message` |
|
||
| VIP4 | `animated_avatar`、`animated_room_cover`、`mic_skin`、`room_border`、`vehicle` |
|
||
| VIP5 | `colored_room_name`、`colored_nickname`、`colored_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` |
|
||
|
||
初始化时高等级行已展开为完整集合,但这是 seed 结果,不是运行时继承规则。后台可以显式移除任一等级的某项权益。
|
||
|
||
## 房间权限和 IM
|
||
|
||
`room-service` 在需要房间权限时读取 wallet 的 `GetMyVip.state`:
|
||
|
||
- `custom_room_background`:P1 program 的房主保存或切换自定义背景前强校验;Lalu 保持原逻辑。
|
||
- `anti_kick`:普通房主/管理员踢人时拒绝操作。
|
||
- `anti_mute`:普通房主/管理员新增禁言时拒绝操作。
|
||
- 平台封禁、风控、超级管理员使用独立系统治理入口,不经过上述普通房管守卫。
|
||
- `room_entry_notice`:权益有效且用户开关开启时,随 `JoinRoomResponse`、`RoomUserJoined` 和腾讯云 IM 当前房间系统消息携带;只在用户进入的当前房间展示,不做全服广播。
|
||
|
||
`online_global_notice` 由 `POST /vip/online-notice` 触发:gateway 先调用 wallet `CheckVipBenefit`,同时校验权益资格和用户开关;然后从 user-service 取服务端用户资料,最后写 activity-service 全局播报 outbox。腾讯云 IM 使用 `TIMCustomElem`,`Ext=im_broadcast`、`Desc=vip_online_notice`、`broadcast_type=vip_online_notice`、`scope=global`。Flutter 在进程内按 UTC 日控制触发:同一进程每天最多触发一次,不持久化该标记;杀进程重启后允许再次触发。服务端不做用户日级去重,但相同 `command_id` 的 HTTP 重试映射到同一 `event_id`。Flutter 不能直接向全服群发消息。
|
||
|
||
## App HTTP API
|
||
|
||
统一前缀 `/api/v1`,响应使用 `{code,message,request_id,data}`:
|
||
|
||
- `GET /vip/packages`:program、可购买等级、每级权益和完整 state。
|
||
- `GET /vip/me`:付费、体验卡、最终 VIP、权益资格和用户功能开关。
|
||
- `POST /vip/purchase`:请求 `{command_id,level}`;价格、时长和资源由服务端读取。
|
||
- `GET|PATCH /vip/settings`:读取或局部修改进房/上线通知偏好;空 PATCH 拒绝。
|
||
- `POST /vip/online-notice`:请求 `{command_id}`,服务端校验权益、开关和资料后写全服播报 outbox。
|
||
- `GET /vip/coin-rebates/current`:返回当前 UTC 日返现资格;无资格或 cron 尚未生成时 `found=false`。
|
||
- `POST /vip/coin-rebates/statuses`:按最多 100 个 `rebate_ids` 恢复系统消息真实状态,或用受限分页查历史。
|
||
- `POST /vip/coin-rebates/{rebate_id}/claim`:请求 `{command_id}`,金额、归属、过期和入账全由 wallet 裁决。
|
||
- `GET /users/me/resources?resource_type=vip_trial_card&active_only=true`:查询背包体验卡资源实例。
|
||
- `POST /vip/trial-cards/{entitlement_id}/equip`:佩戴指定背包实例。
|
||
- `DELETE /vip/trial-cards/equipped`:卸下当前体验卡。
|
||
- 历史通用背包装备/卸下接口命中 `vip_trial_card` 时由 wallet service 委托上述专用状态机,禁止仓储层直接改装备槽;Flutter VIP 流程仍使用专用接口以一次取得完整 `VipState`。
|
||
- `POST /manager-center/vip-grants`:gateway 按当前 program 自动选择直接会员或 30 天体验卡发放;Fami 发卡不自动佩戴。
|
||
|
||
后台接口:
|
||
|
||
- `GET|PUT /v1/admin/activity/vip-program`
|
||
- `GET|PUT /v1/admin/activity/vip-levels`
|
||
- `POST /v1/admin/activity/vip-trial-card-grants`
|
||
- `POST /v1/admin/activity/vip-grants`,仅用于 `direct_membership` program
|
||
|
||
## 权益落地边界
|
||
|
||
当前服务端已强制执行购买有效期、体验卡状态合并、用户通知开关、每日金币返现资格/消息/手动领取入账、自定义房间背景、防踢、防禁言、房内进场通知和上线全服通知。装扮类权益由已绑定的钱包资源与 Flutter 展示链路执行。
|
||
|
||
以下权益仍只有稳定编码和资格下发,不能仅凭本架构宣称业务已完整闭环:访客匿名/隐藏、榜单隐身、房间发图、动态媒体、彩色样式和礼物托盘等。各 owner 实现时必须调用 `CheckVipBenefit` 或消费 `effective_benefits`,并补自己的存储、审计、风控和边界测试。
|