feat(assets): 素材注册表数据层

- 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 构建)
This commit is contained in:
xfy 2026-07-24 16:07:07 +08:00
parent adde92e15c
commit 0c811efd4d
9 changed files with 257 additions and 9 deletions

27
migrations/015_assets.sql Normal file
View File

@ -0,0 +1,27 @@
-- 素材(图片)注册表与文章引用关联。
-- assetsuploads/ 下每张已登记图片的元数据;磁盘是字节唯一存储,本表是注册表。
-- asset_refs哪篇文章引用了哪张素材文章保存时由 sync_asset_refs 全量重建sync_tags 模式)。
-- id 由应用层生成uuid crateSQL 侧不设默认值,避免依赖 PG13+ 的 gen_random_uuid()。
CREATE TABLE IF NOT EXISTS assets (
id UUID PRIMARY KEY,
path TEXT NOT NULL UNIQUE, -- 相对路径 "2026/07/24/153000.<uuid>.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);

View File

@ -330,6 +330,14 @@ fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> {
/// 上传入口三种格式在此统一拦截至尺寸上限;WebP 走 `zenwebp` header, /// 上传入口三种格式在此统一拦截至尺寸上限;WebP 走 `zenwebp` header,
/// JPEG/PNG/GIF 走 `image` crate 的 `into_dimensions`(均只读 header)。 /// JPEG/PNG/GIF 走 `image` crate 的 `into_dimensions`(均只读 header)。
pub fn check_upload_dimensions(data: &[u8], mime_type: &str) -> Result<(), &'static str> { 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 dims = read_dimensions_by_mime(data, mime_type)?;
let (width, height) = dims; let (width, height) = dims;
if width == 0 || height == 0 { if width == 0 || height == 0 {
@ -350,7 +358,7 @@ pub fn check_upload_dimensions(data: &[u8], mime_type: &str) -> Result<(), &'sta
); );
return Err("图片尺寸过大,请压缩后再上传"); return Err("图片尺寸过大,请压缩后再上传");
} }
Ok(()) Ok(dims)
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]

View File

@ -10,7 +10,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")] #[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; use super::types::CreatePostResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
@ -129,6 +129,9 @@ pub async fn create_post(
let tags_cleaned = clean_tags(&tags); let tags_cleaned = clean_tags(&tags);
sync_tags(&tx, post_id, &tags_cleaned).await?; 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)?; tx.commit().await.map_err(AppError::tx)?;
// 写入成功后按粒度失效相关缓存。 // 写入成功后按粒度失效相关缓存。

View File

@ -203,9 +203,72 @@ pub(super) fn clean_tags(tags: &[String]) -> Vec<String> {
.collect() .collect()
} }
/// 匹配 HTML/Markdown 中出现的本地上传图片路径,捕获组为相对路径
/// (如 `2026/07/24/153000.<uuid>.webp`,不含 /uploads/ 前缀与 query
/// 覆盖 blur-img 双层结构的 src 与 data-src。
#[cfg(feature = "server")]
static ASSET_PATH_RE: std::sync::LazyLock<regex::Regex> = 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<String> {
let mut seen = std::collections::HashSet::new();
let mut paths: Vec<String> = 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"))] #[cfg(all(test, feature = "server"))]
mod tests { mod tests {
use super::clean_tags; use super::{clean_tags, extract_asset_paths};
#[test] #[test]
fn clean_tags_trims_whitespace() { fn clean_tags_trims_whitespace() {
@ -249,4 +312,43 @@ mod tests {
vec!["rust".to_string(), "wasm".to_string(), "dioxus".to_string()] 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=20data-src 带 ?w=800同一张图只应提取一次。
let html = r#"<span class="blur-img"><img class="blur-img-placeholder" src="/uploads/2026/07/24/a.webp?w=20"><img class="blur-img-full" data-src="/uploads/2026/07/24/a.webp?w=800"></span>"#;
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#"<p><img src="/uploads/2026/07/24/a.webp"></p><p><img src="/uploads/2026/06/01/b.png?w=800"></p><p><img src="https://cdn.example.com/x.webp"></p>"#;
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#"<img src="/uploads/2026/07/24/a.webp">"#;
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("<p>no image</p>", None).is_empty());
}
} }

View File

@ -10,7 +10,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")] #[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; use super::types::CreatePostResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
@ -183,6 +183,9 @@ pub async fn update_post(
sync_tags(&tx, post_id, &tags_cleaned).await?; 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)?; tx.commit().await.map_err(AppError::tx)?;
// 失效文章列表、标签、当前 slug 与统计缓存。 // 失效文章列表、标签、当前 slug 与统计缓存。

View File

@ -134,6 +134,9 @@ pub async fn upload_image(
return Err(upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型")); 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 // 5. Read file data
let data = match field.bytes().await { let data = match field.bytes().await {
Ok(d) => d, Ok(d) => d,
@ -158,12 +161,13 @@ pub async fn upload_image(
return Err(upload_error(StatusCode::BAD_REQUEST, "文件类型与内容不符")); return Err(upload_error(StatusCode::BAD_REQUEST, "文件类型与内容不符"));
} }
// 仅读 header 统一校验尺寸/像素上限。三种格式走同一路径: // 仅读 header 统一校验尺寸/像素上限,并拿回 (w, h) 供 assets 表登记,避免二次解析。
// JPEG/PNG/GIF 用 image crate 的 into_dimensions,WebP 用 zenwebp header。
// 超限直接拒绝,避免大图走 decode 后被静默降级(原 fallback 存原图)。 // 超限直接拒绝,避免大图走 decode 后被静默降级(原 fallback 存原图)。
if let Err(msg) = crate::api::image::check_upload_dimensions(&data, mime_type.as_str()) { let (img_width, img_height) =
return Err(upload_error(StatusCode::BAD_REQUEST, msg)); 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_gif = mime_type.as_str() == "image/gif";
let is_webp = mime_type.as_str() == "image/webp"; 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()); 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!({ Ok(Json(json!({
"success": true, "success": true,
"url": url_path "url": url_path

View File

@ -60,6 +60,7 @@ const MIGRATIONS: &[(&str, &str)] = &[
"014", "014",
include_str!("../../migrations/014_drop_ineffective_trgm_index.sql"), include_str!("../../migrations/014_drop_ineffective_trgm_index.sql"),
), ),
("015", include_str!("../../migrations/015_assets.sql")),
// 新增迁移在此追加,同时在 migrations/ 下创建对应 .sql 文件。 // 新增迁移在此追加,同时在 migrations/ 下创建对应 .sql 文件。
]; ];

58
src/models/asset.rs Normal file
View File

@ -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.<uuid>.webp"(不含 /uploads/ 前缀)。
pub path: String,
pub filename: String,
pub mime: String,
pub size_bytes: i64,
pub width: i32,
pub height: i32,
pub alt: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
/// 引用该素材的一篇文章(素材详情/删除拦截时列出)。
#[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<AssetRef>,
}
/// 列表筛选:按引用状态。
#[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,
}

View File

@ -3,6 +3,8 @@
//! 定义博客系统使用的核心领域模型包括文章Post、用户User与评论Comment //! 定义博客系统使用的核心领域模型包括文章Post、用户User与评论Comment
//! 这些结构体通过 serde 在服务端与客户端之间共享序列化。 //! 这些结构体通过 serde 在服务端与客户端之间共享序列化。
/// 素材图片模型assets 注册表与引用关联的 serde DTO。
pub mod asset;
/// 评论模型及其状态枚举。 /// 评论模型及其状态枚举。
pub mod comment; pub mod comment;
/// 文章模型、文章状态、标签与统计信息。 /// 文章模型、文章状态、标签与统计信息。