3D 生成请求落点改为平坦可选字段并复用统一落点预检

- shared-contracts 的 text-to-model / image-to-model 请求用平坦可选 projectId / canvasCompletion / assetFolderId / assetLabel 取代 tagged enum target,删除 Model3dGenerationTarget 及其契约测试。
- 同步 ts-rs 生成绑定:新增四个可选字段并移除 Model3dGenerationTarget.ts 与 barrel 导出。
- api-server 校验层改按「项目资源 / 素材库二选一」校验,缺失、冲突与空白值都返回 400 并点名具体字段。
- 落点预检改用其它付费编辑器生成共用的 preflight_editor_generation_target_and_return,素材库落点不再读整库。
- worker 按平坦字段落库,素材名缺省回落到 MODEL3D_DEFAULT_ASSET_LABEL。
- 定向验证:cargo test -p shared-contracts 与 cargo test -p api-server tripo3d:: 全绿(54 passed)。
This commit is contained in:
2026-09-23 14:02:44 +08:00
parent 48ba8b3133
commit fd48829c24
14 changed files with 431 additions and 216 deletions
@@ -1,16 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { EditorCanvasGenerationCompletionPayload } from '../../editor-canvas/EditorCanvasGenerationCompletionPayload';
/**
* 生成结果的落点。两个分支都必须给出定位字段,不存在“都不给就默认落素材库”的口径。
*/
export type Model3dGenerationTarget =
| {
kind: 'projectResource';
projectId: string;
/**
* 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置。
*/
canvasCompletion?: EditorCanvasGenerationCompletionPayload | null;
}
| { kind: 'assetLibrary'; folderId: string; label: string };
@@ -1,13 +1,31 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { EditorCanvasGenerationCompletionPayload } from '../../editor-canvas/EditorCanvasGenerationCompletionPayload';
import type { Model3dGenerationSource } from '../common/Model3dGenerationSource';
import type { Model3dGenerationTarget } from '../common/Model3dGenerationTarget';
import type { Model3dImageToModelParams } from './Model3dImageToModelParams';
/**
* image-to-model API 请求:站内图片引用 + provider 生成参数 + 平台字段。
*
* 结果落点与 text-to-model 同形:平坦的可选 `projectId` / `canvasCompletion` /
* `assetFolderId` / `assetLabel`,二选一,不用 tagged enum。
*/
export type Model3dImageToModelRequest = {
source: Model3dGenerationSource;
generation: Model3dImageToModelParams;
target: Model3dGenerationTarget;
/**
* 项目资源落点;与 `assetFolderId` 二选一。
*/
projectId?: string | null;
/**
* 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置;只在项目资源落点下生效。
*/
canvasCompletion?: EditorCanvasGenerationCompletionPayload | null;
/**
* 素材库落点;与 `projectId` 二选一。
*/
assetFolderId?: string | null;
/**
* 素材名称;缺省用平台默认名。
*/
assetLabel?: string | null;
};
@@ -2,7 +2,6 @@ export type * from './common/Model3dCompression';
export type * from './common/Model3dExportOrientation';
export type * from './common/Model3dGeneratedArtifact';
export type * from './common/Model3dGenerationSource';
export type * from './common/Model3dGenerationTarget';
export type * from './common/Model3dGenerationTargetRef';
export type * from './common/Model3dGeometryQuality';
export type * from './common/Model3dInputOrientation';
@@ -1,5 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Model3dGenerationTarget } from '../common/Model3dGenerationTarget';
import type { EditorCanvasGenerationCompletionPayload } from '../../editor-canvas/EditorCanvasGenerationCompletionPayload';
import type { Model3dTextToModelParams } from './Model3dTextToModelParams';
/**
@@ -7,8 +7,27 @@ import type { Model3dTextToModelParams } from './Model3dTextToModelParams';
*
* 生成参数单独嵌一层而不是摊平到顶层:serde 的 `deny_unknown_fields` 与 `flatten`
* 不能共存,摊平会让顶层未知字段静默通过。
*
* 结果落点与其它生成接口同形:平坦的可选 `projectId` / `canvasCompletion` /
* `assetFolderId` / `assetLabel`,不用 tagged enum —— 客户端不必为「落项目还是落素材库」
* 多拼一层判别结构。服务端按「二选一」校验:两个都给或都不给都拒绝。
*/
export type Model3dTextToModelRequest = {
generation: Model3dTextToModelParams;
target: Model3dGenerationTarget;
/**
* 项目资源落点;与 `assetFolderId` 二选一。
*/
projectId?: string | null;
/**
* 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置;只在项目资源落点下生效。
*/
canvasCompletion?: EditorCanvasGenerationCompletionPayload | null;
/**
* 素材库落点;与 `projectId` 二选一。
*/
assetFolderId?: string | null;
/**
* 素材名称;缺省用平台默认名。
*/
assetLabel?: string | null;
};
+38 -8
View File
@@ -4,7 +4,7 @@
//! 区分,结果类型也按端点严格区分,不做“一个 kind 带 mode 字段”的变形。
use serde_json::Value;
use shared_contracts::model3d::common::Model3dGenerationTarget;
use shared_contracts::editor_canvas::EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest;
use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest;
use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest;
@@ -62,10 +62,11 @@ impl Model3dJobRequest {
}
}
pub(crate) fn target(&self) -> &Model3dGenerationTarget {
/// 结果落点字段;两个端点的契约落点同形,校验、预检与落库共用这一份视图。
pub(crate) fn target(&self) -> Model3dJobTarget<'_> {
match self {
Self::TextToModel(request) => &request.target,
Self::ImageToModel(request) => &request.target,
Self::TextToModel(request) => text_to_model_target(request),
Self::ImageToModel(request) => image_to_model_target(request),
}
}
@@ -95,6 +96,35 @@ impl Model3dJobRequest {
const MODEL3D_IMAGE_TO_MODEL_AUDIT_PROMPT: &str = "图生 3D 模型";
/// 结果落点:与其它生成接口同形的平坦字段(项目资源 / 素材库二选一)。
///
/// 校验层保证「恰好给一个」,因此下游按 `project_id` 是否存在分辨落点,不再需要判别枚举。
#[derive(Clone, Copy, Debug)]
pub(crate) struct Model3dJobTarget<'a> {
pub(crate) project_id: Option<&'a str>,
pub(crate) canvas_completion: Option<&'a EditorCanvasGenerationCompletionRequest>,
pub(crate) asset_folder_id: Option<&'a str>,
pub(crate) asset_label: Option<&'a str>,
}
pub(crate) fn text_to_model_target(request: &Model3dTextToModelRequest) -> Model3dJobTarget<'_> {
Model3dJobTarget {
project_id: request.project_id.as_deref(),
canvas_completion: request.canvas_completion.as_ref(),
asset_folder_id: request.asset_folder_id.as_deref(),
asset_label: request.asset_label.as_deref(),
}
}
pub(crate) fn image_to_model_target(request: &Model3dImageToModelRequest) -> Model3dJobTarget<'_> {
Model3dJobTarget {
project_id: request.project_id.as_deref(),
canvas_completion: request.canvas_completion.as_ref(),
asset_folder_id: request.asset_folder_id.as_deref(),
asset_label: request.asset_label.as_deref(),
}
}
/// 从队列 payload 还原请求。
///
/// 队列 payload 比 API 请求多一个幂等指纹字段,且请求类型是 `deny_unknown_fields`
@@ -152,7 +182,7 @@ mod tests {
"smartLowPoly": false,
"generateParts": false
},
"target": { "kind": "projectResource", "projectId": "project-1" }
"projectId": "project-1"
})
.to_string();
@@ -179,7 +209,7 @@ mod tests {
"smartLowPoly": false,
"generateParts": false
},
"target": { "kind": "projectResource", "projectId": "project-1" },
"projectId": "project-1",
"_externalApiRequestFingerprint": "fingerprint-1"
})
.to_string();
@@ -205,7 +235,7 @@ mod tests {
"smartLowPoly": false,
"generateParts": false
},
"target": { "kind": "projectResource", "projectId": "project-1" },
"projectId": "project-1",
"_externalApiRequestFingerprint": "fingerprint-1",
"unexpectedField": true
})
@@ -215,7 +245,7 @@ mod tests {
.is_err()
);
// 缺 generation / target 的 payload 与未知 job kind 都必须被拒。
// 缺 generation / 落点的 payload 与未知 job kind 都必须被拒。
let payload = json!({ "prompt": "一只木箱" }).to_string();
assert!(parse_model3d_job_request(MODEL3D_TEXT_TO_MODEL_JOB_KIND, &payload).is_err());
assert!(parse_model3d_job_request("unknown_job_kind", &payload).is_err());
@@ -25,7 +25,7 @@ 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;
use super::job::{Model3dJobKind, Model3dJobTarget, image_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};
@@ -65,11 +65,12 @@ pub(crate) async fn submit_text_to_model(
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
// 落点预检与参数校验一样前置于查价:跨 owner / 不存在的落点必须在这里就被拒,
// 不能等 worker 落库时才发现,那时 provider 预算已经花掉了。
preflight_generation_target(&state, authenticated.claims().user_id(), &payload.target)
let target = text_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(&payload.target);
let source_entity_id = target_source_entity_id(target);
let job = enqueue_model3d_job(
&state,
authenticated.claims().user_id(),
@@ -102,11 +103,12 @@ pub(crate) async fn submit_image_to_model(
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
// 落点与图片输入一样,必须在查价、扣费与入队之前确认。
preflight_generation_target(&state, authenticated.claims().user_id(), &payload.target)
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(&payload.target);
let source_entity_id = target_source_entity_id(target);
let job = enqueue_model3d_job(
&state,
authenticated.claims().user_id(),
@@ -170,19 +172,14 @@ fn parse_json_payload<T: DeserializeOwned>(
})
}
fn target_source_entity_id(
target: &shared_contracts::model3d::common::Model3dGenerationTarget,
) -> String {
match target {
shared_contracts::model3d::common::Model3dGenerationTarget::ProjectResource {
project_id,
..
} => project_id.trim().to_string(),
shared_contracts::model3d::common::Model3dGenerationTarget::AssetLibrary {
folder_id,
..
} => folder_id.trim().to_string(),
}
/// 任务行的 `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,不静默生成键。
@@ -357,7 +354,8 @@ mod tests {
"smartLowPoly": false,
"generateParts": false
},
"target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "测试" }
"assetFolderId": "folder-1",
"assetLabel": "测试"
})
}
@@ -375,7 +373,8 @@ mod tests {
"smartLowPoly": false,
"generateParts": false
},
"target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "测试" }
"assetFolderId": "folder-1",
"assetLabel": "测试"
})
}
@@ -4,78 +4,80 @@
//! (画布项目 / 素材库文件夹)属于当前 owner 且仍然存在。否则一次注定落库失败的提交
//! 会先成功入队、扣费并花掉 provider 预算,最后才在 worker 落库阶段失败。
//!
//! 复用其它生成工具同一个定点预检 procedure`preflight_editor_generation_target`):
//! 项目与素材夹都按主键查,不再为了一个 folder id 读整库。传下去的是 trim 后的原值,
//! 不做 `project` / `folder-*` → owner 默认素材夹的归一:归一值必须一路带到落库,
//! 而落点只在 3D 打开素材库入口后才需要(默认目录由落库 procedure 自己归一)。
//!
//! 跨 owner、已删除与不存在收敛成同一句 400,避免把「别人是否存在某个 ID」变成可探测信息;
//! 只有基础设施故障保留原状态码。worker 落库时会再确认同一事实。
use axum::http::StatusCode;
use serde_json::json;
use shared_contracts::model3d::common::Model3dGenerationTarget;
use spacetime_client::EditorProjectGetRecordInput;
use spacetime_client::editor_project::EditorGenerationTargetPreflightRecordInput;
use crate::{
editor_project::{current_utc_micros, map_editor_project_error},
http_error::AppError,
state::AppState,
};
use crate::{editor_project::map_editor_project_error, http_error::AppError, state::AppState};
use super::job::Model3dJobTarget;
use super::provider::TRIPO_PROVIDER;
/// 提交时的落点预检:只读元数据,不写记录,也不产生 provider 副作用。
pub(crate) async fn preflight_generation_target(
state: &AppState,
owner_user_id: &str,
target: &Model3dGenerationTarget,
target: Model3dJobTarget<'_>,
) -> Result<(), AppError> {
match target {
Model3dGenerationTarget::ProjectResource { project_id, .. } => {
// 按 (project_id, owner) 定点读取:跨 owner 与已删除在 procedure 侧就是同一种失败。
state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.trim().to_string(),
owner_user_id: owner_user_id.to_string(),
})
.await
.map(|_| ())
.map_err(map_editor_project_error)
.map_err(collapse_target_unavailable)
}
Model3dGenerationTarget::AssetLibrary { folder_id, .. } => {
// 素材库目前没有「按 folder ID 定点读取」的 procedure,只能读整库再匹配;
// 落点是文件夹(不是素材),因此也不能用引用解析代替。等窄查询补齐后改用它。
let library = state
.spacetime_client()
.get_editor_asset_library(owner_user_id.to_string(), current_utc_micros())
.await
.map_err(map_editor_project_error)?;
let folder_id = folder_id.trim();
if library
.folders
.iter()
.any(|folder| folder.folder_id == folder_id)
{
Ok(())
} else {
Err(target_unavailable())
}
}
let Some((field, project_id, asset_folder_id)) = preflight_target_locator(target) else {
// 校验层保证「恰好一个落点」,这里只是纵深防御:真走到这里也不能放行无落点提交。
return Err(target_unavailable("projectId"));
};
state
.spacetime_client()
.preflight_editor_generation_target(EditorGenerationTargetPreflightRecordInput {
owner_user_id: owner_user_id.trim().to_string(),
project_id,
asset_folder_id,
})
.await
.map(|_| ())
.map_err(map_editor_project_error)
.map_err(|error| collapse_target_unavailable(error, field))
}
/// 平坦落点 → 定点预检入参:返回(出错时点名的字段名、项目 ID、素材夹 ID)。
///
/// 传下去的就是 trim 后的原值:校验值与随后入队、落库的值必须逐字一致,
/// 不能校验归一化后的目录却把原始值落库。
/// 返回 `None` 表示两个落点字段都没给:调用方按「落点不可用」拒绝。
fn preflight_target_locator(
target: Model3dJobTarget<'_>,
) -> Option<(&'static str, Option<String>, Option<String>)> {
match (target.project_id, target.asset_folder_id) {
(Some(project_id), _) => Some(("projectId", Some(project_id.trim().to_string()), None)),
(None, Some(asset_folder_id)) => Some((
"assetFolderId",
None,
Some(asset_folder_id.trim().to_string()),
)),
(None, None) => None,
}
}
/// 未登记 / 跨 owner / 已删除都收敛成同一句 400;基础设施故障保留原状态码。
fn collapse_target_unavailable(error: AppError) -> AppError {
fn collapse_target_unavailable(error: AppError, field: &'static str) -> AppError {
if error.status_code().is_client_error() {
target_unavailable()
target_unavailable(field)
} else {
error
}
}
fn target_unavailable() -> AppError {
/// 落点不可用:字段名跟随平坦参数,指向调用方实际给错的那个键。
fn target_unavailable(field: &'static str) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": TRIPO_PROVIDER,
"reason": "model3d-target-unavailable",
"field": "target",
"field": field,
"message": "生成结果的落点必须是当前账号已存在的画布项目或素材库文件夹。",
}))
}
@@ -86,22 +88,61 @@ mod tests {
#[test]
fn target_unavailable_is_stable_and_machine_readable() {
let error = target_unavailable();
let error = target_unavailable("projectId");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
let details = error.details().expect("details 必须存在");
assert_eq!(details["reason"], json!("model3d-target-unavailable"));
assert_eq!(details["field"], json!("target"));
assert_eq!(details["field"], json!("projectId"));
}
/// 跨 owner / 不存在(4xx)对外只有一种说法;基础设施故障不能被误报成用户问题。
#[test]
fn only_client_errors_collapse_into_target_unavailable() {
let collapsed = collapse_target_unavailable(AppError::from_status(StatusCode::NOT_FOUND));
assert_eq!(collapsed.details(), target_unavailable().details());
let collapsed = collapse_target_unavailable(
AppError::from_status(StatusCode::NOT_FOUND),
"assetFolderId",
);
assert_eq!(
collapsed.details(),
target_unavailable("assetFolderId").details()
);
let preserved =
collapse_target_unavailable(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR));
let preserved = collapse_target_unavailable(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR),
"assetFolderId",
);
assert_eq!(preserved.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert_ne!(preserved.details(), target_unavailable().details());
assert_ne!(
preserved.details(),
target_unavailable("assetFolderId").details()
);
}
/// 平坦落点收敛成定点预检入参:只做 trim,两个都不给返回 `None`。
#[test]
fn flat_locator_maps_to_point_lookup_without_rewriting_ids() {
let locator = |project_id, asset_folder_id| {
preflight_target_locator(Model3dJobTarget {
project_id,
canvas_completion: None,
asset_folder_id,
asset_label: None,
})
};
assert_eq!(
locator(Some(" project-1 "), None),
Some(("projectId", Some("project-1".to_string()), None))
);
assert_eq!(
locator(None, Some(" folder-1 ")),
Some(("assetFolderId", None, Some("folder-1".to_string())))
);
// 两个都给由校验层拒绝;预检的兜底口径是项目优先,不静默换成素材库。
assert_eq!(
locator(Some("project-1"), Some("folder-1")),
Some(("projectId", Some("project-1".to_string()), None))
);
assert_eq!(locator(None, None), None);
}
}
@@ -8,8 +8,7 @@
use platform_tripo::{TripoError, validate_image_to_model_params, validate_text_to_model_params};
use shared_contracts::model3d::common::{
Model3dGenerationSource, Model3dGenerationTarget, Model3dGeometryQuality, Model3dModelVersion,
Model3dTextureQuality,
Model3dGenerationSource, Model3dGeometryQuality, Model3dModelVersion, Model3dTextureQuality,
};
use shared_contracts::model3d::image_to_model::{
Model3dImageToModelParams, Model3dImageToModelRequest,
@@ -18,6 +17,7 @@ use shared_contracts::model3d::text_to_model::{
Model3dTextToModelParams, Model3dTextToModelRequest,
};
use super::job::{Model3dJobTarget, image_to_model_target, text_to_model_target};
use super::pricing::{Model3dEndpoint, Model3dPricingQuery, model3d_add_ons};
/// 请求被拒绝的原因。`InvalidRequest` 的字段名用 API 请求里的 JSON 字段路径。
@@ -68,7 +68,7 @@ pub(crate) fn validate_text_to_model_request(
) -> Result<Model3dPricingQuery, Model3dRequestError> {
let query = PricingParamView::from(&request.generation)
.validate(Model3dEndpoint::TextToModel, request.generation.model)?;
validate_target(&request.target)?;
validate_target(text_to_model_target(request))?;
validate_text_to_model_params(&request.generation).map_err(Model3dRequestError::Provider)?;
Ok(query)
}
@@ -80,7 +80,7 @@ pub(crate) fn validate_image_to_model_request(
validate_source(&request.source)?;
let query = PricingParamView::from(&request.generation)
.validate(Model3dEndpoint::ImageToModel, request.generation.model)?;
validate_target(&request.target)?;
validate_target(image_to_model_target(request))?;
validate_image_to_model_params(&request.generation).map_err(Model3dRequestError::Provider)?;
Ok(query)
}
@@ -233,7 +233,8 @@ mod tests {
fn text_request(generation: Value) -> Model3dTextToModelRequest {
serde_json::from_value(json!({
"generation": generation,
"target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" }
"assetFolderId": "folder-1",
"assetLabel": "木椅"
}))
.expect("测试请求应可反序列化")
}
@@ -338,7 +339,8 @@ mod tests {
let request: Model3dImageToModelRequest = serde_json::from_value(json!({
"source": { "kind": "asset", "assetId": " " },
"generation": image_generation_with(json!({})),
"target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" }
"assetFolderId": "folder-1",
"assetLabel": "木椅"
}))
.expect("测试请求应可反序列化");
let error = validate_image_to_model_request(&request).expect_err("空白素材 ID 应被拒绝");
@@ -347,39 +349,124 @@ mod tests {
let request: Model3dImageToModelRequest = serde_json::from_value(json!({
"source": { "kind": "resource", "resourceId": "resource-1" },
"generation": image_generation_with(json!({})),
"target": { "kind": "assetLibrary", "folderId": " ", "label": "木椅" }
"assetFolderId": " "
}))
.expect("测试请求应可反序列化");
let error = validate_image_to_model_request(&request).expect_err("空白目录 ID 应被拒绝");
assert_eq!(error.field().as_deref(), Some("target.folderId"));
assert_eq!(error.field().as_deref(), Some("assetFolderId"));
}
/// 平坦落点字段的四种组合:恰好一个才通过;都给、都不给与 blank 都按 400 拒绝。
#[test]
fn target_requires_exactly_one_flat_locator() {
let cases: [(&str, Value, Option<&str>); 6] = [
("projectId", json!({ "projectId": "project-1" }), None),
(
"assetFolderId",
json!({ "assetFolderId": "folder-1" }),
None,
),
(
"空白 projectId",
json!({ "projectId": " " }),
Some("projectId"),
),
(
"空白 assetFolderId",
json!({ "assetFolderId": " " }),
Some("assetFolderId"),
),
(
"两个都给",
json!({ "projectId": "project-1", "assetFolderId": "folder-1" }),
Some("assetFolderId"),
),
("都不给", json!({}), Some("projectId")),
];
for (name, target_fields, expected_field) in cases {
let mut payload = json!({ "generation": generation_with(json!({})) });
for (key, value) in target_fields.as_object().expect("落点字段是对象") {
payload[key] = value.clone();
}
let request: Model3dTextToModelRequest =
serde_json::from_value(payload).expect("测试请求应可反序列化");
let error = validate_text_to_model_request(&request);
match expected_field {
None => assert!(error.is_ok(), "{name} 应通过校验:{error:?}"),
Some(field) => assert_eq!(
error.expect_err(name).field().as_deref(),
Some(field),
"{name}"
),
}
}
}
/// 画布占位框只在项目资源落点下有意义:落素材库时给它是调用方写错了字段。
#[test]
fn canvas_completion_is_rejected_outside_project_target() {
let request: Model3dTextToModelRequest = serde_json::from_value(json!({
"generation": generation_with(json!({})),
"assetFolderId": "folder-1",
"canvasCompletion": {
"dialogId": "dialog-1",
"title": "木椅",
"placeholder": {
"x": 10, "y": 20, "width": 100, "height": 120,
"originalWidth": 100, "originalHeight": 120
}
}
}))
.expect("测试请求应可反序列化");
let error =
validate_text_to_model_request(&request).expect_err("素材库落点不应接受画布占位框");
assert_eq!(error.field().as_deref(), Some("canvasCompletion"));
}
}
fn validate_target(target: &Model3dGenerationTarget) -> Result<(), Model3dRequestError> {
match target {
Model3dGenerationTarget::ProjectResource { project_id, .. }
if project_id.trim().is_empty() =>
{
Err(Model3dRequestError::invalid(
"target.projectId",
"项目 ID 不能为空",
))
}
Model3dGenerationTarget::AssetLibrary { folder_id, label } => {
if folder_id.trim().is_empty() {
/// 落点校验:项目资源与素材库二选一。
///
/// 与其它生成接口同一份字段形状,但 3D 生成更贵,因此不接受「都不给就默认落素材库」,
/// 也不接受两个都给 —— 落点不明时宁可在扣费前拒绝。
fn validate_target(target: Model3dJobTarget<'_>) -> Result<(), Model3dRequestError> {
let project_id = target
.project_id
.map(str::trim)
.filter(|value| !value.is_empty());
let asset_folder_id = target
.asset_folder_id
.map(str::trim)
.filter(|value| !value.is_empty());
if target.project_id.is_some() && project_id.is_none() {
return Err(Model3dRequestError::invalid(
"projectId",
"项目 ID 不能为空",
));
}
if target.asset_folder_id.is_some() && asset_folder_id.is_none() {
return Err(Model3dRequestError::invalid(
"assetFolderId",
"素材库目录 ID 不能为空",
));
}
match (project_id, asset_folder_id) {
(Some(_), Some(_)) => Err(Model3dRequestError::invalid(
"assetFolderId",
"项目资源与素材库落点只能给一个",
)),
(Some(_), None) => Ok(()),
(None, Some(_)) => {
if target.canvas_completion.is_some() {
return Err(Model3dRequestError::invalid(
"target.folderId",
"素材库目录 ID 不能为空",
));
}
if label.trim().is_empty() {
return Err(Model3dRequestError::invalid(
"target.label",
"素材名称不能为空",
"canvasCompletion",
"只有项目资源落点才能回填画布占位框",
));
}
Ok(())
}
_ => Ok(()),
(None, None) => Err(Model3dRequestError::invalid(
"projectId",
"必须给出落点:projectId(项目资源)或 assetFolderId(素材库)",
)),
}
}
@@ -13,9 +13,7 @@ use serde_json::json;
use shared_contracts::editor_generation::{
editor_generation_stable_asset_id, editor_generation_stable_resource_id,
};
use shared_contracts::model3d::common::{
Model3dGenerationTarget, Model3dGenerationTargetRef, Model3dTaskStatus,
};
use shared_contracts::model3d::common::{Model3dGenerationTargetRef, Model3dTaskStatus};
use spacetime_client::{
EditorAssetCreateRecordInput, EditorProjectResourceCreateRecordInput,
ExternalGenerationJobProviderCheckpointRecordInput, ExternalGenerationJobRecord,
@@ -241,20 +239,25 @@ async fn persist_result(
"message": format!("落库 3D 生成结果时:{error}。"),
}))
})?;
let (project_id, folder_id, label, canvas_completion) = match request.target() {
Model3dGenerationTarget::ProjectResource {
project_id,
canvas_completion,
} => (
Some(project_id.clone()),
None,
MODEL3D_DEFAULT_ASSET_LABEL.to_string(),
canvas_completion.clone(),
),
Model3dGenerationTarget::AssetLibrary { folder_id, label } => {
(None, Some(folder_id.clone()), label.clone(), None)
}
};
// 落点与其它生成接口同形的平坦字段:校验层保证「项目资源 / 素材库恰好一个」。
let target = request.target();
let project_id = target
.project_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let folder_id = target
.asset_folder_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let label = target
.asset_label
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| MODEL3D_DEFAULT_ASSET_LABEL.to_string());
let canvas_completion = target.canvas_completion.cloned();
let canvas_project_id = project_id.clone();
let resource_id = editor_generation_stable_resource_id(
owner_user_id,
@@ -269,6 +272,8 @@ async fn persist_result(
MODEL3D_MODEL_SLOT,
);
let has_resource = project_id.is_some();
// 结果里回落的落点身份要在 project_id 被搬进资源记录之前留下。
let project_id_for_result = project_id.clone();
let project_resource = project_id.map(|project_id| EditorProjectResourceCreateRecordInput {
resource_id: resource_id.clone(),
project_id,
@@ -323,7 +328,11 @@ async fn persist_result(
// 结果先构造:它引用的是对象元数据,与随后要搬进 item 的 asset_object 是两份值。
let result = build_result(
request.job_kind(),
target_ref(request.target(), resource_id.clone(), asset_id_for_result),
target_ref(
project_id_for_result.is_some(),
resource_id.clone(),
asset_id_for_result,
),
&model,
&preview,
);
@@ -378,18 +387,16 @@ async fn persist_result(
Ok(())
}
/// 结果里的落点引用:项目落点回资源 ID,素材库落点回素材 ID。
fn target_ref(
target: &Model3dGenerationTarget,
has_project: bool,
resource_id: String,
asset_id: String,
) -> Model3dGenerationTargetRef {
match target {
Model3dGenerationTarget::ProjectResource { .. } => {
Model3dGenerationTargetRef::ProjectResource { resource_id }
}
Model3dGenerationTarget::AssetLibrary { .. } => {
Model3dGenerationTargetRef::AssetLibrary { asset_id }
}
if has_project {
Model3dGenerationTargetRef::ProjectResource { resource_id }
} else {
Model3dGenerationTargetRef::AssetLibrary { asset_id }
}
}
@@ -1,29 +0,0 @@
use serde::{Deserialize, Serialize};
use super::MODEL3D_TS_EXPORT_DIR;
use crate::editor_canvas::EditorCanvasGenerationCompletionPayload;
/// 生成结果的落点。两个分支都必须给出定位字段,不存在“都不给就默认落素材库”的口径。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
#[derive(ts_rs::TS)]
#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)]
pub enum Model3dGenerationTarget {
ProjectResource {
#[serde(rename = "projectId")]
project_id: String,
/// 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置。
#[serde(
rename = "canvasCompletion",
default,
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
},
AssetLibrary {
#[serde(rename = "folderId")]
folder_id: String,
label: String,
},
}
@@ -4,7 +4,6 @@ mod compression;
mod export_orientation;
mod generated_artifact;
mod generation_source;
mod generation_target;
mod generation_target_ref;
mod geometry_quality;
mod input_orientation;
@@ -19,7 +18,6 @@ pub use compression::Model3dCompression;
pub use export_orientation::Model3dExportOrientation;
pub use generated_artifact::Model3dGeneratedArtifact;
pub use generation_source::Model3dGenerationSource;
pub use generation_target::Model3dGenerationTarget;
pub use generation_target_ref::Model3dGenerationTargetRef;
pub use geometry_quality::Model3dGeometryQuality;
pub use input_orientation::Model3dInputOrientation;
@@ -2,9 +2,13 @@ use serde::{Deserialize, Serialize};
use super::MODEL3D_TS_EXPORT_DIR;
use super::params::Model3dImageToModelParams;
use crate::model3d::common::{Model3dGenerationSource, Model3dGenerationTarget};
use crate::editor_canvas::EditorCanvasGenerationCompletionPayload;
use crate::model3d::common::Model3dGenerationSource;
/// image-to-model API 请求:站内图片引用 + provider 生成参数 + 平台字段。
///
/// 结果落点与 text-to-model 同形:平坦的可选 `projectId` / `canvasCompletion` /
/// `assetFolderId` / `assetLabel`,二选一,不用 tagged enum。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[derive(ts_rs::TS)]
@@ -12,5 +16,20 @@ use crate::model3d::common::{Model3dGenerationSource, Model3dGenerationTarget};
pub struct Model3dImageToModelRequest {
pub source: Model3dGenerationSource,
pub generation: Model3dImageToModelParams,
pub target: Model3dGenerationTarget,
/// 项目资源落点;与 `assetFolderId` 二选一。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub project_id: Option<String>,
/// 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置;只在项目资源落点下生效。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
/// 素材库落点;与 `projectId` 二选一。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub asset_folder_id: Option<String>,
/// 素材名称;缺省用平台默认名。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub asset_label: Option<String>,
}
@@ -2,17 +2,36 @@ use serde::{Deserialize, Serialize};
use super::MODEL3D_TS_EXPORT_DIR;
use super::params::Model3dTextToModelParams;
use crate::model3d::common::Model3dGenerationTarget;
use crate::editor_canvas::EditorCanvasGenerationCompletionPayload;
/// text-to-model API 请求:provider 生成参数 + 平台字段。
///
/// 生成参数单独嵌一层而不是摊平到顶层:serde 的 `deny_unknown_fields` 与 `flatten`
/// 不能共存,摊平会让顶层未知字段静默通过。
///
/// 结果落点与其它生成接口同形:平坦的可选 `projectId` / `canvasCompletion` /
/// `assetFolderId` / `assetLabel`,不用 tagged enum —— 客户端不必为「落项目还是落素材库」
/// 多拼一层判别结构。服务端按「二选一」校验:两个都给或都不给都拒绝。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[derive(ts_rs::TS)]
#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)]
pub struct Model3dTextToModelRequest {
pub generation: Model3dTextToModelParams,
pub target: Model3dGenerationTarget,
/// 项目资源落点;与 `assetFolderId` 二选一。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub project_id: Option<String>,
/// 画布占位框回填载荷:结果落库后前端据此把新资源放到用户提交时的位置;只在项目资源落点下生效。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
/// 素材库落点;与 `projectId` 二选一。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub asset_folder_id: Option<String>,
/// 素材名称;缺省用平台默认名。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub asset_label: Option<String>,
}
@@ -1,41 +1,65 @@
use serde_json::json;
use shared_contracts::model3d::Model3dGenerationResult;
use shared_contracts::model3d::common::{
Model3dGeneratedArtifact, Model3dGenerationSource, Model3dGenerationTarget,
Model3dGenerationTargetRef,
Model3dGeneratedArtifact, Model3dGenerationSource, Model3dGenerationTargetRef,
};
use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest;
use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest;
#[test]
fn text_to_model_request_accepts_generation_and_target() {
fn text_to_model_request_accepts_generation_and_flat_target() {
let request: Model3dTextToModelRequest = serde_json::from_value(json!({
"generation": { "prompt": "一把木椅", "model": "v3.1-20260211", "texture": true },
"target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" }
"assetFolderId": "folder-1",
"assetLabel": "木椅"
}))
.expect("合法请求应可反序列化");
assert_eq!(request.generation.prompt, "一把木椅");
assert!(matches!(
request.target,
Model3dGenerationTarget::AssetLibrary { .. }
));
assert_eq!(request.asset_folder_id.as_deref(), Some("folder-1"));
assert_eq!(request.asset_label.as_deref(), Some("木椅"));
assert!(request.project_id.is_none());
assert!(request.canvas_completion.is_none());
}
/// 落点是平坦可选字段:契约本身不强制「二选一」,也没给落点时必须照原样透传,
/// 由服务端在扣费前拒绝(见 api-server 的 `validate_target`)。
#[test]
fn target_requires_branch_local_fields() {
for invalid in [
json!({ "kind": "assetLibrary", "label": "木椅" }),
json!({ "kind": "assetLibrary", "folderId": "folder-1" }),
json!({ "kind": "projectResource" }),
] {
let error = serde_json::from_value::<Model3dGenerationTarget>(invalid.clone())
.expect_err("缺少分支必填字段应被拒绝");
assert!(
error.to_string().contains("missing field"),
"缺少字段的报错应指出 missing field,输入 {invalid},实际为:{error}"
);
}
fn target_fields_are_flat_and_optional() {
let project: Model3dTextToModelRequest = serde_json::from_value(json!({
"generation": { "prompt": "一把木椅", "model": "v3.1-20260211" },
"projectId": "project-1",
"canvasCompletion": {
"dialogId": "dialog-1",
"title": "木椅",
"placeholder": {
"x": 1, "y": 2, "width": 3, "height": 4,
"originalWidth": 3, "originalHeight": 4
}
}
}))
.expect("项目落点应可反序列化");
assert_eq!(project.project_id.as_deref(), Some("project-1"));
assert!(project.canvas_completion.is_some());
assert!(project.asset_folder_id.is_none());
let without_target: Model3dTextToModelRequest = serde_json::from_value(json!({
"generation": { "prompt": "一把木椅", "model": "v3.1-20260211" }
}))
.expect("缺少落点字段在契约层仍可反序列化");
assert!(without_target.project_id.is_none());
assert!(without_target.asset_folder_id.is_none());
// 落点是平坦字段:旧的 tagged enum 形状不再是合法输入。
let error = serde_json::from_value::<Model3dTextToModelRequest>(json!({
"generation": { "prompt": "一把木椅", "model": "v3.1-20260211" },
"target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" }
}))
.expect_err("target 已不是请求字段,应被拒绝");
assert!(
error.to_string().contains("unknown field"),
"旧 target 形状应报 unknown field,实际为:{error}"
);
}
#[test]
@@ -43,7 +67,7 @@ fn image_source_rejects_ambiguous_and_raw_input() {
let both_ids = json!({
"source": { "kind": "resource", "resourceId": "r1", "assetId": "a1" },
"generation": { "model": "v3.1-20260211" },
"target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" }
"assetFolderId": "folder-1"
});
let error = serde_json::from_value::<Model3dImageToModelRequest>(both_ids)
.expect_err("同时给出资源与素材 ID 应被拒绝");
@@ -55,12 +79,12 @@ fn image_source_rejects_ambiguous_and_raw_input() {
let raw_input = json!({
"source": "https://example.com/a.png",
"generation": { "model": "v3.1-20260211" },
"target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" }
"assetFolderId": "folder-1"
});
let error = serde_json::from_value::<Model3dImageToModelRequest>(raw_input)
.expect_err("裸字符串图片输入应被拒绝");
// 拒绝必须发生在 source 本身:错误里点名 Model3dGenerationSource,避免将来
// generation / target 的必填变化把这条负例“顶替”成通过。
// generation / 落点字段的变化把这条负例“顶替”成通过。
assert!(
error.to_string().contains("Model3dGenerationSource"),
"裸字符串 source 的报错应点名 Model3dGenerationSource,实际为:{error}"
@@ -75,7 +99,7 @@ fn image_source_rejects_ambiguous_and_raw_input() {
fn api_request_rejects_unknown_top_level_fields() {
let error = serde_json::from_value::<Model3dTextToModelRequest>(json!({
"generation": { "prompt": "一把木椅", "model": "v3.1-20260211" },
"target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "木椅" },
"assetFolderId": "folder-1",
"unexpected": true
}))
.expect_err("顶层未知字段应被拒绝");