hyapp-server/docs/vip-system-architecture.md
2026-05-09 13:36:41 +08:00

462 lines
18 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 系统架构设计
## 目标
VIP 是用户可直接用金币购买的限时会员权益。每个 VIP 等级都可以单独购买,单次购买有效期固定 7 天,不同等级价格不同。购买成功后,系统根据后台配置的资源组发放头像框、座驾、气泡、徽章、礼物等权益。
首版只做当前业务真实需要的能力:
- 用户可以购买任意有效 VIP 等级。
- 用户可以从低等级升级到高等级。
- 用户当前 VIP 未过期时,不能购买比当前等级低的 VIP。
- 同等级重复购买按续期处理。
- VIP 扣金币、会员状态变更、资源组发放必须在 `wallet-service` 内形成一致账务闭环。
- 后台只配置 VIP 等级、金币价格、有效期和赠送资源组;客户端不能传价格或资源明细。
## 服务边界
VIP 属于 `wallet-service` 的账务和权益域,不放在 `user-service`
原因:
- 购买 VIP 必须先扣金币,扣费成功才允许生成 VIP 状态和权益。
- 购买 VIP 会发放后台资源组,资源组、资源权益和钱包资产已经归 `wallet-service` 管。
- 如果 VIP 状态放在 `user-service`,扣费、会员状态、资源发放会跨服务,需要分布式事务或补偿,复杂度和失败窗口都更大。
服务职责:
- `gateway-service`:提供 App HTTP API做鉴权、参数校验、request_id/command_id 透传,调用 `wallet-service`
- `wallet-service`:拥有 VIP 等级配置、用户 VIP 状态、VIP 购买订单、金币扣费、资源组发放、VIP 事件 outbox。
- `user-service`:只在用户资料聚合时读取 VIP 摘要,不拥有 VIP 状态。
- `room-service`:需要展示 VIP 标识时读取用户资料/VIP 投影,不参与 VIP 购买主链路。
- `server/admin`:后台配置 VIP 等级和资源组,具体后台接口单独设计。
## 核心模型
### VIP 等级
`vip_levels`
| 字段 | 说明 |
| -------------------------- | -------------------------------------------------- |
| `id` | 内部主键 |
| `level` | VIP 等级,数字越大等级越高 |
| `name` | 展示名称,例如 VIP1、VIP2 |
| `coin_price` | 购买该等级需要消耗的金币数 |
| `duration_ms` | 有效期,首版固定 `7 * 24 * 60 * 60 * 1000` |
| `reward_resource_group_id` | 购买成功后发放的资源组 |
| `status` | `draft` / `active` / `disabled` |
| `sort_order` | App 展示排序 |
| `display_config_json` | 展示配置快照,例如颜色、权益说明、角标,不参与结算 |
| `created_at_ms` | 创建时间 |
| `updated_at_ms` | 更新时间 |
约束:
- `level` 唯一。
- `coin_price > 0`
- `duration_ms` 首版必须等于 7 天,不允许后台临时改成其它值。
- `active` 状态必须绑定有效资源组。
### 用户 VIP 当前状态
`user_vip_memberships`
| 字段 | 说明 |
| --------------- | ---------------------------- |
| `user_id` | 用户 ID唯一 |
| `level` | 当前等级 |
| `status` | `active` / `expired` |
| `started_at_ms` | 当前等级周期开始时间 |
| `expires_at_ms` | 当前 VIP 到期时间 |
| `last_order_id` | 最近一次购买订单 |
| `version` | 乐观版本,配合行锁防并发覆盖 |
| `created_at_ms` | 创建时间 |
| `updated_at_ms` | 更新时间 |
读取时以 `expires_at_ms > now` 判断是否有效,`status` 只作为后台检索和事件处理的辅助状态。这样即使过期任务延迟App 也不会把过期 VIP 当成有效权益。
### VIP 购买订单
`vip_purchase_orders`
| 字段 | 说明 |
| ---------------------------- | --------------------------------- |
| `order_id` | VIP 订单号 |
| `command_id` | 客户端生成的幂等命令 ID |
| `user_id` | 购买用户 |
| `target_level` | 目标 VIP 等级 |
| `coin_price_snapshot` | 下单时价格快照 |
| `duration_ms_snapshot` | 下单时有效期快照 |
| `resource_group_id_snapshot` | 下单时资源组快照 |
| `previous_level` | 购买前有效等级,无有效 VIP 则为 0 |
| `previous_expires_at_ms` | 购买前到期时间 |
| `new_level` | 购买后等级 |
| `new_expires_at_ms` | 购买后到期时间 |
| `wallet_transaction_id` | 金币扣费交易 ID |
| `resource_grant_id` | 资源组发放记录 ID |
| `status` | `succeeded` / `failed` |
| `failure_reason` | 失败原因 |
| `request_hash` | 幂等请求摘要 |
| `created_at_ms` | 创建时间 |
| `updated_at_ms` | 更新时间 |
约束:
- `(user_id, command_id)` 唯一。
- 相同 `command_id` + 相同 `request_hash` 返回原订单结果。
- 相同 `command_id` + 不同 `request_hash` 返回幂等冲突。
### VIP 变更历史
`user_vip_history`
记录每次购买、续期、升级、过期,供客服、后台账单、风控排查使用。历史表不参与 App 主链路强查询。
核心字段:
- `history_id`
- `user_id`
- `event_type`: `purchase` / `renew` / `upgrade` / `expire`
- `from_level`
- `to_level`
- `from_expires_at_ms`
- `to_expires_at_ms`
- `order_id`
- `created_at_ms`
## 购买和升级规则
定义:
- 当前有效 VIP`membership.expires_at_ms > now && membership.level > 0`
- 目标等级:用户本次购买的 `vip_level`
规则:
| 场景 | 处理 |
| ---------------------- | ------------------------------------------------------------------------ |
| 无有效 VIP | 可以购买任意 active 等级,`expires_at = now + 7d` |
| 有效 VIP购买同等级 | 续期,`expires_at = current_expires_at + 7d` |
| 有效 VIP购买更高等级 | 立即升级,`level = target_level``expires_at = current_expires_at + 7d` |
| 有效 VIP购买更低等级 | 拒绝,返回 `VIP_DOWNGRADE_NOT_ALLOWED` |
| VIP 已过期 | 按无有效 VIP 处理,可以购买任意 active 等级 |
首版升级采用“保留剩余时间并升级”的策略:如果用户 VIP1 还剩 3 天,又购买 VIP3则 VIP3 到期时间为当前 VIP1 到期时间再加 7 天。这个规则实现简单、用户感知清晰,也避免升级时损失已付费时长。
如果后续业务希望“补差价升级”或“升级只生效 7 天”,只需要扩展 VIP 等级的升级策略,不改变主链路表结构。
## 资源组发放规则
购买 VIP 成功后,`wallet-service` 根据 `vip_levels.reward_resource_group_id` 发放资源组。
资源组继续复用现有资源组能力:
- 头像框:`avatar_frame`
- 座驾:`vehicle`
- 聊天气泡:`chat_bubble`
- 徽章:`badge`
- 飘屏:`floating_screen`
- 礼物:`gift`
- 金币等钱包资产:仅在明确配置时发放,默认不建议 VIP 购买返金币,避免账务绕圈。
VIP 资源组需要增加一个明确约束:
- VIP 专属装扮类资源默认与 VIP 到期时间对齐。
- 资源组 item 如果配置了独立 `duration_ms`,后台必须能看到它和 VIP 7 天周期的差异。
- 首版推荐新增 `duration_policy = inherit_vip_expiry`,购买 VIP 时将资源权益到期时间直接设置为 `new_expires_at_ms`
续期和升级时:
- 同一资源重复发放时,使用资源权益的 `extend_expiry``align_to_expiry` 策略,避免重复插入导致用户出现多个同资源有效权益。
- 升级后只发放目标等级配置的资源组;低等级已获得的资源不立即删除,到期后自然失效。
- App 展示和自动佩戴时按用户当前有效资源、资源等级和佩戴状态决定,不从 VIP 等级反推资源。
## 主链路流程
```mermaid
sequenceDiagram
autonumber
participant App
participant Gateway as gateway-service
participant Wallet as wallet-service
participant DB as Wallet MySQL
participant Outbox as Wallet Outbox
App->>Gateway: POST /api/v1/vip/purchase {level, command_id}
Gateway->>Wallet: PurchaseVip(user_id, level, command_id, request_meta)
Wallet->>DB: BEGIN
Wallet->>DB: 查询并锁定 vip_levels(level)
Wallet->>DB: 查询并锁定 user_vip_memberships(user_id)
Wallet->>DB: 校验不能降级、计算 new_expires_at_ms
Wallet->>DB: 锁定用户 COIN 钱包账户
Wallet->>DB: 扣减金币,写 wallet_transaction / wallet_entries
Wallet->>DB: 发放 VIP 资源组,写 resource_entitlements
Wallet->>DB: upsert user_vip_memberships
Wallet->>DB: 写 vip_purchase_orders / user_vip_history
Wallet->>Outbox: 写 VipPurchased / VipUpgraded / VipRenewed
Wallet->>DB: COMMIT
Wallet-->>Gateway: 返回当前 VIP、扣费、权益摘要
Gateway-->>App: {code,message,request_id,data}
```
关键点:
- 金币扣费、VIP 状态、资源组发放必须在同一个 `wallet-service` 数据库事务中完成。
- 客户端只传 `level``command_id`,价格和资源组完全由服务端配置决定。
- 扣费成功但资源发放失败不能提交事务;否则用户会付费但拿不到权益。
- 资源发放成功但 VIP 状态更新失败也不能提交事务;否则权益和会员状态会不一致。
## App 一级接口
### 查询 VIP 等级
`GET /api/v1/vip/levels`
返回:
```json
{
"current_vip": {
"level": 2,
"name": "VIP2",
"expires_at_ms": 1760000000000,
"remaining_ms": 604800000
},
"levels": [
{
"level": 1,
"name": "VIP1",
"coin_price": 1000,
"duration_ms": 604800000,
"can_purchase": true,
"purchase_block_reason": "",
"benefits": [
{
"resource_type": "avatar_frame",
"resource_id": "vip1_frame",
"name": "VIP1 Avatar Frame",
"duration_policy": "inherit_vip_expiry"
}
]
}
]
}
```
说明:
- `can_purchase=false` 通常表示当前有效 VIP 等级高于该等级。
- App 可以展示所有等级,但购买按钮状态以服务端返回为准。
### 查询我的 VIP
`GET /api/v1/vip/me`
返回:
```json
{
"level": 2,
"name": "VIP2",
"status": "active",
"expires_at_ms": 1760000000000,
"remaining_ms": 604800000,
"renewable": true,
"upgradeable_levels": [3, 4, 5]
}
```
说明:
- 底部“我的”页面只需要拿 VIP 摘要,不要拉完整资源列表。
- 完整装扮资源仍从资源/背包接口查询。
### 购买 VIP
`POST /api/v1/vip/purchase`
请求:
```json
{
"level": 3,
"command_id": "client-generated-id"
}
```
返回:
```json
{
"order_id": "vip_order_123",
"event_type": "upgrade",
"coin_spent": 3000,
"vip": {
"level": 3,
"name": "VIP3",
"expires_at_ms": 1760604800000
},
"granted_resources": [
{
"resource_type": "vehicle",
"resource_id": "vip3_vehicle",
"expires_at_ms": 1760604800000
}
]
}
```
## gRPC 契约建议
新增 `wallet.v1.VipService`
```protobuf
service VipService {
rpc ListVipLevels(ListVipLevelsRequest) returns (ListVipLevelsResponse);
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
}
```
核心字段:
- `RequestMeta meta`
- `int64 user_id`
- `int32 level`
- `string command_id`
- `int64 now_ms` 不由客户端传,由服务端生成;测试可通过 clock 注入。
错误码建议纳入 `pkg/xerr` catalog
- `VIP_LEVEL_NOT_FOUND`
- `VIP_LEVEL_DISABLED`
- `VIP_DOWNGRADE_NOT_ALLOWED`
- `VIP_PRICE_CHANGED`,仅在未来支持客户端价格确认时使用
- `WALLET_INSUFFICIENT_BALANCE`
- `IDEMPOTENCY_CONFLICT`
gRPC status 仍保持少量通用 code
- 参数错误:`InvalidArgument`
- 余额不足、不能降级:`FailedPrecondition`
- 等级不存在或禁用:`NotFound` / `FailedPrecondition`
- 幂等冲突:`AlreadyExists`
- 内部事务失败:`Internal`
业务错误放在 `google.rpc.ErrorInfo.reason`
## 并发和幂等
同一用户可能快速点击多次购买或同时升级,必须按用户串行化处理。
事务内锁顺序固定:
1.`vip_levels(level)`,读取配置快照。
2.`user_vip_memberships(user_id)`
3. 锁用户 `COIN` 钱包账户。
4. 写订单、流水、权益、会员状态。
如果 `user_vip_memberships` 不存在,先插入一行占位再锁,或者用用户级 advisory lock。不要只依赖应用内 mutex因为多实例部署时无效。
幂等规则:
- `command_id` 是购买命令幂等键,不使用 `request_id`
- 已成功订单重复请求直接返回原结果。
- 已失败订单是否允许重试取决于失败类型:余额不足可以用新 `command_id` 重试;系统错误如果事务未提交,可以继续用原 `command_id` 重试。
- 订单内保存价格、资源组、有效期快照,后台改配置不影响已成功订单解释。
## VIP 过期
VIP 是否有效以 `expires_at_ms` 为准。过期处理分两层:
- App 查询时实时判断,过期立即返回 `status=expired`
- 后台定时任务扫描过期会员,写 `user_vip_history(expire)``VipExpired` outbox用于消息通知、缓存清理和搜索投影更新。
资源权益也以自己的 `expires_at_ms` 判断有效性。如果使用 `inherit_vip_expiry`VIP 到期后头像框、座驾等权益自然失效,不需要额外删除。
## Outbox 事件
`wallet-service` 写出以下事件:
- `VipPurchased`
- `VipRenewed`
- `VipUpgraded`
- `VipExpired`
事件内容:
- `event_id`
- `user_id`
- `order_id`
- `from_level`
- `to_level`
- `expires_at_ms`
- `coin_spent`
- `resource_group_id`
- `occurred_at_ms`
消费者:
- `user-service` 可消费后更新用户资料投影,便于列表页快速展示 VIP 标识。
- `room-service` 如需房间内展示 VIP 角标,可消费投影事件或通过用户资料聚合读取,不接入购买主链路。
- `activity-service` 可用于 VIP 活动任务统计。
## 缓存策略
VIP 配置可以缓存,用户 VIP 状态不要只放 Redis。
- `vip_levels`:本地内存缓存 + 短 TTL后台改配置后通过版本号或事件刷新。
- `user_vip_memberships`MySQL 为准Redis 只做读优化。
- 购买成功后删除或刷新 `vip:me:{user_id}` 缓存。
- 过期时间短且强相关权益展示,缓存 TTL 不能超过 `min(60s, expires_at_ms-now)`
## 后台配置约束
后台配置 VIP 等级时必须校验:
- 等级唯一且大于 0。
- 金币价格大于 0。
- 有效期首版固定 7 天。
- 绑定资源组必须存在且可用。
- 资源组里的装扮资源建议使用 `inherit_vip_expiry`
- 禁用等级只影响新购买,不影响已购买用户的剩余有效期。
- 修改价格只影响修改后的新订单,不回改历史订单。
后台可以修改资源组,但已成功订单使用订单快照解释;用户已经拿到的权益不因为后台换资源组被自动替换。
## 数据一致性原则
- VIP 购买不是普通“资源组发放”,它必须先扣金币。
- 不允许 gateway 先扣金币再单独调用资源发放。
- 不允许 user-service 写 VIP 状态。
- 不允许客户端传资源 ID、资源组 ID、价格。
- 不允许用 Redis 作为 VIP 当前状态唯一来源。
- 不允许后台直接改用户 VIP 状态来修单,修单必须走后台补单/补偿命令,并写历史和审计。
## 实现顺序
1. 扩展 protobuf新增 `VipService`、VIP 等级、我的 VIP、购买 VIP 响应结构。
2. 增加 MySQL 表:`vip_levels``user_vip_memberships``vip_purchase_orders``user_vip_history`
3.`wallet-service` 实现 VIP repository先完成等级查询和用户 VIP 查询。
4. 实现 `PurchaseVip` 事务:等级校验、幂等、行锁、扣金币、资源组发放、会员状态更新、历史和 outbox。
5.`gateway-service` 增加 App HTTP API`/api/v1/vip/levels``/api/v1/vip/me``/api/v1/vip/purchase`
6. 接入用户资料聚合:底部“我的”和房间用户信息只展示 VIP 摘要。
7. 增加过期扫描任务和 outbox 消费投影。
8. 再做后台 VIP 配置页面和后台补单/审计能力。
## 测试重点
必须覆盖:
- 无 VIP 购买 VIP1 成功。
- VIP1 未过期时续费 VIP1到期时间追加 7 天。
- VIP1 未过期时购买 VIP3等级变 VIP3到期时间追加 7 天。
- VIP3 未过期时购买 VIP1 被拒绝。
- VIP 过期后可以购买任意等级。
- 金币不足时不写 VIP 状态、不发资源。
- 资源组发放失败时金币扣费回滚。
- 同一 `command_id` 重试返回同一订单。
- 同一用户并发购买时只产生符合顺序的一条最终状态。
- 后台禁用等级后不能新购买,但不影响已购买用户有效期。