嗨嗨!大家好!歡迎來到 Rust 三十天挑戰的第二十七天!
昨天我們完成了一個有溫度的留言系統,建立了創作者與讀者之間的互動橋樑。今天我們要進入的主題是:提升體驗 - 個人部落格的實用功能!
隨著部落格功能逐漸完整,我們需要考慮的不再只是「有沒有」這個功能,而是「好不好用」這個體驗。對個人創作者來說,一個好用的部落格系統不僅要功能齊全,還要操作順暢、回應迅速、查詢靈活。
我們先優化現有的查詢 DTO,讓它們支援更豐富的搜尋功能。
更新 src/dtos/post.rs
:
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use validator::Validate;
// 新增進階搜尋查詢結構
#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct PostSearchQuery {
#[validate(length(min = 1, max = 100, message = "搜尋關鍵字長度必須在 1-100 字元之間"))]
#[schema(example = "rust 教學")]
pub q: Option<String>, // 關鍵字搜尋
#[schema(example = "rust,程式語言")]
pub tags: Option<String>, // 標籤篩選,逗號分隔
#[schema(example = "published")]
pub status: Option<String>, // published, draft, all (admin only)
#[schema(example = "2024-01-01")]
pub from_date: Option<String>, // 起始日期 (YYYY-MM-DD)
#[schema(example = "2024-12-31")]
pub to_date: Option<String>, // 結束日期 (YYYY-MM-DD)
#[schema(example = 1)]
pub page: Option<u64>,
#[schema(example = 10)]
pub page_size: Option<u64>,
#[schema(example = "created_at")]
pub sort_by: Option<String>, // created_at, updated_at, title, view_count
#[schema(example = "desc")]
pub sort_order: Option<String>, // asc, desc
#[schema(example = false)]
pub include_content: Option<bool>, // 是否包含完整內容(預設只有摘要)
}
// 搜尋結果回應
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct PostSearchResponse {
#[schema(example = 25)]
pub total_count: i64,
#[schema(example = 3)]
pub total_pages: u64,
#[schema(example = 1)]
pub current_page: u64,
#[schema(example = 10)]
pub page_size: u64,
pub posts: Vec<PostSummaryResponse>,
#[schema(example = "搜尋到 25 篇與 'rust' 相關的文章")]
pub search_summary: String,
pub filters_applied: SearchFiltersApplied,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SearchFiltersApplied {
#[schema(example = "rust 教學")]
pub keyword: Option<String>,
pub tags: Vec<String>,
#[schema(example = "published")]
pub status: Option<String>,
#[schema(example = "2024-01-01")]
pub date_range_start: Option<String>,
#[schema(example = "2024-12-31")]
pub date_range_end: Option<String>,
}
// 優化的文章摘要回應(用於列表)
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct PostSummaryResponse {
#[schema(example = 1)]
pub id: i32,
#[schema(example = "學習 Rust 的第一天")]
pub title: String,
#[schema(example = "今天開始踏上 Rust 學習之旅...")]
pub excerpt: Option<String>,
#[schema(example = "學習-rust-的第一天")]
pub slug: String,
#[schema(example = true)]
pub is_published: bool,
#[schema(example = 42)]
pub view_count: i32,
#[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
pub created_at: chrono::DateTime<chrono::Utc>,
#[schema(value_type = String, example = "2024-01-16T14:20:00Z")]
pub updated_at: chrono::DateTime<chrono::Utc>,
#[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
pub tags: Vec<TagSummaryResponse>,
#[schema(example = 5)]
pub comment_count: i32,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct TagSummaryResponse {
#[schema(example = 1)]
pub id: i32,
#[schema(example = "rust")]
pub name: String,
#[schema(example = "#E74C3C")]
pub color: String,
}
// 熱門搜尋統計回應
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct PopularSearchResponse {
pub popular_tags: Vec<String>,
pub recent_posts: Vec<PostSummaryResponse>,
pub search_suggestions: Vec<String>,
pub total_posts: i64,
pub total_published: i64,
}
在 src/services/post_service.rs
中新增搜尋相關功能:
use crate::dtos::post::*;
use crate::entities::{post, tag, post_tag, comment, CommentStatus};
use crate::error::AppError;
use sea_orm::*;
use std::collections::HashMap;
use std::fmt;
use validator::Validate;
use tracing::{info, error};
use sea_orm::ActiveValue::{Set};
use sea_orm::prelude::Expr;
use sea_orm::sea_query::Query;
pub struct PostService;
impl fmt::Display for CommentStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CommentStatus::Pending => write!(f, "pending"),
CommentStatus::Approved => write!(f, "approved"),
CommentStatus::Rejected => write!(f, "rejected"),
}
}
}
impl PostService {
/// 創建新文章
pub async fn create_post(
db: &DatabaseConnection,
req: CreatePostRequest,
) -> Result<PostResponse, AppError> {
req.validate()
.map_err(|e| AppError::ValidationError(format!("輸入驗證失敗: {}", e)))?;
let slug = match req.slug {
Some(s) if !s.is_empty() => s,
_ => Self::generate_slug(&req.title),
};
if post::Entity::find()
.filter(post::Column::Slug.eq(&slug))
.one(db)
.await?
.is_some()
{
return Err(AppError::ConflictError("該 slug 已經存在".to_string()));
}
let excerpt = match req.excerpt {
Some(e) if !e.is_empty() => Some(e),
_ => Some(Self::generate_excerpt(&req.content)),
};
let is_published = req.is_published.unwrap_or(false);
let published_at = if is_published {
Some(chrono::Utc::now())
} else {
None
};
let txn = db.begin().await?;
let mut post_active_model = post::ActiveModel::new();
post_active_model.title = Set(req.title);
post_active_model.content = Set(req.content);
post_active_model.excerpt = Set(excerpt);
post_active_model.slug = Set(slug);
post_active_model.is_published = Set(is_published);
post_active_model.published_at = Set(published_at);
let post_result = post::Entity::insert(post_active_model)
.exec_with_returning(&txn)
.await
.map_err(|e| {
error!("insert post failed: {e:#?}");
AppError::from(e)
})?;
let mut tag_names: Vec<String> = Vec::new();
if let Some(tags) = req.tags {
for tag_name in tags {
let name = tag_name.trim();
if name.is_empty() { continue; }
Self::create_or_update_tag_txn(&txn, name, post_result.id).await?;
tag_names.push(name.to_string());
}
}
Self::sync_tag_counts_for_post(&txn, vec![], tag_names.clone()).await?;
txn.commit().await?;
Ok(PostResponse {
id: post_result.id,
title: post_result.title,
content: post_result.content,
excerpt: post_result.excerpt,
slug: post_result.slug,
is_published: post_result.is_published,
view_count: post_result.view_count,
created_at: post_result.created_at,
updated_at: post_result.updated_at,
published_at: post_result.published_at,
tags: tag_names,
})
}
/// 取得已發布文章列表
pub async fn get_published_posts(
db: &DatabaseConnection,
query: PostListQuery,
) -> Result<Vec<PostListResponse>, AppError> {
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(10).min(50);
let offset = (page - 1) * page_size;
let mut posts_query = post::Entity::find()
.filter(post::Column::IsPublished.eq(true))
.order_by_desc(post::Column::PublishedAt);
if let Some(tag_name) = query.tag {
let post_ids = post_tag::Entity::find()
.inner_join(tag::Entity)
.filter(tag::Column::Name.eq(&tag_name))
.select_only()
.column(post_tag::Column::PostId)
.into_tuple::<i32>()
.all(db)
.await?;
posts_query = posts_query.filter(post::Column::Id.is_in(post_ids));
}
let posts = posts_query
.offset(offset)
.limit(page_size)
.all(db)
.await?;
let post_ids: Vec<i32> = posts.iter().map(|p| p.id).collect();
let tags_map = Self::get_tags_for_posts(db, &post_ids).await?;
let response = posts
.into_iter()
.map(|post| PostListResponse {
id: post.id,
title: post.title,
excerpt: post.excerpt,
slug: post.slug,
view_count: post.view_count,
published_at: post.published_at,
tags: tags_map.get(&post.id).cloned().unwrap_or_default(),
})
.collect();
Ok(response)
}
/// 生成文章 slug
fn generate_slug(title: &str) -> String {
let slug = slug::slugify(title);
if slug.is_empty() {
format!("post-{}", chrono::Utc::now().timestamp())
} else {
slug
}
}
/// 從內容生成摘要
fn generate_excerpt(content: &str) -> String {
// 單純轉純文字+截斷,避免深層遞迴
let parser = pulldown_cmark::Parser::new(content);
let mut html_buf = String::new();
pulldown_cmark::html::push_html(&mut html_buf, parser);
let plain_text = html2text::from_read(html_buf.as_bytes(), 200).unwrap_or_default();
let excerpt = plain_text.trim();
if excerpt.len() > 200 {
format!("{}...", &excerpt[..197])
} else {
excerpt.to_string()
}
}
/// 批量取得文章的標籤
async fn get_tags_for_posts(
db: &DatabaseConnection,
post_ids: &[i32],
) -> Result<HashMap<i32, Vec<String>>, AppError> {
if post_ids.is_empty() {
return Ok(HashMap::new());
}
let results = post_tag::Entity::find()
.filter(post_tag::Column::PostId.is_in(post_ids.iter().cloned()))
.find_also_related(tag::Entity)
.all(db)
.await?;
let mut tags_map: HashMap<i32, Vec<String>> = HashMap::new();
for (pt, tg) in results {
if let Some(t) = tg {
tags_map.entry(pt.post_id).or_default().push(t.name);
}
}
Ok(tags_map)
}
/// 為標籤生成隨機顏色
fn generate_tag_color(tag_name: &str) -> String {
let colors = [
"#3498db", "#e74c3c", "#2ecc71", "#f39c12",
"#9b59b6", "#1abc9c", "#34495e", "#e67e22",
];
let index = tag_name.len() % colors.len();
colors[index].to_string()
}
pub async fn get_post_by_slug_or_id(
db: &DatabaseConnection,
identifier: &str,
) -> Result<PostDetailResponse, AppError> {
// 嘗試解析為數字 ID,否則當作 slug 處理
let post = if let Ok(id) = identifier.parse::<i32>() {
post::Entity::find_by_id(id).one(db).await?
} else {
post::Entity::find()
.filter(post::Column::Slug.eq(identifier))
.one(db)
.await?
};
let post = match post {
Some(p) => p,
None => return Err(AppError::NotFound("文章不存在".to_string())),
};
// 只有已發布的文章才允許公開查看
if !post.is_published {
return Err(AppError::NotFound("文章不存在".to_string()));
}
// 取得標籤
let tags = Self::get_tags_for_post(db, post.id).await?;
Ok(PostDetailResponse {
id: post.id,
title: post.title,
content: post.content,
excerpt: post.excerpt,
slug: post.slug,
is_published: post.is_published,
view_count: post.view_count,
created_at: post.created_at,
updated_at: post.updated_at,
published_at: post.published_at,
tags,
})
}
/// 管理員取得文章詳情(包含草稿)
pub async fn get_post_for_admin(
db: &DatabaseConnection,
post_id: i32,
) -> Result<PostDetailResponse, AppError> {
let post = post::Entity::find_by_id(post_id)
.one(db)
.await?
.ok_or_else(|| AppError::NotFound("文章不存在".to_string()))?;
let tags = Self::get_tags_for_post(db, post.id).await?;
Ok(PostDetailResponse {
id: post.id,
title: post.title,
content: post.content,
excerpt: post.excerpt,
slug: post.slug,
is_published: post.is_published,
view_count: post.view_count,
created_at: post.created_at,
updated_at: post.updated_at,
published_at: post.published_at,
tags,
})
}
/// 更新文章
pub async fn update_post(
db: &DatabaseConnection,
post_id: i32,
req: UpdatePostRequest,
) -> Result<PostDetailResponse, AppError> {
// 檢查文章是否存在
let existing_post = post::Entity::find_by_id(post_id)
.one(db)
.await?
.ok_or_else(|| AppError::NotFound("文章不存在".to_string()))?;
// 保存發布狀態,避免移動後無法訪問
let was_published = existing_post.is_published;
let now = chrono::Utc::now();
let mut updated_post: post::ActiveModel = existing_post.into();
// 只更新有提供的欄位
if let Some(title) = req.title {
updated_post.title = Set(title);
}
if let Some(content) = req.content {
// 如果內容有更新,可能需要重新生成摘要
let excerpt = req.excerpt.unwrap_or_else(|| {
Self::generate_excerpt(&content)
});
updated_post.content = Set(content);
updated_post.excerpt = Set(Some(excerpt));
} else if let Some(excerpt) = req.excerpt {
updated_post.excerpt = Set(Some(excerpt));
}
if let Some(slug) = req.slug {
// 檢查 slug 是否與其他文章衝突
if let Some(_conflict) = post::Entity::find()
.filter(post::Column::Slug.eq(&slug))
.filter(post::Column::Id.ne(post_id))
.one(db)
.await?
{
return Err(AppError::ConflictError(format!("Slug '{}' 已被使用", slug)));
}
updated_post.slug = Set(slug);
}
// 處理發布狀態變更
if let Some(is_published) = req.is_published {
updated_post.is_published = Set(is_published);
// 如果從草稿變為發布,設定發布時間
if is_published && !was_published {
updated_post.published_at = Set(Some(now));
info!("文章 {} 已發布", post_id);
}
// 如果從發布變為草稿,清除發布時間
else if !is_published && was_published {
updated_post.published_at = Set(None);
info!("文章 {} 已撤回發布", post_id);
}
}
updated_post.updated_at = Set(now);
// 開始交易
let txn = db.begin().await?;
if let Some(tags) = req.tags {
let old_tags: Vec<String> = post_tag::Entity::find()
.filter(post_tag::Column::PostId.eq(post_id))
.find_also_related(tag::Entity)
.all(&txn)
.await?
.into_iter()
.filter_map(|(_, t)| t.map(|x| x.name))
.collect();
post_tag::Entity::delete_many()
.filter(post_tag::Column::PostId.eq(post_id))
.exec(&txn)
.await?;
let mut new_tags: Vec<String> = Vec::new();
for tag_name in tags {
let name = tag_name.trim();
if name.is_empty() { continue; }
Self::create_or_update_tag_txn(&txn, name, post_id).await?;
new_tags.push(name.to_string());
}
Self::sync_tag_counts_for_post(&txn, old_tags, new_tags).await?;
}
// 更新文章
let updated = updated_post.update(&txn).await.map_err(|e| {
error!("更新文章失敗: {e:#?}");
AppError::from(e)
})?;
txn.commit().await?;
// 重新取得標籤資料
let tags = Self::get_tags_for_post(db, updated.id).await?;
Ok(PostDetailResponse {
id: updated.id,
title: updated.title,
content: updated.content,
excerpt: updated.excerpt,
slug: updated.slug,
is_published: updated.is_published,
view_count: updated.view_count,
created_at: updated.created_at,
updated_at: updated.updated_at,
published_at: updated.published_at,
tags,
})
}
/// 刪除文章(軟刪除,實際上是設為草稿並隱藏)
pub async fn delete_post(
db: &DatabaseConnection,
post_id: i32,
) -> Result<DeletePostResponse, AppError> {
let post = post::Entity::find_by_id(post_id)
.one(db)
.await?
.ok_or_else(|| AppError::NotFound("文章不存在".to_string()))?;
let txn = db.begin().await?;
let old_tags: Vec<String> = post_tag::Entity::find()
.filter(post_tag::Column::PostId.eq(post_id))
.find_also_related(tag::Entity)
.all(&txn)
.await?
.into_iter()
.filter_map(|(_, t)| t.map(|x| x.name))
.collect();
// ❷ 刪除關聯與文章
post_tag::Entity::delete_many()
.filter(post_tag::Column::PostId.eq(post_id))
.exec(&txn)
.await?;
post::Entity::delete_by_id(post_id).exec(&txn).await?;
// ❸ 同步計數:old=old_tags,new=[]
Self::sync_tag_counts_for_post(&txn, old_tags, vec![]).await?;
txn.commit().await?;
info!("文章 {} '{}' 已被刪除", post_id, post.title);
Ok(DeletePostResponse {
success: true,
message: "文章已成功刪除".to_string(),
deleted_id: post_id,
})
}
/// 增加文章瀏覽次數
pub async fn increment_view_count(
db: &DatabaseConnection,
post_id: i32,
) -> Result<(), AppError> {
post::Entity::update_many()
.col_expr(post::Column::ViewCount, Expr::add(Expr::col(post::Column::ViewCount), 1))
.filter(post::Column::Id.eq(post_id))
.exec(db)
.await?;
Ok(())
}
/// 取得單篇文章的標籤
async fn get_tags_for_post(
db: &DatabaseConnection,
post_id: i32,
) -> Result<Vec<String>, AppError> {
let results = post_tag::Entity::find()
.filter(post_tag::Column::PostId.eq(post_id))
.find_also_related(tag::Entity)
.all(db)
.await?;
let tags = results
.into_iter()
.filter_map(|(_, tag)| tag.map(|t| t.name))
.collect();
Ok(tags)
}
/// 在交易中建立或更新標籤
async fn create_or_update_tag_txn(
txn: &DatabaseTransaction,
tag_name: &str,
post_id: i32,
) -> Result<(), AppError> {
// 查找或建立標籤
let tag_entity = match tag::Entity::find()
.filter(tag::Column::Name.eq(tag_name))
.one(txn)
.await?
{
Some(existing_tag) => {
// 更新文章計數
let mut active_tag: tag::ActiveModel = existing_tag.into();
active_tag.post_count = Set(active_tag.post_count.unwrap() + 1);
active_tag.update(txn).await?
}
None => {
// 建立新標籤
let new_tag = tag::ActiveModel {
name: Set(tag_name.to_string()),
color: Set(Self::generate_tag_color(tag_name)),
post_count: Set(1),
..Default::default()
};
new_tag.insert(txn).await?
}
};
let post_tag_relation = post_tag::ActiveModel {
post_id: Set(post_id),
tag_id: Set(tag_entity.id),
..Default::default()
};
post_tag_relation.insert(txn).await?;
Ok(())
}
async fn sync_tag_counts_for_post(
txn: &DatabaseTransaction,
old_tags: Vec<String>,
new_tags: Vec<String>,
) -> Result<(), AppError> {
let removed_tags: Vec<_> = old_tags.iter()
.filter(|tag| !new_tags.contains(tag))
.collect();
// 找出新增的標籤
let added_tags: Vec<_> = new_tags.iter()
.filter(|tag| !old_tags.contains(tag))
.collect();
// 更新所有受影響標籤的計數
let mut affected_tag_ids = Vec::new();
// 取得被移除標籤的 ID
if !removed_tags.is_empty() {
let removed_tag_entities = tag::Entity::find()
.filter(tag::Column::Name.is_in(removed_tags))
.all(txn)
.await?;
affected_tag_ids.extend(removed_tag_entities.iter().map(|t| t.id));
}
// 取得新增標籤的 ID
if !added_tags.is_empty() {
let added_tag_entities = tag::Entity::find()
.filter(tag::Column::Name.is_in(added_tags))
.all(txn)
.await?;
affected_tag_ids.extend(added_tag_entities.iter().map(|t| t.id));
}
// 更新所有受影響標籤的文章計數
for tag_id in affected_tag_ids {
let count = post_tag::Entity::find()
.filter(post_tag::Column::TagId.eq(tag_id))
.inner_join(post::Entity)
.filter(post::Column::IsPublished.eq(true))
.count(txn)
.await?;
tag::Entity::update_many()
.col_expr(tag::Column::PostCount, Expr::value(count as i32))
.filter(tag::Column::Id.eq(tag_id))
.exec(txn)
.await?;
}
Ok(())
}
pub async fn search_posts(
db: &DatabaseConnection,
query: PostSearchQuery,
is_admin: bool,
) -> Result<PostSearchResponse, AppError> {
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(10).min(50); // 限制最大每頁數量
let offset = (page - 1) * page_size;
// 建立基本查詢
let mut select = post::Entity::find();
// 狀態篩選
match query.status.as_deref() {
Some("published") => {
select = select.filter(post::Column::IsPublished.eq(true));
},
Some("draft") => {
if is_admin {
select = select.filter(post::Column::IsPublished.eq(false));
} else {
return Err(AppError::Forbidden("無權限查看草稿".to_string()));
}
},
Some("all") => {
if !is_admin {
select = select.filter(post::Column::IsPublished.eq(true));
}
},
_ => {
if !is_admin {
select = select.filter(post::Column::IsPublished.eq(true));
}
}
}
// 關鍵字搜尋
let applied_keyword = if let Some(ref keyword) = query.q {
let search_term = format!("%{}%", keyword.trim());
select = select.filter(
Condition::any()
.add(post::Column::Title.like(&search_term))
.add(post::Column::Content.like(&search_term))
.add(post::Column::Excerpt.like(&search_term))
);
Some(keyword.clone())
} else {
None
};
// 標籤篩選
let applied_tags = if let Some(ref tags_str) = query.tags {
let tag_names: Vec<&str> = tags_str.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if !tag_names.is_empty() {
// 使用 EXISTS 子查詢來篩選包含指定標籤的文章
for tag_name in &tag_names {
select = select.filter(
Expr::exists(
Query::select()
.column(post_tag::Column::PostId)
.from(post_tag::Entity)
.inner_join(
tag::Entity,
Expr::col((tag::Entity, tag::Column::Id))
.equals((post_tag::Entity, post_tag::Column::TagId))
)
.and_where(
Expr::col((post_tag::Entity, post_tag::Column::PostId))
.equals((post::Entity, post::Column::Id))
)
.and_where(
Expr::col((tag::Entity, tag::Column::Name))
.eq(*tag_name)
)
.take()
)
);
}
tag_names.into_iter().map(|s| s.to_string()).collect()
} else {
Vec::new()
}
} else {
Vec::new()
};
// 日期範圍篩選
let (date_start, date_end) = if let (Some(from), Some(to)) = (&query.from_date, &query.to_date) {
if let (Ok(start_date), Ok(end_date)) = (
chrono::NaiveDate::parse_from_str(from, "%Y-%m-%d"),
chrono::NaiveDate::parse_from_str(to, "%Y-%m-%d")
) {
let start_datetime = start_date.and_hms_opt(0, 0, 0)
.ok_or_else(|| AppError::BadRequest("無效的開始日期".to_string()))?
.and_utc();
let end_datetime = end_date.and_hms_opt(23, 59, 59)
.ok_or_else(|| AppError::BadRequest("無效的結束日期".to_string()))?
.and_utc();
select = select.filter(
post::Column::CreatedAt.between(start_datetime, end_datetime)
);
(Some(from.clone()), Some(to.clone()))
} else {
return Err(AppError::BadRequest("日期格式錯誤,請使用 YYYY-MM-DD 格式".to_string()));
}
} else {
(None, None)
};
// 排序
let sort_by = query.sort_by.as_deref().unwrap_or("created_at");
let sort_order = query.sort_order.as_deref().unwrap_or("desc");
select = match (sort_by, sort_order) {
("title", "desc") => select.order_by_desc(post::Column::Title),
("title", _) => select.order_by_asc(post::Column::Title),
("updated_at", "asc") => select.order_by_asc(post::Column::UpdatedAt),
("updated_at", _) => select.order_by_desc(post::Column::UpdatedAt),
("view_count", "asc") => select.order_by_asc(post::Column::ViewCount),
("view_count", _) => select.order_by_desc(post::Column::ViewCount),
("created_at", "asc") => select.order_by_asc(post::Column::CreatedAt),
_ => select.order_by_desc(post::Column::CreatedAt),
};
// 計算總數
let total_count = select.clone().count(db).await?;
let total_pages = (total_count + page_size - 1) / page_size;
// 執行分頁查詢
let posts = select
.offset(offset)
.limit(page_size)
.all(db)
.await?;
// 取得文章的標籤和留言數
let mut post_responses = Vec::new();
for post in posts {
let tags = Self::get_tags_for_post(db, post.id).await?
.into_iter()
.map(|tag_name| TagSummaryResponse {
id: 0,
name: tag_name.clone(),
color: Self::generate_tag_color(&tag_name),
})
.collect();
// 計算留言數(只計算已審核的)
let comment_count = comment::Entity::find()
.filter(comment::Column::PostId.eq(post.id))
.filter(comment::Column::Status.eq(CommentStatus::Approved))
.count(db)
.await? as i32;
post_responses.push(PostSummaryResponse {
id: post.id,
title: post.title,
excerpt: post.excerpt,
slug: post.slug,
is_published: post.is_published,
view_count: post.view_count,
created_at: post.created_at,
updated_at: post.updated_at,
published_at: post.published_at,
tags,
comment_count,
});
}
// 產生搜尋摘要
let search_summary = Self::generate_search_summary(
total_count,
&applied_keyword,
&applied_tags,
&date_start,
&date_end,
);
Ok(PostSearchResponse {
total_count: total_count as i64,
total_pages,
current_page: page,
page_size,
posts: post_responses,
search_summary,
filters_applied: SearchFiltersApplied {
keyword: applied_keyword,
tags: applied_tags,
status: query.status,
date_range_start: date_start,
date_range_end: date_end,
},
})
}
/// 取得熱門搜尋與建議
pub async fn get_popular_search_data(
db: &DatabaseConnection,
) -> Result<PopularSearchResponse, AppError> {
// 取得熱門標籤(按文章數排序)
let popular_tags = tag::Entity::find()
.filter(tag::Column::PostCount.gt(0))
.order_by_desc(tag::Column::PostCount)
.limit(10)
.all(db)
.await?
.into_iter()
.map(|tag| tag.name)
.collect();
// 取得最近發布的文章
let recent_posts = post::Entity::find()
.filter(post::Column::IsPublished.eq(true))
.order_by_desc(post::Column::PublishedAt)
.limit(5)
.all(db)
.await?;
let mut recent_post_responses = Vec::new();
for post in recent_posts {
let tags = Self::get_tags_for_post(db, post.id).await?
.into_iter()
.map(|tag_name| TagSummaryResponse {
id: 0,
name: tag_name.clone(),
color: Self::generate_tag_color(&tag_name),
})
.collect();
let comment_count = comment::Entity::find()
.filter(comment::Column::PostId.eq(post.id))
.filter(comment::Column::Status.eq(CommentStatus::Approved))
.count(db)
.await? as i32;
recent_post_responses.push(PostSummaryResponse {
id: post.id,
title: post.title,
excerpt: post.excerpt,
slug: post.slug,
is_published: post.is_published,
view_count: post.view_count,
created_at: post.created_at,
updated_at: post.updated_at,
published_at: post.published_at,
tags,
comment_count,
});
}
// 搜尋建議(基於文章標題的常見關鍵字)
let search_suggestions = vec![
"Rust".to_string(),
"程式設計".to_string(),
"Web 開發".to_string(),
"後端".to_string(),
"教學".to_string(),
];
// 統計資料
let total_posts = post::Entity::find().count(db).await? as i64;
let total_published = post::Entity::find()
.filter(post::Column::IsPublished.eq(true))
.count(db)
.await? as i64;
Ok(PopularSearchResponse {
popular_tags,
recent_posts: recent_post_responses,
search_suggestions,
total_posts,
total_published,
})
}
/// 產生搜尋摘要文字
fn generate_search_summary(
total_count: u64,
keyword: &Option<String>,
tags: &[String],
date_start: &Option<String>,
date_end: &Option<String>,
) -> String {
let mut parts = Vec::new();
parts.push(format!("找到 {} 篇文章", total_count));
if let Some(kw) = keyword {
parts.push(format!("包含關鍵字 '{}'", kw));
}
if !tags.is_empty() {
if tags.len() == 1 {
parts.push(format!("標籤為 '{}'", tags[0]));
} else {
parts.push(format!("標籤包含 {}", tags.join(", ")));
}
}
if let (Some(start), Some(end)) = (date_start, date_end) {
parts.push(format!("發布日期在 {} 到 {} 之間", start, end));
}
parts.join(",")
}
}
更新 src/routes/posts.rs
,新增搜尋端點:
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::Json,
routing::get,
Router,
};
use validator::Validate;
use crate::{
dtos::{CreatePostRequest, DeletePostResponse, PostDetailResponse, PostListQuery, PostListResponse, PostResponse, UpdatePostRequest},
error::AppError,
services::PostService,
state::AppState,
};
use crate::dtos::{PopularSearchResponse, PostSearchQuery, PostSearchResponse};
pub fn create_post_routes() -> Router<AppState> {
Router::new()
.route("/posts", get(get_posts).post(create_post))
.route("/posts/{identifier}", get(get_post_by_slug))
.route("/posts/search", get(search_posts)) // 新增搜尋端點
.route("/posts/popular", get(get_popular_data)) // 新增熱門資料端點
.route(
"/admin/posts/{id}",
get(get_post_for_admin).put(update_post).delete(delete_post),
)
.route("/admin/posts/search", get(admin_search_posts))
}
/// 取得已發布文章列表
#[utoipa::path(
get,
path = "/posts",
tag = "posts",
params(PostListQuery),
responses(
(status = 200, description = "文章列表", body = Vec<PostListResponse>),
(status = 400, description = "請求參數錯誤")
)
)]
pub async fn get_posts(
State(app_state): State<AppState>,
Query(query): Query<PostListQuery>,
) -> Result<Json<Vec<PostListResponse>>, AppError> {
let posts = PostService::get_published_posts(&app_state.db, query).await?;
Ok(Json(posts))
}
/// 根據 slug 或 id 取得文章詳情(公開查看)
#[utoipa::path(
get,
path = "/posts/{identifier}", // ← 跟上面 route 完全相同
tag = "posts",
params(
("identifier" = String, Path, description = "文章 slug 或 ID")
),
responses(
(status = 200, description = "文章詳情", body = PostDetailResponse),
(status = 404, description = "文章不存在")
)
)]
pub async fn get_post_by_slug(
State(app_state): State<AppState>,
Path(identifier): Path<String>,
) -> Result<Json<PostDetailResponse>, AppError> {
if let Ok(post) = PostService::get_post_by_slug_or_id(&app_state.db, &identifier).await {
let db = app_state.db.clone();
let post_id = post.id;
tokio::spawn(async move {
let _ = PostService::increment_view_count(&db, post_id).await;
});
Ok(Json(post))
} else {
Err(AppError::NotFound("文章不存在".to_string()))
}
}
#[utoipa::path(
post,
path = "/posts",
tag = "admin",
request_body = CreatePostRequest,
responses(
(status = 201, description = "文章建立成功", body = PostResponse),
(status = 400, description = "請求資料錯誤"),
(status = 409, description = "Slug 已存在")
)
)]
pub async fn create_post(
State(app_state): State<AppState>,
Json(req): Json<CreatePostRequest>,
) -> Result<(StatusCode, Json<PostResponse>), AppError> {
req.validate().map_err(|e| AppError::ValidationError(e.to_string()))?;
let post = PostService::create_post(&app_state.db, req).await?;
Ok((StatusCode::CREATED, Json(post)))
}
#[utoipa::path(
get,
path = "/admin/posts/{id}",
tag = "admin",
params(("id" = i32, Path, description = "文章 ID")),
responses(
(status = 200, description = "文章詳情", body = PostDetailResponse),
(status = 404, description = "文章不存在")
)
)]
pub async fn get_post_for_admin(
State(app_state): State<AppState>,
Path(id): Path<i32>,
) -> Result<Json<PostDetailResponse>, AppError> {
let post = PostService::get_post_for_admin(&app_state.db, id).await?;
Ok(Json(post))
}
#[utoipa::path(
put,
path = "/admin/posts/{id}",
tag = "admin",
params(("id" = i32, Path, description = "文章 ID")),
request_body = UpdatePostRequest,
responses(
(status = 200, description = "文章更新成功", body = PostDetailResponse),
(status = 400, description = "請求資料錯誤"),
(status = 404, description = "文章不存在"),
(status = 409, description = "Slug 衝突")
)
)]
pub async fn update_post(
State(app_state): State<AppState>,
Path(id): Path<i32>,
Json(req): Json<UpdatePostRequest>,
) -> Result<Json<PostDetailResponse>, AppError> {
req.validate().map_err(|e| AppError::ValidationError(e.to_string()))?;
let post = PostService::update_post(&app_state.db, id, req).await?;
Ok(Json(post))
}
#[utoipa::path(
delete,
path = "/admin/posts/{id}",
tag = "admin",
params(("id" = i32, Path, description = "文章 ID")),
responses(
(status = 200, description = "文章刪除成功", body = DeletePostResponse),
(status = 404, description = "文章不存在")
)
)]
pub async fn delete_post(
State(app_state): State<AppState>,
Path(id): Path<i32>,
) -> Result<Json<DeletePostResponse>, AppError> {
let result = PostService::delete_post(&app_state.db, id).await?;
Ok(Json(result))
}
#[utoipa::path(
get,
path = "/posts/search",
tag = "posts",
params(PostSearchQuery),
responses(
(status = 200, description = "搜尋結果", body = PostSearchResponse),
(status = 400, description = "搜尋參數錯誤")
)
)]
pub async fn search_posts(
State(app_state): State<AppState>,
Query(query): Query<PostSearchQuery>,
) -> Result<Json<PostSearchResponse>, AppError> {
// 驗證輸入
query.validate().map_err(|e| {
AppError::BadRequest(format!("搜尋參數驗證失敗: {}", e))
})?;
let results = PostService::search_posts(&app_state.db, query, false).await?;
Ok(Json(results))
}
/// 取得熱門搜尋資料
#[utoipa::path(
get,
path = "/posts/popular",
tag = "posts",
responses(
(status = 200, description = "熱門搜尋資料", body = PopularSearchResponse)
)
)]
pub async fn get_popular_data(
State(app_state): State<AppState>,
) -> Result<Json<PopularSearchResponse>, AppError> {
let data = PostService::get_popular_search_data(&app_state.db).await?;
Ok(Json(data))
}
/// 管理員進階搜尋
#[utoipa::path(
get,
path = "/admin/posts/search",
tag = "admin",
params(PostSearchQuery),
responses(
(status = 200, description = "管理員搜尋結果", body = PostSearchResponse),
(status = 401, description = "需要管理員權限")
)
)]
pub async fn admin_search_posts(
State(app_state): State<AppState>,
Query(query): Query<PostSearchQuery>,
) -> Result<Json<PostSearchResponse>, AppError> {
query.validate().map_err(|e| {
AppError::BadRequest(format!("搜尋參數驗證失敗: {}", e))
})?;
let results = PostService::search_posts(&app_state.db, query, true).await?;
Ok(Json(results))
}
更新 src/docs.rs
,加入留言相關的 API:
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa::{Modify, OpenApi};
#[derive(OpenApi)]
#[openapi(
paths(
// 原有的路徑...
crate::routes::comments::get_comments,
crate::routes::comments::create_comment,
crate::routes::comments::get_admin_comments,
crate::routes::comments::update_comment_status,
crate::routes::comments::delete_comment,
),
components(
schemas(
// 原有的 schemas...
crate::dtos::CreateCommentRequest,
crate::dtos::CommentResponse,
crate::dtos::CommentListQuery,
crate::dtos::UpdateCommentStatusRequest,
crate::dtos::CommentModerationResponse,
)
),
tags(
// 原有的 tags...
(name = "comments", description = "留言相關 API"),
),
// 其餘設定保持不變...
)]
pub struct ApiDoc;
# 搜尋包含 "rust" 的文章
curl "http://localhost:3000/api/posts/search?q=rust"
# 關鍵字搜尋並指定排序
curl "http://localhost:3000/api/posts/search?q=教學&sort_by=view_count&sort_order=desc"
# 搜尋特定標籤的文章
curl "http://localhost:3000/api/posts/search?tags=rust,程式語言"
# 組合關鍵字和標籤
curl "http://localhost:3000/api/posts/search?q=學習&tags=rust"
# 搜尋特定日期範圍的文章
curl "http://localhost:3000/api/posts/search?from_date=2024-01-01&to_date=2024-12-31"
# 組合多種篩選條件
curl "http://localhost:3000/api/posts/search?q=rust&tags=教學&from_date=2024-01-01&sort_by=created_at&sort_order=desc"
# 分頁搜尋結果
curl "http://localhost:3000/api/posts/search?q=rust&page=1&page_size=5"
# 管理員搜尋(包含草稿)
curl "http://localhost:3000/api/admin/posts/search?status=all&q=rust"
curl http://localhost:3000/api/posts/popular
# 複合條件搜尋
curl "http://localhost:3000/api/posts/search?q=rust+教學&tags=程式語言,學習&from_date=2024-01-01&to_date=2024-06-30&sort_by=view_count&sort_order=desc&page=1&page_size=10"
今天我們大幅提升了個人部落格的使用體驗:
強大的搜尋功能:
靈活的排序與分頁:
智慧的搜尋體驗:
效能優化考量:
使用者體驗提升:
API 設計原則:
今天我們成功為個人部落格加入了強大的搜尋和篩選功能!從簡單的關鍵字搜尋到複雜的多條件組合查詢,每個功能都考慮到了實際使用場景和效能表現。更重要的是,我們保持了 API 的簡潔性,讓前端開發者能夠輕鬆整合這些功能。
搜尋功能完成後,我們的個人部落格已經具備了現代網站的核心能力。
我們明天見!