Files
stash/src/db/entries.rs
T
stash 427338b329 feat(admin): 分区增删、资料设置、操作条与交互收尾
- POST/DELETE /api/categories(需登录,级联,local 保护)
- profile 设置(头像/名称/角色/简介/位置)应用到资料卡
- 详情操作条:浏览 +1、点赞切换、分享复制、跳转外链
- 编辑按钮垂直居中且留 12px;设置页按钮上间距、保存 Toast、Tabs 去滚动条
- 登录密码可见切换
- 测试 48 passed
2026-10-29 20:15:56 +08:00

335 lines
10 KiB
Rust

//! 条目的读取与编辑(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 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)]
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>,
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
fn validate(patch: &EntryPatch) -> Result<(), StashError> {
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, 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)?,
name: row.get(1)?,
description: row.get(2)?,
icon: row.get(3)?,
href: row.get(4)?,
cover: row.get(5)?,
banner: row.get(6)?,
badge: row.get(7)?,
comment: row.get(8)?,
summary: row.get(9)?,
body: row.get(10)?,
tags: Vec::new(),
updated_at: row.get(11)?,
})
},
)
.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))
}
/// 更新条目;`expected_updated_at` 与库里不一致时冲突。
pub fn update(
conn: &mut Connection,
key: &str,
patch: EntryPatch,
expected_updated_at: i64,
) -> Result<EntryRecord, StashError> {
validate(&patch)?;
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
WHERE key = ?12 AND updated_at = ?13",
params![
patch.name.trim(),
patch.description,
patch.icon,
patch.href,
patch.cover,
patch.banner,
patch.badge,
patch.comment,
patch.summary,
patch.body,
timestamp,
key,
expected_updated_at
],
)
.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,
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
);
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()],
}
}
#[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(_)));
}
}