From 83a884c1f8a0f639014c507b8d284511cdc9945c Mon Sep 17 00:00:00 2001 From: jiozhaoyue <200372196+jiozhaoyue@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:24:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=86=94=E6=96=AD?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=E5=85=A8=E5=B1=80=E8=AE=BE=E7=BD=AE=E4=B8=8E?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=E7=BA=A7=E5=85=8D=E7=86=94=E6=96=AD=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E5=B9=B6=E5=AE=8C=E5=96=84CI=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 14 +++ docs-site/advanced/rotation.md | 5 + docs/KB/patterns/circuit-breaker.md | 7 +- src-tauri/Cargo.lock | 2 +- src-tauri/src/commands/config.rs | 11 +- src-tauri/src/commands/endpoint.rs | 1 + src-tauri/src/commands/health.rs | 12 +- src-tauri/src/models/config.rs | 9 ++ src-tauri/src/models/endpoint.rs | 5 + .../modules/cc_switch_migration/importer.rs | 1 + .../src/modules/proxy/circuit_breaker.rs | 116 +++++++++++++++++- src-tauri/src/modules/proxy/forward.rs | 49 ++++---- src-tauri/src/modules/proxy/inbound.rs | 1 + src-tauri/src/modules/proxy/resolver.rs | 1 + src-tauri/src/modules/proxy/server.rs | 6 +- src-tauri/src/modules/storage/config_repo.rs | 14 +++ .../src/modules/storage/endpoint_repo.rs | 17 ++- src-tauri/src/modules/storage/migration.rs | 14 +++ .../Endpoints/_components/EndpointCard.tsx | 15 ++- .../Endpoints/_components/EndpointForm.tsx | 17 +++ .../_components/CircuitBreakerCard.tsx | 70 +++++++++++ src/pages/Settings/index.tsx | 2 + src/services/modules/config.ts | 3 + src/services/modules/endpoint.ts | 3 + 24 files changed, 348 insertions(+), 47 deletions(-) create mode 100644 src/pages/Settings/_components/CircuitBreakerCard.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c36b47a1..e7507ea7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,14 @@ on: - 'package.json' - 'pnpm-lock.yaml' - '.github/workflows/ci.yml' + pull_request: + branches: [master] + paths: + - 'src-tauri/**' + - 'src/**' + - 'package.json' + - 'pnpm-lock.yaml' + - '.github/workflows/ci.yml' workflow_dispatch: permissions: @@ -94,6 +102,12 @@ jobs: - name: Install frontend dependencies run: pnpm install --frozen-lockfile + - name: Run checks and tests + if: matrix.platform == 'ubuntu-22.04' + run: | + pnpm check + pnpm test + # --no-bundle:只编译 Rust + 前端,跳过 msi/dmg/AppImage 打包,省时间; # 但所有依赖 crate 仍被完整编译,足以填满热缓存。 - name: Build (no bundle) diff --git a/docs-site/advanced/rotation.md b/docs-site/advanced/rotation.md index 49d39f18..df88e9c5 100644 --- a/docs-site/advanced/rotation.md +++ b/docs-site/advanced/rotation.md @@ -86,6 +86,11 @@ CC Mesh 在多个上游之间轮换请求,并为每个端点配备独立熔断 1. 连续失败达到 `failure_threshold` 2. 错误率在样本数 ≥ `min_requests` 时达到 `error_rate_threshold` +### 自定义设置与端点豁免 + +- **全局配置**:可在「设置」页面调整「熔断保护」总开关、连续失败阈值与基础冷却时间,修改后运行中的代理会自动平滑重启生效。 +- **端点级免熔断**:在端点编辑弹窗中勾选「禁止熔断保护」后,该端点遇故障不会跳闸,始终保留在路由候选名单中。 + ## 429 限流降噪 429(瞬时限流)与 5xx/网络(端点坏了)分开处理,避免限流突发把端点打进 60-90s 长冷却: diff --git a/docs/KB/patterns/circuit-breaker.md b/docs/KB/patterns/circuit-breaker.md index 0e89f33b..18df2b9f 100644 --- a/docs/KB/patterns/circuit-breaker.md +++ b/docs/KB/patterns/circuit-breaker.md @@ -15,16 +15,17 @@ - **请求驱动**:没有后台健康探测线程。Open → HalfOpen 发生在下一次真实请求经过 `is_available` / `allow_request` 时(惰性)。 - **运行期内存**:`BreakerRegistry` 挂在 `ProxyState` 上,代理启动时新建。停代理或重启进程即清空。 - **每端点独立**:A 熔断不影响 B;轮换与熔断都先按模型过滤,避免无关端点被误伤。 -- **配置本期固定**:`CircuitBreakerConfig` 结构预留热更新,当前用默认常量。 +- **支持全局与端点级配置**:可在「设置」页自定义全局总开关、失败阈值与冷却时长(平滑重启代理生效);可在端点编辑弹窗中单独开启「禁止熔断」豁免跳闸。 实现入口: | 层 | 文件 | 职责 | |----|------|------| -| 状态机 | `src-tauri/src/modules/proxy/circuit_breaker.rs` | 三态、许可、计数、选路过滤 | +| 状态机 | `src-tauri/src/modules/proxy/circuit_breaker.rs` | 三态、许可、计数、选路过滤、自定义选项构造 | | 结果分类 | `src-tauri/src/modules/proxy/rotation.rs` | HTTP / 网络错误 → Retryable / NonRetryable | -| 接入 | `src-tauri/src/modules/proxy/forward.rs` | 选路、gate、上报、发健康事件 | +| 接入 | `src-tauri/src/modules/proxy/forward.rs` | 选路、gate、上报、发健康事件、单端点免熔断跳过 | | 手动恢复 | `src-tauri/src/commands/endpoint.rs` | 连通性测试成功 → `force_close` | +| 配置设置 | `src/pages/Settings/_components/CircuitBreakerCard.tsx` + `EndpointForm.tsx` | 全局开关/阈值及单端点免熔断配置 | | 对外 | `src-tauri/src/commands/health.rs` + 前端 `useEndpointHealth` | 卡片 Badge / 仪表盘状态点 | --- diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 36fac9e3..4628afc3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -556,7 +556,7 @@ dependencies = [ [[package]] name = "ccmesh" -version = "0.2.6" +version = "0.2.7" dependencies = [ "async-trait", "axum", diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 52937111..40dd7510 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -35,12 +35,15 @@ pub async fn set_config( config_repo::set_value(&conn, k, v)?; } let port_changed = patch.contains_key("port") && patch.get("port") != old_port.as_ref(); - // 代理地址 / 启用代理 / 伪装 UA 变更需重建转发 client → 重启代理使其生效 - let proxy_or_ua_changed = patch.contains_key("proxyUrl") + // 代理地址 / 启用代理 / 伪装 UA / 熔断配置变更需重建转发 client 或熔断器 → 重启代理使其生效 + let proxy_or_breaker_changed = patch.contains_key("proxyUrl") || patch.contains_key("proxyEnabled") || patch.contains_key("openaiUa") - || patch.contains_key("claudeCliUa"); - port_changed || proxy_or_ua_changed + || patch.contains_key("claudeCliUa") + || patch.contains_key("circuitBreakerEnabled") + || patch.contains_key("circuitBreakerFailureThreshold") + || patch.contains_key("circuitBreakerTimeout"); + port_changed || proxy_or_breaker_changed }; if needs_restart { diff --git a/src-tauri/src/commands/endpoint.rs b/src-tauri/src/commands/endpoint.rs index c7c7dae5..aa14f203 100644 --- a/src-tauri/src/commands/endpoint.rs +++ b/src-tauri/src/commands/endpoint.rs @@ -119,6 +119,7 @@ pub fn clone_endpoint(state: State, id: i64) -> AppResult { header_overrides_enabled: src.header_overrides_enabled, remark: src.remark, fast: src.fast, + circuit_breaker_disabled: src.circuit_breaker_disabled, }; endpoint_repo::create(&conn, &req) } diff --git a/src-tauri/src/commands/health.rs b/src-tauri/src/commands/health.rs index a35de200..33b60b52 100644 --- a/src-tauri/src/commands/health.rs +++ b/src-tauri/src/commands/health.rs @@ -64,9 +64,15 @@ pub fn get_endpoint_health(state: State) -> AppResult enabled .iter() .map(|e| { - h.state.breakers.health_of(&e.name).unwrap_or_else(|| { - EndpointHealthInfo::from_test_status(&e.name, &e.test_status) - }) + if !h.state.breakers.is_enabled() || e.circuit_breaker_disabled { + let mut info = EndpointHealthInfo::from_test_status(&e.name, &e.test_status); + info.circuit = "closed".to_string(); + info + } else { + h.state.breakers.health_of(&e.name).unwrap_or_else(|| { + EndpointHealthInfo::from_test_status(&e.name, &e.test_status) + }) + } }) .collect(), None => enabled diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index 08b9f358..79cfaddd 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -61,6 +61,12 @@ pub struct AppConfig { pub openai_ua: String, /// 转发到 Claude 端点时覆盖 User-Agent(空=透传客户端)。 pub claude_cli_ua: String, + /// 是否启用端点熔断保护(默认开)。 + pub circuit_breaker_enabled: bool, + /// 熔断器连续失败触发阈值(次,默认 4)。 + pub circuit_breaker_failure_threshold: u32, + /// 熔断器冷却时长(秒,默认 60)。 + pub circuit_breaker_timeout: u64, pub update: UpdateSettings, pub webdav: WebDavConfig, } @@ -84,6 +90,9 @@ impl Default for AppConfig { proxy_for_update: false, openai_ua: ua::codex_probe_ua(), claude_cli_ua: ua::CLAUDE_PROBE_UA.into(), + circuit_breaker_enabled: true, + circuit_breaker_failure_threshold: 4, + circuit_breaker_timeout: 60, update: UpdateSettings::default(), webdav: WebDavConfig::default(), } diff --git a/src-tauri/src/models/endpoint.rs b/src-tauri/src/models/endpoint.rs index cc1ee746..ec7b8eff 100644 --- a/src-tauri/src/models/endpoint.rs +++ b/src-tauri/src/models/endpoint.rs @@ -55,6 +55,8 @@ pub struct Endpoint { pub fast: bool, /// 快速队列内独立排序,不影响全局 sort_order。 pub fast_sort_order: i64, + /// 是否禁止该端点熔断保护(true 表示故障不跳闸,始终可路由)。 + pub circuit_breaker_disabled: bool, /// 测试状态:unknown / available / unavailable。 pub test_status: String, pub created_at: String, @@ -96,6 +98,8 @@ pub struct CreateEndpointRequest { pub remark: String, #[serde(default)] pub fast: bool, + #[serde(default)] + pub circuit_breaker_disabled: bool, } #[derive(Debug, Clone, Default, Deserialize)] @@ -117,6 +121,7 @@ pub struct UpdateEndpointRequest { pub header_overrides_enabled: Option, pub remark: Option, pub fast: Option, + pub circuit_breaker_disabled: Option, } fn default_true() -> bool { diff --git a/src-tauri/src/modules/cc_switch_migration/importer.rs b/src-tauri/src/modules/cc_switch_migration/importer.rs index 1b7e88af..e981cb75 100644 --- a/src-tauri/src/modules/cc_switch_migration/importer.rs +++ b/src-tauri/src/modules/cc_switch_migration/importer.rs @@ -135,6 +135,7 @@ pub async fn import( header_overrides_enabled: false, remark: p.remark.clone(), fast: false, + circuit_breaker_disabled: false, }; endpoint_repo::create(conn, &req)?; diff --git a/src-tauri/src/modules/proxy/circuit_breaker.rs b/src-tauri/src/modules/proxy/circuit_breaker.rs index df9e88b7..9f3af1ce 100644 --- a/src-tauri/src/modules/proxy/circuit_breaker.rs +++ b/src-tauri/src/modules/proxy/circuit_breaker.rs @@ -286,13 +286,15 @@ impl EndpointHealthInfo { /// 按端点名池化的熔断器注册表(存 `ProxyState`,运行期内存态)。 /// 持两套 config preset:Claude 入站放宽,OpenAI/Responses 默认。状态按端点共享,仅阈值随入站而定。 pub struct BreakerRegistry { + enabled: bool, config_default: CircuitBreakerConfig, config_claude: CircuitBreakerConfig, inner: Mutex>, } impl BreakerRegistry { - /// 生产构造:默认 + Claude 两套 preset。 + /// 生产构造:默认 + Claude 两套 preset(默认开启)。 + #[allow(dead_code)] pub fn new() -> Self { Self::with_configs( CircuitBreakerConfig::default(), @@ -300,9 +302,37 @@ impl BreakerRegistry { ) } - /// 显式指定两套 preset(测试用)。 + /// 根据用户配置项构造。 + pub fn from_options(enabled: bool, failure_threshold: u32, timeout_secs: u64) -> Self { + let ft = failure_threshold.max(1); + let timeout = Duration::from_secs(timeout_secs.max(1)); + let default_cfg = CircuitBreakerConfig { + failure_threshold: ft, + timeout, + ..CircuitBreakerConfig::default() + }; + let claude_cfg = CircuitBreakerConfig { + failure_threshold: ft.saturating_mul(2), + timeout: Duration::from_secs(timeout_secs.max(1).saturating_mul(3) / 2), + ..CircuitBreakerConfig::claude() + }; + Self::with_configs_and_enabled(enabled, default_cfg, claude_cfg) + } + + /// 显式指定两套 preset(测试用,默认开启)。 + #[allow(dead_code)] pub fn with_configs(default: CircuitBreakerConfig, claude: CircuitBreakerConfig) -> Self { + Self::with_configs_and_enabled(true, default, claude) + } + + /// 显式指定是否开启及两套 preset。 + pub fn with_configs_and_enabled( + enabled: bool, + default: CircuitBreakerConfig, + claude: CircuitBreakerConfig, + ) -> Self { Self { + enabled, config_default: default, config_claude: claude, inner: Mutex::new(HashMap::new()), @@ -312,7 +342,12 @@ impl BreakerRegistry { /// 测试用:两套 preset 设为同一份 config。 #[cfg(test)] pub fn new_uniform(config: CircuitBreakerConfig) -> Self { - Self::with_configs(config, config) + Self::with_configs_and_enabled(true, config, config) + } + + /// 是否开启熔断保护。 + pub fn is_enabled(&self) -> bool { + self.enabled } fn cfg_for(&self, inbound: InboundKind) -> &CircuitBreakerConfig { @@ -324,6 +359,9 @@ impl BreakerRegistry { /// 选路过滤用:端点当前是否可选(不占半开许可)。Open 到期会惰性转 HalfOpen。 pub fn is_available(&self, name: &str, _inbound: InboundKind, now: Instant) -> bool { + if !self.enabled { + return true; + } let mut g = self.inner.lock().unwrap(); let b = g.entry(name.to_string()).or_default(); b.maybe_half_open(now); @@ -332,6 +370,12 @@ impl BreakerRegistry { /// 发请求前取许可。HalfOpen 同一时刻只放行 1 个探测。 pub fn allow_request(&self, name: &str, _inbound: InboundKind, now: Instant) -> AllowResult { + if !self.enabled { + return AllowResult { + allowed: true, + used_half_open_permit: false, + }; + } let mut g = self.inner.lock().unwrap(); let b = g.entry(name.to_string()).or_default(); b.maybe_half_open(now); @@ -363,6 +407,9 @@ impl BreakerRegistry { /// 记录成功。返回是否发生状态转换(供调用方发事件)。 pub fn record_success(&self, name: &str, used_permit: bool, inbound: InboundKind) -> bool { + if !self.enabled { + return false; + } let cfg = self.cfg_for(inbound); let mut g = self.inner.lock().unwrap(); let b = g.entry(name.to_string()).or_default(); @@ -392,6 +439,9 @@ impl BreakerRegistry { inbound: InboundKind, kind: FailureKind, ) -> bool { + if !self.enabled { + return false; + } let cfg = self.cfg_for(inbound); let mut g = self.inner.lock().unwrap(); let b = g.entry(name.to_string()).or_default(); @@ -428,7 +478,7 @@ impl BreakerRegistry { /// 记录中性结果(客户端错误/中断):仅释放半开许可,不计入熔断。 pub fn record_neutral(&self, name: &str, used_permit: bool) { - if !used_permit { + if !self.enabled || !used_permit { return; } let mut g = self.inner.lock().unwrap(); @@ -473,6 +523,9 @@ impl BreakerRegistry { _inbound: InboundKind, now: Instant, ) -> Option { + if !self.enabled { + return None; + } let g = self.inner.lock().unwrap(); let mut soonest: Option = None; let mut any_open = false; @@ -529,9 +582,12 @@ pub fn select_candidates( inbound: InboundKind, now: Instant, ) -> Vec { + if !registry.is_enabled() { + return enabled.to_vec(); + } enabled .iter() - .filter(|e| registry.is_available(&e.name, inbound, now)) + .filter(|e| e.circuit_breaker_disabled || registry.is_available(&e.name, inbound, now)) .cloned() .collect() } @@ -573,6 +629,7 @@ mod tests { sort_order: 0, fast: false, fast_sort_order: 0, + circuit_breaker_disabled: false, test_status: "unknown".into(), created_at: "".into(), updated_at: "".into(), @@ -1146,4 +1203,53 @@ mod tests { assert!(!reg.is_available("c", InboundKind::OpenAi, now + Duration::from_secs(61))); assert!(reg.is_available("c", InboundKind::OpenAi, now + Duration::from_secs(91))); } + + #[test] + fn disabled_registry_always_allows_and_never_trips() { + let reg = BreakerRegistry::from_options(false, 3, 60); + let now = Instant::now(); + for _ in 0..10 { + let changed = reg.record_failure( + "a", + false, + now, + "500", + InboundKind::OpenAi, + FailureKind::Broken, + ); + assert!(!changed); + } + assert!(reg.is_available("a", InboundKind::OpenAi, now)); + let allow = reg.allow_request("a", InboundKind::OpenAi, now); + assert!(allow.allowed); + assert!(!allow.used_half_open_permit); + } + + #[test] + fn select_candidates_honors_disabled_endpoint() { + let reg = BreakerRegistry::new_uniform(cfg()); + let now = Instant::now(); + for _ in 0..3 { + reg.record_failure( + "a", + false, + now, + "500", + InboundKind::OpenAi, + FailureKind::Broken, + ); + } + let mut ep_a = ep("a"); + let ep_b = ep("b"); + let eps = vec![ep_a.clone(), ep_b.clone()]; + let cands = select_candidates(&eps, ®, InboundKind::OpenAi, now); + assert_eq!(cands.len(), 1); + assert_eq!(cands[0].name, "b"); + + // 当 a 标记了 circuit_breaker_disabled,即使处于 open 也保留在候选 + ep_a.circuit_breaker_disabled = true; + let eps2 = vec![ep_a, ep_b]; + let cands2 = select_candidates(&eps2, ®, InboundKind::OpenAi, now); + assert_eq!(cands2.len(), 2); + } } diff --git a/src-tauri/src/modules/proxy/forward.rs b/src-tauri/src/modules/proxy/forward.rs index 1006198c..ef83e377 100644 --- a/src-tauri/src/modules/proxy/forward.rs +++ b/src-tauri/src/modules/proxy/forward.rs @@ -603,8 +603,8 @@ pub async fn handle_proxy( last_endpoint = ep.name.clone(); last_transformer = Some(ep.transformer.clone()); - // 熔断许可:gate 时对候选取许可(半开同一时刻仅 1 个探测);拒绝则跳到下一个端点。 - let used_permit = if gate { + // 熔断许可:gate 时对候选取许可(半开同一时刻仅 1 个探测;禁用熔断则跳过);拒绝则跳到下一个端点。 + let used_permit = if gate && !ep.circuit_breaker_disabled { let allow = st.breakers.allow_request(&ep.name, inbound, Instant::now()); if !allow.allowed { st.rotation.advance(n); @@ -778,8 +778,10 @@ pub async fn handle_proxy( actual_model, }; if status == 200 { - // 成功:闭合熔断(半开恢复时回传许可);转换则通知前端 - if st.breakers.record_success(&ep.name, used_permit, inbound) { + // 成功:闭合熔断(半开恢复时回传许可;禁用熔断端点不更新熔断态);转换则通知前端 + if !ep.circuit_breaker_disabled + && st.breakers.record_success(&ep.name, used_permit, inbound) + { st.stats.emit_health_changed(); } // 真实 token 由各响应处理函数解析上游 usage 后记录 @@ -822,14 +824,16 @@ pub async fn handle_proxy( } else { FailureKind::Broken }; - if st.breakers.record_failure( - &ep.name, - used_permit, - Instant::now(), - &format!("HTTP {status}"), - inbound, - kind, - ) { + if !ep.circuit_breaker_disabled + && st.breakers.record_failure( + &ep.name, + used_permit, + Instant::now(), + &format!("HTTP {status}"), + inbound, + kind, + ) + { st.stats.emit_health_changed(); } } @@ -914,15 +918,17 @@ pub async fn handle_proxy( } Some(Err(e)) => { let msg = e.to_string(); - // 网络错误计入熔断(Retryable,Broken 长冷却);转换则通知前端 - if st.breakers.record_failure( - &ep.name, - used_permit, - Instant::now(), - &msg, - inbound, - FailureKind::Broken, - ) { + // 网络错误计入熔断(Retryable,Broken 长冷却;禁用熔断端点跳过);转换则通知前端 + if !ep.circuit_breaker_disabled + && st.breakers.record_failure( + &ep.name, + used_permit, + Instant::now(), + &msg, + inbound, + FailureKind::Broken, + ) + { st.stats.emit_health_changed(); } last_err = msg.clone(); @@ -1381,6 +1387,7 @@ mod tests { sort_order: 0, fast: false, fast_sort_order: 0, + circuit_breaker_disabled: false, test_status: "unknown".into(), created_at: String::new(), updated_at: String::new(), diff --git a/src-tauri/src/modules/proxy/inbound.rs b/src-tauri/src/modules/proxy/inbound.rs index ebea9d0f..fb7e336b 100644 --- a/src-tauri/src/modules/proxy/inbound.rs +++ b/src-tauri/src/modules/proxy/inbound.rs @@ -182,6 +182,7 @@ mod tests { sort_order: 0, fast: false, fast_sort_order: 0, + circuit_breaker_disabled: false, test_status: "unknown".into(), created_at: String::new(), updated_at: String::new(), diff --git a/src-tauri/src/modules/proxy/resolver.rs b/src-tauri/src/modules/proxy/resolver.rs index f841d6cd..87493c2d 100644 --- a/src-tauri/src/modules/proxy/resolver.rs +++ b/src-tauri/src/modules/proxy/resolver.rs @@ -241,6 +241,7 @@ mod tests { sort_order: 0, fast: false, fast_sort_order: 0, + circuit_breaker_disabled: false, test_status: "unknown".into(), created_at: "".into(), updated_at: "".into(), diff --git a/src-tauri/src/modules/proxy/server.rs b/src-tauri/src/modules/proxy/server.rs index 3d20e500..bb2b6d56 100644 --- a/src-tauri/src/modules/proxy/server.rs +++ b/src-tauri/src/modules/proxy/server.rs @@ -134,7 +134,11 @@ pub async fn start_proxy( stats, current_endpoint: Mutex::new(None), proxy_enabled: cfg.proxy_enabled, - breakers: BreakerRegistry::new(), + breakers: BreakerRegistry::from_options( + cfg.circuit_breaker_enabled, + cfg.circuit_breaker_failure_threshold, + cfg.circuit_breaker_timeout, + ), rectifier_config: RectifierConfig::default(), }); diff --git a/src-tauri/src/modules/storage/config_repo.rs b/src-tauri/src/modules/storage/config_repo.rs index 8e23d540..37b5e1eb 100644 --- a/src-tauri/src/modules/storage/config_repo.rs +++ b/src-tauri/src/modules/storage/config_repo.rs @@ -27,6 +27,9 @@ pub const SAFE_CONFIG_KEYS: &[&str] = &[ "update_checkInterval", "openaiUa", "claudeCliUa", + "circuitBreakerEnabled", + "circuitBreakerFailureThreshold", + "circuitBreakerTimeout", ]; pub fn get_value(conn: &Connection, key: &str) -> AppResult> { @@ -96,6 +99,17 @@ pub fn get_config(conn: &Connection) -> AppResult { proxy_for_update: parse_bool(&m, "proxyForUpdate", d.proxy_for_update), openai_ua: parse_str_allow_empty(&m, "openaiUa", &d.openai_ua), claude_cli_ua: parse_str_allow_empty(&m, "claudeCliUa", &d.claude_cli_ua), + circuit_breaker_enabled: parse_bool(&m, "circuitBreakerEnabled", d.circuit_breaker_enabled), + circuit_breaker_failure_threshold: parse_i64( + &m, + "circuitBreakerFailureThreshold", + d.circuit_breaker_failure_threshold as i64, + ) as u32, + circuit_breaker_timeout: parse_i64( + &m, + "circuitBreakerTimeout", + d.circuit_breaker_timeout as i64, + ) as u64, update: UpdateSettings { auto_check: parse_bool(&m, "update_autoCheck", true), check_interval: parse_i64(&m, "update_checkInterval", 24), diff --git a/src-tauri/src/modules/storage/endpoint_repo.rs b/src-tauri/src/modules/storage/endpoint_repo.rs index 8949e9cd..c9b76b5d 100644 --- a/src-tauri/src/modules/storage/endpoint_repo.rs +++ b/src-tauri/src/modules/storage/endpoint_repo.rs @@ -7,7 +7,7 @@ use crate::models::endpoint::{ CreateEndpointRequest, Endpoint, HeaderOverride, UpdateEndpointRequest, }; -const COLS: &str = "id, name, api_url, api_key, auth_mode, enabled, use_proxy, transformer, model, models, active_models, model_mappings, model_mappings_enabled, header_overrides, header_overrides_enabled, remark, sort_order, fast, fast_sort_order, test_status, created_at, updated_at, archived"; +const COLS: &str = "id, name, api_url, api_key, auth_mode, enabled, use_proxy, transformer, model, models, active_models, model_mappings, model_mappings_enabled, header_overrides, header_overrides_enabled, remark, sort_order, fast, fast_sort_order, circuit_breaker_disabled, test_status, created_at, updated_at, archived"; /// 认证头与连接控制头不允许覆写(与转发层剔除口径对齐)。 const FORBIDDEN_OVERRIDE_HEADERS: &[&str] = &[ @@ -53,6 +53,7 @@ fn row_to_endpoint(row: &Row) -> rusqlite::Result { sort_order: row.get("sort_order")?, fast: row.get::<_, i64>("fast")? != 0, fast_sort_order: row.get("fast_sort_order")?, + circuit_breaker_disabled: row.get::<_, i64>("circuit_breaker_disabled")? != 0, test_status: row.get("test_status")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, @@ -163,8 +164,8 @@ pub fn create(conn: &Connection, req: &CreateEndpointRequest) -> AppResult AppResult AppRes if !e.enabled { e.fast = false; } + if let Some(v) = req.circuit_breaker_disabled { + e.circuit_breaker_disabled = v; + } // models 或 active_models 任一变更后,重新规整点亮子集为 models 的子集。 e.active_models = sanitize_active(&e.models, &e.active_models); @@ -260,8 +265,8 @@ pub fn update(conn: &Connection, id: i64, req: &UpdateEndpointRequest) -> AppRes use_proxy = ?6, transformer = ?7, model = ?8, models = ?9, active_models = ?10, model_mappings = ?11, model_mappings_enabled = ?12, header_overrides = ?13, header_overrides_enabled = ?14, remark = ?15, fast = ?16, - updated_at = datetime('now') - WHERE id = ?17", + circuit_breaker_disabled = ?17, updated_at = datetime('now') + WHERE id = ?18", params![ e.name, e.api_url, @@ -279,6 +284,7 @@ pub fn update(conn: &Connection, id: i64, req: &UpdateEndpointRequest) -> AppRes e.header_overrides_enabled as i64, e.remark, e.fast as i64, + e.circuit_breaker_disabled as i64, id, ], )?; @@ -458,6 +464,7 @@ mod tests { header_overrides_enabled: false, remark: String::new(), fast: false, + circuit_breaker_disabled: false, } } diff --git a/src-tauri/src/modules/storage/migration.rs b/src-tauri/src/modules/storage/migration.rs index bbe04b54..52a13d22 100644 --- a/src-tauri/src/modules/storage/migration.rs +++ b/src-tauri/src/modules/storage/migration.rs @@ -168,6 +168,8 @@ const MIGRATIONS: &[&str] = &[ // v18:端点出站请求头覆写(JSON 数组 [{name,value}])+ 总开关。旧行默认关闭。 "ALTER TABLE endpoints ADD COLUMN header_overrides TEXT NOT NULL DEFAULT '[]'; ALTER TABLE endpoints ADD COLUMN header_overrides_enabled INTEGER NOT NULL DEFAULT 0;", + // v19:端点禁止熔断标记(默认 0,即允许熔断)。 + "ALTER TABLE endpoints ADD COLUMN circuit_breaker_disabled INTEGER NOT NULL DEFAULT 0;", ]; /// 幂等执行迁移:读取 `schema_version` 当前版本,仅应用尚未执行的脚本。 @@ -389,4 +391,16 @@ mod tests { assert!(cols.contains(&"header_overrides".to_string())); assert!(cols.contains(&"header_overrides_enabled".to_string())); } + + #[test] + fn v19_adds_circuit_breaker_disabled_column() { + let c = Connection::open_in_memory().unwrap(); + run_migrations(&c).unwrap(); + let cols: Vec = { + let mut stmt = c.prepare("PRAGMA table_info(endpoints)").unwrap(); + let rows = stmt.query_map([], |r| r.get::<_, String>(1)).unwrap(); + rows.filter_map(Result::ok).collect() + }; + assert!(cols.contains(&"circuit_breaker_disabled".to_string())); + } } diff --git a/src/pages/Endpoints/_components/EndpointCard.tsx b/src/pages/Endpoints/_components/EndpointCard.tsx index da818d4f..f28a32c6 100644 --- a/src/pages/Endpoints/_components/EndpointCard.tsx +++ b/src/pages/Endpoints/_components/EndpointCard.tsx @@ -451,10 +451,17 @@ export function EndpointCard({ // 共享 ["endpoint-health"] 查询(多卡片去重);展示运行期熔断态。 const { data: epHealth, dataUpdatedAt } = useEndpointHealth(); const health = epHealth?.find((h) => h.name === endpoint.name); - const circuitBadge = - health && health.circuit !== "closed" ? ( - - ) : null; + const circuitBadge = endpoint.circuitBreakerDisabled ? ( + + 免熔断 + + ) : health && health.circuit !== "closed" ? ( + + ) : null; const toggle = useMutation({ mutationFn: (v: boolean) => endpointApi.update(endpoint.id, { enabled: v }), diff --git a/src/pages/Endpoints/_components/EndpointForm.tsx b/src/pages/Endpoints/_components/EndpointForm.tsx index 1186447d..69b8f62f 100644 --- a/src/pages/Endpoints/_components/EndpointForm.tsx +++ b/src/pages/Endpoints/_components/EndpointForm.tsx @@ -42,6 +42,7 @@ interface FormState { activeModels: string[]; useProxy: boolean; fast: boolean; + circuitBreakerDisabled: boolean; headerOverrides: HeaderOverride[]; headerOverridesEnabled: boolean; remark: string; @@ -57,6 +58,7 @@ const EMPTY: FormState = { activeModels: [], useProxy: false, fast: false, + circuitBreakerDisabled: false, headerOverrides: [], headerOverridesEnabled: false, remark: "", @@ -100,6 +102,7 @@ export function EndpointForm({ open, onOpenChange, editing }: Props) { activeModels: editing.activeModels ?? [], useProxy: editing.useProxy ?? false, fast: editing.fast ?? false, + circuitBreakerDisabled: editing.circuitBreakerDisabled ?? false, headerOverrides: editing.headerOverrides ?? [], headerOverridesEnabled: editing.headerOverridesEnabled ?? false, remark: editing.remark, @@ -484,6 +487,20 @@ export function EndpointForm({ open, onOpenChange, editing }: Props) { /> ) : null} + +
+
+ + + 遇到 5xx/429/网络超时 时不跳闸,始终保留在路由候选列表中 + +
+ update({ circuitBreakerDisabled: v })} + aria-label="禁止熔断保护" + /> +
diff --git a/src/pages/Settings/_components/CircuitBreakerCard.tsx b/src/pages/Settings/_components/CircuitBreakerCard.tsx new file mode 100644 index 00000000..c9267b07 --- /dev/null +++ b/src/pages/Settings/_components/CircuitBreakerCard.tsx @@ -0,0 +1,70 @@ +import { ShieldAlert } from "lucide-react"; + +import { SettingCard, SettingRow } from "@/components/settings"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import type { AppConfig } from "@/services/modules/config"; + +export function CircuitBreakerCard({ + cfg, + save, +}: { + cfg: AppConfig; + save: (patch: Record) => Promise; +}) { + return ( + + +
+ 端点连续故障时自动跳闸并切换,冷却后自动探测恢复 + save({ circuitBreakerEnabled: String(v) })} + aria-label="启用熔断保护" + /> +
+
+ +
+ { + const val = Math.max(1, parseInt(e.target.value, 10) || 4); + if (val !== cfg.circuitBreakerFailureThreshold) { + save({ circuitBreakerFailureThreshold: String(val) }); + } + }} + /> + 次(Claude 入站自动放宽) +
+
+ +
+ { + const val = Math.max(5, parseInt(e.target.value, 10) || 60); + if (val !== cfg.circuitBreakerTimeout) { + save({ circuitBreakerTimeout: String(val) }); + } + }} + /> + 秒(5xx/网络错误的基础冷却) +
+
+

+ 提示:可在单个端点编辑弹窗中单独开启「禁止熔断」;修改后运行中的代理将自动重载生效。 +

+
+ ); +} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index ef259adb..3829e5fb 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -7,6 +7,7 @@ import { SettingsGrid } from "@/components/settings"; import { useAutostartEnabled } from "@/hooks/useAutostartEnabled"; import { configApi } from "@/services/modules/config"; import { AdvancedCard } from "./_components/AdvancedCard"; +import { CircuitBreakerCard } from "./_components/CircuitBreakerCard"; import { GeneralCard } from "./_components/GeneralCard"; import { PetCard } from "./_components/PetCard"; import { ProxyCard } from "./_components/ProxyCard"; @@ -71,6 +72,7 @@ export function Settings() { toggleAutostart={toggleAutostart} /> + ;