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
54 changes: 54 additions & 0 deletions src/routes/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<serde_json::Value>(body).unwrap()["total"]
.as_i64()
.unwrap_or(i64::MIN)
};
// ① 尾随空格与不带空格等价(前端发出的正是 trim 后的值)
for uri in [
"/api/transactions?model=deepseek",
"/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(), "/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(), "/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(), "/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()`),
Expand Down
5 changes: 3 additions & 2 deletions ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 高度收紧。
Expand Down Expand Up @@ -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` → 临时 `<a download>` click → `remove()` → `setTimeout 1s` revoke;文件名 **`aitokenpool-transactions-YYYYMMDD.csv`**(`new Date()` 本地日期);
Expand Down
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=20260912-5"></script>
<script src="js/data.js?v=20260912-5"></script>
<script src="js/i18n.js?v=20260914-4"></script>
<script src="js/app.js?v=20260914-8"></script>
<script src="js/app.js?v=20260914-9"></script>
</body>
</html>
29 changes: 21 additions & 8 deletions ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" ? '<span class="pill pill-ok">' + T("tx.type.earn") + "</span>" : t.type === "consume" ? '<span class="pill pill-accent">' + T("tx.type.consume") + "</span>" : t.type === "gift" ? '<span class="pill pill-ok">' + T("tx.type.gift") + "</span>" : '<span class="pill pill-muted">' + esc(txType(t.type)) + "</span>" },
// 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 ⇒ 比较器恒「相等」,
Expand All @@ -1345,7 +1349,7 @@
{ key: "tokens", title: () => T("tx.col.tokens"), sort: "number", align: "num",
render: (t) => '<span' + t.tokenBrk(T("tx.col.tokens"), t.tokensRaw) + '>' + t.tokens + "</span>",
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);
Expand All @@ -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,
Expand Down Expand Up @@ -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")];
Expand Down Expand Up @@ -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") {
Expand Down
Loading