From d5a641797c834c8b7b33e4591c4ac2c45fd56ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 14 Jun 2026 14:21:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=98=BB=E5=A1=9E=E5=8E=BB=E9=87=8D=20+=20no?= =?UTF-8?q?rmalize=5Fpath/=E7=99=BD=E5=90=8D=E5=8D=95=E6=8A=BD=E5=85=AC?= =?UTF-8?q?=E5=85=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - collect_sample/detect_stack 三处包 spawn_blocking 防 async 阻塞 + bind_directory 补漏一处 - normalize_path 抽公共到 df-project/scan(删 project.rs 本地 + tool_registry inline 闭包) - tool_registry update_project 复用 crud is_allowed_column(删硬编码白名单,与 CRUD 同源) Sprint 20 审查 ③④⑤。todo 记 A/B 验证发现的 2 项非阻断(T-09 idea 补偿删级联 / T-10 normalize_path 同步) --- crates/df-project/src/scan.rs | 13 +++++++ crates/df-storage/src/crud.rs | 12 +++++- docs/todo.md | 2 + src-tauri/src/commands/ai/tool_registry.rs | 24 ++++++------ src-tauri/src/commands/project.rs | 44 +++++++++++----------- 5 files changed, 61 insertions(+), 34 deletions(-) diff --git a/crates/df-project/src/scan.rs b/crates/df-project/src/scan.rs index 9540ff8..1fe86cc 100644 --- a/crates/df-project/src/scan.rs +++ b/crates/df-project/src/scan.rs @@ -10,6 +10,19 @@ use std::path::Path; use anyhow::{Context, Result}; +/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级), +/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。 +/// 注:仅用于比较,存库保留用户输入的原始可读路径。 +pub fn normalize_path(p: &str) -> String { + match Path::new(p).canonicalize() { + Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(), + Err(_) => p + .trim_end_matches(['\\', '/']) + .replace('\\', "/") + .to_lowercase(), + } +} + /// 探测目录的技术栈 /// /// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。 diff --git a/crates/df-storage/src/crud.rs b/crates/df-storage/src/crud.rs index 92513bd..80d3c28 100644 --- a/crates/df-storage/src/crud.rs +++ b/crates/df-storage/src/crud.rs @@ -278,7 +278,7 @@ impl SettingsRepo { /// projects 的 "name" 会在校验阶段拒绝,而非靠 SQLite "no such column" 兜底报错)。 /// 专用更新路径的列不列入:knowledges.embedding(set_embedding)、projects.deleted_at /// (soft_delete/restore)。未登记的表返回 None → 放行(仅靠参数化防注入,向后兼容)。 -fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> { +pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> { Some(match table { "ideas" => &[ "id", "title", "description", "status", "priority", "score", "tags", "source", @@ -339,6 +339,16 @@ fn validate_column_name(field: &str, table: &str) -> Result<()> { } } +/// 是否允许更新某表的某列(按表隔离白名单)。未登记表返回 false(保守拒绝,防误传)。 +/// +/// 供 AI 工具层等外部调用方复用 CRUD 白名单,避免双份字段列表不同步。 +pub fn is_allowed_column(table: &str, field: &str) -> bool { + match allowed_columns_for(table) { + Some(cols) => cols.contains(&field), + None => false, + } +} + // ============================================================ // 时间工具 // ============================================================ diff --git a/docs/todo.md b/docs/todo.md index cab92a9..5d81640 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -67,6 +67,8 @@ - [ ] F-260614-04 — 多 Provider 负载均衡池 — 备用模型/多账号聚合,全局容量=min(各 provider 上限之和, global_cap) (06-14) - [ ] F-260614-05 — 模型能力系统 Phase 2 — 多模态消息支持:ChatMessage.content: String → Vec(Text/Image);前端粘贴/拖拽图片;vision 模型自动路由 (06-14) - [ ] F-260614-06 — 导入历史项目(scan 第二步) — 复用 scan/relocate/checkBinding,加 monorepo 子目录识别 + README 首段抽 description + 批量 (06-14) +- [ ] T-260614-09 — **[P2→非阻断]** idea.rs:149 立项回滚补偿删走 `state.projects.delete()` 物理删不级联 — 当前删刚 insert 的孤儿项目(无子记录)安全;若该路径在项目已有子记录时复用会留孤儿 tasks/branches。建议改 `purge_with_descendants` — source:Workflow A 验证 (06-14) +- [ ] T-260614-10 — **[P2→可选]** project.rs findBinding_conflict 内 normalize_path 同步 canonicalize — 开销小(单路径)且用于比较,spawn_blocking 收益低;批量绑定场景出现阻塞再评估包 — source:Workflow B 验证 (06-14) ## 已完成 diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index ec070f3..4cc952b 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -133,7 +133,10 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?; let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?; - match field { "name" | "status" | "description" | "path" | "stack" => {}, _ => anyhow::bail!("不允许更新字段 '{}'", field) } + // 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源 + if !df_storage::crud::is_allowed_column("projects", field) { + anyhow::bail!("不允许更新字段 '{}'", field); + } let repo = df_storage::crud::ProjectRepo::new(&db); repo.update_field(id, field, value).await?; Ok(serde_json::json!({ "id": id, "field": field, "updated": true })) @@ -176,26 +179,23 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { anyhow::bail!("目录不存在: {path}"); } let repo = df_storage::crud::ProjectRepo::new(&db); - // 防重复:canonicalize 规范化比较,防路径写法差异绕过 - let normalize = |s: &str| -> String { - std::path::Path::new(s) - .canonicalize() - .map(|a| a.to_string_lossy().replace('\\', "/").to_lowercase()) - .unwrap_or_else(|_| s.trim_end_matches(['\\', '/']).replace('\\', "/").to_lowercase()) - }; - let target = normalize(path); + // 防重复:canonicalize 规范化比较,防路径写法差异绕过(复用 df-project 公共 normalize_path) + let target = df_project::scan::normalize_path(path); let projects = repo.list_active().await?; for proj in &projects { if proj.id != id { if let Some(pp) = &proj.path { - if normalize(pp) == target { + if df_project::scan::normalize_path(pp) == target { anyhow::bail!("目录已被项目「{}」绑定", proj.name); } } } } - // 探测技术栈 - let stack = df_project::scan::detect_stack(dir)?; + // 探测技术栈(spawn_blocking: detect_stack 内含多次同步 fs IO,避免阻塞 tokio runtime) + let dir_buf = std::path::PathBuf::from(path); + let stack = tokio::task::spawn_blocking(move || df_project::scan::detect_stack(&dir_buf)) + .await + .map_err(|e| anyhow::anyhow!("技术栈探测任务失败: {e}"))??; let stack_json = serde_json::to_string(&stack)?; repo.update_field(id, "path", path).await?; repo.update_field(id, "stack", &stack_json).await?; diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index caf9cc0..81b7128 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -8,7 +8,7 @@ use tauri::State; use df_ai::build_provider; use df_ai::provider::{ChatMessage, CompletionRequest}; use df_core::types::new_id; -use df_project::scan::{collect_sample, detect_stack}; +use df_project::scan::{collect_sample, detect_stack, normalize_path}; use df_storage::models::ProjectRecord; use crate::state::AppState; @@ -53,11 +53,15 @@ pub async fn create_project( if let Some(conflict) = find_binding_conflict(&state, p, None).await? { return Err(format!("目录已被项目「{}」绑定", conflict.name)); } - // stack 优先用入参,否则自动探测 + // stack 优先用入参,否则自动探测(spawn_blocking 防 IO 阻塞 tokio runtime) let stack_json = match input.stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(s) => s.to_string(), None => { - let detected = detect_stack(Path::new(p)).map_err(|e| e.to_string())?; + let root = std::path::PathBuf::from(p); + let detected = tokio::task::spawn_blocking(move || detect_stack(&root)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; serde_json::to_string(&detected).map_err(|e| e.to_string())? } }; @@ -148,19 +152,6 @@ pub async fn purge_project(state: State<'_, AppState>, id: String) -> Result String { - match Path::new(p).canonicalize() { - Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(), - Err(_) => p - .trim_end_matches(['\\', '/']) - .replace('\\', "/") - .to_lowercase(), - } -} - /// 查找已绑定该目录的项目(排除 exclude_id 自身)。无冲突返回 None。 async fn find_binding_conflict( state: &AppState, @@ -208,8 +199,12 @@ pub async fn relocate_project_path( if let Some(conflict) = find_binding_conflict(&state, &new_path, Some(&id)).await? { return Err(format!("目录已被项目「{}」绑定", conflict.name)); } - // 重探测技术栈 - let stack = detect_stack(Path::new(&new_path)).map_err(|e| e.to_string())?; + // 重探测技术栈(spawn_blocking 防 IO 阻塞 tokio runtime) + let root = std::path::PathBuf::from(&new_path); + let stack = tokio::task::spawn_blocking(move || detect_stack(&root)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; let stack_json = serde_json::to_string(&stack).map_err(|e| e.to_string())?; state .projects @@ -266,9 +261,16 @@ pub async fn scan_project_with_ai( return Err(format!("目录不存在: {path}")); } - // 1. 规则探测(兜底)+ 采样(纯 IO 轻量,直接调) - let rule_stack = detect_stack(root).map_err(|e| e.to_string())?; - let sample = collect_sample(root).map_err(|e| e.to_string())?; + // 1. 规则探测(兜底)+ 采样(纯 IO 轻量,spawn_blocking 防 IO 阻塞 tokio runtime) + let scan_root = std::path::PathBuf::from(&path); + let (rule_stack, sample) = tokio::task::spawn_blocking(move || { + let stack = detect_stack(&scan_root)?; + let sample = collect_sample(&scan_root)?; + Ok::<_, anyhow::Error>((stack, sample)) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; // 2. 取默认 provider(优先 is_default,否则首个) let providers = state.ai_providers.list_all().await.map_err(|e| e.to_string())?;