嗨嗨!大家好!歡迎來到 Rust 三十天挑戰的第二十四天!
昨天我們成功實作了文章的創建和列表功能,今天我們要進一步完善創作者工具箱!身為一個創作者,除了能寫文章之外,還需要能夠管理、編輯、甚至刪除自己的作品。
今天我們要實作文章管理的完整 CRUD 功能,特別關注個人創作者的使用習慣:快速編輯、狀態切換、瀏覽統計等實用功能。讓我們把這個部落格系統打造得更加貼心好用!
GET /api/posts/:slug - 支援 slug 或 id 查詢PUT /api/admin/posts/:id - 靈活的部分更新DELETE /api/admin/posts/:id - 刪除機制首先擴展我們的 DTO,支援更靈活的操作。
src/dto/post.rsuse 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.rsuse 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 的魅力:既能讓我們寫出高效能的程式碼,又能確保系統的穩定性和安全性。而且通過清晰的架構分層,讓整個系統既強大又好維護!
準備好迎接明天的標籤系統挑戰了嗎?我們要讓內容組織變得更加智慧和視覺化!
我們明天見!