diff --git a/server-rs/crates/api-server/src/tripo3d/image_source.rs b/server-rs/crates/api-server/src/tripo3d/image_source.rs index 845511397..20db2a31e 100644 --- a/server-rs/crates/api-server/src/tripo3d/image_source.rs +++ b/server-rs/crates/api-server/src/tripo3d/image_source.rs @@ -71,30 +71,65 @@ async fn resolve_source_object_key( owner_user_id: &str, source: &Model3dGenerationSource, ) -> Result { + // 生产路径只走「按 ID 定点查一条引用」:不列工程、不列素材库,也不读图片字节。 + resolve_source_object_key_with(source, |reference_id| async move { + resolve_editor_reference_record_by_id_for_owner(state, owner_user_id, &reference_id) + .await + .map(ResolvedReference::from) + }) + .await +} + +/// 定点查询返回的窄结果:只保留判定引用是否可用所需的记录类型与对象键。 +struct ResolvedReference { + kind: &'static str, + object_key: Option, +} + +impl From for ResolvedReference { + fn from(record: EditorReferenceRecord) -> Self { + match record { + EditorReferenceRecord::ProjectResource(resource) => Self { + kind: "resource", + object_key: resource.object_key, + }, + EditorReferenceRecord::Asset(asset) => Self { + kind: "asset", + object_key: asset.object_key, + }, + } + } +} + +/// 解析规则本身:把「一次按 ID 查询」的结果映射成对象键,或按引用不可用 / 类型不符收口。 +/// +/// 查询以参数注入,测试据此断言只发起一次定点查询、且各失败分支的对外语义; +/// 这里不接触 provider,也不读对象字节。 +async fn resolve_source_object_key_with( + source: &Model3dGenerationSource, + resolve: F, +) -> Result +where + F: FnOnce(String) -> Fut, + Fut: std::future::Future>, +{ let (requested_kind, reference_id) = match source { Model3dGenerationSource::Resource { resource_id } => ("resource", resource_id.as_str()), Model3dGenerationSource::Asset { asset_id } => ("asset", asset_id.as_str()), }; - let resolved = - resolve_editor_reference_record_by_id_for_owner(state, owner_user_id, reference_id) - .await - .map_err(|error| { - // 未登记、跨 owner 与已删除在 provider 侧都是同一类“引用不可用”, - // 这里也收敛成同一句 400;只有基础设施故障才继续按原状态码上报。 - if error.status_code().is_client_error() { - image_source_unavailable() - } else { - error - } - })?; - let (resolved_kind, object_key) = match resolved { - EditorReferenceRecord::ProjectResource(resource) => ("resource", resource.object_key), - EditorReferenceRecord::Asset(asset) => ("asset", asset.object_key), - }; - if resolved_kind != requested_kind { - return Err(image_source_kind_mismatch(requested_kind, resolved_kind)); + let resolved = resolve(reference_id.to_string()).await.map_err(|error| { + // 未登记、跨 owner 与已删除在 provider 侧都是同一类“引用不可用”, + // 这里也收敛成同一句 400;只有基础设施故障才继续按原状态码上报。 + if error.status_code().is_client_error() { + image_source_unavailable() + } else { + error + } + })?; + if resolved.kind != requested_kind { + return Err(image_source_kind_mismatch(requested_kind, resolved.kind)); } - object_key.ok_or_else(|| image_source_unavailable()) + resolved.object_key.ok_or_else(image_source_unavailable) } /// 未登记、跨 owner、已删除与缺少对象键都收敛成同一句 400。 @@ -121,50 +156,129 @@ fn image_source_kind_mismatch(requested_kind: &str, resolved_kind: &str) -> AppE #[cfg(test)] mod tests { - /// 预检必须是定点、只读元数据的;一旦退回“列工程 / 列素材库再筛”的老路, - /// 或提前把图片正文读出来,这条断言会失败。 - #[test] - fn image_source_preflight_stays_narrow_and_metadata_only() { - let source = include_str!("image_source.rs"); - let preflight = source_region( - source, - "pub(crate) async fn preflight_image_source(", - "pub(crate) async fn resolve_image_input(", - ); - let resolution = source_region( - source, - "async fn resolve_source_object_key(", - "fn image_source_unavailable(", - ); - assert!( - preflight.contains("resolve_source_object_key("), - "预检必须复用定点解析函数,而不是自己取对象键" - ); - assert!( - resolution.contains("resolve_editor_reference_record_by_id_for_owner("), - "定点解析必须复用既有的窄查询,而不是遍历工程或素材库" - ); - for forbidden in [ - "list_editor_projects", - "get_editor_asset_library", - "read_editor_reference_image_object_with_client(", - "upload_image(", - "submit(", - ] { - assert!(!preflight.contains(forbidden), "预检不得包含 {forbidden}"); - assert!( - !resolution.contains(forbidden), - "定点解析不得包含 {forbidden}" - ); + use super::*; + use serde_json::json; + + fn reason_of(error: &AppError) -> Option<&str> { + error.details()?.get("reason")?.as_str() + } + + fn probe_error(status: StatusCode) -> AppError { + AppError::from_status(status).with_details(json!({ "reason": "probe" })) + } + + fn resolved(kind: &'static str, object_key: Option<&str>) -> ResolvedReference { + ResolvedReference { + kind, + object_key: object_key.map(str::to_string), } } - /// 取两个标记之间的源码片段;两段都落在测试模块之前,断言自身不会被算进检查范围。 - fn source_region(source: &'static str, start: &str, end: &str) -> &'static str { - source - .split_once(start) - .and_then(|(_, rest)| rest.split_once(end)) - .map(|(body, _)| body) - .unwrap_or_else(|| panic!("应能在源码里定位 {start}")) + /// 解析只发起一次按 ID 的定点查询:查询到的 ID 必须与请求里的引用 ID 一致。 + #[tokio::test] + async fn image_source_resolution_queries_only_the_requested_reference_id() { + let source = Model3dGenerationSource::Resource { + resource_id: "res-1".to_string(), + }; + let mut queried: Vec = Vec::new(); + + let object_key = resolve_source_object_key_with(&source, |reference_id| { + queried.push(reference_id); + async { Ok(resolved("resource", Some("objects/res-1.png"))) } + }) + .await + .expect("同类型的已登记引用应解析出对象键"); + + assert_eq!(object_key, "objects/res-1.png"); + assert_eq!(queried, vec!["res-1".to_string()]); + } + + /// 记录类型与 `source` 分支不一致时报类型不符,而不是把对象键照用。 + #[tokio::test] + async fn image_source_resolution_rejects_kind_mismatch() { + let source = Model3dGenerationSource::Asset { + asset_id: "asset-1".to_string(), + }; + + let error = resolve_source_object_key_with(&source, |_| async { + Ok(resolved("resource", Some("objects/asset-1.png"))) + }) + .await + .expect_err("素材 ID 指向画布资源时必须报错"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + reason_of(&error), + Some("model3d-image-source-kind-mismatch") + ); + assert_eq!( + error.details().and_then(|details| details.get("field")), + Some(&json!("source.kind")) + ); + } + + /// 记录存在但缺对象键与「引用不可用」收敛成同一句 400。 + #[tokio::test] + async fn image_source_resolution_treats_missing_object_key_as_unavailable() { + let source = Model3dGenerationSource::Resource { + resource_id: "res-1".to_string(), + }; + + let error = + resolve_source_object_key_with(&source, |_| async { Ok(resolved("resource", None)) }) + .await + .expect_err("缺对象键时必须按引用不可用处理"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!(reason_of(&error), Some("model3d-image-source-unavailable")); + } + + /// 未登记 / 跨 owner / 已删除这类 4xx 收敛成同一句 400;基础设施故障保留原状态码。 + #[tokio::test] + async fn image_source_resolution_collapses_client_errors_but_keeps_infra_errors() { + let source = Model3dGenerationSource::Resource { + resource_id: "res-1".to_string(), + }; + + let collapsed = resolve_source_object_key_with(&source, |_| async { + Err(probe_error(StatusCode::NOT_FOUND)) + }) + .await + .expect_err("4xx 必须收敛"); + assert_eq!(collapsed.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + reason_of(&collapsed), + Some("model3d-image-source-unavailable") + ); + + let kept = resolve_source_object_key_with(&source, |_| async { + Err(probe_error(StatusCode::INTERNAL_SERVER_ERROR)) + }) + .await + .expect_err("基础设施故障必须继续报错"); + assert_eq!(kept.status_code(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(reason_of(&kept), Some("probe")); + } + + /// 本模块只做定点解析:一旦退回「列工程 / 列素材库再筛」的老路, + /// 这两个 token 会出现在源码里,断言立刻失败。 + /// + /// 预检不读字节、不调用 provider 由类型保证(`preflight_image_source` 不接 + /// provider client,也不碰 OSS),解析规则本身由上面的注入式用例覆盖。 + #[test] + fn image_source_module_never_lists_projects_or_asset_libraries() { + let source = include_str!("image_source.rs"); + + assert!( + source.contains("resolve_editor_reference_record_by_id_for_owner("), + "定点解析必须复用既有的按 ID 窄查询" + ); + // 用 concat! 拼出待检查的字面量,否则断言里的字面量会被 include_str! 自己命中。 + for forbidden in [ + concat!("list_editor_", "projects"), + concat!("get_editor_", "asset_library"), + ] { + assert!(!source.contains(forbidden), "本模块不得包含 {forbidden}"); + } } }