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
168 changes: 168 additions & 0 deletions src/i18n_pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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})"
);
}
}
}
10 changes: 10 additions & 0 deletions ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,本轴整体不可见,探针必须先断言时区。

2 changes: 1 addition & 1 deletion ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,6 @@ <h3 id="chat-title">使用模型</h3>
<script src="js/api.js?v=20260914-13"></script>
<script src="js/data.js?v=20260914-11"></script>
<script src="js/i18n.js?v=20260914-12"></script>
<script src="js/app.js?v=20260914-13"></script>
<script src="js/app.js?v=20260915-1"></script>
</body>
</html>
18 changes: 15 additions & 3 deletions ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? '<div class="table-wrap compact"><table class="table"><thead><tr>' +
'<th>' + esc(T("admin.raise.col.member")) + "</th>" +
'<th class="num">' + esc(T("admin.raise.col.amount")) + "</th>" +
Expand All @@ -1281,7 +1285,7 @@
"<td data-label='" + T('admin.raise.col.action') + "'>" + (r.status === "pending"
? "<button class='btn btn-ghost' style='padding:4px 10px;font-size:12px' data-raise-approve='" + i + "'>" + T("admin.raise.approve") + "</button> " +
"<button class='btn btn-danger' style='padding:4px 10px;font-size:12px' data-raise-reject='" + i + "'>" + T("admin.raise.reject") + "</button>"
: '<span class="muted" style="font-size:12px">' + esc((r.created_at || "").slice(5, 16)) + "</span>") + "</td></tr>"
: '<span class="muted" style="font-size:12px">' + timeCell(r.created_at, true) + "</span>") + "</td></tr>"
).join("") + "</tbody></table></div>"
: emptyState(T("admin.raise.empty"), T("admin.raise.empty.sub")));
}
Expand Down Expand Up @@ -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",
}));
Expand Down Expand Up @@ -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 || "—",
Expand Down
Loading