# 等级与成就体系架构

本文定义用户等级、普通成就和活动限时成就的服务边界、事实来源、数据模型、接口和落地顺序。等级/成就属于用户成长读模型，不能把进度判断塞进房间、钱包、用户资料或游戏主链路。

## 目标

- 支持三条首版等级轨道：财富、游戏、魅力。
- 支持普通成就：关注/被关注、送礼/收礼、麦上时长、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。
