diff --git a/server-rs/crates/api-server/src/tripo3d/worker.rs b/server-rs/crates/api-server/src/tripo3d/worker.rs index 1b280a8f4..1949eba01 100644 --- a/server-rs/crates/api-server/src/tripo3d/worker.rs +++ b/server-rs/crates/api-server/src/tripo3d/worker.rs @@ -80,7 +80,13 @@ async fn run_model3d_job( let client = tripo_provider_client(&state.config)?; let handle = match existing_checkpoint(job) { Some(task_id) => TripoTaskHandle { task_id }, - None => submit_and_checkpoint(state, caller, job, &client, &request).await?, + None => { + // submit 是不可逆且计费的调用:预算可能在别处就先耗光了(图生 3D 解析与上传 + // 大图、任务排在其他 job 之后),这时提交上去也只会立刻被 deadline 判失败, + // 白花一次 provider 调用。只有还轮询得起才提交。 + ensure_submit_budget(provider_deadline)?; + submit_and_checkpoint(state, caller, job, &client, &request).await? + } }; let snapshot = poll_until_terminal(&client, &handle, provider_deadline).await?; let model = read_artifact( @@ -134,6 +140,14 @@ fn existing_checkpoint(job: &ExternalGenerationJobRecord) -> Option { .map(ToOwned::to_owned) } +/// 提交前的预算检查:本次尝试已经没有轮询预算时,不发起不可逆且计费的 provider submit。 +fn ensure_submit_budget(provider_deadline: Instant) -> Result<(), AppError> { + if Instant::now() >= provider_deadline { + return Err(provider_deadline_error()); + } + Ok(()) +} + async fn submit_and_checkpoint( state: &AppState, caller: &EditorGenerationCaller, @@ -465,6 +479,18 @@ mod tests { use crate::tripo3d::job::Model3dJobKind; use module_assets::{AssetObjectAccessPolicy, AssetObjectUpsertInput}; + /// 预算已经耗尽时不能再提交:submit 不可逆且计费,提交上去也只会立刻被 deadline 判失败。 + #[test] + fn submit_is_rejected_once_the_polling_budget_is_gone() { + let expired = ensure_submit_budget(Instant::now()) + .expect_err("预算已耗尽的尝试不能再发起 provider submit"); + assert_eq!(expired.status_code(), StatusCode::BAD_GATEWAY); + assert!( + ensure_submit_budget(Instant::now() + Duration::from_secs(1)).is_ok(), + "还有预算时必须放行" + ); + } + /// at-most-once submit 完全依赖这条判定:checkpoint 有值就只能续跑查询。 /// 空白值必须等同于“没有 checkpoint”,否则一次空写入会把 job 永久锁死。 #[test]