# 等级与成就体系架构 本文定义用户等级、普通成就和活动限时成就的服务边界、事实来源、数据模型、接口和落地顺序。等级/成就属于用户成长读模型,不能把进度判断塞进房间、钱包、用户资料或游戏主链路。 ## 目标 - 支持三条首版等级轨道:财富、游戏、魅力。 - 支持普通成就:关注/被关注、送礼/收礼、麦上时长、App 活跃天数等长期身份象征。 - 支持活动限时成就:锁定活动、抽奖、特定游戏、特定礼物、指定时间窗内的专属成就。 - 达到等级或解锁成就后,可以发放头像框、徽章、座驾、称号等资源组奖励。 - App 可以读取自己的成长总览、等级详情、成就列表和对外展示身份标识。 - 后台可以配置等级阈值、积分政策、成就条件、活动时间窗和奖励资源组。 ## 非目标 - 不让客户端上报等级经验或成就进度。 - 不把等级体系和 VIP 体系合并。VIP 仍是 `wallet-service` 的付费会员权益;等级是由事实事件派生的成长身份。 - 不在 `room-service.SendGift`、上麦、关注、游戏改账等主链路里同步调用成长系统。 - 首版不做扣回降级。退款、撤销、关系解除等后续事实进入风控和补偿,不直接回滚已解锁身份。 - 首版不做历史兼容;当前开发阶段可以按最新事实直接调整 proto、表结构和配置。 ## 服务边界 | 模块 | 责任 | 不负责 | | --- | --- | --- | | `activity-service` | 拥有等级账户、等级配置、成就定义、成就进度、解锁事实、奖励发放状态和事件幂等 | 不拥有钱包余额、房间状态、用户资料和游戏订单事实 | | `wallet-service` | 发放等级/成就奖励资源组,保持资源权益和钱包资产入账一致 | 不判断用户是否达到等级或成就 | | `user-service` | 拥有用户资料、社交关系、麦上时长、登录审计等基础事实,并通过 outbox 暴露事实 | 不计算等级/成就 | | `room-service` | 继续只拥有 Room Cell、房间状态、麦位、送礼房间表现和 room outbox | 不保存成长进度 | | `game-service` | 拥有游戏订单和游戏结算事实;金币改账继续走 `wallet-service` | 不保存成长进度 | | `gateway-service` | 暴露 App HTTP API,鉴权、生成 `request_id`、透传到 activity gRPC | 不查成长数据库 | | `server/admin` | 后台配置页面和审计,通过 `hyapp.local/api` 访问 activity/wallet | 不 import app 服务内部包 | 首版建议在 `activity-service` 内新增 `growth` module,而不是新建服务。原因是现有每日任务已经在 `activity-service` 内实现了“事实事件 -> 用户进度 -> 奖励领取”的主模式,成就和等级可以复用事件消费、后台配置、inbox 和活动能力。后续只有当成长系统吞吐、团队边界或部署频率独立膨胀时,再拆 `growth-service`。 ## 总体架构 ```mermaid flowchart LR Room["room-service outbox"] --> EventBus["durable event relay"] Wallet["wallet-service outbox"] --> EventBus User["user-service user_outbox"] --> EventBus Game["game-service outbox"] --> EventBus EventBus --> GrowthConsumer["activity-service growth consumer"] GrowthConsumer --> GrowthDB[("activity MySQL growth_* / achievement_*")] GrowthConsumer --> GrowthOutbox["activity outbox / reward jobs"] GrowthOutbox --> WalletGrant["wallet-service GrantResourceGroup"] GrowthOutbox --> Inbox["activity inbox optional unlock notice"] App["App"] --> Gateway["gateway-service /api/v1"] Gateway --> GrowthQuery["activity-service GrowthService"] Admin["server/admin"] --> AdminGrowth["activity-service AdminGrowthService"] ``` 核心原则: - 等级和成就只消费已经提交的服务端事实事件。 - 每个事实事件必须有稳定 `event_id`;每个派生指标也必须有稳定幂等键。 - 成长状态保存在 `activity-service` MySQL,Redis 只可做短期缓存。 - 奖励通过 `wallet-service.GrantResourceGroup` 发放。资源组可以包含装扮权益,也可以包含金币类 wallet asset item。 - 自动奖励走 activity 侧 reward job/outbox,不阻塞事件消费主事务。 ## 事实事件模型 对外不要让每个来源服务理解等级和成就规则。来源服务只发布事实,activity-service 把事实映射为成长指标。 统一内部事件结构: | 字段 | 说明 | | --- | --- | | `event_id` | 来源事实事件 ID;派生多条指标时追加 `:metric:{metric_type}:{role}` | | `source_service` | `wallet` / `user` / `room` / `game` | | `event_type` | 来源事件类型,例如 `WalletGiftDebited` | | `user_id` | 本条指标归属用户 | | `metric_type` | 成长指标,例如 `gift_spend_coin` | | `value` | 正向增量,单位由指标定义决定 | | `occurred_at_ms` | 来源事实发生时间,统一 UTC epoch milliseconds | | `dimensions_json` | 可选过滤维度,例如 `gift_id/game_id/room_id/region_id/activity_id` | 首版指标映射: | 事实来源 | 派生指标 | 用户归属 | 用途 | | --- | --- | --- | --- | | `WalletGiftDebited` | `gift_spend_coin` | 送礼人 | 财富等级、送礼成就 | | `WalletGiftDebited` 或 `RoomGiftSent` | `gift_received_value` | 收礼人 | 魅力等级、收礼成就 | | `WalletRechargeRecorded` | `recharge_coin` | 充值用户 | 财富等级、充值成就 | | `ApplyGameCoinChange` 成功账务事件 | `game_spend_coin` / `game_win_coin` | 游戏用户 | 游戏等级、游戏成就 | | `UserMicSessionClosed` | `mic_online_ms` | 上麦用户 | 魅力等级、麦上成就 | | `UserFollowed` | `follow_count` / `followed_count` | 关注人/被关注人 | 普通成就 | | `UserDailyActive` | `active_day_count` | 活跃用户 | App 天数成就 | 如果一个来源事件会给多个人增加进度,必须拆成多条派生指标并使用不同幂等键。例如送礼同时影响送礼人的财富等级和收礼人的魅力等级。 ## 等级体系 首版等级轨道: | 轨道 | 主要指标 | 产品含义 | | --- | --- | --- | | `wealth` 财富 | 充值、送礼消费、活动消费 | 付费贡献和消费身份 | | `game` 游戏 | 游戏金币消耗、有效局数、中奖额 | 游戏参与和游戏贡献 | | `charm` 魅力 | 收礼价值、麦上在线、被关注 | 被认可、被互动和内容活跃 | ### 积分政策 等级积分不要写死在事件消费者里。后台配置把指标转换为某条轨道的积分: | 字段 | 说明 | | --- | --- | | `track` | `wealth` / `game` / `charm` | | `metric_type` | 输入指标 | | `point_numerator` / `point_denominator` | 转换比例,避免浮点数 | | `daily_cap` | 可选每日上限,0 表示不限 | | `dimension_filter_json` | 可选过滤条件,例如只统计指定礼物、指定游戏 | | `effective_from_ms` / `effective_to_ms` | 政策生效窗口 | 事件消费时先记录原始指标,再按当时有效政策写入积分流水。政策调整只影响新事件;如果运营要求重算,必须走后台重算任务,不在查询路径临时扫描历史事件。 ### 数据模型 ```sql growth_level_tracks( app_code, track, name, status, sort_order, display_config_json, created_at_ms, updated_at_ms ); growth_point_policies( app_code, policy_id, track, metric_type, status, point_numerator, point_denominator, daily_cap, dimension_filter_json, effective_from_ms, effective_to_ms, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ); growth_level_rules( app_code, track, level, name, required_points, reward_resource_group_id, display_config_json, status, sort_order, created_at_ms, updated_at_ms ); user_growth_accounts( app_code, user_id, track, total_points, current_level, level_started_at_ms, level_updated_at_ms, version, updated_at_ms, PRIMARY KEY(app_code, user_id, track) ); growth_point_events( app_code, point_event_id, source_event_id, source_service, source_event_type, user_id, track, metric_type, raw_value, point_delta, policy_id, occurred_at_ms, created_at_ms, PRIMARY KEY(app_code, point_event_id) ); user_growth_level_history( app_code, history_id, user_id, track, previous_level, new_level, total_points, point_event_id, reward_job_id, created_at_ms ); ``` 等级升级规则: - `current_level` 由 `total_points` 命中 `growth_level_rules.required_points` 计算。 - 同一事件必须在一个 activity MySQL 事务内完成幂等记录、积分流水、账户更新和升级历史。 - 允许一次事件跨多级升级;每个新等级可以生成一条 reward job。 - 首版不降级。政策重算如果导致等级下降,必须通过后台批处理显式执行并写审计。 ## 成就体系 成就不是每日任务的简单改名。现有 `task_definitions` 适合 daily/exclusive 任务和金币领取;成就需要公开身份展示、多阶段解锁、活动时间窗、资源奖励和可佩戴标识,因此建议独立建模。 成就分类: | 类型 | 周期 | 示例 | 处理 | | --- | --- | --- | --- | | `ordinary` 普通成就 | lifetime | 关注 100 人、收到 10 万礼物价值、麦上 100 小时、活跃 30 天 | 永久解锁,可公开展示 | | `activity_limited` 活动成就 | `[start_ms,end_ms)` | 指定活动内抽奖 10 次、发送指定礼物、完成某游戏挑战 | 窗口内累计,窗口外不再增长 | ### 成就条件 首版支持两类条件: - 单指标阈值:`metric_type + target_value + optional dimension_filter_json`。 - 多条件 AND:每个条件独立累计,全部达到后解锁。 不支持在客户端表达条件,不支持按本地时区切日。活动窗口统一使用 `[start_ms, end_ms)` UTC 毫秒区间。 ### 数据模型 ```sql achievement_collections( app_code, collection_id, collection_type, title, status, effective_from_ms, effective_to_ms, sort_order, display_config_json, created_at_ms, updated_at_ms ); achievement_definitions( app_code, achievement_id, collection_id, category, title, description, status, version, unlock_mode, reward_resource_group_id, display_resource_id, badge_resource_id, effective_from_ms, effective_to_ms, sort_order, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ); achievement_conditions( app_code, condition_id, achievement_id, version, metric_type, target_value, target_unit, dimension_filter_json, created_at_ms ); user_achievement_progress( app_code, user_id, achievement_id, condition_id, cycle_key, progress_value, target_value, status, updated_at_ms, PRIMARY KEY(app_code, user_id, achievement_id, condition_id, cycle_key) ); user_achievement_unlocks( app_code, user_id, achievement_id, cycle_key, version, status, unlocked_at_ms, reward_job_id, displayed, updated_at_ms, PRIMARY KEY(app_code, user_id, achievement_id, cycle_key) ); achievement_event_consumption( app_code, event_id, source_service, source_event_type, user_id, status, skip_reason, consumed_at_ms, created_at_ms, PRIMARY KEY(app_code, event_id) ); ``` `cycle_key` 对普通成就固定为 `lifetime`。活动限时成就使用活动集合 ID 或 `activity:{collection_id}`,不能使用本地日期。 ### 解锁与奖励 - 自动解锁:事件消费发现条件全部满足时写 `user_achievement_unlocks`。 - 奖励发放:如果配置了 `reward_resource_group_id`,写 `growth_reward_jobs`,异步调用 `wallet-service.GrantResourceGroup`。 - 展示身份:`display_resource_id` 或 `badge_resource_id` 只表示成就身份标识;真正的资源权益仍以 wallet resource entitlement 为准。 - 活动成就如果奖励限时资源,资源组 item 使用 `duration_ms` 控制有效期;成就解锁事实本身可以永久保留。 ## 奖励发放 等级和成就都使用同一张奖励任务表,避免事件消费事务跨服务调用 wallet: ```sql growth_reward_jobs( app_code, reward_job_id, reward_type, source_id, user_id, resource_group_id, wallet_command_id, wallet_grant_id, status, attempt_count, next_retry_at_ms, failure_reason, created_at_ms, updated_at_ms, PRIMARY KEY(app_code, reward_job_id), UNIQUE KEY uk_growth_reward_source(app_code, reward_type, source_id, user_id) ); ``` 发放规则: - `wallet_command_id` 必须稳定,例如 `growth_reward:{reward_job_id}`。 - `wallet-service` 需要允许 `grant_source=growth`,用于区分后台发放和成长系统自动发放。 - 钱包发放成功后回写 `wallet_grant_id`,重复执行必须返回同一结果。 - 发放失败不回滚等级/成就解锁,依靠 reward job 重试和后台排障。 ## App 接口 ### 成长总览 ```http GET /api/v1/growth/me/overview ``` 返回: ```json { "tracks": [ { "track": "wealth", "level": 8, "total_points": 125000, "current_level_points": 100000, "next_level_points": 150000, "reward_claim_status": "granted" } ], "achievement_summary": { "unlocked_count": 12, "displayed_achievement_ids": ["ach_gift_100k"] }, "server_time_ms": 1777996800000 } ``` ### 等级详情 ```http GET /api/v1/growth/levels ``` 返回当前用户每条轨道、等级阈值、已获得奖励和下一等级差值。客户端只展示服务端返回的状态,不自行计算是否升级。 ### 成就列表 ```http GET /api/v1/achievements?collection_type=ordinary GET /api/v1/achievements?collection_id=act_202605 ``` 每项返回 `achievement_id/title/category/progress/target/status/unlocked_at_ms/reward_status/displayed/effective_to_ms`。 ### 展示身份设置 ```http POST /api/v1/achievements/display ``` 请求: ```json { "achievement_ids": ["ach_gift_100k", "ach_mic_100h"], "command_id": "cmd_display_550e8400" } ``` activity-service 校验这些成就是当前用户已解锁且可展示,再保存展示偏好。房间和资料页只读取投影,不实时扫描成就列表。 ## 对外展示读模型 需要一个轻量投影给资料卡、房间成员列表和我的页使用: ```sql user_growth_display_profiles( app_code, user_id, wealth_level, game_level, charm_level, displayed_achievement_ids_json, displayed_badge_resource_ids_json, updated_at_ms, PRIMARY KEY(app_code, user_id) ); ``` `gateway-service` 的 `GET /api/v1/users/me/overview` 当前已经有 `level` 入口,但没有成长摘要。首版可以保持入口不变,在等级页进入后调用成长接口;如果设计稿需要首屏直接展示等级,则 gateway 并行调用 activity-service 的轻量 summary,并允许降级为 `unavailable=true`。 ## 后台管理 后台新增在活动管理下: ```text 活动管理 - 等级配置 - 成就配置 ``` 等级配置: - 等级轨道:财富、游戏、魅力的名称、排序、状态、展示配置。 - 积分政策:指标、转换比例、每日上限、过滤维度、生效时间。 - 等级规则:等级、阈值、奖励资源组、展示配置。 成就配置: - 成就集合:普通成就或活动限时成就,配置 `[start_ms,end_ms)`。 - 成就定义:标题、分类、展示资源、奖励资源组、排序、状态。 - 成就条件:单指标或多条件 AND,条件必须由服务端支持的 `metric_type` 组成。 后台启用 active 配置时只影响新事件;需要追溯时创建 `growth_rebuild_jobs`,由 `cron-service` 触发 activity-service 批处理。cron 只负责调度,重算逻辑、幂等和状态推进仍由 activity-service owner 完成。 ## 与现有每日任务的关系 已有每日任务表和接口继续保留: - `task_definitions`:daily/exclusive 任务,领取金币奖励。 - `user_task_progress`:任务页进度。 - `ClaimTaskReward`:已完成任务领奖。 等级/成就新增独立模型,但共用同一套事实事件源。不要把永久成就硬塞进 `task_definitions`,否则会把“任务奖励领取状态”和“公开身份解锁状态”混在一起,后续活动成就、徽章展示和多阶段成就会变得难以维护。 ## 异常与补偿 - 事件重复:`growth_event_consumption`、`growth_point_events`、`achievement_event_consumption` 都以派生事件 ID 做幂等。 - 事件乱序:等级按正向累计处理;成就只判断达到阈值,不依赖事件顺序。 - 钱包奖励失败:只标记 reward job failed/retryable,不回滚等级或成就。 - 来源 outbox 延迟:成长进度允许短暂延迟,App 展示服务端当前事实。 - 活动窗口边界:只统计 `occurred_at_ms >= start_ms && occurred_at_ms < end_ms` 的事件。 - 重算任务:必须有 `rebuild_job_id`、游标、锁、重试和审计,不允许查询接口临时重扫全量事件。 ## 落地顺序 1. 在 `activity-service` 增加 growth/achievement domain、MySQL 表、repository、service 和单元测试。 2. 在 `api/proto/activity/v1/activity.proto` 增加 `GrowthService`、`AchievementService` 和后台管理 RPC,运行 `make proto`。 3. 在 `wallet-service` 扩展资源发放来源,支持 `grant_source=growth`,并补发放幂等测试。 4. 建立事实事件 relay:优先接 wallet/user/game outbox,room 事件继续走已有 room outbox consumer,不用 cron 从头扫 outbox。 5. 实现等级事件消费:积分政策、账户更新、跨级升级、reward job、展示投影。 6. 实现成就事件消费:条件进度、多条件解锁、活动窗口过滤、reward job、展示设置。 7. 接入 gateway App API 和 server/admin 配置页。 8. 补充 Docker 配置和本地 schema,运行 `make test`;涉及 proto 时额外运行 `make proto && go test ./...`。 ## 验收点 - 重复投递同一送礼事件,财富等级、魅力等级和成就进度都只增加一次。 - 一个大额送礼事件跨多个财富等级时,等级历史和奖励任务数量正确。 - 活动成就只统计 `[start_ms,end_ms)` 内的事件,结束边界毫秒不计入。 - 奖励资源组发放失败后,等级/成就已解锁,reward job 可重试并幂等。 - `room-service`、`wallet-service`、`user-service` 和 `game-service` 主链路不依赖 activity-service 可用性。 - 所有业务时间和窗口使用 UTC epoch milliseconds。