From 8d493cfcb35cffb72a4db97b42dbe0bb7086da68 Mon Sep 17 00:00:00 2001 From: argszero Date: Tue, 15 Sep 2026 12:41:17 +0800 Subject: [PATCH] fix(ui): make the transactions payload signature cover the time range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderTransactions()` holds the only "is the cached payload still valid?" guard: it compares page, page size and the payload signature. The signature hashed `txTable.filters` (column filters) only, while the list and trend URLs are rendered from a *second* piece of state — the time range (`txRange` / `txCustomStart` / `txCustomEnd`). A range-only change was therefore invisible to the guard, so each of the three range handlers paired the render with an explicit `loadTransactions()`. That patch double-fetches whenever the guard also fires, and `#tx-range` resets the page *before* rendering: from page >= 2, `loadedPage !== page` makes the guard fetch once and return, and then the explicit call fetches the same URL again. Two byte-identical list requests plus two trend requests for a single change of one select. On page 1 only one request goes out, which is why it stayed hidden. Make the signature cover every input that changes the request body: * `txFilterSig()` -> `txQuerySig()`, now hashing the column filters *and* the three range state values. It must read the state values — deriving it from `txRangeParams()` would embed a `now - window` timestamp, changing on every call and turning the guard into a per-render request storm. * `txTable.loadedFilterSig` -> `loadedQuerySig`. * New `reloadTransactions()`: the single reload trigger (resetting the page and reloading are one action). All four controls — type tabs, range quick pick, custom start, custom end — go through it, and the explicit `loadTransactions()` calls are gone from the wiring, so a control change costs at most one request (its own fetch stays guarded by an empty slot, for the first visit and the retry path). * New static gate `state_gate::the_transaction_payload_has_one_signature_and_one_reload_trigger` pins that shape in CI (there is no JS runner there). It judges comment-stripped bodies, and matches the loader by identifier — the two symbols are prefixes of each other (`reloadTransactions()` ends with `loadTransactions()`), so a substring test would red every fixed tree. The sibling control (type tabs) was already fixed in C2112 for exactly this reason; this closes the range half. Tests: `cargo test` 289 -> 290; `cargo fmt --check` clean; clippy reports only the pre-existing `protocol.rs:662`. Verified with `tmp/c2146_probe.js` (jsdom, real scripts, logged `fetch`): 10/10 on this tree, and exactly the `A1`/`B1` axis legs red on the unfixed tree, where the two list requests are byte-identical. --- src/state_gate.rs | 368 ++++++++++++++++++++++++++++++++++++++++++++++ ui/README.md | 45 ++++++ ui/index.html | 2 +- ui/js/app.js | 53 ++++--- 4 files changed, 449 insertions(+), 19 deletions(-) diff --git a/src/state_gate.rs b/src/state_gate.rs index ad859cd..6391827 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -513,6 +513,151 @@ fn lines_owned_by(src: &str, hit: impl Fn(&str) -> bool) -> Vec<(String, usize, out } +/// 这一行是否在**写** `txTable.page`。 +/// +/// 判别式有两颗牙:`=`(不是 `==`),且标识符**边界**要收口 —— `txTable.pageSize = 5` 里的 +/// `txTable.page` 是**更长标识符的前缀**,不是同一个字段(`assignment_after` 只处理反方向的 +/// 前缀,这里必须自己跨过剩余标识符字符)。 +fn writes_tx_page(line: &str) -> bool { + const NEEDLE: &str = "txTable.page"; + let mut from = 0usize; + while let Some(rel) = line[from..].find(NEEDLE) { + let at = from + rel; + let mut end = at + NEEDLE.len(); + while end < line.len() + && (line.as_bytes()[end].is_ascii_alphanumeric() + || line.as_bytes()[end] == b'_' + || line.as_bytes()[end] == b'$') + { + end += 1; + } + if end > at + NEEDLE.len() { + from = at + 1; // `txTable.pageSize`:另一个字段,继续往后找 + continue; + } + let trimmed = line[end..].trim_start(); + if trimmed.starts_with('=') && !trimmed.starts_with("==") { + return true; + } + from = at + 1; + if from >= line.len() { + break; + } + } + false +} + +/// 判据(守卫)行里「已加载证据 vs 当前值」比对中**右边**那个签名函数的名字。 +/// +/// 守卫把三项已加载证据与当前值比对,其中签名那一项的右边是一次**函数调用**(另两项的右边 +/// 是 `txTable.page` / `txTable.pageSize` 之类的值)。要在**不假设比对顺序**的前提下取出它: +/// 逐个 `!== ` 的右侧取到第一个 `(` 之间,只有「整段恰好是一个标识符」才算签名函数 —— +/// 前两项的右侧会把后面的比较式一并带进来(含空格),自然被刷掉。 +/// +/// 找不到调用(守卫被改写成比对一个普通值)⇒ `None`:调用方**必须** `expect` 报错, +/// 否则规则 1 会在空集上假绿。 +fn query_sig_name(body: &str) -> Option { + for line in body.lines() { + if !line.contains("txTable.loaded") { + continue; + } + let mut from = 0usize; + while let Some(rel) = line[from..].find("!== ") { + let at = from + rel + "!== ".len(); + let after = &line[at..]; + if let Some(end) = after.find('(') { + let name = after[..end].trim(); + if !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') + { + return Some(name.to_string()); + } + } + from = at; + } + } + None +} + +/// 交易控件(`#tx-range` 的绑定)所在的函数名 —— 由**文件顺序**派生,不写名册。 +/// +/// 不能用「命中行之前最近声明的那个函数」:`bindEvents` 内部还声明了嵌套函数 +/// (`showAuthForm`),位置启发式会把归属判给**最后**那个嵌套函数,而控件绑定写在它**之外**、 +/// 外层函数体内。改为:取**文件里第一个**「函数体含该控件字面量」的函数 —— 外层函数总在嵌套 +/// 函数之前声明,因此拿到的就是属主。 +fn tx_control_owner(src: &str) -> Option { + let mut names: Vec = Vec::new(); + for line in src.lines() { + if let Some(n) = function_name(line) { + names.push(n.to_string()); + } + } + names + .into_iter() + .find(|n| code_body(src, n).contains(TX_RANGE_CONTROL)) +} + +/// 函数体的**代码文本**(注释已剥离,含 `/* … */` 块)—— 逐行与 [`code_text_by_line`] 对齐。 +/// +/// 拿 [`function_code`] 的原文判定会被门禁**自己的说明性注释**判红/判绿(坑 #296 的镜像): +/// 本轮的修法恰好在控件旁边写了两句解释,里面正提到 `loadTransactions()`;而 `code_only` 只剥 +/// `//` 行,够不着块注释(坑 #309)。收尾仍按「首个恰为 ` }` 的行」⇒ 只适用于**多行**函数, +/// 调用方须自证提取结果非空且确实是那个函数。 +fn code_body(src: &str, name: &str) -> String { + let text = code_text_by_line(src); + let mut out: Vec = Vec::new(); + let mut started = false; + for (i, line) in src.lines().enumerate() { + if !started { + if function_name(line) == Some(name) { + started = true; + } else { + continue; + } + } else if line == " }" { + break; + } + if let Some(c) = text.get(i) { + if !c.is_empty() { + out.push(c.clone()); + } + } + } + out.join("\n") +} + +/// 这段代码是否**提到**交易载荷装载器 `loadTransactions`(以标识符为界)。 +/// +/// 不能写成 `body.contains("loadTransactions()")`:本轴的两个符号是前缀关系 —— +/// `reloadTransactions()` **以** `loadTransactions()` 结尾(坑 #333 同族:子串匹配会把兄弟 +/// 标识符当证据)。调用方传来的文本已由 [`code_body`] 剥离注释。 +fn mentions_tx_loader(text: &str) -> bool { + mentions_identifier(text, "loadTransactions") +} + +/// 「重拉触发器」的候选:体内**既重置页码、又直接调用 `loadTransactions()`** 的函数。 +/// +/// 这就是触发器的语义定义(「重置页码并重拉一次」),因此不需要名册;它必须**唯一** —— +/// 两个候选意味着两条各自独立的触发路径,正是本门禁要挡的形态。判据走 [`code_body`] +/// (注释已剥离),否则解释性注释里提一句 `loadTransactions()` 就会凭空多出一个候选。 +fn reload_trigger_candidates(src: &str) -> Vec { + let mut names: BTreeSet = BTreeSet::new(); + for line in src.lines() { + if let Some(n) = function_name(line) { + names.insert(n.to_string()); + } + } + names + .into_iter() + .filter(|n| { + let body = code_body(src, n); + body.lines().any(writes_tx_page) && mentions_tx_loader(&body) + }) + .collect() +} + fn owners_of(src: &str, hit: impl Fn(&str) -> bool) -> BTreeSet { lines_owned_by(src, hit) .into_iter() @@ -929,6 +1074,25 @@ const ZH_PACK_START: &str = "var ZH = {"; const EN_PACK_START: &str = "var EN = {"; const PACK_END: &str = "\n };"; +// ── C2146:交易载荷的**载荷签名**必须覆盖每一个改变请求体的状态 ────────────────────────────── + +/// 决定交易载荷请求体的**时间段**状态(模块级变量,与列筛选是两份状态)。 +/// +/// 交易列表/趋势请求的 URL 由 `txRangeParams()` 从这三个变量渲染,而「缓存还新不新」的判据是 +/// `renderTransactions` 里的签名比对 —— 判据必须覆盖**渲染请求体的全部输入**,否则那个控件 +/// 改完没人重拉,只能由调用方补一次显式拉取,而补的那一次会在判据也成立时并发第二次请求。 +const TX_RANGE_STATE: [&str; 3] = ["txRange", "txCustomStart", "txCustomEnd"]; + +/// 时间段 → 查询参数的渲染器。它含「now − 窗口」的毫秒时间戳,**每次调用都不同** ⇒ +/// 拿它当签名会让签名恒变(守卫每次渲染都重拉,形成请求风暴)。签名只许取状态值。 +const TX_RANGE_RENDERER: &str = "txRangeParams("; + +/// 列筛选状态(签名必须覆盖的另一半)。 +const TX_FILTER_STATE: &str = "txTable.filters"; + +/// `#tx-range` 控件所在的绑定函数(交易控件的归属函数由它派生,不写名册)。 +const TX_RANGE_CONTROL: &str = "\"#tx-range\""; + // ── C2145:会话余额是一个**事实**,只能读钱包载荷的**可花额**那一半 ─────────────────────── /// 会话余额这个事实在客户端的名字(侧栏 `#side-balance`、钱包页、聊天余额都印它)。 @@ -2903,4 +3067,208 @@ mod tests { " Live.wallet = await api.get(\"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/api/wallet\");" )); } + + /// 交易载荷有两件事必须**同源**:① 「缓存还新不新」的判据(载荷签名)必须覆盖每一个改变 + /// 请求体的输入;② 任何一次「控件改了查询状态」只许有**一个**重拉触发器(C2146)。 + /// + /// 轴:交易列表/趋势的 URL 由 `txRangeParams()` 从**时间段状态**渲染,而守卫比对的 + /// `txFilterSig()` 只哈希**列筛选**。两份状态、一份判据 ⇒ 时间段控件改完没人重拉,于是三个 + /// 时间段处理器各自**补一次显式 `loadTransactions()`**。补丁在「守卫也成立」时会并发第二次 + /// 请求:`#tx-range` 处理器**先把页码重置为 1**,再渲染 —— 只要用户不在第 1 页, + /// `loadedPage !== page` 就让守卫成立、`renderTransactions()` 自己发一次并 return,随后那句 + /// 显式调用再发一次 ⇒ **两份逐字相同的列表请求(外加趋势请求也两遍)**。第 1 页上只有一次, + /// 所以它潜伏至今(要复现必须先翻页)。 + /// + /// 四条规则,各有各的牙: + /// 1. **判据覆盖全部输入**:守卫比对的签名函数必须同时读列筛选与三个时间段状态变量,且 + /// **不得**由 `txRangeParams()` 派生(它含「now − 窗口」的时间戳,每次调用都不同 ⇒ + /// 签名恒变 ⇒ 守卫每次渲染都重拉,形成请求风暴 —— 比原缺陷更坏)。 + /// 2. **控件不许自带取数**:控件绑定所在函数体内不得出现 `loadTransactions()`。 + /// 3. **触发器唯一且被所有控件使用**:候选(体内既重置页码、又直接调 `loadTransactions()`) + /// 必须恰好一个;两个候选就是两条各自独立的触发路径。 + /// 4. **触发器的显式取数只在槽为空时用**:它必须提到 `!Live.transactions` —— 槽非空时 + /// `renderTransactions()` 的守卫会自己决定要不要重拉,触发器再无条件拉一次就又是两次。 + #[test] + fn the_transaction_payload_has_one_signature_and_one_reload_trigger() { + // 提取器自证(坑 #296 的镜像:本轮的修法**就在控件旁边**写着两句提到 + // `loadTransactions()` 的解释,原文判定会把门禁自己判红) + let noisy = " function demo() {\n // loadTransactions() 出现在注释里\n /* txTable.page = 1; */\n return 1;\n }\n"; + assert_eq!( + code_body(noisy, "demo"), + "function demo() {\nreturn 1;", + "`code_body` 没剥掉注释(含块注释)—— 规则 2/3 会被说明性注释判红" + ); + assert_eq!( + reload_trigger_candidates(noisy), + Vec::::new(), + "注释里的 `loadTransactions()` / `txTable.page = 1` 被当成了证据" + ); + assert!( + mentions_tx_loader(" loadTransactions();"), + "装载器调用没认出来" + ); + assert!( + !mentions_tx_loader(" reloadTransactions();"), + "`reloadTransactions()` 被当成了 `loadTransactions()` 的证据 —— 两个符号是前缀关系,\ + 子串匹配会让规则 2 在**每一个**修好的树上判红(坑 #333)" + ); + assert_eq!( + tx_control_owner( + " function outer() {\n const el = $(\"#tx-range\");\n function inner() {}\n el.addEventListener(\"change\", () => {});\n }\n" + ) + .as_deref(), + Some("outer"), + "归属被判给了后声明的嵌套函数(位置启发式的老毛病)" + ); + + let guard = code_body(APP_JS, "renderTransactions"); + assert!( + mentions_tx_loader(&guard), + "提取到的 `renderTransactions` 体里没有守卫的重拉调用 —— 提取器坏了,规则 1 会假绿" + ); + + // 阳性对照:签名函数必须被提取到(空集上的断言会假绿)。**不写函数名** —— + // 写死名字会让改前树因为「名字对不上」而红,红在错的那条规则上。 + let sig = query_sig_name(&guard) + .expect("守卫里找不到「已加载签名 !== <函数>()」的比对 —— 判别式坏了(或守卫被改写)"); + assert!( + js_function_body(APP_JS, &sig).is_some(), + "守卫比对的 `{sig}` 不是本文件里的函数" + ); + let sig_body = code_body(APP_JS, &sig); + assert!( + sig_body.lines().count() >= 2, + "签名函数 `{sig}` 的体只提取到 {} 行 —— `code_body` 只适用多行函数,先自证再说规则", + sig_body.lines().count() + ); + + // 规则 1:签名覆盖**列筛选 + 时间段**,且只取状态值 + assert!( + sig_body.contains(TX_FILTER_STATE), + "签名函数 `{sig}` 没读列筛选状态(`{TX_FILTER_STATE}`)—— 列筛选改完不会重拉" + ); + for state in TX_RANGE_STATE { + assert!( + mentions_identifier(&sig_body, state), + "签名函数 `{sig}` 没读时间段状态 `{state}` —— 那个控件改完守卫看不见,\ + 只能靠调用方补一次显式拉取,而补的那一次会在守卫也成立时变成第二次请求(C2146)" + ); + } + assert!( + !sig_body.contains(TX_RANGE_RENDERER), + "签名函数 `{sig}` 由 `{TX_RANGE_RENDERER}` 派生 —— 它含「now − 窗口」的毫秒时间戳,\ + 每次调用都不同 ⇒ 签名恒变 ⇒ 守卫每次渲染都重拉(请求风暴,比原缺陷更坏)" + ); + + // 规则 2:控件绑定所在函数不得自己取数 + let wiring = tx_control_owner(APP_JS) + .expect("找不到注册 `#tx-range` 的函数 —— 规则 2 的射程会静默变空"); + let wiring_body = code_body(APP_JS, &wiring); + assert!( + wiring_body.contains(TX_RANGE_CONTROL), + "`{wiring}` 的体里没有 `{TX_RANGE_CONTROL}` —— 提取错了函数(规则 2 会假绿)" + ); + assert!( + !mentions_tx_loader(&wiring_body), + "`{wiring}`(交易控件的绑定函数)自己调了 `loadTransactions()` —— 控件必须只调唯一的\ + 重拉触发器:`renderTransactions()` 的守卫已经会按签名/页码决定重拉,再补一次会在守卫\ + 也成立时并发第二次请求(C2146:第 2 页起改一次时间段发两遍列表 + 两遍趋势)" + ); + + // 规则 3:触发器唯一,且被控件使用 + let triggers = reload_trigger_candidates(APP_JS); + assert_eq!( + triggers.len(), + 1, + "「体内重置页码、又直接调 `loadTransactions()`」的函数应恰好一个,实际 {triggers:?} —— \ + 多于一个即两条各自独立的触发路径(本轴要消掉的就是这种形状)" + ); + let trigger = &triggers[0]; + let trigger_body = code_body(APP_JS, trigger); + assert!( + trigger_body.contains("renderTransactions()"), + "触发器 `{trigger}` 没走 `renderTransactions()` —— 那就绕过了签名守卫,\ + 槽非空时它必然与守卫重复拉取" + ); + let uses = wiring_body.matches(&format!("{trigger}()")).count(); + assert!( + uses >= 4, + "`{wiring}` 里只调了 `{trigger}()` {uses} 次 —— 四个控件(顶部 tab + 时间段快捷/起/止)\ + 都必须走这一个触发器(漏掉的那个就只能自带取数)" + ); + + // 规则 4:触发器里的显式取数只在槽为空时用 + assert!( + trigger_body.contains("!Live.transactions"), + "触发器 `{trigger}` 里的显式 `loadTransactions()` 没有被「槽为空」守住 —— \ + 槽非空时守卫会自己重拉,这里再拉一次就是两次请求" + ); + + // ── 判别式自证(合成输入)───────────────────────────────────────────────────── + // 规则 1 的判别式:`!==` 右侧取到的是**签名函数**,不是页码/每页行数那些值 + let real_guard = " if (Live.transactions && (txTable.loadedPage !== txTable.page \ + || txTable.loadedPageSize !== txTable.pageSize \ + || txTable.loadedQuerySig !== txQuerySig())) {"; + assert_eq!( + query_sig_name(real_guard).as_deref(), + Some("txQuerySig"), + "判别式没从守卫里取出签名函数名(前两项的右侧把后面的比较式一起带进来,必须被刷掉)" + ); + // 顺序无关:签名比对写在最前面也一样取得到 + assert_eq!( + query_sig_name( + " if (txTable.loadedQuerySig !== txQuerySig() || txTable.loadedPage !== txTable.page) {" + ) + .as_deref(), + Some("txQuerySig"), + "判别式假设了签名比对排在最后 —— 换个顺序就取不到(规则 1 会假绿)" + ); + // 只比页码的守卫:右侧不是调用 ⇒ `None`(调用方 `expect` 会当场报错,而不是静默假绿) + assert_eq!( + query_sig_name(" if (txTable.loadedPage !== txTable.page) {"), + None, + "「右侧不是函数调用」的守卫被当成了签名比对" + ); + + // 规则 1 的牙:`txRangeParams()` 派生的签名必须被这条断言看见 + let storm_sig = function_source( + " function txQuerySig() { return txTable.filters + txRangeParams(); }", + "txQuerySig", + ) + .expect("`function_source` 取不到单行函数(自证提取器)"); + assert!( + storm_sig.contains(TX_RANGE_RENDERER), + "自证失败:由 `txRangeParams()` 派生的签名本该被 `contains({TX_RANGE_RENDERER:?})` 命中 —— \ + 规则 1 的第三条断言没有牙" + ); + + // 规则 2/3 的判别式 + assert!( + !writes_tx_page(" const page = Math.max(1, txTable.page || 1);"), + "读 `txTable.page` 被当成了写" + ); + assert!( + !writes_tx_page(" txTable.pageSize = Math.min(100, x);"), + "`txTable.pageSize` 被当成了 `txTable.page`(前缀没收到标识符边界)" + ); + assert!(writes_tx_page(" txTable.page = 1;"), "真正的写没认出来"); + + // 规则 3:合成输入 —— 两条触发路径必须被点名 + let two_triggers = concat!( + " function reloadTransactions() { txTable.page = 1; loadTransactions(); renderTransactions(); }\n", + " function otherReload() { txTable.page = 1; loadTransactions(); }\n" + ); + assert_eq!( + reload_trigger_candidates(two_triggers), + vec!["otherReload".to_string(), "reloadTransactions".to_string()], + "规则 3 认不出第二条触发路径(半修会全绿)" + ); + assert!( + reload_trigger_candidates( + " function reloadTransactions() { txTable.page = 1; renderTransactions(); }\n" + ) + .is_empty(), + "不含显式取数的函数被当成了触发器" + ); + } } diff --git a/ui/README.md b/ui/README.md index f8390ce..31da47e 100644 --- a/ui/README.md +++ b/ui/README.md @@ -636,3 +636,48 @@ try { const w = await api.get("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/api/wallet"); if (w) D.USER.balance = w.balance; **为什么 CI 用静态门禁**:规则 2 钉的是「同一事实只有一个来源」这个**形状**,而 DOM 探针只能证明 「屏幕上数字对」—— 竞争修法在探针下全绿(`tmp/c2145_probe.js` 实测:修复树 9/9、改前树恰 `A2`/`A3` 两腿红、竞争者 8/9 被 `A3` 拒绝)。CI 里没有 JS 运行器,`cargo test` 是唯一能长期守住的关口。 + +## 载荷签名覆盖所有输入,控件变更只有一个重拉触发器(C2146) + +交易列表/趋势的请求体由**两份**状态渲染:列筛选(`txTable.filters`)与时间段 +(`txRange` / `txCustomStart` / `txCustomEnd`)。而「缓存还新不新」的判据只有一处 —— +`renderTransactions()` 里的三元比对(`loadedPage` / `loadedPageSize` / **载荷签名**), +所以**签名必须覆盖每一个改变请求体的输入**: + +``` +function txQuerySig() { // 只此一处定义「载荷是什么」 + const f = txTable.filters || {}; + const cols = Object.keys(f).sort().map((k) => k + "=" + String(f[k] == null ? "" : f[k])).join("&"); + return cols + "|" + txRange + "|" + txCustomStart + "|" + txCustomEnd; +} +``` + +**改前的形状**:签名只哈希列筛选,于是三个时间段控件**各自补一次显式 `loadTransactions()`**。 +补丁在「守卫也成立」时会并发第二次请求 —— `#tx-range` 处理器**先把页码重置为 1**,用户只要不在 +第 1 页,`loadedPage !== page` 就让守卫自己发一次并 `return`,随后那句显式调用再发一次 ⇒ +**两份逐字相同的列表请求(趋势请求也两遍)**。第 1 页上只有一次,所以它潜伏至今。 + +**约定**: + +1. **`txQuerySig()` 是「交易载荷由什么决定」的唯一陈述**:新增任何一个会改变请求体的状态, + 都必须折进它;签名只取**状态值**。 +2. **⚠️ 签名不得由 `txRangeParams()` 派生**:后者含「now − 窗口」的毫秒时间戳,每次调用都不同 + ⇒ 签名恒变 ⇒ 守卫每次渲染都重拉,**请求风暴**(比原缺陷更坏;探针实测请求数 3→3→2→4→5 递增)。 +3. **控件的状态一改只调 `reloadTransactions()`**(它把「页码重置」与「重拉」当作同一个动作): + 控件的绑定函数体内**不得**出现 `loadTransactions()` —— 守卫已经会按签名/页码决定要不要重拉。 +4. **触发器的显式取数只在槽为空时用**(首次进入 / 上次失败时 `renderTransactions()` 只画降级态、不拉取), + 否则它必然与守卫重复拉取。 + +**CI 覆盖**(`src/state_gate.rs::the_transaction_payload_has_one_signature_and_one_reload_trigger`, +四条规则各有独立的牙:签名读时间段状态且不由 `txRangeParams()` 派生 / 控件绑定函数不自带取数 / +「重置页码 + 直接取数」的函数**恰好一个**且被四个控件共用 / 触发器提到 `!Live.transactions`)。 +⚠️ 两个判别式陷阱都已在门禁里自证:`code_body()` **剥注释**(本轮的修法就在控件旁边写着两句提到 +`loadTransactions()` 的解释,原文判定会把门禁自己判红)、`mentions_tx_loader()` 按**标识符 token** +比(`reloadTransactions()` **以** `loadTransactions()` 结尾 —— 子串匹配会让规则 2 在每一个修好的树上判红)。 +控件归属不写名册:取**文件里第一个**「函数体含该控件字面量」的函数(`bindEvents` 内部还有嵌套函数, +「命中行之前最近声明的函数」会把归属判给那个嵌套函数)。 + +**为什么 CI 用静态门禁**:请求次数是运行期可观测量,但 CI 里没有 JS 运行器;探针 +(`tmp/c2146_probe.js`)只用于本地证明**方向**(改前树恰 `A1`/`B1` 红 = 第 2 页上改时间段发两遍列表 + +两遍趋势),并拒掉最诱人的错修(签名取 `txRangeParams()` ⇒ 请求风暴)。 + diff --git a/ui/index.html b/ui/index.html index 31b8e8a..e850e69 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 e22be93..08163a1 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -738,7 +738,7 @@ // // C2130:这是**另一种查询**的载荷(`page_size=1`,只为读 `total`),只能进仪表盘**自己的**槽。 // 它曾写进 `Live.transactions` —— 那是**交易视图自己的**载荷缓存,由 `loadTransactions` 写入, - // 并由同一个函数写下它的有效性证据(`txTable.loadedPage/loadedPageSize/loadedFilterSig`)。 + // 并由同一个函数写下它的有效性证据(`txTable.loadedPage/loadedPageSize/loadedQuerySig`)。 // 换了写者,证据就与缓存内容脱钩:`renderTransactions` 的守卫比的正是那三项, // 于是**放行**仪表盘的载荷 —— 再入交易页时表格只剩 1 行(page_size=1)、汇总卡挂着 // 「当前筛选」却显示全时段数字(仪表盘那次请求不带时间范围)、趋势卡谎报「加载失败」 @@ -1631,9 +1631,11 @@ setLiveError($("#tx-table"), loadErrorHtml(T("tx.loadFail"), T("err.loadFail")), () => loadTransactions()); return; } - // 真后端分页 + 列筛选(rant 2026-08-24T10:51:57 + 2026-08-25T10:33:26):页码/每页行数/筛选条件 - // 任一与已加载不一致 → 重新向后端拉取对应页(筛选变化同样触发,不再只过滤本地当前页) - if (Live.transactions && (txTable.loadedPage !== txTable.page || txTable.loadedPageSize !== txTable.pageSize || txTable.loadedFilterSig !== txFilterSig())) { + // 真后端分页 + 列筛选 + 时间段(rant 2026-08-24T10:51:57 + 2026-08-25T10:33:26 + C2146): + // 页码/每页行数/**载荷签名**任一与已加载不一致 → 重新向后端拉取对应页。签名要覆盖每一个 + // 改变请求体的输入(列筛选 **与** 时间段),否则那个控件就只能靠调用方补一次显式拉取 —— + // 而补的那一次会在本守卫也成立时变成第二次请求(C2146 实测:第 2 页起改一次时间段发两遍)。 + if (Live.transactions && (txTable.loadedPage !== txTable.page || txTable.loadedPageSize !== txTable.pageSize || txTable.loadedQuerySig !== txQuerySig())) { loadTransactions(); return; } @@ -1717,10 +1719,25 @@ const sel = document.querySelector('#tx-table select[data-filter-key="type"]'); if (sel) sel.value = txTable.filters.type; } - // 列筛选签名:筛选条件变化 → renderTransactions 触发重拉(rant 2026-08-25T10:33:26) - function txFilterSig() { + // 载荷签名(C2146):**决定请求体的全部状态** —— 列筛选 + 时间段(范围值 + 自定义起止)。 + // 它必须覆盖每一个会改变载荷的输入,否则控件改完没人重拉,只能由调用方**补一次显式拉取**, + // 而那个补丁会在「重拉触发条件也成立」时并发第二次请求(C2112 已为顶部 tab 记下这个坑)。 + // ⚠️ 取**状态值**,不能取 `txRangeParams()`:后者含「now − 窗口」的毫秒时间戳,每调用一次都不同 + // ⇒ 签名恒变 ⇒ 守卫每次渲染都重拉 ⇒ 请求风暴(比原缺陷更坏)。 + function txQuerySig() { const f = txTable.filters || {}; - return Object.keys(f).sort().map((k) => k + "=" + String(f[k] == null ? "" : f[k])).join("&"); + const cols = Object.keys(f).sort().map((k) => k + "=" + String(f[k] == null ? "" : f[k])).join("&"); + return cols + "|" + txRange + "|" + txCustomStart + "|" + txCustomEnd; + } + + // 交易载荷的**唯一重拉触发器**(C2146):控件的状态一改就调它一次 —— 页码重置与重拉是同一个 + // 动作,谁也不许绕过(显式 `loadTransactions()` 只在这里出现,且只在**槽为空**时用:首次进入 + // 或上次失败时 `renderTransactions()` 只画降级态、不拉取)。槽非空时交给它内部基于签名的比对 + // 决定 ⇒ 任何一次控件变更**至多一次**请求,而不是「渲染一次 + 补一次」。 + function reloadTransactions() { + txTable.page = 1; + if (loggedIn() && !Live.transactions) { loadTransactions(); return; } + renderTransactions(); } // P2-B:按 tab 拉取交易(真后端分页 rant 2026-08-24T10:51:57:页码/每页行数随请求发出; @@ -1748,7 +1765,7 @@ if (Live.transactions) Live.transactions.trend = trend; txTable.loadedPage = page; txTable.loadedPageSize = pageSize; - txTable.loadedFilterSig = txFilterSig(); // 记录已加载的筛选条件,变化时 renderTransactions 重拉 + txTable.loadedQuerySig = txQuerySig(); // 记录已加载的载荷签名,变化时 renderTransactions 重拉 } catch (e) { Live.transactions = null; /* 登录态降级空态 */ } renderTransactions(); // 翻页后滚动到列表顶部(rant 2026-08-24T10:51:57 需求 4) @@ -3901,16 +3918,18 @@ $$("[data-goto]").forEach((b) => b.addEventListener("click", () => switchView(b.dataset.goto))); // 交易 Tab(P2-B:切 tab 重新拉后端过滤数据):tab 写的就是类型筛选本身(setTxTypeFilter) - // —— 它也负责把「类型」列筛选控件同步成同一个值,否则两份控件又会各说各话;控件状态一变, - // renderTransactions 里的签名比对自会重拉(勿再显式调 loadTransactions,会与它并发两次请求)。 + // —— 它也负责把「类型」列筛选控件同步成同一个值,否则两份控件又会各说各话。控件状态一变只调 + // reloadTransactions():它重置页码,然后由 renderTransactions() 的签名比对决定是否重拉 + // (勿再显式调 loadTransactions,那会与守卫并发两次请求;C2146 起四个控件都走这一个触发器)。 $$("#tx-tabs .tab").forEach((b) => b.addEventListener("click", () => { setTxTypeFilter(b.dataset.txTab === "all" ? "" : b.dataset.txTab); - txTable.page = 1; - renderTransactions(); + reloadTransactions(); })); $("#tx-export-btn").addEventListener("click", exportTxCsv); // 导出 CSV(rant 20:46:57 E) - // 交易时间段(rant 2026-08-22T10:50:00:快捷范围 + 自定义起止,切换后重载列表与汇总) + // 交易时间段(rant 2026-08-22T10:50:00:快捷范围 + 自定义起止,切换后重载列表与汇总)。 + // C2146:时间段是**载荷签名的一部分**(txQuerySig),故与顶部 tab 同款 —— 只调唯一触发器, + // 不再自己补一次 loadTransactions()(那会在「页码从第 2 页重置为 1」时与守卫并发两次请求)。 const txRangeEl = $("#tx-range"); const txStartEl = $("#tx-range-start"); const txEndEl = $("#tx-range-end"); @@ -3923,12 +3942,10 @@ txRangeEl.addEventListener("change", () => { txRange = txRangeEl.value; showCustom(); - txTable.page = 1; - renderTransactions(); - if (loggedIn()) loadTransactions(); + reloadTransactions(); }); - txStartEl.addEventListener("change", () => { txCustomStart = txStartEl.value; txTable.page = 1; if (loggedIn()) loadTransactions(); }); - txEndEl.addEventListener("change", () => { txCustomEnd = txEndEl.value; txTable.page = 1; if (loggedIn()) loadTransactions(); }); + txStartEl.addEventListener("change", () => { txCustomStart = txStartEl.value; reloadTransactions(); }); + txEndEl.addEventListener("change", () => { txCustomEnd = txEndEl.value; reloadTransactions(); }); showCustom(); }