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