iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
Rust

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

Day 23: 文章系統 - 創作者的核心功能

  • 分享至 

  • xImage
  •  

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

昨天我們建立了強大的資料基礎,今天終於要讓這些資料「活」起來了!我們要實作個人部落格最核心的功能——文章系統。作為一個創作者,能夠寫作、發布、管理自己的文章,就是整個部落格的靈魂所在!

今天我們會實作兩個關鍵的 API:創建文章(給創作者用)和瀏覽文章列表(給讀者用)。特別要處理個人創作者的需求:草稿功能、Markdown 支援、摘要生成等。讓我們開始打造一個真正好用的創作平台!


🎯 今天的目標

  1. 文章創建 APIPOST /api/admin/posts - 支援草稿模式
  2. 文章列表 APIGET /api/posts - 只顯示已發布文章
  3. Markdown 處理:內容格式化與摘要自動生成
  4. 資料驗證:確保輸入資料的完整性
  5. 錯誤處理:友善的錯誤回應格式

📦 新增依賴項

更新 Cargo.toml,加入 Markdown 處理相關套件:

[dependencies]
# 資料驗證 - 確保輸入品質
validator = { version = "0.20", features = ["derive"] }
# Slug 生成 - 友善的 URL
slug = "0.1"

🔧 建立 DTO 結構

首先建立資料傳輸物件(DTO),讓 API 介面更清晰。

建立 src/dto/mod.rs

pub mod post;

pub use post::*;

建立 src/dto/post.rs

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

#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreatePostRequest {
    #[validate(length(min = 1, max = 255, message = "標題長度必須在 1-255 字元之間"))]
    #[schema(example = "我的第一篇 Rust 文章")]
    pub title: String,

    #[validate(length(min = 1, message = "內容不能為空"))]
    #[schema(example = "今天開始學習 Rust,發現它真的很棒...")]
    pub content: String,

    #[validate(length(max = 500, message = "摘要不能超過 500 字元"))]
    #[schema(example = "這篇文章分享我學習 Rust 的心得")]
    pub excerpt: Option<String>,

    #[schema(example = "my-first-rust-article")]
    pub slug: Option<String>,

    #[schema(example = false)]
    pub is_published: Option<bool>,

    #[schema(example = json!(["rust", "程式設計", "學習心得"]))]
    pub tags: Option<Vec<String>>,
}

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

    #[schema(example = "我的第一篇 Rust 文章")]
    pub title: String,

    #[schema(example = "今天開始學習 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 = 42)]
    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-15T10:30:00Z")]
    pub updated_at: chrono::DateTime<chrono::Utc>,

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

    #[schema(example = json!(["rust", "程式設計", "學習心得"]))]
    pub tags: Vec<String>,
}

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

    #[schema(example = "我的第一篇 Rust 文章")]
    pub title: String,

    #[schema(example = "這篇文章分享我學習 Rust 的心得")]
    pub excerpt: Option<String>,

    #[schema(example = "my-first-rust-article")]
    pub slug: String,

    #[schema(example = 42)]
    pub view_count: i32,

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

    #[schema(example = json!(["rust", "程式設計", "學習心得"]))]
    pub tags: Vec<String>,
}

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

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

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

🛠️ 建立服務層

創建服務層來處理業務邏輯,讓控制器更簡潔。

建立 src/services/mod.rs

pub mod post;

pub use post::*;

建立 src/services/post.rs

use crate::dtos::post::*;
use crate::entities::{post, tag, post_tag};
use crate::error::AppError;
use sea_orm::*;
use std::collections::HashMap;
use validator::Validate;
use tracing::{info, error};
use sea_orm::ActiveValue::{Set, NotSet};

pub struct PostService;

impl PostService {
    /// 創建新文章
    pub async fn create_post(
        db: &DatabaseConnection,
        req: CreatePostRequest,
    ) -> Result<PostResponse, AppError> {

        req.validate()
            .map_err(|e| AppError::ValidationError(format!("輸入驗證失敗: {}", e)))?;

        let slug = match req.slug {
            Some(s) if !s.is_empty() => s,
            _ => Self::generate_slug(&req.title),
        };

        if post::Entity::find()
            .filter(post::Column::Slug.eq(&slug))
            .one(db)
            .await?
            .is_some()
        {
            return Err(AppError::ConflictError("該 slug 已經存在".to_string()));
        }

        let excerpt = match req.excerpt {
            Some(e) if !e.is_empty() => Some(e),
            _ => Some(Self::generate_excerpt(&req.content)),
        };

        let is_published = req.is_published.unwrap_or(false);
        let published_at = if is_published {
            Some(chrono::Utc::now())
        } else {
            None
        };

        let mut post_active_model = post::ActiveModel::new();
        post_active_model.id = NotSet;
        post_active_model.title = Set(req.title);
        post_active_model.content = Set(req.content);
        post_active_model.excerpt = Set(excerpt);
        post_active_model.slug = Set(slug);
        post_active_model.is_published = Set(is_published);
        post_active_model.published_at = Set(published_at);

        let post_result = post::Entity::insert(post_active_model)
            .exec_with_returning(db)
            .await
            .map_err(|e| {
                error!("insert post failed: {e:#?}");
                AppError::from(e)
            })?;

        let mut tag_names = Vec::new();
        if let Some(tags) = req.tags {
            for tag_name in tags {
                let name = tag_name.trim();
                if name.is_empty() { continue; }

                Self::create_or_update_tag(db, name, post_result.id).await?;
                tag_names.push(name.to_string());
            }
        }

        Ok(PostResponse {
            id: post_result.id,
            title: post_result.title,
            content: post_result.content,
            excerpt: post_result.excerpt,
            slug: post_result.slug,
            is_published: post_result.is_published,
            view_count: post_result.view_count,
            created_at: post_result.created_at,
            updated_at: post_result.updated_at,
            published_at: post_result.published_at,
            tags: tag_names,
        })
    }

    /// 取得已發布文章列表
    pub async fn get_published_posts(
        db: &DatabaseConnection,
        query: PostListQuery,
    ) -> Result<Vec<PostListResponse>, AppError> {
        let page = query.page.unwrap_or(1);
        let page_size = query.page_size.unwrap_or(10).min(50);
        let offset = (page - 1) * page_size;

        let mut posts_query = post::Entity::find()
            .filter(post::Column::IsPublished.eq(true))
            .order_by_desc(post::Column::PublishedAt);

        if let Some(tag_name) = query.tag {
            let post_ids = post_tag::Entity::find()
                .inner_join(tag::Entity)
                .filter(tag::Column::Name.eq(&tag_name))
                .select_only()
                .column(post_tag::Column::PostId)
                .into_tuple::<i32>()
                .all(db)
                .await?;

            posts_query = posts_query.filter(post::Column::Id.is_in(post_ids));
        }

        let posts = posts_query
            .offset(offset)
            .limit(page_size)
            .all(db)
            .await?;

        let post_ids: Vec<i32> = posts.iter().map(|p| p.id).collect();
        let tags_map = Self::get_tags_for_posts(db, &post_ids).await?;

        let response = 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: tags_map.get(&post.id).cloned().unwrap_or_default(),
            })
            .collect();

        Ok(response)
    }

    /// 生成文章 slug
    fn generate_slug(title: &str) -> String {
        let slug = slug::slugify(title);
        if slug.is_empty() {
            format!("post-{}", chrono::Utc::now().timestamp())
        } else {
            slug
        }
    }

    /// 從內容生成摘要
    fn generate_excerpt(content: &str) -> String {
        // 單純轉純文字+截斷,避免深層遞迴
        let parser = pulldown_cmark::Parser::new(content);
        let mut html_buf = String::new();
        pulldown_cmark::html::push_html(&mut html_buf, parser);

        let plain_text = html2text::from_read(html_buf.as_bytes(), 200).unwrap_or_default();
        let excerpt = plain_text.trim();
        if excerpt.len() > 200 {
            format!("{}...", &excerpt[..197])
        } else {
            excerpt.to_string()
        }
    }

    /// 創建或更新標籤
    async fn create_or_update_tag(
        db: &DatabaseConnection,
        tag_name: &str,
        post_id: i32,
    ) -> Result<(), AppError> {
        let maybe_tag = tag::Entity::find()
            .filter(tag::Column::Name.eq(tag_name))
            .one(db)
            .await?;

        let tag_model = if let Some(existing) = maybe_tag {
            let mut active: tag::ActiveModel = existing.clone().into();
            active.post_count = Set(existing.post_count + 1);
            tag::Entity::update(active).exec(db).await?
        } else {
            let mut new_tag = tag::ActiveModel::new();
            new_tag.id = NotSet; // 視你的 schema 而定
            new_tag.name = Set(tag_name.to_string());
            new_tag.color = Set(Self::generate_tag_color(tag_name));
            new_tag.post_count = Set(1);
            tag::Entity::insert(new_tag)
                .exec_with_returning(db)
                .await?
        };
        
        let mut pt = post_tag::ActiveModel::new();
        pt.post_id = Set(post_id);
        pt.tag_id = Set(tag_model.id);
        post_tag::Entity::insert(pt).exec(db).await?;

        Ok(())
    }

    /// 批量取得文章的標籤
    async fn get_tags_for_posts(
        db: &DatabaseConnection,
        post_ids: &[i32],
    ) -> Result<HashMap<i32, Vec<String>>, AppError> {
        if post_ids.is_empty() {
            return Ok(HashMap::new());
        }

        let results = post_tag::Entity::find()
            .filter(post_tag::Column::PostId.is_in(post_ids.iter().cloned()))
            .find_also_related(tag::Entity)
            .all(db)
            .await?;

        let mut tags_map: HashMap<i32, Vec<String>> = HashMap::new();
        for (pt, tg) in results {
            if let Some(t) = tg {
                tags_map.entry(pt.post_id).or_default().push(t.name);
            }
        }

        Ok(tags_map)
    }

    /// 為標籤生成隨機顏色
    fn generate_tag_color(tag_name: &str) -> String {
        let colors = [
            "#3498db", "#e74c3c", "#2ecc71", "#f39c12",
            "#9b59b6", "#1abc9c", "#34495e", "#e67e22",
        ];
        let index = tag_name.len() % colors.len();
        colors[index].to_string()
    }
}

📡 實作文章 API 路由

更新 src/routes/mod.rs

use axum::{
    extract::{Query, State},
    http::StatusCode,
    response::Json,
    routing::{get, post},
    Router,
};
use crate::{error::AppError, services::PostService, state::AppState};
use crate::dtos::{CreatePostRequest, PostListQuery, PostListResponse, PostResponse};

/// 建立文章相關路由(會被 nest 到 /api 底下)
pub fn create_post_routes() -> Router<AppState> {
    Router::new()
        .route("/posts", get(get_posts))          // 實際對外是 /api/posts
        .route("/admin/posts", post(create_post)) // 實際對外是 /api/admin/posts
}


#[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))
}

/// 建立文章 (管理員 API)
#[utoipa::path(
    post,
    path = "/admin/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> {
    let post = PostService::create_post(&app_state.db, req).await?;
    Ok((StatusCode::CREATED, Json(post)))
}

🔧 更新錯誤處理

更新 src/error.rs,加入新的錯誤類型:

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;
use sea_orm::{DbErr, RuntimeErr};
use tracing::{error, warn};

#[derive(Error, Debug)]
pub enum AppError {
    #[error("內部伺服器錯誤")]
    InternalServerError,
    #[error("找不到資源: {0}")]
    NotFound(String),
    #[error("請求無效: {0}")]
    BadRequest(String),
    #[error("未授權")]
    Unauthorized,
    #[error("參數驗證失敗: {0}")]
    ValidationError(String),
    #[error("資源衝突: {0}")]
    ConflictError(String),
}

impl AppError {
    #[inline]
    fn status_and_client_msg(&self) -> (StatusCode, &'static str) {
        match self {
            AppError::InternalServerError   => (StatusCode::INTERNAL_SERVER_ERROR, "內部伺服器錯誤"),
            AppError::NotFound(_)           => (StatusCode::NOT_FOUND,              "找不到資源"),
            AppError::BadRequest(_)         => (StatusCode::BAD_REQUEST,            "請求無效"),
            AppError::Unauthorized          => (StatusCode::UNAUTHORIZED,           "未授權"),
            AppError::ValidationError(_)    => (StatusCode::UNPROCESSABLE_ENTITY,   "參數驗證失敗"),
            AppError::ConflictError(_)      => (StatusCode::CONFLICT,               "資源衝突"),
        }
    }

    #[inline]
    fn log(&self) {
        match self {
            AppError::InternalServerError => error!("內部伺服器錯誤"),
            AppError::NotFound(msg)       => warn!("資源未找到: {msg}"),
            AppError::BadRequest(msg)     => warn!("請求無效: {msg}"),
            AppError::Unauthorized        => warn!("未授權的存取嘗試"),
            AppError::ValidationError(msg)=> warn!("驗證失敗: {msg}"),
            AppError::ConflictError(msg)  => warn!("資源衝突: {msg}"),
        }
    }
}

/// 從 SeaORM 錯誤取出 Postgres SQLSTATE(需要 sqlx 依賴)
#[inline]
fn sqlstate_code(err: &DbErr) -> Option<String> {
    if let DbErr::Exec(rt) | DbErr::Query(rt) = err {
        if let RuntimeErr::SqlxError(sqlx_err) = rt {
            return sqlx_err
                .as_database_error()
                .and_then(|db_err| db_err.code().map(|c| c.as_ref().to_owned()));
        }
    }
    None
}

impl From<DbErr> for AppError {
    fn from(err: DbErr) -> Self {
        if matches!(err, DbErr::RecordNotFound(_)) {
            return AppError::NotFound("資料不存在".into());
        }

        if let Some(code) = sqlstate_code(&err).as_deref() {
            return match code {
                "23505" => AppError::ConflictError("唯一鍵衝突(例如 slug 已存在)".into()),
                "23503" => AppError::BadRequest("外鍵約束失敗".into()),
                "23502" => AppError::BadRequest("必填欄位為空 (NOT NULL)".into()),
                "23514" => AppError::BadRequest("檢查條件不符合 (CHECK)".into()),
                "22001" => AppError::BadRequest("字串超過長度限制".into()),
                _       => {
                    error!("未處理的 SQLSTATE: {code}");
                    AppError::InternalServerError
                }
            };
        }

        // 後備字串偵測(不同 DB/語系可能略有差異)
        let s = err.to_string();
        if s.contains("duplicate key value")                  { return AppError::ConflictError("唯一鍵衝突(可能是 slug 重複)".into()); }
        if s.contains("violates foreign key constraint")      { return AppError::BadRequest("外鍵約束失敗".into()); }
        if s.contains("null value in column")                 { return AppError::BadRequest("必填欄位為空 (NOT NULL)".into()); }
        if s.contains("value too long for type") || s.contains("string data right truncation") {
            return AppError::BadRequest("字串超過長度限制".into());
        }

        error!("資料庫錯誤: {err:#}");
        AppError::InternalServerError
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        self.log();
        let (status, client_msg) = self.status_and_client_msg();

        (status, Json(json!({
            "error": client_msg,
            "status": status.as_u16(),
            "timestamp": chrono::Utc::now().to_rfc3339(),
        }))).into_response()
    }
}

📚 更新 OpenAPI 文件

更新 src/docs.rs,加入文章 API:

use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa::{Modify, OpenApi};

#[derive(OpenApi)]
#[openapi(
    paths(
        crate::routes::health::health_check,
        crate::routes::blog::blog_info,
        crate::routes::posts::get_posts,
        crate::routes::posts::create_post,
    ),
    components(
        schemas(
            crate::routes::health::HealthCheck,
            crate::routes::blog::BlogInfo,
            crate::dtos::post::CreatePostRequest,
            crate::dtos::post::PostResponse,
            crate::dtos::post::PostListResponse,
            crate::dtos::post::PostListQuery,
        )
    ),
    tags(
        (name = "health", description = "系統健康檢查"),
        (name = "blog", description = "部落格基本資訊"),
        (name = "posts", description = "文章相關 API"),
        (name = "admin", description = "管理員 API"),
    ),
    info(
        title = "個人部落格 API",
        version = "0.1.0",
        description = "個人部落格後端 API 文件",
        contact(
            name = "API 支援",
            email = "support@example.com"
        )
    ),
    servers(
        (url = "http://localhost:3000", description = "本地開發環境"),
        (url = "https://api.myblog.com", description = "正式環境")
    )
)]
pub struct ApiDoc;

impl Modify for ApiDoc {
    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
        if let Some(components) = openapi.components.as_mut() {
            components.add_security_scheme(
                "bearer_auth",
                SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("Authorization"))),
            );
        }
    }
}

🔄 更新主程式

更新 src/main.rs,引入新模組:

mod app;
mod config;
mod database;
mod docs;
mod dto;
mod entities;
mod error;
mod middleware;
mod routes;
mod services;
mod startup;
mod state;

// ... 其餘程式碼保持不變

更新 src/startup.rs,加入資料庫初始化:

mod app;
mod config;
mod database;
mod docs;
mod error;
mod middleware;
mod routes;
mod startup;
mod state;
mod entities;
mod dtos;
mod services;

use anyhow::Result;
use config::Config;
use database::establish_connection;
use state::AppState;
use tracing::info;

#[tokio::main]
async fn main() -> Result<()> {
    // 載入環境變數
    dotenvy::dotenv().ok();

    // 初始化日誌
    tracing_subscriber::fmt::init();

    // 載入設定
    let config = Config::from_env()?;
    info!("設定載入完成");

    let db = establish_connection(&config).await?;
    info!("資料庫連線建立完成");

    let app_state = AppState::new(db, config.clone());

    // 啟動服務
    startup::run(app_state).await?;

    Ok(())
}

✅ 測試我們的 API

現在可以測試文章系統了!

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/admin/posts \
  -H "Content-Type: application/json" \
  -d '{
    "title": "我的第一篇 Rust 文章",
    "content": "# 開始學習 Rust\n\n今天開始我的 Rust 學習之旅!\n\n## 為什麼選擇 Rust\n\n- 記憶體安全\n- 高效能\n- 零成本抽象",
    "tags": ["rust", "學習心得", "程式設計"],
    "is_published": true
  }'

3. 查看文章列表

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

4. 瀏覽 Swagger UI

打開 http://localhost:3000/swagger-ui/ 查看完整的 API 文件!


🚀 今天的收穫

今天我們完成了個人部落格的核心功能:

文章創建系統

  • ✅ 支援 Markdown 內容與自動摘要生成
  • ✅ 草稿/發布狀態切換,符合個人寫作習慣
  • ✅ 自動 slug 生成,SEO 友善
  • ✅ 標籤系統整合,靈活分類

資料處理與驗證

  • ✅ 完整的輸入驗證與錯誤處理
  • ✅ 型別安全的資料傳輸物件
  • ✅ 重複 slug 檢查,避免衝突
  • ✅ 批量標籤處理,效能優化

個人化功能

  • ✅ 摘要自動生成,減少手動工作
  • ✅ 標籤顏色自動分配,視覺化分類
  • ✅ 分頁查詢支援,適合內容成長
  • ✅ 標籤過濾功能,方便內容整理

開發體驗

  • ✅ 清晰的服務層架構,業務邏輯分離
  • ✅ 完整的 OpenAPI 文件,API 一目了然
  • ✅ 友善的錯誤訊息,開發與除錯容易
  • ✅ Rust 型別安全優勢,編譯期錯誤檢查

明天預告

明天我們將進入 Day 24:文章管理 - 完善創作者工具

我們會實作文章管理的完整功能:

  • 📖 單篇文章詳細查看 (GET /api/posts/:slug)
  • ✏️ 文章編輯更新 (PUT /api/admin/posts/:id)
  • 🗑️ 文章刪除管理 (DELETE /api/admin/posts/:id)
  • 👀 瀏覽計數功能,了解文章受歡迎程度
  • 🔄 草稿與發布狀態切換,靈活控制內容
  • 🏷️ 標籤關聯更新,動態調整分類

我們會學到如何處理路徑參數、資料更新策略,以及如何設計對個人創作者友善的管理介面!


今天我們成功建立了個人部落格的「心臟」——文章系統!從無到有建立了創作、發布、瀏覽的完整流程。更重要的是,我們遵循了正確的前後端分離原則:後端專注資料管理,前端負責使用者體驗

看到自己親手寫的 API 能夠建立和顯示文章,是不是很有成就感呢?這就是 Rust 帶給我們的魅力:既安全又高效,既強大又優雅!而且架構清晰,職責分明!

我們明天見!


上一篇
Day 22: 個人部落格的資料模型與 SeaORM 整合
系列文
大家一起跟Rust當好朋友吧!23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言