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