图生3D图片引用改为提交时定点预检

- image_source 拆成预检与执行两条路径,预检只做归属、记录类型与对象键校验,不读正文也不调用 provider

- 定点解析改用既有 resolve_editor_reference_record_by_id_for_owner,取代列工程与读素材库的宽查询

- image-to-model 路由在扣费与入队前调用预检,跨 owner、未登记、已删除与 kind 不符统一返回 400

- editor_project 把定点引用查询暴露为 crate 内可见,供生成入口复用

- 新增源码钉住用例,防止预检退回宽查询或提前读图片正文
This commit is contained in:
2026-09-21 15:08:46 +08:00
parent 63c924dba0
commit eb4256f883
3 changed files with 129 additions and 25 deletions
@@ -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,
@@ -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<TripoImageInput, AppError> {
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<String, AppError> {
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}"))
}
}
@@ -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(