嗨嗨!大家好!歡迎來到 Rust 三十天挑戰的第二十四天!
昨天我們成功實作了文章的創建和列表功能,今天我們要進一步完善創作者工具箱!身為一個創作者,除了能寫文章之外,還需要能夠管理、編輯、甚至刪除自己的作品。
今天我們要實作文章管理的完整 CRUD 功能,特別關注個人創作者的使用習慣:快速編輯、狀態切換、瀏覽統計等實用功能。讓我們把這個部落格系統打造得更加貼心好用!
GET /api/posts/:slug
- 支援 slug 或 id 查詢PUT /api/admin/posts/:id
- 靈活的部分更新DELETE /api/admin/posts/:id
- 刪除機制首先擴展我們的 DTO,支援更靈活的操作。
src/dto/post.rs
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use validator::Validate;
// ... 保留昨天的 CreatePostRequest 和 PostResponse
#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdatePostRequest {
#[validate(length(min = 1, max = 255, message = "標題長度必須在 1-255 字元之間"))]
#[schema(example = "更新後的文章標題")]
pub title: Option<String>,
#[validate(length(min = 1, message = "內容不能為空"))]
#[schema(example = "更新後的文章內容...")]
pub content: Option<String>,
#[validate(length(max = 500, message = "摘要不能超過 500 字元"))]
#[schema(example = "更新後的摘要")]
pub excerpt: Option<String>,
#[schema(example = "updated-article-slug")]
pub slug: Option<String>,
#[schema(example = true)]
pub is_published: Option<bool>,
#[schema(example = json!(["rust", "更新", "程式設計"]))]
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct PostDetailResponse {
#[schema(example = 1)]
pub id: i32,
#[schema(example = "我的第一篇 Rust 文章")]
pub title: String,
#[schema(example = "# 開始學習 Rust\n\n今天開始我的 Rust 學習之旅...")]
pub content: String,
#[schema(example = "這篇文章分享我學習 Rust 的心得")]
pub excerpt: Option<String>,
#[schema(example = "my-first-rust-article")]
pub slug: String,
#[schema(example = true)]
pub is_published: bool,
#[schema(example = 128)]
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-15T15:45:00Z")]
pub updated_at: chrono::DateTime<chrono::Utc>,
#[schema(value_type = Option<String>, example = "2024-01-15T12:00:00Z")]
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
#[schema(example = json!(["rust", "程式設計", "學習心得"]))]
pub tags: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct DeletePostResponse {
#[schema(example = true)]
pub success: bool,
#[schema(example = "文章已成功刪除")]
pub message: String,
#[schema(example = 1)]
pub deleted_id: i32,
}
擴展我們的服務層,加入完整的 CRUD 操作。
src/services/post_service.rs
use sea_orm::*;
use std::collections::HashMap;
use crate::{
dtos::{CreatePostRequest, PostListQuery, PostListResponse, PostResponse, UpdatePostRequest, PostDetailResponse, DeletePostResponse},
entities::{post, tag, post_tag},
error::AppError,
};
use tracing::{error, info};
pub struct PostService;
impl PostService {
// ... 保留昨天的 create_post 和 get_published_posts 方法
/// 根據 slug 或 id 取得單篇文章詳情
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?;
// 更新文章
let updated = updated_post.update(&txn).await.map_err(|e| {
error!("更新文章失敗: {e:#?}");
AppError::from(e)
})?;
// 更新標籤關聯
if let Some(tags) = req.tags {
// 刪除現有標籤關聯
post_tag::Entity::delete_many()
.filter(post_tag::Column::PostId.eq(post_id))
.exec(&txn)
.await?;
// 重新建立標籤關聯
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?;
}
}
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?;
// 刪除標籤關聯
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?;
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(())
}
// ... 保留其他輔助方法
}
更新 src/routes/posts.rs
:
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::Json,
routing::{delete, get, post, put},
Router,
};
use validator::Validate;
use crate::{
dtos::{CreatePostRequest, PostListQuery, PostListResponse, PostResponse, UpdatePostRequest, PostDetailResponse, DeletePostResponse},
error::AppError,
services::PostService,
state::AppState,
};
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(
"/admin/posts/{id}",
get(get_post_for_admin).put(update_post).delete(delete_post),
)
}
/// 取得已發布文章列表
#[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))
}
更新 src/docs.rs
,新增新的 API 端點:
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,
),
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,
)
),
// ... 其他設定保持不變
)]
pub struct ApiDoc;
現在我們可以測試完整的文章 CRUD 功能了!
curl -X POST http://localhost:3000/api/posts \
-H "Content-Type: application/json" \
-d '{
"title": "我的草稿文章",
"content": "# 這是一篇草稿\n\n還在思考內容中...",
"is_published": false,
"tags": ["草稿", "思考中"]
}'
curl http://localhost:3000/api/admin/posts/1
curl -X PUT http://localhost:3000/api/admin/posts/1 \
-H "Content-Type: application/json" \
-d '{
"title": "完成的 Rust 學習心得",
"content": "# Rust 學習心得\n\n經過深思熟慮,我想分享...\n\n## 主要收穫\n\n- 型別安全\n- 記憶體管理\n- 併發處理",
"is_published": true,
"tags": ["rust", "學習心得", "完成"]
}'
curl http://localhost:3000/api/posts/完成的-rust-學習心得
curl http://localhost:3000/api/posts
curl -X DELETE http://localhost:3000/api/admin/posts/1
今天我們完成了個人部落格的完整文章管理系統:
完整 CRUD 功能:
個人創作者友善功能:
系統穩定性:
開發體驗:
技術亮點:
Path<T>
)明天我們將進入 Day 25:標籤系統 - 個人化的內容組織!
我們會實作標籤功能的完整系統:
GET/POST/PUT/DELETE /api/tags
)我們會學到多對多關聯的進階處理,以及如何設計對個人創作者友善的分類系統!
今天我們成功建立了一個功能完整的文章管理系統!從創建、查看、編輯到刪除,每個環節都考慮到了個人創作者的實際需求。更重要的是,我們學會了如何在 Rust 中處理複雜的資料庫操作,包括交易、關聯查詢、以及非同步任務。
看到自己親手打造的部落格系統越來越完善,是不是很有成就感呢?這就是 Rust 的魅力:既能讓我們寫出高效能的程式碼,又能確保系統的穩定性和安全性。而且通過清晰的架構分層,讓整個系統既強大又好維護!
準備好迎接明天的標籤系統挑戰了嗎?我們要讓內容組織變得更加智慧和視覺化!
我們明天見!