refactor(db): 删除旧分区展示块与兼容字段

- v23 迁移删除 section_block 表,以及 node 的 view_mode / empty_text / show_in_sidebar / allow_groups
- 重建 node 表并保留 44 条内容、15 个分组、5 张卡片,外键检查通过
- 种子库直接生成 card / card_group,不再依赖 section_block
- 删除旧 /api/catalog、/api/categories、/api/groups/{id} 与 categories 模块
- 分组接口不再接收/返回 format,前端 GroupNode 同步移除
- README/HANDOFF 更新到 v23;管理页提示改为『格式由每条内容决定』
This commit is contained in:
2026-11-02 20:24:08 +08:00
parent 4621f8cb22
commit 27e411fd2c
10 changed files with 213 additions and 1652 deletions
+67 -64
View File
@@ -27,9 +27,8 @@ const SEED_JSON: &str = include_str!("seed.json");
/// 种子会自动重新灌一份干净数据。
const SCHEMA_SQL: &str = r#"
-- 内容节点:分区与分组共用一张自引用树
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL
-- kind = 'group' → 分区下的分组,parent_code 指向所属分区
-- view_mode 只对分区有意义,决定首页与明细页怎么渲染
-- 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)
@@ -38,22 +37,15 @@ CREATE TABLE IF NOT EXISTS node (
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)
bar_color TEXT, -- 标题左侧色条颜色
icon TEXT, -- 字符图标
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)
(kind = 'section' AND parent_key IS NULL)
OR (kind = 'group' AND parent_key IS NOT NULL)
)
);
@@ -95,18 +87,6 @@ CREATE TABLE IF NOT EXISTS entry_tag (
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:登录与设置 ==========
-- 管理员:单用户场景只允许一行
@@ -226,6 +206,41 @@ 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';
@@ -302,7 +317,7 @@ pub struct SeedReport {
///
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
pub const SCHEMA_VERSION: i64 = 22;
pub const SCHEMA_VERSION: i64 = 23;
fn current_version(conn: &Connection) -> Result<i64, StashError> {
conn.pragma_query_value(None, "user_version", |row| row.get(0))
@@ -372,6 +387,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
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)?;
@@ -416,18 +436,14 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
}
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)",
"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,
view,
section.bar_color,
section.icon,
section.preview_limit,
section.empty_text,
i64::from(section.show_in_sidebar),
i64::from(view == "apps"),
section.sort,
],
)
@@ -437,15 +453,9 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
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
],
"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;
@@ -461,17 +471,26 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
}
}
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身)
// 首页卡片:仓库里直接生成卡片与勾选的分组
tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort)
VALUES (?1, ?2, ?3, ?4)",
"INSERT INTO card (key, title, sort) VALUES (?1, ?2, ?3)",
params![
section.block.node_key,
section.block.title,
section.block.preview_limit,
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}",
@@ -481,9 +500,6 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
report.blocks += 1;
}
// 老结构(分区 → 展示块)同步生成卡片,保证新首页立刻有内容
tx.execute_batch(BACKFILL_CARDS_SQL).map_err(map_error)?;
tx.commit().map_err(map_error)?;
Ok(report)
@@ -565,11 +581,6 @@ struct SeedSection {
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>,
@@ -601,17 +612,9 @@ 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);