302 lines
12 KiB
Rust
302 lines
12 KiB
Rust
//! 内容库(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 必须变化");
|
||
}
|
||
}
|