diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 93ea6451f6..3bac6c5cff 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -1882,7 +1882,11 @@ func mergeGroupIDs(a []int64, b []int64) []int64 { return out } -func buildSchedulerGroupPayload(groupIDs []int64) map[string]any { +// buildSchedulerGroupPayload 构造 EventAccountChanged / EventAccountGroupsChanged +// 事件的 payload。空 groupIDs 必须返回 untyped nil(any 而非 map[string]any(nil)), +// 否则 enqueueSchedulerOutbox 的 "payload != nil" 接口判空会被 typed-nil 欺骗, +// 把 payload marshal 成 "null" 写入 dedup_key 哈希,破坏与其他 nil-payload 调用的去重一致性。 +func buildSchedulerGroupPayload(groupIDs []int64) any { if len(groupIDs) == 0 { return nil } diff --git a/backend/internal/repository/scheduler_outbox_repo_test.go b/backend/internal/repository/scheduler_outbox_repo_test.go index 6b4a1af9e9..619339d207 100644 --- a/backend/internal/repository/scheduler_outbox_repo_test.go +++ b/backend/internal/repository/scheduler_outbox_repo_test.go @@ -90,3 +90,29 @@ func TestSchedulerOutboxRepositoryTryAcquireCleanupLockUnavailable(t *testing.T) require.NoError(t, mock.ExpectationsWereMet()) } + +// buildSchedulerGroupPayload 在 groupIDs 为空时必须返回 untyped nil(any), +// 否则 enqueueSchedulerOutbox 的 "payload != nil" 接口判空会被 typed-nil 欺骗, +// 把 payload marshal 成 "null" 写入 dedup_key 哈希,破坏与其他 nil-payload +// 调用的去重一致性。本测试用 ungrouped 账号场景验证两条路径的 dedup_key 一致。 +func TestEnqueueSchedulerOutbox_UngroupedAccountDedupesWithLiteralNilPayload(t *testing.T) { + accountID := int64(42) + + // Path A: 显式 nil payload(如 SetError、SetStatus 等调用模式) + keyLiteralNil := schedulerOutboxDedupKey("account_changed", &accountID, nil, nil) + + // Path B: buildSchedulerGroupPayload(account.GroupIDs) 当账号没有任何分组 + emptyGroupsPayload := buildSchedulerGroupPayload(nil) + require.Nil(t, emptyGroupsPayload, + "buildSchedulerGroupPayload(empty) must return untyped-nil any to avoid typed-nil marshal") + + // 模拟 enqueueSchedulerOutbox 内部的判空逻辑 + var payloadJSON []byte + if emptyGroupsPayload != nil { + t.Fatalf("typed-nil regression: buildSchedulerGroupPayload(empty) interface should be nil") + } + keyEmptyGroups := schedulerOutboxDedupKey("account_changed", &accountID, nil, payloadJSON) + + require.Equal(t, keyLiteralNil, keyEmptyGroups, + "ungrouped-account account_changed must share dedup_key with other nil-payload variants") +}