嗨嗨!大家好!歡迎來到 Rust 三十天挑戰的第二十六天!
昨天我們完成了標籤系統的完整功能,建立了一個既實用又智慧的內容組織機制。今天我們要進入的部分是:留言系統!
建立 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", "<script")
.replace("</script>", "</script>")
.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()) // 新增
}
更新 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,以及如何在功能豐富性和效能之間找到平衡!
今天我們成功建立了一個有溫度的留言互動系統!
留言系統完成後,我們的個人部落格已經具備了基本的互動能力。明天的功能增強將讓整個系統更加實用和高效,真正成為一個專業級的部落格平台!
我們明天見!