# 每日任务系统架构方案

本文定义 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. 游戏消耗金币的事件来源和业务类型枚举需要先统一，否则任务无法区分普通扣费和游戏扣费。
