Skip to content

Commit 23b0eeb

Browse files
committed
fix(notes): 用户编排痕迹接管自动路由空组(写路径置位+谓词存量双闸)
批7 审查 P2-7:空组自动清理谓词原先只看 source/route_overridden, 改名/置色/置顶/手排等用户编排动作不置位 → 被接管空组被静默删除, 与 REQ-315 冲突。双保险: - 写侧同权:rename_group/update_group_color/update_note_group_pin/ save_group_order 与路由改判同权置 route_overridden=1(仅 route/series 产物;单条 UPDATE 内 CASE / 快照同事务完成,Why:用户编排痕迹=接管, 不再自动清理); - 谓词存量防护:额外要求 pin=0 ∧ color IS NULL ∧ 无 note_group_orders 行 (兜底修复前已被编排但 override 仍 0 的存量行); 测试固化反转:置顶/手排空组不再被清;存量痕迹直写 SQL 模拟仍挡; 改名仅留 flag 也挡;写路径置位断言覆盖。
1 parent 02aba9a commit 23b0eeb

6 files changed

Lines changed: 210 additions & 29 deletions

File tree

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

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
//! @ai-context: 用户问题 8——自动路由产生的空组堆积在组侧栏(手动删组需两步
44
//! 确认,空路由组无治理出口)。本模块提供「仅自动路由空组自动
55
//! 删除」的共享判定与删除:
6-
//! ① 谓词 = note_groups.source IN ('route','series')(自动路由/系列
7-
//! 检测产物)且 route_overridden = 0(用户改判过=修改即记忆,
8-
//! REQ-198 永不自动删);source='manual' 手动建组天然不在白名单。
6+
//! ① 谓词 = 自动路由/系列产物(source IN ('route','series'))且零
7+
//! 用户编排痕迹——主闸 route_overridden = 0(用户改判/改名/
8+
//! 置色/置顶/手排后置 1,批 7 审查 P2-7 修复:用户编排=接管
9+
//! 该组,REQ-198 永不自动删);存量副闸 pin=0 ∧ color IS NULL
10+
//! ∧ 无 note_group_orders 行(兜底修复前已被编排但 override
11+
//! 仍 0 的存量行);source='manual' 手动建组天然不在白名单。
912
//! ② 残留全零 = notes + fragments + flashcards + settlements +
1013
//! contracts 五类计数(与 get_group_delete_impact 同口径——SQL
1114
//! 单点在本模块 group_residue,手动删除影响面复用,防口径漂移)。
@@ -80,23 +83,41 @@ pub fn delete_group_row(conn: &Connection, group_id: i64) -> rusqlite::Result<bo
8083
Ok(affected > 0)
8184
}
8285

83-
/// 自动路由产物谓词(组行单表可判——source 白名单 + 未改判)。
84-
fn is_auto_route_candidate(conn: &Connection, group_id: i64) -> rusqlite::Result<bool> {
86+
/// 自动清理候选谓词(组行单表 + 手排子表可判)。
87+
///
88+
/// @ai-context: 双闸防误删(批 7 审查修复 P2-7)——source 白名单只证明「自动
89+
/// 产物」,不足以证明「从未被用户编排」:改名/置色/置顶/手排等
90+
/// 元数据操作在修复前不置 route_overridden,会把被用户接管过的
91+
/// 空组静默删掉(REQ-315 交互与 REQ-316 清理边界冲突)。
92+
/// 主闸=route_overridden=0:修复后所有用户编排写路径同权置 1
93+
/// (db_note_groups::rename_group / update_group_color、
94+
/// db_note_group_orders::update_note_group_pin / save_group_order);
95+
/// 副闸=存量兜底(修复前已被编排但 override 仍 0 的行):
96+
/// pin=0 ∧ color IS NULL ∧ 无 note_group_orders 行——任一编排
97+
/// 痕迹存在即不删。
98+
fn is_clean_candidate(conn: &Connection, group_id: i64) -> rusqlite::Result<bool> {
8599
let count: i64 = conn.query_row(
86-
"SELECT COUNT(*) FROM note_groups
87-
WHERE id = ?1 AND source IN ('route', 'series') AND route_overridden = 0",
100+
"SELECT COUNT(*) FROM note_groups g
101+
WHERE g.id = ?1
102+
AND g.source IN ('route', 'series')
103+
AND g.route_overridden = 0
104+
AND g.pin = 0
105+
AND g.color IS NULL
106+
AND NOT EXISTS (SELECT 1 FROM note_group_orders o WHERE o.group_id = g.id)",
88107
params![group_id],
89108
|row| row.get(0),
90109
)?;
91110
Ok(count > 0)
92111
}
93112

94113
/// 空组自动清理(写事务提交前调用——对**本次写操作影响到的组**判定,防全表
95-
/// 扫描误删他组):自动路由产物 + 残留全零 → 删组;返回实际清理的组。
114+
/// 扫描误删他组):自动清理候选(谓词,见 is_clean_candidate)+ 残留全零 →
115+
/// 删组;返回实际清理的组。
96116
///
97-
/// @ai-context: 边界(用户口径)——手动建组/改判组/有任意残留组绝不在本函数删除
98-
/// (谓词与残留双闸);同一事务内检查与删除原子,无"检查后被并发
99-
/// 写入"窗口(连接 Mutex 串行化)。重复 id 幂等安全(删除后不再出现)。
117+
/// @ai-context: 边界(用户口径)——手动建组/有任意编排痕迹/有任意残留组绝不
118+
/// 在本函数删除(编排痕迹与残留双闸);同一事务内检查与删除原子,
119+
/// 无"检查后被并发写入"窗口(连接 Mutex 串行化)。重复 id 幂等
120+
/// 安全(删除后不再出现)。
100121
pub fn auto_clean_empty_groups(
101122
conn: &Connection,
102123
affected_group_ids: &[i64],
@@ -106,7 +127,7 @@ pub fn auto_clean_empty_groups(
106127
if cleaned.iter().any(|c| c.id == *id) {
107128
continue; // 重复 id 幂等(同批多次命中只清一次)
108129
}
109-
if !is_auto_route_candidate(conn, *id)? {
130+
if !is_clean_candidate(conn, *id)? {
110131
continue;
111132
}
112133
let residue = group_residue(conn, *id)?;

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

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
//! db_note_group_clean 单测(REQ-316 批 7;内存库 AAA 模式)。
22
//!
3-
//! @ai-context: 覆盖自动清理判定表——自动路由产物+五类残留全零才删;手动建组/
4-
//! 改判组/任一残留/影响面外组永不动;级联卫生(note_group_orders
5-
//! 行随删清);写路径收敛(删除/移组/碎片删除/移组/升笔记触发点)。
3+
//! @ai-context: 覆盖自动清理判定表——自动路由产物+零编排痕迹+五类残留全零才删;
4+
//! 手动建组/改判组/任一编排痕迹(置顶/手排/置色——批 7 审查 P2-7
5+
//! 双保险)/任一残留/影响面外组永不动;写路径收敛(删除/移组/
6+
//! 碎片删除/升笔记触发点)。级联卫生(note_group_orders 行随删清)
7+
//! 由 db_note_group_orders_tests::delete_group_cascades_order_row 覆盖。
68
79
use rusqlite::params;
810

@@ -213,22 +215,75 @@ fn groups_outside_affected_scope_untouched() {
213215
}
214216

215217
#[test]
216-
fn cleanup_cascades_order_rows_and_pin_with_group() {
217-
// Arrange:空路由组 + 置顶 + 分区手动序行(批 6 基建——组删随 FK CASCADE 清)
218+
fn pinned_or_hand_ordered_auto_group_is_never_auto_cleaned() {
219+
// 批 7 审查修复(P2-7):原测试固化「置顶/手排的空路由组照样被清」——与
220+
// REQ-315 冲突(置顶/手排是用户编排动作,组删不得绕过用户交互),反转:
221+
// 置顶组/手排组即使空也保留(写路径已同权置 route_overridden=1=主闸)。
222+
// 级联卫生(随组删清 note_group_orders 行)由 db_note_group_orders_tests
223+
// ::delete_group_cascades_order_row 覆盖(自动清理永不删有手排行的组)。
218224
let db = mem_db();
219-
let g = db.create_group(&group("有序组", "route")).expect("g");
220-
db.update_note_group_pin(g.id, 1).expect("pin");
221-
db.save_group_order("standalone", &[g.id]).expect("order");
225+
let pinned = db.create_group(&group("置顶组", "route")).expect("g");
226+
db.update_note_group_pin(pinned.id, 1).expect("pin");
227+
let ordered = db.create_group(&group("手排组", "route")).expect("g2");
228+
db.save_group_order("standalone", &[ordered.id]).expect("order");
229+
// Act
230+
let guard = db_raw_conn(&db);
231+
let cleaned = auto_clean_empty_groups(&guard, &[pinned.id, ordered.id]).expect("clean");
232+
drop(guard);
233+
// Assert:两组全存活(用户编排痕迹=接管该组——不再自动清理)
234+
assert!(cleaned.is_empty());
235+
assert!(db.get_group(pinned.id).expect("get").is_some());
236+
assert!(db.get_group(ordered.id).expect("get").is_some());
237+
}
238+
239+
#[test]
240+
fn legacy_pin_color_order_traces_block_cleanup_even_when_flag_zero() {
241+
// 批 7 审查修复(P2-7 双保险·副闸=存量兜底):修复前的存量行——用户已
242+
// 置顶/置色/手排但旧写路径不置 route_overridden(仍 0)。直写 SQL 模拟
243+
// 存量态(经 db 方法会连置位=主闸,单测副闸必须绕过);任一编排痕迹
244+
// 存在即不删——"仅 route 源+零编排+零残留才删"的存量侧护栏。
245+
let db = mem_db();
246+
let pinned = db.create_group(&group("旧置顶", "route")).expect("pinned");
247+
let colored = db.create_group(&group("旧上色", "route")).expect("colored");
248+
let ordered = db.create_group(&group("旧手排", "route")).expect("ordered");
249+
db.with_conn(|conn| {
250+
conn.execute("UPDATE note_groups SET pin = 1 WHERE id = ?1", params![pinned.id])?;
251+
conn.execute("UPDATE note_groups SET color = 'pink' WHERE id = ?1", params![colored.id])?;
252+
conn.execute(
253+
"INSERT INTO note_group_orders (group_id, seq) VALUES (?1, 0)",
254+
params![ordered.id],
255+
)?;
256+
Ok(())
257+
})
258+
.expect("seed legacy orchestration traces");
259+
// Act:三个存量编排痕迹组全空、全在影响面内
260+
let guard = db_raw_conn(&db);
261+
let cleaned = auto_clean_empty_groups(&guard, &[pinned.id, colored.id, ordered.id]).expect("clean");
262+
drop(guard);
263+
// Assert:全部保留(存量兜底闸生效——修前数据不被静默误删)
264+
assert!(cleaned.is_empty());
265+
for gid in [pinned.id, colored.id, ordered.id] {
266+
assert!(db.get_group(gid).expect("get").is_some(), "组 {gid} 有存量编排痕迹不得清");
267+
}
268+
}
269+
270+
#[test]
271+
fn user_rename_takes_over_auto_group_and_blocks_cleanup() {
272+
// 批 7 审查修复(P2-7 双保险·主闸=写路径):改名=用户编排痕迹——写路径与
273+
// 路由改判同权置 route_overridden=1。仅改名的组无 pin/色/手排行(副闸
274+
// 拦不住),必须靠主闸拦截——验证「新编排只留 flag 也删不掉」。
275+
let db = mem_db();
276+
let g = db.create_group(&group("路由产物", "route")).expect("g");
277+
assert!(db.rename_group(g.id, "我自己改的名").expect("rename"));
222278
// Act
223279
let guard = db_raw_conn(&db);
224280
let cleaned = auto_clean_empty_groups(&guard, &[g.id]).expect("clean");
225281
drop(guard);
226-
// Assert:组没了、序行/置顶残留零
227-
assert_eq!(cleaned.len(), 1);
228-
let orphan: i64 = db
229-
.with_conn(|c| Ok(c.query_row("SELECT COUNT(*) FROM note_group_orders WHERE group_id = ?1", params![g.id], |r| r.get(0))?))
230-
.expect("count");
231-
assert_eq!(orphan, 0, "note_group_orders 随组删级联清行(无孤儿)");
282+
// Assert:改名组即使空也保留(主闸:route_overridden=1)
283+
assert!(cleaned.is_empty());
284+
let fetched = db.get_group(g.id).expect("get").expect("exists");
285+
assert_eq!(fetched.route_overridden, 1);
286+
assert_eq!(fetched.name, "我自己改的名");
232287
}
233288

234289
// ── 写路径集成(真实命令语义在 db 层事务内收敛的验证) ──

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,16 @@ impl Db {
3131
///
3232
/// @ai-context: updated_at 同步刷新:置顶动作把它推为置顶区内最新
3333
/// (置顶区内部按更新时间降序,与取消置顶回落自动区的口径一致)。
34+
/// @ai-context: 批 7 审查修复(P2-7 双保险·写侧):置顶/取消置顶=用户在操作
35+
/// 该组=编排痕迹——与路由改判同权置 route_overridden=1(仅
36+
/// route/series 自动产物;Why 见 db_note_groups::rename_group),
37+
/// 空组自动清理不再误删被用户置过顶的组(REQ-315/REQ-316 边界)。
3438
pub fn update_note_group_pin(&self, id: i64, pin: i64) -> Result<bool> {
3539
self.with_conn(|conn| {
3640
let affected = conn.execute(
37-
"UPDATE note_groups SET pin = ?1, updated_at = ?2 WHERE id = ?3",
41+
"UPDATE note_groups SET pin = ?1,
42+
route_overridden = CASE WHEN source IN ('route', 'series') THEN 1 ELSE route_overridden END,
43+
updated_at = ?2 WHERE id = ?3",
3844
params![pin, unix_seconds(), id],
3945
)?;
4046
Ok(affected > 0)
@@ -77,6 +83,28 @@ impl Db {
7783
stmt.execute(params![*id, i as i64])?;
7884
}
7985
}
86+
// 批 7 审查修复(P2-7 双保险·写侧):手排保存=用户编排痕迹——快照内
87+
// 全部组与路由改判同权置 route_overridden=1(仅 route/series 自动产物;
88+
// Why 见 db_note_groups::rename_group),用户亲手排过位的空组不被自动
89+
// 清理误删。同事务原子:快照回滚则编排标记一并回滚,无半态。
90+
if !group_ids.is_empty() {
91+
let mut sql = String::from(
92+
"UPDATE note_groups SET route_overridden = 1
93+
WHERE source IN ('route', 'series') AND id IN (",
94+
);
95+
for (i, _) in group_ids.iter().enumerate() {
96+
if i > 0 {
97+
sql.push(',');
98+
}
99+
sql.push_str(&format!("?{}", i + 1));
100+
}
101+
sql.push(')');
102+
let mut ps: Vec<&dyn rusqlite::ToSql> = Vec::new();
103+
for id in group_ids {
104+
ps.push(id);
105+
}
106+
tx.execute(&sql, rusqlite::params_from_iter(ps))?;
107+
}
80108
tx.commit()?;
81109
Ok(())
82110
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,26 @@ fn group_pin_roundtrip_and_updated_at_refresh() {
166166
// 不存在组 → false
167167
assert!(!db.update_note_group_pin(9999, 1).expect("missing"));
168168
}
169+
170+
#[test]
171+
fn pin_and_manual_order_save_mark_user_orchestration() {
172+
// 批 7 审查修复(P2-7 写侧单点):置顶/手排=用户编排痕迹——route/series
173+
// 自动产物置 route_overridden=1(空组自动清理谓词主闸),防空组被静默删;
174+
// 手排标记与快照同事务(save_group_order 内一并 UPDATE,回滚同生共死)。
175+
let db = mem_db();
176+
let a = make(&db, "A", "course"); // source=route(helper 默认)
177+
assert!(db.update_note_group_pin(a.id, 1).expect("pin"));
178+
assert_eq!(
179+
db.get_group(a.id).expect("get").expect("a").route_overridden,
180+
1,
181+
"置顶=接管该组"
182+
);
183+
// 手排快照覆盖:快照内(未置顶)组全部接管
184+
let b = make(&db, "B", "course");
185+
db.save_group_order("course", &[a.id, b.id]).expect("snapshot");
186+
assert_eq!(db.get_group(b.id).expect("get").expect("b").route_overridden, 1);
187+
// 分区复位(空 ids)不置位任何组(无快照成员——无对象可标记)
188+
let c = make(&db, "C", "course");
189+
db.save_group_order("course", &[]).expect("reset");
190+
assert_eq!(db.get_group(c.id).expect("get").expect("c").route_overridden, 0);
191+
}

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,18 @@ impl Db {
134134
}
135135

136136
/// 重命名组(用户可改;updated_at 刷新)。
137+
///
138+
/// @ai-context: 批 7 审查修复(P2-7 双保险·写侧):改名=用户编排痕迹——与
139+
/// 路由改判同权置 route_overridden=1(仅 route/series 自动产物;
140+
/// 手动组天然不在清理白名单,无需置位),此后该组即使被删空
141+
/// 也不再被自动清理(REQ-316 口径:只清从未被用户动过的路由
142+
/// 空组)。Why 单条 UPDATE 内 CASE 完成:无跨语句窗口,原子。
137143
pub fn rename_group(&self, id: i64, name: &str) -> Result<bool> {
138144
self.with_conn(|conn| {
139145
let affected = conn.execute(
140-
"UPDATE note_groups SET name = ?1, updated_at = ?2 WHERE id = ?3",
146+
"UPDATE note_groups SET name = ?1,
147+
route_overridden = CASE WHEN source IN ('route', 'series') THEN 1 ELSE route_overridden END,
148+
updated_at = ?2 WHERE id = ?3",
141149
params![name, unix_seconds(), id],
142150
)?;
143151
Ok(affected > 0)
@@ -179,10 +187,18 @@ impl Db {
179187
}
180188

181189
/// v0.14 B(视觉系统):组级颜色设置(色板 id;None=清除回默认灰)。
190+
///
191+
/// @ai-context: 批 7 审查修复(P2-7 双保险·写侧):置色/清色都是用户在操作
192+
/// 该组=编排痕迹——与路由改判同权置 route_overridden=1
193+
/// (仅 route/series 自动产物;Why 见 rename_group 同款注释)。
194+
/// 注:清色回默认灰不撤销接管——标志粘性(用户一旦动过该组
195+
/// 即不再自动清理,无"撤销痕迹"通道,语义简单可预期)。
182196
pub fn update_group_color(&self, id: i64, color: Option<&str>) -> Result<bool> {
183197
self.with_conn(|conn| {
184198
let affected = conn.execute(
185-
"UPDATE note_groups SET color = ?1, updated_at = ?2 WHERE id = ?3",
199+
"UPDATE note_groups SET color = ?1,
200+
route_overridden = CASE WHEN source IN ('route', 'series') THEN 1 ELSE route_overridden END,
201+
updated_at = ?2 WHERE id = ?3",
186202
params![color, unix_seconds(), id],
187203
)?;
188204
Ok(affected > 0)

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,44 @@ fn move_note_between_groups() {
180180
assert_eq!(db.get_note(note.id).expect("get").unwrap().group_id, None);
181181
}
182182

183+
#[test]
184+
fn rename_marks_user_orchestration_on_auto_product_only() {
185+
// 批 7 审查修复(P2-7 写侧单点):改名=用户编排痕迹→接管自动路由产物——
186+
// route/series 组置 route_overridden=1(空组自动清理谓词主闸);手动组
187+
// 天然无权自动清理,不置位(override 语义只对自动产物有意义,模型不污染)。
188+
let db = mem_db();
189+
let auto = db.create_group(&group("路由组")).expect("auto");
190+
assert!(db.rename_group(auto.id, "更名后的路由组").expect("rename"));
191+
let fetched = db.get_group(auto.id).expect("get").expect("exists");
192+
assert_eq!(fetched.name, "更名后的路由组");
193+
assert_eq!(fetched.route_overridden, 1, "改名=用户编排痕迹→接管该组");
194+
// 手动组改名:名照改、不置位
195+
let mut manual = group("手动组");
196+
manual.source = "manual".to_string();
197+
let m = db.create_group(&manual).expect("manual");
198+
assert!(db.rename_group(m.id, "手动组新名").expect("rename"));
199+
let mf = db.get_group(m.id).expect("get").expect("exists");
200+
assert_eq!(mf.name, "手动组新名");
201+
assert_eq!(mf.route_overridden, 0);
202+
}
203+
204+
#[test]
205+
fn group_color_set_marks_user_orchestration_and_stays_sticky() {
206+
// 批 7 审查修复(P2-7 写侧单点):置色/清色=用户在操作该组=编排痕迹→
207+
// route/series 产物接管(route_overridden=1);清色回默认灰不撤销接管
208+
// (标志粘性——用户动过即不再自动清理,无"撤销痕迹"通道)。
209+
let db = mem_db();
210+
let auto = db.create_group(&group("路由组")).expect("auto");
211+
assert!(db.update_group_color(auto.id, Some("pink")).expect("set"));
212+
let fetched = db.get_group(auto.id).expect("get").expect("exists");
213+
assert_eq!(fetched.color.as_deref(), Some("pink"));
214+
assert_eq!(fetched.route_overridden, 1);
215+
assert!(db.update_group_color(auto.id, None).expect("clear"));
216+
let cleared = db.get_group(auto.id).expect("get").expect("exists");
217+
assert_eq!(cleared.color, None);
218+
assert_eq!(cleared.route_overridden, 1, "清色不撤销接管(粘性)");
219+
}
220+
183221
#[test]
184222
fn delete_group_keeps_notes() {
185223
// Arrange:删组只断关联不删笔记(笔记是用户资产)

0 commit comments

Comments
 (0)