mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(account): keep refreshing OAuth tokens for paused accounts
Paused (schedulable=false) active accounts were excluded from the background token refresh candidates, so their stored access_token silently expired. The admin usage-window quota probe uses the stored token verbatim (no token provider / no refresh), so the probe then gets HTTP 401 UNAUTHENTICATED and the dashboard reports a false "needs re-auth" for a perfectly healthy account, while Test Connection and live traffic (which refresh on demand via the token provider) keep working. Pause is not permanent rejection: error accounts are already excluded by the status = 'active' filter, and accounts whose refresh genuinely fails are rate-limited by the retry-cooldown exclusion (10-minute retry- exhausted cooldown). Refresh success only persists credentials and never touches schedulable, so a paused account cannot be silently un-paused. Drop the schedulable filter and update the SQL-filter test, the integration pagination test, and the service-level candidate fake.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user