From 9edb4e5f80ab273106980d88a62d5030fdfb7f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 14:36:50 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E6=8F=90=E4=BA=A4=E4=BE=A7=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E8=90=BD=E7=82=B9=E5=B9=B6=E5=88=A0=E6=8E=89=20Standa?= =?UTF-8?q?rd=20=E9=A1=B6=E5=B1=82=20result=20=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提交侧新增归一并写回请求:项目 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 通过。 --- .../shared-memory/decision-log.md | 8 +- ...技术方案】Tripo 3D生成API集成-2026-09-21.md | 6 +- .../crates/api-server/src/editor_project.rs | 14 +- .../crates/api-server/src/tripo3d/job.rs | 144 ++++++++++++++++++ .../crates/api-server/src/tripo3d/routes.rs | 36 +++-- .../crates/api-server/src/tripo3d/worker.rs | 2 +- 6 files changed, 177 insertions(+), 33 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9cbfb3315..da616c731 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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)。 diff --git a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md index d5715160c..ab4a838d7 100644 --- a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md +++ b/docs/technical/【技术方案】Tripo 3D生成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` 为空时按扩展名兜底。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index ef0723f7f..13d41a06b 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1244,15 +1244,11 @@ fn serialize_atomic_editor_generation_job_result( compact_external_api_generation_result(response.clone()), ); } - // 中文注释:标准消费者默认只回来源身份——编辑器结果由画布读回,不靠任务查询。 - // 3D 生成没有画布读回路径,完成结果只能从任务查询拿到,因此调用方显式给出 - // `result` 时逐字透传;其它标准任务不写这个键,行为不变。 - if queue_result_context.consumer == EditorGenerationQueueConsumer::Standard - && let Some(result) = response.get("result").cloned() - && let Some(object) = payload.as_object_mut() - { - object.insert("result".to_string(), result); - } + // 中文注释:标准消费者只回来源身份——编辑器结果由画布读回(项目快照 / 素材行), + // 不靠任务查询。3D 与其它标准任务同一条口径:worker 写画布 placement 或素材行, + // 客户端读回快照拿到结果,因此这里不给任何标准任务透传 result。 + // 只按「调用方给了就照抄」放行等于把脱敏交给每个 worker 自觉,provider 原始数据 + // 一旦被写进 result 就会原样出现在任务查询里。 if let Some(warning) = extract_editor_generation_warning(response) && let Some(object) = payload.as_object_mut() { diff --git a/server-rs/crates/api-server/src/tripo3d/job.rs b/server-rs/crates/api-server/src/tripo3d/job.rs index 4a112eb31..f11582137 100644 --- a/server-rs/crates/api-server/src/tripo3d/job.rs +++ b/server-rs/crates/api-server/src/tripo3d/job.rs @@ -8,6 +8,12 @@ use shared_contracts::editor_canvas::EditorCanvasGenerationCompletionPayload as use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest; use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest; +use crate::editor_project::{ + normalize_generated_asset_folder_id, resolve_editor_generated_asset_label, +}; + +use super::worker::MODEL3D_DEFAULT_ASSET_LABEL; + pub(crate) const MODEL3D_TEXT_TO_MODEL_JOB_KIND: &str = "model3d_text_to_model"; pub(crate) const MODEL3D_IMAGE_TO_MODEL_JOB_KIND: &str = "model3d_image_to_model"; @@ -107,6 +113,55 @@ pub(crate) struct Model3dJobTarget<'a> { pub(crate) asset_label: Option<&'a str>, } +/// 归一落点与素材名并写回请求,口径与其它生成工具完全一致: +/// 项目 ID 只 trim;素材夹 ID 走 `project` / 旧 `folder-*` → 当前 owner 默认素材夹的映射; +/// 素材名 trim + 截断到上限 + 缺省用平台默认名。 +/// +/// 必须在入队前调用,且入队的就是归一后的请求:预检、队列载荷与 worker 落库必须是同一个 +/// canonical 值。只校验归一值却把原始值入队 / 落库,会让「落在哪、叫什么」两处各说一套。 +pub(crate) fn normalize_text_to_model_target( + request: &mut Model3dTextToModelRequest, + owner_user_id: &str, +) { + normalize_target_fields( + &mut request.project_id, + &mut request.asset_folder_id, + &mut request.asset_label, + owner_user_id, + ); +} + +pub(crate) fn normalize_image_to_model_target( + request: &mut Model3dImageToModelRequest, + owner_user_id: &str, +) { + normalize_target_fields( + &mut request.project_id, + &mut request.asset_folder_id, + &mut request.asset_label, + owner_user_id, + ); +} + +fn normalize_target_fields( + project_id: &mut Option, + asset_folder_id: &mut Option, + asset_label: &mut Option, + owner_user_id: &str, +) { + *project_id = project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let folder_id = asset_folder_id.take(); + *asset_folder_id = normalize_generated_asset_folder_id(folder_id, owner_user_id); + *asset_label = Some(resolve_editor_generated_asset_label( + asset_label.take(), + MODEL3D_DEFAULT_ASSET_LABEL, + )); +} + pub(crate) fn text_to_model_target(request: &Model3dTextToModelRequest) -> Model3dJobTarget<'_> { Model3dJobTarget { project_id: request.project_id.as_deref(), @@ -156,6 +211,7 @@ pub(crate) fn parse_model3d_job_request( mod tests { use super::*; use serde_json::json; + use shared_contracts::assets::EDITOR_ASSET_LABEL_MAX_CHARS; #[test] fn job_kind_mapping_is_total_and_reversible() { @@ -168,6 +224,94 @@ mod tests { ); } + /// 归一落点:项目 ID 只 trim;`project` / 旧 `folder-*` 收敛到当前 owner 的默认素材夹, + /// 其它目录原样透传,因此预检、队列载荷与落库看到的是同一个值。 + #[test] + fn flat_target_is_normalized_before_enqueue() { + let generation = json!({ + "prompt": "一只木箱", + "model": "v3.1-20260211", + "texture": true, + "pbr": true, + "textureQuality": "standard", + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }); + let mut request: Model3dTextToModelRequest = serde_json::from_value(json!({ + "generation": generation.clone(), + "projectId": " project-1 " + })) + .expect("测试请求应可反序列化"); + normalize_text_to_model_target(&mut request, "user-1"); + assert_eq!(request.project_id.as_deref(), Some("project-1")); + assert!(request.asset_folder_id.is_none()); + // 没给素材名时按平台默认名落库,口径与其它生成工具一致。 + assert_eq!(request.asset_label.as_deref(), Some("3D 模型")); + + let long_label = format!(" 超长素材名{}", "字".repeat(200)); + let mut request: Model3dTextToModelRequest = serde_json::from_value(json!({ + "generation": generation.clone(), + "projectId": "project-1", + "assetLabel": long_label + })) + .expect("测试请求应可反序列化"); + normalize_text_to_model_target(&mut request, "user-1"); + assert_eq!( + request + .asset_label + .as_deref() + .map(|label| label.chars().count()), + Some(EDITOR_ASSET_LABEL_MAX_CHARS) + ); + + for raw_folder_id in ["project", "folder-legacy", " folder-legacy "] { + let mut request: Model3dImageToModelRequest = serde_json::from_value(json!({ + "source": { "kind": "asset", "assetId": "asset-1" }, + "generation": { + "model": "v3.1-20260211", + "texture": true, + "pbr": true, + "textureQuality": "standard", + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "assetFolderId": raw_folder_id + })) + .expect("测试请求应可反序列化"); + normalize_image_to_model_target(&mut request, "user-1"); + assert_eq!( + request.asset_folder_id.as_deref(), + Some("user-1:asset-folder:project"), + "占位 / 旧目录 ID {raw_folder_id} 应收敛到 owner 默认素材夹" + ); + } + + let mut request: Model3dImageToModelRequest = serde_json::from_value(json!({ + "source": { "kind": "asset", "assetId": "asset-1" }, + "generation": { + "model": "v3.1-20260211", + "texture": true, + "pbr": true, + "textureQuality": "standard", + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "assetFolderId": " editor-asset-folder-real " + })) + .expect("测试请求应可反序列化"); + normalize_image_to_model_target(&mut request, "user-1"); + assert_eq!( + request.asset_folder_id.as_deref(), + Some("editor-asset-folder-real") + ); + } + #[test] fn model_version_uses_the_contract_wire_value() { let payload = json!({ diff --git a/server-rs/crates/api-server/src/tripo3d/routes.rs b/server-rs/crates/api-server/src/tripo3d/routes.rs index 570935a35..6376b3ead 100644 --- a/server-rs/crates/api-server/src/tripo3d/routes.rs +++ b/server-rs/crates/api-server/src/tripo3d/routes.rs @@ -18,6 +18,7 @@ use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest; use crate::{ api_response::json_success_body, auth::{AuthenticatedAccessToken, require_bearer_auth}, + editor_generation_queue::editor_generation_source_entity_id, http_error::AppError, request_context::RequestContext, state::AppState, @@ -25,7 +26,10 @@ use crate::{ use super::errors::{map_pricing_error, map_pricing_store_error, map_request_error}; use super::image_source::preflight_image_source; -use super::job::{Model3dJobKind, Model3dJobTarget, image_to_model_target, text_to_model_target}; +use super::job::{ + Model3dJobKind, Model3dJobTarget, image_to_model_target, normalize_image_to_model_target, + normalize_text_to_model_target, text_to_model_target, +}; use super::queue::{Model3dSubmissionResponse, enqueue_model3d_job}; use super::target::preflight_generation_target; use super::validation::{validate_image_to_model_request, validate_text_to_model_request}; @@ -58,11 +62,13 @@ pub(crate) async fn submit_text_to_model( headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result<(StatusCode, Json), Response> { - let payload = parse_json_payload(&request_context, payload)?; + let mut payload = parse_json_payload(&request_context, payload)?; let idempotency_key = require_idempotency_key(&headers, &request_context)?; let query = validate_text_to_model_request(&payload) .map_err(map_request_error) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + // 归一落点并写回请求:预检、入队载荷与落库必须是同一个 canonical 值。 + normalize_text_to_model_target(&mut payload, authenticated.claims().user_id()); // 落点预检与参数校验一样前置于查价:跨 owner / 不存在的落点必须在这里就被拒, // 不能等 worker 落库时才发现,那时 provider 预算已经花掉了。 let target = text_to_model_target(&payload); @@ -70,7 +76,9 @@ pub(crate) async fn submit_text_to_model( .await .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let price_mud_points = resolve_price_mud_points(&state, &request_context, &query).await?; - let source_entity_id = target_source_entity_id(target); + // 队列行的来源身份与其它生成工具同一口径:项目 ID,缺项目时回落到 job kind。 + let source_entity_id = + editor_generation_source_entity_id(target.project_id, Model3dJobKind::TextToModel.as_str()); let job = enqueue_model3d_job( &state, authenticated.claims().user_id(), @@ -92,7 +100,7 @@ pub(crate) async fn submit_image_to_model( headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result<(StatusCode, Json), Response> { - let payload = parse_json_payload(&request_context, payload)?; + let mut payload = parse_json_payload(&request_context, payload)?; let idempotency_key = require_idempotency_key(&headers, &request_context)?; let query = validate_image_to_model_request(&payload) .map_err(map_request_error) @@ -102,13 +110,19 @@ pub(crate) async fn submit_image_to_model( preflight_image_source(&state, authenticated.claims().user_id(), &payload.source) .await .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - // 落点与图片输入一样,必须在查价、扣费与入队之前确认。 + // 落点与图片输入一样,必须在查价、扣费与入队之前确认;先归一再预检, + // 入队与落库看到的就是预检过的那个值。 + normalize_image_to_model_target(&mut payload, authenticated.claims().user_id()); let target = image_to_model_target(&payload); preflight_generation_target(&state, authenticated.claims().user_id(), target) .await .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let price_mud_points = resolve_price_mud_points(&state, &request_context, &query).await?; - let source_entity_id = target_source_entity_id(target); + // 队列行的来源身份与其它生成工具同一口径:项目 ID,缺项目时回落到 job kind。 + let source_entity_id = editor_generation_source_entity_id( + target.project_id, + Model3dJobKind::ImageToModel.as_str(), + ); let job = enqueue_model3d_job( &state, authenticated.claims().user_id(), @@ -172,16 +186,6 @@ fn parse_json_payload( }) } -/// 任务行的 `source_entity_id`:落点身份(项目或素材夹),与其它生成接口同形。 -fn target_source_entity_id(target: Model3dJobTarget<'_>) -> String { - target - .project_id - .or(target.asset_folder_id) - .map(str::trim) - .unwrap_or_default() - .to_string() -} - /// 两个提交都必须显式给出 `Idempotency-Key`;缺失或格式非法一律 400,不静默生成键。 fn require_idempotency_key<'a>( headers: &'a HeaderMap, diff --git a/server-rs/crates/api-server/src/tripo3d/worker.rs b/server-rs/crates/api-server/src/tripo3d/worker.rs index 33248d94b..cfc930059 100644 --- a/server-rs/crates/api-server/src/tripo3d/worker.rs +++ b/server-rs/crates/api-server/src/tripo3d/worker.rs @@ -413,7 +413,7 @@ fn preview_dimensions(bytes: &[u8]) -> Result<(u32, u32), AppError> { Ok((width, height)) } -const MODEL3D_DEFAULT_ASSET_LABEL: &str = "3D 模型"; +pub(crate) const MODEL3D_DEFAULT_ASSET_LABEL: &str = "3D 模型"; fn worker_id(caller: &EditorGenerationCaller) -> Result<&str, AppError> { caller