Skip to content

Commit 564dfc5

Browse files
committed
fix(ai): 审查修复——去重按目标粒度/余额先于配额/采纳幂等/成本模型感知 (P1)
1 parent 05fb98a commit 564dfc5

12 files changed

Lines changed: 199 additions & 70 deletions

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,18 @@ pub fn estimate_for_content(chars: usize) -> CostEstimate {
120120

121121
/// 成本记录费用(纯函数:输入+输出 token × 当前单价——与预估同口径,
122122
/// M4 落库 note_ai_usage 用)。
123+
#[allow(dead_code)] // 兼容 API:测试 + 旧调用方(新代码走 _model 版)
123124
pub fn usage_cost(tokens_in: usize, tokens_out: usize) -> f64 {
124125
estimate_cost(tokens_in.saturating_add(tokens_out), price_per_1m())
125126
}
126127

128+
/// 成本记录费用(模型感知——审查修复 2026-08-21:落库成本必须与预估同
129+
/// 口径(模型映射单价),否则付费模型预估 ¥X 但落库记 ¥0,成本报表失真)。
130+
pub fn usage_cost_for_model(tokens_in: usize, tokens_out: usize, model: &str) -> f64 {
131+
let (price, _) = price_for_model(model);
132+
estimate_cost(tokens_in.saturating_add(tokens_out), price)
133+
}
134+
127135
/// 未知模型警告文案(确认弹窗拼接用)。
128136
#[allow(dead_code)] // 文案单一来源(前端经 priceKnown 自行渲染同文案)
129137
pub fn unknown_model_warning() -> &'static str {

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::sync::Mutex;
55

66
use crate::ai_cost::{
77
estimate_cost, estimate_for_content, estimate_for_content_model, estimate_tokens,
8-
price_for_model, price_per_1m, DEFAULT_PRICE_PER_1M,
8+
price_for_model, price_per_1m, usage_cost_for_model, DEFAULT_PRICE_PER_1M,
99
};
1010

1111
/// env 操作互斥(防并行测试互相覆盖 SILICONFLOW_PRICE_PER_1M_TOKENS)。
@@ -113,3 +113,20 @@ fn estimate_for_content_model_respects_mapping() {
113113
assert_eq!(unknown.est_cost_yuan, 0.0);
114114
});
115115
}
116+
117+
/// 审查修复(2026-08-21):落库成本按模型感知单价(与预估同口径——
118+
/// 免费档 ¥0;未知模型 ¥0;env 覆盖生效)。
119+
#[test]
120+
fn usage_cost_for_model_uses_model_price() {
121+
with_env_locked(|| {
122+
std::env::remove_var("SILICONFLOW_PRICE_PER_1M_TOKENS");
123+
// 免费档模型 → 0
124+
assert_eq!(usage_cost_for_model(1000, 500, "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B"), 0.0);
125+
// 未知模型 → 0(单价未登记,保守不记成本)
126+
assert_eq!(usage_cost_for_model(1000, 500, "unknown/model"), 0.0);
127+
// env 覆盖整体生效(开发路径)
128+
std::env::set_var("SILICONFLOW_PRICE_PER_1M_TOKENS", "10");
129+
let cost = usage_cost_for_model(1000, 500, "unknown/model");
130+
assert!((cost - 0.015).abs() < 1e-9, "1500 token × ¥10/1M = ¥0.015,实得 {}", cost);
131+
});
132+
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ pub fn run_refine_task(st: AppState, task_id: u64, session_id: i64, mock: bool)
8888
// F1 修复(2026-08-21):精修调用上审计——REQ-140 轨迹可见化
8989
// (此前只有余额/测试连接/复核有记录,精修补充零审计)
9090
push_refine_audit(&st, session_id, "ok", Some(&result.model));
91-
// F2 任务中心:终态落库(写库失败不阻断——H2 设计)
91+
// F2 任务中心:终态落库(写库失败不阻断——H2 设计)+ 保留策略
92+
// 裁剪(审查修复:trim 原只在启动时跑,运行期终态任务会累积——
93+
// 每次终态后清理超限旧终态,防表膨胀)
9294
let result_json = serde_json::to_string(&result).ok();
9395
let _ = st.db.finish_ai_task(
9496
task_id,
@@ -97,6 +99,7 @@ pub fn run_refine_task(st: AppState, task_id: u64, session_id: i64, mock: bool)
9799
None,
98100
elapsed_ms,
99101
);
102+
let _ = st.db.trim_ai_tasks();
100103
}
101104
Err(reason) => {
102105
// 打印具体 message——区分"未配置密钥"vs"密钥无效(401/403)"(真机排查)
@@ -115,6 +118,7 @@ pub fn run_refine_task(st: AppState, task_id: u64, session_id: i64, mock: bool)
115118
Some(&format!("{}: {}", reason.kind(), reason.message())),
116119
elapsed_ms,
117120
);
121+
let _ = st.db.trim_ai_tasks();
118122
}
119123
}
120124
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ pub fn setup_app_state(app: &mut tauri::App) -> Result<(), String> {
176176
crate::commands_ai_refine::AiTaskEntry {
177177
state: crate::ai_task::AiTaskState::Succeeded,
178178
result,
179+
// 目标 id(精修=ref_id 即会话 id;补充=笔记 id——去重粒度)
180+
target_id: rec.ref_id,
179181
},
180182
);
181183
max_id = max_id.max(rec.task_id);

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

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

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

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ const TASKS_CAP: usize = 100;
3636
pub struct AiTaskEntry {
3737
pub state: AiTaskState,
3838
pub result: Option<serde_json::Value>,
39+
/// 任务目标(去重粒度:精修=session_id、补充=note_id——审查修复
40+
/// 2026-08-21:原实现按全表 any 检查,会话 A 精修中时会话 B 也被拒)
41+
pub target_id: i64,
3942
}
4043

4144
/// 精修成功载荷(前端 diff 预览 + 采纳落库数据源)。
@@ -118,28 +121,36 @@ pub async fn ai_refine_start(
118121
return Err("未配置 API 密钥(设置页保存密钥或配置环境变量 SILICONFLOW_API_KEY)".to_string());
119122
}
120123
}
121-
// ②b F1 修复(2026-08-21):任务去重——同一会话存在进行中任务时拒绝
122-
// 重复启动(防双击/重进/多窗口重复扣费;终态任务不阻塞重试)
124+
// ②b F1 修复(2026-08-21)+ 审查修复(2026-08-21):任务去重——按
125+
// **目标会话**粒度检查进行中任务(防双击/重进/多窗口对同一会话重复
126+
// 扣费;不同会话的任务互不阻塞——原实现全表 any 会误伤其他会话)
123127
{
124128
let tasks = st.ai_tasks.lock().map_err(|e| format!("任务注册表锁中毒: {}", e))?;
125129
let active = tasks.values().any(|t| {
126-
matches!(t.state, AiTaskState::Pending | AiTaskState::Running { .. })
130+
t.target_id == session_id
131+
&& matches!(t.state, AiTaskState::Pending | AiTaskState::Running { .. })
127132
});
128133
if active {
129134
return Err("该会话已有进行中的 AI 任务——请等待完成或到任务中心查看进度(防重复扣费)".to_string());
130135
}
131136
}
132-
// ②c F1 修复(2026-08-21):每日配额接入——按预估片数消耗(启动前
133-
// 拦截而非失败后引导;耗尽 → 明确提示明日再试,REQ-145 配额出口)
137+
// ②c F1/F3-D 修复(2026-08-21):成本硬拦截 + 每日配额接入。
138+
// 顺序铁律(审查修复):先余额拦截(失败不消耗配额),后消耗配额——
139+
// 否则余额不足被拒时配额已扣(浪费每日额度)。
134140
if !mock {
135141
let segments = st.db.list_segments(session_id).map_err(|e| e.to_string())?;
136142
let ocr = st.db.list_ocr_blocks(session_id).map_err(|e| e.to_string())?;
137143
let chars = segments.iter().map(|s| s.text.chars().count()).sum::<usize>()
138144
+ ocr.iter().map(|b| b.text.chars().count()).sum::<usize>();
139-
// 片数估算(与 ai_task::slice_note 同口径的保守上界:字符数 / 单片上限 + 1)
140-
let slices = chars.saturating_add(crate::ai_task::SLICE_MAX_CHARS - 1)
141-
/ crate::ai_task::SLICE_MAX_CHARS
142-
+ 1;
145+
// 成本硬拦截(免费档 ¥0 预估 → 余额 0 也放行;查询失败宽容放行)
146+
ensure_balance_for(&st, chars, &settings.model)?;
147+
// 片数估算(与 ai_task::slice_note 同口径的保守上界:向上取整,
148+
// 空内容 0 片不消耗配额——审查修复:原公式 +1 导致空会话也扣 1)
149+
let slices = if chars == 0 {
150+
0
151+
} else {
152+
chars.saturating_add(crate::ai_task::SLICE_MAX_CHARS - 1) / crate::ai_task::SLICE_MAX_CHARS
153+
};
143154
let now = crate::db_sessions_rows::unix_seconds();
144155
let mut guards = st.ai_guardrails.lock().map_err(|e| format!("护栏状态锁中毒: {}", e))?;
145156
for _ in 0..slices {
@@ -148,16 +159,12 @@ pub async fn ai_refine_start(
148159
}
149160
}
150161
drop(guards);
151-
// ②d F3-D 修复(2026-08-21):成本硬拦截——启动前校验余额
152-
// (余额 < 预估×安全系数 → 拒绝启动 + 三出口引导;不产生"跑完
153-
// 才 402 失败"的浪费;免费档 ¥0 预估 → 余额 0 也放行)
154-
ensure_balance_for(&st, chars, &settings.model)?;
155162
}
156163
// ③ 注册任务 + 后台执行(spawn_blocking——网络/分析不阻塞异步运行时)
157164
let task_id = st.ai_task_seq.fetch_add(1, Ordering::Relaxed);
158165
{
159166
let mut tasks = st.ai_tasks.lock().map_err(|e| format!("任务注册表锁中毒: {}", e))?;
160-
tasks.insert(task_id, AiTaskEntry { state: AiTaskState::Pending, result: None });
167+
tasks.insert(task_id, AiTaskEntry { state: AiTaskState::Pending, result: None, target_id: session_id });
161168
trim_tasks(&mut tasks);
162169
}
163170
// F2 任务中心(2026-08-21):任务记录落库(pending 起步;终态在
@@ -259,9 +266,12 @@ pub fn ai_refine_apply(
259266
};
260267
let note = state.db.create_note(&new).map_err(|e| e.to_string())?;
261268
// ② 精修版落库(新版本 ai-refine + 成本 meta)
262-
let cost = crate::ai_cost::usage_cost(
269+
// 审查修复(2026-08-21):落库成本用模型感知单价(与预估同口径——
270+
// 付费模型预估 ¥X 不再记 ¥0)
271+
let cost = crate::ai_cost::usage_cost_for_model(
263272
result.base_markdown.chars().count(),
264273
result.refined_markdown.chars().count(),
274+
&result.model,
265275
);
266276
let meta = crate::note_version::VersionMeta {
267277
cost_yuan: Some(cost),
@@ -294,8 +304,13 @@ pub fn ai_refine_apply(
294304
)
295305
.map_err(|e| e.to_string())?;
296306
// F2 任务中心:标记采纳 + 成本回填(task_id 可选——旧前端调用不传则跳过;
297-
// 防重启后从任务中心重复采纳产生重复笔记)
307+
// 防重启后从任务中心重复采纳产生重复笔记)。
308+
// 审查修复(2026-08-21):服务端前置校验已采纳状态——防异常/重复调用
309+
// 绕过前端 UI 直接重复建笔记(前端禁用 + 服务端兜底双保险)。
298310
if let Some(tid) = task_id {
311+
if state.db.is_ai_task_adopted(tid) {
312+
return Err("该任务结果已采纳落库——请勿重复采纳(可到笔记页查看)".to_string());
313+
}
299314
let _ = state.db.mark_ai_task_adopted(tid);
300315
let _ = state.db.update_ai_task_cost(tid, cost);
301316
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ impl Db {
125125
Ok(())
126126
}
127127

128+
/// 查询任务是否已采纳(apply 前置校验——服务端防重复采纳兜底;
129+
/// 任务不存在/查询失败视为未采纳——旧任务无记录时放行,防御方向保守)。
130+
pub fn is_ai_task_adopted(&self, task_id: u64) -> bool {
131+
let conn = match self.conn.lock() {
132+
Ok(c) => c,
133+
Err(e) => e.into_inner(),
134+
};
135+
conn.query_row(
136+
"SELECT adopted FROM ai_tasks WHERE task_id=?1",
137+
params![task_id as i64],
138+
|row| row.get::<_, i64>(0),
139+
)
140+
.map(|v| v != 0)
141+
.unwrap_or(false)
142+
}
143+
128144
/// 回填成本(apply 落库成本后;task_id 由前端回传——result 携带)。
129145
pub fn update_ai_task_cost(&self, task_id: u64, cost_yuan: f64) -> Result<()> {
130146
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,16 @@ fn cost_backfill_updates_record() {
120120
let list = db.list_ai_tasks("refine", 10).unwrap();
121121
assert_eq!(list[0].cost_yuan, Some(0.5));
122122
}
123+
124+
#[test]
125+
fn adopted_query_true_after_mark_false_otherwise() {
126+
let db = open_mem();
127+
db.insert_ai_task(&rec(8, "refine", 4, "succeeded")).unwrap();
128+
// 未采纳 → false
129+
assert!(!db.is_ai_task_adopted(8));
130+
// 标记后 → true
131+
db.mark_ai_task_adopted(8).unwrap();
132+
assert!(db.is_ai_task_adopted(8));
133+
// 不存在的任务 → false(防御方向保守——旧任务无记录放行)
134+
assert!(!db.is_ai_task_adopted(999));
135+
}

0 commit comments

Comments
 (0)