Skip to content

Commit 9963be6

Browse files
committed
feat(fragments): feed 消费闭环命令(delete/移组/图片 resolve,REQ-201)
1 parent 5893c8d commit 9963be6

3 files changed

Lines changed: 172 additions & 4 deletions

File tree

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,94 @@ pub fn list_group_fragments(state: State<'_, AppState>, group_id: i64) -> Result
144144
state.db.list_fragments_by_group(group_id).map_err(|e| e.to_string())
145145
}
146146

147+
/// 移动碎片到组(None=移出组;用户纠错/重新归组——REQ-201 消费闭环)。
148+
///
149+
/// @ai-context: 与 capture_fragment 同开关准入(feed 能力默认关纪律对称——
150+
/// 后端不信前端隐藏);目标组存在性校验(不写孤儿引用)。
151+
#[tauri::command]
152+
pub fn update_fragment_group(
153+
state: State<'_, AppState>,
154+
fragment_id: i64,
155+
group_id: Option<i64>,
156+
) -> Result<bool, String> {
157+
require_feed_enabled(&state)?;
158+
if fragment_id <= 0 {
159+
return Err("无效的碎片 id".to_string());
160+
}
161+
if state.db.get_fragment(fragment_id).map_err(|e| e.to_string())?.is_none() {
162+
return Err(format!("碎片不存在: {}", fragment_id));
163+
}
164+
if let Some(gid) = group_id {
165+
if gid <= 0 {
166+
return Err("无效的组 id".to_string());
167+
}
168+
if state.db.get_group(gid).map_err(|e| e.to_string())?.is_none() {
169+
return Err(format!("笔记组不存在: {}", gid));
170+
}
171+
}
172+
state
173+
.db
174+
.update_fragment_group(fragment_id, group_id)
175+
.map_err(|e| e.to_string())
176+
}
177+
178+
/// 删除碎片(REQ-201 用户主动删除——真删;绑定卡自动解绑保留)。
179+
///
180+
/// @ai-context: 开关准入同 capture_fragment(feed 能力对称纪律);存在性校验
181+
/// 前置(删不存在的碎片返回明确错误而非静默 false)。
182+
#[tauri::command]
183+
pub fn delete_fragment(state: State<'_, AppState>, fragment_id: i64) -> Result<bool, String> {
184+
require_feed_enabled(&state)?;
185+
if fragment_id <= 0 {
186+
return Err("无效的碎片 id".to_string());
187+
}
188+
if state.db.get_fragment(fragment_id).map_err(|e| e.to_string())?.is_none() {
189+
return Err(format!("碎片不存在: {}", fragment_id));
190+
}
191+
state.db.delete_fragment(fragment_id).map_err(|e| e.to_string())
192+
}
193+
194+
/// 碎片图片 → 本地绝对路径(前端 convertFileSrc 消费;REQ-201 缩略图)。
195+
///
196+
/// @ai-context: resolve 先例同 resolve_note_image——WebView 不能直接读本地文件;
197+
/// 本命令从库读 image_path 而非信任前端传参(防任意路径穿越),
198+
/// 只放行 fragments/ 前缀;无图 → None(UI 降级文本预览)。
199+
#[tauri::command]
200+
pub fn resolve_fragment_image(
201+
state: State<'_, AppState>,
202+
fragment_id: i64,
203+
) -> Result<Option<String>, String> {
204+
if fragment_id <= 0 {
205+
return Err("无效的碎片 id".to_string());
206+
}
207+
let frag = state
208+
.db
209+
.get_fragment(fragment_id)
210+
.map_err(|e| e.to_string())?
211+
.ok_or_else(|| format!("碎片不存在: {}", fragment_id))?;
212+
let Some(rel) = frag.image_path else { return Ok(None) };
213+
// 只放行 fragments/ 前缀 + 防穿越(落盘时的白名单格式已由 image crate 把关)
214+
let rel_trim = rel.trim_start_matches(['/', '\\']);
215+
if !rel_trim.starts_with("fragments/") || rel_trim.split(['/', '\\']).any(|seg| seg == "..") {
216+
eprintln!("[fragments] 异常图片路径被拒: {}", rel);
217+
return Ok(None);
218+
}
219+
let abs = state.data_dir.join(rel_trim);
220+
Ok(Some(abs.to_string_lossy().into_owned()))
221+
}
222+
223+
/// feed 能力开关准入(capture/delete/移组共用——后端不信前端隐藏)。
224+
fn require_feed_enabled(state: &AppState) -> Result<(), String> {
225+
let guard = state
226+
.feature_flags
227+
.lock()
228+
.map_err(|e| format!("开关锁失败: {}", e))?;
229+
if !guard.feed_capture {
230+
return Err("feed 能力未启用(请在设置中开启 feed 捕获开关)".to_string());
231+
}
232+
Ok(())
233+
}
234+
147235
/// base64 解码 + 解码验证并落盘碎片图片(返回 fragments/ 下相对路径)。
148236
///
149237
/// @ai-context: image::load_from_memory 把关格式白名单(png/jpeg/webp/gif/bmp…

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,7 @@ impl Db {
108108
})
109109
}
110110

111-
/// 移动碎片到组(None=移出;用户纠错/结算归组共用)。
112-
/// 登记豁免 dead_code:碎片移动 UI 随 v0.11.3+ 结算面建设接线。
113-
#[allow(dead_code)]
111+
/// 移动碎片到组(None=移出;用户纠错/结算归组共用;v0.11.4 命令接线)。
114112
pub fn update_fragment_group(&self, id: i64, group_id: Option<i64>) -> Result<bool> {
115113
self.with_conn(|conn| {
116114
let affected = conn.execute(
@@ -121,6 +119,34 @@ impl Db {
121119
})
122120
}
123121

122+
/// 按 id 读取碎片;不存在返回 None(delete/移组命令的存在性校验)。
123+
pub fn get_fragment(&self, id: i64) -> Result<Option<Fragment>> {
124+
self.with_conn(|conn| {
125+
let mut stmt = conn.prepare(&format!(
126+
"SELECT {} FROM fragments WHERE id = ?1",
127+
FRAGMENT_COLUMNS
128+
))?;
129+
let mut rows = stmt.query_map(params![id], row_to_fragment)?;
130+
match rows.next() {
131+
Some(Ok(f)) => Ok(Some(f)),
132+
Some(Err(e)) => Err(e.into()),
133+
None => Ok(None),
134+
}
135+
})
136+
}
137+
138+
/// 删除碎片(v0.11.4 REQ-201 用户主动删除——真删非归档)。
139+
///
140+
/// @ai-context: 绑定闪卡经 flashcards.fragment_id ON DELETE SET NULL 自动
141+
/// 解绑保留(学习循环资产不被碎片删除连带——身份诚实:
142+
/// 卡已生成即独立资产);结算归档走 set_fragment_status 不删。
143+
pub fn delete_fragment(&self, id: i64) -> Result<bool> {
144+
self.with_conn(|conn| {
145+
let affected = conn.execute("DELETE FROM fragments WHERE id = ?1", params![id])?;
146+
Ok(affected > 0)
147+
})
148+
}
149+
124150
/// 标记碎片状态(v0.11.3 结算归档:active↔archived)。
125151
pub fn set_fragment_status(&self, id: i64, status: &str) -> Result<bool> {
126152
self.with_conn(|conn| {

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

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
use crate::db::Db;
44
use crate::db_fragments::NewFragment;
55
use crate::types::NewNoteGroup;
6-
76
/// 内存库。
87
fn mem_db() -> Db {
98
Db::open(":memory:").expect("内存库打开")
@@ -137,3 +136,58 @@ fn delete_group_keeps_fragments() {
137136
assert_eq!(fetched[0].id, f.id);
138137
assert_eq!(fetched[0].group_id, None);
139138
}
139+
140+
#[test]
141+
fn delete_fragment_removes_and_detaches_cards() {
142+
// Arrange:碎片 + 绑定卡(fragment_id 外键)
143+
let db = mem_db();
144+
let group = db
145+
.create_group(&NewNoteGroup {
146+
name: "消费组".to_string(),
147+
terrain: "feed".to_string(),
148+
kind: "topic".to_string(),
149+
domain_tag: Some("beauty".to_string()),
150+
source: "route".to_string(),
151+
series_key: None,
152+
route_reason: None,
153+
})
154+
.expect("group");
155+
let f = db.create_fragment(&frag("待删碎片", None, Some(group.id))).expect("f");
156+
let card = db
157+
.create_card(&crate::db_flashcards::NewFlashcard {
158+
group_id: group.id,
159+
note_id: None,
160+
fragment_id: Some(f.id),
161+
front: "线索".to_string(),
162+
back: "验证".to_string(),
163+
kind: "fact".to_string(),
164+
state_json: "{}".to_string(),
165+
due_at: 0,
166+
})
167+
.expect("card");
168+
// Act:真删碎片
169+
assert!(db.delete_fragment(f.id).expect("delete"));
170+
// Assert:碎片消失;卡保留且 fragment_id 解绑(SET NULL——卡是独立资产)
171+
assert!(db.get_fragment(f.id).expect("get").is_none());
172+
let fetched = db.get_card(card.id).expect("get").expect("卡应保留");
173+
assert_eq!(fetched.fragment_id, None);
174+
}
175+
176+
#[test]
177+
fn get_fragment_roundtrip_and_missing() {
178+
// Arrange
179+
let db = mem_db();
180+
let f = db.create_fragment(&frag("可查碎片", None, None)).expect("f");
181+
// Act/Assert:存在返回完整记录;不存在返回 None
182+
let got = db.get_fragment(f.id).expect("get").expect("应存在");
183+
assert_eq!(got.text, "可查碎片");
184+
assert_eq!(got.status, "active");
185+
assert!(db.get_fragment(9999).expect("get").is_none());
186+
}
187+
188+
#[test]
189+
fn delete_missing_fragment_returns_false() {
190+
// Arrange/Act/Assert:删不存在的碎片诚实返回 false(不报错不panic)
191+
let db = mem_db();
192+
assert!(!db.delete_fragment(9999).expect("delete"));
193+
}

0 commit comments

Comments
 (0)