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` 为空时按扩展名兜底。
@@ -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