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 4bc6665cc..cb7e5e499 100644 --- a/server-rs/crates/api-server/src/tripo3d/image_source.rs +++ b/server-rs/crates/api-server/src/tripo3d/image_source.rs @@ -2,12 +2,17 @@ //! //! 请求只给站内的画布资源 ID 或账号素材 ID,分两步处理: //! 1. 提交时只做元数据预检:按 ID 定点确认归属与记录类型,不读字节、不调用 provider, -//! 因此跨 owner、未登记与类型不符都在扣费与入队之前返回 400; +//! 因此跨 owner、未登记、已删除与类型不符都在扣费与入队之前失败; //! 2. worker 执行时重新确认同一事实,再从私有 OSS 读出字节,最后上传 provider 换 //! `file_token`。**不把带签名的临时地址交给第三方**,也不接受 URL / data URL。 //! //! 预检只按主键定点查引用,不拉取当前用户的工程列表或素材库;跨 owner、已删除与不存在 -//! 收敛成同一句 400,避免把别的账号是否存在该 ID 变成可探测信息。 +//! 收敛成同一句 400,避免把别的账号是否存在该 ID 变成可探测信息。只有这类「引用不可用」 +//! 才是 400:403(服务身份校验失败)、409(版本冲突)与 5xx 都是基础设施 / 并发问题, +//! 按原状态码上报;「记录已解析但缺对象键」是行本身不完整,按 502 上报。 +//! +//! 定点查询是注入到 `resolve_source_object_key_with` 的参数,解析每分支只发一次;测试按可观察 +//! 行为断言(查询次数、查询 ID、带出的对象键、各分支状态码),不依赖源码文本。 //! //! TODO(oss-direct-read): 「让 Tripo 侧直接读站内对象」这条替代路径(交签名 URL,或对端直读 //! OSS)能省掉一次字节中转与一份上行带宽,但等于把私有对象和带时效的读取凭据交给第三方, @@ -189,23 +194,40 @@ mod tests { } } - /// 解析只发起一次按 ID 的定点查询:查询到的 ID 必须与请求里的引用 ID 一致。 + /// 两个 source 分支都只发起一次按 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(); + for (source, requested_id, kind, object_key) in [ + ( + Model3dGenerationSource::Resource { + resource_id: "res-1".to_string(), + }, + "res-1", + "resource", + "objects/res-1.png", + ), + ( + Model3dGenerationSource::Asset { + asset_id: "asset-9".to_string(), + }, + "asset-9", + "asset", + "objects/asset-9.png", + ), + ] { + 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("同类型的已登记引用应解析出对象键"); + let resolved_key = resolve_source_object_key_with(&source, |reference_id| { + queried.push(reference_id); + async move { Ok(resolved(kind, Some(object_key))) } + }) + .await + .expect("同类型的已登记引用应解析出对象键"); - assert_eq!(object_key, "objects/res-1.png"); - assert_eq!(queried, vec!["res-1".to_string()]); + assert_eq!(resolved_key, object_key); + assert_eq!(queried, vec![requested_id.to_string()]); + } } /// 记录类型与 `source` 分支不一致时报类型不符,而不是把对象键照用。 @@ -287,26 +309,4 @@ mod tests { 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}"); - } - } }