refactor(posts): use precise cache invalidation in trash operations
This commit is contained in:
parent
82b070e7ac
commit
bb34e2c36e
@ -1,6 +1,7 @@
|
|||||||
//! 回收站操作接口:恢复、彻底删除、批量操作与一键清空。
|
//! 回收站操作接口:恢复、彻底删除、批量操作与一键清空。
|
||||||
//!
|
//!
|
||||||
//! 所有接口需要 admin 权限,操作后清空全部文章相关缓存。
|
//! 所有接口需要 admin 权限,操作后按影响范围精准失效缓存;
|
||||||
|
//! 仅在影响集很大(如批量清空)时才回退到全量缓存失效。
|
||||||
//! Dioxus server function,注册在 `/api` 路径下。
|
//! Dioxus server function,注册在 `/api` 路径下。
|
||||||
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作。
|
//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作。
|
||||||
|
|
||||||
@ -16,6 +17,11 @@ use crate::api::slug::ensure_unique_slug;
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use crate::db::pool::get_conn;
|
use crate::db::pool::get_conn;
|
||||||
|
|
||||||
|
/// 批量/清空操作使用精准失效的最大记录数阈值。
|
||||||
|
/// 超过该阈值时回退到 `invalidate_all_post_caches()`,避免大量串行缓存操作。
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
const PRECISE_INVALIDATION_LIMIT: usize = 50;
|
||||||
|
|
||||||
/// 恢复一篇已删除的文章(将 deleted_at 置空)。
|
/// 恢复一篇已删除的文章(将 deleted_at 置空)。
|
||||||
///
|
///
|
||||||
/// 若该文章原始 slug 已被其他未删除文章占用,自动追加数字后缀。
|
/// 若该文章原始 slug 已被其他未删除文章占用,自动追加数字后缀。
|
||||||
@ -28,7 +34,7 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
|
|||||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
let tx = client.transaction().await.map_err(AppError::tx)?;
|
let tx = client.transaction().await.map_err(AppError::tx)?;
|
||||||
|
|
||||||
// 读取待恢复文章的当前 slug 与是否确已删除。
|
// 读取待恢复文章的当前 slug、标签与是否确已删除。
|
||||||
let row = tx
|
let row = tx
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||||
@ -51,6 +57,15 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
|
|||||||
// 恢复时确保 slug 在未删除文章中唯一(自动加后缀);在事务内检查避免并发竞态。
|
// 恢复时确保 slug 在未删除文章中唯一(自动加后缀);在事务内检查避免并发竞态。
|
||||||
let new_slug = ensure_unique_slug(&tx, ¤t_slug, Some(post_id)).await?;
|
let new_slug = ensure_unique_slug(&tx, ¤t_slug, Some(post_id)).await?;
|
||||||
|
|
||||||
|
let tag_rows = tx
|
||||||
|
.query(
|
||||||
|
"SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1",
|
||||||
|
&[&post_id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
let tags: Vec<String> = tag_rows.iter().map(|r| r.get(0)).collect();
|
||||||
|
|
||||||
// 置空 deleted_at,并更新 slug(可能已加后缀)。
|
// 置空 deleted_at,并更新 slug(可能已加后缀)。
|
||||||
let result = tx
|
let result = tx
|
||||||
.execute(
|
.execute(
|
||||||
@ -71,7 +86,13 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
|
|||||||
|
|
||||||
tx.commit().await.map_err(AppError::tx)?;
|
tx.commit().await.map_err(AppError::tx)?;
|
||||||
|
|
||||||
crate::cache::invalidate_all_post_caches();
|
// 精准失效:列表、标签云、统计、旧 slug 与新 slug、相关标签文章。
|
||||||
|
crate::cache::invalidate_post_lists();
|
||||||
|
crate::cache::invalidate_all_tags();
|
||||||
|
crate::cache::invalidate_post_stats();
|
||||||
|
crate::cache::invalidate_post_by_slug(¤t_slug).await;
|
||||||
|
crate::cache::invalidate_post_by_slug(&new_slug).await;
|
||||||
|
crate::cache::invalidate_tag_posts_for(&tags).await;
|
||||||
|
|
||||||
Ok(CreatePostResponse {
|
Ok(CreatePostResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@ -104,6 +125,34 @@ pub async fn purge_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErro
|
|||||||
{
|
{
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 物理删除前查询 slug 与标签,用于精准失效缓存。
|
||||||
|
let slug_row = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||||
|
&[&post_id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
let Some(slug_row) = slug_row else {
|
||||||
|
return Ok(CreatePostResponse {
|
||||||
|
success: false,
|
||||||
|
message: "文章不在回收站".to_string(),
|
||||||
|
post_id: None,
|
||||||
|
slug: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let slug: String = slug_row.get(0);
|
||||||
|
|
||||||
|
let tag_rows = client
|
||||||
|
.query(
|
||||||
|
"SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1",
|
||||||
|
&[&post_id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
let tags: Vec<String> = tag_rows.iter().map(|r| r.get(0)).collect();
|
||||||
|
|
||||||
let result = client
|
let result = client
|
||||||
.execute(
|
.execute(
|
||||||
"DELETE FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
"DELETE FROM posts WHERE id = $1 AND deleted_at IS NOT NULL",
|
||||||
@ -121,13 +170,18 @@ pub async fn purge_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErro
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::cache::invalidate_all_post_caches();
|
// 精准失效相关缓存。
|
||||||
|
crate::cache::invalidate_post_lists();
|
||||||
|
crate::cache::invalidate_all_tags();
|
||||||
|
crate::cache::invalidate_post_stats();
|
||||||
|
crate::cache::invalidate_post_by_slug(&slug).await;
|
||||||
|
crate::cache::invalidate_tag_posts_for(&tags).await;
|
||||||
|
|
||||||
Ok(CreatePostResponse {
|
Ok(CreatePostResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "彻底删除成功".to_string(),
|
message: "彻底删除成功".to_string(),
|
||||||
post_id: Some(post_id),
|
post_id: Some(post_id),
|
||||||
slug: None,
|
slug: Some(slug),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,8 +215,12 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
|
|||||||
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
let mut client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
let tx = client.transaction().await.map_err(AppError::tx)?;
|
let tx = client.transaction().await.map_err(AppError::tx)?;
|
||||||
|
|
||||||
// 逐条恢复,slug 冲突时自动加后缀。
|
// 逐条恢复,slug 冲突时自动加后缀;同时收集受影响的 slug 与标签。
|
||||||
let mut restored = 0u64;
|
let mut restored = 0u64;
|
||||||
|
let mut affected_slugs: Vec<String> = Vec::with_capacity(post_ids.len() * 2);
|
||||||
|
let mut affected_tags: std::collections::HashSet<String> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
|
|
||||||
for id in &post_ids {
|
for id in &post_ids {
|
||||||
let row = tx
|
let row = tx
|
||||||
.query_opt(
|
.query_opt(
|
||||||
@ -174,6 +232,18 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
|
|||||||
if let Some(row) = row {
|
if let Some(row) = row {
|
||||||
let current_slug: String = row.get("slug");
|
let current_slug: String = row.get("slug");
|
||||||
let new_slug = ensure_unique_slug(&tx, ¤t_slug, Some(*id)).await?;
|
let new_slug = ensure_unique_slug(&tx, ¤t_slug, Some(*id)).await?;
|
||||||
|
|
||||||
|
let tag_rows = tx
|
||||||
|
.query(
|
||||||
|
"SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1",
|
||||||
|
&[&id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
for tag_row in &tag_rows {
|
||||||
|
affected_tags.insert(tag_row.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
let n = tx
|
let n = tx
|
||||||
.execute(
|
.execute(
|
||||||
"UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL",
|
"UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL",
|
||||||
@ -182,12 +252,24 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::tx)?;
|
.map_err(AppError::tx)?;
|
||||||
restored += n;
|
restored += n;
|
||||||
|
|
||||||
|
affected_slugs.push(current_slug);
|
||||||
|
affected_slugs.push(new_slug);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit().await.map_err(AppError::tx)?;
|
tx.commit().await.map_err(AppError::tx)?;
|
||||||
|
|
||||||
crate::cache::invalidate_all_post_caches();
|
// 精准失效:先去重 slug,再统一失效列表/标签云/统计/标签文章。
|
||||||
|
let unique_slugs: std::collections::HashSet<String> =
|
||||||
|
affected_slugs.into_iter().collect();
|
||||||
|
crate::cache::invalidate_post_lists();
|
||||||
|
crate::cache::invalidate_all_tags();
|
||||||
|
crate::cache::invalidate_post_stats();
|
||||||
|
for slug in &unique_slugs {
|
||||||
|
crate::cache::invalidate_post_by_slug(slug).await;
|
||||||
|
}
|
||||||
|
crate::cache::invalidate_tag_posts_for(&affected_tags.into_iter().collect::<Vec<_>>()).await;
|
||||||
|
|
||||||
Ok(CreatePostResponse {
|
Ok(CreatePostResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@ -225,8 +307,34 @@ pub async fn batch_purge_posts(post_ids: Vec<i32>) -> Result<CreatePostResponse,
|
|||||||
}
|
}
|
||||||
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
let total = post_ids.len() as i64;
|
let total = post_ids.len() as i64;
|
||||||
|
|
||||||
|
// 记录数较少时查询 slug 与标签,使用精准失效;否则回退到全量失效。
|
||||||
|
let use_precise = post_ids.len() <= PRECISE_INVALIDATION_LIMIT;
|
||||||
|
let (slugs, tags) = if use_precise {
|
||||||
|
let slug_rows = client
|
||||||
|
.query(
|
||||||
|
"SELECT slug FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL",
|
||||||
|
&[&post_ids],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
let slugs: Vec<String> = slug_rows.iter().map(|r| r.get(0)).collect();
|
||||||
|
|
||||||
|
let tag_rows = client
|
||||||
|
.query(
|
||||||
|
"SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ANY($1)",
|
||||||
|
&[&post_ids],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
let tags: Vec<String> = tag_rows.iter().map(|r| r.get(0)).collect();
|
||||||
|
|
||||||
|
(slugs, tags)
|
||||||
|
} else {
|
||||||
|
(Vec::new(), Vec::new())
|
||||||
|
};
|
||||||
|
|
||||||
let result = client
|
let result = client
|
||||||
.execute(
|
.execute(
|
||||||
"DELETE FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL",
|
"DELETE FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL",
|
||||||
@ -235,7 +343,18 @@ pub async fn batch_purge_posts(post_ids: Vec<i32>) -> Result<CreatePostResponse,
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::query)?;
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
crate::cache::invalidate_post_lists();
|
||||||
|
crate::cache::invalidate_all_tags();
|
||||||
|
crate::cache::invalidate_post_stats();
|
||||||
|
if use_precise {
|
||||||
|
for slug in &slugs {
|
||||||
|
crate::cache::invalidate_post_by_slug(slug).await;
|
||||||
|
}
|
||||||
|
crate::cache::invalidate_tag_posts_for(&tags).await;
|
||||||
|
} else {
|
||||||
|
// 影响集过大时回退到全量失效,避免大量串行缓存操作。
|
||||||
crate::cache::invalidate_all_post_caches();
|
crate::cache::invalidate_all_post_caches();
|
||||||
|
}
|
||||||
|
|
||||||
Ok(CreatePostResponse {
|
Ok(CreatePostResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@ -265,12 +384,58 @@ pub async fn empty_trash() -> Result<CreatePostResponse, ServerFnError> {
|
|||||||
{
|
{
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
// 记录数较少时查询 slug 与标签,使用精准失效;否则回退到全量失效。
|
||||||
|
let count_row = client
|
||||||
|
.query_one(
|
||||||
|
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NOT NULL",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
let count: i64 = count_row.get(0);
|
||||||
|
let use_precise = count > 0 && (count as usize) <= PRECISE_INVALIDATION_LIMIT;
|
||||||
|
|
||||||
|
let (slugs, tags) = if use_precise {
|
||||||
|
let slug_rows = client
|
||||||
|
.query(
|
||||||
|
"SELECT slug FROM posts WHERE deleted_at IS NOT NULL",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
let slugs: Vec<String> = slug_rows.iter().map(|r| r.get(0)).collect();
|
||||||
|
|
||||||
|
let tag_rows = client
|
||||||
|
.query(
|
||||||
|
"SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id JOIN posts p ON p.id = pt.post_id WHERE p.deleted_at IS NOT NULL",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::query)?;
|
||||||
|
let tags: Vec<String> = tag_rows.iter().map(|r| r.get(0)).collect();
|
||||||
|
|
||||||
|
(slugs, tags)
|
||||||
|
} else {
|
||||||
|
(Vec::new(), Vec::new())
|
||||||
|
};
|
||||||
|
|
||||||
let result = client
|
let result = client
|
||||||
.execute("DELETE FROM posts WHERE deleted_at IS NOT NULL", &[])
|
.execute("DELETE FROM posts WHERE deleted_at IS NOT NULL", &[])
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::query)?;
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
crate::cache::invalidate_post_lists();
|
||||||
|
crate::cache::invalidate_all_tags();
|
||||||
|
crate::cache::invalidate_post_stats();
|
||||||
|
if use_precise {
|
||||||
|
for slug in &slugs {
|
||||||
|
crate::cache::invalidate_post_by_slug(slug).await;
|
||||||
|
}
|
||||||
|
crate::cache::invalidate_tag_posts_for(&tags).await;
|
||||||
|
} else {
|
||||||
|
// 影响集过大时回退到全量失效,避免大量串行缓存操作。
|
||||||
crate::cache::invalidate_all_post_caches();
|
crate::cache::invalidate_all_post_caches();
|
||||||
|
}
|
||||||
|
|
||||||
Ok(CreatePostResponse {
|
Ok(CreatePostResponse {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user