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

19 KiB
Raw Blame History

每日任务配置驱动 + 后端事件驱动触发 设计方案

★ 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.vuedaily_loginasset-detail.vuedaily_browse_asset,原 exhibition.vue 错位实现需迁移),加任务就得改前端,且客户端可伪造。
  • 后端匹配是内联的 def.TaskKey == eventType,带 TODO,无法表达"次数""多事件"。
  • daily_mint / daily_place_assettask_definitions 中 active但全仓库无对应上报点 → 用户永远无法完成(已由实连本地库 top-fans 确认:这两个 task_key 在 user_daily_task_progress 中 0 行)。

整体实现路径

阶段 内容 估时
1. 数据模型 task_definitionstrigger_event/target_countuser_daily_task_progressprogressmigration + 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. 映射机制 = 方案 Atask_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)

二、数据模型变更

所有变更需写 migrationbackend/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 增加字段:

TriggerEvent string `gorm:"column:trigger_event;size:50"`
TargetCount  int    `gorm:"column:target_count;default:1"`

Backfill存量 4 行)trigger_event = task_keytarget_count = 1

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 增加:

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 解耦;后续若有更多业务事件枚举,也放本文件),集中定义有限、稳定的事件枚举:

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 前端 reportEventHeader.vue 进 home 时触发;前端本地缓存 daily_login_completed_${date}_${uid}_${starId} 做日内去重)
daily_browse_asset 每日首次浏览藏品详情 前端 reportEventasset-detail.vue onLoad 时触发;现有 exhibition.vue:1566 错位实现需迁移并删除,避免重复触发)
daily_mint 每日首次铸造 后端(铸造成功处 emit修复悬空
daily_place_asset 每日首次上架作品 前端 reportEventmyWorks.vue placeAssetToGalleryApi 调用成功后触发;走前端 emit 而非后端 galleryService emit简化实施

关键决策daily_login 归属):用户保持登录态今天打开 app 进 home 时没有"登录成功"事件可 emitdaily_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 新增:

// 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:忽略返回值,只关心 errorconsumer 异步执行,无前端依赖)
  • ReportEvent handler:从结果回填 ReportEventResponse——TaskCompleted = len(CompletedTaskKeys) > 0TaskKey = 首个 completedMessage = "任务完成" | "未匹配任务"

两条路径共用 §4.3 同一引擎逻辑(不重复实现 match + 累加 + 状态机),是方案 A "单一隔离单元" 的具体落地。

4.2 两个入口,一个引擎

入口 来源 说明
MQ consumer 后端服务发布的 task:event 抗刷、覆盖纯后端行为
ReportEvent RPC兜底 前端通用上报(纯 UI 动作,如浏览详情) 改为直接调 ProcessTaskEventgateway 无需改造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.TargetCountstatus = "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 > 1MVP 每个合格事件 +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 增加一项:

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=31→2→3 completed第 4 次 no-op
    • 已 completed / claimed 时再来事件 → 跳过
    • 未知 / 无匹配事件 → no-op
    • per-star 定义与全局定义并存时的匹配
  • repositoryprogress 累加、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 新增 ProcessTaskEventReportEvent 改为委托
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 全量覆写);ResetAllDailyTasksUpdates 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见 §3galleryService 不需要新增 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 中不存在的字段:

<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 源文件中给 DailyTaskItemprogress / 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 范围