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