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