@@ -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 }
0 commit comments