Skip to content

Commit 775cf78

Browse files
committed
fix(review): SOP 模板更新保 run 链/步数校验/收尾幂等守卫/存量回填
1 parent e19984f commit 775cf78

5 files changed

Lines changed: 88 additions & 23 deletions

File tree

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ use crate::db::Db;
1616
use crate::db_completion::EV_EXPORT_MANUAL_DONE;
1717
use crate::db_practice::PracticeItem;
1818
use crate::db_questions::QuestionItem;
19-
use crate::tasks_core::{migrate_status, TaskStatus};
2019

2120
/// 迁出文件导出(保存对话框返回路径由用户授权;仅 .txt + 大小护栏)。
2221
#[tauri::command]
@@ -169,7 +168,9 @@ pub(crate) fn weekly_resolve_core(
169168
view.failed.push(format!("行 {} 内容已变化(任务“{}”失效,请刷新)", p.line_no, p.payload));
170169
continue;
171170
}
172-
if let Some(next) = crate::tasks_core::migrate_status(&line, TaskStatus::Done) {
171+
if let Some(next) =
172+
crate::tasks_core::migrate_status(&line, crate::tasks_core::TaskStatus::Done)
173+
{
173174
if next != line {
174175
body_lines[p.line_no as usize] = next;
175176
changed = true;

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

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

1111
use crate::commands::AppState;
1212
use crate::db_sop::{
13-
lines_to_steps, EVIDENCE_PREFIX, MAX_SOP_STEPS, MODE_READDO, RUN_ABORTED,
14-
RUN_DONE, SopRunDetail, SopTemplate,
13+
EVIDENCE_PREFIX, MAX_SOP_STEPS, MODE_READDO, RUN_ABORTED, RUN_DONE, SopRunDetail,
14+
SopTemplate,
1515
};
1616

1717
/// 建模板(编辑器工具栏「生成 SOP」入口:当前选中段落行范围)。
@@ -39,11 +39,12 @@ pub fn sop_template_create(
3939
.get_note(note_id)
4040
.map_err(|e| e.to_string())?
4141
.ok_or_else(|| "笔记不存在".to_string())?;
42-
let steps = lines_to_steps(&note.content, start_line, end_line);
43-
if steps.is_empty() {
42+
// 审查中-6:非截断计数先判(lines_to_steps 的 truncate 只是 DB 层兜底)
43+
let steps_count = crate::db::Db::count_template_steps(&note.content, start_line, end_line);
44+
if steps_count == 0 {
4445
return Err("所选段落无内容(无法生成空 SOP)".to_string());
4546
}
46-
if steps.len() > MAX_SOP_STEPS {
47+
if steps_count > MAX_SOP_STEPS {
4748
return Err(format!("所选段落超过 {} 步上限——请缩小范围", MAX_SOP_STEPS));
4849
}
4950
let mode = mode.unwrap_or_else(|| MODE_READDO.to_string());

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,22 @@ CREATE TABLE IF NOT EXISTS contracts (
514514
crate::db_questions::init(conn)?;
515515
// v0.20.4(REQ-303):web 会话页面表(kind=web 1:1;正文/元数据/降级附件)
516516
crate::db_web::init(conn)?;
517+
// v0.20.3(REQ-292,审查中-8):升级存量回填——task_index 空表时对既有
518+
// 笔记全量重扫(老库升级后行动中心不得“假空”;重复启动幂等:有行即跳过)
519+
let has_rows: i64 = conn
520+
.query_row("SELECT COUNT(*) FROM task_index", [], |r| r.get(0))
521+
.unwrap_or(1);
522+
if has_rows == 0 {
523+
if let Ok(mut stmt) = conn.prepare("SELECT id, content FROM notes") {
524+
if let Ok(rows) = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))) {
525+
for row in rows {
526+
if let Ok((id, content)) = row {
527+
crate::db_task_index::rebuild_note_tasks(conn, id, &content);
528+
}
529+
}
530+
}
531+
}
532+
}
517533
Ok(())
518534
}
519535

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

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
//! 执行面=前端 Overlay(commands_sop.rs 出口)。证据路径白名单
1010
//! `notes-images/` 前缀在命令层校验(G3)。
1111
12-
use rusqlite::{params, Connection};
12+
use rusqlite::{params, Connection, OptionalExtension};
1313
use serde::Serialize;
1414

1515
use crate::db::Db;
@@ -150,7 +150,8 @@ pub fn lines_to_steps(content: &str, start_line: i64, end_line: i64) -> Vec<Stri
150150
}
151151

152152
impl Db {
153-
/// 建模板(范围行数 ≤50;同笔记同名先删后插=覆盖校准)。
153+
/// 建/校准模板(审查中-5:同名存在=原地 UPDATE——覆盖校准不得级联销毁
154+
/// run 档案链/失败聚合,REQ-297 演进原料必须保留)。
154155
pub fn create_sop_template(
155156
&self,
156157
note_id: i64,
@@ -162,19 +163,43 @@ impl Db {
162163
let mode = if mode == MODE_CONFIRM { MODE_CONFIRM } else { MODE_READDO };
163164
let now = crate::db::unix_seconds();
164165
self.with_conn(|conn| {
165-
conn.execute(
166-
"DELETE FROM sop_templates WHERE note_id = ?1 AND name = ?2",
167-
params![note_id, name],
168-
)?;
169-
conn.execute(
170-
"INSERT INTO sop_templates (note_id, name, start_line, end_line, mode, created_at, updated_at)
171-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
172-
params![note_id, name, start_line, end_line, mode, now],
173-
)?;
174-
Ok(conn.last_insert_rowid())
166+
let existing: Option<i64> = conn
167+
.query_row(
168+
"SELECT id FROM sop_templates WHERE note_id = ?1 AND name = ?2",
169+
params![note_id, name],
170+
|r| r.get(0),
171+
)
172+
.optional()?;
173+
match existing {
174+
Some(id) => {
175+
conn.execute(
176+
"UPDATE sop_templates SET start_line = ?1, end_line = ?2, mode = ?3, updated_at = ?4 WHERE id = ?5",
177+
params![start_line, end_line, mode, now, id],
178+
)?;
179+
Ok(id)
180+
}
181+
None => {
182+
conn.execute(
183+
"INSERT INTO sop_templates (note_id, name, start_line, end_line, mode, created_at, updated_at)
184+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
185+
params![note_id, name, start_line, end_line, mode, now],
186+
)?;
187+
Ok(conn.last_insert_rowid())
188+
}
189+
}
175190
})
176191
}
177192

193+
/// 行范围步骤数(不截断——命令层 ≤50 校验用;审查中-6:先判后建,
194+
/// truncate 只作 DB 层兜底)。
195+
pub fn count_template_steps(content: &str, start_line: i64, end_line: i64) -> usize {
196+
let lines: Vec<&str> = content.split('\n').collect();
197+
(start_line..=end_line)
198+
.filter_map(|i| lines.get(i as usize))
199+
.filter(|l| !l.trim().is_empty())
200+
.count()
201+
}
202+
178203
pub fn list_sop_templates(&self, note_id: Option<i64>) -> Result<Vec<SopTemplate>> {
179204
self.with_conn(|conn| {
180205
let base = "SELECT t.id, t.note_id, t.name, t.start_line, t.end_line, t.mode, n.title
@@ -313,7 +338,8 @@ impl Db {
313338
})
314339
}
315340

316-
/// 步状态更新(done|skipped|failed;失败必填 failure_note 或忽略——命令层校验)。
341+
/// 步状态更新(done|skipped|failed;审查中-7:DB 层守卫 run active——
342+
/// 已收尾 run 拒写,防层间复用裸奔)。
317343
pub fn update_sop_step(
318344
&self,
319345
run_id: i64,
@@ -328,6 +354,16 @@ impl Db {
328354
)));
329355
}
330356
self.with_conn(|conn| {
357+
let run_status: Option<String> = conn
358+
.query_row(
359+
"SELECT status FROM sop_runs WHERE id = ?1",
360+
params![run_id],
361+
|r| r.get(0),
362+
)
363+
.optional()?;
364+
if run_status.as_deref() != Some(RUN_ACTIVE) {
365+
return Ok(false); // 不存在或已收尾——统一不可写语义
366+
}
331367
let affected = conn.execute(
332368
"UPDATE sop_run_steps SET status = ?1, evidence_path = ?2, failure_note = ?3, checked_at = ?4
333369
WHERE run_id = ?5 AND step_no = ?6",
@@ -338,16 +374,23 @@ impl Db {
338374
}
339375

340376
/// 结束 run(done/aborted)→ 结算统计写史(sop_run 事件;meta=统计 JSON)。
377+
/// 审查中-7:幂等守卫——仅 active run 可收尾(重复 finish 报错且不重复入史)。
341378
pub fn finish_sop_run(&self, run_id: i64, status: &str) -> Result<SopRunDetail> {
342379
if !matches!(status, RUN_DONE | RUN_ABORTED) {
343380
return Err(crate::error::AppError::Asr("仅支持 done/aborted 收尾".to_string()));
344381
}
345382
let Some(detail) = self.sop_run_detail(run_id)? else {
346383
return Err(crate::error::AppError::Io("run 不存在".to_string()));
347384
};
385+
if detail.run.status != RUN_ACTIVE {
386+
return Err(crate::error::AppError::Asr(format!(
387+
"run 已收尾({})——不可重复结算",
388+
detail.run.status
389+
)));
390+
}
348391
self.with_conn(|conn| {
349392
conn.execute(
350-
"UPDATE sop_runs SET status = ?1, finished_at = ?2 WHERE id = ?3",
393+
"UPDATE sop_runs SET status = ?1, finished_at = ?2 WHERE id = ?3 AND status = 'active'",
351394
params![status, crate::db::unix_seconds(), run_id],
352395
)?;
353396
Ok(())

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,12 @@ fn run_lifecycle_steps_history() {
7878
assert_eq!(hist.len(), 1);
7979
assert!(hist[0].meta_json.as_deref().unwrap_or("").contains("\"failed\":1"));
8080
assert_eq!(db.get_sop_run(run_id).unwrap().unwrap().status, RUN_DONE);
81-
// 已结束再收尾(幂等外层拦截前)——直接调两次不崩(status 覆盖)即可
82-
let _ = db.finish_sop_run(run_id, RUN_ABORTED).unwrap();
81+
// 已收尾 run:重复收尾拒绝(审查中-7 幂等守卫)+ 步骤拒写
82+
assert!(db.finish_sop_run(run_id, RUN_ABORTED).is_err(), "重复收尾必须拒绝");
83+
assert!(!db.update_sop_step(run_id, 3, "done", None, None).unwrap(), "已收尾 run 步骤不可写");
84+
// 非法状态仍拒绝
85+
assert!(db.update_sop_step(run_id, 3, "weird", None, None).is_err());
86+
assert!(db.update_sop_step(999_999, 1, "done", None, None).unwrap() == false, "不存在 run 不可写");
8387
}
8488

8589
#[test]

0 commit comments

Comments
 (0)