feat(api): 原子模型分组树 / 卡片 / catalog2
- 新增 /api/catalog2:多层分组树(继承格式、带条目)+ 卡片勾选列表
- 新增 /api/nodes CRUD:任意层级分组、移动(防环)、排序、根分组格式
- 新增 /api/cards CRUD 与 PUT /api/cards/{id}/groups:一张卡可混合格式、可复用分组
- 条目允许挂在任意层级分组
- 测试 59 passed;API 冒烟通过(三级分组、卡片混合格式)
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
//! 分组树的管理:多层分组、格式、父级移动与排序。
|
||||
//!
|
||||
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`,带
|
||||
//! `view_mode` 格式),下面的节点都叫分组(`kind='group'`),可以继续嵌套。
|
||||
//! 条目挂在任意节点上,格式由最近的根分组决定。
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{db::entries, db::map_error, error::StashError};
|
||||
|
||||
const FORMATS: [&str; 3] = ["apps", "posters", "articles"];
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewNode {
|
||||
/// 业务键,只允许小写字母、数字与连字符
|
||||
pub key: String,
|
||||
pub title: String,
|
||||
/// 为空表示新建一个根分组
|
||||
#[serde(default)]
|
||||
pub parent_key: Option<String>,
|
||||
/// 根分组的格式:apps / posters / articles(子分组继承,不需要传)
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateNode {
|
||||
pub title: Option<String>,
|
||||
/// 传入即移动到新的父节点;不支持移回根(根分组请直接新建)
|
||||
pub parent_key: Option<String>,
|
||||
pub sort: Option<i64>,
|
||||
/// 只对根分组生效
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
fn valid_key(key: &str) -> bool {
|
||||
!key.is_empty()
|
||||
&& key.len() <= 64
|
||||
&& key
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
||||
}
|
||||
|
||||
pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
|
||||
let key = new.key.trim();
|
||||
let title = new.title.trim();
|
||||
|
||||
if !valid_key(key) {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"分组标识只能用小写字母、数字与连字符(≤64)".into(),
|
||||
));
|
||||
}
|
||||
if title.is_empty() || title.chars().count() > 40 {
|
||||
return Err(StashError::InvalidDirectory("分组名称需为 1–40 个字".into()));
|
||||
}
|
||||
|
||||
let exists: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [key], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(map_error)?;
|
||||
if exists > 0 {
|
||||
return Err(StashError::Conflict(format!("分组已存在: {key}")));
|
||||
}
|
||||
|
||||
let (kind, view_mode) = match new.parent_key.as_deref() {
|
||||
Some(parent) => {
|
||||
let parent_exists: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [parent], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(map_error)?;
|
||||
if parent_exists == 0 {
|
||||
return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
|
||||
}
|
||||
("group", None)
|
||||
}
|
||||
None => {
|
||||
let format = new.format.unwrap_or_else(|| "apps".into());
|
||||
if !FORMATS.contains(&format.as_str()) {
|
||||
return Err(StashError::InvalidDirectory("格式不合法".into()));
|
||||
}
|
||||
("section", Some(format))
|
||||
}
|
||||
};
|
||||
|
||||
let sort: i64 = match new.parent_key.as_deref() {
|
||||
Some(parent) => conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1",
|
||||
[parent],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(map_error)?,
|
||||
None => conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key IS NULL",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(map_error)?,
|
||||
};
|
||||
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
tx.execute(
|
||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar, allow_groups)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 1)",
|
||||
params![key, new.parent_key, title, kind, view_mode, sort],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
entries::audit(&tx, "node.create", key, title)?;
|
||||
tx.commit().map_err(map_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> {
|
||||
let current: Option<(String, Option<String>, Option<String>, i64)> = conn
|
||||
.query_row(
|
||||
"SELECT title, parent_key, view_mode, sort FROM node WHERE key = ?1",
|
||||
[key],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let Some((current_title, current_parent, current_format, current_sort)) = current else {
|
||||
return Err(StashError::NotFound(format!("分组不存在: {key}")));
|
||||
};
|
||||
|
||||
let title = match patch.title {
|
||||
Some(title) => {
|
||||
let title = title.trim().to_string();
|
||||
if title.is_empty() || title.chars().count() > 40 {
|
||||
return Err(StashError::InvalidDirectory("分组名称需为 1–40 个字".into()));
|
||||
}
|
||||
title
|
||||
}
|
||||
None => current_title,
|
||||
};
|
||||
|
||||
let parent_key = match patch.parent_key.clone() {
|
||||
Some(parent) => {
|
||||
if parent == key {
|
||||
return Err(StashError::InvalidDirectory("不能把分组移到自己下面".into()));
|
||||
}
|
||||
let exists: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [&parent], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(map_error)?;
|
||||
if exists == 0 {
|
||||
return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
|
||||
}
|
||||
// 防止把节点移进自己的子孙
|
||||
let mut cursor = Some(parent.clone());
|
||||
while let Some(candidate) = cursor {
|
||||
if candidate == key {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"不能把分组移到自己的子分组下面".into(),
|
||||
));
|
||||
}
|
||||
cursor = conn
|
||||
.query_row(
|
||||
"SELECT parent_key FROM node WHERE key = ?1",
|
||||
[candidate],
|
||||
|row| row.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(map_error)?
|
||||
.flatten();
|
||||
}
|
||||
Some(parent)
|
||||
}
|
||||
// 不传表示保持原父级(不支持移回根)
|
||||
None => current_parent.clone(),
|
||||
};
|
||||
|
||||
let is_root = parent_key.is_none();
|
||||
let format = if is_root {
|
||||
match patch.format {
|
||||
Some(format) => {
|
||||
if !FORMATS.contains(&format.as_str()) {
|
||||
return Err(StashError::InvalidDirectory("格式不合法".into()));
|
||||
}
|
||||
Some(format)
|
||||
}
|
||||
None => current_format,
|
||||
}
|
||||
} else {
|
||||
// 子分组没有自己的格式,跟随根分组
|
||||
None
|
||||
};
|
||||
|
||||
if is_root && format.is_none() {
|
||||
return Err(StashError::InvalidDirectory("根分组缺少格式".into()));
|
||||
}
|
||||
|
||||
let sort = patch.sort.unwrap_or(current_sort);
|
||||
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
// 移动时重新排到新父级末尾;不移动则保留原顺序
|
||||
let final_sort = match patch.parent_key {
|
||||
Some(parent) => tx
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1 AND key <> ?2",
|
||||
params![parent, key],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.map_err(map_error)?,
|
||||
None => sort,
|
||||
};
|
||||
|
||||
tx.execute(
|
||||
"UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4 WHERE key = ?5",
|
||||
params![title, parent_key, format, final_sort, key],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
entries::audit(&tx, "node.update", key, &title)?;
|
||||
tx.commit().map_err(map_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(conn: &Connection, key: &str) -> Result<(), StashError> {
|
||||
let removed = conn
|
||||
.execute("DELETE FROM node WHERE key = ?1", [key])
|
||||
.map_err(map_error)?;
|
||||
if removed == 0 {
|
||||
return Err(StashError::NotFound(format!("分组不存在: {key}")));
|
||||
}
|
||||
entries::audit(conn, "node.delete", key, "")?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user