From 2ec290fb2d7bfedea6c4b71b65f19033b15466e5 Mon Sep 17 00:00:00 2001 From: Magicapple <1540796514@qq.com> Date: Wed, 23 Sep 2026 12:28:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E4=BF=A1=E5=B0=81=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E8=AF=B7=E6=B1=82=E5=89=8D=E7=BD=AE=E7=9F=AD=E8=B7=AF?= =?UTF-8?q?=EF=BC=8C=E9=9D=9E=20JSON=20=E9=94=99=E8=AF=AF=E4=BD=93?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 account::envelope_token_error:access_token 为 WorkBuddy 5.6 加密信封形态时返回可读错误;签到 / 积分 / 旅行三条请求链路入口 短路,不再发出空 Bearer(此前 get_str 取不到值经 unwrap_or_default 兜底为空串,被网关 401 后错误页原样回显) - http_request_with_proxy 非 JSON 错误响应体归一化:HTML 提取 作为可读信息,不再把整页 <html>… 塞进通知与界面卡片 - 补回归测试 5 个(信封识别、HTML 归一化、三条链路短路) Fixes #94 --- crates/wb-switch-core/src/modules/account.rs | 43 +++++++++++++++++++ crates/wb-switch-core/src/modules/checkin.rs | 22 +++++++++- crates/wb-switch-core/src/modules/config.rs | 45 +++++++++++++++++++- crates/wb-switch-core/src/modules/credits.rs | 23 +++++++++- crates/wb-switch-core/src/modules/travel.rs | 19 +++++++++ 5 files changed, 149 insertions(+), 3 deletions(-) diff --git a/crates/wb-switch-core/src/modules/account.rs b/crates/wb-switch-core/src/modules/account.rs index e58ea39a..e3e68692 100644 --- a/crates/wb-switch-core/src/modules/account.rs +++ b/crates/wb-switch-core/src/modules/account.rs @@ -274,7 +274,28 @@ fn upsert_account_in(accounts: &mut Vec<Value>, updated: &Value) { accounts.push(updated.clone()); } +/// WorkBuddy 5.6 加密信封凭据的可读错误:`access_token` 为信封形态时返回提示文案。 +/// +/// 信封 token 无法解出明文,不能用于签到 / 积分 / 旅行等 API 请求;此前会经 +/// [`build_auth_headers`] 的 `unwrap_or_default()` 兜底成空 `Bearer`,被网关 +/// 401 后再把 HTML 错误页原样回显到界面(issue #94)。需要账号身份的请求 +/// 发出前应先用本函数短路。 +pub fn envelope_token_error(account: &Value) -> Option<String> { + if is_envelope(account, "access_token") { + return Some( + "该账号凭据为 WorkBuddy 加密信封态,无法直接调用签到 / 积分 / Token 统计等接口;\ + 切换功能不受影响,如需上述功能请删除该账号后改用「OAuth 扫码添加」获取明文凭据。" + .to_string(), + ); + } + None +} + /// 构造与官方对齐的请求头。对照 server.py `build_auth_headers`。 +/// +/// 注意:`access_token` 为加密信封对象时 `get_str` 取不到值,这里会产出空 +/// `Bearer`——调用方必须先用 [`envelope_token_error`] 拦截,不要把空凭据 +/// 真的发出去(issue #94)。 pub fn build_auth_headers(account: &Value) -> HashMap<String, String> { let mut headers = HashMap::new(); headers.insert( @@ -306,6 +327,28 @@ mod tests { use super::*; use serde_json::json; + /// 回归 issue #94:信封凭据要能被识别并给出可读错误,明文/缺字段不误报。 + #[test] + fn envelope_token_error_only_fires_on_envelope_access_token() { + let envelope = json!({ + "id": "a1", + "access_token": {"$wbEncrypted": true, "envelope": "…"}, + "refresh_token": {"$wbEncrypted": true, "envelope": "…"}, + }); + let err = envelope_token_error(&envelope).expect("信封 access_token 应返回错误"); + assert!(err.contains("信封"), "错误文案应可读:{err}"); + assert!(err.contains("OAuth"), "应给出扫码重新添加的指引:{err}"); + + let plain = json!({"id": "a2", "access_token": "SECRET", "refresh_token": "R"}); + assert!(envelope_token_error(&plain).is_none(), "明文凭据不应报错"); + + let legacy = json!({"id": "a3"}); + assert!( + envelope_token_error(&legacy).is_none(), + "缺 access_token 的历史账号不在此拦截(保持既有行为)" + ); + } + #[test] fn account_meta_strips_tokens() { let acc = json!({ diff --git a/crates/wb-switch-core/src/modules/checkin.rs b/crates/wb-switch-core/src/modules/checkin.rs index 4c265623..9d6cd314 100644 --- a/crates/wb-switch-core/src/modules/checkin.rs +++ b/crates/wb-switch-core/src/modules/checkin.rs @@ -24,7 +24,7 @@ use std::sync::{Mutex, OnceLock}; use std::time::Duration; use crate::modules::account::{ - account_display_name, build_auth_headers, load_accounts, variant_of, + account_display_name, build_auth_headers, envelope_token_error, load_accounts, variant_of, }; use crate::modules::config::{ add_checkin_log, http_request, is_route_missing, load_checkin_config, load_checkin_logs, @@ -135,6 +135,10 @@ fn skips_refresh_before_retry(variant: WbVariant, resp: &Value) -> bool { /// 发单次签到请求;遇到未授权且存在 refresh token 时刷新一次并重试。 async fn checkin_request_once(path: &str, account: &Value, variant: WbVariant) -> Value { + // 加密信封凭据短路:不发空 Bearer,直接给出可读错误(issue #94)。 + if let Some(err) = envelope_token_error(account) { + return json!({"code": -2, "message": err}); + } let url = format!("{}{path}", variant.api_endpoint()); let headers = build_auth_headers(account); let mut resp = http_request(&url, "POST", Some(json!({})), Some(&headers)).await; @@ -830,6 +834,22 @@ async fn checkin_all_rows(accounts: Vec<Value>, cfg: &Value) -> Vec<Value> { mod tests { use super::*; + /// 回归 issue #94:信封凭据的签到请求应在入口短路并返回可读错误, + /// 不发出空 Bearer(此前会被网关 401 后把 HTML 原样回显)。 + #[tokio::test] + async fn envelope_credentials_short_circuit_before_request() { + let account = json!({ + "id": "envelope-only", + "variant": "cn", + "access_token": {"$wbEncrypted": true, "envelope": "…"}, + "refresh_token": {"$wbEncrypted": true, "envelope": "…"}, + }); + let resp = checkin_request_once("/whatever", &account, WbVariant::Cn).await; + assert_eq!(resp["code"], -2); + let msg = resp["message"].as_str().expect("message 应为字符串"); + assert!(msg.contains("信封"), "错误文案应可读:{msg}"); + } + #[tokio::test] async fn excluded_account_never_starts_passive_operation() { let account = json!({"id": "excluded", "variant": "cn"}); diff --git a/crates/wb-switch-core/src/modules/config.rs b/crates/wb-switch-core/src/modules/config.rs index 7762620c..67e58276 100644 --- a/crates/wb-switch-core/src/modules/config.rs +++ b/crates/wb-switch-core/src/modules/config.rs @@ -1023,7 +1023,7 @@ pub async fn http_request_with_proxy( serde_json::from_str(&text).unwrap_or_else(|_| { json!({ "code": status.as_u16(), - "message": text.chars().take(500).collect::<String>(), + "message": normalize_error_body(&text), }) }) } @@ -1032,6 +1032,25 @@ pub async fn http_request_with_proxy( } } +/// 非 JSON 错误响应体归一化:网关(openresty / APISIX 等)的 401/5xx 常返回 +/// 整页 HTML,原样截断会把 `<html>…` 整段塞进通知与界面卡片(issue #94)。 +/// HTML 提取 `<title>` 作为可读信息;其余保持原有的 500 字符截断。 +fn normalize_error_body(text: &str) -> String { + if text.trim_start().starts_with('<') { + let title = text + .split_once("<title>") + .and_then(|(_, rest)| rest.split_once("")) + .map(|(title, _)| title.trim()) + .unwrap_or_default(); + return if title.is_empty() { + "服务端返回 HTML 错误页(无标题)".to_string() + } else { + format!("服务端返回 HTML 错误页:{title}") + }; + } + text.chars().take(500).collect::() +} + /// 通用 HTTP 请求,返回原始响应(状态码 + 响应头 + 响应体),可选是否跟随重定向。 /// /// 供需要读取响应头(如 302 的 `Location`)或自行处理非 JSON 响应的场景使用; @@ -1104,6 +1123,30 @@ pub async fn http_request_raw( mod tests { use super::*; + /// 回归 issue #94:网关 401 返回的整页 HTML 要归一化为可读信息, + /// 不能把 `…` 原样塞进通知与界面卡片。 + #[test] + fn normalize_error_body_extracts_html_title() { + let html = "\n401 Authorization Required\n\ + \n

401 Authorization Required

\n\ +
openresty
\n\n\n"; + assert_eq!( + normalize_error_body(html), + "服务端返回 HTML 错误页:401 Authorization Required" + ); + + assert_eq!( + normalize_error_body("boom"), + "服务端返回 HTML 错误页(无标题)" + ); + + // 非 HTML 错误体保持原有截断行为。 + let plain = "plain gateway error"; + assert_eq!(normalize_error_body(plain), plain); + let long = "x".repeat(600); + assert_eq!(normalize_error_body(&long).chars().count(), 500); + } + fn local_timestamp_ms(year: i32, month: u32, day: u32, hour: u32) -> i64 { Local .with_ymd_and_hms(year, month, day, hour, 0, 0) diff --git a/crates/wb-switch-core/src/modules/credits.rs b/crates/wb-switch-core/src/modules/credits.rs index d34af093..43af9537 100644 --- a/crates/wb-switch-core/src/modules/credits.rs +++ b/crates/wb-switch-core/src/modules/credits.rs @@ -8,7 +8,9 @@ use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone}; use serde_json::{json, Value}; use std::collections::HashSet; -use crate::modules::account::{account_display_name, build_auth_headers, variant_of}; +use crate::modules::account::{ + account_display_name, build_auth_headers, envelope_token_error, variant_of, +}; use crate::modules::config::{ http_request, is_route_missing, load_checkin_config, now_ms, CHECKIN_API_PREFIX, WORKBUDDY_API_ENDPOINT, @@ -368,6 +370,10 @@ fn is_transport_error(response: &Value) -> bool { /// 新鲜,遇到未授权时使用 refresh token 重试一次。调用方只拿到上游 JSON, /// 不会把认证字段拼进返回值。 pub async fn authenticated_post(account: &Value, url: &str, body: Value) -> Value { + // 加密信封凭据短路:不发空 Bearer,直接给出可读错误(issue #94)。 + if let Some(err) = envelope_token_error(account) { + return json!({"code": -2, "message": err}); + } let config = load_checkin_config(); let mut working_account = ensure_fresh_token(account.clone(), &config).await; let mut response = post_with_account(&working_account, url, body.clone()).await; @@ -831,6 +837,21 @@ fn legacy_credit_result(account: &Value, response: &Value, now: i64) -> Value { mod tests { use super::*; + /// 回归 issue #94:信封凭据在 authenticated_post 入口短路,不发空 Bearer, + /// 也不会进入刷新重试链路。 + #[tokio::test] + async fn envelope_credentials_short_circuit_before_request() { + let account = json!({ + "id": "envelope-only", + "access_token": {"$wbEncrypted": true, "envelope": "…"}, + "refresh_token": {"$wbEncrypted": true, "envelope": "…"}, + }); + let resp = authenticated_post(&account, "https://example.invalid/api", json!({})).await; + assert_eq!(resp["code"], -2); + let msg = resp["message"].as_str().expect("message 应为字符串"); + assert!(msg.contains("信封"), "错误文案应可读:{msg}"); + } + #[test] fn parses_cockpit_resource_shape_and_marks_expiry() { let now = 1_800_000_000_000_i64; diff --git a/crates/wb-switch-core/src/modules/travel.rs b/crates/wb-switch-core/src/modules/travel.rs index 84dc3994..1a2f16b4 100644 --- a/crates/wb-switch-core/src/modules/travel.rs +++ b/crates/wb-switch-core/src/modules/travel.rs @@ -104,6 +104,10 @@ fn is_unauthorized(resp: &Value) -> bool { /// 发旅行接口请求;遇到未授权且存在 refresh token 时刷新一次并重试。 async fn travel_request(path: &str, method: &str, body: Option, account: &Value) -> Value { + // 加密信封凭据短路:不发空 Bearer,直接给出可读错误(issue #94)。 + if let Some(err) = account::envelope_token_error(account) { + return json!({"code": -2, "message": err}); + } let url = format!("{WORKBUDDY_API_ENDPOINT}{path}"); let headers = build_travel_headers(account); let mut resp = http_request(&url, method, body.clone(), Some(&headers)).await; @@ -1050,6 +1054,21 @@ pub fn travel_display(account_id: &str) -> Value { mod tests { use super::*; + /// 回归 issue #94:信封凭据的旅行请求应在入口短路并返回可读错误, + /// 不发出空 Bearer。 + #[tokio::test] + async fn envelope_credentials_short_circuit_before_request() { + let account = json!({ + "id": "envelope-only", + "access_token": {"$wbEncrypted": true, "envelope": "…"}, + "refresh_token": {"$wbEncrypted": true, "envelope": "…"}, + }); + let resp = travel_request("/whatever", "POST", Some(json!({})), &account).await; + assert_eq!(resp["code"], -2); + let msg = resp["message"].as_str().expect("message 应为字符串"); + assert!(msg.contains("信封"), "错误文案应可读:{msg}"); + } + #[test] fn retryable_skips_are_not_terminal() { assert!(is_retryable_skip(Some("no-buddy")));