diff --git a/src/gift.rs b/src/gift.rs index 923c40d..fceb0e3 100644 --- a/src/gift.rs +++ b/src/gift.rs @@ -87,6 +87,12 @@ pub fn ensure_daily_gift(conn: &Connection, user_id: i64) -> Result { "UPDATE quotas SET gift_balance = gift_balance + ?1 WHERE user_id = ?2", rusqlite::params![GIFT_DAILY_AMOUNT, user_id], )?; + // 赠送写 transactions(rant 2026-08-22T00:04:21:每日赠送不入账 → 交易记录/汇总永远对不上) + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (?1, '', NULL, '', 0, ?2, 'gift', '成功')", + rusqlite::params![user_id, GIFT_DAILY_AMOUNT], + )?; Ok(true) } @@ -268,6 +274,26 @@ mod tests { let _ = std::fs::remove_file(p); } + #[test] + fn gift_writes_transaction_record() { + // rant 2026-08-22T00:04:21:每日赠送必须写 transactions(否则交易记录/汇总对不上余额) + let (conn, p) = tmp_db("g7"); + let uid = register(&conn, "u7@t.local", &ts(&conn, 0)); + assert!(ensure_daily_gift(&conn, uid).unwrap()); + let (typ, pts, tokens): (String, f64, f64) = conn + .query_row( + "SELECT type, pts, tokens FROM transactions WHERE user_id = ?1", + [uid], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(typ, "gift", "赠送应写 type='gift' 的交易记录"); + assert_eq!(pts, GIFT_DAILY_AMOUNT, "赠送点数入账"); + assert_eq!(tokens, 0.0); + drop(conn); + let _ = std::fs::remove_file(p); + } + #[test] fn deduct_gift_first_oldest_expiry() { let (conn, p) = tmp_db("g5"); diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index 52955e3..59d8467 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -76,15 +76,62 @@ 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") => Some(t.to_string()), + t @ ("consume" | "earn" | "topup" | "gift") => Some(t.to_string()), _ => { return Err(( axum::http::StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "error": "type 必须为 consume / earn / topup / all" })), + Json( + serde_json::json!({ "error": "type 必须为 consume / earn / topup / gift / all" }), + ), )) } }; let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + // 汇总(rant 2026-08-22T00:04:21/00:07:08):全量 SQL 聚合(不依赖分页),按当前 type 筛选; + // 口径 = income 白名单(earn/topup/gift)为正、consume 为负;token 统计同口径。 + let summary: serde_json::Value = { + let sql = "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 user_id = ?1"; + match &type_filter { + Some(t) => conn + .query_row( + &format!("{sql} AND type = ?2"), + params![auth.user_id, t], + |r| { + Ok(serde_json::json!({ + "income_pts": r.get::<_, f64>(0)?, + "expense_pts": r.get::<_, f64>(1)?, + "net_pts": r.get::<_, f64>(2)?, + "tokens": r.get::<_, f64>(3)?, + "input_tokens": r.get::<_, f64>(4)?, + "cached_tokens": r.get::<_, f64>(5)?, + "output_tokens": r.get::<_, f64>(6)?, + })) + }, + ) + .map_err(internal)?, + None => conn + .query_row(sql, [auth.user_id], |r| { + Ok(serde_json::json!({ + "income_pts": r.get::<_, f64>(0)?, + "expense_pts": r.get::<_, f64>(1)?, + "net_pts": r.get::<_, f64>(2)?, + "tokens": r.get::<_, f64>(3)?, + "input_tokens": r.get::<_, f64>(4)?, + "cached_tokens": r.get::<_, f64>(5)?, + "output_tokens": r.get::<_, f64>(6)?, + })) + }) + .map_err(internal)?, + } + }; let total: i64 = match &type_filter { Some(t) => conn .query_row( @@ -175,6 +222,7 @@ pub async fn transactions( "total": total, "page": page, "page_size": page_size, + "summary": summary, }))) } @@ -202,10 +250,11 @@ pub async fn dashboard( .map_err(internal)? .collect::>>() .map_err(internal)?; - // 近 7 天净额序列(earn 为正、consume 为负) + // 近 7 天净额序列(earn/topup/gift 为正、consume 为负;rant 2026-08-22T06:34:37: + // 原先只把 earn 当正数 → topup 充值被误算为负) let mut stmt = conn .prepare( - "SELECT date(time), COALESCE(SUM(CASE WHEN type = 'earn' THEN pts ELSE -pts END), 0) \ + "SELECT date(time), COALESCE(SUM(CASE WHEN type IN ('earn','topup','gift') THEN pts ELSE -pts END), 0) \ FROM transactions \ WHERE user_id = ?1 AND date(time) >= date('now', '-6 days') \ GROUP BY date(time) ORDER BY date(time)", @@ -326,10 +375,107 @@ mod tests { let month = v["month"].as_array().unwrap(); assert!(month.len() >= 2, "本月 consume+earn 两类聚合: {month:?}"); let net = v["net"].as_f64().unwrap(); - assert!((net - (1.8 - 2.0)).abs() < 1e-9, "净变化 = earn - consume"); + // 净变化 = earn - consume + gift(demo 今日首次拉 wallet 触发每日赠送 +1 并写 transactions; + // rant 2026-08-22T00:04:21:赠送入账后净变化含 gift) + assert!( + (net - (1.8 - 2.0 + 1.0)).abs() < 1e-9, + "净变化 = earn - consume + gift: {net}" + ); assert!(!v["series"].as_array().unwrap().is_empty(), "近 7 天序列"); } + #[tokio::test] + async fn transactions_summary_and_dashboard_net_with_topup() { + // rant 2026-08-22T00:04:21/00:07:08/06:34:37: + // - /api/transactions.summary:income 白名单(earn/topup/gift)为正、consume 为负 + token 统计 + // - dashboard net:topup 充值不得被算成负数 + let st = test_state("txsum"); + let key = login(st.clone()).await; + { + let conn = st.db.lock().unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (1, 'admin', NULL, '', 0, 2000.0, 'topup', '成功')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, cached_tokens, output_tokens, pts, type, status) \ + VALUES (1, '2', 1, 'm', 1000, 200.0, 100.0, 0.5, 'consume', '成功')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (1, '', NULL, '', 0, 1.0, 'gift', '成功')", + [], + ) + .unwrap(); + } + // all → summary:income=2001(topup+gift)、expense=0.5、net=2000.5;token 统计 + let (s, body) = get( + st.clone(), + "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/api/transactions?type=all&page=1&page_size=10", + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let sum = &v["summary"]; + assert!( + (sum["income_pts"].as_f64().unwrap() - 2001.0).abs() < 1e-9, + "{sum}" + ); + assert!( + (sum["expense_pts"].as_f64().unwrap() - 0.5).abs() < 1e-9, + "{sum}" + ); + assert!( + (sum["net_pts"].as_f64().unwrap() - 2000.5).abs() < 1e-9, + "{sum}" + ); + assert!( + (sum["tokens"].as_f64().unwrap() - 1000.0).abs() < 1e-9, + "{sum}" + ); + assert!( + (sum["input_tokens"].as_f64().unwrap() - 700.0).abs() < 1e-9, + "input = tokens - cached - output = 1000-200-100: {sum}" + ); + assert!( + (sum["cached_tokens"].as_f64().unwrap() - 200.0).abs() < 1e-9, + "{sum}" + ); + assert!( + (sum["output_tokens"].as_f64().unwrap() - 100.0).abs() < 1e-9, + "{sum}" + ); + // type=consume 筛选 → summary 只含 consume + let (s, body) = get( + st.clone(), + "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/api/transactions?type=consume&page=1&page_size=10", + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let sum = &v["summary"]; + assert!((sum["income_pts"].as_f64().unwrap()).abs() < 1e-9, "{sum}"); + assert!( + (sum["expense_pts"].as_f64().unwrap() - 0.5).abs() < 1e-9, + "{sum}" + ); + // dashboard net:topup+gift 为正 → +2000 -0.5 +1 = 2000.5(不得把 topup 算成负) + let (s, body) = get(st.clone(), "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/api/dashboard", &key).await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let net = v["net"].as_f64().unwrap(); + assert!( + (net - 2000.5).abs() < 1e-9, + "dashboard net 应含 topup+gift 为正(=2000.5,实际 {net})" + ); + } + #[tokio::test] async fn transactions_filter_and_pagination() { let st = test_state("tx"); diff --git a/ui/js/app.js b/ui/js/app.js index 91c4fae..fbe0b6c 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -1184,17 +1184,40 @@ render: (t) => t.status === "处理中" ? '' + esc(txStatus(t.status)) + "" : esc(txStatus(t.status)) }, ]; - // 交易汇总条(rant 20:39:30 B:随 tab + 列筛选联动 — 总收入/总支出/净变化,+绿 −红,紧凑 inline 条) + // 交易汇总条(rant 20:39:30 B + 00:04:21 + 00:07:08:改用后端 summary 全量 SQL 聚合, + // 不再对当前页本地加总;口径 = income 白名单(earn/topup/gift)为正、consume 为负; + // 附带 Token 统计 总/输入/缓存/输出,M 单位) function renderTxSummary(list) { + const s = (Live.transactions && Live.transactions.summary) ? Live.transactions.summary : null; let income = 0, expense = 0; - list.forEach((t) => { if (t.pts > 0) income += t.pts; else expense += -t.pts; }); + if (s) { + income = s.income_pts || 0; + expense = s.expense_pts || 0; + } else { + // 兜底(无 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); + }); + } const net = income - expense; const cls = (n) => (n > 0 ? "ok" : n < 0 ? "danger" : ""); const fmt = (n) => (n > 0 ? "+" : n < 0 ? "-" : "") + D.fmt(Math.abs(n)); - $("#tx-summary").innerHTML = + const fmtM = (n) => (n >= 1e6 ? (n / 1e6).toFixed(2) + "M" : (n >= 1000 ? Math.round(n / 1000) + "K" : String(Math.round(n)))); + let html = '
' + T("tx.summary.income") + "" + fmt(income) + "
" + '
' + T("tx.summary.expense") + "" + fmt(-expense) + "
" + '
' + T("tx.summary.net") + "" + fmt(net) + "
"; + // Token 统计(仅后端 summary 提供时显示):总 / 输入 / 缓存 / 输出 + if (s) { + const t = (k) => fmtM(s[k] || 0); + html += + '
' + T("tx.summary.tokens") + "" + t("tokens") + "
" + + '
' + T("tx.summary.input") + "" + t("input_tokens") + "
" + + '
' + T("tx.summary.cached") + "" + t("cached_tokens") + "
" + + '
' + T("tx.summary.output") + "" + t("output_tokens") + "
"; + } + $("#tx-summary").innerHTML = html; } function renderTransactions() { diff --git a/ui/js/i18n.js b/ui/js/i18n.js index cb6fb4e..34c1129 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -308,6 +308,10 @@ "tx.summary.income": "总收入", "tx.summary.expense": "总支出", "tx.summary.net": "净变化", + "tx.summary.tokens": "总 Token", + "tx.summary.input": "输入", + "tx.summary.cached": "缓存", + "tx.summary.output": "输出", "tx.col.time": "时间", "tx.col.type": "类型", "tx.col.partner": "模型 / Key", @@ -997,6 +1001,10 @@ "tx.summary.income": "Income", "tx.summary.expense": "Expense", "tx.summary.net": "Net", + "tx.summary.tokens": "Total", + "tx.summary.input": "Input", + "tx.summary.cached": "Cached", + "tx.summary.output": "Output", "tx.col.time": "Time", "tx.col.type": "Type", "tx.col.partner": "Model / Key",