From 1ac3253edf4f362d53a63c052ca59658f0f715fa Mon Sep 17 00:00:00 2001 From: chenjiarong <2811982368@qq.com> Date: Sat, 17 Oct 2026 14:44:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9AOrcaTerm=20=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=20JWT=20=E8=BF=87=E6=9C=9F=E5=90=8E=E6=B0=B8=E4=B9=85?= =?UTF-8?q?=20502=20=E2=80=94=E2=80=94=20=E5=87=AD=E8=AF=81=E7=BB=AD?= =?UTF-8?q?=E6=9C=9F=E9=93=BE=E8=B7=AF=E4=B8=89=E5=A4=84=E5=A4=B1=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 tokenExpireAt 单位错位:账号记录存**毫秒**(导入与续期回写都是 expires_at * 1000),读取侧却塞进按**秒**理解的 OrcatermCredentials::expires_at, 比 now 大三个数量级,is_expiring() 于是恒返回 false —— 临期续期 (chat / balance)、定时凭证维护、面板「刷新临期凭证」三条自动路径全部静默 失能。落点在读取侧归一(tokenExpireAt 的毫秒口径是展示层依赖的,改写入会 顺手改坏面板「到期时间」那个读数)。 - 修复凭证失效的口径没翻译:上游用 HTTP 200 + code=-1 表达(实测原文 Invalid or expired AccessToken),被 create_conversation 一律映射成 502, 而建会话 / 开流两处自愈链只认 401 —— 这条信号永远触发不了强制续期,账号一旦 过期就没有自愈出口。现按收窄判据(同时提到 token 与 invalid/expired 等) 识别凭证类业务码并翻译成 401(新增 is_credential_rejection);非凭证类 业务码维持 502,不白刷 token。 - 修复 refresh 分派表漏登记 OrcaTerm:面板「刷新 Token」不认这家,会落到 workbuddy 兜底链路并返回腾讯鉴权端点的错 —— 与 Antigravity / Trae / Command Code 漏登记时同一性质(refresh_account 的文档把这条列为 「加带 refreshToken 的家时必须一起加」)。 - 新增回归测试:单位归一(含判不出时回落 JWT 的 exp)、10 分钟临期窗口的 开合、凭证失效判据的宽窄两面。 症状与定位依据:2026-10-17 实测,账号导入约两小时后四种模型的测试全部 502, requests.attempt_details 同一句 code=-1:Invalid or expired AccessToken; 用户侧 OrcaTerm 客户端一切正常(客户端自续自己那份,网关这份自导入起未被 更新过)。612 项后端测试通过(另 6 项 ignored),其中新增 5 项。 --- .../server/src/server/api/accounts.rs | 12 ++- .../server/core/providers/orcaterm/chat.rs | 57 +++++++++++++ .../core/providers/orcaterm/credentials.rs | 82 +++++++++++++++++-- 3 files changed, 145 insertions(+), 6 deletions(-) diff --git a/desktop-tauri/src-tauri/server/src/server/api/accounts.rs b/desktop-tauri/src-tauri/server/src/server/api/accounts.rs index 310770ca5..a42a05f00 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/accounts.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/accounts.rs @@ -1352,7 +1352,7 @@ pub async fn batch_accounts(state: &ServerState, body: &Bytes) -> Response { /// 刷新账号 token(**按账号所属 provider 分派**)。 /// /// workbuddy 账号走既有的 `AuthService::refresh_account`;小浣熊、AutoClaw、 -/// CodeArts、**Trae / ZCode**、Accio、Cline 账号各走自家适配器的 +/// CodeArts、**Trae / ZCode**、Accio、Cline、OrcaTerm 账号各走自家适配器的 /// `refresh_access_token`;**CatPaw / MonkeyCode / Command Code 账号没有可刷新的 /// 东西**(§9.1:`X-Passport-Token` 过期只能在桌面端重新登录,没有 refreshToken; /// 后两家是静态 session / API Key,上游根本没有续期接口), @@ -1539,6 +1539,16 @@ pub async fn refresh_account(state: &ServerState, body: &Bytes) -> Response { if state.store().antigravity_account_record(&id).is_some() { return refresh_provider_account(state, &id, ProviderKind::Antigravity).await; } + // OrcaTerm:**有**真续期接口(`OAuthRefreshToken`,传旧 accessToken 换新, + // 见 `providers::orcaterm::credentials` 的模块头)。这条分派同样不能省 —— + // 与 Antigravity / Trae 漏登记时同一性质:漏了就落到下面的 workbuddy 兜底 + // 链路,用户点「刷新 Token」收到的是腾讯鉴权端点的错。 + // 它的凭证曾因 `expires_at` 单位错位而从不自动续期(2026-10-17 实测,见 + // `providers::orcaterm::credentials::expires_at_seconds` 的记录),这条 + // 手动通路是当时唯一能救回账号的入口 —— 更不能缺。 + if state.store().orcaterm_account_record(&id).is_some() { + return refresh_provider_account(state, &id, ProviderKind::OrcaTerm).await; + } // WorkBuddy 系(国内版 / 国际版)的兜底:两条链路都按**账号自己的** // endpoint / prefixPath / platform / edition 续期 // (`AuthService::refresh_account_inner` 从记录里读,见那里的说明), diff --git a/desktop-tauri/src-tauri/server/src/server/core/providers/orcaterm/chat.rs b/desktop-tauri/src-tauri/server/src/server/core/providers/orcaterm/chat.rs index 07167cbfd..1db596c51 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/providers/orcaterm/chat.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/providers/orcaterm/chat.rs @@ -186,6 +186,17 @@ async fn create_conversation( let code = value.get("code").and_then(Value::as_i64).unwrap_or(-1); if code != 0 { let message = value.get("message").and_then(Value::as_str).unwrap_or("未知错误"); + // 凭证失效在上游是「HTTP 200 + 业务码」,而本文件的自愈链(建会话与 + // 开流两处)只认 401。这里把口径翻译成调用方认得的那一种:不翻译的话 + // 这条信号永远触发不了 `refresh_for_retry`,账号一旦过期就永久 502 + // (2026-10-17 实测,见 `credentials::expires_at_seconds` 的记录)。 + // 非凭证类的业务码维持 502 —— 那些换 token 也救不回来。 + if is_credential_rejection(code, message) { + return Err(GatewayError::with_status( + 401, + format!("OrcaTerm 登录态已失效(code={code}:{message})"), + )); + } return Err(GatewayError::with_status( 502, format!("OrcaTerm 建会话失败(code={code}:{message})"), @@ -194,6 +205,28 @@ async fn create_conversation( Ok(format!("cid-{uuid}-orcaterm")) } +/// 判定一次 CGI 业务错误是不是「凭证失效」。 +/// +/// 上游把凭证失效表达成 **HTTP 200 + `code=-1` + `message` 提到 token** +/// (实测原文 `Invalid or expired AccessToken`),不是 HTTP 401 —— 而 +/// [`refresh_for_retry`] 那两条自愈链只认 401。识别它,是为了把上游口径 +/// 翻译成框架认得的那一种。 +/// +/// 判据刻意收窄(必须同时提到 token 与 invalid / expired / 失效 / 过期): +/// 放宽会把「模型不存在」之类的业务错误也当成凭证问题,白刷一次 token +/// 再白重试一次。 +fn is_credential_rejection(code: i64, message: &str) -> bool { + if code == 0 { + return false; + } + let text = message.to_ascii_lowercase(); + text.contains("token") + && (text.contains("invalid") + || text.contains("expired") + || text.contains("失效") + || text.contains("过期")) +} + /// 打开对话 SSE 流。 async fn open_chat_stream( credentials: &OrcatermCredentials, @@ -691,3 +724,27 @@ fn random_uuid() -> String { &hex[20..] ) } + +#[cfg(test)] +mod tests { + use super::*; + + /// 上游把凭证失效表达成 HTTP 200 + 业务码(2026-10-17 生产原文)。 + #[test] + fn credential_rejections_are_recognized() { + assert!(is_credential_rejection(-1, "Invalid or expired AccessToken")); + assert!(is_credential_rejection(-1, "AccessToken 已过期")); + assert!(is_credential_rejection(-1, "TOKEN EXPIRED")); + } + + /// 判据要窄:非凭证类的业务错误换 token 也救不回来,别白刷一次再白重试一次。 + #[test] + fn other_business_errors_are_not_credential_rejections() { + assert!(!is_credential_rejection(0, "")); + assert!(!is_credential_rejection(-1, "模型不存在")); + // 提到 token 但没说失效 → 不算 + assert!(!is_credential_rejection(-1, "token 数量超出限制")); + // 说了失效但没提 token → 不算 + assert!(!is_credential_rejection(-1, "会话已过期")); + } +} diff --git a/desktop-tauri/src-tauri/server/src/server/core/providers/orcaterm/credentials.rs b/desktop-tauri/src-tauri/server/src/server/core/providers/orcaterm/credentials.rs index d75905c6c..58f855425 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/providers/orcaterm/credentials.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/providers/orcaterm/credentials.rs @@ -397,6 +397,33 @@ async fn call_refresh_api( // ─── 账号记录快照 ─────────────────────────────────────────── +/// 账号记录 → `expires_at`(**秒**)的单位归一。 +/// +/// ── 为什么需要这一步 ───────────────────────────────────────── +/// 记录里的 `tokenExpireAt` 是**毫秒**(导入与续期回写都是 `expires_at * 1000`, +/// 见 `account_store::orcaterm_accounts` 的模块头),而 +/// [`OrcatermCredentials::expires_at`] 是**秒**(JWT `exp` 的口径, +/// [`OrcatermCredentials::is_expiring`] 用 `now_ms() / 1000` 与它相减)。 +/// 毫秒值当秒读会比 `now` 大三个数量级,`is_expiring()` 于是**恒返回 false**: +/// 临期续期、定时凭证维护、面板「刷新临期凭证」三条自动路径全部静默失能, +/// 账号在 JWT 时效(约 2 小时)到时即永久 502 —— 2026-10-17 实测(账号导入 +/// 两小时后失效,四种模型的测试同一句 +/// `建会话失败(code=-1:Invalid or expired AccessToken)`)。 +/// +/// 落点在读取侧而非写入侧:`tokenExpireAt` 的毫秒口径是**展示层依赖**的 +/// (面板按毫秒渲染「到期时间」,见 `account_store::orcaterm_accounts` 的 +/// 出参注释),改写入会顺手改坏那个读数。 +fn expires_at_seconds(record: &Value, jwt_exp: i64) -> i64 { + record + .get("tokenExpireAt") + .and_then(Value::as_i64) + .filter(|millis| *millis > 0) + .map(|millis| millis / 1000) + // 不足 1 秒(理论上不会出现)→ 按「判不出」处理,回落 JWT 的 `exp` + .filter(|seconds| *seconds > 0) + .unwrap_or(jwt_exp) +} + /// 从账号记录取转发凭证(`account_id` 为空 → 本家组内优先级最小的启用账号)。 /// /// 记录里的 `accessToken` 存 JWT 本体(见账号层)。临期**不在这里刷**: @@ -424,11 +451,7 @@ pub fn snapshot_for( let (jwt_user_id, jwt_exp) = claims_of(&token); Ok(OrcatermCredentials { access_token: token, - expires_at: record - .get("tokenExpireAt") - .and_then(Value::as_i64) - .filter(|value| *value > 0) - .unwrap_or(jwt_exp), + expires_at: expires_at_seconds(&record, jwt_exp), user_id: record .get("uid") .and_then(Value::as_str) @@ -438,3 +461,52 @@ pub fn snapshot_for( nickname: record.get("loginName").and_then(Value::as_str).unwrap_or("").to_string(), }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// 造一份「记录里存毫秒」的凭证快照(其它字段填占位值)。 + fn credentials_from_record_expiry(millis: i64) -> OrcatermCredentials { + let record = json!({ "tokenExpireAt": millis }); + OrcatermCredentials { + access_token: "placeholder".to_string(), + expires_at: expires_at_seconds(&record, 0), + user_id: String::new(), + nickname: String::new(), + } + } + + /// 记录里的 `tokenExpireAt` 是毫秒,读出来的 `expires_at` 必须是秒 —— + /// 2026-10-17 的故障就是这一步没归一(毫秒当秒读 → `is_expiring` 恒 false)。 + #[test] + fn record_millis_are_normalized_to_seconds() { + let exp_secs = 1_760_716_800_i64; + let record = json!({ "tokenExpireAt": exp_secs * 1000 }); + assert_eq!(exp_secs, expires_at_seconds(&record, 0)); + } + + /// 判不出(缺字段 / 0 / 不足 1 秒)时回落 JWT 的 `exp`。 + #[test] + fn undecidable_expiry_falls_back_to_jwt_exp() { + let jwt_exp = 1_700_000_000_i64; + assert_eq!(jwt_exp, expires_at_seconds(&json!({}), jwt_exp)); + assert_eq!(jwt_exp, expires_at_seconds(&json!({ "tokenExpireAt": 0 }), jwt_exp)); + assert_eq!(jwt_exp, expires_at_seconds(&json!({ "tokenExpireAt": 500 }), jwt_exp)); + } + + /// 端到端口径:归一之后 10 分钟临期窗口才真的能开合(修复前毫秒值永远在 + /// 窗口之外,临期续期 / 定时维护 / 面板刷新三条自动路径全部静默失能)。 + #[test] + fn normalized_expiry_drives_the_refresh_window() { + let now_secs = crate::server::logging::now_ms() / 1000; + assert!( + credentials_from_record_expiry((now_secs + 300) * 1000).is_expiring(), + "还剩 5 分钟必须判临期" + ); + assert!( + !credentials_from_record_expiry((now_secs + 7_200) * 1000).is_expiring(), + "还剩 2 小时不该判临期" + ); + } +}