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
26 changes: 26 additions & 0 deletions src/gift.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ pub fn ensure_daily_gift(conn: &Connection, user_id: i64) -> Result<bool> {
"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)
}

Expand Down Expand Up @@ -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");
Expand Down
156 changes: 151 additions & 5 deletions src/routes/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -175,6 +222,7 @@ pub async fn transactions(
"total": total,
"page": page,
"page_size": page_size,
"summary": summary,
})))
}

Expand Down Expand Up @@ -202,10 +250,11 @@ pub async fn dashboard(
.map_err(internal)?
.collect::<rusqlite::Result<Vec<_>>>()
.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)",
Expand Down Expand Up @@ -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(),
"/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(),
"/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(), "/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");
Expand Down
29 changes: 26 additions & 3 deletions ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1184,17 +1184,40 @@
render: (t) => t.status === "处理中" ? '<span class="badge warn">' + esc(txStatus(t.status)) + "</span>" : 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 =
'<div class="ts-item"><span class="ts-label">' + T("tx.summary.income") + "</span><span class='ts-value num ' + cls(income) + '\'>" + fmt(income) + "</span></div>" +
'<div class="ts-item"><span class="ts-label">' + T("tx.summary.expense") + "</span><span class='ts-value num ' + cls(-expense) + '\'>" + fmt(-expense) + "</span></div>" +
'<div class="ts-item"><span class="ts-label">' + T("tx.summary.net") + "</span><span class='ts-value num ' + cls(net) + '\'>" + fmt(net) + "</span></div>";
// Token 统计(仅后端 summary 提供时显示):总 / 输入 / 缓存 / 输出
if (s) {
const t = (k) => fmtM(s[k] || 0);
html +=
'<div class="ts-item"><span class="ts-label">' + T("tx.summary.tokens") + "</span><span class='ts-value num '>" + t("tokens") + "</span></div>" +
'<div class="ts-item"><span class="ts-label">' + T("tx.summary.input") + "</span><span class='ts-value num '>" + t("input_tokens") + "</span></div>" +
'<div class="ts-item"><span class="ts-label">' + T("tx.summary.cached") + "</span><span class='ts-value num '>" + t("cached_tokens") + "</span></div>" +
'<div class="ts-item"><span class="ts-label">' + T("tx.summary.output") + "</span><span class='ts-value num '>" + t("output_tokens") + "</span></div>";
}
$("#tx-summary").innerHTML = html;
}

function renderTransactions() {
Expand Down
8 changes: 8 additions & 0 deletions ui/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading