嗨嗨!大家好!歡迎來到 Rust 三十天挑戰的第二十五天!
昨天我們完成了文章管理的完整功能,今天我們要進入一個同樣重要但更有趣的主題:標籤系統!對個人創作者來說,標籤不只是分類工具,更是思維的延伸,是幫助讀者發現相關內容的橋樑。
想像一下:當你寫了關於「Rust」、「學習心得」、「後端開發」的文章,這些標籤不只是標籤,它們代表了你的知識領域、興趣方向,甚至是你的成長軌跡。今天我們要打造一個既實用又有溫度的標籤系統!
首先建立標籤系統的資料傳輸物件。建立 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()) // 新增
}
更新 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;
現在讓我們測試完整的標籤系統!
# 確保資料庫正在運行
docker-compose -f docker-compose.dev.yml up -d
# 執行遷移
sea-orm-cli migrate up
# 啟動應用
cargo run
# 建立第一個標籤
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": "後端開發技術與實踐"
}'
# 查看所有標籤
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"
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
}'
# 查看 Rust 標籤的所有文章
curl http://localhost:3000/api/tags/rust/posts
# 分頁查看
curl "http://localhost:3000/api/tags/rust/posts?page=1&page_size=5"
# 取得熱門標籤建議
curl http://localhost:3000/api/tags/suggestions
# 搜尋相關標籤
curl "http://localhost:3000/api/tags/suggestions?query=ru&limit=5"
curl -X PUT http://localhost:3000/api/admin/tags/1 \
-H "Content-Type: application/json" \
-d '{
"description": "更深入的 Rust 程式語言探討",
"color": "#6366F1"
}'
curl http://localhost:3000/api/admin/tags/1
curl -X DELETE http://localhost:3000/api/admin/tags/3
今天我們完成了一個功能豐富且貼心的標籤系統:
完整 CRUD 功能:
多對多關聯掌握:
個人化功能設計:
創作者友善體驗:
技術亮點:
系統穩定性:
明天我們將進入 Day 26:留言系統 - 有溫度的讀者互動!
我們會實作部落格的互動核心:
POST/GET /api/posts/:id/comments
)我們會學到一對多關聯的實作,以及如何設計一個既開放又安全的互動系統!
今天我們成功建立了一個既實用又智慧的標籤系統!從基本的 CRUD 到進階的建議功能,每個細節都考慮到了個人創作者的實際需求。更重要的是,我們深入掌握了多對多關聯的處理,這是現代 Web 應用中非常常見且重要的技術。
看到標籤與文章完美整合,自動計數更新,智慧建議標籤,是不是很有成就感呢?這就是好的系統設計:表面簡單,背後強大!而且通過 Rust 的型別安全,我們可以確信這個系統既高效又穩定。
標籤系統完成後,我們的個人部落格已經有了內容組織的能力。明天的留言系統將為部落格帶來互動的溫度,讓它真正成為連結創作者與讀者的橋樑!
我們明天見!