feat(usage): NewAPI 订阅额度行 + 修复配置面板错误百分比(v0.8.4)
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
- NewAPI 用量查询新增 /api/subscription/self:订阅额度占比作为独立一行展示, 与钱包余额(/api/user/self)分开——两者是互相独立的账本 - 多订阅全部展示;订阅行 unit=% 自动参与预警阈值 - 老版本站点(无订阅接口 404)或无订阅时静默降级,行为与 v0.8.3 一致 - 修复配置面板「测试查询」对金额行错算百分比(曾把钱包余额显示为「已用 29%」); 抽出 isMoneyRow/isPlanRow/percentOf 到 usage-display.ts 与卡片共用 - models.dev 快照同步至 2026-10-15(8,876 模型 / 230 供应商) - 测试:Rust 205(+5)、前端 150(+4),tsc 零错误
This commit is contained in:
1 parent
f643802f82
commit
e00f6b6591
17 files changed
+21307
-3515
No files matched your search
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["codingplan.site"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//! HTTP 调用与 JSON→UsageData 解析拆成纯函数,便于无 mock 单元测试。
|
||||
|
||||
use super::usage_types::{UsageData, UsageResult};
|
||||
use super::coding_plan;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
@@ -467,11 +468,95 @@ pub async fn query_newapi(
|
||||
|
||||
let (quota_per_unit, symbol) = fetch_newapi_status(base_url, timeout).await;
|
||||
Ok(match parse_newapi(&body, quota_per_unit, &symbol) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Ok(wallet) => {
|
||||
// 订阅额度是决策主信息(钱包余额独立于它),前置到钱包行之前;
|
||||
// compact 卡片只取 data[0],所以顺序决定了卡片主摘要显示什么。
|
||||
let subs = fetch_newapi_subscriptions(&client, base, access_token, user_id, quota_per_unit).await;
|
||||
let mut data = subs;
|
||||
data.extend(wallet);
|
||||
UsageResult::ok(data)
|
||||
}
|
||||
Err(err) => err,
|
||||
})
|
||||
}
|
||||
|
||||
/// 拉取用户的订阅套餐额度(NewAPI `GET /api/subscription/self`)。这是 NewAPI
|
||||
/// 较新的特性:站点无此接口(404)、无订阅、网络失败或解析失败一律返回空
|
||||
/// Vec——订阅是钱包余额之外的附加信息,任何失败都不能拖垮整个用量查询。
|
||||
///
|
||||
/// 与 `/api/user/self` 同源同凭据(Bearer access token + New-Api-User)。
|
||||
async fn fetch_newapi_subscriptions(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
quota_per_unit: f64,
|
||||
) -> Vec<UsageData> {
|
||||
let url = format!("{base}/api/subscription/self");
|
||||
let Ok(resp) = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.header("New-Api-User", user_id)
|
||||
.send()
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(raw) = resp.bytes().await else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(body) = serde_json::from_slice::<serde_json::Value>(&raw) else {
|
||||
return Vec::new();
|
||||
};
|
||||
parse_newapi_subscriptions(&body, quota_per_unit)
|
||||
}
|
||||
|
||||
/// 解析订阅自查响应。每项形如 `{ subscription: { amount_total, amount_used,
|
||||
/// next_reset_time, ... } }`;金额是 int64 原始配额单位,需除以站点
|
||||
/// `quota_per_unit` 才是钱数。产出百分比行(`unit="%"`,与 plan tier 同形态,
|
||||
/// 因此自动参与预警阈值判定)。
|
||||
///
|
||||
/// `amount_total` 为 0(站点未设额度)时百分比记为 0,不做除法。
|
||||
fn parse_newapi_subscriptions(body: &serde_json::Value, quota_per_unit: f64) -> Vec<UsageData> {
|
||||
let Some(subs) = body
|
||||
.get("data")
|
||||
.and_then(|d| d.get("subscriptions"))
|
||||
.and_then(|s| s.as_array())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in subs {
|
||||
// SubscriptionSummary 把实际字段包在 `subscription` 里;兼容站点直接
|
||||
// 平铺字段的情况(取 entry 本身作回退)。
|
||||
let sub = entry.get("subscription").unwrap_or(entry);
|
||||
let total_raw = parse_f64_field(sub, "amount_total").unwrap_or(0.0);
|
||||
let used_raw = parse_f64_field(sub, "amount_used").unwrap_or(0.0);
|
||||
let total = total_raw / quota_per_unit;
|
||||
let used = used_raw / quota_per_unit;
|
||||
let percent = if total > 0.0 {
|
||||
(used / total * 100.0).clamp(0.0, 100.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
out.push(UsageData {
|
||||
plan_name: Some("subscription".to_string()),
|
||||
remaining: Some((100.0 - percent).max(0.0)),
|
||||
total: Some(100.0),
|
||||
used: Some(percent),
|
||||
unit: Some("%".to_string()),
|
||||
is_valid: Some(percent < 100.0),
|
||||
resets_at: sub.get("next_reset_time").and_then(coding_plan::extract_reset_time),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_newapi(
|
||||
body: &serde_json::Value,
|
||||
quota_per_unit: f64,
|
||||
@@ -842,6 +927,91 @@ mod tests {
|
||||
assert_eq!(data[0].used, Some(0.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_subscription_maps_to_percent_row() {
|
||||
// 用户实测站点的数值:总额度 $500、已用 $206.17 → 41.234% 已用
|
||||
// (amount_* 是 int64 原始配额,quota_per_unit=500000 换算成钱)。
|
||||
let body = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"subscriptions": [{
|
||||
"subscription": {
|
||||
"id": 9,
|
||||
"amount_total": 250_000_000,
|
||||
"amount_used": 103_085_000,
|
||||
"next_reset_time": 1_793_491_200,
|
||||
"end_time": 2_106_105_600,
|
||||
"status": "active"
|
||||
}
|
||||
}],
|
||||
"all_subscriptions": []
|
||||
}
|
||||
});
|
||||
let data = parse_newapi_subscriptions(&body, 500_000.0);
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].plan_name.as_deref(), Some("subscription"));
|
||||
assert_eq!(data[0].unit.as_deref(), Some("%"));
|
||||
assert_eq!(data[0].total, Some(100.0));
|
||||
// 41.234% 已用 → 剩余 58.766
|
||||
assert!((data[0].used.unwrap() - 41.234).abs() < 0.01);
|
||||
assert!((data[0].remaining.unwrap() - 58.766).abs() < 0.01);
|
||||
assert_eq!(data[0].is_valid, Some(true));
|
||||
assert!(data[0].resets_at.is_some(), "next_reset_time must map to ISO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_subscription_multiple_all_returned() {
|
||||
let body = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"subscriptions": [
|
||||
{ "subscription": { "amount_total": 100_000_000, "amount_used": 90_000_000 } },
|
||||
{ "subscription": { "amount_total": 200_000_000, "amount_used": 20_000_000 } }
|
||||
]
|
||||
}
|
||||
});
|
||||
let data = parse_newapi_subscriptions(&body, 500_000.0);
|
||||
assert_eq!(data.len(), 2);
|
||||
assert!((data[0].used.unwrap() - 90.0).abs() < 0.01);
|
||||
assert!((data[1].used.unwrap() - 10.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_subscription_empty_or_missing_yields_nothing() {
|
||||
// 站点无订阅 → 空数组
|
||||
let empty = json!({ "success": true, "data": { "subscriptions": [] } });
|
||||
assert!(parse_newapi_subscriptions(&empty, 500_000.0).is_empty());
|
||||
// 老版本无此接口/字段缺失 → 也必须是空,不 panic
|
||||
let no_field = json!({ "success": true, "data": {} });
|
||||
assert!(parse_newapi_subscriptions(&no_field, 500_000.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_subscription_zero_total_does_not_divide_by_zero() {
|
||||
let body = json!({
|
||||
"success": true,
|
||||
"data": { "subscriptions": [{ "subscription": { "amount_total": 0, "amount_used": 0 } }] }
|
||||
});
|
||||
let data = parse_newapi_subscriptions(&body, 500_000.0);
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].used, Some(0.0));
|
||||
assert_eq!(data[0].remaining, Some(100.0));
|
||||
assert_eq!(data[0].is_valid, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_subscription_used_over_total_is_clamped() {
|
||||
// 防御:站点数据异常(已用 > 总额)时百分比钳到 100,不出现负数剩余。
|
||||
let body = json!({
|
||||
"success": true,
|
||||
"data": { "subscriptions": [{ "subscription": { "amount_total": 100, "amount_used": 300 } }] }
|
||||
});
|
||||
let data = parse_newapi_subscriptions(&body, 1.0);
|
||||
assert_eq!(data[0].used, Some(100.0));
|
||||
assert_eq!(data[0].remaining, Some(0.0));
|
||||
assert_eq!(data[0].is_valid, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub2api_site_root_strips_v1_and_slash() {
|
||||
assert_eq!(sub2api_site_root("https://x.com"), "https://x.com");
|
||||
|
||||
@@ -53,7 +53,7 @@ fn millis_to_iso8601(ms: i64) -> Option<String> {
|
||||
/// 从 JSON 值提取重置时间,兼容字符串和数字格式:
|
||||
/// - 字符串:直接返回(视为 ISO 8601)
|
||||
/// - 数字:自动判断秒/毫秒并转为 ISO 8601;0/负值视为无重置时间
|
||||
fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
|
||||
pub(crate) fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
|
||||
if let Some(s) = value.as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in new issue
Block a user