From eb4256f88364aed41734c8653709bdb84e212496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 15:08:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E7=94=9F3D=E5=9B=BE=E7=89=87=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E6=94=B9=E4=B8=BA=E6=8F=90=E4=BA=A4=E6=97=B6=E5=AE=9A?= =?UTF-8?q?=E7=82=B9=E9=A2=84=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - image_source 拆成预检与执行两条路径,预检只做归属、记录类型与对象键校验,不读正文也不调用 provider - 定点解析改用既有 resolve_editor_reference_record_by_id_for_owner,取代列工程与读素材库的宽查询 - image-to-model 路由在扣费与入队前调用预检,跨 owner、未登记、已删除与 kind 不符统一返回 400 - editor_project 把定点引用查询暴露为 crate 内可见,供生成入口复用 - 新增源码钉住用例,防止预检退回宽查询或提前读图片正文 --- .../crates/api-server/src/editor_project.rs | 2 +- .../api-server/src/tripo3d/image_source.rs | 146 +++++++++++++++--- .../crates/api-server/src/tripo3d/routes.rs | 6 + 3 files changed, 129 insertions(+), 25 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 02a3e3d99..5cc9a1fe0 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -4372,7 +4372,7 @@ fn ensure_editor_image_edit_target_matches_source( ) } -async fn resolve_editor_reference_record_by_id_for_owner( +pub(crate) async fn resolve_editor_reference_record_by_id_for_owner( state: &AppState, owner_user_id: &str, reference_id: &str, 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 6bf02c3ba..0b8977025 100644 --- a/server-rs/crates/api-server/src/tripo3d/image_source.rs +++ b/server-rs/crates/api-server/src/tripo3d/image_source.rs @@ -1,18 +1,24 @@ //! 图生 3D 的图片输入解析。 //! -//! 请求只给站内的画布资源 ID 或账号素材 ID,provider 需要的图片地址由这里解析: -//! 先按 ID 定点确认归属并取出对象键,再从私有 OSS 读出字节,最后上传 provider 换 -//! `file_token`。**不把带签名的临时地址交给第三方**,也不接受 URL / data URL。 +//! 请求只给站内的画布资源 ID 或账号素材 ID,分两步处理: +//! 1. 提交时只做元数据预检:按 ID 定点确认归属与记录类型,不读字节、不调用 provider, +//! 因此跨 owner、未登记与类型不符都在扣费与入队之前返回 400; +//! 2. worker 执行时重新确认同一事实,再从私有 OSS 读出字节,最后上传 provider 换 +//! `file_token`。**不把带签名的临时地址交给第三方**,也不接受 URL / data URL。 +//! +//! 预检只按主键定点查引用,不拉取当前用户的工程列表或素材库;跨 owner、已删除与不存在 +//! 收敛成同一句 400,避免把别的账号是否存在该 ID 变成可探测信息。 use axum::http::StatusCode; use platform_tripo::{TripoImageInput, TripoProviderClient}; use serde_json::json; use shared_contracts::model3d::common::Model3dGenerationSource; +use spacetime_client::EditorReferenceRecord; use crate::{ editor_project::{ - find_editor_registered_asset_object_key, find_editor_registered_resource_object_key, read_editor_reference_image_object_with_client, + resolve_editor_reference_record_by_id_for_owner, }, http_error::AppError, state::AppState, @@ -20,14 +26,25 @@ use crate::{ use super::{errors::map_provider_error, provider::TRIPO_PROVIDER}; -/// 把请求里的站内图片引用解析成 provider 可读的图片输入。 +/// 提交时的元数据预检:只回答“这个引用当前是否可用”,不产生任何 provider 副作用。 +pub(crate) async fn preflight_image_source( + state: &AppState, + owner_user_id: &str, + source: &Model3dGenerationSource, +) -> Result<(), AppError> { + resolve_source_object_key(state, owner_user_id, source) + .await + .map(|_| ()) +} + +/// worker 执行时把站内图片引用解析成 provider 可读的图片输入。 pub(crate) async fn resolve_image_input( state: &AppState, client: &TripoProviderClient, owner_user_id: &str, source: &Model3dGenerationSource, ) -> Result { - let object_key = resolve_object_key(state, owner_user_id, source).await?; + let object_key = resolve_source_object_key(state, owner_user_id, source).await?; let image = read_editor_reference_image_object_with_client( state, object_key.as_str(), @@ -44,27 +61,108 @@ pub(crate) async fn resolve_image_input( .map_err(map_provider_error) } -async fn resolve_object_key( +/// 定点解析出对象键:按 `source` 的分支校验记录类型,并确认归属与对象键都存在。 +async fn resolve_source_object_key( state: &AppState, owner_user_id: &str, source: &Model3dGenerationSource, ) -> Result { - let object_key = match source { - Model3dGenerationSource::Resource { resource_id } => { - find_editor_registered_resource_object_key(state, owner_user_id, resource_id).await? - } - Model3dGenerationSource::Asset { asset_id } => { - find_editor_registered_asset_object_key(state, owner_user_id, asset_id).await? - } + 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()), }; - // 未登记、跨 owner 与已删除都收敛成同一句 400:不区分它们,避免把别的账号是否 - // 存在该 ID 变成可探测信息。 - object_key.ok_or_else(|| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": TRIPO_PROVIDER, - "reason": "model3d-image-source-unavailable", - "field": "source", - "message": "图片输入必须是当前账号已登记的画布资源或素材。", - })) - }) + 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)); + } + object_key.ok_or_else(|| image_source_unavailable()) +} + +/// 未登记、跨 owner、已删除与缺少对象键都收敛成同一句 400。 +fn image_source_unavailable() -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "model3d-image-source-unavailable", + "field": "source", + "message": "图片输入必须是当前账号已登记的画布资源或素材。", + })) +} + +/// `kind` 与 ID 实际指向的记录类型不一致;两者都由调用方给出,不涉及他人数据是否存在。 +fn image_source_kind_mismatch(requested_kind: &str, resolved_kind: &str) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": TRIPO_PROVIDER, + "reason": "model3d-image-source-kind-mismatch", + "field": "source.kind", + "message": format!( + "source.kind 是 {requested_kind},但该 ID 指向 {resolved_kind} 记录,两者必须一致。" + ), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 预检必须是定点、只读元数据的;一旦退回“列工程 / 列素材库再筛”的老路, + /// 或提前把图片正文读出来,这条断言会失败。 + #[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}" + ); + } + } + + /// 取两个标记之间的源码片段;两段都落在测试模块之前,断言自身不会被算进检查范围。 + 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}")) + } } diff --git a/server-rs/crates/api-server/src/tripo3d/routes.rs b/server-rs/crates/api-server/src/tripo3d/routes.rs index 436c70256..e1a98e862 100644 --- a/server-rs/crates/api-server/src/tripo3d/routes.rs +++ b/server-rs/crates/api-server/src/tripo3d/routes.rs @@ -24,6 +24,7 @@ use crate::{ }; use super::errors::{map_pricing_error, map_pricing_store_error, map_request_error}; +use super::image_source::preflight_image_source; use super::job::Model3dJobKind; use super::queue::{Model3dSubmissionResponse, enqueue_model3d_job}; use super::validation::{validate_image_to_model_request, validate_text_to_model_request}; @@ -89,6 +90,11 @@ pub(crate) async fn submit_image_to_model( let query = validate_image_to_model_request(&payload) .map_err(map_request_error) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + // 图片输入在扣费与入队之前先做一次元数据预检:跨 owner、未登记或类型不符的引用 + // 直接 400,不产生 operation,也不扣费。worker 执行时会重新确认同一事实。 + preflight_image_source(&state, authenticated.claims().user_id(), &payload.source) + .await + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let price_mud_points = resolve_price_mud_points(&state, &request_context, &query).await?; let source_entity_id = target_source_entity_id(&payload.target); let job = enqueue_model3d_job(