- 新增 /api/catalog2:多层分组树(继承格式、带条目)+ 卡片勾选列表
- 新增 /api/nodes CRUD:任意层级分组、移动(防环)、排序、根分组格式
- 新增 /api/cards CRUD 与 PUT /api/cards/{id}/groups:一张卡可混合格式、可复用分组
- 条目允许挂在任意层级分组
- 测试 59 passed;API 冒烟通过(三级分组、卡片混合格式)
734 lines
26 KiB
Rust
734 lines
26 KiB
Rust
//! 内容库(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 必须变化");
|
||
}
|
||
}
|