diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index db43cc7422..1c0c34fb0e 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -1211,11 +1211,20 @@ func (r *accountRepository) ListOAuthRefreshCandidatePage(ctx context.Context, o // NOT (a AND b) 在 PG 三值逻辑下会把 a 或 b 为 NULL 的行(即绝大多数 // 健康账号:temp_unschedulable_until=NULL)也排除,导致后台 token // 刷新工作器漏掉所有正常账号 → access_token 到期后请求开始 401。 + // + // Deliberately NO `schedulable = TRUE` filter here: paused accounts + // (schedulable=false, status=active) still hold valid refresh tokens and + // their stored access_token must keep working for the admin usage-window + // probe. Excluding them lets the token silently expire, after which the + // dashboard reports a false "needs re-auth" even though Test Connection + // (which refreshes on demand) succeeds. Permanent rejection is already + // covered by the status = 'active' filter (error accounts drop out), and + // accounts whose refresh actually fails are rate-limited by the + // ExcludeRetryCooldown clause below. query := ` SELECT id FROM accounts WHERE deleted_at IS NULL - AND schedulable = TRUE AND platform = ANY($1) AND id > $2` if options.ActiveOnly { diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index 6046f3ad86..4ce01a2c9c 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -327,14 +327,16 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus "expires_at": now.Add(30 * time.Minute).Format(time.RFC3339), }, }) - unschedulable := mustCreateAccount(s.T(), s.client, &service.Account{ - Name: "grok-oauth-unschedulable-excluded", + // Paused but active OAuth accounts (schedulable=false) must remain refresh + // candidates so their stored access_token does not silently expire. + paused := mustCreateAccount(s.T(), s.client, &service.Account{ + Name: "grok-oauth-paused-included", Platform: service.PlatformGrok, Type: service.AccountTypeOAuth, Status: service.StatusActive, - Credentials: map[string]any{"refresh_token": "refresh-unschedulable"}, + Credentials: map[string]any{"refresh_token": "refresh-paused"}, }) - s.Require().NoError(s.client.Account.UpdateOneID(unschedulable.ID).SetSchedulable(false).Exec(s.ctx)) + s.Require().NoError(s.client.Account.UpdateOneID(paused.ID).SetSchedulable(false).Exec(s.ctx)) mustCreateAccount(s.T(), s.client, &service.Account{ Name: "grok-api-key-excluded", Platform: service.PlatformGrok, @@ -393,15 +395,14 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus s.Require().NoError(err) first := firstPage.Accounts s.Require().Len(first, 2) - s.Require().Equal([]int64{valid1.ID, valid2.ID}, []int64{first[0].ID, first[1].ID}) - s.Require().NotContains([]int64{first[0].ID, first[1].ID}, unschedulable.ID) + s.Require().Equal([]int64{valid1.ID, paused.ID}, []int64{first[0].ID, first[1].ID}) options.AfterID = first[len(first)-1].ID secondPage, err := s.repo.ListOAuthRefreshCandidatePage(s.ctx, options) s.Require().NoError(err) second := secondPage.Accounts - s.Require().Len(second, 1) - s.Require().Equal(valid3.ID, second[0].ID) + s.Require().Len(second, 2) + s.Require().Equal([]int64{valid2.ID, valid3.ID}, []int64{second[0].ID, second[1].ID}) s.Require().NotContains([]int64{first[0].ID, first[1].ID}, second[0].ID) } diff --git a/backend/internal/repository/account_repo_temp_unsched_test.go b/backend/internal/repository/account_repo_temp_unsched_test.go index e28dbe0902..9853e272ba 100644 --- a/backend/internal/repository/account_repo_temp_unsched_test.go +++ b/backend/internal/repository/account_repo_temp_unsched_test.go @@ -288,8 +288,8 @@ func TestAccountRepository_ListOAuthRefreshCandidatePage_SQLFilter(t *testing.T) normalized := normalizeSQLWhitespace(capturedSQL) require.Contains(t, normalized, "deleted_at IS NULL") - require.Contains(t, normalized, "schedulable = TRUE", - "permanently unschedulable accounts must not remain OAuth refresh candidates") + require.NotContains(t, normalized, "schedulable = TRUE", + "paused (schedulable=false, status=active) accounts must remain OAuth refresh candidates: excluding them lets their stored access_token expire and the usage-window probe then reports a false 'needs re-auth'; permanent rejection is already covered by status = 'active' and refresh failures are bounded by the retry-cooldown exclusion") require.Contains(t, normalized, "status = 'active'") // setup-token 的 access_token 同为 8h 短期令牌,必须与 oauth 一起纳入后台刷新候选 require.Contains(t, normalized, "type IN ('oauth', 'setup-token')") diff --git a/backend/internal/service/token_refresh_service_candidates_test.go b/backend/internal/service/token_refresh_service_candidates_test.go index 285caa3b91..ba382bd2dc 100644 --- a/backend/internal/service/token_refresh_service_candidates_test.go +++ b/backend/internal/service/token_refresh_service_candidates_test.go @@ -51,7 +51,6 @@ func (r *tokenRefreshCandidateRepo) ListOAuthRefreshCandidatePage(_ context.Cont } } if options.ActiveOnly && account.Status != StatusActive || - !account.Schedulable || account.Type != AccountTypeOAuth || !platformAllowed || options.RequireRefreshToken && strings.TrimSpace(refreshToken) == "" || @@ -176,7 +175,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing Type: AccountTypeOAuth, Status: StatusActive, Schedulable: false, - Credentials: map[string]any{"refresh_token": "permanently-rejected-token"}, + Credentials: map[string]any{"refresh_token": "paused-account-token"}, }, }, } @@ -195,7 +194,9 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing svc.processRefresh() require.Zero(t, repo.listActiveCalls, "TokenRefreshService should not use the broad active-account query") - require.ElementsMatch(t, []int64{1, 6}, repo.updatedCredentialIDs) + // Account 7 is paused (schedulable=false) but active: it must still be + // refreshed so its stored access_token does not silently expire. + require.ElementsMatch(t, []int64{1, 6, 7}, repo.updatedCredentialIDs) require.Equal(t, 1, repo.clearTempCalls, "successful refresh should clear the OAuth 401 temp-unschedulable state") }