iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Rust

大家一起跟Rust當好朋友吧!系列 第 27

Day 27: 提升體驗 - 個人部落格的實用功能

  • 分享至 

  • xImage
  •  

嗨嗨!大家好!歡迎來到 Rust 三十天挑戰的第二十七天!

昨天我們完成了一個有溫度的留言系統,建立了創作者與讀者之間的互動橋樑。今天我們要進入的主題是:提升體驗 - 個人部落格的實用功能

隨著部落格功能逐漸完整,我們需要考慮的不再只是「有沒有」這個功能,而是「好不好用」這個體驗。對個人創作者來說,一個好用的部落格系統不僅要功能齊全,還要操作順暢、回應迅速、查詢靈活。


🎯 今天的目標

  1. 文章搜尋系統:關鍵字搜尋、標題內容檢索
  2. 分頁查詢優化:高效能的分頁實作與參數處理
  3. 多條件篩選:標籤、狀態、日期範圍的靈活組合
  4. 排序功能擴展:多欄位排序、智慧排序邏輯
  5. API 回應優化:減少冗餘資料、提升載入速度
  6. 效能最佳化:查詢優化與實用的索引策略

📋 搜尋與篩選 DTO 優化

我們先優化現有的查詢 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))
}

📚 更新 OpenAPI 文件

更新 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;

🚀 測試進階搜尋功能

1. 關鍵字搜尋

# 搜尋包含 "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"

2. 標籤篩選

# 搜尋特定標籤的文章
curl "http://localhost:3000/api/posts/search?tags=rust,程式語言"

# 組合關鍵字和標籤
curl "http://localhost:3000/api/posts/search?q=學習&tags=rust"

3. 日期範圍篩選

# 搜尋特定日期範圍的文章
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"

4. 分頁搜尋

# 分頁搜尋結果
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"

5. 取得熱門搜尋資料

curl http://localhost:3000/api/posts/popular

6. 進階組合搜尋

# 複合條件搜尋
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"

🚀 今天的收穫

今天我們大幅提升了個人部落格的使用體驗:

強大的搜尋功能

  • ✅ 關鍵字全文搜尋,支援標題、內容、摘要
  • ✅ 標籤篩選,支援多標籤組合查詢
  • ✅ 日期範圍篩選,精確定位時間段
  • ✅ 狀態篩選,管理員可查看草稿

靈活的排序與分頁

  • ✅ 多欄位排序(時間、標題、瀏覽量)
  • ✅ 雙向排序支援(升序、降序)
  • ✅ 高效能分頁實作,自動計算總頁數
  • ✅ 頁面大小限制,避免效能問題

智慧的搜尋體驗

  • ✅ 搜尋結果摘要,清晰顯示篩選條件
  • ✅ 應用的篩選器回傳,方便前端顯示
  • ✅ 熱門標籤建議,提升搜尋效率
  • ✅ 最近文章推薦,增加內容曝光

效能優化考量

  • ✅ 使用 EXISTS 子查詢處理標籤篩選
  • ✅ 合理的分頁限制,避免大量資料查詢
  • ✅ 索引友善的查詢設計
  • ✅ 條件組合的邏輯最佳化

使用者體驗提升

  • ✅ 清晰的錯誤訊息和參數驗證
  • ✅ 靈活的權限控制(管理員 vs 一般使用者)
  • ✅ 結構化的回應格式,前端易於處理
  • ✅ 搜尋無結果時的友善提示

API 設計原則

  • ✅ RESTful 風格的端點設計
  • ✅ 向下相容的參數結構
  • ✅ 完整的 OpenAPI 文件支援
  • ✅ 一致的錯誤處理模式

今天我們成功為個人部落格加入了強大的搜尋和篩選功能!從簡單的關鍵字搜尋到複雜的多條件組合查詢,每個功能都考慮到了實際使用場景和效能表現。更重要的是,我們保持了 API 的簡潔性,讓前端開發者能夠輕鬆整合這些功能。

搜尋功能完成後,我們的個人部落格已經具備了現代網站的核心能力。

我們明天見!


上一篇
Day 26: 留言系統 - 與讀者的互動
下一篇
Day 28: 優化使用者體驗 - 快取策略與效能提升
系列文
大家一起跟Rust當好朋友吧!30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言