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 不被覆盖)。
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 下的图片文件(跳过以 `.` 开头的目录/文件)。
33fn 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        // 尺寸读 header(命中 IMAGE_DIMENSIONS_CACHE 时零 IO);读不到则跳过该文件。
59        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/// 全量重建素材索引。
82#[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        // 磁盘扫描 + header 尺寸读取是 IO 密集同步操作,移到阻塞线程池。
93        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        // 1. upsert assets。xmax = 0 判别新插入(PG 系统列:新行 xmax 为 0)。
106        //    ON CONFLICT 仅当技术字段实际变化时才更新(IS DISTINCT FROM),
107        //    保证幂等重跑 updated = 0 且不覆盖 alt。
108        let mut inserted: i64 = 0;
109        let mut updated: i64 = 0;
110        for f in &scanned {
111            // DO UPDATE 的 WHERE 不满足时不返回行(技术字段无变化),用 query_opt 区分三种结果。
112            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 => {} // 技术字段无变化,幂等跳过
146            }
147        }
148
149        // 2. 删除文件已消失的 DB 行(refs 级联删)。
150        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        // 3. 重建 asset_refs:全表扫 posts(含回收站——回收站文章的引用同样阻止删除)。
157        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}