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:
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user