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:
@@ -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` 为空时按扩展名兜底。
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<String>,
|
||||
asset_folder_id: &mut Option<String>,
|
||||
asset_label: &mut Option<String>,
|
||||
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!({
|
||||
|
||||
@@ -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<Json<Model3dTextToModelRequest>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), 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<Json<Model3dImageToModelRequest>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), 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<T: DeserializeOwned>(
|
||||
})
|
||||
}
|
||||
|
||||
/// 任务行的 `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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user