iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0
Rust

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

Day 26: 留言系統 - 與讀者的互動

  • 分享至 

  • xImage
  •  

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

昨天我們完成了標籤系統的完整功能,建立了一個既實用又智慧的內容組織機制。今天我們要進入的部分是:留言系統


🎯 今天的目標

  1. 留言功能 CRUD:完整的留言生命週期管理
  2. 審核機制設計:確保留言品質與內容安全
  3. 一對多關聯處理:文章與留言的關係管理
  4. 垃圾留言過濾:保護創作環境免受干擾
  5. 互動體驗優化:提升讀者參與的便利性

📋 留言系統 DTO 設計

建立 src/dtos/comment.rs

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

#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateCommentRequest {
    #[validate(length(min = 1, max = 100, message = "姓名長度必須在 1-100 字元之間"))]
    #[schema(example = "讀者小明")]
    pub author_name: String,

    #[validate(email(message = "請提供有效的電子郵件地址"))]
    #[validate(length(max = 255, message = "電子郵件長度不能超過 255 字元"))]
    #[schema(example = "reader@example.com")]
    pub author_email: String,

    #[validate(url(message = "請提供有效的網址格式"))]
    #[validate(length(max = 255, message = "網站 URL 長度不能超過 255 字元"))]
    #[schema(example = "https://example.com")]
    pub author_website: Option<String>,

    #[validate(length(min = 1, max = 2000, message = "留言內容長度必須在 1-2000 字元之間"))]
    #[schema(example = "很棒的文章!學到很多東西。")]
    pub content: String,
}

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

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

    #[schema(example = "讀者小明")]
    pub author_name: String,

    #[schema(example = "https://example.com")]
    pub author_website: Option<String>,

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

    #[schema(example = "approved")]
    pub status: String,

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

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

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

    #[schema(example = "approved")]
    pub status: Option<String>, // pending, approved, rejected, all (admin only)

    #[schema(example = "desc")]
    pub sort_order: Option<String>, // asc, desc
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateCommentStatusRequest {
    #[schema(example = "approved")]
    pub status: String, // approved, rejected, pending

    #[schema(example = "留言內容符合社群規範")]
    pub reason: Option<String>,
}

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

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

    #[schema(example = "讀者小明")]
    pub author_name: String,

    #[schema(example = "reader@example.com")]
    pub author_email: String,

    #[schema(example = "https://example.com")]
    pub author_website: Option<String>,

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

    #[schema(example = "pending")]
    pub status: String,

    #[schema(example = "192.168.1.1")]
    pub ip_address: Option<String>,

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

    #[schema(example = "Rust學習心得分享")]
    pub post_title: String,
}

記得更新 src/dtos/mod.rs

pub mod post;
pub mod tag;
pub mod comment; // 新增

pub use post::*;
pub use tag::*;
pub use comment::*; // 新增

🏗️ 留言服務層實作

建立 src/services/comment_service.rs

use sea_orm::{ActiveModelBehavior, ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait};
use tracing::{info, warn};
use validator::Validate;
use std::fmt;

use crate::{
    dtos::{
        CreateCommentRequest, CommentResponse, CommentListQuery,
        UpdateCommentStatusRequest, CommentModerationResponse,
    },
    entities::{comment, post, comment::CommentStatus},
    error::AppError,
};

// Implement Display for CommentStatus
impl fmt::Display for CommentStatus {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            CommentStatus::Pending => write!(f, "pending"),
            CommentStatus::Approved => write!(f, "approved"),
            CommentStatus::Rejected => write!(f, "rejected"),
        }
    }
}

pub struct CommentService;

impl CommentService {
    /// 為文章建立留言
    pub async fn create_comment(
        db: &DatabaseConnection,
        post_id: i32,
        req: CreateCommentRequest,
        ip_address: Option<String>,
        user_agent: Option<String>,
    ) -> Result<CommentResponse, AppError> {
        // 驗證輸入
        req.validate()
            .map_err(|e| AppError::ValidationError(format!("留言資料驗證失敗: {}", e)))?;

        let txn = db.begin().await?;

        // 檢查文章是否存在且已發布
        let post = post::Entity::find_by_id(post_id)
            .filter(post::Column::IsPublished.eq(true))
            .one(&txn)
            .await?
            .ok_or_else(|| AppError::NotFound("文章不存在或未發布".to_string()))?;

        // 清理內容和偵測垃圾留言
        let cleaned_content = Self::sanitize_content(&req.content);
        let is_spam = Self::detect_spam(&cleaned_content, &req.author_name);

        // 建立留言
        let mut new_comment = comment::ActiveModel::new();
        new_comment.post_id = Set(post_id);
        new_comment.author_name = Set(req.author_name.trim().to_string());
        new_comment.author_email = Set(req.author_email.trim().to_lowercase());
        new_comment.author_website = Set(req.author_website.as_ref().map(|url| url.trim().to_string()));
        new_comment.content = Set(cleaned_content);
        new_comment.status = Set(if is_spam {
            CommentStatus::Rejected
        } else {
            CommentStatus::Pending
        });
        new_comment.ip_address = Set(ip_address);
        new_comment.user_agent = Set(user_agent);

        let comment_model = comment::Entity::insert(new_comment)
            .exec_with_returning(&txn)
            .await?;

        txn.commit().await?;

        info!("新留言建立成功: post_id={}, comment_id={}, is_spam={}", 
              post_id, comment_model.id, is_spam);

        Ok(CommentResponse {
            id: comment_model.id,
            post_id: comment_model.post_id,
            author_name: comment_model.author_name,
            author_website: comment_model.author_website,
            content: comment_model.content,
            status: comment_model.status.to_string(),
            created_at: comment_model.created_at,
        })
    }

    /// 取得文章的留言列表(僅顯示已審核的)
    pub async fn get_comments_for_post(
        db: &DatabaseConnection,
        post_id: i32,
        query: CommentListQuery,
    ) -> Result<Vec<CommentResponse>, AppError> {
        let page = query.page.unwrap_or(1);
        let page_size = query.page_size.unwrap_or(20).min(50);
        let offset = (page - 1) * page_size;

        // 只顯示已審核的留言
        let mut select = comment::Entity::find()
            .filter(comment::Column::PostId.eq(post_id))
            .filter(comment::Column::Status.eq(CommentStatus::Approved));

        // 排序:預設由舊到新
        match query.sort_order.as_deref().unwrap_or("asc") {
            "desc" => select = select.order_by_desc(comment::Column::CreatedAt),
            _ => select = select.order_by_asc(comment::Column::CreatedAt),
        }

        let comments = select
            .offset(offset)
            .limit(page_size)
            .all(db)
            .await?;

        let responses = comments.into_iter().map(|comment| CommentResponse {
            id: comment.id,
            post_id: comment.post_id,
            author_name: comment.author_name,
            author_website: comment.author_website,
            content: comment.content,
            status: comment.status.to_string(),
            created_at: comment.created_at,
        }).collect();

        Ok(responses)
    }

    /// 取得留言列表(管理員用)
    pub async fn get_comments_for_admin(
        db: &DatabaseConnection,
        query: CommentListQuery,
    ) -> Result<Vec<CommentModerationResponse>, AppError> {
        let page = query.page.unwrap_or(1);
        let page_size = query.page_size.unwrap_or(20).min(100);
        let offset = (page - 1) * page_size;

        let mut select = comment::Entity::find()
            .find_also_related(post::Entity);

        // 狀態篩選
        match query.status.as_deref() {
            Some("pending") => select = select.filter(comment::Column::Status.eq(CommentStatus::Pending)),
            Some("approved") => select = select.filter(comment::Column::Status.eq(CommentStatus::Approved)),
            Some("rejected") => select = select.filter(comment::Column::Status.eq(CommentStatus::Rejected)),
            Some("all") | _ => {}, // 管理員可以看到所有狀態
        }

        // 排序:預設最新的在前面
        match query.sort_order.as_deref().unwrap_or("desc") {
            "asc" => select = select.order_by_asc(comment::Column::CreatedAt),
            _ => select = select.order_by_desc(comment::Column::CreatedAt),
        }

        let results = select
            .offset(offset)
            .limit(page_size)
            .all(db)
            .await?;

        let responses = results.into_iter().map(|(comment, post_opt)| {
            let post_title = match &post_opt {
                Some(post) => post.title.clone(),
                None => String::new(),
            };
            CommentModerationResponse {
                id: comment.id,
                post_id: comment.post_id,
                author_name: comment.author_name,
                author_email: comment.author_email,
                author_website: comment.author_website,
                content: comment.content,
                status: comment.status.to_string(),
                ip_address: comment.ip_address,
                created_at: comment.created_at,
                post_title: post_title,
            }
        }).collect();

        Ok(responses)
    }

    /// 更新留言狀態(管理員審核)
    pub async fn update_comment_status(
        db: &DatabaseConnection,
        comment_id: i32,
        req: UpdateCommentStatusRequest,
    ) -> Result<CommentResponse, AppError> {
        let txn = db.begin().await?;

        let comment = comment::Entity::find_by_id(comment_id)
            .one(&txn)
            .await?
            .ok_or_else(|| AppError::NotFound("留言不存在".to_string()))?;

        let new_status = match req.status.as_str() {
            "approved" => CommentStatus::Approved,
            "rejected" => CommentStatus::Rejected,
            "pending" => CommentStatus::Pending,
            _ => return Err(AppError::BadRequest("無效的狀態值".to_string())),
        };

        let mut active_comment: comment::ActiveModel = comment.clone().into();
        active_comment.status = Set(new_status.clone());

        let updated_comment = comment::Entity::update(active_comment)
            .exec(&txn)
            .await?;

        txn.commit().await?;

        info!("留言狀態更新: comment_id={}, new_status={:?}, reason={:?}", 
              comment_id, new_status, req.reason);

        Ok(CommentResponse {
            id: updated_comment.id,
            post_id: updated_comment.post_id,
            author_name: updated_comment.author_name,
            author_website: updated_comment.author_website,
            content: updated_comment.content,
            status: updated_comment.status.to_string(),
            created_at: updated_comment.created_at,
        })
    }

    /// 刪除留言(管理員用)
    pub async fn delete_comment(
        db: &DatabaseConnection,
        comment_id: i32,
    ) -> Result<(), AppError> {
        let txn = db.begin().await?;

        let comment = comment::Entity::find_by_id(comment_id)
            .one(&txn)
            .await?
            .ok_or_else(|| AppError::NotFound("留言不存在".to_string()))?;

        // 刪除留言
        comment::Entity::delete_by_id(comment_id)
            .exec(&txn)
            .await?;

        txn.commit().await?;

        info!("留言刪除成功: comment_id={}", comment_id);
        Ok(())
    }

    /// 垃圾留言偵測
    fn detect_spam(content: &str, author_name: &str) -> bool {
        let content_lower = content.to_lowercase();
        let name_lower = author_name.to_lowercase();

        // 常見垃圾留言特徵
        let spam_keywords = [
            "viagra", "casino", "loan", "mortgage", "porn", "sex",
            "buy now", "click here", "free money", "guaranteed",
            "賺錢", "貸款", "借錢", "免費", "點擊", "色情"
        ];

        // 檢查內容
        for keyword in &spam_keywords {
            if content_lower.contains(keyword) {
                warn!("偵測到疑似垃圾留言關鍵字: {}", keyword);
                return true;
            }
        }

        // 檢查是否全大寫(超過10個字元)
        if content.len() > 10 && content.chars().all(|c| c.is_uppercase() || !c.is_alphabetic()) {
            warn!("偵測到疑似垃圾留言: 全大寫");
            return true;
        }

        // 檢查重複字元
        if Self::has_excessive_repetition(content) {
            warn!("偵測到疑似垃圾留言: 過多重複字元");
            return true;
        }

        // 檢查作者名稱
        for keyword in &spam_keywords {
            if name_lower.contains(keyword) {
                warn!("偵測到疑似垃圾留言作者名稱: {}", keyword);
                return true;
            }
        }

        false
    }

    /// 檢查是否有過多重複字元
    fn has_excessive_repetition(content: &str) -> bool {
        let mut current_char = '\0';
        let mut count = 0;

        for c in content.chars() {
            if c == current_char {
                count += 1;
                if count > 5 {
                    return true;
                }
            } else {
                current_char = c;
                count = 1;
            }
        }

        false
    }

    /// 清理留言內容
    fn sanitize_content(content: &str) -> String {
        content
            .trim()
            .replace("<script", "&lt;script")
            .replace("</script>", "&lt;/script&gt;")
            .replace("javascript:", "")
            .chars()
            .filter(|&c| c != '\0' && c != '\x08')
            .collect::<String>()
            .split_whitespace()
            .collect::<Vec<&str>>()
            .join(" ")
    }
}

記得更新 src/services/mod.rs

pub mod post_service;
pub mod tag_service;
pub mod comment_service; // 新增

pub use post_service::PostService;
pub use tag_service::TagService;
pub use comment_service::CommentService; // 新增

🛣️ 留言路由實作

建立 src/routes/comments.rs

use axum::{
    extract::{Path, Query, State, ConnectInfo},
    http::{StatusCode, HeaderMap},
    response::Json,
    routing::{delete, get, post, put},
    Router,
};
use std::net::SocketAddr;

use crate::{
    dtos::{
        CreateCommentRequest, CommentResponse, CommentListQuery, 
        UpdateCommentStatusRequest, CommentModerationResponse
    },
    error::AppError,
    services::CommentService,
    state::AppState,
};

pub fn create_comment_routes() -> Router<AppState> {
    Router::new()
        .route("/posts/{post_id}/comments", get(get_comments).post(create_comment))
        .route("/admin/comments", get(get_admin_comments))
        .route("/admin/comments/{id}", put(update_comment_status).delete(delete_comment))
}

/// 取得文章的留言列表
#[utoipa::path(
    get,
    path = "/posts/{post_id}/comments",
    tag = "comments",
    params(
        ("post_id" = i32, Path, description = "文章 ID"),
        CommentListQuery
    ),
    responses(
        (status = 200, description = "留言列表", body = Vec<CommentResponse>),
        (status = 404, description = "文章不存在")
    )
)]
pub async fn get_comments(
    State(app_state): State<AppState>,
    Path(post_id): Path<i32>,
    Query(query): Query<CommentListQuery>,
) -> Result<Json<Vec<CommentResponse>>, AppError> {
    let comments = CommentService::get_comments_for_post(&app_state.db, post_id, query).await?;
    Ok(Json(comments))
}

/// 為文章建立留言
#[utoipa::path(
    post,
    path = "/posts/{post_id}/comments",
    tag = "comments",
    params(("post_id" = i32, Path, description = "文章 ID")),
    request_body = CreateCommentRequest,
    responses(
        (status = 201, description = "留言建立成功", body = CommentResponse),
        (status = 400, description = "請求資料錯誤"),
        (status = 404, description = "文章不存在"),
        (status = 422, description = "驗證失敗")
    )
)]
pub async fn create_comment(
    State(app_state): State<AppState>,
    Path(post_id): Path<i32>,
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    Json(req): Json<CreateCommentRequest>,
) -> Result<(StatusCode, Json<CommentResponse>), AppError> {
    // 取得 IP 地址
    let ip_address = Some(addr.ip().to_string());
    
    // 取得 User-Agent
    let user_agent = headers
        .get("user-agent")
        .and_then(|h| h.to_str().ok())
        .map(|s| s.to_string());

    let comment = CommentService::create_comment(&app_state.db, post_id, req, ip_address, user_agent).await?;
    Ok((StatusCode::CREATED, Json(comment)))
}

/// 取得留言列表(管理員用)
#[utoipa::path(
    get,
    path = "/admin/comments",
    tag = "admin",
    params(CommentListQuery),
    responses(
        (status = 200, description = "留言列表", body = Vec<CommentModerationResponse>),
        (status = 401, description = "需要管理員權限")
    )
)]
pub async fn get_admin_comments(
    State(app_state): State<AppState>,
    Query(query): Query<CommentListQuery>,
) -> Result<Json<Vec<CommentModerationResponse>>, AppError> {
    let comments = CommentService::get_comments_for_admin(&app_state.db, query).await?;
    Ok(Json(comments))
}

/// 更新留言狀態(管理員審核)
#[utoipa::path(
    put,
    path = "/admin/comments/{id}",
    tag = "admin",
    params(("id" = i32, Path, description = "留言 ID")),
    request_body = UpdateCommentStatusRequest,
    responses(
        (status = 200, description = "留言狀態更新成功", body = CommentResponse),
        (status = 404, description = "留言不存在"),
        (status = 401, description = "需要管理員權限")
    )
)]
pub async fn update_comment_status(
    State(app_state): State<AppState>,
    Path(id): Path<i32>,
    Json(req): Json<UpdateCommentStatusRequest>,
) -> Result<Json<CommentResponse>, AppError> {
    let comment = CommentService::update_comment_status(&app_state.db, id, req).await?;
    Ok(Json(comment))
}

/// 刪除留言(管理員用)
#[utoipa::path(
    delete,
    path = "/admin/comments/{id}",
    tag = "admin",
    params(("id" = i32, Path, description = "留言 ID")),
    responses(
        (status = 204, description = "留言刪除成功"),
        (status = 404, description = "留言不存在"),
        (status = 401, description = "需要管理員權限")
    )
)]
pub async fn delete_comment(
    State(app_state): State<AppState>,
    Path(id): Path<i32>,
) -> Result<StatusCode, AppError> {
    CommentService::delete_comment(&app_state.db, id).await?;
    Ok(StatusCode::NO_CONTENT)
}

記得在 src/routes/mod.rs 中加入留言路由:

pub mod blog;
pub mod health;
pub mod posts;
pub mod tags;
pub mod comments; // 新增

use axum::Router;
use crate::state::AppState;

pub fn create_routes() -> Router<AppState> {
    Router::new()
        .merge(health::create_health_routes())
        .merge(blog::create_blog_routes())
        .merge(posts::create_post_routes())
        .merge(tags::create_tag_routes())
        .merge(comments::create_comment_routes()) // 新增
}

📚 更新 OpenAPI 文件

更新 src/docs.rs,加入留言相關的 API:

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

#[derive(OpenApi)]
#[openapi(
    paths(
        // 原有的路徑...
        crate::routes::comments::get_comments,
        crate::routes::comments::create_comment,
        crate::routes::comments::get_admin_comments,
        crate::routes::comments::update_comment_status,
        crate::routes::comments::delete_comment,
    ),
    components(
        schemas(
            // 原有的 schemas...
            crate::dtos::CreateCommentRequest,
            crate::dtos::CommentResponse,
            crate::dtos::CommentListQuery,
            crate::dtos::UpdateCommentStatusRequest,
            crate::dtos::CommentModerationResponse,
        )
    ),
    tags(
        // 原有的 tags...
        (name = "comments", description = "留言相關 API"),
    ),
    // 其餘設定保持不變...
)]
pub struct ApiDoc;

明天預告

明天我們將進入 Day 27:提升體驗 - 個人部落格的實用功能

我們會為部落格加入更多實用功能:

  • 🔍 文章搜尋系統 - 關鍵字搜尋、標題內容檢索
  • 📊 分頁查詢優化 - 高效能的分頁實作
  • 🏷️ 多條件篩選 - 標籤、狀態、日期範圍篩選
  • 📈 排序功能擴展 - 多欄位排序、智慧排序

我們會學到如何設計高效能的查詢 API,以及如何在功能豐富性和效能之間找到平衡!


今天我們成功建立了一個有溫度的留言互動系統!

留言系統完成後,我們的個人部落格已經具備了基本的互動能力。明天的功能增強將讓整個系統更加實用和高效,真正成為一個專業級的部落格平台!

我們明天見!


上一篇
Day 25: 標籤系統 - 個人化的內容組織
下一篇
Day 27: 提升體驗 - 個人部落格的實用功能
系列文
大家一起跟Rust當好朋友吧!30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言