From da830a808492eea5dd6a21152b08c07e584d215b Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 14 Sep 2026 22:58:05 +0800 Subject: [PATCH] fix(ui): keep a token-holder in the app when the boot restore fails for non-auth reasons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boot session restore treated every non-401 failure of `GET /api/me` as "not signed in": it left the user on the login page while the token was still in storage and the URL hash still pointed at the previous view — visually identical to being logged out (host report 2026-09-14 21:00: with a slow transactions aggregate, `/api/me` was dragged to a gateway 504 and every refresh of `#/sharing` landed on the login form). Boot now goes through `restoreSession()`, which splits the outcomes three ways and only lets the first one mean "not signed in": - 401 -> api.js already cleared the token and returned to the login page; never retried (retrying cannot make a dead token valid). - network / 5xx (incl. gateway 504) -> retried once after 1s, then the app is entered anyway and a "session data failed to load" toast is shown. - other 4xx -> not retried (pointless), but still enters the app. The two states are kept apart on purpose: "load failed" is carried by each view's own load-error state (`loadErrorHtml` / `loadErrorRow` + retry), and "not signed in" only by the 401 path. If the token really is dead, the first real request after entering gets its 401 and api.js clears the token — that path already says the honest "session expired". - ui/js/app.js : add `restoreSession()`, route the boot IIFE through it - ui/js/i18n.js : `login.session.fail` no longer instructs a signed-in user to sign in again - src/i18n_pack.rs: pin the copy rule (the two copies must differ; the load-failure copy must not read like a re-authentication demand). The control-flow half has no CI JS test runner, so it is documented in ui/README.md instead. - ui/README.md : record the invariant and the smoke-test shape - ui/index.html : cache-bust i18n.js / app.js --- src/i18n_pack.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ ui/README.md | 9 ++++++ ui/index.html | 4 +-- ui/js/app.js | 47 +++++++++++++++++++++++++----- ui/js/i18n.js | 4 +-- 5 files changed, 128 insertions(+), 12 deletions(-) diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index 0a7ea0a..5ba8a69 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -1036,4 +1036,80 @@ mod tests { "阳性对照失败:真实存在的键被误报为缺失" ); } + + /// 「这段文案是在要求用户重新登录吗?」 + /// + /// 这是**检测器**不是断言:必须能对合成语料给出真、假两种答案,才配拿去断言真实文案 + /// (否则一个恒假的检测器会让下面那条断言空转通过)。 + fn demands_reauthentication(text: &str) -> bool { + [ + "重新登录", + "重新认证", + "登录已过期", + "sign in again", + "session expired", + ] + .iter() + .any(|needle| text.contains(needle)) + } + + /// 会话恢复失败的文案**不得**写成「已登出」。 + /// + /// boot 的会话恢复只在 **401** 时才把用户判成未登录(token 已被服务端作废); + /// 网络错误 / 5xx / 网关 504 等**非 401** 失败时 token 仍在,`ui/js/app.js::restoreSession` + /// 会重试一次后**照常进入 app**(rant 2026-09-14T21:15:02 第 4 条)。此时若屏幕上出现 + /// 「请重新登录」,就是把「加载失败」谎报成「未登录」—— 宿主 2026-09-14 21:00 实测的现象 + /// (`/api/me` 被拖到 504 ⇒ 停在登录页、token 仍在、URL hash 仍指向上次视图)。 + /// + /// 这里只钉**文案**这一半:两档必须不同,且「加载失败」那档不得要求重新登录。 + /// 视图状态那一半(非 401 必须进 app)是 JS 控制流,CI 里没有 JS 测试运行器, + /// 由 `ui/README.md` 的「会话恢复」小节作为约定与冒烟测试说明承接。 + #[test] + fn session_failure_copy_does_not_claim_the_user_is_logged_out() { + let LanguagePacks { zh, en, .. } = packs(); + let get = |m: &std::collections::BTreeMap, k: &str| { + m.get(k) + .unwrap_or_else(|| panic!("前置条件:语言包应有 {k}")) + .clone() + }; + let fail_zh = get(&zh, "login.session.fail"); + let fail_en = get(&en, "login.session.fail"); + let expired_zh = get(&zh, "login.session.expired"); + let expired_en = get(&en, "login.session.expired"); + + // ① 检测器的阳性对照(合成语料,不依赖语言包现状):它必须认得「要求重新登录」的写法。 + assert!( + demands_reauthentication("会话已过期,请重新登录") + && demands_reauthentication("Session expired, please sign in again"), + "阳性对照失败:检测器认不出「要求重新登录」的写法 —— 下面的断言等于没写" + ); + // ② 阴性对照:不要求重新登录的写法不得被误报 + assert!( + !demands_reauthentication("加载中…"), + "阴性对照失败:检测器把中性的加载提示误报成「要求重新登录」" + ); + + // ③ 两档文案必须不同 —— 相同的话,用户从屏幕上无法分辨自己是「被登出」还是「没连上」。 + assert_ne!( + fail_zh, expired_zh, + "登录失败档与未登录档的中文文案相同:用户无法分辨状态" + ); + assert_ne!( + fail_en, expired_en, + "the load-failure and not-signed-in English copies are identical — the two states become \ + indistinguishable on screen" + ); + + // ④ 真正的不变量:token 仍在的用户不该被要求重新登录。 + assert!( + !demands_reauthentication(&fail_zh), + "login.session.fail 的中文文案像是在要求重新登录({fail_zh:?})—— 但这条路径上 token 仍在,\ + 用户并没有被登出;「加载失败」与「未登录」必须分开表达" + ); + assert!( + !demands_reauthentication(&fail_en), + "the English login.session.fail copy reads like a sign-in-again instruction ({fail_en:?}) — \ + the token is still held on this path, so the user is not signed out" + ); + } } diff --git a/ui/README.md b/ui/README.md index 590193b..5b03c26 100644 --- a/ui/README.md +++ b/ui/README.md @@ -366,3 +366,12 @@ ui/ - **两侧逐字对应**:`txsToView` 的 `t.model || "—"` / `t.key_name || t.key_label || "—"` ↔ `tx_where` 的 `COALESCE(NULLIF(…, ''), '—')`(Key 列是 `COALESCE(NULLIF(ak.name,''), NULLIF(,''), '—')`,逐层 `NULLIF` 才能对齐 JS `||` 把空串当缺失的语义)。 - **改文案就要同时改两侧**:这是「显示口径 = 筛选口径」类的第 4 处(前 3 处:点数有符号值 C2054、时间列 C2111、Key 列空名兜底 C2101)。`transactions_model_and_key_filters_match_the_displayed_placeholder`(`src/routes/wallet.rs`)钉住服务端半边;阴性对照断言「类型名不再是模型列的可筛值」,防止有人反向把中文标签硬编码进 SQL。 - **冒烟测试注意**:前端半边(单元格文本)用 jsdom 启真 `index.html` + 四脚本、stub `fetch` 喂各类行(consume / gift / topup 哨兵)后**读渲染文本**;服务端半边由 Rust 测试覆盖。两侧的期望值都要**从同一条规则推出**(「库内值,空则 `—`」),不要照抄另一侧的输出 —— 照抄会让两边一起错。 + +## 会话恢复:非 401 失败不得演成「已登出」(C2124) + +- **唯一入口**:boot 的会话恢复(`app.js` `DOMContentLoaded` 里那个 IIFE)只做一件事 —— `if (api.getToken()) await restoreSession();`。判定与降级**全部**写在 `restoreSession()` 里,登录页不再自己接错误。 +- **三档判定,只有 401 能判「未登录」**:401 = token 已被服务端作废(`api.js` 已清 token + `__atpLogout` 回登录页),**不重试**(重试不会让失效 token 变有效);网络错误(`status === 0`)/ 5xx(含网关 504)视为**可重试**,等 1 s 重试**一次**(抖动通常只持续数百毫秒);其余 4xx 不重试(重试无意义)。 +- **不变量**:**`api.getToken()` 非空时,任何非 401 失败都不得把用户摆在登录页**。重试后仍失败 ⇒ **照常 `enterApp()`** 并 `toast(T("login.session.fail"))`。理由:token 仍在却显示登录页 = 谎报「已登出」,而 URL hash 还指向上次视图,用户只会理解为被踢出(宿主 2026-09-14 21:00 实测:`/api/me` 被拖到网关 504 时「停在登录页 + token 仍在 + hash 仍是 `#/sharing`」三特征同现)。 +- **两个状态分开表达**:「加载失败」由各视图自己的降级态(`loadErrorHtml` / `loadErrorRow` + 重试,见「登录态零 mock 约定」)承担;「未登录」只由 401 路径承担。若 token 其实已失效,进入 app 后第一次真实请求会拿到 401,由 `api.js` 清 token 回登录页 —— 那条路径给出的才是诚实的「登录已过期」。**不要**为了「稳妥」把非 401 也当作登出。 +- **文案**:`login.session.fail` 只说「加载失败」,**不得**写成「请重新登录」(会与 `login.session.expired` 混为一谈)。这条由 `src/i18n_pack.rs::session_failure_copy_does_not_claim_the_user_is_logged_out` 在 CI 里钉住(两档文案必须不同 + 失败档不得要求重新登录);**视图那一半(非 401 必须进 app)是 JS 控制流,CI 里没有 JS 测试运行器**,只能靠本节约定与下面的冒烟测试。 +- **冒烟测试注意**:用 jsdom 启真 `index.html` + 四脚本、只 stub `fetch`,按 leg 脚本化 `/api/me` 的响应:`200` → app 可见 + 登录页隐藏 + `/api/me` **恰好 1 次**;`504` 或 fetch reject → app 可见 + 登录页隐藏 + token 仍在 + toast 是 `login.session.fail`;`504 → 200` → 恰好 **2 次**调用且无错误 toast;`401` → 登录页可见 + token 清空 + **恰好 1 次**(不重试);无 token → `/api/me` **0 次**。断言期望值一律 `T("login.session.fail")` 现取,**不要**在测试里写死文案字面量。 diff --git a/ui/index.html b/ui/index.html index 87b93a2..cefb74c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -844,7 +844,7 @@

使用模型

- - + + diff --git a/ui/js/app.js b/ui/js/app.js index 2442aa0..59c6500 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -3064,6 +3064,41 @@ maybeStartTour(); // 首次登录引导(rant 20:46:57 A:atp-tour-done 未标记才触发) } + // 会话恢复(boot 唯一入口;rant 2026-09-14T21:15:02 第 4 条)。 + // + // 判定分三档,只有第一档能把用户判成「未登录」: + // · 401 → token 已失效:api.js 已清 token 并回登录页(`__atpLogout`)。**唯一** + // 不重试的情形 —— 重试不会让一个失效 token 变有效。 + // · 可重试失败 → 网络错误(`status === 0`)/ 5xx(含网关 504):等 1s 重试**一次**。 + // 抖动通常只持续数百毫秒,一次重试即可救回,不必惊动用户。 + // · 其余失败 → 4xx(非 401):重试无意义。 + // + // ⚠️ 重试后仍失败 ⇒ **照常进入 app**,绝不把用户摆在登录页。token 仍在(`api.getToken()` + // 非空)却显示登录页 = 谎报「已登出」,且 URL hash 仍指向上次视图,用户只会理解为被踢出 + // (宿主 2026-09-14 21:00 实测:`/api/me` 被拖到网关 504 时三特征同现)。两个状态必须分开: + // 「加载失败」由各视图自己的降级态(`loadErrorHtml`/`loadErrorRow` + 重试)承担, + // 「未登录」只由 401 路径承担。若 token 其实已失效,进入后第一次真实请求会拿到 401, + // 由 api.js 清 token 回登录页 —— 那条路径给出的是诚实的「登录已过期」。 + async function restoreSession() { + for (let attempt = 0; ; attempt++) { + try { + await loadSession(); + enterApp(); + return true; + } catch (e) { + const status = (e && e.status) || 0; + if (status === 401) return false; // 已由 api.js 处理(清 token + 回登录页) + const transient = status === 0 || status >= 500; + if (!transient || attempt >= 1) { + enterApp(); + toast(T("login.session.fail"), "error"); + return true; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + } + // api.js 401 钩子:token 失效 → 清 token 回登录页 window.__atpLogout = () => { api.clearToken(); @@ -3901,16 +3936,12 @@ renderView("dashboard"); $("#side-balance").textContent = D.fmt(D.USER.balance); - // P2-A 会话恢复:已有 token → 拉 /api/me + /api/wallet 直接进 app;401 自动清 token 回登录页 + // P2-A 会话恢复:已有 token → 拉 /api/me + /api/wallet 直接进 app。 + // 失败处置集中在 restoreSession():401 回登录页(唯一「未登录」信号), + // 其余失败重试一次后照常进 app 并提示 —— 不把「加载失败」演成「已登出」。 (async () => { if (!api.getToken()) return; - try { - await loadSession(); - enterApp(); - } catch (e) { - // 401 已由 api.js 清 token;其余错误保持登录页并提示 - if (!(e && e.status === 401)) toast(T("login.session.fail"), "error"); - } + await restoreSession(); })(); // 主题(rant 18:06:09 B):localStorage 记忆,首次加载尊重 prefers-color-scheme diff --git a/ui/js/i18n.js b/ui/js/i18n.js index 36511b3..cbb73be 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -94,7 +94,7 @@ "forgot.done": "密码已重置,请用新密码登录", "forgot.err.fail": "重置失败,请检查验证码", "verify.err.fail": "验证失败,请重试", - "login.session.fail": "会话恢复失败,请重新登录", + "login.session.fail": "会话数据加载失败,已进入平台,部分内容可能暂时不可用", "login.session.expired": "登录已过期,请重新登录", "login.balance.fail": "余额加载失败,显示 0", @@ -891,7 +891,7 @@ "login.err.bad": "Incorrect email or password", "login.err.fail": "Login failed, please try again later", "login.welcome": "Welcome back, {name}", - "login.session.fail": "Session restore failed, please sign in again", + "login.session.fail": "Session data failed to load — you are still signed in, some content may be unavailable", "login.session.expired": "Session expired, please sign in again", "login.balance.fail": "Balance load failed, showing 0", "register.name": "Nickname (optional)",