feat(assets): 全量重建索引

- rebuild_assets_index:扫 uploads/(跳过点目录)→ upsert assets
  (xmax=0 判别新增;技术字段 IS DISTINCT FROM 才更新,保留 alt,
  幂等重跑 updated=0)→ 删文件已消失的行 → 全表扫 posts 重建 refs
- 磁盘扫描与 header 尺寸读取移入 spawn_blocking
- extract_asset_paths 提升 pub(crate) 供重建复用同一提取逻辑
- 前端「重建索引」按钮(进行中禁点,结果进横幅)
This commit is contained in:
xfy 2026-07-24 16:27:02 +08:00
parent 799455da43
commit 0c47e87d30
5 changed files with 247 additions and 4 deletions

View File

@ -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};

208
src/api/assets/rebuild.rs Normal file
View File

@ -0,0 +1,208 @@
//! 素材索引全量重建接口。
//!
//! 以磁盘为准自愈 DB 与文件系统的不一致:
//! 1. 扫 `uploads/`(跳过 `.cache` 等点目录)→ upsert assets技术字段变化才更新保留 alt
//! 2. 删除文件已消失的 DB 行refs 级联);
//! 3. 全表扫 posts含回收站重建 asset_refs。
//!
//! 幂等:重跑结果相同(技术字段无变化时 updated 为 0alt 不被覆盖)。
//! 幂等性由「手动触发」语义承载非常态路径。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<ScannedFile>) {
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<RebuildAssetsResponse, ServerFnError> {
#[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<String> = 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<String> = pr.get("content_html");
let cover_image: Option<String> = 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!()
}

View File

@ -215,8 +215,9 @@ static ASSET_PATH_RE: std::sync::LazyLock<regex::Regex> = 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<String> {
pub(crate) fn extract_asset_paths(content_html: &str, cover_image: Option<&str>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut paths: Vec<String> = ASSET_PATH_RE
.captures_iter(content_html)

View File

@ -7,7 +7,7 @@
mod create;
mod delete;
mod helpers;
pub(crate) mod helpers;
mod list;
mod read;
mod rebuild;

View File

@ -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<Option<String>> = use_signal(|| None);
let mut purge_confirm = use_signal(|| false);
// 重建索引进行中状态。
let mut rebuilding = use_signal(|| false);
// alt 内联编辑:目标素材 id + 输入框值。
let mut editing_alt: Signal<Option<String>> = 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",