Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion desktop-tauri/src-tauri/server/src/server/api/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,上游根本没有续期接口),
Expand Down Expand Up @@ -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` 从记录里读,见那里的说明),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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})"),
Expand All @@ -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,
Expand Down Expand Up @@ -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, "会话已过期"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 本体(见账号层)。临期**不在这里刷**:
Expand Down Expand Up @@ -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)
Expand All @@ -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 小时不该判临期"
);
}
}