@@ -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> {
342346pub 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+ }
0 commit comments