463 lines
18 KiB
Markdown
463 lines
18 KiB
Markdown
# 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_ms = now_ms + 7d_ms` |
|
||
| 有效 VIP,购买同等级 | 续期,`expires_at_ms = current_expires_at_ms + 7d_ms` |
|
||
| 有效 VIP,购买更高等级 | 立即升级,`level = target_level`,`expires_at_ms = current_expires_at_ms + 7d_ms` |
|
||
| 有效 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`
|
||
- 资料卡:`profile_card`
|
||
- 座驾:`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` 重试返回同一订单。
|
||
- 同一用户并发购买时只产生符合顺序的一条最终状态。
|
||
- 后台禁用等级后不能新购买,但不影响已购买用户有效期。
|