refactor(backend): implement transaction mechanism and i18n errors for provider service
This commit completes phase 4 service layer extraction by introducing: 1. **Transaction mechanism with 2PC (Two-Phase Commit)**: - Introduced `run_transaction()` wrapper with snapshot-based rollback - Implemented `LiveSnapshot` enum to capture and restore live config files - Added `PostCommitAction` to separate config.json persistence from live file writes - Applied to critical operations: add, update, switch providers - Ensures atomicity: memory + config.json + live files stay consistent 2. **Internationalized error handling**: - Added `AppError::Localized` variant with key + zh + en messages - Implemented `AppError::localized()` helper function - Migrated 24 error sites to use i18n-ready errors - Enables frontend to display errors in user's preferred language 3. **Concurrency optimization**: - Fixed `get_custom_endpoints()` to use read lock instead of write lock - Ensured async IO operations (usage query) execute outside lock scope - Added defensive RAII lock management with explicit scope blocks 4. **Code organization improvements**: - Reduced commands/provider.rs from ~800 to ~320 lines (-60%) - Expanded services/provider.rs with transaction infrastructure - Added unit tests for validation and credential extraction - Documented legacy file cleanup logic with inline comments 5. **Backfill mechanism refinement**: - Ensured live config is synced back to memory before switching - Maintains SSOT (Single Source of Truth) architecture principle - Handles Codex dual-file (auth.json + config.toml) atomically Breaking changes: None (internal refactoring only) Performance: Improved read concurrency, no measurable overhead from snapshots Test coverage: Added validation tests, updated service layer tests
This commit is contained in:
@@ -2,46 +2,16 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config;
|
||||
use crate::config::get_claude_settings_path;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
use crate::services::{EndpointLatency, ProviderService, SpeedtestService};
|
||||
use crate::provider::Provider;
|
||||
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
|
||||
use crate::store::AppState;
|
||||
|
||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if !provider.settings_config.is_object() {
|
||||
return Err("Claude 配置必须是 JSON 对象".to_string());
|
||||
}
|
||||
}
|
||||
AppType::Codex => {
|
||||
let settings = provider
|
||||
.settings_config
|
||||
.as_object()
|
||||
.ok_or_else(|| "Codex 配置必须是 JSON 对象".to_string())?;
|
||||
let auth = settings
|
||||
.get("auth")
|
||||
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
|
||||
if !auth.is_object() {
|
||||
return Err("Codex auth 配置必须是 JSON 对象".to_string());
|
||||
}
|
||||
if let Some(config_value) = settings.get("config") {
|
||||
if !(config_value.is_string() || config_value.is_null()) {
|
||||
return Err("Codex config 字段必须是字符串".to_string());
|
||||
}
|
||||
if let Some(cfg_text) = config_value.as_str() {
|
||||
codex_config::validate_config_toml(cfg_text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
fn missing_param(param: &str) -> String {
|
||||
format!("缺少 {} 参数 (Missing {} parameter)", param, param)
|
||||
}
|
||||
|
||||
/// 获取所有供应商
|
||||
@@ -57,16 +27,7 @@ pub async fn get_providers(
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let config = state
|
||||
.config
|
||||
.read()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
Ok(manager.get_all_providers().clone())
|
||||
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取当前供应商ID
|
||||
@@ -82,16 +43,7 @@ pub async fn get_current_provider(
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let config = state
|
||||
.config
|
||||
.read()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
Ok(manager.current.clone())
|
||||
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 添加供应商
|
||||
@@ -108,54 +60,7 @@ pub async fn add_provider(
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
let is_current = {
|
||||
let config = state
|
||||
.config
|
||||
.read()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
manager.current == provider.id
|
||||
};
|
||||
|
||||
if is_current {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let settings_path = crate::config::get_claude_settings_path();
|
||||
crate::config::write_json_file(&settings_path, &provider.settings_config)?;
|
||||
}
|
||||
AppType::Codex => {
|
||||
let auth = provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
|
||||
let cfg_text = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str());
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut config = state
|
||||
.config
|
||||
.write()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
manager
|
||||
.providers
|
||||
.insert(provider.id.clone(), provider.clone());
|
||||
}
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新供应商
|
||||
@@ -172,88 +77,7 @@ pub async fn update_provider(
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
let (exists, is_current) = {
|
||||
let config = state
|
||||
.config
|
||||
.read()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
(
|
||||
manager.providers.contains_key(&provider.id),
|
||||
manager.current == provider.id,
|
||||
)
|
||||
};
|
||||
if !exists {
|
||||
return Err(format!("供应商不存在: {}", provider.id));
|
||||
}
|
||||
|
||||
if is_current {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let settings_path = crate::config::get_claude_settings_path();
|
||||
crate::config::write_json_file(&settings_path, &provider.settings_config)?;
|
||||
}
|
||||
AppType::Codex => {
|
||||
let auth = provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
|
||||
let cfg_text = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str());
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut config = state
|
||||
.config
|
||||
.write()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
let merged_provider = if let Some(existing) = manager.providers.get(&provider.id) {
|
||||
let mut updated = provider.clone();
|
||||
|
||||
match (existing.meta.as_ref(), updated.meta.take()) {
|
||||
(Some(old_meta), None) => {
|
||||
updated.meta = Some(old_meta.clone());
|
||||
}
|
||||
(Some(old_meta), Some(mut new_meta)) => {
|
||||
let mut merged_map = old_meta.custom_endpoints.clone();
|
||||
for (url, ep) in new_meta.custom_endpoints.drain() {
|
||||
merged_map.entry(url).or_insert(ep);
|
||||
}
|
||||
updated.meta = Some(ProviderMeta {
|
||||
custom_endpoints: merged_map,
|
||||
usage_script: new_meta.usage_script.clone(),
|
||||
});
|
||||
}
|
||||
(None, maybe_new) => {
|
||||
updated.meta = maybe_new;
|
||||
}
|
||||
}
|
||||
|
||||
updated
|
||||
} else {
|
||||
provider.clone()
|
||||
};
|
||||
|
||||
manager
|
||||
.providers
|
||||
.insert(merged_provider.id.clone(), merged_provider);
|
||||
}
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除供应商
|
||||
@@ -285,12 +109,7 @@ pub async fn delete_provider(
|
||||
|
||||
/// 切换供应商
|
||||
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
let mut config = state.config.write().map_err(AppError::from)?;
|
||||
|
||||
ProviderService::switch(&mut config, app_type, id)?;
|
||||
|
||||
drop(config);
|
||||
state.save()
|
||||
ProviderService::switch(state, app_type, id)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
@@ -321,54 +140,7 @@ pub async fn switch_provider(
|
||||
}
|
||||
|
||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> {
|
||||
{
|
||||
let config = state.config.read()?;
|
||||
if let Some(manager) = config.get_manager(&app_type) {
|
||||
if !manager.get_all_providers().is_empty() {
|
||||
// 已存在供应商则视为已导入,保持与原逻辑一致
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let settings_config = match app_type {
|
||||
AppType::Codex => {
|
||||
let auth_path = codex_config::get_codex_auth_path();
|
||||
if !auth_path.exists() {
|
||||
return Err(AppError::Message("Codex 配置文件不存在".to_string()));
|
||||
}
|
||||
let auth: serde_json::Value = crate::config::read_json_file(&auth_path)?;
|
||||
let config_str = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||
serde_json::json!({ "auth": auth, "config": config_str })
|
||||
}
|
||||
AppType::Claude => {
|
||||
let settings_path = get_claude_settings_path();
|
||||
if !settings_path.exists() {
|
||||
return Err(AppError::Message("Claude Code 配置文件不存在".to_string()));
|
||||
}
|
||||
crate::config::read_json_file(&settings_path)?
|
||||
}
|
||||
};
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"default".to_string(),
|
||||
"default".to_string(),
|
||||
settings_config,
|
||||
None,
|
||||
);
|
||||
|
||||
let mut config = state.config.write()?;
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?;
|
||||
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
manager.current = "default".to_string();
|
||||
|
||||
drop(config);
|
||||
state.save()?;
|
||||
|
||||
Ok(())
|
||||
ProviderService::import_default_config(state, app_type)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
@@ -407,130 +179,18 @@ pub async fn query_provider_usage(
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
use crate::provider::{UsageData, UsageResult};
|
||||
|
||||
let provider_id = provider_id.or(providerId).ok_or("缺少 providerId 参数")?;
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| missing_param("providerId"))?;
|
||||
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let (api_key, base_url, usage_script_code, timeout) = {
|
||||
let config = state
|
||||
.config
|
||||
.read()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config.get_manager(&app_type).ok_or("应用类型不存在")?;
|
||||
|
||||
let provider = manager.providers.get(&provider_id).ok_or("供应商不存在")?;
|
||||
|
||||
let usage_script = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.usage_script.as_ref())
|
||||
.ok_or("未配置用量查询脚本")?;
|
||||
|
||||
if !usage_script.enabled {
|
||||
return Err("用量查询未启用".to_string());
|
||||
}
|
||||
|
||||
let (api_key, base_url) = extract_credentials(provider, &app_type)?;
|
||||
let timeout = usage_script.timeout.unwrap_or(10);
|
||||
let code = usage_script.code.clone();
|
||||
|
||||
drop(config);
|
||||
|
||||
(api_key, base_url, code, timeout)
|
||||
};
|
||||
|
||||
let result =
|
||||
crate::usage_script::execute_usage_script(&usage_script_code, &api_key, &base_url, timeout)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(data) => {
|
||||
let usage_list: Vec<UsageData> = if data.is_array() {
|
||||
serde_json::from_value(data).map_err(|e| format!("数据格式错误: {}", e))?
|
||||
} else {
|
||||
let single: UsageData =
|
||||
serde_json::from_value(data).map_err(|e| format!("数据格式错误: {}", e))?;
|
||||
vec![single]
|
||||
};
|
||||
|
||||
Ok(UsageResult {
|
||||
success: true,
|
||||
data: Some(usage_list),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(e.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_credentials(
|
||||
provider: &crate::provider::Provider,
|
||||
app_type: &AppType,
|
||||
) -> Result<(String, String), String> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let env = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or("配置格式错误: 缺少 env")?;
|
||||
|
||||
let api_key = env
|
||||
.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少 API Key")?
|
||||
.to_string();
|
||||
|
||||
let base_url = env
|
||||
.get("ANTHROPIC_BASE_URL")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少 ANTHROPIC_BASE_URL 配置")?
|
||||
.to_string();
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::Codex => {
|
||||
let auth = provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or("配置格式错误: 缺少 auth")?;
|
||||
|
||||
let api_key = auth
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少 API Key")?
|
||||
.to_string();
|
||||
|
||||
let config_toml = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let base_url = if config_toml.contains("base_url") {
|
||||
let re = regex::Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).unwrap();
|
||||
re.captures(config_toml)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
.ok_or("config.toml 中 base_url 格式错误")?
|
||||
} else {
|
||||
return Err("config.toml 中缺少 base_url 配置".to_string());
|
||||
};
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
}
|
||||
ProviderService::query_usage(state.inner(), app_type, &provider_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 读取当前生效的配置内容
|
||||
@@ -545,25 +205,7 @@ pub async fn read_live_provider_settings(
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
match app_type {
|
||||
AppType::Codex => {
|
||||
let auth_path = crate::codex_config::get_codex_auth_path();
|
||||
if !auth_path.exists() {
|
||||
return Err("Codex 配置文件不存在:缺少 auth.json".to_string());
|
||||
}
|
||||
let auth: serde_json::Value = crate::config::read_json_file(&auth_path)?;
|
||||
let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||
Ok(serde_json::json!({ "auth": auth, "config": cfg_text }))
|
||||
}
|
||||
AppType::Claude => {
|
||||
let path = crate::config::get_claude_settings_path();
|
||||
if !path.exists() {
|
||||
return Err("Claude Code 配置文件不存在".to_string());
|
||||
}
|
||||
let v: serde_json::Value = crate::config::read_json_file(&path)?;
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 测试第三方/自定义供应商端点的网络延迟
|
||||
@@ -593,28 +235,9 @@ pub async fn get_custom_endpoints(
|
||||
.unwrap_or(AppType::Claude);
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| "缺少 providerId".to_string())?;
|
||||
let mut cfg_guard = state
|
||||
.config
|
||||
.write()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = cfg_guard
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
let Some(provider) = manager.providers.get_mut(&provider_id) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
|
||||
if !meta.custom_endpoints.is_empty() {
|
||||
let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect();
|
||||
result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
Ok(vec![])
|
||||
.ok_or_else(|| missing_param("providerId"))?;
|
||||
ProviderService::get_custom_endpoints(state.inner(), app_type, &provider_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 添加自定义端点
|
||||
@@ -634,39 +257,9 @@ pub async fn add_custom_endpoint(
|
||||
.unwrap_or(AppType::Claude);
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| "缺少 providerId".to_string())?;
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
if normalized.is_empty() {
|
||||
return Err("URL 不能为空".to_string());
|
||||
}
|
||||
|
||||
let mut cfg_guard = state
|
||||
.config
|
||||
.write()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = cfg_guard
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
let Some(provider) = manager.providers.get_mut(&provider_id) else {
|
||||
return Err("供应商不存在或未选择".to_string());
|
||||
};
|
||||
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64;
|
||||
|
||||
let endpoint = crate::settings::CustomEndpoint {
|
||||
url: normalized.clone(),
|
||||
added_at: timestamp,
|
||||
last_used: None,
|
||||
};
|
||||
meta.custom_endpoints.insert(normalized, endpoint);
|
||||
drop(cfg_guard);
|
||||
state.save()?;
|
||||
Ok(())
|
||||
.ok_or_else(|| missing_param("providerId"))?;
|
||||
ProviderService::add_custom_endpoint(state.inner(), app_type, &provider_id, url)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除自定义端点
|
||||
@@ -686,25 +279,9 @@ pub async fn remove_custom_endpoint(
|
||||
.unwrap_or(AppType::Claude);
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| "缺少 providerId".to_string())?;
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
|
||||
let mut cfg_guard = state
|
||||
.config
|
||||
.write()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = cfg_guard
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
if let Some(provider) = manager.providers.get_mut(&provider_id) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.remove(&normalized);
|
||||
}
|
||||
}
|
||||
drop(cfg_guard);
|
||||
state.save()?;
|
||||
Ok(())
|
||||
.ok_or_else(|| missing_param("providerId"))?;
|
||||
ProviderService::remove_custom_endpoint(state.inner(), app_type, &provider_id, url)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新端点最后使用时间
|
||||
@@ -724,38 +301,9 @@ pub async fn update_endpoint_last_used(
|
||||
.unwrap_or(AppType::Claude);
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| "缺少 providerId".to_string())?;
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
|
||||
let mut cfg_guard = state
|
||||
.config
|
||||
.write()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = cfg_guard
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
if let Some(provider) = manager.providers.get_mut(&provider_id) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64;
|
||||
endpoint.last_used = Some(timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(cfg_guard);
|
||||
state.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ProviderSortUpdate {
|
||||
pub id: String,
|
||||
#[serde(rename = "sortIndex")]
|
||||
pub sort_index: usize,
|
||||
.ok_or_else(|| missing_param("providerId"))?;
|
||||
ProviderService::update_endpoint_last_used(state.inner(), app_type, &provider_id, url)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新多个供应商的排序
|
||||
@@ -772,23 +320,5 @@ pub async fn update_providers_sort_order(
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let mut config = state
|
||||
.config
|
||||
.write()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
for update in updates {
|
||||
if let Some(provider) = manager.providers.get_mut(&update.id) {
|
||||
provider.sort_index = Some(update.sort_index);
|
||||
}
|
||||
}
|
||||
|
||||
drop(config);
|
||||
state.save()?;
|
||||
|
||||
Ok(true)
|
||||
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ pub enum AppError {
|
||||
McpValidation(String),
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
#[error("{zh} ({en})")]
|
||||
Localized {
|
||||
key: &'static str,
|
||||
zh: String,
|
||||
en: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
@@ -69,6 +75,14 @@ impl AppError {
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> Self {
|
||||
Self::Localized {
|
||||
key,
|
||||
zh: zh.into(),
|
||||
en: en.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<PoisonError<T>> for AppError {
|
||||
|
||||
@@ -5,5 +5,5 @@ pub mod speedtest;
|
||||
|
||||
pub use config::ConfigService;
|
||||
pub use mcp::McpService;
|
||||
pub use provider::ProviderService;
|
||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,10 @@ fn import_default_config_without_live_file_returns_error() {
|
||||
let err = import_default_config_test_hook(&state, AppType::Claude)
|
||||
.expect_err("missing live file should error");
|
||||
match err {
|
||||
AppError::Localized { zh, .. } => assert!(
|
||||
zh.contains("Claude Code 配置文件不存在"),
|
||||
"unexpected error message: {zh}"
|
||||
),
|
||||
AppError::Message(msg) => assert!(
|
||||
msg.contains("Claude Code 配置文件不存在"),
|
||||
"unexpected error message: {msg}"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use serde_json::json;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use cc_switch_lib::{
|
||||
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType,
|
||||
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppState, AppType,
|
||||
MultiAppConfig, Provider, ProviderService,
|
||||
};
|
||||
|
||||
@@ -33,9 +34,9 @@ command = "echo"
|
||||
write_codex_live_atomic(&legacy_auth, Some(legacy_config))
|
||||
.expect("seed existing codex live config");
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
let mut initial_config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
let manager = initial_config
|
||||
.get_manager_mut(&AppType::Codex)
|
||||
.expect("codex manager");
|
||||
manager.current = "old-provider".to_string();
|
||||
@@ -68,7 +69,7 @@ command = "say"
|
||||
);
|
||||
}
|
||||
|
||||
config.mcp.codex.servers.insert(
|
||||
initial_config.mcp.codex.servers.insert(
|
||||
"echo-server".into(),
|
||||
json!({
|
||||
"id": "echo-server",
|
||||
@@ -80,7 +81,11 @@ command = "say"
|
||||
}),
|
||||
);
|
||||
|
||||
ProviderService::switch(&mut config, AppType::Codex, "new-provider")
|
||||
let state = AppState {
|
||||
config: RwLock::new(initial_config),
|
||||
};
|
||||
|
||||
ProviderService::switch(&state, AppType::Codex, "new-provider")
|
||||
.expect("switch provider should succeed");
|
||||
|
||||
let auth_value: serde_json::Value =
|
||||
@@ -98,7 +103,8 @@ command = "say"
|
||||
"config.toml should contain synced MCP servers"
|
||||
);
|
||||
|
||||
let manager = config
|
||||
let guard = state.config.read().expect("read config after switch");
|
||||
let manager = guard
|
||||
.get_manager(&AppType::Codex)
|
||||
.expect("codex manager after switch");
|
||||
assert_eq!(manager.current, "new-provider", "current provider updated");
|
||||
@@ -188,7 +194,11 @@ fn provider_service_switch_claude_updates_live_and_state() {
|
||||
);
|
||||
}
|
||||
|
||||
ProviderService::switch(&mut config, AppType::Claude, "new-provider")
|
||||
let state = AppState {
|
||||
config: RwLock::new(config),
|
||||
};
|
||||
|
||||
ProviderService::switch(&state, AppType::Claude, "new-provider")
|
||||
.expect("switch provider should succeed");
|
||||
|
||||
let live_after: serde_json::Value =
|
||||
@@ -202,7 +212,11 @@ fn provider_service_switch_claude_updates_live_and_state() {
|
||||
"live settings.json should reflect new provider auth"
|
||||
);
|
||||
|
||||
let manager = config
|
||||
let guard = state
|
||||
.config
|
||||
.read()
|
||||
.expect("read claude config after switch");
|
||||
let manager = guard
|
||||
.get_manager(&AppType::Claude)
|
||||
.expect("claude manager after switch");
|
||||
assert_eq!(manager.current, "new-provider", "current provider updated");
|
||||
@@ -219,9 +233,11 @@ fn provider_service_switch_claude_updates_live_and_state() {
|
||||
|
||||
#[test]
|
||||
fn provider_service_switch_missing_provider_returns_error() {
|
||||
let mut config = MultiAppConfig::default();
|
||||
let state = AppState {
|
||||
config: RwLock::new(MultiAppConfig::default()),
|
||||
};
|
||||
|
||||
let err = ProviderService::switch(&mut config, AppType::Claude, "missing")
|
||||
let err = ProviderService::switch(&state, AppType::Claude, "missing")
|
||||
.expect_err("switching missing provider should fail");
|
||||
match err {
|
||||
AppError::ProviderNotFound(id) => assert_eq!(id, "missing"),
|
||||
@@ -249,7 +265,11 @@ fn provider_service_switch_codex_missing_auth_returns_error() {
|
||||
);
|
||||
}
|
||||
|
||||
let err = ProviderService::switch(&mut config, AppType::Codex, "invalid")
|
||||
let state = AppState {
|
||||
config: RwLock::new(config),
|
||||
};
|
||||
|
||||
let err = ProviderService::switch(&state, AppType::Codex, "invalid")
|
||||
.expect_err("switching should fail without auth");
|
||||
match err {
|
||||
AppError::Config(msg) => assert!(
|
||||
@@ -404,6 +424,10 @@ fn provider_service_delete_current_provider_returns_error() {
|
||||
let err = ProviderService::delete(&mut config, AppType::Claude, "keep")
|
||||
.expect_err("deleting current provider should fail");
|
||||
match err {
|
||||
AppError::Localized { zh, .. } => assert!(
|
||||
zh.contains("不能删除当前正在使用的供应商"),
|
||||
"unexpected message: {zh}"
|
||||
),
|
||||
AppError::Config(msg) => assert!(
|
||||
msg.contains("不能删除当前正在使用的供应商"),
|
||||
"unexpected message: {msg}"
|
||||
|
||||
Reference in New Issue
Block a user