# 每日任务配置驱动 + 后端事件驱动触发 设计方案
> **★ MVP 优先**:本文档是可落地的 MVP 实施方案(方案 A)。计数模型是二元模型的低成本超集,予以采纳;多对多映射(方案 B)与规则引擎(方案 C)**不在本次实现**,仅作为 §7 平滑升级路线图。
## 文档说明
- **适用范围**:`taskService` 每日任务(`task_type='daily'`)的完成/触发机制改造,及其前端联动。不含引导任务(onboarding)与收益(revenue)逻辑。
- **工作量估算**:约 1 周(2 列 + 1 列 migration、1 个完成引擎方法、MQ 事件接入、3~4 处 emit 点、重置改一行、service 单测)。
- **前置版本/历史**:现状为前端硬编码上报 + 后端 `def.TaskKey == eventType` 内联匹配;`daily_mint` / `daily_place_asset` 无 emit 点,属悬空任务(本方案顺带修复)。
- **目标读者**:后端 taskService 开发、前端 App 开发、DBA。
---
## 一、方案概述(必读)
### 要解决的问题
**业务问题**
- 每日任务列表会"不定时更改":大多数时候是改文案 / 奖励 / 次数 / 顺序 / 上下架(复用已有用户行为),偶尔引入全新的完成行为。
- 诉求:每次改动**前端不做大改**。
**技术问题**
- 完成判定硬编码在前端(`Header.vue` 报 `daily_login`、`asset-detail.vue` 报 `daily_browse_asset`,原 `exhibition.vue` 错位实现需迁移),加任务就得改前端,且客户端可伪造。
- 后端匹配是内联的 `def.TaskKey == eventType`,带 `TODO`,无法表达"次数""多事件"。
- `daily_mint` / `daily_place_asset` 在 `task_definitions` 中 active,但全仓库无对应上报点 → 用户永远无法完成(已由实连本地库 `top-fans` 确认:这两个 task_key 在 `user_daily_task_progress` 中 0 行)。
### 整体实现路径
| 阶段 | 内容 | 估时 |
|---|---|---|
| 1. 数据模型 | `task_definitions` 加 `trigger_event`/`target_count`;`user_daily_task_progress` 加 `progress`;migration + backfill | 0.5d |
| 2. 事件目录 | 共享事件常量文件 + 治理规则 | 0.5d |
| 3. 完成引擎 | `ProcessTaskEvent` 方法,替换内联匹配 | 1.5d |
| 4. 事件接入 | MQ `task:event` 消费 + `ReportEvent` 委托 `ProcessTaskEvent`;铸造/上架源头 emit;前端 `daily_login`/`daily_browse_asset` 保留 reportEvent | 2d |
| 5. 重置 | `ResetAllDailyTasks` 增加 `progress=0` | 0.25d |
| 6. 测试 | `ProcessTaskEvent` service 单测 | 1d |
### 关键决策
1. **触发源 = 后端事件驱动为主,前端通用上报兜底**(详见 §4)。抗刷、可覆盖纯后端行为,且新任务复用已有事件时前端零改动。
2. **映射机制 = 方案 A:`task_definitions` 单列 `trigger_event` + `target_count`**(详见 §2、§4)。最低复杂度,覆盖 90% 场景。
3. **计数模型**:`target_count=1` 等价"首次",`>1` 为计数型;MVP 不做 distinct 去重(详见 §5)。
4. **单一隔离单元**:所有匹配逻辑封在 `ProcessTaskEvent` 一个方法内,成为 A→B 升级的唯一改动点(详见 §4、§7)。
### 核心架构图(TL;DR)
```
[后端服务: 铸造成功/上架成功] ──publish──┐
▼
[前端纯 UI 动作(如浏览详情)] ─reportEvent─▶ gateway ─▶ MQ 统一事件
│ task:event { user_id, star_id, event_type }
▼
taskService MQ consumer ┐
ReportEvent RPC(兜底) ├─▶ DailyTaskService.ProcessTaskEvent()
┘ │
▼
查 active daily 定义 where trigger_event = event_type
→ GetOrCreate 进度 → progress += 1
→ progress >= target_count ? status=completed
│
(领取仍是独立步骤: ClaimDailyTask → 发水晶 → claimed)
```
---
## 二、数据模型变更
所有变更需写 migration(放 `backend/migrations/`),并在 `docker/init-db.sql` 同步。
### 2.1 `task_definitions` 新增 2 列
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `trigger_event` | `varchar(50)` | 可空 | 驱动该任务的事件名,取自 §3 事件目录 |
| `target_count` | `int` | `not null default 1` | 完成所需次数;`=1` 即"首次",等价现状 |
对应 GORM model `model.TaskDefinition` 增加字段:
```go
TriggerEvent string `gorm:"column:trigger_event;size:50"`
TargetCount int `gorm:"column:target_count;default:1"`
```
**Backfill(存量 4 行)**:`trigger_event = task_key`,`target_count = 1`。
```sql
UPDATE task_definitions SET trigger_event = task_key WHERE task_type = 'daily' AND trigger_event IS NULL;
UPDATE task_definitions SET target_count = 1 WHERE target_count IS NULL OR target_count = 0;
```
### 2.2 `user_daily_task_progress` 新增 1 列
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `progress` | `int` | `not null default 0` | 当前累计次数;`progress >= target_count` → completed |
对应 model `UserDailyTaskProgress` 增加:
```go
Progress int `gorm:"column:progress;default:0"`
```
状态机不变:`pending → completed → claimed`,每日重置回 `pending`。
> **序列规范提醒**:如后续脚本手动 INSERT 指定 id,须按 CLAUDE.md 规则重置对应 `_id_seq`。
---
## 三、事件目录(单一事实来源)
在后端新建 `backend/pkg/mq/tasks/task_events.go`(**新增约定**:与现有 `pkg/mq/tasks/registry.go` 单文件风格不同——业务事件枚举与 MQ TaskType 解耦;后续若有更多业务事件枚举,也放本文件),集中定义**有限、稳定**的事件枚举:
```go
const (
EventDailyLogin = "daily_login" // 每日首次活跃(进入 home;前端 reportEvent)
EventDailyBrowseAsset = "daily_browse_asset" // 每日首次浏览藏品详情
EventDailyMint = "daily_mint" // 每日首次铸造
EventDailyPlaceAsset = "daily_place_asset" // 每日首次上架作品
)
```
**治理规则**
1. 任务配置的 `trigger_event` **只能引用目录中已存在的事件**。
2. 新增事件(B 类新行为)= 加一个常量 + 在该行为的**源头 emit 一次**;此后该事件可被任意数量的新任务复用(纯配置)。
3. 事件命名与含义在本节表格维护,改动需同步本文档。
4. 每个事件标注**归属路径**(后端 emit / 前端 reportEvent),遵守 §5 F4 单一路径规则:
| 事件 | 含义 | 归属路径 |
|---|---|---|
| `daily_login` | 每日首次活跃(进入 home) | 前端 `reportEvent`(`Header.vue` 进 home 时触发;前端本地缓存 `daily_login_completed_${date}_${uid}_${starId}` 做日内去重) |
| `daily_browse_asset` | 每日首次浏览藏品详情 | 前端 `reportEvent`(`asset-detail.vue` onLoad 时触发;现有 `exhibition.vue:1566` 错位实现需迁移并删除,避免重复触发) |
| `daily_mint` | 每日首次铸造 | 后端(铸造成功处 emit,修复悬空) |
| `daily_place_asset` | 每日首次上架作品 | 前端 `reportEvent`(`myWorks.vue` `placeAssetToGalleryApi` 调用成功后触发;走前端 emit 而非后端 galleryService emit,简化实施) |
> **关键决策(daily_login 归属)**:用户保持登录态今天打开 app 进 home 时**没有"登录成功"事件**可 emit,但 `daily_login` 仍需触发(语义是"每日首次活跃"而非"登录")。因此 `daily_login` 必须走前端 `reportEvent`,**不能**强制走后端 emit。`§5 F4` 单一路径规则适用此场景——前端 `Header.vue` 是**唯一** emit 入口,登录服务不再为 `daily_login` 发事件。
> **MQ 与业务事件分层**:上表常量(`daily_login…`)是**业务事件**枚举,供 `task_definitions.trigger_event` 引用;**MQ 任务类型** `TypeTaskEvent = "task:event"` 及其 `TaskEventPayload{UserID, StarID, EventType}` 是**传输层**概念,放 `backend/pkg/mq/tasks/registry.go`(沿用 `revenue:exhibition` / `gallery:exhibition-settled` 命名风格)。一条 `task:event` MQ 消息的 payload 里携带某个业务 `EventType` 值,由 §4 `ProcessTaskEvent` 引擎消费。
---
## 四、完成引擎(唯一隔离单元)
### 4.1 新方法
在 `DailyTaskService` 新增:
```go
// TaskEventResult 供 ReportEvent RPC 回填响应
type TaskEventResult struct {
CompletedTaskKeys []string // 本次事件导致 completed 的任务(可能 0~N 个)
}
ProcessTaskEvent(ctx context.Context, userID, starID int64, eventType string) (*TaskEventResult, error)
```
**取代** `daily_task_service.go` 现有 `ReportEvent` 内那段 `def.TaskKey == eventType` 内联循环。
> **F1 说明**:`ProcessTaskEvent` 返回 `*TaskEventResult` 同时被两条调用链使用——
> - **MQ consumer**:忽略返回值,只关心 `error`(consumer 异步执行,无前端依赖)
> - **`ReportEvent` handler**:从结果回填 `ReportEventResponse`——`TaskCompleted = len(CompletedTaskKeys) > 0`,`TaskKey = 首个 completed`,`Message = "任务完成" | "未匹配任务"`
>
> 两条路径共用 §4.3 同一引擎逻辑(不重复实现 match + 累加 + 状态机),是方案 A "单一隔离单元" 的具体落地。
### 4.2 两个入口,一个引擎
| 入口 | 来源 | 说明 |
|---|---|---|
| MQ consumer(主) | 后端服务发布的 `task:event` | 抗刷、覆盖纯后端行为 |
| `ReportEvent` RPC(兜底) | 前端通用上报(纯 UI 动作,如浏览详情) | 改为**直接调 `ProcessTaskEvent`**(gateway 无需改造;handler 内部委托),不再自己做匹配;响应从 `TaskEventResult` 回填(见 §4.1 F1) |
前端 `task-api.js` 保留**唯一**的 `reportEvent(eventType, starId)`,不随任务增减而改动。
### 4.3 引擎逻辑
输入 `{userID, starID, eventType}`:
1. 查定义:`is_active = true AND task_type = 'daily' AND trigger_event = eventType AND (star_id = ? OR star_id IS NULL)`。
2. 逐个 `GetOrCreateDailyProgress`;若状态已是 `completed` / `claimed` → 跳过(当天幂等)。
3. `progress += 1`;若 `progress >= def.TargetCount` → `status = "completed"`、`completed_at = now`。
4. `UpdateDailyProgress` 保存。
> **隔离保证**:事件→任务的映射被封在"第 1 步查询 + 本方法"内。这是 §7 升级 B 的唯一改动点。
> **F5 多命中说明**:同一 `trigger_event` 可能同时命中「全局任务(star_id IS NULL)」与「该 star 专属任务」,此时**两者都会各自 +1** —— 这是预期行为(专属任务是全局任务的叠加,而非替代)。若运营需要"专属覆盖全局",属 §7 范畴,MVP 不做。
---
## 五、幂等与去重
- **`target_count = 1`**:天然幂等,`completed` 后跳过,无需额外处理。
- **计数型(`target_count > 1`)**:MVP 每个合格事件 `+1`,封顶 `target_count`。语义为"做 N 次",**不做 distinct 去重**(即"浏览 3 次",而非"3 个不同藏品")。
- **MQ 重试**:consumer `MaxRetry = 3`(沿用现有 revenue handler 模式);引擎对 `target=1` 幂等,重试安全。计数型在无去重前提下,重试可能多计——通过"仅在业务成功后 emit 一次 + 合理 MaxRetry"控制;严格 exactly-once 归入 §7。
- **"N 个不同对象"**(distinct 去重):需要 `event_id`(如 asset_id)+ 幂等表,归入 §7 升级路径,不进 MVP。
> **F4 单一路径规则(强约束)**:同一个用户动作**只能经一条路径 emit** —— 要么后端 MQ 发布,要么前端 `reportEvent`,**不可两者都发**。否则计数型任务(`target_count > 1`)会重复 +1。约定:能被后端观测**且具备完整业务事件**的动作(铸造、上架)一律走后端 emit 且前端**不**再上报;纯 UI 动作(浏览详情)和**仅前端可观测的状态**("每日首次活跃",见 §3)才走前端 `reportEvent`。事件目录(§3)需标注每个事件的归属路径。
---
## 六、每日重置
`DailyResetWorker` 逻辑不变(05:00 Asia/Shanghai、`pg_try_advisory_lock` 防多实例)。仅 `ResetAllDailyTasks()` 的 `Updates` map 增加一项:
```go
Updates(map[string]interface{}{
"status": "pending",
"progress": 0, // 新增
"completed_at": nil,
"claimed_at": nil,
"updated_at": now,
})
```
---
## 七、平滑升级路径(写入文档,不实现)
### 7.1 A → B:一个任务多种触发 / 每事件独立规则
- 新增表 `task_triggers(id, task_id, event_type, increment, dedup_key, created_at)`,支持一任务多事件、一事件多任务、每触发独立增量与去重键。
- 引擎 §4.3 第 1 步的查询从"读 `task_definitions.trigger_event` 列"改为"读 `task_triggers` join `task_definitions`"。
- 因匹配逻辑已隔离在 `ProcessTaskEvent` 一个方法内,**只改这一处 + 加一张表**;`trigger_event` 列降级为冗余快捷方式或废弃。
- **存量数据迁移**:Stage 2 实施时需将存量 `task_definitions.trigger_event` 4 行数据 INSERT 到 `task_triggers`,灰度对比 `ProcessTaskEvent` 行为一致后,再 `ALTER TABLE task_definitions DROP COLUMN trigger_event`(如废弃)。中间态可保留 `trigger_event` 列做冗余快捷方式(命中即返回,避免 `task_triggers` join 性能损耗)。
### 7.2 B → C:复杂规则(时间窗、distinct 计数、连续行为)
- 在 trigger 上加 JSON `rule` 表达式列(如 `{"event":"browse","distinct_by":"asset_id","count":3,"window":"1d"}`),引擎接一个规则求值器。
- 属 Stage 2+,业务复杂度真正上来后再评估,避免 YAGNI。
---
## 八、错误处理与测试
**错误处理**
- 定义缺失 / 未激活 → 静默跳过 + `logger.Info`。
- 发奖仍在独立的 `ClaimDailyTask` / `ClaimAllDailyTasks` 步骤(不变),与完成解耦。
- 引擎内单条任务更新失败不影响其他任务(逐个处理,记 error 日志)。
**测试**(沿用现有 `*_test.go` 事务回滚 / 测试容器模式)
- `ProcessTaskEvent` service 单测覆盖:
- 首次完成(`target=1`)
- 计数累加与封顶(`target=3`:1→2→3 completed,第 4 次 no-op)
- 已 completed / claimed 时再来事件 → 跳过
- 未知 / 无匹配事件 → no-op
- per-star 定义与全局定义并存时的匹配
- repository:`progress` 累加、`ResetAllDailyTasks` 重置 `progress=0`。
---
## 九、受影响文件清单
| 文件 | 改动 |
|---|---|
| `backend/migrations/2026_07_21_*_daily_task_trigger.sql` | 新增:加列 + backfill |
| `docker/init-db.sql` | 同步表结构 + 种子 `trigger_event`/`target_count` |
| `backend/services/taskService/model/task_models.go` | `TaskDefinition` + `UserDailyTaskProgress` 加字段 |
| `backend/services/taskService/service/daily_task_service.go` | 新增 `ProcessTaskEvent`,`ReportEvent` 改为委托 |
| `backend/services/taskService/repository/daily_task_repo.go` | 接口新增 `IncrementProgress(progress *UserDailyTaskProgress, def *TaskDefinition) error`(事务内 `UPDATE ... SET progress = progress + 1, status=?, completed_at=? WHERE id=? AND status='pending'`,避免 Save 全量覆写);`ResetAllDailyTasks` 的 `Updates` map 加 `progress: 0` |
| `backend/pkg/mq/tasks/task_events.go`(新) | 业务事件枚举常量(`daily_login…`) |
| `backend/pkg/mq/tasks/registry.go` | 加 MQ 任务类型 `TypeTaskEvent="task:event"` + `TaskEventPayload` struct |
| `backend/services/taskService/mq/consumer.go` | 注册 `task:event` handler → `ProcessTaskEvent` |
| `backend/services/assetService` 铸造成功处 | emit `task:event { event_type: EventDailyMint, user_id, star_id }`(修复 `daily_mint` 悬空;候选位置:`service/mint_service.go` line 603 前后,伴随现有 `asset.mint` 事件埋点) |
| ~~`backend/services/galleryService` 上架成功处~~ | **本方案不修改**:`daily_place_asset` 改为前端 emit(见 §3),`galleryService` 不需要新增 emit 点。 |
| ~~`backend/services/userService` 登录服务~~ | **本方案不修改**:`daily_login` 改为前端 emit(见 §3),登录服务不 emit `task:event`。如 Stage 2 引入"每日首次活跃"后端事件,需在此处补充。 |
| `backend/services/taskService/worker/daily_reset_worker.go` | 无需改(调 repo) |
| `frontend/pages/components/Header.vue` | 无需改(保留 `reportEvent("daily_login", starId)` 调用,归属=前端;本地缓存 `daily_login_completed_${date}_${uid}_${starId}` 做日内去重) |
| `frontend/pages/exhibition/exhibition.vue` | **需改**:删除 line 1564-1568 的 `reportEvent('daily_browse_asset', starId)`(错位实现;`daily_browse_asset` 应绑定 `asset-detail.vue` 而非展馆浏览页) |
| `frontend/pages/asset-detail/asset-detail.vue` | **需改**:在 `onLoad` 钩子(line 851 之后)新增 `reportEvent('daily_browse_asset', starId)`(正确触发源:藏品详情页) |
| `frontend/pages/profile/myWorks.vue` | **需改**:在 `placeAssetToGalleryApi` 成功后(line 347 后)新增 `reportEvent('daily_place_asset', starId)`(前端 emit 简化实施) |
| `frontend/utils/task-api.js` | 无需改(保留唯一 `reportEvent`) |
| `frontend/pages/tasks/daily-tasks.vue` | **需改**:删除 line 62-63 错误的 `task.current_count` / `task.target_count` 进度渲染(proto 中无此字段;详见 §9.1) |
### 9.1 配套前端清理(必须,与决策"MVP 不展示进度条"对齐)
**现状问题**:现有 `frontend/pages/tasks/daily-tasks.vue:62-63` 错误地引用了 proto 中**不存在**的字段:
```html
({{
task.current_count }}/{{ task.target_count }})
```
实测 `DailyTaskItem` proto 仅含 7 个字段:`TaskKey` / `StarId` / `Name` / `Description` / `CrystalReward` / `Status` / `CanClaim`(见 `backend/pkg/proto/task/task.pb.go`)。该段代码在生产中永远不会渲染任何内容(`v-if="undefined"` 永远为 `false`),属于 MVP 实施必须清理的死代码:
| 文件 | 改动 |
|---|---|
| `frontend/pages/tasks/daily-tasks.vue` | 删除 line 62-63 的 `…` 段(连带 `` 闭合标签,约 3 行) |
**与 §1 "前端不做大改" 的关系**:本清理属于"修正已有 bug",**不是新增功能**。MVP 决策"MVP 不展示进度条"保持不变,`progress` 仅后端内部计数用于判完成。
> **未来扩展**:若运营后续要求展示 "N/M" 进度条,需:
> 1. 在 proto 源文件中给 `DailyTaskItem` 加 `progress` / `target_count` 字段
> 2. 重新生成 `task.pb.go` / `task.triple.go`(**注意**:项目当前所有 `pkg/proto/*` 目录下均无 `.proto` 源文件,团队需先确认 proto 源文件归属位置——可能在独立 repo 或专用生成脚本中)
> 3. `daily_task_service.go` `GetDailyTasks` 映射时填充两个字段
> 4. 前端 `daily-tasks.vue` 重新渲染 `task.progress / task.target_count`
>
> **不在本次 MVP 范围**。