hyapp-server/docs/等级体系架构.md
2026-05-14 17:44:16 +08:00

442 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 等级体系架构
本文只定义等级体系,不包含成就体系。等级体系用于表达用户在财富、游戏和魅力三条成长轨道上的长期身份,并在达到指定等级或等级段时发放永久资源奖励。
## 目标
- 财富等级:按用户送出礼物价值累计。
- 游戏等级:按用户游戏消耗累计。
- 魅力等级:按用户收到礼物价值累计。
- 后台可配置每条轨道的等级阈值、等级段展示、等级奖励资源组。
- 每到指定等级或等级段,系统自动发放头像框、徽章、礼物等永久资源。
- 等级段资源支持替换展示:例如 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 不生效。