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(含回收站)重建 asset_refs。
7//!
8//! 幂等:重跑结果相同(技术字段无变化时 updated 为 0,alt 与 filename 不被覆盖)。
9//! 幂等性由「手动触发」语义承载,非常态路径。Dioxus server function,仅 admin 可用。
10
11use dioxus::prelude::*;
12
13use super::types::RebuildAssetsResponse;
14
15/// 可登记的图片扩展名(与 upload.rs 的 ALLOWED_MIME_TYPES 对应)。
16#[cfg(feature = "server")]
17const IMAGE_EXTS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp"];
18
19#[cfg(feature = "server")]
20/// 扫描到的磁盘文件信息(spawn_blocking 产物)。
21struct ScannedFile {
22    /// 相对路径 "2026/07/24/x.webp"。
23    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")]
32/// 递归收集 dir 下的图片文件(跳过以 `.` 开头的目录/文件)。
33/// `out` 收集可读文件;`unreadable` 收集磁盘存在但尺寸读取失败的路径
34/// (保护其 DB 行不被删除步骤误删——见 issue #30)。
35fn walk_images(
36    dir: &std::path::Path,
37    base: &std::path::Path,
38    out: &mut Vec<ScannedFile>,
39    unreadable: &mut Vec<String>,
40) {
41    let Ok(entries) = std::fs::read_dir(dir) else {
42        return;
43    };
44    for entry in entries.flatten() {
45        let name = entry.file_name();
46        let Some(name_str) = name.to_str() else {
47            continue;
48        };
49        if name_str.starts_with('.') {
50            continue;
51        }
52        let path = entry.path();
53        if path.is_dir() {
54            walk_images(&path, base, out, unreadable);
55            continue;
56        }
57        let ext = name_str.rsplit('.').next().unwrap_or("");
58        if !IMAGE_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
59            continue;
60        }
61        let Ok(rel) = path.strip_prefix(base) else {
62            continue;
63        };
64        let rel_path = rel.to_string_lossy().replace('\\', "/");
65        // 尺寸读 header(命中 IMAGE_DIMENSIONS_CACHE 时零 IO);读不到说明文件损坏或
66        // 格式不支持——记录路径但不删其 DB 行(issue #30:曾因跳过而误删大 WebP)。
67        let Some((w, h)) = crate::api::image::get_image_dimensions(&rel_path) else {
68            tracing::warn!("Rebuild: skip unreadable image {}", rel_path);
69            unreadable.push(rel_path);
70            continue;
71        };
72        let size_bytes = entry.metadata().map(|m| m.len() as i64).unwrap_or(0);
73        let mime = match ext.to_ascii_lowercase().as_str() {
74            "jpg" | "jpeg" => "image/jpeg",
75            "png" => "image/png",
76            "gif" => "image/gif",
77            _ => "image/webp",
78        };
79        out.push(ScannedFile {
80            rel_path,
81            filename: name_str.to_string(),
82            mime,
83            size_bytes,
84            width: w as i32,
85            height: h as i32,
86        });
87    }
88}
89
90/// 全量重建素材索引。
91#[server(RebuildAssetsIndex, "/api")]
92pub async fn rebuild_assets_index() -> Result<RebuildAssetsResponse, ServerFnError> {
93    crate::api::auth::get_current_admin_user().await?;
94    rebuild_assets_index_impl().await
95}
96
97/// 共享素材操作;调用方须先完成管理员鉴权。
98#[cfg(feature = "server")]
99pub(crate) async fn rebuild_assets_index_impl() -> Result<RebuildAssetsResponse, ServerFnError> {
100    use crate::api::error::AppError;
101    use crate::db::pool::get_conn;
102
103    // 磁盘扫描 + header 尺寸读取是 IO 密集同步操作,移到阻塞线程池。
104    let (scanned, unreadable) = tokio::task::spawn_blocking(|| {
105        let base = std::path::Path::new("uploads");
106        let mut files = Vec::new();
107        let mut unreadable = Vec::new();
108        walk_images(base, base, &mut files, &mut unreadable);
109        (files, unreadable)
110    })
111    .await
112    .map_err(|_| AppError::Internal("素材扫描任务失败"))?;
113
114    let mut client = get_conn().await.map_err(AppError::db_conn)?;
115    let tx = client.transaction().await.map_err(AppError::tx)?;
116
117    // 1. upsert assets。xmax = 0 判别新插入(PG 系统列:新行 xmax 为 0)。
118    //    ON CONFLICT 仅当技术字段(mime/size/width/height)实际变化时才更新(IS DISTINCT FROM),
119    //    保证幂等重跑 updated = 0 且不覆盖 alt 和原始 filename(issue #31)。
120    //    filename 仅在新插入时取磁盘文件名;已存在的行保留上传时记录的原始文件名。
121    let mut inserted: i64 = 0;
122    let mut updated: i64 = 0;
123    for f in &scanned {
124        // DO UPDATE 的 WHERE 不满足时不返回行(技术字段无变化),用 query_opt 区分三种结果。
125        let asset_id = uuid::Uuid::new_v4();
126        let row = tx
127            .query_opt(
128                "INSERT INTO assets (id, path, filename, mime, size_bytes, width, height) \
129                 VALUES ($1, $2, $3, $4, $5, $6, $7) \
130                ON CONFLICT (path) DO UPDATE SET \
131                    mime = EXCLUDED.mime, \
132                    size_bytes = EXCLUDED.size_bytes, \
133                    width = EXCLUDED.width, \
134                    height = EXCLUDED.height, \
135                    updated_at = NOW() \
136                WHERE assets.size_bytes IS DISTINCT FROM EXCLUDED.size_bytes \
137                   OR assets.width IS DISTINCT FROM EXCLUDED.width \
138                   OR assets.height IS DISTINCT FROM EXCLUDED.height \
139                   OR assets.mime IS DISTINCT FROM EXCLUDED.mime \
140                RETURNING (xmax = 0) AS was_inserted",
141                &[
142                    &asset_id,
143                    &f.rel_path,
144                    &f.filename,
145                    &f.mime,
146                    &f.size_bytes,
147                    &f.width,
148                    &f.height,
149                ],
150            )
151            .await
152            .map_err(AppError::tx)?;
153        match row {
154            Some(r) if r.get::<_, bool>("was_inserted") => inserted += 1,
155            Some(_) => updated += 1,
156            None => {} // 技术字段无变化,幂等跳过
157        }
158    }
159
160    // 2. 删除文件已消失的 DB 行(refs 级联删)。
161    //    排除集 = 可读文件 + 尺寸读取失败的文件(后者仍在磁盘上,
162    //    不应误删——issue #30 的根因就是此处曾遗漏无法读尺寸的大 WebP)。
163    let mut keep_paths: Vec<String> = scanned.iter().map(|f| f.rel_path.clone()).collect();
164    keep_paths.extend(unreadable.iter().cloned());
165    let removed = tx
166        .execute(
167            "DELETE FROM assets WHERE NOT (path = ANY($1))",
168            &[&keep_paths],
169        )
170        .await
171        .map_err(AppError::tx)?;
172
173    // 3. 重建 asset_refs:全表扫 posts(含回收站——回收站文章的引用同样阻止删除)。
174    let post_rows = tx
175        .query("SELECT id, content_html, cover_image FROM posts", &[])
176        .await
177        .map_err(AppError::query)?;
178    tx.execute("DELETE FROM asset_refs", &[])
179        .await
180        .map_err(AppError::tx)?;
181    let mut ref_count: i64 = 0;
182    for pr in &post_rows {
183        let post_id: i32 = pr.get("id");
184        let content_html: Option<String> = pr.get("content_html");
185        let cover_image: Option<String> = pr.get("cover_image");
186        let found = crate::api::posts::helpers::extract_asset_paths(
187            content_html.as_deref().unwrap_or(""),
188            cover_image.as_deref(),
189        );
190        if found.is_empty() {
191            continue;
192        }
193        let n = tx
194            .execute(
195                "INSERT INTO asset_refs (asset_id, post_id) \
196                 SELECT id, $1 FROM assets WHERE path = ANY($2) \
197                 ON CONFLICT DO NOTHING",
198                &[&post_id, &found],
199            )
200            .await
201            .map_err(AppError::tx)?;
202        ref_count += n as i64;
203    }
204
205    // 笔记所有快照(含回收站与历史)同样保护公开素材。补齐保存时尚未登记的图片。
206    let note_rows = tx
207        .query(
208            "SELECT note_id,version,content_html FROM note_revisions",
209            &[],
210        )
211        .await
212        .map_err(AppError::query)?;
213    tx.execute("DELETE FROM note_asset_refs", &[])
214        .await
215        .map_err(AppError::tx)?;
216    for row in note_rows {
217        let paths = crate::api::posts::helpers::extract_asset_paths(row.get("content_html"), None);
218        if !paths.is_empty() {
219            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;
220        }
221    }
222    tx.commit().await.map_err(AppError::tx)?;
223
224    let scanned_count = scanned.len() as i64;
225    let skipped_count = unreadable.len() as i64;
226    let message = if skipped_count > 0 {
227        format!(
228            "重建完成:扫描 {} 个文件,新增 {},更新 {},移除 {},跳过 {} 个无法读取的文件(已保留)",
229            scanned_count, inserted, updated, removed, skipped_count
230        )
231    } else {
232        format!(
233            "重建完成:扫描 {} 个文件,新增 {},更新 {},移除 {}",
234            scanned_count, inserted, updated, removed
235        )
236    };
237    Ok(RebuildAssetsResponse {
238        success: true,
239        message,
240        scanned: scanned_count,
241        inserted,
242        updated,
243        removed: removed as i64,
244        ref_count,
245        skipped: skipped_count,
246    })
247}