diff --git a/src/db.rs b/src/db.rs index c55c2b1..e4f2b43 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 = 10; +pub const SCHEMA_VERSION: i64 = 12; /// 打开(或创建)数据库并执行幂等迁移(生产标准:空库只建表,不种任何假数据) pub fn open(path: &str) -> Result { @@ -21,6 +21,14 @@ pub fn open(path: &str) -> Result { } } let conn = Connection::open(path).with_context(|| format!("打开数据库失败: {}", path))?; + // v12(rant 2026-08-25T12:02:13):NFS 库性能——64MB 页缓存 + 64MB mmap 预读, + // 整库常驻进程内存,远端存储只首读一次(默认 cache_size 2MB < 库体积 → 每次查询准冷读)。 + // 注意:不要启用 WAL 模式(SQLite 官方明确不支持网络文件系统,有损坏风险); + // 数据库不能移本地盘(部署硬约束,库必须留在 NAS)。 + conn.pragma_update(None, "cache_size", -65536) + .with_context(|| "设置 PRAGMA cache_size 失败".to_string())?; + conn.pragma_update(None, "mmap_size", 67108864) + .with_context(|| "设置 PRAGMA mmap_size 失败".to_string())?; migrate(&conn)?; Ok(conn) } @@ -298,6 +306,14 @@ pub fn migrate(conn: &Connection) -> Result<()> { // transactions 补 api_key_id(分发 key 关联字段,Key 列显示 api_keys.name; // 历史行无此字段 → NULL,前端兜底走 key_label / 交易类型说明) ensure_column(conn, "transactions", "api_key_id", "api_key_id INTEGER")?; + // v12(rant 2026-08-25T12:02:13):transactions 查询性能——summary/COUNT/list 原先 + // 全表扫描 + 3 LEFT JOIN(dev 库 23079 行);建 (user_id) 前缀复合索引覆盖筛选/排序/翻页/时间范围 + conn.execute_batch( + "CREATE INDEX IF NOT EXISTS idx_transactions_user_id ON transactions(user_id); + CREATE INDEX IF NOT EXISTS idx_transactions_user_id_id ON transactions(user_id, id DESC); + 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);", + )?; // schema_version:INSERT OR REPLACE 保证幂等 let v: i64 = conn .query_row("SELECT version FROM schema_version", [], |r| r.get(0)) @@ -561,6 +577,34 @@ mod tests { let _ = std::fs::remove_file(p); } + #[test] + fn transactions_perf_indexes_created_on_migrate() { + // v12(rant 2026-08-25T12:02:13):transactions 性能索引在迁移时建好 + //(summary/COUNT/list 原先全表扫描 + 3 LEFT JOIN,dev 库 23079 行) + let (conn, p) = tmp_db("txidx"); + let names: Vec = conn + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_transactions_%' ORDER BY name", + ) + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .collect::>>() + .unwrap(); + assert_eq!( + names, + vec![ + "idx_transactions_user_id", + "idx_transactions_user_id_id", + "idx_transactions_user_id_time", + "idx_transactions_user_id_type", + ], + "四个性能索引都应建好" + ); + drop(conn); + let _ = std::fs::remove_file(p); + } + #[test] fn empty_db_has_no_seeded_users() { // rant 2026-08-19T10:41:03:生产标准空库——migrate 后无任何种子用户/配额/占位 key diff --git a/ui/index.html b/ui/index.html index 6e32d1e..542e5ff 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,7 +5,7 @@ AITokenPool - + @@ -683,9 +683,9 @@

使用模型

- - - - + + + + diff --git a/ui/js/app.js b/ui/js/app.js index 4724a48..2a295ed 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -1469,18 +1469,21 @@ const page = Math.max(1, txTable.page || 1); const pageSize = Math.min(100, Math.max(1, txTable.pageSize || 10)); const q = "/api/transactions?type=" + type + "&page=" + page + "&page_size=" + pageSize + (range ? "&" + range : "") + (cols ? "&" + cols : ""); + // 趋势图数据(rant 2026-08-23T16:01:07 需求 2):同列筛选口径 + const bucket = txTrendBucket(); + const tq = "/api/transactions/trend?type=" + type + "&bucket=" + bucket + (range ? "&" + range : "") + (cols ? "&" + cols : ""); try { - await liveLoad("transactions", q); + // rant 2026-08-25T12:02:13:列表与趋势并行拉取(页面加载不再串行多等一个 ~0.9s 请求); + // 失败互不阻塞:列表失败降级空态,趋势失败仅 trend=null + const [, trend] = await Promise.all([ + liveLoad("transactions", q).catch(() => { Live.transactions = null; return null; }), + api.get(tq).catch(() => null), + ]); + if (Live.transactions) Live.transactions.trend = trend; txTable.loadedPage = page; txTable.loadedPageSize = pageSize; txTable.loadedFilterSig = txFilterSig(); // 记录已加载的筛选条件,变化时 renderTransactions 重拉 } catch (e) { Live.transactions = null; /* 登录态降级空态 */ } - // 趋势图数据(rant 2026-08-23T16:01:07 需求 2):独立拉取,失败不阻塞列表;同列筛选口径 - const bucket = txTrendBucket(); - try { - const trend = await api.get("/api/transactions/trend?type=" + type + "&bucket=" + bucket + (range ? "&" + range : "") + (cols ? "&" + cols : "")); - if (Live.transactions) Live.transactions.trend = trend; - } catch (e) { if (Live.transactions) Live.transactions.trend = null; } renderTransactions(); // 翻页后滚动到列表顶部(rant 2026-08-24T10:51:57 需求 4) if (page > 1) {