Skip to main content

yggdrasil/models/
post.rs

1//! 文章模型。
2//!
3//! 定义文章状态、文章结构体、标签、统计信息以及前后导航结构体。
4//! Post 结构体在服务端渲染、客户端展示以及缓存层之间共享。
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// 文章发布状态枚举。
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub enum PostStatus {
12    /// 草稿,仅管理员可见。
13    Draft,
14    /// 已发布,面向读者公开。
15    Published,
16}
17
18impl PostStatus {
19    /// 将状态序列化为数据库或 API 使用的小写字符串。
20    pub fn as_str(&self) -> &'static str {
21        match self {
22            PostStatus::Draft => "draft",
23            PostStatus::Published => "published",
24        }
25    }
26
27    /// 将字符串解析为 PostStatus,无法识别时返回 None。
28    #[cfg(feature = "server")]
29    pub fn from_str(s: &str) -> Option<Self> {
30        match s {
31            "draft" => Some(PostStatus::Draft),
32            "published" => Some(PostStatus::Published),
33            _ => None,
34        }
35    }
36}
37
38/// 文章领域模型。
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct Post {
41    /// 文章主键。
42    pub id: i32,
43    /// 作者用户主键。
44    pub author_id: i32,
45    /// 文章标题。
46    pub title: String,
47    /// URL slug,用于生成文章链接。
48    pub slug: String,
49    /// 摘要,可选。
50    pub summary: Option<String>,
51    /// 原始 Markdown 内容。
52    pub content_md: String,
53    /// 渲染后的 HTML 内容,可选。
54    pub content_html: Option<String>,
55    /// 文章发布状态。
56    pub status: PostStatus,
57    /// 正式发布时间,None 表示尚未发布。
58    pub published_at: Option<DateTime<Utc>>,
59    /// 创建时间。
60    pub created_at: DateTime<Utc>,
61    /// 最后更新时间。
62    pub updated_at: DateTime<Utc>,
63    /// 软删除时间,None 表示未删除。仅回收站查询填充。
64    pub deleted_at: Option<DateTime<Utc>>,
65    /// 关联标签列表。
66    pub tags: Vec<String>,
67    /// 封面图片 URL。
68    pub cover_image: Option<String>,
69    /// 预计阅读时间(分钟)。
70    pub reading_time: u32,
71    /// 字数统计。
72    pub word_count: u32,
73    /// 目录 HTML。
74    pub toc_html: Option<String>,
75    /// 上一篇文章导航信息。
76    pub prev_post: Option<PostNav>,
77    /// 下一篇文章导航信息。
78    pub next_post: Option<PostNav>,
79}
80
81/// 将时间戳格式化为展示用的 `YYYY-MM-DD` 字符串。
82///
83/// 此前 `Post::formatted_date` 与 `PostListItem::formatted_date` 各有一份逐字相同的实现。
84fn format_date(dt: DateTime<Utc>) -> String {
85    dt.format("%Y-%m-%d").to_string()
86}
87
88impl Post {
89    /// 返回用于展示的文章日期:优先使用发布时间,否则回退到创建时间。
90    pub fn formatted_date(&self) -> String {
91        format_date(self.published_at.unwrap_or(self.created_at))
92    }
93}
94
95/// 文章列表项 DTO。
96///
97/// 仅包含列表/标签/搜索/归档等场景需要的字段,不含 `content_md` 与 `content_html`,
98/// 以降低缓存内存占用与序列化体积。`deleted_at` 保留,供回收站列表使用。
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct PostListItem {
101    /// 文章主键。
102    pub id: i32,
103    /// 作者用户主键。
104    pub author_id: i32,
105    /// 文章标题。
106    pub title: String,
107    /// URL slug,用于生成文章链接。
108    pub slug: String,
109    /// 摘要,可选。
110    pub summary: Option<String>,
111    /// 文章发布状态。
112    pub status: PostStatus,
113    /// 正式发布时间,None 表示尚未发布。
114    pub published_at: Option<DateTime<Utc>>,
115    /// 创建时间。
116    pub created_at: DateTime<Utc>,
117    /// 最后更新时间。
118    pub updated_at: DateTime<Utc>,
119    /// 软删除时间,None 表示未删除。仅回收站查询填充。
120    pub deleted_at: Option<DateTime<Utc>>,
121    /// 关联标签列表。
122    pub tags: Vec<String>,
123    /// 封面图片 URL。
124    pub cover_image: Option<String>,
125    /// 预计阅读时间(分钟)。
126    pub reading_time: u32,
127    /// 字数统计。
128    pub word_count: u32,
129}
130
131impl PostListItem {
132    /// 返回用于展示的文章日期:优先使用发布时间,否则回退到创建时间。
133    pub fn formatted_date(&self) -> String {
134        format_date(self.published_at.unwrap_or(self.created_at))
135    }
136
137    /// 返回中文状态标签。
138    pub fn status_label(&self) -> &'static str {
139        match self.status {
140            PostStatus::Published => "已发布",
141            PostStatus::Draft => "草稿",
142        }
143    }
144
145    /// 返回状态文本在 light/dark 模式下的 Tailwind 颜色类。
146    pub fn status_class(&self) -> &'static str {
147        match self.status {
148            PostStatus::Published => "text-green-600 dark:text-green-400",
149            PostStatus::Draft => "text-gray-400 dark:text-gray-500",
150        }
151    }
152
153    /// 返回状态徽章在 light/dark 模式下的 Tailwind 背景与颜色类。
154    pub fn status_badge_class(&self) -> &'static str {
155        match self.status {
156            PostStatus::Published => {
157                "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
158            }
159            PostStatus::Draft => "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400",
160        }
161    }
162}
163
164/// Feed 条目 DTO(RSS 2.0 / JSON Feed 共享)。
165///
166/// 与 `PostListItem` 不同,这里携带已渲染的 `content_html` 全文,
167/// 供订阅端点直接输出;缓存单键 `CacheKey::Feed` 存 `Vec<FeedItem>`。
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct FeedItem {
170    /// 文章标题。
171    pub title: String,
172    /// URL slug,用于生成文章链接。
173    pub slug: String,
174    /// 摘要,可选。
175    pub summary: Option<String>,
176    /// 渲染后的 HTML 内容,可选(历史文章可能为空)。
177    pub content_html: Option<String>,
178    /// 正式发布时间(已发布文章必有值,行映射时回退 updated_at)。
179    pub published_at: DateTime<Utc>,
180    /// 最后更新时间。
181    pub updated_at: DateTime<Utc>,
182    /// 关联标签列表。
183    pub tags: Vec<String>,
184}
185
186/// 前后文章导航结构体。
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188pub struct PostNav {
189    /// 文章标题。
190    pub title: String,
191    /// 文章 slug。
192    pub slug: String,
193}
194
195/// 标签领域模型。
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct Tag {
198    /// 标签主键。
199    pub id: i32,
200    /// 标签名称。
201    pub name: String,
202    /// 关联文章数量。
203    pub post_count: i64,
204}
205
206/// 文章统计信息。
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct PostStats {
209    /// 文章总数。
210    pub total: i64,
211    /// 草稿数量。
212    pub drafts: i64,
213    /// 已发布数量。
214    pub published: i64,
215    /// 回收站(软删除)数量。
216    pub trash: i64,
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use chrono::{TimeZone, Utc};
223
224    fn sample_post() -> Post {
225        Post {
226            id: 1,
227            author_id: 1,
228            title: "Test".to_string(),
229            slug: "test".to_string(),
230            summary: None,
231            content_md: "content".to_string(),
232            content_html: None,
233            status: PostStatus::Draft,
234            published_at: None,
235            created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
236            updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
237            deleted_at: None,
238            tags: vec![],
239            cover_image: None,
240            reading_time: 1,
241            word_count: 10,
242            toc_html: None,
243            prev_post: None,
244            next_post: None,
245        }
246    }
247
248    fn sample_post_list_item() -> PostListItem {
249        PostListItem {
250            id: 1,
251            author_id: 1,
252            title: "Test".to_string(),
253            slug: "test".to_string(),
254            summary: None,
255            status: PostStatus::Draft,
256            published_at: None,
257            created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
258            updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
259            deleted_at: None,
260            tags: vec![],
261            cover_image: None,
262            reading_time: 1,
263            word_count: 10,
264        }
265    }
266
267    #[test]
268    #[cfg(feature = "server")]
269    fn post_status_from_str() {
270        assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
271        assert_eq!(
272            PostStatus::from_str("published"),
273            Some(PostStatus::Published)
274        );
275        assert_eq!(PostStatus::from_str("unknown"), None);
276        assert_eq!(PostStatus::from_str(""), None);
277    }
278
279    #[test]
280    fn post_status_as_str() {
281        assert_eq!(PostStatus::Draft.as_str(), "draft");
282        assert_eq!(PostStatus::Published.as_str(), "published");
283    }
284
285    #[test]
286    #[cfg(feature = "server")]
287    fn post_status_roundtrip() {
288        for status in [PostStatus::Draft, PostStatus::Published] {
289            assert_eq!(PostStatus::from_str(status.as_str()), Some(status.clone()));
290        }
291    }
292
293    #[test]
294    fn formatted_date_uses_published_at_when_available() {
295        let mut post = sample_post();
296        post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
297        assert_eq!(post.formatted_date(), "2024-06-01");
298    }
299
300    #[test]
301    fn formatted_date_falls_back_to_created_at() {
302        let post = sample_post();
303        assert_eq!(post.formatted_date(), "2024-01-15");
304    }
305
306    #[test]
307    fn post_list_item_formatted_date_uses_published_at_when_available() {
308        let mut post = sample_post_list_item();
309        post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
310        assert_eq!(post.formatted_date(), "2024-06-01");
311    }
312
313    #[test]
314    fn post_list_item_formatted_date_falls_back_to_created_at() {
315        let post = sample_post_list_item();
316        assert_eq!(post.formatted_date(), "2024-01-15");
317    }
318
319    #[test]
320    fn post_list_item_status_label() {
321        let mut post = sample_post_list_item();
322        post.status = PostStatus::Published;
323        assert_eq!(post.status_label(), "已发布");
324        post.status = PostStatus::Draft;
325        assert_eq!(post.status_label(), "草稿");
326    }
327
328    #[test]
329    fn post_list_item_status_class_returns_non_empty() {
330        let mut post = sample_post_list_item();
331        post.status = PostStatus::Published;
332        assert_eq!(post.status_class(), "text-green-600 dark:text-green-400");
333        post.status = PostStatus::Draft;
334        assert_eq!(post.status_class(), "text-gray-400 dark:text-gray-500");
335    }
336
337    #[test]
338    fn post_list_item_status_badge_class_returns_non_empty() {
339        let mut post = sample_post_list_item();
340        post.status = PostStatus::Published;
341        assert_eq!(
342            post.status_badge_class(),
343            "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
344        );
345        post.status = PostStatus::Draft;
346        assert_eq!(
347            post.status_badge_class(),
348            "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
349        );
350    }
351
352    #[test]
353    fn post_status_serde_roundtrip() {
354        let json = serde_json::to_string(&PostStatus::Draft).unwrap();
355        assert_eq!(
356            serde_json::from_str::<PostStatus>(&json).unwrap(),
357            PostStatus::Draft
358        );
359    }
360}