yggdrasil/api/assets/
rebuild.rs1use dioxus::prelude::*;
12
13use super::types::RebuildAssetsResponse;
14
15#[cfg(feature = "server")]
17const IMAGE_EXTS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp"];
18
19#[cfg(feature = "server")]
20struct ScannedFile {
22 rel_path: String,
24 filename: String,
25 mime: &'static str,
26 size_bytes: i64,
27 width: i32,
28 height: i32,
29}
30
31#[cfg(feature = "server")]
32fn walk_images(dir: &std::path::Path, base: &std::path::Path, out: &mut Vec<ScannedFile>) {
34 let Ok(entries) = std::fs::read_dir(dir) else {
35 return;
36 };
37 for entry in entries.flatten() {
38 let name = entry.file_name();
39 let Some(name_str) = name.to_str() else {
40 continue;
41 };
42 if name_str.starts_with('.') {
43 continue;
44 }
45 let path = entry.path();
46 if path.is_dir() {
47 walk_images(&path, base, out);
48 continue;
49 }
50 let ext = name_str.rsplit('.').next().unwrap_or("");
51 if !IMAGE_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
52 continue;
53 }
54 let Ok(rel) = path.strip_prefix(base) else {
55 continue;
56 };
57 let rel_path = rel.to_string_lossy().replace('\\', "/");
58 let Some((w, h)) = crate::api::image::get_image_dimensions(&rel_path) else {
60 tracing::warn!("Rebuild: skip unreadable image {}", rel_path);
61 continue;
62 };
63 let size_bytes = entry.metadata().map(|m| m.len() as i64).unwrap_or(0);
64 let mime = match ext.to_ascii_lowercase().as_str() {
65 "jpg" | "jpeg" => "image/jpeg",
66 "png" => "image/png",
67 "gif" => "image/gif",
68 _ => "image/webp",
69 };
70 out.push(ScannedFile {
71 rel_path,
72 filename: name_str.to_string(),
73 mime,
74 size_bytes,
75 width: w as i32,
76 height: h as i32,
77 });
78 }
79}
80
81#[server(RebuildAssetsIndex, "/api")]
83pub async fn rebuild_assets_index() -> Result<RebuildAssetsResponse, ServerFnError> {
84 #[cfg(feature = "server")]
85 {
86 use crate::api::auth::get_current_admin_user;
87 use crate::api::error::AppError;
88 use crate::db::pool::get_conn;
89
90 let _admin = get_current_admin_user().await?;
91
92 let scanned = tokio::task::spawn_blocking(|| {
94 let base = std::path::Path::new("uploads");
95 let mut files = Vec::new();
96 walk_images(base, base, &mut files);
97 files
98 })
99 .await
100 .map_err(|_| AppError::Internal("素材扫描任务失败"))?;
101
102 let mut client = get_conn().await.map_err(AppError::db_conn)?;
103 let tx = client.transaction().await.map_err(AppError::tx)?;
104
105 let mut inserted: i64 = 0;
109 let mut updated: i64 = 0;
110 for f in &scanned {
111 let asset_id = uuid::Uuid::new_v4();
113 let row = tx
114 .query_opt(
115 "INSERT INTO assets (id, path, filename, mime, size_bytes, width, height) \
116 VALUES ($1, $2, $3, $4, $5, $6, $7) \
117 ON CONFLICT (path) DO UPDATE SET \
118 filename = EXCLUDED.filename, \
119 mime = EXCLUDED.mime, \
120 size_bytes = EXCLUDED.size_bytes, \
121 width = EXCLUDED.width, \
122 height = EXCLUDED.height, \
123 updated_at = NOW() \
124 WHERE assets.size_bytes IS DISTINCT FROM EXCLUDED.size_bytes \
125 OR assets.width IS DISTINCT FROM EXCLUDED.width \
126 OR assets.height IS DISTINCT FROM EXCLUDED.height \
127 OR assets.mime IS DISTINCT FROM EXCLUDED.mime \
128 OR assets.filename IS DISTINCT FROM EXCLUDED.filename \
129 RETURNING (xmax = 0) AS was_inserted",
130 &[
131 &asset_id,
132 &f.rel_path,
133 &f.filename,
134 &f.mime,
135 &f.size_bytes,
136 &f.width,
137 &f.height,
138 ],
139 )
140 .await
141 .map_err(AppError::tx)?;
142 match row {
143 Some(r) if r.get::<_, bool>("was_inserted") => inserted += 1,
144 Some(_) => updated += 1,
145 None => {} }
147 }
148
149 let paths: Vec<String> = scanned.iter().map(|f| f.rel_path.clone()).collect();
151 let removed = tx
152 .execute("DELETE FROM assets WHERE NOT (path = ANY($1))", &[&paths])
153 .await
154 .map_err(AppError::tx)?;
155
156 let post_rows = tx
158 .query("SELECT id, content_html, cover_image FROM posts", &[])
159 .await
160 .map_err(AppError::query)?;
161 tx.execute("DELETE FROM asset_refs", &[])
162 .await
163 .map_err(AppError::tx)?;
164 let mut ref_count: i64 = 0;
165 for pr in &post_rows {
166 let post_id: i32 = pr.get("id");
167 let content_html: Option<String> = pr.get("content_html");
168 let cover_image: Option<String> = pr.get("cover_image");
169 let found = crate::api::posts::helpers::extract_asset_paths(
170 content_html.as_deref().unwrap_or(""),
171 cover_image.as_deref(),
172 );
173 if found.is_empty() {
174 continue;
175 }
176 let n = tx
177 .execute(
178 "INSERT INTO asset_refs (asset_id, post_id) \
179 SELECT id, $1 FROM assets WHERE path = ANY($2) \
180 ON CONFLICT DO NOTHING",
181 &[&post_id, &found],
182 )
183 .await
184 .map_err(AppError::tx)?;
185 ref_count += n as i64;
186 }
187
188 tx.commit().await.map_err(AppError::tx)?;
189
190 let scanned_count = scanned.len() as i64;
191 Ok(RebuildAssetsResponse {
192 success: true,
193 message: format!(
194 "重建完成:扫描 {} 个文件,新增 {},更新 {},移除 {}",
195 scanned_count, inserted, updated, removed
196 ),
197 scanned: scanned_count,
198 inserted,
199 updated,
200 removed: removed as i64,
201 ref_count,
202 })
203 }
204 #[cfg(not(feature = "server"))]
205 unreachable!()
206}