Skip to main content

yggdrasil/models/
asset.rs

1//! 素材(图片)模型。
2//!
3//! `assets` 表是 `uploads/` 目录的元数据注册表:磁盘是字节唯一存储,
4//! 本表承载路径、尺寸、alt 等管理性字段。`asset_refs` 记录文章引用关系。
5//! 这些结构体通过 serde 在服务端与客户端之间共享序列化。
6//!
7//! id 以 String 承载(SQL 侧 `id::text` 读出、`$1::uuid` 写入),
8//! 避免把 server-only 的 uuid crate 引入 WASM 前端构建。
9
10use serde::{Deserialize, Serialize};
11
12/// 素材记录(对应 assets 表一行)。
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct Asset {
15    pub id: String,
16    /// 相对路径,如 `2026/07/24/153000.<uuid>.webp`(不含 /uploads/ 前缀)。
17    pub path: String,
18    pub filename: String,
19    pub mime: String,
20    pub size_bytes: i64,
21    pub width: i32,
22    pub height: i32,
23    pub alt: Option<String>,
24    pub created_at: chrono::DateTime<chrono::Utc>,
25}
26
27/// 引用该素材的一处来源(素材详情浮层/删除拦截时列出)。
28///
29/// serde tagged enum,WASM 前端按 `kind` 判别分组渲染。四个来源与
30/// `api::assets::ASSET_REF_CLAUSE` 一一对应:
31/// 文章引用来自 asset_refs 表(正文 HTML + 封面,含草稿与回收站文章);
32/// 评论/头像引用在查询时按素材路径直接匹配(存活评论、用户头像、友链头像)。
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum AssetRef {
36    /// 文章引用(asset_refs 表)。
37    Post {
38        post_id: i32,
39        title: String,
40        slug: String,
41        status: AssetRefPostStatus,
42    },
43    /// 存活评论引用(content_html 子串匹配;评论无深链,展示作者 + 所属文章)。
44    Comment {
45        comment_id: i64,
46        author_name: String,
47        post_id: i32,
48        post_title: String,
49        post_slug: String,
50        post_status: AssetRefPostStatus,
51    },
52    /// 用户头像引用(users.avatar_url)。label = display_name 回退 username。
53    UserAvatar { user_id: i32, label: String },
54    /// 友链头像引用(friend_links.avatar_url)。
55    FriendAvatar { friend_id: i32, name: String },
56}
57
58impl AssetRef {
59    /// 一行可读描述(删除禁用 tooltip 等纯文本场景)。
60    pub fn describe(&self) -> String {
61        match self {
62            AssetRef::Post { title, .. } => format!("文章《{title}》"),
63            AssetRef::Comment {
64                author_name,
65                post_title,
66                ..
67            } => format!("评论({author_name} 在《{post_title}》)"),
68            AssetRef::UserAvatar { label, .. } => format!("用户头像({label})"),
69            AssetRef::FriendAvatar { name, .. } => format!("友链头像({name})"),
70        }
71    }
72}
73
74/// 引用来源文章的可见性状态(决定后台链接走向与状态徽标)。
75///
76/// asset_refs 含草稿与回收站文章:回收站引用同样阻止素材删除,
77/// 草稿/回收站文章前台不可见,链接须指向后台编辑页。
78#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(rename_all = "snake_case")]
80pub enum AssetRefPostStatus {
81    Published,
82    Draft,
83    /// 回收站(软删除)文章。
84    Trashed,
85}
86
87impl AssetRefPostStatus {
88    /// 由 posts.status + deleted_at 推导(纯函数,便于单测)。
89    /// 未识别的 status 一律按草稿处理(保守:不链向前台)。
90    #[cfg(any(feature = "server", test))]
91    pub fn resolve(status: &str, deleted_at: Option<chrono::DateTime<chrono::Utc>>) -> Self {
92        if deleted_at.is_some() {
93            Self::Trashed
94        } else if status == "published" {
95            Self::Published
96        } else {
97            Self::Draft
98        }
99    }
100
101    /// 状态徽标(label, class):已发布不显示徽标,返回 None。
102    pub fn badge(&self) -> Option<(&'static str, &'static str)> {
103        match self {
104            Self::Published => None,
105            Self::Draft => Some((
106                "草稿",
107                "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400",
108            )),
109            Self::Trashed => Some((
110                "回收站",
111                "bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400",
112            )),
113        }
114    }
115}
116
117/// 列表页 DTO:素材本体 + 引用计数 + 引用文章列表。
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
119pub struct AssetDto {
120    #[serde(flatten)]
121    pub asset: Asset,
122    pub ref_count: i64,
123    pub refs: Vec<AssetRef>,
124}
125
126/// 列表筛选:按引用状态。
127#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
128pub enum AssetFilter {
129    #[default]
130    All,
131    Used,
132    Orphan,
133}
134
135/// 列表排序。
136#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
137pub enum AssetSort {
138    #[default]
139    CreatedDesc,
140    SizeDesc,
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn ref_post_status_resolve() {
149        let now = chrono::Utc::now();
150        // deleted_at 优先于 status:回收站文章即使 status 仍是 published 也判 Trashed。
151        assert_eq!(
152            AssetRefPostStatus::resolve("published", Some(now)),
153            AssetRefPostStatus::Trashed
154        );
155        assert_eq!(
156            AssetRefPostStatus::resolve("published", None),
157            AssetRefPostStatus::Published
158        );
159        assert_eq!(
160            AssetRefPostStatus::resolve("draft", None),
161            AssetRefPostStatus::Draft
162        );
163        // 未识别 status 保守按草稿(不链向前台)。
164        assert_eq!(
165            AssetRefPostStatus::resolve("unknown", None),
166            AssetRefPostStatus::Draft
167        );
168    }
169
170    #[test]
171    fn ref_post_status_badge() {
172        assert_eq!(AssetRefPostStatus::Published.badge(), None);
173        assert_eq!(AssetRefPostStatus::Draft.badge().map(|b| b.0), Some("草稿"));
174        assert_eq!(
175            AssetRefPostStatus::Trashed.badge().map(|b| b.0),
176            Some("回收站")
177        );
178    }
179
180    #[test]
181    fn asset_ref_describe_covers_all_kinds() {
182        let status = AssetRefPostStatus::Published;
183        let cases = [
184            (
185                AssetRef::Post {
186                    post_id: 1,
187                    title: "标题".into(),
188                    slug: "s".into(),
189                    status,
190                },
191                "文章《标题》",
192            ),
193            (
194                AssetRef::Comment {
195                    comment_id: 1,
196                    author_name: "小明".into(),
197                    post_id: 1,
198                    post_title: "标题".into(),
199                    post_slug: "s".into(),
200                    post_status: status,
201                },
202                "评论(小明 在《标题》)",
203            ),
204            (
205                AssetRef::UserAvatar {
206                    user_id: 1,
207                    label: "xfy".into(),
208                },
209                "用户头像(xfy)",
210            ),
211            (
212                AssetRef::FriendAvatar {
213                    friend_id: 1,
214                    name: "某博客".into(),
215                },
216                "友链头像(某博客)",
217            ),
218        ];
219        for (r, want) in cases {
220            assert_eq!(r.describe(), want);
221        }
222    }
223
224    #[test]
225    fn asset_ref_serde_tagged_shape() {
226        // 前端按 kind 判别分组渲染,tagged enum 的线格式是跨端契约。
227        let r = AssetRef::UserAvatar {
228            user_id: 7,
229            label: "xfy".into(),
230        };
231        let json = serde_json::to_value(&r).unwrap();
232        assert_eq!(json["kind"], "user_avatar");
233        assert_eq!(json["user_id"], 7);
234        let back: AssetRef = serde_json::from_value(json).unwrap();
235        assert_eq!(back, r);
236    }
237}