docs(posts): 补充中文注释

This commit is contained in:
xfy 2026-06-12 18:48:17 +08:00
parent 9921f8eebf
commit a785683fc6
12 changed files with 220 additions and 4 deletions

View File

@ -1,3 +1,10 @@
//! 创建文章接口。
//!
//! 校验标题、内容与 slug生成唯一 slug 并渲染 Markdown
//! 在事务中写入 posts 表并同步标签关联,最后失效相关缓存。
//! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。
#![allow(clippy::too_many_arguments)]
use dioxus::prelude::*;
@ -10,6 +17,10 @@ use crate::api::error::AppError;
use crate::db::pool::get_conn;
use crate::models::post::PostStatus;
/// 创建一篇新文章。
///
/// 校验标题与内容非空、slug 格式合法;未提供 slug 时从标题自动生成。
/// 首次发布时填充 published_at并同步标签与缓存失效。
#[server(CreatePost, "/api")]
pub async fn create_post(
title: String,
@ -22,6 +33,7 @@ pub async fn create_post(
) -> Result<CreatePostResponse, ServerFnError> {
let user = get_current_admin_user().await?;
// 标题不能为空。
if title.trim().is_empty() {
return Ok(CreatePostResponse {
success: false,
@ -31,6 +43,7 @@ pub async fn create_post(
});
}
// 内容不能为空。
if content_md.trim().is_empty() {
return Ok(CreatePostResponse {
success: false,
@ -40,6 +53,7 @@ pub async fn create_post(
});
}
// 确定基础 slug用户传入时校验格式否则由标题生成。
let base_slug = match slug {
Some(ref s) if !s.trim().is_empty() => {
let s = s.trim();
@ -60,7 +74,9 @@ pub async fn create_post(
{
let mut client = get_conn().await.map_err(AppError::db_conn)?;
// 保证 slug 全局唯一,若冲突则追加数字后缀。
let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, None).await?;
// 渲染 Markdown 为 HTML并提取目录。
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
let content_html = rendered.html;
let toc_html = if rendered.toc_html.is_empty() {
@ -68,12 +84,14 @@ pub async fn create_post(
} else {
Some(rendered.toc_html)
};
// 未填写摘要时自动从正文提取。
let summary = summary
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::utils::text::auto_summary(&content_md));
let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft);
let cover_image = cover_image.filter(|s| !s.trim().is_empty());
// 发布状态的文章设置当前发布时间;草稿则为 None。
let published_at = if post_status == PostStatus::Published {
Some(chrono::Utc::now())
} else {
@ -82,6 +100,7 @@ pub async fn create_post(
let tx = client.transaction().await.map_err(AppError::tx)?;
// 插入文章记录。
let row = tx
.query_one(
"INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image)
@ -105,15 +124,18 @@ pub async fn create_post(
let post_id: i32 = row.get(0);
// 清洗标签并在事务中同步 post_tags 关联。
let tags_cleaned = clean_tags(&tags);
sync_tags(&tx, post_id, &tags_cleaned).await?;
tx.commit().await.map_err(AppError::tx)?;
// 写入成功后失效文章列表、标签与统计缓存。
crate::cache::invalidate_post_lists();
crate::cache::invalidate_all_tags();
crate::cache::invalidate_post_stats();
// 失效该文章涉及的所有标签缓存。
for tag_name in &tags_cleaned {
crate::cache::invalidate_posts_by_tag(tag_name).await;
}

View File

@ -1,3 +1,10 @@
//! 删除文章接口。
//!
//! 采用软删除方式,将 posts.deleted_at 设置为当前时间,
//! 同时清空所有文章相关缓存。
//! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中执行删除与缓存失效。
use dioxus::prelude::*;
#[cfg(feature = "server")]
@ -7,6 +14,10 @@ use super::types::CreatePostResponse;
use crate::api::error::AppError;
use crate::db::pool::get_conn;
/// 删除指定文章。
///
/// 仅 admin 可调用;通过设置 deleted_at 实现软删除,
/// 成功后清空全部文章缓存。
#[server(DeletePost, "/api")]
pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
let _user = get_current_admin_user().await?;
@ -15,6 +26,7 @@ pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErr
{
let client = get_conn().await.map_err(AppError::db_conn)?;
// 软删除:仅影响未被删除的文章。
let result = client
.execute(
"UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
@ -32,6 +44,7 @@ pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErr
});
}
// 删除后所有文章相关缓存均失效。
crate::cache::invalidate_all_post_caches();
Ok(CreatePostResponse {

View File

@ -1,3 +1,8 @@
//! 文章模块内部辅助函数。
//!
//! 提供数据库行到 `Post` 模型的转换、标签同步与标签清洗等工具函数。
//! 仅在 `feature = "server"` 启用的服务端构建中可用。
#[cfg(feature = "server")]
use crate::api::error::AppError;
#[cfg(feature = "server")]
@ -5,9 +10,14 @@ use crate::models::post::{Post, PostStatus};
#[cfg(feature = "server")]
use crate::utils::text::count_words;
/// 复用认证模块的当前 admin 用户获取逻辑。
#[cfg(feature = "server")]
pub(super) use crate::api::auth::get_current_admin_user;
/// 将数据库行转换为文章列表项。
///
/// 用于列表接口,包含标签聚合、字数与阅读时长估算,
/// 不包含上下篇导航与目录。
#[cfg(feature = "server")]
pub(super) async fn row_to_post_list(
_client: &tokio_postgres::Client,
@ -17,6 +27,7 @@ pub(super) async fn row_to_post_list(
let role_str: String = row.get("status");
let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft);
// 聚合标签并过滤空字符串。
let tags: Vec<String> = row
.try_get::<_, Vec<String>>("tags")
.unwrap_or_default()
@ -49,6 +60,10 @@ pub(super) async fn row_to_post_list(
}
}
/// 将数据库行转换为完整文章详情。
///
/// 相比列表项额外包含上一篇/下一篇导航,
/// 并在 content_html 为空时重新渲染 Markdown 以兼容旧数据。
#[cfg(feature = "server")]
pub(super) async fn row_to_post_full(
_client: &tokio_postgres::Client,
@ -58,6 +73,7 @@ pub(super) async fn row_to_post_full(
let role_str: String = row.get("status");
let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft);
// 聚合标签并过滤空字符串。
let tags: Vec<String> = row
.try_get::<_, Vec<String>>("tags")
.unwrap_or_default()
@ -65,6 +81,7 @@ pub(super) async fn row_to_post_full(
.filter(|t| !t.is_empty())
.collect();
// 解析上一篇文章导航。
let prev_post = if let Ok(prev_title) = row.try_get::<_, String>("prev_title") {
if let Ok(prev_slug) = row.try_get::<_, String>("prev_slug") {
Some(crate::models::post::PostNav {
@ -78,6 +95,7 @@ pub(super) async fn row_to_post_full(
None
};
// 解析下一篇文章导航。
let next_post = if let Ok(next_title) = row.try_get::<_, String>("next_title") {
if let Ok(next_slug) = row.try_get::<_, String>("next_slug") {
Some(crate::models::post::PostNav {
@ -94,6 +112,7 @@ pub(super) async fn row_to_post_full(
let content_html: Option<String> = row.get("content_html");
let toc_html_row: Option<String> = row.get("toc_html");
// 若数据库中未渲染 HTML旧数据兼容则现场渲染 Markdown。
let (content_html, toc_html) = if let Some(html) = content_html {
(html, toc_html_row)
} else {
@ -134,6 +153,10 @@ pub(super) async fn row_to_post_full(
}
}
/// 在事务中同步文章的标签关联。
///
/// 对传入的每个标签:若不存在则插入 tags 表,否则查询已有 id
/// 然后在 post_tags 表中建立关联。不会删除旧关联,调用方需先清理。
#[cfg(feature = "server")]
pub(super) async fn sync_tags(
tx: &deadpool_postgres::Transaction<'_>,
@ -142,6 +165,7 @@ pub(super) async fn sync_tags(
) -> Result<(), AppError> {
for tag_name in tags {
let tag_id: i32 = {
// 先尝试插入,若已存在则返回空。
let row = tx
.query_opt(
"INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id",
@ -153,6 +177,7 @@ pub(super) async fn sync_tags(
match row {
Some(r) => r.get(0),
None => {
// 插入冲突时回查标签 id。
let row = tx
.query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()])
.await
@ -174,6 +199,9 @@ pub(super) async fn sync_tags(
Ok(())
}
/// 清洗标签列表:去头尾空白并过滤空字符串。
///
/// 注意:该函数保留重复标签,由数据库唯一索引或调用方决定去重。
#[cfg(feature = "server")]
pub(super) fn clean_tags(tags: &[String]) -> Vec<String> {
tags.iter()

View File

@ -1,3 +1,10 @@
//! 文章列表查询接口。
//!
//! 提供已发布文章分页、管理员全量列表、以及按标签筛选三种查询能力,
//! 均通过缓存层减少重复数据库访问。
//! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
use dioxus::prelude::*;
#[cfg(feature = "server")]
@ -7,6 +14,9 @@ use super::types::PostListResponse;
use crate::api::error::AppError;
use crate::db::pool::get_conn;
/// 获取已发布文章分页列表。
///
/// 优先命中缓存;未命中时查询总数与分页记录,并按 published_at 降序排列。
#[server(ListPublishedPosts, "/api")]
pub async fn list_published_posts(
page: i32,
@ -24,7 +34,7 @@ pub async fn list_published_posts(
let client = get_conn().await.map_err(AppError::db_conn)?;
// Get total count from cache or query
// 优先读取缓存中的已发布文章总数,否则查询数据库并回填缓存。
let total = if let Some(cached_total) = crate::cache::get_total_published_posts().await {
cached_total
} else {
@ -78,6 +88,9 @@ pub async fn list_published_posts(
}
}
/// 获取管理员视角的全部文章列表(含草稿与已发布)。
///
/// 需要 admin 权限;结果按创建时间降序,不走缓存。
#[server(ListPosts, "/api")]
pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, ServerFnError> {
let _user = get_current_admin_user().await?;
@ -129,6 +142,9 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, Se
}
}
/// 获取指定标签下的已发布文章列表。
///
/// 优先命中缓存;当前实现返回全部匹配文章,因此 total 用 posts.len() 计算。
#[server(GetPostsByTag, "/api")]
pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, ServerFnError> {
#[cfg(feature = "server")]
@ -143,6 +159,7 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
let client = get_conn().await.map_err(AppError::db_conn)?;
// 通过 JOIN 筛选含目标标签的已发布文章,并聚合该文章的所有标签。
let rows = client
.query(
"SELECT
@ -167,9 +184,8 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
posts.push(row_to_post_list(&client, row).await);
}
// NOTE: total = posts.len() is correct because get_posts_by_tag
// currently fetches ALL matching posts (no LIMIT/OFFSET).
// If pagination is added later, switch to a proper COUNT(*) query.
// 当前查询未分页,返回全部匹配文章,因此 total 等于结果长度。
// 若后续增加分页,应改为 COUNT(*) 查询。
let total = posts.len() as i64;
crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await;
Ok(PostListResponse { posts, total })

View File

@ -1,3 +1,8 @@
//! 文章模块:提供文章的 CRUD、列表、搜索、标签聚合与统计等 server function。
//!
//! 所有 Dioxus server function 均注册在 `/api` 路径下,供前端与服务端调用。
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作与缓存失效。
#![allow(clippy::unused_unit, deprecated)]
mod create;
@ -12,26 +17,40 @@ mod tags;
mod types;
mod update;
/// 创建新文章。
#[allow(unused_imports)]
pub use create::create_post;
/// 删除指定文章。
pub use delete::delete_post;
/// 获取管理员视角的全部文章分页列表。
#[allow(unused_imports)]
pub use list::list_posts;
/// 获取已发布文章分页列表。
pub use list::{get_posts_by_tag, list_published_posts};
/// 根据 id 获取文章详情。
#[allow(unused_imports)]
pub use read::{get_post_by_id, get_post_by_slug};
/// 重新渲染文章的 Markdown HTML 与目录。
pub use rebuild::rebuild_content_html;
/// 全文搜索已发布文章。
pub use search::search_posts;
/// 获取文章统计信息。
#[allow(unused_imports)]
pub use stats::get_post_stats;
/// 获取全部标签及其文章数量。
pub use tags::list_tags;
/// 文章 API 的请求与响应数据结构。
pub use types::*;
/// 更新指定文章。
#[allow(unused_imports)]
pub use update::update_post;
/// 将 Markdown 渲染为增强 HTML含目录
#[cfg(feature = "server")]
#[allow(unused_imports)]
pub use crate::api::markdown::render_markdown_enhanced;
/// slug 生成与唯一性校验工具函数。
#[cfg(feature = "server")]
#[allow(unused_imports)]
pub use crate::api::slug::{ensure_unique_slug, is_valid_slug, slugify};

View File

@ -1,3 +1,10 @@
//! 文章详情查询接口。
//!
//! 提供按 id管理员与按 slug公开两种方式获取文章
//! 其中按 slug 查询包含上下篇导航并启用缓存。
//! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
use dioxus::prelude::*;
#[cfg(feature = "server")]
@ -7,6 +14,9 @@ use super::types::SinglePostResponse;
use crate::api::error::AppError;
use crate::db::pool::get_conn;
/// 根据文章 id 获取详情。
///
/// 需要 admin 权限;不缓存,用于管理后台编辑等场景。
#[server(GetPostById, "/api")]
pub async fn get_post_by_id(post_id: i32) -> Result<SinglePostResponse, ServerFnError> {
let _user = get_current_admin_user().await?;
@ -45,6 +55,9 @@ pub async fn get_post_by_id(post_id: i32) -> Result<SinglePostResponse, ServerFn
}
}
/// 根据 slug 获取公开文章详情。
///
/// 优先命中缓存;未命中时查询数据库,并附带基于 published_at 的上一篇/下一篇导航。
#[server(GetPostBySlug, "/api")]
pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, ServerFnError> {
#[cfg(feature = "server")]
@ -55,6 +68,7 @@ pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, Server
let client = get_conn().await.map_err(AppError::db_conn)?;
// 使用 LATERAL JOIN 查询按 published_at 排序的相邻文章。
let row = client
.query_opt(
"SELECT

View File

@ -1,3 +1,10 @@
//! 批量重建文章 HTML 与目录。
//!
//! 用于数据迁移或修复:遍历符合条件的文章,将 Markdown 重新渲染为 HTML
//! 并更新 content_html 与 toc_html 字段。
//! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库更新。
use dioxus::prelude::*;
#[cfg(feature = "server")]
@ -7,9 +14,15 @@ use crate::api::error::AppError;
use crate::api::posts::RebuildResult;
use crate::db::pool::get_conn;
/// 单次重建批处理数量上限。
const REBUILD_BATCH_LIMIT: i64 = 500;
/// 返回给前端展示的最大错误条数。
const MAX_DISPLAY_ERRORS: usize = 5;
/// 批量重建文章 content_html 与 toc_html。
///
/// 当 `rebuild_all` 为 true 时重建所有未删除文章;否则仅重建 content_html 为空的文章。
/// 单批最多处理 500 条,渲染异常或写入失败会被捕获并汇总。
#[server(RebuildContentHtml, "/api")]
pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, ServerFnError> {
let _user = get_current_admin_user().await?;
@ -18,6 +31,7 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
{
let client = get_conn().await.map_err(AppError::db_conn)?;
// 根据参数构造 WHERE 条件,限制单次处理数量。
let query = if rebuild_all {
format!(
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT}"
@ -38,6 +52,7 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
let id: i32 = row.get(0);
let content_md: String = row.get(1);
// 捕获 Markdown 渲染 panic避免单条记录导致整批失败。
let rendered = match std::panic::catch_unwind(|| {
crate::api::markdown::render_markdown_enhanced(&content_md)
}) {
@ -74,6 +89,7 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
}
}
// 只要有文章被更新,就清空所有文章缓存。
if rebuilt > 0 || failed > 0 {
crate::cache::invalidate_all_post_caches();
}

View File

@ -1,3 +1,10 @@
//! 文章全文搜索接口。
//!
//! 基于 PostgreSQL 的 pg_trgm 扩展,通过 word_similarity 对 search_text 做模糊匹配,
//! 按相似度与发布时间降序返回最多 50 篇已发布文章。
//! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
use dioxus::prelude::*;
#[cfg(feature = "server")]
@ -7,6 +14,10 @@ use super::types::PostListResponse;
use crate::api::error::AppError;
use crate::db::pool::get_conn;
/// 搜索已发布文章。
///
/// 空查询直接返回空结果;非空查询使用 `word_similarity` 计算相关度,
/// 并限制返回 50 条记录。当前未缓存,每次均查询数据库。
#[server(SearchPosts, "/api")]
pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnError> {
#[cfg(feature = "server")]
@ -21,6 +32,7 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
});
}
// 使用 ILIKE 做前缀模糊匹配,并按 word_similarity 降序、发布时间降序排序。
let rows = client
.query(
"SELECT

View File

@ -1,3 +1,9 @@
//! 文章统计接口。
//!
//! 返回文章总数、草稿数与已发布数,供管理后台仪表盘使用,结果缓存。
//! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
use dioxus::prelude::*;
#[cfg(feature = "server")]
@ -8,6 +14,9 @@ use crate::api::error::AppError;
use crate::db::pool::get_conn;
use crate::models::post::PostStats;
/// 获取文章统计信息。
///
/// 需要 admin 权限;优先命中缓存,未命中时分别统计总数、草稿数与已发布数。
#[server(GetPostStats, "/api")]
pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
let _user = get_current_admin_user().await?;
@ -20,12 +29,14 @@ pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
let client = get_conn().await.map_err(AppError::db_conn)?;
// 统计未删除文章总数。
let total: i64 = client
.query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[])
.await
.map_err(AppError::query)?
.get(0);
// 统计草稿数量。
let drafts: i64 = client
.query_one(
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'draft'",
@ -35,6 +46,7 @@ pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
.map_err(AppError::query)?
.get(0);
// 统计已发布数量。
let published: i64 = client
.query_one(
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'published'",

View File

@ -1,3 +1,9 @@
//! 标签列表接口。
//!
//! 返回所有标签及其关联的已发布文章数量,用于标签云与侧边栏。
//! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
use dioxus::prelude::*;
use super::types::TagListResponse;
@ -6,6 +12,9 @@ use crate::api::error::AppError;
use crate::db::pool::get_conn;
use crate::models::post::Tag;
/// 获取全部标签列表。
///
/// 优先命中缓存;未命中时聚合每个标签关联的已发布文章数量,并按标签名升序排列。
#[server(ListTags, "/api")]
pub async fn list_tags() -> Result<TagListResponse, ServerFnError> {
#[cfg(feature = "server")]
@ -16,6 +25,7 @@ pub async fn list_tags() -> Result<TagListResponse, ServerFnError> {
let client = get_conn().await.map_err(AppError::db_conn)?;
// 聚合标签对应的已发布、未删除文章数量。
let rows = client
.query(
"SELECT t.id, t.name, COUNT(pt.post_id) as post_count

View File

@ -1,49 +1,77 @@
//! 文章 API 的请求与响应数据结构。
use crate::models::post::{Post, PostStats, Tag};
/// 创建/更新文章请求体(客户端使用)。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[allow(dead_code)]
pub struct CreatePostRequest {
/// 文章标题。
pub title: String,
/// 自定义 slug为空时由标题自动生成。
pub slug: Option<String>,
/// 文章摘要,为空时自动从正文提取。
pub summary: Option<String>,
/// Markdown 格式正文。
pub content_md: String,
/// 文章状态(如 draft / published
pub status: String,
/// 标签列表。
pub tags: Vec<String>,
/// 封面图 URL。
pub cover_image: Option<String>,
}
/// 创建/更新/删除文章的统一响应结构。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CreatePostResponse {
/// 操作是否成功。
pub success: bool,
/// 提示信息。
pub message: String,
/// 新文章 id失败时为 None。
pub post_id: Option<i32>,
/// 最终 slug失败时为 None。
pub slug: Option<String>,
}
/// 文章列表响应。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PostListResponse {
/// 文章列表。
pub posts: Vec<Post>,
/// 符合查询条件的总数。
pub total: i64,
}
/// 标签列表响应。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TagListResponse {
/// 标签列表。
pub tags: Vec<Tag>,
}
/// 文章统计响应。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PostStatsResponse {
/// 文章统计信息。
pub stats: PostStats,
}
/// 单篇文章详情响应。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SinglePostResponse {
/// 文章详情,不存在时为 None。
pub post: Option<Post>,
}
/// Markdown 重建结果响应。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RebuildResult {
/// 成功重建的文章数量。
pub rebuilt: u64,
/// 重建失败的文章数量。
pub failed: u64,
/// 失败信息摘要(最多 5 条)。
pub errors: Vec<String>,
}

View File

@ -1,3 +1,10 @@
//! 更新文章接口。
//!
//! 校验管理员权限与文章归属,重新生成唯一 slug、渲染 Markdown
//! 在事务中更新 posts 表并同步标签,最后失效相关缓存。
//! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。
#![allow(clippy::too_many_arguments)]
use dioxus::prelude::*;
@ -10,6 +17,10 @@ use crate::api::error::AppError;
use crate::db::pool::get_conn;
use crate::models::post::PostStatus;
/// 更新指定文章。
///
/// 校验文章存在且属于当前 admin处理 slug 变更、发布状态转换、标签同步,
/// 并失效文章详情、列表、标签与统计缓存。
#[server(UpdatePost, "/api")]
pub async fn update_post(
post_id: i32,
@ -27,12 +38,14 @@ pub async fn update_post(
{
let mut client = get_conn().await.map_err(AppError::db_conn)?;
// 查询旧 slug用于后续缓存失效。
let old_slug: Option<String> = client
.query_opt("SELECT slug FROM posts WHERE id = $1", &[&post_id])
.await
.map_err(AppError::query)?
.map(|r| r.get(0));
// 校验文章存在、未删除且归属当前用户。
let exists: bool = client
.query_opt(
"SELECT 1 FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL",
@ -51,6 +64,7 @@ pub async fn update_post(
});
}
// 确定基础 slug用户传入时校验格式否则由标题生成。
let base_slug = match slug {
Some(ref s) if !s.trim().is_empty() => {
let s = s.trim();
@ -67,8 +81,10 @@ pub async fn update_post(
_ => crate::api::slug::slugify(&title),
};
// 保证 slug 全局唯一,排除当前文章自身。
let final_slug =
crate::api::slug::ensure_unique_slug(&client, &base_slug, Some(post_id)).await?;
// 重新渲染 Markdown 与目录。
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
let content_html = rendered.html;
let toc_html = if rendered.toc_html.is_empty() {
@ -76,6 +92,7 @@ pub async fn update_post(
} else {
Some(rendered.toc_html)
};
// 未填写摘要时自动从正文提取。
let summary = summary
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::utils::text::auto_summary(&content_md));
@ -84,6 +101,7 @@ pub async fn update_post(
let tx = client.transaction().await.map_err(AppError::tx)?;
// 获取文章旧标签,用于后续失效标签缓存。
let old_tags: Vec<String> = {
let rows = tx
.query(
@ -95,6 +113,7 @@ pub async fn update_post(
rows.iter().map(|r| r.get(0)).collect()
};
// 获取旧状态与旧发布时间,用于决定是否需要更新 published_at。
let old_status_row = tx
.query_opt(
"SELECT status, published_at FROM posts WHERE id = $1",
@ -103,6 +122,8 @@ pub async fn update_post(
.await
.map_err(AppError::query)?;
// 发布时:若之前已发布则保留原时间,否则使用当前时间。
// 非发布时:保留原有 published_at若为草稿可能为 None
let published_at = if post_status == PostStatus::Published {
let was_published = old_status_row
.as_ref()
@ -123,6 +144,7 @@ pub async fn update_post(
old_status_row.and_then(|r| r.get(1))
};
// 更新文章主表。
tx.execute(
"UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, toc_html = $6, status = $7, published_at = $8, cover_image = $9, updated_at = NOW()
WHERE id = $10",
@ -145,6 +167,7 @@ pub async fn update_post(
let tags_cleaned = clean_tags(&tags);
let tags_for_invalidation = tags_cleaned.clone();
// 先清除旧标签关联,再重新同步新标签。
tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&post_id])
.await
.map_err(AppError::tx)?;
@ -153,11 +176,13 @@ pub async fn update_post(
tx.commit().await.map_err(AppError::tx)?;
// 失效文章列表、标签、当前 slug 与统计缓存。
crate::cache::invalidate_post_lists();
crate::cache::invalidate_all_tags();
crate::cache::invalidate_post_by_slug(&final_slug).await;
crate::cache::invalidate_post_stats();
// 合并旧标签与新标签,统一失效标签下的文章列表缓存。
let all_tags_to_invalidate: std::collections::HashSet<String> = old_tags
.into_iter()
.chain(tags_for_invalidation.into_iter())
@ -166,6 +191,7 @@ pub async fn update_post(
crate::cache::invalidate_posts_by_tag(tag_name).await;
}
// 若 slug 发生变更,额外失效旧 slug 缓存。
if let Some(ref old) = old_slug {
if old != &final_slug {
crate::cache::invalidate_post_by_slug(old).await;