fix(task): backfill id=1/2 trigger_event + cleanup misleading NULL comments

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>
This commit is contained in:
zerosaturation 2026-07-27 16:36:40 +08:00
parent 4284775ed6
commit 89d5d12450
6 changed files with 144 additions and 8 deletions

View File

@ -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) 走前端 reportEventtrigger_event 留 NULL
-- onboarding_complete (id=5) 不属于 dailytrigger_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';

View File

@ -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/4id=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 <host> -U postgres -t task_definitions <db> > 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;

View File

@ -14,7 +14,10 @@ type TaskDefinition struct {
CreatedAt int64 `gorm:"column:created_at"`
UpdatedAt int64 `gorm:"column:updated_at"`
// 方案 Aspec §2.1 / §4trigger_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 为计数型
}

View File

@ -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-27daily_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 pathdaily_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)
}
}

View File

@ -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
\.

View File

@ -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 留 NULLid=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 + 1id=5 onboarding 留 NULL
**Critical files**
- `backend/migrations/2026_07_21_004_daily_task_trigger.sql`(新)