# 等级体系架构

本文只定义等级体系，不包含成就体系。等级体系用于表达用户在财富、游戏和魅力三条成长轨道上的长期身份，并在达到指定等级或等级段时发放永久资源奖励。

## 目标

- 财富等级：按用户送出礼物价值累计。
- 游戏等级：按用户游戏消耗累计。
- 魅力等级：按用户收到礼物价值累计。
- 后台可配置每条轨道的等级阈值、等级段展示、等级奖励资源组。
- 每到指定等级或等级段，系统自动发放头像框、徽章、礼物等永久资源。
- 等级段资源支持替换展示：例如 1-10 级展示 A 头像框/A 徽章，11-20 级展示 B 头像框/B 徽章；A/B 都是用户永久持有资源，但当前等级段只展示 B。
- App 和房间资料只读服务端等级摘要与展示资源，客户端不能自报等级、经验或徽章。

## 非目标

- 不做历史兼容；当前开发阶段按最新等级规则直接调整 proto、表结构和接口。
- 首版不做降级。退款、撤销、风控处罚等后续事实只进入补偿或人工处理，不自动扣回等级。
- 不把等级和 VIP 混合。VIP 仍由 `wallet-service` 作为付费会员权益 owner；等级由 `activity-service` 消费事实事件派生。
- 不在送礼、游戏扣费、房间命令主链路里同步调用等级系统。
- 不允许客户端提交“完成等级进度”或“切换等级段资源”的业务事实。

## 服务边界

| 模块 | 责任 | 不负责 |
| --- | --- | --- |
| `activity-service` | 等级轨道、等级规则、等级段、用户等级账户、等级事件幂等、升级历史、等级奖励任务、展示投影 | 不拥有钱包余额、资源权益、房间状态、游戏订单 |
| `wallet-service` | 礼物扣费/收礼账务事实、资源组发放、永久资源权益、资源装备表 | 不判断等级是否升级 |
| `game-service` | 游戏订单和游戏消耗事实；金币改账继续走 `wallet-service` | 不保存等级进度 |
| `room-service` | 只拥有 Room Cell 和房间表现；送礼成功后写 room outbox | 不计算财富/魅力等级 |
| `gateway-service` | App HTTP 入口、鉴权、`request_id`、调用 activity gRPC | 不查等级数据库 |
| `server/admin` | 等级后台配置和审计，通过 protobuf client 访问 app 后端 | 不 import app 服务内部包 |

等级体系首版放在 `activity-service/internal/domain/growth`。原因是现有每日任务已经在 `activity-service` 内实现了事实事件消费、用户进度、后台配置和钱包奖励发放模式，等级是同类低频派生状态。

## 等级轨道

| 轨道 | 代码 | 权威指标 | 来源事实 | 单位 |
| --- | --- | --- | --- | --- |
| 财富等级 | `wealth` | 送出礼物价值 | `wallet-service` 的 `WalletGiftDebited`，取 sender 侧实际消耗 | coin |
| 游戏等级 | `game` | 游戏消耗 | `wallet-service` 的游戏扣费成功 outbox，取用户实际消耗 | coin |
| 魅力等级 | `charm` | 收到礼物价值 | `wallet-service` 的 `WalletGiftDebited`，取 target 侧收到的礼物结算价值 | coin |

财富和魅力默认以钱包账务事实为准，因为礼物价格、扣费和收礼积分都由 `wallet-service` owner。`RoomGiftSent` 可作为房间表现、活动播报或对账补充，但不能替代账务事实作为价值来源。

## 事件流

```mermaid
flowchart LR
  Wallet["wallet-service wallet_outbox"] --> Relay["durable event relay"]
  Game["game-service/game wallet events"] --> Relay
  Relay --> Consumer["activity-service growth consumer"]
  Consumer --> DB[("activity MySQL growth tables")]
  DB --> RewardJob["growth_reward_jobs"]
  RewardJob --> WalletGrant["wallet-service GrantResourceGroup"]
  DB --> Projection["user_level_display_profiles"]
  App["App/gateway"] --> Query["activity-service GrowthService"]
  Query --> DB
```

处理原则：

- 来源服务只发布事实，不理解等级规则。
- activity-service 用 `event_id + metric_type + user_id + track` 生成派生事件幂等键。
- 同一事件可以同时影响多人或多轨道。例如一次送礼会给 sender 增加财富等级进度，给 target 增加魅力等级进度。
- 等级奖励发放不阻塞事件消费；先写 `growth_reward_jobs`，再异步调用 `wallet-service.GrantResourceGroup`。

## 后台配置模型

### 等级轨道

`growth_level_tracks`

| 字段 | 说明 |
| --- | --- |
| `track` | `wealth` / `game` / `charm` |
| `name` | 后台和 App 展示名 |
| `status` | `active` / `disabled` |
| `sort_order` | 展示排序 |
| `display_config_json` | 颜色、图标、说明等展示配置，不参与计算 |

### 等级规则

`growth_level_rules`

| 字段 | 说明 |
| --- | --- |
| `track` | 等级轨道 |
| `level` | 等级数字，从 1 开始 |
| `required_value` | 达到该等级需要的累计值 |
| `name` | 等级展示名 |
| `status` | `active` / `disabled` |
| `reward_resource_group_id` | 达到该具体等级时发放的资源组，0 表示无奖励 |
| `sort_order` | 后台排序 |
| `display_config_json` | 单等级展示配置 |

约束：

- 同一 `track + level` 唯一。
- 同一轨道内 `required_value` 必须随 `level` 严格递增。
- 已 active 的等级规则如果修改 `required_value` 或奖励资源组，后台必须生成新版本；是否重算历史由单独重算任务决定。

### 等级段展示

`growth_level_tiers`

| 字段 | 说明 |
| --- | --- |
| `tier_id` | 等级段 ID |
| `track` | 等级轨道 |
| `min_level` | 等级段起始等级，包含 |
| `max_level` | 等级段结束等级，包含 |
| `name` | 等级段展示名，例如 `财富 1-10` |
| `display_avatar_frame_resource_id` | 当前等级段默认展示头像框资源 |
| `display_badge_resource_id` | 当前等级段默认展示徽章资源 |
| `reward_resource_group_id` | 首次进入该等级段时发放的永久资源组 |
| `status` | `active` / `disabled` |
| `display_config_json` | 颜色、角标、客户端样式配置 |

等级段用于解决“1-10 级一套头像框/徽章，11-20 级换一套”的需求。资源本身仍由 `wallet-service` 永久发放；当前展示由等级投影决定，不删除旧资源。

约束：

- 同一轨道 active 等级段不能重叠。
- `min_level <= max_level`。
- `display_avatar_frame_resource_id` 必须是 active 且可发放的 `avatar_frame` 资源。
- `display_badge_resource_id` 必须是 active 且可发放的 `badge` 资源。
- `reward_resource_group_id` 对应资源组里的等级段头像框/徽章通常配置 `duration_ms=0`，表示永久权益。

## 用户等级数据

### 用户等级账户

`user_growth_level_accounts`

```sql
user_growth_level_accounts(
  app_code VARCHAR(32) NOT NULL,
  user_id BIGINT NOT NULL,
  track VARCHAR(32) NOT NULL,
  total_value BIGINT NOT NULL DEFAULT 0,
  current_level INT NOT NULL DEFAULT 0,
  current_tier_id BIGINT NOT NULL DEFAULT 0,
  level_updated_at_ms BIGINT NOT NULL DEFAULT 0,
  tier_updated_at_ms BIGINT NOT NULL DEFAULT 0,
  version BIGINT NOT NULL DEFAULT 0,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY(app_code, user_id, track)
);
```

`total_value` 是等级判断唯一累计值：

- 财富：累计送出礼物实际消耗 coin。
- 游戏：累计游戏实际消耗 coin。
- 魅力：累计收到礼物价值 coin。

### 等级事件流水

`growth_level_value_events`

```sql
growth_level_value_events(
  app_code VARCHAR(32) NOT NULL,
  value_event_id VARCHAR(160) NOT NULL,
  source_event_id VARCHAR(128) NOT NULL,
  source_service VARCHAR(64) NOT NULL,
  source_event_type VARCHAR(96) NOT NULL,
  user_id BIGINT NOT NULL,
  track VARCHAR(32) NOT NULL,
  metric_type VARCHAR(64) NOT NULL,
  value_delta BIGINT NOT NULL,
  occurred_at_ms BIGINT NOT NULL,
  dimensions_json JSON NULL,
  created_at_ms BIGINT NOT NULL,
  PRIMARY KEY(app_code, value_event_id),
  KEY idx_growth_value_user_track(app_code, user_id, track, occurred_at_ms)
);
```

`value_event_id` 示例：

- `wallet_outbox_id:wealth:sender:{sender_user_id}`
- `wallet_outbox_id:charm:target:{target_user_id}`
- `game_order_id:game:{user_id}`

同一幂等键重复投递直接返回已消费，不再次增加等级值。

### 升级历史

`user_growth_level_history`

```sql
user_growth_level_history(
  app_code VARCHAR(32) NOT NULL,
  history_id VARCHAR(96) NOT NULL,
  user_id BIGINT NOT NULL,
  track VARCHAR(32) NOT NULL,
  previous_level INT NOT NULL,
  new_level INT NOT NULL,
  previous_tier_id BIGINT NOT NULL DEFAULT 0,
  new_tier_id BIGINT NOT NULL DEFAULT 0,
  total_value BIGINT NOT NULL,
  value_event_id VARCHAR(160) NOT NULL,
  created_at_ms BIGINT NOT NULL,
  PRIMARY KEY(app_code, history_id),
  KEY idx_growth_history_user_track(app_code, user_id, track, created_at_ms)
);
```

一次大额事件可以跨多级。账户只保存最终等级；历史可以选择写一条 `previous_level -> new_level`，奖励任务按“跨过的等级”和“首次进入的等级段”逐条生成。

## 等级奖励

等级奖励分两类：

| 类型 | 触发 | 例子 | 是否永久 |
| --- | --- | --- | --- |
| 具体等级奖励 | 达到某个 level | 到 10 级送礼物包 | 由资源组 item `duration_ms` 决定 |
| 等级段奖励 | 首次进入等级段 | 1-10 头像框/徽章，11-20 新头像框/徽章 | 通常永久，`duration_ms=0` |

奖励任务表：

```sql
growth_level_reward_jobs(
  app_code VARCHAR(32) NOT NULL,
  reward_job_id VARCHAR(96) NOT NULL,
  user_id BIGINT NOT NULL,
  track VARCHAR(32) NOT NULL,
  reward_source_type VARCHAR(32) NOT NULL,
  reward_source_id VARCHAR(96) NOT NULL,
  resource_group_id BIGINT NOT NULL,
  wallet_command_id VARCHAR(128) NOT NULL,
  wallet_grant_id VARCHAR(96) NOT NULL DEFAULT '',
  status VARCHAR(32) NOT NULL,
  attempt_count INT NOT NULL DEFAULT 0,
  next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
  failure_reason VARCHAR(255) NOT NULL DEFAULT '',
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY(app_code, reward_job_id),
  UNIQUE KEY uk_growth_level_reward_once(app_code, user_id, track, reward_source_type, reward_source_id)
);
```

`reward_source_type`：

- `level`: `reward_source_id = level:{level}`
- `tier`: `reward_source_id = tier:{tier_id}`

发放规则：

- activity-service 只创建 reward job，不在事件消费事务里调用 wallet。
- reward worker 调用 `wallet-service.GrantResourceGroup`，`grant_source=growth_level`。
- `wallet_command_id` 必须稳定，例如 `growth_level_reward:{reward_job_id}`。
- 资源组发放成功后写回 `wallet_grant_id`。
- 失败只影响奖励发放状态，不回滚用户等级。
- 重试必须复用同一个 `wallet_command_id`，避免重复发放永久资源。

## 等级段资源替换展示

用户升到新等级段时，旧等级段资源不删除：

1. 用户 1-10 级时首次进入 tier A，系统发放 A 头像框/A 徽章永久权益。
2. 用户 11 级进入 tier B，系统发放 B 头像框/B 徽章永久权益。
3. 等级展示投影切换为 tier B 的头像框/徽章。
4. A 头像框/A 徽章仍在用户背包里，用户资源事实不被删除。

默认展示有两种实现选择，首版建议使用等级投影优先：

| 方案 | 处理 | 适用 |
| --- | --- | --- |
| 等级投影优先 | `user_level_display_profiles` 保存当前等级段头像框/徽章，资料和房间展示优先读它 | 等级段身份必须强制跟随等级 |
| 自动装备钱包资源 | reward job 成功后调用或扩展 `wallet-service` 装备逻辑，更新 `user_resource_equipment` | 产品允许等级奖励直接改变用户当前装备 |

首版推荐“等级投影优先”，原因是 `user_resource_equipment` 表表示用户主动装备的当前资源，同一资源类型只有一条记录；等级段身份是系统身份展示，和用户手动装备头像框/徽章不是同一个语义。后续如果产品要求用户可以手动隐藏等级段徽章，再在 activity-service 增加展示偏好。

展示投影：

```sql
user_level_display_profiles(
  app_code VARCHAR(32) NOT NULL,
  user_id BIGINT NOT NULL,
  wealth_level INT NOT NULL DEFAULT 0,
  wealth_tier_id BIGINT NOT NULL DEFAULT 0,
  wealth_avatar_frame_resource_id BIGINT NOT NULL DEFAULT 0,
  wealth_badge_resource_id BIGINT NOT NULL DEFAULT 0,
  game_level INT NOT NULL DEFAULT 0,
  game_tier_id BIGINT NOT NULL DEFAULT 0,
  game_avatar_frame_resource_id BIGINT NOT NULL DEFAULT 0,
  game_badge_resource_id BIGINT NOT NULL DEFAULT 0,
  charm_level INT NOT NULL DEFAULT 0,
  charm_tier_id BIGINT NOT NULL DEFAULT 0,
  charm_avatar_frame_resource_id BIGINT NOT NULL DEFAULT 0,
  charm_badge_resource_id BIGINT NOT NULL DEFAULT 0,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY(app_code, user_id)
);
```

如果某个等级段没有配置头像框或徽章，对应字段返回 0，客户端不展示该类等级段资源。

## App 接口

### 我的等级总览

```http
GET /api/v1/levels/me/overview
```

返回：

```json
{
  "tracks": [
    {
      "track": "wealth",
      "level": 12,
      "tier_id": 2,
      "total_value": 230000,
      "current_level_required_value": 220000,
      "next_level": 13,
      "next_level_required_value": 260000,
      "display_avatar_frame_resource_id": 91001,
      "display_badge_resource_id": 92001,
      "reward_pending_count": 0
    }
  ],
  "server_time_ms": 1777996800000
}
```

### 等级规则列表

```http
GET /api/v1/levels/tracks/{track}
```

返回指定轨道的等级阈值、等级段、奖励资源组摘要和当前用户进度。客户端只用于展示，不自行判断升级。

### 等级奖励记录

```http
GET /api/v1/levels/rewards
```

返回用户等级奖励发放状态，用于展示“待发放/已发放/失败重试中”。首版奖励自动发放，不需要用户领取。

## 后台配置

后台建议放在活动管理或用户成长管理下：

```text
用户成长
  - 等级轨道
  - 等级规则
  - 等级段奖励
  - 等级奖励记录
```

等级规则页：

- 选择轨道：财富/游戏/魅力。
- 配置每一级 `level` 和 `required_value`。
- 可选配置该级 `reward_resource_group_id`。
- active 规则调整阈值或奖励时生成新版本。

等级段页：

- 选择轨道。
- 配置 `min_level/max_level`。
- 配置头像框资源、徽章资源、奖励资源组。
- 校验同轨道 active 等级段不能重叠。
- 校验头像框/徽章资源 active、grantable，并且资源类型正确。

奖励记录页：

- 按用户、轨道、等级、等级段、状态查询。
- 支持失败 reward job 人工重试。
- 不支持人工删除用户等级历史。

## 事件处理细节

### 送礼事件

`WalletGiftDebited` 派生两条等级事件：

| 轨道 | 用户 | 值 |
| --- | --- | --- |
| `wealth` | sender | `coin_spent` |
| `charm` | target | `received_gift_value`，首版按该 target 对应的礼物结算 coin 价值累计 |

如果一次礼物支持多 target，必须按 wallet 实际拆单结果给每个 target 生成独立 charm 事件，不能用客户端目标列表估算。当前单 target 礼物的 `received_gift_value` 等于本次礼物账务结算的 `coin_spent`。

### 游戏事件

游戏等级只统计成功扣费或已结算的真实消耗：

- `game_spend_coin` 只取用户实际消耗的 COIN。
- 退款、回滚、补单不在首版自动扣减等级。
- 如果 `game-service` 与 `wallet-service` 都能发布游戏事件，activity-service 只消费钱包游戏扣费 outbox，避免同一游戏订单被双计。`game-service` 事件只作为游戏局详情、风控或对账输入。

### 升级计算

事件消费事务内流程：

1. 插入 `growth_level_value_events`，重复则直接返回。
2. 锁定 `user_growth_level_accounts` 当前轨道行。
3. 累加 `total_value`。
4. 根据 active `growth_level_rules` 计算新等级。
5. 如果等级或等级段变化，写 `user_growth_level_history`。
6. 为跨过的具体等级和首次进入的新等级段创建 `growth_level_reward_jobs`。
7. 更新 `user_level_display_profiles`。
8. 提交事务。

## 补偿与重算

- 事件消费者失败：依赖来源 outbox 或 event relay 重试，activity 侧幂等。
- 奖励发放失败：reward job 重试，不回滚等级。
- 后台规则改错：创建 `growth_level_rebuild_jobs`，由 `cron-service` 调度 activity-service 批处理重算。
- 重算必须写操作人、原因、规则版本和影响用户数；不能在查询接口临时扫描全量流水。
- 所有窗口和时间字段统一使用 UTC epoch milliseconds，字段名保持 `*_ms`。

## 落地顺序

1. 在 `activity-service` 增加 growth level domain、repository、service、MySQL 表和单元测试。
2. 在 `api/proto/activity/v1/activity.proto` 增加等级查询和后台配置 RPC，运行 `make proto`。
3. 在 `wallet-service` 允许资源发放来源 `growth_level`，补充 `GrantResourceGroup` 幂等测试。
4. 接入 wallet 游戏/礼物 outbox 到 activity growth consumer，先实现财富和魅力，再实现游戏。
5. 实现 reward worker，调用 `wallet-service.GrantResourceGroup` 发放等级和等级段资源组。
6. 实现 gateway App 接口和 server/admin 配置页。
7. 补充 Docker/local initdb schema，运行 `make test`；涉及 proto 时运行 `make proto && go test ./...`。

## 验收点

- 同一送礼 outbox 重复投递，财富和魅力等级只增加一次。
- sender 财富和 target 魅力使用同一账务事实拆成不同幂等键。
- 用户从 10 级升到 11 级时，旧等级段资源仍在背包，新等级段头像框/徽章成为当前等级展示。
- 用户一次大额送礼跨多级时，账户等级、升级历史、具体等级奖励、等级段奖励都正确。
- 奖励资源组发放失败后，等级已升级，reward job 可重试且不重复发永久资源。
- gateway 返回的等级展示资源来自服务端投影，客户端自报资源 ID 不生效。
