iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
Rust

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

Day 22: 個人部落格的資料模型與 SeaORM 整合

  • 分享至 

  • xImage
  •  

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

終於來到實戰週了!經過前三天的規劃與準備,今天我們要正式開始建立個人部落格的「骨架」—— 資料模型與資料庫整合。這是整個系統的基礎,就像蓋房子要先打地基一樣重要!

今天我們會使用 SeaORM,這是一個現代化的 Rust ORM 框架,讓我們能用型別安全的方式操作資料庫。記住,我們設計的是個人部落格,所以會保持簡潔但功能完整的設計理念!


🎯 今天的目標

  1. 認識 SeaORM:理解 Entity、Model、ActiveModel 的概念
  2. 資料庫設定:建立 PostgreSQL 連線與設定
  3. 設計資料模型:實作 Post、Tag、Comment 實體
  4. 建立關聯:處理一對多、多對多的資料關係
  5. 遷移管理:自動建立和更新資料庫結構

📦 更新專案依賴

首先更新我們的 Cargo.toml,加入資料庫相關的依賴:

[dependencies]
# Web 框架 - 個人專案的最佳選擇
axum = { version = "0.8.4", features = ["macros"] }
tower = "0.5.2"
tower-http = { version = "0.6.6", features = ["cors", "trace"] }

# 非同步運行時
tokio = { version = "1.0", features = ["full"] }

# JSON 處理 - 個人部落格必備
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
# OpenAPI / Swagger UI
utoipa = { version = "5", features = ["axum_extras","chrono"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }

# 日期時間 - 文章發布時間
chrono = { version = "0.4", features = ["serde"] }

# 簡單的日誌
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.2", features = ["env-filter"] }

# 錯誤處理 - 讓個人維護更輕鬆
thiserror = "2.0.16"
anyhow = "1.0.99"
http = "1.3.1"

sea-orm = { version = "1.1.4", features = [
    "sqlx-postgres",          # PostgreSQL 支援
    "runtime-tokio-rustls",   # 非同步運行時
    "macros",                 # 巨集支援
    "with-chrono",            # 時間型別整合
    "with-uuid",              # UUID 支援
] }


sea-orm-migration = "1.1.4"

🏗️ SeaORM 核心概念

在開始寫程式碼之前,讓我們先理解 SeaORM 的三個核心概念:

Entity(實體)

定義資料表的結構與關聯,包含欄位定義、主鍵、索引等。

Model(模型)

純資料結構,代表從資料庫查詢回來的一行資料,是不可變的。

ActiveModel(活動模型)

可變的資料結構,用於插入、更新資料庫。支援部分更新和驗證。

這個設計讓我們能區分「讀取」和「寫入」的資料結構,增加型別安全性!


🗃️ 資料庫設定

1. 環境變數設定

更新 .env 檔案:

# 伺服器設定
HOST=127.0.0.1
PORT=3000
PROTOCOL=http

# 部落格資訊
BLOG_NAME=我的個人技術部落格
BLOG_DESCRIPTION=分享程式設計學習心得與生活感悟
BLOG_AUTHOR=你的名字

# 🆕 資料庫設定
DATABASE_URL=postgres://blog_user:blog_password@localhost:5432/personal_blog
DATABASE_MAX_CONNECTIONS=10

# 日誌設定
RUST_LOG=info
CORS_ORIGIN=http://localhost:3000,http://127.0.0.1:3000

2. Docker Compose 開發環境

建立 docker-compose.dev.yml

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: personal_blog
      POSTGRES_USER: blog_user
      POSTGRES_PASSWORD: blog_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - type: bind
        source: /mnt/c/Users/Jake/Workspace/blog/blog-backend/initdb
        target: /docker-entrypoint-initdb.d
    command: >
      postgres
      -c log_statement=all
      -c log_min_duration_statement=0
      -c shared_preload_libraries=pg_stat_statements

volumes:
  postgres_data:

建立 init.sql

-- 初始化個人部落格資料庫
-- 建立必要的擴展
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- 設定時區(根據你的需求調整)
SET timezone = 'Asia/Taipei';

啟動開發環境:

docker-compose -f docker-compose.dev.yml up -d

3. 資料庫連線設定

更新 src/config.rs

use std::env;
use anyhow::{Context, Result};

#[derive(Debug, Clone)]
pub struct Config {
    pub host: String,
    pub port: u16,
    pub protocol: String,
    pub blog_name: String,
    pub blog_description: String,
    pub blog_author: String,
    pub cors_origins: Vec<String>,
    // 🆕 資料庫設定
    pub database_url: String,
    pub database_max_connections: u32,
}

impl Config {
    pub fn from_env() -> Result<Self> {
        let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".into());
        let port = env::var("PORT")
            .unwrap_or_else(|_| "3000".into())
            .parse::<u16>()
            .context("PORT 必須是整數(u16)")?;
        
        let protocol = env::var("PROTOCOL").unwrap_or_else(|_| "http".into());
        let blog_name = env::var("BLOG_NAME").unwrap_or_else(|_| "個人部落格".into());
        let blog_description = env::var("BLOG_DESCRIPTION")
            .unwrap_or_else(|_| "分享想法與心得".into());
        let blog_author = env::var("BLOG_AUTHOR").unwrap_or_else(|_| "部落格作者".into());
        
        let cors_origins = env::var("CORS_ORIGIN")
            .unwrap_or_else(|_| "*".into())
            .split(',')
            .map(|s| s.trim().to_string())
            .collect();

        // 🆕 資料庫設定
        let database_url = env::var("DATABASE_URL")
            .context("請設定 DATABASE_URL 環境變數")?;
        let database_max_connections = env::var("DATABASE_MAX_CONNECTIONS")
            .unwrap_or_else(|_| "10".into())
            .parse::<u32>()
            .context("DATABASE_MAX_CONNECTIONS 必須是整數")?;

        Ok(Self {
            host,
            port,
            protocol,
            blog_name,
            blog_description,
            blog_author,
            cors_origins,
            database_url,
            database_max_connections,
        })
    }

    pub fn server_url(&self) -> String {
        format!("{}://{}:{}", self.protocol, self.host, self.port)
    }

    // 為了安全,在日誌中隱藏敏感資訊
    pub fn sanitized_for_log(&self) -> Self {
        let mut config = self.clone();
        config.database_url = "postgres://***:***@***/***".to_string();
        config
    }
}

🔗 建立資料庫連線

建立 src/database.rs

use sea_orm::{Database, DatabaseConnection, DbErr};
use tracing::info;

use crate::config::Config;

pub async fn establish_connection(config: &Config) -> Result<DatabaseConnection, DbErr> {
    info!("正在連接到資料庫...");

    let mut opt = sea_orm::ConnectOptions::new(&config.database_url);
    opt.max_connections(config.database_max_connections)
        .min_connections(1)
        .connect_timeout(std::time::Duration::from_secs(8))
        .acquire_timeout(std::time::Duration::from_secs(8))
        .idle_timeout(std::time::Duration::from_secs(8))
        .max_lifetime(std::time::Duration::from_secs(8))
        .sqlx_logging(true)  // 在開發期間顯示 SQL 查詢
        .sqlx_logging_level(log::LevelFilter::Info);

    let db = Database::connect(opt).await?;
    
    info!("資料庫連線成功!");
    Ok(db)
}

// 健康檢查:測試資料庫連線
pub async fn health_check(db: &DatabaseConnection) -> Result<(), DbErr> {
    use sea_orm::Statement;
    
    db.execute(Statement::from_string(
        sea_orm::DatabaseBackend::Postgres,
        "SELECT 1".to_string(),
    ))
    .await?;
    
    Ok(())
}

📝 設計個人部落格的資料模型

現在來設計我們的核心實體!記住,我們要的是「簡單而不簡陋」的設計。

1. 文章實體 (Post Entity)

建立 src/entities/post.rs

use sea_orm::entity::prelude::*;
use sea_orm::{ConnectionTrait};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize, ToSchema)]
#[sea_orm(table_name = "posts")]
pub struct Model {
    #[sea_orm(primary_key)]
    #[serde(skip_deserializing)]
    #[schema(example = 1)]
    pub id: i32,

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

    #[sea_orm(column_type = "Text")]
    #[schema(example = "今天開始學習 Rust...")]
    pub content: String,

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

    #[sea_orm(column_type = "String(StringLen::N(255))", unique)]
    #[schema(example = "my-first-rust-article")]
    pub slug: String,

    #[sea_orm(default_value = "false")]
    #[schema(example = true)]
    pub is_published: bool,

    #[sea_orm(default_value = "0")]
    #[schema(example = 42)]
    pub view_count: i32,

    #[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
    pub created_at: DateTimeUtc,

    #[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
    pub updated_at: DateTimeUtc,

    #[sea_orm(nullable)]
    #[schema(value_type = Option<String>, example = "2024-01-15T10:30:00Z")]
    pub published_at: Option<DateTimeUtc>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    // 一篇文章有多個留言
    #[sea_orm(has_many = "super::comment::Entity")]
    Comments,

    // 一篇文章經由 pivot post_tag 連到多個 tag
    #[sea_orm(has_many = "super::post_tag::Entity")]
    PostTags,
}

// 經由 pivot 取得 tags
impl Related<super::tag::Entity> for Entity {
    fn to() -> RelationDef {
        super::tag::Relation::PostTags.def()
    }
    fn via() -> Option<RelationDef> {
        Some(Relation::PostTags.def().rev())
    }
}

#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
    fn new() -> Self {
        use sea_orm::ActiveValue::{NotSet, Set};
        let now = chrono::Utc::now();
        Self {
            id: NotSet,

            title: NotSet,
            content: NotSet,
            excerpt: NotSet,
            slug: NotSet,

            is_published: Set(false),
            view_count: Set(0),

            // 由這裡幫你預設時間
            created_at: Set(now),
            updated_at: Set(now),

            published_at: NotSet,
        }
    }

    async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
    where
        C: ConnectionTrait,
    {
        use sea_orm::ActiveValue::{NotSet, Set};

        let now = chrono::Utc::now();

        if insert {
            if !self.created_at.is_set() {
                self.created_at = Set(now);
            }
            if !self.is_published.is_set() {
                self.is_published = Set(false);
            }
            if !self.view_count.is_set() {
                self.view_count = Set(0);
            }
        }

        self.updated_at = Set(now);

        let published = matches!(self.is_published, Set(true));
        let published_at_empty = matches!(self.published_at, NotSet | Set(None));
        if published && published_at_empty {
            self.published_at = Set(Some(now));
        }

        if matches!(self.is_published, Set(false)) {
            self.published_at = Set(None);
        }

        Ok(self)
    }
}

2. 標籤實體 (Tag Entity)

建立 src/entities/tag.rs

use sea_orm::entity::prelude::*;
use sea_orm::{ConnectionTrait};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize, ToSchema)]
#[sea_orm(table_name = "tags")]
pub struct Model {
    #[sea_orm(primary_key)]
    #[serde(skip_deserializing)]
    #[schema(example = 1)]
    pub id: i32,

    #[sea_orm(column_type = "String(StringLen::N(100))", unique)]
    #[schema(example = "rust")]
    pub name: String,

    #[sea_orm(column_type = "String(StringLen::N(255))", nullable)]
    #[schema(example = "Rust 程式語言相關文章")]
    pub description: Option<String>,

    #[sea_orm(column_type = "String(StringLen::N(7))", default_value = "#6B7280")]
    #[schema(example = "#8B5CF6")]
    pub color: String,

    #[sea_orm(default_value = "0")]
    #[schema(example = 5)]
    pub post_count: i32,

    #[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
    pub created_at: DateTimeUtc,

    #[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
    pub updated_at: DateTimeUtc,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    #[sea_orm(has_many = "super::post_tag::Entity")]
    PostTags,
}

impl Related<super::post::Entity> for Entity {
    fn to() -> RelationDef {
        super::post::Relation::PostTags.def()
    }
    fn via() -> Option<RelationDef> {
        Some(Relation::PostTags.def().rev())
    }
}

#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
    fn new() -> Self {
        use sea_orm::ActiveValue::{NotSet, Set};
        let now = chrono::Utc::now();
        Self {
            id: NotSet,
            name: NotSet,
            description: NotSet,
            color: Set("#6B7280".to_string()),
            post_count: Set(0),
            created_at: Set(now),
            updated_at: Set(now),
        }
    }

    async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
    where
        C: ConnectionTrait,
    {
        use sea_orm::ActiveValue::{Set};
        let now = chrono::Utc::now();

        if insert {
            if !self.color.is_set() {
                self.color = Set("#6B7280".to_string());
            }
            if !self.post_count.is_set() {
                self.post_count = Set(0);
            }
        }

        self.updated_at = Set(now);

        // color 基本校驗
        if let Set(ref mut c) = self.color {
            if c.len() != 7 || !c.starts_with('#') {
                *c = "#6B7280".to_string();
            }
        }

        Ok(self)
    }
}

3. 文章-標籤關聯表 (Post-Tag Junction)

建立 src/entities/post_tag.rs

use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)]
#[sea_orm(table_name = "post_tags")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub post_id: i32,

    #[sea_orm(primary_key, auto_increment = false)]
    pub tag_id: i32,

    pub created_at: DateTimeUtc,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    #[sea_orm(
        belongs_to = "super::post::Entity",
        from = "Column::PostId",
        to = "super::post::Column::Id"
    )]
    Post,

    #[sea_orm(
        belongs_to = "super::tag::Entity",
        from = "Column::TagId",
        to = "super::tag::Column::Id"
    )]
    Tag,
}

impl Related<super::post::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Post.def()
    }
}

impl Related<super::tag::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Tag.def()
    }
}

impl ActiveModelBehavior for ActiveModel {
    fn new() -> Self {
        Self {
            created_at: Set(chrono::Utc::now()),
            ..ActiveModelTrait::default()
        }
    }
}

4. 留言實體 (Comment Entity)

建立 src/entities/comment.rs

use sea_orm::entity::prelude::*;
use sea_orm::{ConnectionTrait};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Deserialize, Serialize, ToSchema)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "comment_status")]
pub enum CommentStatus {
    #[sea_orm(string_value = "pending")]
    Pending,
    #[sea_orm(string_value = "approved")]
    Approved,
    #[sea_orm(string_value = "rejected")]
    Rejected,
}

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize, ToSchema)]
#[sea_orm(table_name = "comments")]
pub struct Model {
    #[sea_orm(primary_key)]
    #[serde(skip_deserializing)]
    #[schema(example = 1)]
    pub id: i32,

    #[schema(example = 1)]
    pub post_id: i32,

    #[sea_orm(column_type = "String(StringLen::N(100))")]
    #[schema(example = "讀者小明")]
    pub author_name: String,

    #[sea_orm(column_type = "String(StringLen::N(255))")]
    #[schema(example = "reader@example.com")]
    pub author_email: String,

    #[sea_orm(column_type = "String(StringLen::N(255))", nullable)]
    #[schema(example = "https://example.com")]
    pub author_website: Option<String>,

    #[sea_orm(column_type = "Text")]
    #[schema(example = "很棒的文章!學到很多東西。")]
    pub content: String,

    pub status: CommentStatus,

    #[sea_orm(column_type = "String(StringLen::N(45))", nullable)]
    pub ip_address: Option<String>,

    #[sea_orm(column_type = "Text", nullable)]
    pub user_agent: Option<String>,

    #[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
    pub created_at: DateTimeUtc,

    #[schema(value_type = String, example = "2024-01-15T10:30:00Z")]
    pub updated_at: DateTimeUtc,

    #[sea_orm(nullable)]
    #[schema(value_type = Option<String>, example = "2024-01-15T10:30:00Z")]
    pub approved_at: Option<DateTimeUtc>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    #[sea_orm(
        belongs_to = "super::post::Entity",
        from = "Column::PostId",
        to = "super::post::Column::Id"
    )]
    Post,
}

impl Related<super::post::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Post.def()
    }
}

#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
    fn new() -> Self {
        use sea_orm::ActiveValue::{NotSet, Set};
        let now = chrono::Utc::now();
        Self {
            id: NotSet,
            post_id: NotSet,
            author_name: NotSet,
            author_email: NotSet,
            author_website: NotSet,
            content: NotSet,
            status: Set(CommentStatus::Pending),
            ip_address: NotSet,
            user_agent: NotSet,
            created_at: Set(now),
            updated_at: Set(now),
            approved_at: NotSet,
        }
    }

    async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
    where
        C: ConnectionTrait,
    {
        use sea_orm::ActiveValue::{NotSet, Set};
        let now = chrono::Utc::now();

        if insert {
            if !self.created_at.is_set() {
                self.created_at = Set(now);
            }
            if !self.status.is_set() {
                self.status = Set(CommentStatus::Pending);
            }
        }

        // 每次存檔都更新 updated_at
        self.updated_at = Set(now);

        // 若狀態為 approved 且 approved_at 還沒設,補上時間
        let is_approved = matches!(self.status, Set(CommentStatus::Approved));
        let approved_at_empty = matches!(self.approved_at, NotSet | Set(None));
        if is_approved && approved_at_empty {
            self.approved_at = Set(Some(now));
        }

        Ok(self)
    }
}

5. 實體模組整合

建立 src/entities/mod.rs

pub mod post;
pub mod tag;
pub mod post_tag;
pub mod comment;

pub use post::{Entity as Post, Model as PostModel, ActiveModel as PostActiveModel};
pub use tag::{Entity as Tag, Model as TagModel, ActiveModel as TagActiveModel};
pub use post_tag::{Entity as PostTag, Model as PostTagModel, ActiveModel as PostTagActiveModel};
pub use comment::{Entity as Comment, Model as CommentModel, ActiveModel as CommentActiveModel, CommentStatus};

pub mod queries {
    use sea_orm::*;
    use super::*;

    /// 查詢已發布的文章
    pub fn published_posts() -> Select<post::Entity> {
        post::Entity::find()
            .filter(post::Column::IsPublished.eq(true))
            .order_by_desc(post::Column::PublishedAt)
    }

    /// 查詢已審核的留言
    pub fn approved_comments_for_post(post_id: i32) -> Select<comment::Entity> {
        comment::Entity::find()
            .filter(comment::Column::PostId.eq(post_id))
            .filter(comment::Column::Status.eq(CommentStatus::Approved))
            .order_by_asc(comment::Column::CreatedAt)
    }

    /// 查詢標籤及其文章數
    pub fn tags_with_post_count() -> Select<tag::Entity> {
        tag::Entity::find()
            .filter(tag::Column::PostCount.gt(0))
            .order_by_desc(tag::Column::PostCount)
    }
}

🔄 資料庫遷移設定

1. 建立遷移專案

# 在專案根目錄執行
cargo install sea-orm-cli

# 初始化遷移
sea-orm-cli migrate init

這會建立 migration 目錄和相關檔案。

2. 建立遷移檔案

建立 migration/src/m20241122_000001_create_tables.rs

use sea_orm_migration::prelude::*;
use crate::extension::postgres::Type;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        // posts
        manager
            .create_table(
                Table::create()
                    .table(Posts::Table)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(Posts::Id)
                            .integer()
                            .not_null()
                            .auto_increment()
                            .primary_key(),
                    )
                    .col(ColumnDef::new(Posts::Title).string_len(255).not_null())
                    .col(ColumnDef::new(Posts::Content).text().not_null())
                    .col(ColumnDef::new(Posts::Excerpt).string_len(500))
                    .col(
                        ColumnDef::new(Posts::Slug)
                            .string_len(255)
                            .not_null()
                            .unique_key(),
                    )
                    .col(
                        ColumnDef::new(Posts::IsPublished)
                            .boolean()
                            .not_null()
                            .default(false),
                    )
                    .col(
                        ColumnDef::new(Posts::ViewCount)
                            .integer()
                            .not_null()
                            .default(0),
                    )
                    .col(
                        ColumnDef::new(Posts::CreatedAt)
                            .timestamp_with_time_zone()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .col(
                        ColumnDef::new(Posts::UpdatedAt)
                            .timestamp_with_time_zone()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .col(ColumnDef::new(Posts::PublishedAt).timestamp_with_time_zone())
                    .to_owned(),
            )
            .await?;

        // tags
        manager
            .create_table(
                Table::create()
                    .table(Tags::Table)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(Tags::Id)
                            .integer()
                            .not_null()
                            .auto_increment()
                            .primary_key(),
                    )
                    .col(
                        ColumnDef::new(Tags::Name)
                            .string_len(100)
                            .not_null()
                            .unique_key(),
                    )
                    .col(ColumnDef::new(Tags::Description).string_len(255))
                    .col(
                        ColumnDef::new(Tags::Color)
                            .string_len(7)
                            .not_null()
                            .default("#6B7280"),
                    )
                    .col(
                        ColumnDef::new(Tags::PostCount)
                            .integer()
                            .not_null()
                            .default(0),
                    )
                    .col(
                        ColumnDef::new(Tags::CreatedAt)
                            .timestamp_with_time_zone()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .col(
                        ColumnDef::new(Tags::UpdatedAt)
                            .timestamp_with_time_zone()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .to_owned(),
            )
            .await?;

        // post_tags
        manager
            .create_table(
                Table::create()
                    .table(PostTags::Table)
                    .if_not_exists()
                    .col(ColumnDef::new(PostTags::PostId).integer().not_null())
                    .col(ColumnDef::new(PostTags::TagId).integer().not_null())
                    .col(
                        ColumnDef::new(PostTags::CreatedAt)
                            .timestamp_with_time_zone()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .primary_key(Index::create().col(PostTags::PostId).col(PostTags::TagId))
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_post_tags_post_id")
                            .from(PostTags::Table, PostTags::PostId)
                            .to(Posts::Table, Posts::Id)
                            .on_delete(ForeignKeyAction::Cascade),
                    )
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_post_tags_tag_id")
                            .from(PostTags::Table, PostTags::TagId)
                            .to(Tags::Table, Tags::Id)
                            .on_delete(ForeignKeyAction::Cascade),
                    )
                    .to_owned(),
            )
            .await?;

        // enum comment_status
        manager
            .create_type(
                Type::create()
                    .as_enum(CommentStatus::Enum)
                    .values([
                        CommentStatus::Pending,
                        CommentStatus::Approved,
                        CommentStatus::Rejected,
                    ])
                    .to_owned(),
            )
            .await?;

        // comments
        manager
            .create_table(
                Table::create()
                    .table(Comments::Table)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(Comments::Id)
                            .integer()
                            .not_null()
                            .auto_increment()
                            .primary_key(),
                    )
                    .col(ColumnDef::new(Comments::PostId).integer().not_null())
                    .col(ColumnDef::new(Comments::AuthorName).string_len(100).not_null())
                    .col(ColumnDef::new(Comments::AuthorEmail).string_len(255).not_null())
                    .col(ColumnDef::new(Comments::AuthorWebsite).string_len(255))
                    .col(ColumnDef::new(Comments::Content).text().not_null())
                    .col(
                        ColumnDef::new(Comments::Status)
                            .enumeration(
                                CommentStatus::Enum,
                                [
                                    CommentStatus::Pending,
                                    CommentStatus::Approved,
                                    CommentStatus::Rejected,
                                ],
                            )
                            .not_null()
                            // 明確 cast 成 PostgreSQL enum
                            .default(Expr::cust("'pending'::comment_status")),
                    )
                    .col(ColumnDef::new(Comments::IpAddress).string_len(45))
                    .col(ColumnDef::new(Comments::UserAgent).text())
                    .col(
                        ColumnDef::new(Comments::CreatedAt)
                            .timestamp_with_time_zone()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .col(
                        ColumnDef::new(Comments::UpdatedAt)
                            .timestamp_with_time_zone()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .col(ColumnDef::new(Comments::ApprovedAt).timestamp_with_time_zone())
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk_comments_post_id")
                            .from(Comments::Table, Comments::PostId)
                            .to(Posts::Table, Posts::Id)
                            .on_delete(ForeignKeyAction::Cascade),
                    )
                    .to_owned(),
            )
            .await?;

        // indexes
        manager
            .create_index(
                Index::create()
                    .name("idx_posts_published")
                    .table(Posts::Table)
                    .col(Posts::IsPublished)
                    .col(Posts::PublishedAt)
                    .to_owned(),
            )
            .await?;

        manager
            .create_index(
                Index::create()
                    .name("idx_posts_slug")
                    .table(Posts::Table)
                    .col(Posts::Slug)
                    .to_owned(),
            )
            .await?;

        manager
            .create_index(
                Index::create()
                    .name("idx_comments_post_status")
                    .table(Comments::Table)
                    .col(Comments::PostId)
                    .col(Comments::Status)
                    .to_owned(),
            )
            .await?;

        Ok(())
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        // drop indexes
        manager
            .drop_index(Index::drop().name("idx_comments_post_status").to_owned())
            .await?;
        manager
            .drop_index(Index::drop().name("idx_posts_slug").to_owned())
            .await?;
        manager
            .drop_index(Index::drop().name("idx_posts_published").to_owned())
            .await?;

        // drop tables (FK 依賴順序)
        manager
            .drop_table(Table::drop().table(Comments::Table).to_owned())
            .await?;
        manager
            .drop_table(Table::drop().table(PostTags::Table).to_owned())
            .await?;
        manager
            .drop_table(Table::drop().table(Tags::Table).to_owned())
            .await?;
        manager
            .drop_table(Table::drop().table(Posts::Table).to_owned())
            .await?;

        // drop enum type
        manager
            .drop_type(Type::drop().name(CommentStatus::Enum).to_owned())
            .await?;

        Ok(())
    }
}

// ===== Idens =====

#[derive(Iden)]
enum Posts {
    Table,
    Id,
    Title,
    Content,
    Excerpt,
    Slug,
    IsPublished,
    ViewCount,
    CreatedAt,
    UpdatedAt,
    PublishedAt,
}

#[derive(Iden)]
enum Tags {
    Table,
    Id,
    Name,
    Description,
    Color,
    PostCount,
    CreatedAt,
    UpdatedAt,
}

#[derive(Iden)]
enum PostTags {
    Table,
    PostId,
    TagId,
    CreatedAt,
}

#[derive(Iden)]
enum Comments {
    Table,
    Id,
    PostId,
    AuthorName,
    AuthorEmail,
    AuthorWebsite,
    Content,
    Status,
    IpAddress,
    UserAgent,
    CreatedAt,
    UpdatedAt,
    ApprovedAt,
}

#[derive(Iden)]
enum CommentStatus {
    #[iden = "comment_status"] // PostgreSQL enum type name
    Enum,
    #[iden = "pending"]
    Pending,
    #[iden = "approved"]
    Approved,
    #[iden = "rejected"]
    Rejected,
}

3. 更新遷移管理器

更新 migration/src/lib.rs

pub use sea_orm_migration::prelude::*;

mod m20241122_000001_create_tables;

pub struct Migrator;

#[async_trait::async_trait]
impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            Box::new(m20241122_000001_create_tables::Migration),
        ]
    }
}

4. 執行遷移

在專案根目錄執行:

# 設定環境變數
export DATABASE_URL="postgres://blog_user:blog_password@localhost:5432/personal_blog"

# 執行遷移
sea-orm-cli migrate up

# 檢查遷移狀態
sea-orm-cli migrate status

🔗 整合到應用程式

1. 更新應用程式狀態

建立 src/state.rs

use sea_orm::DatabaseConnection;
use crate::config::Config;

#[derive(Clone)]
pub struct AppState {
    pub db: DatabaseConnection,
    pub config: Config,
}

impl AppState {
    pub fn new(db: DatabaseConnection, config: Config) -> Self {
        Self { db, config }
    }
}

2. 更新主程式

更新 src/main.rs

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

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!("資料庫連線建立完成");

    // 🆕 執行遷移(開發期間自動執行)
    #[cfg(debug_assertions)]
    {
        use sea_orm_migration::MigratorTrait;
        migration::Migrator::up(&db, None).await?;
        info!("資料庫遷移完成");
    }

    // 🆕 建立應用程式狀態
    let app_state = AppState::new(db, config.clone());

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

    Ok(())
}

3. 更新路由模組

更新 src/routes/mod.rs

use axum::{routing::get, Router};

use crate::state::AppState;

pub mod blog;
pub mod health;

pub fn router() -> Router<AppState> {
    Router::new()
        .route("/", get(blog::blog_info))
        .route("/health", get(health::health_check))
}

4. 更新 startup.rs

更新 src/startup.rs

use axum::Router;
use tokio::net::TcpListener;
use tracing::info;

use crate::{app::build_app, state::AppState};

pub async fn run(app_state: AppState) -> anyhow::Result<()> {
    let addr = format!("{}:{}", app_state.config.host, app_state.config.port);
    let server_url = app_state.config.server_url();

    info!("設定載入完成: {:?}", app_state.config.sanitized_for_log());

    let listener = TcpListener::bind(&addr).await?;
    let app: Router = build_app(app_state);

    info!("🚀 個人部落格服務啟動於 {}", server_url);
    info!("📚 API 文件:{}/docs", server_url);
    info!("🔍 健康檢查:{}/health", server_url);

    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;

    Ok(())
}

async fn shutdown_signal() {
    let ctrl_c = async {
        tokio::signal::ctrl_c()
            .await
            .expect("failed to install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("failed to install signal handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }

    info!("🛑 個人部落格服務正在關閉...");
}

5. 更新 app.rs

更新 src/app.rs

use axum::{middleware, Router};
use http::HeaderValue;
use tower_http::cors::{Any, AllowOrigin, CorsLayer};
use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;

use crate::{docs::ApiDoc, middleware::request_logging, routes, state::AppState};

pub fn build_app(app_state: AppState) -> Router {
    let cors = if app_state.config.cors_origins.iter().any(|o| o == "*") {
        // 開發期萬用
        CorsLayer::new()
            .allow_origin(Any)
            .allow_methods(Any)
            .allow_headers(Any)
    } else {
        // 嚴格白名單
        let origins = app_state
            .config
            .cors_origins
            .iter()
            .filter_map(|o| HeaderValue::from_str(o).ok());
        CorsLayer::new()
            .allow_origin(AllowOrigin::list(origins))
            .allow_methods(Any)
            .allow_headers(Any)
    };

    let api = routes::router().with_state(app_state);

    Router::new()
        .merge(api)
        .merge(
            SwaggerUi::new("/docs")
                .url("/api-docs/openapi.json", ApiDoc::openapi()),
        )
        .layer(middleware::from_fn(request_logging))
        .layer(TraceLayer::new_for_http())
        .layer(cors)
}

6. 更新 blog.rs 路由

更新 src/routes/blog.rs

use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use crate::state::AppState;

#[derive(Serialize, Deserialize, ToSchema)]
pub struct BlogInfo {
    #[schema(example = "我的個人技術部落格")]
    pub name: String,
    #[schema(example = "分享程式設計學習心得與生活感悟")]
    pub description: String,
    #[schema(example = "你的名字")]
    pub author: String,
    #[schema(example = "0.1.0")]
    pub version: String,
    #[schema(example = "2024-01-15T10:30:00Z")]
    pub timestamp: String,
}

#[utoipa::path(
    get,
    path = "/",
    tag = "blog",
    responses(
        (status = 200, description = "部落格基本資訊", body = BlogInfo)
    )
)]
pub async fn blog_info(State(app_state): State<AppState>) -> Json<BlogInfo> {
    Json(BlogInfo {
        name: app_state.config.blog_name.clone(),
        description: app_state.config.blog_description.clone(),
        author: app_state.config.blog_author.clone(),
        version: env!("CARGO_PKG_VERSION").to_string(),
        timestamp: chrono::Utc::now().to_rfc3339(),
    })
}

7. 更新健康檢查

更新 src/routes/health.rs

use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use crate::{database, state::AppState};

#[derive(Serialize, Deserialize, ToSchema)]
pub struct HealthCheck {
    #[schema(example = "healthy")]
    pub status: String,
    #[schema(example = "2024-01-15T10:30:00Z")]
    pub timestamp: String,
    #[schema(example = "0.1.0")]
    pub version: String,
    // 🆕 資料庫狀態
    #[schema(example = "connected")]
    pub database: String,
}

#[utoipa::path(
    get,
    path = "/health",
    tag = "health",
    responses(
        (status = 200, description = "服務健康狀態", body = HealthCheck),
        (status = 503, description = "服務不可用")
    )
)]
pub async fn health_check(State(app_state): State<AppState>) -> Json<HealthCheck> {
    // 🆕 檢查資料庫連線
    let database_status = match database::health_check(&app_state.db).await {
        Ok(_) => "connected".to_string(),
        Err(_) => "disconnected".to_string(),
    };

    Json(HealthCheck {
        status: if database_status == "connected" {
            "healthy".to_string()
        } else {
            "unhealthy".to_string()
        },
        timestamp: chrono::Utc::now().to_rfc3339(),
        version: env!("CARGO_PKG_VERSION").to_string(),
        database: database_status,
    })
}

🚀 今天的收穫

今天我們完成了個人部落格的資料基礎建設:

SeaORM 掌握

  • ✅ 理解 Entity、Model、ActiveModel 的設計理念
  • ✅ 學會定義複雜的資料關聯(一對多、多對多)
  • ✅ 實作自動時間戳記和資料驗證

個人化設計

  • ✅ 文章支援草稿/發布狀態,符合個人寫作習慣
  • ✅ 標籤有顏色屬性,讓分類更視覺化
  • ✅ 留言審核機制,確保內容品質
  • ✅ 簡化但完整的資料結構

開發基礎

  • ✅ 建立完整的遷移系統
  • ✅ 設定資料庫連線與健康檢查
  • ✅ 為未來的 CRUD 操作做好準備

型別安全

  • ✅ 編譯期間就能發現資料模型錯誤
  • ✅ 自動的 JSON 序列化與 OpenAPI 文件生成
  • ✅ 清晰的關聯定義與查詢優化

明天預告

明天我們將進入 Day 23:文章系統 - 創作者的核心功能

我們會實作個人部落格最重要的功能:

  • 📝 建立和發布文章 API (POST /api/admin/posts)
  • 📚 公開文章列表 API (GET /api/posts)
  • 🏷️ 文章與標籤的關聯處理
  • ⚡ Markdown 內容支援與摘要生成
  • 🛡️ 基本的資料驗證與錯誤處理

我們會看到 SeaORM 如何讓資料庫操作變得既安全又優雅!


今天我們打好了強大的資料基礎,明天開始就要讓這些資料「活」起來了!讓我們用 Rust 的型別安全優勢,建立一個既穩定又好用的個人創作平台!

我們明天見!


上一篇
Day 21: 增加基礎設施 - 準備實戰開發
系列文
大家一起跟Rust當好朋友吧!22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言