Skip to content

Commit c5c0448

Browse files
committed
refactor(desktop): db/live_session/engine 超限模块硬拆与诊断模块清理
1 parent 48c913a commit c5c0448

22 files changed

Lines changed: 1884 additions & 1478 deletions

‎app/src-tauri/src/commands.rs‎

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,29 @@ const MAX_IMAGES: usize = 20;
3232
/// 拼接输入段/块数量上限(防恶意超大列表拖垮拼接)。
3333
const MAX_INPUT_ITEMS: usize = 5000;
3434

35+
/// 音频文件扩展名白名单(安全 L1 修复:transcribe_audio 不得接受任意非音频路径,
36+
/// 与 commands_import::VIDEO_EXTENSIONS 同口径)。
37+
const AUDIO_EXTENSIONS: [&str; 6] = ["mp3", "wav", "m4a", "flac", "ogg", "aac"];
38+
/// 图片文件扩展名白名单(安全 L1 修复:recognize_image 同口径)。
39+
const IMAGE_EXTENSIONS: [&str; 5] = ["png", "jpg", "jpeg", "webp", "bmp"];
40+
41+
/// 扩展名白名单校验(安全 L1):返回小写扩展名,不在白名单内返回可诊断错误。
42+
/// Why:旧实现接受任意绝对路径无限制——非媒体文件进推理管线只能得到
43+
/// 模糊的引擎报错,且扩大了 IPC 入参攻击面;白名单前置拒绝可诊断。
44+
fn require_media_extension(path: &str, allowed: &[&str], kind: &str) -> Result<String, String> {
45+
let ext = std::path::Path::new(path)
46+
.extension()
47+
.and_then(|e| e.to_str())
48+
.map(|e| e.to_ascii_lowercase())
49+
.unwrap_or_default();
50+
if !allowed.contains(&ext.as_str()) {
51+
// 三维复审 #7:无扩展名时显示"(无扩展名)"而非空的 "."(可诊断性)
52+
let shown = if ext.is_empty() { "(无扩展名)".to_string() } else { format!(".{}", ext) };
53+
return Err(format!("不支持的{}文件类型: {}(支持: {})", kind, shown, allowed.join("/")));
54+
}
55+
Ok(ext)
56+
}
57+
3558
/// 校验并归一化标题:空串回退默认名,超长截断(TD-005 统一口径,commands_session 复用)。
3659
pub(crate) fn normalize_title(raw: String, fallback: &str) -> String {
3760
let trimmed = raw.trim().to_string();
@@ -150,8 +173,13 @@ pub async fn transcribe_audio(state: State<'_, AppState>, path: String) -> Resul
150173
if path.trim().is_empty() {
151174
return Err("音频路径为空".to_string());
152175
}
176+
// 安全 L1 修复:扩展名白名单前置校验(拒绝非音频路径)
177+
require_media_extension(&path, &AUDIO_EXTENSIONS, "音频")?;
153178
let engines = state.engines.clone();
154-
tauri::async_runtime::spawn_blocking(move || engines.transcribe(&path))
179+
// H2 修复:有界等待变体——引擎卡死时返回可诊断超时错误而非永久阻塞
180+
tauri::async_runtime::spawn_blocking(move || {
181+
engines.transcribe_timeout(&path, crate::engine::ASR_REQUEST_TIMEOUT)
182+
})
155183
.await
156184
.map_err(|e| format!("任务调度失败: {}", e))?
157185
.map_err(|e| e.to_string())
@@ -163,8 +191,13 @@ pub async fn recognize_image(state: State<'_, AppState>, path: String) -> Result
163191
if path.trim().is_empty() {
164192
return Err("图片路径为空".to_string());
165193
}
194+
// 安全 L1 修复:扩展名白名单前置校验(拒绝非图片路径)
195+
require_media_extension(&path, &IMAGE_EXTENSIONS, "图片")?;
166196
let engines = state.engines.clone();
167-
tauri::async_runtime::spawn_blocking(move || engines.recognize(&path))
197+
// H2 修复:有界等待变体(同 transcribe_audio)
198+
tauri::async_runtime::spawn_blocking(move || {
199+
engines.recognize_timeout(&path, crate::engine::OCR_REQUEST_TIMEOUT)
200+
})
168201
.await
169202
.map_err(|e| format!("任务调度失败: {}", e))?
170203
.map_err(|e| e.to_string())
@@ -224,20 +257,35 @@ pub async fn process_to_note(
224257
if audio_path.as_ref().is_some_and(|p| p.trim().is_empty()) {
225258
return Err("音频路径为空".to_string());
226259
}
260+
// 安全 L1 修复(三维复审 #4):直送媒体路径同样套用扩展名白名单,
261+
// 与 transcribe_audio/recognize_image 同口径(此前本 command 是缺口)
262+
if let Some(p) = audio_path.as_deref() {
263+
require_media_extension(p, &AUDIO_EXTENSIONS, "音频")?;
264+
}
265+
for p in &image_paths {
266+
require_media_extension(p, &IMAGE_EXTENSIONS, "图片")?;
267+
}
227268
let title = normalize_title(title, "未命名笔记");
228269
let engines = state.engines.clone();
229270
let db = state.db.clone();
230271
tauri::async_runtime::spawn_blocking(move || {
231272
// 1) 转写(可选音频)
273+
// H2 修复:有界等待变体——Err 携带超时/引擎诊断信息返回前端
274+
// 三维复审 #3:整文件转写用 ASR_FILE_TIMEOUT(30 分钟)——耗时与音频
275+
// 时长线性相关,短请求 60s 预算会误杀长录音(行为契约回归)
232276
let mut segments = Vec::new();
233277
if let Some(path) = audio_path {
234-
segments.push(engines.transcribe(&path).map_err(|e| e.to_string())?);
278+
segments.push(
279+
engines
280+
.transcribe_timeout(&path, crate::engine::ASR_FILE_TIMEOUT)
281+
.map_err(|e| e.to_string())?,
282+
);
235283
}
236284
// 2) 多图 OCR(单图失败跳过不阻断,但记录警告——TD-001 修复:不再静默吞错)
237285
let mut ocr_blocks = Vec::new();
238286
let mut skipped: Vec<String> = Vec::new();
239287
for path in image_paths {
240-
match engines.recognize(&path) {
288+
match engines.recognize_timeout(&path, crate::engine::OCR_REQUEST_TIMEOUT) {
241289
Ok(blocks) => ocr_blocks.extend(blocks),
242290
Err(e) => skipped.push(format!("{}: {}", path, e)),
243291
}

‎app/src-tauri/src/commands_ai_enrich.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,9 @@ pub async fn ai_enrich_start(
129129
tasks.insert(task_id, AiTaskEntry { state: AiTaskState::Pending, result: None, target_id: note_id });
130130
trim_tasks(&mut tasks);
131131
}
132-
// F2 任务中心(2026-08-21):任务记录落库(写库失败不阻断 AI 调用)
133-
let _ = st.db.insert_ai_task(&crate::db_ai_tasks::AiTaskRecord {
132+
// F2 任务中心(2026-08-21):任务记录落库。L4 修复:写库失败仍不阻断
133+
// AI 调用,但不再静默(落库失败 = 重启后任务不可恢复,必须可观测)
134+
if let Err(e) = st.db.insert_ai_task(&crate::db_ai_tasks::AiTaskRecord {
134135
task_id,
135136
op_type: "enrich".to_string(),
136137
ref_id: note_id,
@@ -144,7 +145,9 @@ pub async fn ai_enrich_start(
144145
created_at: crate::db_sessions_rows::unix_seconds(),
145146
finished_at: None,
146147
adopted: false,
147-
});
148+
}) {
149+
eprintln!("[AiTasks] enrich 任务 {} 落库失败(不阻断 AI 调用;重启后不可恢复): {}", task_id, e);
150+
}
148151
let st2 = st.clone();
149152
tauri::async_runtime::spawn_blocking(move || run_enrich_task(st2, task_id, note_id, kinds, mock));
150153
Ok(AiTaskHandle { task_id, state: AiTaskState::Pending })

‎app/src-tauri/src/commands_ai_refine.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,9 @@ pub async fn ai_refine_start(
168168
trim_tasks(&mut tasks);
169169
}
170170
// F2 任务中心(2026-08-21):任务记录落库(pending 起步;终态在
171-
// run_refine_task 回写——写库失败不阻断 AI 调用,H2 设计)
172-
let _ = st.db.insert_ai_task(&crate::db_ai_tasks::AiTaskRecord {
171+
// run_refine_task 回写)。L4 修复:写库失败仍不阻断 AI 调用(H2 设计不变),
172+
// 但不再静默——落库失败意味着任务中心重启后无法恢复该任务,必须可观测
173+
if let Err(e) = st.db.insert_ai_task(&crate::db_ai_tasks::AiTaskRecord {
173174
task_id,
174175
op_type: "refine".to_string(),
175176
ref_id: session_id,
@@ -183,7 +184,9 @@ pub async fn ai_refine_start(
183184
created_at: crate::db_sessions_rows::unix_seconds(),
184185
finished_at: None,
185186
adopted: false,
186-
});
187+
}) {
188+
eprintln!("[AiTasks] refine 任务 {} 落库失败(不阻断 AI 调用;重启后不可恢复): {}", task_id, e);
189+
}
187190
let st2 = st.clone();
188191
tauri::async_runtime::spawn_blocking(move || crate::ai_refine_task::run_refine_task(st2, task_id, session_id, mock));
189192
Ok(AiTaskHandle { task_id, state: AiTaskState::Pending })

‎app/src-tauri/src/commands_live.rs‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::sync::Arc;
1212
use std::time::Instant;
1313

1414
use serde::Serialize;
15-
use tauri::State;
15+
use tauri::{Emitter, State};
1616

1717
use crate::commands::AppState;
1818
use crate::live_session::LiveSessionParams;
@@ -153,16 +153,25 @@ fn trigger_auto_refine(state: &AppState, session_id: i64) {
153153
eprintln!("[Refine] 会话 {} 停止:检测到表格/公式区域,自动触发结构精修", session_id);
154154
let state: AppState = (*state).clone();
155155
tauri::async_runtime::spawn_blocking(move || {
156-
let _ = crate::commands_refine_inner::run_refine(&state, session_id);
156+
// L1 修复:不再静默吞错——run_refine 失败(引擎/落库异常)时日志 +
157+
// 事件双通道可观测;与内部降级路径同事件名(session:refine-skipped),
158+
// 前端已有监听无需新增。不阻断语义保留(自动精修是增强非关键路径)
159+
if let Err(e) = crate::commands_refine_inner::run_refine(&state, session_id) {
160+
eprintln!("[Refine] 会话 {} 自动精修失败: {}", session_id, e);
161+
let _ = state.app.emit("session:refine-skipped", format!("自动精修失败: {}", e));
162+
}
157163
});
158164
}
159165

160166
/// 查询实时会话状态(活动会话 id + 预热就绪标记 + 暂停标记)。
161167
#[tauri::command]
162168
pub fn live_session_status(state: State<'_, AppState>) -> LiveSessionStatus {
169+
// L1 修复:active_session_id 只查一次存局部变量(原两次调用间状态可能
170+
// 变化导致 active 与 session_id 口径不一致;也省一次锁)
171+
let active_id = state.live_session.active_session_id();
163172
LiveSessionStatus {
164-
active: state.live_session.active_session_id().is_some(),
165-
session_id: state.live_session.active_session_id(),
173+
active: active_id.is_some(),
174+
session_id: active_id,
166175
prepared: matches!(
167176
state.live_session.prepare_status(),
168177
crate::live_session_prepare::PrepareStatus::Ready

‎app/src-tauri/src/commands_session.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,9 @@ pub async fn search_ocr_blocks(
323323
state: State<'_, AppState>,
324324
keyword: String,
325325
) -> Result<Vec<crate::db_ocr_search::OcrBlockHit>, String> {
326-
crate::db_ocr_search::search_command(&state.db, &keyword).map_err(|e| e.to_string())
326+
// M4 修复:传入数据目录——命中图路径做真实存在性校验(不再假数据)
327+
crate::db_ocr_search::search_command(&state.db, Some(&state.data_dir), &keyword)
328+
.map_err(|e| e.to_string())
327329
}
328330

329331
/// 片段上下文(纯函数):命中位置前后各 12 字符(省略号标注截断)。

0 commit comments

Comments
 (0)