mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 01:25:33 +08:00
Feat/auto failover (#367)
* feat(db): add circuit breaker config table and provider proxy target APIs Add database support for auto-failover feature: - Add circuit_breaker_config table for storing failover thresholds - Add get/update_circuit_breaker_config methods in proxy DAO - Add reset_provider_health method for manual recovery - Add set_proxy_target and get_proxy_targets methods in providers DAO for managing multi-provider failover configuration * feat(proxy): implement circuit breaker and provider router for auto-failover Add core failover logic: - CircuitBreaker: Tracks provider health with three states: - Closed: Normal operation, requests pass through - Open: Circuit broken after consecutive failures, skip provider - HalfOpen: Testing recovery with limited requests - ProviderRouter: Routes requests across multiple providers with: - Health tracking and automatic failover - Configurable failure/success thresholds - Auto-disable proxy target after reaching failure threshold - Support for manual circuit breaker reset - Export new types in proxy module * feat(proxy): add failover Tauri commands and integrate with forwarder Expose failover functionality to frontend: - Add Tauri commands: get_proxy_targets, set_proxy_target, get_provider_health, reset_circuit_breaker, get/update_circuit_breaker_config, get_circuit_breaker_stats - Register all new commands in lib.rs invoke handler - Update forwarder with improved error handling and logging - Integrate ProviderRouter with proxy server startup - Add provider health tracking in request handlers * feat(frontend): add failover API layer and TanStack Query hooks Add frontend data layer for failover management: - Add failover.ts API: Tauri invoke wrappers for all failover commands - Add failover.ts query hooks: TanStack Query mutations and queries - useProxyTargets, useProviderHealth queries - useSetProxyTarget, useResetCircuitBreaker mutations - useCircuitBreakerConfig query and mutation - Update queries.ts with provider health query key - Update mutations.ts to invalidate health on provider changes - Add CircuitBreakerConfig and ProviderHealth types * feat(ui): add auto-failover configuration UI and provider health display Add comprehensive UI for failover management: Components: - ProviderHealthBadge: Display provider health status with color coding - CircuitBreakerConfigPanel: Configure failure/success thresholds, timeout duration, and error rate limits - AutoFailoverConfigPanel: Manage proxy targets with drag-and-drop priority ordering and individual enable/disable controls - ProxyPanel: Integrate failover tabs for unified proxy management Provider enhancements: - ProviderCard: Show health badge and proxy target indicator - ProviderActions: Add "Set as Proxy Target" action - EditProviderDialog: Add is_proxy_target toggle - ProviderList: Support proxy target filtering mode Other: - Update App.tsx routing for settings integration - Update useProviderActions hook with proxy target mutation - Fix ProviderList tests for updated component API * fix(usage): stabilize date range to prevent infinite re-renders * feat(backend): add tool version check command Add get_tool_versions command to check local and latest versions of Claude, Codex, and Gemini CLI tools: - Detect local installed versions via command line execution - Fetch latest versions from npm registry (Claude, Gemini) and GitHub releases API (Codex) - Return comprehensive version info including error details for uninstalled tools - Register command in Tauri invoke handler * style(ui): format accordion component code style Apply consistent code formatting to accordion component: - Convert double quotes to semicolons at line endings - Adjust indentation to 2-space standard - Align with project code style conventions * refactor(providers): update provider card styling to use theme tokens Replace hardcoded color classes with semantic design tokens: - Use bg-card, border-border, text-card-foreground instead of glass-card - Replace gray/white color literals with muted/foreground tokens - Change proxy target indicator color from purple to green - Improve hover states with border-border-active - Ensure consistent dark mode support via CSS variables * refactor(proxy): simplify auto-failover config panel structure Restructure AutoFailoverConfigPanel for better integration: - Remove internal Card wrapper and expansion toggle (now handled by parent) - Extract enabled state to props for external control - Simplify loading state display - Clean up redundant CardHeader/CardContent wrappers - ProxyPanel: reduce complexity by delegating to parent components * feat(settings): enhance settings page with accordion layout and tool versions Major settings page improvements: AboutSection: - Add local tool version detection (Claude, Codex, Gemini) - Display installed vs latest version comparison with visual indicators - Show update availability badges and environment check cards SettingsPage: - Reorganize advanced settings into collapsible accordion sections - Add proxy control panel with inline status toggle - Integrate auto-failover configuration with accordion UI - Add database and cost calculation config sections DirectorySettings & WindowSettings: - Minor styling adjustments for consistency settings.ts API: - Add getToolVersions() wrapper for new backend command * refactor(usage): restructure usage dashboard components Comprehensive usage statistics panel refactoring: UsageDashboard: - Reorganize layout with improved section headers - Add better loading states and empty state handling ModelStatsTable & ProviderStatsTable: - Minor styling updates for consistency ModelTestConfigPanel & PricingConfigPanel: - Simplify component structure - Remove redundant Card wrappers - Improve form field organization RequestLogTable: - Enhance table layout with better column sizing - Improve pagination controls UsageSummaryCards: - Update card styling with semantic tokens - Better responsive grid layout UsageTrendChart: - Refine chart container styling - Improve legend and tooltip display * chore(deps): add accordion and animation dependencies Package updates: - Add @radix-ui/react-accordion for collapsible sections - Add cmdk for command palette support - Add framer-motion for enhanced animations Tailwind config: - Add accordion-up/accordion-down animations - Update darkMode config to support both selector and class - Reorganize color and keyframe definitions for clarity * style(app): update header and app switcher styling App.tsx: - Replace glass-header with explicit bg-background/80 backdrop-blur - Update navigation button container to use bg-muted AppSwitcher: - Replace hardcoded gray colors with semantic muted/foreground tokens - Ensure consistent dark mode support via CSS variables - Add group class for better hover state transitions
This commit is contained in:
@@ -58,3 +58,116 @@ pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
|
||||
pub async fn get_migration_result() -> Result<bool, String> {
|
||||
Ok(crate::init_status::take_migration_success())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ToolVersion {
|
||||
name: String,
|
||||
version: Option<String>,
|
||||
latest_version: Option<String>, // 新增字段:最新版本
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||
use std::process::Command;
|
||||
|
||||
let tools = vec!["claude", "codex", "gemini"];
|
||||
let mut results = Vec::new();
|
||||
|
||||
// 用于获取远程版本的 client
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("cc-switch/1.0")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for tool in tools {
|
||||
// 1. 获取本地版本 (保持不变)
|
||||
let (local_version, local_error) = {
|
||||
let output = if cfg!(target_os = "windows") {
|
||||
Command::new("cmd")
|
||||
.args(["/C", &format!("{tool} --version")])
|
||||
.output()
|
||||
} else {
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!("{tool} --version"))
|
||||
.output()
|
||||
};
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
if out.status.success() {
|
||||
(
|
||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string()),
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
(
|
||||
None,
|
||||
Some(if err.is_empty() {
|
||||
"未安装或无法执行".to_string()
|
||||
} else {
|
||||
err
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => (None, Some(e.to_string())),
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 获取远程最新版本
|
||||
let latest_version = match tool {
|
||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||
"codex" => fetch_github_latest_release(&client, "openai/openai-python").await,
|
||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await, // 修正:使用 npm 官方包 @google/gemini-cli
|
||||
_ => None,
|
||||
};
|
||||
|
||||
results.push(ToolVersion {
|
||||
name: tool.to_string(),
|
||||
version: local_version,
|
||||
latest_version,
|
||||
error: local_error,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Helper function to fetch latest version from GitHub Release
|
||||
async fn fetch_github_latest_release(client: &reqwest::Client, repo: &str) -> Option<String> {
|
||||
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
|
||||
// GitHub API 需要 user-agent
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) => {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
json.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to fetch latest version from npm registry
|
||||
async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option<String> {
|
||||
let url = format!("https://registry.npmjs.org/{package}");
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) => {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
json.get("dist-tags")
|
||||
.and_then(|tags| tags.get("latest"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! 提供前端调用的 API 接口
|
||||
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::types::*;
|
||||
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
||||
use crate::store::AppState;
|
||||
|
||||
/// 启动代理服务器
|
||||
@@ -45,3 +47,115 @@ pub async fn update_proxy_config(
|
||||
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||
Ok(state.proxy_service.is_running().await)
|
||||
}
|
||||
|
||||
// ==================== 故障转移相关命令 ====================
|
||||
|
||||
/// 获取代理目标列表
|
||||
#[tauri::command]
|
||||
pub async fn get_proxy_targets(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
) -> Result<Vec<Provider>, String> {
|
||||
let db = &state.db;
|
||||
db.get_proxy_targets(&app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.map(|providers| providers.into_values().collect())
|
||||
}
|
||||
|
||||
/// 设置代理目标
|
||||
#[tauri::command]
|
||||
pub async fn set_proxy_target(
|
||||
state: tauri::State<'_, AppState>,
|
||||
provider_id: String,
|
||||
app_type: String,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let db = &state.db;
|
||||
|
||||
// 设置代理目标状态
|
||||
db.set_proxy_target(&provider_id, &app_type, enabled)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 如果是禁用代理目标,重置健康状态
|
||||
if !enabled {
|
||||
log::info!(
|
||||
"Resetting health status for provider {provider_id} (app: {app_type}) after disabling proxy target"
|
||||
);
|
||||
if let Err(e) = db.reset_provider_health(&provider_id, &app_type).await {
|
||||
log::warn!("Failed to reset provider health: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取供应商健康状态
|
||||
#[tauri::command]
|
||||
pub async fn get_provider_health(
|
||||
state: tauri::State<'_, AppState>,
|
||||
provider_id: String,
|
||||
app_type: String,
|
||||
) -> Result<ProviderHealth, String> {
|
||||
let db = &state.db;
|
||||
db.get_provider_health(&provider_id, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 重置熔断器
|
||||
#[tauri::command]
|
||||
pub async fn reset_circuit_breaker(
|
||||
state: tauri::State<'_, AppState>,
|
||||
provider_id: String,
|
||||
app_type: String,
|
||||
) -> Result<(), String> {
|
||||
// 重置数据库健康状态
|
||||
let db = &state.db;
|
||||
db.update_provider_health(&provider_id, &app_type, true, None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 注意:熔断器状态在内存中,重启代理服务器后会重置
|
||||
// 如果代理服务器正在运行,需要通知它重置熔断器
|
||||
// 目前先通过数据库重置健康状态,熔断器会在下次超时后自动尝试半开
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取熔断器配置
|
||||
#[tauri::command]
|
||||
pub async fn get_circuit_breaker_config(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<CircuitBreakerConfig, String> {
|
||||
let db = &state.db;
|
||||
db.get_circuit_breaker_config()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新熔断器配置
|
||||
#[tauri::command]
|
||||
pub async fn update_circuit_breaker_config(
|
||||
state: tauri::State<'_, AppState>,
|
||||
config: CircuitBreakerConfig,
|
||||
) -> Result<(), String> {
|
||||
let db = &state.db;
|
||||
db.update_circuit_breaker_config(&config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取熔断器统计信息(仅当代理服务器运行时)
|
||||
#[tauri::command]
|
||||
pub async fn get_circuit_breaker_stats(
|
||||
state: tauri::State<'_, AppState>,
|
||||
provider_id: String,
|
||||
app_type: String,
|
||||
) -> Result<Option<CircuitBreakerStats>, String> {
|
||||
// 这个功能需要访问运行中的代理服务器的内存状态
|
||||
// 目前先返回 None,后续可以通过 ProxyService 暴露接口来实现
|
||||
let _ = (state, provider_id, app_type);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::format_skill_error;
|
||||
use crate::services::skill::SkillState;
|
||||
use crate::services::{Skill, SkillRepo, SkillService};
|
||||
@@ -9,46 +8,15 @@ use tauri::State;
|
||||
|
||||
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||
|
||||
/// 解析 app 参数为 AppType
|
||||
fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||
match app.to_lowercase().as_str() {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 app_type 生成带前缀的 skill key
|
||||
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
|
||||
let prefix = match app_type {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
};
|
||||
format!("{prefix}:{directory}")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_skills(
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<Skill>, String> {
|
||||
get_skills_for_app("claude".to_string(), service, app_state).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_skills_for_app(
|
||||
app: String,
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<Skill>, String> {
|
||||
let app_type = parse_app_type(&app)?;
|
||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
|
||||
let skills = service
|
||||
.0
|
||||
.list_skills(repos)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -58,19 +26,16 @@ pub async fn get_skills_for_app(
|
||||
let existing_states = app_state.db.get_skills().unwrap_or_default();
|
||||
|
||||
for skill in &skills {
|
||||
if skill.installed {
|
||||
let key = get_skill_key(&app_type, &skill.directory);
|
||||
if !existing_states.contains_key(&key) {
|
||||
// 本地有该 skill,但数据库中没有记录,自动添加
|
||||
if let Err(e) = app_state.db.update_skill_state(
|
||||
&key,
|
||||
&SkillState {
|
||||
installed: true,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
) {
|
||||
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
|
||||
}
|
||||
if skill.installed && !existing_states.contains_key(&skill.directory) {
|
||||
// 本地有该 skill,但数据库中没有记录,自动添加
|
||||
if let Err(e) = app_state.db.update_skill_state(
|
||||
&skill.directory,
|
||||
&SkillState {
|
||||
installed: true,
|
||||
installed_at: Utc::now(),
|
||||
},
|
||||
) {
|
||||
log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,23 +49,11 @@ pub async fn install_skill(
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
install_skill_for_app("claude".to_string(), directory, service, app_state).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_skill_for_app(
|
||||
app: String,
|
||||
directory: String,
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = parse_app_type(&app)?;
|
||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
// 先在不持有写锁的情况下收集仓库与技能信息
|
||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||
|
||||
let skills = service
|
||||
.0
|
||||
.list_skills(repos)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -140,16 +93,16 @@ pub async fn install_skill_for_app(
|
||||
};
|
||||
|
||||
service
|
||||
.0
|
||||
.install_skill(directory.clone(), repo)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let key = get_skill_key(&app_type, &directory);
|
||||
app_state
|
||||
.db
|
||||
.update_skill_state(
|
||||
&key,
|
||||
&directory,
|
||||
&SkillState {
|
||||
installed: true,
|
||||
installed_at: Utc::now(),
|
||||
@@ -166,29 +119,16 @@ pub fn uninstall_skill(
|
||||
service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill_for_app(
|
||||
app: String,
|
||||
directory: String,
|
||||
_service: State<'_, SkillServiceState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = parse_app_type(&app)?;
|
||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
service
|
||||
.0
|
||||
.uninstall_skill(directory.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Remove from database by setting installed = false
|
||||
let key = get_skill_key(&app_type, &directory);
|
||||
app_state
|
||||
.db
|
||||
.update_skill_state(
|
||||
&key,
|
||||
&directory,
|
||||
&SkillState {
|
||||
installed: false,
|
||||
installed_at: Utc::now(),
|
||||
|
||||
@@ -357,6 +357,116 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置单个供应商的代理目标状态(支持多个代理目标)
|
||||
pub async fn set_proxy_target(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"UPDATE providers SET is_proxy_target = ?1
|
||||
WHERE id = ?2 AND app_type = ?3",
|
||||
params![if enabled { 1 } else { 0 }, provider_id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取指定应用类型的所有代理目标供应商(按 sort_index 排序)
|
||||
pub async fn get_proxy_targets(
|
||||
&self,
|
||||
app_type: &str,
|
||||
) -> Result<IndexMap<String, Provider>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, is_proxy_target
|
||||
FROM providers WHERE app_type = ?1 AND is_proxy_target = 1
|
||||
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let provider_iter = stmt
|
||||
.query_map(params![app_type], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let settings_config_str: String = row.get(2)?;
|
||||
let website_url: Option<String> = row.get(3)?;
|
||||
let category: Option<String> = row.get(4)?;
|
||||
let created_at: Option<i64> = row.get(5)?;
|
||||
let sort_index: Option<usize> = row.get(6)?;
|
||||
let notes: Option<String> = row.get(7)?;
|
||||
let icon: Option<String> = row.get(8)?;
|
||||
let icon_color: Option<String> = row.get(9)?;
|
||||
let meta_str: String = row.get(10)?;
|
||||
let is_proxy_target: bool = row.get(11)?;
|
||||
|
||||
let settings_config =
|
||||
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
||||
|
||||
Ok((
|
||||
id,
|
||||
Provider {
|
||||
id: "".to_string(),
|
||||
name,
|
||||
settings_config,
|
||||
website_url,
|
||||
category,
|
||||
created_at,
|
||||
sort_index,
|
||||
notes,
|
||||
meta: Some(meta),
|
||||
icon,
|
||||
icon_color,
|
||||
is_proxy_target: Some(is_proxy_target),
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut providers = IndexMap::new();
|
||||
for provider_res in provider_iter {
|
||||
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
provider.id = id.clone();
|
||||
|
||||
// 加载 endpoints
|
||||
let mut stmt_endpoints = conn.prepare(
|
||||
"SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let endpoints_iter = stmt_endpoints
|
||||
.query_map(params![id, app_type], |row| {
|
||||
let url: String = row.get(0)?;
|
||||
let added_at: Option<i64> = row.get(1)?;
|
||||
Ok((
|
||||
url,
|
||||
crate::settings::CustomEndpoint {
|
||||
url: "".to_string(),
|
||||
added_at: added_at.unwrap_or(0),
|
||||
last_used: None,
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut custom_endpoints = HashMap::new();
|
||||
for ep_res in endpoints_iter {
|
||||
let (url, mut ep) = ep_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
ep.url = url.clone();
|
||||
custom_endpoints.insert(url, ep);
|
||||
}
|
||||
|
||||
if let Some(meta) = &mut provider.meta {
|
||||
meta.custom_endpoints = custom_endpoints;
|
||||
}
|
||||
|
||||
providers.insert(id, provider);
|
||||
}
|
||||
|
||||
Ok(providers)
|
||||
}
|
||||
|
||||
/// 获取所有活跃的代理目标
|
||||
pub fn get_all_proxy_targets(&self) -> Result<Vec<(String, String, String)>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
@@ -165,6 +165,25 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重置Provider健康状态
|
||||
pub async fn reset_provider_health(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
log::debug!("Reset health status for provider {provider_id} (app: {app_type})");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Proxy Usage (可选) ====================
|
||||
|
||||
/// 记录代理使用统计
|
||||
@@ -241,4 +260,62 @@ impl Database {
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
// ==================== Circuit Breaker Config ====================
|
||||
|
||||
/// 获取熔断器配置
|
||||
pub async fn get_circuit_breaker_config(
|
||||
&self,
|
||||
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let config = conn
|
||||
.query_row(
|
||||
"SELECT failure_threshold, success_threshold, timeout_seconds,
|
||||
error_rate_threshold, min_requests
|
||||
FROM circuit_breaker_config WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||
failure_threshold: row.get::<_, i32>(0)? as u32,
|
||||
success_threshold: row.get::<_, i32>(1)? as u32,
|
||||
timeout_seconds: row.get::<_, i64>(2)? as u64,
|
||||
error_rate_threshold: row.get(3)?,
|
||||
min_requests: row.get::<_, i32>(4)? as u32,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 更新熔断器配置
|
||||
pub async fn update_circuit_breaker_config(
|
||||
&self,
|
||||
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute(
|
||||
"UPDATE circuit_breaker_config
|
||||
SET failure_threshold = ?1,
|
||||
success_threshold = ?2,
|
||||
timeout_seconds = ?3,
|
||||
error_rate_threshold = ?4,
|
||||
min_requests = ?5,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1",
|
||||
rusqlite::params![
|
||||
config.failure_threshold as i32,
|
||||
config.success_threshold as i32,
|
||||
config.timeout_seconds as i64,
|
||||
config.error_rate_threshold,
|
||||
config.min_requests as i32,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,22 +13,18 @@ impl Database {
|
||||
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
|
||||
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let skill_iter = stmt
|
||||
.query_map([], |row| {
|
||||
let directory: String = row.get(0)?;
|
||||
let app_type: String = row.get(1)?;
|
||||
let installed: bool = row.get(2)?;
|
||||
let installed_at_ts: i64 = row.get(3)?;
|
||||
let key: String = row.get(0)?;
|
||||
let installed: bool = row.get(1)?;
|
||||
let installed_at_ts: i64 = row.get(2)?;
|
||||
|
||||
let installed_at =
|
||||
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
|
||||
|
||||
// 构建复合 key:"app_type:directory"
|
||||
let key = format!("{app_type}:{directory}");
|
||||
|
||||
Ok((
|
||||
key,
|
||||
SkillState {
|
||||
@@ -48,21 +44,11 @@ impl Database {
|
||||
}
|
||||
|
||||
/// 更新 Skill 状态
|
||||
/// key 格式为 "app_type:directory"
|
||||
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
||||
// 解析 key
|
||||
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
||||
let (app, dir) = key.split_at(idx);
|
||||
(app, &dir[1..]) // 跳过冒号
|
||||
} else {
|
||||
// 向后兼容:如果没有前缀,默认为 claude
|
||||
("claude", key)
|
||||
};
|
||||
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![directory, app_type, state.installed, state.installed_at.timestamp()],
|
||||
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
||||
params![key, state.installed, state.installed_at.timestamp()],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
|
||||
@@ -96,11 +96,9 @@ impl Database {
|
||||
// 5. Skills 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS skills (
|
||||
directory TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
key TEXT PRIMARY KEY,
|
||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (directory, app_type)
|
||||
installed_at INTEGER NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)
|
||||
@@ -338,6 +336,28 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 15. Circuit Breaker Config 表 (熔断器配置)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS circuit_breaker_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
failure_threshold INTEGER NOT NULL DEFAULT 5,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 60,
|
||||
error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
||||
min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 插入默认熔断器配置
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO circuit_breaker_config (id) VALUES (1)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -371,9 +391,7 @@ impl Database {
|
||||
Self::set_user_version(conn, 1)?;
|
||||
}
|
||||
1 => {
|
||||
log::info!(
|
||||
"迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)"
|
||||
);
|
||||
log::info!("迁移数据库从 v1 到 v2(添加使用统计表和完整字段)");
|
||||
Self::migrate_v1_to_v2(conn)?;
|
||||
Self::set_user_version(conn, 2)?;
|
||||
}
|
||||
@@ -462,7 +480,7 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v1 -> v2 迁移:添加使用统计表和完整字段,重构 skills 表
|
||||
/// v1 -> v2 迁移:添加使用统计表和完整字段
|
||||
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
|
||||
// providers 表字段
|
||||
Self::add_column_if_missing(
|
||||
@@ -558,82 +576,6 @@ impl Database {
|
||||
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
||||
Self::seed_model_pricing(conn)?;
|
||||
|
||||
// 重构 skills 表(添加 app_type 字段)
|
||||
Self::migrate_skills_table(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键
|
||||
fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
|
||||
// 检查是否已经是新表结构
|
||||
if Self::has_column(conn, "skills", "app_type")? {
|
||||
log::info!("skills 表已经包含 app_type 字段,跳过迁移");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("开始迁移 skills 表...");
|
||||
|
||||
// 1. 重命名旧表
|
||||
conn.execute("ALTER TABLE skills RENAME TO skills_old", [])
|
||||
.map_err(|e| AppError::Database(format!("重命名旧 skills 表失败: {e}")))?;
|
||||
|
||||
// 2. 创建新表
|
||||
conn.execute(
|
||||
"CREATE TABLE skills (
|
||||
directory TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (directory, app_type)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
|
||||
|
||||
// 3. 迁移数据:解析 key 格式(如 "claude:my-skill" 或 "codex:foo")
|
||||
// 旧数据如果没有前缀,默认为 claude
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT key, installed, installed_at FROM skills_old")
|
||||
.map_err(|e| AppError::Database(format!("查询旧 skills 数据失败: {e}")))?;
|
||||
|
||||
let old_skills: Vec<(String, bool, i64)> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, bool>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("读取旧 skills 数据失败: {e}")))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| AppError::Database(format!("解析旧 skills 数据失败: {e}")))?;
|
||||
|
||||
let count = old_skills.len();
|
||||
|
||||
for (key, installed, installed_at) in old_skills {
|
||||
// 解析 key: "app:directory" 或 "directory"(默认 claude)
|
||||
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
||||
let (app, dir) = key.split_at(idx);
|
||||
(app.to_string(), dir[1..].to_string()) // 跳过冒号
|
||||
} else {
|
||||
("claude".to_string(), key.clone())
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![directory, app_type, installed, installed_at],
|
||||
)
|
||||
.map_err(|e| {
|
||||
AppError::Database(format!("迁移 skill {key} 到新表失败: {e}"))
|
||||
})?;
|
||||
}
|
||||
|
||||
// 4. 删除旧表
|
||||
conn.execute("DROP TABLE skills_old", [])
|
||||
.map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
|
||||
|
||||
log::info!("skills 表迁移完成,共迁移 {count} 条记录");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -635,11 +635,8 @@ pub fn run() {
|
||||
commands::restore_env_backup,
|
||||
// Skill management
|
||||
commands::get_skills,
|
||||
commands::get_skills_for_app,
|
||||
commands::install_skill,
|
||||
commands::install_skill_for_app,
|
||||
commands::uninstall_skill,
|
||||
commands::uninstall_skill_for_app,
|
||||
commands::get_skill_repos,
|
||||
commands::add_skill_repo,
|
||||
commands::remove_skill_repo,
|
||||
@@ -653,6 +650,14 @@ pub fn run() {
|
||||
commands::get_proxy_config,
|
||||
commands::update_proxy_config,
|
||||
commands::is_proxy_running,
|
||||
// Proxy failover commands
|
||||
commands::get_proxy_targets,
|
||||
commands::set_proxy_target,
|
||||
commands::get_provider_health,
|
||||
commands::reset_circuit_breaker,
|
||||
commands::get_circuit_breaker_config,
|
||||
commands::update_circuit_breaker_config,
|
||||
commands::get_circuit_breaker_stats,
|
||||
// Usage statistics
|
||||
commands::get_usage_summary,
|
||||
commands::get_usage_trends,
|
||||
@@ -671,6 +676,7 @@ pub fn run() {
|
||||
commands::save_model_test_config,
|
||||
commands::get_model_test_logs,
|
||||
commands::cleanup_model_test_logs,
|
||||
commands::get_tool_versions,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
//! 熔断器模块
|
||||
//!
|
||||
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 熔断器状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CircuitState {
|
||||
/// 关闭状态 - 正常工作
|
||||
Closed,
|
||||
/// 打开状态 - 熔断激活,拒绝请求
|
||||
Open,
|
||||
/// 半开状态 - 尝试恢复,允许部分请求通过
|
||||
HalfOpen,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CircuitState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CircuitState::Closed => write!(f, "closed"),
|
||||
CircuitState::Open => write!(f, "open"),
|
||||
CircuitState::HalfOpen => write!(f, "half_open"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CircuitBreakerConfig {
|
||||
/// 失败阈值 - 连续失败多少次后打开熔断器
|
||||
pub failure_threshold: u32,
|
||||
/// 成功阈值 - 半开状态下成功多少次后关闭熔断器
|
||||
pub success_threshold: u32,
|
||||
/// 超时时间 - 熔断器打开后多久尝试半开(秒)
|
||||
pub timeout_seconds: u64,
|
||||
/// 错误率阈值 - 错误率超过此值时打开熔断器 (0.0-1.0)
|
||||
pub error_rate_threshold: f64,
|
||||
/// 最小请求数 - 计算错误率前的最小请求数
|
||||
pub min_requests: u32,
|
||||
}
|
||||
|
||||
impl Default for CircuitBreakerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
failure_threshold: 5,
|
||||
success_threshold: 2,
|
||||
timeout_seconds: 60,
|
||||
error_rate_threshold: 0.5,
|
||||
min_requests: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器实例
|
||||
pub struct CircuitBreaker {
|
||||
/// 当前状态
|
||||
state: Arc<RwLock<CircuitState>>,
|
||||
/// 连续失败计数
|
||||
consecutive_failures: Arc<AtomicU32>,
|
||||
/// 连续成功计数(半开状态)
|
||||
consecutive_successes: Arc<AtomicU32>,
|
||||
/// 总请求计数
|
||||
total_requests: Arc<AtomicU32>,
|
||||
/// 失败请求计数
|
||||
failed_requests: Arc<AtomicU32>,
|
||||
/// 上次打开时间
|
||||
last_opened_at: Arc<RwLock<Option<Instant>>>,
|
||||
/// 配置
|
||||
config: CircuitBreakerConfig,
|
||||
}
|
||||
|
||||
impl CircuitBreaker {
|
||||
/// 创建新的熔断器
|
||||
pub fn new(config: CircuitBreakerConfig) -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(CircuitState::Closed)),
|
||||
consecutive_failures: Arc::new(AtomicU32::new(0)),
|
||||
consecutive_successes: Arc::new(AtomicU32::new(0)),
|
||||
total_requests: Arc::new(AtomicU32::new(0)),
|
||||
failed_requests: Arc::new(AtomicU32::new(0)),
|
||||
last_opened_at: Arc::new(RwLock::new(None)),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否允许请求通过
|
||||
pub async fn allow_request(&self) -> bool {
|
||||
let state = *self.state.read().await;
|
||||
|
||||
match state {
|
||||
CircuitState::Closed => true,
|
||||
CircuitState::Open => {
|
||||
// 检查是否应该尝试半开
|
||||
if let Some(opened_at) = *self.last_opened_at.read().await {
|
||||
if opened_at.elapsed().as_secs() >= self.config.timeout_seconds {
|
||||
log::info!(
|
||||
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
|
||||
);
|
||||
self.transition_to_half_open().await;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
CircuitState::HalfOpen => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录成功
|
||||
pub async fn record_success(&self) {
|
||||
let state = *self.state.read().await;
|
||||
|
||||
// 重置失败计数
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
match state {
|
||||
CircuitState::HalfOpen => {
|
||||
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
log::debug!(
|
||||
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
|
||||
successes,
|
||||
self.config.success_threshold
|
||||
);
|
||||
|
||||
if successes >= self.config.success_threshold {
|
||||
log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)");
|
||||
self.transition_to_closed().await;
|
||||
}
|
||||
}
|
||||
CircuitState::Closed => {
|
||||
log::debug!("Circuit breaker Closed: request succeeded");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录失败
|
||||
pub async fn record_failure(&self) {
|
||||
let state = *self.state.read().await;
|
||||
|
||||
// 更新计数器
|
||||
let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||
self.failed_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
// 重置成功计数
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
|
||||
log::debug!(
|
||||
"Circuit breaker {:?}: {} consecutive failures (threshold: {})",
|
||||
state,
|
||||
failures,
|
||||
self.config.failure_threshold
|
||||
);
|
||||
|
||||
// 检查是否应该打开熔断器
|
||||
match state {
|
||||
CircuitState::Closed | CircuitState::HalfOpen => {
|
||||
// 检查连续失败次数
|
||||
if failures >= self.config.failure_threshold {
|
||||
log::warn!(
|
||||
"Circuit breaker opening due to {} consecutive failures (threshold: {})",
|
||||
failures,
|
||||
self.config.failure_threshold
|
||||
);
|
||||
self.transition_to_open().await;
|
||||
} else {
|
||||
// 检查错误率
|
||||
let total = self.total_requests.load(Ordering::SeqCst);
|
||||
let failed = self.failed_requests.load(Ordering::SeqCst);
|
||||
|
||||
if total >= self.config.min_requests {
|
||||
let error_rate = failed as f64 / total as f64;
|
||||
log::debug!(
|
||||
"Circuit breaker error rate: {:.2}% ({}/{} requests)",
|
||||
error_rate * 100.0,
|
||||
failed,
|
||||
total
|
||||
);
|
||||
|
||||
if error_rate >= self.config.error_rate_threshold {
|
||||
log::warn!(
|
||||
"Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)",
|
||||
error_rate * 100.0,
|
||||
self.config.error_rate_threshold * 100.0
|
||||
);
|
||||
self.transition_to_open().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前状态
|
||||
pub async fn get_state(&self) -> CircuitState {
|
||||
*self.state.read().await
|
||||
}
|
||||
|
||||
/// 获取统计信息
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_stats(&self) -> CircuitBreakerStats {
|
||||
CircuitBreakerStats {
|
||||
state: *self.state.read().await,
|
||||
consecutive_failures: self.consecutive_failures.load(Ordering::SeqCst),
|
||||
consecutive_successes: self.consecutive_successes.load(Ordering::SeqCst),
|
||||
total_requests: self.total_requests.load(Ordering::SeqCst),
|
||||
failed_requests: self.failed_requests.load(Ordering::SeqCst),
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置熔断器(手动恢复)
|
||||
#[allow(dead_code)]
|
||||
pub async fn reset(&self) {
|
||||
log::info!("Circuit breaker manually reset to Closed state");
|
||||
self.transition_to_closed().await;
|
||||
}
|
||||
|
||||
/// 转换到打开状态
|
||||
async fn transition_to_open(&self) {
|
||||
*self.state.write().await = CircuitState::Open;
|
||||
*self.last_opened_at.write().await = Some(Instant::now());
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 转换到半开状态
|
||||
async fn transition_to_half_open(&self) {
|
||||
*self.state.write().await = CircuitState::HalfOpen;
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 转换到关闭状态
|
||||
async fn transition_to_closed(&self) {
|
||||
*self.state.write().await = CircuitState::Closed;
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
// 重置计数器
|
||||
self.total_requests.store(0, Ordering::SeqCst);
|
||||
self.failed_requests.store(0, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器统计信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CircuitBreakerStats {
|
||||
pub state: CircuitState,
|
||||
pub consecutive_failures: u32,
|
||||
pub consecutive_successes: u32,
|
||||
pub total_requests: u32,
|
||||
pub failed_requests: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circuit_breaker_closed_to_open() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 3,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 初始状态应该是关闭
|
||||
assert_eq!(breaker.get_state().await, CircuitState::Closed);
|
||||
assert!(breaker.allow_request().await);
|
||||
|
||||
// 记录 3 次失败
|
||||
for _ in 0..3 {
|
||||
breaker.record_failure().await;
|
||||
}
|
||||
|
||||
// 应该转换到打开状态
|
||||
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
||||
assert!(!breaker.allow_request().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circuit_breaker_half_open_to_closed() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 2,
|
||||
success_threshold: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 打开熔断器
|
||||
breaker.record_failure().await;
|
||||
breaker.record_failure().await;
|
||||
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
||||
|
||||
// 手动转换到半开状态
|
||||
breaker.transition_to_half_open().await;
|
||||
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
|
||||
|
||||
// 记录 2 次成功
|
||||
breaker.record_success().await;
|
||||
breaker.record_success().await;
|
||||
|
||||
// 应该转换到关闭状态
|
||||
assert_eq!(breaker.get_state().await, CircuitState::Closed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circuit_breaker_reset() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 打开熔断器
|
||||
breaker.record_failure().await;
|
||||
breaker.record_failure().await;
|
||||
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
||||
|
||||
// 重置
|
||||
breaker.reset().await;
|
||||
assert_eq!(breaker.get_state().await, CircuitState::Closed);
|
||||
assert!(breaker.allow_request().await);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
use super::{
|
||||
error::*,
|
||||
provider_router::ProviderRouter as NewProviderRouter,
|
||||
providers::{get_adapter, ProviderAdapter},
|
||||
router::ProviderRouter,
|
||||
types::ProxyStatus,
|
||||
ProxyError,
|
||||
};
|
||||
@@ -18,9 +18,11 @@ use tokio::sync::RwLock;
|
||||
|
||||
pub struct RequestForwarder {
|
||||
client: Client,
|
||||
router: ProviderRouter,
|
||||
router: Arc<NewProviderRouter>,
|
||||
#[allow(dead_code)]
|
||||
max_retries: u8,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
}
|
||||
|
||||
impl RequestForwarder {
|
||||
@@ -29,6 +31,7 @@ impl RequestForwarder {
|
||||
timeout_secs: u64,
|
||||
max_retries: u8,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
) -> Self {
|
||||
let mut client_builder = Client::builder();
|
||||
if timeout_secs > 0 {
|
||||
@@ -41,13 +44,14 @@ impl RequestForwarder {
|
||||
|
||||
Self {
|
||||
client,
|
||||
router: ProviderRouter::new(db),
|
||||
router: Arc::new(NewProviderRouter::new(db)),
|
||||
max_retries,
|
||||
status,
|
||||
current_providers,
|
||||
}
|
||||
}
|
||||
|
||||
/// 转发请求(带重试和故障转移)
|
||||
/// 转发请求(带故障转移)
|
||||
pub async fn forward_with_retry(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
@@ -55,21 +59,39 @@ impl RequestForwarder {
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<Response, ProxyError> {
|
||||
let mut failed_ids = Vec::new();
|
||||
let mut failover_happened = false;
|
||||
|
||||
// 获取适配器
|
||||
let adapter = get_adapter(app_type);
|
||||
let app_type_str = app_type.as_str();
|
||||
|
||||
for attempt in 0..self.max_retries {
|
||||
// 选择Provider
|
||||
let provider = self.router.select_provider(app_type, &failed_ids).await?;
|
||||
// 使用新的 ProviderRouter 选择所有可用供应商
|
||||
let providers = self
|
||||
.router
|
||||
.select_providers(app_type_str)
|
||||
.await
|
||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||
|
||||
log::debug!(
|
||||
"尝试 {} - 使用Provider: {} ({})",
|
||||
if providers.is_empty() {
|
||||
return Err(ProxyError::NoAvailableProvider);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] 故障转移链: {} 个可用供应商",
|
||||
app_type_str,
|
||||
providers.len()
|
||||
);
|
||||
|
||||
let mut last_error = None;
|
||||
let mut failover_happened = false;
|
||||
|
||||
// 依次尝试每个供应商
|
||||
for (attempt, provider) in providers.iter().enumerate() {
|
||||
log::info!(
|
||||
"[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})",
|
||||
app_type_str,
|
||||
attempt + 1,
|
||||
providers.len(),
|
||||
provider.name,
|
||||
provider.id
|
||||
provider.sort_index.unwrap_or(999999)
|
||||
);
|
||||
|
||||
// 更新状态中的当前Provider信息
|
||||
@@ -88,16 +110,29 @@ impl RequestForwarder {
|
||||
|
||||
// 转发请求
|
||||
match self
|
||||
.forward(&provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let _latency = start.elapsed().as_millis() as u64;
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
|
||||
// 成功:更新健康状态
|
||||
self.router
|
||||
.update_health(&provider, app_type, true, None)
|
||||
.await;
|
||||
// 成功:记录成功并更新熔断器
|
||||
if let Err(e) = self
|
||||
.router
|
||||
.record_result(&provider.id, app_type_str, true, None)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to record success: {e}");
|
||||
}
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
{
|
||||
let mut current_providers = self.current_providers.write().await;
|
||||
current_providers.insert(
|
||||
app_type_str.to_string(),
|
||||
(provider.id.clone(), provider.name.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
// 更新成功统计
|
||||
{
|
||||
@@ -106,6 +141,12 @@ impl RequestForwarder {
|
||||
status.last_error = None;
|
||||
if failover_happened {
|
||||
status.failover_count += 1;
|
||||
log::info!(
|
||||
"[{}] 故障转移成功!切换到 Provider: {} (耗时: {}ms)",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
latency
|
||||
);
|
||||
}
|
||||
// 重新计算成功率
|
||||
if status.total_requests > 0 {
|
||||
@@ -115,23 +156,33 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] 请求成功 - Provider: {} - {}ms",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
latency
|
||||
);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
Err(e) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
|
||||
// 失败:分类错误
|
||||
// 失败:记录失败并更新熔断器
|
||||
if let Err(record_err) = self
|
||||
.router
|
||||
.record_result(&provider.id, app_type_str, false, Some(e.to_string()))
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to record failure: {record_err}");
|
||||
}
|
||||
|
||||
// 分类错误
|
||||
let category = self.categorize_proxy_error(&e);
|
||||
|
||||
match category {
|
||||
ErrorCategory::Retryable => {
|
||||
// 可重试:更新健康状态,添加到失败列表
|
||||
self.router
|
||||
.update_health(&provider, app_type, false, Some(e.to_string()))
|
||||
.await;
|
||||
failed_ids.push(provider.id.clone());
|
||||
|
||||
// 更新错误信息
|
||||
// 可重试:更新错误信息,继续尝试下一个供应商
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.last_error =
|
||||
@@ -139,15 +190,19 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"请求失败(可重试): Provider {} - {} - {}ms",
|
||||
"[{}] Provider {} 失败(可重试): {} - {}ms",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
e,
|
||||
latency
|
||||
);
|
||||
|
||||
last_error = Some(e);
|
||||
// 继续尝试下一个供应商
|
||||
continue;
|
||||
}
|
||||
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
|
||||
// 不可重试:更新失败统计并返回
|
||||
// 不可重试:直接返回错误
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
@@ -158,7 +213,12 @@ impl RequestForwarder {
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
log::error!("请求失败(不可重试): {e}");
|
||||
log::error!(
|
||||
"[{}] Provider {} 失败(不可重试): {}",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
@@ -166,18 +226,24 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败
|
||||
// 所有供应商都失败了
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some("已达到最大重试次数".to_string());
|
||||
status.last_error = Some("所有供应商都失败".to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate =
|
||||
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
Err(ProxyError::MaxRetriesExceeded)
|
||||
log::error!(
|
||||
"[{}] 所有 {} 个供应商都失败了",
|
||||
app_type_str,
|
||||
providers.len()
|
||||
);
|
||||
|
||||
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
|
||||
}
|
||||
|
||||
/// 转发单个请求(使用适配器)
|
||||
|
||||
@@ -322,6 +322,7 @@ pub async fn handle_messages(
|
||||
config.request_timeout,
|
||||
config.max_retries,
|
||||
state.status.clone(),
|
||||
state.current_providers.clone(),
|
||||
);
|
||||
|
||||
let response = forwarder
|
||||
@@ -641,6 +642,7 @@ pub async fn handle_gemini(
|
||||
config.request_timeout,
|
||||
config.max_retries,
|
||||
state.status.clone(),
|
||||
state.current_providers.clone(),
|
||||
);
|
||||
|
||||
// 提取完整的路径和查询参数
|
||||
@@ -806,6 +808,7 @@ pub async fn handle_responses(
|
||||
config.request_timeout,
|
||||
config.max_retries,
|
||||
state.status.clone(),
|
||||
state.current_providers.clone(),
|
||||
);
|
||||
|
||||
let response = forwarder
|
||||
@@ -985,6 +988,7 @@ pub async fn handle_chat_completions(
|
||||
config.request_timeout,
|
||||
config.max_retries,
|
||||
state.status.clone(),
|
||||
state.current_providers.clone(),
|
||||
);
|
||||
|
||||
let response = forwarder
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
//!
|
||||
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
|
||||
|
||||
pub mod circuit_breaker;
|
||||
pub mod error;
|
||||
mod forwarder;
|
||||
mod handlers;
|
||||
mod health;
|
||||
pub mod provider_router;
|
||||
pub mod providers;
|
||||
pub mod response_handler;
|
||||
mod router;
|
||||
@@ -16,8 +18,14 @@ pub mod usage;
|
||||
|
||||
// 公开导出给外部使用(commands, services等模块需要)
|
||||
#[allow(unused_imports)]
|
||||
pub use circuit_breaker::{
|
||||
CircuitBreaker, CircuitBreakerConfig, CircuitBreakerStats, CircuitState,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use error::ProxyError;
|
||||
#[allow(unused_imports)]
|
||||
pub use provider_router::ProviderRouter;
|
||||
#[allow(unused_imports)]
|
||||
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
|
||||
#[allow(unused_imports)]
|
||||
pub use session::{ClientFormat, ProxySession};
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! 供应商路由器模块
|
||||
//!
|
||||
//! 负责选择和管理代理目标供应商,实现智能故障转移
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::circuit_breaker::CircuitBreaker;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 供应商路由器
|
||||
pub struct ProviderRouter {
|
||||
/// 数据库连接
|
||||
db: Arc<Database>,
|
||||
/// 熔断器管理器 - key 格式: "app_type:provider_id"
|
||||
circuit_breakers: Arc<RwLock<HashMap<String, Arc<CircuitBreaker>>>>,
|
||||
}
|
||||
|
||||
impl ProviderRouter {
|
||||
/// 创建新的供应商路由器
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择可用的供应商(支持故障转移)
|
||||
/// 返回按优先级排序的可用供应商列表
|
||||
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||
// 1. 获取所有启用代理的供应商
|
||||
let providers = self.db.get_proxy_targets(app_type).await?;
|
||||
|
||||
if providers.is_empty() {
|
||||
return Err(AppError::Config(
|
||||
"No proxy target providers configured".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"Found {} proxy target providers for app_type: {}",
|
||||
providers.len(),
|
||||
app_type
|
||||
);
|
||||
|
||||
// 2. 按 sort_index 排序(已经在数据库查询中排序了)
|
||||
let sorted_providers: Vec<_> = providers.into_values().collect();
|
||||
|
||||
// 3. 过滤可用的供应商(检查熔断器状态)
|
||||
let mut available_providers = Vec::new();
|
||||
|
||||
for provider in sorted_providers {
|
||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
|
||||
if breaker.allow_request().await {
|
||||
log::debug!(
|
||||
"Provider {} is available (circuit state: {:?})",
|
||||
provider.id,
|
||||
breaker.get_state().await
|
||||
);
|
||||
available_providers.push(provider);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Provider {} is unavailable (circuit breaker open)",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if available_providers.is_empty() {
|
||||
return Err(AppError::Config(
|
||||
"All proxy target providers are unavailable (circuit breakers open)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Selected {} available providers for failover chain",
|
||||
available_providers.len()
|
||||
);
|
||||
|
||||
Ok(available_providers)
|
||||
}
|
||||
|
||||
/// 记录供应商请求结果
|
||||
pub async fn record_result(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
success: bool,
|
||||
error_msg: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
// 1. 更新熔断器状态
|
||||
let circuit_key = format!("{app_type}:{provider_id}");
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
|
||||
if success {
|
||||
breaker.record_success().await;
|
||||
log::debug!("Provider {provider_id} request succeeded");
|
||||
} else {
|
||||
breaker.record_failure().await;
|
||||
log::warn!(
|
||||
"Provider {} request failed: {}",
|
||||
provider_id,
|
||||
error_msg.as_deref().unwrap_or("Unknown error")
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 更新数据库健康状态
|
||||
self.db
|
||||
.update_provider_health(provider_id, app_type, success, error_msg.clone())
|
||||
.await?;
|
||||
|
||||
// 3. 如果连续失败达到熔断阈值,自动禁用代理目标
|
||||
if !success {
|
||||
let health = self.db.get_provider_health(provider_id, app_type).await?;
|
||||
|
||||
// 获取熔断器配置
|
||||
let config = self.db.get_circuit_breaker_config().await.ok();
|
||||
let failure_threshold = config.map(|c| c.failure_threshold).unwrap_or(5);
|
||||
|
||||
// 如果连续失败达到阈值,自动关闭该供应商的代理开关
|
||||
if health.consecutive_failures >= failure_threshold {
|
||||
log::warn!(
|
||||
"Provider {} has failed {} times (threshold: {}), auto-disabling proxy target",
|
||||
provider_id,
|
||||
health.consecutive_failures,
|
||||
failure_threshold
|
||||
);
|
||||
self.db
|
||||
.set_proxy_target(provider_id, app_type, false)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重置熔断器(手动恢复)
|
||||
#[allow(dead_code)]
|
||||
pub async fn reset_circuit_breaker(&self, circuit_key: &str) {
|
||||
let breakers = self.circuit_breakers.read().await;
|
||||
if let Some(breaker) = breakers.get(circuit_key) {
|
||||
log::info!("Manually resetting circuit breaker for {circuit_key}");
|
||||
breaker.reset().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取熔断器状态
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_circuit_breaker_stats(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
) -> Option<crate::proxy::circuit_breaker::CircuitBreakerStats> {
|
||||
let circuit_key = format!("{app_type}:{provider_id}");
|
||||
let breakers = self.circuit_breakers.read().await;
|
||||
|
||||
if let Some(breaker) = breakers.get(&circuit_key) {
|
||||
Some(breaker.get_stats().await)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取或创建熔断器
|
||||
async fn get_or_create_circuit_breaker(&self, key: &str) -> Arc<CircuitBreaker> {
|
||||
// 先尝试读锁获取
|
||||
{
|
||||
let breakers = self.circuit_breakers.read().await;
|
||||
if let Some(breaker) = breakers.get(key) {
|
||||
return breaker.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不存在,获取写锁创建
|
||||
let mut breakers = self.circuit_breakers.write().await;
|
||||
|
||||
// 双重检查,防止竞争条件
|
||||
if let Some(breaker) = breakers.get(key) {
|
||||
return breaker.clone();
|
||||
}
|
||||
|
||||
// 从数据库加载配置
|
||||
let config = self
|
||||
.db
|
||||
.get_circuit_breaker_config()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
|
||||
|
||||
let breaker = Arc::new(CircuitBreaker::new(config));
|
||||
breakers.insert(key.to_string(), breaker.clone());
|
||||
|
||||
breaker
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::Database;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_provider_router_creation() {
|
||||
let db = Arc::new(Database::new_in_memory().unwrap());
|
||||
let router = ProviderRouter::new(db);
|
||||
|
||||
// 测试创建熔断器
|
||||
let breaker = router.get_or_create_circuit_breaker("claude:test").await;
|
||||
assert!(breaker.allow_request().await);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ impl ProviderRouter {
|
||||
}
|
||||
|
||||
/// 更新Provider健康状态(保留接口但不影响选择)
|
||||
#[allow(dead_code)]
|
||||
pub async fn update_health(
|
||||
&self,
|
||||
_provider: &Provider,
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct ProxyState {
|
||||
pub config: Arc<RwLock<ProxyConfig>>,
|
||||
pub status: Arc<RwLock<ProxyStatus>>,
|
||||
pub start_time: Arc<RwLock<Option<std::time::Instant>>>,
|
||||
/// 每个应用类型当前使用的 provider (app_type -> (provider_id, provider_name))
|
||||
pub current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
}
|
||||
|
||||
/// 代理HTTP服务器
|
||||
@@ -36,6 +38,7 @@ impl ProxyServer {
|
||||
config: Arc::new(RwLock::new(config.clone())),
|
||||
status: Arc::new(RwLock::new(ProxyStatus::default())),
|
||||
start_time: Arc::new(RwLock::new(None)),
|
||||
current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -121,17 +124,16 @@ impl ProxyServer {
|
||||
status.uptime_seconds = start.elapsed().as_secs();
|
||||
}
|
||||
|
||||
// 获取所有活跃的代理目标
|
||||
if let Ok(targets) = self.state.db.get_all_proxy_targets() {
|
||||
status.active_targets = targets
|
||||
.into_iter()
|
||||
.map(|(app_type, name, id)| ActiveTarget {
|
||||
app_type,
|
||||
provider_name: name,
|
||||
provider_id: id,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// 从 current_providers HashMap 获取每个应用类型当前正在使用的 provider
|
||||
let current_providers = self.state.current_providers.read().await;
|
||||
status.active_targets = current_providers
|
||||
.iter()
|
||||
.map(|(app_type, (provider_id, provider_name))| ActiveTarget {
|
||||
app_type: app_type.clone(),
|
||||
provider_id: provider_id.clone(),
|
||||
provider_name: provider_name.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
status
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::format_skill_error;
|
||||
|
||||
/// 技能对象
|
||||
@@ -107,16 +106,11 @@ pub struct SkillMetadata {
|
||||
pub struct SkillService {
|
||||
http_client: Client,
|
||||
install_dir: PathBuf,
|
||||
app_type: AppType,
|
||||
}
|
||||
|
||||
impl SkillService {
|
||||
pub fn new() -> Result<Self> {
|
||||
Self::new_for_app(AppType::Claude)
|
||||
}
|
||||
|
||||
pub fn new_for_app(app_type: AppType) -> Result<Self> {
|
||||
let install_dir = Self::get_install_dir_for_app(&app_type)?;
|
||||
let install_dir = Self::get_install_dir()?;
|
||||
|
||||
// 确保目录存在
|
||||
fs::create_dir_all(&install_dir)?;
|
||||
@@ -128,38 +122,16 @@ impl SkillService {
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()?,
|
||||
install_dir,
|
||||
app_type,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_install_dir_for_app(app_type: &AppType) -> Result<PathBuf> {
|
||||
fn get_install_dir() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context(format_skill_error(
|
||||
"GET_HOME_DIR_FAILED",
|
||||
&[],
|
||||
Some("checkPermission"),
|
||||
))?;
|
||||
|
||||
let dir = match app_type {
|
||||
AppType::Claude => home.join(".claude").join("skills"),
|
||||
AppType::Codex => {
|
||||
// 检查是否有自定义 Codex 配置目录
|
||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||
custom.join("skills")
|
||||
} else {
|
||||
home.join(".codex").join("skills")
|
||||
}
|
||||
}
|
||||
AppType::Gemini => {
|
||||
// 为 Gemini 预留,暂时使用默认路径
|
||||
home.join(".gemini").join("skills")
|
||||
}
|
||||
};
|
||||
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn app_type(&self) -> &AppType {
|
||||
&self.app_type
|
||||
Ok(home.join(".claude").join("skills"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user