topfans/docs/superpowers/specs/2026-07-21-daily-task-config-driven-design.md
zerosaturation 4284775ed6 docs(daily-task): 修订设计 spec + 新增实施计划
- specs/2026-07-21-daily-task-config-driven-design.md: 修订 8 处反映实施反馈
  (frontend 入口迁移、TaskEventPayload 路径、emit 失败仅 Warn 等)
- plans/2026-07-21-daily-task-config-driven-impl.md: 新增 8 阶段实施计划
  (A schema → B 事件目录 → C 完成引擎 → D 事件接入 → E 重置 → F 测试 → G 部署 → H 验收)
  时序与 spec §4 子章节对齐

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:47:24 +08:00

289 lines
19 KiB
Markdown
Raw 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.

# 每日任务配置驱动 + 后端事件驱动触发 设计方案
> **★ 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
<text class="task-progress" v-if="task.current_count !== undefined">{{
task.current_count }}/{{ task.target_count }}</text>
```
实测 `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 的 `<text class="task-progress" v-if="task.current_count !== undefined">…</text>` 段(连带 `<text>` 闭合标签,约 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 范围**。