Skip to content

Commit b0508d9

Browse files
committed
fix(session): 删除会话时级联清理音频文件
1 parent 5a2170b commit b0508d9

5 files changed

Lines changed: 191 additions & 12 deletions

File tree

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,12 @@ pub fn session_audio_cleanup(state: State<'_, AppState>) -> Result<crate::audio_
118118
}
119119

120120
/// 会话音频目录名(与写入侧 `audio_store` 的 `{data_dir}/session-audio/{id}.wav` 一致)。
121-
const SESSION_AUDIO_DIR: &str = "session-audio";
121+
///
122+
/// @ai-context: `pub(crate)`(批 8 T28):删除侧的清理面
123+
/// (`commands_session::session_audio_purge`)按**同一常量**拼路径 ——
124+
/// 读写两侧各写一份字面量,正是「读的地方有音频、删的地方找不到」这类
125+
/// 静默漂移的温床。可见性放开是**零行为**改动(编译期单一来源)。
126+
pub(crate) const SESSION_AUDIO_DIR: &str = "session-audio";
122127
/// WAV 头长度(RIFF 12 + fmt 24 + data 8;与 `audio_store::WAV_HEADER_LEN` 同值)。
123128
const WAV_HEADER_LEN: usize = 44;
124129
/// `data` 块长度字段在头内的偏移(`audio_store::write_header` 的写入位置)。

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

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ use tauri::State;
1212
use crate::commands::{normalize_title, AppState, TITLE_MAX_CHARS};
1313
use crate::types::{NewSession, NewSessionOcrBlock, NewSessionSegment, Session, SessionDetail, SessionListItem};
1414

15+
/// 会话删除的音频级联清理面(T28;`#[path]` 由父模块声明 ⇒ `lib.rs` 一行未动)。
16+
#[path = "session_audio_purge.rs"]
17+
pub(crate) mod session_audio_purge;
18+
1519
/// 会话列表单页上限。
1620
///
1721
/// @ai-context: pub(crate) 供 commands_session_delete 等引用(批 4 审查修复
@@ -164,13 +168,10 @@ pub async fn get_session_detail(state: State<'_, AppState>, id: i64) -> Result<S
164168
Ok(SessionDetail { session, segments, ocr_blocks, events, screens })
165169
}
166170

167-
/// 删除会话(级联清理转写段与 OCR 块)。
171+
/// 删除会话(级联清理转写段与 OCR 块 + **音频文件**,批 8 T28/U1-a)。
168172
#[tauri::command]
169173
pub async fn delete_session(state: State<'_, AppState>, id: i64) -> Result<bool, String> {
170-
if id <= 0 {
171-
return Err("无效的会话 id".to_string());
172-
}
173-
let ok = state.db.delete_session(id).map_err(|e| e.to_string())?;
174+
let ok = delete_session_in(&state.db, &state.data_dir, id)?;
174175
// REQ-278:删除会话 → 广播 sessions 域(列表/关联视图即时刷新)
175176
if ok {
176177
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Sessions);
@@ -180,6 +181,29 @@ pub async fn delete_session(state: State<'_, AppState>, id: i64) -> Result<bool,
180181
Ok(ok)
181182
}
182183

184+
/// `delete_session` 的命令体(抽出是为可单测:`#[tauri::command]` 薄壳要运行中的
185+
/// `AppHandle`,单测里造不出——同 T22 `session_audio_ref_in` 口径)。
186+
///
187+
/// @ai-context: 顺序 = ① 校验 id ② DB 删除(失败**上抛**,文件一个不动——活会话不得丢音频)
188+
/// ③ 音频清理 `{data_dir}/session-audio/{id}.wav` + sidecar;清理失败只登记
189+
/// 不上抛(「部分删除」是允许的终态,见 `session_audio_purge` 的「DB 原子 +
190+
/// 文件非原子」注解;AGENTS.md §4:不得空忽略,失败经 `report` 落 stderr)。
191+
/// @ai-context: **不读** `AudioStoreConfig`:删音频与「是否落盘」无关(关掉落盘不得变成
192+
/// 「删会话不清音频」的豁免通道)。已不存在的会话(`ok == false`)同样清理
193+
/// ——幂等自愈,返回值语义逐字不变(仍 `Ok(false)`)。
194+
pub(crate) fn delete_session_in(
195+
db: &crate::db::Db,
196+
data_dir: &std::path::Path,
197+
id: i64,
198+
) -> Result<bool, String> {
199+
if id <= 0 {
200+
return Err("无效的会话 id".to_string());
201+
}
202+
let ok = db.delete_session(id).map_err(|e| e.to_string())?;
203+
session_audio_purge::purge_session_audio(data_dir, &[id]).report("delete_session", &[id]);
204+
Ok(ok)
205+
}
206+
183207
/// 追加转写段(**不是**实时捕获链路的调用点 —— 真实链路是 `live_session_persist.rs` 直呼 `db.add_segment`)。
184208
/// @ai-context 已依规格 §1 L5 行 31 从 IPC 撤下(批 7);实现保留供内部/未来接线,勿再注册。
185209
#[allow(dead_code)] // 批 7:已从 IPC 撤下(规格 §1 L5 行 31)⇒ lib 内暂无调用方,保留实现(同 db_colors.rs:42 先例)

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

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
//! @ai-context: 本层只做参数校验、调用数据层、错误映射(AGENTS.md §6);
1010
//! DB 读写为快速操作直接调用,不额外 spawn_blocking(与
1111
//! delete_session 同口径——连接内 Mutex 保护)。
12+
//! @ai-context: 批 8 T28(U1-a #2):两条删除路径**都**级联清理音频文件
13+
//! (`{data_dir}/session-audio/{id}.wav` + 对齐 sidecar)——批量删若漏这一步,
14+
//! 勾选多行删完音频仍留在盘上,与「数据不出本机」的承诺冲突。🔴 DB 删除是
15+
//! 单事务原子,而**文件系统删除无法参与该事务** ⇒ 「DB 原子 + 文件非原子」
16+
//! 必然产生「会话记录已删、个别音频仍在」的中间态 ⇒ 清理失败**不上抛**
17+
//! (会话记录消失才是用户意图),只登记 —— **「部分删除」是允许的终态**。
1218
1319
use tauri::State;
1420

@@ -27,12 +33,13 @@ use crate::types::BatchSessionDeleteResult;
2733
/// 豁免,两条路径同样受 UI 勾选入口与确认弹窗约束。
2834
const BATCH_DELETE_LIMIT: u64 = LIST_LIMIT_MAX;
2935

30-
/// 批量删除核心编排(仅测试基准——命令入口走同函数后补域广播)。
36+
/// 批量删除的校验 + 去重(纯函数;`run_batch_delete` 与 `run_batch_delete_with_audio` 共用)。
3137
///
3238
/// @ai-context: 校验顺序:空集 → 超限 → 非法 id(与单条 delete_session 对
3339
/// id<=0 直接报错的语义逐条一致);重复 id 静默去重(同批量转
34-
/// 笔记口径——同一会话只删一次,不影响返回计数)。
35-
pub fn run_batch_delete(db: &crate::db::Db, ids: Vec<i64>) -> Result<BatchSessionDeleteResult, String> {
40+
/// 笔记口径——同一会话只删一次,不影响返回计数)。排序后去重 ⇒
41+
/// 返回集**顺序确定**,音频清理的入参也因此确定(T28)。
42+
fn validated_ids(ids: &[i64]) -> Result<Vec<i64>, String> {
3643
if ids.is_empty() {
3744
return Err("批量删除 ids 不能为空".to_string());
3845
}
@@ -42,26 +49,57 @@ pub fn run_batch_delete(db: &crate::db::Db, ids: Vec<i64>) -> Result<BatchSessio
4249
if let Some(&bad) = ids.iter().find(|&&id| id <= 0) {
4350
return Err(format!("无效的会话 id: {}", bad));
4451
}
45-
let mut uniq: Vec<i64> = ids.clone();
52+
let mut uniq: Vec<i64> = ids.to_vec();
4653
uniq.sort_unstable();
4754
uniq.dedup();
55+
Ok(uniq)
56+
}
57+
58+
/// 批量删除核心编排(仅测试基准——命令入口走同函数后补域广播)。
59+
///
60+
/// @ai-context: T28 起本函数只管 **DB 面**(签名与语义逐字不变 ⇒ 批 4 起的既有用例
61+
/// 继续覆盖它);音频级联清理在 `run_batch_delete_with_audio` 里叠加。
62+
pub fn run_batch_delete(db: &crate::db::Db, ids: Vec<i64>) -> Result<BatchSessionDeleteResult, String> {
63+
let uniq = validated_ids(&ids)?;
4864
let deleted = db
4965
.delete_sessions_batch(&uniq)
5066
.map_err(|e| e.to_string())?;
5167
Ok(BatchSessionDeleteResult { deleted })
5268
}
5369

70+
/// 批量删除的**完整**编排:DB 原子删除 + 音频级联清理(命令入口与单测共用)。
71+
///
72+
/// @ai-context: 分层与顺序——先 `run_batch_delete`(任何校验/DB 失败**早退**,文件一个不动:
73+
/// 会话还在,音频必须还在),再清音频(失败只登记不上抛)。
74+
/// @ai-context: 为什么重复校验一次(`run_batch_delete` 内部已校验):校验是纯函数、幂等、
75+
/// O(n)(n ≤ 200 上限),重算一次换来 DB 核心**签名零改动**(既有用例零改动),
76+
/// 而这里需要**去重后的 id 集**来定位音频文件(`uniq` 不再回传)。
77+
/// @ai-context: 返回值的 `deleted` 只数**会话行**,不含文件——IPC 契约(
78+
/// `BatchSessionDeleteResult` 的线格式)逐字不变 ⇒ 前端零改动。
79+
pub fn run_batch_delete_with_audio(
80+
db: &crate::db::Db,
81+
data_dir: &std::path::Path,
82+
ids: Vec<i64>,
83+
) -> Result<BatchSessionDeleteResult, String> {
84+
let uniq = validated_ids(&ids)?;
85+
let result = run_batch_delete(db, ids)?;
86+
crate::commands_session::session_audio_purge::purge_session_audio(data_dir, &uniq)
87+
.report("batch_delete_sessions", &uniq);
88+
Ok(result)
89+
}
90+
5491
/// 批量删除会话(批 4:单事务原子——全删或全不删,不再逐条半删)。
5592
///
5693
/// @ai-context: 域广播与单条 delete_session 完全一致:确有删除(deleted>0)
5794
/// 才广播 sessions + notes 域(会话删除级联断笔记关联);
5895
/// 全不存在/全空结果不广播(无变化不打扰订阅端)。
96+
/// @ai-context: T28:编排体换成 `run_batch_delete_with_audio`(同一 DB 语义 + 音频清理)。
5997
#[tauri::command]
6098
pub async fn batch_delete_sessions(
6199
state: State<'_, AppState>,
62100
ids: Vec<i64>,
63101
) -> Result<BatchSessionDeleteResult, String> {
64-
let result = run_batch_delete(&state.db, ids)?;
102+
let result = run_batch_delete_with_audio(&state.db, &state.data_dir, ids)?;
65103
if result.deleted > 0 {
66104
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Sessions);
67105
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//! 会话删除的音频级联清理(批 8 T28 · 用户裁决 U1-a 的 #2)。
2+
//!
3+
//! @ai-context: 产品问题——**删会话不删音频**:用户删了会话,磁盘上的
4+
//! `{data_dir}/session-audio/{id}.wav` 与 T23 对齐 sidecar
5+
//! `{id}.wav.meta.json` 还留着,与「数据不出本机」的承诺(AGENTS.md §4)冲突。
6+
//! 本模块是删会话**两条**路径——单条 `commands_session::delete_session_in`
7+
//! 与批量 `commands_session_delete::run_batch_delete_with_audio`——共用的清理面。
8+
//! @ai-context: 🔴 为什么「部分删除」是**允许的终态**(**显式设计,不是意外**):DB 删除是
9+
//! 单事务原子(`commands_session_delete.rs` 头注逐字「单事务原子
10+
//! batch_delete_sessions」),而**文件系统删除无法参与该事务** ⇒
11+
//! 「DB 原子 + 文件非原子」**必然**产生「会话记录已删、个别音频文件仍在」的
12+
//! 中间态。事务消不掉它,只能**选择失败方向**:删会话以**会话记录消失**为准
13+
//! (用户意图就是「这个会话别留」),文件删除失败**只登记不阻断** ⇒
14+
//! 本模块**永不返回 `Err`**(返回 `AudioPurgeSummary` 供调用方登记)。
15+
//! @ai-context: 顺序**先 DB 后文件**(调用方保证):DB 删除失败/回滚 ⇒ 文件一个不动
16+
//! (会话还在,音频必须还在——否则是「活会话丢音频」的真数据损失)。
17+
//! @ai-context: 与落盘开关**无关**(不读 `AudioStoreConfig`):删会话就该删它的音频,
18+
//! 否则「关掉落盘开关」会变成「删会话不清音频」的豁免通道,隐私面反被削弱。
19+
//! @ai-context: 幂等——音频 / sidecar 不存在(含整个 `session-audio/` 目录不存在)不报错、
20+
//! 不计失败:`NotFound` 是**唯一**被吞的错误,且被**显式计数**(`absent`);
21+
//! 其余错误一律进 `failures`(AGENTS.md §4 红线:不得空 catch / 忽略错误)。
22+
//! @ai-context: 路径由 `data_dir` + `session_id: i64` 构造,**不拼用户输入**、不用相对路径
23+
//! (AGENTS.md §4「文件系统访问限定应用数据目录」)。
24+
25+
use std::path::{Path, PathBuf};
26+
27+
use crate::commands_audio::SESSION_AUDIO_DIR;
28+
29+
/// 会话音频清理结果(命令层登记/上报用)。
30+
///
31+
/// @ai-context: `deleted` / `absent` / `failures` 三态互斥且**穷尽** `remove_file` 的返回
32+
/// ——覆盖「真删掉 / 本就不存在 / 真失败」全部出口,**没有**「静默丢弃」档。
33+
#[derive(Debug, Default, Clone, PartialEq)]
34+
pub struct AudioPurgeSummary {
35+
/// 实际删除的文件数(WAV 本体与 sidecar 都计)
36+
pub deleted: usize,
37+
/// 本就不存在、无需删除的文件数(幂等,**不算**失败)
38+
pub absent: usize,
39+
/// 失败明细(`路径: 原因`)——命令层据此登记
40+
pub failures: Vec<String>,
41+
}
42+
43+
impl AudioPurgeSummary {
44+
/// 失败文件数。
45+
pub fn failed(&self) -> usize {
46+
self.failures.len()
47+
}
48+
49+
/// 是否无失败(`false` ⇒ 存在「部分删除」终态,须登记)。
50+
pub fn is_clean(&self) -> bool {
51+
self.failures.is_empty()
52+
}
53+
54+
/// 失败登记(命令层调用;**无失败不打日志**,正常路径不刷屏)。
55+
///
56+
/// @ai-context: 这是「失败项必须登记/上报」的落地形态:文件删除失败**不上抛**
57+
/// (不阻断已生效的会话删除),但必须**可见**——stderr 一条 + 调用方手里的
58+
/// 计数与明细。🔴 绝不写成 `let _ = fs::remove_file(..)` 这种空忽略
59+
/// (AGENTS.md §4;`audio_store.rs` 的 sidecar 写失败是本仓既有的
60+
/// `let _ = std::fs::write(..)` 形态,属**别的**任务面,本件不复制它)。
61+
pub fn report(&self, scope: &str, ids: &[i64]) {
62+
if self.is_clean() {
63+
return;
64+
}
65+
eprintln!(
66+
"[sessions] {scope} 音频清理失败 {} 件(已删 {} · 本不存在 {})ids={:?}:{:?} —— 会话记录已删;残留音频待下次删除或人工清理",
67+
self.failed(),
68+
self.deleted,
69+
self.absent,
70+
ids,
71+
self.failures
72+
);
73+
}
74+
}
75+
76+
/// 会话音频的两件路径:WAV 本体 + T23 对齐 sidecar。
77+
///
78+
/// @ai-context: sidecar 名与写侧(`audio_store.rs` 的 `format!("{}.wav.meta.json", ..)`)和
79+
/// 读侧(`commands_audio::read_aligned`)逐字同形——改名必须同提交改三处
80+
/// (T23 的读侧快照纪律:改名会让读侧**静默回落** `aligned = false`)。
81+
pub fn session_audio_paths(base: &Path, session_id: i64) -> (PathBuf, PathBuf) {
82+
(base.join(format!("{}.wav", session_id)), base.join(format!("{}.wav.meta.json", session_id)))
83+
}
84+
85+
/// 删一个文件:成功 / 不存在 / 失败 三态归档(幂等;失败登记不阻断)。
86+
fn remove_counted(path: &Path, summary: &mut AudioPurgeSummary) {
87+
match std::fs::remove_file(path) {
88+
Ok(()) => summary.deleted += 1,
89+
// 幂等:本就不存在(含 session-audio/ 目录本身不存在)——不算失败,但显式计数
90+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => summary.absent += 1,
91+
Err(e) => summary.failures.push(format!("{}: {}", path.display(), e)),
92+
}
93+
}
94+
95+
/// 级联清理若干会话的音频(**两条删除路径共用**;永不返回 `Err`)。
96+
///
97+
/// @ai-context: 入参 id 由调用方在删除前校验(`<= 0` 已拒绝,批 4 口径)——本函数只做
98+
/// 「把 `{id}.wav` 与 sidecar 从应用数据目录删掉」这一件事,不连库、不发事件。
99+
/// @ai-context: 对**已不存在**的会话 id 同样清理:用户删会话的意图是「这个会话的东西都别留」,
100+
/// 而该 id 已无行可删时(`delete_session` 返 `Ok(false)`)磁盘上残留的音频只可能
101+
/// 来自半删 / 清理漏项 ⇒ 一并删掉是**自愈**,不是越界(该文件名只可能由本应用
102+
/// 按会话 id 写出)。返回值语义不变(单条仍是 `Ok(false)`、批量 `deleted` 不含它)。
103+
pub fn purge_session_audio(data_dir: &Path, ids: &[i64]) -> AudioPurgeSummary {
104+
let base = data_dir.join(SESSION_AUDIO_DIR);
105+
let mut summary = AudioPurgeSummary::default();
106+
for &id in ids {
107+
let (wav, sidecar) = session_audio_paths(&base, id);
108+
remove_counted(&wav, &mut summary);
109+
remove_counted(&sidecar, &mut summary);
110+
}
111+
summary
112+
}

‎docs/standards/line-limit-exemptions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
| app/src-tauri/src/structure_note_tests.rs | 458 | v0.7.6(REQ-177~181):结构渲染层单测域(章节插入位置/命名窗口/词汇表排序上限锚点/零回归护栏/JSON 往返)单模块 #[path] 挂载 | 若再增长:词汇表组拆至 structure_note_glossary_tests.rs |
5353
| app/src-tauri/src/db_note_group_clean_tests.rs | 447 | REQ-316(批 7)测试域:判定表(自动/系列/手动/改判/五类残留/影响面外/级联卫生)+ 写路径集成 16 例,单模块 #[path] 挂载 | 若再增长:写路径集成组拆至 db_note_group_clean_flow_tests.rs |
5454
| app/src/pages/KnowledgePage.tsx | 444 | v0.13.8 画布接线越线(原 296):中栏「画布」标签 + 树/画布双入口 + v0.15 左列/详情列状态迁移(useColumnLayout + 折叠窄条 + 宽度 prop)——页面编排层内聚(数据获取/选中态/标签态为页面本地状态),子组件已全部下沉(Tree/Canvas/DetailPanel/Wizard/ConceptCardRow/Sample) | 若再增长:中栏视图块(树/画布/概念/模型 + 标签栏)拆至 KnowledgeMiddlePane.tsx,SystemCard 拆至 SystemSidebarCard.tsx |
55-
| app/src-tauri/src/commands_session.rs | 447 | v0.6.0 M6 + v0.7.6 审查硬拆后回归:会话命令域(CRUD/质量报告/课程分组/段搜索)内聚;笔记转换管线已拆至 commands_session_note.rs;M2 修复(search_ocr_blocks 传 data_dir 参数) | 若再增长:course/search 拆至 commands_session_extra.rs |
55+
| app/src-tauri/src/commands_session.rs | 471 | v0.6.0 M6 + v0.7.6 审查硬拆后回归:会话命令域(CRUD/质量报告/课程分组/段搜索)内聚;笔记转换管线已拆至 commands_session_note.rs;M2 修复(search_ocr_blocks 传 data_dir 参数);批 8 T28 加「删会话的音频级联清理」接线与 `#[path]` 子模块声明(+24) | 若再增长:course/search 拆至 commands_session_extra.rs |
5656
| app/src-tauri/src/symbol_normalize.rs | 443 | v0.6.0 M1(REQ-060):口语符号映射域(映射表/上下文守卫/中文数字解析)内聚;数字解析与守卫共享字符判定 | 若再增长:parse_chinese_number/replace_number_runs 拆至 symbol_numbers.rs |
5757
| app/src-tauri/src/commands_goals_plan.rs | 442 | 规划=单次同步调用 + spawn_blocking(10-30s 交互等待可接受,对话框 loading 态);审计完整(ai_tasks op_type='goal_plan',trajectory/成本落库——轨迹可见性达成,任务化轮询留观察项)。(自动摘取,待细化) | 若再增长:按职责拆分 |
5858
| app/src-tauri/src/screens.rs | 437 | v0.7.3(REQ-155/156/160):画面要点屏构建编排(分组/聚类/图匹配 IO)+ 可消费块过滤扩展——编排与纯函数分层(纯函数在 screen_merge.rs) | 若再增长:filter_usable_blocks 拆至 screen_filter.rs |

0 commit comments

Comments
 (0)