xfy b4e15ecbff feat(admin): 文章列表支持按标题搜索
为 /admin/posts 的「全部文章」tab 增加按标题搜索能力:

- list_posts server fn 增加可选 search: Option<String> 参数,非空时按
  title ILIKE 子串匹配(转义 % / _ / \,限长 200 字符)过滤草稿+已发布,
  否则走原全量列表路径;COUNT 与分页查询同步带过滤条件。
- AllPostsList 增加搜索输入条(回车 / 搜索按钮提交,清除按钮复位),
  search_query signal 在 use_paginated 的 page 闭包内同步读取以建立响应式
  依赖:提交新词即使停在首页也会重新请求;fetch 闭包请求时读取当前词传后端。
  搜索结果为空时展示「未找到匹配的文章」空状态。
- 同步更新 dashboard.rs 与 query.rs 文档示例中的 list_posts 调用签名。
2026-07-28 17:33:06 +08:00

475 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 文章列表查询接口。
//!
//! 提供已发布文章分页、管理员全量列表、以及按标签筛选三种查询能力,
//! 均通过缓存层减少重复数据库访问。
//! Dioxus server function注册在 `/api` 路径下。
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
use dioxus::prelude::*;
#[cfg(feature = "server")]
use super::helpers::{get_current_admin_user, row_to_post_list_item};
use super::types::PostListResponse;
#[cfg(feature = "server")]
use crate::api::error::AppError;
#[cfg(feature = "server")]
use crate::db::pool::get_conn;
/// 单页允许的最大文章数。
///
/// 公开的 `list_published_posts` 接口无需认证,若不对 `per_page` 设上限,
/// 攻击者可传入巨大值迫使数据库扫描并实例化超大 Vec造成内存放大与拒绝服务。
#[cfg(feature = "server")]
const MAX_PER_PAGE: i32 = 50;
/// 允许的最大页码。
///
/// `page` 无上限时,攻击者可用海量不同 `page` 值撑大缓存键空间(缓存污染),
/// 并触发无意义的超大 `OFFSET` 扫描。10_000 对任何实际博客都足够宽裕
/// (配合 `MAX_PER_PAGE` 最多覆盖 50 万篇文章),同时把缓存键空间限制在有限范围。
#[cfg(feature = "server")]
const MAX_PAGE: i32 = 10_000;
/// 将分页参数钳制到安全范围:页码 1`MAX_PAGE`,每页 1`MAX_PER_PAGE`。
///
/// 注意:返回值必须同时用于缓存键与 SQL 查询,避免同一逻辑页落入不同缓存条目。
#[cfg(feature = "server")]
fn clamp_pagination(page: i32, per_page: i32) -> (i32, i32) {
(page.clamp(1, MAX_PAGE), per_page.clamp(1, MAX_PER_PAGE))
}
/// 获取已发布文章分页列表。
///
/// 优先命中缓存;未命中时查询总数与分页记录,并按 published_at 降序排列。
#[server(ListPublishedPosts, "/api")]
pub async fn list_published_posts(
page: i32,
per_page: i32,
) -> Result<PostListResponse, ServerFnError> {
// 钳制分页参数,防止无认证调用方请求超大每页数量导致内存放大 / DoS。
let (page, per_page) = clamp_pagination(page, per_page);
#[cfg(feature = "server")]
{
let cache_key = crate::cache::CacheKey::PublishedPosts { page, per_page };
if let Some((cached_posts, cached_total)) = crate::cache::get_post_list(&cache_key).await {
return Ok(PostListResponse {
posts: cached_posts,
total: cached_total,
});
}
let client = get_conn().await.map_err(AppError::db_conn)?;
// 优先读取缓存中的已发布文章总数,否则查询数据库并回填缓存。
let total = if let Some(cached_total) = crate::cache::get_total_published_posts().await {
cached_total
} else {
let count_row = client
.query_one(
"SELECT COUNT(*) FROM posts WHERE status = 'published' AND deleted_at IS NULL",
&[],
)
.await
.map_err(AppError::query)?;
let total: i64 = count_row.get(0);
crate::cache::set_total_published_posts(total).await;
total
};
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(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
FROM posts p
LEFT JOIN post_tags pt ON p.id = pt.post_id
LEFT JOIN tags t ON pt.tag_id = t.id
WHERE p.status = 'published' AND p.deleted_at IS NULL
GROUP BY p.id
ORDER BY p.published_at DESC
LIMIT $1 OFFSET $2",
&[&limit, &offset],
)
.await
.map_err(AppError::query)?;
let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
crate::cache::set_post_list(&cache_key, posts.clone(), total).await;
Ok(PostListResponse { posts, total })
}
#[cfg(not(feature = "server"))]
{
Ok(PostListResponse {
posts: Vec::new(),
total: 0,
})
}
}
/// 获取管理员视角的全部文章列表(含草稿与已发布)。
///
/// 需要 admin 权限;结果按创建时间降序,不走缓存。
#[server(ListPosts, "/api")]
pub async fn list_posts(
page: i32,
per_page: i32,
search: Option<String>,
) -> Result<PostListResponse, ServerFnError> {
// 与公开接口保持一致的分页钳制,避免单次请求拉取过多记录。
let (page, per_page) = clamp_pagination(page, per_page);
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
let client = get_conn().await.map_err(AppError::db_conn)?;
// 归一化标题搜索词trim 后为空则视为不搜索;限长 200 字符并转义 SQL
// LIKE 通配符(% / _ / \),避免用户输入导致模式错配或全表误匹配。
// 与 search.rs 的全文检索不同:管理后台需覆盖草稿,且仅按标题匹配。
let title_filter: Option<String> = search
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| {
let mut esc = String::with_capacity(s.len());
for ch in s.chars().take(200) {
match ch {
'\\' | '%' | '_' => {
esc.push('\\');
esc.push(ch);
}
_ => esc.push(ch),
}
}
esc
});
let offset = ((page - 1).max(0) as i64) * (per_page as i64);
let limit = per_page as i64;
// 有搜索词时按 title ILIKE 子串匹配过滤(双侧 %),否则走原全量列表。
// posts 表对管理后台属低频访问、行数有限ILIKE 全表扫可接受,靠 LIMIT 兜底。
let (total, rows) = if let Some(esc) = title_filter {
let pattern = format!("%{esc}%");
let total: i64 = client
.query_one(
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND title ILIKE $1 ESCAPE '\\'",
&[&pattern],
)
.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(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
FROM posts p
LEFT JOIN post_tags pt ON p.id = pt.post_id
LEFT JOIN tags t ON pt.tag_id = t.id
WHERE p.deleted_at IS NULL AND p.title ILIKE $3 ESCAPE '\\'
GROUP BY p.id
ORDER BY p.created_at DESC
LIMIT $1 OFFSET $2",
&[&limit, &offset, &pattern],
)
.await
.map_err(AppError::query)?;
(total, rows)
} else {
let total: i64 = client
.query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[])
.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(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
FROM posts p
LEFT JOIN post_tags pt ON p.id = pt.post_id
LEFT JOIN tags t ON pt.tag_id = t.id
WHERE p.deleted_at IS NULL
GROUP BY p.id
ORDER BY p.created_at DESC
LIMIT $1 OFFSET $2",
&[&limit, &offset],
)
.await
.map_err(AppError::query)?;
(total, rows)
};
let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
Ok(PostListResponse { posts, total })
}
#[cfg(not(feature = "server"))]
{
Ok(PostListResponse {
posts: Vec::new(),
total: 0,
})
}
}
/// 获取回收站中已软删除的文章列表。
///
/// 需要 admin 权限;按删除时间降序,不走缓存。
#[server(ListDeletedPosts, "/api")]
pub async fn list_deleted_posts(
page: i32,
per_page: i32,
) -> Result<PostListResponse, ServerFnError> {
// 与 list_posts 一致的分页钳制。
let (page, per_page) = clamp_pagination(page, per_page);
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
let client = get_conn().await.map_err(AppError::db_conn)?;
let count_row = client
.query_one(
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NOT NULL",
&[],
)
.await
.map_err(AppError::query)?;
let total: i64 = count_row.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.deleted_at,
p.word_count, p.reading_time,
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
FROM posts p
LEFT JOIN post_tags pt ON p.id = pt.post_id
LEFT JOIN tags t ON pt.tag_id = t.id
WHERE p.deleted_at IS NOT NULL
GROUP BY p.id
ORDER BY p.deleted_at DESC
LIMIT $1 OFFSET $2",
&[&limit, &offset],
)
.await
.map_err(AppError::query)?;
let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
Ok(PostListResponse { posts, total })
}
#[cfg(not(feature = "server"))]
{
Ok(PostListResponse {
posts: Vec::new(),
total: 0,
})
}
}
/// 获取指定标签下的已发布文章列表。
///
/// 分页参数为可选:
/// - `page` 与 `per_page` 均为 `None` 时返回该标签下全部已发布文章(上限 200
/// 用于无分页 UI 的标签详情页。
/// - 两者均提供时走标准分页(经 `clamp_pagination` 钳制)。
/// 结果缓存于按标签的分页键空间。
#[server(GetPostsByTag, "/api")]
pub async fn get_posts_by_tag(
tag_name: String,
page: Option<i32>,
per_page: Option<i32>,
) -> Result<PostListResponse, ServerFnError> {
#[cfg(feature = "server")]
{
// 仅当两个分页参数都提供时才走分页路径;任一为 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)?;
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 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 posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
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 posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
// total 为真实 COUNT(*),不再用 posts.len()。
crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await;
Ok(PostListResponse { posts, total })
}
}
#[cfg(not(feature = "server"))]
{
Ok(PostListResponse {
posts: Vec::new(),
total: 0,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamp_pagination_keeps_valid_values() {
assert_eq!(clamp_pagination(1, 10), (1, 10));
assert_eq!(clamp_pagination(3, 20), (3, 20));
}
#[test]
fn clamp_pagination_clamps_oversized_per_page() {
// 攻击者传入超大 per_page 必须被压回上限,避免内存放大 / DoS。
assert_eq!(clamp_pagination(1, 1_000_000_000), (1, MAX_PER_PAGE));
assert_eq!(clamp_pagination(2, 51), (2, MAX_PER_PAGE));
}
#[test]
fn clamp_pagination_clamps_non_positive() {
assert_eq!(clamp_pagination(0, 10), (1, 10));
assert_eq!(clamp_pagination(-5, 10), (1, 10));
assert_eq!(clamp_pagination(1, 0), (1, 1));
assert_eq!(clamp_pagination(1, -100), (1, 1));
}
#[test]
fn clamp_pagination_clamps_oversized_page() {
// 巨大 page 必须被压回上限,避免无界 OFFSET 扫描与缓存键扇出。
assert_eq!(clamp_pagination(i32::MAX, 10), (MAX_PAGE, 10));
assert_eq!(clamp_pagination(MAX_PAGE + 1, 10), (MAX_PAGE, 10));
}
#[test]
fn clamp_pagination_max_page_boundary() {
assert_eq!(clamp_pagination(MAX_PAGE, 10), (MAX_PAGE, 10));
assert_eq!(clamp_pagination(MAX_PAGE - 1, 10), (MAX_PAGE - 1, 10));
}
#[test]
fn clamp_pagination_max_per_page_boundary() {
assert_eq!(clamp_pagination(1, MAX_PER_PAGE), (1, MAX_PER_PAGE));
assert_eq!(clamp_pagination(1, MAX_PER_PAGE - 1), (1, MAX_PER_PAGE - 1));
}
}