1use crate::api::error::ApiError;
12use leptos::prelude::*;
13
14use super::types::RebuildAssetsResponse;
15
16#[cfg(feature = "server")]
18const IMAGE_EXTS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp"];
19
20#[cfg(feature = "server")]
21struct ScannedFile {
23 rel_path: String,
25 filename: String,
26 mime: &'static str,
27 size_bytes: i64,
28 width: i32,
29 height: i32,
30}
31
32#[cfg(feature = "server")]
33fn walk_images(
39 dir: &std::path::Path,
40 base: &std::path::Path,
41 out: &mut Vec<ScannedFile>,
42 unreadable: &mut Vec<String>,
43) -> Result<(), (std::path::PathBuf, std::io::Error)> {
44 let entries = std::fs::read_dir(dir).map_err(|e| (dir.to_path_buf(), e))?;
45 for entry in entries {
46 let entry = entry.map_err(|e| (dir.to_path_buf(), e))?;
47 let name = entry.file_name();
48 let Some(name_str) = name.to_str() else {
49 continue;
50 };
51 if name_str.starts_with('.') {
52 continue;
53 }
54 let path = entry.path();
55 if path.is_dir() {
56 walk_images(&path, base, out, unreadable)?;
57 continue;
58 }
59 let ext = name_str.rsplit('.').next().unwrap_or("");
60 if !IMAGE_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
61 continue;
62 }
63 let Ok(rel) = path.strip_prefix(base) else {
64 continue;
65 };
66 let rel_path = rel.to_string_lossy().replace('\\', "/");
67 let Some((w, h)) = crate::api::image::get_image_dimensions(&rel_path) else {
70 tracing::warn!("Rebuild: skip unreadable image {}", rel_path);
71 unreadable.push(rel_path);
72 continue;
73 };
74 let size_bytes = entry.metadata().map(|m| m.len() as i64).unwrap_or(0);
75 let mime = match ext.to_ascii_lowercase().as_str() {
76 "jpg" | "jpeg" => "image/jpeg",
77 "png" => "image/png",
78 "gif" => "image/gif",
79 _ => "image/webp",
80 };
81 out.push(ScannedFile {
82 rel_path,
83 filename: name_str.to_string(),
84 mime,
85 size_bytes,
86 width: w as i32,
87 height: h as i32,
88 });
89 }
90 Ok(())
91}
92
93#[server(RebuildAssetsIndex, endpoint = "rebuild_assets_index", input = leptos::server_fn::codec::Json)]
95pub async fn rebuild_assets_index() -> Result<RebuildAssetsResponse, ApiError> {
96 crate::api::auth::get_current_admin_user().await?;
97 rebuild_assets_index_impl().await
98}
99
100#[cfg(feature = "server")]
102pub(crate) async fn rebuild_assets_index_impl() -> Result<RebuildAssetsResponse, ApiError> {
103 use crate::api::error::AppError;
104 use crate::db::pool::get_conn;
105
106 let scan = tokio::task::spawn_blocking(|| {
108 let base = std::path::Path::new("uploads");
109 let mut files = Vec::new();
110 let mut unreadable = Vec::new();
111 walk_images(base, base, &mut files, &mut unreadable).map(|()| (files, unreadable))
112 })
113 .await
114 .map_err(|_| AppError::Internal("素材扫描任务失败"))?;
115 let (scanned, unreadable) = scan.map_err(|(path, e)| {
116 tracing::error!("Rebuild: scan {} failed: {}", path.display(), e);
117 AppError::Internal("素材目录读取失败,已中止重建")
118 })?;
119
120 let mut client = get_conn().await.map_err(AppError::db_conn)?;
121 let tx = client.transaction().await.map_err(AppError::tx)?;
122
123 let mut inserted: i64 = 0;
128 let mut updated: i64 = 0;
129 for f in &scanned {
130 let asset_id = uuid::Uuid::new_v4();
132 let row = tx
133 .query_opt(
134 "INSERT INTO assets (id, path, filename, mime, size_bytes, width, height) \
135 VALUES ($1, $2, $3, $4, $5, $6, $7) \
136 ON CONFLICT (path) DO UPDATE SET \
137 mime = EXCLUDED.mime, \
138 size_bytes = EXCLUDED.size_bytes, \
139 width = EXCLUDED.width, \
140 height = EXCLUDED.height, \
141 updated_at = NOW() \
142 WHERE assets.size_bytes IS DISTINCT FROM EXCLUDED.size_bytes \
143 OR assets.width IS DISTINCT FROM EXCLUDED.width \
144 OR assets.height IS DISTINCT FROM EXCLUDED.height \
145 OR assets.mime IS DISTINCT FROM EXCLUDED.mime \
146 RETURNING (xmax = 0) AS was_inserted",
147 &[
148 &asset_id,
149 &f.rel_path,
150 &f.filename,
151 &f.mime,
152 &f.size_bytes,
153 &f.width,
154 &f.height,
155 ],
156 )
157 .await
158 .map_err(AppError::tx)?;
159 match row {
160 Some(r) if r.get::<_, bool>("was_inserted") => inserted += 1,
161 Some(_) => updated += 1,
162 None => {} }
164 }
165
166 let mut keep_paths: Vec<String> = scanned.iter().map(|f| f.rel_path.clone()).collect();
170 keep_paths.extend(unreadable.iter().cloned());
171 let removed = tx
172 .execute(
173 "DELETE FROM assets WHERE NOT (path = ANY($1))",
174 &[&keep_paths],
175 )
176 .await
177 .map_err(AppError::tx)?;
178
179 let post_rows = tx
181 .query("SELECT id, content_html, cover_image FROM posts", &[])
182 .await
183 .map_err(AppError::query)?;
184 tx.execute("DELETE FROM asset_refs", &[])
185 .await
186 .map_err(AppError::tx)?;
187 let mut ref_count: i64 = 0;
188 for pr in &post_rows {
189 let post_id: i32 = pr.get("id");
190 let content_html: Option<String> = pr.get("content_html");
191 let cover_image: Option<String> = pr.get("cover_image");
192 let found = crate::api::posts::helpers::extract_asset_paths(
193 content_html.as_deref().unwrap_or(""),
194 cover_image.as_deref(),
195 );
196 if found.is_empty() {
197 continue;
198 }
199 let n = tx
200 .execute(
201 "INSERT INTO asset_refs (asset_id, post_id) \
202 SELECT id, $1 FROM assets WHERE path = ANY($2) \
203 ON CONFLICT DO NOTHING",
204 &[&post_id, &found],
205 )
206 .await
207 .map_err(AppError::tx)?;
208 ref_count += n as i64;
209 }
210
211 let note_rows = tx
213 .query(
214 "SELECT note_id,version,content_html FROM note_revisions",
215 &[],
216 )
217 .await
218 .map_err(AppError::query)?;
219 tx.execute("DELETE FROM note_asset_refs", &[])
220 .await
221 .map_err(AppError::tx)?;
222 for row in note_rows {
223 let paths = crate::api::posts::helpers::extract_asset_paths(row.get("content_html"), None);
224 if !paths.is_empty() {
225 ref_count+=tx.execute("INSERT INTO note_asset_refs(note_id,version,asset_id) SELECT $1,$2,id FROM assets WHERE path=ANY($3) ON CONFLICT DO NOTHING",&[&row.get::<_,i32>("note_id"),&row.get::<_,i32>("version"),&paths]).await.map_err(AppError::tx)? as i64;
226 }
227 }
228
229 let comment_rows = tx
231 .query(
232 "SELECT id, content_html FROM comments WHERE content_html IS NOT NULL",
233 &[],
234 )
235 .await
236 .map_err(AppError::query)?;
237 tx.execute("DELETE FROM comment_asset_refs", &[])
238 .await
239 .map_err(AppError::tx)?;
240 for row in comment_rows {
241 let paths = crate::api::posts::helpers::extract_asset_paths(row.get("content_html"), None);
242 if paths.is_empty() {
243 continue;
244 }
245 let n = tx
246 .execute(
247 "INSERT INTO comment_asset_refs (comment_id, asset_id) \
248 SELECT $1, id FROM assets WHERE path = ANY($2) \
249 ON CONFLICT DO NOTHING",
250 &[&row.get::<_, i64>("id"), &paths],
251 )
252 .await
253 .map_err(AppError::tx)?;
254 ref_count += n as i64;
255 }
256
257 tx.execute("DELETE FROM user_avatar_asset_refs", &[])
259 .await
260 .map_err(AppError::tx)?;
261 ref_count += tx
262 .execute(
263 "INSERT INTO user_avatar_asset_refs (user_id, asset_id) \
264 SELECT u.id, a.id FROM users u JOIN assets a ON u.avatar_url = '/uploads/' || a.path",
265 &[],
266 )
267 .await
268 .map_err(AppError::tx)? as i64;
269 tx.execute("DELETE FROM friend_avatar_asset_refs", &[])
270 .await
271 .map_err(AppError::tx)?;
272 ref_count += tx
273 .execute(
274 "INSERT INTO friend_avatar_asset_refs (friend_id, asset_id) \
275 SELECT f.id, a.id FROM friend_links f \
276 JOIN assets a ON f.avatar_url = '/uploads/' || a.path",
277 &[],
278 )
279 .await
280 .map_err(AppError::tx)? as i64;
281 tx.commit().await.map_err(AppError::tx)?;
282
283 let scanned_count = scanned.len() as i64;
284 let skipped_count = unreadable.len() as i64;
285 let message = if skipped_count > 0 {
286 format!(
287 "重建完成:扫描 {} 个文件,新增 {},更新 {},移除 {},跳过 {} 个无法读取的文件(已保留)",
288 scanned_count, inserted, updated, removed, skipped_count
289 )
290 } else {
291 format!(
292 "重建完成:扫描 {} 个文件,新增 {},更新 {},移除 {}",
293 scanned_count, inserted, updated, removed
294 )
295 };
296 Ok(RebuildAssetsResponse {
297 success: true,
298 message,
299 scanned: scanned_count,
300 inserted,
301 updated,
302 removed: removed as i64,
303 ref_count,
304 skipped: skipped_count,
305 })
306}
307
308#[cfg(all(test, feature = "server"))]
309mod tests {
310 use super::walk_images;
311
312 #[test]
314 fn walk_images_fails_on_missing_directory() {
315 let base =
316 std::env::temp_dir().join(format!("ygg-rebuild-missing-{}", uuid::Uuid::new_v4()));
317 let mut files = Vec::new();
318 let mut unreadable = Vec::new();
319 let (path, err) = walk_images(&base, &base, &mut files, &mut unreadable).unwrap_err();
320 assert_eq!(path, base);
321 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
322 }
323}