From c2c63e4d5a482d5eeb21369716cf2b8c7756c242 Mon Sep 17 00:00:00 2001 From: John Ye Date: Thu, 11 Dec 2025 09:32:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=2088code=20PAYGO=20=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当 PLUS 套餐用完后,自动从 Subscription API 获取 PAYGO 额度并显示: - 显示格式:PAYGO $XX.XX(蓝色) - 使用 5 分钟缓存避免频繁 API 调用 - 仅对 88code 服务生效,不影响其他中转站 同时修复: - 为 SubscriptionData 添加 current_credits 和 credit_limit 字段 - 其他中转站(relay)检测到 API 数据异常时显示友好提示 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 213 ++++++++++++++++++++++++++ src/api/client.rs | 40 +++-- src/api/mod.rs | 75 ++++++--- src/core/segments/byebyecode_usage.rs | 97 +++++++++++- 4 files changed, 378 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c2a51a8..d8f9216 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -262,6 +262,219 @@ pub fn get_subscriptions(&self, model: Option<&str>) -> Result 0.0); + + if let Some(paygo_sub) = paygo { + // 显示 PAYGO 剩余额度(蓝色) + return Some(SegmentData { + primary: format!("PAYGO ${:.2}", paygo_sub.current_credits), + ... + }); + } + } + } +} +``` + +**性能优化**: +- 订阅数据使用 5 分钟缓存,避免频繁 API 调用 +- API 失败时降级到过期缓存 + +### 状态 + +✅ **已解决**(2025-12-11) + +**限制**:PAYGO 无法显示进度条,因为 Subscription API 不返回 `creditLimit`(总额度)字段。 + +--- + +## 🚨 待解决:Privnode API 返回数据无法正确显示账户余额(2025-12-11) + +### 问题描述 + +使用 Privnode 中转站时,状态栏显示 `relay $10.92/$1`,与实际账户数据不符: +- **实际当前余额**:$14.01 +- **实际历史消耗**:$11.01 +- **状态栏显示**:`$10.92/$1`(已用/总额) + +### API 返回数据分析 + +**请求**: +```bash +curl -s "https://privnode.com/api/usage/token/" \ + -H "Authorization: Bearer sk-xxx" +``` + +**返回**: +```json +{ + "code": true, + "data": { + "expires_at": 0, + "model_limits": {}, + "model_limits_enabled": false, + "name": "251113", + "object": "token_usage", + "total_available": -5007103, + "total_granted": 500000, + "total_used": 5507103, + "unlimited_quota": true + }, + "message": "ok" +} +``` + +### 字段分析 + +| 字段 | 值 | 转换后(÷500000) | 含义 | +|------|-----|------------------|------| +| `total_used` | 5507103 | **$11.01** | 历史消耗 ✓ 正确 | +| `total_granted` | 500000 | **$1.00** | 初始赠送额度(不是账户总额) | +| `total_available` | -5007103 | **-$10.01** | 负数,计算值(granted - used) | +| `unlimited_quota` | true | - | 无限额度账户 | + +### 问题根因 + +1. **`total_granted` 只返回初始赠送额度($1)**,不是用户充值后的账户总额度 +2. **缺少"当前账户余额"字段**:用户实际余额 $14.01 不在 API 返回中 +3. **`total_available` 计算方式有问题**:`granted - used = $1 - $11.01 = -$10.01`,对于充值账户无意义 +4. **`unlimited_quota: true` 时**:`total_granted` 和 `total_available` 无法反映真实账户状态 + +### 期望的 API 返回 + +为了正确显示账户余额,建议 API 返回以下字段: + +```json +{ + "data": { + "total_used": 5507103, // 历史消耗(保持不变) + "total_balance": 7005000, // 当前账户余额:$14.01 × 500000 + "total_granted": 12512103, // 账户总额度(充值+赠送):余额+已用 + "total_available": 7005000, // 可用额度 = 当前余额 + "unlimited_quota": true + } +} +``` + +或者添加新字段: + +```json +{ + "data": { + "account_balance": 7005000, // 新增:账户余额($14.01 × 500000) + "total_used": 5507103, + "total_granted": 500000, // 可以保持为初始赠送 + "unlimited_quota": true + } +} +``` + +### 影响范围 + +- byebyecode 状态栏无法正确显示 Privnode 用户的账户余额 +- 进度条显示异常(已用 $11 / 总额 $1 = 1100%) +- 用户无法通过状态栏了解真实的账户状态 + +### 临时解决方案 + +在 Privnode 修复 API 之前,byebyecode 可以: +1. 当 `unlimited_quota: true` 且 `total_available < 0` 时,只显示已用金额 +2. 不显示误导性的总额度和进度条 + +### 状态 + +🔴 **待 Privnode 修复** - 需要 API 返回正确的账户余额字段 + +--- + ## 已完成的功能 ### Issue #9 修复 (PR #10, #12) diff --git a/src/api/client.rs b/src/api/client.rs index 998590f..d8914e9 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -18,16 +18,12 @@ impl ApiClient { } pub fn get_usage(&self, model: Option<&str>) -> Result> { - let is_packyapi = self.config.is_packyapi(); + // 88code 使用特定的 API 格式(POST + ResponseDTO) + // 其他中转站(Packy 及其他)使用通用格式(GET + 直接响应) + let is_88code = self.config.is_88code(); - let response = if is_packyapi { - self.client - .get(&self.config.usage_url) - .header("Authorization", format!("Bearer {}", self.config.api_key)) - .send()? - } else { - // 构建请求体,传入 model 参数以获取正确套餐的用量 - // 如果不传 model,API 会默认返回 free 套餐的用量 + let response = if is_88code { + // 88code 系列:POST 请求,传入 model 参数 let body = match model { Some(m) => serde_json::json!({ "model": m }), None => serde_json::json!({}), @@ -38,6 +34,12 @@ impl ApiClient { .header("Content-Type", "application/json") .json(&body) .send()? + } else { + // Packy 及其他中转站:GET 请求 + self.client + .get(&self.config.usage_url) + .header("Authorization", format!("Bearer {}", self.config.api_key)) + .send()? }; if !response.status().is_success() { @@ -46,22 +48,26 @@ impl ApiClient { let response_text = response.text()?; - let mut usage: UsageData = if is_packyapi { - let resp: super::PackyUsageResponse = + let mut usage: UsageData = if is_88code { + // 88code:解析 ResponseDTO 包装的响应 + let resp: super::ResponseDTO = serde_json::from_str(&response_text).map_err(|e| { format!( - "Packyapi JSON parse error: {} | Response: {}", + "88code JSON parse error: {} | Response: {}", e, response_text ) })?; - UsageData::Packy(resp.data) + UsageData::Code88(resp.data) } else { - // 解析 ResponseDTO 包装的响应 - let resp: super::ResponseDTO = + // Packy 及其他中转站:使用 Packy 格式解析 + let resp: super::PackyUsageResponse = serde_json::from_str(&response_text).map_err(|e| { - format!("API JSON parse error: {} | Response: {}", e, response_text) + format!( + "Relay JSON parse error: {} | Response: {}", + e, response_text + ) })?; - UsageData::Code88(resp.data) + UsageData::Packy(resp.data) }; usage.calculate(); diff --git a/src/api/mod.rs b/src/api/mod.rs index 7b81cd7..d705ce4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -33,9 +33,29 @@ impl Default for ApiConfig { } impl ApiConfig { - pub fn is_packyapi(&self) -> bool { + /// 判断是否是 88code 系列中转站 + /// 88code 使用特定的 API 格式(POST + ResponseDTO 包装) + pub fn is_88code(&self) -> bool { + self.usage_url.contains("88code.org") + || self.usage_url.contains("88code.ai") + || self.usage_url.contains("rainapp.top") + } + + /// 判断是否是 Packy 中转站 + pub fn is_packy(&self) -> bool { self.usage_url.contains("packyapi.com") } + + /// 获取服务名称(用于状态栏显示) + pub fn get_service_name(&self) -> &'static str { + if self.is_88code() { + "88code" + } else if self.is_packy() { + "packy" + } else { + "relay" // 其他中转站统一显示为 relay + } + } } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -91,9 +111,9 @@ pub struct PackyUsageData { pub expires_at: i64, pub name: String, pub object: String, - pub total_available: u64, - pub total_granted: u64, - pub total_used: u64, + pub total_available: i64, // 改为 i64,支持负数(超额使用) + pub total_granted: i64, + pub total_used: i64, pub unlimited_quota: bool, #[serde(default)] @@ -193,12 +213,9 @@ impl PackyUsageData { // Packy API 返回的字段含义: // - total_granted: 套餐总额度(积分) // - total_used: 已使用额度(积分) - // - total_available: 剩余可用额度(积分)= total_granted - total_used + // - total_available: 剩余可用额度(积分),可能为负数(超额使用) // // 单位转换:Packy 使用 500000 积分 = 1 美元(从用户实际数据推算) - // 用户实际:$0.52 余额 + $0.48 已用 = $1.00 总额 - // API 返回:260425 + 239575 = 500000 - // 所以:500000 积分 = $1.00,转换因子 = 500000 const PACKY_CONVERSION_FACTOR: f64 = 500000.0; // 500000 积分 = 1 美元 @@ -208,19 +225,21 @@ impl PackyUsageData { let total_dollars = self.total_granted as f64 / PACKY_CONVERSION_FACTOR; // 转换为 cents(与 88code 统一,因为显示层会除以 100) - self.used_tokens = (used_dollars * 100.0) as u64; - self.remaining_tokens = (remaining_dollars * 100.0) as u64; + // 处理负数:used_tokens 和 remaining_tokens 是 u64,需要 clamp 到 0 + self.used_tokens = (used_dollars * 100.0).max(0.0) as u64; + self.remaining_tokens = (remaining_dollars * 100.0).max(0.0) as u64; // 百分比基于 total_granted(套餐总额度)计算 + // 超额使用时百分比可能超过 100% self.percentage_used = if self.total_granted > 0 { - (self.total_used as f64 / self.total_granted as f64 * 100.0).clamp(0.0, 100.0) + (self.total_used as f64 / self.total_granted as f64 * 100.0).max(0.0) } else { 0.0 }; // 设置美元金额(用于 get_credit_limit 等方法) - self.credit_limit = total_dollars; - self.current_credits = remaining_dollars; + self.credit_limit = total_dollars.max(0.0); + self.current_credits = remaining_dollars; // 可以是负数,表示超额 } pub fn is_exhausted(&self) -> bool { @@ -245,6 +264,12 @@ pub struct SubscriptionData { pub reset_times: i32, #[serde(rename = "isActive")] pub is_active: bool, + /// 当前剩余额度(美元)- 用于 PAYGO 等套餐显示 + #[serde(rename = "currentCredits", default)] + pub current_credits: f64, + /// 套餐总额度(美元)- 用于计算进度条 + #[serde(rename = "creditLimit", default)] + pub credit_limit: f64, // 计算字段 #[serde(skip)] @@ -290,15 +315,10 @@ pub fn get_api_key_from_claude_settings() -> Option { let env = settings.env?; - // Support 88code (both .org and .ai), packyapi.com, and rainapp.top (国内线路) - if let Some(base_url) = env.base_url { - if base_url.contains("88code.org") - || base_url.contains("88code.ai") - || base_url.contains("packyapi.com") - || base_url.contains("rainapp.top") - { - return env.auth_token; - } + // 只要配置了 ANTHROPIC_BASE_URL,就返回对应的 auth_token + // 支持所有中转站(88code、packy、以及其他第三方中转站) + if env.base_url.is_some() { + return env.auth_token; } None @@ -320,12 +340,17 @@ pub fn get_usage_url_from_claude_settings() -> Option { if base_url.contains("packyapi.com") { Some("https://www.packyapi.com/api/usage/token/".to_string()) } else if base_url.contains("88code.ai") || base_url.contains("rainapp.top") { - // 新域名:88code.ai 和国内线路 rainapp.top + // 88code 新域名和国内线路 Some("https://www.88code.ai/api/usage".to_string()) } else if base_url.contains("88code.org") { - // 旧域名兼容 + // 88code 旧域名兼容 Some("https://www.88code.org/api/usage".to_string()) } else { - None + // 其他中转站:基于 base_url 构造 usage URL + // 假设 API 路径为 /api/usage/token/(与 Packy 兼容) + let base = base_url.trim_end_matches('/'); + // 移除可能存在的 /v1 或 /api 后缀 + let base = base.trim_end_matches("/v1").trim_end_matches("/api"); + Some(format!("{}/api/usage/token/", base)) } } diff --git a/src/core/segments/byebyecode_usage.rs b/src/core/segments/byebyecode_usage.rs index 934af5e..a4ffb2a 100644 --- a/src/core/segments/byebyecode_usage.rs +++ b/src/core/segments/byebyecode_usage.rs @@ -42,10 +42,22 @@ pub fn collect(config: &Config, input: &InputData) -> Option { .unwrap_or_else(|| "https://www.88code.ai/api/usage".to_string()); // 根据 usage_url 判断是哪个服务,并设置动态图标 - let service_name = if usage_url.contains("packyapi.com") { + let service_name = if usage_url.contains("88code.org") + || usage_url.contains("88code.ai") + || usage_url.contains("rainapp.top") + { + "88code" + } else if usage_url.contains("packyapi.com") { "packy" } else { - "88code" + // 其他中转站不支持额度显示,因为 API 返回的数据格式不正确 + let mut metadata = HashMap::new(); + metadata.insert("dynamic_icon".to_string(), "88code".to_string()); + return Some(SegmentData { + primary: "未配置订阅".to_string(), + secondary: String::new(), + metadata, + }); }; // Try to get API key from segment options first, then from Claude settings @@ -147,6 +159,61 @@ pub fn collect(config: &Config, input: &InputData) -> Option { let subscriptions = fetch_subscriptions_sync(&api_key, &subscription_url, Some(model_id)); if let Some(subs) = subscriptions { + // 仅 88code 服务支持 PAYGO 回退逻辑 + if service_name == "88code" { + // 查找有余额的 PAYGO 套餐(按顺序取第一个) + let paygo = subs + .iter() + .filter(|s| s.is_active) + .filter(|s| s.plan_name.to_uppercase() == "PAYGO") + .find(|s| s.current_credits > 0.0); + + if let Some(paygo_sub) = paygo { + // 显示 PAYGO 剩余额度(蓝色) + let paygo_color = "\x1b[38;5;39m"; // 蓝色 + + // 如果有总额度信息,显示进度条 + if paygo_sub.credit_limit > 0.0 { + let used = paygo_sub.credit_limit - paygo_sub.current_credits; + let percentage = (used / paygo_sub.credit_limit * 100.0).clamp(0.0, 100.0); + + // 生成进度条(10格) + let bar_length = 10; + let filled = ((percentage / 100.0) * bar_length as f64).round() as usize; + let empty = bar_length - filled; + + // PAYGO 使用蓝色进度条 + let progress_bar = format!( + "{}{}{}{}", + paygo_color, + "▓".repeat(filled), + "░".repeat(empty), + RESET + ); + + return Some(SegmentData { + primary: format!( + "{}PAYGO{} ${:.2}/${:.0} {}", + paygo_color, RESET, used, paygo_sub.credit_limit, progress_bar + ), + secondary: String::new(), + metadata, + }); + } + + // 无总额度信息,只显示剩余额度 + return Some(SegmentData { + primary: format!( + "{}PAYGO{} ${:.2}", + paygo_color, RESET, paygo_sub.current_credits + ), + secondary: String::new(), + metadata, + }); + } + } + + // 非 88code 或无 PAYGO 可用,使用原有逻辑 let active_subs: Vec<_> = subs.iter().filter(|s| s.is_active).collect(); if active_subs.len() > 1 { @@ -215,11 +282,24 @@ pub fn collect(config: &Config, input: &InputData) -> Option { }) } +/// 带缓存的订阅数据获取 +/// 1. 先尝试使用缓存(5分钟有效期) +/// 2. 缓存过期或不存在时调用 API +/// 3. API 成功后更新缓存 fn fetch_subscriptions_sync( api_key: &str, subscription_url: &str, model: Option<&str>, ) -> Option> { + // 先检查缓存 + let (cached, needs_refresh) = cache::get_cached_subscriptions(); + + // 缓存新鲜且存在,直接返回 + if cached.is_some() && !needs_refresh { + return cached; + } + + // 缓存过期或不存在,调用 API let api_config = ApiConfig { enabled: true, api_key: api_key.to_string(), @@ -227,7 +307,14 @@ fn fetch_subscriptions_sync( subscription_url: subscription_url.to_string(), }; - let client = ApiClient::new(api_config).ok()?; - let subs = client.get_subscriptions(model).ok()?; - Some(subs) + if let Ok(client) = ApiClient::new(api_config) { + if let Ok(subs) = client.get_subscriptions(model) { + // 保存到缓存 + let _ = cache::save_cached_subscriptions(&subs); + return Some(subs); + } + } + + // API 失败,返回过期缓存(降级处理) + cached }