3D 提交侧归一落点并删掉 Standard 顶层 result 透传

- 提交侧新增归一并写回请求:项目 ID 只 trim,素材夹 ID 走其它工具同一套 normalize_generated_asset_folder_id(project / 旧 folder-* 收敛到 owner 默认素材夹),素材名走 resolve_editor_generated_asset_label(trim、截断到上限、缺省「3D 模型」),保证预检值 = 队列载荷 = 落库值。
- 队列行 sourceEntityId 改用共用 editor_generation_source_entity_id(项目 ID,缺项目回落 job kind),不再拿素材夹 ID 顶替。
- 按你的决定删掉 Standard 消费者顶层 result 逐字透传:标准消费者只回来源身份,3D 结果与图片等画布任务一样靠画布 placement / 素材行读回,连带删掉「3D 生成没有画布读回路径」这条错注释。
- 技术方案与共享记忆同步落点归一、身份口径与结果读取路径;job.rs 补归一用例(含素材名截断)。
- 验证:cargo test -p api-server(1193 passed)、cargo test -p api-server tripo3d::(55 passed)、npx vitest run src/components/image-editor/model3d-generation src/components/image-editor/model3d-preview(63 passed)、check:rustfmt / check:encoding / check-doc-index / git diff --check 通过。
This commit is contained in:
2026-09-23 14:36:50 +08:00
parent 21b9d850c7
commit 9edb4e5f80
6 changed files with 177 additions and 33 deletions
@@ -9387,9 +9387,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-09-23 3D 提交落点改为平坦可选字段:`target` tagged enum 作废,服务端按二选一校验
- 背景:3D 生成最初把结果落点设计成请求里的 tagged enum(`target = { kind: "projectResource", … } | { kind: "assetLibrary", … }`),理由是“落项目还是落素材库”是判别分支。落地后发现,其它所有生成接口(图片、音乐、图标、音效等)的落点都是平坦的可选字段 `projectId` / `canvasCompletion` / `assetFolderId` / `assetLabel`,只有 3D 多一层判别结构:同一件事在仓库里有两套形状,前端要为 3D 单独拼一次判别对象,服务端也要单独维护一套 tagged 校验。
- 决策:撤销 tagged enum,请求契约改为与其它生成接口同形的平坦字段 `projectId?` / `canvasCompletion?` / `assetFolderId?` / `assetLabel?`;两个端点(text-to-model、image-to-model)完全同形。服务端在 `tripo3d/validation.rs` 按「`projectId` 与 `assetFolderId` 二选一」校验:都不给报 `projectId` 缺失、都给报 `assetFolderId` 冲突、`canvasCompletion` 与 `assetFolderId` 同现报冲突,字段名直接进错误响应,便于前端定位。结果侧的 `Model3dGenerationResult` 与 `Model3dGenerationTargetRef` 仍是按端点的 tagged enum,本次不动。同时把落点预检换成其它付费编辑器生成共用的 `preflight_editor_generation_target_and_return`(原先素材库落点只能读整库再匹配),`assetLabel` 缺省落 `MODEL3D_DEFAULT_ASSET_LABEL`。
- 决策:撤销 tagged enum,请求契约改为与其它生成接口同形的平坦字段 `projectId?` / `canvasCompletion?` / `assetFolderId?` / `assetLabel?`;两个端点(text-to-model、image-to-model)完全同形。服务端在 `tripo3d/validation.rs` 按「`projectId` 与 `assetFolderId` 二选一」校验:都不给报 `projectId` 缺失、都给报 `assetFolderId` 冲突、`canvasCompletion` 与 `assetFolderId` 同现报冲突,字段名直接进错误响应,便于前端定位。结果侧的 `Model3dGenerationResult` 与 `Model3dGenerationTargetRef` 仍是按端点的 tagged enum,本次不动。同时把落点预检换成其它付费编辑器生成共用的 `preflight_editor_generation_target_and_return`(原先素材库落点只能读整库再匹配)。提交侧新增一步「归一落点并写回请求」:项目 ID trim、素材夹 ID 走 `normalize_generated_asset_folder_id`(`project` / 旧 `folder-*` → 当前 owner 默认素材夹)、素材名走 `resolve_editor_generated_asset_label`(trim + 截断 + 缺省 `MODEL3D_DEFAULT_ASSET_LABEL`),入队的就是归一后的请求;队列行的 `sourceEntityId` 也改用共用 `editor_generation_source_entity_id`(项目 ID,缺项目回落 job kind),不再拿素材夹 ID 顶替。另删掉 3D 专用的「Standard 消费者顶层 `result` 逐字透传」:标准消费者只回来源身份,3D 结果靠画布 placement / 素材行读回,与图片等画布任务一致。
- 原因:**同一语义只保留一套形状**——判别结构并没有带来额外表达力(“二选一”在两侧都能表达),却让客户端多一层拼装、服务端多一套类型与校验,也让 3D 与其它生成接口的请求体不能共用同一套前端提交路径与错误提示;扁平字段还能天然复用既有的 `preflight_editor_billable_generation_target` / 归属预检口径。
- 代价与取舍:`deny_unknown_fields` 在请求顶层继续生效,旧的 `target` 字段会被当成未知字段直接拒绝,因此这是一次**破坏性契约变更**(本分支未合并,无外部调用方,不需要兼容层);「二选一」从此是运行期校验而不是类型系统保证,缺两个或多个同时给要在服务端显式报错(已有定向用例钉住)。
- 影响面:`server-rs/crates/shared-contracts/src/model3d/{text_to_model/request.rs,image_to_model/request.rs,common/mod.rs}`(删除 `common/generation_target.rs`)、`server-rs/crates/api-server/src/tripo3d/{job.rs,validation.rs,target.rs,routes.rs,worker.rs}`、`packages/shared/src/contracts/model3d/`(删除 `common/Model3dGenerationTarget.ts` 与 barrel 导出)、`src/components/image-editor/model3d-generation/{Model3dGenerationSubmission.ts,useModel3dGenerationTask.test.tsx}`、`src/services/image-editor/editorProjectClient.ts`、技术方案 / 里程碑 / 实施计划与共享记忆。
- 验证方式:`cargo test -p shared-contracts`、`cargo test -p api-server tripo3d::`(54 passed,含 `target_requires_exactly_one_flat_locator` 与 `flat_locator_maps_to_point_lookup_without_rewriting_ids`)、`npx vitest run src/components/image-editor/model3d-generation src/components/image-editor/model3d-preview`(63 passed)、`npm run typecheck`、`npm run contracts:model3d:generate`、`npm run check:encoding`、`npm run check:rustfmt`、`git diff --check`。
- 代价与取舍:`deny_unknown_fields` 在请求顶层继续生效,旧的 `target` 字段会被当成未知字段直接拒绝,因此这是一次**破坏性契约变更**(本分支未合并,无外部调用方,不需要兼容层);「二选一」从此是运行期校验而不是类型系统保证,缺两个或多个同时给要在服务端显式报错(已有定向用例钉住)。删掉 `result` 透传后,`GET /api/runtime/external-generation/jobs/{jobId}` 对 3D 不再返回 `result`(此前也没有任何消费方读它):若将来真需要外部读取 3D 结果,应像图片那样单开专用接口或按契约白名单收口,而不是恢复「调用方给了就照抄」。归一化改的是队列载荷(同一份客户端请求始终算出同一份归一载荷),幂等比较仍然一致;「恰好一个落点」与其它工具允许同时落两处仍不同,这是此前明确放弃的组合能力。「二选一」不变。
- 影响面:`server-rs/crates/shared-contracts/src/model3d/{text_to_model/request.rs,image_to_model/request.rs,common/mod.rs}`(删除 `common/generation_target.rs`)、`server-rs/crates/api-server/src/tripo3d/{job.rs,validation.rs,target.rs,routes.rs,worker.rs}`、`server-rs/crates/api-server/src/editor_project.rs`(删除 Standard 的 `result` 透传)、`packages/shared/src/contracts/model3d/`(删除 `common/Model3dGenerationTarget.ts` 与 barrel 导出)、`src/components/image-editor/model3d-generation/{Model3dGenerationSubmission.ts,useModel3dGenerationTask.test.tsx}`、`src/services/image-editor/editorProjectClient.ts`、技术方案 / 里程碑 / 实施计划与共享记忆。
- 验证方式:`cargo test -p shared-contracts`、`cargo test -p api-server`(1193 passed)、`cargo test -p api-server tripo3d::`(55 passed,含 `target_requires_exactly_one_flat_locator`、`flat_locator_maps_to_point_lookup_without_rewriting_ids` 与 `flat_target_is_normalized_before_enqueue`)、`npx vitest run src/components/image-editor/model3d-generation src/components/image-editor/model3d-preview`(63 passed)、`npm run typecheck`、`npm run contracts:model3d:generate`、`npm run check:encoding`、`npm run check:rustfmt`、`git diff --check`。
- 关联文档:[技术方案 Tripo 3D生成API集成](../../technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[实施计划 Tripo生成API契约与数据模型](../plans/【实施计划】Tripo生成API契约与数据模型-2026-09-21.md)。
@@ -23,7 +23,7 @@
| --- | --- | --- | --- |
| `/api/assets/tripo/text-to-model` | POST | Bearer | 校验、定价、入队,返回 operation |
| `/api/assets/tripo/image-to-model` | POST | Bearer | 校验、输入解析、定价、入队 |
| `/api/runtime/external-generation/jobs/{jobId}` | GET | Bearer | 复用现有任务查询,返回 operation 与严格结果 |
| `/api/runtime/external-generation/jobs/{jobId}` | GET | Bearer | 复用现有任务查询,返回 operation 与阶段 / 终态(结果由画布 / 素材读回) |
分层职责固定为:
@@ -92,7 +92,7 @@ ImageToModelResult = { modelArtifact, renderedPreview }
每个 artifact 只携带正式资源引用与对象元数据,不携带 provider 临时 URL。
**完成结果的读取**:3D 生成没有画布读回路径,完成结果由既有任务查询 `/api/runtime/external-generation/jobs/{jobId}` 的 `result` 字段返回,形状就是上面的严格 tagged enum。标准消费者此前只回来源身份,现在仅在调用方显式给出 `result` 时透传,其它标准任务行为不变。
**完成结果的读取**:与其它画布生成任务同形——结果落在画布 / 素材行,客户端靠读回拿到。项目资源落点的 worker 写画布 placement(预览图层),客户端在任务终态后用 `loadEditorProject` 复读项目快照,按 `layer.objectKey` 打开模型;素材库落点写素材行。标准消费者的队列结果只回来源身份,**不透传 `result`**,因此 `/api/runtime/external-generation/jobs/{jobId}` 的 `result` 对 3D 为空——图片等其它画布生成任务也是如此。上面那份严格 tagged enum 是 worker 落库时构造的内部结果值:它只携带正式资源引用与对象元数据,不携带 provider 事实。
## 定价与扣费
@@ -196,7 +196,7 @@ width/height = 预览图像素尺寸
6. worker 崩溃后重新 claim 不会产生第二次 submit:已有 checkpoint 的 job 只轮询、下载与落库。
7. submit 成功但 checkpoint 写入失败的 attempt 终态失败且不自动重提。
8. provider 成功但落库失败时 job 失败、该 attempt 退款,checkpoint 保留供对账。
9. 完成结果按端点严格类型返回,只含模型与预览的正式资源引用,不含 provider task ID 与带签名的临时 URL。
9. worker 落库时构造的完成结果按端点严格类型化,只含模型与预览的正式资源引用,不含 provider task ID 与带签名的临时 URL;标准消费者的队列结果不透传它(与其它画布生成任务一致,结果靠画布 / 素材读回)。
10. 素材库落点产出 `assetId`;项目资源落点产出 `resourceId`,且 `assetKind=model3d`、`AssetObject.content_type` 为按字节识别出的模型 mime、`content_length` 与实际对象一致。
11. 客户端媒体投影对 `model3d` 只暴露预览图:`imageSrc` / `thumbnailSrc` 指向预览对象、`objectKey` 指向模型本体;预览缺失时才回落到模型对象,且该回落不得让图片入口加载模型文件。
12. 格式判定的顺序在查看器包内可被用例钉住:声明 `Content-Type` 优先于字节魔数、字节魔数优先于地址扩展名;三者都判不出来时模态展示 `unsupported-format` 原因与支持的格式清单,画布侧不做任何格式判断、也不在 `assetKind` 为空时按扩展名兜底。