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 提取
作为可读信息,不再把整页 … 塞进通知与界面卡片
- 补回归测试 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, 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 {
+ 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 {
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, cfg: &Value) -> Vec {
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::(),
+ "message": normalize_error_body(&text),
})
})
}
@@ -1032,6 +1032,25 @@ pub async fn http_request_with_proxy(
}
}
+/// 非 JSON 错误响应体归一化:网关(openresty / APISIX 等)的 401/5xx 常返回
+/// 整页 HTML,原样截断会把 `…` 整段塞进通知与界面卡片(issue #94)。
+/// HTML 提取 `` 作为可读信息;其余保持原有的 500 字符截断。
+fn normalize_error_body(text: &str) -> String {
+ if text.trim_start().starts_with('<') {
+ let title = text
+ .split_once("")
+ .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\
+ \n401 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")));