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",
|
"chrono",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
"postgres-protocol",
|
"postgres-protocol",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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)?;
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user