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:
parent
8f75a64bfd
commit
fa40f4bd95
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -3445,6 +3445,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"fallible-iterator",
|
||||
"postgres-protocol",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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)?;
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user