From b8c106867edff535ed27968da6cfd48c5673d13e Mon Sep 17 00:00:00 2001 From: argszero Date: Sun, 13 Sep 2026 09:41:44 +0800 Subject: [PATCH] fix(gift): record the expiry of unused daily-gift points in the ledger --- src/gift.rs | 139 +++++++++++++++++++++++++++++++++++++++++++ src/i18n_pack.rs | 2 +- src/routes/ops.rs | 2 +- src/routes/wallet.rs | 110 +++++++++++++++++++++++++++++++--- ui/index.html | 2 +- ui/js/app.js | 4 +- 6 files changed, 247 insertions(+), 12 deletions(-) diff --git a/src/gift.rs b/src/gift.rs index fceb0e3..f3b0472 100644 --- a/src/gift.rs +++ b/src/gift.rs @@ -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')", @@ -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(()) } @@ -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 { + 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::>>() + .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); + } } diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index 2309a93..8d088aa 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -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; /// 切出语言包区段(起点标记 → 终点标记,含起点)。 diff --git a/src/routes/ops.rs b/src/routes/ops.rs index 941a151..991b991 100644 --- a/src/routes/ops.rs +++ b/src/routes/ops.rs @@ -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), diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index a351667..a47a822 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -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}; @@ -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" }), ), )) } @@ -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), \ @@ -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) @@ -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), \ @@ -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::(&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::(&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}"); + } } diff --git a/ui/index.html b/ui/index.html index dbc31db..cbb82fa 100644 --- a/ui/index.html +++ b/ui/index.html @@ -845,6 +845,6 @@

使用模型

- + diff --git a/ui/js/app.js b/ui/js/app.js index 5a86ec1..3909756 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -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; @@ -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" ? '' + T("tx.type.earn") + "" : t.type === "consume" ? '' + T("tx.type.consume") + "" : t.type === "gift" ? '' + T("tx.type.gift") + "" : '' + esc(txType(t.type)) + "" }, // rant 2026-08-22T17:21:39 需求 2:新增「用户」列(transactions.user_id JOIN users 取用户名)