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::de::{self, Deserializer};
13use serde::{Deserialize, Serialize};
14
15/// 素材记录(对应 assets 表一行)。
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct Asset {
18    pub id: String,
19    /// 相对路径,如 `2026/07/24/153000.<uuid>.webp`(不含 /uploads/ 前缀)。
20    pub path: String,
21    pub filename: String,
22    pub mime: String,
23    pub size_bytes: i64,
24    pub width: i32,
25    pub height: i32,
26    pub alt: Option<String>,
27    #[cfg_attr(
28        target_arch = "wasm32",
29        serde(deserialize_with = "crate::models::rfc3339::deserialize")
30    )]
31    pub created_at: chrono::DateTime<chrono::Utc>,
32}
33
34/// 引用该素材的一处来源(素材详情浮层/删除拦截时列出)。
35///
36/// 线格式是以 `kind` 为内部标签的扁平对象,WASM 前端按 `kind` 判别分组渲染。
37/// 序列化用派生实现;反序列化手写(见下方 `impl Deserialize`),避免内部标签
38/// 派生在 WASM 里引入 `Content` 缓冲。来源与
39/// `api::assets::ASSET_REF_CLAUSE` 一一对应:
40/// 文章引用来自 asset_refs 表(正文 HTML + 封面,含草稿与回收站文章);
41/// 笔记引用来自 note_asset_refs(包含所有历史版本);
42/// 评论引用来自 comment_asset_refs(含回收站评论),头像引用来自
43/// user_avatar_asset_refs / friend_avatar_asset_refs。
44#[derive(Debug, Clone, Serialize, PartialEq)]
45#[serde(tag = "kind", rename_all = "snake_case")]
46pub enum AssetRef {
47    /// 包括工作稿、收录版、公开版和历史版本,始终链接后台。
48    Note { note_id: i32, title: String },
49    /// 文章引用(asset_refs 表)。
50    Post {
51        post_id: i32,
52        title: String,
53        slug: String,
54        status: AssetRefPostStatus,
55    },
56    /// 评论引用(comment_asset_refs,含回收站评论;评论无深链,展示作者 + 所属文章)。
57    Comment {
58        comment_id: i64,
59        author_name: String,
60        post_id: i32,
61        post_title: String,
62        post_slug: String,
63        post_status: AssetRefPostStatus,
64    },
65    /// 用户头像引用(users.avatar_url)。label = display_name 回退 username。
66    UserAvatar { user_id: i32, label: String },
67    /// 友链头像引用(friend_links.avatar_url)。
68    FriendAvatar { friend_id: i32, name: String },
69}
70
71impl<'de> Deserialize<'de> for AssetRef {
72    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
73        #[derive(Deserialize)]
74        #[serde(rename_all = "snake_case")]
75        enum Kind {
76            Note,
77            Post,
78            Comment,
79            UserAvatar,
80            FriendAvatar,
81        }
82
83        /// 各变体字段的并集;按 `kind` 取用,多余字段忽略(与派生行为一致)。
84        #[derive(Deserialize)]
85        struct Wire {
86            kind: Kind,
87            note_id: Option<i32>,
88            title: Option<String>,
89            post_id: Option<i32>,
90            slug: Option<String>,
91            status: Option<AssetRefPostStatus>,
92            comment_id: Option<i64>,
93            author_name: Option<String>,
94            post_title: Option<String>,
95            post_slug: Option<String>,
96            post_status: Option<AssetRefPostStatus>,
97            user_id: Option<i32>,
98            label: Option<String>,
99            friend_id: Option<i32>,
100            name: Option<String>,
101        }
102
103        fn req<T, E: de::Error>(value: Option<T>, field: &'static str) -> Result<T, E> {
104            value.ok_or_else(|| E::missing_field(field))
105        }
106
107        let w = Wire::deserialize(d)?;
108        Ok(match w.kind {
109            Kind::Note => AssetRef::Note {
110                note_id: req(w.note_id, "note_id")?,
111                title: req(w.title, "title")?,
112            },
113            Kind::Post => AssetRef::Post {
114                post_id: req(w.post_id, "post_id")?,
115                title: req(w.title, "title")?,
116                slug: req(w.slug, "slug")?,
117                status: req(w.status, "status")?,
118            },
119            Kind::Comment => AssetRef::Comment {
120                comment_id: req(w.comment_id, "comment_id")?,
121                author_name: req(w.author_name, "author_name")?,
122                post_id: req(w.post_id, "post_id")?,
123                post_title: req(w.post_title, "post_title")?,
124                post_slug: req(w.post_slug, "post_slug")?,
125                post_status: req(w.post_status, "post_status")?,
126            },
127            Kind::UserAvatar => AssetRef::UserAvatar {
128                user_id: req(w.user_id, "user_id")?,
129                label: req(w.label, "label")?,
130            },
131            Kind::FriendAvatar => AssetRef::FriendAvatar {
132                friend_id: req(w.friend_id, "friend_id")?,
133                name: req(w.name, "name")?,
134            },
135        })
136    }
137}
138
139/// 引用来源文章的可见性状态(决定后台链接走向与状态徽标)。
140///
141/// asset_refs 含草稿与回收站文章:回收站引用同样阻止素材删除,
142/// 草稿/回收站文章前台不可见,链接须指向后台编辑页。
143#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
144#[serde(rename_all = "snake_case")]
145pub enum AssetRefPostStatus {
146    Published,
147    Draft,
148    /// 回收站(软删除)文章。
149    Trashed,
150}
151
152impl AssetRefPostStatus {
153    /// 由 posts.status + deleted_at 推导(纯函数,便于单测)。
154    /// 未识别的 status 一律按草稿处理(保守:不链向前台)。
155    #[cfg(any(feature = "server", test))]
156    pub fn resolve(status: &str, deleted_at: Option<chrono::DateTime<chrono::Utc>>) -> Self {
157        if deleted_at.is_some() {
158            Self::Trashed
159        } else if status == "published" {
160            Self::Published
161        } else {
162            Self::Draft
163        }
164    }
165
166    /// 来源文章的状态标注:已发布不标。
167    pub fn note(&self) -> Option<&'static str> {
168        match self {
169            Self::Published => None,
170            Self::Draft => Some("草稿"),
171            Self::Trashed => Some("回收站"),
172        }
173    }
174}
175
176/// 列表页 DTO:素材本体 + 引用计数 + 引用文章列表。
177///
178/// 线格式把素材字段平铺在顶层(序列化用 `flatten`);反序列化手写,避免
179/// `flatten` 派生在 WASM 里引入 `Content` 缓冲。
180#[derive(Debug, Clone, Serialize, PartialEq)]
181pub struct AssetDto {
182    #[serde(flatten)]
183    pub asset: Asset,
184    pub ref_count: i64,
185    pub refs: Vec<AssetRef>,
186}
187
188impl<'de> Deserialize<'de> for AssetDto {
189    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
190        #[derive(Deserialize)]
191        struct Wire {
192            id: String,
193            path: String,
194            filename: String,
195            mime: String,
196            size_bytes: i64,
197            width: i32,
198            height: i32,
199            alt: Option<String>,
200            #[cfg_attr(
201                target_arch = "wasm32",
202                serde(deserialize_with = "crate::models::rfc3339::deserialize")
203            )]
204            created_at: chrono::DateTime<chrono::Utc>,
205            ref_count: i64,
206            refs: Vec<AssetRef>,
207        }
208
209        let w = Wire::deserialize(d)?;
210        Ok(AssetDto {
211            asset: Asset {
212                id: w.id,
213                path: w.path,
214                filename: w.filename,
215                mime: w.mime,
216                size_bytes: w.size_bytes,
217                width: w.width,
218                height: w.height,
219                alt: w.alt,
220                created_at: w.created_at,
221            },
222            ref_count: w.ref_count,
223            refs: w.refs,
224        })
225    }
226}
227
228/// 列表筛选:按引用状态。
229#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
230#[cfg_attr(feature = "server", derive(rmcp::schemars::JsonSchema))]
231pub enum AssetFilter {
232    #[default]
233    All,
234    Used,
235    Orphan,
236}
237
238/// 列表排序。
239#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
240#[cfg_attr(feature = "server", derive(rmcp::schemars::JsonSchema))]
241pub enum AssetSort {
242    #[default]
243    CreatedDesc,
244    SizeDesc,
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn ref_post_status_resolve() {
253        let now = chrono::Utc::now();
254        // deleted_at 优先于 status:回收站文章即使 status 仍是 published 也判 Trashed。
255        assert_eq!(
256            AssetRefPostStatus::resolve("published", Some(now)),
257            AssetRefPostStatus::Trashed
258        );
259        assert_eq!(
260            AssetRefPostStatus::resolve("published", None),
261            AssetRefPostStatus::Published
262        );
263        assert_eq!(
264            AssetRefPostStatus::resolve("draft", None),
265            AssetRefPostStatus::Draft
266        );
267        // 未识别 status 保守按草稿(不链向前台)。
268        assert_eq!(
269            AssetRefPostStatus::resolve("unknown", None),
270            AssetRefPostStatus::Draft
271        );
272    }
273
274    #[test]
275    fn ref_post_status_note() {
276        assert_eq!(AssetRefPostStatus::Published.note(), None);
277        assert_eq!(AssetRefPostStatus::Draft.note(), Some("草稿"));
278        assert_eq!(AssetRefPostStatus::Trashed.note(), Some("回收站"));
279    }
280
281    #[test]
282    fn asset_ref_serde_tagged_shape() {
283        // 前端按 kind 判别分组渲染,tagged enum 的线格式是跨端契约。
284        let r = AssetRef::UserAvatar {
285            user_id: 7,
286            label: "xfy".into(),
287        };
288        let json = serde_json::to_value(&r).unwrap();
289        assert_eq!(json["kind"], "user_avatar");
290        assert_eq!(json["user_id"], 7);
291        let back: AssetRef = serde_json::from_value(json).unwrap();
292        assert_eq!(back, r);
293    }
294
295    #[test]
296    fn asset_refs_round_trip_all_kinds() {
297        for r in [
298            AssetRef::Note {
299                note_id: 1,
300                title: "笔记".into(),
301            },
302            AssetRef::Post {
303                post_id: 2,
304                title: "文章".into(),
305                slug: "post".into(),
306                status: AssetRefPostStatus::Draft,
307            },
308            AssetRef::Comment {
309                comment_id: 3,
310                author_name: "访客".into(),
311                post_id: 2,
312                post_title: "文章".into(),
313                post_slug: "post".into(),
314                post_status: AssetRefPostStatus::Trashed,
315            },
316            AssetRef::UserAvatar {
317                user_id: 4,
318                label: "xfy".into(),
319            },
320            AssetRef::FriendAvatar {
321                friend_id: 5,
322                name: "友链".into(),
323            },
324        ] {
325            let json = serde_json::to_value(&r).unwrap();
326            assert_eq!(serde_json::from_value::<AssetRef>(json).unwrap(), r);
327        }
328        assert!(serde_json::from_str::<AssetRef>(r#"{"kind":"post","post_id":1}"#).is_err());
329        assert!(serde_json::from_str::<AssetRef>(r#"{"kind":"teapot"}"#).is_err());
330    }
331
332    #[test]
333    fn asset_dto_round_trips_flat_json() {
334        let dto = AssetDto {
335            asset: Asset {
336                id: "00000000-0000-0000-0000-000000000001".into(),
337                path: "2026/07/24/153000.a.webp".into(),
338                filename: "a.webp".into(),
339                mime: "image/webp".into(),
340                size_bytes: 1024,
341                width: 640,
342                height: 480,
343                alt: Some("示意图".into()),
344                created_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
345            },
346            ref_count: 1,
347            refs: vec![AssetRef::Post {
348                post_id: 2,
349                title: "文章".into(),
350                slug: "post".into(),
351                status: AssetRefPostStatus::Published,
352            }],
353        };
354        let json = serde_json::to_value(&dto).unwrap();
355        assert_eq!(json["path"], "2026/07/24/153000.a.webp");
356        assert_eq!(serde_json::from_value::<AssetDto>(json).unwrap(), dto);
357    }
358}