Files
stash/src/db/entries.rs
T
CNWei 11f992cdad feat: 格式从分组下沉到条目(v21)
- entry 新增 format(apps/posters/articles),老数据按根分组 view_mode 回填;seed 按分区写入
- 分组管理移除格式字段/显示,根分组只作容器
- 内容弹窗新增逐条格式选择,列表增加格式列;ItemEditor 保存时保留原格式
- 前台按 entry.format 渲染:卡片/分组页支持同组混合格式,详情按条目格式选模板
- 迁移后 live 20 应用 / 13 海报;cargo test 59 passed;Playwright 验证混合展示
2026-10-31 14:26:28 +08:00

687 lines
22 KiB
Rust
Raw 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.
//! 条目的读取与编辑(P2)。
//!
//! 编辑用 `updated_at` 做乐观锁:带上打开表单时的时间戳,
//! 别人已经改过就返回冲突,而不是静默覆盖。
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use crate::{db::map_error, error::StashError};
const MAX_NAME: usize = 120;
const MAX_BODY: usize = 100_000;
const MAX_TAGS: usize = 30;
const MAX_TAG_LEN: usize = 40;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct EntryRecord {
pub key: String,
pub format: String,
pub name: String,
pub description: String,
pub icon: String,
pub href: String,
pub cover: String,
pub banner: String,
pub badge: String,
pub comment: String,
pub summary: String,
pub body: String,
pub tags: Vec<String>,
pub updated_at: i64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EntryPatch {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub icon: String,
#[serde(default)]
pub href: String,
#[serde(default)]
pub cover: String,
#[serde(default)]
pub banner: String,
#[serde(default)]
pub badge: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub tags: Vec<String>,
/// 可选:把条目移动到另一个分区 / 分组(不传则保持原位)
#[serde(default)]
pub node_key: Option<String>,
/// 条目自身的展示格式:apps / posters / articles;不传默认 apps
#[serde(default)]
pub format: Option<String>,
}
/// 新建条目的请求体:`key` 是业务键,`nodeKey` 决定挂在哪个分区 / 分组。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewEntry {
pub key: String,
pub node_key: String,
pub item: EntryPatch,
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
/// 规范化跳转地址:补协议、拒绝危险协议、空值统一成空串。
///
/// 用户经常直接填 `www.zhihu.com`。原样存进 `href` 会被浏览器当成
/// 相对地址(点「跳转到」会跳到 `/c/.../www.zhihu.com`),所以这里统一补
/// `https://`。`href` 会原样渲染进 `<a>`,`javascript:` / `data:` 这类
/// 协议必须直接拒绝,不能只靠前端过滤。
fn normalize_href(raw: &str) -> Result<String, StashError> {
let value = raw.trim();
if value.is_empty() || value == "#" {
return Ok(String::new());
}
if value.chars().any(char::is_whitespace) {
return Err(StashError::InvalidDirectory("跳转地址不能包含空格".into()));
}
// 站内路径(单个斜杠开头);`//host` 是协议相对地址,按外链处理
if value.starts_with('/') && !value.starts_with("//") {
return Ok(value.to_string());
}
let lower = value.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
return Ok(value.to_string());
}
if value.starts_with("//") {
return Ok(format!("https:{value}"));
}
// 带「其他协议」的地址一律拒绝(javascript: / data: / vbscript: / file: / mailto: …);
// 只放行上面已经处理过的 http(s) 与站内路径。
if let Some((scheme, _)) = lower.split_once(':') {
let looks_like_scheme = !scheme.is_empty()
&& scheme.chars().next().is_some_and(|ch| ch.is_ascii_alphabetic())
&& scheme
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.');
if looks_like_scheme && !looks_like_domain(value) {
return Err(StashError::InvalidDirectory(
"跳转地址只支持 http(s)、站内路径或域名".into(),
));
}
}
if looks_like_domain(value) {
return Ok(format!("https://{value}"));
}
Err(StashError::InvalidDirectory(
"跳转地址需以 http(s):// 或 / 开头".into(),
))
}
/// 裸域名判断:`www.zhihu.com`、`example.com:8080/path` 这类没有协议头的地址。
fn looks_like_domain(value: &str) -> bool {
let host_port = value.split('/').next().unwrap_or("");
let host = host_port.split(':').next().unwrap_or("");
if !host.contains('.') {
return false;
}
let mut parts = host.split('.');
let Some(tld) = parts.next_back() else {
return false;
};
tld.len() >= 2
&& tld.chars().all(|ch| ch.is_ascii_alphabetic())
&& parts.all(|part| {
!part.is_empty()
&& part
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
})
}
fn validate(patch: &EntryPatch) -> Result<(), StashError> {
if let Some(format) = patch.format.as_deref() {
if !matches!(format, "apps" | "posters" | "articles") {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
}
if patch.name.trim().is_empty() {
return Err(StashError::InvalidDirectory("标题不能为空".into()));
}
if patch.name.chars().count() > MAX_NAME {
return Err(StashError::InvalidDirectory(format!(
"标题最多 {MAX_NAME} 个字"
)));
}
if patch.body.chars().count() > MAX_BODY {
return Err(StashError::InvalidDirectory("正文过长".into()));
}
if patch.tags.len() > MAX_TAGS {
return Err(StashError::InvalidDirectory(format!(
"标签最多 {MAX_TAGS} 个"
)));
}
for tag in &patch.tags {
if tag.chars().count() > MAX_TAG_LEN {
return Err(StashError::InvalidDirectory(format!(
"单个标签最多 {MAX_TAG_LEN} 个字"
)));
}
}
Ok(())
}
/// 读一条条目(编辑表单用),不存在返回 None。
pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashError> {
let row = conn
.query_row(
"SELECT key, COALESCE(format, 'apps'), name, COALESCE(description, ''), COALESCE(icon, ''), COALESCE(href, ''),
COALESCE(cover, ''), COALESCE(banner, ''), COALESCE(badge, ''),
COALESCE(comment, ''), COALESCE(summary, ''), COALESCE(body, ''), updated_at
FROM entry WHERE key = ?1",
[key],
|row| {
Ok(EntryRecord {
key: row.get(0)?,
format: row.get(1)?,
name: row.get(2)?,
description: row.get(3)?,
icon: row.get(4)?,
href: row.get(5)?,
cover: row.get(6)?,
banner: row.get(7)?,
badge: row.get(8)?,
comment: row.get(9)?,
summary: row.get(10)?,
body: row.get(11)?,
tags: Vec::new(),
updated_at: row.get(12)?,
})
},
)
.optional()
.map_err(map_error)?;
let Some(mut entry) = row else {
return Ok(None);
};
let mut statement = conn
.prepare("SELECT label FROM entry_tag WHERE entry_key = ?1 ORDER BY sort")
.map_err(map_error)?;
entry.tags = statement
.query_map([key], |row| row.get::<_, String>(0))
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
Ok(Some(entry))
}
/// 条目能挂到哪:分区本身,或分区下的分组(任意类型都支持分组)。
fn validate_target(conn: &Connection, node_key: &str) -> Result<(), StashError> {
let row: Option<(String, Option<String>)> = conn
.query_row(
"SELECT n.kind, p.kind
FROM node n LEFT JOIN node p ON p.key = n.parent_key
WHERE n.key = ?1",
[node_key],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(map_error)?;
let Some((kind, parent_kind)) = row else {
return Err(StashError::NotFound(format!("节点不存在: {node_key}")));
};
match (kind.as_str(), parent_kind.as_deref()) {
("section", _) => Ok(()),
// 多层分组:父级可以是分区或任意分组
("group", Some(_)) => Ok(()),
("group", _) => Err(StashError::InvalidDirectory(
"分组的父节点必须是分区或分组".into(),
)),
_ => Err(StashError::InvalidDirectory(
"条目只能挂在分区或分组下".into(),
)),
}
}
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 == '-')
}
fn write_tags(conn: &Connection, key: &str, tags: &[String]) -> Result<(), StashError> {
conn.execute("DELETE FROM entry_tag WHERE entry_key = ?1", [key])
.map_err(map_error)?;
for (index, tag) in tags.iter().enumerate() {
conn.execute(
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
params![key, index as i64, tag],
)
.map_err(map_error)?;
}
Ok(())
}
/// 新建条目:用于后台「内容」页添加网址 / 海报 / 图文。
pub fn create(conn: &mut Connection, new: NewEntry) -> Result<EntryRecord, StashError> {
let key = new.key.trim().to_string();
if !valid_key(&key) {
return Err(StashError::InvalidDirectory(
"标识只能用小写字母、数字与连字符(≤64)".into(),
));
}
let patch = new.item;
validate(&patch)?;
let format = patch.format.clone().unwrap_or_else(|| "apps".into());
let href = normalize_href(&patch.href)?;
validate_target(conn, &new.node_key)?;
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM entry WHERE key = ?1", [&key], |row| {
row.get(0)
})
.map_err(map_error)?;
if exists > 0 {
return Err(StashError::Conflict(format!("条目已存在: {key}")));
}
let tags: Vec<String> = patch
.tags
.iter()
.map(|tag| tag.trim().to_string())
.filter(|tag| !tag.is_empty())
.collect();
let timestamp = now();
let tx = conn.transaction().map_err(map_error)?;
let sort: i64 = tx
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM entry WHERE node_key = ?1",
[&new.node_key],
|row| row.get(0),
)
.map_err(map_error)?;
tx.execute(
"INSERT INTO entry (key, node_key, format, name, description, icon, href, cover, banner,
badge, comment, summary, body, hot, views, likes, sort, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0, 0, 0, ?14, ?15)",
params![
key,
new.node_key,
format,
patch.name.trim(),
patch.description,
patch.icon,
href,
patch.cover,
patch.banner,
patch.badge,
patch.comment,
patch.summary,
patch.body,
sort,
timestamp
],
)
.map_err(map_error)?;
write_tags(&tx, &key, &tags)?;
tx.execute(
"INSERT INTO audit (at, action, target, detail) VALUES (?1, 'item.create', ?2, ?3)",
params![timestamp, key, patch.name.trim()],
)
.map_err(map_error)?;
tx.commit().map_err(map_error)?;
load(conn, &key)?
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
}
/// 删除条目(后台内容页用)。
pub fn remove(conn: &Connection, key: &str) -> Result<(), StashError> {
let removed = conn
.execute("DELETE FROM entry WHERE key = ?1", [key])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("条目不存在: {key}")));
}
audit(conn, "item.delete", key, "")?;
Ok(())
}
/// 更新条目;`expected_updated_at` 与库里不一致时冲突。
pub fn update( conn: &mut Connection,
key: &str,
patch: EntryPatch,
expected_updated_at: i64,
) -> Result<EntryRecord, StashError> {
validate(&patch)?;
let format = patch.format.clone().unwrap_or_else(|| "apps".into());
// 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议
let href = normalize_href(&patch.href)?;
if let Some(node_key) = patch.node_key.as_deref() {
validate_target(conn, node_key)?;
}
let tags: Vec<String> = patch
.tags
.iter()
.map(|tag| tag.trim().to_string())
.filter(|tag| !tag.is_empty())
.collect();
let timestamp = now();
let tx = conn.transaction().map_err(map_error)?;
let updated = tx
.execute(
"UPDATE entry SET
name = ?1, description = ?2, icon = ?3, href = ?4,
cover = ?5, banner = ?6, badge = ?7, comment = ?8,
summary = ?9, body = ?10, updated_at = ?11,
node_key = COALESCE(?14, node_key),
format = ?15
WHERE key = ?12 AND updated_at = ?13",
params![
patch.name.trim(),
patch.description,
patch.icon,
href,
patch.cover,
patch.banner,
patch.badge,
patch.comment,
patch.summary,
patch.body,
timestamp,
key,
expected_updated_at,
patch.node_key,
format
],
)
.map_err(map_error)?;
if updated == 0 {
return Err(StashError::Conflict(
"条目已被修改,请刷新后重试".into(),
));
}
tx.execute("DELETE FROM entry_tag WHERE entry_key = ?1", [key])
.map_err(map_error)?;
for (index, tag) in tags.iter().enumerate() {
tx.execute(
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
params![key, index as i64, tag],
)
.map_err(map_error)?;
}
tx.execute(
"INSERT INTO audit (at, action, target, detail) VALUES (?1, 'item.update', ?2, ?3)",
params![timestamp, key, patch.name.trim()],
)
.map_err(map_error)?;
tx.commit().map_err(map_error)?;
load(conn, key)?
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
}
/// 记录一次浏览:views +1,返回 (views, likes)。
pub fn bump_view(conn: &Connection, key: &str) -> Result<(i64, i64), StashError> {
conn.execute("UPDATE entry SET views = views + 1 WHERE key = ?1", [key])
.map_err(map_error)?;
counts(conn, key)
}
/// 点赞 / 取消点赞:likes 加一或减一(不低于 0),返回 (views, likes)。
pub fn set_like(conn: &Connection, key: &str, liked: bool) -> Result<(i64, i64), StashError> {
let delta: i64 = if liked { 1 } else { -1 };
conn.execute(
"UPDATE entry SET likes = MAX(0, likes + ?1) WHERE key = ?2",
params![delta, key],
)
.map_err(map_error)?;
counts(conn, key)
}
fn counts(conn: &Connection, key: &str) -> Result<(i64, i64), StashError> {
conn.query_row("SELECT views, likes FROM entry WHERE key = ?1", [key], |row| {
Ok((row.get(0)?, row.get(1)?))
})
.optional()
.map_err(map_error)?
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
}
/// 记录一条审计(设置保存等)。
pub fn audit(conn: &Connection, action: &str, target: &str, detail: &str) -> Result<(), StashError> {
conn.execute(
"INSERT INTO audit (at, action, target, detail) VALUES (?1, ?2, ?3, ?4)",
params![now(), action, target, detail],
)
.map_err(map_error)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch(
"CREATE TABLE entry (
key TEXT PRIMARY KEY,
node_key TEXT NOT NULL,
format TEXT NOT NULL DEFAULT 'apps',
name TEXT NOT NULL,
description TEXT, icon TEXT, href TEXT, cover TEXT, banner TEXT,
badge TEXT, comment TEXT, summary TEXT, body TEXT,
hot INTEGER NOT NULL DEFAULT 0, 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
);
CREATE TABLE entry_tag (
entry_key TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL,
PRIMARY KEY (entry_key, sort)
);
CREATE TABLE audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT
);
CREATE TABLE node (
key TEXT PRIMARY KEY,
parent_key TEXT,
title TEXT NOT NULL,
kind TEXT NOT NULL,
view_mode TEXT
);
INSERT INTO node (key, parent_key, title, kind, view_mode) VALUES
('movie', NULL, '推荐电影', 'section', 'posters'),
('apps1', NULL, '常用推荐', 'section', 'apps'),
('g1', 'apps1', '常用站点', 'group', NULL);
INSERT INTO entry (key, node_key, name, updated_at) VALUES ('movie-1', 'movie', '千与千寻', 0);",
)
.expect("应能建表");
conn
}
fn patch(name: &str) -> EntryPatch {
EntryPatch {
name: name.into(),
description: "简介".into(),
icon: String::new(),
href: "https://example.com".into(),
cover: String::new(),
banner: String::new(),
badge: "推荐".into(),
comment: "一句话".into(),
summary: "正文".into(),
body: "## 标题".into(),
tags: vec!["推荐电影".into(), "动画".into()],
node_key: None,
format: None,
}
}
#[test]
fn update_replaces_fields_and_tags() {
let mut conn = db();
let saved = update(&mut conn, "movie-1", patch("千与千寻 2"), 0).expect("应能更新");
assert_eq!(saved.name, "千与千寻 2");
assert_eq!(saved.tags, vec!["推荐电影".to_string(), "动画".to_string()]);
assert!(saved.updated_at > 0);
let audit: i64 = conn
.query_row("SELECT COUNT(*) FROM audit", [], |row| row.get(0))
.expect("应能查审计");
assert_eq!(audit, 1);
}
#[test]
fn stale_timestamp_conflicts() {
let mut conn = db();
let saved = update(&mut conn, "movie-1", patch("A"), 0).expect("应能更新");
let error = update(&mut conn, "movie-1", patch("B"), saved.updated_at - 1)
.expect_err("应冲突");
assert!(matches!(error, StashError::Conflict(_)));
}
#[test]
fn rejects_empty_name() {
let mut conn = db();
assert!(update(&mut conn, "movie-1", patch(" "), 0).is_err());
}
#[test]
fn missing_entry_is_not_found() {
let mut conn = db();
let error = update(&mut conn, "none", patch("X"), 0).expect_err("应不存在");
assert!(matches!(error, StashError::Conflict(_) | StashError::NotFound(_)));
}
#[test]
fn normalizes_links_so_jump_works() {
// 裸域名补 https,避免被浏览器当成相对地址
assert_eq!(normalize_href("www.zhihu.com").unwrap(), "https://www.zhihu.com");
assert_eq!(
normalize_href("example.com:8080/path").unwrap(),
"https://example.com:8080/path"
);
// 已带协议的保持原样;站内路径保持站内
assert_eq!(normalize_href("https://example.com/a").unwrap(), "https://example.com/a");
assert_eq!(normalize_href("/git_helper/").unwrap(), "/git_helper/");
// 空值和占位符统一成空串
assert_eq!(normalize_href(" ").unwrap(), "");
assert_eq!(normalize_href("#").unwrap(), "");
}
#[test]
fn rejects_dangerous_or_unknown_links() {
for bad in ["javascript:alert(1)", "data:text/html,hi", "mailto:[email protected]", "not a url"] {
assert!(normalize_href(bad).is_err(), "{bad} 应被拒绝");
}
}
#[test]
fn update_stores_normalized_href() {
let mut conn = db();
let mut input = patch("知乎");
input.href = "www.zhihu.com".into();
let saved = update(&mut conn, "movie-1", input, 0).expect("应能更新");
assert_eq!(saved.href, "https://www.zhihu.com");
}
#[test]
fn creates_entry_under_section_or_group() {
let mut conn = db();
let saved = create(
&mut conn,
NewEntry {
key: "new-site".into(),
node_key: "g1".into(),
item: patch("新站点"),
},
)
.expect("应能创建");
assert_eq!(saved.key, "new-site");
assert_eq!(saved.name, "新站点");
let node: String = conn
.query_row("SELECT node_key FROM entry WHERE key='new-site'", [], |row| {
row.get(0)
})
.expect("应能查");
assert_eq!(node, "g1");
// 任何类型的分区都允许条目直属分区
let direct = create(
&mut conn,
NewEntry {
key: "direct-site".into(),
node_key: "apps1".into(),
item: patch("直属条目"),
},
)
.expect("应能创建");
assert_eq!(direct.key, "direct-site");
}
#[test]
fn moves_entry_between_nodes() {
let mut conn = db();
let mut input = patch("搬家");
input.node_key = Some("g1".into());
update(&mut conn, "movie-1", input, 0).expect("应能移动");
let node: String = conn
.query_row("SELECT node_key FROM entry WHERE key='movie-1'", [], |row| {
row.get(0)
})
.expect("应能查");
assert_eq!(node, "g1");
}
#[test]
fn deletes_entry() {
let conn = db();
remove(&conn, "movie-1").expect("应能删除");
assert!(remove(&conn, "movie-1").is_err(), "重复删除应失败");
}
}