diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d924d35..f3a77cc 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -10,6 +10,23 @@ use crate::config::{ConfigStatus, get_claude_settings_path}; use crate::provider::Provider; use crate::store::AppState; +fn norm_name(s: &str) -> String { s.trim().to_lowercase() } + +fn extract_key_for(app_type: &crate::app_config::AppType, settings: &serde_json::Value) -> Option { + match app_type { + crate::app_config::AppType::Claude => settings + .get("env") + .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + crate::app_config::AppType::Codex => settings + .get("auth") + .and_then(|auth| auth.get("OPENAI_API_KEY").or_else(|| auth.get("openai_api_key"))) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + } +} + /// 获取所有供应商 #[tauri::command] pub async fn get_providers( @@ -83,6 +100,17 @@ pub async fn add_provider( .get_manager_mut(&app_type) .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + // 名称(忽略大小写)+Key 唯一性校验 + let new_key = extract_key_for(&app_type, &provider.settings_config).unwrap_or_default(); + let new_name = norm_name(&provider.name); + if manager.providers.iter().any(|(id, p)| { + *id != provider.id + && norm_name(&p.name) == new_name + && extract_key_for(&app_type, &p.settings_config).unwrap_or_default() == new_key + }) { + return Err("已存在同名(不区分大小写)且相同密钥的供应商".to_string()); + } + // 根据应用类型保存配置文件 // 不再写入供应商副本文件,仅更新内存配置(SSOT) let is_current = manager.current == provider.id; @@ -175,6 +203,17 @@ pub async fn update_provider( return Err(format!("供应商不存在: {}", provider.id)); } + // 名称(忽略大小写)+Key 唯一性校验(允许覆盖自身) + let new_key = extract_key_for(&app_type, &provider.settings_config).unwrap_or_default(); + let new_name = norm_name(&provider.name); + if manager.providers.iter().any(|(id, p)| { + *id != provider.id + && norm_name(&p.name) == new_name + && extract_key_for(&app_type, &p.settings_config).unwrap_or_default() == new_key + }) { + return Err("已存在同名(不区分大小写)且相同密钥的供应商".to_string()); + } + // 不再写入供应商副本文件,仅更新内存配置(SSOT) let is_current = manager.current == provider.id; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 077b029..5a6f299 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -66,6 +66,11 @@ pub fn run() { // 确保两个 App 条目存在 config_guard.ensure_app(&app_config::AppType::Claude); config_guard.ensure_app(&app_config::AppType::Codex); + // 启动去重:名称(忽略大小写)+API Key + let removed = migration::dedupe_config(&mut *config_guard); + if removed > 0 { + log::info!("已去重重复供应商 {} 个", removed); + } } // 保存配置 diff --git a/src-tauri/src/migration.rs b/src-tauri/src/migration.rs index 7b28237..809c0ca 100644 --- a/src-tauri/src/migration.rs +++ b/src-tauri/src/migration.rs @@ -52,6 +52,10 @@ fn extract_codex_api_key(value: &Value) -> Option { .map(|s| s.to_string()) } +fn norm_name(s: &str) -> String { + s.trim().to_lowercase() +} + // 去重策略:name + 原始 key 直接比较(不做哈希) fn scan_claude_copies() -> Vec<(String, PathBuf, Value)> { @@ -184,7 +188,11 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result Result Result Result Result usize { + use std::collections::HashMap as Map; + + fn dedupe_one( + mgr: &mut crate::provider::ProviderManager, + extract_key: &dyn Fn(&Value) -> Option, + ) -> usize { + let mut keep: Map = Map::new(); // key -> id 保留 + let mut remove: Vec = Vec::new(); + for (id, p) in mgr.providers.iter() { + let k = format!("{}|{}", norm_name(&p.name), extract_key(&p.settings_config).unwrap_or_default()); + if let Some(exist_id) = keep.get(&k) { + // 若当前是正在使用的,则用当前替换之前的,反之丢弃当前 + if *id == mgr.current { + // 替换:把原先的标记为删除,改保留为当前 + remove.push(exist_id.clone()); + keep.insert(k, id.clone()); + } else { + remove.push(id.clone()); + } + } else { + keep.insert(k, id.clone()); + } + } + for id in remove.iter() { + mgr.providers.remove(id); + } + remove.len() + } + + let mut removed = 0; + if let Some(mgr) = config.get_manager_mut(&crate::app_config::AppType::Claude) { + removed += dedupe_one(mgr, &extract_claude_api_key); + } + if let Some(mgr) = config.get_manager_mut(&crate::app_config::AppType::Codex) { + removed += dedupe_one(mgr, &extract_codex_api_key); + } + removed +}