From a9fb9e3f726cdec34779d66dca4b9f6ee71fd983 Mon Sep 17 00:00:00 2001 From: argszero Date: Mon, 21 Sep 2026 23:31:59 +0800 Subject: [PATCH] fix(ui): print the spendable half in the ops member balance column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/api/ops/users` returns both `balance` (permanent points) and `gift_balance`, and the ops members table rendered only `u.balance` while its column head carries the site-wide unqualified wording "余额(点数)" / "Balance (pts)" — the wording the site binds to the SPENDABLE amount (`available = balance + gift_balance`, the same half `common.balance` / `dash.balance` / `wallet.balance` name). So the operator read a smaller number than the user reads for the same account, and the difference was exactly the gift half (spendable, expiring, really deducted by the gift sweep). Fix: fold `gift_balance` into the cell — byte-identical in shape to the sibling table that already got it right (`admin.emp.col.avail`), zero new i18n keys. The admin "permanent points" cell (`admin.emp.col.perm`) deliberately keeps the bare half; it is the reverse control. CI: `src/state_gate.rs::the_ops_members_balance_cell_is_the_half_its_caption_names` (three rules, each with an independent tooth: the value expression must read both `balance` and `gift_balance` by identifier token; both packs' column head must live in the spendable family, whose marker is derived from the `wallet.balance` pack value; and the reverse leg pins `admin.emp.col.perm` to the bare half) plus a teeth test. Scope note: the gate is lexical — it proves the value READS both fields and that the label lives in the spendable family; it does not prove the arithmetic is `+`. That half is pinned by the jsdom instrument, whose A/B ran the baseline red on exactly {A1,A1zh,A2,A2zh} while both legitimate fix trees stay green and three near-miss trees are rejected. Tests: cargo test 316 passed / 0 failed; cargo fmt --check clean; clippy --all-targets -D warnings clean. --- src/state_gate.rs | 316 ++++++++++++++++++++++++++++++++++++++++++++++ ui/README.md | 8 ++ ui/index.html | 2 +- ui/js/app.js | 2 +- 4 files changed, 326 insertions(+), 2 deletions(-) diff --git a/src/state_gate.rs b/src/state_gate.rs index 9245798..66246cb 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -2272,6 +2272,100 @@ fn csv_class_matches(elems: &[String], tail: &str, s: &str) -> bool { .any(|c| elems.iter().any(|e| csv_element_matches(e, c))) } +// ── C2173:运营成员表的「余额」列说的是哪一半 ────────────────────────────────────── + +/// 运营成员表余额列的**列头键**(单元格的 `data-label` 与列头用的是同一个键)。 +const OPS_BALANCE_CAPTION: &str = "ops.users.col.balance"; + +/// 管理页「永久点数」列的键 —— 规则 3 的**反向对照**:那一列的名字说它只有永久额。 +const ADMIN_PERM_CAPTION: &str = "admin.emp.col.perm"; + +/// 全站给两个半区起名的那两个键 —— 规则 2 的**尺子**(可花额 / 永久额)。 +/// +/// 不写死词表:标记从语言包自己的这两个值里推出来(见 [`half_markers`]),两种语言同一条规则。 +const SPENDABLE_WORD_KEY: &str = "wallet.balance"; +const PERMANENT_WORD_KEY: &str = "wallet.forever"; + +/// 一个用户的配额被切成的那两项(规则 1 必须**同时**读到)。 +const HALF_FIELDS: [&str; 2] = ["balance", "gift_balance"]; + +/// `'") + '">' + <取值> + ""` 里那截**取值表达式**(C2173)。 +/// +/// 判别式按 `data-label` 的**收尾标记** `'">'`(引号里就是闭合那个 `td` 的 `>`)与 `""` 收口, +/// 不写死空格:只要还是「一个 td 的取值段」,重排空白也取得到。取不到 ⇒ `None`, +/// 调用方**必须**报错(空集上的断言会假绿,坑 68)。 +fn cell_value_rhs(line: &str, caption_key: &str) -> Option { + let needle = format!("\"{caption_key}\""); + let at = line.find(&needle)? + needle.len(); + let rest = &line[at..]; + let close = rest.find("'\">'")? + "'\">'".len(); + let value = rest[close..].trim_start().strip_prefix('+')?.trim_start(); + let end = value.find("\"\"")?; + let head = value[..end].trim_end(); + let head = head.strip_suffix('+').unwrap_or(head).trim_end(); + if head.is_empty() { + None + } else { + Some(head.to_string()) + } +} + +/// `a` / `b` 的**最长公共子串**(按字符,长度相同取先遇到的那条)。 +/// +/// 尺子用:全站给两个半区起的名字共享的那截「名词」(en: `points`;zh: `点数`)。 +fn longest_common_substring(a: &str, b: &str) -> String { + let ac: Vec = a.chars().collect(); + let mut best = String::new(); + for i in 0..ac.len() { + for j in (i + 1)..=ac.len() { + let cand: String = ac[i..j].iter().collect(); + if cand.chars().count() > best.chars().count() && b.contains(cand.as_str()) { + best = cand; + } + } + } + best +} + +/// 从全站给两个半区起的**名字**里取出各自的**标记**:去掉共同名词后剩下的那截。 +/// +/// `("点数余额", "永久点数") -> ("余额", "永久")`;`("Points balance", "Permanent points") -> +/// ("balance", "permanent")`。两边都去空白与标点。 +/// +/// 取不到(没有共同名词、或某一边去掉共同名词后为空)⇒ `None`:调用方必须报错, +/// **不得**退化成空标记(空标记会让 `contains` 恒真 ⇒ 规则 2 静默变哑)。 +fn half_markers(spendable: &str, permanent: &str) -> Option<(String, String)> { + let a = spendable.to_lowercase(); + let b = permanent.to_lowercase(); + let common = longest_common_substring(&a, &b); + if common.trim().is_empty() { + return None; + } + let strip = |s: &str| { + s.split(&common) + .collect::>() + .join(" ") + .trim_matches(|c: char| c.is_whitespace() || "()()·,、::".contains(c)) + .trim() + .to_string() + }; + let (x, y) = (strip(&a), strip(&b)); + if x.is_empty() || y.is_empty() { + return None; + } + Some((x, y)) +} + +/// 这一条文案是否落在**可花族**:含可花标记,且**不含**永久标记(C2173 规则 2)。 +fn caption_names_spendable_half( + caption: &str, + spendable_marker: &str, + permanent_marker: &str, +) -> bool { + let c = caption.to_lowercase(); + c.contains(spendable_marker) && !c.contains(permanent_marker) +} + #[cfg(test)] mod tests { use super::*; @@ -4865,4 +4959,226 @@ mod tests { " const cell = (v) => {{ const s = String(v == null ? \"\" : v); return /{class}/.test(s) ? '\"' + s.replace(/\"/g, '\"\"') + '\"' : s; }};\n" ) } + /// 运营成员表的「余额」列说的必须是它列头那个词所指的那一半 —— **可花额**(C2173)。 + /// + /// 产品定义:`available = balance + gift_balance`(`src/routes/wallet.rs`),而全站把**不带限定**的 + /// 「点数余额 / Points balance」(`wallet.balance` / `common.balance` / `dash.balance`)绑给 + /// `available`、把「永久点数 / Permanent points」(`wallet.forever` / `admin.emp.col.perm`)绑给 + /// `balance`。`/api/ops/users` 两个半区**都**返回,前端却只印了 `u.balance` ⇒ 同一张屏幕上, + /// 用户自己看到 101、运营者在成员表里看到 100,差的就是**可花、会过期**的赠送那一半。 + /// + /// 三条规则各有各的牙: + /// - 规则 1(取值):单元格的取值必须**同时**读到两个半区 —— 只读到永久额=本缺陷; + /// 只读到赠送=把轴修反。 + /// - 规则 2(词):两包的列头文案必须仍落在**可花族**。把列头改名成「永久点数」也能让两边对上, + /// 但那是**改这一列声明的口径**(设计变更),不是修 —— 列头用的就是全站给可花额起的那个名字。 + /// - 规则 3(反向):管理页的「永久点数」列必须**仍然只**读永久额 —— 挡「一键全改成 available」 + /// 那种过度纠正(同屏那份兄弟表把两半各自起对了名,正确范例就是从它那里来的)。 + #[test] + fn the_ops_members_balance_cell_is_the_half_its_caption_names() { + let ops = function_source(APP_JS, "renderOps").expect("找不到 renderOps"); + let ops_lines = code_text_by_line(&ops); + + // ── 前置:提取器停对了地方,且填充位点唯一(空集上的断言会假绿,坑 68)────────────── + assert!( + ops_lines.iter().any(|l| l.contains("ops-body")) + && !ops_lines.iter().any(|l| l.contains("function loadOps(")), + "renderOps 提取错了地方(规则 1 会在空集上假绿):{}", + ops_lines.join("\n") + ); + assert_eq!( + ops_lines.iter().filter(|l| l.contains("function ")).count(), + 1, + "renderOps 提取长了(吞进了下一个函数):{}", + ops_lines.join("\n") + ); + let fills: Vec<&String> = ops_lines + .iter() + .filter(|l| l.contains(OPS_BALANCE_CAPTION)) + .collect(); + assert_eq!( + fills.len(), + 1, + "`{OPS_BALANCE_CAPTION}` 在 renderOps 里出现了 {} 次(单元格要么没了要么重复)", + fills.len() + ); + let value = cell_value_rhs(fills[0], OPS_BALANCE_CAPTION) + .unwrap_or_else(|| panic!("取不到余额单元格的取值表达式:{}", fills[0])); + assert!( + value.contains("D.fmt"), + "取值表达式取错了地方(规则 1 可能判在别的文本上):`{value}`" + ); + + // ── 规则 1:取值同时读到两个半区 ──────────────────────────────────────────────── + assert!( + value_reaches_all_fields(APP_JS, &ops, &value, &HALF_FIELDS), + "运营成员表的余额列没有同时读到 `balance` 与 `gift_balance` —— 列头(`{OPS_BALANCE_CAPTION}`)\ + 用的是全站给**可花额**起的那个名字,而这一列只印了永久额,比用户自己在侧栏/钱包里看到的\ + 余额少掉全部赠送额:取值 = `{value}`" + ); + + // ── 规则 2:两包的列头文案仍须落在可花族 ──────────────────────────────────────── + let zh = pack_region_strict(I18N_JS, ZH_PACK_START, EN_PACK_START); + let en = pack_region_strict(I18N_JS, EN_PACK_START, PACK_END); + for (pack, region) in [("zh", zh), ("en", en)] { + let spendable = pack_string(region, SPENDABLE_WORD_KEY) + .unwrap_or_else(|| panic!("{pack} 包里没有键 `{SPENDABLE_WORD_KEY}`")); + let permanent = pack_string(region, PERMANENT_WORD_KEY) + .unwrap_or_else(|| panic!("{pack} 包里没有键 `{PERMANENT_WORD_KEY}`")); + let (s_mark, p_mark) = half_markers(&spendable, &permanent).unwrap_or_else(|| { + panic!( + "{pack} 包的两个半区名推不出各自的标记(尺子坏了):\"{spendable}\" / \"{permanent}\"" + ) + }); + let caption = pack_string(region, OPS_BALANCE_CAPTION) + .unwrap_or_else(|| panic!("{pack} 包里没有键 `{OPS_BALANCE_CAPTION}`")); + assert!( + caption_names_spendable_half(&caption, &s_mark, &p_mark), + "{pack} 包的 `{OPS_BALANCE_CAPTION}`(\"{caption}\")不再落在**可花族**\ + (标记:可花 `{s_mark}` / 永久 `{p_mark}`)—— 把列头改名成永久族也能让两边对上,\ + 但这一列的头用的就是全站给可花额起的那个名字,改它就是改这一列声明的口径" + ); + } + + // ── 规则 3(反向):管理页的「永久点数」列仍只读永久额 ────────────────────────── + let admin = function_source(APP_JS, "renderAdmin").expect("找不到 renderAdmin"); + let admin_lines = code_text_by_line(&admin); + let perm: Vec<&String> = admin_lines + .iter() + .filter(|l| l.contains(ADMIN_PERM_CAPTION)) + .collect(); + assert_eq!( + perm.len(), + 1, + "`{ADMIN_PERM_CAPTION}` 在 renderAdmin 里出现了 {} 次(反向对照的位点不唯一)", + perm.len() + ); + let perm_value = cell_value_rhs(perm[0], ADMIN_PERM_CAPTION) + .unwrap_or_else(|| panic!("取不到「永久点数」列的取值表达式:{}", perm[0])); + assert!( + mentions_identifier(&perm_value, "balance"), + "管理页的「永久点数」列不再读永久额(反向对照失去意义):取值 = `{perm_value}`" + ); + assert!( + !mentions_identifier(&perm_value, "gift_balance"), + "管理页的「永久点数」列读到了赠送额 —— 那一列的名字说它只有永久额;\ + 同一张表本来就另有两列(赠送 / 可用)。这条挡的是「一键全改 available」式过度纠正:\ + 取值 = `{perm_value}`" + ); + } + + /// 规则 1/2/3 的三把尺子各有各的牙(合成输入,坑 68 与 #348)。 + #[test] + fn the_ops_balance_half_checkers_have_teeth() { + // 单元格取值提取器:真形状取得到、且**停在取值段**;没有该单元格 ⇒ None + let line = " '' + D.fmt(u.balance || 0) + \" \" + T(\"common.points\") + \"\" +"; + assert_eq!( + cell_value_rhs(line, OPS_BALANCE_CAPTION).as_deref(), + Some("D.fmt(u.balance || 0) + \" \" + T(\"common.points\")"), + "取值提取器取错了(规则 1 会判在别的文本上)" + ); + let admin_line = " '' + D.fmt(u.balance || 0) + \"\" +"; + assert_eq!( + cell_value_rhs(admin_line, ADMIN_PERM_CAPTION).as_deref(), + Some("D.fmt(u.balance || 0)"), + "同一个提取器在反向对照那一列上取错了" + ); + assert!( + cell_value_rhs(" \"\"", OPS_BALANCE_CAPTION).is_none(), + "没有该单元格时提取器凭空取到了值(规则 1 会在空集上假绿)" + ); + // 少了 `data-label` 的收尾标记(`'">'`)⇒ 取不到,而不是把后面的东西当取值 + assert!( + cell_value_rhs( + " '\"", + OPS_BALANCE_CAPTION + ) + .is_none(), + "少了收尾标记时提取器仍取到值(形状变了却静默取值)" + ); + + // 尺子:在**真语言包**上推出的标记 + let zh = pack_region_strict(I18N_JS, ZH_PACK_START, EN_PACK_START); + let en = pack_region_strict(I18N_JS, EN_PACK_START, PACK_END); + let (zs, zp) = half_markers( + &pack_string(zh, SPENDABLE_WORD_KEY).expect("zh 包缺 wallet.balance"), + &pack_string(zh, PERMANENT_WORD_KEY).expect("zh 包缺 wallet.forever"), + ) + .expect("zh 半区标记取不到"); + assert_eq!( + (zs.as_str(), zp.as_str()), + ("余额", "永久"), + "zh 半区标记取错了" + ); + let (es, ep) = half_markers( + &pack_string(en, SPENDABLE_WORD_KEY).expect("en 包缺 wallet.balance"), + &pack_string(en, PERMANENT_WORD_KEY).expect("en 包缺 wallet.forever"), + ) + .expect("en 半区标记取不到"); + assert_eq!( + (es.as_str(), ep.as_str()), + ("balance", "permanent"), + "en 半区标记取错了" + ); + // 两个名字完全相同 ⇒ 取不到标记(**不得**静默退化成空标记:空标记让 contains 恒真、规则 2 变哑) + assert!( + half_markers("points", "points").is_none(), + "两个名字相同时尺子给出了空标记(规则 2 会静默变哑)" + ); + + // 分类判别式:可花族、永久族、谁都不指(化妆)三态都要判对 + assert!(caption_names_spendable_half("余额(点数)", &zs, &zp)); + assert!(caption_names_spendable_half("Balance (pts)", &es, &ep)); + assert!( + !caption_names_spendable_half("永久点数(点数)", &zs, &zp), + "改名成永久族没有被拒(那一列口径被换了却判绿)" + ); + assert!( + !caption_names_spendable_half("Permanent points (pts)", &es, &ep), + "改名成永久族没有被拒(en 侧)" + ); + assert!( + !caption_names_spendable_half("点数", &zs, &zp), + "谁都不指的化妆文案没有被拒(它既不是可花族也不是永久族)" + ); + assert!( + !caption_names_spendable_half("Points", &es, &ep), + "谁都不指的化妆文案没有被拒(en 侧)" + ); + + // 规则 1 的牙:只读永久额 ⇒ 判红;只读赠送 ⇒ 判红;两项都读(含抽 helper 的等价写法)⇒ 判绿 + let bare = + " function renderOps() { const row = \"\" + D.fmt(u.balance || 0) + \"\"; }"; + assert!( + !value_reaches_all_fields(bare, bare, "D.fmt(u.balance || 0)", &HALF_FIELDS), + "规则 1 放行了只读永久额的取值(本缺陷的形状)" + ); + let gift_only = + " function renderOps() { const row = \"\" + D.fmt(u.gift_balance || 0) + \"\"; }"; + assert!( + !value_reaches_all_fields( + gift_only, + gift_only, + "D.fmt(u.gift_balance || 0)", + &HALF_FIELDS + ), + "规则 1 放行了只读赠送的取值(轴修反)" + ); + let both = " function renderOps() { const row = \"\" + D.fmt((u.balance || 0) + (u.gift_balance || 0)) + \"\"; }"; + assert!( + value_reaches_all_fields( + both, + both, + "D.fmt((u.balance || 0) + (u.gift_balance || 0))", + &HALF_FIELDS + ), + "规则 1 把两项都读到的取值判红了" + ); + // 说明性注释里提到 `gift_balance` 不算证据(#296 的镜像:被自己的解释满足) + let commented = " function renderOps() { const row = \"\" + D.fmt(u.balance || 0) + \"\"; // 另一半是 u.gift_balance\n }"; + assert!( + !value_reaches_all_fields(commented, commented, "D.fmt(u.balance || 0)", &HALF_FIELDS), + "规则 1 被注释里的 `gift_balance` 满足了(假绿)" + ); + } } diff --git a/ui/README.md b/ui/README.md index d3ade8f..51c996c 100644 --- a/ui/README.md +++ b/ui/README.md @@ -643,6 +643,14 @@ try { const w = await api.get("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/api/wallet"); if (w) D.USER.balance = w.balance; 「屏幕上数字对」—— 竞争修法在探针下全绿(`tmp/c2145_probe.js` 实测:修复树 9/9、改前树恰 `A2`/`A3` 两腿红、竞争者 8/9 被 `A3` 拒绝)。CI 里没有 JS 运行器,`cargo test` 是唯一能长期守住的关口。 +## 运营成员表的「余额」列 = 可花额 = 用户自己所见(C2173) + +- **一个词一个数**:全站把**不带限定的**「点数余额 / Points balance」绑给 `available = balance + gift_balance`(`common.balance` / `dash.balance` / `wallet.balance` 三处),把「**永久**点数 / Permanent points」绑给 `balance`(`wallet.forever` / `admin.emp.col.perm`)。运营成员表的列头用的正是**前者**(`ops.users.col.balance` zh「余额(点数)」/ en「Balance (pts)」),而取值只印 `u.balance` ⇒ 用户自己看 101、运营者看 100,差的正是「赠送」那一半(可花、会过期、真划走,`gift.rs` 清扫时真扣)。 +- **修法**:取值折进 `gift_balance`,**与兄弟表逐字同形** —— `admin.emp.col.avail` 那一格早就是 `D.fmt((u.balance || 0) + (u.gift_balance || 0))`(同一个 PR 家族里写对了的范本)。**零新 i18n 键**。 +- **CI 覆盖**:`src/state_gate.rs::the_ops_members_balance_cell_is_the_half_its_caption_names`(三规则:① 取值表达式按**标识符 token** 同时读到 `balance` 与 `gift_balance` ② 两包列头须落在**可花族**(标记由 `wallet.balance` 的包值派生)③ 反向:`admin.emp.col.perm` 仍只读 `balance`)。 +- ⚠️ **射程**:门禁是**词法**的 —— 它证明取值**读到**两个字段、名字落在可花族,**不证明算术是 `+`**;行为由仪器 `c2173_probe.js` 钉(19 腿;甲/乙两棵合法树全绿,三条「半个答案 / 化妆 / 自相矛盾」被拒)。 + + ## 载荷签名覆盖所有输入,控件变更只有一个重拉触发器(C2146) 交易列表/趋势的请求体由**两份**状态渲染:列筛选(`txTable.filters`)与时间段 diff --git a/ui/index.html b/ui/index.html index a7f2fd6..82b92ab 100644 --- a/ui/index.html +++ b/ui/index.html @@ -855,6 +855,6 @@

使用模型

- + diff --git a/ui/js/app.js b/ui/js/app.js index 00039d9..7f8d3ba 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -2909,7 +2909,7 @@ $("#ops-body").innerHTML = list.length ? list.map((u) => "" + hl(u.name, rawQ) + "" + "" + hl(u.email, rawQ) + "" + - '' + D.fmt(u.balance || 0) + " " + T("common.points") + "" + + '' + D.fmt((u.balance || 0) + (u.gift_balance || 0)) + " " + T("common.points") + "" + "" ).join("") : emptyRow(4, T("ops.users.empty"), T("ops.users.empty.sub")); pulseTbody($("#ops-body"));