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
+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>,
}