docs(posts): 补充中文注释
This commit is contained in:
parent
9921f8eebf
commit
a785683fc6
@ -1,3 +1,10 @@
|
|||||||
|
//! 创建文章接口。
|
||||||
|
//!
|
||||||
|
//! 校验标题、内容与 slug,生成唯一 slug 并渲染 Markdown,
|
||||||
|
//! 在事务中写入 posts 表并同步标签关联,最后失效相关缓存。
|
||||||
|
//! Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。
|
||||||
|
|
||||||
#![allow(clippy::too_many_arguments)]
|
#![allow(clippy::too_many_arguments)]
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
@ -10,6 +17,10 @@ use crate::api::error::AppError;
|
|||||||
use crate::db::pool::get_conn;
|
use crate::db::pool::get_conn;
|
||||||
use crate::models::post::PostStatus;
|
use crate::models::post::PostStatus;
|
||||||
|
|
||||||
|
/// 创建一篇新文章。
|
||||||
|
///
|
||||||
|
/// 校验标题与内容非空、slug 格式合法;未提供 slug 时从标题自动生成。
|
||||||
|
/// 首次发布时填充 published_at,并同步标签与缓存失效。
|
||||||
#[server(CreatePost, "/api")]
|
#[server(CreatePost, "/api")]
|
||||||
pub async fn create_post(
|
pub async fn create_post(
|
||||||
title: String,
|
title: String,
|
||||||
@ -22,6 +33,7 @@ pub async fn create_post(
|
|||||||
) -> Result<CreatePostResponse, ServerFnError> {
|
) -> Result<CreatePostResponse, ServerFnError> {
|
||||||
let user = get_current_admin_user().await?;
|
let user = get_current_admin_user().await?;
|
||||||
|
|
||||||
|
// 标题不能为空。
|
||||||
if title.trim().is_empty() {
|
if title.trim().is_empty() {
|
||||||
return Ok(CreatePostResponse {
|
return Ok(CreatePostResponse {
|
||||||
success: false,
|
success: false,
|
||||||
@ -31,6 +43,7 @@ pub async fn create_post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 内容不能为空。
|
||||||
if content_md.trim().is_empty() {
|
if content_md.trim().is_empty() {
|
||||||
return Ok(CreatePostResponse {
|
return Ok(CreatePostResponse {
|
||||||
success: false,
|
success: false,
|
||||||
@ -40,6 +53,7 @@ pub async fn create_post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 确定基础 slug:用户传入时校验格式,否则由标题生成。
|
||||||
let base_slug = match slug {
|
let base_slug = match slug {
|
||||||
Some(ref s) if !s.trim().is_empty() => {
|
Some(ref s) if !s.trim().is_empty() => {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
@ -60,7 +74,9 @@ pub async fn create_post(
|
|||||||
{
|
{
|
||||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
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?;
|
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 rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
|
||||||
let content_html = rendered.html;
|
let content_html = rendered.html;
|
||||||
let toc_html = if rendered.toc_html.is_empty() {
|
let toc_html = if rendered.toc_html.is_empty() {
|
||||||
@ -68,12 +84,14 @@ pub async fn create_post(
|
|||||||
} else {
|
} else {
|
||||||
Some(rendered.toc_html)
|
Some(rendered.toc_html)
|
||||||
};
|
};
|
||||||
|
// 未填写摘要时自动从正文提取。
|
||||||
let summary = summary
|
let summary = summary
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.unwrap_or_else(|| crate::utils::text::auto_summary(&content_md));
|
.unwrap_or_else(|| crate::utils::text::auto_summary(&content_md));
|
||||||
let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft);
|
let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft);
|
||||||
let cover_image = cover_image.filter(|s| !s.trim().is_empty());
|
let cover_image = cover_image.filter(|s| !s.trim().is_empty());
|
||||||
|
|
||||||
|
// 发布状态的文章设置当前发布时间;草稿则为 None。
|
||||||
let published_at = if post_status == PostStatus::Published {
|
let published_at = if post_status == PostStatus::Published {
|
||||||
Some(chrono::Utc::now())
|
Some(chrono::Utc::now())
|
||||||
} else {
|
} else {
|
||||||
@ -82,6 +100,7 @@ pub async fn create_post(
|
|||||||
|
|
||||||
let tx = client.transaction().await.map_err(AppError::tx)?;
|
let tx = client.transaction().await.map_err(AppError::tx)?;
|
||||||
|
|
||||||
|
// 插入文章记录。
|
||||||
let row = tx
|
let row = tx
|
||||||
.query_one(
|
.query_one(
|
||||||
"INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image)
|
"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);
|
let post_id: i32 = row.get(0);
|
||||||
|
|
||||||
|
// 清洗标签并在事务中同步 post_tags 关联。
|
||||||
let tags_cleaned = clean_tags(&tags);
|
let tags_cleaned = clean_tags(&tags);
|
||||||
sync_tags(&tx, post_id, &tags_cleaned).await?;
|
sync_tags(&tx, post_id, &tags_cleaned).await?;
|
||||||
|
|
||||||
tx.commit().await.map_err(AppError::tx)?;
|
tx.commit().await.map_err(AppError::tx)?;
|
||||||
|
|
||||||
|
// 写入成功后失效文章列表、标签与统计缓存。
|
||||||
crate::cache::invalidate_post_lists();
|
crate::cache::invalidate_post_lists();
|
||||||
crate::cache::invalidate_all_tags();
|
crate::cache::invalidate_all_tags();
|
||||||
crate::cache::invalidate_post_stats();
|
crate::cache::invalidate_post_stats();
|
||||||
|
|
||||||
|
// 失效该文章涉及的所有标签缓存。
|
||||||
for tag_name in &tags_cleaned {
|
for tag_name in &tags_cleaned {
|
||||||
crate::cache::invalidate_posts_by_tag(tag_name).await;
|
crate::cache::invalidate_posts_by_tag(tag_name).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
//! 删除文章接口。
|
||||||
|
//!
|
||||||
|
//! 采用软删除方式,将 posts.deleted_at 设置为当前时间,
|
||||||
|
//! 同时清空所有文章相关缓存。
|
||||||
|
//! Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中执行删除与缓存失效。
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -7,6 +14,10 @@ use super::types::CreatePostResponse;
|
|||||||
use crate::api::error::AppError;
|
use crate::api::error::AppError;
|
||||||
use crate::db::pool::get_conn;
|
use crate::db::pool::get_conn;
|
||||||
|
|
||||||
|
/// 删除指定文章。
|
||||||
|
///
|
||||||
|
/// 仅 admin 可调用;通过设置 deleted_at 实现软删除,
|
||||||
|
/// 成功后清空全部文章缓存。
|
||||||
#[server(DeletePost, "/api")]
|
#[server(DeletePost, "/api")]
|
||||||
pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
|
pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
|
||||||
let _user = get_current_admin_user().await?;
|
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 client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 软删除:仅影响未被删除的文章。
|
||||||
let result = client
|
let result = client
|
||||||
.execute(
|
.execute(
|
||||||
"UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
|
"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();
|
crate::cache::invalidate_all_post_caches();
|
||||||
|
|
||||||
Ok(CreatePostResponse {
|
Ok(CreatePostResponse {
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
//! 文章模块内部辅助函数。
|
||||||
|
//!
|
||||||
|
//! 提供数据库行到 `Post` 模型的转换、标签同步与标签清洗等工具函数。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中可用。
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use crate::api::error::AppError;
|
use crate::api::error::AppError;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -5,9 +10,14 @@ use crate::models::post::{Post, PostStatus};
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use crate::utils::text::count_words;
|
use crate::utils::text::count_words;
|
||||||
|
|
||||||
|
/// 复用认证模块的当前 admin 用户获取逻辑。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub(super) use crate::api::auth::get_current_admin_user;
|
pub(super) use crate::api::auth::get_current_admin_user;
|
||||||
|
|
||||||
|
/// 将数据库行转换为文章列表项。
|
||||||
|
///
|
||||||
|
/// 用于列表接口,包含标签聚合、字数与阅读时长估算,
|
||||||
|
/// 不包含上下篇导航与目录。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub(super) async fn row_to_post_list(
|
pub(super) async fn row_to_post_list(
|
||||||
_client: &tokio_postgres::Client,
|
_client: &tokio_postgres::Client,
|
||||||
@ -17,6 +27,7 @@ pub(super) async fn row_to_post_list(
|
|||||||
let role_str: String = row.get("status");
|
let role_str: String = row.get("status");
|
||||||
let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft);
|
let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft);
|
||||||
|
|
||||||
|
// 聚合标签并过滤空字符串。
|
||||||
let tags: Vec<String> = row
|
let tags: Vec<String> = row
|
||||||
.try_get::<_, Vec<String>>("tags")
|
.try_get::<_, Vec<String>>("tags")
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@ -49,6 +60,10 @@ pub(super) async fn row_to_post_list(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将数据库行转换为完整文章详情。
|
||||||
|
///
|
||||||
|
/// 相比列表项额外包含上一篇/下一篇导航,
|
||||||
|
/// 并在 content_html 为空时重新渲染 Markdown 以兼容旧数据。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub(super) async fn row_to_post_full(
|
pub(super) async fn row_to_post_full(
|
||||||
_client: &tokio_postgres::Client,
|
_client: &tokio_postgres::Client,
|
||||||
@ -58,6 +73,7 @@ pub(super) async fn row_to_post_full(
|
|||||||
let role_str: String = row.get("status");
|
let role_str: String = row.get("status");
|
||||||
let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft);
|
let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft);
|
||||||
|
|
||||||
|
// 聚合标签并过滤空字符串。
|
||||||
let tags: Vec<String> = row
|
let tags: Vec<String> = row
|
||||||
.try_get::<_, Vec<String>>("tags")
|
.try_get::<_, Vec<String>>("tags")
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@ -65,6 +81,7 @@ pub(super) async fn row_to_post_full(
|
|||||||
.filter(|t| !t.is_empty())
|
.filter(|t| !t.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// 解析上一篇文章导航。
|
||||||
let prev_post = if let Ok(prev_title) = row.try_get::<_, String>("prev_title") {
|
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") {
|
if let Ok(prev_slug) = row.try_get::<_, String>("prev_slug") {
|
||||||
Some(crate::models::post::PostNav {
|
Some(crate::models::post::PostNav {
|
||||||
@ -78,6 +95,7 @@ pub(super) async fn row_to_post_full(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 解析下一篇文章导航。
|
||||||
let next_post = if let Ok(next_title) = row.try_get::<_, String>("next_title") {
|
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") {
|
if let Ok(next_slug) = row.try_get::<_, String>("next_slug") {
|
||||||
Some(crate::models::post::PostNav {
|
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 content_html: Option<String> = row.get("content_html");
|
||||||
let toc_html_row: Option<String> = row.get("toc_html");
|
let toc_html_row: Option<String> = row.get("toc_html");
|
||||||
|
|
||||||
|
// 若数据库中未渲染 HTML(旧数据兼容),则现场渲染 Markdown。
|
||||||
let (content_html, toc_html) = if let Some(html) = content_html {
|
let (content_html, toc_html) = if let Some(html) = content_html {
|
||||||
(html, toc_html_row)
|
(html, toc_html_row)
|
||||||
} else {
|
} else {
|
||||||
@ -134,6 +153,10 @@ pub(super) async fn row_to_post_full(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 在事务中同步文章的标签关联。
|
||||||
|
///
|
||||||
|
/// 对传入的每个标签:若不存在则插入 tags 表,否则查询已有 id,
|
||||||
|
/// 然后在 post_tags 表中建立关联。不会删除旧关联,调用方需先清理。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub(super) async fn sync_tags(
|
pub(super) async fn sync_tags(
|
||||||
tx: &deadpool_postgres::Transaction<'_>,
|
tx: &deadpool_postgres::Transaction<'_>,
|
||||||
@ -142,6 +165,7 @@ pub(super) async fn sync_tags(
|
|||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
for tag_name in tags {
|
for tag_name in tags {
|
||||||
let tag_id: i32 = {
|
let tag_id: i32 = {
|
||||||
|
// 先尝试插入,若已存在则返回空。
|
||||||
let row = tx
|
let row = tx
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id",
|
"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 {
|
match row {
|
||||||
Some(r) => r.get(0),
|
Some(r) => r.get(0),
|
||||||
None => {
|
None => {
|
||||||
|
// 插入冲突时回查标签 id。
|
||||||
let row = tx
|
let row = tx
|
||||||
.query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()])
|
.query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()])
|
||||||
.await
|
.await
|
||||||
@ -174,6 +199,9 @@ pub(super) async fn sync_tags(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清洗标签列表:去头尾空白并过滤空字符串。
|
||||||
|
///
|
||||||
|
/// 注意:该函数保留重复标签,由数据库唯一索引或调用方决定去重。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub(super) fn clean_tags(tags: &[String]) -> Vec<String> {
|
pub(super) fn clean_tags(tags: &[String]) -> Vec<String> {
|
||||||
tags.iter()
|
tags.iter()
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
//! 文章列表查询接口。
|
||||||
|
//!
|
||||||
|
//! 提供已发布文章分页、管理员全量列表、以及按标签筛选三种查询能力,
|
||||||
|
//! 均通过缓存层减少重复数据库访问。
|
||||||
|
//! Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -7,6 +14,9 @@ use super::types::PostListResponse;
|
|||||||
use crate::api::error::AppError;
|
use crate::api::error::AppError;
|
||||||
use crate::db::pool::get_conn;
|
use crate::db::pool::get_conn;
|
||||||
|
|
||||||
|
/// 获取已发布文章分页列表。
|
||||||
|
///
|
||||||
|
/// 优先命中缓存;未命中时查询总数与分页记录,并按 published_at 降序排列。
|
||||||
#[server(ListPublishedPosts, "/api")]
|
#[server(ListPublishedPosts, "/api")]
|
||||||
pub async fn list_published_posts(
|
pub async fn list_published_posts(
|
||||||
page: i32,
|
page: i32,
|
||||||
@ -24,7 +34,7 @@ pub async fn list_published_posts(
|
|||||||
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
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 {
|
let total = if let Some(cached_total) = crate::cache::get_total_published_posts().await {
|
||||||
cached_total
|
cached_total
|
||||||
} else {
|
} else {
|
||||||
@ -78,6 +88,9 @@ pub async fn list_published_posts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取管理员视角的全部文章列表(含草稿与已发布)。
|
||||||
|
///
|
||||||
|
/// 需要 admin 权限;结果按创建时间降序,不走缓存。
|
||||||
#[server(ListPosts, "/api")]
|
#[server(ListPosts, "/api")]
|
||||||
pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, ServerFnError> {
|
pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, ServerFnError> {
|
||||||
let _user = get_current_admin_user().await?;
|
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")]
|
#[server(GetPostsByTag, "/api")]
|
||||||
pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, ServerFnError> {
|
pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, ServerFnError> {
|
||||||
#[cfg(feature = "server")]
|
#[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)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 通过 JOIN 筛选含目标标签的已发布文章,并聚合该文章的所有标签。
|
||||||
let rows = client
|
let rows = client
|
||||||
.query(
|
.query(
|
||||||
"SELECT
|
"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);
|
posts.push(row_to_post_list(&client, row).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: total = posts.len() is correct because get_posts_by_tag
|
// 当前查询未分页,返回全部匹配文章,因此 total 等于结果长度。
|
||||||
// currently fetches ALL matching posts (no LIMIT/OFFSET).
|
// 若后续增加分页,应改为 COUNT(*) 查询。
|
||||||
// If pagination is added later, switch to a proper COUNT(*) query.
|
|
||||||
let total = posts.len() as i64;
|
let total = posts.len() as i64;
|
||||||
crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await;
|
crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await;
|
||||||
Ok(PostListResponse { posts, total })
|
Ok(PostListResponse { posts, total })
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
//! 文章模块:提供文章的 CRUD、列表、搜索、标签聚合与统计等 server function。
|
||||||
|
//!
|
||||||
|
//! 所有 Dioxus server function 均注册在 `/api` 路径下,供前端与服务端调用。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作与缓存失效。
|
||||||
|
|
||||||
#![allow(clippy::unused_unit, deprecated)]
|
#![allow(clippy::unused_unit, deprecated)]
|
||||||
|
|
||||||
mod create;
|
mod create;
|
||||||
@ -12,26 +17,40 @@ mod tags;
|
|||||||
mod types;
|
mod types;
|
||||||
mod update;
|
mod update;
|
||||||
|
|
||||||
|
/// 创建新文章。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use create::create_post;
|
pub use create::create_post;
|
||||||
|
/// 删除指定文章。
|
||||||
pub use delete::delete_post;
|
pub use delete::delete_post;
|
||||||
|
/// 获取管理员视角的全部文章分页列表。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use list::list_posts;
|
pub use list::list_posts;
|
||||||
|
/// 获取已发布文章分页列表。
|
||||||
pub use list::{get_posts_by_tag, list_published_posts};
|
pub use list::{get_posts_by_tag, list_published_posts};
|
||||||
|
/// 根据 id 获取文章详情。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use read::{get_post_by_id, get_post_by_slug};
|
pub use read::{get_post_by_id, get_post_by_slug};
|
||||||
|
/// 重新渲染文章的 Markdown HTML 与目录。
|
||||||
pub use rebuild::rebuild_content_html;
|
pub use rebuild::rebuild_content_html;
|
||||||
|
/// 全文搜索已发布文章。
|
||||||
pub use search::search_posts;
|
pub use search::search_posts;
|
||||||
|
/// 获取文章统计信息。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use stats::get_post_stats;
|
pub use stats::get_post_stats;
|
||||||
|
/// 获取全部标签及其文章数量。
|
||||||
pub use tags::list_tags;
|
pub use tags::list_tags;
|
||||||
|
/// 文章 API 的请求与响应数据结构。
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
/// 更新指定文章。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use update::update_post;
|
pub use update::update_post;
|
||||||
|
|
||||||
|
/// 将 Markdown 渲染为增强 HTML(含目录)。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use crate::api::markdown::render_markdown_enhanced;
|
pub use crate::api::markdown::render_markdown_enhanced;
|
||||||
|
|
||||||
|
/// slug 生成与唯一性校验工具函数。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use crate::api::slug::{ensure_unique_slug, is_valid_slug, slugify};
|
pub use crate::api::slug::{ensure_unique_slug, is_valid_slug, slugify};
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
//! 文章详情查询接口。
|
||||||
|
//!
|
||||||
|
//! 提供按 id(管理员)与按 slug(公开)两种方式获取文章,
|
||||||
|
//! 其中按 slug 查询包含上下篇导航并启用缓存。
|
||||||
|
//! Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -7,6 +14,9 @@ use super::types::SinglePostResponse;
|
|||||||
use crate::api::error::AppError;
|
use crate::api::error::AppError;
|
||||||
use crate::db::pool::get_conn;
|
use crate::db::pool::get_conn;
|
||||||
|
|
||||||
|
/// 根据文章 id 获取详情。
|
||||||
|
///
|
||||||
|
/// 需要 admin 权限;不缓存,用于管理后台编辑等场景。
|
||||||
#[server(GetPostById, "/api")]
|
#[server(GetPostById, "/api")]
|
||||||
pub async fn get_post_by_id(post_id: i32) -> Result<SinglePostResponse, ServerFnError> {
|
pub async fn get_post_by_id(post_id: i32) -> Result<SinglePostResponse, ServerFnError> {
|
||||||
let _user = get_current_admin_user().await?;
|
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")]
|
#[server(GetPostBySlug, "/api")]
|
||||||
pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, ServerFnError> {
|
pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, ServerFnError> {
|
||||||
#[cfg(feature = "server")]
|
#[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)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 使用 LATERAL JOIN 查询按 published_at 排序的相邻文章。
|
||||||
let row = client
|
let row = client
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT
|
"SELECT
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
//! 批量重建文章 HTML 与目录。
|
||||||
|
//!
|
||||||
|
//! 用于数据迁移或修复:遍历符合条件的文章,将 Markdown 重新渲染为 HTML,
|
||||||
|
//! 并更新 content_html 与 toc_html 字段。
|
||||||
|
//! Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库更新。
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -7,9 +14,15 @@ use crate::api::error::AppError;
|
|||||||
use crate::api::posts::RebuildResult;
|
use crate::api::posts::RebuildResult;
|
||||||
use crate::db::pool::get_conn;
|
use crate::db::pool::get_conn;
|
||||||
|
|
||||||
|
/// 单次重建批处理数量上限。
|
||||||
const REBUILD_BATCH_LIMIT: i64 = 500;
|
const REBUILD_BATCH_LIMIT: i64 = 500;
|
||||||
|
/// 返回给前端展示的最大错误条数。
|
||||||
const MAX_DISPLAY_ERRORS: usize = 5;
|
const MAX_DISPLAY_ERRORS: usize = 5;
|
||||||
|
|
||||||
|
/// 批量重建文章 content_html 与 toc_html。
|
||||||
|
///
|
||||||
|
/// 当 `rebuild_all` 为 true 时重建所有未删除文章;否则仅重建 content_html 为空的文章。
|
||||||
|
/// 单批最多处理 500 条,渲染异常或写入失败会被捕获并汇总。
|
||||||
#[server(RebuildContentHtml, "/api")]
|
#[server(RebuildContentHtml, "/api")]
|
||||||
pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, ServerFnError> {
|
pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, ServerFnError> {
|
||||||
let _user = get_current_admin_user().await?;
|
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)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 根据参数构造 WHERE 条件,限制单次处理数量。
|
||||||
let query = if rebuild_all {
|
let query = if rebuild_all {
|
||||||
format!(
|
format!(
|
||||||
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT}"
|
"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 id: i32 = row.get(0);
|
||||||
let content_md: String = row.get(1);
|
let content_md: String = row.get(1);
|
||||||
|
|
||||||
|
// 捕获 Markdown 渲染 panic,避免单条记录导致整批失败。
|
||||||
let rendered = match std::panic::catch_unwind(|| {
|
let rendered = match std::panic::catch_unwind(|| {
|
||||||
crate::api::markdown::render_markdown_enhanced(&content_md)
|
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 {
|
if rebuilt > 0 || failed > 0 {
|
||||||
crate::cache::invalidate_all_post_caches();
|
crate::cache::invalidate_all_post_caches();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
//! 文章全文搜索接口。
|
||||||
|
//!
|
||||||
|
//! 基于 PostgreSQL 的 pg_trgm 扩展,通过 word_similarity 对 search_text 做模糊匹配,
|
||||||
|
//! 按相似度与发布时间降序返回最多 50 篇已发布文章。
|
||||||
|
//! Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -7,6 +14,10 @@ use super::types::PostListResponse;
|
|||||||
use crate::api::error::AppError;
|
use crate::api::error::AppError;
|
||||||
use crate::db::pool::get_conn;
|
use crate::db::pool::get_conn;
|
||||||
|
|
||||||
|
/// 搜索已发布文章。
|
||||||
|
///
|
||||||
|
/// 空查询直接返回空结果;非空查询使用 `word_similarity` 计算相关度,
|
||||||
|
/// 并限制返回 50 条记录。当前未缓存,每次均查询数据库。
|
||||||
#[server(SearchPosts, "/api")]
|
#[server(SearchPosts, "/api")]
|
||||||
pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnError> {
|
pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnError> {
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -21,6 +32,7 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用 ILIKE 做前缀模糊匹配,并按 word_similarity 降序、发布时间降序排序。
|
||||||
let rows = client
|
let rows = client
|
||||||
.query(
|
.query(
|
||||||
"SELECT
|
"SELECT
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
//! 文章统计接口。
|
||||||
|
//!
|
||||||
|
//! 返回文章总数、草稿数与已发布数,供管理后台仪表盘使用,结果缓存。
|
||||||
|
//! Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -8,6 +14,9 @@ use crate::api::error::AppError;
|
|||||||
use crate::db::pool::get_conn;
|
use crate::db::pool::get_conn;
|
||||||
use crate::models::post::PostStats;
|
use crate::models::post::PostStats;
|
||||||
|
|
||||||
|
/// 获取文章统计信息。
|
||||||
|
///
|
||||||
|
/// 需要 admin 权限;优先命中缓存,未命中时分别统计总数、草稿数与已发布数。
|
||||||
#[server(GetPostStats, "/api")]
|
#[server(GetPostStats, "/api")]
|
||||||
pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
|
pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
|
||||||
let _user = get_current_admin_user().await?;
|
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 client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 统计未删除文章总数。
|
||||||
let total: i64 = client
|
let total: i64 = client
|
||||||
.query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[])
|
.query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[])
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::query)?
|
.map_err(AppError::query)?
|
||||||
.get(0);
|
.get(0);
|
||||||
|
|
||||||
|
// 统计草稿数量。
|
||||||
let drafts: i64 = client
|
let drafts: i64 = client
|
||||||
.query_one(
|
.query_one(
|
||||||
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'draft'",
|
"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)?
|
.map_err(AppError::query)?
|
||||||
.get(0);
|
.get(0);
|
||||||
|
|
||||||
|
// 统计已发布数量。
|
||||||
let published: i64 = client
|
let published: i64 = client
|
||||||
.query_one(
|
.query_one(
|
||||||
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'published'",
|
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'published'",
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
//! 标签列表接口。
|
||||||
|
//!
|
||||||
|
//! 返回所有标签及其关联的已发布文章数量,用于标签云与侧边栏。
|
||||||
|
//! Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use super::types::TagListResponse;
|
use super::types::TagListResponse;
|
||||||
@ -6,6 +12,9 @@ use crate::api::error::AppError;
|
|||||||
use crate::db::pool::get_conn;
|
use crate::db::pool::get_conn;
|
||||||
use crate::models::post::Tag;
|
use crate::models::post::Tag;
|
||||||
|
|
||||||
|
/// 获取全部标签列表。
|
||||||
|
///
|
||||||
|
/// 优先命中缓存;未命中时聚合每个标签关联的已发布文章数量,并按标签名升序排列。
|
||||||
#[server(ListTags, "/api")]
|
#[server(ListTags, "/api")]
|
||||||
pub async fn list_tags() -> Result<TagListResponse, ServerFnError> {
|
pub async fn list_tags() -> Result<TagListResponse, ServerFnError> {
|
||||||
#[cfg(feature = "server")]
|
#[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 client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 聚合标签对应的已发布、未删除文章数量。
|
||||||
let rows = client
|
let rows = client
|
||||||
.query(
|
.query(
|
||||||
"SELECT t.id, t.name, COUNT(pt.post_id) as post_count
|
"SELECT t.id, t.name, COUNT(pt.post_id) as post_count
|
||||||
|
|||||||
@ -1,49 +1,77 @@
|
|||||||
|
//! 文章 API 的请求与响应数据结构。
|
||||||
|
|
||||||
use crate::models::post::{Post, PostStats, Tag};
|
use crate::models::post::{Post, PostStats, Tag};
|
||||||
|
|
||||||
|
/// 创建/更新文章请求体(客户端使用)。
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct CreatePostRequest {
|
pub struct CreatePostRequest {
|
||||||
|
/// 文章标题。
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
/// 自定义 slug,为空时由标题自动生成。
|
||||||
pub slug: Option<String>,
|
pub slug: Option<String>,
|
||||||
|
/// 文章摘要,为空时自动从正文提取。
|
||||||
pub summary: Option<String>,
|
pub summary: Option<String>,
|
||||||
|
/// Markdown 格式正文。
|
||||||
pub content_md: String,
|
pub content_md: String,
|
||||||
|
/// 文章状态(如 draft / published)。
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
/// 标签列表。
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
|
/// 封面图 URL。
|
||||||
pub cover_image: Option<String>,
|
pub cover_image: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建/更新/删除文章的统一响应结构。
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct CreatePostResponse {
|
pub struct CreatePostResponse {
|
||||||
|
/// 操作是否成功。
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
|
/// 提示信息。
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
/// 新文章 id,失败时为 None。
|
||||||
pub post_id: Option<i32>,
|
pub post_id: Option<i32>,
|
||||||
|
/// 最终 slug,失败时为 None。
|
||||||
pub slug: Option<String>,
|
pub slug: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 文章列表响应。
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct PostListResponse {
|
pub struct PostListResponse {
|
||||||
|
/// 文章列表。
|
||||||
pub posts: Vec<Post>,
|
pub posts: Vec<Post>,
|
||||||
|
/// 符合查询条件的总数。
|
||||||
pub total: i64,
|
pub total: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 标签列表响应。
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct TagListResponse {
|
pub struct TagListResponse {
|
||||||
|
/// 标签列表。
|
||||||
pub tags: Vec<Tag>,
|
pub tags: Vec<Tag>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 文章统计响应。
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct PostStatsResponse {
|
pub struct PostStatsResponse {
|
||||||
|
/// 文章统计信息。
|
||||||
pub stats: PostStats,
|
pub stats: PostStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 单篇文章详情响应。
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct SinglePostResponse {
|
pub struct SinglePostResponse {
|
||||||
|
/// 文章详情,不存在时为 None。
|
||||||
pub post: Option<Post>,
|
pub post: Option<Post>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Markdown 重建结果响应。
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct RebuildResult {
|
pub struct RebuildResult {
|
||||||
|
/// 成功重建的文章数量。
|
||||||
pub rebuilt: u64,
|
pub rebuilt: u64,
|
||||||
|
/// 重建失败的文章数量。
|
||||||
pub failed: u64,
|
pub failed: u64,
|
||||||
|
/// 失败信息摘要(最多 5 条)。
|
||||||
pub errors: Vec<String>,
|
pub errors: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
//! 更新文章接口。
|
||||||
|
//!
|
||||||
|
//! 校验管理员权限与文章归属,重新生成唯一 slug、渲染 Markdown,
|
||||||
|
//! 在事务中更新 posts 表并同步标签,最后失效相关缓存。
|
||||||
|
//! Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
//! 仅在 `feature = "server"` 启用的服务端构建中写入数据库。
|
||||||
|
|
||||||
#![allow(clippy::too_many_arguments)]
|
#![allow(clippy::too_many_arguments)]
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
@ -10,6 +17,10 @@ use crate::api::error::AppError;
|
|||||||
use crate::db::pool::get_conn;
|
use crate::db::pool::get_conn;
|
||||||
use crate::models::post::PostStatus;
|
use crate::models::post::PostStatus;
|
||||||
|
|
||||||
|
/// 更新指定文章。
|
||||||
|
///
|
||||||
|
/// 校验文章存在且属于当前 admin;处理 slug 变更、发布状态转换、标签同步,
|
||||||
|
/// 并失效文章详情、列表、标签与统计缓存。
|
||||||
#[server(UpdatePost, "/api")]
|
#[server(UpdatePost, "/api")]
|
||||||
pub async fn update_post(
|
pub async fn update_post(
|
||||||
post_id: i32,
|
post_id: i32,
|
||||||
@ -27,12 +38,14 @@ pub async fn update_post(
|
|||||||
{
|
{
|
||||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 查询旧 slug,用于后续缓存失效。
|
||||||
let old_slug: Option<String> = client
|
let old_slug: Option<String> = client
|
||||||
.query_opt("SELECT slug FROM posts WHERE id = $1", &[&post_id])
|
.query_opt("SELECT slug FROM posts WHERE id = $1", &[&post_id])
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::query)?
|
.map_err(AppError::query)?
|
||||||
.map(|r| r.get(0));
|
.map(|r| r.get(0));
|
||||||
|
|
||||||
|
// 校验文章存在、未删除且归属当前用户。
|
||||||
let exists: bool = client
|
let exists: bool = client
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT 1 FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL",
|
"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 {
|
let base_slug = match slug {
|
||||||
Some(ref s) if !s.trim().is_empty() => {
|
Some(ref s) if !s.trim().is_empty() => {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
@ -67,8 +81,10 @@ pub async fn update_post(
|
|||||||
_ => crate::api::slug::slugify(&title),
|
_ => crate::api::slug::slugify(&title),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 保证 slug 全局唯一,排除当前文章自身。
|
||||||
let final_slug =
|
let final_slug =
|
||||||
crate::api::slug::ensure_unique_slug(&client, &base_slug, Some(post_id)).await?;
|
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 rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
|
||||||
let content_html = rendered.html;
|
let content_html = rendered.html;
|
||||||
let toc_html = if rendered.toc_html.is_empty() {
|
let toc_html = if rendered.toc_html.is_empty() {
|
||||||
@ -76,6 +92,7 @@ pub async fn update_post(
|
|||||||
} else {
|
} else {
|
||||||
Some(rendered.toc_html)
|
Some(rendered.toc_html)
|
||||||
};
|
};
|
||||||
|
// 未填写摘要时自动从正文提取。
|
||||||
let summary = summary
|
let summary = summary
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.unwrap_or_else(|| crate::utils::text::auto_summary(&content_md));
|
.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 tx = client.transaction().await.map_err(AppError::tx)?;
|
||||||
|
|
||||||
|
// 获取文章旧标签,用于后续失效标签缓存。
|
||||||
let old_tags: Vec<String> = {
|
let old_tags: Vec<String> = {
|
||||||
let rows = tx
|
let rows = tx
|
||||||
.query(
|
.query(
|
||||||
@ -95,6 +113,7 @@ pub async fn update_post(
|
|||||||
rows.iter().map(|r| r.get(0)).collect()
|
rows.iter().map(|r| r.get(0)).collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取旧状态与旧发布时间,用于决定是否需要更新 published_at。
|
||||||
let old_status_row = tx
|
let old_status_row = tx
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT status, published_at FROM posts WHERE id = $1",
|
"SELECT status, published_at FROM posts WHERE id = $1",
|
||||||
@ -103,6 +122,8 @@ pub async fn update_post(
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::query)?;
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
// 发布时:若之前已发布则保留原时间,否则使用当前时间。
|
||||||
|
// 非发布时:保留原有 published_at(若为草稿可能为 None)。
|
||||||
let published_at = if post_status == PostStatus::Published {
|
let published_at = if post_status == PostStatus::Published {
|
||||||
let was_published = old_status_row
|
let was_published = old_status_row
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@ -123,6 +144,7 @@ pub async fn update_post(
|
|||||||
old_status_row.and_then(|r| r.get(1))
|
old_status_row.and_then(|r| r.get(1))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 更新文章主表。
|
||||||
tx.execute(
|
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()
|
"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",
|
WHERE id = $10",
|
||||||
@ -145,6 +167,7 @@ pub async fn update_post(
|
|||||||
let tags_cleaned = clean_tags(&tags);
|
let tags_cleaned = clean_tags(&tags);
|
||||||
let tags_for_invalidation = tags_cleaned.clone();
|
let tags_for_invalidation = tags_cleaned.clone();
|
||||||
|
|
||||||
|
// 先清除旧标签关联,再重新同步新标签。
|
||||||
tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&post_id])
|
tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&post_id])
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::tx)?;
|
.map_err(AppError::tx)?;
|
||||||
@ -153,11 +176,13 @@ pub async fn update_post(
|
|||||||
|
|
||||||
tx.commit().await.map_err(AppError::tx)?;
|
tx.commit().await.map_err(AppError::tx)?;
|
||||||
|
|
||||||
|
// 失效文章列表、标签、当前 slug 与统计缓存。
|
||||||
crate::cache::invalidate_post_lists();
|
crate::cache::invalidate_post_lists();
|
||||||
crate::cache::invalidate_all_tags();
|
crate::cache::invalidate_all_tags();
|
||||||
crate::cache::invalidate_post_by_slug(&final_slug).await;
|
crate::cache::invalidate_post_by_slug(&final_slug).await;
|
||||||
crate::cache::invalidate_post_stats();
|
crate::cache::invalidate_post_stats();
|
||||||
|
|
||||||
|
// 合并旧标签与新标签,统一失效标签下的文章列表缓存。
|
||||||
let all_tags_to_invalidate: std::collections::HashSet<String> = old_tags
|
let all_tags_to_invalidate: std::collections::HashSet<String> = old_tags
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(tags_for_invalidation.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;
|
crate::cache::invalidate_posts_by_tag(tag_name).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 若 slug 发生变更,额外失效旧 slug 缓存。
|
||||||
if let Some(ref old) = old_slug {
|
if let Some(ref old) = old_slug {
|
||||||
if old != &final_slug {
|
if old != &final_slug {
|
||||||
crate::cache::invalidate_post_by_slug(old).await;
|
crate::cache::invalidate_post_by_slug(old).await;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user