补Tripo worker纯逻辑定向用例

- checkpoint 只在 provider task id 非空且去空白后有效时才算存在,空白等同未提交

- 完成结果按端点严格区分,且序列化后不含 provider、taskId 与带签名的临时地址

- 预览图尺寸必须能真实解码,损坏字节直接失败而不是写占位宽高

- 预检用例去掉多余的通配导入,实施计划进展同步当前通过数
This commit is contained in:
2026-09-21 15:22:01 +08:00
parent 25ee11771c
commit c0fcb8553d
3 changed files with 147 additions and 3 deletions
@@ -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。
## 验证命令
@@ -117,8 +117,6 @@ fn image_source_kind_mismatch(requested_kind: &str, resolved_kind: &str) -> AppE
#[cfg(test)]
mod tests {
use super::*;
/// 预检必须是定点、只读元数据的;一旦退回“列工程 / 列素材库再筛”的老路,
/// 或提前把图片正文读出来,这条断言会失败。
#[test]
@@ -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,
}
}
}