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
7 changes: 4 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
| `quotas` | 点数账户(balance 永久 + gift_balance 有效赠送) |
| `gift_grants` | 赠送明细(amount / expires_at / status: active\|used\|expired) |
| `transactions` | 交易流水(type 取值以 `src/routes/wallet.rs` 的 `TX_FILTER_TYPES` 为准;含 token 明细列) |
| `transactions_rollup` | 交易明细折叠出的分钟级**可加**汇总(`src/tx_rollup.rs`);聚合经视图 `tx_facts` 取数,见下 |
| `transactions_rollup` | 交易明细折叠出的分钟级**可加**汇总(`src/tx_rollup.rs`);聚合与交易页列表经视图 `tx_facts` 取数,见下 |
| `usage_records` | 调用明细(tokens 拆 input / cached / output) |
| `departments` / `raise_requests` | 部门 + 成员加额申请(企业版) |
| `email_verifications` | 注册邮箱验证码(与 `users.verified` 配套,v6 起) |
Expand All @@ -68,8 +68,9 @@
> 服务现在**先归档、再删除**。`transactions`:明细 → 归档成 `<数据目录>/archive/transactions-*.jsonl`
> (`src/archive.rs`)→ 折叠成 `transactions_rollup`(`src/tx_rollup.rs`)→ **聚合改从视图 `tx_facts`
> (=汇总 ∪ 未折叠明细,`src/tx_facts.rs`)取数** → 删除已折叠且已归档、过了 `[rollup] retain_minutes`
> 的行。分页列表要的是逐行身份(汇总行没有),仍读明细 ⇒ 它与聚合会在保留窗口之外分叉,
> `/api/transactions` 随响应发布这段边界。`usage_records`:读者全是写死的月/日窗口,**不需要折叠** ——
> 的行。**分页列表与聚合读同一个视图** ⇒ 「更早的历史」不会在交易页上凭空消失:折叠掉的明细
> 以分钟汇总行的形式照样列得出来,每行用它自己的行粒度(`row_count`)交代它代表多少条真实明细,
> 而「账本共 N 条」(`SUM(row_count)`)与「列表共 N 行」(`COUNT(*)`)**刻意不是同一个数**。`usage_records`:读者全是写死的月/日窗口,**不需要折叠** ——
> 归档后按同一套日历窗口删除(`src/usage_retention.rs`)。两表的开关与批次见 `config.example.toml` 的
> `[archive]` / `[rollup]` / `[usage_retention]`;**归档未就绪(水位为 0)时一行不删**。

Expand Down
8 changes: 4 additions & 4 deletions src/i18n_pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,12 @@ const EN_END: &str = "\n };";
///
/// ⚠️ `T_LITERAL_COUNT` 是 `T("…")` **调用点**总数,不是键数,也不是去重后的键数 ——
/// 三个集合各不相同(坑 99);说「这个数不该变」之前先确认它在数哪个集合。
const ZH_KEY_COUNT: usize = 800;
const EN_KEY_COUNT: usize = 800;
const ZH_KEY_COUNT: usize = 801;
const EN_KEY_COUNT: usize = 801;
const STATIC_ATTR_COUNT: usize = 335;
const STATIC_ATTR_DISTINCT: usize = 309;
const T_LITERAL_COUNT: usize = 547;
const T_LITERAL_DISTINCT: usize = 434;
const T_LITERAL_COUNT: usize = 548;
const T_LITERAL_DISTINCT: usize = 435;

/// 切出语言包区段(起点标记 → 终点标记,含起点)。
fn pack_region<'a>(src: &'a str, start_mark: &str, end_mark: &str) -> &'a str {
Expand Down
11 changes: 8 additions & 3 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2565,7 +2565,10 @@ mod tests {
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["items"].as_array().unwrap().len(), 1);
assert_eq!(v["items"][0]["counterpart"], "加额审批");
// 列表不再发布 `counterpart`(读模型没有它,前端交易语境也零消费点)⇒ 改钉这一行
// **是什么**:一笔 topup、金额正是批下来的 500。
assert_eq!(v["items"][0]["type"], "topup");
assert_eq!(v["items"][0]["pts"], 500.0);
// 重复批准 → 409
let (s, _) = post(
st.clone(),
Expand Down Expand Up @@ -2690,11 +2693,13 @@ mod tests {
assert_eq!(s, StatusCode::OK, "ops 充值应 200: {body}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["balance"], 12471.0 + 200.0);
// 交易记录 counterpart=运营者(demo 视角)
// 交易记录(demo 视角):一笔 topup、金额 200 —— 列表不再发布 `counterpart`
// (读模型没有它),故钉这一行**是什么**而不是它的对手方文案。
let (s, body) = get(st.clone(), "/api/transactions?type=topup", Some(&demo)).await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["items"][0]["counterpart"], "运营者");
assert_eq!(v["items"][0]["type"], "topup");
assert_eq!(v["items"][0]["pts"], 200.0);
// 负数金额 → 400
let (s, _) = post(
st,
Expand Down
78 changes: 45 additions & 33 deletions src/routes/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,18 @@ fn tx_sort_expr(key: &str) -> Option<String> {

/// 解析 `sort` / `dir` 查询参数 → `ORDER BY` 片段(列表端点专用;用户串**永不**进入 SQL)。
///
/// - 缺省(或空串)⇒ `t.id DESC`:**今日行为不变**(最新在前)。
/// - 缺省(或空串)⇒ `t.time DESC, t.id DESC`:**最新在前**。缺省键从 `t.id DESC` 改成
/// `t.time DESC` 是验收项 2 的后果,不是口味:列表改读读模型之后,同一列上同时住着
/// 「分钟桶」(16 字符)与「未折叠明细的时刻」(19 字符),而两条臂的 `id` 各自从 1 起 ——
/// 按 `id` 排会把**汇总行的主键**当成时间用,最新那批反而排到最后。
/// - `sort` 是逗号分隔的键列表,`dir` 是**逐列对应**的方向列表(缺省全 `asc`)——仓内排序本
/// 就是多列的(Shift 点击叠加),服务端只认第一列会让两支箭头里只有一支被兑现,那正是本轴
/// 要修的病本身。
/// - 未知键 / 长度不匹配 / 非法方向 ⇒ 400,文案由白名单**派生**;**不得**静默回退默认排序
/// (回退会让「箭头 ⇔ 顺序」再次脱钩,也就是又回到缺陷本身)。
/// - 尾部恒加 `, t.id DESC`:分页是 `LIMIT/OFFSET`,非唯一排序会让相邻页的边界不确定
/// (同一行出现两次、另一行永不出现)。
/// (同一行出现两次、另一行永不出现)。收尾键用视图的 [`tx_facts`] 行身份列 —— 两条臂的
/// `id` 各自唯一,且臂间的时间串长度不同(16 vs 19 字符)⇒ `(time, id)` 唯一。
fn tx_order_by(sort: Option<&str>, dir: Option<&str>) -> Result<String, ApiErr> {
// ⚠️ 400 的信封**写在调用点**(`json!({ "error": … })`,与仓内其余 60 余处同形),
// 不要收回成「接收 `String` 的助手/闭包」:那样 `"error"` 的值就成了**裸标识符**,
Expand All @@ -140,7 +144,7 @@ fn tx_order_by(sort: Option<&str>, dir: Option<&str>) -> Result<String, ApiErr>
};
let keys = sort.map(split).unwrap_or_default();
if keys.is_empty() {
return Ok("t.id DESC".to_string());
return Ok("t.time DESC, t.id DESC".to_string());
}
let dirs = dir.map(split).unwrap_or_default();
if !dirs.is_empty() && dirs.len() != keys.len() {
Expand Down Expand Up @@ -496,26 +500,27 @@ pub async fn transactions(
"cached_tokens": r.get::<_, f64>(5)?,
"output_tokens": r.get::<_, f64>(6)?,
// `entries` = 这条查询命中的**账本条数**(读模型口径:汇总行按 `row_count` 折算成
// 原来的条数)⇒ 汇总/删除对它是**不可见**的,它答的是「有多少条账本记录」。
// ⚠️ 与 `total`(明细表里还剩几行)**刻意是两个数**:明细被删掉之后
// `entries > total`,差额正是「已折叠归档、不再逐条列得出」的那段 —— 见
// `detail_since` 与前端零行态。数条数必须 `SUM(row_count)`,不得 `COUNT(*)`
// (后者数的是视图有几行,即分钟桶数)。
// 原来的条数)⇒ 折叠对它是**不可见**的,它答的是「有多少条账本记录」。
// ⚠️ 与 `total`(**视图有几行** = 这一页翻得出多少行)**刻意是两个数**:折叠过的
// 那部分以分钟桶的形式占**一行**而不是 N 行 ⇒ `entries >= total`,差额正是
// 「被折进桶里的条数」(已经不是「列不出来的那段」—— 列表与 `total` 都改读读模型
// 之后没有列不出来的行了)。数条数必须 `SUM(row_count)`,不得 `COUNT(*)`
// (后者数的是视图有几行,即分钟桶数 + 未折叠明细数)。
"entries": r.get::<_, i64>(7)?,
}))
})
.map_err(internal)?;
// ⚠️ 分页列表**不走** `tx_facts`(明细才是它要的东西):汇总行是**聚合**,
// 没有 `id`、没有 `counterpart`,一行代表 N 条调用 ⇒ 视图答不出「第 N 页的第 K 条」。
// `total` 因此必须与列表**同一个载体**(都读明细),否则页码数与实际能翻出来的行数不一致。
// 代价如实记录:保留窗口之外的那段明细答不出来,而**聚合答得出** ⇒ 两者会在同一屏上
// 分叉。分叉由响应里的 `detail_since`(保留边界)与 `summary.entries`(账本条数)披露,
// 由前端说明(`txArchivedNoteText`:常驻一句 + 零行态的副标题,**有行也看得见**),
// 由 `tx_retention_gate` 守。
// ⚠️ 分页列表与 `total` **同源,都读读模型 `tx_facts`**(rant 2026-10-15T16:25:04.530850+08:00
// 验收项 2/3)。一行的含义是「一个分钟桶 × 一组维度值」或「一条尚未折叠的明细」——
// 前者自带 `row_count`(它代表多少条真实明细),后者恒 1。于是「明细只留一小段」不再让
// 交易页只能答出那一小段。`total` 因此是**行数**,与列表逐行对得上(页码数不会与能翻出来的
// 行数不一致);它与 `summary.entries`(**条数** = `SUM(row_count)`)刻意是两个数,
// 差额就是折叠本身,由每行的 `row_count` 与 `detail_since` 说给页面。
let total: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM transactions t {} WHERE {where_sql}",
"SELECT COUNT(*) FROM {} {} WHERE {where_sql}",
tx_facts::source("t"),
tx_joins_if(join_needed)
),
params_from_iter(where_binds.iter()),
Expand All @@ -531,44 +536,50 @@ pub async fn transactions(
list_binds.push(rusqlite::types::Value::Integer(page_size as i64));
list_binds.push(rusqlite::types::Value::Integer(offset as i64));
let list_sql = format!(
"SELECT t.id, t.counterpart, t.key_id, t.api_key_id, t.model, t.tokens, t.cached_tokens, t.output_tokens, \
t.pts, t.type, t.status, t.time, \
"SELECT t.id, t.key_id, t.api_key_id, t.model, t.tokens, t.cached_tokens, t.output_tokens, \
t.pts, t.type, t.status, t.time, t.row_count, \
CASE WHEN k.note <> '' THEN k.note \
WHEN k.plan <> '' THEN k.provider || ' / ' || k.plan \
ELSE k.provider END AS key_label, \
u.name AS user_name, \
ak.name AS key_name \
FROM transactions t \
FROM {} \
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 \
WHERE {list_where} \
ORDER BY {order} LIMIT ?{} OFFSET ?{}",
tx_facts::source("t"),
n + 1,
n + 2
);
let mut stmt = conn.prepare(&list_sql).map_err(internal)?;
let rows: Vec<serde_json::Value> = stmt
.query_map(params_from_iter(list_binds.iter()), |r| {
let time: String = r.get(11)?;
let tokens: f64 = r.get(5)?;
let cached: f64 = r.get(6)?;
let output: f64 = r.get(7)?;
let time: String = r.get(10)?;
let tokens: f64 = r.get(4)?;
let cached: f64 = r.get(5)?;
let output: f64 = r.get(6)?;
let input = tokens - cached - output;
Ok(serde_json::json!({
// 行身份:汇总臂是汇总行主键、明细臂是明细行主键(见 `tx_facts::IDENTITY`)。
// 前端交易语境不读它,它只保证翻页边界确定 —— 所以汇总行的这个值**不是**
// 「哪一笔交易」,页面也不得据此宣称任何逐条身份。
"id": r.get::<_, i64>(0)?,
"counterpart": r.get::<_, String>(1)?,
"key_id": r.get::<_, Option<i64>>(2)?,
"api_key_id": r.get::<_, Option<i64>>(3)?,
"model": r.get::<_, String>(4)?,
"key_id": r.get::<_, Option<i64>>(1)?,
"api_key_id": r.get::<_, Option<i64>>(2)?,
"model": r.get::<_, String>(3)?,
"tokens": tokens,
"input_tokens": input,
"cached_tokens": cached,
"output_tokens": output,
"pts": r.get::<_, f64>(8)?,
"type": r.get::<_, String>(9)?,
"status": r.get::<_, String>(10)?,
"pts": r.get::<_, f64>(7)?,
"type": r.get::<_, String>(8)?,
"status": r.get::<_, String>(9)?,
"time": crate::dao::utc_iso(&time),
// 本行代表多少条真实明细(验收项 2):汇总行的 `row_count`,明细尾巴恒 1。
// 页面据此把汇总行说清楚,而不是把它当作单条真实明细。
"row_count": r.get::<_, i64>(11)?,
"key_label": r.get::<_, Option<String>>(12)?,
"user_name": r.get::<_, Option<String>>(13)?,
"key_name": r.get::<_, Option<String>>(14)?,
Expand All @@ -577,9 +588,10 @@ pub async fn transactions(
.map_err(internal)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(internal)?;
// 明细保留边界(`tx_rollup::detail_since`,与删除同一算式):早于它的明细不保证还逐条在
// `transactions` 里 —— 聚合(summary/trend)答得出、本列表答不出的分界就是它。
// 折叠关掉时**没有**删除发生,也就没有这条边界 ⇒ 不发(`null`),页面不会去说一件没发生的事。
// 明细保留边界(`tx_rollup::detail_since`,与删除同一算式):早于它的**明细**不保证还逐条在
// `transactions` 里(它们仍以分钟汇总行的形式列得出来 —— 列表读的正是读模型)。
// 它同时是「折叠开着」的**同义信号**:折叠关掉时既没有删除、也没有汇总行 ⇒ 不发(`null`),
// 页面不会去说一件没发生的事,也不会去解释不存在的汇总行。
let detail_since: Option<String> = if st.cfg.rollup.enabled {
Some(tx_rollup::detail_since(&conn, st.cfg.rollup.window_minutes()).map_err(internal)?)
} else {
Expand Down
Loading
Loading