multiview 视图输入显式化为 url / fileToken,线上按文档 view-key 形态发出
- 契约新增 Model3dViewInput(url / fileToken 的 tagged enum),views 的四个视图都改用它,裸字符串在契约层即被拒绝
- platform-tripo 不再走 SDK 的位置数组形态:按 Tripo 文档推荐的 view-key 构造 inputs,值写成显式 {url} / {file_token},种类由契约字段决定、链路上不再猜前缀
- 视图校验(front 必填、至少两张、空白按未提供)与 inputs 构造合并成一条路径,不再两处重复规则
- 新增 4 条定向用例钉住发出的 JSON 形状、空白视图、视图数量与 taskId 复用;契约用例补齐裸字符串与视图内未知字段被拒
- 重新生成 ts-rs 绑定与导出索引,技术方案同步 multiview 输入口径
This commit is contained in:
@@ -26,7 +26,7 @@ task 尚未完成时 `output` 为空;完成后 `output` 必须是与 task type
|
||||
|
||||
- text-to-model:`prompt` 必填,上限 1024 字符;`negative_prompt` 上限 255 字符。空白 prompt 在提交前拒绝。
|
||||
- image-to-model:`source` 是 `Model3dGenerationSource`,即带 `kind` 的站内引用 tagged enum(`resource { resourceId }` 或 `asset { assetId }`),不接受裸字符串、远程 URL 与 data URL。提交时只按 ID 定点校验归属与记录类型(跨 owner、未登记、类型不符统一 400),worker 执行时再从私有 OSS 读出字节、上传 provider 换回 `file_token` 后提交;provider 生成参数在 `generation` 里。
|
||||
- multiview-to-model:`inputs` 是带 `kind` 的 tagged enum,二选一——`views { front, left, back, right }` 或复用已有结果的 `taskId`。`views` 下 `front` 必填,其余视图至少再提供一张(少于两张视图直接拒绝);左/后/右视图为空白字符串时按未提供处理,不会上传空文件。图片与视图引用都先去掉首尾空白再交给 SDK,`taskId` 走统一的 task id 校验。
|
||||
- multiview-to-model:`inputs` 是带 `kind` 的 tagged enum,二选一——`views { front, left, back, right }` 或复用已有结果的 `taskId`。`views` 下 `front` 必填,其余视图至少再提供一张(少于两张视图直接拒绝);每个视图是 `Model3dViewInput`(`url` 或 `fileToken`,同样是带 `kind` 的 tagged enum),不接受裸字符串。取值空白(含纯空白)的视图按未提供处理,不会上传空文件;图片引用与视图地址都先去掉首尾空白再交给 provider,`taskId` 走统一的 task id 校验。线上 `inputs` 按 Tripo 文档推荐的 view-key 形态构造(`[{"front":{"url":…}},{"left":{"file_token":…}}]`):SDK 的 `MultiviewToModelParams::from_views` 只能发位置数组 `["<裸字符串>", "", …]`,服务端得按前缀猜那是 URL、file_token 还是 task_id,因此该字段由 `platform-tripo` 自己构造后经 `extra` 透传,种类由契约字段决定。
|
||||
|
||||
三个入口的 `model` 都必填,其余生成参数可选。SDK params 未命名的 `texture_version` 与 `delight`,由 image-to-model 和 multiview-to-model 通过 extra 字段透传。
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export type * from './image-to-model/Model3dImageToModelResult';
|
||||
export type * from './Model3dGenerationResult';
|
||||
export type * from './multiview-to-model/Model3dMultiviewInputs';
|
||||
export type * from './multiview-to-model/Model3dMultiviewToModelRequest';
|
||||
export type * from './multiview-to-model/Model3dViewInput';
|
||||
export type * from './text-to-model/Model3dTextToModelParams';
|
||||
export type * from './text-to-model/Model3dTextToModelRequest';
|
||||
export type * from './text-to-model/Model3dTextToModelResult';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Model3dViewInput } from './Model3dViewInput';
|
||||
|
||||
export type Model3dMultiviewInputs =
|
||||
| {
|
||||
kind: 'views';
|
||||
front: string;
|
||||
left?: string | null;
|
||||
back?: string | null;
|
||||
right?: string | null;
|
||||
front: Model3dViewInput;
|
||||
left?: Model3dViewInput | null;
|
||||
back?: Model3dViewInput | null;
|
||||
right?: Model3dViewInput | null;
|
||||
}
|
||||
| { kind: 'taskId'; taskId: string };
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 单个视图的输入形态:公网可读地址或 provider 上传返回的 `file_token`,显式二选一。
|
||||
*
|
||||
* 不用裸字符串:Tripo 文档里 `inputs` 的取值可以是 URL、file_token 或嵌套
|
||||
* `{url}` / `{file_token}` / `{object:{bucket,key}}`,裸字符串要靠服务端按前缀猜种类,
|
||||
* file_token 与 task_id 尤其容易混。契约里写明「哪一种」,平台层就能构造显式对象,
|
||||
* 提交链路上不再有猜测。
|
||||
*
|
||||
* 平台自己只产出这两种:站内引用先上传换成 `file_token`,公网地址按 `url` 直传。
|
||||
*/
|
||||
export type Model3dViewInput =
|
||||
| { kind: 'url'; url: string }
|
||||
| { kind: 'fileToken'; fileToken: string };
|
||||
@@ -18,7 +18,9 @@ use platform_tripo::{TripoImageInput, TripoProviderClient};
|
||||
use shared_contracts::model3d::{
|
||||
common::{Model3dModelVersion, Model3dTaskStatus, Model3dTextureQuality},
|
||||
image_to_model::Model3dImageToModelParams,
|
||||
multiview_to_model::{Model3dMultiviewInputs, Model3dMultiviewToModelRequest},
|
||||
multiview_to_model::{
|
||||
Model3dMultiviewInputs, Model3dMultiviewToModelRequest, Model3dViewInput,
|
||||
},
|
||||
text_to_model::Model3dTextToModelParams,
|
||||
};
|
||||
use tokio::{fs::File, io::AsyncWriteExt};
|
||||
@@ -106,8 +108,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let multiview = client
|
||||
.submit_multiview_to_model(&Model3dMultiviewToModelRequest {
|
||||
inputs: Model3dMultiviewInputs::Views {
|
||||
front: SAMPLE_IMAGE_URL.to_string(),
|
||||
left: Some(SAMPLE_IMAGE_URL.to_string()),
|
||||
front: Model3dViewInput::Url {
|
||||
url: SAMPLE_IMAGE_URL.to_string(),
|
||||
},
|
||||
left: Some(Model3dViewInput::Url {
|
||||
url: SAMPLE_IMAGE_URL.to_string(),
|
||||
}),
|
||||
back: None,
|
||||
right: None,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use serde_json::Value;
|
||||
use shared_contracts::model3d::multiview_to_model::{
|
||||
Model3dMultiviewInputs, Model3dMultiviewToModelRequest,
|
||||
Model3dMultiviewInputs, Model3dMultiviewToModelRequest, Model3dViewInput,
|
||||
};
|
||||
use tripo3d_sdk::{models::FileInput, params::MultiviewToModelParams};
|
||||
use tripo3d_sdk::params::MultiviewToModelParams;
|
||||
|
||||
use crate::common::{
|
||||
TripoError, TripoField, TripoGenerationOptions, TripoProviderClient, TripoTaskHandle,
|
||||
@@ -9,43 +10,19 @@ use crate::common::{
|
||||
validate_task_id, wire, wire_geometry_quality, wire_option,
|
||||
};
|
||||
|
||||
/// `inputs` 的线上字段名。SDK 的位置数组形态表达不了文档推荐的 view-key 形态,
|
||||
/// 因此这个字段由平台自己按文档构造后经 `extra` 透传(见 `view_inputs_wire`)。
|
||||
const MODEL3D_MULTIVIEW_INPUTS_FIELD: &str = "inputs";
|
||||
|
||||
impl TripoProviderClient {
|
||||
pub async fn submit_multiview_to_model(
|
||||
&self,
|
||||
request: &Model3dMultiviewToModelRequest,
|
||||
) -> Result<TripoTaskHandle, TripoError> {
|
||||
match &request.inputs {
|
||||
Model3dMultiviewInputs::Views {
|
||||
front,
|
||||
left,
|
||||
back,
|
||||
right,
|
||||
} => {
|
||||
if front.trim().is_empty() {
|
||||
return Err(TripoError::InvalidParameters {
|
||||
field: Some(TripoField::Inputs),
|
||||
reason: TripoValidationReason::Required,
|
||||
message: "front view is required".into(),
|
||||
});
|
||||
}
|
||||
if [left, back, right]
|
||||
.into_iter()
|
||||
.filter(|value| view_input(*value).is_some())
|
||||
.count()
|
||||
== 0
|
||||
{
|
||||
return Err(TripoError::InvalidParameters {
|
||||
field: Some(TripoField::Inputs),
|
||||
reason: TripoValidationReason::InvalidCombination,
|
||||
message: "at least two views are required".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Model3dMultiviewInputs::TaskId { task_id } => validate_task_id(task_id)?,
|
||||
}
|
||||
// 与 text / image 路径共用同一份字段映射(见 common::validation 的宏),
|
||||
// 新增共享字段时三条路径一起跟上。
|
||||
validate_generation_options(&TripoGenerationOptions::from(request))?;
|
||||
// 视图校验与 `inputs` 构造走同一条路径,不在这里重复一遍规则。
|
||||
let task_id = self
|
||||
.client
|
||||
.multiview_to_model(to_sdk_params(request)?)
|
||||
@@ -54,41 +31,96 @@ impl TripoProviderClient {
|
||||
submitted_task_handle(task_id)
|
||||
}
|
||||
}
|
||||
/// 空白(含纯空白)视图按「未提供」处理:提交前校验与 SDK 参数转换共用这一条规则。
|
||||
fn view_input(value: &Option<String>) -> Option<FileInput> {
|
||||
value
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(FileInput::from)
|
||||
}
|
||||
|
||||
fn to_sdk_params(
|
||||
request: &Model3dMultiviewToModelRequest,
|
||||
) -> Result<MultiviewToModelParams, TripoError> {
|
||||
let mut params = match &request.inputs {
|
||||
/// 视图输入的线上形态:Tripo 文档推荐的 view-key 数组,每项只含一个视图键,值为显式对象。
|
||||
///
|
||||
/// 不走 SDK 的 `MultiviewToModelParams::from_views`:它产出的是位置数组
|
||||
/// `["<裸字符串>", "", ...]`,服务端只能按前缀猜那是 URL、file_token 还是 task_id
|
||||
/// (见 review #77)。文档允许把值写成嵌套的 `{url}` / `{file_token}`,这里就按这个形态
|
||||
/// 构造,种类由契约字段决定,链路上没有猜测。
|
||||
///
|
||||
/// 空白(含纯空白)视图按「未提供」处理:校验与请求构造共用这一条规则。
|
||||
/// `front` 必填、至少两张视图的口径与 SDK 自己的校验一致,只是提前到提交前报错。
|
||||
fn view_inputs_wire(inputs: &Model3dMultiviewInputs) -> Result<Vec<Value>, TripoError> {
|
||||
match inputs {
|
||||
Model3dMultiviewInputs::Views {
|
||||
front,
|
||||
left,
|
||||
back,
|
||||
right,
|
||||
} => MultiviewToModelParams::from_views([
|
||||
Some(FileInput::from(front.trim())),
|
||||
view_input(left),
|
||||
view_input(back),
|
||||
view_input(right),
|
||||
]),
|
||||
Model3dMultiviewInputs::TaskId { task_id } => {
|
||||
MultiviewToModelParams::from_task_id(task_id.trim().to_owned())
|
||||
} => {
|
||||
let front = view_value(front).ok_or_else(|| TripoError::InvalidParameters {
|
||||
field: Some(TripoField::Inputs),
|
||||
reason: TripoValidationReason::Required,
|
||||
message: "front view is required".into(),
|
||||
})?;
|
||||
let mut entries = vec![view_entry("front", front)];
|
||||
let mut provided = 0;
|
||||
for (key, view) in [("left", left), ("back", back), ("right", right)] {
|
||||
if let Some(value) = view.as_ref().and_then(view_value) {
|
||||
provided += 1;
|
||||
entries.push(view_entry(key, value));
|
||||
}
|
||||
}
|
||||
if provided == 0 {
|
||||
return Err(TripoError::InvalidParameters {
|
||||
field: Some(TripoField::Inputs),
|
||||
reason: TripoValidationReason::InvalidCombination,
|
||||
message: "at least two views are required".into(),
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
};
|
||||
Model3dMultiviewInputs::TaskId { task_id } => {
|
||||
validate_task_id(task_id)?;
|
||||
Ok(vec![view_entry(
|
||||
"task_id",
|
||||
Value::String(task_id.trim().to_string()),
|
||||
)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个视图的显式取值:URL 与 file_token 各自一种形态,不合并成裸字符串。
|
||||
///
|
||||
/// 只做非空白校验,不在这里收窄地址范围(例如拒绝 userinfo / 内网主机):该字段是
|
||||
/// provider 侧要读的地址,与 image-to-model 的 `PublicUrl` 同一口径,见 ADR 0001 的边界约定。
|
||||
fn view_value(view: &Model3dViewInput) -> Option<Value> {
|
||||
match view {
|
||||
Model3dViewInput::Url { url } => {
|
||||
let url = url.trim();
|
||||
(!url.is_empty()).then(|| serde_json::json!({ "url": url }))
|
||||
}
|
||||
Model3dViewInput::FileToken { file_token } => {
|
||||
let file_token = file_token.trim();
|
||||
(!file_token.is_empty()).then(|| serde_json::json!({ "file_token": file_token }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_entry(key: &str, value: Value) -> Value {
|
||||
let mut entry = serde_json::Map::new();
|
||||
entry.insert(key.to_string(), value);
|
||||
Value::Object(entry)
|
||||
}
|
||||
|
||||
fn to_sdk_params(
|
||||
request: &Model3dMultiviewToModelRequest,
|
||||
) -> Result<MultiviewToModelParams, TripoError> {
|
||||
let mut params = MultiviewToModelParams::default();
|
||||
params.extra.insert(
|
||||
MODEL3D_MULTIVIEW_INPUTS_FIELD.to_string(),
|
||||
Value::Array(view_inputs_wire(&request.inputs)?),
|
||||
);
|
||||
params.model = Some(wire(&request.model)?);
|
||||
params.model_seed = request.model_seed;
|
||||
params.texture_seed = request.texture_seed;
|
||||
params.texture = request.texture;
|
||||
params.pbr = request.pbr;
|
||||
params.texture_quality = wire_option(request.texture_quality.as_ref())?;
|
||||
params.extra = extra_fields(request.texture_version, request.delight)?;
|
||||
params
|
||||
.extra
|
||||
.extend(extra_fields(request.texture_version, request.delight)?);
|
||||
params.geometry_quality =
|
||||
wire_option(wire_geometry_quality(request.model, request.geometry_quality).as_ref())?;
|
||||
params.texture_alignment = wire_option(request.texture_alignment.as_ref())?;
|
||||
@@ -103,3 +135,113 @@ fn to_sdk_params(
|
||||
params.export_orientation = wire_option(request.export_orientation.as_ref())?;
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn views_request(inputs: Value) -> Model3dMultiviewToModelRequest {
|
||||
serde_json::from_value(json!({
|
||||
"inputs": inputs,
|
||||
"model": "v3.1-20260211",
|
||||
"texture": true,
|
||||
"pbr": true,
|
||||
"textureQuality": "standard",
|
||||
"geometryQuality": "standard"
|
||||
}))
|
||||
.expect("测试请求应可反序列化")
|
||||
}
|
||||
|
||||
fn serialized_inputs(inputs: Value) -> Value {
|
||||
let request = views_request(inputs);
|
||||
let params = to_sdk_params(&request).expect("视图输入应可映射成 SDK 参数");
|
||||
serde_json::to_value(params).expect("SDK 参数应可序列化")["inputs"].clone()
|
||||
}
|
||||
|
||||
/// 视图按文档推荐的 view-key 形态发出,值是显式对象:服务端不需要按前缀猜
|
||||
/// URL / file_token / task_id,file_token 也就不会被误当成 task_id。
|
||||
#[test]
|
||||
fn views_are_sent_as_view_key_entries_with_explicit_values() {
|
||||
assert_eq!(
|
||||
serialized_inputs(json!({
|
||||
"kind": "views",
|
||||
"front": { "kind": "url", "url": " https://example.com/front.png " },
|
||||
"left": { "kind": "fileToken", "fileToken": "tripo-token-1" },
|
||||
"back": null,
|
||||
"right": null
|
||||
})),
|
||||
json!([
|
||||
{ "front": { "url": "https://example.com/front.png" } },
|
||||
{ "left": { "file_token": "tripo-token-1" } }
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// 空白视图(含纯空白)等于没给:不会退化成空对象或空字符串发出去。
|
||||
#[test]
|
||||
fn blank_views_are_treated_as_missing() {
|
||||
assert_eq!(
|
||||
serialized_inputs(json!({
|
||||
"kind": "views",
|
||||
"front": { "kind": "url", "url": "https://example.com/front.png" },
|
||||
"left": { "kind": "fileToken", "fileToken": " " },
|
||||
"back": { "kind": "url", "url": "https://example.com/back.png" },
|
||||
"right": { "kind": "fileToken", "fileToken": "" }
|
||||
})),
|
||||
json!([
|
||||
{ "front": { "url": "https://example.com/front.png" } },
|
||||
{ "back": { "url": "https://example.com/back.png" } }
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// front 必填、至少两张视图的口径在这里就报错,不把请求发出去。
|
||||
#[test]
|
||||
fn views_require_front_and_at_least_two_entries() {
|
||||
for (inputs, expected) in [
|
||||
(
|
||||
json!({
|
||||
"kind": "views",
|
||||
"front": { "kind": "url", "url": " " },
|
||||
"left": { "kind": "url", "url": "https://example.com/left.png" }
|
||||
}),
|
||||
"front view is required",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"kind": "views",
|
||||
"front": { "kind": "url", "url": "https://example.com/front.png" },
|
||||
"left": null,
|
||||
"back": null,
|
||||
"right": null
|
||||
}),
|
||||
"at least two views are required",
|
||||
),
|
||||
] {
|
||||
let error = to_sdk_params(&views_request(inputs))
|
||||
.err()
|
||||
.map(|error| error.to_string())
|
||||
.unwrap_or_else(|| panic!("{expected} 应被拒绝"));
|
||||
assert!(error.contains(expected), "实际错误为:{error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 复用已有任务时也走同一条构造路径:task id 单独校验后放进 `[{task_id}]`。
|
||||
#[test]
|
||||
fn task_id_reuse_is_validated_and_wrapped() {
|
||||
assert_eq!(
|
||||
serialized_inputs(json!({ "kind": "taskId", "taskId": " tripo-task-1 " })),
|
||||
json!([{ "task_id": "tripo-task-1" }])
|
||||
);
|
||||
|
||||
let error = to_sdk_params(&views_request(json!({ "kind": "taskId", "taskId": " " })))
|
||||
.err()
|
||||
.expect("空白 task id 应被拒绝");
|
||||
assert!(
|
||||
error.to_string().contains("task_id"),
|
||||
"实际错误为:{}",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@ pub(crate) const MODEL3D_TS_EXPORT_DIR: &str =
|
||||
|
||||
mod request;
|
||||
|
||||
pub use request::{Model3dMultiviewInputs, Model3dMultiviewToModelRequest};
|
||||
pub use request::{Model3dMultiviewInputs, Model3dMultiviewToModelRequest, Model3dViewInput};
|
||||
|
||||
@@ -24,18 +24,46 @@ use super::MODEL3D_TS_EXPORT_DIR;
|
||||
#[derive(ts_rs::TS)]
|
||||
#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)]
|
||||
pub enum Model3dMultiviewInputs {
|
||||
/// 直接给多视图:`front` 必填,`left` / `back` / `right` 至少再给一张。
|
||||
///
|
||||
/// 视图顺序由服务端按 [front, left, back, right] 归一,这里的字段名就是视图语义,
|
||||
/// 与 provider 的数组位置无关。
|
||||
Views {
|
||||
front: String,
|
||||
front: Model3dViewInput,
|
||||
#[ts(optional = nullable)]
|
||||
left: Option<String>,
|
||||
left: Option<Model3dViewInput>,
|
||||
#[ts(optional = nullable)]
|
||||
back: Option<String>,
|
||||
back: Option<Model3dViewInput>,
|
||||
#[ts(optional = nullable)]
|
||||
right: Option<String>,
|
||||
},
|
||||
TaskId {
|
||||
task_id: String,
|
||||
right: Option<Model3dViewInput>,
|
||||
},
|
||||
/// 复用已有任务的视图数据:只给 provider task id,不再重复传图。
|
||||
TaskId { task_id: String },
|
||||
}
|
||||
|
||||
/// 单个视图的输入形态:公网可读地址或 provider 上传返回的 `file_token`,显式二选一。
|
||||
///
|
||||
/// 不用裸字符串:Tripo 文档里 `inputs` 的取值可以是 URL、file_token 或嵌套
|
||||
/// `{url}` / `{file_token}` / `{object:{bucket,key}}`,裸字符串要靠服务端按前缀猜种类,
|
||||
/// file_token 与 task_id 尤其容易混。契约里写明「哪一种」,平台层就能构造显式对象,
|
||||
/// 提交链路上不再有猜测。
|
||||
///
|
||||
/// 平台自己只产出这两种:站内引用先上传换成 `file_token`,公网地址按 `url` 直传。
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(
|
||||
tag = "kind",
|
||||
rename_all = "camelCase",
|
||||
// 见上:多词字段必须单独声明 camelCase。
|
||||
rename_all_fields = "camelCase",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
#[derive(ts_rs::TS)]
|
||||
#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)]
|
||||
pub enum Model3dViewInput {
|
||||
/// 公网可读地址;provider 自己去取。
|
||||
Url { url: String },
|
||||
/// provider 上传接口返回的 `file_token`;私有对象必须先上传再提交。
|
||||
FileToken { file_token: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
|
||||
@@ -4,22 +4,32 @@ use shared_contracts::model3d::common::{
|
||||
Model3dModelVersion, Model3dTextureAlignment, Model3dTextureQuality, Model3dTextureVersion,
|
||||
};
|
||||
use shared_contracts::model3d::multiview_to_model::{
|
||||
Model3dMultiviewInputs, Model3dMultiviewToModelRequest,
|
||||
Model3dMultiviewInputs, Model3dMultiviewToModelRequest, Model3dViewInput,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn multiview_inputs_accepts_declared_fields_and_camel_case_tag() {
|
||||
let inputs: Model3dMultiviewInputs = serde_json::from_value(json!({
|
||||
"kind": "views",
|
||||
"front": "front.png",
|
||||
"left": "left.png"
|
||||
"front": { "kind": "url", "url": "front.png" },
|
||||
"left": { "kind": "fileToken", "fileToken": "left-token" }
|
||||
}))
|
||||
.expect("已声明字段应可反序列化");
|
||||
|
||||
match inputs {
|
||||
Model3dMultiviewInputs::Views { front, left, .. } => {
|
||||
assert_eq!(front, "front.png");
|
||||
assert_eq!(left.as_deref(), Some("left.png"));
|
||||
assert_eq!(
|
||||
front,
|
||||
Model3dViewInput::Url {
|
||||
url: "front.png".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
left,
|
||||
Some(Model3dViewInput::FileToken {
|
||||
file_token: "left-token".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
Model3dMultiviewInputs::TaskId { .. } => panic!("kind=views 不应解成 taskId 变体"),
|
||||
}
|
||||
@@ -39,10 +49,10 @@ fn multiview_inputs_reject_unknown_fields_inside_variants() {
|
||||
// 这条用例钉住该严格性,防止为消除 ts-rs 告警而把它改成非严格模式。
|
||||
let error = serde_json::from_value::<Model3dMultiviewInputs>(json!({
|
||||
"kind": "views",
|
||||
"front": "front.png",
|
||||
"left": "left.png",
|
||||
"back": "back.png",
|
||||
"right": "right.png",
|
||||
"front": { "kind": "url", "url": "front.png" },
|
||||
"left": { "kind": "url", "url": "left.png" },
|
||||
"back": { "kind": "url", "url": "back.png" },
|
||||
"right": { "kind": "url", "url": "right.png" },
|
||||
"unexpected": true
|
||||
}))
|
||||
.expect_err("变体里出现未知字段应被拒绝");
|
||||
@@ -87,10 +97,10 @@ fn multiview_request_round_trips_every_optional_field() {
|
||||
let payload = json!({
|
||||
"inputs": {
|
||||
"kind": "views",
|
||||
"front": "front.png",
|
||||
"left": "left.png",
|
||||
"back": "back.png",
|
||||
"right": "right.png"
|
||||
"front": { "kind": "url", "url": "front.png" },
|
||||
"left": { "kind": "fileToken", "fileToken": "left-token" },
|
||||
"back": { "kind": "url", "url": "back.png" },
|
||||
"right": { "kind": "fileToken", "fileToken": "right-token" }
|
||||
},
|
||||
"model": "v3.1-20260211",
|
||||
"modelSeed": 7,
|
||||
@@ -123,10 +133,30 @@ fn multiview_request_round_trips_every_optional_field() {
|
||||
back,
|
||||
right,
|
||||
} => {
|
||||
assert_eq!(front, "front.png");
|
||||
assert_eq!(left.as_deref(), Some("left.png"));
|
||||
assert_eq!(back.as_deref(), Some("back.png"));
|
||||
assert_eq!(right.as_deref(), Some("right.png"));
|
||||
assert_eq!(
|
||||
front,
|
||||
&Model3dViewInput::Url {
|
||||
url: "front.png".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
left,
|
||||
&Some(Model3dViewInput::FileToken {
|
||||
file_token: "left-token".to_string()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
back,
|
||||
&Some(Model3dViewInput::Url {
|
||||
url: "back.png".to_string()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
right,
|
||||
&Some(Model3dViewInput::FileToken {
|
||||
file_token: "right-token".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
Model3dMultiviewInputs::TaskId { .. } => panic!("kind=views 不应解成 taskId 变体"),
|
||||
}
|
||||
@@ -213,3 +243,33 @@ fn multiview_request_round_trips_explicit_nulls() {
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiview_views_require_typed_inputs_without_unknown_fields() {
|
||||
// 裸字符串只能靠前缀猜种类(URL / file_token / task_id),契约层直接拒绝,
|
||||
// 逼调用方写明是 `url` 还是 `fileToken`。
|
||||
let error = serde_json::from_value::<Model3dMultiviewInputs>(json!({
|
||||
"kind": "views",
|
||||
"front": "front.png",
|
||||
"left": "left.png"
|
||||
}))
|
||||
.expect_err("裸字符串视图应被拒绝");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("invalid type"),
|
||||
"裸字符串应报类型错误,实际为:{error}"
|
||||
);
|
||||
|
||||
// 每个视图只允许一种取值:kind=url 时再给 fileToken 会被拒绝。
|
||||
let error = serde_json::from_value::<Model3dMultiviewInputs>(json!({
|
||||
"kind": "views",
|
||||
"front": { "kind": "url", "url": "front.png", "fileToken": "front-token" },
|
||||
"left": { "kind": "fileToken", "fileToken": "left-token" }
|
||||
}))
|
||||
.expect_err("视图取值里出现未知字段应被拒绝");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("unknown field"),
|
||||
"未知字段错误应指出 unknown field,实际为:{error}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user