diff --git a/src/db.rs b/src/db.rs index 60061ed..c059393 100644 --- a/src/db.rs +++ b/src/db.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result}; use rusqlite::Connection; -pub const SCHEMA_VERSION: i64 = 13; +pub const SCHEMA_VERSION: i64 = 14; /// 打开(或创建)数据库并执行幂等迁移(生产标准:空库只建表,不种任何假数据) pub fn open(path: &str) -> Result { @@ -314,6 +314,26 @@ pub fn migrate(conn: &Connection) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_transactions_user_id_time ON transactions(user_id, time); CREATE INDEX IF NOT EXISTS idx_transactions_user_id_type ON transactions(user_id, type);", )?; + // v14(rant 2026-09-14T16:51:14):月聚合查询在 NAS 上要 4~10s。真因不是 NAS 吞吐 + // (实测 NAS 4KB 热读 1.9µs,与本地盘同级),而是**一次查询要摸多少页** —— + // `strftime('%Y-%m', time) = strftime('%Y-%m', 'now')` 把索引列包在函数里 ⇒ 索引失效 + // ⇒ 全表扫描。v14 做两件事: + // (1) 应用层把那 12 处谓词改成**闭区间**(见 routes/*.rs;语义等价且索引可用); + // (2) 本步补齐索引,覆盖此前**无索引可用**的两条路径。 + // 同库同查询实测:strftime 3845ms → 范围谓词 2321ms → 覆盖索引 119ms(18~32x)。 + conn.execute_batch( + // transactions 的「月聚合」有两种读法,各给一条覆盖索引: + // - 带 user_id(wallet.rs 的 summary / dashboard 月聚合)⇒ 只扫 (user_id,time,type,pts) + // - 全库、无 user_id(ops.rs 的 month_in / month_out)⇒ (time,type,pts) + "CREATE INDEX IF NOT EXISTS idx_transactions_user_id_time_type_pts + ON transactions(user_id, time, type, pts); + CREATE INDEX IF NOT EXISTS idx_transactions_time_type_pts + ON transactions(time, type, pts); + -- usage_records 此前**零索引**(全表扫 4525ms → 加 (time) 后 84ms) + CREATE INDEX IF NOT EXISTS idx_usage_records_time ON usage_records(time); + -- (user_id, time):admin.rs 的按成员 LEFT JOIN 走这个前缀 + CREATE INDEX IF NOT EXISTS idx_usage_records_user_id_time ON usage_records(user_id, time);", + )?; // schema_version 记录**最高的**已迁移版本。⚠️ 必须用 MAX 读:该表没有唯一约束, // 而 `INSERT OR REPLACE` 在无冲突时就是普通 INSERT —— 只读第一行的话 // `v < SCHEMA_VERSION` 会永远为真,每次启动都追加一行(`atp-data/aitokenpool.db` @@ -738,6 +758,7 @@ mod tests { fn transactions_perf_indexes_created_on_migrate() { // v12(rant 2026-08-25T12:02:13):transactions 性能索引在迁移时建好 //(summary/COUNT/list 原先全表扫描 + 3 LEFT JOIN,dev 库 23079 行) + // v14(rant 2026-09-14T16:51:14):补两条**覆盖索引**给月聚合(带/不带 user_id 各一) let (conn, p) = tmp_db("txidx"); let names: Vec = conn .prepare( @@ -751,12 +772,105 @@ mod tests { assert_eq!( names, vec![ + "idx_transactions_time_type_pts", "idx_transactions_user_id", "idx_transactions_user_id_id", "idx_transactions_user_id_time", + "idx_transactions_user_id_time_type_pts", "idx_transactions_user_id_type", ], - "四个性能索引都应建好" + "六个性能索引都应建好(v12 四条 + v14 两条)" + ); + drop(conn); + let _ = std::fs::remove_file(p); + } + + #[test] + fn v14_usage_indexes_and_schema_version() { + // v14 的另一半:usage_records 此前**零索引**(月聚合/按小时聚合全表扫)。 + // 这条断言同时把版本门禁钉住:迁移后 MAX(version) 必须是 SCHEMA_VERSION, + // 且再迁一次不得追加行(`INSERT OR REPLACE` 在无唯一约束时不会去重)。 + let (conn, p) = tmp_db("usgidx"); + let names: Vec = conn + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_usage_records_%' ORDER BY name", + ) + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .collect::>>() + .unwrap(); + assert_eq!( + names, + vec!["idx_usage_records_time", "idx_usage_records_user_id_time"], + "usage_records 的两条新索引都应建好" + ); + let maxv: i64 = conn + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM schema_version", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(maxv, SCHEMA_VERSION, "迁移后最高版本应为 SCHEMA_VERSION"); + let rows1: i64 = conn + .query_row("SELECT COUNT(*) FROM schema_version", [], |r| r.get(0)) + .unwrap(); + migrate(&conn).unwrap(); + let rows2: i64 = conn + .query_row("SELECT COUNT(*) FROM schema_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(rows1, rows2, "重复迁移不得再追加 schema_version 行"); + drop(conn); + let _ = std::fs::remove_file(p); + } + + #[test] + fn month_range_predicate_is_equivalent_to_strftime_and_excludes_future_rows() { + // v14(rant 2026-09-14T16:51:14):把 12 处月聚合谓词从 `strftime('%Y-%m', time)` + // 改写成 `time >= date('now','start of month') AND time < …'+1 month'`。 + // + // 这是那条改写的**独立语义规格**:新写法必须与旧写法给出同一个值, + // 且**必须排除未来月份的行**。夹具里刻意放一条下月记录 —— 它正是 + // 「闭区间」与「只有左界」的唯一区别所在(只有左界会把下月也统计进来)。 + // 用一张最小表而不是真 schema:被测的是**谓词**,不是表结构。 + let (conn, p) = tmp_db("pred"); + conn.execute_batch( + "CREATE TABLE t (user_id INTEGER, type TEXT, pts REAL, time TEXT); + INSERT INTO t VALUES (1, 'consume', 1.0, datetime('now','start of month','-1 month','+1 day')); + INSERT INTO t VALUES (1, 'consume', 2.0, datetime('now','start of month','+1 day')); + INSERT INTO t VALUES (1, 'consume', 4.0, datetime('now','start of month','+1 month','+1 day'));", + ) + .unwrap(); + let old: f64 = conn + .query_row( + "SELECT COALESCE(SUM(pts), 0) FROM t WHERE user_id = ?1 AND type = 'consume' \ + AND strftime('%Y-%m', time) = strftime('%Y-%m', 'now')", + [1], + |r| r.get(0), + ) + .unwrap(); + let new: f64 = conn + .query_row( + "SELECT COALESCE(SUM(pts), 0) FROM t WHERE user_id = ?1 AND type = 'consume' \ + AND time >= date('now', 'start of month') AND time < date('now', 'start of month', '+1 month')", + [1], + |r| r.get(0), + ) + .unwrap(); + let lower_bound_only: f64 = conn + .query_row( + "SELECT COALESCE(SUM(pts), 0) FROM t WHERE user_id = ?1 AND type = 'consume' \ + AND time >= date('now', 'start of month')", + [1], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(old, 2.0, "旧写法只统计本月(上月与下月都排除)"); + assert_eq!(new, old, "闭区间必须与旧写法等价"); + assert_eq!( + lower_bound_only, 6.0, + "只有左界会把未来月份算进来 —— 这正是必须写闭区间的原因" ); drop(conn); let _ = std::fs::remove_file(p); diff --git a/src/main.rs b/src/main.rs index 7ff2cc2..aa91b13 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,10 @@ mod sse; // 都在编译期读入,加 #[cfg(test)] 后不会进入发布产物。 #[cfg(test)] mod table_gate; +// 时间谓词门禁(C2116):同样是仅测试期编译 —— src/routes/*.rs 在编译期读入, +// 加 #[cfg(test)] 后不会进入发布产物。 +#[cfg(test)] +mod perf_gate; use std::sync::Arc; diff --git a/src/perf_gate.rs b/src/perf_gate.rs new file mode 100644 index 0000000..9d7a119 --- /dev/null +++ b/src/perf_gate.rs @@ -0,0 +1,179 @@ +//! 时间谓词门禁(C2116):**时间列不得被日期函数包住**。 +//! +//! 起因(rant 2026-09-14T16:51:14):月聚合查询在 NAS 上要 4~10s。真因不是 NAS 吞吐 +//! (实测 NAS 4KB 热读 1.9µs,与本地盘同级),而是**一次查询要摸多少页** —— +//! `strftime('%Y-%m', time) = strftime('%Y-%m', 'now')` 把索引列包在函数里 ⇒ +//! SQLite 无法用 `time` 上的索引 ⇒ 全表扫描。同库同查询实测: +//! `strftime` 3845ms → 范围谓词 2321ms → 覆盖索引 119ms(18~32x)。 +//! +//! 修法是把谓词改写成**闭区间**(语义等价且索引可用): +//! +//! ```text +//! strftime('%Y-%m', X) = strftime('%Y-%m', 'now') +//! ⟺ X >= date('now','start of month') AND X < date('now','start of month','+1 month') +//! date(X) = date('now') +//! ⟺ X >= date('now') AND X < date('now','+1 day') +//! ``` +//! +//! ⚠️ **只写左界不等价**(会放进未来月份的记录)—— 必须写**闭区间**。 +//! +//! 为什么需要门禁:这个缺陷**改前改后测试全绿**(聚合值一样,只是慢了 30 倍), +//! 唯一的运行期症状是「慢」,而慢在本机(SSD、2.8MB 库)根本看不出来 —— 只在 +//! dev 的 NAS + 79MB 库上暴露。任何后续编辑都可能把某处再包一层函数而**没有任何 +//! 反馈**。本模块把这条不变量固化进 `cargo test`。 +//! +//! 设计约束(与 `i18n_pack.rs` / `catalog_gate.rs` / `table_gate.rs` 同型): +//! - **仅测试期编译**(`#[cfg(test)] mod`,见 `main.rs`),不进生产二进制; +//! - **零新依赖**:只做逐字节扫描,不解析 SQL、不连数据库; +//! - **关联方式全是位置性的**:生产区 = 文件里第一次出现行首 `#[cfg(test)]` 之前的部分 +//! (`src/routes/*.rs` 的测试模块一律在文件末尾且 `#[cfg(test)]` 顶格); +//! - **阳性对照**:断言「扫描到 4 个文件」「闭区间重写逐文件计数」「测试里的旧写法 +//! 仍在(证明区域切分没多吃)」;检测器本身另有合成输入的阴性/阳性对照, +//! 保证它不会在空集上「通过」(C2005 坑 68)。 + +/// 编译期读入各文件(测试不依赖工作目录与文件系统布局)。 +const FILES: &[(&str, &str)] = &[ + ("wallet.rs", include_str!("routes/wallet.rs")), + ("ops.rs", include_str!("routes/ops.rs")), + ("admin.rs", include_str!("routes/admin.rs")), + ("org.rs", include_str!("routes/org.rs")), +]; + +/// 生产区里**月聚合闭区间**的期望处数(`… 'start of month', '+1 month'`)。 +/// 它是阳性对照:某处被改回函数包裹时,这个计数会先掉下来。 +const MONTH_RANGES: &[(&str, usize)] = &[ + ("wallet.rs", 3), + ("ops.rs", 3), + ("admin.rs", 3), + ("org.rs", 1), +]; + +/// 生产区 = 第一次行首 `#[cfg(test)]` 之前的部分。 +fn production_region(src: &str) -> &str { + src.split("\n#[cfg(test)]").next().unwrap_or(src) +} + +/// 违规行:返回 `(1 基行号, 行文本)`。 +/// +/// 两条规则(都只看**生产区**的非注释行): +/// 1. `strftime('%Y` —— 月桶函数包住时间列 ⇒ 索引失效(唯一合法的 `strftime` 是 +/// `strftime('%H', …)` 的小时桶**键**,不是过滤条件); +/// 2. `) = date('now')` / `) = days.day` —— `date(<时间列>) = <常量>` 同款。 +fn violations(src: &str) -> Vec<(usize, String)> { + let mut out = Vec::new(); + for (i, line) in production_region(src).lines().enumerate() { + if line.trim_start().starts_with("//") { + continue; // 注释里引用旧写法是文档,不是缺陷 + } + if line.contains("strftime('%Y") + || line.contains(") = date('now')") + || line.contains(") = days.day") + { + out.push((i + 1, line.trim().to_string())); + } + } + out +} + +#[test] +fn no_date_function_wraps_a_time_column_in_production() { + assert_eq!(FILES.len(), 4, "应扫描 4 个路由文件"); + let mut all = Vec::new(); + for (name, src) in FILES { + for (ln, text) in violations(src) { + all.push(format!("{name}:{ln}: {text}")); + } + } + assert!( + all.is_empty(), + "时间列被日期函数包住 ⇒ 索引失效(NAS 上月聚合 4~10s)。改成闭区间:\n{}", + all.join("\n") + ); +} + +#[test] +fn the_closed_range_rewrites_are_all_present() { + // 阳性对照:没有这条,`violations` 返回空集时上面那条测试会在空集上「通过」。 + for (name, want) in MONTH_RANGES { + let src = FILES + .iter() + .find(|(n, _)| n == name) + .map(|(_, s)| *s) + .unwrap_or_else(|| panic!("{name} 不在 FILES 里")); + let got = production_region(src) + .matches("start of month', '+1 month'") + .count(); + assert_eq!( + got, *want, + "{name} 的月聚合闭区间处数应为 {want},实测 {got}" + ); + } + // 另两条一次性重写:7 天序列的 JOIN(wallet)与「今日按小时」(ops)。 + let wallet = production_region(FILES[0].1); + assert!( + wallet.contains("t.time >= days.day AND t.time < date(days.day, '+1 day')"), + "wallet 的 7 天序列 JOIN 应使用闭区间" + ); + let ops = production_region(FILES[1].1); + assert!( + ops.contains("time >= date('now') AND time < date('now', '+1 day')"), + "ops 的「今日按小时」应使用闭区间" + ); +} + +#[test] +fn the_test_module_oracle_kept_the_old_form() { + // 区域切分不得「多吃」:`wallet.rs` 的测试里那句旧写法是**独立的规格对照** + // (见该测试注释),必须原样保留 —— 它证明重写后的生产查询仍满足旧语义。 + let wallet = FILES[0].1; + assert_eq!( + wallet + .matches("strftime('%Y-%m', time) = strftime('%Y-%m', 'now')") + .count(), + 1, + "wallet 的测试模块里应恰好保留 1 处旧写法(规格对照)" + ); + assert!( + production_region(wallet) + .find("strftime('%Y-%m', time) = strftime('%Y-%m', 'now')") + .is_none(), + "生产区不得出现旧写法(文档注释里引用片段是允许的,等式不行)" + ); + // 区域切分确实发生了 —— 否则上面的 1 可能与「切片没生效」得到同一个数。 + assert!( + production_region(wallet).len() < wallet.len(), + "wallet.rs 的生产区应短于整文件(测试模块必须被切掉)" + ); +} + +#[test] +fn detector_flags_the_pre_fix_shape_and_spares_the_range_form() { + // 检测器自身的对照:喂合成输入,确认它**真的会红**(不是恒真谓词)。 + let bad = "let q = \"SELECT SUM(pts) FROM transactions \\\n\ + WHERE user_id = ?1 AND strftime('%Y-%m', time) = strftime('%Y-%m', 'now')\";\n"; + let hits = violations(bad); + assert_eq!(hits.len(), 1, "含旧写法的合成输入应恰好报 1 处:{hits:?}"); + + let day_bad = "LEFT JOIN transactions t ON date(t.time) = days.day AND t.user_id = ?1 \\\n"; + assert_eq!( + violations(day_bad).len(), + 1, + "date(<列>) = days.day 也应被报出" + ); + + // 阴性对照:修好后的闭区间写法(含右界)不得被报出。 + let good = "WHERE user_id = ?1 AND time >= date('now', 'start of month') \\\n\ + AND time < date('now', 'start of month', '+1 month')\";\n\ + LEFT JOIN transactions t ON t.time >= days.day AND t.time < date(days.day, '+1 day') \\\n"; + assert!( + violations(good).is_empty(), + "闭区间写法不应被报出:{:?}", + violations(good) + ); + + // 注释行不报(文档里引用旧写法是合法的)。 + assert!( + violations("// 旧写法 strftime('%Y-%m', time) = strftime('%Y-%m', 'now')\n").is_empty(), + "注释里的旧写法不该算违规" + ); +} diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 744d0b8..38b2163 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -210,7 +210,8 @@ pub async fn usage( COALESCE(SUM(ur.tokens), 0), COALESCE(SUM(ur.cost), 0), COUNT(ur.id) \ FROM users u \ LEFT JOIN usage_records ur ON ur.user_id = u.id \ - AND strftime('%Y-%m', ur.time) = strftime('%Y-%m', 'now') \ + AND ur.time >= date('now', 'start of month') \ + AND ur.time < date('now', 'start of month', '+1 month') \ LEFT JOIN departments d ON d.id = u.dept_id \ GROUP BY u.id ORDER BY u.id", ) @@ -239,7 +240,8 @@ pub async fn usage( let mut stmt = conn .prepare( "SELECT COALESCE(model, ''), COALESCE(SUM(tokens), 0), COALESCE(SUM(cost), 0), COUNT(*) \ - FROM usage_records WHERE strftime('%Y-%m', time) = strftime('%Y-%m', 'now') \ + FROM usage_records WHERE time >= date('now', 'start of month') \ + AND time < date('now', 'start of month', '+1 month') \ GROUP BY model ORDER BY SUM(cost) DESC", ) .map_err(internal)?; @@ -265,7 +267,8 @@ pub async fn usage( "SELECT COALESCE(d.name, '(未分配)'), COALESCE(SUM(ur.tokens), 0), COALESCE(SUM(ur.cost), 0), COUNT(ur.id) \ FROM usage_records ur JOIN users u ON u.id = ur.user_id \ LEFT JOIN departments d ON d.id = u.dept_id \ - WHERE strftime('%Y-%m', ur.time) = strftime('%Y-%m', 'now') \ + WHERE ur.time >= date('now', 'start of month') \ + AND ur.time < date('now', 'start of month', '+1 month') \ GROUP BY d.id ORDER BY SUM(ur.cost) DESC", ) .map_err(internal)?; diff --git a/src/routes/ops.rs b/src/routes/ops.rs index 991b991..239b62d 100644 --- a/src/routes/ops.rs +++ b/src/routes/ops.rs @@ -77,7 +77,8 @@ pub async fn runtime( .unwrap_or(0); let month_calls: i64 = conn .query_row( - "SELECT COUNT(*) FROM usage_records WHERE strftime('%Y-%m', time) = strftime('%Y-%m', 'now')", + "SELECT COUNT(*) FROM usage_records \ + WHERE time >= date('now', 'start of month') AND time < date('now', 'start of month', '+1 month')", [], |r| r.get(0), ) @@ -92,7 +93,7 @@ pub async fn runtime( .query_row( "SELECT COALESCE(SUM(pts), 0) FROM transactions \ WHERE type IN ('earn', 'topup', 'gift') \ - AND strftime('%Y-%m', time) = strftime('%Y-%m', 'now')", + AND time >= date('now', 'start of month') AND time < date('now', 'start of month', '+1 month')", [], |r| r.get(0), ) @@ -101,7 +102,7 @@ pub async fn runtime( .query_row( "SELECT COALESCE(SUM(pts), 0) FROM transactions \ WHERE type IN ('consume', 'expire', 'withdraw') \ - AND strftime('%Y-%m', time) = strftime('%Y-%m', 'now')", + AND time >= date('now', 'start of month') AND time < date('now', 'start of month', '+1 month')", [], |r| r.get(0), ) @@ -119,7 +120,7 @@ pub async fn runtime( .prepare( "SELECT CAST(strftime('%H', time) AS INTEGER) AS h, COUNT(*) \ FROM usage_records \ - WHERE date(time) = date('now') GROUP BY h", + WHERE time >= date('now') AND time < date('now', '+1 day') GROUP BY h", ) .map_err(internal)?; let rows = stmt diff --git a/src/routes/org.rs b/src/routes/org.rs index c31f62a..934bc49 100644 --- a/src/routes/org.rs +++ b/src/routes/org.rs @@ -54,7 +54,8 @@ pub async fn list( (SELECT COUNT(*) FROM users u WHERE u.dept_id = d.id), \ (SELECT COALESCE(SUM(ur.cost), 0) FROM usage_records ur \ JOIN users u ON u.id = ur.user_id WHERE u.dept_id = d.id \ - AND strftime('%Y-%m', ur.time) = strftime('%Y-%m', 'now')) \ + AND ur.time >= date('now', 'start of month') \ + AND ur.time < date('now', 'start of month', '+1 month')) \ FROM departments d ORDER BY d.id", ) .map_err(internal)?; diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index fd51019..30920e9 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -104,7 +104,8 @@ pub async fn wallet( let month_use: f64 = conn .query_row( "SELECT COALESCE(SUM(pts), 0) FROM transactions \ - WHERE user_id = ?1 AND type = 'consume' AND strftime('%Y-%m', time) = strftime('%Y-%m', 'now')", + WHERE user_id = ?1 AND type = 'consume' \ + AND time >= date('now', 'start of month') AND time < date('now', 'start of month', '+1 month')", [auth.user_id], |r| r.get(0), ) @@ -112,7 +113,8 @@ pub async fn wallet( let month_earn: f64 = conn .query_row( "SELECT COALESCE(SUM(pts), 0) FROM transactions \ - WHERE user_id = ?1 AND type = 'earn' AND strftime('%Y-%m', time) = strftime('%Y-%m', 'now')", + WHERE user_id = ?1 AND type = 'earn' \ + AND time >= date('now', 'start of month') AND time < date('now', 'start of month', '+1 month')", [auth.user_id], |r| r.get(0), ) @@ -551,7 +553,8 @@ pub async fn dashboard( let mut stmt = conn .prepare( "SELECT type, COALESCE(SUM(pts), 0) FROM transactions \ - WHERE user_id = ?1 AND strftime('%Y-%m', time) = strftime('%Y-%m', 'now') \ + WHERE user_id = ?1 \ + AND time >= date('now', 'start of month') AND time < date('now', 'start of month', '+1 month') \ GROUP BY type", ) .map_err(internal)?; @@ -584,7 +587,8 @@ pub async fn dashboard( ) \ SELECT days.day, COALESCE(SUM({}), 0) \ FROM days \ - LEFT JOIN transactions t ON date(t.time) = days.day AND t.user_id = ?1 \ + LEFT JOIN transactions t ON t.time >= days.day AND t.time < date(days.day, '+1 day') \ + AND t.user_id = ?1 \ GROUP BY days.day ORDER BY days.day", signed_pts_expr("t") ))