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") }