From 79cb80901051d5e2e8ee9358dd6d4eadf2de40fa Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 11:14:56 +0800 Subject: [PATCH] perf(posts): add optional pagination to get_posts_by_tag, fix total count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_posts_by_tag 现接受 page/per_page 可选参数;两者均 None 时返回全部 (上限 200,用于无翻页 UI 的标签详情页),均提供时走标准分页。 - 修正 total:不再用 posts.len(),改为真实 COUNT(*),即使被 LIMIT 截断 也返回完整计数。 - 新增 CacheKey::PostsByTagPage 分页缓存键,与不分页键 PostsByTag 共存。 - 前端 tags.rs 传 (None, None) 保持原全部展示行为。 --- src/api/posts/list.rs | 166 ++++++++++++++++++++++++++++++++---------- src/cache.rs | 16 +++- src/pages/tags.rs | 5 +- 3 files changed, 145 insertions(+), 42 deletions(-) diff --git a/src/api/posts/list.rs b/src/api/posts/list.rs index 657f8ff..2ef67db 100644 --- a/src/api/posts/list.rs +++ b/src/api/posts/list.rs @@ -235,53 +235,141 @@ pub async fn list_deleted_posts( /// 获取指定标签下的已发布文章列表。 /// -/// 优先命中缓存;当前实现返回全部匹配文章,因此 total 用 posts.len() 计算。 +/// 分页参数为可选: +/// - `page` 与 `per_page` 均为 `None` 时返回该标签下全部已发布文章(上限 200), +/// 用于无分页 UI 的标签详情页。 +/// - 两者均提供时走标准分页(经 `clamp_pagination` 钳制)。 +/// 结果缓存于按标签的分页键空间。 #[server(GetPostsByTag, "/api")] -pub async fn get_posts_by_tag(tag_name: String) -> Result { +pub async fn get_posts_by_tag( + tag_name: String, + page: Option, + per_page: Option, +) -> Result { #[cfg(feature = "server")] { - 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, - }); - } + // 仅当两个分页参数都提供时才走分页路径;任一为 None 视为不分页。 + let (page, per_page) = match (page, per_page) { + (Some(p), Some(pp)) => (Some(p), Some(pp)), + _ => (None, None), + }; let client = get_conn().await.map_err(AppError::db_conn)?; - // 通过 JOIN 筛选含目标标签的已发布文章,并聚合该文章的所有标签。 - 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)?; + if let (Some(page), Some(per_page)) = (page, per_page) { + // 分页路径:钳制参数,走分页缓存键。 + let (page, per_page) = clamp_pagination(page, per_page); + let cache_key = crate::cache::CacheKey::PostsByTagPage { + tag: tag_name.clone(), + page, + per_page, + }; + if let Some((cached_posts, cached_total)) = + crate::cache::get_posts_by_tag_paged(&cache_key).await + { + return Ok(PostListResponse { + posts: cached_posts, + total: cached_total, + }); + } - let mut posts = Vec::new(); - for row in &rows { - posts.push(row_to_post_list_item(row)); + // 标签下已发布文章总数。 + 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 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"))] diff --git a/src/cache.rs b/src/cache.rs index 4c190dd..3b200d5 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -74,8 +74,10 @@ pub enum CacheKey { AllTags, /// 按 slug 查询的单篇文章。 PostBySlug(String), - /// 按标签查询的文章列表。 + /// 按标签查询的文章列表(不分页,返回全部)。 PostsByTag(String), + /// 按标签查询的分页文章列表。 + PostsByTagPage { tag: String, page: i32, per_page: i32 }, /// 文章统计信息。 PostStats, /// 某篇文章下的评论列表。 @@ -274,6 +276,18 @@ pub async fn set_posts_by_tag(tag: &str, posts: Vec, total: i64) { .await; } +/// 按标签+分页读取文章列表缓存。 +#[cfg(feature = "server")] +pub async fn get_posts_by_tag_paged(key: &CacheKey) -> Option<(Vec, i64)> { + TAG_POSTS_CACHE.get(key).await +} + +/// 按标签+分页写入文章列表缓存。 +#[cfg(feature = "server")] +pub async fn set_posts_by_tag_paged(key: &CacheKey, posts: Vec, total: i64) { + let _ = TAG_POSTS_CACHE.insert(key.clone(), (posts, total)).await; +} + /// 读取文章统计缓存。 #[cfg(feature = "server")] pub async fn get_post_stats() -> Option { diff --git a/src/pages/tags.rs b/src/pages/tags.rs index b0dc7a9..232e9a3 100644 --- a/src/pages/tags.rs +++ b/src/pages/tags.rs @@ -6,7 +6,8 @@ //! //! 数据获取: //! - 标签云通过 `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 请求的客户端存根; //! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。 @@ -109,7 +110,7 @@ pub fn TagDetail(tag: String) -> Element { /// 成功时渲染文章总数与文章卡片。 #[component] 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) 形式以便渲染。 let posts_data = posts_res.read().as_ref().map(|r| match r {