Skip to content

Commit a3a3b48

Browse files
committed
fix(review): 批决议载荷复核/任务索引元数据保留/保存收口钩子/快照净化
1 parent 943238f commit a3a3b48

4 files changed

Lines changed: 104 additions & 35 deletions

File tree

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

Lines changed: 69 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,22 @@ pub(crate) fn weekly_resolve_core(
9898
db: &Db,
9999
decisions: &[WeeklyDecision],
100100
) -> Result<WeeklyResolveView, String> {
101-
// 预读:行 → (note_id, line_no, text, 是否 abandon) + 事件载荷
102-
let mut by_note: std::collections::HashMap<i64, Vec<(i64, String)>> = Default::default();
101+
// 校验并预读任务行(失效/非法显式记 failed——不静默)
102+
struct Pending {
103+
note_id: i64,
104+
line_no: i64,
105+
payload: String,
106+
reason: Option<String>,
107+
abandon: bool,
108+
}
103109
let mut view = WeeklyResolveView { done: 0, abandoned: 0, failed: Vec::new() };
104-
let mut events: Vec<(String, Option<String>, i64)> = Vec::new(); // (text, reason, note_id)
105-
let mut items: Vec<(i64, i64, String)> = Vec::new(); // (note_id, line_no, action)
110+
let mut pendings: Vec<Pending> = Vec::new();
106111
for d in decisions {
112+
let action = d.action.clone();
113+
if !matches!(action.as_str(), "done" | "abandon") {
114+
view.failed.push(format!("行 {} 非法动作 {}", d.row_id, action));
115+
continue;
116+
}
107117
let row = match db.get_task_row(d.row_id) {
108118
Ok(Some(r)) => r,
109119
Ok(None) => {
@@ -115,45 +125,71 @@ pub(crate) fn weekly_resolve_core(
115125
continue;
116126
}
117127
};
118-
let action = d.action.clone();
119-
if !matches!(action.as_str(), "done" | "abandon") {
120-
view.failed.push(format!("行 {} 非法动作 {}", row.id, action));
121-
continue;
122-
}
123-
items.push((row.note_id, row.line_no, action.clone()));
124-
by_note.entry(row.note_id).or_default().push((row.line_no, row.task_text.clone()));
125-
if action == "abandon" {
126-
events.push((row.task_text.clone(), d.reason.clone(), row.note_id));
127-
} else {
128-
events.push((row.task_text.clone(), None, row.note_id));
129-
}
130-
if action == "abandon" {
131-
view.abandoned += 1;
132-
} else {
133-
view.done += 1;
134-
}
128+
pendings.push(Pending {
129+
note_id: row.note_id,
130+
line_no: row.line_no,
131+
payload: row.task_text,
132+
reason: d.reason.clone(),
133+
abandon: action == "abandon",
134+
});
135135
}
136-
if items.is_empty() {
136+
if pendings.is_empty() {
137137
return Ok(view);
138138
}
139-
// 每笔记单次读正文 + 行变换(字符级迁移)——事务外只读
140-
let mut contents: std::collections::HashMap<i64, (String, String)> = Default::default(); // note_id -> (title, content)
141-
for (note_id, lines) in &by_note {
139+
// 按笔记分组(保持批内顺序——正文行独立迁移互不影响)
140+
let mut by_note: std::collections::HashMap<i64, Vec<usize>> = Default::default();
141+
for (i, p) in pendings.iter().enumerate() {
142+
by_note.entry(p.note_id).or_default().push(i);
143+
}
144+
// 事务外只读+校验+字符级迁移;审查高-3:目标行必须是同一任务
145+
// (parse 载荷完全吻合),失效行记 failed 剔除——计数/史/正文一致推进
146+
let mut contents: std::collections::HashMap<i64, (String, String)> = Default::default();
147+
let mut kept_events: Vec<(String, Option<String>, i64)> = Vec::new();
148+
for (note_id, indices) in &by_note {
142149
let note = db
143150
.get_note(*note_id)
144151
.map_err(|e| e.to_string())?
145152
.ok_or_else(|| format!("笔记 {} 不存在", note_id))?;
146153
let mut body_lines: Vec<String> = note.content.split('\n').map(|s| s.to_string()).collect();
147-
for (line_no, _) in lines {
148-
if let Some(line) = body_lines.get_mut(*line_no as usize) {
149-
if let Some(next) = migrate_status(line, TaskStatus::Done) {
150-
*line = next;
154+
let mut changed = false;
155+
for &i in indices {
156+
let p = &pendings[i];
157+
let line = match body_lines.get(p.line_no as usize) {
158+
Some(l) => l.clone(),
159+
None => {
160+
view.failed.push(format!("行 {} 已越界(正文变化请刷新后重试)", p.line_no));
161+
continue;
162+
}
163+
};
164+
let Some(parsed) = crate::tasks_core::parse_task_line(&line) else {
165+
view.failed.push(format!("行 {} 已非任务行(任务“{}”失效)", p.line_no, p.payload));
166+
continue;
167+
};
168+
if parsed.payload != p.payload {
169+
view.failed.push(format!("行 {} 内容已变化(任务“{}”失效,请刷新)", p.line_no, p.payload));
170+
continue;
171+
}
172+
if let Some(next) = crate::tasks_core::migrate_status(&line, TaskStatus::Done) {
173+
if next != line {
174+
body_lines[p.line_no as usize] = next;
175+
changed = true;
151176
}
152177
}
178+
kept_events.push((p.payload.clone(), p.reason.clone(), *note_id));
179+
if p.abandon {
180+
view.abandoned += 1;
181+
} else {
182+
view.done += 1;
183+
}
184+
}
185+
if changed {
186+
contents.insert(*note_id, (note.title.clone(), body_lines.join("\n")));
153187
}
154-
contents.insert(*note_id, (note.title.clone(), body_lines.join("\n")));
155188
}
156-
// 单事务提交:正文/索引/完成史原子
189+
if kept_events.is_empty() {
190+
return Ok(view);
191+
}
192+
// 单事务提交:正文/索引(kb+task)/完成史原子(任一步失败整体回滚)
157193
let now = crate::db::unix_seconds();
158194
let tx: crate::error::Result<()> = db.with_conn(|conn| {
159195
conn.execute("BEGIN TRANSACTION", [])?;
@@ -163,7 +199,7 @@ pub(crate) fn weekly_resolve_core(
163199
for (note_id, (_title, content)) in &contents {
164200
stmt.execute(params![content, now, note_id])?;
165201
// 派生索引同事务重建(kb 影子表——与 db_notes::update_note 同钩子;
166-
// 绕过高层 update_note 的直写路径必须在此补齐,防 kb 陈旧)
202+
// 绕过高层 update_note 的直写路径必须在此补齐,防 kb/队列陈旧)
167203
crate::kb_index::soft_rebuild_note(conn, *note_id, content);
168204
crate::db_task_index::rebuild_note_tasks(conn, *note_id, content);
169205
}
@@ -173,7 +209,7 @@ pub(crate) fn weekly_resolve_core(
173209
"INSERT INTO completion_history (ts, event_type, source_type, source_id, note_id, text, note)
174210
VALUES (?1, ?2, 'task_line', NULL, ?3, ?4, ?5)",
175211
)?;
176-
for (text, reason, note_id) in &events {
212+
for (text, reason, note_id) in &kept_events {
177213
let event_type = if reason.is_some() { "abandoned" } else { "done" };
178214
stmt.execute(params![now, event_type, note_id, text, reason])?;
179215
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,8 @@ impl Db {
249249
// 则在此建笔记分支索引最终正文——事务内软失败记录不阻断)
250250
if !content.trim().is_empty() {
251251
crate::kb_index::soft_rebuild_note(&tx, note_id, &content);
252+
// v0.20.3(REQ-292)保存收口钩子补齐:升笔记直写路径任务索引同事务
253+
crate::db_task_index::rebuild_note_tasks(&tx, note_id, &content);
252254
}
253255
tx.commit()?;
254256
// ⑤ 组装返回(与库内一致)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ impl Db {
141141
// 单笔记毫秒级);失败软记录进 kb_meta,主链路(保存)不反悔
142142
if cur != content {
143143
crate::kb_index::soft_rebuild_note(&tx, note_id, content);
144+
// v0.20.3(REQ-292)保存收口钩子补齐:versioned_save 统一写路径
145+
// (显式保存/AI 采纳/回滚/补充)正文变化同事务重扫任务索引
146+
crate::db_task_index::rebuild_note_tasks(&tx, note_id, content);
144147
}
145148
tx.commit()?;
146149
Ok(NoteVersion {

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,23 +66,51 @@ pub fn rebuild_note_tasks(conn: &Connection, note_id: i64, content: &str) {
6666

6767
fn rebuild_inner(conn: &Connection, note_id: i64, content: &str) -> Result<()> {
6868
let now = crate::db::unix_seconds();
69+
// 重扫前读取旧行元数据(plan_date/disposition/created_at)——行号漂移由
70+
// (note_id,line_no) 键吸收;改期/纠偏为索引列元数据,不得被每次正文保存
71+
// 的重扫抹除(审查高-1:先删后插必须回填,否则计划分区/徽标失真)
72+
let mut meta: std::collections::HashMap<i64, (Option<i64>, Option<String>, i64)> = Default::default();
73+
{
74+
let mut stmt = conn.prepare(
75+
"SELECT line_no, plan_date, disposition, created_at FROM task_index WHERE note_id = ?1",
76+
)?;
77+
let mapped = stmt.query_map(params![note_id], |r| {
78+
Ok((
79+
r.get::<_, i64>(0)?,
80+
r.get::<_, Option<i64>>(1)?,
81+
r.get::<_, Option<String>>(2)?,
82+
r.get::<_, i64>(3)?,
83+
))
84+
})?;
85+
for row in mapped {
86+
let (line_no, plan_date, disposition, created_at) = row?;
87+
meta.insert(line_no, (plan_date, disposition, created_at));
88+
}
89+
}
6990
conn.execute("DELETE FROM task_index WHERE note_id = ?1", params![note_id])?;
7091
let mut stmt = conn.prepare(
71-
"INSERT INTO task_index (note_id, line_no, task_text, status, unrefined, created_at, updated_at)
72-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
92+
"INSERT INTO task_index (note_id, line_no, task_text, status, unrefined, plan_date, disposition, created_at, updated_at)
93+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
7394
)?;
7495
for (line_no, line) in content.split('\n').enumerate() {
7596
if let Some(p) = parse_task_line(line) {
7697
let status = match p.status {
7798
TaskStatus::Todo => "todo",
7899
TaskStatus::Done => "done",
79100
};
101+
let (plan_date, disposition, created_at) = match meta.get(&(line_no as i64)) {
102+
Some((pd, dp, ca)) => (*pd, dp.clone(), *ca),
103+
None => (None, None, now),
104+
};
80105
stmt.execute(params![
81106
note_id,
82107
line_no as i64,
83108
p.payload,
84109
status,
85110
p.unrefined as i64,
111+
plan_date,
112+
disposition,
113+
created_at,
86114
now
87115
])?;
88116
}

0 commit comments

Comments
 (0)