feat(usage): CodingPlan.site 自动识别 NewAPI + 账单查询体验修复

- detect_provider 新增 codingplan.site → balance:newapi(该站为
  NewAPI 实例,实测确认),保存补齐 TS 侧缺失的 balance:newapi 断言
- 凭据缺失等确定性错误改走 Ok(failure)(原 Err 被前端一律显示为
  「网络异常」),错误消息按 kind 前缀包裹后仍可本地化映射
- formatAmount 收口到 usage-display 统一复用:¤ 占位符号不再渲染、
  金额统一两位小数(配置弹窗此前直接拼原始数值)
This commit is contained in:
KimiSwitch Dev committed 2026-09-05 16:04:38 +08:00
1 parent 81699e757d
commit 1a11cfbaaf
6 files changed
+198 -31

No files matched your search

+73 -10
View File
@@ -97,6 +97,11 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
if url.contains("openrouter.ai") {
kinds.push(UsageKind::BalanceOpenrouter);
}
// codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询);
// 家族匹配意味着 ai. 子域也自然命中该规则。
if url.contains("codingplan.site") {
kinds.push(UsageKind::BalanceNewapi);
}
if url.contains("api.stepfun.com") {
kinds.push(UsageKind::BalanceStepfun);
}
@@ -126,6 +131,19 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
kinds
}
/// BalanceNewapi 模板所需凭据(web 控制台 access token + user id)。
/// 两者都必须存在且非空;返回 None 表示缺少凭据(确定性配置错误)。
fn newapi_creds(
usage_config: Option<&crate::models::UsageConfig>,
) -> Option<(&str, &str)> {
usage_config.and_then(|c| {
c.access_token
.as_deref()
.zip(c.user_id.as_deref())
.filter(|(t, u)| !t.is_empty() && !u.is_empty())
})
}
/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的
/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。
/// `usage_config` 仅 BalanceNewapi 分支读取(access_token / user_id),
@@ -155,16 +173,13 @@ pub async fn query_kind(
balance::query_kimi(api_key, !lower.contains("moonshot.ai"), timeout).await
}
UsageKind::BalanceNewapi => {
let (token, uid) = usage_config
.and_then(|c| {
c.access_token
.as_deref()
.zip(c.user_id.as_deref())
.filter(|(t, u)| !t.is_empty() && !u.is_empty())
})
.ok_or_else(|| {
"newapi template requires accessToken and userId".to_string()
})?;
// 缺凭据是确定性配置错误 → Ok(failure)(前端按前缀本地化提示),
// 而非 Err(Err 语义 = 瞬时网络错误,见 commands.rs newapi 分支)。
let Some((token, uid)) = newapi_creds(usage_config) else {
return Ok(UsageResult::failure(
"newapi template requires accessToken and userId".to_string(),
));
};
let url = usage_config
.and_then(|c| c.base_url.as_deref())
.filter(|s| !s.is_empty())
@@ -262,6 +277,28 @@ mod tests {
);
}
#[test]
fn detect_provider_codingplan_site_family() {
// codingplan.site 是 NewAPI 实例(本工具仅支持 NewAPI 查询):
// 主域与 ai. 子域都命中家族规则,且只映射到 BalanceNewapi。
assert_eq!(
detect_provider("https://codingplan.site"),
vec![UsageKind::BalanceNewapi]
);
assert_eq!(
detect_provider("https://codingplan.site/v1"),
vec![UsageKind::BalanceNewapi]
);
assert_eq!(
detect_provider("https://ai.codingplan.site"),
vec![UsageKind::BalanceNewapi]
);
assert_eq!(
detect_provider("https://ai.codingplan.site/v1"),
vec![UsageKind::BalanceNewapi]
);
}
#[test]
fn usage_kind_string_roundtrip() {
use std::str::FromStr;
@@ -272,4 +309,30 @@ mod tests {
assert!(UsageKind::from_str("balance:unknown").is_err());
assert!(UsageKind::from_str("").is_err());
}
#[test]
fn newapi_creds_requires_both_and_nonempty() {
use crate::models::UsageConfig;
let cfg = |token: Option<&str>, uid: Option<&str>| UsageConfig {
enabled: true,
template_type: "newapi".to_string(),
base_url: None,
access_token: token.map(str::to_string),
user_id: uid.map(str::to_string),
auto_query_interval_minutes: None,
timeout_seconds: None,
};
// 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误)
assert_eq!(newapi_creds(None), None);
assert_eq!(newapi_creds(Some(&cfg(None, None))), None);
assert_eq!(newapi_creds(Some(&cfg(Some("tok"), None))), None);
assert_eq!(newapi_creds(Some(&cfg(None, Some("uid")))), None);
assert_eq!(newapi_creds(Some(&cfg(Some(""), Some("uid")))), None);
assert_eq!(newapi_creds(Some(&cfg(Some("tok"), Some("")))), None);
// 两项齐全 → Some
assert_eq!(
newapi_creds(Some(&cfg(Some("tok"), Some("uid")))),
Some(("tok", "uid"))
);
}
}