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