From 7faad52ca4b680b6fee1616aa719c28e9954a171 Mon Sep 17 00:00:00 2001 From: argszero Date: Mon, 14 Sep 2026 08:45:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(wallet,ui):=20show=20only=20filterable=20te?= =?UTF-8?q?xt=20in=20the=20=E6=A8=A1=E5=9E=8B/Key=20transaction=20cells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transactions table filters its 模型 and Key columns server-side: `tx_where` builds `model LIKE ?` and `COALESCE(...) LIKE ?` from the text the user types into the column's `.th-filter` input. Both cells, however, invented a fallback for rows that have no stored value: `txsToView` rendered `t.model || txType(t.type)` and `t.key_name || t.key_label || (consume||earn ? "—" : txType(t.type))` — a LOCALIZED type label (赠送 / 过期). The server has no language pack (there is no Rust-side label table at all), so that string can never match the SQL, and a user who filters by the text they just saw on screen gets 0 rows. Rows that do have a stored value were already fine, which is what makes the column look usable: it is a filter that lies, not a missing feature (the 类型 column and the top tabs cover type filtering correctly). The invariant is "every piece of text a cell can display must be matched by that column's filter". Since the offending fallback is localized, the fix belongs on the display side: no-value rows now show the language-neutral placeholder `—`, which the sibling 用户 column (`t.user_name || "—"`) and the Key column's own consume/earn branch already used. Both sides then share one definition: front `t.model || "—"` `t.key_name || t.key_label || "—"` SQL `COALESCE(NULLIF(model, ''), '—')` `COALESCE(NULLIF(ak.name,''), NULLIF(, ''), '—')` The SQL needs the `NULLIF` layer-by-layer because JS `||` treats the empty string as missing while `COALESCE` only falls through on NULL — the same asymmetry C2101 fixed for an empty `api_keys.name`. The exported CSV needs no change: it prints `t.model` / `t.key` from the same view row, so it follows the cell (the same-口径 rule established for 点数 in C2054 and for 时间 in C2111). Tests: `cargo test` 235 -> 238 (three new tests: the placeholder must be findable through both column filters, the real-model-name path must keep working, and a negative control asserting the localized type label is NOT a filterable value — the guard for the rejected alternative of hardcoding Chinese labels into SQL). Verified with a jsdom probe (real ui/index.html + four real scripts, only `fetch` and the download stubbed) that reads the rendered cells, the wire parameter and the exported CSV bytes. A/B across both instruments, baseline pinned to 2be0d54 via `git show` (the driver aborts if the baseline equals the working tree) — red sets: v0_orig pre-change both sides B1 B2 B3 B4 R_main m_nosql display fixed, filters reverted R_main m_nodisp filters fixed, display kept B1 B2 B3 B4 m_label display kept + zh labels in the SQL B1 B2 B3 B4 R_main R_neg live the working tree none (14/14) m_nosql and m_nodisp are disjoint, so each half is pinned by its own instrument; the built-in control A0 and all five non-axis controls are green in every leg. Recorded, not claimed away: m_label's red set contains v0_orig's (the competing design fails the same front-end checks plus the negative control), and it is rejected on the argument that a localized label cannot live in SQL. --- src/routes/wallet.rs | 153 +++++++++++++++++++++++++++++++++++++++++-- ui/README.md | 8 +++ ui/index.html | 2 +- ui/js/app.js | 17 +++-- 4 files changed, 169 insertions(+), 11 deletions(-) diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index ffda84f..e9cabb8 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -132,11 +132,11 @@ pub async fn wallet( /// 值为字符串以宽容空值/非法输入(解析失败按未筛处理)。 #[derive(Debug, Default, Deserialize)] pub struct TxColFilters { - /// 模型名 LIKE(%v%) + /// 模型名 LIKE(%v%;无模型的行按显示占位符 `—` 匹配,见 `tx_where`) pub model: Option, /// 用户名 LIKE(JOIN users u) pub user_name: Option, - /// Key 名 LIKE(JOIN api_keys ak;历史行兜底 key_label 表达式) + /// Key 名 LIKE(JOIN api_keys ak;历史行兜底 key_label 表达式,无 key 行按占位符 `—`) pub key_name: Option, /// 状态精确匹配(库内中文值:成功/入账/处理中) pub status: Option, @@ -210,7 +210,15 @@ fn tx_where( .map(|s| format!("%{s}%")) }; if let Some(s) = like(&f.model) { - conds.push(format!("{} LIKE ?{}", col("model"), binds.len() + 1)); + // C2113:**筛选口径必须等于显示口径**。前端「模型」列无值的行(gift/expire 等)显示占位符 + // `—`(`ui/js/app.js` `txsToView`:`t.model || "—"`),故筛选表达式也只能搜同一个文案 + // (`NULLIF` 把库内空串(`model TEXT NOT NULL DEFAULT ''`)归零,与 JS `||` 同义)。 + // 此前该列无值行显示的本地化类型名(「赠送」)在服务端不存在对应文案 —— 按它筛选永远 0 行。 + conds.push(format!( + "COALESCE(NULLIF({}, ''), '—') LIKE ?{}", + col("model"), + binds.len() + 1 + )); binds.push(rusqlite::types::Value::Text(s)); } if let Some(s) = like(&f.user_name) { @@ -224,10 +232,15 @@ fn tx_where( // 且登录自动建的分发 key 正是空名(dao::get_or_create_api_key 写 '')—— // 而 `COALESCE` 只对 NULL 回退 ⇒ 表格显示兜底文案、按该文案筛选却 0 行。 // 先 `NULLIF(...,'')` 归零再 COALESCE,与前端 `t.key_name || t.key_label`(JS `||` 视空串为缺失)同口径。 + // C2113:尾部的 `NULLIF(…, '')` + `'—'` 是 Key 列显示的**最后两层兜底** —— + // 前端单元格是 `t.key_name || t.key_label || "—"`:`key_label` 本身可能为空串 + // (key 存在但 note/plan/provider 全空),无 key 的历史行(如 gift/expire)则连 + // `key_label` 都是 NULL ⇒ 单元格显示 `—`。JS `||` 逐层把空串当缺失,SQL 侧必须逐层 + // `NULLIF` 才能对齐,否则显示 `—` 而按 `—` 筛选 0 行。 conds.push(format!( - "COALESCE(NULLIF(ak.name, ''), CASE WHEN k.note <> '' THEN k.note \ + "COALESCE(NULLIF(ak.name, ''), NULLIF(CASE WHEN k.note <> '' THEN k.note \ WHEN k.plan <> '' THEN k.provider || ' / ' || k.plan \ - ELSE k.provider END) LIKE ?{}", + ELSE k.provider END, ''), '—') LIKE ?{}", binds.len() + 1 )); binds.push(rusqlite::types::Value::Text(s)); @@ -1425,6 +1438,136 @@ mod tests { assert_eq!(v["total"], 0, "改名后旧兜底文案不再命中: {body}"); } + /// C2113:模型列 / Key 列 —— **单元格里出现的每一段文字都必须能被该列筛选命中**。 + /// + /// 两列的筛选都走服务端(`tx_where` 的 `model` / `key_name` LIKE),而前端的无值兜底曾是 + /// **本地化类型名**(`ui/js/app.js` `txsToView` 旧版 `t.model || txType(t.type)`)—— 服务端没有 + /// 语言包,那个文案永远匹配不到 ⇒ 用户按屏幕上刚看到的文案筛选得 0 行;客户端 `filterRows` + /// 与「类型」列本就有正确的控件 ⇒ 丢的不是功能,是**可筛选的假象**。 + /// 规格:无值行显示语言中性的占位符 `—`(与 `user` 列同款),服务端搜同一个文案。 + /// + /// 夹具与「显示文案」助手被下面三个测试共用 —— 拆成三个是为了让 A/B 能读出互不遮蔽的红集合。 + fn c2113_rows(st: &AppState) { + let conn = st.db.lock().unwrap(); + // 与 gift.rs 的真实写入一致:当日赠送行既无模型也无 key(gift.rs `model` 传 '') + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (1, '', NULL, '', 0, 2.0, 'gift', '成功')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (1, '2', 1, 'deepseek-v4-flash', 100, 10.0, 'consume', '成功')", + [], + ) + .unwrap(); + } + + /// 前端「模型」列显示的文案(`ui/js/app.js` `txsToView`:`t.model || "—"`)。 + /// 由**规则**派生,不照抄任何一处输出。 + fn displayed_model(row: &serde_json::Value) -> String { + let m = row["model"].as_str().unwrap_or(""); + if m.is_empty() { + "—".into() + } else { + m.into() + } + } + + /// 前端「Key」列显示的文案(`txsToView`:`t.key_name || t.key_label || "—"`;JS `||` 视空串为缺失)。 + fn displayed_key(row: &serde_json::Value) -> String { + for k in ["key_name", "key_label"] { + let v = row[k].as_str().unwrap_or(""); + if !v.is_empty() { + return v.into(); + } + } + "—".into() + } + + async fn tx_row_by_type(st: &AppState, key: &str, ty: &str) -> serde_json::Value { + let (s, body) = get(st.clone(), "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/api/transactions", key).await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + serde_json::from_str::(&body).unwrap()["items"] + .as_array() + .unwrap() + .iter() + .find(|r| r["type"].as_str() == Some(ty)) + .unwrap_or_else(|| panic!("no {ty} row in {body}")) + .clone() + } + + #[tokio::test] + async fn tx_model_and_key_filters_match_the_displayed_placeholder() { + let st = test_state("txfall"); + let key = login(st.clone()).await; + c2113_rows(&st); + let gift = tx_row_by_type(&st, &key, "gift").await; + assert_eq!(gift["model"].as_str(), Some(""), "前提:赠送行库内无模型"); + assert!(gift["key_label"].is_null(), "前提:赠送行无 key"); + // 1) 显示的文案 = 占位符 + assert_eq!(displayed_model(&gift), "—", "模型列无值行显示占位符"); + assert_eq!(displayed_key(&gift), "—", "Key 列无值行显示占位符"); + // 2) 缺陷断言:按**表格里显示的文案**筛选必须命中该行(修前模型列为 0 行) + for (col, shown) in [ + ("model", displayed_model(&gift)), + ("key_name", displayed_key(&gift)), + ] { + let (s, body) = get( + st.clone(), + &format!("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/api/transactions?{col}={}", pct(&shown)), + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["total"], 1, "按 {col} 列显示的文案筛选命中该行: {body}"); + assert_eq!( + v["items"][0]["type"].as_str(), + Some("gift"), + "命中正是那一行: {body}" + ); + } + } + + #[tokio::test] + async fn tx_model_filter_still_matches_real_model_names() { + // 阳性对照:修复没弄坏正常路径 —— 有模型名的行照旧按模型名命中。 + let st = test_state("txfallpos"); + let key = login(st.clone()).await; + c2113_rows(&st); + let (s, body) = get( + st.clone(), + "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/api/transactions?model=deepseek-v4-flash", + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["total"], 1, "按真实模型名命中消费行: {body}"); + assert_eq!(v["items"][0]["type"].as_str(), Some("consume")); + } + + #[tokio::test] + async fn tx_model_filter_does_not_take_localized_type_labels() { + // 阴性对照:本地化类型名(zh 包 `tx.type.gift` = 「赠送」)**不属于该列口径** ⇒ 0 行。 + // 这条是给「反向修法」准备的:服务端没有语言包,若有人把类型名硬编码进 SQL 去匹配, + // 它就亮了 —— 正确解只有一处:让单元格显示服务端搜得到的文案(`—`)。 + let st = test_state("txfallneg"); + let key = login(st.clone()).await; + c2113_rows(&st); + let (s, body) = get( + st.clone(), + &format!("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/api/transactions?model={}", pct("赠送")), + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["total"], 0, "类型名不再是模型列的可筛值: {body}"); + } + #[tokio::test] async fn pts_range_filter_matches_the_rendered_signed_value() { // C2054:交易页「点数」列**渲染**的是有符号值(income 正 / expense 负,`signedPts()`), diff --git a/ui/README.md b/ui/README.md index 2f109b3..f8aeed9 100644 --- a/ui/README.md +++ b/ui/README.md @@ -357,3 +357,11 @@ ui/ - **删掉的第二份状态**:`txTab`。此前顶部 tab 另存一份 `txTab`,而请求按 `filters.type || txTab` 取值 ⇒ 列筛选一旦出值,tab 的写入就被永久盖住:点 tab 只是挪高亮、列表不变(**死控件**),高亮却仍按 `txTab` 画(**说谎的指示器**)。jsdom 实测(改前 5/11):列筛选选「赠送」后,请求 `type=gift` 而「全部」仍高亮;点「消费」后请求仍是 `gift`、`select` 仍显示 `gift` 而高亮已跳到「消费」——三者各说各话。 - **高亮口径**:生效值不属于 all/consume/earn 时(topup / withdraw / gift / expire)**没有任何 tab 自称生效**(都不高亮),因为把「全部」点亮而列表只有赠送行同样是说谎。改版后不变量:**请求参数 / tab 高亮 / 列筛选 select 三者恒为同一份状态的投影**。 - **冒烟测试注意**:点 tab 后状态已变,重拉由 `renderTransactions` 的筛选签名比对触发 —— **不要再显式 `loadTransactions()`**(会与它并发两次请求,后到者可能把先到者的行覆盖回去)。断言请比对「最近一次 `/api/transactions` 请求的 `type`」与「高亮的 tab」与「`select` 的 value」,三者用**同一个派生**(`wantTabs(type)`),不要写死字面量 —— 字面量会在应用的高亮恰好拼对时误绿。 + +## 交易表「模型」/「Key」列:单元格文案 = 筛选口径(C2113) + +- **两列都是服务端筛选**:`txFilterParams()` 把「模型」列的文字发成 `model`、「Key」列发成 `key_name`,服务端 `tx_where` 用 LIKE 匹配库内值(`transactions.model` / `api_keys.name` → `key_label` 表达式)。**客户端 `filterRows` 只用于导出 CSV**(过滤当前页),表格本身的行由服务端全量过滤 + 分页。 +- **不变量**:**单元格里出现的每一段文字,都必须能被该列筛选命中**。因此无值行只能用**语言中性的占位符 `—`**(`user` 列与 Key 列消费分支早已如此),**不能**填本地化类型名 —— 服务端没有语言包,`txType(t.type)`(「赠送」「过期」)在 SQL 里永远匹配不到,按它筛选得 0 行(C2113 修前即此:文案看似可筛选,实际是**说谎的漏斗**)。 +- **两侧逐字对应**:`txsToView` 的 `t.model || "—"` / `t.key_name || t.key_label || "—"` ↔ `tx_where` 的 `COALESCE(NULLIF(…, ''), '—')`(Key 列是 `COALESCE(NULLIF(ak.name,''), NULLIF(,''), '—')`,逐层 `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 测试覆盖。两侧的期望值都要**从同一条规则推出**(「库内值,空则 `—`」),不要照抄另一侧的输出 —— 照抄会让两边一起错。 diff --git a/ui/index.html b/ui/index.html index d293f65..7cc706b 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 14f75fa..6657066 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -3209,11 +3209,18 @@ const exact = typeof v === "number" && v > 0 ? fmtTokensExact(v) : "0"; return ' title="' + esc(label + ": " + exact) + '"'; }; - // 模型列:consume/earn 显示模型名;无模型(topup/gift 等)显示交易类型说明 - // Key 列(rant 2026-08-22T17:21:39 需求 2):优先分发 key 的 name(api_keys.name); - // 历史行无 api_key_id → 兜底 key_label(note/provider/plan),再兜底交易类型说明 - const model = t.model || txType(t.type); - const key = t.key_name || t.key_label || (t.type === "consume" || t.type === "earn" ? "—" : txType(t.type)); + // 模型列 / Key 列(rant 2026-08-22T17:21:39 需求 2):优先库内值 —— + // 模型列取 `t.model`;Key 列优先分发 key 的 name(api_keys.name),历史行无 api_key_id + // → 兜底 key_label(note/provider/plan)。 + // C2113:**无值时只能用服务端同样搜得到的占位符**。两列的筛选都走后端(`tx_where` 的 + // `model` / `key_name` LIKE),故单元格里出现的每一段文字都必须是该列筛选能命中的值; + // 此前无值行显示的是**本地化类型名**(`txType(t.type)`,如「赠送」)—— 服务端拿不到语言包, + // 那个文案永远匹配不到 ⇒ 按屏幕上刚看到的文案筛选 0 行(客户端与「类型」列都有正确的控件, + // 所以这不是丢功能,是**说谎的漏斗**:文案看着可筛选、实际 0 行)。占位符 `—` 与 + // `user` 列(`t.user_name || "—"`)及 Key 列消费分支同款:**无值、且语言中性**, + // 服务端筛选表达式尾部的 `'—'` 与这里逐字对应(见 `src/routes/wallet.rs` `tx_where`)。 + const model = t.model || "—"; + const key = t.key_name || t.key_label || "—"; return { id: t.id, time: (t.time || "").replace("T", " ").slice(0, 16),