feat(db): 内容库迁入 SQLite,分类与侧栏导航改为数据库驱动

This commit is contained in:
2026-10-23 20:22:23 +08:00
parent 386c0f77c7
commit 41fed5e64d
23 changed files with 2867 additions and 319 deletions
+202 -1
View File
@@ -1,9 +1,20 @@
use std::path::PathBuf;
use std::path::{Component, Path, PathBuf};
use crate::error::StashError;
/// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。
pub const DATA_DIR_NAME: &str = "data";
/// 内容库文件名:固定为这个名字,一个实例一个库。
pub const DB_FILE_NAME: &str = "stash.db";
#[derive(Debug, Clone)]
pub struct Config {
/// 静态页面托管根目录(存放一个或多个前端项目)
pub root: PathBuf,
/// 数据目录:存放内容库与将来的备份 / 导出文件
pub data_dir: PathBuf,
/// 内容库路径,固定为 `data/stash.db`
pub db_path: PathBuf,
/// 监听端口
pub port: u16,
/// 监听地址,默认 127.0.0.1
@@ -16,8 +27,13 @@ pub struct Config {
impl Default for Config {
fn default() -> Self {
// 默认值只在测试与兜底路径上使用,真实启动路径由 main.rs 解析
let data_dir = PathBuf::from(DATA_DIR_NAME);
Self {
root: PathBuf::from("../public"),
db_path: resolve_db_path(&data_dir),
data_dir,
port: 8080,
host: "127.0.0.1".to_string(),
directory_listing: true,
@@ -25,3 +41,188 @@ impl Default for Config {
}
}
}
/// 数据目录的默认位置:**二进制所在目录**下的 `data/`。
///
/// 之所以不用当前工作目录,是因为双击启动、快捷方式启动时 CWD 并不可控
/// (可能落在 System32 这种没写权限的位置),而数据库属于程序自身状态,
/// 跟着 exe 走最稳。托管目录 `--root` 仍然保持 CWD 相对,行为不变。
pub fn default_data_dir() -> PathBuf {
default_data_dir_from(std::env::current_exe().ok().as_deref())
}
/// `default_data_dir` 的纯函数版本,便于在测试里用假路径验证。
fn default_data_dir_from(exe: Option<&Path>) -> PathBuf {
match exe.and_then(Path::parent) {
// 拿不到 exe 路径时退化成 CWD 相对目录,至少不会 panic
Some(dir) => dir.join(DATA_DIR_NAME),
None => PathBuf::from(DATA_DIR_NAME),
}
}
/// 由数据目录推导出内容库路径。
pub fn resolve_db_path(data_dir: &Path) -> PathBuf {
data_dir.join(DB_FILE_NAME)
}
/// 校验内容库没有落在静态托管目录里。
///
/// `ServeDir` 会把 `--root` 下的所有文件当静态资源对外提供,
/// 一旦库文件在其中,任何人都能直接下载整个数据库,属于必须拦截的配置错误。
pub fn validate_db_location(root: &Path, db_path: &Path) -> Result<(), StashError> {
let root = normalize(root);
let db = normalize(db_path);
if db.starts_with(&root) {
return Err(StashError::InvalidDirectory(format!(
"内容库不能放在托管目录内部:库={},托管目录={}。请用 --data 指定其它位置。",
db.display(),
root.display()
)));
}
Ok(())
}
/// 去掉 Windows `canonicalize` 加上的 `\\?\` 前缀。
///
/// 该前缀本身合法,但会让 `Path::starts_with` 把它们当成不同的路径前缀:
/// `\\?\D:\sites` 与 `D:\sites` 的 `Prefix` 组件不相等,
/// 于是「库是否在托管目录内」的校验会被绕过。比较之前必须统一。
pub fn strip_verbatim(path: &Path) -> PathBuf {
match path.as_os_str().to_string_lossy().strip_prefix(r"\\?\") {
Some(stripped) => PathBuf::from(stripped),
None => path.to_path_buf(),
}
}
/// 展示用路径:去掉 `\\?\` 前缀后再转成字符串。
///
/// 前缀对程序无害,但打印在启动横幅和日志里很碍眼。
pub fn display_path(path: &Path) -> String {
strip_verbatim(path).display().to_string()
}
/// 把路径转成绝对路径,并消掉 `.` / `..` 与重复分隔符。///
/// 这里刻意不做 `canonicalize`:校验发生在目录创建之前,
/// 目标文件往往还不存在,而 `canonicalize` 要求路径真实存在。
/// 纯字符串层面的归一化已经足够判断「谁在谁内部」。
fn normalize(path: &Path) -> PathBuf {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
};
// `canonicalize` 产出的 `\\?\` 前缀会让后续 starts_with 比较失效,先统一掉
let absolute = strip_verbatim(&absolute);
let mut normalized = PathBuf::new();
for component in absolute.components() {
match component {
// 当前目录不产生任何变化
Component::CurDir => {}
// 父目录回退一级;已经到根时 pop 是空操作,正好不会越界
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn data_dir_follows_the_binary_directory() {
let dir = default_data_dir_from(Some(Path::new(r"D:\apps\stash\stash.exe")));
assert_eq!(dir, PathBuf::from(r"D:\apps\stash\data"));
}
#[test]
fn data_dir_falls_back_to_relative_when_exe_path_is_unknown() {
assert_eq!(default_data_dir_from(None), PathBuf::from(DATA_DIR_NAME));
}
#[test]
fn db_path_is_fixed_inside_the_data_dir() {
assert_eq!(
resolve_db_path(Path::new(r"D:\apps\stash\data")),
PathBuf::from(r"D:\apps\stash\data\stash.db")
);
}
#[test]
fn rejects_database_nested_inside_the_hosted_root() {
let err = validate_db_location(
Path::new(r"D:\sites"),
Path::new(r"D:\sites\data\stash.db"),
);
assert!(err.is_err(), "库在托管目录内时必须拒绝启动");
}
#[test]
fn accepts_database_beside_the_hosted_root() {
assert!(
validate_db_location(
Path::new(r"D:\stash\public"),
Path::new(r"D:\stash\data\stash.db"),
)
.is_ok()
);
}
#[test]
fn location_check_survives_dot_segments() {
// `..` 归一化之后应认出这是同一个目录,而不是被绕过
assert!(
validate_db_location(
Path::new(r"D:\stash\sites"),
Path::new(r"D:\stash\sites\..\sites\stash.db"),
)
.is_err()
);
}
/// 回归测试:`canonicalize` 会给路径加 `\\?\` 前缀,
/// 若比较前不统一,库落在托管目录内这种配置错误会被漏过。
#[test]
fn location_check_ignores_verbatim_prefix() {
assert!(
validate_db_location(
Path::new(r"\\?\D:\stash\sites"),
Path::new(r"D:\stash\sites\data\stash.db"),
)
.is_err(),
"带 \\\\?\\ 前缀的托管目录也必须能拦住库"
);
assert!(
validate_db_location(
Path::new(r"D:\stash\sites"),
Path::new(r"\\?\D:\stash\sites\data\stash.db"),
)
.is_err(),
"带 \\\\?\\ 前缀的库路径也必须能被识别"
);
}
#[test]
fn verbatim_prefix_is_stripped() {
assert_eq!(
strip_verbatim(Path::new(r"\\?\D:\apps\stash")),
PathBuf::from(r"D:\apps\stash")
);
assert_eq!(
strip_verbatim(Path::new(r"D:\apps\stash")),
PathBuf::from(r"D:\apps\stash")
);
}
}
+321
View File
@@ -0,0 +1,321 @@
//! 内容库的查询组装:把关系表还原成前端 `types.ts` 里的 `Category[]`。
//!
//! 这里输出的是 `serde_json::Value` 而不是一堆 Rust DTO,理由是:
//! 条目的可变字段本来就存在 JSON 列里,直接用 Value 拼装可以保证
//! 「用户往 JSON 里加什么字段,前端就能读到什么字段」,不需要每加一个
//! 字段就改一次 Rust 结构体。
use rusqlite::Connection;
use serde_json::{Map, Value, json};
use crate::{
db::{Catalog, map_error},
error::StashError,
};
/// 分类行:与 `category` 表一一对应
struct CategoryRow {
id: String,
kind: String,
title: String,
bar_color: String,
icon: String,
preview_limit: Option<i64>,
empty_text: Option<String>,
}
/// 导航行:侧栏自引用树的原始形态
struct NavRow {
id: String,
parent_id: Option<String>,
label: String,
category_id: Option<String>,
group_id: Option<String>,
}
/// 条目行:`data` 里是尚未展开的 JSON 文本
struct EntryRow {
id: String,
group_id: Option<String>,
name: String,
views: i64,
likes: i64,
data: String,
}
/// 读出整个内容目录:分类树 + 侧栏导航。
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
Ok(Catalog {
categories: load_categories(conn)?,
nav: load_nav(conn)?,
})
}
/// 读出全部分类,按 `sort` 排序;顺序即首页分区顺序。
fn load_categories(conn: &Connection) -> Result<Vec<Value>, StashError> {
let mut stmt = conn
.prepare(
"SELECT id, kind, title, bar_color, icon, preview_limit, empty_text
FROM category
ORDER BY sort, id",
)
.map_err(map_error)?;
let categories = stmt
.query_map([], |row| {
Ok(CategoryRow {
id: row.get(0)?,
kind: row.get(1)?,
title: row.get(2)?,
bar_color: row.get(3)?,
icon: row.get(4)?,
preview_limit: row.get(5)?,
empty_text: row.get(6)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
// 先释放语句再发后续查询,避免同一连接上的借用冲突
drop(stmt);
categories
.iter()
.map(|category| build_category(conn, category))
.collect()
}
/// 按 kind 分派到不同的组装方式。
fn build_category(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
let mut object = Map::new();
object.insert("id".into(), json!(category.id));
object.insert("kind".into(), json!(category.kind));
object.insert("title".into(), json!(category.title));
object.insert("barColor".into(), json!(category.bar_color));
object.insert("icon".into(), json!(category.icon));
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
if let Some(limit) = category.preview_limit {
object.insert("previewLimit".into(), json!(limit));
}
if let Some(empty_text) = &category.empty_text {
object.insert("emptyText".into(), json!(empty_text));
}
match category.kind.as_str() {
"apps" => object.insert("groups".into(), build_groups(conn, category)?),
"posters" | "articles" => object.insert("items".into(), build_items(conn, category)?),
other => {
return Err(StashError::Database(format!(
"分类 {} 的 kind 非法: {other}",
category.id
)));
}
};
Ok(Value::Object(object))
}
/// 应用类:先取出分组,再把条目按 `group_id` 分派进去。
///
/// 只用两条查询(分组一条、条目一条)而不是每组一条,避免 N+1。
fn build_groups(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
let mut stmt = conn
.prepare("SELECT id, name FROM app_group WHERE category_id = ?1 ORDER BY sort, id")
.map_err(map_error)?;
let groups = stmt
.query_map([&category.id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let entries = load_entries(conn, &category.id)?;
let mut rendered_groups = Vec::with_capacity(groups.len());
for (group_id, group_name) in groups {
let mut items = Vec::new();
// 保持 entry.sort 决定的顺序;group_id 为空的条目不属于任何分组
for entry in entries.iter().filter(|entry| {
entry.group_id.as_deref() == Some(group_id.as_str())
}) {
items.push(Value::Object(entry_object(entry, &category.id, "name")?));
}
rendered_groups.push(json!({
"id": group_id,
"name": group_name,
"items": items,
}));
}
Ok(Value::Array(rendered_groups))
}
/// 海报 / 图文类:没有分组,直接输出条目数组。
fn build_items(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
let entries = load_entries(conn, &category.id)?;
let items = entries
.iter()
.map(|entry| entry_object(entry, &category.id, "title").map(Value::Object))
.collect::<Result<Vec<_>, _>>()?;
Ok(Value::Array(items))
}
/// 取某个分类下的全部条目,按 `sort` 升序。
fn load_entries(conn: &Connection, category_id: &str) -> Result<Vec<EntryRow>, StashError> {
let mut stmt = conn
.prepare(
"SELECT id, group_id, name, views, likes, data
FROM entry
WHERE category_id = ?1
ORDER BY sort, id",
)
.map_err(map_error)?;
let entries = stmt
.query_map([category_id], |row| {
Ok(EntryRow {
id: row.get(0)?,
group_id: row.get(1)?,
name: row.get(2)?,
views: row.get(3)?,
likes: row.get(4)?,
data: row.get(5)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
Ok(entries)
}
/// 把一条条目展开成对象。
///
/// `name_key` 决定展示名落在哪个字段:应用用 `name`,海报与图文用 `title`。
/// 数据库列优先于 JSON,用户即便在 `data` 里写了同名键也不会顶掉真实值。
fn entry_object(
entry: &EntryRow,
category_id: &str,
name_key: &str,
) -> Result<Map<String, Value>, StashError> {
let parsed: Value = serde_json::from_str(&entry.data).map_err(|error| {
// 把位置写进报错里,用户改坏 JSON 时能立刻定位到是哪一行
StashError::Database(format!(
"条目 {category_id}/{} 的 data 列不是合法 JSON: {error}",
entry.id
))
})?;
let Value::Object(mut object) = parsed else {
return Err(StashError::Database(format!(
"条目 {category_id}/{} 的 data 列必须是 JSON 对象",
entry.id
)));
};
object.insert("id".into(), json!(entry.id));
object.insert(name_key.into(), json!(entry.name));
object.insert("views".into(), json!(entry.views));
object.insert("likes".into(), json!(entry.likes));
Ok(object)
}
// ---------------------------------------------------------------------------
// 侧栏导航
// ---------------------------------------------------------------------------
/// 读出侧栏导航,组装成 `{ topLevel, groups }`,结构与 frontend 的 `nav.ts` 一致。
///
/// 侧栏只有两层(顶层项 / 分组内的叶子),所以不需要通用递归:
/// 顶层里带 `category_id` 的是直达链接,不带的是可折叠分组,其子节点即叶子。
fn load_nav(conn: &Connection) -> Result<Value, StashError> {
let mut stmt = conn
.prepare(
"SELECT id, parent_id, label, category_id, group_id
FROM nav_entry
ORDER BY sort, id",
)
.map_err(map_error)?;
let rows = stmt
.query_map([], |row| {
Ok(NavRow {
id: row.get(0)?,
parent_id: row.get(1)?,
label: row.get(2)?,
category_id: row.get(3)?,
group_id: row.get(4)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let mut top_level = Vec::new();
let mut groups = Vec::new();
for row in &rows {
// 有 parent_id 的都是分组内的叶子,在这里由分组统一收集
if row.parent_id.is_some() {
continue;
}
if row.category_id.is_some() {
// 顶层直达项
top_level.push(nav_leaf(row));
continue;
}
// 可折叠分组:收集它的子节点
let leaves = rows
.iter()
.filter(|child| child.parent_id.as_deref() == Some(row.id.as_str()))
.filter(|child| child.category_id.is_some())
.map(nav_leaf)
.collect::<Vec<_>>();
groups.push(json!({
"id": row.id,
"label": row.label,
"leaves": leaves,
}));
}
Ok(json!({
"topLevel": top_level,
"groups": groups,
}))
}
/// 把一个导航叶子转成前端结构;`groupId` 为空时不下发该键。
fn nav_leaf(row: &NavRow) -> Value {
let mut target = Map::new();
target.insert(
"categoryId".into(),
json!(row.category_id.clone().unwrap_or_default()),
);
if let Some(group_id) = &row.group_id {
target.insert("groupId".into(), json!(group_id));
}
json!({
"id": row.id,
"label": row.label,
"target": Value::Object(target),
})
}
+301
View File
@@ -0,0 +1,301 @@
//! 内容库(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 必须变化");
}
}
+361
View File
@@ -0,0 +1,361 @@
//! 内容库的表结构与种子导入。
//!
//! 表结构刻意保持「薄」:分类和分组是关系表,条目除 id / 排序 / 计数外的
//! 全部字段都塞进 `entry.data` 的 JSON 列。原因是前端 `types.ts` 用的是
//! 可辨识联合(apps / posters / articles),三种形态字段差异很大,
//! 拆成三张表会让查询和后续加字段都变啰嗦;JSON 列则天然与前端结构 1:1。
use rusqlite::{Connection, params};
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::{db::map_error, error::StashError};
/// 种子文件在编译期内嵌,分发时不需要额外带数据文件。
const SEED_JSON: &str = include_str!("seed.json");
/// 建表语句。
///
/// 开发阶段结构还会调整,所以这里只做 `IF NOT EXISTS` 式的«建库»,
/// 不实现版本迁移:需要改结构时删掉 `data/stash.db` 重启即可,
/// 种子会自动重新灌一份干净数据。
const SCHEMA_SQL: &str = r#"
-- 分类:首页的一张大卡片
CREATE TABLE IF NOT EXISTS category (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('apps', 'posters', 'articles')),
title TEXT NOT NULL,
bar_color TEXT NOT NULL,
icon TEXT NOT NULL,
preview_limit INTEGER,
empty_text TEXT,
sort INTEGER NOT NULL DEFAULT 0
);
-- 应用类分类的分组:明细页按组展示,首页把各组前几项拼成预览
CREATE TABLE IF NOT EXISTS app_group (
category_id TEXT NOT NULL REFERENCES category(id) ON DELETE CASCADE,
id TEXT NOT NULL,
name TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (category_id, id)
);
-- 条目:应用 / 海报 / 图文共用一张表
-- name 展示名(应用是 name,海报与图文是 title)
-- group_id 只有应用类用得上,其余为 NULL
-- data 该条目其余字段的 JSON,键名与前端 types.ts 一致
CREATE TABLE IF NOT EXISTS entry (
category_id TEXT NOT NULL REFERENCES category(id) ON DELETE CASCADE,
id TEXT NOT NULL,
group_id TEXT,
name TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
likes INTEGER NOT NULL DEFAULT 0,
sort INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL,
PRIMARY KEY (category_id, id)
);
CREATE INDEX IF NOT EXISTS entry_group_idx ON entry(category_id, group_id, sort);
-- 侧栏导航:自引用树,一条表同时表达两种节点
-- parent_id 为 NULL → 顶层项
-- category_id 非 NULL → 叶子,点击后切到该分类(可带分组)
-- category_id 为 NULL → 可折叠分组,它的子节点通过 parent_id 指向它
-- 层级和标签必须单独存:侧栏的「素材资源 / 开发工具」分组名与分类名不同,
-- 同一个分组还能被多个叶子复用(如「前端调试工具」和「在线编辑器」都指向 recommend/dev)。
CREATE TABLE IF NOT EXISTS nav_entry (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES nav_entry(id) ON DELETE CASCADE,
label TEXT NOT NULL,
category_id TEXT REFERENCES category(id) ON DELETE CASCADE,
group_id TEXT,
sort INTEGER NOT NULL DEFAULT 0
);
"#;
/// 种子导入结果,仅用于启动日志。
#[derive(Debug, Default, PartialEq, Eq)]
pub struct SeedReport {
pub categories: usize,
pub groups: usize,
pub entries: usize,
/// 侧栏导航节点数(顶层项 + 叶子)
pub nav_items: usize,
}
/// 建表(幂等):每次启动都跑一遍,缺表就补,已有数据不动。
pub fn apply(conn: &Connection) -> Result<(), StashError> {
conn.execute_batch(SCHEMA_SQL).map_err(map_error)
}
/// 把内嵌种子写进空库。
///
/// 只在数据库文件是本次启动新建时调用,所以不存在「覆盖用户数据」的问题;
/// 用户删掉库以后重启会重新得到一份干净的内置内容,这也是开发期的重置手段。
pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
let seed: SeedFile = serde_json::from_str(SEED_JSON)
.map_err(|error| StashError::Database(format!("种子文件不是合法 JSON: {error}")))?;
let mut report = SeedReport::default();
// 单事务写入:避免中途失败留下半个内容库
let tx = conn.transaction().map_err(map_error)?;
for category in &seed.categories {
let kind = category.kind.as_str();
// 只认识前端支持的三种形态,拼错时立刻报错而不是静默写脏数据
if !matches!(kind, "apps" | "posters" | "articles") {
return Err(StashError::Database(format!(
"种子分类 {} 的 kind 非法: {kind}",
category.id
)));
}
tx.execute(
"INSERT INTO category (id, kind, title, bar_color, icon, preview_limit, empty_text, sort)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
category.id,
kind,
category.title,
category.bar_color,
category.icon,
category.preview_limit,
category.empty_text,
category.sort,
],
)
.map_err(map_error)?;
report.categories += 1;
if kind == "apps" {
for group in &category.groups {
tx.execute(
"INSERT INTO app_group (category_id, id, name, sort) VALUES (?1, ?2, ?3, ?4)",
params![category.id, group.id, group.name, group.sort],
)
.map_err(map_error)?;
report.groups += 1;
for (index, item) in group.items.iter().enumerate() {
insert_entry(&tx, &category.id, Some(&group.id), index, item)?;
report.entries += 1;
}
}
continue;
}
// 海报与图文没有分组,直接挂在分类下
for (index, item) in category.items.iter().enumerate() {
insert_entry(&tx, &category.id, None, index, item)?;
report.entries += 1;
}
}
// 侧栏导航必须放在分类之后插入:叶子对 category(id) 有外键依赖
report.nav_items = seed_nav(&tx, &seed.nav)?;
tx.commit().map_err(map_error)?;
Ok(report)
}
/// 导入侧栏导航。
///
/// 先插顶层项(parent_id 为空),再插各分组的叶子——自引用外键要求父节点先存在。
/// 返回写入的节点总数。
fn seed_nav(tx: &rusqlite::Transaction<'_>, nav: &SeedNav) -> Result<usize, StashError> {
let mut inserted = 0usize;
// 顶层直达项与折叠分组是同一层的兄弟节点,必须共用一个递增序号,
// 否则两组序号都从 0 开始、互相撞号,最终顺序会退化成按 id 排序。
let mut top_sort = 0usize;
// 顶层直达项:有 category_id,渲染成不带展开箭头的链接
for leaf in &nav.top_level {
insert_nav_node(tx, leaf, None, top_sort)?;
top_sort += 1;
inserted += 1;
}
// 折叠分组:本身没有 category_id,子节点才是真正的链接
for group in &nav.groups {
tx.execute(
"INSERT INTO nav_entry (id, parent_id, label, category_id, group_id, sort)
VALUES (?1, NULL, ?2, NULL, NULL, ?3)",
params![group.id, group.label, top_sort as i64],
)
.map_err(map_error)?;
top_sort += 1;
inserted += 1;
// 组内叶子单独编号:它们是另一个层级,序号从 0 起不会和顶层冲突
for (leaf_index, leaf) in group.leaves.iter().enumerate() {
insert_nav_node(tx, leaf, Some(&group.id), leaf_index)?;
inserted += 1;
}
}
Ok(inserted)
}
/// 写入一个导航叶子节点,指向「分类 + 可选分组」。
fn insert_nav_node(
tx: &rusqlite::Transaction<'_>,
leaf: &SeedNavLeaf,
parent_id: Option<&str>,
sort: usize,
) -> Result<(), StashError> {
tx.execute(
"INSERT INTO nav_entry (id, parent_id, label, category_id, group_id, sort)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
leaf.id,
parent_id,
leaf.label,
leaf.target.category_id,
leaf.target.group_id,
sort as i64,
],
)
.map_err(|error| {
// 外键失败通常意味着种子里的 target 写错了分类 id,把节点名带进报错方便定位
StashError::Database(format!("写入导航节点 {} 失败: {error}", leaf.id))
})?;
Ok(())
}
/// 写入一个条目,并把 `views` / `likes` 从 JSON 里提到独立列。
///
/// 计数放列上是为了让用户用任何 SQLite 工具都能直接改数,
/// 也避免将来自增时重写整个 JSON。
fn insert_entry(
tx: &rusqlite::Transaction<'_>,
category_id: &str,
group_id: Option<&str>,
index: usize,
item: &SeedItem,
) -> Result<(), StashError> {
let mut extra = item.extra.clone();
let views = take_count(&mut extra, "views");
let likes = take_count(&mut extra, "likes");
let data = Value::Object(extra).to_string();
tx.execute(
"INSERT INTO entry (category_id, id, group_id, name, views, likes, sort, data)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
category_id,
item.id,
group_id,
item.name,
views,
likes,
index as i64,
data,
],
)
.map_err(map_error)?;
Ok(())
}
/// 从 JSON 对象里取出计数值并删除该键,缺省或类型不对时按 0 处理。
fn take_count(extra: &mut Map<String, Value>, key: &str) -> i64 {
match extra.remove(key) {
Some(Value::Number(number)) => number.as_i64().unwrap_or(0),
_ => 0,
}
}
// ---------------------------------------------------------------------------
// 种子文件的反序列化结构
// ---------------------------------------------------------------------------
/// 种子文件根节点
#[derive(Debug, Deserialize)]
struct SeedFile {
categories: Vec<SeedCategory>,
/// 侧栏导航;缺省时侧栏为空,但不影响分类内容
#[serde(default)]
nav: SeedNav,
}
/// 侧栏导航:顶层直达项 + 可折叠分组
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedNav {
#[serde(default)]
top_level: Vec<SeedNavLeaf>,
#[serde(default)]
groups: Vec<SeedNavGroup>,
}
/// 一个可折叠分组,本身不可点击,只负责收纳叶子
#[derive(Debug, Deserialize)]
struct SeedNavGroup {
id: String,
label: String,
#[serde(default)]
leaves: Vec<SeedNavLeaf>,
}
/// 导航叶子:点击后切到 `target` 指定的分类 / 分组
#[derive(Debug, Deserialize)]
struct SeedNavLeaf {
id: String,
label: String,
target: SeedNavTarget,
}
/// 导航目标;`group_id` 为空表示直接打开整个分类
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedNavTarget {
category_id: String,
#[serde(default)]
group_id: Option<String>,
}
/// 一个分类;`groups` 与 `items` 按 kind 二者取一
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedCategory {
id: String,
kind: String,
title: String,
bar_color: String,
icon: String,
sort: i64,
#[serde(default)]
preview_limit: Option<i64>,
#[serde(default)]
empty_text: Option<String>,
#[serde(default)]
groups: Vec<SeedGroup>,
#[serde(default)]
items: Vec<SeedItem>,
}
/// 应用类分类下的一个分组
#[derive(Debug, Deserialize)]
struct SeedGroup {
id: String,
name: String,
sort: i64,
items: Vec<SeedItem>,
}
/// 一条内容;除 id / name 外全部字段原样落进 JSON 列。
///
/// 用 `flatten` 收集剩余键,这样种子文件里新增字段不需要同步改 Rust 结构。
#[derive(Debug, Deserialize)]
struct SeedItem {
id: String,
name: String,
#[serde(flatten)]
extra: Map<String, Value>,
}
+986
View File
@@ -0,0 +1,986 @@
{
"categories": [
{
"id": "recommend",
"kind": "apps",
"title": "常用推荐",
"barColor": "#3858f6",
"icon": "🔖",
"sort": 1,
"groups": [
{
"id": "common",
"name": "常用站点",
"sort": 1,
"items": [
{
"id": "github",
"name": "GitHub",
"desc": "代码托管与开源社区",
"icon": "G",
"href": "#",
"cover": "https://picsum.photos/seed/github/200/200",
"hot": true,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/github-banner/800/300",
"badge": "推荐好站",
"views": 429,
"likes": 38
},
{
"id": "zhihu",
"name": "知乎",
"desc": "深度内容问答社区",
"icon": "Z",
"href": "#",
"cover": "https://picsum.photos/seed/zhihu/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/zhihu-banner/800/300",
"badge": "推荐好站",
"views": 294,
"likes": 73
},
{
"id": "bilibili",
"name": "哔哩哔哩",
"desc": "弹幕视频分享网站",
"icon": "B",
"href": "#",
"cover": "https://picsum.photos/seed/bilibili/200/200",
"hot": true,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/bilibili-banner/800/300",
"badge": "推荐好站",
"views": 280,
"likes": 9
},
{
"id": "v2ex",
"name": "V2EX",
"desc": "创意工作者讨论社区",
"icon": "V",
"href": "#",
"cover": "https://picsum.photos/seed/v2ex/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/v2ex-banner/800/300",
"badge": "推荐好站",
"views": 387,
"likes": 72
}
]
},
{
"id": "ui",
"name": "UI 设计资源",
"sort": 2,
"items": [
{
"id": "wallhaven",
"name": "Wallhaven",
"desc": "WallBase 创办人失踪后推出的精选壁纸站",
"icon": "W",
"href": "#",
"cover": "https://picsum.photos/seed/wallhaven/200/200",
"hot": true,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/wallhaven-banner/800/300",
"badge": "推荐好站",
"views": 608,
"likes": 51
},
{
"id": "500px",
"name": "500px",
"desc": "有着世界上最好的照片分享社区",
"icon": "5",
"href": "#",
"cover": "https://picsum.photos/seed/500px/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/500px-banner/800/300",
"badge": "推荐好站",
"views": 573,
"likes": 68
},
{
"id": "chuangyiren",
"name": "创意人",
"desc": "去掉了形式主义描述的原创社区",
"icon": "C",
"href": "#",
"cover": "https://picsum.photos/seed/chuangyiren/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/chuangyiren-banner/800/300",
"badge": "推荐好站",
"views": 364,
"likes": 20
},
{
"id": "topys",
"name": "顶尖文案",
"desc": "全球顶尖创意分享平台 OPEN YOUR EYES",
"icon": "T",
"href": "#",
"cover": "https://picsum.photos/seed/topys/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/topys-banner/800/300",
"badge": "推荐好站",
"views": 745,
"likes": 68
},
{
"id": "uichina",
"name": "UI中国",
"desc": "前身为 iconfans,国内专业的 UI 设计平台",
"icon": "U",
"href": "#",
"cover": "https://picsum.photos/seed/uichina/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/uichina-banner/800/300",
"badge": "推荐好站",
"views": 743,
"likes": 4
}
]
},
{
"id": "vector",
"name": "矢量图标",
"sort": 3,
"items": [
{
"id": "iconfont",
"name": "Iconfont",
"desc": "阿里妈妈矢量图标库",
"icon": "I",
"href": "#",
"cover": "https://picsum.photos/seed/iconfont/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/iconfont-banner/800/300",
"badge": "推荐好站",
"views": 585,
"likes": 41
},
{
"id": "feather",
"name": "Feather Icons",
"desc": "简单漂亮的开源图标库",
"icon": "F",
"href": "#",
"cover": "https://picsum.photos/seed/feather/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/feather-banner/800/300",
"badge": "推荐好站",
"views": 390,
"likes": 48
},
{
"id": "fontawesome",
"name": "FontAwesome",
"desc": "图标字体与 CSS 框架",
"icon": "A",
"href": "#",
"cover": "https://picsum.photos/seed/fontawesome/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/fontawesome-banner/800/300",
"badge": "推荐好站",
"views": 317,
"likes": 17
}
]
},
{
"id": "wallpaper",
"name": "免费壁纸",
"sort": 4,
"items": [
{
"id": "unsplash",
"name": "Unsplash",
"desc": "免费高清无版权摄影图片",
"icon": "U",
"href": "#",
"cover": "https://picsum.photos/seed/unsplash/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/unsplash-banner/800/300",
"badge": "推荐好站",
"views": 807,
"likes": 5
},
{
"id": "pexels",
"name": "Pexels",
"desc": "才华横溢的摄影师分享的素材",
"icon": "P",
"href": "#",
"cover": "https://picsum.photos/seed/pexels/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/pexels-banner/800/300",
"badge": "推荐好站",
"views": 380,
"likes": 12
},
{
"id": "pixabay",
"name": "Pixabay",
"desc": "超过 2.7 百万张优质图片和视频",
"icon": "X",
"href": "#",
"cover": "https://picsum.photos/seed/pixabay/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/pixabay-banner/800/300",
"badge": "推荐好站",
"views": 771,
"likes": 83
}
]
},
{
"id": "dev",
"name": "开发工具",
"sort": 5,
"items": [
{
"id": "codepen",
"name": "CodePen",
"desc": "前端在线代码演示和练习场",
"icon": "C",
"href": "#",
"cover": "https://picsum.photos/seed/codepen/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/codepen-banner/800/300",
"badge": "推荐好站",
"views": 266,
"likes": 13
},
{
"id": "stackoverflow",
"name": "Stack Overflow",
"desc": "全球程序员技术问答社区",
"icon": "S",
"href": "#",
"cover": "https://picsum.photos/seed/stackoverflow/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/stackoverflow-banner/800/300",
"badge": "推荐好站",
"views": 112,
"likes": 25
},
{
"id": "github-dev",
"name": "GitHub",
"desc": "代码托管与开源协作社区",
"icon": "G",
"href": "#",
"cover": "https://picsum.photos/seed/github-dev/200/200",
"hot": false,
"comment": "收录在本地导航里的常用站点,点开就能直达。",
"banner": "https://picsum.photos/seed/github-dev-banner/800/300",
"badge": "推荐好站",
"views": 587,
"likes": 32
}
]
}
]
},
{
"id": "movie",
"kind": "posters",
"title": "推荐电影",
"barColor": "#ff6034",
"icon": "🎬",
"sort": 2,
"items": [
{
"id": "movie-1",
"name": "千与千寻 千と千尋の神隠し (2001)",
"cover": "https://picsum.photos/seed/m1/300/450",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "少女千寻误入神灵世界,为救回变成猪的父母,在汤屋打工并逐渐学会担当。",
"href": "#",
"views": 896,
"likes": 51
},
{
"id": "movie-2",
"name": "美丽人生 La vita è bella (1997)",
"cover": "https://picsum.photos/seed/m2/300/450",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "二战集中营里,父亲用一场「游戏」保护儿子,让他相信世界仍然美好。",
"href": "#",
"views": 897,
"likes": 20
},
{
"id": "movie-3",
"name": "这个杀手不太冷 Léon (1994)",
"cover": "https://picsum.photos/seed/m3/300/450",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "孤独的职业杀手莱昂收留了邻居少女玛蒂尔达,两个边缘人相互依靠。",
"href": "#",
"views": 898,
"likes": 55
},
{
"id": "movie-4",
"name": "阿甘正传 Forrest Gump (1994)",
"cover": "https://picsum.photos/seed/m4/300/450",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "智商不高的阿甘凭着一股单纯的执着,意外见证并参与了几十年美国历史。",
"href": "#",
"views": 899,
"likes": 32
},
{
"id": "movie-5",
"name": "霸王别姬 (1993)",
"cover": "https://picsum.photos/seed/m5/300/450",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "两位京剧伶人半个世纪的悲欢离合,与时代洪流交织在一起。",
"href": "#",
"views": 900,
"likes": 59
},
{
"id": "movie-6",
"name": "肖申克的救赎 The Shawshank Redemption",
"cover": "https://picsum.photos/seed/m6/300/450",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "蒙冤入狱的银行家安迪,在漫长牢狱中守住希望并完成自救。",
"href": "#",
"views": 901,
"likes": 36
},
{
"id": "movie-7",
"name": "泰坦尼克号 Titanic (1997)",
"cover": "https://picsum.photos/seed/m7/300/450",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "沉船之夜,来自不同阶层的杰克与露丝相爱,也被人性的选择所考验。",
"href": "#",
"views": 902,
"likes": 13
},
{
"id": "movie-8",
"name": "盗梦空间 Inception (2010)",
"cover": "https://picsum.photos/seed/m8/300/450",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "盗梦团队潜入层层梦境,试图在目标的潜意识里植入一个念头。",
"href": "#",
"views": 903,
"likes": 48
},
{
"id": "movie-9",
"name": "星际穿越 Interstellar (2014)",
"cover": "https://picsum.photos/seed/m9/300/450",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "地球濒临枯竭,父亲穿越虫洞寻找新家园,与女儿跨越时间重逢。",
"href": "#",
"views": 904,
"likes": 17
},
{
"id": "movie-10",
"name": "楚门的世界 The Truman Show (1998)",
"cover": "https://picsum.photos/seed/m10/300/450",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "楚门渐渐发现,自己的整个人生其实是一档被全世界围观的真人秀。",
"href": "#",
"views": 608,
"likes": 45
},
{
"id": "movie-11",
"name": "疯狂动物城 Zootopia (2016)",
"cover": "https://picsum.photos/seed/m11/300/450",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "兔子警官与狐狸搭档,在动物城里侦破一桩失踪案,也打破偏见。",
"href": "#",
"views": 609,
"likes": 22
},
{
"id": "movie-12",
"name": "大话西游之大圣娶亲 (1995)",
"cover": "https://picsum.photos/seed/m12/300/450",
"tags": [
"推荐电影",
"豆瓣电影 Top250"
],
"comment": "收藏在本地片单里,闲下来就重看一遍。",
"summary": "至尊宝在爱情与宿命之间反复抉择,最终戴上金箍踏上取经路。",
"href": "#",
"views": 610,
"likes": 57
}
]
},
{
"id": "ces",
"kind": "articles",
"title": "CES 现场报道",
"barColor": "#ffba3a",
"icon": "🏷️",
"sort": 3,
"items": [
{
"id": "ces-1",
"name": "可塑性极强的玻璃,在设计师手里有了更多可能 | 这个设计了不起",
"desc": "前沿科技",
"cover": "https://picsum.photos/seed/a1/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"blocks": [
{
"type": "paragraph",
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「前沿科技」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
},
{
"type": "heading",
"text": "设计细节"
},
{
"type": "paragraph",
"text": "《可塑性极强的玻璃,在设计师手里有了更多可能》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
},
{
"type": "paragraph",
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
},
{
"type": "image",
"src": "https://picsum.photos/seed/a1/400/250"
},
{
"type": "paragraph",
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
},
{
"id": "ces-2",
"name": "废旧共享单车如何变成微型共享书屋? | 这个设计了不起",
"desc": "环保设计",
"cover": "https://picsum.photos/seed/a2/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"blocks": [
{
"type": "paragraph",
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「环保设计」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
},
{
"type": "heading",
"text": "设计细节"
},
{
"type": "paragraph",
"text": "《废旧共享单车如何变成微型共享书屋?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
},
{
"type": "paragraph",
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
},
{
"type": "image",
"src": "https://picsum.photos/seed/a2/400/250"
},
{
"type": "paragraph",
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
},
{
"id": "ces-3",
"name": "荧光灯管与混凝土块的组合,可以玩出多少花样? | 这个设计了不起",
"desc": "材料探索",
"cover": "https://picsum.photos/seed/a3/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"blocks": [
{
"type": "paragraph",
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「材料探索」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
},
{
"type": "heading",
"text": "设计细节"
},
{
"type": "paragraph",
"text": "《荧光灯管与混凝土块的组合,可以玩出多少花样?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
},
{
"type": "paragraph",
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
},
{
"type": "image",
"src": "https://picsum.photos/seed/a3/400/250"
},
{
"type": "paragraph",
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
},
{
"id": "ces-4",
"name": "混凝土做的模块化收纳框,还能改成搁架用 | 这个设计了不起",
"desc": "家居模块",
"cover": "https://picsum.photos/seed/a4/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"blocks": [
{
"type": "paragraph",
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「家居模块」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
},
{
"type": "heading",
"text": "设计细节"
},
{
"type": "paragraph",
"text": "《混凝土做的模块化收纳框,还能改成搁架用》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
},
{
"type": "paragraph",
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
},
{
"type": "image",
"src": "https://picsum.photos/seed/a4/400/250"
},
{
"type": "paragraph",
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
},
{
"id": "ces-5",
"name": "这个用拉伸材料做的头盔,还能帮你呼叫救护车 | 这个设计了不起",
"desc": "智能穿戴",
"cover": "https://picsum.photos/seed/a5/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"blocks": [
{
"type": "paragraph",
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「智能穿戴」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
},
{
"type": "heading",
"text": "设计细节"
},
{
"type": "paragraph",
"text": "《这个用拉伸材料做的头盔,还能帮你呼叫救护车》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
},
{
"type": "paragraph",
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
},
{
"type": "image",
"src": "https://picsum.photos/seed/a5/400/250"
},
{
"type": "paragraph",
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
},
{
"id": "ces-6",
"name": "你如何选择字体,简单易读还是好看有趣? | 这个设计了不起",
"desc": "视觉体验",
"cover": "https://picsum.photos/seed/a6/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"blocks": [
{
"type": "paragraph",
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「视觉体验」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
},
{
"type": "heading",
"text": "设计细节"
},
{
"type": "paragraph",
"text": "《你如何选择字体,简单易读还是好看有趣?》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
},
{
"type": "paragraph",
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
},
{
"type": "image",
"src": "https://picsum.photos/seed/a6/400/250"
},
{
"type": "paragraph",
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
},
{
"id": "ces-7",
"name": "家里空间小?你需要几把可以悬挂在墙上的椅子 | 这个设计了不起",
"desc": "空间收纳",
"cover": "https://picsum.photos/seed/a7/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"blocks": [
{
"type": "paragraph",
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「空间收纳」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
},
{
"type": "heading",
"text": "设计细节"
},
{
"type": "paragraph",
"text": "《家里空间小?你需要几把可以悬挂在墙上的椅子》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
},
{
"type": "paragraph",
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
},
{
"type": "image",
"src": "https://picsum.photos/seed/a7/400/250"
},
{
"type": "paragraph",
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
},
{
"id": "ces-8",
"name": "宜家厨房家具又被改造了,色彩是亮点 | 这个设计了不起",
"desc": "色彩灵感",
"cover": "https://picsum.photos/seed/a8/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"blocks": [
{
"type": "paragraph",
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「色彩灵感」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
},
{
"type": "heading",
"text": "设计细节"
},
{
"type": "paragraph",
"text": "《宜家厨房家具又被改造了,色彩是亮点》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
},
{
"type": "paragraph",
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
},
{
"type": "image",
"src": "https://picsum.photos/seed/a8/400/250"
},
{
"type": "paragraph",
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
},
{
"id": "ces-9",
"name": "这款诞生 50 年的扶手椅,推出了特别版配色 | 这个设计了不起",
"desc": "经典产品",
"cover": "https://picsum.photos/seed/a9/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"blocks": [
{
"type": "paragraph",
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「经典产品」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
},
{
"type": "heading",
"text": "设计细节"
},
{
"type": "paragraph",
"text": "《这款诞生 50 年的扶手椅,推出了特别版配色》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
},
{
"type": "paragraph",
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
},
{
"type": "image",
"src": "https://picsum.photos/seed/a9/400/250"
},
{
"type": "paragraph",
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
},
{
"id": "ces-10",
"name": "让家和办公室界限变得模糊的一组家具 | 这个设计了不起",
"desc": "办公家具",
"cover": "https://picsum.photos/seed/a10/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"blocks": [
{
"type": "paragraph",
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「办公家具」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
},
{
"type": "heading",
"text": "设计细节"
},
{
"type": "paragraph",
"text": "《让家和办公室界限变得模糊的一组家具》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
},
{
"type": "paragraph",
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
},
{
"type": "image",
"src": "https://picsum.photos/seed/a10/400/250"
},
{
"type": "paragraph",
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
},
{
"id": "ces-11",
"name": "这个酒店设计奖,挖掘过去一年体验最好的酒店 | 这个设计了不起",
"desc": "空间建筑",
"cover": "https://picsum.photos/seed/a11/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"blocks": [
{
"type": "paragraph",
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「空间建筑」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
},
{
"type": "heading",
"text": "设计细节"
},
{
"type": "paragraph",
"text": "《这个酒店设计奖,挖掘过去一年体验最好的酒店》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
},
{
"type": "paragraph",
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
},
{
"type": "image",
"src": "https://picsum.photos/seed/a11/400/250"
},
{
"type": "paragraph",
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
},
{
"id": "ces-12",
"name": "一款可移动办公桌,挂墙上当作白板就能写写画画 | 这个设计了不起",
"desc": "移动办公",
"cover": "https://picsum.photos/seed/a12/400/250",
"meta": [
"2 年前",
"CES 现场报道"
],
"blocks": [
{
"type": "paragraph",
"text": "开放与明亮的氛围,在室内空间中尤为注重。这条以「移动办公」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。"
},
{
"type": "heading",
"text": "设计细节"
},
{
"type": "paragraph",
"text": "《一款可移动办公桌,挂墙上当作白板就能写写画画》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。"
},
{
"type": "paragraph",
"text": "在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。"
},
{
"type": "image",
"src": "https://picsum.photos/seed/a12/400/250"
},
{
"type": "paragraph",
"text": "文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。"
}
]
}
]
}
],
"nav": {
"topLevel": [
{
"id": "nav-recommend",
"label": "常用推荐",
"target": {
"categoryId": "recommend"
}
}
],
"groups": [
{
"id": "nav-assets",
"label": "素材资源",
"leaves": [
{
"id": "nav-ui",
"label": "UI 设计资源",
"target": {
"categoryId": "recommend",
"groupId": "ui"
}
},
{
"id": "nav-vector",
"label": "矢量图标",
"target": {
"categoryId": "recommend",
"groupId": "vector"
}
},
{
"id": "nav-wallpaper",
"label": "免费壁纸",
"target": {
"categoryId": "recommend",
"groupId": "wallpaper"
}
}
]
},
{
"id": "nav-tools",
"label": "开发工具",
"leaves": [
{
"id": "nav-debug",
"label": "前端调试工具",
"target": {
"categoryId": "recommend",
"groupId": "dev"
}
},
{
"id": "nav-editor",
"label": "在线编辑器",
"target": {
"categoryId": "recommend",
"groupId": "dev"
}
}
]
}
]
}
}
+5
View File
@@ -25,6 +25,11 @@ pub enum StashError {
#[error("未找到资源: {0}")]
NotFound(String),
/// 数据库相关失败:打开、建表、种子导入、查询。
/// 统一收敛成一个变体,避免把 rusqlite 的类型泄漏到库的公开 API。
#[error("数据库错误: {0}")]
Database(String),
}
impl IntoResponse for StashError {
+2
View File
@@ -1,10 +1,12 @@
pub mod config;
pub mod db;
pub mod error;
pub mod listing;
pub mod server;
mod watcher;
pub use config::Config;
pub use db::Db;
pub use error::StashError;
pub use listing::{AppProject, scan_projects};
pub use server::{ServerHandle, start};
+33 -5
View File
@@ -1,7 +1,11 @@
use std::path::PathBuf;
use clap::Parser;
use colored::*;
use stash::{Config, start};
use stash::{
Config,
config::{default_data_dir, display_path, resolve_db_path, validate_db_location},
start,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Parser, Debug)]
@@ -17,6 +21,10 @@ struct Cli {
#[arg(short, long, default_value = "./public", value_name = "DIR")]
root: PathBuf,
/// 数据目录(默认是与二进制同级的 data/,用于存放 stash.db)
#[arg(long, value_name = "DIR")]
data: Option<PathBuf>,
/// 监听端口
#[arg(short, long, default_value_t = 8080, value_name = "PORT")]
port: u16,
@@ -69,9 +77,28 @@ async fn main() -> anyhow::Result<()> {
let abs_root = cli.root.canonicalize().unwrap_or_else(|_| cli.root.clone());
// 3. 构造 stash-core 配置并启动
// 3. 解析数据目录:默认跟随二进制(双击启动时 CWD 不可控),可用 --data 覆盖
let data_dir = match cli.data {
Some(dir) => dir,
None => default_data_dir(),
};
// 先校验再建目录:配置错了就不该在磁盘上留下任何痕迹
let db_path = resolve_db_path(&data_dir);
if let Err(error) = validate_db_location(&abs_root, &db_path) {
eprintln!("{} {}", "[ERROR]".red().bold(), error);
std::process::exit(1);
}
std::fs::create_dir_all(&data_dir)?;
let data_dir = data_dir.canonicalize().unwrap_or(data_dir);
let db_path = resolve_db_path(&data_dir);
// 4. 构造配置并启动
let config = Config {
root: abs_root.clone(),
data_dir: data_dir.clone(),
db_path: db_path.clone(),
port: cli.port,
host: cli.host,
directory_listing: !cli.no_listing,
@@ -86,20 +113,21 @@ async fn main() -> anyhow::Result<()> {
}
};
// 4. 打印启动成功 Banner
// 5. 打印启动成功 Banner
println!();
println!("{}", "==================================================".bright_blue());
println!(" ✨ {} {}", "Stash Server".green().bold(), format!("v{}", env!("CARGO_PKG_VERSION")).dimmed());
println!("{}", "==================================================".bright_blue());
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
println!(" ➜ 托管路径: {}", abs_root.display().to_string().white());
println!(" ➜ 托管路径: {}", display_path(&abs_root).white());
println!(" ➜ 内容库: {}", display_path(&db_path).white());
println!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white());
println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed());
println!("{}", "--------------------------------------------------".bright_blue());
println!(" 按 {} 停止服务", "Ctrl+C".yellow().bold());
println!();
// 5. 跨平台捕获退出信号
// 6. 跨平台捕获退出信号
wait_for_shutdown_signal().await;
println!("\n{} 正在停止服务并释放端口...", "[SYS]".yellow());
+80
View File
@@ -17,6 +17,7 @@ use tower_http::{
use tracing::{info, warn};
use crate::config::Config;
use crate::db::Db;
use crate::error::StashError;
use crate::listing::{AppProject, scan_projects};
use crate::watcher;
@@ -33,6 +34,8 @@ const HTML_CACHE: &str = "no-cache";
pub struct AppState {
pub config: Arc<Config>,
pub project_updates: broadcast::Sender<Vec<AppProject>>,
/// 内容库:分类 / 分组 / 条目的唯一数据源
pub db: Arc<Db>,
}
pub struct ServerHandle {
@@ -138,6 +141,77 @@ async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
}))
}
/// API: 完整内容目录(分类 / 分组 / 条目 + 侧栏导航)
///
/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。
async fn catalog_api(State(state): State<AppState>) -> Response {
let db = state.db.clone();
// rusqlite 是同步的,查询扔进阻塞线程池,别占住 async 执行器
match tokio::task::spawn_blocking(move || db.load_catalog()).await {
Ok(Ok(catalog)) => Json(serde_json::json!({
"categories": catalog.categories,
"nav": catalog.nav,
}))
.into_response(),
Ok(Err(error)) => {
// 典型原因是用户手改坏了 JSON,错误信息里已带条目 id
warn!("读取内容库失败: {error}");
error.into_response()
}
Err(error) => {
warn!("内容库查询任务崩溃: {error}");
StashError::Database(format!("内容库查询任务崩溃: {error}")).into_response()
}
}
}
/// SSE:内容库被外部修改时通知前端重新拉取。
///
/// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件:
/// SQLite 在 journal 模式下会产生临时文件的频繁读写,文件监听会误报,
/// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。
async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
let db = state.db.clone();
let stream = async_stream::stream! {
let mut last = db.data_version().unwrap_or(0);
let mut ticker = tokio::time::interval(Duration::from_secs(5));
loop {
ticker.tick().await;
let current = match db.data_version() {
Ok(version) => version,
Err(error) => {
warn!("读取内容库版本失败: {error}");
continue;
}
};
// 只在其它连接提交之后才推送。
// 当前服务对内容库是只读的;将来若加入写接口,必须在那里主动广播,
// 因为 data_version 不会因本连接自己的提交而变化。
tracing::debug!(last, current, "内容库版本检查");
if current == last {
continue;
}
last = current;
// 只告诉前端「变了」,具体内容由它重新 GET /api/catalog
yield Ok::<Event, Infallible>(
Event::default().event("catalog").data(r#"{"changed":true}"#),
);
}
};
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keep-alive"),
)
}
/// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。
async fn project_events(State(state): State<AppState>) -> impl IntoResponse {
let root = state.config.root.clone();
@@ -188,9 +262,13 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
let (project_updates, _) = broadcast::channel(32);
watcher::start(config.root.clone(), project_updates.clone())?;
// 内容库在启动时打开:建库 / 建表 / 空库时灌种子都在这里完成
let db = Arc::new(Db::open(&config)?);
let shared_state = AppState {
config: Arc::new(config.clone()),
project_updates,
db,
};
// 托管目录里找不到的路径,交给 404 页面而不是 SPA 回落:
@@ -205,6 +283,8 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
.route("/api/health", get(health_check))
.route("/api/projects", get(list_projects_api))
.route("/api/projects/events", get(project_events))
.route("/api/catalog", get(catalog_api))
.route("/api/catalog/events", get(catalog_events))
.route("/", get(web_index))
.route("/index.html", get(web_index))
.route("/favicon.svg", get(web_root_file))