feat(web): 地址栏驱动视图,刷新详情页不再回首页

- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退
- 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点
- 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404
- 明细页返回按钮去掉左侧箭头图标
This commit is contained in:
2026-10-24 17:18:30 +08:00
parent 41fed5e64d
commit 19ead7b716
22 changed files with 2098 additions and 1321 deletions
+288 -195
View File
@@ -1,9 +1,11 @@
//! 内容库的查询组装:把关系表还原成前端 `types.ts` 里的 `Category[]`。
//! 内容库的查询组装:把统一的 node 树还原成前端 `types.ts` 想要的三种形态。
//!
//! 这里输出的是 `serde_json::Value` 而不是一堆 Rust DTO,理由是:
//! 条目的可变字段本来就存在 JSON 列里,直接用 Value 拼装可以保证
//! 「用户往 JSON 里加什么字段,前端就能读到什么字段」,不需要每加一个
//! 字段就改一次 Rust 结构体。
//! 库里只有一套容器树(分区 → 分组),前端需要 `apps / posters / articles`
//! 的可辨识联合,那是**查询层按 `node.view_mode` 组装**的事。
//!
//! 所有关联都走业务键(`key`),SQL 里能直接看懂指向的是哪个分区 / 分组。
use std::collections::HashMap;
use rusqlite::Connection;
use serde_json::{Map, Value, json};
@@ -13,64 +15,66 @@ use crate::{
error::StashError,
};
/// 分类行:与 `category` 表一一对应
struct CategoryRow {
id: String,
kind: String,
/// 容器节点行:分区与分组共用
struct NodeRow {
key: String,
title: String,
bar_color: String,
icon: String,
view_mode: Option<String>,
bar_color: Option<String>,
icon: Option<String>,
preview_limit: Option<i64>,
empty_text: Option<String>,
show_in_sidebar: bool,
}
/// 导航行:侧栏自引用树的原始形态
struct NavRow {
id: String,
parent_id: Option<String>,
label: String,
category_id: Option<String>,
group_id: Option<String>,
}
/// 条目行:`data` 里是尚未展开的 JSON 文本
/// 条目行:与 `entry` 表的列一一对应
struct EntryRow {
id: String,
group_id: Option<String>,
key: String,
node_key: String,
name: String,
description: Option<String>,
icon: Option<String>,
href: Option<String>,
cover: Option<String>,
banner: Option<String>,
badge: Option<String>,
comment: Option<String>,
summary: Option<String>,
body: Option<String>,
hot: bool,
views: i64,
likes: i64,
data: String,
}
/// 读出整个内容目录:分类树 + 侧栏导航。
/// 读出整个内容目录。
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
Ok(Catalog {
categories: load_categories(conn)?,
nav: load_nav(conn)?,
categories: load_sections(conn)?,
})
}
/// 读出全部分类,按 `sort` 排序;顺序即首页分区顺序。
fn load_categories(conn: &Connection) -> Result<Vec<Value>, StashError> {
/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。
fn load_sections(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",
"SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar
FROM node
WHERE kind = 'section'
ORDER BY sort, key",
)
.map_err(map_error)?;
let categories = stmt
let sections = stmt
.query_map([], |row| {
Ok(CategoryRow {
id: row.get(0)?,
kind: row.get(1)?,
title: row.get(2)?,
Ok(NodeRow {
key: row.get(0)?,
title: row.get(1)?,
view_mode: row.get(2)?,
bar_color: row.get(3)?,
icon: row.get(4)?,
preview_limit: row.get(5)?,
empty_text: row.get(6)?,
show_in_sidebar: row.get(7)?,
})
})
.map_err(map_error)?
@@ -80,36 +84,102 @@ fn load_categories(conn: &Connection) -> Result<Vec<Value>, StashError> {
// 先释放语句再发后续查询,避免同一连接上的借用冲突
drop(stmt);
categories
// 每个分区对应的首页卡片:展示哪个节点、几条
let blocks = load_blocks(conn)?;
sections
.iter()
.map(|category| build_category(conn, category))
.map(|section| build_section(conn, section, blocks.get(&section.key)))
.collect()
}
/// 按 kind 分派到不同的组装方式。
fn build_category(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
/// 读出所有卡片配置,按推导出的分区业务键索引。
///
/// 分区不单独存:节点是分组就取它的 `parent_key`,节点是分区就取它自己。
/// `title` 同样在 SQL 里解析:卡片自己配了标题就用它,否则用被展示节点的标题。
fn load_blocks(conn: &Connection) -> Result<HashMap<String, Value>, StashError> {
let mut stmt = conn
.prepare(
"SELECT COALESCE(p.key, n.key) AS section_key,
b.node_key,
COALESCE(b.title, n.title),
b.preview_limit
FROM section_block b
JOIN node n ON n.key = b.node_key
LEFT JOIN node p ON p.key = n.parent_key
ORDER BY b.sort, b.node_key",
)
.map_err(map_error)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<i64>>(3)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
let mut blocks = HashMap::new();
for (section_key, node_key, title, preview_limit) in rows {
let mut block = Map::new();
block.insert("nodeKey".into(), json!(node_key));
block.insert("title".into(), json!(title));
if let Some(limit) = preview_limit {
block.insert("previewLimit".into(), json!(limit));
}
// 同一分区配了多个卡片时只保留第一个:当前 UI 一张卡片展示一个节点
blocks.entry(section_key).or_insert(Value::Object(block));
}
Ok(blocks)
}
/// 按 `view_mode` 分派到不同的组装方式。
fn build_section(
conn: &Connection,
section: &NodeRow,
block: Option<&Value>,
) -> Result<Value, StashError> {
let view_mode = section
.view_mode
.as_deref()
.ok_or_else(|| StashError::Database(format!("分区 {} 缺少 view_mode", section.key)))?;
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));
object.insert("id".into(), json!(section.key));
object.insert("kind".into(), json!(view_mode));
object.insert("title".into(), json!(section.title));
object.insert(
"barColor".into(),
json!(section.bar_color.clone().unwrap_or_default()),
);
object.insert("icon".into(), json!(section.icon.clone().unwrap_or_default()));
object.insert("showInSidebar".into(), json!(section.show_in_sidebar));
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
if let Some(limit) = category.preview_limit {
if let Some(limit) = section.preview_limit {
object.insert("previewLimit".into(), json!(limit));
}
if let Some(empty_text) = &category.empty_text {
if let Some(empty_text) = &section.empty_text {
object.insert("emptyText".into(), json!(empty_text));
}
if let Some(block) = block {
object.insert("block".into(), block.clone());
}
match category.kind.as_str() {
"apps" => object.insert("groups".into(), build_groups(conn, category)?),
"posters" | "articles" => object.insert("items".into(), build_items(conn, category)?),
match view_mode {
"apps" => object.insert("groups".into(), build_groups(conn, section)?),
"posters" => object.insert("items".into(), build_poster_items(conn, section)?),
"articles" => object.insert("items".into(), build_article_items(conn, section)?),
other => {
return Err(StashError::Database(format!(
"分类 {} 的 kind 非法: {other}",
category.id
"分区 {} 的 view_mode 非法: {other}",
section.key
)));
}
};
@@ -117,17 +187,23 @@ fn build_category(conn: &Connection, category: &CategoryRow) -> Result<Value, St
Ok(Value::Object(object))
}
/// 应用类:先取出分组,再把条目按 `group_id` 分派进去。
///
/// 只用两条查询(分组一条、条目一条)而不是每组一条,避免 N+1。
fn build_groups(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
/// 应用类:取出分组节点,再把条目按 `node_key` 分派进去。
fn build_groups(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
let mut stmt = conn
.prepare("SELECT id, name FROM app_group WHERE category_id = ?1 ORDER BY sort, id")
.prepare(
"SELECT key, title, show_in_sidebar FROM node
WHERE kind = 'group' AND parent_key = ?1
ORDER BY sort, key",
)
.map_err(map_error)?;
let groups = stmt
.query_map([&category.id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
.query_map([&section.key], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, bool>(2)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
@@ -135,23 +211,22 @@ fn build_groups(conn: &Connection, category: &CategoryRow) -> Result<Value, Stas
drop(stmt);
let entries = load_entries(conn, &category.id)?;
let entries = load_entries(conn, &section.key)?;
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")?));
}
for (group_key, group_title, show_in_sidebar) in groups {
let items = entries
.iter()
// 保持 entry.sort 决定的顺序
.filter(|entry| entry.node_key == group_key)
.map(app_item)
.collect::<Vec<_>>();
rendered_groups.push(json!({
"id": group_id,
"name": group_name,
// 对外用业务键,不用数字主键
"id": group_key,
"name": group_title,
"showInSidebar": show_in_sidebar,
"items": items,
}));
}
@@ -159,38 +234,147 @@ fn build_groups(conn: &Connection, category: &CategoryRow) -> Result<Value, Stas
Ok(Value::Array(rendered_groups))
}
/// 海报 / 图文类:没有分组,直接输出条目数组。
fn build_items(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
let entries = load_entries(conn, &category.id)?;
/// 海报类:竖版卡片要 `title` / `cover` / `tags`。
fn build_poster_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
let entries = load_entries(conn, &section.key)?;
let tags = load_tags(conn, &section.key)?;
let items = entries
.iter()
.map(|entry| entry_object(entry, &category.id, "title").map(Value::Object))
.collect::<Result<Vec<_>, _>>()?;
.map(|entry| poster_item(entry, tags.get(&entry.key)))
.collect::<Vec<_>>();
Ok(Value::Array(items))
}
/// 取某个分类下的全部条目,按 `sort` 升序。
fn load_entries(conn: &Connection, category_id: &str) -> Result<Vec<EntryRow>, StashError> {
/// 图文类:需要 `title` / `desc` / `cover` / `meta` / `body`。
fn build_article_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
let entries = load_entries(conn, &section.key)?;
let tags = load_tags(conn, &section.key)?;
let items = entries
.iter()
.map(|entry| article_item(entry, tags.get(&entry.key)))
.collect::<Vec<_>>();
Ok(Value::Array(items))
}
/// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。
fn app_item(entry: &EntryRow) -> Value {
let mut object = Map::new();
object.insert("id".into(), json!(entry.key));
object.insert("name".into(), json!(entry.name));
// 列表卡片必须有副标题,空字符串比缺字段安全
object.insert(
"desc".into(),
json!(entry.description.clone().unwrap_or_default()),
);
object.insert("icon".into(), json!(entry.icon.clone().unwrap_or_default()));
object.insert(
"href".into(),
json!(entry.href.clone().unwrap_or_else(|| "#".to_string())),
);
insert_optional(&mut object, "cover", &entry.cover);
insert_optional(&mut object, "banner", &entry.banner);
insert_optional(&mut object, "badge", &entry.badge);
insert_optional(&mut object, "comment", &entry.comment);
// hot 只在为真时下发,前端判 `item.hot === true`
if entry.hot {
object.insert("hot".into(), json!(true));
}
object.insert("views".into(), json!(entry.views));
object.insert("likes".into(), json!(entry.likes));
Value::Object(object)
}
/// 海报条目:前端用 `title` 而不是 `name`。
fn poster_item(entry: &EntryRow, tags: Option<&Vec<String>>) -> Value {
let mut object = Map::new();
object.insert("id".into(), json!(entry.key));
object.insert("title".into(), json!(entry.name));
object.insert("cover".into(), json!(entry.cover.clone().unwrap_or_default()));
if let Some(tags) = tags.filter(|tags| !tags.is_empty()) {
object.insert("tags".into(), json!(tags));
}
insert_optional(&mut object, "comment", &entry.comment);
insert_optional(&mut object, "summary", &entry.summary);
insert_optional(&mut object, "href", &entry.href);
object.insert("views".into(), json!(entry.views));
object.insert("likes".into(), json!(entry.likes));
Value::Object(object)
}
/// 图文条目:`meta` 来自 entry_tag,`body` 是 Markdown 正文。
fn article_item(entry: &EntryRow, meta: Option<&Vec<String>>) -> Value {
let mut object = Map::new();
object.insert("id".into(), json!(entry.key));
object.insert("title".into(), json!(entry.name));
object.insert(
"desc".into(),
json!(entry.description.clone().unwrap_or_default()),
);
object.insert("cover".into(), json!(entry.cover.clone().unwrap_or_default()));
if let Some(meta) = meta.filter(|meta| !meta.is_empty()) {
object.insert("meta".into(), json!(meta));
}
if let Some(body) = entry.body.as_ref().filter(|body| !body.trim().is_empty()) {
object.insert("body".into(), json!(body));
}
Value::Object(object)
}
/// 可选字段:`None` 就不下发,前端用 `??` 兜底。
fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<String>) {
if let Some(value) = value {
object.insert(key.into(), json!(value));
}
}
/// 取某个分区下的全部条目,按 `sort` 升序。
///
/// 条目只存 `node_key`,所属分区由父节点推导:
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
fn load_entries(conn: &Connection, section_key: &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",
"SELECT e.key, e.node_key, e.name, e.description, e.icon, e.href, e.cover,
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes
FROM entry e
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = ?1
ORDER BY e.sort, e.key",
)
.map_err(map_error)?;
let entries = stmt
.query_map([category_id], |row| {
.query_map([section_key], |row| {
Ok(EntryRow {
id: row.get(0)?,
group_id: row.get(1)?,
key: row.get(0)?,
node_key: row.get(1)?,
name: row.get(2)?,
views: row.get(3)?,
likes: row.get(4)?,
data: row.get(5)?,
description: row.get(3)?,
icon: row.get(4)?,
href: row.get(5)?,
cover: row.get(6)?,
banner: row.get(7)?,
badge: row.get(8)?,
comment: row.get(9)?,
summary: row.get(10)?,
body: row.get(11)?,
hot: row.get(12)?,
views: row.get(13)?,
likes: row.get(14)?,
})
})
.map_err(map_error)?
@@ -200,122 +384,31 @@ fn load_entries(conn: &Connection, category_id: &str) -> Result<Vec<EntryRow>, S
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> {
/// 取某个分区下所有条目的标签,按条目业务键聚合;一次查询避免 N+1。
fn load_tags(conn: &Connection, section_key: &str) -> Result<HashMap<String, Vec<String>>, StashError> {
let mut stmt = conn
.prepare(
"SELECT id, parent_id, label, category_id, group_id
FROM nav_entry
ORDER BY sort, id",
"SELECT t.entry_key, t.label
FROM entry_tag t
JOIN entry e ON e.key = t.entry_key
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = ?1
ORDER BY t.entry_key, t.sort",
)
.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)?,
})
.query_map([section_key], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.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,
}));
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
for (entry_key, label) in rows {
grouped.entry(entry_key).or_default().push(label);
}
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),
})
Ok(grouped)
}
+296 -79
View File
@@ -21,15 +21,13 @@ 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,
}
/// 内容库句柄。
@@ -59,41 +57,36 @@ impl Db {
)
.map_err(map_error)?;
// 先校验结构版本,再动任何东西:旧库直接报错,避免用旧表跑新查询
let version = schema::check_version(&conn)?;
schema::apply(&conn)?;
// 结构版本:开发期只写不校验,等结构稳定后再拿它做迁移判断
conn.pragma_update(None, "user_version", 1)
// 写入当前结构版本,供下次启动比对
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
.map_err(map_error)?;
// 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来
let existing: i64 = conn
.query_row("SELECT COUNT(*) FROM category", [], |row| row.get(0))
.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.categories, report.groups, report.entries, report.nav_items
"内容库为空,已导入内嵌种子:{} 分区 / {} 分组 / {} 条目 / {} 标签 / {} 展示块",
report.sections, report.groups, report.entries, report.tags, report.blocks
);
} 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 {
@@ -101,7 +94,7 @@ impl Db {
})
}
/// 读出完整的分类树与侧栏导航,供 `/api/catalog` 直接序列化。
/// 读出完整的内容目录,供 `/api/catalog` 直接序列化。
pub fn load_catalog(&self) -> Result<Catalog, StashError> {
let conn = self.lock()?;
catalog::load(&conn)
@@ -129,6 +122,18 @@ 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::*;
@@ -144,26 +149,78 @@ mod tests {
}
#[test]
fn seed_fills_every_category_group_and_entry() {
fn seed_fills_every_section_group_and_entry() {
let conn = seeded();
let categories: i64 = conn
.query_row("SELECT COUNT(*) FROM category", [], |row| row.get(0))
.expect("应能统计分类");
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 app_group", [], |row| row.get(0))
.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 nav_items: i64 = conn
.query_row("SELECT COUNT(*) FROM nav_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!(categories, 3, "种子应含 常用推荐 / 推荐电影 / CES 三个分类");
assert_eq!(sections, 3, "种子应含 常用推荐 / 推荐电影 / CES 三个分区");
assert_eq!(groups, 5, "常用推荐应有 5 个分组");
assert_eq!(entries, 42, "18 应用 + 12 电影 + 12 图文");
assert_eq!(nav_items, 8, "1 个顶层直达 + 2 个折叠分组 + 5 个叶子");
// 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]
@@ -178,8 +235,16 @@ mod tests {
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);
@@ -195,63 +260,182 @@ mod tests {
"海报不应同时下发 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");
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");
// 正文是一整段 Markdown,而不是块数组
let body = articles["items"][0]["body"]
.as_str()
.expect("图文应有正文");
assert!(body.contains("## 设计细节"), "正文应含小标题: {body}");
assert!(body.contains("!["), "正文应含配图语法: {body}");
assert!(body.contains("\n\n"), "段落之间用空行分隔");
assert!(
top_level[0]["target"].get("groupId").is_none(),
"整分类直达时不应下发 groupId"
articles["items"][0].get("blocks").is_none(),
"不应再下发 blocks 数组"
);
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"]
// meta 与海报的 tags 共用 entry_tag 表
assert_eq!(
articles["items"][0]["meta"]
.as_array()
.expect("应有顶层数组")
.is_empty(),
"指向已删分类的顶层链接应被级联删除"
.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 broken_json_names_the_offending_entry() {
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(
"UPDATE entry SET data = '{oops' WHERE category_id = 'recommend' AND id = 'github'",
"DELETE FROM node WHERE kind = 'group' AND key = 'ui' AND parent_key = 'recommend'",
[],
)
.expect("应能写入坏数据");
.expect("应能删除分组");
let error = catalog::load(&conn).expect_err("坏 JSON 应报错");
let message = error.to_string();
let after: i64 = conn
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
.expect("应能统计条目");
assert!(message.contains("recommend/github"), "报错要能定位到具体条目: {message}");
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]
@@ -262,6 +446,39 @@ mod tests {
assert!(db.data_version().expect("应能读取 data_version") >= 0);
}
/// 全新库(user_version = 0)允许直接建表;当前版本也允许。
#[test]
fn accepts_fresh_or_current_schema_version() {
let conn = Connection::open_in_memory().expect("内存库应能打开");
assert_eq!(
schema::check_version(&conn).expect("全新库应放行"),
0,
"新库的 user_version 应为 0"
);
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
.expect("应能写入版本");
assert_eq!(
schema::check_version(&conn).expect("当前版本应放行"),
schema::SCHEMA_VERSION
);
}
/// 结构变过之后,旧库必须被拒绝并提示删库,而不是用旧表跑新查询。
#[test]
fn rejects_database_from_older_schema() {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION - 1)
.expect("应能写入旧版本号");
let error = schema::check_version(&conn).expect_err("旧库必须被拒绝");
let message = error.to_string();
assert!(message.contains("结构版本"), "报错要说明原因: {message}");
assert!(message.contains("删除数据库"), "报错要给出处理方式: {message}");
}
/// 回归测试:外部连接提交后,服务端连接读到的 data_version 必须变化。
///
/// 这是「用户用 DB Browser 改库,页面自动刷新」整条链路的地基;
@@ -287,14 +504,14 @@ mod tests {
// 模拟外部工具:另开一个连接提交一次写事务
let external = Connection::open(&path).expect("外部连接应能打开");
external
.execute(
"UPDATE entry SET name = '改过的名字' WHERE category_id = 'recommend' AND id = 'github'",
[],
)
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 必须变化");
}
+279 -236
View File
@@ -1,13 +1,19 @@
//! 内容库的表结构与种子导入。
//!
//! 表结构刻意保持「薄」:分类和分组是关系表,条目除 id / 排序 / 计数外的
//! 全部字段都塞进 `entry.data` 的 JSON 列。原因是前端 `types.ts` 用的是
//! 可辨识联合(apps / posters / articles),三种形态字段差异很大,
//! 拆成三张表会让查询和后续加字段都变啰嗦;JSON 列则天然与前端结构 1:1。
//! 两条设计原则:
//!
//! 1. **一个容器树**:分区(section)与分组(group)是同一种东西——「装条目的容器」,
//! 合并在 `node` 一张自引用表里,用 `kind` 区分层级。
//! 2. **内容与展示分离**:`nav_item` 不存内容,只存侧栏的排列(哪个节点、叫什么、放在哪)。
//!
//! 命名规则:
//!
//! - 所有表用**业务编码 `code`** 做主键(`recommend` / `ui` / `github`),不再有数字 id;
//! - 外键统一写成 `<目标>_code`:`parent_code`、`section_code`、`node_code`、`entry_code`;
//! - 改编码时下游由 `ON UPDATE CASCADE` 自动跟随。
use rusqlite::{Connection, params};
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::{db::map_error, error::StashError};
@@ -20,69 +26,114 @@ const SEED_JSON: &str = include_str!("seed.json");
/// 不实现版本迁移:需要改结构时删掉 `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
-- 内容节点:分区与分组共用一张自引用树
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL
-- kind = 'group' → 分区下的分组,parent_code 指向所属分区
-- view_mode 只对分区有意义,决定首页与明细页怎么渲染
CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 父节点业务键:分组指向分区,分区为 NULL
title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
-- 容器层级
view_mode TEXT CHECK (view_mode IN ('apps', 'posters', 'articles')),
-- 分区渲染形态;分组为 NULL
bar_color TEXT, -- 分区标题左侧色条颜色(分组为 NULL)
icon TEXT, -- 分区 hero 字符图标(分组为 NULL)
preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认
empty_text TEXT, -- 分区为空时的提示文案
show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)),
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
CHECK (
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL)
OR (kind = 'group' AND parent_key IS NOT NULL AND view_mode IS NULL)
)
);
-- 应用类分类的分组:明细页按组展示,首页把各组前几项拼成预览
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)
);
CREATE INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort);
-- 条目:应用 / 海报 / 图文共用一张表
-- 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)
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 github / movie-1)
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 直接所属容器:应用是分组,海报/图文是分区本身
-- 所属分区不单独存:由 node_key 推导(分组看它的 parent_key)
name TEXT NOT NULL, -- 名称:网站名 / 电影名 / 文章标题
description TEXT, -- 一句话简介,列表卡片的副标题
icon TEXT, -- 字符图标(封面缺失时的占位)
href TEXT, -- 点击后打开的页面;空表示暂无可跳转地址
cover TEXT, -- 封面图:应用头像 / 海报 / 文章配图
banner TEXT, -- 详情页横幅图
badge TEXT, -- 详情页标签行上的一句话标记(如「推荐好站」)
comment TEXT, -- 详情页的一句话评价(引用块)
summary TEXT, -- 详情页正文段落(电影简介这类长文本)
body TEXT, -- 文章正文(Markdown):一篇一段文本
hot INTEGER NOT NULL DEFAULT 0 CHECK (hot IN (0, 1)),-- 是否热门:列表里显示 HOT 徽标
views INTEGER NOT NULL DEFAULT 0, -- 浏览量,详情页操作条「眼睛」后的数字
likes INTEGER NOT NULL DEFAULT 0, -- 点赞数,详情页操作条「赞」后的数字
sort INTEGER NOT NULL DEFAULT 0 -- 条目顺序(组内或分区内),越小越靠前
);
CREATE INDEX IF NOT EXISTS entry_group_idx ON entry(category_id, group_id, sort);
CREATE INDEX IF NOT EXISTS entry_parent_idx ON entry(node_key, 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
-- 条目的行内文本列表:海报的 tags、图文的 meta 都放这里(有序、一对多)
CREATE TABLE IF NOT EXISTS entry_tag (
entry_key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 所属条目业务键
sort INTEGER NOT NULL DEFAULT 0, -- 展示顺序
label TEXT NOT NULL, -- 标签文字(如「推荐电影」「2 年前」)
PRIMARY KEY (entry_key, sort)
);
-- 首页卡片:一行一个卡片,说明展示哪个节点
-- node_key 指向要展示的节点(通常是分组;电影/文章这类无分组的填分区自己)
-- 「属于哪个分区」不单独存:分组看 parent_key,分区就是它自己,查询时推导
CREATE TABLE IF NOT EXISTS section_block (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 要展示的节点业务键
title TEXT, -- 块标题;NULL 时用节点 title
preview_limit INTEGER, -- 卡片里显示几条;NULL 时用前端默认
sort INTEGER NOT NULL DEFAULT 0 -- 卡片顺序
);
"#;
/// 种子导入结果,仅用于启动日志。
#[derive(Debug, Default, PartialEq, Eq)]
pub struct SeedReport {
pub categories: usize,
pub sections: usize,
pub groups: usize,
pub entries: usize,
/// 侧栏导航节点数(顶层项 + 叶子)
pub nav_items: usize,
pub tags: usize,
/// 首页展示块数量(每个分区一个)
pub blocks: usize,
}
/// 当前表结构版本,写在 SQLite 的 `PRAGMA user_version` 里。
///
/// 开发阶段不做迁移:版本对不上时直接拒绝启动,让用户删库重建,
/// 比悄悄用旧表跑新查询(最终表现为 500)好排查得多。
/// 结构调整时把这个数字 +1。
pub const SCHEMA_VERSION: i64 = 15;
/// 校验库结构版本:0 表示全新库,允许;其它非当前版本一律拒绝。
pub fn check_version(conn: &Connection) -> Result<i64, StashError> {
let version: i64 = conn
.pragma_query_value(None, "user_version", |row| row.get(0))
.map_err(map_error)?;
if version != 0 && version != SCHEMA_VERSION {
return Err(StashError::Database(format!(
"内容库结构版本为 {version},当前程序需要 {SCHEMA_VERSION}。\
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。"
)));
}
Ok(version)
}
/// 建表(幂等):每次启动都跑一遍,缺表就补,已有数据不动。
@@ -92,8 +143,8 @@ pub fn apply(conn: &Connection) -> Result<(), StashError> {
/// 把内嵌种子写进空库。
///
/// 只在数据库文件是本次启动新建时调用,所以不存在「覆盖用户数据」的问题;
/// 用户删掉库以后重启会重新得到一份干净的内置内容,这也是开发期的重置手段。
/// 只用业务编码,不需要先插入再回查自增 id:父子关系在种子文件里已经写清楚,
/// 按「分区 → 分组 → 条目 → 侧栏」的顺序插入即可满足外键。
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}")))?;
@@ -102,173 +153,144 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
// 单事务写入:避免中途失败留下半个内容库
let tx = conn.transaction().map_err(map_error)?;
for category in &seed.categories {
let kind = category.kind.as_str();
for section in &seed.sections {
let view = section.view.as_str();
// 只认识前端支持的三种形态,拼错时立刻报错而不是静默写脏数据
if !matches!(kind, "apps" | "posters" | "articles") {
if !matches!(view, "apps" | "posters" | "articles") {
return Err(StashError::Database(format!(
"种子分类 {} 的 kind 非法: {kind}",
category.id
"种子分区 {} 的 view 非法: {view}",
section.key
)));
}
// 只有 apps 分区才允许有分组:其余类型直接挂条目
if view != "apps" && !section.groups.is_empty() {
return Err(StashError::Database(format!(
"种子分区 {} 是 {view} 类型,不应包含 groups",
section.key
)));
}
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)",
"INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, sort)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
category.id,
kind,
category.title,
category.bar_color,
category.icon,
category.preview_limit,
category.empty_text,
category.sort,
section.key,
section.title,
view,
section.bar_color,
section.icon,
section.preview_limit,
section.empty_text,
i64::from(section.show_in_sidebar),
section.sort,
],
)
.map_err(map_error)?;
report.categories += 1;
report.sections += 1;
if kind == "apps" {
for group in &category.groups {
if view == "apps" {
for group in &section.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],
"INSERT INTO node (key, parent_key, title, kind, show_in_sidebar, sort)
VALUES (?1, ?2, ?3, 'group', ?4, ?5)",
params![
group.key,
section.key,
group.title,
i64::from(group.show_in_sidebar),
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;
for (index, entry) in group.entries.iter().enumerate() {
insert_entry(&tx, &group.key, index, entry, &mut report)?;
}
}
continue;
} else {
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
for (index, entry) in section.entries.iter().enumerate() {
insert_entry(&tx, &section.key, index, entry, &mut report)?;
}
}
// 海报与图文没有分组,直接挂在分类下
for (index, item) in category.items.iter().enumerate() {
insert_entry(&tx, &category.id, None, index, item)?;
report.entries += 1;
}
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身)
tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort)
VALUES (?1, ?2, ?3, ?4)",
params![
section.block.node_key,
section.block.title,
section.block.preview_limit,
section.sort,
],
)
.map_err(|error| {
StashError::Database(format!(
"分区 {} 的展示节点 {} 无效: {error}",
section.key, section.block.node_key
))
})?;
report.blocks += 1;
}
// 侧栏导航必须放在分类之后插入:叶子对 category(id) 有外键依赖
report.nav_items = seed_nav(&tx, &seed.nav)?;
tx.commit().map_err(map_error)?;
Ok(report)
}
/// 导入侧栏导航。
/// 写入一个条目:基础字段进 entry,行内标签进 entry_tag。
///
/// 先插顶层项(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。
/// `node_key` 是直接容器的业务键;所属分区由它推导,不单独存。
fn insert_entry(
tx: &rusqlite::Transaction<'_>,
category_id: &str,
group_id: Option<&str>,
node_key: &str,
index: usize,
item: &SeedItem,
entry: &SeedEntry,
report: &mut SeedReport,
) -> 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)",
"INSERT INTO entry (
key, node_key, name, description, icon, href, cover,
banner, badge, comment, summary, body, hot, views, likes, sort
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
params![
category_id,
item.id,
group_id,
item.name,
views,
likes,
entry.key,
node_key,
entry.name,
entry.description,
entry.icon,
entry.href,
entry.cover,
entry.banner,
entry.badge,
entry.comment,
entry.summary,
entry.body,
i64::from(entry.hot),
entry.views,
entry.likes,
index as i64,
data,
],
)
.map_err(map_error)?;
Ok(())
}
report.entries += 1;
/// 从 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,
// 标签:海报的 tags 与图文的 meta 共用一张子表,按出现顺序编号
for (sort, label) in entry.tags.iter().chain(entry.meta.iter()).enumerate() {
tx.execute(
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
params![entry.key, sort as i64, label],
)
.map_err(map_error)?;
report.tags += 1;
}
Ok(())
}
// ---------------------------------------------------------------------------
@@ -278,54 +300,16 @@ fn take_count(extra: &mut Map<String, Value>, key: &str) -> i64 {
/// 种子文件根节点
#[derive(Debug, Deserialize)]
struct SeedFile {
categories: Vec<SeedCategory>,
/// 侧栏导航;缺省时侧栏为空,但不影响分类内容
#[serde(default)]
nav: SeedNav,
sections: Vec<SeedSection>,
}
/// 侧栏导航:顶层直达项 + 可折叠分组
#[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,
struct SeedSection {
key: String,
/// 渲染形态:apps / posters / articles
view: String,
title: String,
bar_color: String,
icon: String,
@@ -334,28 +318,87 @@ struct SeedCategory {
preview_limit: Option<i64>,
#[serde(default)]
empty_text: Option<String>,
/// 是否出现在侧栏;默认 true
#[serde(default = "default_true")]
show_in_sidebar: bool,
/// 仅 apps 分区使用
#[serde(default)]
groups: Vec<SeedGroup>,
/// 非 apps 分区直接挂条目
#[serde(default)]
items: Vec<SeedItem>,
entries: Vec<SeedEntry>,
/// 首页卡片展示哪个节点(分组编码,或分区自己的编码)
block: SeedBlockConfig,
}
/// 应用类分类下的一个分组
/// 首页分区的展示配置
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedBlockConfig {
/// 要展示的节点业务键;通常是分组,电影/文章填分区自己
node_key: String,
/// 块标题;空则用节点 title
#[serde(default)]
title: Option<String>,
/// 卡片显示几条;空则用前端默认
#[serde(default)]
preview_limit: Option<i64>,
}
/// 分区下的一个分组
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedGroup {
id: String,
name: String,
key: String,
title: String,
sort: i64,
items: Vec<SeedItem>,
/// 是否出现在侧栏;默认 true
#[serde(default = "default_true")]
show_in_sidebar: bool,
entries: Vec<SeedEntry>,
}
/// 一条内容;除 id / name 外全部字段原样落进 JSON 列。
/// serde 默认值:侧栏默认显示
fn default_true() -> bool {
true
}
/// 一条内容:字段与 `entry` 表的列一一对应。
///
/// 用 `flatten` 收集剩余键,这样种子文件里新增字段不需要同步改 Rust 结构。
#[derive(Debug, Deserialize)]
struct SeedItem {
id: String,
/// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta);
/// `body` 是文章的 Markdown 正文,一篇一段文本。
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedEntry {
key: String,
name: String,
#[serde(flatten)]
extra: Map<String, Value>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
href: Option<String>,
#[serde(default)]
cover: Option<String>,
#[serde(default)]
banner: Option<String>,
#[serde(default)]
badge: Option<String>,
#[serde(default)]
comment: Option<String>,
#[serde(default)]
summary: Option<String>,
/// 文章正文(Markdown)
#[serde(default)]
body: Option<String>,
#[serde(default)]
hot: bool,
#[serde(default)]
views: i64,
#[serde(default)]
likes: i64,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
meta: Vec<String>,
}
+177 -546
View File
File diff suppressed because it is too large Load Diff
+209 -34
View File
@@ -1,7 +1,7 @@
use axum::{
Json, Router,
extract::{OriginalUri, Path, State},
http::{HeaderValue, StatusCode, header},
http::{HeaderValue, Method, StatusCode, header},
response::sse::{Event, KeepAlive, Sse},
response::{Html, IntoResponse, Response},
routing::get,
@@ -113,7 +113,30 @@ fn not_found_page(path: &str) -> Response {
(StatusCode::NOT_FOUND, Html(body)).into_response()
}
async fn not_found_handler(OriginalUri(uri): OriginalUri) -> Response {
/// 前端客户端路由占用的路径前缀(见 `stash-web/src/data/view.ts`)。
///
/// 这些地址在服务端没有对应文件:`/c/apps` 刷新时 `ServeDir` 找不到东西,
/// 会落到下面的兜底处理器,由这里回落到内嵌的 `index.html`,
/// 前端再按 `location.pathname` 还原出同一页。
///
/// 只接管明确列出的前缀,托管目录里写错的路径仍然返回 404 页面:
/// 把所有未命中都冒充成 200 的「万能 SPA 回落」,会让人分不清
/// 到底是路由拼错了还是文件真的不存在。
const SPA_PREFIXES: &[&str] = &["/c/"];
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。
fn is_spa_path(path: &str) -> bool {
SPA_PREFIXES.iter().any(|prefix| path.starts_with(prefix))
}
/// `ServeDir` 的兜底:前端路由回落到入口页,其余路径仍是 404 页面。
async fn fallback_handler(OriginalUri(uri): OriginalUri, method: Method) -> Response {
let is_page = method == Method::GET || method == Method::HEAD;
if is_page && is_spa_path(uri.path()) {
return web_index().await;
}
not_found_page(uri.path())
}
@@ -141,9 +164,10 @@ async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
}))
}
/// API: 完整内容目录(分类 / 分组 / 条目 + 侧栏导航)
/// API: 完整内容目录(分区 / 分组 / 条目 + 首页展示块)
///
/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。
/// 侧栏由这份数据派生,不再单独下发导航。
async fn catalog_api(State(state): State<AppState>) -> Response {
let db = state.db.clone();
@@ -151,7 +175,6 @@ async fn catalog_api(State(state): State<AppState>) -> Response {
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)) => {
@@ -252,6 +275,47 @@ fn project_event(projects: Vec<AppProject>) -> Event {
})
}
/// 组装路由:API + 内嵌前端 + 托管目录。
///
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
/// 起监听任务,没法在单元测试里用。
fn build_router(state: AppState) -> Router {
let root = state.config.root.clone();
let enable_cors = state.config.enable_cors;
// 顺序就是优先级:显式路由(API / 内嵌前端)→ 托管目录 → 前端路由回落 → 404
let serve_dir = ServeDir::new(&root)
.append_index_html_on_directories(true)
.fallback(axum::handler::HandlerWithoutStateExt::into_service(
fallback_handler,
));
let mut app = Router::new()
.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))
.route("/assets/{*path}", get(web_asset))
.fallback_service(serve_dir)
.with_state(state)
.layer(TraceLayer::new_for_http());
if enable_cors {
app = app.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
);
}
app
}
pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
if !config.root.exists() {
return Err(StashError::InvalidDirectory(
@@ -271,36 +335,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
db,
};
// 托管目录里找不到的路径,交给 404 页面而不是 SPA 回落:
// 前端目前没有客户端路由,把 404 冒充 200 只会让排错更难。
let serve_dir = ServeDir::new(&config.root)
.append_index_html_on_directories(true)
.fallback(axum::handler::HandlerWithoutStateExt::into_service(
not_found_handler,
));
let mut app = Router::new()
.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))
.route("/assets/{*path}", get(web_asset))
.fallback_service(serve_dir)
.with_state(shared_state)
.layer(TraceLayer::new_for_http());
if config.enable_cors {
app = app.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
);
}
let app = build_router(shared_state);
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = tokio::net::TcpListener::bind(addr)
@@ -328,3 +363,143 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
local_addr,
})
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Body, http::Request};
use std::path::PathBuf;
use tower::ServiceExt;
/// 测试用的托管目录:放在 `target/` 下,不依赖系统临时目录的权限。
fn hosted_root() -> PathBuf {
let root =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/spa-root");
std::fs::create_dir_all(root.join("demo")).expect("应能创建测试项目");
std::fs::write(
root.join("demo/index.html"),
"<!doctype html><title>demo</title>",
)
.expect("应能写入项目入口页");
std::fs::write(root.join("note.txt"), "plain file").expect("应能写入普通文件");
root
}
fn router() -> Router {
let mut config = Config::default();
config.root = hosted_root();
// 测试不关心 CORS 标头
config.enable_cors = false;
let (project_updates, _) = broadcast::channel(4);
build_router(AppState {
config: Arc::new(config),
project_updates,
db: Arc::new(Db::in_memory()),
})
}
/// 发一个 GET,返回(状态码,Content-Type,响应体)。
async fn get(path: &str) -> (StatusCode, String, String) {
let response = router()
.oneshot(
Request::builder()
.uri(path)
.body(Body::empty())
.expect("请求应能构造"),
)
.await
.expect("路由应能处理请求");
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("应能读出响应体");
(
status,
content_type,
String::from_utf8_lossy(&bytes).into_owned(),
)
}
/// 刷新分类明细页:服务端没有 `/c/apps` 这个文件,必须回落到前端入口页。
#[tokio::test]
async fn spa_routes_fall_back_to_the_embedded_index() {
let (status, content_type, body) = get("/c/apps").await;
assert_eq!(status, StatusCode::OK);
assert!(
content_type.starts_with("text/html"),
"应返回 HTML: {content_type}"
);
assert!(body.contains(r#"<div id="root">"#), "应返回前端入口页");
}
/// 条目详情地址(含分组)同样回落:`/c/` 前缀下的任意层级都是前端路由。
#[tokio::test]
async fn item_routes_fall_back_to_the_embedded_index() {
let (status, _, body) = get("/c/apps/common/i/github").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains(r#"<div id="root">"#));
// 本地项目:条目 id 是项目名,可能带空格 / 中文,照样要能落到入口页
let (status, _, _) = get("/c/local/local-all/i/demo").await;
assert_eq!(status, StatusCode::OK);
}
/// 托管目录里的真实文件优先于回落,不能被前端路由吞掉。
#[tokio::test]
async fn hosted_files_still_win_over_the_spa_fallback() {
let (status, _, body) = get("/demo/").await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("<title>demo</title>"),
"应直接返回项目入口页: {body}"
);
let (status, _, body) = get("/note.txt").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "plain file");
}
/// 不是前端路由的路径仍然返回 404 页面(带请求路径,便于排错)。
#[tokio::test]
async fn unknown_paths_still_return_the_404_page() {
let (status, content_type, body) = get("/missing.txt").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(content_type.starts_with("text/html"));
assert!(body.contains("404"));
assert!(body.contains("/missing.txt"), "404 页面应显示请求路径");
}
/// API 不能被回落影响。
#[tokio::test]
async fn api_routes_are_not_swallowed_by_the_fallback() {
let (status, content_type, body) = get("/api/health").await;
assert_eq!(status, StatusCode::OK);
assert!(content_type.starts_with("application/json"));
assert!(body.contains("\"status\":\"ok\""));
}
/// 只接管登记过的前缀:`/c` 本身与 `/css/...` 都还是普通路径。
#[test]
fn only_registered_prefixes_fall_back() {
assert!(is_spa_path("/c/apps"));
assert!(is_spa_path("/c/"));
assert!(!is_spa_path("/c"));
assert!(!is_spa_path("/css/app.css"));
}
}