重构: secret纯密钥逻辑下沉df-storage(方案B避循环依赖,12调用点零改)
This commit is contained in:
@@ -36,7 +36,7 @@ df-project = { path = "../crates/df-project" }
|
||||
futures = "0.3"
|
||||
# write_file base64 编码:写二进制/非 UTF-8 文件(图片/PDF/Excel 等)
|
||||
base64 = "0.22"
|
||||
# keyring v3 默认不带任何 platform 后端,必须显式启 feature——
|
||||
# 否则 Entry 走 noop store:set_password 静默成功不持久化,get_password 永远空(=FR-S1 密钥存了等于没存,根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md)
|
||||
# Win+mac 启用原生后端;linux(secret-service)按需补
|
||||
keyring = { version = "3", features = ["windows-native", "apple-native"] }
|
||||
# keyring:密钥解析下沉到 df-storage(workspace 统一声明平台 feature),
|
||||
# src-tauri 经 workspace 引用(转发壳 build_provider_for 不直接碰 keyring,但旧路径/兼容保留)。
|
||||
# 根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md
|
||||
keyring = { workspace = true }
|
||||
|
||||
@@ -1,159 +1,25 @@
|
||||
//! FR-S1 api_key 密钥管理 — 真实密钥存 OS keyring,DB `api_key` 列迁移后存空串
|
||||
//! FR-S1 api_key 密钥管理 — **转发壳(方案 B 下沉,2026-06-16)**
|
||||
//!
|
||||
//! 设计:
|
||||
//! - keyring entry: service=`devflow-ai-provider`, username=provider_id
|
||||
//! - DB `api_key` 列恒空(迁移后/新建均空),真实密钥唯一源 = OS keyring
|
||||
//! - 启动一次性迁移:`migrate_secrets_to_keyring` 读老明文 → keyring → DB 置空(失败保留明文下次重试)
|
||||
//! - 消费点(build_provider)经 `resolve_provider_secret` 取:keyring 优先,fallback DB(兼容未迁移)
|
||||
//! - 跨平台:Windows Credential Manager / macOS Keychain / Linux Secret Service
|
||||
//! 纯密钥逻辑(get/set/delete/resolve/ensure/migrate + failcount sidecar)已下沉到
|
||||
//! `df_storage::secret`(密钥解析唯一源,供 df-nodes AiNode 与本转发壳共用,DRY)。
|
||||
//!
|
||||
//! 本文件保留两项职责:
|
||||
//! 1. **转发**:`pub use df_storage::secret::*;` —— 保 src-tauri 内调用路径
|
||||
//! `crate::commands::ai::secret::*` / `super::secret::*` 不变(12 调用点零改动)。
|
||||
//! 2. **`build_provider_for`**:消费点统一入口(resolve→ensure→`df_ai::build_provider` 三步打包)。
|
||||
//! 此函数依赖 `df_ai::build_provider`,**不下沉** —— df-storage 不依赖 df-ai,
|
||||
//! 下沉会引 df-storage→df-ai 反向依赖(循环)。src-tauri 既依赖 df-storage 又依赖 df-ai,
|
||||
//! 天然适合拼装,故留 app 层。
|
||||
//!
|
||||
//! 下沉前原文件全貌见 git 历史(commit c10adaf 前 src-tauri/src/commands/ai/secret.rs:1-228)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
// 转发所有下沉的纯密钥函数,保 src-tauri 内调用路径不变。
|
||||
// 不再额外 `use df_storage::secret::{...}` 显式导入 —— 会与 glob re-export 冲突
|
||||
// (编译器判 private function import)。build_provider_for 内用全限定路径调即可。
|
||||
#[allow(unused_imports)]
|
||||
pub use df_storage::secret::*;
|
||||
|
||||
use df_storage::crud::AiProviderRepo;
|
||||
use df_storage::models::AiProviderRecord;
|
||||
use keyring::Entry;
|
||||
|
||||
const KEYRING_SERVICE: &str = "devflow-ai-provider";
|
||||
|
||||
/// 迁移失败计数器阈值:同一 provider 累计失败到此次数 → 升级为 warn 提示明文密钥长期滞留风险。
|
||||
/// 跨启动持久化(sidecar 文件),计数仅用于告警,不影响兼容时序(不强制迁移、不删明文)。
|
||||
const MIGRATION_FAIL_THRESHOLD: u32 = 3;
|
||||
|
||||
/// 迁移失败计数 sidecar 文件(<cwd>/.devflow-keyring-failcount):逐行 `provider_id=count`。
|
||||
/// cwd 未必是稳定路径,但 R-PD-4 目标仅是「检测到反复失败/滞留时告警」,误读为 0 即按未达阈值处理,无副作用。
|
||||
fn failcount_path() -> PathBuf {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(".devflow-keyring-failcount")
|
||||
}
|
||||
|
||||
/// 读取全部失败计数(id → count)。文件缺失/损坏 → 空 map(按未达阈值处理)。
|
||||
fn read_failcounts() -> HashMap<String, u32> {
|
||||
let mut map = HashMap::new();
|
||||
if let Ok(text) = fs::read_to_string(failcount_path()) {
|
||||
for line in text.lines() {
|
||||
let mut parts = line.splitn(2, '=');
|
||||
let id = parts.next().unwrap_or("").trim();
|
||||
let cnt = parts.next().and_then(|s| s.trim().parse::<u32>().ok());
|
||||
if !id.is_empty() {
|
||||
if let Some(c) = cnt {
|
||||
map.insert(id.to_string(), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// 持久化全部失败计数。写入失败仅 log,不阻断迁移主流程。
|
||||
fn write_failcounts(map: &HashMap<String, u32>) {
|
||||
let mut text = String::new();
|
||||
let mut entries: Vec<_> = map.iter().collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(b.0)); // 稳定顺序,减少无谓 diff
|
||||
for (id, cnt) in entries {
|
||||
text.push_str(id);
|
||||
text.push('=');
|
||||
text.push_str(&cnt.to_string());
|
||||
text.push('\n');
|
||||
}
|
||||
if let Err(e) = fs::write(failcount_path(), text) {
|
||||
tracing::debug!("[FR-S1] 迁移失败计数文件写入失败(忽略): {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录一次迁移失败并返回累计失败次数。持久化失败也不影响返回值(仍递增内存计数用于本次告警)。
|
||||
fn record_migration_fail(id: &str) -> u32 {
|
||||
let mut map = read_failcounts();
|
||||
let next = map.get(id).copied().unwrap_or(0).saturating_add(1);
|
||||
map.insert(id.to_string(), next);
|
||||
write_failcounts(&map);
|
||||
next
|
||||
}
|
||||
|
||||
/// 清零某 provider 的失败计数(迁移成功后调用,避免历史失败在后续再触发误告警)。
|
||||
fn clear_migration_failcount(id: &str) {
|
||||
let mut map = read_failcounts();
|
||||
if map.remove(id).is_some() {
|
||||
write_failcounts(&map);
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_for(id: &str) -> anyhow::Result<Entry> {
|
||||
Entry::new(KEYRING_SERVICE, id).map_err(|e| anyhow::anyhow!("keyring entry 创建失败: {}", e))
|
||||
}
|
||||
|
||||
/// 读取 provider 密钥(优先 keyring;无则 None)
|
||||
pub fn get_provider_secret(id: &str) -> Option<String> {
|
||||
let entry = entry_for(id).ok()?;
|
||||
match entry.get_password() {
|
||||
Ok(s) if !s.is_empty() => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 消费点用:解析 provider 真实密钥 — keyring 优先,fallback DB.api_key(兼容未迁移老库)
|
||||
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
|
||||
if !record.api_key.is_empty() {
|
||||
return record.api_key.clone();
|
||||
}
|
||||
get_provider_secret(&record.id).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 写入密钥到 keyring(覆盖)
|
||||
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
|
||||
let entry = entry_for(id)?;
|
||||
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败: {}", e))
|
||||
}
|
||||
|
||||
/// 删除 keyring 密钥(provider 删除时清理)
|
||||
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
|
||||
let entry = entry_for(id)?;
|
||||
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败: {}", e))
|
||||
}
|
||||
|
||||
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
|
||||
pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> {
|
||||
let providers = repo.list_all().await?;
|
||||
let mut migrated = 0;
|
||||
for mut p in providers {
|
||||
if p.api_key.is_empty() {
|
||||
continue; // 已迁移或无密钥
|
||||
}
|
||||
if let Err(e) = set_provider_secret(&p.id, &p.api_key) {
|
||||
// 累计失败次数:达阈值(默认 3)升级告警,提示明文密钥长期滞留 SQLite(无加密)风险。
|
||||
// 计数仅告警用,不改兼容时序——仍保留明文下次重试,不强制迁移、不删明文。
|
||||
let n = record_migration_fail(&p.id);
|
||||
if n >= MIGRATION_FAIL_THRESHOLD {
|
||||
tracing::warn!(
|
||||
"[FR-S1] provider {} keyring 迁移已连续失败 {} 次,明文 api_key 长期滞留 SQLite 文件(无加密)。\
|
||||
建议:1) 确认 OS 钥匙串可用(Win Credential Manager / macOS Keychain);\
|
||||
2) keyring 后端异常时排查对应平台后端;3) 必要时手动在设置中重新保存密钥触发写入",
|
||||
p.id, n
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"[FR-S1] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}",
|
||||
p.id, n, MIGRATION_FAIL_THRESHOLD, e
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let pid = p.id.clone();
|
||||
p.api_key.clear();
|
||||
if let Err(e) = repo.insert(p).await {
|
||||
tracing::warn!("[FR-S1] 迁移后清空 DB api_key 失败 {}: {}", pid, e);
|
||||
}
|
||||
// 迁移成功 → 清零该 provider 的失败计数(下次若再出现失败从 1 重新累计)
|
||||
clear_migration_failcount(&pid);
|
||||
migrated += 1;
|
||||
}
|
||||
if migrated > 0 {
|
||||
tracing::info!("[FR-S1] {} 条 provider 密钥迁移至 OS keyring", migrated);
|
||||
}
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
/// 消费点统一入口:resolve_provider_secret → ensure_resolved_key → build_provider 三步打包。
|
||||
///
|
||||
@@ -161,11 +27,13 @@ pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result
|
||||
/// 空 key 照发请求吃 401,用户看「key 已保存」反复重试无解)。返 Result 让调用方按场景处理:
|
||||
/// - 主链(agentic loop):空 key 早失败 emit AiError
|
||||
/// - 后台 task(title/knowledge 提炼/embedding):Err 上层降级/记 log
|
||||
///
|
||||
/// **不下沉原因**:依赖 `df_ai::build_provider`,下沉 df-storage 会引 df-storage→df-ai 循环依赖。
|
||||
pub fn build_provider_for(
|
||||
record: &AiProviderRecord,
|
||||
) -> Result<Box<dyn df_ai::provider::LlmProvider>, String> {
|
||||
let api_key = resolve_provider_secret(record);
|
||||
ensure_resolved_key(&record.name, &api_key)?;
|
||||
let api_key = df_storage::secret::resolve_provider_secret(record);
|
||||
df_storage::secret::ensure_resolved_key(&record.name, &api_key)?;
|
||||
Ok(df_ai::build_provider(
|
||||
&record.provider_type,
|
||||
&record.base_url,
|
||||
@@ -173,56 +41,3 @@ pub fn build_provider_for(
|
||||
&record.default_model,
|
||||
))
|
||||
}
|
||||
|
||||
/// 校验已解析的密钥是否可用:空(含纯空白)→明确错误信息,非空→Ok。
|
||||
/// 用于消费点(build_provider 前)早失败,避免空 key 发请求吃 401,错误伪装成"API Key 无效"。
|
||||
pub fn ensure_resolved_key(provider_name: &str, resolved: &str) -> Result<(), String> {
|
||||
if resolved.trim().is_empty() {
|
||||
Err(format!(
|
||||
"未读取到「{}」的 API 密钥(系统钥匙串无记录或已损坏),请在设置中重新填写并保存",
|
||||
provider_name
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ensure_resolved_key_rejects_empty() {
|
||||
assert!(ensure_resolved_key("GLM", "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_resolved_key_rejects_whitespace() {
|
||||
// 纯空白也视为无密钥(防粘贴时只有空格)
|
||||
assert!(ensure_resolved_key("GLM", " ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_resolved_key_accepts_nonempty() {
|
||||
assert!(ensure_resolved_key("GLM", "sk-abc").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_resolved_key_error_mentions_provider_name() {
|
||||
let err = ensure_resolved_key("我的提供商", "").unwrap_err();
|
||||
assert!(err.contains("我的提供商"), "错误信息应含 provider 名便于定位");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_db_when_non_empty() {
|
||||
// DB api_key 非空 → 直接返回 DB 值,不触发 keyring(FR-S1 兼容未迁移老库)
|
||||
use df_storage::models::AiProviderRecord;
|
||||
let rec = AiProviderRecord {
|
||||
id: "t1".into(), name: "t".into(), provider_type: "openai_compat".into(),
|
||||
api_key: "sk-db-fallback".into(), base_url: "https://x".into(),
|
||||
default_model: "m".into(), models: None, is_default: false,
|
||||
config: None, created_at: "0".into(), updated_at: "0".into(),
|
||||
};
|
||||
assert_eq!(resolve_provider_secret(&rec), "sk-db-fallback");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user