# 可配置 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`，并补自己的存储、审计、风控和边界测试。
