ListWithFilters reused the Ent query builder across Count() and All(),
so interceptor-appended predicates (SoftDeleteMixin's deleted_at IS NULL)
accumulated on the shared builder — the same pattern already fixed in
group_repo/promo_code_repo/user_repo by commit 2588fa6a (P1-03 audit).
The integration test now asserts pagination.Total matches len(items) on a
single page, guarding the total-vs-items invariant reported in #3601.
Fixes#3601
The risk control center's moderation log records had no field for the
keyword that triggered a keyword block, so the admin UI couldn't show
which keyword was hit (only the application slog logged it).
- migration 156: add matched_keyword column to content_moderation_logs
- ContentModerationLog gains MatchedKeyword; set it on keyword block
- repo CreateLog/ListLogs persist and read the column
- frontend: show "命中关键词" inline in the log table and detail modal
- i18n: add matchedKeyword (zh/en)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#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.
Post-merge audit of #3272 found two regressions:
1. ListOAuthRefreshCandidates used "AND NOT (a AND b)" which, under PG
3-valued logic, evaluates to NULL when both temp_unschedulable_until
and temp_unschedulable_reason are NULL — i.e., the common healthy
account state. Such rows were silently excluded from the background
token refresh worker, so their OAuth access tokens would never get
refreshed and eventually start returning 401.
Verified empirically against PostgreSQL: only 3 of 5 test rows
matched before the fix; after switching to "(a AND b) IS NOT TRUE"
the expected 4 rows match.
2. The new ListOAuthRefreshCandidates method on AccountRepository was
not implemented on stubAccountRepo in api_contract_test.go (build
tag "unit"), breaking "make test-unit" which CI runs in
.github/workflows/backend-ci.yml.
Tests:
- Added IS NOT TRUE and "AND NOT (" assertions to the SQL-shape unit
test so the predicate can't regress to the broken form again.
- "go test -tags=unit ./internal/..." now passes cleanly.
PG sequences advance outside transactions, so a slow committer can hold
an id that gets surpassed by the watermark before its commit becomes
visible. Without the grace period, cleanup would delete such rows before
the snapshot poller ever sees them. 10s is a comfortable upper bound
on realistic enqueueSchedulerOutbox commit latency.