From acaffe29eccebc1d7f6b92ec96f4dcbd5b7ce27e Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 16 Jun 2026 14:08:50 +0800 Subject: [PATCH] fix(account-repo): refresh candidates SQL excluded healthy accounts; fix CI build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/internal/repository/account_repo.go | 8 ++++++-- .../internal/repository/account_repo_temp_unsched_test.go | 4 ++++ backend/internal/server/api_contract_test.go | 4 ++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index fb59de6717..93ea6451f6 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -653,6 +653,10 @@ func (r *accountRepository) ListOAuthRefreshCandidates(ctx context.Context) ([]s if r.sql == nil { return nil, errors.New("account repository SQL executor not configured") } + // (cond) IS NOT TRUE 把 NULL 和 FALSE 都视为"可被刷新"。直接写 + // NOT (a AND b) 在 PG 三值逻辑下会把 a 或 b 为 NULL 的行(即绝大多数 + // 健康账号:temp_unschedulable_until=NULL)也排除,导致后台 token + // 刷新工作器漏掉所有正常账号 → access_token 到期后请求开始 401。 rows, err := r.sql.QueryContext(ctx, ` SELECT id FROM accounts @@ -662,10 +666,10 @@ func (r *accountRepository) ListOAuthRefreshCandidates(ctx context.Context) ([]s AND platform IN ('anthropic', 'openai', 'gemini', 'antigravity') AND credentials ? 'refresh_token' AND btrim(credentials->>'refresh_token') <> '' - AND NOT ( + AND ( temp_unschedulable_until > NOW() AND temp_unschedulable_reason LIKE 'token refresh retry exhausted:%' - ) + ) IS NOT TRUE ORDER BY priority ASC, id ASC `) if err != nil { diff --git a/backend/internal/repository/account_repo_temp_unsched_test.go b/backend/internal/repository/account_repo_temp_unsched_test.go index e802e3b0cb..eb123ee6a3 100644 --- a/backend/internal/repository/account_repo_temp_unsched_test.go +++ b/backend/internal/repository/account_repo_temp_unsched_test.go @@ -49,6 +49,10 @@ func TestAccountRepository_ListOAuthRefreshCandidates_SQLFilter(t *testing.T) { require.Contains(t, normalized, "btrim(credentials->>'refresh_token') <> ''") require.Contains(t, normalized, "temp_unschedulable_until > NOW()") require.Contains(t, normalized, "temp_unschedulable_reason LIKE 'token refresh retry exhausted:%'") + require.Contains(t, normalized, "IS NOT TRUE", + "must use IS NOT TRUE so accounts with NULL temp_unschedulable_until are not silently excluded by PG 3-valued logic") + require.NotContains(t, normalized, "AND NOT (", + "plain NOT (...) excludes NULL temp_unschedulable_until rows (the common healthy case)") require.Contains(t, normalized, "ORDER BY priority ASC, id ASC") require.NotContains(t, normalized, "credentials->>'expires_at'") require.NoError(t, mock.ExpectationsWereMet()) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 8a103c509b..6ae4a9dff2 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1681,6 +1681,10 @@ func (s *stubAccountRepo) ListActive(ctx context.Context) ([]service.Account, er return nil, errors.New("not implemented") } +func (s *stubAccountRepo) ListOAuthRefreshCandidates(ctx context.Context) ([]service.Account, error) { + return nil, errors.New("not implemented") +} + func (s *stubAccountRepo) ListByPlatform(ctx context.Context, platform string) ([]service.Account, error) { return nil, errors.New("not implemented") }