Files
stash/src/db/schema.rs
CNWei 57ab90a1fa feat: 搜索框下滚动、本地服务卡片排序、拖拽末位修复
- 恢复内容区内部滚动:页头/搜索框固定,滚动区域从搜索框下方开始;侧栏跟随内容滚动,侧栏滚轮驱动内容区
- v22:本地服务以 card 行 local-service 参与排序;首页按卡片顺序渲染,后台可拖动排序
- 本地服务卡片后端禁止删除/改名/配置分组
- 修复向下拖动时插到目标之后,倒数第二项可拖到最后
- 验证:搜索框位置固定、侧栏 scrollTop 跟随、本地服务与倒数第二拖末位均落库、首页顺序一致
2026-10-31 15:30:09 +08:00

654 lines
25 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 内容库的表结构与种子导入。
//!
//! 两条设计原则:
//!
//! 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 &section.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, &section.view, index, entry, &mut report)?;
}
}
} else {
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
for (index, entry) in section.entries.iter().enumerate() {
insert_entry(&tx, &section.key, &section.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>,
}