Skip to content

Commit 4b1f693

Browse files
committed
feat(session): 原子 batch_delete_sessions 批量删除(单事务全删)
批4 会话页交互矩阵:新增后端批量删除命令——单事务逐条 DELETE,中途失败整体回滚(与单条 delete_session 级联子表/笔记 SET NULL/域广播语义逐条一致);空 ids 与超限报错、重复去重;返回实际删除数。替代前端逐条循环的半删路径。
1 parent 7588656 commit 4b1f693

5 files changed

Lines changed: 321 additions & 0 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//! 会话批量删除 Tauri commands(批 4 会话页交互矩阵——用户问题 4 已授权彻底方案)。
2+
//!
3+
//! @ai-context: 会话页勾选批量删除原为前端逐条循环调 delete_session(部分失败
4+
//! 残留半删状态);本模块提供单事务原子 batch_delete_sessions,
5+
//! 语义与单条 delete_session 完全一致——子表外键级联
6+
//! (session_segments/session_ocr_blocks/artifact_blocks/
7+
//! session_events)、notes.session_id SET NULL、域广播
8+
//! (Sessions+Notes)均在命令层对等复刻。
9+
//! @ai-context: 本层只做参数校验、调用数据层、错误映射(AGENTS.md §6);
10+
//! DB 读写为快速操作直接调用,不额外 spawn_blocking(与
11+
//! delete_session 同口径——连接内 Mutex 保护)。
12+
13+
use tauri::State;
14+
15+
use crate::commands::AppState;
16+
use crate::types::BatchSessionDeleteResult;
17+
18+
/// 批量删除单次上限(= 列表单页上限,见 commands_session::LIST_LIMIT_MAX)。
19+
///
20+
/// @ai-context Why 有界:勾选集恒为当前可见列表子集(≤200),上限只是防御
21+
/// 越界 payload;与批量转笔记 50 条上限同为批次护栏,但删除是
22+
/// 原子全删,超限直接拒绝而非截断(防误删)。
23+
const BATCH_DELETE_LIMIT: usize = 200;
24+
25+
/// 批量删除核心编排(仅测试基准——命令入口走同函数后补域广播)。
26+
///
27+
/// @ai-context: 校验顺序:空集 → 超限 → 非法 id(与单条 delete_session 对
28+
/// id<=0 直接报错的语义逐条一致);重复 id 静默去重(同批量转
29+
/// 笔记口径——同一会话只删一次,不影响返回计数)。
30+
pub fn run_batch_delete(db: &crate::db::Db, ids: Vec<i64>) -> Result<BatchSessionDeleteResult, String> {
31+
if ids.is_empty() {
32+
return Err("批量删除 ids 不能为空".to_string());
33+
}
34+
if ids.len() > BATCH_DELETE_LIMIT {
35+
return Err(format!("批量删除上限 {} 条", BATCH_DELETE_LIMIT));
36+
}
37+
if let Some(&bad) = ids.iter().find(|&&id| id <= 0) {
38+
return Err(format!("无效的会话 id: {}", bad));
39+
}
40+
let mut uniq: Vec<i64> = ids.clone();
41+
uniq.sort_unstable();
42+
uniq.dedup();
43+
let deleted = db
44+
.delete_sessions_batch(&uniq)
45+
.map_err(|e| e.to_string())?;
46+
Ok(BatchSessionDeleteResult { deleted })
47+
}
48+
49+
/// 批量删除会话(批 4:单事务原子——全删或全不删,不再逐条半删)。
50+
///
51+
/// @ai-context: 域广播与单条 delete_session 完全一致:确有删除(deleted>0)
52+
/// 才广播 sessions + notes 域(会话删除级联断笔记关联);
53+
/// 全不存在/全空结果不广播(无变化不打扰订阅端)。
54+
#[tauri::command]
55+
pub async fn batch_delete_sessions(
56+
state: State<'_, AppState>,
57+
ids: Vec<i64>,
58+
) -> Result<BatchSessionDeleteResult, String> {
59+
let result = run_batch_delete(&state.db, ids)?;
60+
if result.deleted > 0 {
61+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Sessions);
62+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
63+
}
64+
Ok(result)
65+
}
66+
67+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
68+
#[cfg(test)]
69+
#[path = "commands_session_delete_tests.rs"]
70+
mod delete_tests;
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
//! 批量删除会话单测(批 4;AAA 模式 + :memory: 隔离库,绝不触碰真实数据)。
2+
//!
3+
//! @ai-context: 由 commands_session_delete.rs 以 #[cfg(test)] #[path] 引入;
4+
//! 被测对象 run_batch_delete 为命令核心(注入 Db,无 Tauri 态),
5+
//! 域广播在命令宏入口、不在此测试(与批量转笔记测试同构)。
6+
//! @ai-context: 原子性用 SQLite RAISE(ABORT) 触发器模拟中途失败——真实语句
7+
//! 失败难以在 :memory: 造出,触发器是 schema 层最接近的失败源。
8+
9+
use crate::commands_session_delete::run_batch_delete;
10+
use crate::db::Db;
11+
use crate::types::{NewNote, NewSession, NewSessionSegment};
12+
13+
fn mem_db() -> Db {
14+
Db::open(":memory:").expect("open in-memory db")
15+
}
16+
17+
/// 造一个已结束(finished)会话(带转写段 = 有内容)。
18+
fn finished_session(db: &Db, title: &str) -> i64 {
19+
let s = db
20+
.create_session(&NewSession {
21+
title: title.into(),
22+
source_window: None,
23+
profile: None,
24+
kind: None,
25+
})
26+
.expect("create session");
27+
db.finish_session(s.id).expect("finish session");
28+
db.add_segment(&NewSessionSegment {
29+
session_id: s.id,
30+
start_ms: 0,
31+
end_ms: 1000,
32+
text: format!("{} 的转写", title),
33+
source: "asr".into(),
34+
confidence: Some(0.9),
35+
volume: None,
36+
speech_rate: None,
37+
pause_ms: None,
38+
speaker: None,
39+
})
40+
.expect("add segment");
41+
s.id
42+
}
43+
44+
/// 直插子表行(转写段由 finished_session 已插一条;此处补 OCR 块/产物块/
45+
/// 信号事件各一——加段共四张表四行,验证删除外键级联全清)。
46+
fn seed_child_rows(db: &Db, session_id: i64) {
47+
db.with_conn(|conn| {
48+
conn.execute(
49+
"INSERT INTO session_ocr_blocks (session_id, timestamp_ms, text, score, region)
50+
VALUES (?1, 0, 'cascade ocr', 0.9, 'full')",
51+
rusqlite::params![session_id],
52+
)?;
53+
conn.execute(
54+
"INSERT INTO artifact_blocks (session_id, kind, payload_json, block_order)
55+
VALUES (?1, 'chapter', '{}', 0)",
56+
rusqlite::params![session_id],
57+
)?;
58+
conn.execute(
59+
"INSERT INTO session_events (session_id, kind, timestamp_ms)
60+
VALUES (?1, 'app_foreground', 0)",
61+
rusqlite::params![session_id],
62+
)?;
63+
Ok(())
64+
})
65+
.expect("seed child rows");
66+
}
67+
68+
/// 子表残留行数(>0 说明级联未生效)。
69+
fn child_row_count(db: &Db, session_id: i64) -> i64 {
70+
db.with_conn(|conn| {
71+
let mut count = 0i64;
72+
for table in ["session_segments", "session_ocr_blocks", "artifact_blocks", "session_events"] {
73+
let sql = format!("SELECT COUNT(*) FROM {} WHERE session_id = ?1", table);
74+
count += conn.query_row(&sql, rusqlite::params![session_id], |row| {
75+
row.get::<_, i64>(0)
76+
})?;
77+
}
78+
Ok(count)
79+
})
80+
.expect("count child rows")
81+
}
82+
83+
#[test]
84+
fn batch_delete_removes_all_and_cascades_children() {
85+
// Arrange:三个会话(两个待删),各带四张子表行
86+
let db = mem_db();
87+
let a = finished_session(&db, "A课");
88+
let b = finished_session(&db, "B课");
89+
let keep = finished_session(&db, "保留课");
90+
for id in [a, b, keep] {
91+
seed_child_rows(&db, id);
92+
}
93+
assert_eq!(child_row_count(&db, a), 4);
94+
// Act
95+
let result = run_batch_delete(&db, vec![a, b]).expect("batch delete");
96+
// Assert:计数精确;会话与子表全清;未选会话原样保留
97+
assert_eq!(result.deleted, 2);
98+
assert!(db.get_session(a).expect("db").is_none());
99+
assert!(db.get_session(b).expect("db").is_none());
100+
assert_eq!(child_row_count(&db, a), 0);
101+
assert_eq!(child_row_count(&db, b), 0);
102+
assert_eq!(child_row_count(&db, keep), 4);
103+
assert!(db.get_session(keep).expect("db").is_some());
104+
}
105+
106+
#[test]
107+
fn batch_delete_atomic_rollback_on_mid_failure() {
108+
// Arrange:A 在触发器守卫之前、B 命中守卫——任何实现若逐条提交都会半删
109+
let db = mem_db();
110+
let a = finished_session(&db, "A课");
111+
let b = finished_session(&db, "B课");
112+
db.with_conn(|conn| {
113+
// 触发器 DDL 不支持绑定变量——b 为刚由本库 create_session 生成的 i64,
114+
// 非用户输入,直接内联安全(防御注释:杜绝字符串拼接的例外场景)
115+
let sql = format!(
116+
"CREATE TRIGGER guard_delete BEFORE DELETE ON sessions
117+
WHEN OLD.id = {} BEGIN SELECT RAISE(ABORT, 'guard: session busy'); END",
118+
b,
119+
);
120+
conn.execute_batch(&sql)?;
121+
Ok(())
122+
})
123+
.expect("create guard trigger");
124+
// Act
125+
let err = run_batch_delete(&db, vec![a, b]).expect_err("must fail atomically");
126+
// Assert:整体报错且 A 也未被删除(单事务回滚——无半删残留)
127+
assert!(err.contains("guard: session busy"), "err={err}");
128+
assert!(db.get_session(a).expect("db").is_some(), "A 应随事务回滚保留");
129+
assert!(db.get_session(b).expect("db").is_some());
130+
}
131+
132+
#[test]
133+
fn batch_delete_keeps_note_but_unbinds_session() {
134+
// Arrange:已转笔记的会话(笔记是用户资产——删会话只断关联不删笔记)
135+
let db = mem_db();
136+
let id = finished_session(&db, "已转课");
137+
let note = db
138+
.create_note(&NewNote {
139+
title: "转换笔记".into(),
140+
content: "x".into(),
141+
source: "classroom".into(),
142+
session_id: Some(id),
143+
rule_version: None,
144+
purify_stats: None,
145+
tags: None,
146+
properties: None,
147+
group_id: None,
148+
})
149+
.expect("create note");
150+
// Act
151+
run_batch_delete(&db, vec![id]).expect("batch delete");
152+
// Assert:笔记保留且 session_id 已置空(外键 SET NULL 语义)
153+
let kept = db.get_note(note.id).expect("db").expect("note kept");
154+
assert_eq!(kept.session_id, None);
155+
assert!(db.find_note_by_session(id).expect("db").is_none());
156+
}
157+
158+
#[test]
159+
fn batch_delete_coexists_with_batch_convert() {
160+
// Arrange:与批量转笔记同一编排链走一遍(先转后删——真实页面动线)
161+
let db = mem_db();
162+
let id = finished_session(&db, "先转后删课");
163+
let converted = crate::commands_session_note::run_batch_conversion(
164+
&db,
165+
&crate::ui_junk::UiJunkList::defaults(),
166+
&crate::note_filter::PurifyEnv::default(),
167+
std::path::Path::new("."),
168+
vec![id],
169+
)
170+
.expect("batch convert");
171+
assert_eq!(converted.converted.len(), 1);
172+
let note_id = converted.converted[0].note_id;
173+
// Act:删除已转会话
174+
run_batch_delete(&db, vec![id]).expect("batch delete");
175+
// Assert:笔记留存且解绑;会话已删
176+
let kept = db.get_note(note_id).expect("db").expect("note kept");
177+
assert_eq!(kept.session_id, None);
178+
assert!(db.get_session(id).expect("db").is_none());
179+
}
180+
181+
#[test]
182+
fn batch_delete_validation_and_counting() {
183+
let db = mem_db();
184+
let a = finished_session(&db, "A课");
185+
// 空 ids → 报错
186+
let err = run_batch_delete(&db, vec![]).expect_err("empty must err");
187+
assert!(err.contains("不能为空"), "err={err}");
188+
// 非正 id → 报错(与单条 delete_session 同口径)
189+
let err = run_batch_delete(&db, vec![0]).expect_err("invalid must err");
190+
assert!(err.contains("无效的会话 id"), "err={err}");
191+
// 超上限 → 报错(201 > 200)
192+
let over: Vec<i64> = (1..=201).collect();
193+
let err = run_batch_delete(&db, over).expect_err("over-limit must err");
194+
assert!(err.contains("上限"), "err={err}");
195+
// 重复 id 静默去重 + 不存在的 id 宽容(计 0,不报错——与单条一致)
196+
let result = run_batch_delete(&db, vec![a, a, 99_999]).expect("dedupe & missing ok");
197+
assert_eq!(result.deleted, 1);
198+
assert!(db.get_session(a).expect("db").is_none());
199+
}

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,46 @@ impl Db {
131131
})
132132
}
133133

134+
/// 批量删除会话(批 4 会话页交互矩阵:后端原子 batch_delete_sessions)。
135+
///
136+
/// @ai-context Why 单事务逐条 DELETE 而非一条 `WHERE id IN (...)`:本函数与
137+
/// 单条 delete_session 走同一条 DELETE 语句(外键级联子表 +
138+
/// notes.session_id SET NULL 逐行生效),任一条中途失败(如
139+
/// 外部触发器 RAISE)整体 ROLLBACK——批量=多次单条语义的原子版,
140+
/// 绝不留半删状态(用户勾选多行后要的是"全删或全不删")。
141+
/// @ai-context: 返回实际删除行数(已不存在/重复 id 计 0,不报错——与单条
142+
/// delete_session 对不存在会话返回 Ok(false) 的宽容语义一致);
143+
/// ids 去重由命令层负责,本层不假设输入形状。
144+
pub fn delete_sessions_batch(&self, ids: &[i64]) -> Result<usize> {
145+
if ids.is_empty() {
146+
return Ok(0);
147+
}
148+
self.with_conn(|conn| {
149+
conn.execute("BEGIN TRANSACTION", [])?;
150+
let result = (|| -> rusqlite::Result<usize> {
151+
let mut deleted = 0usize;
152+
{
153+
let mut stmt = conn.prepare("DELETE FROM sessions WHERE id = ?1")?;
154+
for &id in ids {
155+
deleted += stmt.execute(params![id])?;
156+
}
157+
}
158+
Ok(deleted)
159+
})();
160+
match result {
161+
Ok(n) => {
162+
conn.execute("COMMIT", [])?;
163+
Ok(n)
164+
}
165+
Err(e) => {
166+
let _ = conn.execute("ROLLBACK", []);
167+
Err(e)
168+
}
169+
}
170+
.map_err(Into::into)
171+
})
172+
}
173+
134174
// ── REQ-282(v0.19.6):标题内容化 A 层 ──
135175

136176
/// 近 N 天非失败会话的标题(同源去重候选;录制/已结束都算,失败排除)。

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ mod commands_refine_inner;
186186
mod commands_session;
187187
// v0.7.6 审查硬拆:会话 → 笔记转换管线(原料装载/结构渲染/单条转换/批量编排/预览)
188188
mod commands_session_note;
189+
// 批 4(会话页交互矩阵):批量删除会话(单事务原子;语义/广播与单条一致)
190+
mod commands_session_delete;
189191
// v0.11.5(spec 8️⃣):会话详情术语表——词汇表移出笔记后直供前端展示
190192
mod commands_session_glossary;
191193
// v0.7.7(REQ-182/183/184):结构图命令层——批量捕获/手动框选/列表/删除
@@ -715,6 +717,8 @@ pub fn run() {
715717
commands_session::list_sessions,
716718
commands_session::get_session_detail,
717719
commands_session::delete_session,
720+
// 批 4(会话页交互矩阵):批量删除(单事务原子,替代前端逐条循环)
721+
commands_session_delete::batch_delete_sessions,
718722
// REQ-282(v0.19.6):会话改名(title_kind=manual 停止自动覆写)
719723
commands_session::update_session_title,
720724
commands_session::add_session_segment,

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,14 @@ pub struct BatchNoteResult {
548548
pub skipped: Vec<SkippedNote>,
549549
}
550550

551+
/// 批量删除会话结果(批 4:原子全删语义——失败整体报错,无部分成功)。
552+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
553+
#[serde(rename_all = "camelCase")]
554+
pub struct BatchSessionDeleteResult {
555+
/// 实际删除的会话行数(传入中已不存在的 id 不计入)。
556+
pub deleted: usize,
557+
}
558+
551559
/// 笔记列表排序模式(v0.10.0)。
552560
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
553561
#[serde(rename_all = "kebab-case")]

0 commit comments

Comments
 (0)