feat(comments): add complete comment system with guest commenting, moderation, and admin UI

Implements a fully self-built comment system for the blog:

Data layer:
- comments table with BIGSERIAL PK, parent_id self-reference (ON DELETE SET NULL),
  depth tracking (max 20), status workflow (pending/approved/spam/trash),
  content hashing for dedup, GDPR consent tracking, IP/UA storage with auto-purge
- 5 partial indexes optimized for read patterns
- updated_at auto-trigger

API (9 Dioxus server functions):
- Public: get_comments, get_comment_count, create_comment
- Admin: get_pending_comments, get_pending_count, get_all_comments,
  approve_comment (with ancestor auto-approval), spam_comment, trash_comment,
  batch_update_comment_status

Security:
- Function-level rate limiting (1/sec, burst 5) via FullstackContext IP extraction
- Input validation (name, email, URL scheme, content length, consent)
- Parent chain validation (must be approved, same post)
- Strict comment Markdown renderer (headings→strong, no img/id/data URIs, nofollow links)
- Honeypot anti-spam field
- 5-minute dedup window via SHA-256 content hash

Frontend:
- CommentSection with SuspenseBoundary isolation
- Flat-list rendering with depth-based CSS indentation (responsive)
- Gravatar via cravatar.cn (server-computed, email never exposed)
- Inline reply forms (one-at-a-time via Signal)
- Admin action buttons (approve/spam/delete) visible per-comment
- CommentForm with privacy consent, Markdown hint, loading states

Admin:
- /admin/comments page with status tabs, batch operations, pagination
- Pending count badge on admin dashboard

Infrastructure:
- Shared get_current_admin_user moved from posts/helpers to auth module
- COMMENT_LIMITER rate limiter tier
- Moka caches (60s TTL for comments, 10s for pending count)
- IP/UA purge background task (daily, 90-day retention)
This commit is contained in:
xfy 2026-06-11 12:34:26 +08:00
parent efa41b42c2
commit 04737300e6
35 changed files with 2706 additions and 18 deletions

13
Cargo.lock generated
View File

@ -2690,6 +2690,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest 0.10.7",
]
[[package]]
name = "md-5"
version = "0.11.0"
@ -3137,7 +3147,7 @@ dependencies = [
"bytes",
"fallible-iterator",
"hmac",
"md-5",
"md-5 0.11.0",
"memchr",
"rand 0.10.1",
"sha2 0.11.0",
@ -5358,6 +5368,7 @@ dependencies = [
"http",
"image",
"js-sys",
"md-5 0.10.6",
"moka",
"pulldown-cmark",
"rand 0.8.6",

View File

@ -34,6 +34,7 @@ image = { version = "0.25", optional = true, default-features = false, features
zenwebp = { version = "0.3", optional = true }
moka = { version = "0.12", features = ["future"], optional = true }
governor = { version = "0.8", optional = true }
md-5 = { version = "0.10", optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator"] }
@ -69,4 +70,5 @@ server = [
"dep:zenwebp",
"dep:moka",
"dep:governor",
"dep:md-5",
]

View File

@ -0,0 +1,58 @@
CREATE TABLE IF NOT EXISTS comments (
id BIGSERIAL PRIMARY KEY,
post_id INT NOT NULL REFERENCES posts(id) ON DELETE RESTRICT,
parent_id BIGINT REFERENCES comments(id) ON DELETE SET NULL,
depth INT NOT NULL DEFAULT 0,
author_name VARCHAR(50) NOT NULL,
author_email VARCHAR(255) NOT NULL,
author_url VARCHAR(500),
content_md TEXT NOT NULL,
content_html TEXT,
content_hash VARCHAR(64),
status TEXT NOT NULL DEFAULT 'pending',
ip_address VARCHAR(45),
user_agent VARCHAR(500),
consented_at TIMESTAMPTZ,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT comments_status_check
CHECK (status IN ('pending', 'approved', 'spam', 'trash')),
CONSTRAINT comments_depth_check
CHECK (depth >= 0 AND depth <= 20),
CONSTRAINT comments_content_not_empty
CHECK (length(trim(content_md)) >= 1),
CONSTRAINT comments_name_not_empty
CHECK (length(trim(author_name)) >= 1)
);
CREATE INDEX IF NOT EXISTS idx_comments_post_approved
ON comments(post_id, created_at) WHERE status = 'approved' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_comments_top_level
ON comments(post_id, created_at)
WHERE parent_id IS NULL AND status = 'approved' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_comments_pending
ON comments(created_at DESC) WHERE status = 'pending' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_comments_admin_list
ON comments(status, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_comments_parent
ON comments(parent_id) WHERE parent_id IS NOT NULL;
CREATE OR REPLACE FUNCTION update_comments_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_comments_updated_at
BEFORE UPDATE ON comments
FOR EACH ROW
EXECUTE FUNCTION update_comments_updated_at();

View File

@ -326,6 +326,22 @@ pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
Ok(CurrentUserResponse { user })
}
#[cfg(feature = "server")]
pub async fn get_current_admin_user() -> Result<User, AppError> {
let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
let user = get_user_by_token(&token)
.await
.map_err(AppError::query)?
.ok_or(AppError::Unauthorized("会话已过期"))?;
if user.role != UserRole::Admin {
return Err(AppError::Forbidden("权限不足"));
}
Ok(user)
}
#[cfg(test)]
mod tests {
use super::*;

234
src/api/comments/create.rs Normal file
View File

@ -0,0 +1,234 @@
use dioxus::prelude::*;
use crate::api::comments::types::*;
#[server(CreateComment, "/api")]
pub async fn create_comment(
post_id: i32,
parent_id: Option<i64>,
author_name: String,
author_email: String,
author_url: Option<String>,
content_md: String,
consented: bool,
) -> Result<CommentResponse, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::cache;
use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::comments::helpers::{
validate_comment_name, validate_comment_email, validate_comment_url,
validate_comment_content, compute_content_hash,
};
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
let parts = ctx.parts_mut();
let ip = crate::api::rate_limit::get_client_ip(&parts.headers);
if let Err(msg) = crate::api::rate_limit::check_comment_limit(&ip) {
return Ok(CommentResponse {
success: false,
message: msg,
error_code: Some("rate_limited".into()),
});
}
}
if !consented {
return Ok(CommentResponse {
success: false,
message: "请同意隐私政策".to_string(),
error_code: Some("invalid_input".into()),
});
}
if let Err(e) = validate_comment_name(&author_name) {
return Ok(CommentResponse {
success: false,
message: e,
error_code: Some("invalid_input".into()),
});
}
if let Err(e) = validate_comment_email(&author_email) {
return Ok(CommentResponse {
success: false,
message: e,
error_code: Some("invalid_input".into()),
});
}
if let Some(ref url) = author_url {
if let Err(e) = validate_comment_url(url) {
return Ok(CommentResponse {
success: false,
message: e,
error_code: Some("invalid_input".into()),
});
}
}
if let Err(e) = validate_comment_content(&content_md) {
return Ok(CommentResponse {
success: false,
message: e,
error_code: Some("invalid_input".into()),
});
}
let client = get_conn().await.map_err(AppError::db_conn)?;
let post_row = client
.query_opt(
"SELECT status, deleted_at FROM posts WHERE id = $1",
&[&post_id],
)
.await
.map_err(AppError::query)?;
match post_row {
None => {
return Ok(CommentResponse {
success: false,
message: "文章不存在".to_string(),
error_code: Some("post_not_found".into()),
});
}
Some(row) => {
let status: String = row.get("status");
let deleted_at: Option<chrono::DateTime<chrono::Utc>> = row.get("deleted_at");
if status != "published" || deleted_at.is_some() {
return Ok(CommentResponse {
success: false,
message: "文章不存在".to_string(),
error_code: Some("post_not_found".into()),
});
}
}
}
let mut depth: i32 = 0;
if let Some(pid) = parent_id {
let parent_row = client
.query_opt(
"SELECT post_id, status, depth FROM comments WHERE id = $1 AND deleted_at IS NULL",
&[&pid],
)
.await
.map_err(AppError::query)?;
match parent_row {
None => {
return Ok(CommentResponse {
success: false,
message: "父评论不存在".to_string(),
error_code: Some("parent_not_found".into()),
});
}
Some(row) => {
let parent_post_id: i32 = row.get("post_id");
let parent_status: String = row.get("status");
let parent_depth: i32 = row.get("depth");
if parent_post_id != post_id {
return Ok(CommentResponse {
success: false,
message: "父评论不存在".to_string(),
error_code: Some("parent_not_found".into()),
});
}
if parent_status != "approved" {
return Ok(CommentResponse {
success: false,
message: "父评论未通过审核".to_string(),
error_code: Some("parent_not_approved".into()),
});
}
depth = parent_depth + 1;
if depth > 20 {
return Ok(CommentResponse {
success: false,
message: "评论嵌套层级过深".to_string(),
error_code: Some("too_deep".into()),
});
}
}
}
}
let content_hash = compute_content_hash(
post_id,
parent_id,
&author_name,
&content_md,
);
let dup: Option<i64> = client
.query_opt(
"SELECT id FROM comments WHERE post_id = $1 AND content_hash = $2 AND created_at > NOW() - INTERVAL '5 minutes'",
&[&post_id, &content_hash],
)
.await
.map_err(AppError::query)?
.map(|r| r.get(0));
if dup.is_some() {
return Ok(CommentResponse {
success: false,
message: "请勿重复提交".to_string(),
error_code: Some("duplicate".into()),
});
}
let content_html = crate::api::comments::markdown::render_comment_markdown(&content_md);
let ip_address = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
let parts = ctx.parts_mut();
Some(crate::api::rate_limit::get_client_ip(&parts.headers))
} else {
None
};
let user_agent = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
let parts = ctx.parts_mut();
parts.headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
} else {
None
};
client
.query_one(
"INSERT INTO comments \
(post_id, parent_id, depth, author_name, author_email, author_url, \
content_md, content_html, content_hash, status, ip_address, user_agent, consented_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11, NOW()) \
RETURNING id",
&[
&post_id,
&parent_id,
&depth,
&author_name.trim(),
&author_email.trim(),
&author_url.as_ref().map(|u| u.trim()).filter(|u| !u.is_empty()),
&content_md,
&content_html,
&content_hash,
&ip_address,
&user_agent,
],
)
.await
.map_err(AppError::query)?;
cache::invalidate_comments_by_post(post_id).await;
cache::invalidate_comment_count(post_id).await;
Ok(CommentResponse {
success: true,
message: "评论已提交,等待审核".to_string(),
error_code: None,
})
}
#[cfg(not(feature = "server"))]
unreachable!()
}

316
src/api/comments/helpers.rs Normal file
View File

@ -0,0 +1,316 @@
#![allow(clippy::unused_unit, deprecated, unused_imports)]
#[cfg(feature = "server")]
use crate::models::comment::{AdminComment, CommentStatus, PublicComment};
#[cfg(feature = "server")]
pub fn md5_hash(input: &str) -> String {
use md5::Digest;
let hash = md5::Md5::digest(input.as_bytes());
hex::encode(hash)
}
#[cfg(feature = "server")]
pub fn gravatar_url(email: &str) -> String {
let hash = md5_hash(&email.trim().to_lowercase());
format!("https://cravatar.cn/avatar/{}?d=mp&s=80", hash)
}
#[cfg(feature = "server")]
pub fn row_to_public_comment(row: &tokio_postgres::Row) -> PublicComment {
let email: String = row.get("author_email");
let created_at_dt: chrono::DateTime<chrono::Utc> = row.get("created_at");
let created_at_iso = created_at_dt.to_rfc3339();
let created_at_relative = format_relative_time(created_at_dt);
PublicComment {
id: row.get("id"),
parent_id: row.get("parent_id"),
depth: row.get("depth"),
author_name: row.get("author_name"),
author_url: row.get("author_url"),
avatar_url: gravatar_url(&email),
content_html: row.get("content_html"),
created_at: created_at_relative,
created_at_iso,
}
}
#[cfg(feature = "server")]
pub fn row_to_admin_comment(row: &tokio_postgres::Row) -> AdminComment {
let status_str: String = row.get("status");
AdminComment {
id: row.get("id"),
post_id: row.get("post_id"),
post_title: row.get("post_title"),
post_slug: row.get("post_slug"),
parent_id: row.get("parent_id"),
depth: row.get("depth"),
author_name: row.get("author_name"),
author_email: row.get("author_email"),
author_url: row.get("author_url"),
content_md: row.get("content_md"),
status: CommentStatus::from_str(&status_str),
created_at: row.get("created_at"),
}
}
pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
let now = chrono::Utc::now();
let diff = now.signed_duration_since(dt);
if diff.num_seconds() < 60 {
"刚刚".to_string()
} else if diff.num_minutes() < 60 {
format!("{} 分钟前", diff.num_minutes())
} else if diff.num_hours() < 24 {
format!("{} 小时前", diff.num_hours())
} else if diff.num_days() < 30 {
format!("{} 天前", diff.num_days())
} else {
dt.format("%Y-%m-%d").to_string()
}
}
#[allow(dead_code)]
pub fn validate_comment_name(name: &str) -> Result<(), String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("请输入昵称".to_string());
}
if trimmed.len() > 50 {
return Err("昵称长度不能超过 50 个字符".to_string());
}
Ok(())
}
#[allow(dead_code)]
pub fn validate_comment_email(email: &str) -> Result<(), String> {
let re =
regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
if !re.is_match(email.trim()) {
return Err("邮箱格式不正确".to_string());
}
Ok(())
}
#[allow(dead_code)]
pub fn validate_comment_url(url: &str) -> Result<(), String> {
if url.trim().is_empty() {
return Ok(());
}
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err("网址必须以 http:// 或 https:// 开头".to_string());
}
if url.len() > 200 {
return Err("网址长度不能超过 200 个字符".to_string());
}
Ok(())
}
#[allow(dead_code)]
pub fn validate_comment_content(content: &str) -> Result<(), String> {
let trimmed = content.trim();
if trimmed.is_empty() {
return Err("请输入评论内容".to_string());
}
if trimmed.len() > 10000 {
return Err("评论内容不能超过 10000 个字符".to_string());
}
Ok(())
}
pub fn compute_content_hash(
post_id: i32,
parent_id: Option<i64>,
name: &str,
content: &str,
) -> String {
use sha2::Digest;
let input = format!(
"{}:{}:{}:{}",
post_id,
parent_id.map(|id| id.to_string()).unwrap_or_default(),
name.trim(),
content.trim()
);
let hash = sha2::Sha256::digest(input.as_bytes());
hex::encode(hash)
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn md5_hash_known_value() {
assert_eq!(md5_hash("hello"), "5d41402abc4b2a76b9719d911017c592");
}
#[test]
fn md5_hash_empty() {
assert_eq!(md5_hash(""), "d41d8cd98f00b204e9800998ecf8427e");
}
#[test]
fn gravatar_url_format() {
let url = gravatar_url("test@example.com");
assert!(url.starts_with("https://cravatar.cn/avatar/"));
assert!(url.contains("?d=mp&s=80"));
}
#[test]
fn gravatar_url_normalizes_email() {
let url1 = gravatar_url("Test@Example.com");
let url2 = gravatar_url("test@example.com");
assert_eq!(url1, url2);
}
#[test]
fn gravatar_url_trims_whitespace() {
let url1 = gravatar_url(" test@example.com ");
let url2 = gravatar_url("test@example.com");
assert_eq!(url1, url2);
}
#[test]
fn format_relative_time_just_now() {
let now = chrono::Utc::now();
assert_eq!(format_relative_time(now), "刚刚");
}
#[test]
fn format_relative_time_minutes() {
let dt = chrono::Utc::now() - chrono::Duration::minutes(5);
assert_eq!(format_relative_time(dt), "5 分钟前");
}
#[test]
fn format_relative_time_hours() {
let dt = chrono::Utc::now() - chrono::Duration::hours(3);
assert_eq!(format_relative_time(dt), "3 小时前");
}
#[test]
fn format_relative_time_days() {
let dt = chrono::Utc::now() - chrono::Duration::days(7);
assert_eq!(format_relative_time(dt), "7 天前");
}
#[test]
fn format_relative_time_old_date() {
let dt = chrono::Utc::now() - chrono::Duration::days(60);
let result = format_relative_time(dt);
assert!(result.contains('-'));
assert_eq!(result.len(), 10);
}
#[test]
fn validate_comment_name_valid() {
assert!(validate_comment_name("Alice").is_ok());
assert!(validate_comment_name("张三").is_ok());
}
#[test]
fn validate_comment_name_empty() {
assert!(validate_comment_name("").is_err());
assert!(validate_comment_name(" ").is_err());
}
#[test]
fn validate_comment_name_too_long() {
assert!(validate_comment_name(&"a".repeat(51)).is_err());
}
#[test]
fn validate_comment_name_max_length() {
assert!(validate_comment_name(&"a".repeat(50)).is_ok());
}
#[test]
fn validate_comment_email_valid() {
assert!(validate_comment_email("user@example.com").is_ok());
assert!(validate_comment_email("a.b+c@domain.co").is_ok());
}
#[test]
fn validate_comment_email_invalid() {
assert!(validate_comment_email("notanemail").is_err());
assert!(validate_comment_email("@domain.com").is_err());
assert!(validate_comment_email("user@").is_err());
}
#[test]
fn validate_comment_url_valid() {
assert!(validate_comment_url("http://example.com").is_ok());
assert!(validate_comment_url("https://example.com/path").is_ok());
}
#[test]
fn validate_comment_url_empty_is_ok() {
assert!(validate_comment_url("").is_ok());
assert!(validate_comment_url(" ").is_ok());
}
#[test]
fn validate_comment_url_invalid_scheme() {
assert!(validate_comment_url("ftp://example.com").is_err());
assert!(validate_comment_url("javascript:alert(1)").is_err());
}
#[test]
fn validate_comment_url_too_long() {
let long_url = format!("https://example.com/{}", "a".repeat(200));
assert!(validate_comment_url(&long_url).is_err());
}
#[test]
fn validate_comment_content_valid() {
assert!(validate_comment_content("Hello world").is_ok());
}
#[test]
fn validate_comment_content_empty() {
assert!(validate_comment_content("").is_err());
assert!(validate_comment_content(" ").is_err());
}
#[test]
fn validate_comment_content_too_long() {
assert!(validate_comment_content(&"a".repeat(10001)).is_err());
}
#[test]
fn validate_comment_content_max_length() {
assert!(validate_comment_content(&"a".repeat(10000)).is_ok());
}
#[test]
fn compute_content_hash_deterministic() {
let h1 = compute_content_hash(1, None, "Alice", "Hello");
let h2 = compute_content_hash(1, None, "Alice", "Hello");
assert_eq!(h1, h2);
}
#[test]
fn compute_content_hash_different_inputs() {
let h1 = compute_content_hash(1, None, "Alice", "Hello");
let h2 = compute_content_hash(2, None, "Alice", "Hello");
assert_ne!(h1, h2);
}
#[test]
fn compute_content_hash_trims_whitespace() {
let h1 = compute_content_hash(1, None, "Alice", "Hello");
let h2 = compute_content_hash(1, None, " Alice ", " Hello ");
assert_eq!(h1, h2);
}
#[test]
fn compute_content_hash_64_hex_chars() {
let h = compute_content_hash(1, None, "Alice", "Hello");
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
}

166
src/api/comments/list.rs Normal file
View File

@ -0,0 +1,166 @@
use dioxus::prelude::*;
use crate::api::comments::types::*;
#[server(GetPendingComments, "/api")]
pub async fn get_pending_comments(
page: i32,
) -> Result<PendingCommentsResponse, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::comments::helpers::row_to_admin_comment;
use crate::api::auth::get_current_admin_user;
let _admin = get_current_admin_user().await?;
let page = page.max(1);
let per_page: i64 = 20;
let offset: i64 = (page as i64 - 1) * per_page;
let client = get_conn().await.map_err(AppError::db_conn)?;
let total: i64 = client
.query_one(
"SELECT COUNT(*) FROM comments WHERE status = 'pending' AND deleted_at IS NULL",
&[],
)
.await
.map_err(AppError::query)?
.get(0);
let rows = client
.query(
"SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, c.author_email, \
c.author_url, c.content_md, c.status, c.created_at, \
p.title as post_title, p.slug as post_slug \
FROM comments c JOIN posts p ON c.post_id = p.id \
WHERE c.status = 'pending' AND c.deleted_at IS NULL \
ORDER BY c.created_at DESC LIMIT $1 OFFSET $2",
&[&per_page, &offset],
)
.await
.map_err(AppError::query)?;
let comments = rows.iter().map(row_to_admin_comment).collect();
Ok(PendingCommentsResponse { comments, total })
}
#[cfg(not(feature = "server"))]
unreachable!()
}
#[server(GetPendingCount, "/api")]
pub async fn get_pending_count() -> Result<PendingCountResponse, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::cache;
use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::auth::get_current_admin_user;
let _admin = get_current_admin_user().await?;
if let Some(cached) = cache::get_pending_count().await {
return Ok(PendingCountResponse { count: cached });
}
let client = get_conn().await.map_err(AppError::db_conn)?;
let count: i64 = client
.query_one(
"SELECT COUNT(*) FROM comments WHERE status = 'pending' AND deleted_at IS NULL",
&[],
)
.await
.map_err(AppError::query)?
.get(0);
cache::set_pending_count(count).await;
Ok(PendingCountResponse { count })
}
#[cfg(not(feature = "server"))]
unreachable!()
}
#[server(GetAllComments, "/api")]
pub async fn get_all_comments(
page: i32,
status: Option<String>,
) -> Result<AllCommentsResponse, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::comments::helpers::row_to_admin_comment;
use crate::api::auth::get_current_admin_user;
let _admin = get_current_admin_user().await?;
let page = page.max(1);
let per_page: i64 = 20;
let offset: i64 = (page as i64 - 1) * per_page;
let client = get_conn().await.map_err(AppError::db_conn)?;
let (total, rows) = match status.as_deref() {
Some(s) if !s.is_empty() => {
let total: i64 = client
.query_one(
"SELECT COUNT(*) FROM comments WHERE status = $1 AND deleted_at IS NULL",
&[&s],
)
.await
.map_err(AppError::query)?
.get(0);
let rows = client
.query(
"SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, c.author_email, \
c.author_url, c.content_md, c.status, c.created_at, \
p.title as post_title, p.slug as post_slug \
FROM comments c JOIN posts p ON c.post_id = p.id \
WHERE c.status = $1 AND c.deleted_at IS NULL \
ORDER BY c.created_at DESC LIMIT $2 OFFSET $3",
&[&s, &per_page, &offset],
)
.await
.map_err(AppError::query)?;
(total, rows)
}
_ => {
let total: i64 = client
.query_one(
"SELECT COUNT(*) FROM comments WHERE deleted_at IS NULL",
&[],
)
.await
.map_err(AppError::query)?
.get(0);
let rows = client
.query(
"SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, c.author_email, \
c.author_url, c.content_md, c.status, c.created_at, \
p.title as post_title, p.slug as post_slug \
FROM comments c JOIN posts p ON c.post_id = p.id \
WHERE c.deleted_at IS NULL \
ORDER BY c.created_at DESC LIMIT $1 OFFSET $2",
&[&per_page, &offset],
)
.await
.map_err(AppError::query)?;
(total, rows)
}
};
let comments = rows.iter().map(row_to_admin_comment).collect();
Ok(AllCommentsResponse { comments, total })
}
#[cfg(not(feature = "server"))]
unreachable!()
}

View File

@ -0,0 +1,206 @@
#![allow(clippy::unused_unit, deprecated, unused_imports)]
#[cfg(feature = "server")]
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(feature = "server")]
pub fn clean_comment_html(input: &str) -> String {
let mut builder = ammonia::Builder::default();
builder
.rm_tags(["img", "details", "summary"])
.add_generic_attributes(&[
"class",
"title",
"aria-hidden",
"aria-label",
"role",
"accesskey",
])
.url_relative(ammonia::UrlRelative::PassThrough)
.add_tag_attributes("a", &["class", "aria-hidden", "aria-label"])
.add_tag_attributes("span", &["class"])
.link_rel(Some("nofollow noopener"));
builder.clean(input).to_string()
}
#[cfg(feature = "server")]
pub fn render_comment_markdown(md: &str) -> String {
use pulldown_cmark::{CodeBlockKind, Event, Options, Tag, TagEnd};
let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
let parser = pulldown_cmark::Parser::new_ext(md, opts);
let mut events: Vec<Event> = Vec::new();
let mut in_codeblock = false;
let mut code_lang: Option<String> = None;
let mut code_buffer = String::new();
for event in parser {
match event {
Event::Start(Tag::Heading { .. }) => {
events.push(Event::Start(Tag::Strong));
}
Event::End(TagEnd::Heading(_)) => {
events.push(Event::End(TagEnd::Strong));
}
Event::Start(Tag::CodeBlock(kind)) => {
in_codeblock = true;
code_lang = match kind {
CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
Some(lang.to_string())
}
_ => None,
};
code_buffer.clear();
}
Event::Text(text) if in_codeblock => {
code_buffer.push_str(&text);
}
Event::End(TagEnd::CodeBlock) => {
let html = if let Some(ref lang) = code_lang {
let highlighted =
crate::highlight::server::highlight_code(&code_buffer, Some(lang));
format!("<pre><code>{}</code></pre>", highlighted)
} else {
format!("<pre><code>{}</code></pre>", html_escape(&code_buffer))
};
events.push(Event::Html(html.into()));
in_codeblock = false;
}
_ if !in_codeblock => {
events.push(event);
}
_ => {}
}
}
let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, events.into_iter());
clean_comment_html(&html)
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn render_comment_heading_converted_to_strong() {
let result = render_comment_markdown("## Hello World");
assert!(result.contains("<strong>Hello World</strong>"));
assert!(!result.contains("<h2>"));
}
#[test]
fn render_comment_heading_all_levels() {
for md in &[
"# H1", "## H2", "### H3", "#### H4", "##### H5", "###### H6",
] {
let result = render_comment_markdown(md);
assert!(result.contains("<strong>"), "heading not converted for: {}", md);
}
}
#[test]
fn render_comment_paragraph() {
let result = render_comment_markdown("Hello **world**");
assert!(result.contains("<strong>world</strong>"));
}
#[test]
fn render_comment_code_block_with_language() {
let result = render_comment_markdown("```rust\nfn main() {}\n```");
assert!(result.contains("<pre><code>"));
assert!(result.contains("main"));
}
#[test]
fn render_comment_code_block_without_language() {
let result = render_comment_markdown("```\nplain text\n```");
assert!(result.contains("<pre><code>"));
assert!(result.contains("plain text"));
}
#[test]
fn render_comment_code_block_without_language_escapes_html() {
let result = render_comment_markdown("```\n<div>alert('xss')</div>\n```");
assert!(result.contains("&lt;div&gt;"));
assert!(!result.contains("<div>"));
}
#[test]
fn render_comment_strips_script() {
let result = render_comment_markdown("<script>alert('xss')</script>");
assert!(!result.contains("script"));
}
#[test]
fn render_comment_no_img_tags() {
let result = render_comment_markdown("![alt](https://example.com/img.png)");
assert!(!result.contains("<img"));
}
#[test]
fn render_comment_link_has_nofollow() {
let result = render_comment_markdown("[link](https://example.com)");
assert!(result.contains("nofollow"));
assert!(result.contains("noopener"));
}
#[test]
fn render_comment_no_id_attribute() {
let result = render_comment_markdown("<div id=\"test\">text</div>");
assert!(!result.contains("id="));
}
#[test]
fn render_comment_table() {
let result = render_comment_markdown("| a | b |\n|---|---|\n| 1 | 2 |");
assert!(result.contains("<table>"));
}
#[test]
fn render_comment_strikethrough() {
let result = render_comment_markdown("~~deleted~~");
assert!(result.contains("<del>deleted</del>"));
}
#[test]
fn render_comment_inline_code() {
let result = render_comment_markdown("Use `println!` to print");
assert!(result.contains("<code>println!</code>"));
}
#[test]
fn clean_comment_html_removes_details_summary() {
let result = clean_comment_html("<details><summary>Click</summary><p>Content</p></details>");
assert!(!result.contains("details"));
assert!(!result.contains("summary"));
}
#[test]
fn clean_comment_html_removes_data_uri() {
let result =
clean_comment_html("<a href=\"data:text/html,<script>alert(1)</script>\">click</a>");
assert!(!result.contains("data:"));
}
#[test]
fn render_comment_empty() {
let result = render_comment_markdown("");
assert!(result.is_empty());
}
#[test]
fn render_comment_heading_with_inline_code() {
let result = render_comment_markdown("## Using `foo()`");
assert!(result.contains("<strong>"));
assert!(result.contains("<code>foo()</code>"));
assert!(!result.contains("<h2>"));
}
}

18
src/api/comments/mod.rs Normal file
View File

@ -0,0 +1,18 @@
#![allow(clippy::unused_unit, deprecated, unused_imports, clippy::too_many_arguments)]
mod types;
mod helpers;
mod markdown;
mod create;
mod read;
mod update;
mod list;
pub use types::*;
pub use create::create_comment;
pub use read::{get_comments, get_comment_count};
pub use update::{approve_comment, spam_comment, trash_comment, batch_update_comment_status};
pub use list::{get_pending_comments, get_pending_count, get_all_comments};
#[cfg(feature = "server")]
pub use markdown::render_comment_markdown;

78
src/api/comments/read.rs Normal file
View File

@ -0,0 +1,78 @@
use dioxus::prelude::*;
use crate::api::comments::types::*;
#[server(GetComments, "/api")]
pub async fn get_comments(
post_id: i32,
) -> Result<CommentTreeResponse, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::cache;
use crate::db::pool::get_conn;
use crate::api::comments::helpers::row_to_public_comment;
use crate::api::error::AppError;
if let Some(cached) = cache::get_comments_by_post(post_id).await {
let count = cached.len() as i64;
return Ok(CommentTreeResponse {
comments: cached,
count,
});
}
let client = get_conn().await.map_err(AppError::db_conn)?;
let rows = client
.query(
"SELECT id, parent_id, depth, author_name, author_email, author_url, content_html, created_at \
FROM comments \
WHERE post_id = $1 AND status = 'approved' AND deleted_at IS NULL \
ORDER BY id ASC",
&[&post_id],
)
.await
.map_err(AppError::query)?;
let comments: Vec<_> = rows.iter().map(row_to_public_comment).collect();
let count = comments.len() as i64;
cache::set_comments_by_post(post_id, comments.clone()).await;
Ok(CommentTreeResponse { comments, count })
}
#[cfg(not(feature = "server"))]
unreachable!()
}
#[server(GetCommentCount, "/api")]
pub async fn get_comment_count(
post_id: i32,
) -> Result<CommentCountResponse, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::cache;
use crate::db::pool::get_conn;
use crate::api::error::AppError;
if let Some(cached) = cache::get_comment_count(post_id).await {
return Ok(CommentCountResponse { count: cached });
}
let client = get_conn().await.map_err(AppError::db_conn)?;
let count: i64 = client
.query_one(
"SELECT COUNT(*) FROM comments WHERE post_id = $1 AND status = 'approved' AND deleted_at IS NULL",
&[&post_id],
)
.await
.map_err(AppError::query)?
.get(0);
cache::set_comment_count(post_id, count).await;
Ok(CommentCountResponse { count })
}
#[cfg(not(feature = "server"))]
unreachable!()
}

55
src/api/comments/types.rs Normal file
View File

@ -0,0 +1,55 @@
use serde::{Deserialize, Serialize};
use crate::models::comment::{AdminComment, PublicComment};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct CreateCommentRequest {
pub post_id: i32,
pub parent_id: Option<i64>,
pub author_name: String,
pub author_email: String,
pub author_url: Option<String>,
pub content_md: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentResponse {
pub success: bool,
pub message: String,
pub error_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentTreeResponse {
pub comments: Vec<PublicComment>,
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentCountResponse {
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingCommentsResponse {
pub comments: Vec<AdminComment>,
pub total: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AllCommentsResponse {
pub comments: Vec<AdminComment>,
pub total: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingCountResponse {
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchStatusResponse {
pub success: bool,
pub updated_count: i64,
pub message: String,
}

244
src/api/comments/update.rs Normal file
View File

@ -0,0 +1,244 @@
use dioxus::prelude::*;
use crate::api::comments::types::*;
#[server(ApproveComment, "/api")]
pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::cache;
use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::auth::get_current_admin_user;
let _admin = get_current_admin_user().await?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let row = client
.query_opt(
"SELECT post_id, status FROM comments WHERE id = $1 AND deleted_at IS NULL",
&[&id],
)
.await
.map_err(AppError::query)?;
let post_id: i32 = match row {
Some(r) => r.get("post_id"),
None => {
return Ok(CommentResponse {
success: false,
message: "评论不存在".to_string(),
error_code: Some("not_found".into()),
});
}
};
client
.execute(
"UPDATE comments SET status = 'approved', approved_at = NOW() WHERE id = $1",
&[&id],
)
.await
.map_err(AppError::query)?;
client
.execute(
"WITH RECURSIVE ancestors AS ( \
SELECT parent_id FROM comments WHERE id = $1 \
UNION ALL \
SELECT c.parent_id FROM comments c JOIN ancestors a ON c.id = a.parent_id WHERE a.parent_id IS NOT NULL \
) \
UPDATE comments SET status = 'approved', approved_at = NOW() \
WHERE id IN (SELECT parent_id FROM ancestors WHERE parent_id IS NOT NULL) AND status = 'pending'",
&[&id],
)
.await
.map_err(AppError::query)?;
cache::invalidate_comments_by_post(post_id).await;
cache::invalidate_comment_count(post_id).await;
cache::invalidate_pending_count().await;
Ok(CommentResponse {
success: true,
message: "已通过".to_string(),
error_code: None,
})
}
#[cfg(not(feature = "server"))]
unreachable!()
}
#[server(SpamComment, "/api")]
pub async fn spam_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::cache;
use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::auth::get_current_admin_user;
let _admin = get_current_admin_user().await?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let row = client
.query_opt(
"SELECT post_id, status FROM comments WHERE id = $1 AND deleted_at IS NULL",
&[&id],
)
.await
.map_err(AppError::query)?;
if let Some(r) = row {
let post_id: i32 = r.get("post_id");
let old_status: String = r.get("status");
client
.execute(
"UPDATE comments SET status = 'spam' WHERE id = $1 AND deleted_at IS NULL",
&[&id],
)
.await
.map_err(AppError::query)?;
if old_status == "approved" {
cache::invalidate_comments_by_post(post_id).await;
cache::invalidate_comment_count(post_id).await;
}
cache::invalidate_pending_count().await;
}
Ok(CommentResponse {
success: true,
message: "已标记为垃圾".to_string(),
error_code: None,
})
}
#[cfg(not(feature = "server"))]
unreachable!()
}
#[server(TrashComment, "/api")]
pub async fn trash_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::cache;
use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::auth::get_current_admin_user;
let _admin = get_current_admin_user().await?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let row = client
.query_opt(
"SELECT post_id FROM comments WHERE id = $1 AND deleted_at IS NULL",
&[&id],
)
.await
.map_err(AppError::query)?;
if let Some(r) = row {
let post_id: i32 = r.get("post_id");
client
.execute(
"UPDATE comments SET status = 'trash', deleted_at = NOW() WHERE id = $1",
&[&id],
)
.await
.map_err(AppError::query)?;
cache::invalidate_comments_by_post(post_id).await;
cache::invalidate_comment_count(post_id).await;
cache::invalidate_pending_count().await;
}
Ok(CommentResponse {
success: true,
message: "已删除".to_string(),
error_code: None,
})
}
#[cfg(not(feature = "server"))]
unreachable!()
}
#[server(BatchUpdateCommentStatus, "/api")]
pub async fn batch_update_comment_status(
ids: Vec<i64>,
status: String,
) -> Result<BatchStatusResponse, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::cache;
use crate::db::pool::get_conn;
use crate::api::error::AppError;
use crate::api::auth::get_current_admin_user;
let _admin = get_current_admin_user().await?;
if !matches!(status.as_str(), "approved" | "spam" | "trash") {
return Ok(BatchStatusResponse {
success: false,
updated_count: 0,
message: "无效的状态".to_string(),
});
}
let client = get_conn().await.map_err(AppError::db_conn)?;
let post_ids: Vec<i32> = client
.query(
"SELECT DISTINCT post_id FROM comments WHERE id = ANY($1)",
&[&ids],
)
.await
.map_err(AppError::query)?
.iter()
.map(|r| r.get("post_id"))
.collect();
let result = if status == "trash" {
client
.execute(
"UPDATE comments SET status = $1, deleted_at = NOW() WHERE id = ANY($2)",
&[&status, &ids],
)
.await
.map_err(AppError::query)?
} else if status == "approved" {
client
.execute(
"UPDATE comments SET status = $1, approved_at = NOW() WHERE id = ANY($2)",
&[&status, &ids],
)
.await
.map_err(AppError::query)?
} else {
client
.execute(
"UPDATE comments SET status = $1 WHERE id = ANY($2)",
&[&status, &ids],
)
.await
.map_err(AppError::query)?
};
cache::invalidate_pending_count().await;
for pid in post_ids {
cache::invalidate_comments_by_post(pid).await;
cache::invalidate_comment_count(pid).await;
}
Ok(BatchStatusResponse {
success: true,
updated_count: result as i64,
message: format!("已更新 {} 条评论", result),
})
}
#[cfg(not(feature = "server"))]
unreachable!()
}

View File

@ -1,4 +1,5 @@
pub mod auth;
pub mod comments;
pub mod error;
pub mod image;
pub mod markdown;

View File

@ -3,25 +3,10 @@ use crate::api::error::AppError;
#[cfg(feature = "server")]
use crate::models::post::{Post, PostStatus};
#[cfg(feature = "server")]
use crate::models::user::{User, UserRole};
#[cfg(feature = "server")]
use crate::utils::text::count_words;
#[cfg(feature = "server")]
pub(super) async fn get_current_admin_user() -> Result<User, AppError> {
let token = crate::auth::session::get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
let user = crate::api::auth::get_user_by_token(&token)
.await
.map_err(AppError::query)?
.ok_or(AppError::Unauthorized("会话已过期"))?;
if user.role != UserRole::Admin {
return Err(AppError::Forbidden("权限不足"));
}
Ok(user)
}
pub(super) use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")]
pub(super) async fn row_to_post_list(

View File

@ -42,6 +42,22 @@ static IMAGE_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(
)
});
#[cfg(feature = "server")]
static COMMENT_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed(
Quota::per_second(env_or("RATE_LIMIT_COMMENT_PER_SEC", 1))
.allow_burst(env_or("RATE_LIMIT_COMMENT_BURST", 5)),
)
});
#[cfg(feature = "server")]
pub fn check_comment_limit(ip: &str) -> Result<(), String> {
COMMENT_LIMITER
.check_key(&ip.to_string())
.map(|_| ())
.map_err(|_| "评论过于频繁,请稍后再试".to_string())
}
#[cfg(feature = "server")]
pub fn check_image_limit(ip: &str) -> Result<(), StatusCode> {
IMAGE_LIMITER

View File

@ -5,6 +5,8 @@ use std::sync::LazyLock;
#[cfg(feature = "server")]
use std::time::Duration;
#[cfg(feature = "server")]
use crate::models::comment::PublicComment;
#[cfg(feature = "server")]
use crate::models::post::{Post, PostStats, Tag};
@ -22,6 +24,12 @@ const TTL_SINGLE_POST: Duration = Duration::from_secs(600);
const TTL_POST_STATS: Duration = Duration::from_secs(60);
#[cfg(feature = "server")]
const TTL_TAG_POSTS: Duration = Duration::from_secs(120);
#[cfg(feature = "server")]
const TTL_COMMENTS: Duration = Duration::from_secs(60);
#[cfg(feature = "server")]
const TTL_COMMENT_COUNT: Duration = Duration::from_secs(60);
#[cfg(feature = "server")]
const TTL_PENDING_COUNT: Duration = Duration::from_secs(10);
// ============================================================================
// Cache Key Types
@ -36,6 +44,9 @@ pub enum CacheKey {
PostBySlug(String),
PostsByTag(String),
PostStats,
CommentsByPost { post_id: i32 },
CommentCount { post_id: i32 },
PendingCommentCount,
}
@ -96,6 +107,36 @@ static TAG_POSTS_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
.build()
});
#[cfg(feature = "server")]
pub type CommentListCache = Cache<CacheKey, Vec<PublicComment>>;
#[cfg(feature = "server")]
pub type CommentCountCache = Cache<CacheKey, i64>;
#[cfg(feature = "server")]
static COMMENT_CACHE: LazyLock<CommentListCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(200)
.time_to_live(TTL_COMMENTS)
.build()
});
#[cfg(feature = "server")]
static COMMENT_COUNT_CACHE: LazyLock<CommentCountCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(200)
.time_to_live(TTL_COMMENT_COUNT)
.build()
});
#[cfg(feature = "server")]
static PENDING_COUNT_CACHE: LazyLock<CommentCountCache> = LazyLock::new(|| {
Cache::builder()
.max_capacity(10)
.time_to_live(TTL_PENDING_COUNT)
.build()
});
// ============================================================================
// Public Cache API
// ============================================================================
@ -210,9 +251,81 @@ pub fn invalidate_all_post_caches() {
TAG_POSTS_CACHE.invalidate_all();
}
#[cfg(feature = "server")]
pub async fn get_comments_by_post(post_id: i32) -> Option<Vec<PublicComment>> {
COMMENT_CACHE
.get(&CacheKey::CommentsByPost { post_id })
.await
}
#[cfg(feature = "server")]
pub async fn set_comments_by_post(post_id: i32, comments: Vec<PublicComment>) {
let _ = COMMENT_CACHE
.insert(CacheKey::CommentsByPost { post_id }, comments)
.await;
}
#[cfg(feature = "server")]
pub async fn get_comment_count(post_id: i32) -> Option<i64> {
COMMENT_COUNT_CACHE
.get(&CacheKey::CommentCount { post_id })
.await
}
#[cfg(feature = "server")]
pub async fn set_comment_count(post_id: i32, count: i64) {
let _ = COMMENT_COUNT_CACHE
.insert(CacheKey::CommentCount { post_id }, count)
.await;
}
#[cfg(feature = "server")]
pub async fn get_pending_count() -> Option<i64> {
PENDING_COUNT_CACHE
.get(&CacheKey::PendingCommentCount)
.await
}
#[cfg(feature = "server")]
pub async fn set_pending_count(count: i64) {
let _ = PENDING_COUNT_CACHE
.insert(CacheKey::PendingCommentCount, count)
.await;
}
#[cfg(feature = "server")]
pub async fn invalidate_comments_by_post(post_id: i32) {
COMMENT_CACHE
.invalidate(&CacheKey::CommentsByPost { post_id })
.await;
}
#[cfg(feature = "server")]
pub async fn invalidate_comment_count(post_id: i32) {
COMMENT_COUNT_CACHE
.invalidate(&CacheKey::CommentCount { post_id })
.await;
}
#[cfg(feature = "server")]
pub async fn invalidate_pending_count() {
PENDING_COUNT_CACHE
.invalidate(&CacheKey::PendingCommentCount)
.await;
}
#[cfg(feature = "server")]
#[allow(dead_code)]
pub async fn invalidate_all_comment_caches() {
COMMENT_CACHE.invalidate_all();
COMMENT_COUNT_CACHE.invalidate_all();
PENDING_COUNT_CACHE.invalidate_all();
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
use crate::models::comment::PublicComment;
use crate::models::post::PostStatus;
#[test]
@ -322,4 +435,83 @@ mod tests {
let cached_after = get_post_by_slug("invalidation-test").await;
assert!(cached_after.is_none());
}
#[tokio::test]
async fn comment_cache_roundtrip() {
let comments = vec![PublicComment {
id: 1,
parent_id: None,
depth: 0,
author_name: "Alice".to_string(),
author_url: None,
avatar_url: "https://example.com/avatar".to_string(),
content_html: Some("<p>Hello</p>".to_string()),
created_at: "刚刚".to_string(),
created_at_iso: "2026-01-01T00:00:00Z".to_string(),
}];
set_comments_by_post(42, comments.clone()).await;
let cached = get_comments_by_post(42).await;
assert!(cached.is_some());
assert_eq!(cached.unwrap().len(), 1);
}
#[tokio::test]
async fn comment_count_cache_roundtrip() {
set_comment_count(42, 15).await;
let cached = get_comment_count(42).await;
assert!(cached.is_some());
assert_eq!(cached.unwrap(), 15);
}
#[tokio::test]
async fn pending_count_cache_roundtrip() {
set_pending_count(7).await;
let cached = get_pending_count().await;
assert!(cached.is_some());
assert_eq!(cached.unwrap(), 7);
}
#[tokio::test]
async fn comment_cache_invalidation() {
set_comments_by_post(99, vec![]).await;
assert!(get_comments_by_post(99).await.is_some());
invalidate_comments_by_post(99).await;
assert!(get_comments_by_post(99).await.is_none());
}
#[tokio::test]
async fn comment_count_invalidation() {
set_comment_count(99, 5).await;
assert!(get_comment_count(99).await.is_some());
invalidate_comment_count(99).await;
assert!(get_comment_count(99).await.is_none());
}
#[tokio::test]
async fn pending_count_invalidation() {
set_pending_count(3).await;
assert!(get_pending_count().await.is_some());
invalidate_pending_count().await;
assert!(get_pending_count().await.is_none());
}
#[tokio::test]
async fn invalidate_all_comment_caches_clears_everything() {
set_comments_by_post(1, vec![]).await;
set_comment_count(1, 10).await;
set_pending_count(5).await;
invalidate_all_comment_caches().await;
assert!(get_comments_by_post(1).await.is_none());
assert!(get_comment_count(1).await.is_none());
assert!(get_pending_count().await.is_none());
}
}

View File

@ -0,0 +1,60 @@
use dioxus::prelude::*;
use crate::api::comments::{approve_comment, spam_comment, trash_comment};
use crate::components::comments::section::CommentContext;
#[component]
pub fn CommentActions(comment_id: i64, post_id: i32) -> Element {
let ctx: CommentContext = use_context();
let refresh_trigger = ctx.refresh_trigger;
let mut busy = use_signal(|| false);
let _ = post_id;
rsx! {
div { class: "flex items-center gap-1.5",
button {
class: "text-xs px-2 py-0.5 rounded-full text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors cursor-pointer",
disabled: busy(),
onclick: move |_| {
busy.set(true);
let mut refresh_trigger = refresh_trigger;
spawn(async move {
let _ = approve_comment(comment_id).await;
refresh_trigger.set(!refresh_trigger());
busy.set(false);
});
},
"通过"
}
button {
class: "text-xs px-2 py-0.5 rounded-full text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors cursor-pointer",
disabled: busy(),
onclick: move |_| {
busy.set(true);
let mut refresh_trigger = refresh_trigger;
spawn(async move {
let _ = spam_comment(comment_id).await;
refresh_trigger.set(!refresh_trigger());
busy.set(false);
});
},
"垃圾"
}
button {
class: "text-xs px-2 py-0.5 rounded-full text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors cursor-pointer",
disabled: busy(),
onclick: move |_| {
busy.set(true);
let mut refresh_trigger = refresh_trigger;
spawn(async move {
let _ = trash_comment(comment_id).await;
refresh_trigger.set(!refresh_trigger());
busy.set(false);
});
},
"删除"
}
}
}
}

View File

@ -0,0 +1,197 @@
use dioxus::prelude::*;
use crate::api::comments::create_comment;
use crate::components::comments::section::CommentContext;
use crate::components::forms::{INPUT_CLASS, BUTTON_PRIMARY_CLASS, AlertBox};
#[component]
pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
let ctx: CommentContext = use_context();
let mut active_reply = ctx.active_reply;
let mut refresh_trigger = ctx.refresh_trigger;
let mut author_name = use_signal(String::new);
let mut author_email = use_signal(String::new);
let mut author_url = use_signal(String::new);
let mut content_md = use_signal(String::new);
let mut honeypot = use_signal(String::new);
let mut consented = use_signal(|| false);
let mut submitting = use_signal(|| false);
let mut message = use_signal(|| Option::<(String, &'static str)>::None);
if let Some(pid) = parent_id {
if active_reply() != Some(pid) {
return rsx! {};
}
}
let is_reply = parent_id.is_some();
rsx! {
div {
class: if is_reply { "mt-3 pt-3 border-t border-gray-100 dark:border-[#333]" } else { "" },
role: "form",
aria_label: if is_reply { "回复评论" } else { "发表评论" },
if let Some((msg, variant)) = message() {
div { aria_live: "polite",
AlertBox { message: msg, variant }
}
}
div { class: "space-y-3",
if !is_reply {
div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
div {
label { class: "block text-sm font-medium text-paper-secondary mb-1",
"昵称 *"
}
input {
class: INPUT_CLASS,
r#type: "text",
placeholder: "你的昵称",
value: "{author_name}",
disabled: submitting(),
oninput: move |e| author_name.set(e.value()),
}
}
div {
label { class: "block text-sm font-medium text-paper-secondary mb-1",
"邮箱 *"
}
input {
class: INPUT_CLASS,
r#type: "email",
placeholder: "your@email.com",
value: "{author_email}",
disabled: submitting(),
oninput: move |e| author_email.set(e.value()),
}
}
}
div {
label { class: "block text-sm font-medium text-paper-secondary mb-1",
"网站"
}
input {
class: INPUT_CLASS,
r#type: "url",
placeholder: "https://example.com可选",
value: "{author_url}",
disabled: submitting(),
oninput: move |e| author_url.set(e.value()),
}
}
}
textarea {
class: "{INPUT_CLASS} min-h-[100px] resize-y",
placeholder: "写下你的评论…",
value: "{content_md}",
disabled: submitting(),
oninput: move |e| content_md.set(e.value()),
}
p { class: "text-xs text-paper-tertiary",
"支持 Markdown 语法"
}
textarea {
class: "hidden",
aria_hidden: "true",
tabindex: "-1",
value: "{honeypot}",
oninput: move |e| honeypot.set(e.value()),
}
div { class: "flex items-start gap-2",
input {
r#type: "checkbox",
id: "consent-{post_id}-{parent_id.unwrap_or(0)}",
checked: consented(),
disabled: submitting(),
class: "mt-1 rounded border-gray-300 text-paper-accent focus:ring-paper-accent/30",
onchange: move |e| consented.set(e.checked()),
}
label {
r#for: "consent-{post_id}-{parent_id.unwrap_or(0)}",
class: "text-sm text-paper-secondary select-none cursor-pointer",
"同意隐私政策"
}
}
button {
class: BUTTON_PRIMARY_CLASS,
disabled: submitting(),
onclick: move |_| {
let post_id = post_id;
let parent_id = parent_id;
let name = author_name();
let email = author_email();
let url_val = author_url();
let content = content_md();
let hp = honeypot();
let consent = consented();
if !hp.is_empty() {
return;
}
if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty() {
message.set(Some(("请填写所有必填项".to_string(), "error")));
return;
}
if !consent {
message.set(Some(("请同意隐私政策".to_string(), "error")));
return;
}
submitting.set(true);
message.set(None);
spawn(async move {
let result = create_comment(
post_id,
parent_id,
name,
email,
if url_val.trim().is_empty() { None } else { Some(url_val) },
content,
consent,
).await;
submitting.set(false);
match result {
Ok(resp) => {
if resp.success {
content_md.set(String::new());
consented.set(false);
message.set(Some((resp.message, "success")));
if parent_id.is_some() {
active_reply.set(None);
}
refresh_trigger.set(!refresh_trigger());
} else {
message.set(Some((resp.message, "error")));
}
}
Err(_) => {
message.set(Some(("提交失败,请稍后重试".to_string(), "error")));
}
}
});
},
if submitting() {
"提交中…"
} else if is_reply {
"回复"
} else {
"发表评论"
}
}
}
}
}
}

View File

@ -0,0 +1,105 @@
use dioxus::prelude::*;
use crate::context::UserContext;
use crate::models::comment::PublicComment;
use crate::components::comments::section::CommentContext;
use crate::components::comments::form::CommentForm;
use crate::components::comments::actions::CommentActions;
#[component]
pub fn CommentItem(comment: PublicComment, post_id: i32) -> Element {
let ctx: CommentContext = use_context();
let mut active_reply = ctx.active_reply;
let refresh_trigger = ctx.refresh_trigger;
let user_ctx: UserContext = use_context();
let depth = if comment.parent_id.is_none() && comment.depth > 0 {
0
} else {
comment.depth
};
let indent = depth.min(6) * 24;
let is_admin = user_ctx.user.read().as_ref().is_some();
let is_replying = active_reply() == Some(comment.id);
let show_reply = depth < 20;
let _ = refresh_trigger;
let author_element = match &comment.author_url {
Some(url) if !url.is_empty() => rsx! {
a {
href: "{url}",
rel: "nofollow noopener",
target: "_blank",
class: "font-medium text-paper-primary hover:text-paper-accent transition-colors",
"{comment.author_name}"
}
},
_ => rsx! {
span { class: "font-medium text-paper-primary",
"{comment.author_name}"
}
},
};
rsx! {
div {
class: "py-4",
style: "margin-left: {indent}px",
div { class: "flex gap-3",
img {
src: "{comment.avatar_url}",
alt: "{comment.author_name} 的头像",
loading: "lazy",
decoding: "async",
class: "w-8 h-8 rounded-full shrink-0 mt-0.5 bg-gray-200 dark:bg-[#2a2a2a]",
}
div { class: "flex-1 min-w-0",
div { class: "flex items-center gap-1.5 text-sm mb-1.5 flex-wrap",
{author_element}
span { class: "text-paper-tertiary", "·" }
span {
class: "text-paper-tertiary",
title: "{comment.created_at_iso}",
"{comment.created_at}"
}
}
div {
class: "prose prose-sm dark:prose-invert max-w-none text-paper-secondary",
dangerous_inner_html: comment.content_html.as_deref().unwrap_or(""),
}
div { class: "flex items-center gap-3 mt-2",
if show_reply {
button {
class: "text-xs text-paper-tertiary hover:text-paper-accent transition-colors cursor-pointer",
aria_label: "回复 {comment.author_name} 的评论",
onclick: move |_| {
if is_replying {
active_reply.set(None);
} else {
active_reply.set(Some(comment.id));
}
},
if is_replying { "取消回复" } else { "回复" }
}
}
if is_admin {
CommentActions { comment_id: comment.id, post_id }
}
}
if is_replying {
CommentForm { post_id, parent_id: Some(comment.id) }
}
}
}
}
}
}

View File

@ -0,0 +1,15 @@
use dioxus::prelude::*;
use crate::models::comment::PublicComment;
use crate::components::comments::item::CommentItem;
#[component]
pub fn CommentList(comments: Vec<PublicComment>, post_id: i32) -> Element {
rsx! {
div { class: "space-y-0 divide-y divide-gray-100 dark:divide-[#2a2a2a]",
for comment in comments {
CommentItem { comment, post_id }
}
}
}
}

View File

@ -0,0 +1,5 @@
pub mod section;
pub mod form;
pub mod list;
pub mod item;
pub mod actions;

View File

@ -0,0 +1,58 @@
use dioxus::prelude::*;
use crate::api::comments::{get_comments, CommentTreeResponse};
use crate::components::comments::form::CommentForm;
use crate::components::comments::list::CommentList;
use crate::components::skeletons::comment_skeleton::CommentListSkeleton;
#[derive(Clone, Copy)]
pub struct CommentContext {
pub active_reply: Signal<Option<i64>>,
pub refresh_trigger: Signal<bool>,
}
#[component]
pub fn CommentSection(post_id: i32) -> Element {
let ctx = use_context_provider(|| CommentContext {
active_reply: Signal::new(None),
refresh_trigger: Signal::new(false),
});
let comments_resource = use_server_future(move || {
let _ = ctx.refresh_trigger;
get_comments(post_id)
})?;
let data = comments_resource.read();
match data.as_ref().map(|r| r.as_ref()) {
Some(Ok(CommentTreeResponse { comments, count })) => {
let count = *count;
rsx! {
div { class: "space-y-8",
h2 { class: "text-xl font-bold text-paper-primary",
"评论区 ({count})"
}
CommentForm { post_id, parent_id: None }
if comments.is_empty() {
p { class: "text-paper-tertiary text-center py-8",
"暂无评论,成为第一个评论的人吧!"
}
} else {
CommentList { comments: comments.clone(), post_id }
}
}
}
}
Some(Err(_)) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-8",
"评论加载失败"
}
}
}
None => rsx! { CommentListSkeleton {} },
}
}

View File

@ -1,5 +1,6 @@
pub mod admin_layout;
pub mod admin_skeleton;
pub mod comments;
pub mod footer;
pub mod forms;
pub mod frontend_layout;

View File

@ -0,0 +1,38 @@
use dioxus::prelude::*;
use crate::components::skeletons::atoms::*;
#[component]
pub fn CommentListSkeleton() -> Element {
rsx! {
div { class: "animate-pulse space-y-6",
div { class: "h-8 w-32 bg-paper-tertiary/30 rounded mb-6" }
div { class: "space-y-4 bg-paper-tertiary/30 rounded-lg p-4",
div { class: "flex gap-3",
div { class: "w-10 h-10 rounded-full bg-paper-tertiary/50 shrink-0" }
div { class: "flex-1 space-y-2",
SkeletonBox { class: "h-4 w-1/4 rounded" }
SkeletonBox { class: "h-3 w-3/4 rounded" }
}
}
}
div { class: "space-y-4 bg-paper-tertiary/30 rounded-lg p-4 ml-6",
div { class: "flex gap-3",
div { class: "w-10 h-10 rounded-full bg-paper-tertiary/50 shrink-0" }
div { class: "flex-1 space-y-2",
SkeletonBox { class: "h-4 w-1/4 rounded" }
SkeletonBox { class: "h-3 w-3/4 rounded" }
}
}
}
div { class: "space-y-4 bg-paper-tertiary/30 rounded-lg p-4",
div { class: "flex gap-3",
div { class: "w-10 h-10 rounded-full bg-paper-tertiary/50 shrink-0" }
div { class: "flex-1 space-y-2",
SkeletonBox { class: "h-4 w-1/4 rounded" }
SkeletonBox { class: "h-3 w-3/4 rounded" }
}
}
}
}
}
}

View File

@ -1,5 +1,6 @@
pub mod atoms;
pub mod archive_skeleton;
pub mod comment_skeleton;
pub mod delayed_skeleton;
pub mod home_skeleton;
pub mod post_card_skeleton;

View File

@ -36,6 +36,10 @@ fn main() {
use dioxus::server::{axum, DioxusRouterExt, ServeConfig};
use tower_http::trace::TraceLayer;
tokio::spawn(async {
tasks::ip_purge::run_purge().await;
});
tokio::spawn(async {
tasks::session_cleanup::run_cleanup().await;
});

136
src/models/comment.rs Normal file
View File

@ -0,0 +1,136 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum CommentStatus {
Pending,
Approved,
Spam,
Trash,
}
impl CommentStatus {
pub fn from_str(s: &str) -> Self {
match s {
"approved" => Self::Approved,
"spam" => Self::Spam,
"trash" => Self::Trash,
_ => Self::Pending,
}
}
#[allow(dead_code)]
pub fn as_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Approved => "approved",
Self::Spam => "spam",
Self::Trash => "trash",
}
}
}
#[cfg(feature = "server")]
#[allow(dead_code)]
pub struct Comment {
pub id: i64,
pub post_id: i32,
pub parent_id: Option<i64>,
pub depth: i32,
pub author_name: String,
pub author_email: String,
pub author_url: Option<String>,
pub content_md: String,
pub content_html: Option<String>,
pub content_hash: Option<String>,
pub status: CommentStatus,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub consented_at: Option<DateTime<Utc>>,
pub approved_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PublicComment {
pub id: i64,
pub parent_id: Option<i64>,
pub depth: i32,
pub author_name: String,
pub author_url: Option<String>,
pub avatar_url: String,
pub content_html: Option<String>,
pub created_at: String,
pub created_at_iso: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AdminComment {
pub id: i64,
pub post_id: i32,
pub post_title: String,
pub post_slug: String,
pub parent_id: Option<i64>,
pub depth: i32,
pub author_name: String,
pub author_email: String,
pub author_url: Option<String>,
pub content_md: String,
pub status: CommentStatus,
pub created_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn comment_status_from_str() {
assert_eq!(CommentStatus::from_str("pending"), CommentStatus::Pending);
assert_eq!(CommentStatus::from_str("approved"), CommentStatus::Approved);
assert_eq!(CommentStatus::from_str("spam"), CommentStatus::Spam);
assert_eq!(CommentStatus::from_str("trash"), CommentStatus::Trash);
}
#[test]
fn comment_status_from_str_unknown_defaults_to_pending() {
assert_eq!(CommentStatus::from_str("unknown"), CommentStatus::Pending);
assert_eq!(CommentStatus::from_str(""), CommentStatus::Pending);
}
#[test]
fn comment_status_as_str() {
assert_eq!(CommentStatus::Pending.as_str(), "pending");
assert_eq!(CommentStatus::Approved.as_str(), "approved");
assert_eq!(CommentStatus::Spam.as_str(), "spam");
assert_eq!(CommentStatus::Trash.as_str(), "trash");
}
#[test]
fn comment_status_serde_roundtrip() {
let statuses = vec![
CommentStatus::Pending,
CommentStatus::Approved,
CommentStatus::Spam,
CommentStatus::Trash,
];
for status in statuses {
let json = serde_json::to_string(&status).unwrap();
let expected = format!("\"{}\"", status.as_str());
assert_eq!(json, expected);
let deserialized: CommentStatus = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, status);
}
}
#[test]
fn comment_status_deserialize_from_lowercase() {
let pending: CommentStatus = serde_json::from_str("\"pending\"").unwrap();
assert_eq!(pending, CommentStatus::Pending);
let approved: CommentStatus = serde_json::from_str("\"approved\"").unwrap();
assert_eq!(approved, CommentStatus::Approved);
}
}

View File

@ -1,2 +1,3 @@
pub mod comment;
pub mod post;
pub mod user;

400
src/pages/admin/comments.rs Normal file
View File

@ -0,0 +1,400 @@
use std::collections::HashSet;
use dioxus::prelude::*;
use dioxus::router::components::Link;
use crate::api::comments::{
approve_comment, batch_update_comment_status, get_all_comments, spam_comment,
AllCommentsResponse,
};
#[cfg(target_arch = "wasm32")]
use crate::api::comments::trash_comment;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::models::comment::CommentStatus;
use crate::router::Route;
const COMMENTS_PER_PAGE: i32 = 20;
#[component]
pub fn AdminComments() -> Element {
rsx! { AdminCommentsPage { page: 1 } }
}
#[component]
pub fn AdminCommentsPage(page: i32) -> Element {
let current_page = page.max(1);
let mut active_filter = use_signal(|| {
#[cfg(target_arch = "wasm32")]
{
web_sys::window()
.and_then(|w| w.location().search().ok())
.and_then(|s| {
let params = s.trim_start_matches('?');
for pair in params.split('&') {
if let Some(val) = pair.strip_prefix("status=") {
return Some(val.to_string());
}
}
None
})
.unwrap_or_default()
}
#[cfg(not(target_arch = "wasm32"))]
String::new()
});
let mut selected_ids: Signal<HashSet<i64>> = use_signal(HashSet::new);
let filter_status = move || {
let f = active_filter();
if f.is_empty() { None } else { Some(f) }
};
let mut comments_res =
use_server_future(move || get_all_comments(current_page, filter_status()))?;
rsx! {
div { class: "space-y-6",
h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]",
"评论管理"
}
div { class: "flex gap-1 border-b border-gray-200 dark:border-[#333]",
for (status, label) in [("", "全部"), ("pending", "待审核"), ("approved", "已通过"), ("spam", "垃圾箱")] {
button {
class: if active_filter() == status {
"px-4 py-2 text-sm font-medium border-b-2 border-gray-900 dark:border-[#dadadb] text-gray-900 dark:text-[#dadadb]"
} else {
"px-4 py-2 text-sm font-medium text-gray-500 dark:text-[#9b9c9d] hover:text-gray-700 dark:hover:text-[#dadadb] transition-colors"
},
onclick: move |_| active_filter.set(status.to_string()),
"{label}"
}
}
}
if !selected_ids().is_empty() {
{ rsx! {
div { class: "flex items-center gap-3 p-3 bg-gray-50 dark:bg-[#2a2a2a] rounded-lg",
span { class: "text-sm text-gray-600 dark:text-[#9b9c9d]",
"已选择 {selected_ids().len()} 条"
}
button {
class: "px-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded hover:bg-green-700 transition-colors",
onclick: move |_| {
let ids: Vec<i64> = selected_ids().iter().copied().collect();
spawn(async move {
let _ = batch_update_comment_status(ids, "approved".to_string()).await;
});
selected_ids.set(HashSet::new());
comments_res.restart();
},
"批量通过"
}
button {
class: "px-3 py-1.5 text-xs font-medium bg-amber-600 text-white rounded hover:bg-amber-700 transition-colors",
onclick: move |_| {
let ids: Vec<i64> = selected_ids().iter().copied().collect();
spawn(async move {
let _ = batch_update_comment_status(ids, "spam".to_string()).await;
});
selected_ids.set(HashSet::new());
comments_res.restart();
},
"批量垃圾"
}
button {
class: "px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded hover:bg-red-700 transition-colors",
onclick: move |_| {
#[cfg(target_arch = "wasm32")]
{
if web_sys::window()
.and_then(|w| w.confirm_with_message("确定要删除这些评论吗?"))
.unwrap_or(false)
{
let ids: Vec<i64> = selected_ids().iter().copied().collect();
spawn(async move {
let _ = batch_update_comment_status(ids, "trash".to_string()).await;
});
selected_ids.set(HashSet::new());
comments_res.restart();
}
}
},
"批量删除"
}
}
} }
}
{
let data = comments_res.read().as_ref().map(|r| match r {
Ok(AllCommentsResponse { comments, total }) => Ok((comments.clone(), *total)),
Err(e) => Err(e.to_string()),
});
match data {
Some(Ok((comments, total))) => {
if comments.is_empty() {
rsx! {
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
"暂无评论"
}
}
} else {
let all_selected = comments.iter().all(|c| selected_ids().contains(&c.id));
let all_ids: Vec<i64> = comments.iter().map(|c| c.id).collect();
rsx! {
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
div { class: "overflow-x-auto",
table { class: "w-full text-sm",
thead {
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
th { class: "px-4 py-3 font-medium w-10",
input {
r#type: "checkbox",
class: "rounded border-gray-300 dark:border-[#555]",
checked: all_selected,
onchange: {
move |_| {
let mut s = selected_ids();
if all_selected {
for id in &all_ids { s.remove(id); }
} else {
for id in &all_ids { s.insert(*id); }
}
selected_ids.set(s);
}
}
}
}
th { class: "px-4 py-3 font-medium", "作者" }
th { class: "px-4 py-3 font-medium", "内容" }
th { class: "px-4 py-3 font-medium", "文章" }
th { class: "px-4 py-3 font-medium w-20 text-center", "状态" }
th { class: "px-4 py-3 font-medium w-28", "日期" }
th { class: "px-4 py-3 font-medium w-32 text-right", "操作" }
}
}
tbody {
for comment in comments.iter() {
CommentRow {
key: "{comment.id}",
comment: comment.clone(),
selected: selected_ids().contains(&comment.id),
on_select: {
let id = comment.id;
move |checked: bool| {
let mut s = selected_ids();
if checked { s.insert(id); } else { s.remove(&id); }
selected_ids.set(s);
}
},
on_approve: {
let id = comment.id;
move |_| {
spawn(async move {
let _ = approve_comment(id).await;
});
comments_res.restart();
}
},
on_spam: {
let id = comment.id;
move |_| {
spawn(async move {
let _ = spam_comment(id).await;
});
comments_res.restart();
}
},
on_trash: {
#[allow(unused_variables)]
let id = comment.id;
move |_| {
#[cfg(target_arch = "wasm32")]
{
if web_sys::window()
.and_then(|w| w.confirm_with_message("确定要删除这条评论吗?"))
.unwrap_or(false)
{
let id = id;
spawn(async move {
let _ = trash_comment(id).await;
});
comments_res.restart();
}
}
}
},
}
}
}
}
}
}
CommentsPagination { current_page, total }
}
}
}
Some(Err(_e)) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败"
}
}
}
None => {
rsx! {
DelayedSkeleton {
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] p-6 space-y-4",
for _ in 0..5 {
div { class: "flex items-center gap-4",
div { class: "h-4 w-4 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-8 w-8 bg-gray-200 dark:bg-[#2a2a2a] rounded-full" }
div { class: "h-4 w-32 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-4 flex-1 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
}
}
}
}
}
}
}
}
}
}
}
#[component]
fn CommentRow(
comment: crate::models::comment::AdminComment,
selected: bool,
on_select: EventHandler<bool>,
on_approve: EventHandler,
on_spam: EventHandler,
on_trash: EventHandler,
) -> Element {
let (badge_class, status_label) = match &comment.status {
CommentStatus::Pending => ("bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", "待审核"),
CommentStatus::Approved => ("bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", "已通过"),
CommentStatus::Spam => ("bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", "垃圾"),
CommentStatus::Trash => ("bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400", "已删除"),
};
let date_str = comment.created_at.format("%Y-%m-%d").to_string();
let preview = if comment.content_md.len() > 100 {
format!("{}...", &comment.content_md[..comment.content_md.ceil_char_boundary(100)])
} else {
comment.content_md.clone()
};
rsx! {
tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors",
td { class: "px-4 py-3",
input {
r#type: "checkbox",
class: "rounded border-gray-300 dark:border-[#555]",
checked: selected,
onchange: move |e| on_select.call(e.checked()),
}
}
td { class: "px-4 py-3",
div { class: "flex items-center gap-2",
div { class: "w-8 h-8 rounded-full bg-gray-200 dark:bg-[#444] flex-shrink-0" }
div { class: "min-w-0",
div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb] truncate",
"{comment.author_name}"
}
div { class: "text-xs text-gray-400 dark:text-[#666] truncate",
"{comment.author_email}"
}
}
}
}
td { class: "px-4 py-3 max-w-xs",
p { class: "text-sm text-gray-600 dark:text-[#9b9c9d] truncate",
"{preview}"
}
}
td { class: "px-4 py-3",
Link {
class: "text-sm text-gray-700 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
to: Route::PostDetail { slug: comment.post_slug.clone() },
"{comment.post_title}"
}
}
td { class: "px-4 py-3 text-center",
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {badge_class}",
"{status_label}"
}
}
td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
"{date_str}"
}
td { class: "px-4 py-3 text-right",
div { class: "flex justify-end gap-2",
button {
class: "text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors",
onclick: move |_| on_approve.call(()),
"通过"
}
button {
class: "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 transition-colors",
onclick: move |_| on_spam.call(()),
"垃圾"
}
button {
class: "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors",
onclick: move |_| on_trash.call(()),
"删除"
}
}
}
}
}
}
#[component]
fn CommentsPagination(current_page: i32, total: i64) -> Element {
let has_prev = current_page > 1;
let total_pages =
((total + COMMENTS_PER_PAGE as i64 - 1) / COMMENTS_PER_PAGE as i64).max(1) as i32;
let has_next = current_page < total_pages;
let prev_route = if current_page - 1 <= 1 {
Route::AdminComments {}
} else {
Route::AdminCommentsPage { page: current_page - 1 }
};
let next_route = Route::AdminCommentsPage { page: current_page + 1 };
rsx! {
nav { class: "flex mt-6 justify-between",
if has_prev {
Link {
class: "inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
to: prev_route,
span { class: "mr-1", "«" }
"上一页"
}
} else {
span { class: "inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed",
span { class: "mr-1", "«" }
"上一页"
}
}
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d] self-center",
"{current_page} / {total_pages} 页 (共 {total} 条)"
}
if has_next {
Link {
class: "inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
to: next_route,
"下一页"
span { class: "ml-1", "»" }
}
} else {
span { class: "inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed",
"下一页"
span { class: "ml-1", "»" }
}
}
}
}
}

View File

@ -1,6 +1,7 @@
use dioxus::prelude::*;
use dioxus::router::components::Link;
use crate::api::comments::get_pending_count;
use crate::api::posts::{get_post_stats, list_posts, PostListResponse, PostStatsResponse};
use crate::hooks::delayed_loading::use_delayed_loading;
use crate::models::post::Post;
@ -10,6 +11,7 @@ use crate::router::Route;
pub fn Admin() -> Element {
let stats_res = use_resource(get_post_stats);
let posts_res = use_resource(|| list_posts(1, 5));
let pending_res = use_resource(get_pending_count);
let show_stats_skeleton = use_delayed_loading(move || stats_res.read().is_none());
let show_posts_skeleton = use_delayed_loading(move || posts_res.read().is_none());
@ -37,6 +39,29 @@ pub fn Admin() -> Element {
}
}
Link {
class: "block rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center hover:border-gray-300 dark:hover:border-[#555] transition-colors",
to: Route::AdminComments {},
match &*pending_res.read() {
Some(Ok(resp)) => {
rsx! {
div { class: "text-3xl font-bold text-amber-600 dark:text-amber-400",
"{resp.count}"
}
div { class: "text-sm text-gray-500 dark:text-[#9b9c9d] mt-2",
"待审核评论"
}
}
}
_ => {
rsx! {
div { class: "h-9 w-16 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" }
div { class: "h-4 w-20 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded mt-3 animate-pulse" }
}
}
}
}
div { class: "grid grid-cols-1 md:grid-cols-2 gap-4",
Link {
class: "bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full px-6 py-3 text-center font-medium hover:opacity-80 transition-opacity cursor-pointer",

View File

@ -1,7 +1,9 @@
pub mod comments;
pub mod dashboard;
pub mod posts;
pub mod write;
pub use comments::{AdminComments, AdminCommentsPage};
pub use dashboard::Admin;
pub use posts::{Posts, PostsPage};
pub use write::{Write, WriteEdit};

View File

@ -48,6 +48,17 @@ pub fn PostDetail(slug: String) -> Element {
}
PostFooter { post: post.clone() }
if post.status == crate::models::post::PostStatus::Published {
div { class: "mt-12 border-t border-gray-200 dark:border-[#333] pt-8",
SuspenseBoundary {
fallback: move |_| rsx! {
crate::components::skeletons::comment_skeleton::CommentListSkeleton {}
},
crate::components::comments::section::CommentSection { post_id: post.id }
}
}
}
}
}
}

View File

@ -5,7 +5,7 @@ use crate::components::admin_layout::AdminLayout;
use crate::components::frontend_layout::FrontendLayout;
use crate::context::UserContext;
use crate::pages::about::About;
use crate::pages::admin::{Admin, Posts, PostsPage, Write, WriteEdit};
use crate::pages::admin::{Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Write, WriteEdit};
use crate::pages::archives::Archives;
use crate::pages::home::{Home, HomePage};
use crate::pages::login::Login;
@ -52,6 +52,10 @@ pub enum Route {
Posts {},
#[route("/posts/:page")]
PostsPage { page: i32 },
#[route("/comments")]
AdminComments {},
#[route("/comments/:page")]
AdminCommentsPage { page: i32 },
#[end_layout]
#[end_nest]

25
src/tasks/ip_purge.rs Normal file
View File

@ -0,0 +1,25 @@
use std::time::Duration;
use tokio::time::interval;
use crate::db::pool::get_conn;
pub async fn run_purge() {
let mut ticker = interval(Duration::from_secs(86400));
loop {
ticker.tick().await;
match get_conn().await {
Ok(client) => {
if let Err(e) = client
.execute("UPDATE comments SET ip_address = NULL, user_agent = NULL WHERE created_at < NOW() - INTERVAL '90 days' AND ip_address IS NOT NULL", &[])
.await
{
tracing::error!("IP purge error: {:?}", e);
}
}
Err(e) => {
tracing::error!("Failed to get DB connection for IP purge: {:?}", e);
}
}
}
}

View File

@ -1,2 +1,4 @@
#[cfg(feature = "server")]
pub mod ip_purge;
#[cfg(feature = "server")]
pub mod session_cleanup;