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
139 changes: 139 additions & 0 deletions src/gift.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,19 @@ pub const GIFT_DAYS: i64 = 10;

/// 惰性清理:把已过期的 active 赠送标记 expired,并重算 gift_balance
/// (重算 = 自愈:任何路径下 gift_balance 都能与 gift_grants 对齐)
///
/// 过期点数会**真的离开账户**(`balance + gift_balance` 变小),所以与赠送(`type='gift'`)、
/// 消费(`type='consume'`)、充值(`type='topup'`)一样必须留下账本行:写一笔 `type='expire'`
/// 的支出(C2050:此前只有这一步不记账 ⇒ `SUM(transactions)` 与 `balance + gift_balance` 不可对账)。
/// 一次清扫只写一行、且只记**真正过期的那部分**(赠 1.0 先消费 0.4 ⇒ 记 0.6)。
pub fn expire_past_gifts(conn: &Connection, user_id: i64) -> Result<()> {
// 先算「即将过期的 active 赠送总额」(改状态前算,改完就查不到了)
let expiring: f64 = conn.query_row(
"SELECT COALESCE(SUM(amount), 0) FROM gift_grants \
WHERE user_id = ?1 AND status = 'active' AND expires_at < datetime('now')",
[user_id],
|r| r.get(0),
)?;
conn.execute(
"UPDATE gift_grants SET status = 'expired' \
WHERE user_id = ?1 AND status = 'active' AND expires_at < datetime('now')",
Expand All @@ -29,6 +41,14 @@ pub fn expire_past_gifts(conn: &Connection, user_id: i64) -> Result<()> {
), 0) WHERE user_id = ?1",
[user_id],
)?;
// 账本行:仅当真的有过期点数才写(幂等 —— 第二次清扫看不到 active 的过期行,故不会再写)
if expiring > 0.0 {
conn.execute(
"INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \
VALUES (?1, '', NULL, '', 0, ?2, 'expire', '成功')",
rusqlite::params![user_id, expiring],
)?;
}
Ok(())
}

Expand Down Expand Up @@ -376,4 +396,123 @@ mod tests {
drop(conn);
let _ = std::fs::remove_file(p);
}

// ---- C2050:赠送过期的账本行 ----
//
// 规格(不是实现的复述):账本是账单 —— 按 `type` 判方向的
// `SUM(transactions)` 必须恒等于账户里的 `balance + gift_balance`。
// 三种生命周期(赠送 / 消费 / **过期**)都要成立;过期与消费的唯一区别是「谁把点数拿走了」。

/// 账本净额(方向由 `type` 决定,与 wallet.rs / ops.rs 同口径 —— 所有 writer 都存正数 pts)
fn ledger_net(conn: &Connection, uid: i64) -> f64 {
conn.query_row(
"SELECT COALESCE(SUM(CASE WHEN type IN ('earn','topup','gift') THEN pts ELSE -pts END), 0) \
FROM transactions WHERE user_id = ?1",
[uid],
|r| r.get(0),
)
.unwrap()
}

/// 账户总额(赠送余额 + 永久余额)
fn account_total(conn: &Connection, uid: i64) -> f64 {
conn.query_row(
"SELECT COALESCE(balance, 0) + COALESCE(gift_balance, 0) FROM quotas WHERE user_id = ?1",
[uid],
|r| r.get(0),
)
.unwrap()
}

/// 某类型的账本行:点数列表(按 id)
fn tx_pts(conn: &Connection, uid: i64, typ: &str) -> Vec<f64> {
let mut stmt = conn
.prepare("SELECT pts FROM transactions WHERE user_id = ?1 AND type = ?2 ORDER BY id")
.unwrap();
let v = stmt
.query_map(rusqlite::params![uid, typ], |r| r.get(0))
.unwrap()
.collect::<rusqlite::Result<Vec<f64>>>()
.unwrap();
v
}

#[test]
fn gift_expiry_is_recorded_in_the_ledger() {
// C2050:过期的赠送点数会真的离开账户 ⇒ 必须写账本行,否则账单与账户不可对账。
let (conn, p) = tmp_db("g8");
let uid = register(&conn, "u8@t.local", &ts(&conn, 0));

// 腿 1 赠送(阳性对照:不变量本就成立)
assert!(ensure_daily_gift(&conn, uid).unwrap());
assert_eq!(ledger_net(&conn, uid), 1.0);
assert_eq!(account_total(&conn, uid), 1.0);

// 腿 2 消费 0.4(阳性对照):扣赠送本身不改账本 —— consume 行由调用方
// `billing::settle` 写(gift.rs 只管余额),这里照 settle 的写法补上那一行。
let rest = deduct_gift_first(&conn, uid, 0.4).unwrap();
assert!(rest.abs() < 1e-9);
conn.execute(
"INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \
VALUES (?1, '2', 1, 'm', 0, 0.4, 'consume', '成功')",
[uid],
)
.unwrap();
assert_eq!(account_total(&conn, uid), 0.6);
assert_eq!(ledger_net(&conn, uid), 0.6);

// 腿 3 过期 —— 本次缺陷所在:点数离账,账本必须同步
conn.execute(
"UPDATE gift_grants SET expires_at = datetime('now', '-1 minute') WHERE user_id = ?1",
[uid],
)
.unwrap();
expire_past_gifts(&conn, uid).unwrap();

assert_eq!(account_total(&conn, uid), 0.0, "过期后账户归零");
assert_eq!(
tx_pts(&conn, uid, "expire"),
vec![0.6],
"过期的账本行只记真正过期的部分(赠 1.0 消费 0.4 ⇒ 0.6)"
);
let ln = ledger_net(&conn, uid);
let at = account_total(&conn, uid);
assert!(
(ln - at).abs() < 1e-9,
"不变量:账本净额 == 账户总额(ledger={ln} account={at})"
);
drop(conn);
let _ = std::fs::remove_file(p);
}

#[test]
fn gift_expiry_is_idempotent_and_writes_nothing_when_nothing_expired() {
let (conn, p) = tmp_db("g9");
let uid = register(&conn, "u9@t.local", &ts(&conn, 0));
assert!(ensure_daily_gift(&conn, uid).unwrap());

// 未过期 ⇒ 不写行(阳性对照:无事件则无账本行)
expire_past_gifts(&conn, uid).unwrap();
assert!(
tx_pts(&conn, uid, "expire").is_empty(),
"没有点数过期时不应写 expire 行"
);
assert_eq!(account_total(&conn, uid), 1.0);
assert_eq!(ledger_net(&conn, uid), 1.0);

// 过期后重复清扫 ⇒ 仍然只有一行(第二次看不到 active 的过期行)
conn.execute(
"UPDATE gift_grants SET expires_at = datetime('now', '-1 minute') WHERE user_id = ?1",
[uid],
)
.unwrap();
expire_past_gifts(&conn, uid).unwrap();
expire_past_gifts(&conn, uid).unwrap();
expire_past_gifts(&conn, uid).unwrap();
assert_eq!(tx_pts(&conn, uid, "expire"), vec![1.0], "清扫必须幂等");
assert_eq!(account_total(&conn, uid), 0.0);
assert_eq!(ledger_net(&conn, uid), 0.0, "账本与账户同时归零");
drop(conn);
let _ = std::fs::remove_file(p);
}
}
2 changes: 1 addition & 1 deletion src/i18n_pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const ZH_KEY_COUNT: usize = 786;
const EN_KEY_COUNT: usize = 786;
const STATIC_ATTR_COUNT: usize = 330;
const STATIC_ATTR_DISTINCT: usize = 305;
const T_LITERAL_COUNT: usize = 537;
const T_LITERAL_COUNT: usize = 538;
const T_LITERAL_DISTINCT: usize = 430;

/// 切出语言包区段(起点标记 → 终点标记,含起点)。
Expand Down
2 changes: 1 addition & 1 deletion src/routes/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ pub async fn runtime(
let month_out: f64 = conn
.query_row(
"SELECT COALESCE(SUM(pts), 0) FROM transactions \
WHERE type IN ('consume', 'withdraw') \
WHERE type IN ('consume', 'expire', 'withdraw') \
AND strftime('%Y-%m', time) = strftime('%Y-%m', 'now')",
[],
|r| r.get(0),
Expand Down
110 changes: 103 additions & 7 deletions src/routes/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//!
//! P0-C(rant 2026-08-18T10:36:04):
//! - GET /api/wallet → {balance, month_use, month_earn}
//! - GET /api/transactions?type=&page=&page_size= → 分页 + type 过滤(consume/earn/all)
//! - GET /api/transactions?type=&page=&page_size= → 分页 + type 过滤(consume/earn/topup/gift/expire/withdraw/all)
//! - GET /api/dashboard → 本月按类型聚合 + 本月净变化 + 近 7 天净额序列(sparkline)

use axum::extract::{Query, State};
Expand Down Expand Up @@ -192,12 +192,13 @@ pub async fn transactions(
let type_filter = match q.r#type.as_str() {
"" | "all" => None,
// 列筛选 select 含 withdraw(rant 2026-08-25T10:33.26:列筛选后端化后 UI 选项须全被 API 接受)
t @ ("consume" | "earn" | "topup" | "gift" | "withdraw") => Some(t.to_string()),
// expire 同列(C2050:赠送过期的账本行,见 gift::expire_past_gifts)
t @ ("consume" | "earn" | "topup" | "gift" | "expire" | "withdraw") => Some(t.to_string()),
_ => {
return Err((
axum::http::StatusCode::BAD_REQUEST,
Json(
serde_json::json!({ "error": "type 必须为 consume / earn / topup / gift / withdraw / all" }),
serde_json::json!({ "error": "type 必须为 consume / earn / topup / gift / expire / withdraw / all" }),
),
))
}
Expand All @@ -222,13 +223,15 @@ 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 为负。
// 按当前 type + 时间段 + 列筛选;口径 = income 白名单(earn/topup/gift)为正,
// 支出 = 其余「离开账户」的类型(consume 消费 / expire 赠送过期 / withdraw 提现)——
// 方向由 `type` 决定,不是 `pts` 的符号(每个 writer 都存正数,C2045/C2050)。
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 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 ('consume','expire','withdraw') 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), \
Expand Down Expand Up @@ -327,7 +330,7 @@ pub async fn transactions(
/// GET /api/transactions/trend 查询参数
#[derive(Debug, Deserialize)]
pub struct TxTrendQuery {
/// consume / earn / topup / gift / all(缺省 all)
/// consume / earn / topup / gift / expire / withdraw / all(缺省 all)
#[serde(default)]
pub r#type: String,
/// 起始时间(同 /api/transactions,ISO 8601 UTC)
Expand Down Expand Up @@ -401,7 +404,7 @@ pub async fn transactions_trend(
let trend_sql = format!(
"SELECT strftime('{expr}', t.time{mods}) AS b, \
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 ('consume','expire','withdraw') 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), \
Expand Down Expand Up @@ -1332,4 +1335,97 @@ mod tests {
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn expired_gift_is_visible_in_the_ledger_through_the_handlers() {
// C2050:惰性清扫发生在 GET /api/wallet(ensure_daily_gift → expire_past_gifts)。
// 过期点数真的离开账户 ⇒ 账单必须能看到这一行,且汇总口径把它算作**支出**。
// 断言写的是规格(收入白名单 / 支出集合 / 净额 = 收入 − 支出 / 对账),不是实现的复述。
let st = test_state("expire");
let key = login(st.clone()).await;

// 1) 首次拉钱包 → 触发每日赠送(1 点 active)
let (s, body) = get(st.clone(), "/api/wallet", &key).await;
assert_eq!(s, axum::http::StatusCode::OK, "body: {body}");
let avail0 = serde_json::from_str::<serde_json::Value>(&body).unwrap()["available"]
.as_f64()
.unwrap();

// 2) 把赠送推到过期,再拉钱包 → 清扫发生
{
let conn = st.db.lock().unwrap();
conn.execute(
"UPDATE gift_grants SET expires_at = datetime('now', '-1 minute') \
WHERE user_id = 1 AND status = 'active'",
[],
)
.unwrap();
}
let (s, body) = get(st.clone(), "/api/wallet", &key).await;
assert_eq!(s, axum::http::StatusCode::OK, "body: {body}");
let avail1 = serde_json::from_str::<serde_json::Value>(&body).unwrap()["available"]
.as_f64()
.unwrap();
assert!(
(avail0 - avail1 - 1.0).abs() < 1e-9,
"过期 1 点离开账户: {avail0} -> {avail1}"
);

// 3) 账单里能看到 expire 行,且汇总把它算作支出
let (s, body) = get(
st.clone(),
"/api/transactions?type=all&page=1&page_size=50",
&key,
)
.await;
assert_eq!(s, axum::http::StatusCode::OK, "body: {body}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let items = v["items"].as_array().unwrap();
assert!(
items
.iter()
.any(|r| r["type"] == "expire" && (r["pts"].as_f64().unwrap() - 1.0).abs() < 1e-9),
"账单中应有 expire 行: {items:?}"
);
let inc = v["summary"]["income_pts"].as_f64().unwrap();
let exp = v["summary"]["expense_pts"].as_f64().unwrap();
let net = v["summary"]["net_pts"].as_f64().unwrap();
assert!(
(inc - 1.0).abs() < 1e-9,
"收入只有赠送的 1 点(过期不算收入): {inc}"
);
assert!(exp >= 1.0, "支出应含过期的 1 点: {exp}");
assert!(
(net - (inc - exp)).abs() < 1e-9,
"净额 = 收入 − 支出: {inc} - {exp} != {net}"
);

// 4) type 过滤器接受 expire(列筛选选项后端化,rant 2026-08-25T10:33.26)
let (s, body) = get(
st.clone(),
"/api/transactions?type=expire&page=1&page_size=50",
&key,
)
.await;
assert_eq!(
s,
axum::http::StatusCode::OK,
"type=expire 应被接受: {body}"
);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["total"], 1, "只有一笔过期: {body}");

// 5) 趋势(expense 列)同样把它算作支出
let (s, body) = get(
st.clone(),
"/api/transactions/trend?type=all&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();
let sum_exp: f64 = buckets.iter().map(|b| b["expense"].as_f64().unwrap()).sum();
assert!(sum_exp >= 1.0, "趋势支出应含过期的 1 点: {sum_exp}");
}
}
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=20260912-5"></script>
<script src="js/app.js?v=20260912-7"></script>
<script src="js/app.js?v=20260913-1"></script>
</body>
</html>
4 changes: 2 additions & 2 deletions ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1310,7 +1310,7 @@

const TX_TYPE = {
consume: () => T("tx.type.consume"), earn: () => T("tx.type.earn"), topup: () => T("tx.type.topup"),
withdraw: () => T("tx.type.withdraw"), gift: () => T("tx.type.gift"),
withdraw: () => T("tx.type.withdraw"), gift: () => T("tx.type.gift"), expire: () => T("tx.type.expire"),
};
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;
Expand All @@ -1321,7 +1321,7 @@
{ key: "type", title: () => T("tx.col.type"), sort: "string", filter: "select",
// C2031:选项「值」与「文案」分离 —— value 恒为数据库值(语言无关),label 才随语言变。
// 这样筛选状态存的是 DB 值,切换语言后 filterVal 仍能匹配(此前状态存本地化文案 ⇒ 切语言即失配,表格变空)
options: () => ["consume", "earn", "topup", "withdraw", "gift"].map((k) => ({ value: k, label: txType(k) })),
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 取用户名)
Expand Down
Loading