daily_browse_asset (id=2) / daily_login (id=1) 之前永远不触发,根因:
2026_07_21_004 迁移只 backfill 了 id=3/4 的 trigger_event=task_key,
id=1/2 留 NULL。但 ProcessTaskEvent 引擎按 trigger_event = ? 严格匹配
(spec §4 单一隔离单元),NULL 不会命中,前端 reportEvent 后引擎跑空
循环、daily-tasks 任务永远 pending。
修复:
- 新增 2026_07_27_005 迁移:回填 id=1/2 的 trigger_event=task_key(幂等)
- 修订原 004 迁移的错误注释('trigger_event 留 NULL'误导后人)
- model/task_models.go:删除 'NULL 仍走 def.TaskKey 兜底' 不实承诺
- impl plan §37/39:修订错描述('暂不依赖 trigger_event 列')
- docker/init-db.sql:124668/124671:重建 dev DB 时也带 trigger_event
- daily_task_repo_test.go:补 2 个回归测试
- NullTriggerEvent_NotMatched:trigger_event=NULL 时查不到
- TriggerEventEqualsTaskKey_Matches:trigger_event=task_key 时能命中
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
24 KiB
Daily-Task 配置驱动改造 — 实施计划
配套文档:
docs/superpowers/specs/2026-07-21-daily-task-config-driven-design.md(设计方案,已审过 12 处修复) 状态:待实施(计划已批准) 工作量:约 1 周(8 阶段 × 0.5~1.5d)
Context
每日任务系统当前完成判定硬编码在前端(Header.vue 报 daily_login、asset-detail.vue 报 daily_browse_asset,原 exhibition.vue 错位实现待迁移),后端用 def.TaskKey == eventType 内联匹配并带 TODO 注释,无法表达"次数 / 多事件"语义。daily_mint 在 task_definitions 中 active 但无后端 emit;daily_place_asset 同样 active 但全仓库无对应 emit 点,用户永远无法完成(已确认本地库 user_daily_task_progress 中 0 行)—— 属悬空任务。
目标:把完成判定搬到后端 ProcessTaskEvent 引擎(方案 A:单列 trigger_event + target_count),加 MQ task:event 统一事件流,让新增每日任务只需 SQL 配置而不改前端 / 后端代码。
预期结果:
- 加新每日任务 = SQL 一行(
INSERT INTO task_definitions ...)+ 后端业务源头 emit 一次;前端零改动 - 修复
daily_mint/daily_place_asset悬空 - 现有 4 个每日任务行为不变(前端保留
Header.vue/asset-detail.vue/myWorks.vue的reportEvent调用;exhibition.vue错位调用删除)
用户已确认的关键决策
| 问题 | 决定 |
|---|---|
| 上线策略 | 全量上线(不加 feature flag / 灰度) |
| 前端 daily_browse_asset 缓存 | 当前无缓存,依赖后端幂等(status='completed' 后跳过) |
| assetService MQ 依赖 | 新增 mq.Init + EnqueueTaskEvent helper(沿用 galleryService 模式) |
实施步骤(8 阶段,可独立 commit)
Phase A — 数据库 schema 变更(0.5d)
新建 migration backend/migrations/2026_07_21_004_daily_task_trigger.sql:
ALTER TABLE task_definitions ADD COLUMN trigger_event varchar(64)+ADD COLUMN target_count int NOT NULL DEFAULT 1ALTER TABLE user_daily_task_progress ADD COLUMN progress int NOT NULL DEFAULT 0- Backfill:
UPDATE task_definitions SET trigger_event='daily_mint', target_count=1 WHERE id=3 AND task_type='daily'+ 同上WHERE id=4 AND task_key='daily_place_asset'。 - ★ 修订 2026-07-27:原计划只 backfill id=3/4 是错的——
daily_login(id=1) /daily_browse_asset(id=2) 虽走前端 reportEvent,但 ProcessTaskEvent 引擎按trigger_event = ?严格匹配(spec §4 单一隔离单元),NULL 不会命中,daily-tasks 任务永远 pending。补充迁移backend/migrations/2026_07_27_005_daily_task_browse_login_trigger.sql回填 id=1/2 的 trigger_event=task_key。onboarding_complete(id=5) 不属于 daily,留 NULL。
同步 docker/init-db.sql(line 3564 task_definitions CREATE TABLE、line 3744 user_daily_task_progress CREATE TABLE、line 124667 COPY 数据):加新列;为新 COPY 行加 trigger_event / target_count 值(id=1,2,3,4 填对应 task_key + 1,id=5 onboarding 留 NULL)
Critical files:
backend/migrations/2026_07_21_004_daily_task_trigger.sql(新)docker/init-db.sql(line 3564, 3744, 124664 三处同步)
验证:psql -f backend/migrations/2026_07_21_004_daily_task_trigger.sql + \d task_definitions \d user_daily_task_progress 确认列存在 + backfill 4 行值正确
Phase B — GORM model + Repository 层(0.5d)
修改 backend/services/taskService/model/task_models.go:
TaskDefinition加TriggerEvent string+TargetCount int字段UserDailyTaskProgress加Progress int字段
修改 backend/services/taskService/repository/daily_task_repo.go:
DailyTaskRepositoryinterface 新增IncrementProgress(progress *UserDailyTaskProgress, def *TaskDefinition) error(实现位于 line ~140)- 事务内
UPDATE user_daily_task_progress SET progress = progress + 1, status=?, completed_at=? WHERE id=? AND status='pending'(避免 Save 全量覆写)
- 事务内
ListActiveDailyTaskDefinitions查询条件追加trigger_event = ?参数化(line 111 起,WHERE 子句改为Where("trigger_event = ? AND ...", eventType, ...))ResetAllDailyTasks的Updatesmap 加"progress": 0(line 131)- 接口签名变更:现有
ListActiveDailyTaskDefinitions(starID)改为ListActiveDailyTaskDefinitions(starID, eventType string)—— 单一参数变化,全仓调用点同步(grep 验证;只有daily_task_service.go调用)
Critical files:
backend/services/taskService/model/task_models.gobackend/services/taskService/repository/daily_task_repo.gobackend/services/taskService/service/daily_task_service.go(ListActiveDailyTaskDefinitions 调用点同步)
验证:cd backend/services/taskService && go build ./...;手动 INSERT INTO task_definitions (..., trigger_event='test_xxx', ...) + 调 ListActiveDailyTaskDefinitions(1, "test_xxx") 返回结果对得上
Phase C — 事件目录 + MQ 基础设施(0.5d)
新建 backend/pkg/mq/tasks/task_events.go:
package tasks
const (
EventDailyLogin = "daily_login"
EventDailyBrowseAsset = "daily_browse_asset"
EventDailyMint = "daily_mint"
EventDailyPlaceAsset = "daily_place_asset"
)
type TaskEventPayload struct {
UserID int64 `json:"user_id"`
StarID int64 `json:"star_id"`
EventType string `json:"event_type"` // 取自 EventDailyXxx 常量
}
修改 backend/pkg/mq/tasks/registry.go:
- 加
TypeTaskEvent = "task:event"常量(与现有TypeRevenueExhibition风格一致)
Critical files:
backend/pkg/mq/tasks/task_events.go(新)backend/pkg/mq/tasks/registry.go(追加 1 行常量)
验证:go build ./...;grep -rn "EventDailyMint\|TypeTaskEvent" backend/ 确认可被 import
Phase D — 完成引擎 ProcessTaskEvent(1.5d)
修改 backend/services/taskService/service/daily_task_service.go:
-
接口
DailyTaskService(line 20-25)新增方法签名:ProcessTaskEvent(ctx context.Context, userID, starID int64, eventType string) (*TaskEventResult, error) -
新增 struct(line 27 后):
type TaskEventResult struct { CompletedTaskKeys []string } -
重写
ReportEvent方法(line 99-165):删除原内联def.TaskKey != eventType循环,整体替换为委托给ProcessTaskEvent:func (s *dailyTaskService) ReportEvent(ctx context.Context, userID, starID int64, eventType string) (*pb.ReportEventResponse, error) { result, err := s.ProcessTaskEvent(ctx, userID, starID, eventType) if err != nil { return &pb.ReportEventResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.Internal)}, Success: false}, err } taskKey := "" if len(result.CompletedTaskKeys) > 0 { taskKey = result.CompletedTaskKeys[0] } return &pb.ReportEventResponse{ Base: &pbCommon.BaseResponse{Code: uint32(codes.OK)}, Success: true, TaskKey: taskKey, TaskCompleted: len(result.CompletedTaskKeys) > 0, Message: "任务完成", }, nil } -
新增
ProcessTaskEvent方法(line ~166 后):func (s *dailyTaskService) ProcessTaskEvent(ctx context.Context, userID, starID int64, eventType string) (*TaskEventResult, error) { logger.Logger.Info("ProcessTaskEvent", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.String("event_type", eventType)) defs, err := s.dailyRepo.ListActiveDailyTaskDefinitions(starID, eventType) // ★ Phase B 改后的签名 if err != nil { return nil, err } result := &TaskEventResult{} for _, def := range defs { progress, err := s.dailyRepo.GetOrCreateDailyProgress(userID, starID, def.TaskKey, def) if err != nil { logger.Logger.Error("ProcessTaskEvent: get/create failed", zap.Error(err), zap.String("task_key", def.TaskKey)) continue } if progress.Status == "completed" || progress.Status == "claimed" { continue // 当天幂等 } if err := s.dailyRepo.IncrementProgress(progress, def); err != nil { logger.Logger.Error("ProcessTaskEvent: increment failed", zap.Error(err)) continue } if progress.Progress >= def.TargetCount { progress.Status = "completed" now := time.Now().UnixMilli() progress.CompletedAt = &now if err := s.dailyRepo.UpdateDailyProgress(progress); err != nil { logger.Logger.Error("ProcessTaskEvent: status update failed", zap.Error(err)) continue } result.CompletedTaskKeys = append(result.CompletedTaskKeys, def.TaskKey) } } return result, nil }
修改 backend/services/taskService/provider/task_mobile_provider.go line 62:
return p.dailySvc.ReportEvent(...)保持不变(provider 仍叫ReportEvent,内部委托给ProcessTaskEvent)
Critical files:
backend/services/taskService/service/daily_task_service.gobackend/services/taskService/provider/task_mobile_provider.go(不改,确认接口稳定即可)
验证:go build ./...;单测(Phase G)覆盖 5 个 case
Phase E — MQ consumer 注册(0.5d)
修改 backend/services/taskService/mq/consumer.go:
func newHandleTaskEvent(dailySvc service.DailyTaskService) adapter.TaskHandler {
return func(ctx context.Context, t *adapter.Task) error {
var p tasks.TaskEventPayload
if err := tasks.UnmarshalPayload(t.Payload, &p); err != nil {
return fmt.Errorf("unmarshal: %w", err)
}
if _, err := dailySvc.ProcessTaskEvent(ctx, p.UserID, p.StarID, p.EventType); err != nil {
logger.Logger.Error("handle task:event: ProcessTaskEvent failed",
zap.String("event_type", p.EventType), zap.Error(err))
return err
}
return nil
}
}
修改 RegisterHandlers 签名(line 26):增加 dailySvc service.DailyTaskService 参数
func RegisterHandlers(revenueSvc service.RevenueService, dailySvc service.DailyTaskService) error {
// ... 现有注册保留
if err := tc.RegisterTask(tasks.TypeTaskEvent, newHandleTaskEvent(dailySvc), adapter.TaskRegisterOptions{
MaxRetry: 3,
Queue: queueconsts.QueueDefault,
}); err != nil {
return fmt.Errorf("register task:event: %w", err)
}
}
修改 backend/services/taskService/main.go line 195:
taskmq.RegisterHandlers(revenueSvc)改为taskmq.RegisterHandlers(revenueSvc, dailySvc)(dailySvc已在 line 161 构造)
Critical files:
backend/services/taskService/mq/consumer.gobackend/services/taskService/main.go(line 195)
验证:go build ./...;本地起 MQ + 手 enqueue task:event 消息,确认 taskService 消费
Phase F — 触发源 emit 接入(1d)
修订(2026-07-27):
daily_place_asset改为前端 emit(myWorks.vueplaceAssetToGalleryApi成功后);daily_browse_asset由exhibition.vue迁移到asset-detail.vue。galleryService不再需要新增 emit 代码。
F.1 assetService(铸造成功 emit daily_mint — 后端)
新建 backend/services/assetService/mq/producer.go:
package mq
import (
"context"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/mq/adapter"
"github.com/topfans/backend/pkg/mq/tasks"
"go.uber.org/zap"
)
// EnqueueTaskEvent 业务事件 emit(修复 daily_mint 悬空)
func EnqueueTaskEvent(ctx context.Context, userID, starID int64, eventType string) error {
payload, err := tasks.MarshalToPayload(tasks.TaskEventPayload{
UserID: userID, StarID: starID, EventType: eventType,
})
if err != nil { return err }
task := adapter.Task{
Type: tasks.TypeTaskEvent,
Payload: payload,
MaxRetry: 3,
}
if _, err := adapter.Get().TaskProducer().Enqueue(ctx, task); err != nil {
logger.Logger.Warn("EnqueueTaskEvent failed", zap.Error(err), zap.String("event_type", eventType))
return err
}
return nil
}
修改 backend/services/assetService/main.go:
- 新增 4 个 flag(参照 galleryService line 46-49):
mqRedisAddr / mqRedisDB / mqRedisPassword / mqConcurrency - 新增 3 个 import:
pkg/mq/pkg/mq/asynq/pkg/queue/consts - 新增
mq.Init(mq.Config{...})块(参照 galleryService line 213-236),只 init 不注册 handler(assetService 是 producer-only)
修改 backend/services/assetService/service/mint_service.go:
-
新增 import
assetmq "github.com/topfans/backend/services/assetService/mq" -
line 603(
statistic.Get().TrackEvent(...)之后,return response, nil之前)插入:// 业务事件:触发 daily_mint 每日任务 if err := assetmq.EnqueueTaskEvent(context.Background(), userID, starID, "daily_mint"); err != nil { logger.Logger.Warn("emit task:event daily_mint failed", zap.Error(err)) } -
同样补到
finalizeMintOrderRecovery(line 1324)和reconcileOneMintOrder补单成功分支,避免 reconcile 路径漏发
F.2 前端 emit 接入(替换原 galleryService emit 方案)
frontend/pages/asset-detail/asset-detail.vue:在 onLoad 钩子(line 851 之后)新增:
// 触发每日首次浏览藏品任务
const starIdBrowse = uni.getStorageSync('star_id') || 1
reportEvent('daily_browse_asset', starIdBrowse).catch(err => {
console.error('上报浏览事件失败:', err)
})
顶部 import 加 import { reportEvent } from '@/utils/task-api.js'。
frontend/pages/profile/myWorks.vue:在 placeAssetToGalleryApi 成功后(line 347 后)插入:
// 触发每日首次上架任务
const starIdPlace = uni.getStorageSync('star_id') || 1
reportEvent('daily_place_asset', starIdPlace).catch(err => {
console.error('上报上架事件失败:', err)
})
顶部 import 加 import { reportEvent } from '@/utils/task-api.js'。
frontend/pages/exhibition/exhibition.vue:删除 line 1564-1568 错位的 reportEvent('daily_browse_asset', starId) 调用块(含上方注释 "上报每日首次预览事件"),并删除顶部 import { reportEvent } from '@/utils/task-api.js'(如该文件不再使用 reportEvent)。
Critical files:
backend/services/assetService/mq/producer.go(新)backend/services/assetService/main.gobackend/services/assetService/service/mint_service.gofrontend/pages/asset-detail/asset-detail.vuefrontend/pages/profile/myWorks.vuefrontend/pages/exhibition/exhibition.vue
验证:
- 本地 dev 环境铸造一个藏品 → 后端日志看到
EnqueueTaskEvent daily_mint→ Asynq Web UI 队列收到task:event→ taskService consumer 处理 →user_daily_task_progress中daily_mint进度 +1 - 上架一枚藏品 → 前端
myWorks.vue调reportEvent('daily_place_asset')→ taskService 后端daily_place_asset进度 +1 - 进入任一藏品详情页 → 前端
asset-detail.vue调reportEvent('daily_browse_asset')→daily_browse_asset进度 +1
Phase G — 单测覆盖(1d)
新建 backend/services/taskService/service/daily_task_service_test.go(参照 like_bet_repo_test.go + revenue_service_test.go 的混合模式):
- TestMain + setupTestDB + cleanupTestRecords(sentinel user_id < 0, star_id = -77, task_key 前缀
test_xxx_) - 用
db.AutoMigrate(&model.TaskDefinition{}, &model.UserDailyTaskProgress{})准备 schema - 5 个 case(覆盖 spec §8 测试清单):
TestProcessTaskEvent_FirstCompletion:target=1 + emit daily_login → status=completedTestProcessTaskEvent_Counting:target=3 + emit 3 次 daily_browse_asset → progress=1,2,3 → 第三次 completed,第四次 no-opTestProcessTaskEvent_AlreadyCompleted:emit 时 status='completed' → skipTestProcessTaskEvent_UnknownEvent:emit "test_unknown_event" → no-op, no errorTestProcessTaskEvent_GlobalAndStarSpecific:star_id=NULL 定义 + star_id=87 定义并存 → emit 时两者都 +1
- 每个 case 末尾
defer cleanupTestRecords清理 sentinel 行
新建 backend/services/taskService/repository/daily_task_repo_test.go:
TestIncrementProgress:pending → progress=1 → completedTestIncrementProgress_AlreadyCompleted:status='completed' → UPDATE 行 0 影响TestResetAllDailyTasks:completed/claimed → pending, progress=0TestListActiveDailyTaskDefinitions_WithTriggerEvent:trigger_event='daily_login' 只返回对应行
Critical files:
backend/services/taskService/service/daily_task_service_test.go(新)backend/services/taskService/repository/daily_task_repo_test.go(新)
验证:cd backend/services/taskService && go test ./...;要求本地 dev PostgreSQL 可连(localhost:15432,与 .env.dev 一致),否则 t.Skipf
Phase H — 前端清理 + 部署(0.5d)
修改 frontend/pages/tasks/daily-tasks.vue line 62-63 + 闭合 <text>:删除 task-progress 渲染段(约 3 行),proto 字段不存在(已确认)
部署顺序:
- 数据库 migration 先上线(
ALTER TABLE无破坏,列加 + 默认值) - 后端 3 个服务滚动重启(taskService / assetService / galleryService)
- 前端发版(删 3 行死代码)
回滚预案:
- migration 回滚:
ALTER TABLE task_definitions DROP COLUMN trigger_event, DROP COLUMN target_count;ALTER TABLE user_daily_task_progress DROP COLUMN progress - 代码回滚:git revert 各 commit
- 数据无损:所有新列都有默认值 + backfill 已固化
Critical files:
frontend/pages/tasks/daily-tasks.vue(删除 line 62-63 + 闭合标签)
关键文件清单(汇总)
新增
backend/migrations/2026_07_21_004_daily_task_trigger.sqlbackend/pkg/mq/tasks/task_events.gobackend/services/assetService/mq/producer.gobackend/services/taskService/service/daily_task_service_test.gobackend/services/taskService/repository/daily_task_repo_test.go
修改
docker/init-db.sqlbackend/services/taskService/model/task_models.gobackend/services/taskService/repository/daily_task_repo.gobackend/services/taskService/service/daily_task_service.gobackend/services/taskService/mq/consumer.gobackend/services/taskService/main.gobackend/services/assetService/main.gobackend/services/assetService/service/mint_service.gofrontend/pages/asset-detail/asset-detail.vue(新增reportEvent('daily_browse_asset'))frontend/pages/profile/myWorks.vue(新增reportEvent('daily_place_asset'))frontend/pages/exhibition/exhibition.vue(删除错位的reportEvent('daily_browse_asset'))frontend/pages/tasks/daily-tasks.vue
不修改(确认无变化)
backend/services/taskService/provider/task_mobile_provider.go(line 62 保留ReportEvent调用,内部委托已在新daily_task_service.go完成)backend/services/taskService/worker/daily_reset_worker.go(已通过 repo 层ResetAllDailyTasks加progress: 0间接修复)backend/services/galleryService/mq/producer.go(不修改:daily_place_asset改为前端 emit,不需要新增EnqueueTaskEventhelper)backend/services/galleryService/service/exhibition_service.go(不修改)backend/services/galleryService/main.go(不修改)frontend/utils/task-api.js、frontend/pages/components/Header.vue(保留前端reportEvent调用)
验证方法(端到端)
-
本地 dev 环境全流程:
# Phase A psql -h localhost -p 15432 -U postgres -d top-fans -f backend/migrations/2026_07_21_004_daily_task_trigger.sql # Phase B-F cd backend && go build ./... # 重启 taskService / assetService / galleryService(dev 环境) # Phase G cd backend/services/taskService && go test ./... # Phase H # uniapp 编译 + 模拟器测试 -
手测 daily_mint 端到端:
- App 内铸造一个藏品 → 后端日志查
[asset.mint] emit task:event daily_mint - Asynq Web UI(
http://localhost:8081/queues)查default队列出现task:event任务 - taskService 日志查
[task:event] ProcessTaskEvent ... task_completed: daily_mint SELECT * FROM user_daily_task_progress WHERE task_key='daily_mint'应有 status='completed' 行
- App 内铸造一个藏品 → 后端日志查
-
手测 daily_place_asset 端到端:上架一个藏品到展位 → 同样流程
-
手测 daily_login(前端 reportEvent 路径):打开 App 进 home →
task-api.js调/api/v1/tasks/report-event→ 后端ReportEventRPC 委托ProcessTaskEvent→daily_login进度更新 -
手测 daily_browse_asset:进入任一藏品详情页 →
asset-detail.vue调reportEvent('daily_browse_asset')→ 后端处理 → 进度更新 -
重置验证:手动调
ResetAllDailyTasks或等 05:00 Asia/Shanghai →progress=0, status='pending'(查询验证) -
回滚演练:本地先演练 migration 回滚 SQL,确认无破坏
风险与缓解
| 风险 | 概率 | 缓解 |
|---|---|---|
| MQ emit 失败导致任务永远完不成 | 低 | Emit 走 Asynq MaxRetry=3;前端可重新触发(daily_login / daily_browse_asset);EnqueueTaskEvent 失败仅 Warn 不阻塞主路径 |
| Backfill 数据偏差 | 低 | Migration 用显式 WHERE id IN (3,4) 而非全表 UPDATE;手动验证 4 行值 |
| assetService 新增 MQ 依赖增加启动失败面 | 中 | mq.Init 失败仅 Warn 日志(仿 galleryService),不影响 RPC;失败时 daily_mint 永远完不成,但任务保留 active 状态待修复后补发 |
IncrementProgress 事务并发导致进度丢失 |
低 | GORM 默认事务隔离;如需严格一致后续 Stage 2 加 SELECT FOR UPDATE |
Reset 时 progress 字段未加导致 daily 重置不全 |
高(已规避) | Phase B ResetAllDailyTasks 必须同步加 progress: 0 |
| daily_browse_asset 重复触发(同一天进入详情页多次) | 低 | 后端 completed 后跳过;无前端缓存依赖,行为正确 |
任务清单(commit 粒度建议)
按 8 阶段独立 commit,每个 commit 通过 go build + 单测:
feat(task): migration + backfill daily_task trigger_event(Phase A)feat(task): GORM model + repository schema support(Phase B)feat(mq): task:event task type + business event constants(Phase C)feat(task): ProcessTaskEvent engine + ReportEvent delegation(Phase D)feat(task): register task:event MQ consumer(Phase E)feat(asset,frontend): emit task:event on mint success + frontend daily_place/browse_asset(Phase F)test(task): ProcessTaskEvent + IncrementProgress coverage(Phase G)chore(frontend): remove dead task-progress rendering(Phase H)
每个 commit 完成后跑 go build ./... + go test ./...,全绿后再继续。
关键参考(已读过)
- 设计文档:
docs/superpowers/specs/2026-07-21-daily-task-config-driven-design.md(12 处修复已落地) backend/services/taskService/service/daily_task_service.go:99-165—— 当前 ReportEvent 内联实现(待删除)backend/pkg/mq/tasks/registry.go:30-88—— 现有 TaskType 常量风格backend/services/galleryService/mq/producer.go:62-99—— EnqueueExhibitSettled 模式(EnqueueTaskEvent 参考)backend/services/userService/mq/consumer.go:36-47—— RegisterHandlers 模式(task:event handler 参考)backend/services/taskService/repository/like_bet_repo_test.go:16-78—— 测试模式(TestMain + setupTestDB + sentinel cleanup)docker/init-db.sql:124664-124670—— task_definitions 现有 5 行 seed(id=1,2,3,4 是 daily)
不在本次范围(明确延后)
- proto 加
progress/target_count字段 + 前端展示进度条(spec §9.1) - 计数型任务 distinct 去重(spec §5、§7.1)
- A→B 升级:task_triggers 表 + JSON 规则(spec §7)
- 用户服务
daily_login后端化(当前 daily_login 必须前端,因为"每日首次活跃"语义后端观测不到)