feat(db): 内容库迁入 SQLite,分类与侧栏导航改为数据库驱动
This commit is contained in:
@@ -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>,
|
||||
}
|
||||
Reference in New Issue
Block a user