//! 内容库的表结构与种子导入。 //! //! 两条设计原则: //! //! 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_key 必须为 NULL -- kind = 'group' → 分区下的分组,parent_key 指向分区或上层分组 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')), -- 容器层级 bar_color TEXT, -- 标题左侧色条颜色 icon TEXT, -- 字符图标 preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认 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) OR (kind = 'group' AND parent_key IS NOT 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) ); -- ========== 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); "#; /// v22 → v23:删除旧分区展示块与 node 上的兼容字段,node 只保留当前模型需要的列。 const V23_SQL: &str = r#" PRAGMA foreign_keys = OFF; DROP TABLE IF EXISTS section_block; CREATE TABLE node_new ( id INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE, parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE, title TEXT NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('section', 'group')), bar_color TEXT, icon TEXT, preview_limit INTEGER, 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) OR (kind = 'group' AND parent_key IS NOT NULL) ) ); INSERT INTO node_new (key, parent_key, title, kind, bar_color, icon, preview_limit, enabled, sort) SELECT key, parent_key, title, kind, bar_color, icon, preview_limit, enabled, sort FROM node; DROP TABLE node; ALTER TABLE node_new RENAME TO node; CREATE INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort); PRAGMA foreign_keys = ON; "#; /// 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 = 23; fn current_version(conn: &Connection) -> Result { 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 { 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; } if version == 22 { conn.execute_batch(V23_SQL).map_err(map_error)?; version = 23; } 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 { 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, bar_color, icon, preview_limit, sort) VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6)", params![ section.key, section.title, section.bar_color, section.icon, section.preview_limit, 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, sort) VALUES (?1, ?2, ?3, 'group', ?4)", params![group.key, section.key, group.title, 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 card (key, title, sort) VALUES (?1, ?2, ?3)", params![ format!("card-{}", section.key), section .block .title .clone() .unwrap_or_else(|| section.title.clone()), section.sort, ], ) .map_err(map_error)?; let card_id = tx.last_insert_rowid(); tx.execute( "INSERT INTO card_group (card_id, group_key, sort, preview_limit) VALUES (?1, ?2, 0, ?3)", params![card_id, section.block.node_key, section.block.preview_limit], ) .map_err(|error| { StashError::Database(format!( "分区 {} 的展示节点 {} 无效: {error}", section.key, section.block.node_key )) })?; report.blocks += 1; } 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, } /// 一个分区(顶层节点) #[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, /// 仅 apps 分区使用 #[serde(default)] groups: Vec, /// 非 apps 分区直接挂条目 #[serde(default)] entries: Vec, /// 首页卡片展示哪个节点(分组编码,或分区自己的编码) block: SeedBlockConfig, } /// 首页分区的展示配置 #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SeedBlockConfig { /// 要展示的节点业务键;通常是分组,电影/文章填分区自己 node_key: String, /// 块标题;空则用节点 title #[serde(default)] title: Option, /// 卡片显示几条;空则用前端默认 #[serde(default)] preview_limit: Option, } /// 分区下的一个分组 #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SeedGroup { key: String, title: String, sort: i64, entries: Vec, } /// 一条内容:字段与 `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, #[serde(default)] icon: Option, #[serde(default)] href: Option, #[serde(default)] cover: Option, #[serde(default)] banner: Option, #[serde(default)] badge: Option, #[serde(default)] comment: Option, #[serde(default)] summary: Option, /// 文章正文(Markdown) #[serde(default)] body: Option, #[serde(default)] hot: bool, #[serde(default)] views: i64, #[serde(default)] likes: i64, #[serde(default)] tags: Vec, #[serde(default)] meta: Vec, }