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:
parent
adde92e15c
commit
0c811efd4d
27
migrations/015_assets.sql
Normal file
27
migrations/015_assets.sql
Normal file
@ -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.<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);
|
||||
@ -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")]
|
||||
|
||||
@ -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)?;
|
||||
|
||||
// 写入成功后按粒度失效相关缓存。
|
||||
|
||||
@ -203,9 +203,72 @@ pub(super) fn clean_tags(tags: &[String]) -> Vec<String> {
|
||||
.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"))]
|
||||
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#"<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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 与统计缓存。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 文件。
|
||||
];
|
||||
|
||||
|
||||
58
src/models/asset.rs
Normal file
58
src/models/asset.rs
Normal 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,
|
||||
}
|
||||
@ -3,6 +3,8 @@
|
||||
//! 定义博客系统使用的核心领域模型,包括文章(Post)、用户(User)与评论(Comment)。
|
||||
//! 这些结构体通过 serde 在服务端与客户端之间共享序列化。
|
||||
|
||||
/// 素材(图片)模型:assets 注册表与引用关联的 serde DTO。
|
||||
pub mod asset;
|
||||
/// 评论模型及其状态枚举。
|
||||
pub mod comment;
|
||||
/// 文章模型、文章状态、标签与统计信息。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user