fix(provider): prevent case-insensitive name + key duplicates; migrate and startup dedupe
- Use case-insensitive compare for name + API key in migration - Add runtime uniqueness checks in add/update commands - Startup dedupe prefers current provider; archives others - Keep original display casing; only normalize for comparisons - Validate Codex config.toml as before; archive before overwrite
This commit is contained in:
@@ -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<String> {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
|
||||
@@ -52,6 +52,10 @@ fn extract_codex_api_key(value: &Value) -> Option<String> {
|
||||
.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<bool, S
|
||||
.iter()
|
||||
.find_map(|(id, p)| {
|
||||
let pk = extract_claude_api_key(&p.settings_config);
|
||||
if p.name == *name && pk == cand_key { Some(id.clone()) } else { None }
|
||||
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(exist_id) = exist_id {
|
||||
if let Some(prov) = manager.providers.get_mut(&exist_id) {
|
||||
@@ -212,7 +220,11 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
|
||||
.iter()
|
||||
.find_map(|(id, p)| {
|
||||
let pk = extract_claude_api_key(&p.settings_config);
|
||||
if p.name == *name && pk == cand_key { Some(id.clone()) } else { None }
|
||||
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(exist_id) = exist_id {
|
||||
if let Some(prov) = manager.providers.get_mut(&exist_id) {
|
||||
@@ -282,7 +294,11 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
|
||||
.iter()
|
||||
.find_map(|(id, p)| {
|
||||
let pk = extract_codex_api_key(&p.settings_config);
|
||||
if p.name == *name && pk == cand_key { Some(id.clone()) } else { None }
|
||||
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(exist_id) = exist_id {
|
||||
if let Some(prov) = manager.providers.get_mut(&exist_id) {
|
||||
@@ -310,7 +326,11 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
|
||||
.iter()
|
||||
.find_map(|(id, p)| {
|
||||
let pk = extract_codex_api_key(&p.settings_config);
|
||||
if p.name == *name && pk == cand_key { Some(id.clone()) } else { None }
|
||||
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(exist_id) = exist_id {
|
||||
if let Some(prov) = manager.providers.get_mut(&exist_id) {
|
||||
@@ -365,3 +385,44 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
|
||||
fs::write(&marker, b"done").map_err(|e| format!("写入迁移标记失败: {}", e))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 启动时对现有配置做一次去重:按名称(忽略大小写)+API Key
|
||||
pub fn dedupe_config(config: &mut MultiAppConfig) -> usize {
|
||||
use std::collections::HashMap as Map;
|
||||
|
||||
fn dedupe_one(
|
||||
mgr: &mut crate::provider::ProviderManager,
|
||||
extract_key: &dyn Fn(&Value) -> Option<String>,
|
||||
) -> usize {
|
||||
let mut keep: Map<String, String> = Map::new(); // key -> id 保留
|
||||
let mut remove: Vec<String> = 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user