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:
parent
799455da43
commit
0c47e87d30
@ -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
208
src/api/assets/rebuild.rs
Normal file
@ -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<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!()
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
mod create;
|
||||
mod delete;
|
||||
mod helpers;
|
||||
pub(crate) mod helpers;
|
||||
mod list;
|
||||
mod read;
|
||||
mod rebuild;
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user