18 KiB
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_iduser_idevent_type:purchase/renew/upgrade/expirefrom_levelto_levelfrom_expires_at_msto_expires_at_msorder_idcreated_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 等级反推资源。
主链路流程
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
返回:
{
"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
返回:
{
"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
请求:
{
"level": 3,
"command_id": "client-generated-id"
}
返回:
{
"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:
service VipService {
rpc ListVipLevels(ListVipLevelsRequest) returns (ListVipLevelsResponse);
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
}
核心字段:
RequestMeta metaint64 user_idint32 levelstring command_idint64 now_ms不由客户端传,由服务端生成;测试可通过 clock 注入。
错误码建议纳入 pkg/xerr catalog:
VIP_LEVEL_NOT_FOUNDVIP_LEVEL_DISABLEDVIP_DOWNGRADE_NOT_ALLOWEDVIP_PRICE_CHANGED,仅在未来支持客户端价格确认时使用WALLET_INSUFFICIENT_BALANCEIDEMPOTENCY_CONFLICT
gRPC status 仍保持少量通用 code:
- 参数错误:
InvalidArgument - 余额不足、不能降级:
FailedPrecondition - 等级不存在或禁用:
NotFound/FailedPrecondition - 幂等冲突:
AlreadyExists - 内部事务失败:
Internal
业务错误放在 google.rpc.ErrorInfo.reason。
并发和幂等
同一用户可能快速点击多次购买或同时升级,必须按用户串行化处理。
事务内锁顺序固定:
- 锁
vip_levels(level),读取配置快照。 - 锁
user_vip_memberships(user_id)。 - 锁用户
COIN钱包账户。 - 写订单、流水、权益、会员状态。
如果 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)和VipExpiredoutbox,用于消息通知、缓存清理和搜索投影更新。
资源权益也以自己的 expires_at_ms 判断有效性。如果使用 inherit_vip_expiry,VIP 到期后头像框、座驾等权益自然失效,不需要额外删除。
Outbox 事件
wallet-service 写出以下事件:
VipPurchasedVipRenewedVipUpgradedVipExpired
事件内容:
event_iduser_idorder_idfrom_levelto_levelexpires_at_mscoin_spentresource_group_idoccurred_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 状态来修单,修单必须走后台补单/补偿命令,并写历史和审计。
实现顺序
- 扩展 protobuf:新增
VipService、VIP 等级、我的 VIP、购买 VIP 响应结构。 - 增加 MySQL 表:
vip_levels、user_vip_memberships、vip_purchase_orders、user_vip_history。 - 在
wallet-service实现 VIP repository,先完成等级查询和用户 VIP 查询。 - 实现
PurchaseVip事务:等级校验、幂等、行锁、扣金币、资源组发放、会员状态更新、历史和 outbox。 - 在
gateway-service增加 App HTTP API:/api/v1/vip/levels、/api/v1/vip/me、/api/v1/vip/purchase。 - 接入用户资料聚合:底部“我的”和房间用户信息只展示 VIP 摘要。
- 增加过期扫描任务和 outbox 消费投影。
- 再做后台 VIP 配置页面和后台补单/审计能力。
测试重点
必须覆盖:
- 无 VIP 购买 VIP1 成功。
- VIP1 未过期时续费 VIP1,到期时间追加 7 天。
- VIP1 未过期时购买 VIP3,等级变 VIP3,到期时间追加 7 天。
- VIP3 未过期时购买 VIP1 被拒绝。
- VIP 过期后可以购买任意等级。
- 金币不足时不写 VIP 状态、不发资源。
- 资源组发放失败时金币扣费回滚。
- 同一
command_id重试返回同一订单。 - 同一用户并发购买时只产生符合顺序的一条最终状态。
- 后台禁用等级后不能新购买,但不影响已购买用户有效期。