Skip to main content

yggdrasil/models/
comment.rs

1//! 评论模型。
2//!
3//! 定义评论状态、服务端内部使用的 Comment 结构体,
4//! 以及面向前端展示的 PublicComment 与面向后台管理的 AdminComment。
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// 评论审核状态枚举,序列化时使用小写字符串。
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11#[serde(rename_all = "lowercase")]
12pub enum CommentStatus {
13    /// 待审核。
14    Pending,
15    /// 已通过。
16    Approved,
17    /// 垃圾评论。
18    Spam,
19    /// 已删除/回收站。
20    Trash,
21}
22
23impl CommentStatus {
24    /// 将数据库或 API 中的状态字符串解析为 CommentStatus,未知值默认回退到 Pending。
25    #[cfg(feature = "server")]
26    // 返回值不是 Result(未知值回退/None),不能实现 std::str::FromStr。
27    #[allow(clippy::should_implement_trait)]
28    pub fn from_str(s: &str) -> Self {
29        match s {
30            "approved" => Self::Approved,
31            "spam" => Self::Spam,
32            "trash" => Self::Trash,
33            _ => Self::Pending,
34        }
35    }
36
37    /// 将 CommentStatus 序列化为小写字符串。
38    pub fn as_str(&self) -> &'static str {
39        match self {
40            Self::Pending => "pending",
41            Self::Approved => "approved",
42            Self::Spam => "spam",
43            Self::Trash => "trash",
44        }
45    }
46}
47
48/// 面向前端展示的评论结构体,已脱敏。
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub struct PublicComment {
51    /// 评论主键。
52    pub id: i64,
53    /// 父评论主键,None 表示顶层评论。
54    pub parent_id: Option<i64>,
55    /// 嵌套深度。
56    pub depth: i32,
57    /// 评论者名称。
58    pub author_name: String,
59    /// 评论者个人主页 URL。
60    pub author_url: Option<String>,
61    /// 评论者头像 URL。
62    pub avatar_url: String,
63    /// 是否为登录用户(作者)发表的评论。
64    pub is_author: bool,
65    /// 渲染后的 HTML 内容。
66    pub content_html: Option<String>,
67    /// 用于展示的人类可读创建时间。
68    pub created_at: String,
69    /// ISO 8601 格式的创建时间。
70    pub created_at_iso: String,
71}
72
73/// 面向后台管理的评论结构体,包含审核所需字段。
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct AdminComment {
76    /// 评论主键。
77    pub id: i64,
78    /// 所属文章主键。
79    pub post_id: i32,
80    /// 所属文章标题。
81    pub post_title: String,
82    /// 所属文章 slug。
83    pub post_slug: String,
84    /// 父评论主键。
85    pub parent_id: Option<i64>,
86    /// 被回复的评论作者(列表显示「回复 X」)。
87    #[serde(default)]
88    pub reply_to: Option<String>,
89    /// 嵌套深度。
90    pub depth: i32,
91    /// 评论者名称。
92    pub author_name: String,
93    /// 评论者邮箱。
94    pub author_email: String,
95    /// 评论者个人主页 URL。
96    pub author_url: Option<String>,
97    /// 评论者头像 URL。
98    pub avatar_url: String,
99    /// 原始 Markdown 内容。
100    pub content_md: String,
101    /// 当前审核状态。
102    pub status: CommentStatus,
103    /// 评论创建时间。
104    #[cfg_attr(
105        target_arch = "wasm32",
106        serde(deserialize_with = "crate::models::rfc3339::deserialize")
107    )]
108    pub created_at: DateTime<Utc>,
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    #[cfg(feature = "server")]
117    fn comment_status_from_str() {
118        assert_eq!(CommentStatus::from_str("pending"), CommentStatus::Pending);
119        assert_eq!(CommentStatus::from_str("approved"), CommentStatus::Approved);
120        assert_eq!(CommentStatus::from_str("spam"), CommentStatus::Spam);
121        assert_eq!(CommentStatus::from_str("trash"), CommentStatus::Trash);
122    }
123
124    #[test]
125    #[cfg(feature = "server")]
126    fn comment_status_from_str_unknown_defaults_to_pending() {
127        assert_eq!(CommentStatus::from_str("unknown"), CommentStatus::Pending);
128        assert_eq!(CommentStatus::from_str(""), CommentStatus::Pending);
129    }
130
131    #[test]
132    fn comment_status_as_str() {
133        assert_eq!(CommentStatus::Pending.as_str(), "pending");
134        assert_eq!(CommentStatus::Approved.as_str(), "approved");
135        assert_eq!(CommentStatus::Spam.as_str(), "spam");
136        assert_eq!(CommentStatus::Trash.as_str(), "trash");
137    }
138
139    #[test]
140    fn comment_status_serde_roundtrip() {
141        let statuses = vec![
142            CommentStatus::Pending,
143            CommentStatus::Approved,
144            CommentStatus::Spam,
145            CommentStatus::Trash,
146        ];
147        for status in statuses {
148            let json = serde_json::to_string(&status).unwrap();
149            let expected = format!("\"{}\"", status.as_str());
150            assert_eq!(json, expected);
151            let deserialized: CommentStatus = serde_json::from_str(&json).unwrap();
152            assert_eq!(deserialized, status);
153        }
154    }
155
156    #[test]
157    fn comment_status_deserialize_from_lowercase() {
158        let pending: CommentStatus = serde_json::from_str("\"pending\"").unwrap();
159        assert_eq!(pending, CommentStatus::Pending);
160        let approved: CommentStatus = serde_json::from_str("\"approved\"").unwrap();
161        assert_eq!(approved, CommentStatus::Approved);
162    }
163}