From ede8922dc8416465691e00bc630c3fa1dd3cbb6c Mon Sep 17 00:00:00 2001 From: argszero Date: Tue, 15 Sep 2026 00:24:42 +0800 Subject: [PATCH] fix(ui): stop hand-slicing wire timestamps before the time helpers see them `src/dao.rs::utc_iso()` serialises every timestamp as `YYYY-MM-DDTHH:MM:SSZ`, and `ui/js/app.js` has a family of helpers for turning that into something a user can read (`fmtPrecise`, `timeCell`, `timeAgo`, `localMD`). Three consumers sliced the raw string themselves instead, and each slice produced its own wrong face on screen: admin raise-requests, handled rows: (r.created_at || "").slice(5, 16) -> "09-13T16:30": the ISO `T` separator leaks into the UI, and the hour is UTC. settings -> API keys, "created": String(k.created_at || "").slice(0, 10) -> the UTC day, so a UTC+8 user before 08:00 local is shown yesterday. transactions view row: time: (t.time || "").replace("T"," ").slice(0,16) -> the seconds are destroyed *before* the renderer sees the value, while both renderers of that value (the column via `timeCell(t.time, true)` and the CSV export via `fmtPrecise`) print `HH:MM:SS`. The seconds on screen are a fabricated `00` -- always. #229 made the export agree with the cell; once both read the same value, the truncation in the source became the only reading left. None of the three is a trade-off: the helpers already exist for exactly this, and the backend guarantees the format (comments at `app.js:3206` document UTC for *aggregate* buckets only, which is a different thing). The fix hands each value over intact: timeCell(r.created_at, true) -- local, to the second, relative in the title fmtPrecise(k.created_at).slice(0, 10) -- slicing a helper's *output* is fine time: t.time || "" -- the row carries the wire value `src/i18n_pack.rs::wire_timestamps_reach_the_renderer_unsliced` pins the shape in CI: a wire timestamp field (`created_at` / `last_used`) may not be processed inline by `.slice()` / `.replace()` (processing the *helper's* output is allowed, and a plain passthrough like `last: k.last_used || null` is allowed), and the transactions view row's `time` property must be a bare value. The extractor is proven on synthetic pre-fix and post-fix text, and on the pre-fix tree itself: restoring either `_at` slice panics with both offending expressions, restoring the view-row slice panics on the row shape. The control flow has no JS runner in CI, so the runtime half is carried by `ui/README.md` and its smoke-test notes. Verified with a jsdom instrument over the real `ui/index.html` + four real scripts (only `fetch` and the Blob download stubbed, with the backend's real wire format), seven legs driven through the real login form and the real nav: live (this tree) md5=c0f23c7c passed=13/13 reds={} pre-fix tree (301ca4dc) passed=7/13 reds={A1 B1 B2 C1 C2 E1} F1 reverted only reds={B1 B2} F2 reverted only reds={C1 C2} F3 reverted only reds={A1 E1} half fix (localMD, loses time) reds={B2} wrong fix (pre-format, re-parsed as UTC) reds={A1 E1} The three per-face red sets are pairwise disjoint and their union is exactly the pre-fix set. The half fix shows the `T` alone is not the whole rule; the wrong fix shows "call a helper somewhere" is not either -- the value has to arrive raw. - ui/js/app.js : the three consumers go through the helpers - src/i18n_pack.rs: CI tripwire for the shape, with detector controls - ui/README.md : record the rule and the smoke-test shape - ui/index.html : cache-bust app.js Co-authored-by: EMRG Evolution --- src/i18n_pack.rs | 168 +++++++++++++++++++++++++++++++++++++++++++++++ ui/README.md | 10 +++ ui/index.html | 2 +- ui/js/app.js | 18 ++++- 4 files changed, 194 insertions(+), 4 deletions(-) diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index f62e2ed..612d813 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -1211,4 +1211,172 @@ mod tests { "阳性对照失败:提取器认不出已声明的调用点" ); } + + /// 前端**自己切**线上时间戳的字段族:`dao::utc_iso()` 序列化出去的那一批。 + /// + /// `transactions.time` 不在其中:`time` 这个名字太泛(游客 mock 的 `MM-DD HH:mm` 也叫 + /// `time`,`dailySeries` 对它取前 5 位是**故意的**),所以交易视图行的形状由 + /// `txs_to_view_row_carries_the_raw_timestamp` 单独按位置钉。 + const WIRE_TS_FIELDS: [&str; 2] = ["created_at", "last_used"]; + + /// 前端现成的本地化 helper —— 出现在「被切的表达式」里就算这串是**先交给 helper 再切**的。 + const TIME_HELPERS: [&str; 5] = [ + "fmtPrecise(", + "timeCell(", + "timeAgo(", + "localMD(", + "utcMonth(", + ]; + + /// `at` 之前那个「表达式窗口」:从最近的 `,;:{}` 或换行起、到 `at` 为止(含两端之间的全部文本)。 + /// + /// 刻意**不**在 `(` / `)` / 运算符处断开:`created: fmtPrecise(k.created_at)` 是个整体, + /// 在括号处断开会把 helper 名切出去,于是「切的是 helper 的输出」这条合法形态会被误判成违规。 + fn expression_window(src: &str, at: usize) -> &str { + let bytes = src.as_bytes(); + let mut i = at; + while i > 0 { + let c = bytes[i - 1] as char; + if matches!(c, ',' | ';' | ':' | '{' | '}' | '\n' | '\r') { + break; + } + i -= 1; + } + src[i..at].trim() + } + + /// `src` 里对线上时间戳的**原地加工**(返回 表达式窗口 + 运算符)。 + fn inline_timestamp_processing(src: &str) -> Vec<(String, &'static str)> { + let mut out = Vec::new(); + for op in [".slice(", ".replace("] { + for (at, _) in src.match_indices(op) { + let window = expression_window(src, at); + if WIRE_TS_FIELDS.iter().any(|f| window.contains(f)) + && !TIME_HELPERS.iter().any(|h| window.contains(h)) + { + out.push((window.to_string(), op)); + } + } + } + out + } + + /// 取 JS 函数体:`signature` 之后的第一个 `{` 到配对的 `}`(跳过字符串/模板字面量里的花括号)。 + fn js_function_body<'a>(src: &'a str, signature: &str) -> Option<&'a str> { + let start = src.find(signature)? + signature.len(); + let open = src[start..].find('{')? + start; + let bytes = src.as_bytes(); + let (mut depth, mut i, mut quote) = (0i32, open, 0u8); + while i < bytes.len() { + let c = bytes[i]; + if quote != 0 { + if c == b'\\' { + i += 2; + continue; + } + if c == quote { + quote = 0; + } + } else if c == b'"' || c == b'\'' || c == b'`' { + quote = c; + } else if c == b'{' { + depth += 1; + } else if c == b'}' { + depth -= 1; + if depth == 0 { + return Some(&src[open..i + 1]); + } + } + i += 1; + } + None + } + + /// 时间戳必须以**原始串**到达渲染器,格式化只能由 helper 做(C2126)。 + /// + /// 后端用 `dao::utc_iso()` 统一序列化(`format!("{date}T{time}Z")`,见 `src/dao.rs`), + /// 前端则有现成的本地化 helper(`fmtPrecise` / `timeCell` / `timeAgo` / `localMD`)。 + /// 但 `ui/js/app.js` 里三处消费者把这个串**自己切了**,于是同一把尺子上出现三种错法: + /// + /// - 管理员加额申请「已处理」行 `(r.created_at || "").slice(5, 16)` ⇒ 屏幕上真的印出 + /// `09-13T16:30`:ISO 的 `T` 分隔符泄露进 UI,而且小时是 UTC 的; + /// - 设置页 API Key「创建时间」`String(k.created_at || "").slice(0, 10)` ⇒ UTC 日, + /// 东八区用户在当地 08:00 之前看到的是「昨天」; + /// - 交易视图行 `time: (t.time || "").replace("T", " ").slice(0, 16)` ⇒ **在渲染器之前** + /// 就把秒抹掉,而这一列(`timeCell(t.time, true)`)与 CSV 导出(`fmtPrecise`)的口径都是 + /// `HH:MM:SS` ⇒ 屏幕上的秒数永远是伪造的 `00`。C2111 把「导出 = 单元格」统一之后, + /// 两份口径同源,源头的截断就成了口径本身。 + /// + /// CI 里没有 JS 运行器,所以这里只钉**形状**(运行期那一半 —— 屏幕/CSV 上到底印出什么 —— + /// 由 jsdom 仪器与 `ui/README.md` 的「时间戳」小节承接): + /// 线上字段不得被原地加工(**加工 helper 的输出可以**,见负/阳性对照); + /// 交易视图行的 `time` 属性必须是裸值。 + #[test] + fn wire_timestamps_reach_the_renderer_unsliced() { + let app = strip_js_comments(APP_JS); + assert!( + app.contains("function txsToView("), + "ui/js/app.js 没读到(提取器的输入为空)" + ); + + // ① 线上时间戳字段不得被 `.slice()` / `.replace()` 原地加工。 + let found = inline_timestamp_processing(&app); + assert!( + found.is_empty(), + "ui/js/app.js 有 {} 处原地加工线上时间戳:{found:?} —— 服务端的时间戳是 \ + `dao::utc_iso()` 的 `YYYY-MM-DDTHH:MM:SSZ`,自己切会同时泄露 ISO 的 `T` \ + (屏幕上真的出现 `09-13T16:30`)并按 UTC 显示;整串交给 `fmtPrecise` / `timeCell` \ + 之后要截断也截它们的输出", + found.len() + ); + + // ② 交易视图行必须把服务端串**原样**带出去(`time` 太泛,不进 ① 的字段集,按位置钉)。 + let body = js_function_body(&app, "function txsToView(") + .expect("ui/js/app.js 里找不到 txsToView 的函数体"); + assert_eq!( + body.matches("time:").count(), + 1, + "txsToView 里 `time:` 不再唯一,本断言按位置取属性 —— 请同步更新本测试" + ); + let at = body.find("time:").unwrap() + "time:".len(); + let rhs = &body[at..]; + let rhs = rhs[..rhs.find(',').unwrap_or(rhs.len())].trim(); + assert!( + !rhs.contains('('), + "交易视图行的 `time` 不再是裸值({rhs:?}):渲染器(`timeCell(..., true)` 与 CSV 导出)\ + 拿到的必须是库内原串。在这里先把 `YYYY-MM-DDTHH:MM:SSZ` 切成 `YYYY-MM-DD HH:MM` \ + 会把秒抹掉,而两处渲染的口径都是 `HH:MM:SS` ⇒ 屏幕上的秒永远是伪造的 `00`" + ); + + // ③ 阴性对照:提取器必须对**修前**的两种形态给出相反答案(否则 ① 只是恒真的形状巧合)。 + for (before, what) in [ + ( + "esc((r.created_at || \"\").slice(5, 16))", + "管理员加额申请「已处理」单元格", + ), + ( + "created: String(k.created_at || \"\").slice(0, 10),", + "API Key「创建时间」单元格", + ), + ] { + assert_eq!( + inline_timestamp_processing(before).len(), + 1, + "阴性对照失败:提取器认不出修前形态({what})—— ① 等于没写" + ); + } + // 阳性对照:同一种「切」落在 helper 输出上必须被放过 —— 要截断就截 helper 的结果。 + for (after, what) in [ + ( + "created: fmtPrecise(k.created_at).slice(0, 10),", + "取本地日期", + ), + ("last: k.last_used || null,", "原样透传(渲染器再格式化)"), + ] { + assert!( + inline_timestamp_processing(after).is_empty(), + "阳性对照失败:提取器把合法形态判成违规({what}:{after})" + ); + } + } } diff --git a/ui/README.md b/ui/README.md index a2c1c7a..ace562a 100644 --- a/ui/README.md +++ b/ui/README.md @@ -386,3 +386,13 @@ ui/ - **折入的同轴缺陷**:登录成功后不再 `await loadSession(); enterApp();`,而是走 boot 的**同一个**入口 `restoreSession()`(`if (await restoreSession()) toast(login.welcome)`)。理由与「会话恢复」小节完全相同:**token 已存却停在登录页 = 谎报「已登出」**(改前 `saveToken()` 之后 `loadSession()` 一旦非 401 失败,token 留在 storage 里而人留在登录页)。凭据已被接受之后,401 就不再是「登录失败」了。 - **CI 覆盖**:形状(调用点声明了 + 咽喉的 401 分支受该声明守卫 + 全局登出没被整段删掉)由 `src/i18n_pack.rs::credential_401_is_not_a_session_expiry` 钉住;控制流本身没有 JS 运行器,与上节同理。 - **冒烟测试注意**:① stub `POST /api/auth/login` → 401,主断言是 **toasts 不含** `T("login.session.expired")`(改前为红,有鉴别力),行内错误 **等于** `T("login.err.bad")` 只能作阳性对照(改前已绿);② 另起一条 leg:带 token boot + stub `GET /api/me` → 401,断言 token 被清 + 回登录页 + toasts **含** `login.session.expired` —— 这条防止有人用「干脆不做全局登出」来让 ① 变绿;③ 再一条:登录成功但 `/api/me` 回 500,断言 app 可见(与「非 401 不得停在登录页」同一条不变量)。 + +## 时间戳:一律整串交给时间 helper(C2126) + +- **唯一的线上格式**:后端用 `dao::utc_iso()` 统一序列化,前端拿到的一律是 `YYYY-MM-DDTHH:MM:SSZ`(`src/dao.rs` 的 `format!("{date}T{time}Z")` 是这条契约的载体)。前端**有**一族现成的本地化 helper:`fmtPrecise`(本地精确到秒)、`timeCell`(单元格:主文本 + 悬停)、`timeAgo`(相对时间)、`localMD`(本地月-日)、`utcMonth`(UTC 月键)。 +- **不变量**:**时间戳必须以原始串到达渲染器,格式化只能由 helper 做**。不允许在中间层用 `.slice()` / `.replace()` 自己加工一个线上时间戳 —— 加工 helper 的**输出**可以(`fmtPrecise(k.created_at).slice(0, 10)` 取本地日期就是对的),加工**线上串**不行。理由:那个串是 UTC 且带 `T`,自己切会同时犯两个错 —— 泄出 ISO 的 `T`(屏幕上真的出现 `09-13T16:30`)并按 UTC 显示。要截断/换格式,就加在 helper 之后。 +- **修前三处**(都在 `ui/js/app.js`,同一把尺子):① 管理员加额申请「已处理」行 `(r.created_at || "").slice(5, 16)` → 屏幕上是 `09-13T16:30`;② 设置页 API Key「创建时间」`String(k.created_at || "").slice(0, 10)` → UTC 日,东八区用户在当地 08:00 前看到「昨天」;③ 交易视图行 `time: (t.time || "").replace("T", " ").slice(0, 16)` → **在渲染器之前**就把秒抹掉,而列(`timeCell(t.time, true)`)与 CSV 导出的口径都是 `HH:MM:SS` ⇒ 屏幕上的秒永远是伪造的 `00`(C2111 统一了「导出 = 单元格」,两份口径同源之后,源头的截断就成了口径本身)。 +- **改法**:① `timeCell(r.created_at, true)`;② `fmtPrecise(k.created_at).slice(0, 10)`;③ 视图行原样 `time: t.time || ""`。**零新增 i18n 键**(helper 只做数值格式化,文案键与本次无关)。 +- **CI 覆盖**:`src/i18n_pack.rs::wire_timestamps_reach_the_renderer_unsliced` 钉两件事 —— `_at` / `last_used` 这类线上字段不得被 `.slice()` / `.replace()` 原地加工(含阴性对照:提取器必须认得出修前的两种形态、并放过 `fmtPrecise(...).slice(...)` 与纯透传 `last: k.last_used || null`),以及 `txsToView` 返回的视图行里 `time` 必须是裸值。 +- **冒烟测试注意**:jsdom 启真 `index.html` + 四脚本、只 stub `fetch`,夹具必须带**非零秒**(`2026-09-13T16:30:45Z`)—— 用 `:00` 的夹具看不见第 ③ 面(改前改后都是 `:00`)。三面的期望值都从夹具用 `Date` **独立算出**(本地时间/本地日期),不要抄 helper 的输出。**时区是前提而不是细节**:`TZ=UTC` 下本地 == UTC,本轴整体不可见,探针必须先断言时区。 + diff --git a/ui/index.html b/ui/index.html index 7b8f216..2b54339 100644 --- a/ui/index.html +++ b/ui/index.html @@ -845,6 +845,6 @@

使用模型

- + diff --git a/ui/js/app.js b/ui/js/app.js index 0d578ca..d644dca 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -1267,6 +1267,10 @@ } // 表头在渲染期取 i18n(本表由 JS 在 boot 之后注入,无 data-i18n 钩子可走): // 既保证首屏语言正确,也让 atp:langchange → renderView 能实时换语言 + // C2126:已处理行里显示的是「处理时间」,必须交给时间 helper(`timeCell` → 本地化、 + // 秒精度、悬停给相对时间),不能自己切服务端串 —— `created_at` 是 + // `dao::utc_iso` 的 `YYYY-MM-DDTHH:MM:SSZ`,`slice(5, 16)` 会同时泄露 ISO 的 `T` + // 分隔符(屏幕上真的出现 `09-13T16:30`)并按 UTC 显示小时。 el.innerHTML = (list.length ? '
' + '" + '" + @@ -1281,7 +1285,7 @@ "" + : '' + timeCell(r.created_at, true) + "") + "" ).join("") + "
' + esc(T("admin.raise.col.member")) + "' + esc(T("admin.raise.col.amount")) + "" + (r.status === "pending" ? " " + "" - : '' + esc((r.created_at || "").slice(5, 16)) + "") + "
" : emptyState(T("admin.raise.empty"), T("admin.raise.empty.sub"))); } @@ -2075,7 +2079,10 @@ fullKey: k.full_key || null, name: k.name || T("common.unnamed"), key: k.key, - created: String(k.created_at || "").slice(0, 10), + // C2126:「创建时间」列显示的是**本地日** —— 直接切服务端串的前 10 位拿到的是 + // UTC 日(东八区用户在当地 08:00 之前会看到昨天)。取 helper 产出的本地串的 + // 日期部分,而不是再抄一份「时间转字符串」的口径。 + created: fmtPrecise(k.created_at).slice(0, 10), last: k.last_used || null, // rant 2026-08-24T12:41:25:真实最近使用时间(NULL=从未使用) status: k.status || "active", })); @@ -3271,7 +3278,12 @@ const key = t.key_name || t.key_label || "—"; return { id: t.id, - time: (t.time || "").replace("T", " ").slice(0, 16), + // C2126:视图行**原样**带出服务端串,格式化交给渲染器(列是 `timeCell(..., true)` + // → `fmtPrecise`,CSV 导出用同一个 helper)。此前在这里先切/替换成 + // `YYYY-MM-DD HH:MM`,秒位被抹掉,而两处渲染的口径都是 `HH:MM:SS` + // ⇒ 屏幕上的秒数永远是伪造的 `00`(C2111 修好的是「导出与单元格同口径」, + // 两份口径同源之后,源头的截断就成了唯一的口径)。 + time: t.time || "", type: t.type, // 用户列(rant 2026-08-22T17:21:39 需求 2):transactions.user_id JOIN users 取用户名 user: t.user_name || "—",