iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Rust

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

Day 25: 標籤系統 - 個人化的內容組織

  • 分享至 

  • xImage
  •  

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

昨天我們完成了文章管理的完整功能,今天我們要進入一個同樣重要但更有趣的主題:標籤系統!對個人創作者來說,標籤不只是分類工具,更是思維的延伸,是幫助讀者發現相關內容的橋樑。

想像一下:當你寫了關於「Rust」、「學習心得」、「後端開發」的文章,這些標籤不只是標籤,它們代表了你的知識領域、興趣方向,甚至是你的成長軌跡。今天我們要打造一個既實用又有溫度的標籤系統!


🎯 今天的目標

  1. 標籤管理 CRUD:完整的標籤生命週期管理
  2. 多對多關聯處理:深入理解複雜資料關係
  3. 智慧標籤功能:自動建議、熱門統計、顏色管理
  4. 內容發現機制:透過標籤探索相關文章
  5. 個人化設計:符合創作者思維的標籤體驗

📋 擴展標籤相關 DTO

首先建立標籤系統的資料傳輸物件。建立 src/dtos/tag.rs

use serde::{Deserialize, Serialize};
use serde_json::json;
use utoipa::ToSchema;
use validator::Validate;

#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateTagRequest {
    #[validate(length(min = 1, max = 50, message = "標籤名稱長度必須在 1-50 字元之間"))]
    #[validate(regex(
        path = "crate::utils::validation::TAG_NAME_REGEX",
        message = "標籤名稱只能包含中英文、數字、連字符和底線"
    ))]
    #[schema(example = "rust")]
    pub name: String,

    #[validate(length(max = 255, message = "描述不能超過 255 字元"))]
    #[schema(example = "Rust 程式語言相關文章")]
    pub description: Option<String>,

    #[validate(regex(
        path = "crate::utils::validation::COLOR_REGEX",
        message = "顏色必須是有效的十六進位色碼格式 (#RRGGBB)"
    ))]
    #[schema(example = "#8B5CF6")]
    pub color: Option<String>,
}


#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdateTagRequest {
    #[validate(length(min = 1, max = 50, message = "標籤名稱長度必須在 1-50 字元之間"))]
    #[validate(regex(
        path = "crate::utils::validation::TAG_NAME_REGEX",
        message = "標籤名稱只能包含中英文、數字、連字符和底線"
    ))]
    #[schema(example = "rust-updated")]
    pub name: Option<String>,

    #[validate(length(max = 255, message = "描述不能超過 255 字元"))]
    #[schema(example = "更新後的 Rust 程式語言描述")]
    pub description: Option<String>,

    #[validate(regex(
        path = "crate::utils::validation::COLOR_REGEX",
        message = "顏色必須是有效的十六進位色碼格式 (#RRGGBB)"
    ))]
    #[schema(example = "#10B981")]
    pub color: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct TagResponse {
    #[schema(example = 1)]
    pub id: i32,

    #[schema(example = "rust")]
    pub name: String,

    #[schema(example = "Rust 程式語言相關文章")]
    pub description: Option<String>,

    #[schema(example = "#8B5CF6")]
    pub color: String,

    #[schema(example = 15)]
    pub post_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-15T15:45:00Z")]
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct TagWithPostsResponse {
    #[schema(example = 1)]
    pub id: i32,

    #[schema(example = "rust")]
    pub name: String,

    #[schema(example = "Rust 程式語言相關文章")]
    pub description: Option<String>,

    #[schema(example = "#8B5CF6")]
    pub color: String,

    #[schema(example = 15)]
    pub post_count: i32,

    #[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
    pub created_at: chrono::DateTime<chrono::Utc>,

    pub posts: Vec<crate::dtos::PostListResponse>,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct DeleteTagResponse {
    #[schema(example = true)]
    pub success: bool,

    #[schema(example = "標籤已成功刪除")]
    pub message: String,

    #[schema(example = 1)]
    pub deleted_id: i32,

    #[schema(example = 15)]
    pub affected_posts: i32,
}

#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct TagListQuery {
    #[schema(example = 1)]
    pub page: Option<u64>,

    #[schema(example = 20)]
    pub page_size: Option<u64>,

    #[schema(example = "rust")]
    pub search: Option<String>,

    #[schema(example = "post_count")]
    pub sort_by: Option<String>, // name, post_count, created_at

    #[schema(example = "desc")]
    pub sort_order: Option<String>, // asc, desc

    #[schema(example = true)]
    pub include_empty: Option<bool>, // 是否包含沒有文章的標籤
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct TagSuggestionResponse {
    #[schema(example = json!(["rust", "程式設計", "學習心得"]))]
    pub suggestions: Vec<String>,

    #[schema(example = 10)]
    pub total_tags: i32,

    #[schema(example = json!(["rust", "javascript", "python"]))]
    pub popular_tags: Vec<String>,
}

記得更新 src/dtos/mod.rs

pub mod health;
pub mod blog;
pub mod post;
pub mod tag; // 新增

pub use health::*;
pub use blog::*;
pub use post::*;
pub use tag::*; // 新增

🔧 新增驗證工具

建立 src/utils/validation.rs

use regex::Regex;
use once_cell::sync::Lazy;
use std::sync::LazyLock;

pub static TAG_NAME_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^[\p{L}\p{N}_-]+$").expect("TAG_NAME_REGEX"));

pub static COLOR_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^#[0-9A-Fa-f]{6}$").expect("COLOR_REGEX"));

pub static SLUG_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^[a-z0-9-]+$").expect("SLUG_REGEX"));

pub fn generate_random_color() -> String {
    use rand::Rng;

    let colors = [
        "#EF4444", "#F97316", "#F59E0B", "#EAB308", "#84CC16",
        "#22C55E", "#10B981", "#14B8A6", "#06B6D4", "#0EA5E9",
        "#3B82F6", "#6366F1", "#8B5CF6", "#A855F7", "#D946EF",
        "#EC4899", "#F43F5E", "#64748B", "#6B7280", "#374151",
    ];

    let mut rng = rand::thread_rng();
    colors[rng.gen_range(0..colors.len())].to_string()
}

pub fn sanitize_tag_name(name: &str) -> String {
    name.trim()
        .to_lowercase()
        .chars()
        .map(|c| {
            if c.is_alphanumeric() || c == '-' || c == '_' {
                c
            } else if c.is_whitespace() {
                '-'
            } else {
                '_'
            }
        })
        .collect::<String>()
        .split('-')
        .filter(|s| !s.is_empty())
        .collect::<Vec<_>>()
        .join("-")
}

記得更新 src/utils/mod.rs

pub mod validation; // 新增
pub mod slug;

pub use validation::*; // 新增
pub use slug::*;

並在 Cargo.toml 中新增依賴:

once_cell = "1.21.3"
regex = "1.11.2"
rand = "0.8"

🏗️ 標籤服務層實作

建立 src/services/tag.rs

use sea_orm::*;
use sea_orm::prelude::Expr;
use tracing::{info, error};

use crate::{
    dtos::{
        CreateTagRequest, UpdateTagRequest, TagResponse, TagWithPostsResponse,
        DeleteTagResponse, TagListQuery, TagSuggestionResponse, PostListResponse,
    },
    entities::{tag, post, post_tag},
    error::AppError,
    utils::validation::{generate_random_color, sanitize_tag_name},
};

pub struct TagService;

impl TagService {
    /// 取得標籤列表
    pub async fn get_tags(
        db: &DatabaseConnection,
        query: TagListQuery,
    ) -> Result<Vec<TagResponse>, AppError> {
        let page = query.page.unwrap_or(1);
        let page_size = query.page_size.unwrap_or(20).min(100); // 限制最大 100 筆
        let offset = (page - 1) * page_size;

        let mut select = tag::Entity::find();

        // 搜尋功能
        if let Some(search) = &query.search {
            let search_term = format!("%{}%", search.trim());
            select = select.filter(
                Condition::any()
                    .add(tag::Column::Name.like(&search_term))
                    .add(tag::Column::Description.like(&search_term))
            );
        }

        // 是否包含空標籤(沒有文章的標籤)
        if !query.include_empty.unwrap_or(true) {
            select = select.filter(tag::Column::PostCount.gt(0));
        }

        // 排序
        let sort_by = query.sort_by.as_deref().unwrap_or("name");
        let sort_order = query.sort_order.as_deref().unwrap_or("asc");

        select = match (sort_by, sort_order) {
            ("name", "desc") => select.order_by_desc(tag::Column::Name),
            ("name", _) => select.order_by_asc(tag::Column::Name),
            ("post_count", "asc") => select.order_by_asc(tag::Column::PostCount),
            ("post_count", _) => select.order_by_desc(tag::Column::PostCount),
            ("created_at", "desc") => select.order_by_desc(tag::Column::CreatedAt),
            ("created_at", _) => select.order_by_asc(tag::Column::CreatedAt),
            _ => select.order_by_asc(tag::Column::Name),
        };

        let tags = select
            .offset(offset)
            .limit(page_size)
            .all(db)
            .await?;

        let tag_responses = tags.into_iter().map(|tag| TagResponse {
            id: tag.id,
            name: tag.name,
            description: tag.description,
            color: tag.color,
            post_count: tag.post_count,
            created_at: tag.created_at,
            updated_at: tag.updated_at,
        }).collect();

        Ok(tag_responses)
    }

    /// 根據 ID 取得單一標籤
    pub async fn get_tag_by_id(
        db: &DatabaseConnection,
        tag_id: i32,
    ) -> Result<TagResponse, AppError> {
        let tag = tag::Entity::find_by_id(tag_id)
            .one(db)
            .await?
            .ok_or_else(|| AppError::NotFound("標籤不存在".to_string()))?;

        Ok(TagResponse {
            id: tag.id,
            name: tag.name,
            description: tag.description,
            color: tag.color,
            post_count: tag.post_count,
            created_at: tag.created_at,
            updated_at: tag.updated_at,
        })
    }

    /// 根據名稱取得標籤及其文章
    pub async fn get_tag_with_posts(
        db: &DatabaseConnection,
        tag_name: &str,
        page: Option<u64>,
        page_size: Option<u64>,
    ) -> Result<TagWithPostsResponse, AppError> {
        let tag = tag::Entity::find()
            .filter(tag::Column::Name.eq(tag_name))
            .one(db)
            .await?
            .ok_or_else(|| AppError::NotFound(format!("標籤 '{}' 不存在", tag_name)))?;

        // 分頁設定
        let page = page.unwrap_or(1);
        let page_size = page_size.unwrap_or(10).min(50);
        let offset = (page - 1) * page_size;

        // 查詢該標籤的已發布文章
        let posts = post::Entity::find()
            .filter(post::Column::IsPublished.eq(true))
            .join(JoinType::InnerJoin, post_tag::Relation::Post.def())
            .filter(post_tag::Column::TagId.eq(tag.id))
            .order_by_desc(post::Column::PublishedAt)
            .offset(offset)
            .limit(page_size)
            .all(db)
            .await?;

        let post_responses = 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: vec![], // 這裡可以後續優化,避免 N+1 查詢
        }).collect();

        Ok(TagWithPostsResponse {
            id: tag.id,
            name: tag.name,
            description: tag.description,
            color: tag.color,
            post_count: tag.post_count,
            created_at: tag.created_at,
            posts: post_responses,
        })
    }

    /// 建立新標籤
    pub async fn create_tag(
        db: &DatabaseConnection,
        req: CreateTagRequest,
    ) -> Result<TagResponse, AppError> {
        let sanitized_name = sanitize_tag_name(&req.name);

        if sanitized_name.is_empty() {
            return Err(AppError::BadRequest("標籤名稱不能為空".to_string()));
        }

        // 檢查標籤名稱是否已存在
        let existing = tag::Entity::find()
            .filter(tag::Column::Name.eq(&sanitized_name))
            .one(db)
            .await?;

        if existing.is_some() {
            return Err(AppError::BadRequest(format!("標籤 '{}' 已存在", sanitized_name)));
        }

        let color = req.color.unwrap_or_else(generate_random_color);

        let tag_active_model = tag::ActiveModel {
            name: Set(sanitized_name),
            description: Set(req.description),
            color: Set(color),
            ..Default::default()
        };

        let tag = tag_active_model.insert(db).await.map_err(|e| {
            error!("建立標籤失敗: {e:#?}");
            AppError::from(e)
        })?;

        info!("標籤 '{}' 建立成功,ID: {}", tag.name, tag.id);

        Ok(TagResponse {
            id: tag.id,
            name: tag.name,
            description: tag.description,
            color: tag.color,
            post_count: tag.post_count,
            created_at: tag.created_at,
            updated_at: tag.updated_at,
        })
    }

    /// 更新標籤
    pub async fn update_tag(
        db: &DatabaseConnection,
        tag_id: i32,
        req: UpdateTagRequest,
    ) -> Result<TagResponse, AppError> {
        let tag = tag::Entity::find_by_id(tag_id)
            .one(db)
            .await?
            .ok_or_else(|| AppError::NotFound("標籤不存在".to_string()))?;

        let mut updated_tag: tag::ActiveModel = tag.into();

        if let Some(name) = req.name {
            let sanitized_name = sanitize_tag_name(&name);

            if sanitized_name.is_empty() {
                return Err(AppError::BadRequest("標籤名稱不能為空".to_string()));
            }

            // 檢查新名稱是否與其他標籤衝突
            if sanitized_name != *updated_tag.name.as_ref() {
                let existing = tag::Entity::find()
                    .filter(tag::Column::Name.eq(&sanitized_name))
                    .filter(tag::Column::Id.ne(tag_id))
                    .one(db)
                    .await?;

                if existing.is_some() {
                    return Err(AppError::BadRequest(format!("標籤 '{}' 已存在", sanitized_name)));
                }
            }

            updated_tag.name = Set(sanitized_name);
        }

        if let Some(description) = req.description {
            updated_tag.description = Set(Some(description));
        }

        if let Some(color) = req.color {
            updated_tag.color = Set(color);
        }

        let updated = updated_tag.update(db).await.map_err(|e| {
            error!("更新標籤失敗: {e:#?}");
            AppError::from(e)
        })?;

        info!("標籤 {} '{}' 更新成功", tag_id, updated.name);

        Ok(TagResponse {
            id: updated.id,
            name: updated.name,
            description: updated.description,
            color: updated.color,
            post_count: updated.post_count,
            created_at: updated.created_at,
            updated_at: updated.updated_at,
        })
    }

    /// 刪除標籤
    pub async fn delete_tag(
        db: &DatabaseConnection,
        tag_id: i32,
    ) -> Result<DeleteTagResponse, AppError> {
        let tag = tag::Entity::find_by_id(tag_id)
            .one(db)
            .await?
            .ok_or_else(|| AppError::NotFound("標籤不存在".to_string()))?;

        // 計算受影響的文章數量
        let affected_posts = post_tag::Entity::find()
            .filter(post_tag::Column::TagId.eq(tag_id))
            .count(db)
            .await?;

        // 開始交易
        let txn = db.begin().await?;

        // 刪除所有相關的文章-標籤關聯
        post_tag::Entity::delete_many()
            .filter(post_tag::Column::TagId.eq(tag_id))
            .exec(&txn)
            .await?;

        // 刪除標籤
        tag::Entity::delete_by_id(tag_id)
            .exec(&txn)
            .await?;

        txn.commit().await?;

        info!("標籤 {} '{}' 已刪除,影響 {} 篇文章", tag_id, tag.name, affected_posts);

        Ok(DeleteTagResponse {
            success: true,
            message: format!("標籤 '{}' 已成功刪除", tag.name),
            deleted_id: tag_id,
            affected_posts: affected_posts as i32,
        })
    }

    /// 取得標籤建議
    pub async fn get_tag_suggestions(
        db: &DatabaseConnection,
        query: Option<String>,
        limit: Option<u64>,
    ) -> Result<TagSuggestionResponse, AppError> {
        let limit = limit.unwrap_or(10).min(20);

        let mut suggestions = Vec::new();

        // 如果有查詢字串,搜尋相似的標籤
        if let Some(q) = &query {
            if !q.trim().is_empty() {
                let search_term = format!("%{}%", q.trim());
                let similar_tags = tag::Entity::find()
                    .filter(tag::Column::Name.like(&search_term))
                    .order_by_desc(tag::Column::PostCount)
                    .limit(limit)
                    .all(db)
                    .await?;

                suggestions = similar_tags.into_iter().map(|tag| tag.name).collect();
            }
        }

        // 取得熱門標籤
        let popular_tags = tag::Entity::find()
            .filter(tag::Column::PostCount.gt(0))
            .order_by_desc(tag::Column::PostCount)
            .limit(10)
            .all(db)
            .await?;

        let popular_tag_names = popular_tags.into_iter().map(|tag| tag.name).collect();

        // 取得總標籤數
        let total_tags = tag::Entity::find().count(db).await? as i32;

        Ok(TagSuggestionResponse {
            suggestions,
            total_tags,
            popular_tags: popular_tag_names,
        })
    }

    /// 更新標籤的文章計數(內部使用)
    pub async fn update_tag_post_count(
        db: &DatabaseConnection,
        tag_id: i32,
    ) -> Result<(), AppError> {
        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(db)
            .await?;

        tag::Entity::update_many()
            .col_expr(tag::Column::PostCount, Expr::value(count as i32))
            .filter(tag::Column::Id.eq(tag_id))
            .exec(db)
            .await?;

        Ok(())
    }
}

記得更新 src/services/mod.rs

pub mod post_service;
pub mod tag_service; // 新增

pub use post_service::PostService;
pub use tag_service::TagService; // 新增

🛣️ 標籤路由實作

建立 src/routes/tags.rs

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::Json,
    routing::{delete, get, post, put},
    Router,
};
use validator::Validate;

use crate::{
    dtos::{
        CreateTagRequest, UpdateTagRequest, TagResponse, TagWithPostsResponse,
        DeleteTagResponse, TagListQuery, TagSuggestionResponse,
    },
    error::AppError,
    services::TagService,
    state::AppState,
};

pub fn create_tag_routes() -> Router<AppState> {
    Router::new()
        .route("/tags", get(get_tags).post(create_tag))
        .route("/tags/suggestions", get(get_tag_suggestions))
        .route("/tags/:name/posts", get(get_tag_with_posts))
        .route(
            "/admin/tags/:id",
            get(get_tag_by_id).put(update_tag).delete(delete_tag),
        )
}

/// 取得標籤列表
#[utoipa::path(
    get,
    path = "/tags",
    tag = "tags",
    params(TagListQuery),
    responses(
        (status = 200, description = "標籤列表", body = Vec<TagResponse>),
        (status = 400, description = "請求參數錯誤")
    )
)]
pub async fn get_tags(
    State(app_state): State<AppState>,
    Query(query): Query<TagListQuery>,
) -> Result<Json<Vec<TagResponse>>, AppError> {
    let tags = TagService::get_tags(&app_state.db, query).await?;
    Ok(Json(tags))
}

/// 根據 ID 取得單一標籤(管理員用)
#[utoipa::path(
    get,
    path = "/admin/tags/{id}",
    tag = "admin",
    params(("id" = i32, Path, description = "標籤 ID")),
    responses(
        (status = 200, description = "標籤詳情", body = TagResponse),
        (status = 404, description = "標籤不存在")
    )
)]
pub async fn get_tag_by_id(
    State(app_state): State<AppState>,
    Path(id): Path<i32>,
) -> Result<Json<TagResponse>, AppError> {
    let tag = TagService::get_tag_by_id(&app_state.db, id).await?;
    Ok(Json(tag))
}

/// 根據名稱取得標籤及其文章
#[utoipa::path(
    get,
    path = "/tags/{name}/posts",
    tag = "tags",
    params(
        ("name" = String, Path, description = "標籤名稱"),
        ("page" = Option<u64>, Query, description = "頁碼"),
        ("page_size" = Option<u64>, Query, description = "每頁筆數")
    ),
    responses(
        (status = 200, description = "標籤及其文章", body = TagWithPostsResponse),
        (status = 404, description = "標籤不存在")
    )
)]
pub async fn get_tag_with_posts(
    State(app_state): State<AppState>,
    Path(name): Path<String>,
    Query(params): Query<TagListQuery>,
) -> Result<Json<TagWithPostsResponse>, AppError> {
    let tag_with_posts = TagService::get_tag_with_posts(
        &app_state.db, 
        &name, 
        params.page, 
        params.page_size
    ).await?;
    Ok(Json(tag_with_posts))
}

/// 建立新標籤
#[utoipa::path(
    post,
    path = "/tags",
    tag = "admin",
    request_body = CreateTagRequest,
    responses(
        (status = 201, description = "標籤建立成功", body = TagResponse),
        (status = 400, description = "請求參數錯誤"),
        (status = 409, description = "標籤已存在")
    )
)]
pub async fn create_tag(
    State(app_state): State<AppState>,
    Json(req): Json<CreateTagRequest>,
) -> Result<(StatusCode, Json<TagResponse>), AppError> {
    req.validate().map_err(|e| AppError::ValidationError(e.to_string()))?;
    
    let tag = TagService::create_tag(&app_state.db, req).await?;
    Ok((StatusCode::CREATED, Json(tag)))
}

/// 更新標籤
#[utoipa::path(
    put,
    path = "/admin/tags/{id}",
    tag = "admin",
    params(("id" = i32, Path, description = "標籤 ID")),
    request_body = UpdateTagRequest,
    responses(
        (status = 200, description = "標籤更新成功", body = TagResponse),
        (status = 400, description = "請求參數錯誤"),
        (status = 404, description = "標籤不存在"),
        (status = 409, description = "標籤名稱已存在")
    )
)]
pub async fn update_tag(
    State(app_state): State<AppState>,
    Path(id): Path<i32>,
    Json(req): Json<UpdateTagRequest>,
) -> Result<Json<TagResponse>, AppError> {
    req.validate().map_err(|e| AppError::ValidationError(e.to_string()))?;
    
    let tag = TagService::update_tag(&app_state.db, id, req).await?;
    Ok(Json(tag))
}

/// 刪除標籤
#[utoipa::path(
    delete,
    path = "/admin/tags/{id}",
    tag = "admin",
    params(("id" = i32, Path, description = "標籤 ID")),
    responses(
        (status = 200, description = "標籤刪除成功", body = DeleteTagResponse),
        (status = 404, description = "標籤不存在")
    )
)]
pub async fn delete_tag(
    State(app_state): State<AppState>,
    Path(id): Path<i32>,
) -> Result<Json<DeleteTagResponse>, AppError> {
    let result = TagService::delete_tag(&app_state.db, id).await?;
    Ok(Json(result))
}

/// 取得標籤建議
#[utoipa::path(
    get,
    path = "/tags/suggestions",
    tag = "tags",
    params(
        ("query" = Option<String>, Query, description = "搜尋關鍵字"),
        ("limit" = Option<u64>, Query, description = "建議數量限制")
    ),
    responses(
        (status = 200, description = "標籤建議", body = TagSuggestionResponse),
        (status = 400, description = "請求參數錯誤")
    )
)]
pub async fn get_tag_suggestions(
    State(app_state): State<AppState>,
    Query(params): Query<serde_json::Value>,
) -> Result<Json<TagSuggestionResponse>, AppError> {
    let query = params.get("query").and_then(|v| v.as_str()).map(|s| s.to_string());
    let limit = params.get("limit").and_then(|v| v.as_u64());
    
    let suggestions = TagService::get_tag_suggestions(&app_state.db, query, limit).await?;
    Ok(Json(suggestions))
}

🔄 更新文章服務,整合標籤計數

我們需要更新 src/services/post.rs,讓文章的標籤操作能自動更新標籤計數:

// 在 PostService 中新增這個方法
impl PostService {
    // ... 保留其他方法

    /// 更新文章時同步更新標籤計數
    async fn sync_tag_counts_for_post(
        txn: &DatabaseTransaction,
        post_id: i32,
        old_tags: Vec<String>,
        new_tags: Vec<String>,
    ) -> Result<(), AppError> {
        use crate::services::TagService;
        
        // 找出被移除的標籤
        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(())
    }

    // 在 update_post、delete_post、create 方法中調用標籤計數同步
    // 你需要在更新文章標籤前後記錄標籤變化,然後調用 sync_tag_counts_for_post
}

🛠️ 更新應用路由

更新 src/routes/mod.rs

pub mod health;
pub mod blog;
pub mod posts;
pub mod tags; // 新增

use axum::Router;
use crate::state::AppState;

pub fn create_routes() -> Router<AppState> {
    Router::new()
        .merge(health::create_health_routes())
        .merge(blog::create_blog_routes())
        .merge(posts::create_post_routes())
        .merge(tags::create_tag_routes()) // 新增
}

📋 更新 OpenAPI 文件

更新 src/docs.rs

use utoipa::OpenApi;

#[derive(OpenApi)]
#[openapi(
    paths(
        // 健康檢查
        crate::routes::health::health_check,
        
        // 部落格資訊
        crate::routes::blog::blog_info,
        
        // 文章相關
        crate::routes::posts::get_posts,
        crate::routes::posts::get_post_by_slug,
        crate::routes::posts::create_post,
        crate::routes::posts::get_post_for_admin,
        crate::routes::posts::update_post,
        crate::routes::posts::delete_post,
        
        // 🆕 標籤相關
        crate::routes::tags::get_tags,
        crate::routes::tags::get_tag_by_id,
        crate::routes::tags::get_tag_with_posts,
        crate::routes::tags::create_tag,
        crate::routes::tags::update_tag,
        crate::routes::tags::delete_tag,
        crate::routes::tags::get_tag_suggestions,
    ),
    components(
        schemas(
            // 健康檢查
            crate::routes::health::HealthCheck,
            
            // 部落格資訊
            crate::routes::blog::BlogInfo,
            
            // 文章相關
            crate::dtos::CreatePostRequest,
            crate::dtos::UpdatePostRequest,
            crate::dtos::PostResponse,
            crate::dtos::PostDetailResponse,
            crate::dtos::PostListResponse,
            crate::dtos::DeletePostResponse,
            crate::dtos::PostListQuery,
            
            // 🆕 標籤相關
            crate::dtos::CreateTagRequest,
            crate::dtos::UpdateTagRequest,
            crate::dtos::TagResponse,
            crate::dtos::TagWithPostsResponse,
            crate::dtos::DeleteTagResponse,
            crate::dtos::TagListQuery,
            crate::dtos::TagSuggestionResponse,
        )
    ),
    tags(
        (name = "health", description = "系統健康狀態 API"),
        (name = "blog", description = "部落格基本資訊 API"),
        (name = "posts", description = "文章相關 API"),
        (name = "tags", description = "標籤相關 API"), // 🆕
        (name = "admin", description = "管理員 API")
    )
)]
pub struct ApiDoc;

✅ 測試標籤系統功能

現在讓我們測試完整的標籤系統!

1. 啟動伺服器

# 確保資料庫正在運行
docker-compose -f docker-compose.dev.yml up -d

# 執行遷移
sea-orm-cli migrate up

# 啟動應用
cargo run

2. 建立標籤

# 建立第一個標籤
curl -X POST http://localhost:3000/api/tags \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Rust",
    "description": "Rust 程式語言相關文章",
    "color": "#8B5CF6"
  }'

# 建立第二個標籤
curl -X POST http://localhost:3000/api/tags \
  -H "Content-Type: application/json" \
  -d '{
    "name": "學習心得",
    "description": "個人學習經驗分享",
    "color": "#10B981"
  }'

# 建立第三個標籤(不指定顏色,會自動生成)
curl -X POST http://localhost:3000/api/tags \
  -H "Content-Type: application/json" \
  -d '{
    "name": "後端開發",
    "description": "後端開發技術與實踐"
  }'

3. 查看標籤列表

# 查看所有標籤
curl http://localhost:3000/api/tags

# 搜尋標籤
curl "http://localhost:3000/api/tags?search=rust"

# 依文章數量排序(降序)
curl "http://localhost:3000/api/tags?sort_by=post_count&sort_order=desc"

# 分頁查詢
curl "http://localhost:3000/api/tags?page=1&page_size=5"

4. 建立帶標籤的文章

curl -X POST http://localhost:3000/api/posts \
  -H "Content-Type: application/json" \
  -d '{
    "title": "我的 Rust 學習之旅",
    "content": "# Rust 學習心得\n\n## 為什麼選擇 Rust\n\n學習 Rust 讓我重新思考程式設計...",
    "tags": ["rust", "學習心得", "後端開發"],
    "is_published": true
  }'

5. 查看標籤的文章

# 查看 Rust 標籤的所有文章
curl http://localhost:3000/api/tags/rust/posts

# 分頁查看
curl "http://localhost:3000/api/tags/rust/posts?page=1&page_size=5"

6. 取得標籤建議

# 取得熱門標籤建議
curl http://localhost:3000/api/tags/suggestions

# 搜尋相關標籤
curl "http://localhost:3000/api/tags/suggestions?query=ru&limit=5"

7. 更新標籤

curl -X PUT http://localhost:3000/api/admin/tags/1 \
  -H "Content-Type: application/json" \
  -d '{
    "description": "更深入的 Rust 程式語言探討",
    "color": "#6366F1"
  }'

8. 查看標籤詳情

curl http://localhost:3000/api/admin/tags/1

9. 刪除標籤

curl -X DELETE http://localhost:3000/api/admin/tags/3

🚀 今天的收穫

今天我們完成了一個功能豐富且貼心的標籤系統:

完整 CRUD 功能

  • ✅ 標籤的建立、查詢、更新、刪除
  • ✅ 智慧的標籤名稱處理和驗證
  • ✅ 顏色管理讓分類更視覺化
  • ✅ 自動生成隨機顏色,減少配置負擔

多對多關聯掌握

  • ✅ 深入理解文章與標籤的複雜關係
  • ✅ 自動維護標籤文章計數
  • ✅ 交易機制確保關聯資料一致性
  • ✅ 高效能的關聯查詢設計

個人化功能設計

  • ✅ 標籤建議系統,提升創作效率
  • ✅ 熱門標籤統計,了解內容趨勢
  • ✅ 靈活的搜尋和排序功能
  • ✅ 空標籤處理,保持系統整潔

創作者友善體驗

  • ✅ 自動標籤名稱正規化
  • ✅ 重複標籤檢查和衝突處理
  • ✅ 分頁查詢支援大量標籤管理
  • ✅ 標籤與文章的雙向導航

技術亮點

  • ✅ 學會處理複雜的多對多關聯
  • ✅ 掌握資料庫交易和計數同步
  • ✅ 實作智慧搜尋和建議系統
  • ✅ 理解正規表達式驗證應用

系統穩定性

  • ✅ 完整的輸入驗證和清理
  • ✅ 友善的錯誤處理和回饋
  • ✅ 效能優化的查詢設計
  • ✅ 資料一致性保證

明天預告

明天我們將進入 Day 26:留言系統 - 有溫度的讀者互動

我們會實作部落格的互動核心:

  • 💬 留言發布與管理 (POST/GET /api/posts/:id/comments)
  • 🛡️ 留言審核機制,確保內容品質
  • 📧 留言通知功能,即時掌握讀者回饋
  • 🎯 垃圾留言過濾,保護創作環境
  • ❤️ 留言回覆功能,增進讀者互動
  • 📊 留言統計分析,了解互動熱度

我們會學到一對多關聯的實作,以及如何設計一個既開放又安全的互動系統!


今天我們成功建立了一個既實用又智慧的標籤系統!從基本的 CRUD 到進階的建議功能,每個細節都考慮到了個人創作者的實際需求。更重要的是,我們深入掌握了多對多關聯的處理,這是現代 Web 應用中非常常見且重要的技術。

看到標籤與文章完美整合,自動計數更新,智慧建議標籤,是不是很有成就感呢?這就是好的系統設計:表面簡單,背後強大!而且通過 Rust 的型別安全,我們可以確信這個系統既高效又穩定。

標籤系統完成後,我們的個人部落格已經有了內容組織的能力。明天的留言系統將為部落格帶來互動的溫度,讓它真正成為連結創作者與讀者的橋樑!

我們明天見!


上一篇
Day 24: 文章管理 - 完善創作者工具
下一篇
Day 26: 留言系統 - 與讀者的互動
系列文
大家一起跟Rust當好朋友吧!30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言