perf(posts): add optional pagination to get_posts_by_tag, fix total count

- get_posts_by_tag 现接受 page/per_page 可选参数;两者均 None 时返回全部
  (上限 200,用于无翻页 UI 的标签详情页),均提供时走标准分页。
- 修正 total:不再用 posts.len(),改为真实 COUNT(*),即使被 LIMIT 截断
  也返回完整计数。
- 新增 CacheKey::PostsByTagPage 分页缓存键,与不分页键 PostsByTag 共存。
- 前端 tags.rs 传 (None, None) 保持原全部展示行为。
This commit is contained in:
xfy 2026-06-18 11:14:56 +08:00
parent 2dda168b19
commit 79cb809010
3 changed files with 145 additions and 42 deletions

View File

@ -235,53 +235,141 @@ pub async fn list_deleted_posts(
/// 获取指定标签下的已发布文章列表。 /// 获取指定标签下的已发布文章列表。
/// ///
/// 优先命中缓存;当前实现返回全部匹配文章,因此 total 用 posts.len() 计算。 /// 分页参数为可选:
/// - `page` 与 `per_page` 均为 `None` 时返回该标签下全部已发布文章(上限 200
/// 用于无分页 UI 的标签详情页。
/// - 两者均提供时走标准分页(经 `clamp_pagination` 钳制)。
/// 结果缓存于按标签的分页键空间。
#[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,
page: Option<i32>,
per_page: Option<i32>,
) -> Result<PostListResponse, ServerFnError> {
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
if let Some((cached_posts, cached_total)) = crate::cache::get_posts_by_tag(&tag_name).await // 仅当两个分页参数都提供时才走分页路径;任一为 None 视为不分页。
{ let (page, per_page) = match (page, per_page) {
return Ok(PostListResponse { (Some(p), Some(pp)) => (Some(p), Some(pp)),
posts: cached_posts, _ => (None, None),
total: cached_total, };
});
}
let client = get_conn().await.map_err(AppError::db_conn)?; let client = get_conn().await.map_err(AppError::db_conn)?;
// 通过 JOIN 筛选含目标标签的已发布文章,并聚合该文章的所有标签。 if let (Some(page), Some(per_page)) = (page, per_page) {
let rows = client // 分页路径:钳制参数,走分页缓存键。
.query( let (page, per_page) = clamp_pagination(page, per_page);
"SELECT let cache_key = crate::cache::CacheKey::PostsByTagPage {
p.id, p.author_id, p.title, p.slug, p.summary, p.status, tag: tag_name.clone(),
p.published_at, p.created_at, p.updated_at, p.cover_image, page,
p.word_count, p.reading_time, per_page,
COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags };
FROM posts p if let Some((cached_posts, cached_total)) =
JOIN post_tags pt ON p.id = pt.post_id crate::cache::get_posts_by_tag_paged(&cache_key).await
JOIN tags t ON pt.tag_id = t.id {
LEFT JOIN post_tags pt2 ON p.id = pt2.post_id return Ok(PostListResponse {
LEFT JOIN tags t2 ON pt2.tag_id = t2.id posts: cached_posts,
WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL total: cached_total,
GROUP BY p.id });
ORDER BY p.published_at DESC }
LIMIT 200",
&[&tag_name],
)
.await
.map_err(AppError::query)?;
let mut posts = Vec::new(); // 标签下已发布文章总数。
for row in &rows { let total: i64 = client
posts.push(row_to_post_list_item(row)); .query_one(
"SELECT COUNT(*) FROM posts p
JOIN post_tags pt ON p.id = pt.post_id
JOIN tags t ON pt.tag_id = t.id
WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL",
&[&tag_name],
)
.await
.map_err(AppError::query)?
.get(0);
let offset = ((page - 1).max(0) as i64) * (per_page as i64);
let limit = per_page as i64;
let rows = client
.query(
"SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.status,
p.published_at, p.created_at, p.updated_at, p.cover_image,
p.word_count, p.reading_time,
COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags
FROM posts p
JOIN post_tags pt ON p.id = pt.post_id
JOIN tags t ON pt.tag_id = t.id
LEFT JOIN post_tags pt2 ON p.id = pt2.post_id
LEFT JOIN tags t2 ON pt2.tag_id = t2.id
WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL
GROUP BY p.id
ORDER BY p.published_at DESC
LIMIT $2 OFFSET $3",
&[&tag_name, &limit, &offset],
)
.await
.map_err(AppError::query)?;
let mut posts = Vec::new();
for row in &rows {
posts.push(row_to_post_list_item(row));
}
crate::cache::set_posts_by_tag_paged(&cache_key, posts.clone(), total).await;
Ok(PostListResponse { posts, total })
} else {
// 不分页路径:返回全部(上限 200用于无翻页 UI 的标签详情页。
if let Some((cached_posts, cached_total)) =
crate::cache::get_posts_by_tag(&tag_name).await
{
return Ok(PostListResponse {
posts: cached_posts,
total: cached_total,
});
}
// 真实总数(即使被 LIMIT 截断也返回完整计数)。
let total: i64 = client
.query_one(
"SELECT COUNT(*) FROM posts p
JOIN post_tags pt ON p.id = pt.post_id
JOIN tags t ON pt.tag_id = t.id
WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL",
&[&tag_name],
)
.await
.map_err(AppError::query)?
.get(0);
let rows = client
.query(
"SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.status,
p.published_at, p.created_at, p.updated_at, p.cover_image,
p.word_count, p.reading_time,
COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags
FROM posts p
JOIN post_tags pt ON p.id = pt.post_id
JOIN tags t ON pt.tag_id = t.id
LEFT JOIN post_tags pt2 ON p.id = pt2.post_id
LEFT JOIN tags t2 ON pt2.tag_id = t2.id
WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL
GROUP BY p.id
ORDER BY p.published_at DESC
LIMIT 200",
&[&tag_name],
)
.await
.map_err(AppError::query)?;
let mut posts = Vec::new();
for row in &rows {
posts.push(row_to_post_list_item(row));
}
// total 为真实 COUNT(*),不再用 posts.len()。
crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await;
Ok(PostListResponse { posts, total })
} }
// 当前查询未分页,返回全部匹配文章,因此 total 等于结果长度。
// 若后续增加分页,应改为 COUNT(*) 查询。
let total = posts.len() as i64;
crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await;
Ok(PostListResponse { posts, total })
} }
#[cfg(not(feature = "server"))] #[cfg(not(feature = "server"))]

View File

@ -74,8 +74,10 @@ pub enum CacheKey {
AllTags, AllTags,
/// 按 slug 查询的单篇文章。 /// 按 slug 查询的单篇文章。
PostBySlug(String), PostBySlug(String),
/// 按标签查询的文章列表 /// 按标签查询的文章列表(不分页,返回全部)
PostsByTag(String), PostsByTag(String),
/// 按标签查询的分页文章列表。
PostsByTagPage { tag: String, page: i32, per_page: i32 },
/// 文章统计信息。 /// 文章统计信息。
PostStats, PostStats,
/// 某篇文章下的评论列表。 /// 某篇文章下的评论列表。
@ -274,6 +276,18 @@ pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
.await; .await;
} }
/// 按标签+分页读取文章列表缓存。
#[cfg(feature = "server")]
pub async fn get_posts_by_tag_paged(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
TAG_POSTS_CACHE.get(key).await
}
/// 按标签+分页写入文章列表缓存。
#[cfg(feature = "server")]
pub async fn set_posts_by_tag_paged(key: &CacheKey, posts: Vec<PostListItem>, total: i64) {
let _ = TAG_POSTS_CACHE.insert(key.clone(), (posts, total)).await;
}
/// 读取文章统计缓存。 /// 读取文章统计缓存。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn get_post_stats() -> Option<PostStats> { pub async fn get_post_stats() -> Option<PostStats> {

View File

@ -6,7 +6,8 @@
//! //!
//! 数据获取: //! 数据获取:
//! - 标签云通过 `use_server_future(list_tags)` 获取全部标签信息。 //! - 标签云通过 `use_server_future(list_tags)` 获取全部标签信息。
//! - 标签详情通过 `use_server_future` 调用 `get_posts_by_tag(tag)` 获取该标签下的文章列表。 //! - 标签详情通过 `use_server_future` 调用 `get_posts_by_tag(tag, None, None)`
//! 获取该标签下的全部已发布文章(不分页)。
//! 在 `wasm32` 目标下,这些 server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根; //! 在 `wasm32` 目标下,这些 server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。 //! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
@ -109,7 +110,7 @@ pub fn TagDetail(tag: String) -> Element {
/// 成功时渲染文章总数与文章卡片。 /// 成功时渲染文章总数与文章卡片。
#[component] #[component]
fn TagDetailContent(tag: String) -> Element { fn TagDetailContent(tag: String) -> Element {
let posts_res = use_server_future(move || get_posts_by_tag(tag.clone()))?; let posts_res = use_server_future(move || get_posts_by_tag(tag.clone(), None, None))?;
// 将结果映射为 (posts, total) 形式以便渲染。 // 将结果映射为 (posts, total) 形式以便渲染。
let posts_data = posts_res.read().as_ref().map(|r| match r { let posts_data = posts_res.read().as_ref().map(|r| match r {