diff --git a/src/api/assets/mod.rs b/src/api/assets/mod.rs index 9c203a6..311d8b9 100644 --- a/src/api/assets/mod.rs +++ b/src/api/assets/mod.rs @@ -8,9 +8,12 @@ pub mod delete; /// 素材分页列表。 pub mod list; +/// 素材索引全量重建。 +pub mod rebuild; /// 请求与响应数据结构。 pub mod types; pub use delete::{delete_asset, purge_orphan_assets, update_asset_alt}; pub use list::list_assets; +pub use rebuild::rebuild_assets_index; pub use types::{AssetListResponse, AssetOpResponse, PurgeOrphansResponse, RebuildAssetsResponse}; diff --git a/src/api/assets/rebuild.rs b/src/api/assets/rebuild.rs new file mode 100644 index 0000000..b4aeb09 --- /dev/null +++ b/src/api/assets/rebuild.rs @@ -0,0 +1,208 @@ +//! 素材索引全量重建接口。 +//! +//! 以磁盘为准自愈 DB 与文件系统的不一致: +//! 1. 扫 `uploads/`(跳过 `.cache` 等点目录)→ upsert assets(技术字段变化才更新,保留 alt); +//! 2. 删除文件已消失的 DB 行(refs 级联); +//! 3. 全表扫 posts(含回收站)重建 asset_refs。 +//! +//! 幂等:重跑结果相同(技术字段无变化时 updated 为 0,alt 不被覆盖)。 +//! 幂等性由「手动触发」语义承载,非常态路径。Dioxus server function,仅 admin 可用。 + +use dioxus::prelude::*; + +use super::types::RebuildAssetsResponse; + +/// 可登记的图片扩展名(与 upload.rs 的 ALLOWED_MIME_TYPES 对应)。 +#[cfg(feature = "server")] +const IMAGE_EXTS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp"]; + +#[cfg(feature = "server")] +/// 扫描到的磁盘文件信息(spawn_blocking 产物)。 +struct ScannedFile { + /// 相对路径 "2026/07/24/x.webp"。 + rel_path: String, + filename: String, + mime: &'static str, + size_bytes: i64, + width: i32, + height: i32, +} + +#[cfg(feature = "server")] +/// 递归收集 dir 下的图片文件(跳过以 `.` 开头的目录/文件)。 +fn walk_images(dir: &std::path::Path, base: &std::path::Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + if name_str.starts_with('.') { + continue; + } + let path = entry.path(); + if path.is_dir() { + walk_images(&path, base, out); + continue; + } + let ext = name_str.rsplit('.').next().unwrap_or(""); + if !IMAGE_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)) { + continue; + } + let Ok(rel) = path.strip_prefix(base) else { + continue; + }; + let rel_path = rel.to_string_lossy().replace('\\', "/"); + // 尺寸读 header(命中 IMAGE_DIMENSIONS_CACHE 时零 IO);读不到则跳过该文件。 + let Some((w, h)) = crate::api::image::get_image_dimensions(&rel_path) else { + tracing::warn!("Rebuild: skip unreadable image {}", rel_path); + continue; + }; + let size_bytes = entry.metadata().map(|m| m.len() as i64).unwrap_or(0); + let mime = match ext.to_ascii_lowercase().as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + _ => "image/webp", + }; + out.push(ScannedFile { + rel_path, + filename: name_str.to_string(), + mime, + size_bytes, + width: w as i32, + height: h as i32, + }); + } +} + +/// 全量重建素材索引。 +#[server(RebuildAssetsIndex, "/api")] +pub async fn rebuild_assets_index() -> Result { + #[cfg(feature = "server")] + { + use crate::api::auth::get_current_admin_user; + use crate::api::error::AppError; + use crate::db::pool::get_conn; + + let _admin = get_current_admin_user().await?; + + // 磁盘扫描 + header 尺寸读取是 IO 密集同步操作,移到阻塞线程池。 + let scanned = tokio::task::spawn_blocking(|| { + let base = std::path::Path::new("uploads"); + let mut files = Vec::new(); + walk_images(base, base, &mut files); + files + }) + .await + .map_err(|_| AppError::Internal("素材扫描任务失败"))?; + + let mut client = get_conn().await.map_err(AppError::db_conn)?; + let tx = client.transaction().await.map_err(AppError::tx)?; + + // 1. upsert assets。xmax = 0 判别新插入(PG 系统列:新行 xmax 为 0)。 + // ON CONFLICT 仅当技术字段实际变化时才更新(IS DISTINCT FROM), + // 保证幂等重跑 updated = 0 且不覆盖 alt。 + let mut inserted: i64 = 0; + let mut updated: i64 = 0; + for f in &scanned { + // DO UPDATE 的 WHERE 不满足时不返回行(技术字段无变化),用 query_opt 区分三种结果。 + let row = tx + .query_opt( + "INSERT INTO assets (id, path, filename, mime, size_bytes, width, height) \ + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7) \ + ON CONFLICT (path) DO UPDATE SET \ + filename = EXCLUDED.filename, \ + mime = EXCLUDED.mime, \ + size_bytes = EXCLUDED.size_bytes, \ + width = EXCLUDED.width, \ + height = EXCLUDED.height, \ + updated_at = NOW() \ + WHERE assets.size_bytes IS DISTINCT FROM EXCLUDED.size_bytes \ + OR assets.width IS DISTINCT FROM EXCLUDED.width \ + OR assets.height IS DISTINCT FROM EXCLUDED.height \ + OR assets.mime IS DISTINCT FROM EXCLUDED.mime \ + OR assets.filename IS DISTINCT FROM EXCLUDED.filename \ + RETURNING (xmax = 0) AS was_inserted", + &[ + &uuid::Uuid::new_v4().to_string(), + &f.rel_path, + &f.filename, + &f.mime, + &f.size_bytes, + &f.width, + &f.height, + ], + ) + .await + .map_err(AppError::tx)?; + match row { + Some(r) if r.get::<_, bool>("was_inserted") => inserted += 1, + Some(_) => updated += 1, + None => {} // 技术字段无变化,幂等跳过 + } + } + + // 2. 删除文件已消失的 DB 行(refs 级联删)。 + let paths: Vec = scanned.iter().map(|f| f.rel_path.clone()).collect(); + let removed = tx + .execute( + "DELETE FROM assets WHERE NOT (path = ANY($1))", + &[&paths], + ) + .await + .map_err(AppError::tx)?; + + // 3. 重建 asset_refs:全表扫 posts(含回收站——回收站文章的引用同样阻止删除)。 + let post_rows = tx + .query("SELECT id, content_html, cover_image FROM posts", &[]) + .await + .map_err(AppError::query)?; + tx.execute("DELETE FROM asset_refs", &[]) + .await + .map_err(AppError::tx)?; + let mut ref_count: i64 = 0; + for pr in &post_rows { + let post_id: i32 = pr.get("id"); + let content_html: Option = pr.get("content_html"); + let cover_image: Option = pr.get("cover_image"); + let found = crate::api::posts::helpers::extract_asset_paths( + content_html.as_deref().unwrap_or(""), + cover_image.as_deref(), + ); + if found.is_empty() { + continue; + } + let n = tx + .execute( + "INSERT INTO asset_refs (asset_id, post_id) \ + SELECT id, $1 FROM assets WHERE path = ANY($2) \ + ON CONFLICT DO NOTHING", + &[&post_id, &found], + ) + .await + .map_err(AppError::tx)?; + ref_count += n as i64; + } + + tx.commit().await.map_err(AppError::tx)?; + + let scanned_count = scanned.len() as i64; + Ok(RebuildAssetsResponse { + success: true, + message: format!( + "重建完成:扫描 {} 个文件,新增 {},更新 {},移除 {}", + scanned_count, inserted, updated, removed + ), + scanned: scanned_count, + inserted, + updated, + removed: removed as i64, + ref_count, + }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} diff --git a/src/api/posts/helpers.rs b/src/api/posts/helpers.rs index a5d8cf1..fa04e20 100644 --- a/src/api/posts/helpers.rs +++ b/src/api/posts/helpers.rs @@ -215,8 +215,9 @@ static ASSET_PATH_RE: std::sync::LazyLock = std::sync::LazyLock::n /// 从文章 HTML 与封面 URL 中提取全部引用的本地上传图片相对路径(去重)。 /// /// 外链图(非 /uploads/ 路径)与无法识别的路径自然被忽略。 +/// pub(crate):重建素材索引(api::assets::rebuild)复用同一提取逻辑。 #[cfg(feature = "server")] -pub(super) fn extract_asset_paths(content_html: &str, cover_image: Option<&str>) -> Vec { +pub(crate) fn extract_asset_paths(content_html: &str, cover_image: Option<&str>) -> Vec { let mut seen = std::collections::HashSet::new(); let mut paths: Vec = ASSET_PATH_RE .captures_iter(content_html) diff --git a/src/api/posts/mod.rs b/src/api/posts/mod.rs index 4e6600d..3799613 100644 --- a/src/api/posts/mod.rs +++ b/src/api/posts/mod.rs @@ -7,7 +7,7 @@ mod create; mod delete; -mod helpers; +pub(crate) mod helpers; mod list; mod read; mod rebuild; diff --git a/src/pages/admin/assets.rs b/src/pages/admin/assets.rs index ecb2ece..cf1bfbc 100644 --- a/src/pages/admin/assets.rs +++ b/src/pages/admin/assets.rs @@ -6,8 +6,10 @@ use dioxus::prelude::*; -use crate::api::assets::{delete_asset, list_assets, purge_orphan_assets, update_asset_alt}; -use crate::api::assets::{AssetListResponse, PurgeOrphansResponse}; +use crate::api::assets::{ + delete_asset, list_assets, purge_orphan_assets, rebuild_assets_index, update_asset_alt, +}; +use crate::api::assets::{AssetListResponse, PurgeOrphansResponse, RebuildAssetsResponse}; use crate::components::empty_state::EmptyState; use crate::components::ui::{FilterTabs, Pagination}; use crate::models::asset::{AssetFilter, AssetSort}; @@ -53,6 +55,8 @@ pub fn Assets() -> Element { // 待二次确认的删除目标(素材 id)与一键清理确认态。 let mut confirm_delete: Signal> = use_signal(|| None); let mut purge_confirm = use_signal(|| false); + // 重建索引进行中状态。 + let mut rebuilding = use_signal(|| false); // alt 内联编辑:目标素材 id + 输入框值。 let mut editing_alt: Signal> = use_signal(|| None); let mut alt_input = use_signal(String::new); @@ -140,6 +144,33 @@ pub fn Assets() -> Element { }, } div { class: "flex items-center gap-3 pb-1", + // 重建索引:以磁盘为准全量自愈(存量回填/不一致修复)。 + button { + class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:border-[var(--color-paper-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed", + disabled: rebuilding(), + title: "扫描 uploads/ 全量文件,同步素材注册表与文章引用(幂等,可随时重跑)", + onclick: move |_| { + rebuilding.set(true); + #[cfg(target_arch = "wasm32")] + spawn(async move { + match rebuild_assets_index().await { + Ok(RebuildAssetsResponse { message, .. }) => { + op_message.set(Some(message)); + reload.set(reload() + 1); + } + Err(e) => { + op_message.set(Some(format!("重建失败:{e}"))) + } + } + rebuilding.set(false); + }); + }, + if rebuilding() { + "重建中..." + } else { + "重建索引" + } + } input { class: "w-56 text-sm bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-2xl px-4 py-2 shadow-sm transition-all", r#type: "search",