diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index c966cb7d..25456bb3 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -977,6 +977,58 @@ func (h *SettingHandler) DeleteAdminAPIKey(c *gin.Context) { response.Success(c, gin.H{"message": "Admin API key deleted"}) } +// GetOverloadCooldownSettings 获取529过载冷却配置 +// GET /api/v1/admin/settings/overload-cooldown +func (h *SettingHandler) GetOverloadCooldownSettings(c *gin.Context) { + settings, err := h.settingService.GetOverloadCooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.OverloadCooldownSettings{ + Enabled: settings.Enabled, + CooldownMinutes: settings.CooldownMinutes, + }) +} + +// UpdateOverloadCooldownSettingsRequest 更新529过载冷却配置请求 +type UpdateOverloadCooldownSettingsRequest struct { + Enabled bool `json:"enabled"` + CooldownMinutes int `json:"cooldown_minutes"` +} + +// UpdateOverloadCooldownSettings 更新529过载冷却配置 +// PUT /api/v1/admin/settings/overload-cooldown +func (h *SettingHandler) UpdateOverloadCooldownSettings(c *gin.Context) { + var req UpdateOverloadCooldownSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + settings := &service.OverloadCooldownSettings{ + Enabled: req.Enabled, + CooldownMinutes: req.CooldownMinutes, + } + + if err := h.settingService.SetOverloadCooldownSettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + + updatedSettings, err := h.settingService.GetOverloadCooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.OverloadCooldownSettings{ + Enabled: updatedSettings.Enabled, + CooldownMinutes: updatedSettings.CooldownMinutes, + }) +} + // 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 29b00bb8..b953e336 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -157,6 +157,12 @@ type ListSoraS3ProfilesResponse struct { Items []SoraS3Profile `json:"items"` } +// OverloadCooldownSettings 529过载冷却配置 DTO +type OverloadCooldownSettings struct { + Enabled bool `json:"enabled"` + CooldownMinutes int `json:"cooldown_minutes"` +} + // 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 89faf6dc..c80cca54 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -402,6 +402,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey) adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey) adminSettings.DELETE("/admin-api-key", h.Admin.Setting.DeleteAdminAPIKey) + // 529过载冷却配置 + adminSettings.GET("/overload-cooldown", h.Admin.Setting.GetOverloadCooldownSettings) + adminSettings.PUT("/overload-cooldown", h.Admin.Setting.UpdateOverloadCooldownSettings) // 流超时处理配置 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 2d8681d4..7b629e14 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -170,6 +170,13 @@ const ( // SettingKeyOpsRuntimeLogConfig stores JSON config for runtime log settings. SettingKeyOpsRuntimeLogConfig = "ops_runtime_log_config" + // ========================= + // Overload Cooldown (529) + // ========================= + + // SettingKeyOverloadCooldownSettings stores JSON config for 529 overload cooldown handling. + SettingKeyOverloadCooldownSettings = "overload_cooldown_settings" + // ========================= // Stream Timeout Handling // ========================= diff --git a/backend/internal/service/overload_cooldown_test.go b/backend/internal/service/overload_cooldown_test.go new file mode 100644 index 00000000..ef5e7fd1 --- /dev/null +++ b/backend/internal/service/overload_cooldown_test.go @@ -0,0 +1,298 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// errSettingRepo: a SettingRepository that always returns errors on read +// --------------------------------------------------------------------------- + +type errSettingRepo struct { + mockSettingRepo // embed the existing mock from backup_service_test.go + readErr error +} + +func (r *errSettingRepo) GetValue(_ context.Context, _ string) (string, error) { + return "", r.readErr +} + +func (r *errSettingRepo) Get(_ context.Context, _ string) (*Setting, error) { + return nil, r.readErr +} + +// --------------------------------------------------------------------------- +// overloadAccountRepoStub: records SetOverloaded calls +// --------------------------------------------------------------------------- + +type overloadAccountRepoStub struct { + mockAccountRepoForGemini + overloadCalls int + lastOverloadID int64 + lastOverloadEnd time.Time +} + +func (r *overloadAccountRepoStub) SetOverloaded(_ context.Context, id int64, until time.Time) error { + r.overloadCalls++ + r.lastOverloadID = id + r.lastOverloadEnd = until + return nil +} + +// =========================================================================== +// SettingService: GetOverloadCooldownSettings +// =========================================================================== + +func TestGetOverloadCooldownSettings_DefaultsWhenNotSet(t *testing.T) { + repo := newMockSettingRepo() + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.Enabled) + require.Equal(t, 10, settings.CooldownMinutes) +} + +func TestGetOverloadCooldownSettings_ReadsFromDB(t *testing.T) { + repo := newMockSettingRepo() + data, _ := json.Marshal(OverloadCooldownSettings{Enabled: false, CooldownMinutes: 30}) + repo.data[SettingKeyOverloadCooldownSettings] = string(data) + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.Enabled) + require.Equal(t, 30, settings.CooldownMinutes) +} + +func TestGetOverloadCooldownSettings_ClampsMinValue(t *testing.T) { + repo := newMockSettingRepo() + data, _ := json.Marshal(OverloadCooldownSettings{Enabled: true, CooldownMinutes: 0}) + repo.data[SettingKeyOverloadCooldownSettings] = string(data) + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, settings.CooldownMinutes) +} + +func TestGetOverloadCooldownSettings_ClampsMaxValue(t *testing.T) { + repo := newMockSettingRepo() + data, _ := json.Marshal(OverloadCooldownSettings{Enabled: true, CooldownMinutes: 999}) + repo.data[SettingKeyOverloadCooldownSettings] = string(data) + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 120, settings.CooldownMinutes) +} + +func TestGetOverloadCooldownSettings_InvalidJSON_ReturnsDefaults(t *testing.T) { + repo := newMockSettingRepo() + repo.data[SettingKeyOverloadCooldownSettings] = "not-json" + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.Enabled) + require.Equal(t, 10, settings.CooldownMinutes) +} + +func TestGetOverloadCooldownSettings_EmptyValue_ReturnsDefaults(t *testing.T) { + repo := newMockSettingRepo() + repo.data[SettingKeyOverloadCooldownSettings] = "" + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.Enabled) + require.Equal(t, 10, settings.CooldownMinutes) +} + +// =========================================================================== +// SettingService: SetOverloadCooldownSettings +// =========================================================================== + +func TestSetOverloadCooldownSettings_Success(t *testing.T) { + repo := newMockSettingRepo() + svc := NewSettingService(repo, &config.Config{}) + + err := svc.SetOverloadCooldownSettings(context.Background(), &OverloadCooldownSettings{ + Enabled: false, + CooldownMinutes: 25, + }) + require.NoError(t, err) + + // Verify round-trip + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.Enabled) + require.Equal(t, 25, settings.CooldownMinutes) +} + +func TestSetOverloadCooldownSettings_RejectsNil(t *testing.T) { + svc := NewSettingService(newMockSettingRepo(), &config.Config{}) + err := svc.SetOverloadCooldownSettings(context.Background(), nil) + require.Error(t, err) +} + +func TestSetOverloadCooldownSettings_EnabledRejectsOutOfRange(t *testing.T) { + svc := NewSettingService(newMockSettingRepo(), &config.Config{}) + + for _, minutes := range []int{0, -1, 121, 999} { + err := svc.SetOverloadCooldownSettings(context.Background(), &OverloadCooldownSettings{ + Enabled: true, CooldownMinutes: minutes, + }) + require.Error(t, err, "should reject enabled=true + cooldown_minutes=%d", minutes) + require.Contains(t, err.Error(), "cooldown_minutes must be between 1-120") + } +} + +func TestSetOverloadCooldownSettings_DisabledNormalizesOutOfRange(t *testing.T) { + repo := newMockSettingRepo() + svc := NewSettingService(repo, &config.Config{}) + + // enabled=false + cooldown_minutes=0 应该保存成功,值被归一化为10 + err := svc.SetOverloadCooldownSettings(context.Background(), &OverloadCooldownSettings{ + Enabled: false, CooldownMinutes: 0, + }) + require.NoError(t, err, "disabled with invalid minutes should NOT be rejected") + + // 验证持久化后读回来的值 + settings, err := svc.GetOverloadCooldownSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.Enabled) + require.Equal(t, 10, settings.CooldownMinutes, "should be normalized to default") +} + +func TestSetOverloadCooldownSettings_AcceptsBoundaries(t *testing.T) { + svc := NewSettingService(newMockSettingRepo(), &config.Config{}) + + for _, minutes := range []int{1, 60, 120} { + err := svc.SetOverloadCooldownSettings(context.Background(), &OverloadCooldownSettings{ + Enabled: true, CooldownMinutes: minutes, + }) + require.NoError(t, err, "should accept cooldown_minutes=%d", minutes) + } +} + +// =========================================================================== +// RateLimitService: handle529 behaviour +// =========================================================================== + +func TestHandle529_EnabledFromDB_PausesAccount(t *testing.T) { + accountRepo := &overloadAccountRepoStub{} + settingRepo := newMockSettingRepo() + data, _ := json.Marshal(OverloadCooldownSettings{Enabled: true, CooldownMinutes: 15}) + settingRepo.data[SettingKeyOverloadCooldownSettings] = string(data) + + settingSvc := NewSettingService(settingRepo, &config.Config{}) + svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + svc.SetSettingService(settingSvc) + + account := &Account{ID: 42, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + before := time.Now() + svc.handle529(context.Background(), account) + + require.Equal(t, 1, accountRepo.overloadCalls) + require.Equal(t, int64(42), accountRepo.lastOverloadID) + require.WithinDuration(t, before.Add(15*time.Minute), accountRepo.lastOverloadEnd, 2*time.Second) +} + +func TestHandle529_DisabledFromDB_SkipsAccount(t *testing.T) { + accountRepo := &overloadAccountRepoStub{} + settingRepo := newMockSettingRepo() + data, _ := json.Marshal(OverloadCooldownSettings{Enabled: false, CooldownMinutes: 15}) + settingRepo.data[SettingKeyOverloadCooldownSettings] = string(data) + + settingSvc := NewSettingService(settingRepo, &config.Config{}) + svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + svc.SetSettingService(settingSvc) + + account := &Account{ID: 42, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + svc.handle529(context.Background(), account) + + require.Equal(t, 0, accountRepo.overloadCalls, "should NOT pause when disabled") +} + +func TestHandle529_NilSettingService_FallsBackToConfig(t *testing.T) { + accountRepo := &overloadAccountRepoStub{} + cfg := &config.Config{} + cfg.RateLimit.OverloadCooldownMinutes = 20 + svc := NewRateLimitService(accountRepo, nil, cfg, nil, nil) + // NOT calling SetSettingService — remains nil + + account := &Account{ID: 77, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + before := time.Now() + svc.handle529(context.Background(), account) + + require.Equal(t, 1, accountRepo.overloadCalls) + require.WithinDuration(t, before.Add(20*time.Minute), accountRepo.lastOverloadEnd, 2*time.Second) +} + +func TestHandle529_NilSettingService_ZeroConfig_DefaultsTen(t *testing.T) { + accountRepo := &overloadAccountRepoStub{} + svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + + account := &Account{ID: 88, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + before := time.Now() + svc.handle529(context.Background(), account) + + require.Equal(t, 1, accountRepo.overloadCalls) + require.WithinDuration(t, before.Add(10*time.Minute), accountRepo.lastOverloadEnd, 2*time.Second) +} + +func TestHandle529_DBReadError_FallsBackToConfig(t *testing.T) { + accountRepo := &overloadAccountRepoStub{} + errRepo := &errSettingRepo{readErr: context.DeadlineExceeded} + errRepo.data = make(map[string]string) + + cfg := &config.Config{} + cfg.RateLimit.OverloadCooldownMinutes = 7 + settingSvc := NewSettingService(errRepo, cfg) + svc := NewRateLimitService(accountRepo, nil, cfg, nil, nil) + svc.SetSettingService(settingSvc) + + account := &Account{ID: 99, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + before := time.Now() + svc.handle529(context.Background(), account) + + require.Equal(t, 1, accountRepo.overloadCalls) + require.WithinDuration(t, before.Add(7*time.Minute), accountRepo.lastOverloadEnd, 2*time.Second) +} + +// =========================================================================== +// Model: defaults & JSON round-trip +// =========================================================================== + +func TestDefaultOverloadCooldownSettings(t *testing.T) { + d := DefaultOverloadCooldownSettings() + require.True(t, d.Enabled) + require.Equal(t, 10, d.CooldownMinutes) +} + +func TestOverloadCooldownSettings_JSONRoundTrip(t *testing.T) { + original := OverloadCooldownSettings{Enabled: false, CooldownMinutes: 42} + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded OverloadCooldownSettings + require.NoError(t, json.Unmarshal(data, &decoded)) + require.Equal(t, original, decoded) + + // Verify JSON uses snake_case field names + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + _, hasEnabled := raw["enabled"] + _, hasCooldown := raw["cooldown_minutes"] + require.True(t, hasEnabled, "JSON must use 'enabled'") + require.True(t, hasCooldown, "JSON must use 'cooldown_minutes'") +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index ef8a65c9..c59dd68d 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1023,11 +1023,34 @@ func parseOpenAIRateLimitResetTime(body []byte) *int64 { } // handle529 处理529过载错误 -// 根据配置设置过载冷却时间 +// 根据配置决定是否暂停账号调度及冷却时长 func (s *RateLimitService) handle529(ctx context.Context, account *Account) { - cooldownMinutes := s.cfg.RateLimit.OverloadCooldownMinutes + var settings *OverloadCooldownSettings + if s.settingService != nil { + var err error + settings, err = s.settingService.GetOverloadCooldownSettings(ctx) + if err != nil { + slog.Warn("overload_settings_read_failed", "account_id", account.ID, "error", err) + settings = nil + } + } + // 回退到配置文件 + if settings == nil { + cooldown := s.cfg.RateLimit.OverloadCooldownMinutes + if cooldown <= 0 { + cooldown = 10 + } + settings = &OverloadCooldownSettings{Enabled: true, CooldownMinutes: cooldown} + } + + if !settings.Enabled { + slog.Info("account_529_ignored", "account_id", account.ID, "reason", "overload_cooldown_disabled") + return + } + + cooldownMinutes := settings.CooldownMinutes if cooldownMinutes <= 0 { - cooldownMinutes = 10 // 默认10分钟 + cooldownMinutes = 10 } until := time.Now().Add(time.Duration(cooldownMinutes) * time.Minute) diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 141cdb39..ece95c4e 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -1172,6 +1172,57 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf return effective, nil } +// GetOverloadCooldownSettings 获取529过载冷却配置 +func (s *SettingService) GetOverloadCooldownSettings(ctx context.Context) (*OverloadCooldownSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyOverloadCooldownSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultOverloadCooldownSettings(), nil + } + return nil, fmt.Errorf("get overload cooldown settings: %w", err) + } + if value == "" { + return DefaultOverloadCooldownSettings(), nil + } + + var settings OverloadCooldownSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultOverloadCooldownSettings(), nil + } + + // 修正配置值范围 + if settings.CooldownMinutes < 1 { + settings.CooldownMinutes = 1 + } + if settings.CooldownMinutes > 120 { + settings.CooldownMinutes = 120 + } + + return &settings, nil +} + +// SetOverloadCooldownSettings 设置529过载冷却配置 +func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settings *OverloadCooldownSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + // 禁用时修正为合法值即可,不拒绝请求 + if settings.CooldownMinutes < 1 || settings.CooldownMinutes > 120 { + if settings.Enabled { + return fmt.Errorf("cooldown_minutes must be between 1-120") + } + settings.CooldownMinutes = 10 // 禁用状态下归一化为默认值 + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal overload cooldown settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data)) +} + // GetStreamTimeoutSettings 获取流超时处理配置 func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) { value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 71c2e7aa..23188a09 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -222,6 +222,22 @@ type BetaPolicySettings struct { Rules []BetaPolicyRule `json:"rules"` } +// OverloadCooldownSettings 529过载冷却配置 +type OverloadCooldownSettings struct { + // Enabled 是否在收到529时暂停账号调度 + Enabled bool `json:"enabled"` + // CooldownMinutes 冷却时长(分钟) + CooldownMinutes int `json:"cooldown_minutes"` +} + +// DefaultOverloadCooldownSettings 返回默认的过载冷却配置(启用,10分钟) +func DefaultOverloadCooldownSettings() *OverloadCooldownSettings { + return &OverloadCooldownSettings{ + Enabled: true, + CooldownMinutes: 10, + } +} + // DefaultBetaPolicySettings 返回默认的 Beta 策略配置 func DefaultBetaPolicySettings() *BetaPolicySettings { return &BetaPolicySettings{ diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 040cf71e..a2cd67f0 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -242,6 +242,33 @@ export async function deleteAdminApiKey(): Promise<{ message: string }> { return data } +// ==================== Overload Cooldown Settings ==================== + +/** + * Overload cooldown settings interface (529 handling) + */ +export interface OverloadCooldownSettings { + enabled: boolean + cooldown_minutes: number +} + +export async function getOverloadCooldownSettings(): Promise { + const { data } = await apiClient.get('/admin/settings/overload-cooldown') + return data +} + +export async function updateOverloadCooldownSettings( + settings: OverloadCooldownSettings +): Promise { + const { data } = await apiClient.put( + '/admin/settings/overload-cooldown', + settings + ) + return data +} + +// ==================== Stream Timeout Settings ==================== + /** * Stream timeout settings interface */ @@ -499,6 +526,8 @@ export const settingsAPI = { getAdminApiKey, regenerateAdminApiKey, deleteAdminApiKey, + getOverloadCooldownSettings, + updateOverloadCooldownSettings, getStreamTimeoutSettings, updateStreamTimeoutSettings, getRectifierSettings, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 13a6c1b1..da790a0a 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4362,6 +4362,16 @@ export default { testFailed: 'Google Drive storage test failed' } }, + overloadCooldown: { + title: '529 Overload Cooldown', + description: 'Configure account scheduling pause strategy when upstream returns 529 (overloaded)', + enabled: 'Enable Overload Cooldown', + enabledHint: 'Pause account scheduling on 529 errors, auto-recover after cooldown', + cooldownMinutes: 'Cooldown Duration (minutes)', + cooldownMinutesHint: 'Duration to pause account scheduling (1-120 minutes)', + saved: 'Overload cooldown settings saved', + saveFailed: 'Failed to save overload 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 3cfc7953..e3150f76 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4527,6 +4527,16 @@ export default { testFailed: 'Google Drive 存储测试失败' } }, + overloadCooldown: { + title: '529 过载冷却', + description: '配置上游返回 529(过载)时的账号调度暂停策略', + enabled: '启用过载冷却', + enabledHint: '收到 529 错误时暂停该账号的调度,冷却后自动恢复', + cooldownMinutes: '冷却时长(分钟)', + cooldownMinutesHint: '账号暂停调度的持续时间(1-120 分钟)', + saved: '过载冷却设置保存成功', + saveFailed: '保存过载冷却设置失败' + }, streamTimeout: { title: '流超时处理', description: '配置上游响应超时时的账户处理策略,避免问题账户持续被选中', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 2ce34510..fda62b29 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -168,8 +168,93 @@ - +
+ + +
+
+

+ {{ t('admin.settings.overloadCooldown.title') }} +

+

+ {{ t('admin.settings.overloadCooldown.description') }} +

+
+
+
+
+ {{ t('common.loading') }} +
+ + +
+
+
@@ -1765,6 +1850,14 @@ const adminApiKeyOperating = ref(false) const newAdminApiKey = ref('') const subscriptionGroups = ref([]) +// Overload Cooldown (529) 状态 +const overloadCooldownLoading = ref(true) +const overloadCooldownSaving = ref(false) +const overloadCooldownForm = reactive({ + enabled: true, + cooldown_minutes: 10 +}) + // Stream Timeout 状态 const streamTimeoutLoading = ref(true) const streamTimeoutSaving = ref(false) @@ -2274,6 +2367,37 @@ function copyNewKey() { }) } +// Overload Cooldown 方法 +async function loadOverloadCooldownSettings() { + overloadCooldownLoading.value = true + try { + const settings = await adminAPI.settings.getOverloadCooldownSettings() + Object.assign(overloadCooldownForm, settings) + } catch (error: any) { + console.error('Failed to load overload cooldown settings:', error) + } finally { + overloadCooldownLoading.value = false + } +} + +async function saveOverloadCooldownSettings() { + overloadCooldownSaving.value = true + try { + const updated = await adminAPI.settings.updateOverloadCooldownSettings({ + enabled: overloadCooldownForm.enabled, + cooldown_minutes: overloadCooldownForm.cooldown_minutes + }) + Object.assign(overloadCooldownForm, updated) + appStore.showSuccess(t('admin.settings.overloadCooldown.saved')) + } catch (error: any) { + appStore.showError( + t('admin.settings.overloadCooldown.saveFailed') + ': ' + (error.message || t('common.unknownError')) + ) + } finally { + overloadCooldownSaving.value = false + } +} + // Stream Timeout 方法 async function loadStreamTimeoutSettings() { streamTimeoutLoading.value = true @@ -2396,6 +2520,7 @@ onMounted(() => { loadSettings() loadSubscriptionGroups() loadAdminApiKey() + loadOverloadCooldownSettings() loadStreamTimeoutSettings() loadRectifierSettings() loadBetaPolicySettings()