Skip to main content

yggdrasil/api/assets/
rebuild.rs

1//! 素材索引全量重建接口。
2//!
3//! 以磁盘为准自愈 DB 与文件系统的不一致:
4//! 1. 扫 `uploads/`(跳过 `.cache` 等点目录)→ upsert assets(技术字段变化才更新,保留 alt 与原始文件名);
5//! 2. 删除文件已消失的 DB 行(refs 级联);
6//! 3. 全表扫 posts(含回收站)、笔记快照、评论(含回收站)与头像,重建文章、笔记、评论、头像引用。
7//!
8//! 幂等:重跑结果相同(技术字段无变化时 updated 为 0,alt 与 filename 不被覆盖)。
9//! 幂等性由「手动触发」语义承载,非常态路径。Leptos server function,仅 admin 可用。
10
11use crate::api::error::ApiError;
12use leptos::prelude::*;
13
14use super::types::RebuildAssetsResponse;
15
16/// 可登记的图片扩展名(与 upload.rs 的 ALLOWED_MIME_TYPES 对应)。
17#[cfg(feature = "server")]
18const IMAGE_EXTS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp"];
19
20#[cfg(feature = "server")]
21/// 扫描到的磁盘文件信息(spawn_blocking 产物)。
22struct ScannedFile {
23    /// 相对路径 "2026/07/24/x.webp"。
24    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")]
33/// 递归收集 dir 下的图片文件(跳过以 `.` 开头的目录/文件)。
34/// `out` 收集可读文件;`unreadable` 收集磁盘存在但尺寸读取失败的路径
35/// (保护其 DB 行不被删除步骤误删——见 issue #30)。
36/// 任一目录或目录项读取失败即返回出错路径:扫描不完整时后续删除步骤
37/// 会把未扫到的素材当成已消失,不能把读取失败当作空目录。
38fn 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        // 尺寸读 header(命中 IMAGE_DIMENSIONS_CACHE 时零 IO);读不到说明文件损坏或
68        // 格式不支持——记录路径但不删其 DB 行(issue #30:曾因跳过而误删大 WebP)。
69        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/// 全量重建素材索引。
94#[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/// 共享素材操作;调用方须先完成管理员鉴权。
101#[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    // 磁盘扫描 + header 尺寸读取是 IO 密集同步操作,移到阻塞线程池。
107    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    // 1. upsert assets。xmax = 0 判别新插入(PG 系统列:新行 xmax 为 0)。
124    //    ON CONFLICT 仅当技术字段(mime/size/width/height)实际变化时才更新(IS DISTINCT FROM),
125    //    保证幂等重跑 updated = 0 且不覆盖 alt 和原始 filename(issue #31)。
126    //    filename 仅在新插入时取磁盘文件名;已存在的行保留上传时记录的原始文件名。
127    let mut inserted: i64 = 0;
128    let mut updated: i64 = 0;
129    for f in &scanned {
130        // DO UPDATE 的 WHERE 不满足时不返回行(技术字段无变化),用 query_opt 区分三种结果。
131        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 => {} // 技术字段无变化,幂等跳过
163        }
164    }
165
166    // 2. 删除文件已消失的 DB 行(refs 级联删)。
167    //    排除集 = 可读文件 + 尺寸读取失败的文件(后者仍在磁盘上,
168    //    不应误删——issue #30 的根因就是此处曾遗漏无法读尺寸的大 WebP)。
169    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    // 3. 重建 asset_refs:全表扫 posts(含回收站——回收站文章的引用同样阻止删除)。
180    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    // 笔记所有快照(含回收站与历史)同样保护公开素材。补齐保存时尚未登记的图片。
212    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    // 评论(含回收站评论)同样保护素材;缺失素材静默跳过,与文章一致。
230    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    // 用户与友链头像:URL 与素材路径完全相等才登记。
258    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    /// 目录不可读(未挂载)必须报错,不能当成空扫描进而删除整张素材表。
313    #[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}