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