Skip to content
Merged
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
76 changes: 76 additions & 0 deletions src/i18n_pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>, 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"
);
}
}
9 changes: 9 additions & 0 deletions ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,12 @@ ui/
- **两侧逐字对应**:`txsToView` 的 `t.model || "—"` / `t.key_name || t.key_label || "—"` ↔ `tx_where` 的 `COALESCE(NULLIF(…, ''), '—')`(Key 列是 `COALESCE(NULLIF(ak.name,''), NULLIF(<key_label 表达式>,''), '—')`,逐层 `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")` 现取,**不要**在测试里写死文案字面量。
4 changes: 2 additions & 2 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,7 @@ <h3 id="chat-title">使用模型</h3>

<script src="js/api.js?v=20260914-11"></script>
<script src="js/data.js?v=20260914-11"></script>
<script src="js/i18n.js?v=20260914-11"></script>
<script src="js/app.js?v=20260914-11"></script>
<script src="js/i18n.js?v=20260914-12"></script>
<script src="js/app.js?v=20260914-12"></script>
</body>
</html>
47 changes: 39 additions & 8 deletions ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions ui/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Expand Down Expand Up @@ -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)",
Expand Down
Loading