1#[cfg(feature = "server")]
11use rmcp::schemars;
12use serde::de::{self, Deserializer};
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct Asset {
18 pub id: String,
19 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#[derive(Debug, Clone, Serialize, PartialEq)]
45#[serde(tag = "kind", rename_all = "snake_case")]
46pub enum AssetRef {
47 Note { note_id: i32, title: String },
49 Post {
51 post_id: i32,
52 title: String,
53 slug: String,
54 status: AssetRefPostStatus,
55 },
56 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 UserAvatar { user_id: i32, label: String },
67 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 #[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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
144#[serde(rename_all = "snake_case")]
145pub enum AssetRefPostStatus {
146 Published,
147 Draft,
148 Trashed,
150}
151
152impl AssetRefPostStatus {
153 #[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 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#[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#[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#[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 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 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 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}