diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 0669cbbd..6f0c5424 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -437,6 +437,14 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati switch status { case "rate_limited": q = q.Where(dbaccount.RateLimitResetAtGT(time.Now())) + case "temp_unschedulable": + q = q.Where(dbpredicate.Account(func(s *entsql.Selector) { + col := s.C("temp_unschedulable_until") + s.Where(entsql.And( + entsql.Not(entsql.IsNull(col)), + entsql.GT(col, entsql.Expr("NOW()")), + )) + })) default: q = q.Where(dbaccount.StatusEQ(status)) } @@ -640,7 +648,17 @@ func (r *accountRepository) ClearError(ctx context.Context, id int64) error { SetStatus(service.StatusActive). SetErrorMessage(""). Save(ctx) - return err + if err != nil { + return err + } + // 清除临时不可调度状态,重置 401 升级链 + _, _ = r.sql.ExecContext(ctx, ` + UPDATE accounts + SET temp_unschedulable_until = NULL, + temp_unschedulable_reason = NULL + WHERE id = $1 AND deleted_at IS NULL + `, id) + return nil } func (r *accountRepository) AddToGroup(ctx context.Context, accountID, groupID int64, priority int) error { diff --git a/backend/internal/service/error_policy_test.go b/backend/internal/service/error_policy_test.go index 9d7d025e..59375cf5 100644 --- a/backend/internal/service/error_policy_test.go +++ b/backend/internal/service/error_policy_test.go @@ -88,6 +88,49 @@ func TestCheckErrorPolicy(t *testing.T) { body: []byte(`overloaded service`), expected: ErrorPolicyTempUnscheduled, }, + { + name: "temp_unschedulable_401_first_hit_returns_temp_unscheduled", + account: &Account{ + ID: 14, + Type: AccountTypeOAuth, + Platform: PlatformAntigravity, + Credentials: map[string]any{ + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(401), + "keywords": []any{"unauthorized"}, + "duration_minutes": float64(10), + }, + }, + }, + }, + statusCode: 401, + body: []byte(`unauthorized`), + expected: ErrorPolicyTempUnscheduled, + }, + { + name: "temp_unschedulable_401_second_hit_upgrades_to_none", + account: &Account{ + ID: 15, + Type: AccountTypeOAuth, + Platform: PlatformAntigravity, + TempUnschedulableReason: `{"status_code":401,"until_unix":1735689600}`, + Credentials: map[string]any{ + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(401), + "keywords": []any{"unauthorized"}, + "duration_minutes": float64(10), + }, + }, + }, + }, + statusCode: 401, + body: []byte(`unauthorized`), + expected: ErrorPolicyNone, + }, { name: "temp_unschedulable_body_miss_returns_none", account: &Account{ diff --git a/backend/internal/service/gemini_error_policy_test.go b/backend/internal/service/gemini_error_policy_test.go index 2ce8793a..4bd1ced7 100644 --- a/backend/internal/service/gemini_error_policy_test.go +++ b/backend/internal/service/gemini_error_policy_test.go @@ -122,6 +122,28 @@ func TestCheckErrorPolicy_GeminiAccounts(t *testing.T) { body: []byte(`overloaded service`), expected: ErrorPolicyTempUnscheduled, }, + { + name: "gemini_apikey_temp_unschedulable_401_second_hit_returns_none", + account: &Account{ + ID: 105, + Type: AccountTypeAPIKey, + Platform: PlatformGemini, + TempUnschedulableReason: `{"status_code":401,"until_unix":1735689600}`, + Credentials: map[string]any{ + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(401), + "keywords": []any{"unauthorized"}, + "duration_minutes": float64(10), + }, + }, + }, + }, + statusCode: 401, + body: []byte(`unauthorized`), + expected: ErrorPolicyNone, + }, { name: "gemini_custom_codes_override_temp_unschedulable", account: &Account{ diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 96e30db2..9f16fb2b 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1091,6 +1091,22 @@ func (s *RateLimitService) tryTempUnschedulable(ctx context.Context, account *Ac if !account.IsTempUnschedulableEnabled() { return false } + // 401 首次命中可临时不可调度(给 token 刷新窗口); + // 若历史上已因 401 进入过临时不可调度,则本次应升级为 error(返回 false 交由默认错误逻辑处理)。 + if statusCode == http.StatusUnauthorized { + reason := account.TempUnschedulableReason + // 缓存可能没有 reason,从 DB 回退读取 + if reason == "" { + if dbAcc, err := s.accountRepo.GetByID(ctx, account.ID); err == nil && dbAcc != nil { + reason = dbAcc.TempUnschedulableReason + } + } + if wasTempUnschedByStatusCode(reason, statusCode) { + slog.Info("401_escalated_to_error", "account_id", account.ID, + "reason", "previous temp-unschedulable was also 401") + return false + } + } rules := account.GetTempUnschedulableRules() if len(rules) == 0 { return false @@ -1122,6 +1138,22 @@ func (s *RateLimitService) tryTempUnschedulable(ctx context.Context, account *Ac return false } +func wasTempUnschedByStatusCode(reason string, statusCode int) bool { + if statusCode <= 0 { + return false + } + reason = strings.TrimSpace(reason) + if reason == "" { + return false + } + + var state TempUnschedState + if err := json.Unmarshal([]byte(reason), &state); err != nil { + return false + } + return state.StatusCode == statusCode +} + func matchTempUnschedKeyword(bodyLower string, keywords []string) string { if bodyLower == "" { return "" diff --git a/backend/internal/service/ratelimit_service_401_db_fallback_test.go b/backend/internal/service/ratelimit_service_401_db_fallback_test.go new file mode 100644 index 00000000..e1611425 --- /dev/null +++ b/backend/internal/service/ratelimit_service_401_db_fallback_test.go @@ -0,0 +1,119 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// dbFallbackRepoStub extends errorPolicyRepoStub with a configurable DB account +// returned by GetByID, simulating cache miss + DB fallback. +type dbFallbackRepoStub struct { + errorPolicyRepoStub + dbAccount *Account // returned by GetByID when non-nil +} + +func (r *dbFallbackRepoStub) GetByID(ctx context.Context, id int64) (*Account, error) { + if r.dbAccount != nil && r.dbAccount.ID == id { + return r.dbAccount, nil + } + return nil, nil // not found, no error +} + +func TestCheckErrorPolicy_401_DBFallback_Escalates(t *testing.T) { + // Scenario: cache account has empty TempUnschedulableReason (cache miss), + // but DB account has a previous 401 record → should escalate to ErrorPolicyNone. + repo := &dbFallbackRepoStub{ + dbAccount: &Account{ + ID: 20, + TempUnschedulableReason: `{"status_code":401,"until_unix":1735689600}`, + }, + } + svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + + account := &Account{ + ID: 20, + Type: AccountTypeOAuth, + Platform: PlatformAntigravity, + TempUnschedulableReason: "", // cache miss — reason is empty + Credentials: map[string]any{ + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(401), + "keywords": []any{"unauthorized"}, + "duration_minutes": float64(10), + }, + }, + }, + } + + result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized, []byte(`unauthorized`)) + require.Equal(t, ErrorPolicyNone, result, "401 with DB fallback showing previous 401 should escalate to ErrorPolicyNone") +} + +func TestCheckErrorPolicy_401_DBFallback_NoDBRecord_FirstHit(t *testing.T) { + // Scenario: cache account has empty TempUnschedulableReason, + // DB also has no previous 401 record → should NOT escalate (first hit → temp unscheduled). + repo := &dbFallbackRepoStub{ + dbAccount: &Account{ + ID: 21, + TempUnschedulableReason: "", // DB also empty + }, + } + svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + + account := &Account{ + ID: 21, + Type: AccountTypeOAuth, + Platform: PlatformAntigravity, + TempUnschedulableReason: "", + Credentials: map[string]any{ + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(401), + "keywords": []any{"unauthorized"}, + "duration_minutes": float64(10), + }, + }, + }, + } + + result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized, []byte(`unauthorized`)) + require.Equal(t, ErrorPolicyTempUnscheduled, result, "401 first hit with no DB record should temp-unschedule") +} + +func TestCheckErrorPolicy_401_DBFallback_DBError_FirstHit(t *testing.T) { + // Scenario: cache account has empty TempUnschedulableReason, + // DB lookup returns nil (not found) → should treat as first hit → temp unscheduled. + repo := &dbFallbackRepoStub{ + dbAccount: nil, // GetByID returns nil, nil + } + svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + + account := &Account{ + ID: 22, + Type: AccountTypeOAuth, + Platform: PlatformAntigravity, + TempUnschedulableReason: "", + Credentials: map[string]any{ + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(401), + "keywords": []any{"unauthorized"}, + "duration_minutes": float64(10), + }, + }, + }, + } + + result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized, []byte(`unauthorized`)) + require.Equal(t, ErrorPolicyTempUnscheduled, result, "401 first hit with DB not found should temp-unschedule") +} diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue index 5280e787..abffbaa2 100644 --- a/frontend/src/components/admin/account/AccountTableFilters.vue +++ b/frontend/src/components/admin/account/AccountTableFilters.vue @@ -25,6 +25,6 @@ const updateStatus = (value: string | number | boolean | null) => { emit('update const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) } const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'sora', label: 'Sora' }]) const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }]) -const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }]) +const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }]) const gOpts = computed(() => [{ value: '', label: t('admin.accounts.allGroups') }, ...(props.groups || []).map(g => ({ value: String(g.id), label: g.name }))])