Files
stash/src/db/mod.rs
T

302 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 内容库(SQLite)。
//!
//! 三条设计约束:
//!
//! 1. **单文件**:库固定是 `data/stash.db`,用 `journal_mode=DELETE`,
//! 不会像 WAL 那样在磁盘上多出 `-wal` / `-shm` 两个旁文件。
//! 2. **可编辑**:用户可以用任何 SQLite 工具直接改库;服务只读,不独占文件。
//! 3. **开发期不做迁移**:表结构还在变,改结构就删掉 `data/stash.db` 重启,
//! 启动时会重新建表并灌入内嵌种子。
mod catalog;
mod schema;
use std::sync::Mutex;
use rusqlite::Connection;
use serde_json::Value;
use tracing::{info, warn};
use crate::{config::Config, error::StashError};
pub use schema::SeedReport;
/// 一次查询得到的完整内容目录:分类树 + 侧栏导航。
///
/// 两者总是一起下发:首页与侧栏同时需要,拆成两个接口只会多一次往返。
#[derive(Debug)]
pub struct Catalog {
/// 分类树,结构对应前端 `types.ts` 的 `Category[]`
pub categories: Vec<Value>,
/// 侧栏导航,结构对应前端 `nav.ts` 的 `{ topLevel, groups }`
pub nav: Value,
}
/// 内容库句柄。
///
/// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来:
/// 本地工具并发极低,单连接足够,没必要引入连接池。
/// 所有耗时操作都应放在 `spawn_blocking` 里调用(见 server.rs)。
pub struct Db {
conn: Mutex<Connection>,
}
impl Db {
/// 打开(必要时创建)内容库。
///
/// 顺序很重要:先建目录,再连库,再设 PRAGMA,最后建表 / 判断是否需要灌种子。
pub fn open(config: &Config) -> Result<Self, StashError> {
std::fs::create_dir_all(&config.data_dir)?;
let mut conn = Connection::open(&config.db_path).map_err(map_error)?;
// busy_timeout 让用户用外部工具改库时的短暂写锁变成等待而不是立刻失败
conn.execute_batch(
"PRAGMA journal_mode = DELETE;
PRAGMA synchronous = FULL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;",
)
.map_err(map_error)?;
schema::apply(&conn)?;
// 结构版本:开发期只写不校验,等结构稳定后再拿它做迁移判断
conn.pragma_update(None, "user_version", 1)
.map_err(map_error)?;
// 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来
let existing: i64 = conn
.query_row("SELECT COUNT(*) FROM category", [], |row| row.get(0))
.map_err(map_error)?;
if existing == 0 {
let report = schema::seed(&mut conn)?;
info!(
"内容库为空,已导入内嵌种子:{} 分类 / {} 分组 / {} 条目 / {} 导航节点",
report.categories, report.groups, report.entries, report.nav_items
);
} else {
info!(
"内容库已存在,沿用现有数据:{}",
crate::config::display_path(&config.db_path)
);
// 开发阶段新增过表(比如侧栏导航),旧库不会被补数据。
// 这里给一条明确提示,避免用户对着空侧栏猜原因。
let nav_nodes: i64 = conn
.query_row("SELECT COUNT(*) FROM nav_entry", [], |row| row.get(0))
.map_err(map_error)?;
if nav_nodes == 0 {
warn!(
"内容库有分类但侧栏导航为空:如果这是旧版本留下的库,删掉 {} 后重启即可重新导入种子",
crate::config::display_path(&config.db_path)
);
}
}
Ok(Self {
conn: Mutex::new(conn),
})
}
/// 读出完整的分类树与侧栏导航,供 `/api/catalog` 直接序列化。
pub fn load_catalog(&self) -> Result<Catalog, StashError> {
let conn = self.lock()?;
catalog::load(&conn)
}
/// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。
///
/// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。
pub fn data_version(&self) -> Result<i64, StashError> {
let conn = self.lock()?;
conn.pragma_query_value(None, "data_version", |row| row.get::<_, i64>(0))
.map_err(map_error)
}
/// 取连接;锁中毒说明此前某个线程 panic 过,此时继续用库并不安全。
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>, StashError> {
self.conn
.lock()
.map_err(|_| StashError::Database("数据库连接锁已损坏,请重启进程".into()))
}
}
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
pub(crate) fn map_error(error: rusqlite::Error) -> StashError {
StashError::Database(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
/// 建一个内存库并灌种子;内存库不落盘,测试不受临时目录权限影响。
fn seeded() -> Connection {
let mut conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch("PRAGMA foreign_keys = ON;")
.expect("PRAGMA 应能设置");
schema::apply(&conn).expect("建表应成功");
schema::seed(&mut conn).expect("种子导入应成功");
conn
}
#[test]
fn seed_fills_every_category_group_and_entry() {
let conn = seeded();
let categories: i64 = conn
.query_row("SELECT COUNT(*) FROM category", [], |row| row.get(0))
.expect("应能统计分类");
let groups: i64 = conn
.query_row("SELECT COUNT(*) FROM app_group", [], |row| row.get(0))
.expect("应能统计分组");
let entries: i64 = conn
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
.expect("应能统计条目");
let nav_items: i64 = conn
.query_row("SELECT COUNT(*) FROM nav_entry", [], |row| row.get(0))
.expect("应能统计导航节点");
assert_eq!(categories, 3, "种子应含 常用推荐 / 推荐电影 / CES 三个分类");
assert_eq!(groups, 5, "常用推荐应有 5 个分组");
assert_eq!(entries, 42, "18 应用 + 12 电影 + 12 图文");
assert_eq!(nav_items, 8, "1 个顶层直达 + 2 个折叠分组 + 5 个叶子");
}
#[test]
fn catalog_matches_the_frontend_model() {
let conn = seeded();
let catalog = catalog::load(&conn).expect("应能组装内容目录");
let categories = &catalog.categories;
assert_eq!(categories.len(), 3);
let apps = &categories[0];
assert_eq!(apps["id"], "recommend");
assert_eq!(apps["kind"], "apps");
assert_eq!(apps["barColor"], "#3858f6");
assert_eq!(apps["groups"].as_array().expect("应有分组").len(), 5);
let first_app = &apps["groups"][0]["items"][0];
assert_eq!(first_app["name"], "GitHub");
assert_eq!(first_app["hot"], true);
// 计数从 JSON 提到了独立列,这里确认读取时又合并回来了
assert_eq!(first_app["views"], 429);
let posters = &categories[1];
assert_eq!(posters["kind"], "posters");
// 海报与图文用 title 而不是 name
assert_eq!(
posters["items"][0].get("name"),
None,
"海报不应同时下发 name 字段"
);
assert!(posters["items"][0]["title"].is_string());
let articles = &categories[2];
assert_eq!(articles["kind"], "articles");
assert!(articles["items"][0]["blocks"].is_array(), "图文正文应是块数组");
// ---- 侧栏导航 ----
let nav = &catalog.nav;
let top_level = nav["topLevel"].as_array().expect("应有顶层项");
assert_eq!(top_level.len(), 1);
assert_eq!(top_level[0]["label"], "常用推荐");
assert_eq!(top_level[0]["target"]["categoryId"], "recommend");
assert!(
top_level[0]["target"].get("groupId").is_none(),
"整分类直达时不应下发 groupId"
);
let nav_groups = nav["groups"].as_array().expect("应有折叠分组");
assert_eq!(nav_groups.len(), 2);
assert_eq!(nav_groups[0]["label"], "素材资源");
assert_eq!(nav_groups[0]["leaves"].as_array().expect("应有叶子").len(), 3);
assert_eq!(nav_groups[1]["label"], "开发工具");
assert_eq!(nav_groups[1]["leaves"][0]["label"], "前端调试工具");
assert_eq!(nav_groups[1]["leaves"][0]["target"]["groupId"], "dev");
}
/// 删除分类时,指向它的导航叶子必须一起消失(外键级联),
/// 否则侧栏会留下点了没反应的死链接。
#[test]
fn nav_leaves_cascade_when_their_category_is_removed() {
let conn = seeded();
conn.execute("DELETE FROM category WHERE id = 'recommend'", [])
.expect("应能删除分类");
let catalog = catalog::load(&conn).expect("应能组装内容目录");
assert!(
catalog.nav["topLevel"]
.as_array()
.expect("应有顶层数组")
.is_empty(),
"指向已删分类的顶层链接应被级联删除"
);
}
#[test]
fn broken_json_names_the_offending_entry() {
let conn = seeded();
conn.execute(
"UPDATE entry SET data = '{oops' WHERE category_id = 'recommend' AND id = 'github'",
[],
)
.expect("应能写入坏数据");
let error = catalog::load(&conn).expect_err("坏 JSON 应报错");
let message = error.to_string();
assert!(message.contains("recommend/github"), "报错要能定位到具体条目: {message}");
}
#[test]
fn data_version_is_readable() {
let db = Db {
conn: Mutex::new(seeded()),
};
assert!(db.data_version().expect("应能读取 data_version") >= 0);
}
/// 回归测试:外部连接提交后,服务端连接读到的 data_version 必须变化。
///
/// 这是「用户用 DB Browser 改库,页面自动刷新」整条链路的地基;
/// 用文件库而不是内存库,因为内存库无法被第二个连接看到。
#[test]
fn data_version_changes_when_another_connection_commits() {
use crate::config::Config;
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/db-version");
std::fs::create_dir_all(&dir).expect("应能创建测试目录");
let path = dir.join("probe.db");
let _ = std::fs::remove_file(&path);
let config = Config {
root: dir.clone(),
data_dir: dir.clone(),
db_path: path.clone(),
..Config::default()
};
let db = Db::open(&config).expect("应能打开文件库");
let before = db.data_version().expect("应能读取初始版本");
// 模拟外部工具:另开一个连接提交一次写事务
let external = Connection::open(&path).expect("外部连接应能打开");
external
.execute(
"UPDATE entry SET name = '改过的名字' WHERE category_id = 'recommend' AND id = 'github'",
[],
)
.expect("外部写入应成功");
drop(external);
let after = db.data_version().expect("应能读取新版本");
assert_ne!(before, after, "外部提交后 data_version 必须变化");
}
}