topfans/docs/superpowers/specs/2026-07-21-daily-task-config-driven-design.md
2026-07-21 21:14:22 +08:00

264 lines
15 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`、`exhibition.vue` 报 `daily_browse_asset`),加任务就得改前端,且客户端可伪造。
- 后端匹配是内联的 `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` 改为生产者;铸造/上架/登录/浏览源头 emit | 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``services/taskService/model`),集中定义**有限、稳定**的事件枚举:
```go
const (
EventDailyLogin = "daily_login" // 每日首次登录
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` | 每日首次登录 | 后端auth/登录服务 emitstar_id 从 JWT/token 中取) |
| `daily_browse_asset` | 每日首次浏览藏品详情 | 前端 `reportEvent`(纯 UI 动作) |
| `daily_mint` | 每日首次铸造 | 后端(铸造成功处 emit修复悬空 |
| `daily_place_asset` | 每日首次上架作品 | 后端(上架成功处 emit修复悬空 |
> **F3 概念区分**:业务事件枚举(上表 `daily_login…`,供 `trigger_event` 引用)与 **MQ 传输层的任务类型**是两回事。MQ 任务类型常量 `TypeTaskEvent = "task:event"` 及其 payload struct `TaskEventPayload{UserID, StarID, EventType}` 应放在 `backend/pkg/mq/tasks/registry.go`(沿用现有 `revenue:` / `gallery:` 命名风格);业务事件枚举放 `task_events.go`。一条 `task:event` MQ 消息的 payload 里携带某个业务 `EventType`。
---
## 四、完成引擎(唯一隔离单元)
### 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 说明**:返回结果而非仅 `error`,是因为 `ReportEvent` RPC 的 `ReportEventResponse` 需要 `TaskKey`/`TaskCompleted`/`Message`proto 现有字段)。委托后由 `ReportEvent` 用 `TaskEventResult` 回填:`TaskCompleted = len(CompletedTaskKeys) > 0``TaskKey = 首个 completed`。MQ consumer 则忽略返回值只关心 `error`。
### 4.2 两个入口,一个引擎
| 入口 | 来源 | 说明 |
|---|---|---|
| MQ consumer | 后端服务发布的 `task:event` | 抗刷、覆盖纯后端行为 |
| `ReportEvent` RPC兜底 | 前端通用上报(纯 UI 动作,如浏览详情) | 改为"发同一条 MQ 事件 / 或直接调 `ProcessTaskEvent`",不再自己做匹配 |
前端 `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 动作(浏览详情)才走前端 `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` 列降级为冗余快捷方式或废弃。
### 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` | 进度累加;`ResetAllDailyTasks` 加 `progress` |
| `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` |
| 铸造 / 上架 / 登录服务源头 | emit `task:event`(修复 `daily_mint`/`daily_place_asset` 悬空;遵守 §5 F4 单一路径) |
| `backend/services/taskService/worker/daily_reset_worker.go` | 无需改(调 repo |
| `frontend/utils/task-api.js` | 无需改(保留唯一 `reportEvent` |
| `frontend/pages/tasks/daily-tasks.vue` | 默认无需改(已配置驱动) |
### 9.1 若需前端展示进度条 "N/M"(可选,非 MVP 默认)
计数型任务若要在前端显示 `2/3` 进度,`DailyTaskItem` proto **当前无 progress/target 字段**(实测仅 TaskKey/StarId/Name/Description/CrystalReward/Status/CanClaim需额外
| 文件 | 改动 |
|---|---|
| `backend/pkg/proto/task/*.proto` | `DailyTaskItem``progress` / `target_count` 字段 |
| (重新生成) | `protoc` 重新生成 `task.pb.go` / `task.triple.go` |
| `daily_task_service.go` `GetDailyTasks` | 映射时填充 `progress` / `target_count` |
| `frontend/pages/tasks/daily-tasks.vue` | 渲染进度 `progress/target_count` |
> **决策(已定稿)**MVP **不展示进度条**,前端完全零改动,`progress` 仅后端内部计数用于判完成。本 §9.1 的 proto 改动作为将来需要进度展示时的参考,不在本次实现范围。