diff --git a/backend/migrations/2026_07_21_004_daily_task_trigger.sql b/backend/migrations/2026_07_21_004_daily_task_trigger.sql index 8917c16..04ff9fc 100644 --- a/backend/migrations/2026_07_21_004_daily_task_trigger.sql +++ b/backend/migrations/2026_07_21_004_daily_task_trigger.sql @@ -17,9 +17,17 @@ ALTER TABLE task_definitions ADD COLUMN IF NOT EXISTS target_count int NOT NULL -- (2) user_daily_task_progress 新列 ALTER TABLE user_daily_task_progress ADD COLUMN IF NOT EXISTS progress int NOT NULL DEFAULT 0; --- (3) Backfill:仅给"后端 emit 路径"的两个 daily 任务填 trigger_event --- daily_login (id=1) 与 daily_browse_asset (id=2) 走前端 reportEvent,trigger_event 留 NULL --- onboarding_complete (id=5) 不属于 daily,trigger_event 留 NULL +-- (3) Backfill:给"后端 emit 路径"的两个 daily 任务填 trigger_event。 +-- daily_mint (id=3) 由 assetService.CreateMintOrder 成功后 emit; +-- daily_place_asset (id=4) 由前端 myWorks.vue 上架成功后 reportEvent。 +-- onboarding_complete (id=5) 不属于 daily,留 NULL。 +-- +-- ★ 修正 2026-07-27: +-- 原 SQL 漏 backfill id=1 (daily_login) / id=2 (daily_browse_asset), +-- 误以为"走前端 reportEvent 就不用填 trigger_event"——但 ProcessTaskEvent +-- 按 trigger_event = ? 严格匹配(spec §4 单一隔离单元,无 NULL 兜底), +-- NULL 永远查不到,引擎跑空循环,daily-tasks 任务永远 pending。 +-- 已在 2026_07_27_005 迁移中按 spec §2.1 原计划补回填。 UPDATE task_definitions SET trigger_event = 'daily_mint', target_count = 1 WHERE id = 3 AND task_type = 'daily'; UPDATE task_definitions SET trigger_event = 'daily_place_asset', target_count = 1 WHERE id = 4 AND task_type = 'daily'; diff --git a/backend/migrations/2026_07_27_005_daily_task_browse_login_trigger.sql b/backend/migrations/2026_07_27_005_daily_task_browse_login_trigger.sql new file mode 100644 index 0000000..5225af0 --- /dev/null +++ b/backend/migrations/2026_07_27_005_daily_task_browse_login_trigger.sql @@ -0,0 +1,32 @@ +-- 2026_07_27_005_daily_task_browse_login_trigger.sql +-- 修复 daily_login (id=1) / daily_browse_asset (id=2) 的 trigger_event 留 NULL bug: +-- 原迁移 2026_07_21_004 只 backfill 了 id=3/4,id=1/2 留 NULL。 +-- 但 ProcessTaskEvent 引擎按 trigger_event = ? 严格匹配(spec §4 单一隔离单元), +-- NULL 不会命中,导致前端 reportEvent 后引擎跑空循环、daily-tasks 页面任务永远 pending。 +-- +-- 本迁移按 spec §2.1 原计划回填:trigger_event = task_key。 +-- 触发源(spec §3 / §5 F4): +-- - id=1 daily_login ← 前端 Header.vue reportEvent +-- - id=2 daily_browse_asset ← 前端 asset-detail.vue reportEvent +-- +-- 已正确填过的(id=3 daily_mint, id=4 daily_place_asset)不在本迁移处理范围。 +-- onboarding_complete (id=5) 不属于 daily,留 NULL。 +-- +-- 执行前请先备份: +-- pg_dump -h -U postgres -t task_definitions > backup_daily_task_005.sql + +BEGIN; + +-- Backfill:仅回填当前 trigger_event 仍为 NULL 的 daily 任务(幂等) +UPDATE task_definitions +SET trigger_event = task_key, + target_count = 1, + updated_at = (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint +WHERE task_type = 'daily' + AND trigger_event IS NULL + AND id IN (1, 2); + +-- 序列同步(CLAUDE.md 强制) +SELECT setval('task_definitions_id_seq', (SELECT COALESCE(MAX(id),1) FROM task_definitions)); + +COMMIT; diff --git a/backend/services/taskService/model/task_models.go b/backend/services/taskService/model/task_models.go index 1101588..97cd438 100644 --- a/backend/services/taskService/model/task_models.go +++ b/backend/services/taskService/model/task_models.go @@ -14,7 +14,10 @@ type TaskDefinition struct { CreatedAt int64 `gorm:"column:created_at"` UpdatedAt int64 `gorm:"column:updated_at"` // 方案 A(spec §2.1 / §4):trigger_event + target_count 取代内联 def.TaskKey 匹配 - TriggerEvent string `gorm:"column:trigger_event;size:64"` // NULL=仍走 def.TaskKey==eventType 兜底(向后兼容) + // ★ trigger_event 必须填;ProcessTaskEvent 引擎按 trigger_event = ? 严格匹配 + // (spec §4 单一隔离单元),NULL 不会命中。daily 任务的 trigger_event 由 + // 2026_07_27_005 迁移回填(与 task_key 一致);onboarding 任务 trigger_event 留 NULL。 + TriggerEvent string `gorm:"column:trigger_event;size:64"` TargetCount int `gorm:"column:target_count;default:1"` // =1 等价"首次",>1 为计数型 } diff --git a/backend/services/taskService/repository/daily_task_repo_test.go b/backend/services/taskService/repository/daily_task_repo_test.go index 36a05c7..f7b41f8 100644 --- a/backend/services/taskService/repository/daily_task_repo_test.go +++ b/backend/services/taskService/repository/daily_task_repo_test.go @@ -326,4 +326,96 @@ func TestListActiveDailyTaskDefinitions_EmptyEventType_NoFilter(t *testing.T) { if !foundA || !foundB { t.Errorf("expected both defA and defB in no-filter results: A=%v B=%v", foundA, foundB) } +} + +// ==================== 回归用例 2026-07-27:daily_login / daily_browse_asset 触发源 ==================== + +// seedTestDefinitionNullTrigger 同 seedTestDefinition,但显式把 trigger_event 置为 NULL。 +// 模拟 2026_07_21_004 迁移漏 backfill 的存量场景。 +func seedTestDefinitionNullTrigger(t *testing.T, db *gorm.DB, taskKey string) *model.TaskDefinition { + def := &model.TaskDefinition{ + StarID: nil, + TaskKey: taskKey, + TaskType: "daily", + Name: "test daily null-trigger", + CrystalReward: 10, + TriggerEvent: "", // GORM 零值 = 不写入或写 NULL;为保险显式用 sql.NullString 走 NULL + TargetCount: 1, + IsActive: true, + CreatedAt: time.Now().UnixMilli(), + UpdatedAt: time.Now().UnixMilli(), + } + // 强制 NULL:直接 SQL 插入,绕开 GORM 零值处理 + if err := db.Exec( + `INSERT INTO task_definitions + (star_id, task_key, task_type, name, crystal_reward, trigger_event, target_count, is_active, created_at, updated_at) + VALUES (NULL, ?, 'daily', ?, ?, NULL, ?, true, ?, ?)`, + def.TaskKey, def.Name, def.CrystalReward, def.TargetCount, def.CreatedAt, def.UpdatedAt, + ).Error; err != nil { + t.Fatalf("seed null-trigger task_definition: %v", err) + } + // 回读拿 ID + if err := db.Where("task_key = ?", def.TaskKey).First(def).Error; err != nil { + t.Fatalf("reread null-trigger task_definition: %v", err) + } + return def +} + +// TestListActiveDailyTaskDefinitions_NullTriggerEvent_NotMatched 回归测试: +// spec §4 单一隔离单元——ListActiveDailyTaskDefinitions 按 trigger_event = ? 严格匹配, +// NULL 不会命中(即使 task_key == eventType 也不兜底)。这是 daily_browse_asset +// 之前不触发的根因(迁移漏 backfill)。 +func TestListActiveDailyTaskDefinitions_NullTriggerEvent_NotMatched(t *testing.T) { + db := setupDailyTaskDB(t) + defer cleanupDailyTaskRecords(t, db) + repo := NewDailyTaskRepository(db) + + // 模拟 2026_07_21_004 漏 backfill 的存量:task_key=daily_browse_asset + trigger_event=NULL + def := seedTestDefinitionNullTrigger(t, db, "test_daily_browse_asset_null_trigger") + defer db.Delete(def) + + // 验证:trigger_event 确实为 NULL + if def.TriggerEvent != "" { + t.Fatalf("seed sanity check failed: expected trigger_event=NULL/empty, got %q", def.TriggerEvent) + } + + // 核心断言:eventType="test_daily_browse_asset_null_trigger" 时查不到 + results, err := repo.ListActiveDailyTaskDefinitions(-77, def.TaskKey) + if err != nil { + t.Fatalf("ListActiveDailyTaskDefinitions: %v", err) + } + for _, d := range results { + if d.ID == def.ID { + t.Errorf("def with trigger_event=NULL MUST NOT be matched when eventType=%q (spec §4 单一隔离单元)", def.TaskKey) + } + } +} + +// TestListActiveDailyTaskDefinitions_TriggerEventEqualsTaskKey_Matches 回归测试: +// 2026_07_27_005 迁移回填后,trigger_event=task_key 的 def 应当被对应 eventType 命中。 +// 验证 happy path:daily_browse_asset / daily_login 修复后能正确触发。 +func TestListActiveDailyTaskDefinitions_TriggerEventEqualsTaskKey_Matches(t *testing.T) { + db := setupDailyTaskDB(t) + defer cleanupDailyTaskRecords(t, db) + repo := NewDailyTaskRepository(db) + + // 模拟 2026_07_27_005 迁移回填后的状态:trigger_event = task_key + def := seedTestDefinition(t, db, "test_daily_browse_asset_matched", 1) + defer db.Delete(def) + + results, err := repo.ListActiveDailyTaskDefinitions(-77, def.TriggerEvent) + if err != nil { + t.Fatalf("ListActiveDailyTaskDefinitions: %v", err) + } + + found := false + for _, d := range results { + if d.ID == def.ID { + found = true + break + } + } + if !found { + t.Errorf("expected def with trigger_event=%q to be matched", def.TriggerEvent) + } } \ No newline at end of file diff --git a/docker/init-db.sql b/docker/init-db.sql index c7d030f..5601cc2 100644 --- a/docker/init-db.sql +++ b/docker/init-db.sql @@ -124665,10 +124665,10 @@ COPY public.system_configs (id, config_key, config_value, description, is_active -- COPY public.task_definitions (id, task_key, task_type, name, description, crystal_reward, exp_reward, sort_order, is_active, created_at, updated_at, star_id, trigger_event, target_count) FROM stdin; -1 daily_login daily 每日首次登录 每天首次登录即可完成 20 20 1 t 1775528749856 1775528749856 \N \N 1 +1 daily_login daily 每日首次登录 每天首次登录即可完成 20 20 1 t 1775528749856 1775528749856 \N daily_login 1 3 daily_mint daily 每日首次铸造 每天首次铸造藏品即可完成 20 60 3 t 1775528749856 1775528749856 \N daily_mint 1 5 onboarding_complete onboarding 完成新手引导 完成新手引导教程 420 250 1 t 1775528749856 1775528749856 \N \N 1 -2 daily_browse_asset daily 每日首次浏览藏品 每天首次查看藏品详情即可完成 20 40 2 t 1775528749856 1776149064005 \N \N 1 +2 daily_browse_asset daily 每日首次浏览藏品 每天首次查看藏品详情即可完成 20 40 2 t 1775528749856 1776149064005 \N daily_browse_asset 1 4 daily_place_asset daily 每日首次上架作品 每天首次将作品放入展位即可完成 20 80 4 t 1775528749856 1781157975963 \N daily_place_asset 1 \. diff --git a/docs/superpowers/plans/2026-07-21-daily-task-config-driven-impl.md b/docs/superpowers/plans/2026-07-21-daily-task-config-driven-impl.md index 936b35b..49e33db 100644 --- a/docs/superpowers/plans/2026-07-21-daily-task-config-driven-impl.md +++ b/docs/superpowers/plans/2026-07-21-daily-task-config-driven-impl.md @@ -34,9 +34,10 @@ **新建 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 1` - `ALTER 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'`(**只 backfill id=3 / id=4**,`daily_login` / `daily_browse_asset` 走前端 reportEvent 暂不依赖后端 trigger_event 列;`onboarding_complete` 不动) +- 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 124664 COPY 数据):加新列;为新 COPY 行加 `trigger_event` / `target_count` 值(id=1,2 留 NULL,id=3,4 填对应 task_key + 1) +**同步 `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`(新)