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", "chrono",
"fallible-iterator", "fallible-iterator",
"postgres-protocol", "postgres-protocol",
"uuid",
] ]
[[package]] [[package]]

View File

@ -14,7 +14,7 @@ required-features = ["server"]
dioxus = { version = "0.7.9", features = ["fullstack", "router"] } dioxus = { version = "0.7.9", features = ["fullstack", "router"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "fs", "time", "sync"], optional = true } 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 } deadpool-postgres = { version = "0.14", optional = true }
argon2 = { version = "0.5", optional = true } argon2 = { version = "0.5", optional = true }
uuid = { version = "1", features = ["v4"], 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 _admin = get_current_admin_user().await?;
let client = get_conn().await.map_err(AppError::db_conn)?; 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 alt = alt.trim().to_string();
let updated = client let updated = client
.execute( .execute(
"UPDATE assets SET alt = NULLIF($2, ''), updated_at = NOW() WHERE id = $1::uuid", "UPDATE assets SET alt = NULLIF($2, ''), updated_at = NOW() WHERE id = $1",
&[&id, &alt], &[&asset_uuid, &alt],
) )
.await .await
.map_err(AppError::query)?; .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 _admin = get_current_admin_user().await?;
let client = get_conn().await.map_err(AppError::db_conn)?; 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 let row = client
.query_opt( .query_opt(
"SELECT id::text AS id, path, filename FROM assets WHERE id = $1::uuid", "SELECT id AS id, path, filename FROM assets WHERE id = $1",
&[&id], &[&asset_uuid],
) )
.await .await
.map_err(AppError::query)?; .map_err(AppError::query)?;
@ -71,8 +82,8 @@ pub async fn delete_asset(id: String) -> Result<AssetOpResponse, ServerFnError>
let ref_rows = client let ref_rows = client
.query( .query(
"SELECT p.id, p.title FROM asset_refs r JOIN posts p ON p.id = r.post_id \ "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", WHERE r.asset_id = $1 ORDER BY p.id",
&[&id], &[&asset_uuid],
) )
.await .await
.map_err(AppError::query)?; .map_err(AppError::query)?;
@ -99,7 +110,7 @@ pub async fn delete_asset(id: String) -> Result<AssetOpResponse, ServerFnError>
} }
} }
client client
.execute("DELETE FROM assets WHERE id = $1::uuid", &[&id]) .execute("DELETE FROM assets WHERE id = $1", &[&asset_uuid])
.await .await
.map_err(AppError::query)?; .map_err(AppError::query)?;
crate::api::image::invalidate_asset_caches(&path).await; crate::api::image::invalidate_asset_caches(&path).await;
@ -127,7 +138,7 @@ pub async fn purge_orphan_assets() -> Result<PurgeOrphansResponse, ServerFnError
let rows = client let rows = client
.query( .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) \ WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \
AND a.created_at < NOW() - make_interval(days => $1)", AND a.created_at < NOW() - make_interval(days => $1)",
&[&super::list::PURGE_GRACE_DAYS], &[&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 freed_bytes: i64 = 0;
let mut failures: i64 = 0; let mut failures: i64 = 0;
for row in &rows { for row in &rows {
let id: String = row.get("id"); let id: uuid::Uuid = row.get("id");
let path: String = row.get("path"); let path: String = row.get("path");
freed_bytes += row.get::<_, i64>("size_bytes"); freed_bytes += row.get::<_, i64>("size_bytes");
let file_path = format!("uploads/{}", path); let file_path = format!("uploads/{}", path);
@ -166,10 +177,7 @@ pub async fn purge_orphan_assets() -> Result<PurgeOrphansResponse, ServerFnError
} }
let deleted = client let deleted = client
.execute( .execute("DELETE FROM assets WHERE id = ANY($1)", &[&ids])
"DELETE FROM assets WHERE id = ANY($1::uuid[])",
&[&ids],
)
.await .await
.map_err(AppError::query)?; .map_err(AppError::query)?;

View File

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

View File

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

View File

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