From 2aa59328de349cfbebfe921bfb7da2110462ffe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 20:46:42 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E4=BA=A7=E7=89=A9=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=97=B6=E5=88=A0=E6=8E=89=E5=B7=B2=E7=BB=8F?= =?UTF-8?q?=E4=BC=A0=E4=B8=8A=E5=8E=BB=E7=9A=84=E5=AF=B9=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storage:新增 `discard_model3d_artifact`,按对象键删除刚写入的产物;OSS 未配置或删除失败都只记警告并带上对象键,方便人工清理 - worker:预览图上传失败时删掉已传的模型对象;登记失败时把模型与预览图两个对象都删掉;`persist_result` 改成接收引用,好让调用方在失败后还能按对象键清理 - docs/technical:写明「上传成功但后续失败就删掉对象,不接受失败就烂在 bucket 里」 --- ...技术方案】Tripo 3D生成API集成-2026-09-21.md | 2 +- .../crates/api-server/src/tripo3d/storage.rs | 37 ++++++++++++++++- .../crates/api-server/src/tripo3d/worker.rs | 41 +++++++++++++------ 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md index 920039352..be02d46bb 100644 --- a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md +++ b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md @@ -164,7 +164,7 @@ width/height = 预览图像素尺寸 模型格式与体积由 `AssetObject` 承担(`content_type`、`content_length`、`content_hash`),因此不新增 `model_format`、`size_bytes`、`poly_count` 等列。两个槽位的 `content_type` 都在写入前按字节识别真实产物:模型按魔数认 GLB 的 `glTF` 头、FBX 的 `Kaydara FBX Binary` 头与 glTF 的 JSON 正文,预览按魔数认 PNG / JPEG / WebP;不直接采信 provider 返回或下载响应头声明的类型,对象键扩展名由该内容类型派生。只有字节判不出来时才采信声明值,且声明值必须落在该槽位的白名单内(模型:`model/gltf-binary`、`model/gltf+json`、`model/fbx`、`application/x-fbx`;预览:`image/png`、`image/jpeg`、`image/jpg`、`image/webp`);两边都对不上就直接按上游内容不合法失败并退款,不做任何兜底 —— 放行 `text/plain` 这类未知类型只会落成「错的 content type + 拼出来的扩展名」。客户端不复制这份格式清单:画布只按「`assetKind=model3d` 且 `objectKey` 非空」放行,能否交给 3D 查看器由 `packages/model3d-viewer` 判定,判据依次是读接口声明的 `Content-Type`、字节魔数、地址扩展名,全判不出来即按 `unsupported-format` 报错。这里的顺序是「信任后端检测结果」:`content_type` 在后端写入前已经按字节核过,所以客户端以声明为先;字节魔数只兜住声明缺失或认不出来(例如 `application/octet-stream`)的情况,不负责纠正一个「认得出来但不对」的声明。 -产物对象由 api-server 按固定前缀 PUT 写入、再 HEAD 复核后才登记;PUT 带退避重试(最多 3 次尝试,退避 250 / 500 ms),只对传输 / 超时 / 408 / 429 / 5xx 重试,确定性 4xx 直接失败。3D job 最多只尝试一次,一次瞬时网络失败就会废掉一笔已经计费的生成,所以这条链路上的每个网络动作都要能重试。 +产物对象由 api-server 按固定前缀 PUT 写入、再 HEAD 复核后才登记;PUT 带退避重试(最多 3 次尝试,退避 250 / 500 ms),只对传输 / 超时 / 408 / 429 / 5xx 重试,确定性 4xx 直接失败。3D job 最多只尝试一次,一次瞬时网络失败就会废掉一笔已经计费的生成,所以这条链路上的每个网络动作都要能重试。反过来,产物一旦上传成功、后续预览图上传或登记失败,就把已经传上去的对象删掉(删除失败只记警告,残留对象按日志人工清理):不接受“失败就烂在 bucket 里”。 `external_generation_job.phase` 的取值集合不变,仍只允许现有两种执行阶段;阶段文案由 api-server 映射,不扩展 schema 常量。 diff --git a/server-rs/crates/api-server/src/tripo3d/storage.rs b/server-rs/crates/api-server/src/tripo3d/storage.rs index 8040ae9d8..126143bb9 100644 --- a/server-rs/crates/api-server/src/tripo3d/storage.rs +++ b/server-rs/crates/api-server/src/tripo3d/storage.rs @@ -13,10 +13,14 @@ use axum::http::StatusCode; use module_assets::{ AssetObjectAccessPolicy, AssetObjectUpsertInput, build_asset_object_upsert_input, }; -use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; +use platform_oss::{ + LegacyAssetPrefix, OssDeleteObjectRequest, OssHeadObjectRequest, OssObjectAccess, + OssPutObjectRequest, +}; use serde_json::json; use sha2::{Digest, Sha256}; use shared_contracts::editor_generation::editor_generation_stable_asset_object_id; +use tracing::warn; use crate::{ editor_project::{ @@ -214,6 +218,37 @@ pub(crate) async fn store_model3d_artifact( }) } +/// 丢弃一个已经写进 OSS、但最终没能登记成正式资源的产物对象。 +/// +/// 上传成功、登记失败时如果不清理,bucket 里会一直堆着按 job 命名的私有残留对象。 +/// 删除失败只记警告:这时 job 已经注定失败,删除不成功不该改写对外错误,但残留对象 +/// 需要人工按日志清理。 +pub(crate) async fn discard_model3d_artifact(state: &AppState, artifact: &StoredModel3dArtifact) { + let object_key = artifact.object_key.clone(); + let Some(oss_client) = state.oss_client() else { + warn!( + object_key = %object_key, + "3D 产物登记失败后要删除对象,但 OSS 未配置,需要人工清理" + ); + return; + }; + if let Err(error) = oss_client + .delete_object( + state.editor_oss_http_client(), + OssDeleteObjectRequest { + object_key: object_key.clone(), + }, + ) + .await + { + warn!( + object_key = %object_key, + error = %error, + "3D 产物登记失败后删除对象也失败,需要人工清理" + ); + } +} + /// provider 声明的 content type 去参数、去空白、转小写后与槽位白名单比对; /// 不在表里(含空值、`application/octet-stream`)返回 `None`。 fn declared_artifact_content_type(raw: &str, slot: Model3dArtifactSlot) -> Option<&'static str> { diff --git a/server-rs/crates/api-server/src/tripo3d/worker.rs b/server-rs/crates/api-server/src/tripo3d/worker.rs index 081f5129b..f70e9d122 100644 --- a/server-rs/crates/api-server/src/tripo3d/worker.rs +++ b/server-rs/crates/api-server/src/tripo3d/worker.rs @@ -44,7 +44,8 @@ use super::{ provider::{TRIPO_PROVIDER, tripo_provider_client}, result::{build_result, job_result_payload}, storage::{ - MODEL3D_ASSET_KIND, Model3dArtifactSlot, StoredModel3dArtifact, store_model3d_artifact, + MODEL3D_ASSET_KIND, Model3dArtifactSlot, StoredModel3dArtifact, discard_model3d_artifact, + store_model3d_artifact, }, }; @@ -119,25 +120,40 @@ async fn run_model3d_job( model.content_type.as_str(), ) .await?; - let stored_preview = store_model3d_artifact( + let stored_preview = match store_model3d_artifact( state, caller, Model3dArtifactSlot::Preview, preview.bytes, preview.content_type.as_str(), ) - .await?; - persist_result( + .await + { + Ok(stored) => stored, + Err(error) => { + discard_model3d_artifact(state, &stored_model).await; + return Err(error); + } + }; + // 产物已经进了 OSS,但还没登记成正式资源:这一步失败就把刚传上去的对象删掉, + // 不让按 job 命名的私有残留对象一直堆在 bucket 里。 + if let Err(error) = persist_result( state, caller, job, &request, - stored_model, - stored_preview, + &stored_model, + &stored_preview, preview_width, preview_height, ) .await + { + discard_model3d_artifact(state, &stored_model).await; + discard_model3d_artifact(state, &stored_preview).await; + return Err(error); + } + Ok(()) } /// checkpoint 是 provider 侧任务的唯一凭据,也是 at-most-once 的开关:有值就只能查询, @@ -260,8 +276,9 @@ async fn persist_result( caller: &EditorGenerationCaller, job: &ExternalGenerationJobRecord, request: &Model3dJobRequest, - model: StoredModel3dArtifact, - preview: StoredModel3dArtifact, + // 取引用而不是拿走所有权:登记失败时调用方还要按对象键把刚传上去的产物删掉。 + model: &StoredModel3dArtifact, + preview: &StoredModel3dArtifact, preview_width: u32, preview_height: u32, ) -> Result<(), AppError> { @@ -382,22 +399,22 @@ async fn persist_result( has_resource.then(|| resource_id.clone()), has_asset.then_some(asset_id_for_result), ), - &model, - &preview, + model, + preview, ); // 预览图单独登记成 asset_object:它没有自己的资源行,但必须属于当前账号, // 否则后续按 objectKey 解析归属时会认为它未登记。 let items = vec![ EditorGenerationResultPersistItemRecordInput { slot: MODEL3D_MODEL_SLOT.to_string(), - asset_object: Some(model.asset_object), + asset_object: Some(model.asset_object.clone()), project_resource, asset, binding: None, }, EditorGenerationResultPersistItemRecordInput { slot: MODEL3D_PREVIEW_SLOT.to_string(), - asset_object: Some(preview.asset_object), + asset_object: Some(preview.asset_object.clone()), project_resource: None, asset: None, binding: None,