Skip to content

Commit 190cb3c

Browse files
committed
feat(notes): REQ-316 路由空组随写路径事务自动清理
- 新 db_note_group_clean.rs:自动路由组谓词单点(source route/series 且 route_overridden=0——手动建组/改判组永不自动删)+ 五类残留计数单点 (与 get_group_delete_impact 同口径,影响面改接共用防漂移)+ 组行删除 语义共享(delete_group 与自动清理同一实现) - 五写路径(delete_note/move_note_to_group/delete_fragment/ update_fragment_group/promote_fragment_to_note)改显式事务:源组清空 同事务判定并删组(无独立定时/全表清扫任务);结果契约扩展 autoCleanedGroups(组标题),组域条件广播(无清理零变化、复用既有 通道无双发) - 单测 16 例(判定表/五类残留逐类挡/影响面外不碰/级联卫生/写路径集成); 全量回归 2321 通过/0 失败/6 ignored;clippy 本批文件零告警
1 parent 1243641 commit 190cb3c

13 files changed

Lines changed: 882 additions & 116 deletions

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -477,12 +477,22 @@ pub async fn update_note(
477477
}
478478

479479
/// 删除笔记(REQ-004;v0.15 顺带清理笔记图片目录——防孤立残留)。
480+
///
481+
/// @ai-context: REQ-316(批 7)返回契约扩展为 DeleteNoteResult——删除使空路由组
482+
/// 被自动清理时回传组标题(前端 toast 留痕;组域刷新走下方条件
483+
/// 广播,无清理零变化)。
480484
#[tauri::command]
481-
pub async fn delete_note(state: State<'_, AppState>, id: i64) -> Result<bool, String> {
485+
pub async fn delete_note(
486+
state: State<'_, AppState>,
487+
id: i64,
488+
) -> Result<crate::types::DeleteNoteResult, String> {
482489
if id <= 0 {
483490
return Err("无效的笔记 id".to_string());
484491
}
485-
let deleted = state.db.delete_note(id).map_err(|e| e.to_string())?;
492+
let out = state.db.delete_note(id).map_err(|e| e.to_string())?;
493+
let deleted = out.deleted;
494+
// 自动清理留痕(空数组=无清理——前端零变化)
495+
let auto_cleaned_groups = out.auto_cleaned.iter().map(|g| g.name.clone()).collect();
486496
if deleted {
487497
// 审查 L6:删除即清手动序行(note_orders 无 FK——防孤儿行累积)
488498
let _ = state.db.purge_note_ids(&[id]);
@@ -498,8 +508,13 @@ pub async fn delete_note(state: State<'_, AppState>, id: i64) -> Result<bool, St
498508
// Knowledge 页图谱/引用同样需即时刷新(低-1 审查补端)
499509
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
500510
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Knowledge);
511+
// REQ-316:删除触发空组自动清理 → 组域广播(侧栏空组消失即时可见;
512+
// 无清理不发——零变化纪律)
513+
if !out.auto_cleaned.is_empty() {
514+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::NoteGroups);
515+
}
501516
}
502-
Ok(deleted)
517+
Ok(crate::types::DeleteNoteResult { deleted, auto_cleaned_groups })
503518
}
504519

505520
/// 搜索笔记(REQ-004;关键词截断——TD-005;v0.10.0 支持按标签过滤)。

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

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use tauri::State;
1010

1111
use crate::commands::AppState;
1212
use crate::db_fragments::NewFragment;
13-
use crate::types::{Fragment, NewNoteGroup, Note};
13+
use crate::types::{Fragment, NewNoteGroup};
1414
use crate::video_profile_domain::{detect_domain, DomainSignals};
1515

1616
/// 碎片文本最大长度(防超大 payload 拖垮 IPC/DB;几句话的碎片远用不到)。
@@ -143,12 +143,14 @@ pub fn list_group_fragments(state: State<'_, AppState>, group_id: i64) -> Result
143143
///
144144
/// @ai-context: 与 capture_fragment 同开关准入(feed 能力默认关纪律对称——
145145
/// 后端不信前端隐藏);目标组存在性校验(不写孤儿引用)。
146+
/// @ai-context: REQ-316(批 7):源组因移走变空被自动清理时回传组标题
147+
/// (前端 toast 留痕;组域条件广播,无清理零变化)。
146148
#[tauri::command]
147149
pub fn update_fragment_group(
148150
state: State<'_, AppState>,
149151
fragment_id: i64,
150152
group_id: Option<i64>,
151-
) -> Result<bool, String> {
153+
) -> Result<crate::types::MoveFragmentResult, String> {
152154
require_feed_enabled(&state)?;
153155
if fragment_id <= 0 {
154156
return Err("无效的碎片 id".to_string());
@@ -164,26 +166,46 @@ pub fn update_fragment_group(
164166
return Err(format!("笔记组不存在: {}", gid));
165167
}
166168
}
167-
state
169+
let out = state
168170
.db
169171
.update_fragment_group(fragment_id, group_id)
170-
.map_err(|e| e.to_string())
172+
.map_err(|e| e.to_string())?;
173+
let moved = out.moved;
174+
let auto_cleaned_groups = out.auto_cleaned.iter().map(|g| g.name.clone()).collect();
175+
if moved && !out.auto_cleaned.is_empty() {
176+
// REQ-316:自动清理删组 → 组域广播(碎片组无独立域事件——组消失
177+
// 走组域通道刷新侧栏;无清理不发)
178+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::NoteGroups);
179+
}
180+
Ok(crate::types::MoveFragmentResult { moved, auto_cleaned_groups })
171181
}
172182

173183
/// 删除碎片(REQ-201 用户主动删除——真删;绑定卡自动解绑保留)。
174184
///
175185
/// @ai-context: 开关准入同 capture_fragment(feed 能力对称纪律);存在性校验
176186
/// 前置(删不存在的碎片返回明确错误而非静默 false)。
187+
/// @ai-context: REQ-316(批 7):碎片源组因删除变空被自动清理时回传组标题
188+
/// (前端 toast 留痕;组域条件广播,无清理零变化)。
177189
#[tauri::command]
178-
pub fn delete_fragment(state: State<'_, AppState>, fragment_id: i64) -> Result<bool, String> {
190+
pub fn delete_fragment(
191+
state: State<'_, AppState>,
192+
fragment_id: i64,
193+
) -> Result<crate::types::DeleteFragmentResult, String> {
179194
require_feed_enabled(&state)?;
180195
if fragment_id <= 0 {
181196
return Err("无效的碎片 id".to_string());
182197
}
183198
if state.db.get_fragment(fragment_id).map_err(|e| e.to_string())?.is_none() {
184199
return Err(format!("碎片不存在: {}", fragment_id));
185200
}
186-
state.db.delete_fragment(fragment_id).map_err(|e| e.to_string())
201+
let out = state.db.delete_fragment(fragment_id).map_err(|e| e.to_string())?;
202+
let deleted = out.deleted;
203+
let auto_cleaned_groups = out.auto_cleaned.iter().map(|g| g.name.clone()).collect();
204+
if deleted && !out.auto_cleaned.is_empty() {
205+
// REQ-316:自动清理删组 → 组域广播(无清理零变化)
206+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::NoteGroups);
207+
}
208+
Ok(crate::types::DeleteFragmentResult { deleted, auto_cleaned_groups })
187209
}
188210

189211
/// 碎片升为笔记(v0.12.2 收件箱动线:原料→沉淀;REQ-201 补升级出口)。
@@ -198,7 +220,7 @@ pub fn promote_fragment_to_note(
198220
fragment_id: i64,
199221
title: String,
200222
group_id: Option<i64>,
201-
) -> Result<Note, String> {
223+
) -> Result<crate::types::PromoteNoteResult, String> {
202224
require_feed_enabled(&state)?;
203225
if fragment_id <= 0 {
204226
return Err("无效的碎片 id".to_string());
@@ -226,8 +248,13 @@ pub fn promote_fragment_to_note(
226248
.map_err(|e| e.to_string())?;
227249
// REQ-278 审查补端:碎片升笔记 = 笔记新增 + 组内容/计数变化(碎片移出)
228250
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
251+
// REQ-316:碎片源组若因升笔记变空,清理已在数据层事务内完成——组域
252+
// 广播走既有通道(本命令本就广播组域,无双发);清理标题随结果回传
229253
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::NoteGroups);
230-
Ok(note)
254+
Ok(crate::types::PromoteNoteResult {
255+
note: note.note,
256+
auto_cleaned_groups: note.auto_cleaned.iter().map(|g| g.name.clone()).collect(),
257+
})
231258
}
232259

233260
/// 碎片图片 → 本地绝对路径(前端 convertFileSrc 消费;REQ-201 缩略图)。

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,16 @@ pub fn override_group_route(
160160
}
161161

162162
/// 移动笔记到组(group_id=None 移出组——手动纠错路由误判的兜底路径)。
163+
///
164+
/// @ai-context: REQ-316(批 7)返回契约扩展为 MoveNoteResult——源组因移走变空
165+
/// 被自动清理时回传组标题(前端 toast 留痕;数据层同事务完成,
166+
/// 广播仍走本命令既有 NoteGroups 通道,无双发)。
163167
#[tauri::command]
164168
pub fn move_note_to_group(
165169
state: State<'_, AppState>,
166170
note_id: i64,
167171
group_id: Option<i64>,
168-
) -> Result<bool, String> {
172+
) -> Result<crate::types::MoveNoteResult, String> {
169173
if note_id <= 0 {
170174
return Err("无效的笔记 id".to_string());
171175
}
@@ -177,15 +181,23 @@ pub fn move_note_to_group(
177181
return Err(format!("笔记组不存在: {}", gid));
178182
}
179183
}
180-
let ok = state.db.update_note_group(note_id, group_id).map_err(|e| e.to_string())?;
184+
let out = state
185+
.db
186+
.update_note_group(note_id, group_id)
187+
.map_err(|e| e.to_string())?;
188+
let moved = out.moved;
189+
// 自动清理留痕(空数组=无清理——前端零变化)
190+
let auto_cleaned_groups = out.auto_cleaned.iter().map(|g| g.name.clone()).collect();
181191
// REQ-278:归组 = 笔记归属 + 组内容双变 → 双域广播(成功才发)
182-
if ok {
192+
if moved {
183193
// 审查 L6:移组即清旧 scope 手动序行(防"移出后移回复活旧序位")
184194
let _ = state.db.purge_note_ids(&[note_id]);
185195
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
196+
// REQ-316:清理发生在数据层写事务内;组域广播走既有通道(空组消失
197+
// 即时可见——无双发:本命令本就广播组域)
186198
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::NoteGroups);
187199
}
188-
Ok(ok)
200+
Ok(crate::types::MoveNoteResult { moved, auto_cleaned_groups })
189201
}
190202

191203

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

Lines changed: 110 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,36 @@
44
//! 本层只管读写——DomainTag 归组判定在 commands_fragments.rs
55
//! (复用 detect_domain 纯函数),组 CRUD 在 db_note_groups.rs。
66
7-
use rusqlite::params;
7+
use rusqlite::{params, OptionalExtension};
88

99
use crate::db::{unix_seconds, Db};
10+
use crate::db_note_group_clean::CleanedGroup;
1011
use crate::error::Result;
1112
use crate::kb_index::{soft_clear_fragment, soft_index_fragment};
1213
use crate::types::{Fragment, Note};
1314

15+
/// 删除碎片结果(REQ-316 批 7:auto_cleaned=删除使组变空后同事务清理的路由组)。
16+
#[derive(Debug, Clone, Default, PartialEq)]
17+
pub struct FragmentDeleteOutcome {
18+
pub deleted: bool,
19+
pub auto_cleaned: Vec<CleanedGroup>,
20+
}
21+
22+
/// 碎片移组结果(REQ-316 批 7:auto_cleaned=源组变空后同事务清理的路由组)。
23+
#[derive(Debug, Clone, Default, PartialEq)]
24+
pub struct FragmentGroupMoveOutcome {
25+
pub moved: bool,
26+
pub auto_cleaned: Vec<CleanedGroup>,
27+
}
28+
29+
/// 碎片升笔记结果(REQ-316 批 7:auto_cleaned=碎片源组变空后同事务清理的路由组;
30+
/// note=与旧返回契约同构的新建笔记)。
31+
#[derive(Debug, Clone, PartialEq)]
32+
pub struct PromoteNoteOutcome {
33+
pub note: Note,
34+
pub auto_cleaned: Vec<CleanedGroup>,
35+
}
36+
1437
/// fragments 表统一查询列(列顺序与 row_to_fragment 严格对应)。
1538
const FRAGMENT_COLUMNS: &str =
1639
"id, text, image_path, domain_tag, group_id, source, status, created_at";
@@ -115,14 +138,35 @@ impl Db {
115138
}
116139

117140
/// 移动碎片到组(None=移出;用户纠错/结算归组共用;v0.11.4 命令接线)。
118-
pub fn update_fragment_group(&self, id: i64, group_id: Option<i64>) -> Result<bool> {
119-
self.with_conn(|conn| {
120-
let affected = conn.execute(
121-
"UPDATE fragments SET group_id = ?1 WHERE id = ?2",
122-
params![group_id, id],
123-
)?;
124-
Ok(affected > 0)
125-
})
141+
///
142+
/// @ai-context: REQ-316(批 7):先读旧组再更新——源组若因此变空则同一事务
143+
/// 内自动清理(谓词/残留双闸见 db_note_group_clean)。
144+
pub fn update_fragment_group(&self, id: i64, group_id: Option<i64>) -> Result<FragmentGroupMoveOutcome> {
145+
let mut conn = self.conn.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
146+
let tx = conn.transaction()?;
147+
let prev_group: Option<Option<i64>> = tx
148+
.query_row(
149+
"SELECT group_id FROM fragments WHERE id = ?1",
150+
params![id],
151+
|row| row.get(0),
152+
)
153+
.optional()?;
154+
let affected = tx.execute(
155+
"UPDATE fragments SET group_id = ?1 WHERE id = ?2",
156+
params![group_id, id],
157+
)?;
158+
let moved = affected > 0;
159+
let old_group = prev_group.flatten();
160+
let auto_cleaned = if moved && group_id != old_group {
161+
match old_group {
162+
Some(old) => crate::db_note_group_clean::auto_clean_empty_groups(&tx, &[old])?,
163+
None => Vec::new(),
164+
}
165+
} else {
166+
Vec::new()
167+
};
168+
tx.commit()?;
169+
Ok(FragmentGroupMoveOutcome { moved, auto_cleaned })
126170
}
127171

128172
/// 按 id 读取碎片;不存在返回 None(delete/移组命令的存在性校验)。
@@ -146,14 +190,34 @@ impl Db {
146190
/// @ai-context: 绑定闪卡经 flashcards.fragment_id ON DELETE SET NULL 自动
147191
/// 解绑保留(学习循环资产不被碎片删除连带——身份诚实:
148192
/// 卡已生成即独立资产);结算归档走 set_fragment_status 不删。
149-
pub fn delete_fragment(&self, id: i64) -> Result<bool> {
150-
self.with_conn(|conn| {
151-
// v0.19.0(REQ-258):先清派生索引(kb_fts 影子表 FK 级联不负责——
152-
// 显式清理为主路径;失败软记录不阻断删除)
153-
soft_clear_fragment(conn, id);
154-
let affected = conn.execute("DELETE FROM fragments WHERE id = ?1", params![id])?;
155-
Ok(affected > 0)
156-
})
193+
/// @ai-context: REQ-316(批 7):删除先读所属组——组若因此变空则同一事务
194+
/// 内自动清理(残留闸含组内闪卡:delete_fragment_removes…
195+
/// 既有"卡保留"场景天然挡住组清理)。
196+
pub fn delete_fragment(&self, id: i64) -> Result<FragmentDeleteOutcome> {
197+
let mut conn = self.conn.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
198+
let tx = conn.transaction()?;
199+
let prev_group: Option<Option<i64>> = tx
200+
.query_row(
201+
"SELECT group_id FROM fragments WHERE id = ?1",
202+
params![id],
203+
|row| row.get(0),
204+
)
205+
.optional()?;
206+
// v0.19.0(REQ-258):先清派生索引(kb_fts 影子表 FK 级联不负责——
207+
// 显式清理为主路径;失败软记录不阻断删除)
208+
soft_clear_fragment(&tx, id);
209+
let affected = tx.execute("DELETE FROM fragments WHERE id = ?1", params![id])?;
210+
let deleted = affected > 0;
211+
let auto_cleaned = if deleted {
212+
match prev_group.flatten() {
213+
Some(gid) => crate::db_note_group_clean::auto_clean_empty_groups(&tx, &[gid])?,
214+
None => Vec::new(),
215+
}
216+
} else {
217+
Vec::new()
218+
};
219+
tx.commit()?;
220+
Ok(FragmentDeleteOutcome { deleted, auto_cleaned })
157221
}
158222

159223
/// 标记碎片状态(v0.11.3 结算归档:active↔archived)。
@@ -183,7 +247,7 @@ impl Db {
183247
fragment_id: i64,
184248
title: &str,
185249
group_id: Option<i64>,
186-
) -> Result<Note> {
250+
) -> Result<PromoteNoteOutcome> {
187251
let now = unix_seconds();
188252
// 显式事务(审查修复):with_conn 只给 &Connection 无法开事务,而
189253
// rusqlite 默认 autocommit——多语句各自提交,④ 失败会留下"笔记已建/
@@ -252,22 +316,36 @@ impl Db {
252316
// v0.20.3(REQ-292)保存收口钩子补齐:升笔记直写路径任务索引同事务
253317
crate::db_task_index::rebuild_note_tasks(&tx, note_id, &content);
254318
}
319+
// REQ-316(批 7):碎片离开源组——源组若因此变空(且笔记未落回该组)
320+
// 则同事务自动清理(谓词/残留双闸见 db_note_group_clean;残留闸含笔记
321+
// 计数——笔记落回同组时该组 count≥1 自然挡住清理)。
322+
let auto_cleaned = if fragment.group_id != group_id {
323+
match fragment.group_id {
324+
Some(gid) => crate::db_note_group_clean::auto_clean_empty_groups(&tx, &[gid])?,
325+
None => Vec::new(),
326+
}
327+
} else {
328+
Vec::new()
329+
};
255330
tx.commit()?;
256331
// ⑤ 组装返回(与库内一致)
257-
Ok(Note {
258-
id: note_id,
259-
title: title.to_string(),
260-
content,
261-
source: "manual".to_string(),
262-
session_id: None,
263-
rule_version: None,
264-
purify_stats: None,
265-
tags: "[]".to_string(),
266-
properties: None,
267-
pin: 0,
268-
group_id,
269-
created_at: now,
270-
updated_at: now,
332+
Ok(PromoteNoteOutcome {
333+
auto_cleaned,
334+
note: Note {
335+
id: note_id,
336+
title: title.to_string(),
337+
content,
338+
source: "manual".to_string(),
339+
session_id: None,
340+
rule_version: None,
341+
purify_stats: None,
342+
tags: "[]".to_string(),
343+
properties: None,
344+
pin: 0,
345+
group_id,
346+
created_at: now,
347+
updated_at: now,
348+
},
271349
})
272350
}
273351
}

0 commit comments

Comments
 (0)