fix(assets): uuid 参数序列化失败导致上传 500

tokio-postgres 将 $1::uuid 的参数推断为 uuid 类型,String 无法
序列化(缺类型桥接),INSERT assets 报 error serializing parameter 0,
补偿删文件后返回 500。list/delete/purge/rebuild 的 $1::uuid 与
ANY($1::uuid[]) 全是同一颗未引爆的雷。

正路:tokio-postgres 启用 with-uuid-1,SQL 侧去掉全部 ::uuid cast,
Rust 侧全程 Uuid 类型,仅在 serde DTO 边界(WASM 共享模型)转 String;
外部传入的 id 在边界 parse_str,非法 id 走业务错误而非 500。
This commit is contained in:
xfy 2026-07-24 17:04:35 +08:00
parent 8f75a64bfd
commit fa40f4bd95
6 changed files with 41 additions and 31 deletions

1
Cargo.lock generated
View File

@ -3445,6 +3445,7 @@ dependencies = [
"chrono",
"fallible-iterator",
"postgres-protocol",
"uuid",
]
[[package]]

View File

@ -14,7 +14,7 @@ required-features = ["server"]
dioxus = { version = "0.7.9", features = ["fullstack", "router"] }
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "fs", "time", "sync"], optional = true }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"], optional = true }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1"], optional = true }
deadpool-postgres = { version = "0.14", optional = true }
argon2 = { version = "0.5", optional = true }
uuid = { version = "1", features = ["v4"], optional = true }

View File

@ -21,11 +21,17 @@ pub async fn update_asset_alt(id: String, alt: String) -> Result<AssetOpResponse
let _admin = get_current_admin_user().await?;
let client = get_conn().await.map_err(AppError::db_conn)?;
// id 在边界处从 String 解析为 Uuid非法 id 属业务错误,不走 500
let asset_uuid = match uuid::Uuid::parse_str(&id) {
Ok(u) => u,
Err(_) => return Ok(AssetOpResponse::err("素材 id 非法".to_string())),
};
let alt = alt.trim().to_string();
let updated = client
.execute(
"UPDATE assets SET alt = NULLIF($2, ''), updated_at = NOW() WHERE id = $1::uuid",
&[&id, &alt],
"UPDATE assets SET alt = NULLIF($2, ''), updated_at = NOW() WHERE id = $1",
&[&asset_uuid, &alt],
)
.await
.map_err(AppError::query)?;
@ -55,10 +61,15 @@ pub async fn delete_asset(id: String) -> Result<AssetOpResponse, ServerFnError>
let _admin = get_current_admin_user().await?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let asset_uuid = match uuid::Uuid::parse_str(&id) {
Ok(u) => u,
Err(_) => return Ok(AssetOpResponse::err("素材 id 非法".to_string())),
};
let row = client
.query_opt(
"SELECT id::text AS id, path, filename FROM assets WHERE id = $1::uuid",
&[&id],
"SELECT id AS id, path, filename FROM assets WHERE id = $1",
&[&asset_uuid],
)
.await
.map_err(AppError::query)?;
@ -71,8 +82,8 @@ pub async fn delete_asset(id: String) -> Result<AssetOpResponse, ServerFnError>
let ref_rows = client
.query(
"SELECT p.id, p.title FROM asset_refs r JOIN posts p ON p.id = r.post_id \
WHERE r.asset_id = $1::uuid ORDER BY p.id",
&[&id],
WHERE r.asset_id = $1 ORDER BY p.id",
&[&asset_uuid],
)
.await
.map_err(AppError::query)?;
@ -99,7 +110,7 @@ pub async fn delete_asset(id: String) -> Result<AssetOpResponse, ServerFnError>
}
}
client
.execute("DELETE FROM assets WHERE id = $1::uuid", &[&id])
.execute("DELETE FROM assets WHERE id = $1", &[&asset_uuid])
.await
.map_err(AppError::query)?;
crate::api::image::invalidate_asset_caches(&path).await;
@ -127,7 +138,7 @@ pub async fn purge_orphan_assets() -> Result<PurgeOrphansResponse, ServerFnError
let rows = client
.query(
"SELECT a.id::text AS id, a.path, a.size_bytes FROM assets a \
"SELECT a.id AS id, a.path, a.size_bytes FROM assets a \
WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \
AND a.created_at < NOW() - make_interval(days => $1)",
&[&super::list::PURGE_GRACE_DAYS],
@ -145,11 +156,11 @@ pub async fn purge_orphan_assets() -> Result<PurgeOrphansResponse, ServerFnError
});
}
let mut ids: Vec<String> = Vec::with_capacity(rows.len());
let mut ids: Vec<uuid::Uuid> = Vec::with_capacity(rows.len());
let mut freed_bytes: i64 = 0;
let mut failures: i64 = 0;
for row in &rows {
let id: String = row.get("id");
let id: uuid::Uuid = row.get("id");
let path: String = row.get("path");
freed_bytes += row.get::<_, i64>("size_bytes");
let file_path = format!("uploads/{}", path);
@ -166,10 +177,7 @@ pub async fn purge_orphan_assets() -> Result<PurgeOrphansResponse, ServerFnError
}
let deleted = client
.execute(
"DELETE FROM assets WHERE id = ANY($1::uuid[])",
&[&ids],
)
.execute("DELETE FROM assets WHERE id = ANY($1)", &[&ids])
.await
.map_err(AppError::query)?;

View File

@ -87,7 +87,7 @@ pub async fn list_assets(
let rows = client
.query(
&format!(
"SELECT a.id::text AS id, a.path, a.filename, a.mime, a.size_bytes, \
"SELECT a.id AS id, a.path, a.filename, a.mime, a.size_bytes, \
a.width, a.height, a.alt, a.created_at, \
(SELECT COUNT(*) FROM asset_refs r WHERE r.asset_id = a.id) AS ref_count \
FROM assets a {where_clause} \
@ -124,25 +124,23 @@ pub async fn list_assets(
.map_err(AppError::query)?;
// 本页素材的引用文章(第二查询,避免 JOIN fan-out 与分页错位)。
let ids: Vec<String> = rows.iter().map(|r| r.get::<_, String>("id")).collect();
let ids: Vec<uuid::Uuid> = rows.iter().map(|r| r.get::<_, uuid::Uuid>("id")).collect();
let mut refs_map: std::collections::HashMap<String, Vec<AssetRef>> =
std::collections::HashMap::new();
if !ids.is_empty() {
let ref_rows = client
.query(
"SELECT r.asset_id::text AS asset_id, p.id AS post_id, p.title \
"SELECT r.asset_id AS asset_id, p.id AS post_id, p.title \
FROM asset_refs r JOIN posts p ON p.id = r.post_id \
WHERE r.asset_id = ANY($1::uuid[]) \
WHERE r.asset_id = ANY($1) \
ORDER BY p.id",
&[&ids],
)
.await
.map_err(AppError::query)?;
for rr in ref_rows {
refs_map
.entry(rr.get::<_, String>("asset_id"))
.or_default()
.push(AssetRef {
let asset_key: String = rr.get::<_, uuid::Uuid>("asset_id").to_string();
refs_map.entry(asset_key).or_default().push(AssetRef {
post_id: rr.get("post_id"),
title: rr.get("title"),
});
@ -152,7 +150,7 @@ pub async fn list_assets(
let assets = rows
.into_iter()
.map(|row| {
let id: String = row.get("id");
let id: String = row.get::<_, uuid::Uuid>("id").to_string();
let refs = refs_map.remove(&id).unwrap_or_default();
AssetDto {
asset: Asset {

View File

@ -109,10 +109,11 @@ pub async fn rebuild_assets_index() -> Result<RebuildAssetsResponse, ServerFnErr
let mut updated: i64 = 0;
for f in &scanned {
// DO UPDATE 的 WHERE 不满足时不返回行(技术字段无变化),用 query_opt 区分三种结果。
let asset_id = uuid::Uuid::new_v4();
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) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
ON CONFLICT (path) DO UPDATE SET \
filename = EXCLUDED.filename, \
mime = EXCLUDED.mime, \
@ -127,7 +128,7 @@ pub async fn rebuild_assets_index() -> Result<RebuildAssetsResponse, ServerFnErr
OR assets.filename IS DISTINCT FROM EXCLUDED.filename \
RETURNING (xmax = 0) AS was_inserted",
&[
&uuid::Uuid::new_v4().to_string(),
&asset_id,
&f.rel_path,
&f.filename,
&f.mime,

View File

@ -307,12 +307,14 @@ pub async fn upload_image(
let client = crate::db::pool::get_conn()
.await
.map_err(|e| e.to_string())?;
// id 用 Uuid 类型直连 uuid 列with-uuid-1 桥接),避免 String→uuid 序列化失败。
let asset_id = uuid::Uuid::new_v4();
client
.execute(
"INSERT INTO assets (id, path, filename, mime, size_bytes, width, height)\
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7)",
VALUES ($1, $2, $3, $4, $5, $6, $7)",
&[
&uuid::Uuid::new_v4().to_string(),
&asset_id,
&rel_path,
&original_filename.unwrap_or_else(|| file_name.clone()),
&final_mime,