- 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>
19 KiB
每日任务配置驱动 + 后端事件驱动触发 设计方案
★ 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 |
关键决策
- 触发源 = 后端事件驱动为主,前端通用上报兜底(详见 §4)。抗刷、可覆盖纯后端行为,且新任务复用已有事件时前端零改动。
- 映射机制 = 方案 A:
task_definitions单列trigger_event+target_count(详见 §2、§4)。最低复杂度,覆盖 90% 场景。 - 计数模型:
target_count=1等价"首次",>1为计数型;MVP 不做 distinct 去重(详见 §5)。 - 单一隔离单元:所有匹配逻辑封在
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 增加字段:
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。
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" // 每日首次上架作品
)
治理规则
- 任务配置的
trigger_event只能引用目录中已存在的事件。 - 新增事件(B 类新行为)= 加一个常量 + 在该行为的源头 emit 一次;此后该事件可被任意数量的新任务复用(纯配置)。
- 事件命名与含义在本节表格维护,改动需同步本文档。
- 每个事件标注归属路径(后端 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:eventMQ 消息的 payload 里携带某个业务EventType值,由 §4ProcessTaskEvent引擎消费。
四、完成引擎(唯一隔离单元)
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:忽略返回值,只关心
error(consumer 异步执行,无前端依赖)ReportEventhandler:从结果回填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}:
- 查定义:
is_active = true AND task_type = 'daily' AND trigger_event = eventType AND (star_id = ? OR star_id IS NULL)。 - 逐个
GetOrCreateDailyProgress;若状态已是completed/claimed→ 跳过(当天幂等)。 progress += 1;若progress >= def.TargetCount→status = "completed"、completed_at = now。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 增加一项:
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_triggersjointask_definitions"。 - 因匹配逻辑已隔离在
ProcessTaskEvent一个方法内,只改这一处 + 加一张表;trigger_event列降级为冗余快捷方式或废弃。 - 存量数据迁移:Stage 2 实施时需将存量
task_definitions.trigger_event4 行数据 INSERT 到task_triggers,灰度对比ProcessTaskEvent行为一致后,再ALTER TABLE task_definitions DROP COLUMN trigger_event(如废弃)。中间态可保留trigger_event列做冗余快捷方式(命中即返回,避免task_triggersjoin 性能损耗)。
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 事务回滚 / 测试容器模式)
ProcessTaskEventservice 单测覆盖:- 首次完成(
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 中不存在的字段:
<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" 进度条,需:
- 在 proto 源文件中给
DailyTaskItem加progress/target_count字段- 重新生成
task.pb.go/task.triple.go(注意:项目当前所有pkg/proto/*目录下均无.proto源文件,团队需先确认 proto 源文件归属位置——可能在独立 repo 或专用生成脚本中)daily_task_service.goGetDailyTasks映射时填充两个字段- 前端
daily-tasks.vue重新渲染task.progress / task.target_count不在本次 MVP 范围。