hyapp-server/docs/每日任务系统架构.md
2026-05-12 20:07:49 +08:00

573 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.

# 每日任务系统架构方案
本文定义 App 每日任务和专属任务的产品边界、服务归属、事件来源、数据模型、后台管理和关键异常处理。
## 目标
- 后台增加 `活动管理` 一级菜单,`每日任务` 作为活动管理下的二级菜单。
- `每日任务` 页面包含两个 Tab`每日任务``专属任务`
- 每日任务按 UTC 自然日刷新,当天只能领取一次,超额完成不结转到明天。
- 专属任务是用户维度终身只能完成并领取一次的任务。
- 任务奖励统一发金币,金币发放必须走 `wallet-service` 账本。
- 任务进度由服务端事实事件驱动,客户端不能上报任务进度。
## 非目标
- 首版不做客户端自定义任务排序。
- 首版不做多奖励类型;奖励只支持金币。
- 首版不做任务进度回滚退款、撤销、CP 解除等后续事件进入风控和补偿,不直接减已完成任务。
- 首版不让 room-service、wallet-service、user-service 同步调用任务系统更新进度。
## 服务边界
| 模块 | 责任 | 不负责 |
| --- | --- | --- |
| `server/admin` | 菜单、后台 HTTP API、任务配置管理、启停、排序、查询统计 | 不直接写 app 业务库;通过 protobuf client 或明确集成接口访问后端 |
| `activity-service` | 任务定义、任务版本、任务进度、完成状态、领奖状态、任务事件消费幂等 | 不直接改钱包余额,不拥有房间/钱包/用户基础事实 |
| `wallet-service` | 金币奖励入账、账本幂等、奖励流水 | 不判断任务是否完成 |
| `user-service` | CP 关系事实、用户麦上时长事实 | 不判断任务奖励 |
| `room-service` | 房间和麦位状态 owner产生房间 outbox | 不同步更新任务 |
建议首版把任务系统放在 `activity-service`因为它已经承接活动、消息、fanout 等低频活动能力。后续如果任务规模独立膨胀,再拆 `task-service`
## 总体架构
```mermaid
flowchart LR
Admin["admin 活动管理 > 每日任务"] --> AdminAPI["admin-server task APIs"]
AdminAPI --> ActivityAdmin["activity-service AdminTaskService"]
Room["room-service outbox"] --> UserMic["user-service mic time consumer"]
UserMic --> UserOutbox["user-service user_outbox"]
Wallet["wallet-service outbox"] --> TaskConsumer["activity-service task consumers"]
UserOutbox --> TaskConsumer
CP["user-service CP outbox"] --> TaskConsumer
TaskConsumer --> TaskDB["activity MySQL task tables"]
App["App task page"] --> Gateway["gateway-service"]
Gateway --> ActivityQuery["activity-service TaskQueryService"]
Gateway --> ActivityClaim["activity-service TaskClaimService"]
ActivityClaim --> WalletGrant["wallet-service CreditTaskReward"]
```
核心原则:
- 任务系统只消费事实事件不参与房间、钱包、CP 主链路。
- 金币奖励只通过钱包账本发放。
- 每个事件必须有稳定 `event_id`,任务消费必须幂等。
- 任务完成和领奖是两个状态,完成不等于已经发奖。
## 任务类型
| 分类 | 进度指标 | 事件来源 | 口径 |
| --- | --- | --- | --- |
| CP 任务 | 组成 CP 次数 | user-service CP 关系创建事件 | 以 `CpRelationshipCreated` 为准,解除 CP 不回滚 |
| 上麦任务 | 麦上有效分钟数 | user-service 麦位时长聚合事件 | 使用 `mic_online_ms`,不是单纯占麦时间 |
| 游戏任务 | 游戏消耗金币数 | wallet-service 游戏扣费事件 | 只统计成功扣费的 COIN |
| 送礼任务 | 送礼消耗金币数 | wallet-service 送礼扣费事件 | 使用钱包扣费金额,不能用客户端礼物价格 |
| 幸运礼物任务 | 幸运礼物消耗金币数或中奖金币数 | wallet-service 幸运礼物结算事件 | 首版一个任务只配置一种指标:`spend_coin``win_coin` |
| 充值任务 | 充值金币数 | wallet-service 充值成功事件 | Google、Apple、三方支付、币商转普通金币都可以按配置纳入口径 |
### 上麦任务口径
上麦任务默认读取 `user_mic_daily_stats.mic_online_ms` 或消费 `UserMicSessionClosed` 派生事件。`mic_online_ms` 只从确认发流开始累计未发声、只占麦、pending_publish 不计入有效麦上时间。
跨天上麦必须按 UTC 自然日拆分到不同 `task_day`,不能把整段时长归到下麦当天。
### 幸运礼物任务口径
幸运礼物任务需要避免把“消耗”和“中奖”混在同一个字段:
| 指标 | 含义 |
| --- | --- |
| `lucky_gift_spend_coin` | 用户购买/发送幸运礼物实际消耗金币 |
| `lucky_gift_win_coin` | 用户因幸运礼物中奖获得的金币价值 |
如果运营需要“消耗 n 金币且中奖 n 金币”,应在第二阶段支持多条件 AND。首版建议一个任务一个指标减少配置歧义。
## 任务周期
### 每日任务
- 周期 key 使用 UTC 自然日计算,例如 UTC 下的 `2026-05-09`
- 任务周期不读取机器本地时区、用户手机时区或请求 IP 所在时区。
- 事件归属日按 `occurred_at_ms` 转 UTC 计算,不按消费时间。
- 用户当天完成后只能领取一次。
- 当天超额完成只保留当天进度,不结转到明天。
- 每日刷新不需要批量清空用户数据;查询时按当前 `task_day` 读取或懒创建进度。
### 专属任务
- 周期 key 固定为 `lifetime`
- 用户终身只能完成一次、领取一次。
- 首版可作为全体用户可见的 lifetime 任务。
- 如果后续要“指定用户专属”,再增加任务分配表,不要把用户列表塞进任务定义 JSON。
## 后台管理
后台菜单:
```text
活动管理
- 每日任务
Tab 1: 每日任务
Tab 2: 专属任务
```
### 列表字段
| 字段 | 说明 |
| --- | --- |
| 任务 ID | 系统生成 |
| 任务名称 | App 展示名 |
| 任务分类 | CP / 上麦 / 游戏 / 送礼 / 幸运礼物 / 充值 |
| 任务类型 | 每日任务 / 专属任务 |
| 目标值 | n 次、n 分钟、n 金币 |
| 奖励金币 | 完成后领取的金币数 |
| 状态 | draft / active / paused / archived |
| 生效时间 | 可选 |
| 结束时间 | 可选 |
| 排序值 | App 列表排序 |
| 当前版本 | 配置版本 |
| 创建人/更新时间 | 审计 |
### 新增/编辑任务
后台表单:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| 任务类型 | 是 | daily / exclusive |
| 任务分类 | 是 | 固定枚举 |
| 任务名称 | 是 | App 展示 |
| 任务描述 | 否 | App 展示 |
| 指标类型 | 是 | 按分类收敛,例如 `mic_online_minutes``gift_spend_coin` |
| 目标值 | 是 | 必须大于 0 |
| 奖励金币 | 是 | 必须大于 0 |
| 排序值 | 是 | 小值靠前 |
| 生效/结束时间 | 否 | 控制任务上下架窗口 |
| 状态 | 是 | draft 保存草稿active 对 App 生效 |
编辑 active 任务时建议生成新版本。每日任务的新版本默认从下一个 `task_day` 生效,避免当天用户看到目标值突然变化。
## App 接口
### 查询任务列表
```text
GET /api/v1/tasks/tabs
```
返回两个分区:
- `daily`
- `exclusive`
每个任务返回:
| 字段 | 说明 |
| --- | --- |
| `task_id` | 任务 ID |
| `task_type` | daily / exclusive |
| `category` | 任务分类 |
| `title` | 展示名称 |
| `description` | 展示描述 |
| `target_value` | 目标值 |
| `progress_value` | 当前进度 |
| `reward_coin_amount` | 奖励金币 |
| `status` | in_progress / completed / claimed / expired |
| `claimable` | 是否可领取 |
| `task_day` | daily 任务返回当前日期 |
| `server_time_ms` | 服务端时间 |
| `next_refresh_at_ms` | 下次刷新时间 |
### 领取奖励
```text
POST /api/v1/tasks/claim
```
请求:
```json
{
"task_id": "123",
"task_type": "daily",
"task_day": "2026-05-09",
"command_id": "cmd_task_claim_550e8400"
}
```
规则:
- 必须已完成。
- 必须未领取。
- daily 只能领取当前 `task_day` 的任务。
- exclusive 只能领取 `lifetime` 任务。
- `command_id` 由客户端按领取动作生成,同一次动作重试必须复用,重复请求返回同一结果。
可选第二阶段增加:
```text
POST /api/v1/tasks/claim-all
```
## 数据模型
### `task_definitions`
任务定义当前版本的读模型。
| 字段 | 说明 |
| --- | --- |
| `task_id` | 主键 |
| `app_code` | App 租户 |
| `task_type` | daily / exclusive |
| `category` | cp / mic / game / gift / lucky_gift / recharge |
| `metric_type` | 具体指标 |
| `title` | 展示名称 |
| `description` | 展示描述 |
| `target_value` | 目标值 |
| `target_unit` | count / minute / coin |
| `reward_coin_amount` | 奖励金币 |
| `status` | draft / active / paused / archived |
| `sort_order` | 排序 |
| `version` | 当前版本 |
| `effective_from_ms` | 生效时间 |
| `effective_to_ms` | 结束时间 |
| `created_by_admin_id` | 创建人 |
| `updated_by_admin_id` | 更新人 |
| `created_at_ms` | 创建时间 |
| `updated_at_ms` | 更新时间 |
### `task_definition_versions`
保存历史版本,保证事件和进度可以追溯。
| 字段 | 说明 |
| --- | --- |
| `task_version_id` | 主键 |
| `task_id` | 任务 ID |
| `version` | 版本号 |
| `snapshot_json` | 任务配置快照 |
| `effective_from_cycle` | 从哪个 daily cycle 或 lifetime 生效 |
| `created_at_ms` | 创建时间 |
### `user_task_progress`
用户任务进度。
| 字段 | 说明 |
| --- | --- |
| `app_code` | App 租户 |
| `user_id` | 用户 ID |
| `task_id` | 任务 ID |
| `task_version_id` | 任务版本 |
| `cycle_key` | daily 使用 `yyyy-mm-dd`exclusive 使用 `lifetime` |
| `progress_value` | 当前进度,可超过目标值 |
| `target_value` | 进度创建时的目标快照 |
| `status` | in_progress / completed / claimed / expired |
| `completed_at_ms` | 完成时间 |
| `claimed_at_ms` | 领取时间 |
| `updated_at_ms` | 更新时间 |
唯一键:
```text
UNIQUE(app_code, user_id, task_id, cycle_key)
```
### `task_event_consumption`
事件幂等表。
| 字段 | 说明 |
| --- | --- |
| `app_code` | App 租户 |
| `event_id` | 来源事件 ID |
| `event_type` | 来源事件类型 |
| `source_service` | room / wallet / user |
| `user_id` | 事件归属用户 |
| `task_day` | UTC 日期 |
| `status` | consumed / skipped / failed |
| `skip_reason` | 跳过原因 |
| `created_at_ms` | 创建时间 |
| `consumed_at_ms` | 消费时间 |
主键:
```text
PRIMARY KEY(app_code, event_id)
```
### `task_reward_claims`
领奖幂等和钱包发奖状态。
| 字段 | 说明 |
| --- | --- |
| `claim_id` | 主键 |
| `app_code` | App 租户 |
| `command_id` | 领奖幂等键 |
| `user_id` | 用户 ID |
| `task_id` | 任务 ID |
| `cycle_key` | daily 日期或 lifetime |
| `reward_coin_amount` | 奖励金币快照 |
| `wallet_command_id` | 钱包发奖幂等键 |
| `status` | pending / granted / failed |
| `failure_reason` | 失败原因 |
| `created_at_ms` | 创建时间 |
| `updated_at_ms` | 更新时间 |
唯一键:
```text
UNIQUE(app_code, command_id)
UNIQUE(app_code, user_id, task_id, cycle_key)
```
## 事件消费流程
```mermaid
sequenceDiagram
participant Source as Source outbox
participant Task as activity task consumer
participant DB as activity MySQL
Source->>Task: event(event_id, user_id, metric, value, occurred_at_ms)
Task->>DB: INSERT task_event_consumption(event_id)
alt event_id 已存在
DB-->>Task: duplicate
Task-->>Source: ack
else 新事件
Task->>DB: 查 active task definitions
Task->>DB: upsert user_task_progress
Task->>DB: progress >= target 时标记 completed
Task->>DB: task_event_consumption = consumed
Task-->>Source: ack
end
```
进度更新必须在一个数据库事务内完成:
1. 写入 `task_event_consumption`
2. 找到事件命中的 active 任务。
3.`user_id + task_id + cycle_key` 锁定或创建进度行。
4. 增加进度。
5. 如果达到目标,标记 `completed`
6. 标记事件已消费。
如果事件没有匹配任何任务,写 `skipped`,避免重复消费同一个无效事件。
## 领奖流程
```mermaid
sequenceDiagram
participant App
participant Gateway
participant Activity as activity-service
participant DB as activity MySQL
participant Wallet as wallet-service
App->>Gateway: POST /tasks/claim
Gateway->>Activity: ClaimTaskReward
Activity->>DB: 锁 user_task_progress
DB-->>Activity: completed and not claimed
Activity->>DB: 创建 task_reward_claims(pending)
Activity->>Wallet: CreditTaskReward(wallet_command_id)
Wallet-->>Activity: granted
Activity->>DB: progress=claimed, claim=granted
Activity-->>Gateway: reward granted
Gateway-->>App: OK
```
钱包调用失败时:
- `task_reward_claims` 保持 `pending``failed`
- `user_task_progress` 不应标记 `claimed`
- 用户重试同一 `command_id` 返回当前状态。
- 后台可以扫描 `pending/failed` claim 重试。
## 边界和补充规则
### 1. UTC 周期
所有 daily 周期都按 UTC 计算:
- `task_day`
- `cycle_start_ms`
- `cycle_end_ms`
- `next_refresh_at_ms`
不要使用用户手机时区、请求 IP 所在时区或服务器部署所在地时区。
### 2. 任务定义修改
active 任务修改目标值或奖励时,必须生成新版本。
建议规则:
- 文案、排序可以立即生效。
- 目标值、奖励金币、指标类型从下一个 daily cycle 生效。
- 专属任务修改后只影响未开始用户,已完成或已领取用户不重算。
### 3. 延迟事件
事件按 `occurred_at_ms` 归属日期。
建议首版:
- 当前 task_day 之前的迟到事件可以记录到历史进度。
- App 只展示当前 daily 任务。
- daily 奖励只允许在当前 task_day 领取。
- 是否允许“昨日补领”不要默认支持,后续作为后台补偿能力单独设计。
### 4. 超额完成
`progress_value` 可以超过 `target_value`,便于展示真实进度,例如 `120/100`
但:
- daily 超额不结转。
- reward 仍只发一次。
- exclusive 完成后不再重复累计领取。
### 5. 任务可见性
首版所有 active 任务对当前 App 全体用户可见。
后续可扩展:
- 地区可见。
- 新用户可见。
- VIP 可见。
- 指定用户可见。
- 主播/Agency/BD 身份可见。
这些应通过 `task_audience_rules` 扩展,不要把复杂人群条件塞进基础任务表。
### 6. CP 任务解除关系
CP 任务以成功组成 CP 的事件为准。后续解除 CP 不回滚已完成进度,避免用户完成后被动丢奖励。
如果运营需要“当前仍保持 CP”应该设计状态型任务不要复用事件计数任务。
### 7. 上麦任务跨天
跨天麦位 session 必须拆分到不同 `task_day`
例如 UTC 23:50 上麦,次日 UTC 00:10 下麦:
```text
前一天 +10 分钟
后一天 +10 分钟
```
### 8. 充值退款和拒付
充值任务按成功充值事件完成。Google/Apple 拒付、退款、风控追回不直接回滚已领任务奖励。
如果后续需要严格财务反作弊,应新增:
- 充值奖励冻结期。
- 领奖后拒付追扣。
- 风控黑名单。
首版不建议把这些复杂规则放进每日任务主链路。
### 9. 金币奖励发放
activity-service 不能直接写钱包余额。
钱包发奖命令必须包含:
| 字段 | 说明 |
| --- | --- |
| `wallet_command_id` | 幂等键 |
| `user_id` | 领取用户 |
| `asset_type` | COIN |
| `amount` | 奖励金币 |
| `reason` | `daily_task_reward` / `exclusive_task_reward` |
| `biz_id` | `task_id + cycle_key` |
### 10. 幂等
必须有三层幂等:
- 来源事件:`task_event_consumption(event_id)`
- 用户进度:`user_task_progress(user_id, task_id, cycle_key)`
- 领奖:`task_reward_claims(command_id)``task_reward_claims(user_id, task_id, cycle_key)`
### 11. 删除任务
后台不做物理删除。
状态流转:
```text
draft -> active -> paused -> archived
```
`archived` 后 App 不展示,但历史进度、领奖记录仍可审计。
### 12. 任务排序
建议排序:
1. 可领取任务。
2. 已完成未领取任务。
3. 进行中任务。
4. 已领取任务。
5. 同状态按 `sort_order`
### 13. 任务进度查询性能
App 查询任务列表时不要实时扫钱包流水或房间事件。
查询路径只读:
```text
task_definitions + user_task_progress
```
没有进度行时返回 0不必提前为所有用户初始化每日任务。
### 14. 多 App 隔离
所有任务定义、进度、事件消费、领奖记录都必须带 `app_code`
任务 ID 不能跨 App 复用语义。
### 15. 管理端审计
后台每次创建、启停、修改任务都要写审计:
- operator_admin_id
- request_id
- before_json
- after_json
- created_at_ms
任务配置影响发奖,必须可追溯。
## 推荐实现顺序
1. Admin 菜单和权限:`活动管理` 一级菜单下增加 `每日任务` 二级菜单。
2. activity-service 增加任务定义表、版本表、进度表、事件消费表、领奖表。
3. Admin API每日任务/专属任务列表、新增、编辑、启停、排序。
4. App 查询接口:返回 daily/exclusive 两个分区和用户进度。
5. 钱包金币奖励接口:`CreditTaskReward` 幂等入账。
6. 领奖接口完成校验、claim 幂等、调用钱包发奖。
7. 接入充值、送礼、游戏、幸运礼物事件。
8. 接入上麦时长事件或 user-service 麦时长 read model。
9. 接入 CP 创建事件。
10. 增加补偿任务:扫描 pending claim、failed event、消费延迟。
## 需要提前确认的问题
1. 幸运礼物任务首版是“消耗金币”或“中奖金币”二选一,还是需要 AND 条件。
2. 充值任务是否包含币商转金币、后台补偿、活动赠币;建议首版只包含真实充值和币商转普通金币。
3. daily 任务是否允许次日补领;建议首版不允许。
4. 专属任务是否全体用户可见,还是需要后台指定用户或人群。
5. 游戏消耗金币的事件来源和业务类型枚举需要先统一,否则任务无法区分普通扣费和游戏扣费。