From 4b904c887cc51003077ee01aba5f6c8429848718 Mon Sep 17 00:00:00 2001 From: gaoren002 Date: Thu, 30 Apr 2026 03:01:39 +0000 Subject: [PATCH] fix(rate-limit): make 429 fallback cooldown configurable --- backend/internal/config/config.go | 6 +- .../internal/handler/admin/setting_handler.go | 52 ++++++ backend/internal/handler/dto/settings.go | 6 + backend/internal/server/routes/admin.go | 3 + backend/internal/service/domain_constants.go | 3 + .../service/rate_limit_429_cooldown_test.go | 114 +++++++++++++ backend/internal/service/ratelimit_service.go | 63 ++++++-- backend/internal/service/setting_service.go | 49 ++++++ backend/internal/service/settings_view.go | 16 ++ deploy/config.example.yaml | 4 + frontend/src/api/admin/settings.ts | 26 +++ frontend/src/i18n/locales/en.ts | 10 ++ frontend/src/i18n/locales/zh.ts | 10 ++ frontend/src/views/admin/SettingsView.vue | 150 ++++++++++++++++++ .../admin/__tests__/SettingsView.spec.ts | 20 +++ 15 files changed, 520 insertions(+), 12 deletions(-) create mode 100644 backend/internal/service/rate_limit_429_cooldown_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 87263db0..316ae9e2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1093,8 +1093,9 @@ type DefaultConfig struct { } type RateLimitConfig struct { - OverloadCooldownMinutes int `mapstructure:"overload_cooldown_minutes"` // 529过载冷却时间(分钟) - OAuth401CooldownMinutes int `mapstructure:"oauth_401_cooldown_minutes"` // OAuth 401临时不可调度冷却(分钟) + OverloadCooldownMinutes int `mapstructure:"overload_cooldown_minutes"` // 529过载冷却时间(分钟) + RateLimit429CooldownSeconds int `mapstructure:"rate_limit_429_cooldown_seconds"` // 429无重置时间时的默认回避时间(秒) + OAuth401CooldownMinutes int `mapstructure:"oauth_401_cooldown_minutes"` // OAuth 401临时不可调度冷却(分钟) } // APIKeyAuthCacheConfig API Key 认证缓存配置 @@ -1554,6 +1555,7 @@ func setDefaults() { // RateLimit viper.SetDefault("rate_limit.overload_cooldown_minutes", 10) + viper.SetDefault("rate_limit.rate_limit_429_cooldown_seconds", 5) viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10) // Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index d6580191..1afaa7a2 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -2450,6 +2450,58 @@ func (h *SettingHandler) UpdateOverloadCooldownSettings(c *gin.Context) { }) } +// GetRateLimit429CooldownSettings 获取429默认回避配置 +// GET /api/v1/admin/settings/rate-limit-429-cooldown +func (h *SettingHandler) GetRateLimit429CooldownSettings(c *gin.Context) { + settings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.RateLimit429CooldownSettings{ + Enabled: settings.Enabled, + CooldownSeconds: settings.CooldownSeconds, + }) +} + +// UpdateRateLimit429CooldownSettingsRequest 更新429默认回避配置请求 +type UpdateRateLimit429CooldownSettingsRequest struct { + Enabled bool `json:"enabled"` + CooldownSeconds int `json:"cooldown_seconds"` +} + +// UpdateRateLimit429CooldownSettings 更新429默认回避配置 +// PUT /api/v1/admin/settings/rate-limit-429-cooldown +func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) { + var req UpdateRateLimit429CooldownSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + settings := &service.RateLimit429CooldownSettings{ + Enabled: req.Enabled, + CooldownSeconds: req.CooldownSeconds, + } + + if err := h.settingService.SetRateLimit429CooldownSettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + + updatedSettings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.RateLimit429CooldownSettings{ + Enabled: updatedSettings.Enabled, + CooldownSeconds: updatedSettings.CooldownSeconds, + }) +} + // GetStreamTimeoutSettings 获取流超时处理配置 // GET /api/v1/admin/settings/stream-timeout func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) { diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index b865d703..551c0124 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -263,6 +263,12 @@ type OverloadCooldownSettings struct { CooldownMinutes int `json:"cooldown_minutes"` } +// RateLimit429CooldownSettings 429默认回避配置 DTO +type RateLimit429CooldownSettings struct { + Enabled bool `json:"enabled"` + CooldownSeconds int `json:"cooldown_seconds"` +} + // StreamTimeoutSettings 流超时处理配置 DTO type StreamTimeoutSettings struct { Enabled bool `json:"enabled"` diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 1c786f50..0a6a2962 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -408,6 +408,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { // 529过载冷却配置 adminSettings.GET("/overload-cooldown", h.Admin.Setting.GetOverloadCooldownSettings) adminSettings.PUT("/overload-cooldown", h.Admin.Setting.UpdateOverloadCooldownSettings) + // 429默认回避配置 + adminSettings.GET("/rate-limit-429-cooldown", h.Admin.Setting.GetRateLimit429CooldownSettings) + adminSettings.PUT("/rate-limit-429-cooldown", h.Admin.Setting.UpdateRateLimit429CooldownSettings) // 流超时处理配置 adminSettings.GET("/stream-timeout", h.Admin.Setting.GetStreamTimeoutSettings) adminSettings.PUT("/stream-timeout", h.Admin.Setting.UpdateStreamTimeoutSettings) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index bddcf6ab..6eb901b3 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -286,6 +286,9 @@ const ( // SettingKeyOverloadCooldownSettings stores JSON config for 529 overload cooldown handling. SettingKeyOverloadCooldownSettings = "overload_cooldown_settings" + // SettingKeyRateLimit429CooldownSettings stores JSON config for 429 fallback cooldown handling. + SettingKeyRateLimit429CooldownSettings = "rate_limit_429_cooldown_settings" + // ========================= // Stream Timeout Handling // ========================= diff --git a/backend/internal/service/rate_limit_429_cooldown_test.go b/backend/internal/service/rate_limit_429_cooldown_test.go new file mode 100644 index 00000000..35206454 --- /dev/null +++ b/backend/internal/service/rate_limit_429_cooldown_test.go @@ -0,0 +1,114 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type rateLimit429AccountRepoStub struct { + mockAccountRepoForGemini + rateLimitCalls int + lastRateLimitID int64 + lastRateLimitReset time.Time +} + +func (r *rateLimit429AccountRepoStub) SetRateLimited(_ context.Context, id int64, resetAt time.Time) error { + r.rateLimitCalls++ + r.lastRateLimitID = id + r.lastRateLimitReset = resetAt + return nil +} + +func TestGetRateLimit429CooldownSettings_DefaultsWhenNotSet(t *testing.T) { + repo := newMockSettingRepo() + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetRateLimit429CooldownSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.Enabled) + require.Equal(t, 5, settings.CooldownSeconds) +} + +func TestGetRateLimit429CooldownSettings_ReadsFromDB(t *testing.T) { + repo := newMockSettingRepo() + data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: false, CooldownSeconds: 12}) + repo.data[SettingKeyRateLimit429CooldownSettings] = string(data) + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetRateLimit429CooldownSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.Enabled) + require.Equal(t, 12, settings.CooldownSeconds) +} + +func TestSetRateLimit429CooldownSettings_EnabledRejectsOutOfRange(t *testing.T) { + svc := NewSettingService(newMockSettingRepo(), &config.Config{}) + + for _, seconds := range []int{0, -1, 7201, 99999} { + err := svc.SetRateLimit429CooldownSettings(context.Background(), &RateLimit429CooldownSettings{ + Enabled: true, CooldownSeconds: seconds, + }) + require.Error(t, err, "should reject enabled=true + cooldown_seconds=%d", seconds) + require.Contains(t, err.Error(), "cooldown_seconds must be between 1-7200") + } +} + +func TestHandle429_FallbackUsesDBSeconds(t *testing.T) { + accountRepo := &rateLimit429AccountRepoStub{} + settingRepo := newMockSettingRepo() + data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: true, CooldownSeconds: 12}) + settingRepo.data[SettingKeyRateLimit429CooldownSettings] = string(data) + + settingSvc := NewSettingService(settingRepo, &config.Config{}) + svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + svc.SetSettingService(settingSvc) + + account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + before := time.Now() + svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"type":"rate_limit_error","message":"slow down"}}`)) + after := time.Now() + + require.Equal(t, 1, accountRepo.rateLimitCalls) + require.Equal(t, int64(42), accountRepo.lastRateLimitID) + require.True(t, !accountRepo.lastRateLimitReset.Before(before.Add(12*time.Second)) && !accountRepo.lastRateLimitReset.After(after.Add(12*time.Second))) +} + +func TestHandle429_FallbackDisabledSkipsLocalMark(t *testing.T) { + accountRepo := &rateLimit429AccountRepoStub{} + settingRepo := newMockSettingRepo() + data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: false, CooldownSeconds: 12}) + settingRepo.data[SettingKeyRateLimit429CooldownSettings] = string(data) + + settingSvc := NewSettingService(settingRepo, &config.Config{}) + svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + svc.SetSettingService(settingSvc) + + account := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"type":"rate_limit_error","message":"slow down"}}`)) + + require.Zero(t, accountRepo.rateLimitCalls) +} + +func TestHandle429_FallbackUsesConfigSecondsWhenSettingServiceMissing(t *testing.T) { + accountRepo := &rateLimit429AccountRepoStub{} + cfg := &config.Config{} + cfg.RateLimit.RateLimit429CooldownSeconds = 9 + svc := NewRateLimitService(accountRepo, nil, cfg, nil, nil) + + account := &Account{ID: 44, Platform: PlatformGemini, Type: AccountTypeAPIKey} + before := time.Now() + svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"message":"slow down"}}`)) + after := time.Now() + + require.Equal(t, 1, accountRepo.rateLimitCalls) + require.Equal(t, int64(44), accountRepo.lastRateLimitID) + require.True(t, !accountRepo.lastRateLimitReset.Before(before.Add(9*time.Second)) && !accountRepo.lastRateLimitReset.After(after.Add(9*time.Second))) +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 9344de47..293fc528 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -55,6 +55,11 @@ type geminiUsageTotalsBatchProvider interface { const geminiPrecheckCacheTTL = time.Minute +const ( + defaultRateLimit429CooldownSeconds = 5 + maxRateLimit429CooldownSeconds = 7200 +) + const ( openAI403CooldownMinutesDefault = 10 openAI403DisableThreshold = 3 @@ -891,12 +896,8 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head return } - // 其他平台:没有重置时间,使用默认5分钟 - resetAt := time.Now().Add(5 * time.Minute) - slog.Warn("rate_limit_no_reset_time", "account_id", account.ID, "platform", account.Platform, "using_default", "5m") - if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil { - slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) - } + // 其他平台:没有重置时间,使用可配置的秒级默认回避,避免误伤长时间不可调度。 + s.apply429FallbackRateLimit(ctx, account, "no_reset_time") return } @@ -904,10 +905,7 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head ts, err := strconv.ParseInt(resetTimestamp, 10, 64) if err != nil { slog.Warn("rate_limit_reset_parse_failed", "reset_timestamp", resetTimestamp, "error", err) - resetAt := time.Now().Add(5 * time.Minute) - if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil { - slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) - } + s.apply429FallbackRateLimit(ctx, account, "reset_parse_failed") return } @@ -929,6 +927,51 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head slog.Info("account_rate_limited", "account_id", account.ID, "reset_at", resetAt) } +func (s *RateLimitService) apply429FallbackRateLimit(ctx context.Context, account *Account, reason string) { + cooldown, enabled := s.get429FallbackCooldown(ctx, account) + if !enabled { + slog.Info("rate_limit_429_fallback_ignored", "account_id", account.ID, "platform", account.Platform, "reason", reason) + return + } + + resetAt := time.Now().Add(cooldown) + slog.Warn("rate_limit_429_fallback_used", "account_id", account.ID, "platform", account.Platform, "reason", reason, "using_default", cooldown.String()) + if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil { + slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) + } +} + +func (s *RateLimitService) get429FallbackCooldown(ctx context.Context, account *Account) (time.Duration, bool) { + if s.settingService != nil { + settings, err := s.settingService.GetRateLimit429CooldownSettings(ctx) + if err == nil && settings != nil { + if !settings.Enabled { + return 0, false + } + seconds := clampRateLimit429CooldownSeconds(settings.CooldownSeconds) + return time.Duration(seconds) * time.Second, true + } + slog.Warn("rate_limit_429_settings_read_failed", "account_id", account.ID, "error", err) + } + + seconds := defaultRateLimit429CooldownSeconds + if s.cfg != nil && s.cfg.RateLimit.RateLimit429CooldownSeconds > 0 { + seconds = s.cfg.RateLimit.RateLimit429CooldownSeconds + } + seconds = clampRateLimit429CooldownSeconds(seconds) + return time.Duration(seconds) * time.Second, true +} + +func clampRateLimit429CooldownSeconds(seconds int) int { + if seconds < 1 { + return 1 + } + if seconds > maxRateLimit429CooldownSeconds { + return maxRateLimit429CooldownSeconds + } + return seconds +} + // calculateOpenAI429ResetTime 从 OpenAI 429 响应头计算正确的重置时间 // 返回 nil 表示无法从响应头中确定重置时间 func calculateOpenAI429ResetTime(headers http.Header) *time.Time { diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 966b4b84..c0cd9902 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -2748,6 +2748,55 @@ func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settin return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data)) } +// GetRateLimit429CooldownSettings 获取429默认回避配置 +func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (*RateLimit429CooldownSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyRateLimit429CooldownSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultRateLimit429CooldownSettings(), nil + } + return nil, fmt.Errorf("get 429 cooldown settings: %w", err) + } + if value == "" { + return DefaultRateLimit429CooldownSettings(), nil + } + + var settings RateLimit429CooldownSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultRateLimit429CooldownSettings(), nil + } + + if settings.CooldownSeconds < 1 { + settings.CooldownSeconds = 1 + } + if settings.CooldownSeconds > 7200 { + settings.CooldownSeconds = 7200 + } + + return &settings, nil +} + +// SetRateLimit429CooldownSettings 设置429默认回避配置 +func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, settings *RateLimit429CooldownSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 { + if settings.Enabled { + return fmt.Errorf("cooldown_seconds must be between 1-7200") + } + settings.CooldownSeconds = 5 + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal 429 cooldown settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data)) +} + // GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。 // // 优先级: diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index c0962ff0..bc81d4ac 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -380,6 +380,14 @@ type OverloadCooldownSettings struct { CooldownMinutes int `json:"cooldown_minutes"` } +// RateLimit429CooldownSettings 429默认回避配置 +type RateLimit429CooldownSettings struct { + // Enabled 是否在无法解析上游重置时间时应用默认429回避 + Enabled bool `json:"enabled"` + // CooldownSeconds 默认回避时长(秒) + CooldownSeconds int `json:"cooldown_seconds"` +} + // DefaultOverloadCooldownSettings 返回默认的过载冷却配置(启用,10分钟) func DefaultOverloadCooldownSettings() *OverloadCooldownSettings { return &OverloadCooldownSettings{ @@ -388,6 +396,14 @@ func DefaultOverloadCooldownSettings() *OverloadCooldownSettings { } } +// DefaultRateLimit429CooldownSettings 返回默认的429回避配置(启用,5秒) +func DefaultRateLimit429CooldownSettings() *RateLimit429CooldownSettings { + return &RateLimit429CooldownSettings{ + Enabled: true, + CooldownSeconds: 5, + } +} + // DefaultBetaPolicySettings 返回默认的 Beta 策略配置 func DefaultBetaPolicySettings() *BetaPolicySettings { return &BetaPolicySettings{ diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index dfc363b5..01760657 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -924,6 +924,10 @@ rate_limit: # 上游返回 529(过载)时的冷却时间(分钟) overload_cooldown_minutes: 10 + # Default cooldown time (in seconds) when upstream returns 429 without a reset time + # 上游返回 429 且无明确重置时间时的默认回避时间(秒) + rate_limit_429_cooldown_seconds: 5 + # ============================================================================= # Pricing Data Source (Optional) # 定价数据源(可选) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index e8ab6af5..f20ed8fb 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -803,6 +803,30 @@ export async function updateOverloadCooldownSettings( return data; } +// ==================== 429 Rate Limit Cooldown Settings ==================== + +export interface RateLimit429CooldownSettings { + enabled: boolean; + cooldown_seconds: number; +} + +export async function getRateLimit429CooldownSettings(): Promise { + const { data } = await apiClient.get( + "/admin/settings/rate-limit-429-cooldown", + ); + return data; +} + +export async function updateRateLimit429CooldownSettings( + settings: RateLimit429CooldownSettings, +): Promise { + const { data } = await apiClient.put( + "/admin/settings/rate-limit-429-cooldown", + settings, + ); + return data; +} + // ==================== Stream Timeout Settings ==================== /** @@ -1022,6 +1046,8 @@ export const settingsAPI = { deleteAdminApiKey, getOverloadCooldownSettings, updateOverloadCooldownSettings, + getRateLimit429CooldownSettings, + updateRateLimit429CooldownSettings, getStreamTimeoutSettings, updateStreamTimeoutSettings, getRectifierSettings, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 0425955f..93ada241 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5484,6 +5484,16 @@ export default { saved: 'Overload cooldown settings saved', saveFailed: 'Failed to save overload cooldown settings' }, + rateLimit429Cooldown: { + title: '429 Default Cooldown', + description: 'Configure the default account cooldown when upstream returns 429 without an explicit reset time', + enabled: 'Enable 429 Default Cooldown', + enabledHint: 'Pause account scheduling when a 429 has no reset time, then auto-recover after cooldown', + cooldownSeconds: 'Cooldown Duration (seconds)', + cooldownSecondsHint: 'Default cooldown duration (1-7200 seconds); explicit upstream reset times still take precedence', + saved: '429 default cooldown settings saved', + saveFailed: 'Failed to save 429 default cooldown settings' + }, streamTimeout: { title: 'Stream Timeout Handling', description: 'Configure account handling strategy when upstream response times out', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index a8656a7b..e0189350 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5644,6 +5644,16 @@ export default { saved: '过载冷却设置保存成功', saveFailed: '保存过载冷却设置失败' }, + rateLimit429Cooldown: { + title: '429 默认回避', + description: '配置上游返回 429 且没有明确重置时间时的默认账号回避策略', + enabled: '启用 429 默认回避', + enabledHint: '收到无重置时间的 429 时暂停该账号调度,冷却后自动恢复', + cooldownSeconds: '回避时长(秒)', + cooldownSecondsHint: '默认回避持续时间(1-7200 秒);上游返回明确 reset 时仍优先使用上游时间', + saved: '429 默认回避设置保存成功', + saveFailed: '保存 429 默认回避设置失败' + }, streamTimeout: { title: '流超时处理', description: '配置上游响应超时时的账户处理策略,避免问题账户持续被选中', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index ad0587b8..55e69820 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -291,6 +291,113 @@ + +
+
+

+ {{ t("admin.settings.rateLimit429Cooldown.title") }} +

+

+ {{ t("admin.settings.rateLimit429Cooldown.description") }} +

+
+
+
+
+ {{ t("common.loading") }} +
+ + +
+
+
{ loadSubscriptionGroups(); loadAdminApiKey(); loadOverloadCooldownSettings(); + loadRateLimit429CooldownSettings(); loadStreamTimeoutSettings(); loadRectifierSettings(); loadBetaPolicySettings(); diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 239c474e..9144649c 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -11,6 +11,8 @@ const { updateWebSearchEmulationConfig, getAdminApiKey, getOverloadCooldownSettings, + getRateLimit429CooldownSettings, + updateRateLimit429CooldownSettings, getStreamTimeoutSettings, getRectifierSettings, getBetaPolicySettings, @@ -31,6 +33,8 @@ const { updateWebSearchEmulationConfig: vi.fn(), getAdminApiKey: vi.fn(), getOverloadCooldownSettings: vi.fn(), + getRateLimit429CooldownSettings: vi.fn(), + updateRateLimit429CooldownSettings: vi.fn(), getStreamTimeoutSettings: vi.fn(), getRectifierSettings: vi.fn(), getBetaPolicySettings: vi.fn(), @@ -57,6 +61,8 @@ vi.mock("@/api", () => ({ updateWebSearchEmulationConfig, getAdminApiKey, getOverloadCooldownSettings, + getRateLimit429CooldownSettings, + updateRateLimit429CooldownSettings, getStreamTimeoutSettings, getRectifierSettings, getBetaPolicySettings, @@ -453,6 +459,8 @@ describe("admin SettingsView payment visible method controls", () => { updateWebSearchEmulationConfig.mockReset(); getAdminApiKey.mockReset(); getOverloadCooldownSettings.mockReset(); + getRateLimit429CooldownSettings.mockReset(); + updateRateLimit429CooldownSettings.mockReset(); getStreamTimeoutSettings.mockReset(); getRectifierSettings.mockReset(); getBetaPolicySettings.mockReset(); @@ -489,6 +497,11 @@ describe("admin SettingsView payment visible method controls", () => { enabled: true, cooldown_minutes: 10, }); + getRateLimit429CooldownSettings.mockResolvedValue({ + enabled: true, + cooldown_seconds: 5, + }); + updateRateLimit429CooldownSettings.mockImplementation(async (payload) => payload); getStreamTimeoutSettings.mockResolvedValue({ enabled: true, action: "temp_unsched", @@ -669,6 +682,8 @@ describe("admin SettingsView wechat connect controls", () => { updateWebSearchEmulationConfig.mockReset(); getAdminApiKey.mockReset(); getOverloadCooldownSettings.mockReset(); + getRateLimit429CooldownSettings.mockReset(); + updateRateLimit429CooldownSettings.mockReset(); getStreamTimeoutSettings.mockReset(); getRectifierSettings.mockReset(); getBetaPolicySettings.mockReset(); @@ -708,6 +723,11 @@ describe("admin SettingsView wechat connect controls", () => { enabled: true, cooldown_minutes: 10, }); + getRateLimit429CooldownSettings.mockResolvedValue({ + enabled: true, + cooldown_seconds: 5, + }); + updateRateLimit429CooldownSettings.mockImplementation(async (payload) => payload); getStreamTimeoutSettings.mockResolvedValue({ enabled: true, action: "temp_unsched",