From 0c811efd4d4282ee1d47b915b20022ac31106e82 Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 24 Jul 2026 16:07:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(assets):=20=E7=B4=A0=E6=9D=90=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8=E6=95=B0=E6=8D=AE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration 015:assets 元数据注册表 + asset_refs 引用关联表 - upload_image 落盘后登记 assets(失败补偿删文件);尺寸校验复用 upload_dimensions 一次解析同时返回 (w,h) - sync_asset_refs 镜像 sync_tags:文章 create/update 事务内 解析 content_html + cover_image 的 /uploads/ 路径重建引用 - Asset/AssetDto/AssetFilter/AssetSort 共享模型,id 以 String 承载 (SQL 侧 ::uuid cast,避免 uuid crate 进 WASM 构建) --- migrations/015_assets.sql | 27 ++++++++++ src/api/image.rs | 10 +++- src/api/posts/create.rs | 5 +- src/api/posts/helpers.rs | 104 +++++++++++++++++++++++++++++++++++++- src/api/posts/update.rs | 5 +- src/api/upload.rs | 54 ++++++++++++++++++-- src/db/migrate.rs | 1 + src/models/asset.rs | 58 +++++++++++++++++++++ src/models/mod.rs | 2 + 9 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 migrations/015_assets.sql create mode 100644 src/models/asset.rs diff --git a/migrations/015_assets.sql b/migrations/015_assets.sql new file mode 100644 index 0000000..c17d240 --- /dev/null +++ b/migrations/015_assets.sql @@ -0,0 +1,27 @@ +-- 素材(图片)注册表与文章引用关联。 +-- assets:uploads/ 下每张已登记图片的元数据;磁盘是字节唯一存储,本表是注册表。 +-- asset_refs:哪篇文章引用了哪张素材,文章保存时由 sync_asset_refs 全量重建(sync_tags 模式)。 +-- id 由应用层生成(uuid crate),SQL 侧不设默认值,避免依赖 PG13+ 的 gen_random_uuid()。 + +CREATE TABLE IF NOT EXISTS assets ( + id UUID PRIMARY KEY, + path TEXT NOT NULL UNIQUE, -- 相对路径 "2026/07/24/153000..webp" + filename TEXT NOT NULL, -- 原始文件名(客户端提供,仅展示用) + mime TEXT NOT NULL, -- 落盘后的实际 MIME(转码后可能变为 image/webp) + size_bytes BIGINT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + alt TEXT, -- 管理性 alt:仅作默认值/备注,不回写已有文章 HTML + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_assets_created_at ON assets (created_at DESC); + +CREATE TABLE IF NOT EXISTS asset_refs ( + asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + post_id INT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + PRIMARY KEY (asset_id, post_id) +); + +CREATE INDEX IF NOT EXISTS idx_asset_refs_post ON asset_refs (post_id); diff --git a/src/api/image.rs b/src/api/image.rs index 3cecea8..e8b28ec 100644 --- a/src/api/image.rs +++ b/src/api/image.rs @@ -330,6 +330,14 @@ fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> { /// 上传入口三种格式在此统一拦截至尺寸上限;WebP 走 `zenwebp` header, /// JPEG/PNG/GIF 走 `image` crate 的 `into_dimensions`(均只读 header)。 pub fn check_upload_dimensions(data: &[u8], mime_type: &str) -> Result<(), &'static str> { + upload_dimensions(data, mime_type).map(|_| ()) +} + +#[cfg(feature = "server")] +/// 与 [`check_upload_dimensions`] 相同的校验,但返回 (width, height)。 +/// +/// 供 `upload_image` 在校验通过后直接把尺寸写入 assets 表,避免二次解析 header。 +pub(crate) fn upload_dimensions(data: &[u8], mime_type: &str) -> Result<(u32, u32), &'static str> { let dims = read_dimensions_by_mime(data, mime_type)?; let (width, height) = dims; if width == 0 || height == 0 { @@ -350,7 +358,7 @@ pub fn check_upload_dimensions(data: &[u8], mime_type: &str) -> Result<(), &'sta ); return Err("图片尺寸过大,请压缩后再上传"); } - Ok(()) + Ok(dims) } #[cfg(feature = "server")] diff --git a/src/api/posts/create.rs b/src/api/posts/create.rs index 92f0041..1d9b7fa 100644 --- a/src/api/posts/create.rs +++ b/src/api/posts/create.rs @@ -10,7 +10,7 @@ use dioxus::prelude::*; #[cfg(feature = "server")] -use super::helpers::{clean_tags, get_current_admin_user, sync_tags}; +use super::helpers::{clean_tags, get_current_admin_user, sync_asset_refs, sync_tags}; use super::types::CreatePostResponse; #[cfg(feature = "server")] use crate::api::error::AppError; @@ -129,6 +129,9 @@ pub async fn create_post( let tags_cleaned = clean_tags(&tags); sync_tags(&tx, post_id, &tags_cleaned).await?; + // 同步素材引用关联(asset_refs):内部自带 DELETE 再重建。 + sync_asset_refs(&tx, post_id, &content_html, cover_image.as_deref()).await?; + tx.commit().await.map_err(AppError::tx)?; // 写入成功后按粒度失效相关缓存。 diff --git a/src/api/posts/helpers.rs b/src/api/posts/helpers.rs index 69a45a2..a5d8cf1 100644 --- a/src/api/posts/helpers.rs +++ b/src/api/posts/helpers.rs @@ -203,9 +203,72 @@ pub(super) fn clean_tags(tags: &[String]) -> Vec { .collect() } +/// 匹配 HTML/Markdown 中出现的本地上传图片路径,捕获组为相对路径 +/// (如 `2026/07/24/153000..webp`,不含 /uploads/ 前缀与 query)。 +/// 覆盖 blur-img 双层结构的 src 与 data-src。 +#[cfg(feature = "server")] +static ASSET_PATH_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + regex::Regex::new(r#"/uploads/(\d{4}/\d{2}/\d{2}/[^\"'\s?#\)]+)"#) + .expect("ASSET_PATH_RE 正则模式应在编译期通过校验") +}); + +/// 从文章 HTML 与封面 URL 中提取全部引用的本地上传图片相对路径(去重)。 +/// +/// 外链图(非 /uploads/ 路径)与无法识别的路径自然被忽略。 +#[cfg(feature = "server")] +pub(super) 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) + .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) + .filter(|p| seen.insert(p.clone())) + .collect(); + if let Some(cover) = cover_image { + if let Some(rel) = cover + .strip_prefix("/uploads/") + .map(|p| p.split('?').next().unwrap_or(p)) + { + if seen.insert(rel.to_string()) { + paths.push(rel.to_string()); + } + } + } + paths +} + +/// 在事务中同步文章的素材引用关联(asset_refs)。 +/// +/// 语义镜像 [`sync_tags`]:调用方需在事务内先删除旧关联(本函数自带 DELETE), +/// 再按 content_html + cover_image 中出现的 /uploads/ 路径重建。 +/// 未登记到 assets 表的路径(如回填前的旧图)静默跳过,由重建索引兜底。 +#[cfg(feature = "server")] +pub(super) async fn sync_asset_refs( + tx: &deadpool_postgres::Transaction<'_>, + post_id: i32, + content_html: &str, + cover_image: Option<&str>, +) -> Result<(), AppError> { + tx.execute("DELETE FROM asset_refs WHERE post_id = $1", &[&post_id]) + .await + .map_err(AppError::tx)?; + + let paths = extract_asset_paths(content_html, cover_image); + if !paths.is_empty() { + 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, &paths], + ) + .await + .map_err(AppError::tx)?; + } + Ok(()) +} + #[cfg(all(test, feature = "server"))] mod tests { - use super::clean_tags; + use super::{clean_tags, extract_asset_paths}; #[test] fn clean_tags_trims_whitespace() { @@ -249,4 +312,43 @@ mod tests { vec!["rust".to_string(), "wasm".to_string(), "dioxus".to_string()] ); } + + // —— extract_asset_paths —— + + #[test] + fn extract_asset_paths_from_blur_img_html() { + // blur-img 双层结构:src 带 ?w=20,data-src 带 ?w=800,同一张图只应提取一次。 + let html = r#""#; + assert_eq!( + extract_asset_paths(html, None), + vec!["2026/07/24/a.webp".to_string()] + ); + } + + #[test] + fn extract_asset_paths_multiple_and_cover() { + let html = r#"

"#; + let paths = extract_asset_paths(html, Some("/uploads/2026/07/24/cover.jpg?w=600")); + assert_eq!(paths.len(), 3); + assert!(paths.contains(&"2026/07/24/a.webp".to_string())); + assert!(paths.contains(&"2026/06/01/b.png".to_string())); + assert!(paths.contains(&"2026/07/24/cover.jpg".to_string())); + } + + #[test] + fn extract_asset_paths_cover_dedup_and_external() { + // 封面与正文同图时去重;外链封面不产生路径。 + let html = r#""#; + assert_eq!( + extract_asset_paths(html, Some("/uploads/2026/07/24/a.webp")), + vec!["2026/07/24/a.webp".to_string()] + ); + assert!(extract_asset_paths(html, Some("https://example.com/c.webp")).len() == 1); + assert!(extract_asset_paths(html, None).len() == 1); + } + + #[test] + fn extract_asset_paths_empty() { + assert!(extract_asset_paths("

no image

", None).is_empty()); + } } diff --git a/src/api/posts/update.rs b/src/api/posts/update.rs index b73d7b8..89e711c 100644 --- a/src/api/posts/update.rs +++ b/src/api/posts/update.rs @@ -10,7 +10,7 @@ use dioxus::prelude::*; #[cfg(feature = "server")] -use super::helpers::{clean_tags, get_current_admin_user, sync_tags}; +use super::helpers::{clean_tags, get_current_admin_user, sync_asset_refs, sync_tags}; use super::types::CreatePostResponse; #[cfg(feature = "server")] use crate::api::error::AppError; @@ -183,6 +183,9 @@ pub async fn update_post( sync_tags(&tx, post_id, &tags_cleaned).await?; + // 同步素材引用关联(asset_refs):内部自带 DELETE 再重建。 + sync_asset_refs(&tx, post_id, &content_html, cover_image.as_deref()).await?; + tx.commit().await.map_err(AppError::tx)?; // 失效文章列表、标签、当前 slug 与统计缓存。 diff --git a/src/api/upload.rs b/src/api/upload.rs index dd986b3..a0d0745 100644 --- a/src/api/upload.rs +++ b/src/api/upload.rs @@ -134,6 +134,9 @@ pub async fn upload_image( return Err(upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型")); } + // 原始文件名(客户端提供,仅作 assets 表展示字段);需在 bytes() 消耗 field 前取出。 + let original_filename = field.file_name().map(|s| s.to_string()); + // 5. Read file data let data = match field.bytes().await { Ok(d) => d, @@ -158,12 +161,13 @@ pub async fn upload_image( return Err(upload_error(StatusCode::BAD_REQUEST, "文件类型与内容不符")); } - // 仅读 header 统一校验尺寸/像素上限。三种格式走同一路径: - // JPEG/PNG/GIF 用 image crate 的 into_dimensions,WebP 用 zenwebp header。 + // 仅读 header 统一校验尺寸/像素上限,并拿回 (w, h) 供 assets 表登记,避免二次解析。 // 超限直接拒绝,避免大图走 decode 后被静默降级(原 fallback 存原图)。 - if let Err(msg) = crate::api::image::check_upload_dimensions(&data, mime_type.as_str()) { - return Err(upload_error(StatusCode::BAD_REQUEST, msg)); - } + let (img_width, img_height) = + match crate::api::image::upload_dimensions(&data, mime_type.as_str()) { + Ok(dims) => dims, + Err(msg) => return Err(upload_error(StatusCode::BAD_REQUEST, msg)), + }; let is_gif = mime_type.as_str() == "image/gif"; let is_webp = mime_type.as_str() == "image/webp"; @@ -291,6 +295,46 @@ pub async fn upload_image( tracing::info!("Image uploaded: {} ({} bytes)", file_path, final_data.len()); + // 登记 assets 注册表。失败时补偿删除已落盘文件,避免产生未登记的孤儿文件。 + let rel_path = format!("{}/{}", date, file_name); + let final_mime = match final_ext.as_str() { + "jpg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + _ => "image/webp", + }; + let registered: Result<(), String> = async { + let client = crate::db::pool::get_conn() + .await + .map_err(|e| e.to_string())?; + client + .execute( + "INSERT INTO assets (id, path, filename, mime, size_bytes, width, height)\ + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7)", + &[ + &uuid::Uuid::new_v4().to_string(), + &rel_path, + &original_filename.unwrap_or_else(|| file_name.clone()), + &final_mime, + &(final_data.len() as i64), + &(img_width as i32), + &(img_height as i32), + ], + ) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + .await; + if let Err(e) = registered { + tracing::error!("Register asset failed ({}), removing uploaded file", e); + let _ = tokio::fs::remove_file(&file_path).await; + return Err(upload_error( + StatusCode::INTERNAL_SERVER_ERROR, + "文件保存失败", + )); + } + Ok(Json(json!({ "success": true, "url": url_path diff --git a/src/db/migrate.rs b/src/db/migrate.rs index 20b95f5..9c126ce 100644 --- a/src/db/migrate.rs +++ b/src/db/migrate.rs @@ -60,6 +60,7 @@ const MIGRATIONS: &[(&str, &str)] = &[ "014", include_str!("../../migrations/014_drop_ineffective_trgm_index.sql"), ), + ("015", include_str!("../../migrations/015_assets.sql")), // 新增迁移在此追加,同时在 migrations/ 下创建对应 .sql 文件。 ]; diff --git a/src/models/asset.rs b/src/models/asset.rs new file mode 100644 index 0000000..05faa31 --- /dev/null +++ b/src/models/asset.rs @@ -0,0 +1,58 @@ +//! 素材(图片)模型。 +//! +//! `assets` 表是 `uploads/` 目录的元数据注册表:磁盘是字节唯一存储, +//! 本表承载路径、尺寸、alt 等管理性字段。`asset_refs` 记录文章引用关系。 +//! 这些结构体通过 serde 在服务端与客户端之间共享序列化。 +//! +//! id 以 String 承载(SQL 侧 `id::text` 读出、`$1::uuid` 写入), +//! 避免把 server-only 的 uuid crate 引入 WASM 前端构建。 + +use serde::{Deserialize, Serialize}; + +/// 素材记录(对应 assets 表一行)。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Asset { + pub id: String, + /// 相对路径,如 "2026/07/24/153000..webp"(不含 /uploads/ 前缀)。 + pub path: String, + pub filename: String, + pub mime: String, + pub size_bytes: i64, + pub width: i32, + pub height: i32, + pub alt: Option, + pub created_at: chrono::DateTime, +} + +/// 引用该素材的一篇文章(素材详情/删除拦截时列出)。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AssetRef { + pub post_id: i32, + pub title: String, +} + +/// 列表页 DTO:素材本体 + 引用计数 + 引用文章列表。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AssetDto { + #[serde(flatten)] + pub asset: Asset, + pub ref_count: i64, + pub refs: Vec, +} + +/// 列表筛选:按引用状态。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)] +pub enum AssetFilter { + #[default] + All, + Used, + Orphan, +} + +/// 列表排序。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)] +pub enum AssetSort { + #[default] + CreatedDesc, + SizeDesc, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 34751ae..dfad32c 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -3,6 +3,8 @@ //! 定义博客系统使用的核心领域模型,包括文章(Post)、用户(User)与评论(Comment)。 //! 这些结构体通过 serde 在服务端与客户端之间共享序列化。 +/// 素材(图片)模型:assets 注册表与引用关联的 serde DTO。 +pub mod asset; /// 评论模型及其状态枚举。 pub mod comment; /// 文章模型、文章状态、标签与统计信息。