From fd452d229a5ba82c84bdd85716609a8c58cbf980 Mon Sep 17 00:00:00 2001 From: argszero Date: Thu, 15 Oct 2026 18:33:05 +0800 Subject: [PATCH] feat(transactions): serve the transactions list from the tx_facts read model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail-retention window is about to shrink from days to minutes, and until now the page could not survive that: `GET /api/transactions` read raw `transactions`, so "details are kept for a short while" meant "the transactions page can only answer that short while". Everything the list renders already exists on the read model — `counterpart` has no consumer in the page, and the aggregate row's primary key is never read back. The list and its `total` now both come from `tx_facts` (folded minute roll-ups UNION the not-yet-folded tail), so older history is still listed — as minute roll-ups — instead of disappearing. `total` counts view rows (`COUNT(*)`), which is exactly what the pager pages through, while `summary.entries` keeps counting ledger records (`SUM(row_count)`); the two are deliberately different numbers and the gap is the folding itself. Each row now carries `row_count` (how many real records it stands for; the detail tail is always 1) and the page renders `×N` next to the type when it exceeds 1, so a roll-up row is never presented as a single call. A row's `id` is row identity for LIMIT/OFFSET only — it is not "which transaction", and the front-end does not read it. The seam between the two arms is deliberate and documented: because folding goes in `id` batches, one minute can be half folded, and that minute then appears as a roll-up row plus its tail rows. The halves are disjoint, no record is counted twice, and `SUM(row_count)` over the minute still equals its real record count (`tx_facts.rs::a_minute_folded_in_half_still_adds_up` pins this). Default sort moves from `t.id DESC` to `t.time DESC, t.id DESC`: the two arms keep separate id spaces, so ordering by id would sort by the roll-up table's primary key rather than by time. `fmtPrecise()` no longer invents seconds for a minute-only source string, and the `tx_facts` gate is rewritten — R2 now requires every view read to declare its grain (aggregate / per-row with `row_count` / explicit row count) and the new R4 requires the list to actually read the view, so moving it back to the raw table turns the gate red. --- docs/architecture.md | 7 +- src/i18n_pack.rs | 8 +- src/routes/mod.rs | 11 ++- src/routes/wallet.rs | 78 ++++++++++-------- src/tx_facts.rs | 149 +++++++++++++++++++++++++++++----- src/tx_facts_gate.rs | 187 +++++++++++++++++++++++++++++++------------ src/tx_rollup.rs | 10 ++- ui/README.md | 56 ++++++++++--- ui/index.html | 4 +- ui/js/app.js | 69 ++++++++++++---- ui/js/i18n.js | 4 + 11 files changed, 439 insertions(+), 144 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 963aa81..85f4483 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 起) | @@ -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)时一行不删**。 diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index 507f303..b3e6959 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -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 { diff --git a/src/routes/mod.rs b/src/routes/mod.rs index ce44ecd..36f2148 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -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(), @@ -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, diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index 80506b3..1b66d79 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -118,14 +118,18 @@ fn tx_sort_expr(key: &str) -> Option { /// 解析 `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 { // ⚠️ 400 的信封**写在调用点**(`json!({ "error": … })`,与仓内其余 60 余处同形), // 不要收回成「接收 `String` 的助手/闭包」:那样 `"error"` 的值就成了**裸标识符**, @@ -140,7 +144,7 @@ fn tx_order_by(sort: Option<&str>, dir: Option<&str>) -> Result }; 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() { @@ -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()), @@ -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 = 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>(2)?, - "api_key_id": r.get::<_, Option>(3)?, - "model": r.get::<_, String>(4)?, + "key_id": r.get::<_, Option>(1)?, + "api_key_id": r.get::<_, Option>(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>(12)?, "user_name": r.get::<_, Option>(13)?, "key_name": r.get::<_, Option>(14)?, @@ -577,9 +588,10 @@ pub async fn transactions( .map_err(internal)? .collect::>>() .map_err(internal)?; - // 明细保留边界(`tx_rollup::detail_since`,与删除同一算式):早于它的明细不保证还逐条在 - // `transactions` 里 —— 聚合(summary/trend)答得出、本列表答不出的分界就是它。 - // 折叠关掉时**没有**删除发生,也就没有这条边界 ⇒ 不发(`null`),页面不会去说一件没发生的事。 + // 明细保留边界(`tx_rollup::detail_since`,与删除同一算式):早于它的**明细**不保证还逐条在 + // `transactions` 里(它们仍以分钟汇总行的形式列得出来 —— 列表读的正是读模型)。 + // 它同时是「折叠开着」的**同义信号**:折叠关掉时既没有删除、也没有汇总行 ⇒ 不发(`null`), + // 页面不会去说一件没发生的事,也不会去解释不存在的汇总行。 let detail_since: Option = if st.cfg.rollup.enabled { Some(tx_rollup::detail_since(&conn, st.cfg.rollup.window_minutes()).map_err(internal)?) } else { diff --git a/src/tx_facts.rs b/src/tx_facts.rs index b7ff0ef..787cdc8 100644 --- a/src/tx_facts.rs +++ b/src/tx_facts.rs @@ -17,13 +17,15 @@ //! ``` //! //! **列集完全由 `tx_rollup::{DIMENSION, ADDITIVE}` 派生**(本模块不另立一份列名清单): -//! 维度 7 列 + 可加 5 列 ⇒ 视图 12 列。三处**刻意不相同的映射**,都由下面的常量钉住: +//! 行身份 1 列([`IDENTITY`])+ 维度 7 列 + 可加 5 列 ⇒ 视图 13 列。三处**刻意不相同的映射**, +//! 都由下面的常量钉住: //! //! | 维度列 | 汇总臂取值 | 明细臂取值 | 为什么 | //! |---|---|---|---| +//! | `id` | `id`(汇总行主键) | `t.id`(明细行主键) | 分页要一个**唯一**排序键(见 [`IDENTITY`])。 | //! | `key_id` / `api_key_id` | `NULLIF(, 0)` | `t.`(保留 NULL) | 汇总表为让 NULL 在唯一索引里相撞,用 `0` 当哨兵(`IFNULL(…, 0)`)。若不还原,`GROUP BY key_id` 会把「没有 key」的行归到 `id = 0` 这一组 —— 分组键**变了**,尽管 `SUM` 不变(实测:`(None, 6.0)` 变 `(0, 6.0)`)。 | //! | `bucket` | `bucket`(16 字符分钟串) | `t.time`(19 字符整秒) | 汇总行**没有比分钟更细的时刻**可用;明细侧保留原值,见下「分钟对齐」。 | -//! | `row_count` | `row_count` | `1` | 明细一行就是一行;汇总行带的是它压了多少行 —— 所以**数行数必须 `SUM(row_count)`,不得 `COUNT(*)`**(后者数的是「视图有几行」,即分钟桶数)。 | +//! | `row_count` | `row_count` | `1` | 明细一行就是一行;汇总行带的是它压了多少行 —— 所以**数「条数」必须 `SUM(row_count)`,不得 `COUNT(*)`**(后者数的是「视图有几行」,即分钟桶数)。 | //! //! ## 分钟对齐(读侧的时间窗契约) //! @@ -39,27 +41,44 @@ //! //! ## 射程(如实) //! -//! - 本视图服务的是**聚合读数**:`SUM` / `GROUP BY` / 数行数。它**不是**明细列表 —— -//! 汇总行没有 `id`、没有 `counterpart`,一行代表 N 条调用。分页交易列表因此**仍读 -//! `transactions`**(`delete_folded` 已落地,两者在保留窗口之外会分叉 —— 披露见下表)。 +//! - 本视图服务**两件事**:聚合读数(`SUM` / `GROUP BY`)与**分页明细列表** +//! (`/api/transactions`,rant 2026-10-15T16:25:04.530850+08:00 验收项 2)。列表从视图取, +//! 于是「明细只留一小段」不再意味着「交易页只能答出那一小段」—— 更早的历史以**分钟级汇总行** +//! 的形式照样列得出来。 +//! - 列表与聚合的**粒度不同、口径相同**:列表一行是「一个分钟桶 × 一组维度值」(或尚未折叠的 +//! 一条明细),聚合一行是「一条账本记录」。所以**列表的 `total` 是行数、`summary.entries` 是 +//! 条数**,两者刻意不是同一个数(差额就是折叠 —— 由 `row_count` 说给页面)。 +//! - 视图**没有 `counterpart`**:对手方标识在汇总行上不存在,且前端交易语境零消费点 +//! (`ui/js/app.js`)。列表因此不再发布它(`tx_facts_gate` R2 盯着「读视图的 SQL 不得点它」)。 +//! - **分钟桶的交界不沉默**(rant §3.2):折叠按 `id` 分批 ⇒ 某一分钟可能**一半已折进桶、 +//! 一半还留在尾巴**(`id ≤ 水位` 的那部分在汇总行里,`id > 水位` 的那部分在明细臂里)。 +//! 那一分钟于是**在列表里出现不止一行**:一行汇总(`row_count` = 折进去的那几条)+ 若干行 +//! 明细(`row_count` = 1 各一条)。这是**有意的呈现**,不是重复:两截互斥、无一行被算两次, +//! `SUM(row_count)` 仍等于那一分钟的真实明细条数(`a_minute_folded_in_half_still_adds_up` +//! 在夹具上钉住这条),而「这一行代表几条明细」由每一行自己用 `row_count` / `×N` 交代 —— +//! 用户不会把汇总行当成单条真实明细,也不会看到凭空多出来或凭空少掉的记录。 +//! 交界行只在**水位所在的那一分钟**存在,且随折叠推进每分钟只出现一次(水位单调、一旦 +//! 越过该分钟,那一分钟的尾巴行就被折进桶里,两行合一)。 //! - 账本(`SUM(transactions)` 按 `type` 判方向,C2050)**不**走本视图:账单是权威, //! 它读原始表。 //! - 视图的**定义**是代码(每次开库 `DROP`+`CREATE`)⇒ 不存在「旧库里的定义陈旧」这一态; //! 代价是每次开库多一条 DDL。 //! -//! ## 还有哪些生产聚合仍在读原始表(本切片**故意**没搬) +//! ## 还有哪些生产读数仍在读原始表(**故意**没搬) //! //! 删除落地时,这张清单上每一条要么搬进视图、要么被证明「不可能读到被删的那段」: //! //! | 站点 | 为什么不搬 | //! |---|---| -//! | `routes/wallet.rs` 的分页列表与 `total` | 要的是**逐行身份**(`id`/`counterpart`),汇总行没有;`total` 必须与列表同源,否则页码数对不上能翻出来的行数。**删除按保留窗口**放行,窗口必须盖住列表自己提供的每一个预设区间(`tx_retention_gate` 守这条)。⚠️ 但它不是「最近一段」的读者就完事了:`#tx-range` 还提供**无界**的区间(`全部时间` / 自定义起点),它们可以整段落在窗口之外 —— 那时明细答不出、聚合答得出。这段分叉**不许沉默**:`/api/transactions` 随响应发布保留边界(`tx_rollup::detail_since`)与账本条数(`summary.entries`),前端零行态据此说明原因(`tx_retention_gate` 第四条规则守这条)。 | //! | `db.rs::keys_used_from_ledger` | `v < 13` 的一次性修复步:真源是**账本**,而它只可能在「还没有汇总表」的老库上跑 —— 那时一行明细都没被折叠过,更没被删过。 | //! | `tx_rollup.rs` / `archive.rs` | 折叠与归档**本来就**读明细 —— 它们是明细的写者。 | //! | `billing.rs` / `gateway.rs` 的 `COUNT(*)` | 那些在 `#[cfg(test)]` 里(断言 settle 写了行),非生产读数。 | //! //! `routes/sharing.rs::row_select()` 的 earn 批量聚合**曾**在这张清单上,已随删除切片搬进视图 //! (常量 `ROW_SELECT` 改成函数 `row_select()`,计划断言一并改写)。 +//! `routes/wallet.rs` 的分页列表与它的 `total` **也曾**在这张清单上,已随验收项 2 搬进视图 +//! —— 它当初「要逐行身份、汇总行没有」的理由随 [`IDENTITY`] 与「列表按分钟汇总展示」这条 +//! 产品决定消失,`tx_retention_gate` 的第一条规则(窗口必须盖住列表的预设区间)也随之改写。 use anyhow::{Context, Result}; use rusqlite::Connection; @@ -73,6 +92,20 @@ pub fn source(alias: &str) -> String { format!("{VIEW} {alias}") } +/// 视图的**行身份**列 —— 两条臂各自的主键(汇总臂 `transactions_rollup.id`,明细臂 +/// `transactions.id`)。 +/// +/// 它**不是**交易身份:汇总行代表 N 条调用,它答不出「这是哪一笔」。分页列表要的正是 +/// 「每行一个可翻的位次」,而 `LIMIT/OFFSET` 需要一个**唯一**排序键 —— 少了它,并列的行 +/// 在相邻页的边界上不确定(同一行出现两次、另一行永不出现,R164 轴)。 +/// +/// 两条臂的 `id` 各自唯一,且**永不互相撞车**:汇总臂的时间列是 16 字符的分钟桶、明细臂是 +/// 19 字符(见「分钟对齐」)⇒ 任何两行只要来自不同的臂,时间串就不相等,`(time, id)` 于是唯一。 +/// +/// 汇总臂的 `id` **从来不渲染**:前端不读它(`ui/js/app.js` 交易语境零消费点),CSV 的 +/// 11 列里也没有它。它只活在 `ORDER BY` 的收尾里。 +const IDENTITY: &str = "id"; + /// 明细臂取值:凡 `DIMENSION` 里**表达式不是裸列名**的列,必须在此显式给出明细侧的取值。 /// /// 明细侧一律取**原值**(不做汇总表那套 NULL 归一、也不做分钟截断)—— 这一条正是 @@ -131,9 +164,12 @@ fn detail_value(col: &str) -> String { /// (它连测试目标一起编,于是这个函数在它眼里「被读过」)。 #[cfg(test)] pub fn view_columns() -> Vec { - crate::tx_rollup::DIMENSION - .iter() - .map(|(c, _)| view_column(c).to_string()) + std::iter::once(IDENTITY.to_string()) + .chain( + crate::tx_rollup::DIMENSION + .iter() + .map(|(c, _)| view_column(c).to_string()), + ) .chain( crate::tx_rollup::ADDITIVE .iter() @@ -144,18 +180,24 @@ pub fn view_columns() -> Vec { /// `CREATE VIEW` 语句 —— **从 `tx_rollup` 的声明渲染**,不另立列名清单。 pub fn view_sql() -> String { - let rollup: Vec = crate::tx_rollup::DIMENSION - .iter() - .map(|(c, e)| format!("{} AS {}", rollup_value(c, e), view_column(c))) + let rollup: Vec = std::iter::once(IDENTITY.to_string()) + .chain( + crate::tx_rollup::DIMENSION + .iter() + .map(|(c, e)| format!("{} AS {}", rollup_value(c, e), view_column(c))), + ) .chain( crate::tx_rollup::ADDITIVE .iter() .map(|(c, _)| (*c).to_string()), ) .collect(); - let detail: Vec = crate::tx_rollup::DIMENSION - .iter() - .map(|(c, _)| format!("{} AS {}", detail_value(c), view_column(c))) + let detail: Vec = std::iter::once(format!("{} AS {IDENTITY}", detail_value(IDENTITY))) + .chain( + crate::tx_rollup::DIMENSION + .iter() + .map(|(c, _)| format!("{} AS {}", detail_value(c), view_column(c))), + ) .chain( crate::tx_rollup::ADDITIVE .iter() @@ -438,8 +480,9 @@ mod tests { ); assert_eq!( cols.len(), - crate::tx_rollup::DIMENSION.len() + crate::tx_rollup::ADDITIVE.len(), - "维度 + 可加量的条数变了" + // 行身份 1 列(`id`)+ 维度 + 可加量。 + 1 + crate::tx_rollup::DIMENSION.len() + crate::tx_rollup::ADDITIVE.len(), + "行身份 + 维度 + 可加量的条数变了" ); assert!( cols.contains(&"time".to_string()) && cols.contains(&"row_count".to_string()), @@ -488,6 +531,76 @@ mod tests { ); } + /// **轴**:某一分钟被**折到一半**时,视图里那一分钟同时住着汇总行与尾巴行 —— + /// 两行合一仍然是那一分钟的全部分量(不丢行、不重复计数)。 + /// + /// 这是 rant §3.2「分钟桶的交界不许沉默」的夹具:交界**本来就是两行**(汇总一截 + 尾巴 + /// 一截),所以判据不是「只有一行」,而是「那两行加起来仍等于真实明细」,且**行数 > 1** + /// 这件事本身可见(每行自己的 `row_count` / `×N` 就是那个说明)。 + #[test] + fn a_minute_folded_in_half_still_adds_up() { + let conn = migrated_db(); + seed(&conn); + let minute = "2026-10-08 13:56"; + let details_in_minute = |c: &Connection| -> i64 { + c.query_row( + "SELECT COUNT(*) FROM transactions WHERE substr(time, 1, 16) = ?1", + params![minute], + |r| r.get(0), + ) + .unwrap() + }; + // 只折**一批**(2 条,恰好都落在同一分钟)⇒ 那一分钟被折到一半。 + assert_eq!( + fold_pending(&conn, i64::MAX, 2).unwrap(), + 2, + "夹具:本批应当恰好折掉落在这分钟的 2 条" + ); + let before = details_in_minute(&conn); + assert!( + before > 2, + "夹具:这一分钟里必须还有没被折掉的尾巴,否则踩不到交界(实得 {before})" + ); + + // 交界:这一分钟在视图里**不止一行**(汇总一截 + 尾巴一截)。 + let rows: i64 = conn + .query_row( + &format!("SELECT COUNT(*) FROM {VIEW} WHERE substr(time, 1, 16) = ?1"), + params![minute], + |r| r.get(0), + ) + .unwrap(); + assert!( + rows > 1 && rows < before, + "交界应当让这一分钟多出一行汇总(行数应介于 1 与真实条数之间):{rows} 行 / {before} 条" + ); + + // 不丢、不重:这一分钟的 `SUM(row_count)` 仍等于真实明细条数。 + let counted: i64 = conn + .query_row( + &format!( + "SELECT COALESCE(SUM(row_count), 0) FROM {VIEW} WHERE substr(time, 1, 16) = ?1" + ), + params![minute], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(counted, before, "交界处的条数必须仍等于真实明细条数"); + + // 全库同理(交界不会让任何一行被算两次或漏掉)。 + let all: i64 = conn + .query_row( + &format!("SELECT COALESCE(SUM(row_count), 0) FROM {VIEW}"), + [], + |r| r.get(0), + ) + .unwrap(); + let all_details: i64 = conn + .query_row("SELECT COUNT(*) FROM transactions", [], |r| r.get(0)) + .unwrap(); + assert_eq!(all, all_details, "折叠不可见:视图的条数恒等于明细行数"); + } + /// **事实**:视图的**行数**不是调用数 —— 数调用只能 `SUM(row_count)`。 /// /// `transactions_trend` 的 `count` 因此用 `SUM(t.row_count)` 而不是 `COUNT(*)` diff --git a/src/tx_facts_gate.rs b/src/tx_facts_gate.rs index 2f6cf3e..d166a61 100644 --- a/src/tx_facts_gate.rs +++ b/src/tx_facts_gate.rs @@ -1,35 +1,49 @@ -//! 交易事实读模型门禁(R97):视图 `tx_facts` 的**拼写、读法与时间窗**。 +//! 交易事实读模型门禁(R97):视图 `tx_facts` 的**拼写、读法、时间窗,以及列表是否真的走它**。 //! //! `src/tx_facts.rs` 把「已折叠的分钟级汇总行」与「尚未折叠的明细」合成一个读模型视图, //! 好让**删掉已折叠的明细**对聚合读数不可见。视图本身只是一段 SQL 文本,它兑现承诺 -//! 与否由三条**调用侧**的形状决定 —— 三条都不在类型系统里,只能由本门禁守: +//! 与否由四条**调用侧**的形状决定 —— 四条都不在类型系统里,只能由本门禁守: //! //! * **R1 拼写**:视图名只有一个拼写点(`tx_facts::VIEW` 之后**放进 SQL 的那一处**), //! 生产 SQL 一律经 `tx_facts::source()` 取它。**字符串字面量**里手写视图名 ⇒ 一个 //! 新的载体出现了:改名/换形态时它会静默留在原地(`FROM tx_facts` 这种写法尤其隐蔽, //! 它不单独成串)。判据是「每个**点了这个名字的字符串字面量**都必须住在 //! `tx_facts.rs` 里」,因此不靠「名字 = 整个字面量」这个过窄的形状。 -//! * **R2 读法**:读视图的查询必须是**聚合**,且**不得数行数**。视图的一行是「一个分钟桶」, -//! 而 `row_count` 才是「这个桶压了多少条调用」 ⇒ `COUNT(*)` 数的是桶数。这一条有分母: -//! `tx_facts.rs::counting_view_rows_is_not_counting_calls` 用夹具钉住了两者不相等 -//! (事实归测试,这里只守**形状**:读视图的那段 SQL 里不许出现 `COUNT(`)。 +//! * **R2 读法**:读视图的每一处都必须**声明它读的是什么粒度**。视图的一行是「一个分钟桶 +//! **或**一条尚未折叠的明细」,**不是**一条调用 ⇒ 说得通的形状只有三种:① **聚合** +//! (含 `SUM(`)—— `summary` / `trend` / 账本读数 / 共享收益;② **逐行**(含 `row_count`) +//! —— 分页列表:每行自报它代表多少条真实明细(rant 2026-10-15T16:25:04.530850+08:00 +//! 验收项 2「每行带计数」);③ **数行数**(`COUNT(`)—— 分页的 `total`:数的是「视图有 +//! 几行」,即这一页翻得出多少行,与列表**同源**是它成立的前提(验收项 3)。 +//! 三者都没有 ⇒ 有人把视图当成「一行一条调用」的表在读(既不聚合、也不自报行数)。 +//! ⚠️ `COUNT(` **曾是**本门禁的牙:旧契约是「分页列表留在原始表上」,读视图只有聚合一种 +//! 形状,`COUNT(*)` 只可能是「把桶数当条数」。验收项 2/3 翻转了那个前提 —— 列表现在**必须** +//! 数视图的行数 —— 于是这条牙改守它真正要守的那半:「**条数**一律 `SUM(row_count)`」, +//! 而那已是**事实**(分母:`tx_facts.rs::counting_view_rows_is_not_counting_calls` 钉住 +//! 两者不相等),不再是形状。形状侧留下的是**声明粒度**:三者缺一即红。 //! 同理视图没有 `counterpart`(展示用的对手方标识在汇总行上不存在)——读视图的 SQL -//! 一旦点它,说明有人在拿视图当**明细列表**用(分页列表必须留在原始表上)。 +//! 一旦点它,说明有人在拿视图当**明细列表**用,而汇总行给不出它。 //! * **R3 时间窗**:读侧的时间界必须过 `tx_facts::minute_aligned()`,且那个截断点**长在 //! `tx_where` 里**。汇总臂的时间列只有 16 字符(分钟桶),明细臂 19 字符 //! (`YYYY-MM-DD HH:MM:SS`);带秒的界会让两条臂对同一个桶给出不同答案(短前缀更小), //! 于是折叠前后读数不同 —— 而前端发的正是 `toISOString()`(秒几乎从不为 0)。 +//! * **R4 列表在视图上**(验收项 2 的**存在性**):视图必须真的在服务分页列表 —— 一处逐行 +//! 读法(带 `row_count`)**且**一处行数统计(`COUNT(`)。R1/R2/R3 全是「不许」,只剩它们时 +//! 「列表搬回原始表」这一态照样全绿(聚合读法一处都没动)—— 门禁就守不住本轮的成果。 +//! R4 是它的反面:列表走了,两条见证都没了,红。这正是验收项 5 的变异腿「把列表改回读 +//! 原始表,门禁必须变红」。⚠️ 它要的是**见证存在**(每类至少一处),不是把调用点总数钉死。 //! //! # 射程(如实) //! -//! * **词法的**:它证「源码里那三处形状成立」,**不证**屏幕/接口上的数对 —— 数值等价由 +//! * **词法的**:它证「源码里那四处形状成立」,**不证**屏幕/接口上的数对 —— 数值等价由 //! `tx_facts.rs` 的真库测试(视图 vs 明细,三个折叠状态)与 `cargo test` 一起兜。 //! * 语料 = `body_limit_gate::source_files()`(走盘 `src/**`、跳过门禁模块自身); //! 掩码 = `deploy_gate::code_mask()`(**等长**掩码,偏移量与原文一一对应)。 //! 两处都**复用**,不另立一份机械(`js_gate::mask_js` 是同一个先例)。 //! * **不写快照**:期望值全部从制品推导(视图名取自 `tx_facts::VIEW`,语料走盘, //! 调用点由锚点现找);读视图的**调用点数刻意不钉死** —— 本门禁遍历全部调用点, -//! 新增读者自动进射程,钉一个数反而会变成第二个需要同步的载体。 +//! 新增读者自动进射程,钉一个数反而会变成第二个需要同步的载体。R4 要的是**见证存在** +//! (逐行读法、行数统计各至少一处),同样不钉总数。 //! * **阳性对照**:`the_scanner_sees_the_carriers_it_judges` 断言扫描器确实看见了视图名的 //! 那个字面量、至少一个读视图的调用点、以及那一个截断点 —— 否则「零违规」与 //! 「扫描器是瞎的」在读数上无法区分(坑 #814)。 @@ -52,6 +66,9 @@ const WHERE_FN: &str = "fn tx_where("; /// 视图上不存在、只属于明细的列 —— 读视图的 SQL 点到它,就是把视图当明细列表用。 const DETAIL_ONLY: &str = "counterpart"; +/// 「这一行代表多少条真实明细」的列 —— 逐行读法**必须**带着它(R2 的第二种声明、R4 的第一条见证)。 +const ROW_COUNT: &str = "row_count"; + /// 一次扫描的读数 —— 判决与它的证据一起产出(一个藏在脚注里的期望会报出自我一致的谎)。 #[derive(Debug, Clone, Default, PartialEq, Eq)] struct Reading { @@ -61,10 +78,12 @@ struct Reading { name_sites: usize, /// 读视图的调用点数。 view_reads: usize, - /// 读视图却不含 `SUM(` 的调用点(应 0)。 - non_additive: Vec, - /// 读视图却数行数(`COUNT(`)的调用点(应 0)。 + /// 读视图却没声明粒度的调用点(应 0):既不含 `SUM(`、也不读 `row_count`、也不数行数。 + undeclared: Vec, + /// 读视图且数行数(`COUNT(`)的调用点 —— 分页的 `total` 正是它(R4 的第二条见证)。 row_counting: Vec, + /// 读视图且**逐行**读、带着 `row_count` 的调用点 —— 分页列表正是它(R4 的第一条见证)。 + row_carrying: Vec, /// 读视图却点了明细专属列的调用点(应 0)。 detail_only: Vec, /// 拿不到外层 `format!(…)` 的调用点(应 0:取不到就等于那个调用点**没有判据**)。 @@ -81,12 +100,10 @@ impl Reading { self.name_sites == 1 && self.name_homes == [OWNER.to_string()] } - /// R2:读视图的每一处都是可加聚合,且不数行数、不点明细专属列;每处都读得懂。 + /// R2:读视图的每一处都声明了粒度(聚合 / 逐行带 `row_count` / 数行数),不点明细专属列, + /// 且每处都读得懂。 fn r2(&self) -> bool { - self.non_additive.is_empty() - && self.row_counting.is_empty() - && self.detail_only.is_empty() - && self.unreadable.is_empty() + self.undeclared.is_empty() && self.detail_only.is_empty() && self.unreadable.is_empty() } /// R3:时间窗的截断点唯一,且长在 `tx_where` 里。 @@ -94,22 +111,30 @@ impl Reading { self.aligns == 1 && self.aligned_in_where } + /// R4:分页列表真的走读模型 —— 逐行读法(带 `row_count`)与它同源的行数统计都在。 + fn r4(&self) -> bool { + !self.row_carrying.is_empty() && !self.row_counting.is_empty() + } + fn ok(&self) -> bool { - self.r1() && self.r2() && self.r3() + self.r1() && self.r2() && self.r3() && self.r4() } fn report(&self) -> String { format!( - "r1={} r2={} r3={} | 名字面量 {} 处/{} 个文件 {:?} · 读视图 {} 处 · 非可加 {:?} · \ - 数行数 {:?} · 明细专属 {:?} · 读不懂 {:?} · 对齐 {} 处(在 {WHERE_FN} 内 {})", + "r1={} r2={} r3={} r4={} | 名字面量 {} 处/{} 个文件 {:?} · 读视图 {} 处 · 未声明粒度 {:?} · \ + 逐行带 {ROW_COUNT} {:?} · 数行数 {:?} · 明细专属 {:?} · 读不懂 {:?} · \ + 对齐 {} 处(在 {WHERE_FN} 内 {})", self.r1(), self.r2(), self.r3(), + self.r4(), self.name_sites, self.name_homes.len(), self.name_homes, self.view_reads, - self.non_additive, + self.undeclared, + self.row_carrying, self.row_counting, self.detail_only, self.unreadable, @@ -251,12 +276,19 @@ fn judge(corpus: &[(String, String)]) -> Reading { r.view_reads += 1; match enclosing_format(text, &structure, at) { Some(span) => { - if !span.contains("SUM(") { - r.non_additive.push(format!("{path}: {}", one_line(span))); - } - if counts_rows(span) { + // 粒度的三种声明(R2)。逐行读法**必须**带 `row_count` —— 视图的一行是一个 + // 分钟桶或一条明细,不是一条调用;不自报行数就等于把桶当成一条真调用。 + let aggregate = span.contains("SUM("); + let counting = counts_rows(span); + if counting { r.row_counting.push(format!("{path}: {}", one_line(span))); } + if span.contains(ROW_COUNT) && !aggregate && !counting { + r.row_carrying.push(format!("{path}: {}", one_line(span))); + } + if !aggregate && !counting && !span.contains(ROW_COUNT) { + r.undeclared.push(format!("{path}: {}", one_line(span))); + } if span.contains(DETAIL_ONLY) { r.detail_only.push(format!("{path}: {}", one_line(span))); } @@ -322,9 +354,10 @@ fn the_scanner_sees_the_carriers_it_judges() { println!("{}", r.report()); } -/// **轴**:视图名只有一个拼写点、读它的每一处都是可加聚合、时间窗只有一个截断点。 +/// **轴**:视图名只有一个拼写点、读它的每一处都声明了粒度、时间窗只有一个截断点, +/// 且分页列表真的走它。 #[test] -fn the_view_is_named_once_and_read_only_by_additive_aggregates() { +fn the_view_is_named_once_and_read_in_declared_grains() { let r = judge(&corpus()); assert!( r.r1(), @@ -333,7 +366,8 @@ fn the_view_is_named_once_and_read_only_by_additive_aggregates() { ); assert!( r.r2(), - "读视图的查询里有非可加 / 数行数 / 点明细专属列 / 读不懂的调用点:{}", + "读视图的查询里有没声明粒度的(既不聚合、也不带 `{ROW_COUNT}`、也不数行数)/ \ + 点明细专属列 / 读不懂的调用点:{}", r.report() ); assert!( @@ -341,6 +375,12 @@ fn the_view_is_named_once_and_read_only_by_additive_aggregates() { "时间窗的分钟对齐不止一处、或不在 `{WHERE_FN}` 里:{}", r.report() ); + assert!( + r.r4(), + "读模型没有在服务分页列表 —— 逐行读法(带 `{ROW_COUNT}`)与它同源的行数统计(`COUNT(`)\ + 必须各有一处:列表搬回原始表了?{}", + r.report() + ); } // --------------------------------------------------------------------------------------------- @@ -356,10 +396,22 @@ fn owner_fixture() -> String { /// (内含分钟对齐)与一个读视图的聚合查询。 /// /// `select` 是那个查询的选择列表,`fn_name` 用来把截断点搬进/搬出 `tx_where`。 +/// `with_list` 决定语料里有没有**分页列表的两条见证**(逐行读法带 `row_count` + 行数统计 +/// `COUNT(`)—— R4 的变异腿就是把它们去掉。 /// /// ⚠️ 配对必须真的平衡:`format!(…, tx_facts::source("t"))` 是**两**层括号,少一层时 /// `matched()` 报 `None`,症状是「扫描器读不懂语料」——看着像门禁坏了,其实是夹具写错了。 -fn fixture(fn_name: &str, select: &str) -> Vec<(String, String)> { +fn fixture_with(fn_name: &str, select: &str, with_list: bool) -> Vec<(String, String)> { + let list = if with_list { + format!( + "fn tx_list(...) {{\n \ + let sql = format!(\"SELECT t.id, t.{ROW_COUNT} FROM {{}} WHERE {{w}}\", {CALL}\"t\"));\n}}\n\ + fn tx_total(...) {{\n \ + let sql = format!(\"SELECT COUNT(*) FROM {{}} WHERE {{w}}\", {CALL}\"t\"));\n}}\n" + ) + } else { + String::new() + }; vec![ (OWNER.to_string(), owner_fixture()), ( @@ -368,12 +420,18 @@ fn fixture(fn_name: &str, select: &str) -> Vec<(String, String)> { "fn {fn_name}(...) {{\n \ binds.push(rusqlite::types::Value::Text({ALIGN}s)));\n}}\n\ fn summary(...) {{\n \ - let sql = format!(\"SELECT {select} FROM {{}} {{}} WHERE {{w}}\", {CALL}\"t\"));\n}}\n" + let sql = format!(\"SELECT {select} FROM {{}} {{}} WHERE {{w}}\", {CALL}\"t\"));\n}}\n\ + {list}" ), ), ] } +/// 带分页列表见证的合成语料(各条 R1/R2/R3 牙都建在它上面,于是那些牙只翻自己那一条)。 +fn fixture(fn_name: &str, select: &str) -> Vec<(String, String)> { + fixture_with(fn_name, select, true) +} + /// 对照语料(全绿)。 fn good_fixture() -> Vec<(String, String)> { fixture("tx_where", "COALESCE(SUM(t.pts), 0)") @@ -399,7 +457,7 @@ fn a_second_file_writing_the_view_name_reddens_the_spelling_rule() { )); let r = judge(&c); assert!( - !r.r1() && r.r2() && r.r3(), + !r.r1() && r.r2() && r.r3() && r.r4(), "字面量里手写的视图名没被点名(且只该点这一条):{}", r.report() ); @@ -415,7 +473,7 @@ fn a_second_literal_in_the_owner_file_reddens_the_spelling_rule() { )); let r = judge(&c); assert!( - !r.r1() && r.r2() && r.r3(), + !r.r1() && r.r2() && r.r3() && r.r4(), "第二个拼写点没被点名:{}", r.report() ); @@ -433,27 +491,26 @@ fn mentioning_the_module_file_name_is_not_spelling_the_view() { assert!(r.r1(), "把模块文件名当成了视图名的拼写点:{}", r.report()); } -/// **牙(R2)**:读视图却用 `COUNT(*)` ⇒ 数的是分钟桶数而不是调用数。 +/// **牙(R2)·对照**:`COUNT(*)` 今天是**合法声明** —— 分页的 `total` 数的是「视图有几行」, +/// 而它与列表同源正是验收项 3 的要求。这一条钉住那次翻转(旧契约下它是红的): +/// `COUNT(` 不再是违规,「数**条数**必须 `SUM(row_count)`」那半归**事实**管,分母是 +/// `tx_facts.rs::counting_view_rows_is_not_counting_calls`。 #[test] -fn a_view_reader_that_counts_rows_reddens() { - let c = fixture("tx_where", "COALESCE(SUM(t.pts), 0), COUNT(*)"); +fn counting_the_views_rows_is_a_declared_grain() { + let c = fixture("tx_where", "COUNT(*)"); let r = judge(&c); - assert!( - !r.r2() && r.r1() && r.r3(), - "`COUNT(*)` 没被点名:{}", - r.report() - ); + assert!(r.ok(), "`COUNT(*)` 被误判成违规:{}", r.report()); } -/// **牙(R2)**:`row_count` 不是 `COUNT(` —— 加和它是对的,不能被误判。 +/// **牙(R2)·对照**:`SUM(row_count)` 是**聚合**声明,不是逐行读法,也不该被当成数行数。 #[test] -fn summing_the_row_count_is_not_row_counting() { +fn summing_the_row_count_is_a_declared_grain() { let c = fixture( "tx_where", "COALESCE(SUM(t.pts), 0), COALESCE(SUM(t.row_count), 0)", ); let r = judge(&c); - assert!(r.ok(), "`SUM(row_count)` 被误判成数行数:{}", r.report()); + assert!(r.ok(), "`SUM(row_count)` 被误判:{}", r.report()); } /// **牙(R2)**:读视图却点了明细专属列 ⇒ 有人拿视图当明细列表用。 @@ -462,20 +519,21 @@ fn a_view_reader_that_touches_a_detail_only_column_reddens() { let c = fixture("tx_where", "COALESCE(SUM(t.pts), 0), t.counterpart"); let r = judge(&c); assert!( - !r.r2() && r.r1() && r.r3(), + !r.r2() && r.r1() && r.r3() && r.r4(), "明细专属列没被点名:{}", r.report() ); } -/// **牙(R2)**:读视图却不聚合(取单列)⇒ 非可加。 +/// **牙(R2)**:读视图却**不声明粒度** —— 逐行取列却不带 `row_count`(既不聚合、也不数行数) +/// ⇒ 有人把视图当成「一行一条调用」的表在读。 #[test] -fn a_view_reader_that_does_not_aggregate_reddens() { - let c = fixture("tx_where", "t.pts"); +fn a_view_reader_that_declares_no_grain_reddens() { + let c = fixture("tx_where", "t.id, t.pts, t.type"); let r = judge(&c); assert!( - !r.r2() && r.r1() && r.r3(), - "非可加读法没被点名:{}", + !r.r2() && r.r1() && r.r3() && r.r4(), + "没声明粒度的读法没被点名:{}", r.report() ); } @@ -486,12 +544,41 @@ fn an_aligner_outside_the_where_builder_reddens() { let c = fixture("somewhere_else", "COALESCE(SUM(t.pts), 0)"); let r = judge(&c); assert!( - !r.r3() && r.r1() && r.r2(), + !r.r3() && r.r1() && r.r2() && r.r4(), "截断点不在 `{WHERE_FN}` 里却没被点名:{}", r.report() ); } +/// **牙(R4)**:分页列表搬回原始表 —— 语料里不再有逐行读法、也没有行数统计 ⇒ 只翻 R4。 +/// +/// 这是验收项 5 的变异腿在门禁侧的对照:R1/R2/R3 照旧全绿(聚合读法一处都没动), +/// 只有 R4 知道列表已经不在读模型上了。 +#[test] +fn a_list_that_leaves_the_view_reddens_the_list_rule() { + let c = fixture_with("tx_where", "COALESCE(SUM(t.pts), 0)", false); + let r = judge(&c); + assert!( + !r.r4() && r.r1() && r.r2() && r.r3(), + "列表搬走之后 R4 没红(其余三条本该照旧全绿):{}", + r.report() + ); +} + +/// **牙(R4)**:只把**行数统计**搬回原始表(`total` 不再数视图的行数)⇒ 同样只翻 R4 —— +/// 「列表与 `total` 同源」是两条见证,各有一条牙。 +#[test] +fn a_total_that_leaves_the_view_reddens_the_list_rule() { + let mut c = good_fixture(); + c[1].1 = c[1].1.replace("SELECT COUNT(*)", "SELECT SUM(t.pts)"); + let r = judge(&c); + assert!( + !r.r4() && r.r1() && r.r2() && r.r3(), + "`total` 搬走之后 R4 没红:{}", + r.report() + ); +} + /// **牙(掩码)**:注释里逐字引用的视图名与读法**不是**载体。 /// /// 本仓的注释到处在逐字引用代码(本模块的文档就是一例);不掩码的话,一条写着 diff --git a/src/tx_rollup.rs b/src/tx_rollup.rs index 07f47b8..24c71c3 100644 --- a/src/tx_rollup.rs +++ b/src/tx_rollup.rs @@ -190,8 +190,9 @@ pub fn fold_pending(conn: &Connection, archive_watermark: i64, batch: i64) -> Re /// 1. **`id ≤ MIN(汇总水位, 归档水位)`** —— 只删汇总表已经算过、且原件已经进 JSONL 的行。 /// 汇总水位是 `fold_pending` 自己写下的 `up_to_id`,归档水位由调用方传入(与折叠**同源**); /// 取小 ⇒ 归档一旦落后(或归档目录丢失 ⇒ 水位 0)就一行都不删,与折叠同一条 fail-closed 纪律。 -/// 2. **`time < now - retain_minutes`** —— 保留窗口内的明细仍逐条留在库里(交易页翻页、共享页按行 -/// 读的都是 `transactions` 本身)。窗口是**明细**的保留期;**汇总行永远不删**。 +/// 2. **`time < now - retain_minutes`** —— 保留窗口内的明细仍逐条留在库里。窗口是**明细**的保留 +/// 期;**汇总行永远不删**。窗口外的明细不再是交易页的**行粒度**来源(页面对这些分钟只给汇总行), +/// 但它们的量仍逐条算进读模型的 `row_count`(rant 2026-10-15T16:25:04.530850+08:00 验收项 2)。 /// 3. **`LIMIT batch`** —— 单次持库时间有界,后台维护绝不与请求抢锁(与折叠、归档同一条纪律)。 /// /// 按 `id` 升序删 ⇒ 先删最旧的;`id` 单调(`AUTOINCREMENT`,删了也不会被复用),所以扫描一撞到 @@ -202,12 +203,13 @@ pub fn fold_pending(conn: &Connection, archive_watermark: i64, batch: i64) -> Re /// (`delete_folded_leaves_the_read_model_answering_the_same_numbers` 用真库逐列钉住这条)。 /// 明细保留边界(UTC,`YYYY-MM-DDTHH:MM:SSZ`):**早于它的明细不保证还在 `transactions` 里**。 /// -/// 这是「列表答不出来的那段」的**唯一定义处** —— 与 [`delete_folded`] 同一个时钟、同一个算式 +/// 这是「明细不再逐条存在」的**唯一定义处** —— 与 [`delete_folded`] 同一个时钟、同一个算式 /// (SQL 的 `datetime('now', '-N minutes')`,负数按 0 处理)⇒ 删的是它、页面说的是它,两者不可能 /// 各自算出一个边界。读侧只读**边界值**,不做删除判断(那是 `delete_folded` 的事)。 /// /// ⚠️ 边界早于它 ≠ 明细已经没了:删除还受「已折叠 ∧ 已归档」两道闸(见 `delete_folded`)。 -/// 所以本值是**上界**——真正少答的那段只可能落在它之外。调用方据此说明口径,不据此断言缺失。 +/// 所以本值是**上界**——真正被删掉的那段只可能落在它之外。调用方据此说明口径,不据此断言缺失。 +/// 它今天的读者是交易页:边界之外的行粒度**只由汇总行承担**(不丢信息,但一个桶一行)。 pub fn detail_since(conn: &Connection, retain_minutes: i64) -> Result { let window = format!("-{} minutes", retain_minutes.max(0)); conn.query_row( diff --git a/ui/README.md b/ui/README.md index f486c7d..1a496b8 100644 --- a/ui/README.md +++ b/ui/README.md @@ -194,7 +194,7 @@ ui/ ## 交易汇总卡约定(v1.19 → v1.23 改版,rant 2026-09-11T16:23:43 第 7 节) - 交易记录页顶部为**汇总卡** `#tx-summary`(`.stat-grid` + 5 张 `.stat-card`,改用 PR3 通用组件层):**消费 / 收益 / 点数变化 / Token 合计 / 记录数**,正数 `var(--ok)`、负数 `var(--danger-text)`、零值中性; -- **口径不变**(数据契约未动):汇总值来自后端 `summary` 全量 SQL 聚合(无 summary 时兜底用 `filterRows(list, TX_COLUMNS, txTable.filters)`),**不受分页影响**;「记录数」取后端 `total`(真分页下即当前筛选条件的全量条数); +- **口径不变**(数据契约未动):汇总值来自后端 `summary` 全量 SQL 聚合(无 summary 时兜底用 `filterRows(list, TX_COLUMNS, txTable.filters)`),**不受分页影响**;「记录数」取后端 `total`(真分页下即当前筛选条件下的全量**行数** —— 列表与它同源,一行可能是一个分钟桶,见「交易页列表从读模型取数」); - 工具栏右侧 `#tx-count` 同步显示 `tx.pager.count`(共 N 条),与汇总卡的记录数同源; - 图表:`#tx-trend` 改用原型 `.trend` 双色柱状(消费 / 收益两柱 + `.legend`),数据源仍是 `/api/transactions/trend`;**x 轴按请求窗口补零,保证左→右时间递增且柱距恒定**(后端 GROUP BY 只返回有交易的桶,缺行会导致柱子左移——原型同款 bug 的根因); - 窄屏(≤560px)`.stat-grid` 两列、`.trend` 高度收紧。 @@ -1307,18 +1307,54 @@ key**(正常操作)于是让运营者看到红色故障警报,而屏幕上 - ⚠️ **刻意的豁免**:**函数调用形式一律放行** —— 空选项的标签就是 `T("…")`,`esc(…)` / `D.fmt(…)` / `String(…)` 都算「产出可信串或预渲染片段」。所以它证的是「**没有裸变量被原样插进选项模板**」,**不证**每个调用返回值都转过义(将来有人写个 `String(p)` 当帮凶,本门禁看不见);也只看 `ui/js/*.js`,不看 `ui/index.html` 的静态 `