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
169#[cfg(any(feature = "server", test))]
170/// Feed 条目 DTO(RSS 2.0 / JSON Feed 共享)。
171///
172/// 与 `PostListItem` 不同,这里携带已渲染的 `content_html` 全文,
173/// 供订阅端点直接输出;缓存单键 `CacheKey::Feed` 存 `Vec<FeedItem>`。
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct FeedItem {
176    /// 文章标题。
177    pub title: String,
178    /// URL slug,用于生成文章链接。
179    pub slug: String,
180    /// 摘要,可选。
181    pub summary: Option<String>,
182    /// 渲染后的 HTML 内容,可选(历史文章可能为空)。
183    pub content_html: Option<String>,
184    /// 正式发布时间(已发布文章必有值,行映射时回退 updated_at)。
185    pub published_at: DateTime<Utc>,
186    /// 最后更新时间。
187    pub updated_at: DateTime<Utc>,
188    /// 关联标签列表。
189    pub tags: Vec<String>,
190}
191
192/// 前后文章导航结构体。
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194pub struct PostNav {
195    /// 文章标题。
196    pub title: String,
197    /// 文章 slug。
198    pub slug: String,
199}
200
201/// 标签领域模型。
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203pub struct Tag {
204    /// 标签主键。
205    pub id: i32,
206    /// 标签名称。
207    pub name: String,
208    /// 关联文章数量。
209    pub post_count: i64,
210}
211
212/// 文章统计信息。
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214pub struct PostStats {
215    /// 文章总数。
216    pub total: i64,
217    /// 草稿数量。
218    pub drafts: i64,
219    /// 已发布数量。
220    pub published: i64,
221    /// 回收站(软删除)数量。
222    pub trash: i64,
223    /// 近 30 天新建文章数(不含软删除),供仪表盘趋势徽章展示真实增量。
224    pub recent_30d: i64,
225    /// 近 30 个自然日每日新建文章数(旧 → 新,不含软删除,无文章的日为 0),
226    /// 供仪表盘 sparkline 使用。
227    pub activity_30d: Vec<i64>,
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use chrono::{TimeZone, Utc};
234
235    fn sample_post() -> Post {
236        Post {
237            id: 1,
238            author_id: 1,
239            title: "Test".to_string(),
240            slug: "test".to_string(),
241            summary: None,
242            content_md: "content".to_string(),
243            content_html: None,
244            status: PostStatus::Draft,
245            published_at: None,
246            created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
247            updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
248            deleted_at: None,
249            tags: vec![],
250            cover_image: None,
251            reading_time: 1,
252            word_count: 10,
253            toc_html: None,
254            prev_post: None,
255            next_post: None,
256        }
257    }
258
259    fn sample_post_list_item() -> PostListItem {
260        PostListItem {
261            id: 1,
262            author_id: 1,
263            title: "Test".to_string(),
264            slug: "test".to_string(),
265            summary: None,
266            status: PostStatus::Draft,
267            published_at: None,
268            created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
269            updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
270            deleted_at: None,
271            tags: vec![],
272            cover_image: None,
273            reading_time: 1,
274            word_count: 10,
275        }
276    }
277
278    #[test]
279    #[cfg(feature = "server")]
280    fn post_status_from_str() {
281        assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
282        assert_eq!(
283            PostStatus::from_str("published"),
284            Some(PostStatus::Published)
285        );
286        assert_eq!(PostStatus::from_str("unknown"), None);
287        assert_eq!(PostStatus::from_str(""), None);
288    }
289
290    #[test]
291    fn post_status_as_str() {
292        assert_eq!(PostStatus::Draft.as_str(), "draft");
293        assert_eq!(PostStatus::Published.as_str(), "published");
294    }
295
296    #[test]
297    #[cfg(feature = "server")]
298    fn post_status_roundtrip() {
299        for status in [PostStatus::Draft, PostStatus::Published] {
300            assert_eq!(PostStatus::from_str(status.as_str()), Some(status.clone()));
301        }
302    }
303
304    #[test]
305    fn formatted_date_uses_published_at_when_available() {
306        let mut post = sample_post();
307        post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
308        assert_eq!(post.formatted_date(), "2024-06-01");
309    }
310
311    #[test]
312    fn formatted_date_falls_back_to_created_at() {
313        let post = sample_post();
314        assert_eq!(post.formatted_date(), "2024-01-15");
315    }
316
317    #[test]
318    fn post_list_item_formatted_date_uses_published_at_when_available() {
319        let mut post = sample_post_list_item();
320        post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
321        assert_eq!(post.formatted_date(), "2024-06-01");
322    }
323
324    #[test]
325    fn post_list_item_formatted_date_falls_back_to_created_at() {
326        let post = sample_post_list_item();
327        assert_eq!(post.formatted_date(), "2024-01-15");
328    }
329
330    #[test]
331    fn post_list_item_status_label() {
332        let mut post = sample_post_list_item();
333        post.status = PostStatus::Published;
334        assert_eq!(post.status_label(), "已发布");
335        post.status = PostStatus::Draft;
336        assert_eq!(post.status_label(), "草稿");
337    }
338
339    #[test]
340    fn post_list_item_status_class_returns_non_empty() {
341        let mut post = sample_post_list_item();
342        post.status = PostStatus::Published;
343        assert_eq!(post.status_class(), "text-green-600 dark:text-green-400");
344        post.status = PostStatus::Draft;
345        assert_eq!(post.status_class(), "text-gray-400 dark:text-gray-500");
346    }
347
348    #[test]
349    fn post_status_badge_class_matches_status() {
350        assert_eq!(
351            PostStatus::Published.badge_class(),
352            "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
353        );
354        assert_eq!(
355            PostStatus::Draft.badge_class(),
356            "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
357        );
358    }
359
360    #[test]
361    fn post_status_serde_roundtrip() {
362        let json = serde_json::to_string(&PostStatus::Draft).unwrap();
363        assert_eq!(
364            serde_json::from_str::<PostStatus>(&json).unwrap(),
365            PostStatus::Draft
366        );
367    }
368}