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:
Jason
2025-09-05 14:26:11 +08:00
parent 5624a2d11a
commit 79ad0b9368
3 changed files with 109 additions and 4 deletions

View File

@@ -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;

View File

@@ -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);
}
}
// 保存配置

View File

@@ -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
}