- 恢复内容区内部滚动:页头/搜索框固定,滚动区域从搜索框下方开始;侧栏跟随内容滚动,侧栏滚轮驱动内容区 - v22:本地服务以 card 行 local-service 参与排序;首页按卡片顺序渲染,后台可拖动排序 - 本地服务卡片后端禁止删除/改名/配置分组 - 修复向下拖动时插到目标之后,倒数第二项可拖到最后 - 验证:搜索框位置固定、侧栏 scrollTop 跟随、本地服务与倒数第二拖末位均落库、首页顺序一致
654 lines
25 KiB
Rust
654 lines
25 KiB
Rust
//! 内容库的表结构与种子导入。
|
||
//!
|
||
//! 两条设计原则:
|
||
//!
|
||
//! 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 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#"
|
||
-- 内容节点:分区与分组共用一张自引用树
|
||
-- 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 表示只当首页卡片/内容,不进导航
|
||
allow_groups INTEGER NOT NULL DEFAULT 0 CHECK (allow_groups IN (0, 1)),
|
||
-- 是否允许子分组(与类型无关,后台开关)
|
||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||
-- 是否启用(禁用后前台侧栏/卡片不展示)
|
||
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 INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort);
|
||
|
||
-- 条目:网站 / 电影 / 文章都在这张表里,基础字段各占一列
|
||
CREATE TABLE IF NOT EXISTS entry (
|
||
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)
|
||
format TEXT NOT NULL DEFAULT 'apps', -- 条目自身的展示格式:apps / posters / articles
|
||
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, -- 条目顺序(组内或分区内),越小越靠前
|
||
updated_at INTEGER NOT NULL DEFAULT 0 -- 最近一次编辑时间(unix 秒),0 = 从未编辑
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS entry_parent_idx ON entry(node_key, sort);
|
||
|
||
-- 条目的行内文本列表:海报的 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 -- 卡片顺序
|
||
);
|
||
|
||
-- ========== v16:登录与设置 ==========
|
||
|
||
-- 管理员:单用户场景只允许一行
|
||
CREATE TABLE IF NOT EXISTS admin (
|
||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||
username TEXT NOT NULL,
|
||
password_hash TEXT NOT NULL, -- argon2id PHC 字符串
|
||
updated_at INTEGER NOT NULL -- unix 秒
|
||
);
|
||
|
||
-- 会话:只存 token 的 sha256,原始 token 只在 Cookie 里
|
||
CREATE TABLE IF NOT EXISTS session (
|
||
token_hash TEXT PRIMARY KEY,
|
||
created_at INTEGER NOT NULL,
|
||
expires_at INTEGER NOT NULL,
|
||
last_seen_at INTEGER NOT NULL
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS session_expiry_idx ON session(expires_at);
|
||
|
||
-- 全局设置:一个 key 一段 JSON,version 用于乐观锁
|
||
CREATE TABLE IF NOT EXISTS setting (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL,
|
||
version INTEGER NOT NULL DEFAULT 1,
|
||
updated_at INTEGER NOT NULL
|
||
);
|
||
|
||
-- ========== v17:审计 ==========
|
||
|
||
CREATE TABLE IF NOT EXISTS audit (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
at INTEGER NOT NULL,
|
||
action TEXT NOT NULL,
|
||
target TEXT,
|
||
detail TEXT
|
||
);
|
||
|
||
-- ========== v19:首页卡片(展示层组合) ==========
|
||
|
||
-- 一张卡片 = 标题 + 顺序;内容由 card_group 勾选分组组成,卡片不拥有数据
|
||
CREATE TABLE IF NOT EXISTS card (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
key TEXT NOT NULL UNIQUE,
|
||
title TEXT NOT NULL,
|
||
sort INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
|
||
-- 卡片包含哪些分组(可混合格式),每个分组可单独设预览条数
|
||
CREATE TABLE IF NOT EXISTS card_group (
|
||
card_id INTEGER NOT NULL REFERENCES card(id) ON DELETE CASCADE,
|
||
group_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||
sort INTEGER NOT NULL DEFAULT 0,
|
||
preview_limit INTEGER,
|
||
PRIMARY KEY (card_id, group_key)
|
||
);
|
||
"#;
|
||
|
||
/// 把老的「分区 → 一个展示块」迁移成「卡片 → 多个分组」。
|
||
const BACKFILL_CARDS_SQL: &str = r#"
|
||
INSERT OR IGNORE INTO card (key, title, sort)
|
||
SELECT 'card-' || COALESCE(p.key, n.key),
|
||
COALESCE(b.title, COALESCE(p.title, n.title)),
|
||
MIN(b.sort)
|
||
FROM section_block b
|
||
JOIN node n ON n.key = b.node_key
|
||
LEFT JOIN node p ON p.key = n.parent_key
|
||
GROUP BY COALESCE(p.key, n.key);
|
||
|
||
INSERT OR IGNORE INTO card_group (card_id, group_key, sort, preview_limit)
|
||
SELECT c.id, b.node_key, b.sort, 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
|
||
JOIN card c ON c.key = 'card-' || COALESCE(p.key, n.key);
|
||
|
||
-- 本地服务是运行时卡片,但用一行 card 记录它的位置,参与首页排序
|
||
INSERT OR IGNORE INTO card (key, title, sort) VALUES ('local-service', '本地服务', -1);
|
||
"#;
|
||
|
||
/// v16 → v17:条目加 `updated_at`(乐观锁),新增审计表。
|
||
const V17_SQL: &str = r#"
|
||
ALTER TABLE entry ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0;
|
||
|
||
CREATE TABLE IF NOT EXISTS audit (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
at INTEGER NOT NULL,
|
||
action TEXT NOT NULL,
|
||
target TEXT,
|
||
detail TEXT
|
||
);
|
||
"#;
|
||
|
||
/// v17 → v18:分区增加「允许子分组」开关(与类型无关)。
|
||
///
|
||
/// 老库默认保持原行为:应用类开启,其余类型关闭;已经有分组的非应用分区也开启。
|
||
const V18_SQL: &str = r#"
|
||
ALTER TABLE node ADD COLUMN allow_groups INTEGER NOT NULL DEFAULT 1;
|
||
|
||
UPDATE node SET allow_groups = 0
|
||
WHERE kind = 'section' AND view_mode <> 'apps';
|
||
|
||
UPDATE node SET allow_groups = 1
|
||
WHERE kind = 'section' AND key IN (
|
||
SELECT DISTINCT parent_key FROM node
|
||
WHERE kind = 'group' AND parent_key IS NOT NULL
|
||
);
|
||
"#;
|
||
|
||
/// v19 → v20:分组增加启停状态。
|
||
const V20_SQL: &str = r#"
|
||
ALTER TABLE node ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
|
||
"#;
|
||
|
||
/// v21 → v22:本地服务卡片入库,用 card.sort 参与首页排序(内容仍来自运行时项目)。
|
||
const V22_SQL: &str = r#"
|
||
INSERT OR IGNORE INTO card (key, title, sort) VALUES ('local-service', '本地服务', -1);
|
||
"#;
|
||
|
||
/// v20 → v21:格式从分组下沉到条目;老数据按所属根分组的 view_mode 回填。
|
||
const V21_SQL: &str = r#"
|
||
ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps';
|
||
|
||
WITH RECURSIVE roots(key, root_key, view_mode) AS (
|
||
SELECT key, key, view_mode FROM node WHERE parent_key IS NULL
|
||
UNION ALL
|
||
SELECT n.key, r.root_key, r.view_mode
|
||
FROM node n JOIN roots r ON n.parent_key = r.key
|
||
)
|
||
UPDATE entry
|
||
SET format = COALESCE(
|
||
(SELECT view_mode FROM roots WHERE roots.key = entry.node_key),
|
||
'apps'
|
||
);
|
||
"#;
|
||
|
||
/// v18 → v19:首页卡片改为「卡片 + 勾选分组」。
|
||
const V19_SQL: &str = r#"
|
||
CREATE TABLE IF NOT EXISTS card (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
key TEXT NOT NULL UNIQUE,
|
||
title TEXT NOT NULL,
|
||
sort INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS card_group (
|
||
card_id INTEGER NOT NULL REFERENCES card(id) ON DELETE CASCADE,
|
||
group_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||
sort INTEGER NOT NULL DEFAULT 0,
|
||
preview_limit INTEGER,
|
||
PRIMARY KEY (card_id, group_key)
|
||
);
|
||
"#;
|
||
|
||
/// v15 → v16 的增量(老库升级用;新库直接走 SCHEMA_SQL)。
|
||
const V16_SQL: &str = r#"
|
||
CREATE TABLE IF NOT EXISTS admin (
|
||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||
username TEXT NOT NULL,
|
||
password_hash TEXT NOT NULL,
|
||
updated_at INTEGER NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS session (
|
||
token_hash TEXT PRIMARY KEY,
|
||
created_at INTEGER NOT NULL,
|
||
expires_at INTEGER NOT NULL,
|
||
last_seen_at INTEGER NOT NULL
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS session_expiry_idx ON session(expires_at);
|
||
|
||
CREATE TABLE IF NOT EXISTS setting (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL,
|
||
version INTEGER NOT NULL DEFAULT 1,
|
||
updated_at INTEGER NOT NULL
|
||
);
|
||
"#;
|
||
|
||
/// 种子导入结果,仅用于启动日志。
|
||
#[derive(Debug, Default, PartialEq, Eq)]
|
||
pub struct SeedReport {
|
||
pub sections: usize,
|
||
pub groups: usize,
|
||
pub entries: usize,
|
||
pub tags: usize,
|
||
/// 首页展示块数量(每个分区一个)
|
||
pub blocks: usize,
|
||
}
|
||
|
||
/// 当前表结构版本,写在 SQLite 的 `PRAGMA user_version` 里。
|
||
///
|
||
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
||
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
||
pub const SCHEMA_VERSION: i64 = 22;
|
||
|
||
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||
.map_err(map_error)
|
||
}
|
||
|
||
/// 把库升到 `SCHEMA_VERSION`,返回升级后的版本。
|
||
///
|
||
/// 逐级迁移:0 建全量;15 → 16 → 17;已最新直接返回;更高版本拒绝。
|
||
pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
||
let mut version = current_version(conn)?;
|
||
|
||
if version == 0 {
|
||
conn.execute_batch(SCHEMA_SQL).map_err(map_error)?;
|
||
conn.pragma_update(None, "user_version", SCHEMA_VERSION)
|
||
.map_err(map_error)?;
|
||
return Ok(SCHEMA_VERSION);
|
||
}
|
||
|
||
if version > SCHEMA_VERSION {
|
||
return Err(StashError::Database(format!(
|
||
"内容库结构版本为 {version},比当前程序({SCHEMA_VERSION})更新;\
|
||
请升级程序,或用旧版本打开。"
|
||
)));
|
||
}
|
||
|
||
if version < 15 {
|
||
return Err(StashError::Database(format!(
|
||
"内容库结构版本为 {version},缺少可用的迁移路径(当前程序支持 15 → {SCHEMA_VERSION})。\
|
||
请备份后删除数据库重建。"
|
||
)));
|
||
}
|
||
|
||
if version == 15 {
|
||
conn.execute_batch(V16_SQL).map_err(map_error)?;
|
||
version = 16;
|
||
}
|
||
|
||
if version == 16 {
|
||
conn.execute_batch(V17_SQL).map_err(map_error)?;
|
||
version = 17;
|
||
}
|
||
|
||
if version == 17 {
|
||
conn.execute_batch(V18_SQL).map_err(map_error)?;
|
||
version = 18;
|
||
}
|
||
|
||
if version == 18 {
|
||
conn.execute_batch(V19_SQL).map_err(map_error)?;
|
||
conn.execute_batch(BACKFILL_CARDS_SQL).map_err(map_error)?;
|
||
version = 19;
|
||
}
|
||
|
||
if version == 19 {
|
||
conn.execute_batch(V20_SQL).map_err(map_error)?;
|
||
version = 20;
|
||
}
|
||
|
||
if version == 20 {
|
||
conn.execute_batch(V21_SQL).map_err(map_error)?;
|
||
version = 21;
|
||
}
|
||
|
||
if version == 21 {
|
||
conn.execute_batch(V22_SQL).map_err(map_error)?;
|
||
version = 22;
|
||
}
|
||
|
||
conn.pragma_update(None, "user_version", version)
|
||
.map_err(map_error)?;
|
||
|
||
Ok(version)
|
||
}
|
||
|
||
/// 建表(幂等):只在测试里直接用;启动流程走 `migrate`。
|
||
#[cfg(test)]
|
||
pub fn apply(conn: &Connection) -> Result<(), StashError> {
|
||
conn.execute_batch(SCHEMA_SQL).map_err(map_error)
|
||
}
|
||
|
||
/// 把内嵌种子写进空库。
|
||
///
|
||
/// 只用业务编码,不需要先插入再回查自增 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}")))?;
|
||
|
||
let mut report = SeedReport::default();
|
||
// 单事务写入:避免中途失败留下半个内容库
|
||
let tx = conn.transaction().map_err(map_error)?;
|
||
|
||
for section in &seed.sections {
|
||
let view = section.view.as_str();
|
||
|
||
// 只认识前端支持的三种形态,拼错时立刻报错而不是静默写脏数据
|
||
if !matches!(view, "apps" | "posters" | "articles") {
|
||
return Err(StashError::Database(format!(
|
||
"种子分区 {} 的 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 node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups, sort)
|
||
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||
params![
|
||
section.key,
|
||
section.title,
|
||
view,
|
||
section.bar_color,
|
||
section.icon,
|
||
section.preview_limit,
|
||
section.empty_text,
|
||
i64::from(section.show_in_sidebar),
|
||
i64::from(view == "apps"),
|
||
section.sort,
|
||
],
|
||
)
|
||
.map_err(map_error)?;
|
||
report.sections += 1;
|
||
|
||
if view == "apps" {
|
||
for group in §ion.groups {
|
||
tx.execute(
|
||
"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, entry) in group.entries.iter().enumerate() {
|
||
insert_entry(&tx, &group.key, §ion.view, index, entry, &mut report)?;
|
||
}
|
||
}
|
||
} else {
|
||
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
|
||
for (index, entry) in section.entries.iter().enumerate() {
|
||
insert_entry(&tx, §ion.key, §ion.view, index, entry, &mut report)?;
|
||
}
|
||
}
|
||
|
||
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身)
|
||
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;
|
||
}
|
||
|
||
// 老结构(分区 → 展示块)同步生成卡片,保证新首页立刻有内容
|
||
tx.execute_batch(BACKFILL_CARDS_SQL).map_err(map_error)?;
|
||
|
||
tx.commit().map_err(map_error)?;
|
||
|
||
Ok(report)
|
||
}
|
||
|
||
/// 写入一个条目:基础字段进 entry,行内标签进 entry_tag。
|
||
///
|
||
/// `node_key` 是直接容器的业务键;所属分区由它推导,不单独存。
|
||
fn insert_entry(
|
||
tx: &rusqlite::Transaction<'_>,
|
||
node_key: &str,
|
||
format: &str,
|
||
index: usize,
|
||
entry: &SeedEntry,
|
||
report: &mut SeedReport,
|
||
) -> Result<(), StashError> {
|
||
tx.execute(
|
||
"INSERT INTO entry (
|
||
key, node_key, format, 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, ?17)",
|
||
params![
|
||
entry.key,
|
||
node_key,
|
||
format,
|
||
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,
|
||
],
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
report.entries += 1;
|
||
|
||
// 标签:海报的 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(())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 种子文件的反序列化结构
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 种子文件根节点
|
||
#[derive(Debug, Deserialize)]
|
||
struct SeedFile {
|
||
sections: Vec<SeedSection>,
|
||
}
|
||
|
||
/// 一个分区(顶层节点)
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct SeedSection {
|
||
key: String,
|
||
/// 渲染形态:apps / posters / articles
|
||
view: String,
|
||
title: String,
|
||
bar_color: String,
|
||
icon: String,
|
||
sort: i64,
|
||
#[serde(default)]
|
||
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)]
|
||
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 {
|
||
key: String,
|
||
title: String,
|
||
sort: i64,
|
||
/// 是否出现在侧栏;默认 true
|
||
#[serde(default = "default_true")]
|
||
show_in_sidebar: bool,
|
||
entries: Vec<SeedEntry>,
|
||
}
|
||
|
||
/// serde 默认值:侧栏默认显示
|
||
fn default_true() -> bool {
|
||
true
|
||
}
|
||
|
||
/// 一条内容:字段与 `entry` 表的列一一对应。
|
||
///
|
||
/// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta);
|
||
/// `body` 是文章的 Markdown 正文,一篇一段文本。
|
||
#[derive(Debug, Default, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct SeedEntry {
|
||
key: String,
|
||
name: String,
|
||
#[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>,
|
||
}
|