diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index 9feab17..4f079ef 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -47,6 +47,26 @@ pub async fn wallet( }))) } +/// 交易列筛选(rant 2026-08-25T10:33:26:列筛选从本地当前页改为后端全量过滤)。 +/// 与前端 TX_COLUMNS 各列 filter 对应:文本列(model/user_name/key_name)LIKE 匹配、 +/// select 列(status)精确匹配、number-range(pts)区间匹配;与 type/start/end 叠加。 +/// 值为字符串以宽容空值/非法输入(解析失败按未筛处理)。 +#[derive(Debug, Default, Deserialize)] +pub struct TxColFilters { + /// 模型名 LIKE(%v%) + pub model: Option, + /// 用户名 LIKE(JOIN users u) + pub user_name: Option, + /// Key 名 LIKE(JOIN api_keys ak;历史行兜底 key_label 表达式) + pub key_name: Option, + /// 状态精确匹配(库内中文值:成功/入账/处理中) + pub status: Option, + /// 点数下限(>=) + pub pts_min: Option, + /// 点数上限(<=) + pub pts_max: Option, +} + /// GET /api/transactions 查询参数 #[derive(Debug, Deserialize)] pub struct TxQuery { @@ -61,17 +81,22 @@ pub struct TxQuery { pub start: Option, /// 结束时间(ISO 8601,UTC,SQLite 可解析),time < end;缺省不限 pub end: Option, + /// 列筛选(model/user_name/key_name/status/pts_min/pts_max) + #[serde(flatten)] + pub filters: TxColFilters, } /// 构建 transactions 查询条件与绑定参数。 -/// 参数顺序固定:user_id → type → start → end(存在的才加入,占位符序号递增)。 -/// `prefix` 非空时列名加前缀(如 "t"),供带 JOIN 的列表查询使用。 +/// 参数顺序固定:user_id → type → start → end → 列筛选(存在的才加入,占位符序号递增)。 +/// 列筛选引用 JOIN 表列(u.name / ak.name / k.*)——调用方须带对应 LEFT JOIN +/// (列表/summary/trend 统一带 keys/users/api_keys 三 JOIN,LEFT JOIN 主键 1:1 不放大行数)。 fn tx_where( prefix: &str, user_id: i64, type_filter: &Option, start: &Option, end: &Option, + f: &TxColFilters, ) -> (String, Vec) { let col = |c: &str| { if prefix.is_empty() { @@ -98,9 +123,57 @@ fn tx_where( } } } + // 列筛选:文本列 LIKE、status 精确、pts 区间 + let like = |v: &Option| -> Option { + v.as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| format!("%{s}%")) + }; + if let Some(s) = like(&f.model) { + conds.push(format!("{} LIKE ?{}", col("model"), binds.len() + 1)); + binds.push(rusqlite::types::Value::Text(s)); + } + if let Some(s) = like(&f.user_name) { + conds.push(format!("u.name LIKE ?{}", binds.len() + 1)); + binds.push(rusqlite::types::Value::Text(s)); + } + if let Some(s) = like(&f.key_name) { + // 与前端 Key 列显示口径一致:key_name 优先,历史行兜底 key_label(note/provider/plan) + conds.push(format!( + "COALESCE(ak.name, CASE WHEN k.note <> '' THEN k.note \ + WHEN k.plan <> '' THEN k.provider || ' / ' || k.plan \ + ELSE k.provider END) LIKE ?{}", + binds.len() + 1 + )); + binds.push(rusqlite::types::Value::Text(s)); + } + if let Some(s) = f + .status + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + conds.push(format!("{} = ?{}", col("status"), binds.len() + 1)); + binds.push(rusqlite::types::Value::Text(s.to_string())); + } + for (v, op) in [(f.pts_min.as_deref(), ">="), (f.pts_max.as_deref(), "<=")] { + if let Some(n) = v.and_then(|s| s.trim().parse::().ok()) { + conds.push(format!("{} {op} ?{}", col("pts"), binds.len() + 1)); + binds.push(rusqlite::types::Value::Real(n)); + } + } (conds.join(" AND "), binds) } +/// transactions 三 JOIN(keys/users/api_keys)片段:列表/summary/trend 共用, +/// 使列筛选中的 user_name/key_name 可引用 JOIN 表列。 +fn tx_joins() -> &'static str { + "LEFT JOIN keys k ON k.id = t.key_id \ + LEFT JOIN users u ON u.id = t.user_id \ + LEFT JOIN api_keys ak ON ak.id = t.api_key_id" +} + fn default_page() -> u32 { 1 } @@ -118,12 +191,13 @@ pub async fn transactions( let page_size = q.page_size.clamp(1, 100); let type_filter = match q.r#type.as_str() { "" | "all" => None, - t @ ("consume" | "earn" | "topup" | "gift") => Some(t.to_string()), + // 列筛选 select 含 withdraw(rant 2026-08-25T10:33.26:列筛选后端化后 UI 选项须全被 API 接受) + t @ ("consume" | "earn" | "topup" | "gift" | "withdraw") => Some(t.to_string()), _ => { return Err(( axum::http::StatusCode::BAD_REQUEST, Json( - serde_json::json!({ "error": "type 必须为 consume / earn / topup / gift / all" }), + serde_json::json!({ "error": "type 必须为 consume / earn / topup / gift / withdraw / all" }), ), )) } @@ -148,18 +222,20 @@ pub async fn transactions( let start = norm(&q.start)?; let end = norm(&q.end)?; // 汇总(rant 2026-08-22T00:04:21/00:07:08):全量 SQL 聚合(不依赖分页), - // 按当前 type + 时间段筛选;口径 = income 白名单(earn/topup/gift)为正、consume 为负。 - let (where_sql, where_binds) = tx_where("", auth.user_id, &type_filter, &start, &end); + // 按当前 type + 时间段 + 列筛选;口径 = income 白名单(earn/topup/gift)为正、consume 为负。 + let (where_sql, where_binds) = + tx_where("t", auth.user_id, &type_filter, &start, &end, &q.filters); let summary_sql = format!( "SELECT \ - COALESCE(SUM(CASE WHEN type IN ('earn','topup','gift') THEN pts ELSE 0 END), 0), \ - COALESCE(SUM(CASE WHEN type = 'consume' THEN pts ELSE 0 END), 0), \ - COALESCE(SUM(CASE WHEN type IN ('earn','topup','gift') THEN pts ELSE -pts END), 0), \ - COALESCE(SUM(tokens), 0), \ - COALESCE(SUM(tokens - cached_tokens - output_tokens), 0), \ - COALESCE(SUM(cached_tokens), 0), \ - COALESCE(SUM(output_tokens), 0) \ - FROM transactions WHERE {where_sql}" + COALESCE(SUM(CASE WHEN t.type IN ('earn','topup','gift') THEN t.pts ELSE 0 END), 0), \ + COALESCE(SUM(CASE WHEN t.type = 'consume' THEN t.pts ELSE 0 END), 0), \ + COALESCE(SUM(CASE WHEN t.type IN ('earn','topup','gift') THEN t.pts ELSE -t.pts END), 0), \ + COALESCE(SUM(t.tokens), 0), \ + COALESCE(SUM(t.tokens - t.cached_tokens - t.output_tokens), 0), \ + COALESCE(SUM(t.cached_tokens), 0), \ + COALESCE(SUM(t.output_tokens), 0) \ + FROM transactions t {} WHERE {where_sql}", + tx_joins() ); let summary: serde_json::Value = conn .query_row(&summary_sql, params_from_iter(where_binds.iter()), |r| { @@ -176,7 +252,10 @@ pub async fn transactions( .map_err(internal)?; let total: i64 = conn .query_row( - &format!("SELECT COUNT(*) FROM transactions WHERE {where_sql}"), + &format!( + "SELECT COUNT(*) FROM transactions t {} WHERE {where_sql}", + tx_joins() + ), params_from_iter(where_binds.iter()), |r| r.get(0), ) @@ -184,7 +263,8 @@ pub async fn transactions( let offset = (page - 1) * page_size; // rant 2026-08-22T06:36:54/06:37:50:模型/Key 列 — 补 key_label(JOIN keys: // note 非空用 note,否则 provider / plan,plan 空则仅 provider;key 已删 → NULL) - let (list_where, mut list_binds) = tx_where("t", auth.user_id, &type_filter, &start, &end); + let (list_where, mut list_binds) = + tx_where("t", auth.user_id, &type_filter, &start, &end, &q.filters); let n = list_binds.len(); list_binds.push(rusqlite::types::Value::Integer(page_size as i64)); list_binds.push(rusqlite::types::Value::Integer(offset as i64)); @@ -257,6 +337,9 @@ pub struct TxTrendQuery { /// 聚合粒度:hour / day / week(缺省 day;非法值回退 day) #[serde(default)] pub bucket: String, + /// 列筛选(model/user_name/key_name/status/pts_min/pts_max,与 /api/transactions 一致) + #[serde(flatten)] + pub filters: TxColFilters, } /// GET /api/transactions/trend(rant 2026-08-23T16:01:07:交易页趋势图数据源) @@ -270,12 +353,13 @@ pub async fn transactions_trend( ) -> Result, ApiErr> { let type_filter = match q.r#type.as_str() { "" | "all" => None, - t @ ("consume" | "earn" | "topup" | "gift") => Some(t.to_string()), + // 与 /api/transactions 一致:列筛选 select 含 withdraw(rant 2026-08-25T10:33:26) + t @ ("consume" | "earn" | "topup" | "gift" | "withdraw") => Some(t.to_string()), _ => { return Err(( axum::http::StatusCode::BAD_REQUEST, Json( - serde_json::json!({ "error": "type 必须为 consume / earn / topup / gift / all" }), + serde_json::json!({ "error": "type 必须为 consume / earn / topup / gift / withdraw / all" }), ), )) } @@ -301,7 +385,8 @@ pub async fn transactions_trend( }; let start = norm(&q.start)?; let end = norm(&q.end)?; - let (where_sql, where_binds) = tx_where("t", auth.user_id, &type_filter, &start, &end); + let (where_sql, where_binds) = + tx_where("t", auth.user_id, &type_filter, &start, &end, &q.filters); // 桶表达式:day/week 产出 "YYYY-MM-DD",hour 产出 "YYYY-MM-DD HH:00" let expr = match bucket.as_str() { "hour" => "%Y-%m-%d %H:00", @@ -323,7 +408,8 @@ pub async fn transactions_trend( COALESCE(SUM(t.cached_tokens), 0), \ COALESCE(SUM(t.output_tokens), 0), \ COUNT(*) \ - FROM transactions t WHERE {where_sql} GROUP BY b ORDER BY b" + FROM transactions t {} WHERE {where_sql} GROUP BY b ORDER BY b", + tx_joins() ); let mut stmt = conn.prepare(&trend_sql).map_err(internal)?; let buckets: Vec = stmt @@ -699,6 +785,117 @@ mod tests { ); } + #[tokio::test] + async fn transactions_column_filters() { + // rant 2026-08-25T10:33:26:列筛选(model/user_name/key_name/status/pts 区间) + // 从本地当前页过滤改为后端全量过滤——列表 total/summary 联动;trend 同口径。 + let st = test_state("txcolf"); + let key = login(st.clone()).await; + { + let conn = st.db.lock().unwrap(); + conn.execute( + "UPDATE api_keys SET name = '我的测试key' WHERE user_id = 1", + [], + ) + .unwrap(); + let ak_id: i64 = conn + .query_row("SELECT id FROM api_keys WHERE user_id = 1", [], |r| { + r.get(0) + }) + .unwrap(); + // 3 条:model/点数各异;1 条 earn + 状态「处理中」 + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, api_key_id, model, tokens, pts, type, status) \ + VALUES (1, '2', 1, ?1, 'deepseek-v4-flash', 100, 10.0, 'consume', '成功')", + rusqlite::params![ak_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (1, '3', 1, 'gpt-4o', 200, 20.0, 'consume', '成功')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (1, '4', 2, 'claude-3-5-sonnet', 300, 30.0, 'earn', '处理中')", + [], + ) + .unwrap(); + } + // model LIKE(%deepseek%)→ 1 条;summary 联动为消费 10 + let (s, body) = get(st.clone(), "/api/transactions?model=deepseek", &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, "model LIKE 全量过滤: {body}"); + assert_eq!( + v["items"].as_array().unwrap()[0]["model"], + "deepseek-v4-flash" + ); + assert!( + (v["summary"]["expense_pts"].as_f64().unwrap() - 10.0).abs() < 1e-9, + "summary 随列筛选联动: {body}" + ); + // status 精确(处理中,URL-encoded)→ 1 条 earn + let (s, body) = get( + st.clone(), + "/api/transactions?status=%E5%A4%84%E7%90%86%E4%B8%AD", + &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, "status 精确过滤: {body}"); + assert_eq!(v["items"].as_array().unwrap()[0]["type"], "earn"); + // pts 区间 [15, 25] → 1 条(20) + let (s, body) = get(st.clone(), "/api/transactions?pts_min=15&pts_max=25", &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, "pts 区间过滤: {body}"); + assert!((v["items"].as_array().unwrap()[0]["pts"].as_f64().unwrap() - 20.0).abs() < 1e-9); + // user_name LIKE(demo,全部 3 条命中) + let (s, body) = get(st.clone(), "/api/transactions?user_name=dem", &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"], 3, "user_name LIKE: {body}"); + // key_name LIKE(我的测试key)→ 仅 api_key_id 那条(10.0) + let (s, body) = get( + st.clone(), + "/api/transactions?key_name=%E6%88%91%E7%9A%84", + &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, "key_name LIKE: {body}"); + assert!((v["items"].as_array().unwrap()[0]["pts"].as_f64().unwrap() - 10.0).abs() < 1e-9); + // 列筛选 + type 叠加(model=deepseek & type=consume)→ 仍 1 条 + let (s, body) = get( + st.clone(), + "/api/transactions?type=consume&model=deepseek", + &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, "type + 列筛选叠加: {body}"); + // trend 与列表同口径(model=deepseek → 单桶 expense=10) + let (s, body) = get( + st.clone(), + "/api/transactions/trend?model=deepseek&bucket=day", + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let buckets = v["buckets"].as_array().unwrap(); + assert_eq!(buckets.len(), 1, "trend 随列筛选: {body}"); + assert!( + (buckets[0]["expense"].as_f64().unwrap() - 10.0).abs() < 1e-9, + "{body}" + ); + } + #[tokio::test] async fn transactions_trend_buckets() { // rant 2026-08-23T16:01:07:/api/transactions/trend 按时间桶聚合趋势 diff --git a/ui/index.html b/ui/index.html index 8b2b584..d410f55 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,7 +5,7 @@ AITokenPool - + @@ -683,9 +683,9 @@

使用模型

- - - - + + + + diff --git a/ui/js/app.js b/ui/js/app.js index 97088a4..b2a783c 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -1202,11 +1202,10 @@ // 附带 Token 统计 总/输入/缓存/输出,M 单位) function renderTxSummary(list) { const s = (Live.transactions && Live.transactions.summary) ? Live.transactions.summary : null; - // rant 2026-08-23T16:01:07 Bug 1:统计指标与表格内部筛选联动—— - // 内部筛选非空时基于筛选后的行本地加总(含 token 口径一致);无内部筛选才用后端 summary(全量 SQL 聚合)。 - const hasFilter = Object.keys(txTable.filters).some((k) => txTable.filters[k] !== ""); + // rant 2026-08-25T10:33:26:后端 summary 已随列筛选全量 SQL 聚合(income 白名单 earn/topup/gift + // 为正、consume 为负;token 口径一致)——登录态一律用后端 summary,本地加总仅作无 summary 的兜底。 let income = 0, expense = 0, tokens = 0, inputT = 0, cachedT = 0, outputT = 0; - if (s && !hasFilter) { + if (s) { income = s.income_pts || 0; expense = s.expense_pts || 0; tokens = s.tokens || 0; @@ -1214,7 +1213,7 @@ cachedT = s.cached_tokens || 0; outputT = s.output_tokens || 0; } else { - // 兜底(无 summary 的旧数据/游客 mock / 内部筛选后):按 type 而非 pts 符号(rant 00:04:21 Bug A) + // 兜底(无 summary 的旧数据/游客 mock):按 type 而非 pts 符号(rant 00:04:21 Bug A) list.forEach((t) => { if (t.type === "consume") expense += Math.abs(t.pts); else income += Math.abs(t.pts); @@ -1390,8 +1389,9 @@ $("#tx-table").innerHTML = loadErrorHtml(T("tx.loadFail"), null, T("err.loadFail")); return; } - // 真后端分页(rant 2026-08-24T10:51:57):本地页码/每页行数与已加载页不一致 → 重新向后端拉取对应页 - if (Live.transactions && (txTable.loadedPage !== txTable.page || txTable.loadedPageSize !== txTable.pageSize)) { + // 真后端分页 + 列筛选(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())) { loadTransactions(); return; } @@ -1429,24 +1429,56 @@ return p.join("&"); } + // 列筛选 → 后端全量过滤查询参数(rant 2026-08-25T10:33:26:列筛选不再只过滤当前加载页)。 + // 列 key → 后端参数:user→user_name、key→key_name、pts 区间→pts_min/pts_max、status 精确; + // select 列(type/status)筛选值为 i18n 文案,反查英文 key / 库内中文值后发送。 + const TX_TYPE_INV = {}; + Object.keys(TX_TYPE).forEach((k) => { TX_TYPE_INV[txType(k)] = k; }); + const TX_STATUS_INV = {}; + ["成功", "入账", "处理中"].forEach((s) => { TX_STATUS_INV[txStatus(s)] = s; }); + function txFilterParams() { + const f = txTable.filters || {}; + const p = []; + const add = (k, v) => { const s = String(v == null ? "" : v).trim(); if (s !== "") p.push(k + "=" + encodeURIComponent(s)); }; + if (f.user) add("user_name", f.user); + if (f.model) add("model", f.model); + if (f.key) add("key_name", f.key); + if (f.pts) { + const parts = String(f.pts).split(":"); + if (parts[0] !== "") add("pts_min", parts[0]); + if (parts[1] !== "" && parts[1] != null) add("pts_max", parts[1]); + } + if (f.status) add("status", TX_STATUS_INV[f.status] || f.status); + return p.join("&"); + } + // 列筛选签名:筛选条件变化 → renderTransactions 触发重拉(rant 2026-08-25T10:33:26) + function txFilterSig() { + const f = txTable.filters || {}; + return Object.keys(f).sort().map((k) => k + "=" + String(f[k] == null ? "" : f[k])).join("&"); + } + // P2-B:按 tab 拉取交易(真后端分页 rant 2026-08-24T10:51:57:页码/每页行数随请求发出; // loadedPage/loadedPageSize 记录已加载页,renderTransactions 发现页码不一致时自动重拉) async function loadTransactions() { if (!loggedIn()) return; - const type = txTab === "all" ? "" : txTab; + // 类型:列筛选 type(select)优先于顶部 tab(tab=all 时即列筛选值);列筛选后端化后同走 type 参数 + const colType = (txTable.filters && txTable.filters.type) ? (TX_TYPE_INV[txTable.filters.type] || txTable.filters.type) : ""; + const type = colType || (txTab === "all" ? "" : txTab); const range = txRangeParams(); + const cols = txFilterParams(); // rant 2026-08-25T10:33:26:列筛选随请求发出,后端全量过滤 const page = Math.max(1, txTable.page || 1); const pageSize = Math.min(100, Math.max(1, txTable.pageSize || 10)); - const q = "/api/transactions?type=" + type + "&page=" + page + "&page_size=" + pageSize + (range ? "&" + range : ""); + const q = "/api/transactions?type=" + type + "&page=" + page + "&page_size=" + pageSize + (range ? "&" + range : "") + (cols ? "&" + cols : ""); try { await liveLoad("transactions", q); txTable.loadedPage = page; txTable.loadedPageSize = pageSize; + txTable.loadedFilterSig = txFilterSig(); // 记录已加载的筛选条件,变化时 renderTransactions 重拉 } catch (e) { Live.transactions = null; /* 登录态降级空态 */ } - // 趋势图数据(rant 2026-08-23T16:01:07 需求 2):独立拉取,失败不阻塞列表 + // 趋势图数据(rant 2026-08-23T16:01:07 需求 2):独立拉取,失败不阻塞列表;同列筛选口径 const bucket = txTrendBucket(); try { - const trend = await api.get("/api/transactions/trend?type=" + type + "&bucket=" + bucket + (range ? "&" + range : "")); + const trend = await api.get("/api/transactions/trend?type=" + type + "&bucket=" + bucket + (range ? "&" + range : "") + (cols ? "&" + cols : "")); if (Live.transactions) Live.transactions.trend = trend; } catch (e) { if (Live.transactions) Live.transactions.trend = null; } renderTransactions();