From 8af02daeeab28ccb2f05fb12ea574c2c527a2110 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:43:39 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E4=BA=A7=E7=89=A9=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=B8=A6=E4=B8=8A=E9=80=80=E9=81=BF=E9=87=8D=E8=AF=95=EF=BC=8C?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E7=9E=AC=E6=97=B6=E7=BD=91=E7=BB=9C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B8=8D=E5=86=8D=E5=BA=9F=E6=8E=89=E6=95=B4=E7=AC=94?= =?UTF-8?q?=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - platform-oss:新增按前缀拼对象键的 `put_object_with_transient_retry`,与内部对象 PUT 共用同一套「可判定才重试」规则与退避(传输 / 超时 / 408 / 429 / 5xx 才重试,确定性 4xx 直接失败),body 只准备一次、每次 attempt 复用引用计数字节;重试次数或退避配置不足时按配置错误失败关闭 - api-server storage:3D 产物的模型 / 预览图 PUT 改用该入口,最多 3 次尝试、退避 250 / 500 ms(与角色动画同一口径);模型上限 512 MiB 但实际产物远小,重试成本可控 - 新增用例:重试次数为 0 与退避不足都失败关闭;对一定解析不出的域名会真的按退避重试多次 - docs/technical:写明产物 PUT 的重试口径与「每个网络动作都要能重试」的理由 --- ...技术方案】Tripo 3D生成API集成-2026-09-21.md | 2 + .../crates/api-server/src/tripo3d/storage.rs | 9 +- server-rs/crates/platform-oss/src/lib.rs | 119 ++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md index 951576b0d..920039352 100644 --- a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md +++ b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md @@ -164,6 +164,8 @@ 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 最多只尝试一次,一次瞬时网络失败就会废掉一笔已经计费的生成,所以这条链路上的每个网络动作都要能重试。 + `external_generation_job.phase` 的取值集合不变,仍只允许现有两种执行阶段;阶段文案由 api-server 映射,不扩展 schema 常量。 ## assetKind 接入清单对照 diff --git a/server-rs/crates/api-server/src/tripo3d/storage.rs b/server-rs/crates/api-server/src/tripo3d/storage.rs index eb4d9c823..8040ae9d8 100644 --- a/server-rs/crates/api-server/src/tripo3d/storage.rs +++ b/server-rs/crates/api-server/src/tripo3d/storage.rs @@ -36,6 +36,11 @@ pub(crate) const MODEL3D_ASSET_KIND: &str = crate::editor_project::EDITOR_MODEL3 /// `asset_object.entity_kind`:说明这个对象属于 3D 生成产物。 const MODEL3D_OBJECT_ENTITY_KIND: &str = "model3d"; +/// 产物 PUT 的重试预算:模型最大 512 MiB,一次瞬时网络失败就会废掉一笔已经计费的生成, +/// 而任务本身最多只尝试一次,所以这里按与角色动画一致的口径退避重试三次。 +const MODEL3D_ARTIFACT_PUT_MAX_ATTEMPTS: usize = 3; +const MODEL3D_ARTIFACT_PUT_RETRY_DELAYS_MS: [u64; 2] = [250, 500]; + const MODEL3D_OBJECT_PATH_SEGMENT: &str = "model3d"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -131,7 +136,7 @@ pub(crate) async fn store_model3d_artifact( .ok_or_else(|| unsupported_artifact_content_type(content_type.as_str(), slot))?; let file_name = format!("{}.{}", slot.file_stem(), extension); let put_result = oss_client - .put_object( + .put_object_with_transient_retry( http_client, OssPutObjectRequest { prefix: LegacyAssetPrefix::CharacterDrafts, @@ -156,6 +161,8 @@ pub(crate) async fn store_model3d_artifact( ]), body: bytes, }, + MODEL3D_ARTIFACT_PUT_MAX_ATTEMPTS, + &MODEL3D_ARTIFACT_PUT_RETRY_DELAYS_MS, ) .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 4cce2d31a..4f99073ae 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -323,6 +323,7 @@ struct PreparedHeadObject { target_url: reqwest::Url, } +#[derive(Clone)] struct PreparedPutObject { object_key: String, target_url: reqwest::Url, @@ -1401,6 +1402,46 @@ impl OssClient { result } + /// 派生对象(按前缀拼对象键)的受控重试 PUT。 + /// + /// 与 `put_internal_object_with_retry` 用同一套「可判定才重试」规则与退避:传输 / 超时 / + /// 408 / 429 / 5xx 才重试,确定性 4xx 直接失败。body 只准备一次(引用计数字节),每次 + /// attempt 复用同一份,不做重复编码。 + /// + /// `max_attempts` 至少为 1,`retry_delays_ms` 至少提供 `max_attempts - 1` 个退避值; + /// 参数不满足时按配置错误失败关闭,不静默降级成单次请求。 + pub async fn put_object_with_transient_retry( + &self, + client: &reqwest::Client, + request: OssPutObjectRequest, + max_attempts: usize, + retry_delays_ms: &[u64], + ) -> Result { + if max_attempts == 0 { + return Err(OssError::InvalidConfig( + "派生对象 PUT 重试次数至少为 1".to_string(), + )); + } + if retry_delays_ms.len() < max_attempts.saturating_sub(1) { + return Err(OssError::InvalidConfig( + "派生对象 PUT 重试缺少退避配置".to_string(), + )); + } + let prepared = self.prepare_put_object(request)?; + let object_key = prepared.object_key.clone(); + run_internal_put_with_retry(max_attempts, retry_delays_ms, &object_key, move || { + // 每次 attempt 复制一份准备结果(body 是引用计数的字节,不复制内容), + // 让 future 拥有它自己的那份,避免把借用带出闭包。 + let prepared = prepared.clone(); + async move { + self.put_object_once(client, &prepared) + .await + .map(|(response, _status)| response) + } + }) + .await + } + /// 角色动画帧专用的可重试 PUT。调用方传入进程级并发限制器,单次网络 attempt /// 独占一个 permit,退避等待期间不会占用 permit。 pub async fn put_object_with_retry( @@ -3473,6 +3514,84 @@ mod tests { assert_eq!(attempts.load(Ordering::SeqCst), 2); } + #[tokio::test] + async fn derived_put_retry_fails_closed_on_bad_retry_config() { + let client = build_client(); + let http = reqwest::Client::new(); + let request = OssPutObjectRequest { + prefix: LegacyAssetPrefix::CharacterDrafts, + path_segments: vec!["editor".to_string(), "model3d".to_string()], + file_name: "model.glb".to_string(), + content_type: Some("model/gltf-binary".to_string()), + access: OssObjectAccess::Private, + metadata: BTreeMap::new(), + body: MOCK_PUT_BODY.to_vec(), + }; + + // 次数为 0 或退避不够都必须按配置错误失败,不能静默降级成单次请求。 + let error = client + .put_object_with_transient_retry(&http, request.clone(), 0, &[]) + .await + .expect_err("重试次数为 0 必须失败关闭"); + assert!(matches!(error, OssError::InvalidConfig(_)), "{error:?}"); + + let error = client + .put_object_with_transient_retry(&http, request, 3, &[1]) + .await + .expect_err("退避配置不足必须失败关闭"); + assert!(matches!(error, OssError::InvalidConfig(_)), "{error:?}"); + } + + /// 端点的域名一定解析不出来(`.invalid` 是 RFC 2606 保留给「一定不存在」的顶级域, + /// 前半段保留 `oss-` 形状以便通过 region 解析),于是每次 attempt 都是可重试的 + /// 连接失败,而且不会真的打到任何服务端。 + fn build_unreachable_client() -> OssClient { + OssClient::new( + OssConfig::new( + "genarrative-assets".to_string(), + "oss-cn-shanghai.invalid".to_string(), + "test-access-key-id".to_string(), + "test-access-key-secret".to_string(), + DEFAULT_READ_EXPIRE_SECONDS, + DEFAULT_POST_EXPIRE_SECONDS, + DEFAULT_POST_MAX_SIZE_BYTES, + DEFAULT_SUCCESS_ACTION_STATUS, + ) + .expect("OSS config should be valid"), + ) + } + + #[tokio::test] + async fn derived_put_retry_repeats_transient_transport_failures() { + let client = build_unreachable_client(); + let http = reqwest::Client::new(); + let started_at = Instant::now(); + let error = client + .put_object_with_transient_retry( + &http, + OssPutObjectRequest { + prefix: LegacyAssetPrefix::CharacterDrafts, + path_segments: vec!["editor".to_string(), "model3d".to_string()], + file_name: "model.glb".to_string(), + content_type: Some("model/gltf-binary".to_string()), + access: OssObjectAccess::Private, + metadata: BTreeMap::new(), + body: MOCK_PUT_BODY.to_vec(), + }, + 3, + &[50, 50], + ) + .await + .expect_err("连不上的域名必须失败"); + assert!(oss_error_is_retryable(&error), "{error:?}"); + // 三次尝试之间的两段退避必须真的等过:只打一次请求不会花这么久。 + assert!( + started_at.elapsed() >= std::time::Duration::from_millis(100), + "退避没有生效:{:?}", + started_at.elapsed() + ); + } + #[test] fn structured_log_labels_are_stable() { assert_eq!(