hyapp-server/docs/VIP系统架构.md

18 KiB
Raw Permalink Blame History

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

购买和升级规则

定义:

  • 当前有效 VIPmembership.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_levelexpires_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_expiryalign_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 数据库事务中完成。
  • 客户端只传 levelcommand_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 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_expiryVIP 到期后头像框、座驾等权益自然失效,不需要额外删除。

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_membershipsMySQL 为准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_levelsuser_vip_membershipsvip_purchase_ordersuser_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 重试返回同一订单。
  • 同一用户并发购买时只产生符合顺序的一条最终状态。
  • 后台禁用等级后不能新购买,但不影响已购买用户有效期。