@@ -90,23 +90,30 @@ pub async fn ai_enrich_start(
9090 return Err ( "未配置 API 密钥(设置页保存密钥或配置环境变量 SILICONFLOW_API_KEY)" . to_string ( ) ) ;
9191 }
9292 }
93- // F1 修复(2026-08-21):任务去重——同笔记存在进行中任务时拒绝重复启动
93+ // F1 修复(2026-08-21)+ 审查修复(2026-08-21):任务去重——按
94+ // **目标笔记**粒度检查(防同笔记重复扣费;不同笔记互不阻塞)
9495 {
9596 let tasks = st. ai_tasks . lock ( ) . map_err ( |e| format ! ( "任务注册表锁中毒: {}" , e) ) ?;
9697 let active = tasks. values ( ) . any ( |t| {
97- matches ! ( t. state, crate :: ai_task:: AiTaskState :: Pending | crate :: ai_task:: AiTaskState :: Running { .. } )
98+ t. target_id == note_id
99+ && matches ! ( t. state, crate :: ai_task:: AiTaskState :: Pending | crate :: ai_task:: AiTaskState :: Running { .. } )
98100 } ) ;
99101 if active {
100102 return Err ( "该笔记已有进行中的 AI 任务——请等待完成或到任务中心查看进度(防重复扣费)" . to_string ( ) ) ;
101103 }
102104 }
103- // F1 修复(2026-08-21):每日配额接入——按预估片数消耗(启动前拦截)
105+ // F1/F3-D 修复(2026-08-21):成本硬拦截 + 每日配额接入。
106+ // 顺序铁律(审查修复):先余额拦截(失败不消耗配额),后消耗配额。
104107 if !mock {
105108 let note = get_note ( & st, note_id) ?;
106109 let chars = note. content . chars ( ) . count ( ) ;
107- let slices = chars. saturating_add ( crate :: ai_task:: SLICE_MAX_CHARS - 1 )
108- / crate :: ai_task:: SLICE_MAX_CHARS
109- + 1 ;
110+ let model = st. ai_settings . lock ( ) . map ( |s| s. model . clone ( ) ) . unwrap_or_default ( ) ;
111+ crate :: commands_ai_refine:: ensure_balance_for ( & st, chars, & model) ?;
112+ let slices = if chars == 0 {
113+ 0
114+ } else {
115+ chars. saturating_add ( crate :: ai_task:: SLICE_MAX_CHARS - 1 ) / crate :: ai_task:: SLICE_MAX_CHARS
116+ } ;
110117 let now = crate :: db_sessions_rows:: unix_seconds ( ) ;
111118 let mut guards = st. ai_guardrails . lock ( ) . map_err ( |e| format ! ( "护栏状态锁中毒: {}" , e) ) ?;
112119 for _ in 0 ..slices {
@@ -115,15 +122,11 @@ pub async fn ai_enrich_start(
115122 }
116123 }
117124 drop ( guards) ;
118- // F3-D 修复(2026-08-21):成本硬拦截——启动前校验余额(精修共用
119- // ensure_balance_for:免费档放行、余额不足拒绝、查询失败宽容放行)
120- let model = st. ai_settings . lock ( ) . map ( |s| s. model . clone ( ) ) . unwrap_or_default ( ) ;
121- crate :: commands_ai_refine:: ensure_balance_for ( & st, chars, & model) ?;
122125 }
123126 let task_id = st. ai_task_seq . fetch_add ( 1 , Ordering :: Relaxed ) ;
124127 {
125128 let mut tasks = st. ai_tasks . lock ( ) . map_err ( |e| format ! ( "任务注册表锁中毒: {}" , e) ) ?;
126- tasks. insert ( task_id, AiTaskEntry { state : AiTaskState :: Pending , result : None } ) ;
129+ tasks. insert ( task_id, AiTaskEntry { state : AiTaskState :: Pending , result : None , target_id : note_id } ) ;
127130 trim_tasks ( & mut tasks) ;
128131 }
129132 // F2 任务中心(2026-08-21):任务记录落库(写库失败不阻断 AI 调用)
@@ -174,9 +177,11 @@ pub fn ai_enrich_apply(
174177) -> Result < Note , String > {
175178 // 存在性校验(versioned_save 内部也会校验——提前失败给明确错误)
176179 get_note ( state. inner ( ) , note_id) ?;
177- let cost = crate :: ai_cost:: usage_cost (
180+ // 审查修复(2026-08-21):落库成本用模型感知单价(与预估同口径)
181+ let cost = crate :: ai_cost:: usage_cost_for_model (
178182 result. base_markdown . chars ( ) . count ( ) ,
179183 result. enriched_markdown . chars ( ) . count ( ) ,
184+ & result. model ,
180185 ) ;
181186 let meta = crate :: note_version:: VersionMeta {
182187 cost_yuan : Some ( cost) ,
@@ -207,8 +212,13 @@ pub fn ai_enrich_apply(
207212 } ,
208213 )
209214 . map_err ( |e| e. to_string ( ) ) ?;
210- // F2 任务中心:标记采纳 + 成本回填(task_id 可选;防重启后重复采纳)
215+ // F2 任务中心:标记采纳 + 成本回填(task_id 可选;防重启后重复采纳)。
216+ // 审查修复(2026-08-21):服务端前置校验已采纳状态(前端禁用 + 服务端
217+ // 兜底双保险——防异常调用重复建笔记)。
211218 if let Some ( tid) = task_id {
219+ if state. db . is_ai_task_adopted ( tid) {
220+ return Err ( "该任务结果已采纳落库——请勿重复采纳(可到笔记页查看)" . to_string ( ) ) ;
221+ }
212222 let _ = state. db . mark_ai_task_adopted ( tid) ;
213223 let _ = state. db . update_ai_task_cost ( tid, cost) ;
214224 }
@@ -275,11 +285,24 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
275285 selected_kinds : selected. clone ( ) ,
276286 profile : profile. clone ( ) ,
277287 } ;
278- let resp: AiEnrichResponse = if mock {
279- mock_adapter. enrich ( & req, & selected)
280- } else {
281- adapter. enrich ( & req, & selected) . map_err ( AiTaskFailure :: from) ?
282- } ;
288+ // 单片重试 1 次(审查修复 2026-08-21:与精修 SLICE_RETRY 对齐——
289+ // 网络抖动瞬态失败直接重试,避免整任务失败浪费已成功的片)
290+ let mut resp: Option < AiEnrichResponse > = None ;
291+ for attempt in 0 ..=1 {
292+ let r = if mock {
293+ Ok ( mock_adapter. enrich ( & req, & selected) )
294+ } else {
295+ adapter. enrich ( & req, & selected) . map_err ( AiTaskFailure :: from)
296+ } ;
297+ match r {
298+ Ok ( v) => { resp = Some ( v) ; break ; }
299+ Err ( e) if attempt < 1 => {
300+ eprintln ! ( "[enrich-task] task={} 片 {} 第{}次失败,重试: {}" , task_id, i + 1 , attempt + 1 , e. message( ) ) ;
301+ }
302+ Err ( e) => return Err ( e) ,
303+ }
304+ }
305+ let resp = resp. expect ( "重试循环必然产出结果或返回" ) ;
283306 all_blocks. extend ( resp. blocks ) ;
284307 set_task (
285308 & st,
@@ -318,7 +341,8 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
318341 set_task ( & st, task_id, AiTaskState :: Succeeded ) ;
319342 // F1 修复(2026-08-21):补充调用上审计(REQ-140 轨迹可见化)
320343 push_enrich_audit ( & st, note_id, "ok" , Some ( & result. model ) ) ;
321- // F2 任务中心:终态落库(写库失败不阻断——H2 设计)
344+ // F2 任务中心:终态落库(写库失败不阻断——H2 设计)+ 保留策略
345+ // 裁剪(审查修复:运行期终态后清理超限旧终态,防表膨胀)
322346 let result_json = serde_json:: to_string ( & result) . ok ( ) ;
323347 let _ = st. db . finish_ai_task (
324348 task_id,
@@ -327,6 +351,7 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
327351 None ,
328352 elapsed_ms,
329353 ) ;
354+ let _ = st. db . trim_ai_tasks ( ) ;
330355 }
331356 Err ( reason) => {
332357 set_task ( & st, task_id, AiTaskState :: Failed { reason : reason. clone ( ) } ) ;
@@ -338,6 +363,7 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
338363 Some ( & format ! ( "{}: {}" , reason. kind( ) , reason. message( ) ) ) ,
339364 elapsed_ms,
340365 ) ;
366+ let _ = st. db . trim_ai_tasks ( ) ;
341367 }
342368 }
343369}
0 commit comments