Skip to content

Commit 790f964

Browse files
committed
feat(ai): 成本硬拦截——启动前余额预校验 + 三出口引导 + 前端提示 (F3-D)
1 parent 7f575cd commit 790f964

4 files changed

Lines changed: 65 additions & 0 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ pub async fn ai_enrich_start(
115115
}
116116
}
117117
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)?;
118122
}
119123
let task_id = st.ai_task_seq.fetch_add(1, Ordering::Relaxed);
120124
{

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ pub async fn ai_refine_start(
148148
}
149149
}
150150
drop(guards);
151+
// ②d F3-D 修复(2026-08-21):成本硬拦截——启动前校验余额
152+
// (余额 < 预估×安全系数 → 拒绝启动 + 三出口引导;不产生"跑完
153+
// 才 402 失败"的浪费;免费档 ¥0 预估 → 余额 0 也放行)
154+
ensure_balance_for(&st, chars, &settings.model)?;
151155
}
152156
// ③ 注册任务 + 后台执行(spawn_blocking——网络/分析不阻塞异步运行时)
153157
let task_id = st.ai_task_seq.fetch_add(1, Ordering::Relaxed);
@@ -342,3 +346,48 @@ pub fn task_seq() -> Arc<AtomicU64> {
342346
pub fn task_registry() -> Arc<Mutex<HashMap<u64, AiTaskEntry>>> {
343347
Arc::new(Mutex::new(HashMap::new()))
344348
}
349+
350+
/// 成本硬拦截安全系数(预估费用 × 系数 < 余额才放行——防预估偏差导致
351+
/// 中途余额耗尽;免费档 ¥0 预估恒放行)。
352+
const BALANCE_SAFETY_FACTOR: f64 = 1.2;
353+
354+
/// 成本硬拦截(F3-D,2026-08-21):启动前校验余额。
355+
///
356+
/// @ai-context: 流程:按字符数预估费用(模型映射单价 + 输出 token)→ 查余额
357+
/// (复用 AiBalanceAdapter)→ 余额 < 预估×1.2 → 拒绝启动 + 三出口
358+
/// 引导(充值/切免费档模型/放弃)。免费档(预估 ¥0)→ 恒放行
359+
/// (余额 0 也可精修——免费模型不扣费);余额查询失败 → 放行
360+
/// (不因余额接口抖动阻断功能——降级宽容,费用风险由确认弹窗
361+
/// 展示承担)。精修/补充共用(补充经 enrich 命令调用本函数)。
362+
pub(crate) fn ensure_balance_for(st: &AppState, chars: usize, model: &str) -> Result<(), String> {
363+
let est = estimate_for_content_model(chars, model);
364+
if est.est_cost_yuan <= 0.0 {
365+
return Ok(()); // 免费档/单价 0——无扣费风险,不拦截
366+
}
367+
let required = est.est_cost_yuan * BALANCE_SAFETY_FACTOR;
368+
// 余额查询(短超时——余额接口抖动不阻断精修;失败放行宽容降级)
369+
let api_key = std::env::var("SILICONFLOW_API_KEY")
370+
.ok()
371+
.filter(|k| !k.is_empty())
372+
.or(st.ai_credentials.load_key().ok().flatten())
373+
.unwrap_or_default();
374+
if api_key.is_empty() {
375+
return Err("未配置 API 密钥(设置页保存密钥或配置环境变量 SILICONFLOW_API_KEY)".to_string());
376+
}
377+
let settings = st.ai_settings.lock().map_err(|e| format!("AI 设置锁中毒: {}", e))?.clone();
378+
let cfg = crate::ai_client::AiClient::from_settings(&settings, Some(api_key)).config;
379+
let adapter = crate::ai_balance::AiBalanceAdapter {
380+
base_url: cfg.base_url,
381+
api_key: cfg.api_key,
382+
timeout_secs: cfg.timeout_secs,
383+
max_retries: 0, // 拦截是前置守卫——不重试,失败放行
384+
};
385+
match adapter.fetch() {
386+
Ok(balance) if balance.total_balance < required => Err(format!(
387+
"余额不足:当前 ¥{:.2},本次预估 ¥{:.4}(安全系数 ×1.2)——请充值或切换免费档模型后重试",
388+
balance.total_balance, est.est_cost_yuan
389+
)),
390+
Ok(_) => Ok(()),
391+
Err(_) => Ok(()), // 余额查询失败 → 放行(宽容降级,费用由确认弹窗展示)
392+
}
393+
}

‎app/src/components/AiRefineCard.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,12 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
291291
{balance.lowBalanceWarning && <span style={{ marginLeft: 6 }}>⚠️ {balance.lowBalanceWarning}</span>}
292292
</div>
293293
)}
294+
{/* F3-D:成本硬拦截提示——余额不足时启动会被后端拒绝(三出口引导) */}
295+
{balance && est.estCostYuan > 0 && balance.balance.totalBalance < est.estCostYuan * 1.2 && (
296+
<div style={{ color: "#b91c1c", marginTop: 2 }}>
297+
⚠️ 余额不足本次预估(含安全系数)——启动将被拦截,请充值或切换免费档模型
298+
</div>
299+
)}
294300
<label style={{ display: "flex", alignItems: "center", gap: 4, margin: "4px 0" }}>
295301
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
296302
记住此选择,下次不再确认

‎app/src/components/EnrichPanel.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,12 @@ export default function EnrichPanel({ noteId, onUpdated }: { noteId: number; onU
321321
{balance.lowBalanceWarning && <span style={{ marginLeft: 6 }}>⚠️ {balance.lowBalanceWarning}</span>}
322322
</div>
323323
)}
324+
{/* F3-D:成本硬拦截提示——余额不足时启动会被后端拒绝 */}
325+
{balance && est.estCostYuan > 0 && balance.balance.totalBalance < est.estCostYuan * 1.2 && (
326+
<div style={{ color: "#b91c1c", marginTop: 2 }}>
327+
⚠️ 余额不足本次预估(含安全系数)——启动将被拦截,请充值或切换免费档模型
328+
</div>
329+
)}
324330
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
325331
<button style={{ ...btn, background: "#0d9488", color: "#fff", border: "none" }} onClick={() => void start()}>
326332
开始补充

0 commit comments

Comments
 (0)