diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index e9cabb8..fd51019 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -1568,6 +1568,60 @@ mod tests { assert_eq!(v["total"], 0, "类型名不再是模型列的可筛值: {body}"); } + #[tokio::test] + async fn tx_text_column_filters_trim_the_value_before_matching() { + // C2114:交易表的列筛选**只有服务端这一个实现**(rant 2026-08-25T10:33:26 把列筛选 + // 从「本地过滤当前页」改为「后端全量过滤」)。前端 `txFilterParams` 发请求前会 `trim()` + // 每个值 —— 本测试把服务端这一侧的口径钉住,因为前端**不得**再用另一套语义本地筛一遍, + // 正是建立在这几条之上: + // ① 值先 trim:用户输入 `"deepseek "`(尾随空格)等价于 `"deepseek"` + // ② trim 后为空 ⇒ **不筛**(而不是「筛一个空串」⇒ 0 行) + // ③ 文本列走 SQL LIKE(`%v%`)⇒ `%` 是通配符,不是字面量 + let st = test_state("txcoltrim"); + let key = login(st.clone()).await; + { + let conn = st.db.lock().unwrap(); + for (model, ty) in [ + ("deepseek-v4-flash", "consume"), + ("qwen-max", "earn"), + ("recharge", "topup"), + ] { + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (1, '2', 1, ?1, 100, 1.0, ?2, '成功')", + rusqlite::params![model, ty], + ) + .unwrap(); + } + } + let total = |body: &str| -> i64 { + serde_json::from_str::(body).unwrap()["total"] + .as_i64() + .unwrap_or(i64::MIN) + }; + // ① 尾随空格与不带空格等价(前端发出的正是 trim 后的值) + for uri in [ + "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/api/transactions?model=deepseek", + "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/api/transactions?model=deepseek%20", + ] { + let (s, body) = get(st.clone(), uri, &key).await; + assert_eq!(s, axum::http::StatusCode::OK, "{uri}: {body}"); + assert_eq!(total(&body), 1, "{uri} 应命中 deepseek 行: {body}"); + } + // ② 纯空白值 = 未筛(筛的是「没有值」,不是空串) + let (s, body) = get(st.clone(), "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/api/transactions?model=%20", &key).await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + assert_eq!(total(&body), 3, "纯空白值应视为未筛: {body}"); + // ③ 文本列是 LIKE 而非子串比较 + let (s, body) = get(st.clone(), "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/api/transactions?model=%25", &key).await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + assert_eq!(total(&body), 3, "`%` 是 LIKE 通配符: {body}"); + // 阴性对照:真匹配不到的值 ⇒ 0 行(避免「一律不筛」也能通过上面三条) + let (s, body) = get(st.clone(), "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/api/transactions?model=zzz", &key).await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + assert_eq!(total(&body), 0, "无匹配值应为 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 f8aeed9..590193b 100644 --- a/ui/README.md +++ b/ui/README.md @@ -179,7 +179,7 @@ ui/ ## 交易汇总卡约定(v1.19 → v1.23 改版,rant 2026-09-11T16:23:43 第 7 节) - 交易记录页顶部为**汇总卡** `#tx-summary`(`.stat-grid` + 5 张 `.stat-card`,改用 PR3 通用组件层):**消费 / 收益 / 点数变化 / Token 合计 / 记录数**,正数 `var(--ok)`、负数 `var(--danger-text)`、零值中性; -- **口径不变**(数据契约未动):汇总值来自后端 `summary` 全量 SQL 聚合(`filterRows(list, TX_COLUMNS, txTable.filters)` 为无 summary 时的兜底),**不受分页影响**;「记录数」取后端 `total`(真分页下即当前筛选条件的全量条数); +- **口径不变**(数据契约未动):汇总值来自后端 `summary` 全量 SQL 聚合(无 summary 时兜底用 `filterRows(list, TX_COLUMNS, txTable.filters)`),**不受分页影响**;「记录数」取后端 `total`(真分页下即当前筛选条件的全量条数); - 工具栏右侧 `#tx-count` 同步显示 `tx.pager.count`(共 N 条),与汇总卡的记录数同源; - 图表:`#tx-trend` 改用原型 `.trend` 双色柱状(消费 / 收益两柱 + `.legend`),数据源仍是 `/api/transactions/trend`;**x 轴按请求窗口补零,保证左→右时间递增且柱距恒定**(后端 GROUP BY 只返回有交易的桶,缺行会导致柱子左移——原型同款 bug 的根因); - 窄屏(≤560px)`.stat-grid` 两列、`.trend` 高度收紧。 @@ -267,7 +267,8 @@ ui/ ## 交易记录导出 CSV 约定(v1.20,rant 2026-08-17T20:46:57 E) - 入口:交易页 page-head 右上 **`#tx-export-btn`「导出 CSV」**(`.btn.btn-secondary.btn-sm`),`bindEvents` 绑 `exportTxCsv`; -- 数据范围:**当前筛选可见行** = tab(全部/消费/收益)→ `filterRows(list, TX_COLUMNS, txTable.filters)`(与表格、汇总条同一数据源);无数据 → toast info 不导出; +- 数据范围:**当前筛选可见行** = 服务端按 tab + 列筛选返回的行(**列筛选只有服务端一个实现**:`TX_COLUMNS` 每个带 `filter` 的列都声明 `serverFilter: true`,见 C2114);导出与表格共用 `filterRows(list, TX_COLUMNS, txTable.filters)`,而该调用对已声明 `serverFilter` 的列**不生效** ⇒ 导出的行 = 表格显示的行;无数据 → toast info 不导出; + ⚠️ 不要在客户端再筛一遍:请求侧 `txFilterParams` 会 `trim()`、服务端用 SQL `LIKE`,与本地子串比较的语义不同,重复筛选会把服务端认可的行删掉(表格空、计数与汇总卡却仍有数 —— 见 C2114); - **各列与表格单元格同口径**(C2054 点数 / C2111 时间):时间列走渲染单元格的同一个 `fmtPrecise(t.time)`(本地精确时间),**不**直接写视图行的 `t.time`(库内 UTC 串,`txsToView` 不转换)——否则同一行在表里是 `23:04`、在导出文件里却是 `15:04`(东八区;西半球反向)。`#139`(rant 2026-08-24T12:38:44)把单元格改成当地时间展示时,漏了导出这个消费者; - 格式:**UTF-8 BOM**(`"\uFEFF"` 前缀)+ `\r\n` 换行 + 表头 `时间,类型,模型 / Key,Token 用量,点数,状态`;类型用 `TX_TYPE` 中文映射;点数正负号原值;字段含 `,`/`"`/换行按 RFC4180 双引号转义(`cell()` 助手); - 下载:`Blob(type="text/csv;charset=utf-8")` → `URL.createObjectURL` → 临时 `` click → `remove()` → `setTimeout 1s` revoke;文件名 **`aitokenpool-transactions-YYYYMMDD.csv`**(`new Date()` 本地日期); diff --git a/ui/index.html b/ui/index.html index 7cc706b..3f98d7b 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 6657066..2442aa0 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -1315,20 +1315,24 @@ const txType = (k) => (TX_TYPE[k] ? TX_TYPE[k]() : k); const txStatus = (s) => s === "成功" ? T("tx.status.success") : s === "处理中" ? T("tx.status.pending") : s === "入账" ? T("tx.status.credited") : s; + // 交易表的列筛选**只有服务端一个实现**(rant 2026-08-25T10:33:26):每个带 `filter` 的列都声明 + // `serverFilter: true`,含义是「这列的筛选值随请求发给服务端(`txFilterParams`)」⇒ 客户端 + // 不得再用 `filterRows` 本地筛一遍(同一条规则的第二份实现,归一化与语义都不同,见 `filterRows`)。 + // 新增带 `filter` 的列时**必须**一并声明它 —— 少了它,本地就会多筛一层。 const TX_COLUMNS = [ // rant 2026-08-23T16:01:07:列表内移除 time 列筛选(外部 tx-range 已有时间段筛选,两套并存冗余) { key: "time", title: () => T("tx.col.time"), sort: "string", render: (t) => timeCell(t.time, true) }, - { key: "type", title: () => T("tx.col.type"), sort: "string", filter: "select", + { key: "type", title: () => T("tx.col.type"), sort: "string", filter: "select", serverFilter: true, // C2031:选项「值」与「文案」分离 —— value 恒为数据库值(语言无关),label 才随语言变。 // 这样筛选状态存的是 DB 值,切换语言后 filterVal 仍能匹配(此前状态存本地化文案 ⇒ 切语言即失配,表格变空) options: () => ["consume", "earn", "topup", "withdraw", "gift", "expire"].map((k) => ({ value: k, label: txType(k) })), filterVal: (t) => t.type, render: (t) => t.type === "earn" ? '' + T("tx.type.earn") + "" : t.type === "consume" ? '' + T("tx.type.consume") + "" : t.type === "gift" ? '' + T("tx.type.gift") + "" : '' + esc(txType(t.type)) + "" }, // rant 2026-08-22T17:21:39 需求 2:新增「用户」列(transactions.user_id JOIN users 取用户名) - { key: "user", title: () => T("tx.col.user"), sort: "string", filter: "text" }, - { key: "model", title: () => T("tx.col.model"), sort: "string", filter: "text" }, + { key: "user", title: () => T("tx.col.user"), sort: "string", filter: "text", serverFilter: true }, + { key: "model", title: () => T("tx.col.model"), sort: "string", filter: "text", serverFilter: true }, // rant 2026-08-22T17:21:39 需求 2:Key 列改为显示分发 key 的 name(api_keys.name),而非上游 keys 的 provider/plan - { key: "key", title: () => T("tx.col.apiKeyName"), sort: "string", filter: "text" }, + { key: "key", title: () => T("tx.col.apiKeyName"), sort: "string", filter: "text", serverFilter: true }, // C2102:四列 Token 的排序键不能在行对象上找 —— 行里存的是 K/M **显示串**(`tokens`/`inputTokens`…) // 与精确值两套字段(`tokensRaw`/`inputRaw`…),列 key 与行字段名不同源 ⇒ 默认 `row[key]` 取到 // undefined(input/cached/output)或显示串(tokens),`Number()` 得 NaN ⇒ 比较器恒「相等」, @@ -1345,7 +1349,7 @@ { key: "tokens", title: () => T("tx.col.tokens"), sort: "number", align: "num", render: (t) => '' + t.tokens + "", sortVal: (t) => t.tokensRaw }, - { key: "pts", title: () => T("tx.col.pts"), sort: "number", filter: "number-range", align: "num", + { key: "pts", title: () => T("tx.col.pts"), sort: "number", filter: "number-range", align: "num", serverFilter: true, // C2047:方向取自 type(signedPts),不能按 pts 的符号判 —— 所有 writer 都存正数 ⇒ 消费会显示成绿色的 +N render: (t) => { const v = signedPts(t.type, t.pts); @@ -1356,7 +1360,7 @@ // 按 3 到 4 反而命中那条「-3.7」的行。 filterVal: (t) => signedPts(t.type, t.pts), sortVal: (t) => signedPts(t.type, t.pts) }, - { key: "status", title: () => T("tx.col.status"), sort: "string", filter: "select", + { key: "status", title: () => T("tx.col.status"), sort: "string", filter: "select", serverFilter: true, // C2031:同 type —— value 是库内值(成功/入账/处理中,语言无关),label 随语言变 options: () => ["成功", "入账", "处理中"].map((s) => ({ value: s, label: txStatus(s) })), filterVal: (t) => t.status, @@ -1668,7 +1672,9 @@ // 零 mock(rant 15:54:06):登录态用后端数据,失败直接提示 if (loggedIn() && !Live.transactions) { toast(T("tx.export.none"), "info"); return; } let list = Live.transactions ? txsToView(Live.transactions.items || []) : []; - list = filterRows(list, TX_COLUMNS, txTable.filters); // 与表格可见行一致(含列筛选) + // 与表格可见行一致:列筛选由**服务端**施加(列上声明了 `serverFilter`,见 TX_COLUMNS), + // 故这里不需要也不得本地再筛一遍 —— 服务端返回的行就是表格显示的行(C2114)。 + list = filterRows(list, TX_COLUMNS, txTable.filters); if (!list.length) { toast(T("tx.export.none"), "info"); return; } const cell = (v) => { const s = String(v == null ? "" : v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const headers = [T("tx.col.time"), T("tx.col.type"), T("tx.col.user"), T("tx.col.model"), T("tx.col.apiKeyName"), T("tx.col.input"), T("tx.col.cached"), T("tx.col.output"), T("tx.col.tokens"), T("tx.col.pts"), T("tx.col.status")]; @@ -1750,13 +1756,20 @@ state: { sort: [{key,dir}], filters: {key:val}, page, pageSize }(原地更新,跨页保留) */ // 按列筛选条件过滤行(buildDataTable 与交易汇总条共用,保证汇总与表格可见行一致) + // + // ⚠️ 只筛**客户端自己筛**的列。交易表的列筛选由**服务端**施加(rant 2026-08-25T10:33:26:列筛选 + // 从「本地过滤当前页」改为「后端全量过滤」),那些列声明 `serverFilter: true` ⇒ 这里跳过。 + // 理由:服务端已按**同一组**筛选返回了行,本地再筛一遍等于同一规则的**第二份实现**,而两边的 + // 归一化与匹配语义并不相同 —— 请求侧 `txFilterParams` 先 `trim()`、服务端用 SQL `LIKE`,本函数 + // 既不去空白也不认通配符 ⇒ 用户输入 `"deepseek "`(尾随空格)时服务端命中并返回该行,本地却把它 + // 删掉:表格空,而「共 N 条」与汇总卡仍按 N 显示(C2114)。消除副本,而不是让副本跟上。 function filterRows(rows, columns, filters) { return rows.filter((row) => { for (const key of Object.keys(filters)) { const fv = filters[key]; if (fv == null || fv === "") continue; const col = columns.find((c) => c.key === key); - if (!col || !col.filter) continue; + if (!col || !col.filter || col.serverFilter) continue; const v = col.filterVal ? col.filterVal(row) : row[key]; if (col.filter === "select") { if (String(v) !== String(fv)) return false; } else if (col.filter === "number-range") {