From f069c9ae00ae2565208b13c79a1097b471f10ed9 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 16 Jun 2026 14:21:09 +0800 Subject: [PATCH] fix(outbox-dedup): buildSchedulerGroupPayload typed-nil broke dedup_key consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3255 introduced payload-aware dedup_key (sha256 over event_type, account_id, group_id, payload_json). enqueueSchedulerOutbox checks "if payload != nil" before json.Marshal — but Go interface containing a typed-nil map (returned by buildSchedulerGroupPayload(empty)) is NOT == nil at the interface level. So an ungrouped account's account_changed event went through this path: payload := buildSchedulerGroupPayload(account.GroupIDs) // typed-nil map enqueueSchedulerOutbox(..., payload) // interface != nil → json.Marshal(typedNilMap) = "null" → dedup_key hash = sha256(... + "null") While other call sites pass literal nil: enqueueSchedulerOutbox(..., nil) // interface == nil → payloadJSON stays empty → dedup_key hash = sha256(... + "") The two dedup_keys differ for what should be the same logical event, silently degrading dedup effectiveness in bursts on ungrouped accounts. Fix: change buildSchedulerGroupPayload return type from map[string]any to any so empty input returns true untyped-nil. All call sites pass the result straight to enqueueSchedulerOutbox(payload any) — no inspection, no breakage. Adds regression test TestEnqueueSchedulerOutbox_UngroupedAccountDedupesWithLiteralNilPayload asserting (1) typed-nil regression doesn't sneak back, (2) dedup_key for empty-groups payload matches the literal-nil-payload key. --- backend/internal/repository/account_repo.go | 6 ++++- .../repository/scheduler_outbox_repo_test.go | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) 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") +}