iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Rust

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

Day 24: 文章管理 - 完善創作者工具

  • 分享至 

  • xImage
  •  

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

昨天我們成功實作了文章的創建和列表功能,今天我們要進一步完善創作者工具箱!身為一個創作者,除了能寫文章之外,還需要能夠管理、編輯、甚至刪除自己的作品。

今天我們要實作文章管理的完整 CRUD 功能,特別關注個人創作者的使用習慣:快速編輯、狀態切換、瀏覽統計等實用功能。讓我們把這個部落格系統打造得更加貼心好用!


🎯 今天的目標

  1. 單篇文章查看GET /api/posts/:slug - 支援 slug 或 id 查詢
  2. 文章編輯更新PUT /api/admin/posts/:id - 靈活的部分更新
  3. 文章刪除管理DELETE /api/admin/posts/:id - 刪除機制
  4. 瀏覽計數功能:追蹤文章受歡迎程度
  5. 草稿發布切換:快速控制文章可見性

📦 擴展 DTO 結構

首先擴展我們的 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,
}

🔧 擴展 PostService

擴展我們的服務層,加入完整的 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))
}


📋 更新 OpenAPI 文件

更新 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 功能了!

1. 建立一篇草稿文章

curl -X POST http://localhost:3000/api/posts \
  -H "Content-Type: application/json" \
  -d '{
    "title": "我的草稿文章",
    "content": "# 這是一篇草稿\n\n還在思考內容中...",
    "is_published": false,
    "tags": ["草稿", "思考中"]
  }'

2. 查看文章詳情(管理員視角)

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

3. 更新文章並發布

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", "學習心得", "完成"]
  }'

4. 公開查看文章(會增加瀏覽次數)

curl http://localhost:3000/api/posts/完成的-rust-學習心得

5. 查看文章列表

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

6. 刪除文章

curl -X DELETE http://localhost:3000/api/admin/posts/1

🚀 今天的收穫

今天我們完成了個人部落格的完整文章管理系統:

完整 CRUD 功能

  • ✅ 根據 slug 或 ID 靈活查詢文章
  • ✅ 支援部分欄位更新,減少不必要的資料傳輸
  • ✅ 刪除機制
  • ✅ 自動處理發布時間,狀態管理更智慧

個人創作者友善功能

  • ✅ 草稿與發布狀態快速切換
  • ✅ 瀏覽次數自動追蹤,了解文章受歡迎程度
  • ✅ 標籤關聯動態更新,靈活調整分類
  • ✅ Slug 衝突檢測,避免 URL 重複

系統穩定性

  • ✅ 交易機制確保資料一致性
  • ✅ 完整的錯誤處理與驗證
  • ✅ 非同步瀏覽計數,不影響回應速度
  • ✅ 路徑參數安全處理

開發體驗

  • ✅ 清晰的 API 文件,Swagger UI 一目了然
  • ✅ 型別安全的路由處理
  • ✅ 結構化的錯誤回應
  • ✅ 完整的日誌記錄,便於除錯

技術亮點

  • ✅ 學會處理路徑參數 (Path<T>)
  • ✅ 掌握資料庫交易的使用
  • ✅ 理解部分更新的實作策略
  • ✅ 實作非同步後台任務

明天預告

明天我們將進入 Day 25:標籤系統 - 個人化的內容組織

我們會實作標籤功能的完整系統:

  • 🏷️ 標籤 CRUD 管理 (GET/POST/PUT/DELETE /api/tags)
  • 🎨 標籤顏色管理,讓分類更視覺化
  • 📊 標籤文章計數,了解使用頻率
  • 🔗 文章與標籤的關聯查詢
  • 🌈 智慧標籤建議,提升創作效率
  • 📈 標籤統計分析,優化內容策略

我們會學到多對多關聯的進階處理,以及如何設計對個人創作者友善的分類系統!


今天我們成功建立了一個功能完整的文章管理系統!從創建、查看、編輯到刪除,每個環節都考慮到了個人創作者的實際需求。更重要的是,我們學會了如何在 Rust 中處理複雜的資料庫操作,包括交易、關聯查詢、以及非同步任務。

看到自己親手打造的部落格系統越來越完善,是不是很有成就感呢?這就是 Rust 的魅力:既能讓我們寫出高效能的程式碼,又能確保系統的穩定性和安全性。而且通過清晰的架構分層,讓整個系統既強大又好維護!

準備好迎接明天的標籤系統挑戰了嗎?我們要讓內容組織變得更加智慧和視覺化!

我們明天見!


上一篇
Day 23: 文章系統 - 創作者的核心功能
下一篇
Day 25: 標籤系統 - 個人化的內容組織
系列文
大家一起跟Rust當好朋友吧!30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言