Files
stash/src/db/mod.rs
T
CNWei 6340e435e9 feat(api): 原子模型分组树 / 卡片 / catalog2
- 新增 /api/catalog2:多层分组树(继承格式、带条目)+ 卡片勾选列表
- 新增 /api/nodes CRUD:任意层级分组、移动(防环)、排序、根分组格式
- 新增 /api/cards CRUD 与 PUT /api/cards/{id}/groups:一张卡可混合格式、可复用分组
- 条目允许挂在任意层级分组
- 测试 59 passed;API 冒烟通过(三级分组、卡片混合格式)
2026-10-30 20:35:37 +08:00

734 lines
26 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` 重启,
//! 启动时会重新建表并灌入内嵌种子。
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<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)?;
// 先迁移结构,再动数据:迁移失败会直接报错,不会用半套表跑查询
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<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()))
}
// ---------- 登录 / 会话 ----------
pub fn auth_state(&self, token: Option<&str>) -> Result<SessionState, StashError> {
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<Option<String>, StashError> {
let conn = self.lock()?;
auth::purge_expired(&conn)?;
auth::verify_login(&conn, password)
}
pub fn issue_session(&self) -> Result<String, StashError> {
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<bool, StashError> {
let mut conn = self.lock()?;
auth::change_password(&mut conn, current, next)
}
// ---------- 站点设置 ----------
pub fn load_settings(&self) -> Result<SettingsDocument, StashError> {
let conn = self.lock()?;
settings::load(&conn)
}
pub fn save_settings(
&self,
next: Settings,
expected_version: i64,
) -> Result<SettingsDocument, StashError> {
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<Option<EntryRecord>, StashError> {
let conn = self.lock()?;
entries::load(&conn, key)
}
pub fn create_entry(&self, new: entries::NewEntry) -> Result<EntryRecord, StashError> {
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<EntryRecord, StashError> {
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<Value, StashError> {
let conn = self.lock()?;
catalog::load_tree(&conn)
}
pub fn load_cards(&self) -> Result<Vec<Value>, 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<i64, StashError> {
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<cards::CardGroupInput>,
) -> 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<String>) = 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 必须变化");
}
}