3D 落点放宽为「至少一个」并把结果引用改成平坦字段

- 校验层不再要求「projectId 与 assetFolderId 恰好一个」:两个都不给 400 点名 projectId,canvasCompletion 与「没有 projectId」同现 400 点名 canvasCompletion,两个都给按合法处理;与其它画布生成工具同一口径。
- 落点预检改成对给出的每个落点逐个定点查,出错字段只在「只给一个」时点名该键,两个都给无法归因回落到 target;去掉已经没有意义的旧用法注释。
- 结果侧 Model3dGenerationTargetRef 从 tagged enum(kind=projectResource/assetLibrary)改成与请求落点同形的平坦可选字段 resourceId / assetId,两个落点都写就两个都带;生成绑定同步变宽。
- worker 的 target_ref 改为按「写了哪条落点行」分别带 ID,完成结果用例改成断言平坦字段且只落项目资源时不得凭空出现 assetId;契约测试对称覆盖「只落素材库」形态。
- 验证:cargo test -p api-server tripo3d::(56 passed)、cargo test -p shared-contracts、npm run contracts:model3d:generate、check:rustfmt / check:encoding / git diff --check 通过。
This commit is contained in:
2026-09-23 15:54:07 +08:00
parent c64bfc3867
commit 158f9b741d
7 changed files with 146 additions and 88 deletions
@@ -1,8 +1,18 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 结果落点引用:与请求 `target` 同分支,但给的是已创建资源 / 素材的正式 ID。
* 结果落点引用:与请求落点同形的平坦可选字段,给的是已创建资源 / 素材的正式 ID。
*
* 与其它生成工具一致,项目资源与素材库可以同时产出(前端画布链路两个都发),
* 因此两个 ID 都是可选的:哪个落点写了行就带哪个 ID,两个都写了就都带。
*/
export type Model3dGenerationTargetRef =
| { kind: 'projectResource'; resourceId: string }
| { kind: 'assetLibrary'; assetId: string };
export type Model3dGenerationTargetRef = {
/**
* 项目资源落点的正式资源 ID;没落画布时缺省。
*/
resourceId?: string | null;
/**
* 素材库落点的正式素材 ID;没落素材库时缺省。
*/
assetId?: string | null;
};
@@ -102,9 +102,10 @@ impl Model3dJobRequest {
const MODEL3D_IMAGE_TO_MODEL_AUDIT_PROMPT: &str = "图生 3D 模型";
/// 结果落点:与其它生成接口同形的平坦字段(项目资源 / 素材库二选一)。
/// 结果落点:与其它生成接口同形的平坦字段(项目资源 / 素材库可选,至少给一个)。
///
/// 校验层保证「恰好给一个」,因此下游按 `project_id` 是否存在分辨落点,不再需要判别枚举。
/// 与其它生成工具一致,两个落点可以同时给(前端画布链路两个都发),因此下游不再按
/// 「二选一」分辨落点:`project_id` 有就落画布,`asset_folder_id` 有就落素材库。
#[derive(Clone, Copy, Debug)]
pub(crate) struct Model3dJobTarget<'a> {
pub(crate) project_id: Option<&'a str>,
@@ -27,10 +27,11 @@ pub(crate) async fn preflight_generation_target(
owner_user_id: &str,
target: Model3dJobTarget<'_>,
) -> Result<(), AppError> {
let Some((field, project_id, asset_folder_id)) = preflight_target_locator(target) else {
// 校验层保证「恰好一个落点」,这里只是纵深防御:真走到这里也不能放行无落点提交。
let Some((project_id, asset_folder_id)) = preflight_target_locator(target) else {
// 校验层保证「至少一个落点」,这里只是纵深防御:真走到这里也不能放行无落点提交。
return Err(target_unavailable("projectId"));
};
let field = target_unavailable_field(project_id.as_deref(), asset_folder_id.as_deref());
state
.spacetime_client()
.preflight_editor_generation_target(EditorGenerationTargetPreflightRecordInput {
@@ -44,22 +45,37 @@ pub(crate) async fn preflight_generation_target(
.map_err(|error| collapse_target_unavailable(error, field))
}
/// 平坦落点 → 定点预检入参:返回(出错时点名的字段名、项目 ID、素材夹 ID)。
/// 平坦落点 → 定点预检入参:返回(项目 ID、素材夹 ID)。
///
/// 传下去的就是 trim 后的原值:校验值与随后入队、落库的值必须逐字一致,
/// 与其它生成工具同一口径:两个落点可以同时给(前端画布链路两个都发),procedure 会逐个定点查;
/// 传下去的就是 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,
) -> Option<(Option<String>, Option<String>)> {
let project_id = target
.project_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let asset_folder_id = target
.asset_folder_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
(project_id.is_some() || asset_folder_id.is_some()).then_some((project_id, asset_folder_id))
}
/// 落点不可用时对外点名的字段:只给一个就点它,两个都给无法归因,回落到 `target`。
fn target_unavailable_field(
project_id: Option<&str>,
asset_folder_id: Option<&str>,
) -> &'static str {
match (project_id.is_some(), asset_folder_id.is_some()) {
(true, false) => "projectId",
(false, true) => "assetFolderId",
_ => "target",
}
}
@@ -118,7 +134,7 @@ mod tests {
);
}
/// 平坦落点收敛成定点预检入参:只做 trim,两个都不给返回 `None`。
/// 平坦落点收敛成定点预检入参:只做 trim,两个都给就都传,都不给返回 `None`。
#[test]
fn flat_locator_maps_to_point_lookup_without_rewriting_ids() {
let locator = |project_id, asset_folder_id| {
@@ -132,17 +148,34 @@ mod tests {
assert_eq!(
locator(Some(" project-1 "), None),
Some(("projectId", Some("project-1".to_string()), None))
Some((Some("project-1".to_string()), None))
);
assert_eq!(
locator(None, Some(" folder-1 ")),
Some(("assetFolderId", None, Some("folder-1".to_string())))
Some((None, Some("folder-1".to_string())))
);
// 两个都给由校验层拒绝;预检的兜底口径是项目优先,不静默换成素材库。
assert_eq!(
locator(Some("project-1"), Some("folder-1")),
Some(("projectId", Some("project-1".to_string()), None))
Some((Some("project-1".to_string()), Some("folder-1".to_string())))
);
assert_eq!(locator(None, None), None);
}
/// 出错字段:只有一个落点时点名它,两个都给时无法归因,回落到 `target`。
#[test]
fn unavailable_field_names_the_given_locators() {
assert_eq!(
target_unavailable_field(Some("project-1"), None),
"projectId"
);
assert_eq!(
target_unavailable_field(None, Some("folder-1")),
"assetFolderId"
);
assert_eq!(
target_unavailable_field(Some("project-1"), Some("folder-1")),
"target"
);
assert_eq!(target_unavailable_field(None, None), "target");
}
}
@@ -356,16 +356,22 @@ mod tests {
assert_eq!(error.field().as_deref(), Some("assetFolderId"));
}
/// 平坦落点字段的四种组合:恰好一个才通过;都给、都不给与 blank 都按 400 拒绝。
/// 平坦落点的合法组合:项目、素材库都可以给,两个一起给也合法(与其它生成工具一致);
/// 一个都不给、blank 与「素材库落点带画布占位框」都按 400 拒绝。
#[test]
fn target_requires_exactly_one_flat_locator() {
let cases: [(&str, Value, Option<&str>); 6] = [
("projectId", json!({ "projectId": "project-1" }), None),
fn target_accepts_both_locators_but_requires_at_least_one() {
let cases: [(&str, Value, Option<&str>); 7] = [
("只给 projectId", json!({ "projectId": "project-1" }), None),
(
"assetFolderId",
"只给 assetFolderId",
json!({ "assetFolderId": "folder-1" }),
None,
),
(
"两个都给",
json!({ "projectId": "project-1", "assetFolderId": "folder-1" }),
None,
),
(
"空白 projectId",
json!({ "projectId": " " }),
@@ -376,12 +382,22 @@ mod tests {
json!({ "assetFolderId": " " }),
Some("assetFolderId"),
),
(
"两个都给",
json!({ "projectId": "project-1", "assetFolderId": "folder-1" }),
Some("assetFolderId"),
),
("都不给", json!({}), Some("projectId")),
(
"素材库落点带画布占位框",
json!({
"assetFolderId": "folder-1",
"canvasCompletion": {
"dialogId": "dialog-1",
"title": "木椅",
"placeholder": {
"x": 1, "y": 2, "width": 3, "height": 4,
"originalWidth": 3, "originalHeight": 4
}
}
}),
Some("canvasCompletion"),
),
];
for (name, target_fields, expected_field) in cases {
let mut payload = json!({ "generation": generation_with(json!({})) });
@@ -449,24 +465,20 @@ fn validate_target(target: Model3dJobTarget<'_>) -> Result<(), Model3dRequestErr
"素材库目录 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(
"canvasCompletion",
"只有项目资源落点才能回填画布占位框",
));
}
Ok(())
}
(None, None) => Err(Model3dRequestError::invalid(
// 与其它生成工具同一条口径:项目资源与素材库可以同时给(前端画布链路两个都发),
// 但一个都不给就没有结果落点——付费生成不能接受这种提交。
if project_id.is_none() && asset_folder_id.is_none() {
return Err(Model3dRequestError::invalid(
"projectId",
"必须给出落点:projectId(项目资源)或 assetFolderId(素材库)",
)),
));
}
// 画布占位框是项目资源落点的回填载荷:没有项目就没有画布可以回填。
if project_id.is_none() && target.canvas_completion.is_some() {
return Err(Model3dRequestError::invalid(
"canvasCompletion",
"只有项目资源落点才能回填画布占位框",
));
}
Ok(())
}
@@ -239,7 +239,7 @@ async fn persist_result(
"message": format!("落库 3D 生成结果时:{error}。"),
}))
})?;
// 落点与其它生成接口同形的平坦字段:校验层保证「项目资源 / 素材库恰好一个」
// 落点与其它生成接口同形的平坦字段:校验层保证「至少一个」,两个都给时两条落点都写
let target = request.target();
let project_id = target
.project_id
@@ -272,8 +272,7 @@ 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 has_asset = folder_id.is_some();
let project_resource = project_id.map(|project_id| EditorProjectResourceCreateRecordInput {
resource_id: resource_id.clone(),
project_id,
@@ -329,9 +328,8 @@ async fn persist_result(
let result = build_result(
request.job_kind(),
target_ref(
project_id_for_result.is_some(),
resource_id.clone(),
asset_id_for_result,
has_resource.then(|| resource_id.clone()),
has_asset.then_some(asset_id_for_result),
),
&model,
&preview,
@@ -387,16 +385,12 @@ async fn persist_result(
Ok(())
}
/// 结果里的落点引用:项目落点回资源 ID,素材库落点回素材 ID。
fn target_ref(
has_project: bool,
resource_id: String,
asset_id: String,
) -> Model3dGenerationTargetRef {
if has_project {
Model3dGenerationTargetRef::ProjectResource { resource_id }
} else {
Model3dGenerationTargetRef::AssetLibrary { asset_id }
/// 结果里的落点引用:写了项目资源行就带 `resourceId`,写了素材行就带 `assetId`
/// 两个落点都写(前端画布链路的常态)时两个都带 —— 与请求落点同形的平坦字段。
fn target_ref(resource_id: Option<String>, asset_id: Option<String>) -> Model3dGenerationTargetRef {
Model3dGenerationTargetRef {
resource_id,
asset_id,
}
}
@@ -500,8 +494,9 @@ mod tests {
] {
let result = build_result(
kind,
Model3dGenerationTargetRef::ProjectResource {
resource_id: "resource-1".to_string(),
Model3dGenerationTargetRef {
resource_id: Some("resource-1".to_string()),
asset_id: None,
},
&model,
&preview,
@@ -510,8 +505,9 @@ mod tests {
let result = &payload["result"];
assert_eq!(result["kind"], json!(expected_kind));
assert_eq!(result["target"]["kind"], json!("projectResource"));
assert_eq!(result["target"]["resourceId"], json!("resource-1"));
// 只落项目资源时不得凭空带出素材 ID:结果引用与请求落点同形。
assert_eq!(result["target"].get("assetId"), None);
assert_eq!(
result["model"]["objectKey"],
json!("editor/model3d/task-1/model.glb")
@@ -2,18 +2,21 @@ use serde::{Deserialize, Serialize};
use super::MODEL3D_TS_EXPORT_DIR;
/// 结果落点引用:与请求 `target` 同分支,但给的是已创建资源 / 素材的正式 ID。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
/// 结果落点引用:与请求落点同形的平坦可选字段,给的是已创建资源 / 素材的正式 ID。
///
/// 与其它生成工具一致,项目资源与素材库可以同时产出(前端画布链路两个都发),
/// 因此两个 ID 都是可选的:哪个落点写了行就带哪个 ID,两个都写了就都带。
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[derive(ts_rs::TS)]
#[ts(export, export_to = MODEL3D_TS_EXPORT_DIR)]
pub enum Model3dGenerationTargetRef {
ProjectResource {
#[serde(rename = "resourceId")]
resource_id: String,
},
AssetLibrary {
#[serde(rename = "assetId")]
asset_id: String,
},
pub struct Model3dGenerationTargetRef {
/// 项目资源落点的正式资源 ID;没落画布时缺省。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub resource_id: Option<String>,
/// 素材库落点的正式素材 ID;没落素材库时缺省。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub asset_id: Option<String>,
}
@@ -114,7 +114,7 @@ fn api_request_rejects_unknown_top_level_fields() {
fn generation_result_is_tagged_per_endpoint() {
let result: Model3dGenerationResult = serde_json::from_value(json!({
"kind": "textToModel",
"target": { "kind": "projectResource", "resourceId": "res-1" },
"target": { "resourceId": "res-1" },
"model": {
"objectKey": "models/res-1.glb",
"contentType": "model/gltf-binary",
@@ -135,8 +135,9 @@ fn generation_result_is_tagged_per_endpoint() {
};
assert_eq!(
text.target,
Model3dGenerationTargetRef::ProjectResource {
resource_id: "res-1".into()
Model3dGenerationTargetRef {
resource_id: Some("res-1".into()),
asset_id: None,
}
);
assert_eq!(
@@ -161,7 +162,7 @@ fn generation_result_is_tagged_per_endpoint() {
// 对称覆盖 imageToModel 分支:tag 路由与分支字段都要单独守住。
let image_result: Model3dGenerationResult = serde_json::from_value(json!({
"kind": "imageToModel",
"target": { "kind": "projectResource", "resourceId": "res-2" },
"target": { "assetId": "asset-2" },
"model": {
"objectKey": "models/res-2.glb",
"contentType": "model/gltf-binary",
@@ -180,10 +181,12 @@ fn generation_result_is_tagged_per_endpoint() {
let Model3dGenerationResult::ImageToModel(image) = image_result else {
panic!("kind=imageToModel 不应解成 textToModel 分支");
};
// 只落素材库时,平坦引用只带 assetId。
assert_eq!(
image.target,
Model3dGenerationTargetRef::ProjectResource {
resource_id: "res-2".into()
Model3dGenerationTargetRef {
resource_id: None,
asset_id: Some("asset-2".into()),
}
);
assert_eq!(