Skip to content

Commit 313dd9f

Browse files
committed
fix(v0.14.1): 审查即修(悬空引用/布局丢位重叠/领域枚举/IME/竞态等 14 项)
1 parent 8908beb commit 313dd9f

21 files changed

Lines changed: 385 additions & 144 deletions

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

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -206,27 +206,35 @@ pub fn delete_note_group(state: State<'_, AppState>, id: i64) -> Result<bool, St
206206
delete_note_group_inner(&state.db, id)
207207
}
208208

209-
pub(crate) fn get_group_delete_impact_inner(
210-
db: &crate::db::Db,
211-
id: i64,
212-
) -> Result<GroupDeleteImpact, String> {
209+
/// 组存在性校验(R-低3:get_group_delete_impact/delete_note_group 共用——
210+
/// 防校验规则演进时口径漂移;风格与 commands_knowledge::require_id 一致)。
211+
fn require_group(db: &crate::db::Db, id: i64) -> Result<(), String> {
213212
if id <= 0 {
214213
return Err("无效的组 id".to_string());
215214
}
216215
if db.get_group(id).map_err(|e| e.to_string())?.is_none() {
217216
return Err(format!("笔记组不存在: {}", id));
218217
}
218+
Ok(())
219+
}
220+
221+
pub(crate) fn get_group_delete_impact_inner(
222+
db: &crate::db::Db,
223+
id: i64,
224+
) -> Result<GroupDeleteImpact, String> {
225+
require_group(db, id)?;
219226
db.group_delete_impact(id).map_err(|e| e.to_string())
220227
}
221228

222229
pub(crate) fn delete_note_group_inner(db: &crate::db::Db, id: i64) -> Result<bool, String> {
223-
if id <= 0 {
224-
return Err("无效的组 id".to_string());
225-
}
226-
if db.get_group(id).map_err(|e| e.to_string())?.is_none() {
230+
require_group(db, id)?;
231+
// R-低2:存在性检查与删除之间仍可能有并发删除窗口——Ok(false) 显式转错误,
232+
// 使"组不存在 → 错误"的文档承诺成立(前端不误判为成功)
233+
let deleted = db.delete_group(id).map_err(|e| e.to_string())?;
234+
if !deleted {
227235
return Err(format!("笔记组不存在: {}", id));
228236
}
229-
db.delete_group(id).map_err(|e| e.to_string())
237+
Ok(true)
230238
}
231239

232240
/// 命令层单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。

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

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! 知识体系画布命令层(v0.13.8 系统层;4 命令)。
1+
//! 知识体系画布命令层(v0.13.8 系统层;v0.14.1:6 命令)。
22
//!
33
//! @ai-context: 本层只做参数校验、调用数据层、错误映射(AGENTS.md §6);编排逻辑
44
//! `fn xxx_inner(db, ...)` 为纯函数(:memory: 可测),薄 `#[tauri::command]`
@@ -10,6 +10,9 @@
1010
//! @ai-context: 设计规格 §4.6 列 3 条命令;规格同时要求"切回画布视口恢复",
1111
//! 恢复需要读路径——补 `get_canvas_viewport`(第 4 条)补齐读写闭环,
1212
//! 已在版本文档 v0.13.8 交付记录登记(规格增补,非契约变更)。
13+
//! @ai-context: v0.14.1 增 2 条(第 5/6 条)——get_canvas_prefs/save_canvas_prefs:
14+
//! 连线样式与布局算法按体系持久化,枚举白名单前后端同口径
15+
//! (EDGE_STYLES/LAYOUT_ALGORITHMS 未知值拒绝,防任意字符串入库)。
1316
1417
use tauri::State;
1518

@@ -169,13 +172,23 @@ pub(crate) fn get_canvas_viewport_inner(
169172
.map_err(|e| e.to_string())
170173
}
171174

172-
/// 读取体系画布偏好(v0.14.1;从未保存 → None——前端回落默认值)。
175+
/// 体系存在性校验(R-低3:偏好读写 inner 共用;风格与 require_id 一致)。
176+
fn require_system(db: &Db, system_id: i64) -> Result<(), String> {
177+
require_id(system_id)?;
178+
if db.get_knowledge_system(system_id).map_err(|e| e.to_string())?.is_none() {
179+
return Err(format!("体系不存在: {}", system_id));
180+
}
181+
Ok(())
182+
}
183+
184+
/// 读取体系画布偏好(v0.14.1;从未保存 → None——前端回落默认值,与迁移
185+
/// DEFAULT smoothstep/radial/无箭头同口径)。
173186
#[tauri::command]
174187
pub fn get_canvas_prefs(state: State<'_, AppState>, system_id: i64) -> Result<Option<CanvasPrefs>, String> {
175188
get_canvas_prefs_inner(&state.db, system_id)
176189
}
177190

178-
/// 保存体系画布偏好(upsert;枚举白名单校验)。
191+
/// 保存体系画布偏好(upsert——只改偏好列不碰视口;枚举白名单校验)。
179192
#[tauri::command]
180193
pub fn save_canvas_prefs(
181194
state: State<'_, AppState>,
@@ -188,10 +201,7 @@ pub fn save_canvas_prefs(
188201
}
189202

190203
pub(crate) fn get_canvas_prefs_inner(db: &Db, system_id: i64) -> Result<Option<CanvasPrefs>, String> {
191-
require_id(system_id)?;
192-
if db.get_knowledge_system(system_id).map_err(|e| e.to_string())?.is_none() {
193-
return Err(format!("体系不存在: {}", system_id));
194-
}
204+
require_system(db, system_id)?;
195205
db.get_canvas_prefs(system_id).map_err(|e| e.to_string())
196206
}
197207

@@ -202,10 +212,7 @@ pub(crate) fn save_canvas_prefs_inner(
202212
edge_arrows: bool,
203213
layout_algorithm: &str,
204214
) -> Result<bool, String> {
205-
require_id(system_id)?;
206-
if db.get_knowledge_system(system_id).map_err(|e| e.to_string())?.is_none() {
207-
return Err(format!("体系不存在: {}", system_id));
208-
}
215+
require_system(db, system_id)?;
209216
if !EDGE_STYLES.contains(&edge_style) {
210217
return Err(format!(
211218
"不支持的连线样式: {}(支持: {})",

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,37 @@ fn viewport_and_prefs_do_not_clobber_each_other() {
113113
assert!(p.edge_arrows);
114114
assert_eq!(p.layout_algorithm, "fishbone");
115115
}
116+
117+
#[test]
118+
fn migration_adds_pref_columns_to_legacy_states() {
119+
// Arrange:手工造 v0.13.8 旧 schema(canvas_states 仅视口三列 + 一行旧视口)
120+
//(R-低7:ALTER 迁移分支此前无覆盖——新库走 CREATE TABLE 含三列,旧库
121+
// ensure_column 分支从未被执行验证;本用例按 db_notes_tests 旧库造法)
122+
let path = std::env::temp_dir().join(format!("entropy_mig_canvas_{}.db", std::process::id()));
123+
let _ = std::fs::remove_file(&path);
124+
{
125+
let conn = rusqlite::Connection::open(&path).expect("open old db");
126+
conn.execute_batch(
127+
"CREATE TABLE knowledge_canvas_states (
128+
system_id INTEGER PRIMARY KEY,
129+
viewport_x REAL DEFAULT 0,
130+
viewport_y REAL DEFAULT 0,
131+
zoom REAL DEFAULT 1.0
132+
);
133+
INSERT INTO knowledge_canvas_states (system_id, viewport_x, viewport_y, zoom)
134+
VALUES (1, 10, 20, 1.5);",
135+
)
136+
.expect("create old schema");
137+
}
138+
// Act:以新代码打开旧库(ensure_column 补齐三列;旧表无 FK 子句——不依赖表关联)
139+
let db = Db::open(path.to_str().unwrap()).expect("open migrated db");
140+
// Assert:旧行落 DEFAULT(smoothstep / 无箭头 / radial,与前端缺省同口径);
141+
// 视口列未被迁移触碰
142+
let p = db.get_canvas_prefs(1).expect("读").expect("有");
143+
assert_eq!(p.edge_style, "smoothstep");
144+
assert!(!p.edge_arrows);
145+
assert_eq!(p.layout_algorithm, "radial");
146+
let (x, y, z) = db.get_canvas_viewport(1).expect("读").expect("有");
147+
assert_eq!((x, y, z), (10.0, 20.0, 1.5));
148+
let _ = std::fs::remove_file(&path);
149+
}

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,10 @@ impl Db {
195195
settlements: count("SELECT COUNT(*) FROM settlements WHERE group_id = ?1")?,
196196
contracts: count("SELECT COUNT(*) FROM contracts WHERE group_id = ?1")?,
197197
system_refs: count(
198-
"SELECT COUNT(*) FROM knowledge_links WHERE target_type = 'note_group' AND target_id = ?1",
198+
"SELECT COUNT(*) FROM knowledge_links WHERE
199+
(target_type = 'note_group' AND target_id = ?1)
200+
OR (target_type = 'flashcard'
201+
AND target_id IN (SELECT id FROM flashcards WHERE group_id = ?1))",
199202
)?,
200203
})
201204
})
@@ -205,16 +208,30 @@ impl Db {
205208
///
206209
/// @ai-context: knowledge_links 无 FK 到 note_groups(target_type/target_id
207210
/// 泛化目标,白名单校验在命令层),必须显式清理——否则体系页
208-
/// 引用区出现悬空反查。PRAGMA foreign_keys=ON 每连接开启(db.rs),
211+
/// 引用区出现悬空反查。两类引用都在本事务清理且**先于删组**:
212+
/// note_group 直引 + flashcard 间接引(组闪卡经 FK CASCADE 删除
213+
/// 后其引用同样悬空——审查 R-中1 补漏;子查询必须在级联删行
214+
/// 之前执行)。PRAGMA foreign_keys=ON 每连接开启(db.rs),
209215
/// notes/fragments SET NULL 与 flashcards/settlements/contracts
210216
/// CASCADE 由建表契约自动生效;组不存在 → false(命令层转错误)。
211217
pub fn delete_group(&self, id: i64) -> Result<bool> {
218+
// @ai-context: 事务需要 &mut Connection,with_conn 只给 &Connection——
219+
// 同 db_knowledge_canvas::set_node_canvas_positions 手法:
220+
// 直接锁 + conn.transaction()(显式事务;毒锁恢复后的孤儿
221+
// 事务由 SQLite 对未提交事务隐式回滚兜住,与 with_conn
222+
// 恢复路径语义一致)。
212223
let mut conn = self.conn.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
213224
let tx = conn.transaction()?;
214225
tx.execute(
215226
"DELETE FROM knowledge_links WHERE target_type = 'note_group' AND target_id = ?1",
216227
params![id],
217228
)?;
229+
// flashcard 间接引用清理(先于级联删行——子查询须在行存在时执行)
230+
tx.execute(
231+
"DELETE FROM knowledge_links WHERE target_type = 'flashcard'
232+
AND target_id IN (SELECT id FROM flashcards WHERE group_id = ?1)",
233+
params![id],
234+
)?;
218235
let affected = tx.execute("DELETE FROM note_groups WHERE id = ?1", params![id])?;
219236
tx.commit()?;
220237
Ok(affected > 0)

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

Lines changed: 54 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -198,20 +198,18 @@ fn delete_group_keeps_notes() {
198198
group_id: Some(g.id),
199199
})
200200
.expect("note");
201-
// Act
202-
db.with_conn(|conn| {
203-
conn.execute("DELETE FROM note_groups WHERE id = ?1", rusqlite::params![g.id])
204-
.map_err(Into::into)
205-
})
206-
.expect("delete");
201+
// Act:走新 API(R-低8:原裸 SQL 直删与 delete_group_cascades 重叠——
202+
// 改经被测代码的真实删除路径,SET NULL 断言覆盖新 API)
203+
assert!(db.delete_group(g.id).expect("delete"));
207204
// Assert:笔记存活且 group_id 置空(ON DELETE SET NULL)
208205
let fetched = db.get_note(note.id).expect("get").expect("exists");
209206
assert_eq!(fetched.group_id, None);
210207
}
211208

212209
#[test]
213210
fn group_delete_impact_counts_six_dimensions() {
214-
// Arrange:组内挂 2 笔记 + 1 碎片 + 1 卡 + 1 结算 + 1 契约;体系引用 2 条
211+
// Arrange:组内挂 2 笔记 + 1 碎片 + 1 卡 + 1 结算 + 1 契约;
212+
// 体系引用 2 条(note_group 直引)+ 1 条 flashcard 间接引(R-中1)
215213
let db = mem_db();
216214
let g = db.create_group(&group("将删")).expect("create");
217215
for title in ["A", "B"] {
@@ -236,17 +234,18 @@ fn group_delete_impact_counts_six_dimensions() {
236234
source: "manual".to_string(),
237235
})
238236
.expect("frag");
239-
db.create_card(&NewFlashcard {
240-
group_id: g.id,
241-
note_id: None,
242-
fragment_id: None,
243-
front: "f".to_string(),
244-
back: "b".to_string(),
245-
kind: "fact".to_string(),
246-
state_json: "{}".to_string(),
247-
due_at: 0,
248-
})
249-
.expect("card");
237+
let card = db
238+
.create_card(&NewFlashcard {
239+
group_id: g.id,
240+
note_id: None,
241+
fragment_id: None,
242+
front: "f".to_string(),
243+
back: "b".to_string(),
244+
kind: "fact".to_string(),
245+
state_json: "{}".to_string(),
246+
due_at: 0,
247+
})
248+
.expect("card");
250249
db.create_settlement(g.id, "{}").expect("settlement");
251250
db.upsert_week_contract(g.id, 1, 3, 5).expect("contract");
252251
let sys = db
@@ -268,20 +267,30 @@ fn group_delete_impact_counts_six_dimensions() {
268267
})
269268
.expect("link");
270269
}
270+
db.add_knowledge_link(&NewKnowledgeLink {
271+
system_id: sys.id,
272+
node_id: None,
273+
concept_id: None,
274+
model_id: None,
275+
target_type: "flashcard".to_string(),
276+
target_id: card.id,
277+
})
278+
.expect("link");
271279
// Act
272280
let impact = db.group_delete_impact(g.id).expect("impact");
273-
// Assert:六项计数如实(确认弹窗数据源口径)
281+
// Assert:六项计数如实(确认弹窗数据源口径;system_refs 含两类引用)
274282
assert_eq!(impact.notes, 2);
275283
assert_eq!(impact.fragments, 1);
276284
assert_eq!(impact.cards, 1);
277285
assert_eq!(impact.settlements, 1);
278286
assert_eq!(impact.contracts, 1);
279-
assert_eq!(impact.system_refs, 2);
287+
assert_eq!(impact.system_refs, 3);
280288
}
281289

282290
#[test]
283291
fn delete_group_cascades_and_clears_links() {
284-
// Arrange:1 笔记 + 1 碎片 + 1 卡 + 1 契约 + 1 体系引用(级联影响面最小集)
292+
// Arrange:1 笔记 + 1 碎片 + 1 卡 + 1 契约 + note_group/fashcard 引用各 1
293+
//(级联影响面最小集;R-中1:flashcard 间接引用必须一并清理)
285294
let db = mem_db();
286295
let g = db.create_group(&group("将删")).expect("create");
287296
let note = db
@@ -305,17 +314,18 @@ fn delete_group_cascades_and_clears_links() {
305314
source: "manual".to_string(),
306315
})
307316
.expect("frag");
308-
db.create_card(&NewFlashcard {
309-
group_id: g.id,
310-
note_id: None,
311-
fragment_id: None,
312-
front: "f".to_string(),
313-
back: "b".to_string(),
314-
kind: "fact".to_string(),
315-
state_json: "{}".to_string(),
316-
due_at: 0,
317-
})
318-
.expect("card");
317+
let card = db
318+
.create_card(&NewFlashcard {
319+
group_id: g.id,
320+
note_id: None,
321+
fragment_id: None,
322+
front: "f".to_string(),
323+
back: "b".to_string(),
324+
kind: "fact".to_string(),
325+
state_json: "{}".to_string(),
326+
due_at: 0,
327+
})
328+
.expect("card");
319329
db.upsert_week_contract(g.id, 1, 3, 5).expect("contract");
320330
let sys = db
321331
.create_knowledge_system(&NewKnowledgeSystem {
@@ -333,10 +343,19 @@ fn delete_group_cascades_and_clears_links() {
333343
target_type: "note_group".to_string(),
334344
target_id: g.id,
335345
})
336-
.expect("link");
346+
.expect("group link");
347+
db.add_knowledge_link(&NewKnowledgeLink {
348+
system_id: sys.id,
349+
node_id: None,
350+
concept_id: None,
351+
model_id: None,
352+
target_type: "flashcard".to_string(),
353+
target_id: card.id,
354+
})
355+
.expect("card link");
337356
// Act
338357
let ok = db.delete_group(g.id).expect("delete");
339-
// Assert:组消失;笔记/碎片 SET NULL 存活;卡/契约 CASCADE 归零;引用清理
358+
// Assert:组消失;笔记/碎片 SET NULL 存活;卡/契约 CASCADE 归零;两类引用清理
340359
assert!(ok);
341360
assert!(db.get_group(g.id).expect("get").is_none());
342361
assert_eq!(db.get_note(note.id).expect("get").expect("note").group_id, None);
@@ -355,6 +374,7 @@ fn delete_group_cascades_and_clears_links() {
355374
.expect("counts");
356375
assert_eq!(counts, (0, 0, 0));
357376
assert!(db.list_links_by_target("note_group", g.id).expect("links").is_empty());
377+
assert!(db.list_links_by_target("flashcard", card.id).expect("card links").is_empty());
358378
}
359379

360380
#[test]

0 commit comments

Comments
 (0)