- POST/DELETE /api/categories(需登录,级联,local 保护) - profile 设置(头像/名称/角色/简介/位置)应用到资料卡 - 详情操作条:浏览 +1、点赞切换、分享复制、跳转外链 - 编辑按钮垂直居中且留 12px;设置页按钮上间距、保存 Toast、Tabs 去滚动条 - 登录密码可见切换 - 测试 48 passed
335 lines
10 KiB
Rust
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(_)));
|
|
}
|
|
}
|