Skip to content

Commit 7818e90

Browse files
committed
feat(ai): v0.8.0 M1 AI 使能层——密钥管理/余额查询/授权审计 + 共享 client (REQ-138/139/140)
1 parent 6fb5d58 commit 7818e90

22 files changed

Lines changed: 1841 additions & 96 deletions

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33
本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 与 [SemVer](https://semver.org/lang/zh-CN/)。
44
各版本的深度版本文档见 [docs/versions/](docs/versions/)。
55

6+
## [0.8.0] - 2026-08(AI 接入,M1 开发中)
7+
8+
### 新增(M1 AI 使能层,REQ-138/139/140)
9+
10+
- **AI 服务设置面板**(课堂助手左栏):全局开关(授权红线默认关)+ 端点/模型可配 + 一键测试连接 + 余额卡片(total/grants/topped_up 分项 + 刷新 + 低余额提醒)+ 审计列表(可清空)
11+
- **密钥管理(REQ-138)**:密钥掩码输入/保存/清除——Windows DPAPI 加密存储到 `ai_credentials.bin`(明文红线:不落 SQLite/明文文件;ADR-016:keyring spike 因本机 TLS 拦截跳过,走规划 DPAPI fallback);环境变量 `SILICONFLOW_API_KEY` 保留为开发路径(优先级 env > 凭据库);前端永不回传密钥(视图只有 has_key/key_source)
12+
- **余额查询(REQ-139)**:`ai_balance.rs`——GET /v1/user/balance + 解析容错(字段缺失按 0 尽力而为)+ 低余额阈值可配(默认 ¥1);`ai_test_connection` 复用余额接口做一键连通性验证(错误密钥明确报错)
13+
- **授权与审计(REQ-140)**:`ai_settings.rs` 持久化(enabled/authorized 默认关——授权红线);开启开关时内联授权确认卡(上传说明:仅文本+最小上下文,音视频/图像永不出本机);审计列表消费 REQ-085 `AiAuditEntry` 缓冲
14+
- **共享 AI client(REQ-138)**:`ai_client.rs` 抽取——base_url/api_key/model 配置聚合(env > 设置 > 默认)+ 超时/指数退避重试 + 错误归一六类(Auth/Network/Balance/Quota/Server/Parse,REQ-145 失败原因映射基础);`ai_text_filter.rs` 重构复用(既有测试保持全绿)
15+
16+
### 测试
17+
18+
- 单测 +20(ai_settings 默认关/partial JSON/损坏回退/roundtrip/双门控边界 · ai_credentials 内存桩 roundtrip/清除/空密钥拒绝 · ai_balance 解析容错/低余额边界 · ai_client payload/no_think/剥围栏/解析错误/无密钥 Auth 错误);全量 `cargo test --lib` 1204 通过
19+
620
## [0.7.2] - 2026-08(开发中)
721

822
### 新增

‎app/src-tauri/Cargo.toml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,10 @@ windows = { version = "0.61", features = [
7979
# v0.7.0(2026-08 用户需求):DwmGetWindowAttribute(DWMWA_CLOAKED) 过滤
8080
# cloaked 窗口(UWP/Edge 悬浮层被 DWM 隐藏但 IsWindowVisible 误报)
8181
"Win32_Graphics_Dwm",
82+
# v0.8.0 M1(REQ-138):AI 密钥安全存储——DPAPI CryptProtectData/
83+
# CryptUnprotectData(Windows Credential 保护;明文红线:密钥不落 SQLite/
84+
# 明文文件)。keyring spike 因本机 TLS 拦截(crates.io 新依赖下载失败)
85+
# 跳过,直接走规划裁决的 DPAPI fallback 路径(ADR-016)
86+
"Win32_Security_Cryptography",
8287
] }
8388

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

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
//! AI 余额查询(REQ-139,v0.8.0 M1 使能层)。
2+
//!
3+
//! @ai-context: GET {base_url}/user/balance(Bearer 密钥)→ SiliconFlow 返回
4+
//! {total_balance, grants_balance, topped_up_balance, currency}。
5+
//! 超时+重试沿用适配器模式(ai_text_filter 先例:429/5xx/传输
6+
//! 错误指数退避,4xx 非瞬态不重试)。
7+
//! @ai-context: 解析容错(防御性编程):字段缺失/非法数值按 0 处理并记录
8+
//! 缺失项(不 panic、不整单丢弃——余额展示尽力而为);
9+
//! currency 缺失默认 "CNY"。
10+
//! @ai-context: 本模块只做余额数据与查询,不 gate 授权(查询为读操作,
11+
//! 不涉及内容上传——门控见 ai_settings.rs content_gate)。
12+
13+
use serde::{Deserialize, Serialize};
14+
15+
/// 余额快照(SiliconFlow /v1/user/balance 响应;字段缺失容错解析)。
16+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17+
#[serde(rename_all = "camelCase")]
18+
pub struct AiBalance {
19+
pub total_balance: f64,
20+
pub grants_balance: f64,
21+
pub topped_up_balance: f64,
22+
pub currency: String,
23+
}
24+
25+
/// 解析余额响应(纯函数;容错:字段缺失/非法 → 0,currency 缺失 → "CNY")。
26+
///
27+
/// @ai-context: 返回 Err 仅当整体非 JSON 对象(无法尽力而为);
28+
/// 字段缺失按 0 处理——SiliconFlow 分项字段可能因账户类型缺失。
29+
pub fn parse_balance(body: &str) -> Result<AiBalance, String> {
30+
let v: serde_json::Value =
31+
serde_json::from_str(body).map_err(|e| format!("余额响应 JSON 解析失败: {}", e))?;
32+
let obj = v
33+
.as_object()
34+
.ok_or_else(|| "余额响应不是 JSON 对象".to_string())?;
35+
let num = |k: &str| -> f64 {
36+
obj.get(k).and_then(|x| x.as_f64()).or_else(|| {
37+
obj.get(k).and_then(|x| x.as_str()).and_then(|s| s.parse().ok())
38+
}).unwrap_or(0.0)
39+
};
40+
let currency = obj
41+
.get("currency")
42+
.and_then(|c| c.as_str())
43+
.filter(|s| !s.is_empty())
44+
.unwrap_or("CNY")
45+
.to_string();
46+
Ok(AiBalance {
47+
total_balance: num("total_balance"),
48+
grants_balance: num("grants_balance"),
49+
topped_up_balance: num("topped_up_balance"),
50+
currency,
51+
})
52+
}
53+
54+
/// 低余额提醒(纯函数:total < 阈值 → Some 提示;负数余额也触发)。
55+
pub fn low_balance_warning(balance: &AiBalance, threshold: f64) -> Option<String> {
56+
if balance.total_balance < threshold {
57+
Some(format!(
58+
"余额不足:当前 {:.2} {}(低于提醒阈值 {:.2}),AI 生成可能中断,请及时充值",
59+
balance.total_balance, balance.currency, threshold
60+
))
61+
} else {
62+
None
63+
}
64+
}
65+
66+
/// 余额查询适配器(阻塞调用——command 层 spawn_blocking 包裹)。
67+
#[derive(Debug, Clone)]
68+
pub struct AiBalanceAdapter {
69+
pub base_url: String,
70+
pub api_key: String,
71+
pub timeout_secs: u64,
72+
pub max_retries: u32,
73+
}
74+
75+
impl AiBalanceAdapter {
76+
/// 查询余额(HTTP 429/5xx/传输错误指数退避重试;4xx 非瞬态不重试)。
77+
///
78+
/// @ai-context: 401/403 → 明确"密钥无效"错误(REQ-138 一键连通性验证
79+
/// 复用本函数——错误密钥验证失败有明确提示)。
80+
pub fn fetch(&self) -> Result<AiBalance, String> {
81+
if self.api_key.trim().is_empty() {
82+
return Err("未配置 API 密钥(设置页保存密钥或配置环境变量 SILICONFLOW_API_KEY)".to_string());
83+
}
84+
let url = format!(
85+
"{}/user/balance",
86+
self.base_url.trim_end_matches('/').trim_end_matches("/v1")
87+
);
88+
let agent = ureq::AgentBuilder::new()
89+
.timeout(std::time::Duration::from_secs(self.timeout_secs.max(5)))
90+
.build();
91+
let mut last_err = "未发起请求".to_string();
92+
for attempt in 0..=self.max_retries {
93+
if attempt > 0 {
94+
let backoff = (500u64 << attempt.min(4)).min(8000);
95+
std::thread::sleep(std::time::Duration::from_millis(backoff));
96+
}
97+
match agent
98+
.get(&url)
99+
.set("Authorization", &format!("Bearer {}", self.api_key.trim()))
100+
.call()
101+
{
102+
Ok(resp) => {
103+
let body = resp
104+
.into_string()
105+
.map_err(|e| format!("读取余额响应失败: {}", e))?;
106+
return parse_balance(&body);
107+
}
108+
Err(ureq::Error::Status(401 | 403, _)) => {
109+
return Err("API 密钥无效或无权限(请检查设置页密钥)".to_string());
110+
}
111+
Err(ureq::Error::Status(429, _)) => {
112+
last_err = "请求过频(HTTP 429)".to_string();
113+
if attempt == self.max_retries {
114+
break;
115+
}
116+
}
117+
Err(ureq::Error::Status(code, _)) if code >= 500 => {
118+
last_err = format!("服务端错误 HTTP {}", code);
119+
if attempt == self.max_retries {
120+
break;
121+
}
122+
}
123+
Err(ureq::Error::Status(code, _)) => {
124+
return Err(format!("余额查询被拒绝 HTTP {}(不重试——4xx 非瞬态)", code));
125+
}
126+
Err(e) => {
127+
last_err = format!("传输错误: {}", e);
128+
if attempt == self.max_retries {
129+
break;
130+
}
131+
}
132+
}
133+
}
134+
Err(format!("重试 {} 次后仍失败: {}", self.max_retries, last_err))
135+
}
136+
}
137+
138+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
139+
#[cfg(test)]
140+
#[path = "ai_balance_tests.rs"]
141+
mod tests;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//! ai_balance.rs 单测(AAA 模式)。
2+
//!
3+
//! @ai-context: 覆盖:完整响应解析、字段缺失容错(分项缺失/非法 → 0)、
4+
//! currency 缺失默认 CNY、非 JSON 报错、低余额提醒边界
5+
//! (等于阈值不提醒/低于阈值提醒/负余额提醒)。
6+
7+
use crate::ai_balance::{low_balance_warning, parse_balance, AiBalance};
8+
9+
#[test]
10+
fn parse_full_response() {
11+
let raw = r#"{"total_balance":12.34,"grants_balance":10.0,"topped_up_balance":2.34,"currency":"CNY"}"#;
12+
let b = parse_balance(raw).expect("合法响应");
13+
assert_eq!(
14+
b,
15+
AiBalance {
16+
total_balance: 12.34,
17+
grants_balance: 10.0,
18+
topped_up_balance: 2.34,
19+
currency: "CNY".to_string(),
20+
}
21+
);
22+
}
23+
24+
#[test]
25+
fn parse_missing_fields_default_zero() {
26+
// 字段缺失容错:分项缺失/非法 → 0(账户类型差异不整单丢弃)
27+
let raw = r#"{"total_balance":5.0}"#;
28+
let b = parse_balance(raw).expect("缺失字段容错解析");
29+
assert_eq!(b.grants_balance, 0.0);
30+
assert_eq!(b.topped_up_balance, 0.0);
31+
assert_eq!(b.currency, "CNY");
32+
}
33+
34+
#[test]
35+
fn parse_invalid_number_treated_as_zero() {
36+
// 非法数值(字符串"abc")→ 0,不 panic
37+
let raw = r#"{"total_balance":"abc","grants_balance":1,"currency":"USD"}"#;
38+
let b = parse_balance(raw).expect("非法数值容错");
39+
assert_eq!(b.total_balance, 0.0);
40+
assert_eq!(b.grants_balance, 1.0);
41+
assert_eq!(b.currency, "USD");
42+
}
43+
44+
#[test]
45+
fn parse_non_json_is_error() {
46+
// 整体非 JSON → Err(无法尽力而为)
47+
assert!(parse_balance("not json").is_err());
48+
assert!(parse_balance(r#"[1,2,3]"#).is_err());
49+
}
50+
51+
#[test]
52+
fn low_balance_boundary() {
53+
let mk = |total: f64| AiBalance {
54+
total_balance: total,
55+
grants_balance: 0.0,
56+
topped_up_balance: 0.0,
57+
currency: "CNY".to_string(),
58+
};
59+
// 等于阈值 → 不提醒(阈值语义:< 才提醒)
60+
assert_eq!(low_balance_warning(&mk(1.0), 1.0), None);
61+
// 低于阈值 → 提醒
62+
assert!(low_balance_warning(&mk(0.99), 1.0).is_some());
63+
// 负余额(透支)→ 提醒
64+
assert!(low_balance_warning(&mk(-3.0), 1.0).is_some());
65+
// 充足 → 不提醒
66+
assert_eq!(low_balance_warning(&mk(50.0), 1.0), None);
67+
}

0 commit comments

Comments
 (0)