diff --git a/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md b/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md index 405eff0b6..e124de920 100644 --- a/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md +++ b/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md @@ -74,7 +74,8 @@ Milestone Spec: `docs/project-memory/plans/【里程碑】Tripo生成Worker执 - 提交路由已补应用级验收用例:走真实 `build_router` 断言两个端点未鉴权 401、缺 `Idempotency-Key` 400、组合校验与定价必填字段冲突在定价与 provider 之前 400,因此路由 merge、鉴权中间件与校验顺序都有可执行证据,不依赖 provider 与 SpacetimeDB 环境。 - 默认配置里的 `model3d` 泥点定价数值仍未确认,缺段时提交按 fail closed 拒绝。 -- 没有跑过端到端真实调用(提交 → worker → OSS → 资源登记),现有证据只到 `cargo test -p api-server` 全绿(1089 通过)与应用级路由验收用例。 +- worker 的纯逻辑部分已补定向用例:checkpoint 只在 provider task id 非空时才算存在、完成结果按端点严格区分且不含 provider 事实、预览图尺寸必须能真实解码;全量 `cargo test -p api-server` 1092 通过。 +- 没有跑过端到端真实调用(提交 → worker → OSS → 资源登记),现有证据只到上述单元与应用级用例,缺少真实 provider 与 SpacetimeDB / OSS 的联调证据。 - 新增的 `tripo3d/` 文件里,`storage.rs` 的完整字节写入、`artifacts.rs` 的完整字节读取都留着流式 TODO。 ## 验证命令 diff --git a/server-rs/crates/api-server/src/tripo3d/image_source.rs b/server-rs/crates/api-server/src/tripo3d/image_source.rs index 0b8977025..69c23df52 100644 --- a/server-rs/crates/api-server/src/tripo3d/image_source.rs +++ b/server-rs/crates/api-server/src/tripo3d/image_source.rs @@ -117,8 +117,6 @@ fn image_source_kind_mismatch(requested_kind: &str, resolved_kind: &str) -> AppE #[cfg(test)] mod tests { - use super::*; - /// 预检必须是定点、只读元数据的;一旦退回“列工程 / 列素材库再筛”的老路, /// 或提前把图片正文读出来,这条断言会失败。 #[test] diff --git a/server-rs/crates/api-server/src/tripo3d/worker.rs b/server-rs/crates/api-server/src/tripo3d/worker.rs index a9b37ec44..7a23b0fdb 100644 --- a/server-rs/crates/api-server/src/tripo3d/worker.rs +++ b/server-rs/crates/api-server/src/tripo3d/worker.rs @@ -451,3 +451,148 @@ fn output_schema_error(message: String) -> AppError { "message": message, })) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tripo3d::job::Model3dJobKind; + use module_assets::{AssetObjectAccessPolicy, AssetObjectUpsertInput}; + + /// at-most-once submit 完全依赖这条判定:checkpoint 有值就只能续跑查询。 + /// 空白值必须等同于“没有 checkpoint”,否则一次空写入会把 job 永久锁死。 + #[test] + fn checkpoint_is_only_recognized_when_provider_task_id_is_present() { + let mut job = job_fixture(); + assert_eq!(existing_checkpoint(&job), None); + + job.provider_task_id = Some(" ".to_string()); + assert_eq!(existing_checkpoint(&job), None); + + job.provider_task_id = Some(" task-123 ".to_string()); + assert_eq!(existing_checkpoint(&job).as_deref(), Some("task-123")); + } + + /// 完成结果按端点严格区分,且只带正式对象元数据:provider task ID、SDK 类型与 + /// 带签名的临时地址都不允许出现在返回给调用方的结果里。 + #[test] + fn completed_result_is_strict_per_endpoint_and_free_of_provider_facts() { + let model = stored_artifact("editor/model3d/task-1/model.glb", "model/gltf-binary", 4096); + let preview = stored_artifact("editor/model3d/task-1/preview.webp", "image/webp", 2048); + + for (kind, expected_kind) in [ + (Model3dJobKind::TextToModel, "textToModel"), + (Model3dJobKind::ImageToModel, "imageToModel"), + ] { + let result = build_result( + kind, + Model3dGenerationTargetRef::ProjectResource { + resource_id: "resource-1".to_string(), + }, + &model, + &preview, + ); + let payload = job_result_payload(result); + let result = &payload["result"]; + + assert_eq!(result["kind"], json!(expected_kind)); + assert_eq!(result["target"]["kind"], json!("projectResource")); + assert_eq!(result["target"]["resourceId"], json!("resource-1")); + assert_eq!( + result["model"]["objectKey"], + json!("editor/model3d/task-1/model.glb") + ); + assert_eq!(result["model"]["contentLength"], json!(4096)); + assert_eq!(result["preview"]["contentLength"], json!(2048)); + + let serialized = payload.to_string(); + for forbidden in ["provider", "taskId", "providerTaskId", "https://"] { + assert!( + !serialized.contains(forbidden), + "完成结果不得携带 {forbidden}:{serialized}" + ); + } + } + } + + /// 预览图尺寸是资源行的宽高来源,读不出来就宁可失败退款,也不写占位尺寸。 + #[test] + fn preview_dimensions_require_a_real_decodable_image() { + let image = image::RgbaImage::from_pixel(3, 2, image::Rgba([10, 20, 30, 255])); + let mut bytes = Vec::new(); + image + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("测试用 PNG 应可编码"); + + assert_eq!( + preview_dimensions(bytes.as_slice()).expect("应读出尺寸"), + (3, 2) + ); + assert!(preview_dimensions(b"not an image").is_err()); + } + + fn stored_artifact( + object_key: &str, + content_type: &str, + content_length: u64, + ) -> StoredModel3dArtifact { + StoredModel3dArtifact { + object_key: object_key.to_string(), + content_type: content_type.to_string(), + content_length, + sha256: "0".repeat(64), + asset_object: AssetObjectUpsertInput { + asset_object_id: format!("asset-object-{object_key}"), + bucket: "genarrative-test".to_string(), + object_key: object_key.to_string(), + access_policy: AssetObjectAccessPolicy::Private, + content_type: Some(content_type.to_string()), + content_length, + content_hash: Some("0".repeat(64)), + version: 1, + source_job_id: Some("extgen-1".to_string()), + owner_user_id: Some("user-1".to_string()), + profile_id: None, + entity_id: Some("extgen-1".to_string()), + asset_kind: MODEL3D_ASSET_KIND.to_string(), + updated_at_micros: 1_780_444_800_000_000, + }, + } + } + + fn job_fixture() -> ExternalGenerationJobRecord { + ExternalGenerationJobRecord { + job_id: "extgen-1".to_string(), + dedupe_key: "model3d-generation:user-1:model3d_text_to_model:issue-1".to_string(), + job_kind: "model3d_text_to_model".to_string(), + owner_user_id: "user-1".to_string(), + source_module: "editor-canvas".to_string(), + source_entity_id: "project-1".to_string(), + request_label: "文生 3D 模型".to_string(), + request_payload_json: "{}".to_string(), + status: "running".to_string(), + attempt: 1, + max_attempts: 1, + last_error_message: None, + worker_id: Some("worker-a".to_string()), + lease_expires_at: Some("2026-09-21T00:00:00Z".to_string()), + available_at: "2026-09-21T00:00:00Z".to_string(), + result_payload_json: None, + created_at: "2026-09-21T00:00:00Z".to_string(), + started_at: Some("2026-09-21T00:00:00Z".to_string()), + completed_at: None, + updated_at: "2026-09-21T00:00:00Z".to_string(), + updated_at_micros: 1_780_444_800_000_000, + lease_token: Some("lease-1".to_string()), + price_mud_points: 20, + refund_ledger_id: None, + notification_acknowledged_at: None, + notification_acknowledged_at_micros: None, + phase: Some("generating".to_string()), + provider_kind: None, + provider_task_id: None, + } + } +}