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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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("顶层未知字段应被拒绝");
|
||||
|
||||
Reference in New Issue
Block a user