//! 内容库(SQLite)。 //! //! 三条设计约束: //! //! 1. **单文件**:库固定是 `data/stash.db`,用 `journal_mode=DELETE`, //! 不会像 WAL 那样在磁盘上多出 `-wal` / `-shm` 两个旁文件。 //! 2. **可编辑**:用户可以用任何 SQLite 工具直接改库;服务只读,不独占文件。 //! 3. **开发期不做迁移**:表结构还在变,改结构就删掉 `data/stash.db` 重启, //! 启动时会重新建表并灌入内嵌种子。 pub mod auth; pub mod cards; pub mod categories; mod catalog; pub mod entries; pub mod groups; mod schema; pub mod settings; 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; /// 登录/会话的对外状态(server 层直接用)。 pub use auth::SessionState; /// 站点设置:页头滚动信息 + 侧栏顺序/显隐。 pub use settings::{Settings, SettingsDocument}; /// 条目编辑(P2)。 pub use entries::{EntryPatch, EntryRecord}; /// 一次查询得到的完整内容目录。 /// /// 侧栏已改为由这份分类树派生,不再单独下发导航数据。 #[derive(Debug)] pub struct Catalog { /// 分类树,结构对应前端 `types.ts` 的 `Category[]` pub categories: Vec, } /// 内容库句柄。 /// /// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来: /// 本地工具并发极低,单连接足够,没必要引入连接池。 /// 所有耗时操作都应放在 `spawn_blocking` 里调用(见 server.rs)。 pub struct Db { conn: Mutex, } impl Db { /// 打开(必要时创建)内容库。 /// /// 顺序很重要:先建目录,再连库,再设 PRAGMA,最后建表 / 判断是否需要灌种子。 pub fn open(config: &Config) -> Result { 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)?; // 先迁移结构,再动数据:迁移失败会直接报错,不会用半套表跑查询 let version = schema::migrate(&conn)?; if version != schema::SCHEMA_VERSION { return Err(StashError::Database(format!( "内容库迁移后版本为 {version},期望 {}", schema::SCHEMA_VERSION ))); } // 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来 let existing: i64 = conn .query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0)) .map_err(map_error)?; if existing == 0 { // 全新库是常态;版本号为 0 但库里已经有数据属于异常(比如手工清表),给条提示 if version != 0 { warn!("结构版本为 {version} 但节点表为空,将按空库重新导入种子"); } let report = schema::seed(&mut conn)?; info!( "内容库为空,已导入内嵌种子:{} 分区 / {} 分组 / {} 条目 / {} 标签 / {} 展示块", report.sections, report.groups, report.entries, report.tags, report.blocks ); } else { info!( "内容库已存在,沿用现有数据:{}", crate::config::display_path(&config.db_path) ); } Ok(Self { conn: Mutex::new(conn), }) } /// 读出完整的内容目录,供 `/api/catalog` 直接序列化。 pub fn load_catalog(&self) -> Result { let conn = self.lock()?; catalog::load(&conn) } /// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。 /// /// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。 pub fn data_version(&self) -> Result { 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, StashError> { self.conn .lock() .map_err(|_| StashError::Database("数据库连接锁已损坏,请重启进程".into())) } // ---------- 登录 / 会话 ---------- pub fn auth_state(&self, token: Option<&str>) -> Result { let conn = self.lock()?; auth::state(&conn, token) } pub fn setup_admin(&self, username: &str, password: &str) -> Result<(), StashError> { let conn = self.lock()?; auth::create_admin(&conn, username, password) } pub fn login(&self, password: &str) -> Result, StashError> { let conn = self.lock()?; auth::purge_expired(&conn)?; auth::verify_login(&conn, password) } pub fn issue_session(&self) -> Result { let conn = self.lock()?; auth::issue_session(&conn) } pub fn logout(&self, token: &str) -> Result<(), StashError> { let conn = self.lock()?; auth::revoke(&conn, token) } pub fn change_password(&self, current: &str, next: &str) -> Result { let mut conn = self.lock()?; auth::change_password(&mut conn, current, next) } // ---------- 站点设置 ---------- pub fn load_settings(&self) -> Result { let conn = self.lock()?; settings::load(&conn) } pub fn save_settings( &self, next: Settings, expected_version: i64, ) -> Result { let mut conn = self.lock()?; let document = settings::save(&mut conn, next, expected_version)?; entries::audit(&conn, "settings.update", "site", "站点设置")?; Ok(document) } // ---------- 条目编辑 ---------- pub fn load_entry(&self, key: &str) -> Result, StashError> { let conn = self.lock()?; entries::load(&conn, key) } pub fn create_entry(&self, new: entries::NewEntry) -> Result { let mut conn = self.lock()?; entries::create(&mut conn, new) } pub fn remove_entry(&self, key: &str) -> Result<(), StashError> { let conn = self.lock()?; entries::remove(&conn, key) } pub fn update_entry( &self, key: &str, patch: EntryPatch, expected_updated_at: i64, ) -> Result { let mut conn = self.lock()?; entries::update(&mut conn, key, patch, expected_updated_at) } pub fn bump_view(&self, key: &str) -> Result<(i64, i64), StashError> { let conn = self.lock()?; entries::bump_view(&conn, key) } pub fn set_like(&self, key: &str, liked: bool) -> Result<(i64, i64), StashError> { let conn = self.lock()?; entries::set_like(&conn, key, liked) } // ---------- 分区(首页卡片 / 侧栏项) ---------- pub fn create_category(&self, new: categories::NewCategory) -> Result<(), StashError> { let mut conn = self.lock()?; categories::create(&mut conn, new) } pub fn update_category( &self, id: &str, patch: categories::UpdateCategory, ) -> Result<(), StashError> { let mut conn = self.lock()?; categories::update(&mut conn, id, patch) } pub fn create_group( &self, section_id: &str, new: categories::NewGroup, ) -> Result<(), StashError> { let mut conn = self.lock()?; categories::create_group(&mut conn, section_id, new) } pub fn update_group(&self, id: &str, patch: categories::UpdateGroup) -> Result<(), StashError> { let conn = self.lock()?; categories::update_group(&conn, id, patch) } pub fn remove_group(&self, id: &str) -> Result<(), StashError> { let conn = self.lock()?; categories::remove_group(&conn, id) } // ---------- 原子模型:分组树 / 卡片 ---------- /// 完整分组树(多层,带条目) pub fn load_tree(&self) -> Result { let conn = self.lock()?; catalog::load_tree(&conn) } pub fn load_cards(&self) -> Result, StashError> { let conn = self.lock()?; cards::load(&conn) } pub fn create_node(&self, new: groups::NewNode) -> Result<(), StashError> { let mut conn = self.lock()?; groups::create(&mut conn, new) } pub fn update_node(&self, key: &str, patch: groups::UpdateNode) -> Result<(), StashError> { let mut conn = self.lock()?; groups::update(&mut conn, key, patch) } pub fn remove_node(&self, key: &str) -> Result<(), StashError> { let conn = self.lock()?; groups::remove(&conn, key) } pub fn create_card(&self, new: cards::NewCard) -> Result { let mut conn = self.lock()?; cards::create(&mut conn, new) } pub fn update_card(&self, id: i64, patch: cards::UpdateCard) -> Result<(), StashError> { let mut conn = self.lock()?; cards::update(&mut conn, id, patch) } pub fn remove_card(&self, id: i64) -> Result<(), StashError> { let conn = self.lock()?; cards::remove(&conn, id) } pub fn set_card_groups( &self, id: i64, groups: Vec, ) -> Result<(), StashError> { let mut conn = self.lock()?; cards::set_groups(&mut conn, id, groups) } pub fn remove_category(&self, id: &str) -> Result<(), StashError> { let mut conn = self.lock()?; categories::remove(&mut conn, id) } } /// 把 rusqlite 的错误统一收敛成 `StashError::Database`。 pub(crate) fn map_error(error: rusqlite::Error) -> StashError { StashError::Database(error.to_string()) } #[cfg(test)] impl Db { /// 测试用:包一个空的内存库。 /// /// 路由测试只关心静态资源与回落规则,不查内容,所以既不建表也不灌种子。 pub(crate) fn in_memory() -> Self { Self { conn: Mutex::new(Connection::open_in_memory().expect("内存库应能打开")), } } } #[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_section_group_and_entry() { let conn = seeded(); let sections: i64 = conn .query_row( "SELECT COUNT(*) FROM node WHERE kind = 'section'", [], |row| row.get(0), ) .expect("应能统计分区"); let groups: i64 = conn .query_row( "SELECT COUNT(*) FROM node WHERE kind = 'group'", [], |row| row.get(0), ) .expect("应能统计分组"); let entries: i64 = conn .query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0)) .expect("应能统计条目"); let blocks: i64 = conn .query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0)) .expect("应能统计展示块"); let tags: i64 = conn .query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0)) .expect("应能统计标签"); let articles_with_body: i64 = conn .query_row( "SELECT COUNT(*) FROM entry WHERE body IS NOT NULL", [], |row| row.get(0), ) .expect("应能统计带正文的条目"); assert_eq!(sections, 3, "种子应含 常用推荐 / 推荐电影 / CES 三个分区"); assert_eq!(groups, 5, "常用推荐应有 5 个分组"); assert_eq!(entries, 42, "18 应用 + 12 电影 + 12 图文"); // 12 部电影各 2 个 tags,12 篇图文各 2 个 meta assert_eq!(tags, 48, "标签与元信息共用 entry_tag"); // 正文是一篇一段 Markdown,只有 12 篇文章有 assert_eq!(articles_with_body, 12, "只有图文条目带正文"); assert_eq!(blocks, 3, "每个分区一个首页展示块"); } /// 分区与分组是同一张表:分组必须挂在分区下,且不能有 view_mode。 #[test] fn groups_are_nodes_under_their_section() { let conn = seeded(); let (parent_key, view_mode): (String, Option) = conn .query_row( "SELECT p.key, g.view_mode FROM node g JOIN node p ON p.key = g.parent_key WHERE g.kind = 'group' AND g.key = 'ui'", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .expect("应能查到分组及其父节点"); assert_eq!(parent_key, "recommend"); assert!(view_mode.is_none(), "分组不应带 view_mode"); // 分区一定是顶层节点 let root_sections: i64 = conn .query_row( "SELECT COUNT(*) FROM node WHERE kind = 'section' AND parent_key IS NULL", [], |row| row.get(0), ) .expect("应能统计数据"); assert_eq!(root_sections, 3); } #[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["showInSidebar"], true); assert_eq!(apps["groups"].as_array().expect("应有分组").len(), 5); // 常用站点只作为首页卡片展示,不进侧栏 assert_eq!( apps["groups"][0]["showInSidebar"], false, "常用站点不应出现在侧栏" ); assert_eq!(apps["groups"][1]["showInSidebar"], true); 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()); // tags 来自 entry_tag 子表 assert_eq!( posters["items"][0]["tags"] .as_array() .expect("海报应有标签") .len(), 2 ); let articles = &categories[2]; assert_eq!(articles["kind"], "articles"); // 正文是一整段 Markdown,而不是块数组 let body = articles["items"][0]["body"] .as_str() .expect("图文应有正文"); assert!(body.contains("## 设计细节"), "正文应含小标题: {body}"); assert!(body.contains("!["), "正文应含配图语法: {body}"); assert!(body.contains("\n\n"), "段落之间用空行分隔"); assert!( articles["items"][0].get("blocks").is_none(), "不应再下发 blocks 数组" ); // meta 与海报的 tags 共用 entry_tag 表 assert_eq!( articles["items"][0]["meta"] .as_array() .expect("图文应有元信息") .len(), 2 ); // ---- 首页展示块 ---- // 常用推荐卡片展示「常用站点」分组,最多 4 条 assert_eq!(apps["block"]["nodeKey"], "common"); assert_eq!(apps["block"]["previewLimit"], 4); // 电影/文章没有分组,块直接指向分区自己 assert_eq!(posters["block"]["nodeKey"], "movie"); assert_eq!(articles["block"]["nodeKey"], "ces"); } /// 删除分区时,它的展示块一起消失(外键级联),不会留下指向空分区的卡片。 #[test] fn section_blocks_cascade_with_their_section() { let conn = seeded(); conn.execute("DELETE FROM node WHERE key = 'recommend' AND kind = 'section'", []) .expect("应能删除分区"); let remaining: i64 = conn .query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0)) .expect("应能统计展示块"); assert_eq!(remaining, 2, "三个展示块应随分区删除只剩两个"); } /// 删除分组节点时,组内条目也要一起删掉。 #[test] fn entries_cascade_when_their_group_is_removed() { let conn = seeded(); let before: i64 = conn .query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0)) .expect("应能统计条目"); conn.execute( "DELETE FROM node WHERE kind = 'group' AND key = 'ui' AND parent_key = 'recommend'", [], ) .expect("应能删除分组"); let after: i64 = conn .query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0)) .expect("应能统计条目"); assert_eq!(before - after, 5, "ui 分组原有 5 个应用,应被级联删除"); } /// 主键是 `id`(SQLite rowid),`key` 是唯一业务键;外键仍指向 key。 #[test] fn primary_key_is_id_and_key_is_unique() { let conn = seeded(); for table in ["node", "entry", "section_block"] { let pk: String = conn .query_row( &format!("SELECT name FROM pragma_table_info('{table}') WHERE pk = 1"), [], |row| row.get(0), ) .expect("应能查到主键列"); assert_eq!(pk, "id", "{table} 的主键应为 id"); } let duplicate = conn.execute( "INSERT INTO entry (key, node_key, name) VALUES ('github', 'common', '重复')", [], ); assert!(duplicate.is_err(), "key 必须唯一"); } /// `key` 是业务键:不允许重复,否则详情页按它查找会命中错行。 #[test] fn entry_key_is_unique() { let conn = seeded(); let duplicate = conn.execute( "INSERT INTO entry (key, node_key, name) SELECT key, node_key, '重复条目' FROM entry WHERE key = 'github'", [], ); assert!(duplicate.is_err(), "重复的条目 key 必须被拒绝"); } /// 条目不再存所属分区,分区由 `node_key` 推导: /// 改分区业务键后,它的分组与条目应当自动归到新键下。 #[test] fn section_membership_is_derived_from_parent() { let conn = seeded(); conn.execute("UPDATE node SET key = 'recommend-v2' WHERE key = 'recommend'", []) .expect("应能改分区业务键"); let catalog = catalog::load(&conn).expect("应能组装内容目录"); let apps = catalog .categories .iter() .find(|category| category["id"] == "recommend-v2") .expect("应能找到改名后的分区"); let total: usize = apps["groups"] .as_array() .expect("应有分组") .iter() .map(|group| group["items"].as_array().expect("应有条目").len()) .sum(); assert_eq!(total, 18, "改名后条目仍应归在这个分区下"); } /// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。 #[test] fn entry_fields_are_columns_not_json_blobs() { let conn = seeded(); let (name, href, cover, hot, views): (String, String, String, i64, i64) = conn .query_row( "SELECT name, href, cover, hot, views FROM entry WHERE key = 'github'", [], |row| { Ok(( row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, )) }, ) .expect("应能按列读取条目"); assert_eq!(name, "GitHub"); assert_eq!(href, "#"); assert!(cover.starts_with("https://")); assert_eq!(hot, 1, "布尔字段以 0/1 存储"); assert_eq!(views, 429); // 旧结构里那个 data 列已经不存在了 let has_data_column: i64 = conn .query_row( "SELECT COUNT(*) FROM pragma_table_info('entry') WHERE name = 'data'", [], |row| row.get(0), ) .expect("应能查询表结构"); assert_eq!(has_data_column, 0, "entry 不应再有 data JSON 列"); } #[test] fn data_version_is_readable() { let db = Db { conn: Mutex::new(seeded()), }; assert!(db.data_version().expect("应能读取 data_version") >= 0); } /// 全新库(user_version = 0)建全量结构;当前版本直接返回; /// 15 会迁移到 16;比程序新的版本拒绝。 #[test] fn migrates_fresh_previous_and_current_schema() { let conn = Connection::open_in_memory().expect("内存库应能打开"); assert_eq!( schema::migrate(&conn).expect("全新库应能建表"), schema::SCHEMA_VERSION, "新库应直接建到当前版本" ); let fresh = Connection::open_in_memory().expect("内存库应能打开"); fresh .execute_batch( "CREATE TABLE node (key TEXT PRIMARY KEY, parent_key TEXT, title TEXT, kind TEXT, view_mode TEXT); CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL); CREATE TABLE section_block (id INTEGER PRIMARY KEY, node_key TEXT NOT NULL, title TEXT, preview_limit INTEGER, sort INTEGER NOT NULL DEFAULT 0); PRAGMA user_version = 15;", ) .expect("应能造出 v15 老库"); assert_eq!( schema::migrate(&fresh).expect("v15 应能迁移"), schema::SCHEMA_VERSION ); let migrated: i64 = fresh .pragma_query_value(None, "user_version", |row| row.get(0)) .expect("应能读出版本"); assert_eq!(migrated, schema::SCHEMA_VERSION); let table: i64 = fresh .query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('admin','session','setting')", [], |row| row.get(0), ) .expect("应能查表"); assert_eq!(table, 3, "迁移后必须有登录/设置三张表"); } /// 比程序更新的库必须被拒绝,不能按旧结构乱读。 #[test] fn rejects_database_from_newer_schema() { let conn = Connection::open_in_memory().expect("内存库应能打开"); conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION + 1) .expect("应能写入新版本号"); let error = schema::migrate(&conn).expect_err("更新的库必须被拒绝"); let message = error.to_string(); assert!(message.contains("结构版本"), "报错要说明原因: {message}"); assert!(message.contains("升级程序"), "报错要给出处理方式: {message}"); } /// 回归测试:外部连接提交后,服务端连接读到的 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("外部连接应能打开"); let changed = external .execute("UPDATE entry SET name = '改过的名字' WHERE key = 'github'", []) .expect("外部写入应成功"); drop(external); // 没有实际改动就不会推进 data_version,这里先确认确实改到了行 assert_eq!(changed, 1, "外部更新应命中 github 条目"); let after = db.data_version().expect("应能读取新版本"); assert_ne!(before, after, "外部提交后 data_version 必须变化"); } }