fix(outbox-dedup): buildSchedulerGroupPayload typed-nil broke dedup_key consistency

#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.
This commit is contained in:
shaw
2026-06-16 14:21:09 +08:00
parent acaffe29ec
commit f069c9ae00
2 changed files with 31 additions and 1 deletions
+5 -1
View File
@@ -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 nilany 而非 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
}
@@ -90,3 +90,29 @@ func TestSchedulerOutboxRepositoryTryAcquireCleanupLockUnavailable(t *testing.T)
require.NoError(t, mock.ExpectationsWereMet())
}
// buildSchedulerGroupPayload 在 groupIDs 为空时必须返回 untyped nilany),
// 否则 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")
}