嗨嗨!大家好!歡迎來到 Rust 三十天挑戰的第二十三天!
昨天我們建立了強大的資料基礎,今天終於要讓這些資料「活」起來了!我們要實作個人部落格最核心的功能——文章系統。作為一個創作者,能夠寫作、發布、管理自己的文章,就是整個部落格的靈魂所在!
今天我們會實作兩個關鍵的 API:創建文章(給創作者用)和瀏覽文章列表(給讀者用)。特別要處理個人創作者的需求:草稿功能、Markdown 支援、摘要生成等。讓我們開始打造一個真正好用的創作平台!
POST /api/admin/posts
- 支援草稿模式GET /api/posts
- 只顯示已發布文章更新 Cargo.toml
,加入 Markdown 處理相關套件:
[dependencies]
# 資料驗證 - 確保輸入品質
validator = { version = "0.20", features = ["derive"] }
# Slug 生成 - 友善的 URL
slug = "0.1"
首先建立資料傳輸物件(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()
}
}
更新 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()
}
}
更新 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(())
}
現在可以測試文章系統了!
# 確保資料庫正在運行
docker-compose -f docker-compose.dev.yml up -d
# 執行遷移
sea-orm-cli migrate up
# 啟動應用
cargo run
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
}'
curl http://localhost:3000/api/posts
打開 http://localhost:3000/swagger-ui/
查看完整的 API 文件!
今天我們完成了個人部落格的核心功能:
文章創建系統:
資料處理與驗證:
個人化功能:
開發體驗:
明天我們將進入 Day 24:文章管理 - 完善創作者工具!
我們會實作文章管理的完整功能:
GET /api/posts/:slug
)PUT /api/admin/posts/:id
)DELETE /api/admin/posts/:id
)我們會學到如何處理路徑參數、資料更新策略,以及如何設計對個人創作者友善的管理介面!
今天我們成功建立了個人部落格的「心臟」——文章系統!從無到有建立了創作、發布、瀏覽的完整流程。更重要的是,我們遵循了正確的前後端分離原則:後端專注資料管理,前端負責使用者體驗。
看到自己親手寫的 API 能夠建立和顯示文章,是不是很有成就感呢?這就是 Rust 帶給我們的魅力:既安全又高效,既強大又優雅!而且架構清晰,職責分明!
我們明天見!