diff --git a/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md b/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md index 3e424be90..956da6545 100644 --- a/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md +++ b/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md @@ -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 字段透传。 diff --git a/packages/shared/src/contracts/model3d/index.ts b/packages/shared/src/contracts/model3d/index.ts index 7e3b5c036..ff7d09170 100644 --- a/packages/shared/src/contracts/model3d/index.ts +++ b/packages/shared/src/contracts/model3d/index.ts @@ -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'; diff --git a/packages/shared/src/contracts/model3d/multiview-to-model/Model3dMultiviewInputs.ts b/packages/shared/src/contracts/model3d/multiview-to-model/Model3dMultiviewInputs.ts index a3159a264..7dd1db54a 100644 --- a/packages/shared/src/contracts/model3d/multiview-to-model/Model3dMultiviewInputs.ts +++ b/packages/shared/src/contracts/model3d/multiview-to-model/Model3dMultiviewInputs.ts @@ -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 }; diff --git a/packages/shared/src/contracts/model3d/multiview-to-model/Model3dViewInput.ts b/packages/shared/src/contracts/model3d/multiview-to-model/Model3dViewInput.ts new file mode 100644 index 000000000..71e98057a --- /dev/null +++ b/packages/shared/src/contracts/model3d/multiview-to-model/Model3dViewInput.ts @@ -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 }; diff --git a/server-rs/crates/platform-tripo/examples/tripo_generation_smoke.rs b/server-rs/crates/platform-tripo/examples/tripo_generation_smoke.rs index 745d4c881..fdb38fe8c 100644 --- a/server-rs/crates/platform-tripo/examples/tripo_generation_smoke.rs +++ b/server-rs/crates/platform-tripo/examples/tripo_generation_smoke.rs @@ -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> { 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, }, diff --git a/server-rs/crates/platform-tripo/src/multiview_to_model/client.rs b/server-rs/crates/platform-tripo/src/multiview_to_model/client.rs index 4eb2393fe..4a0557481 100644 --- a/server-rs/crates/platform-tripo/src/multiview_to_model/client.rs +++ b/server-rs/crates/platform-tripo/src/multiview_to_model/client.rs @@ -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 { - 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) -> Option { - value - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(FileInput::from) -} -fn to_sdk_params( - request: &Model3dMultiviewToModelRequest, -) -> Result { - 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, 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 { + 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 { + 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 + ); + } +} diff --git a/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/mod.rs b/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/mod.rs index cea6cb4d8..0b7db88dc 100644 --- a/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/mod.rs +++ b/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/mod.rs @@ -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}; diff --git a/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/request.rs b/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/request.rs index 3138644da..be3eaa399 100644 --- a/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/request.rs +++ b/server-rs/crates/shared-contracts/src/model3d/multiview_to_model/request.rs @@ -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, + left: Option, #[ts(optional = nullable)] - back: Option, + back: Option, #[ts(optional = nullable)] - right: Option, - }, - TaskId { - task_id: String, + right: Option, }, + /// 复用已有任务的视图数据:只给 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)] diff --git a/server-rs/crates/shared-contracts/tests/model3d_multiview_request_contract.rs b/server-rs/crates/shared-contracts/tests/model3d_multiview_request_contract.rs index de52020ac..6bc80d0fe 100644 --- a/server-rs/crates/shared-contracts/tests/model3d_multiview_request_contract.rs +++ b/server-rs/crates/shared-contracts/tests/model3d_multiview_request_contract.rs @@ -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::(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::(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::(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}" + ); +}