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
46 changes: 45 additions & 1 deletion src/db.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Connection> {
Expand All@@ -21,6 +21,14 @@ pub fn open(path: &str) -> Result<Connection> {
}
}
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)
}
Expand DownExpand Up@@ -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))
Expand DownExpand Up@@ -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<String> = 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::<rusqlite::Result<Vec<_>>>()
.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
Expand Down
10 changes: 5 additions & 5 deletions ui/index.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AITokenPool</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%234ecdc4'/%3E%3Cstop offset='1' stop-color='%232a9d8f'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Ctext x='32' y='43' font-family='-apple-system,Segoe UI,Arial,sans-serif' font-size='30' font-weight='800' text-anchor='middle' fill='%23062021'%3EAT%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="css/style.css?v=20260825-3">
<link rel="stylesheet" href="css/style.css?v=20260825-4">
</head>
<body>

Expand DownExpand Up@@ -683,9 +683,9 @@ <h3 id="chat-title">使用模型</h3>
</div>
</div>

<script src="js/api.js?v=20260825-3"></script>
<script src="js/data.js?v=20260825-3"></script>
<script src="js/i18n.js?v=20260825-3"></script>
<script src="js/app.js?v=20260825-3"></script>
<script src="js/api.js?v=20260825-4"></script>
<script src="js/data.js?v=20260825-4"></script>
<script src="js/i18n.js?v=20260825-4"></script>
<script src="js/app.js?v=20260825-4"></script>
</body>
</html>
17 changes: 10 additions & 7 deletions ui/js/app.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
Loading