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
118 changes: 116 additions & 2 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Connection> {
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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<String> = conn
.prepare(
Expand All @@ -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<String> = 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::<rusqlite::Result<Vec<_>>>()
.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);
Expand Down
4 changes: 4 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
179 changes: 179 additions & 0 deletions src/perf_gate.rs
Original file line number Diff line number Diff line change
@@ -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(),
"注释里的旧写法不该算违规"
);
}
9 changes: 6 additions & 3 deletions src/routes/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down Expand Up @@ -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)?;
Expand All @@ -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)?;
Expand Down
Loading
Loading