3D 提交在扣费前预检落点归属

- 新增 target 预检模块:画布项目按 (project_id, owner) 定点确认,素材库文件夹按 owner 的库匹配
- 两个提交路由都在查价与入队之前调用;跨 owner / 不存在的落点回 400 model3d-target-unavailable
- 只剩基础设施故障保留原状态码,并补了收敛口径的单测
This commit is contained in:
2026-09-22 15:21:25 +08:00
parent 8473877bc3
commit ee2d505eb3
3 changed files with 119 additions and 1 deletions
@@ -18,6 +18,7 @@ pub(crate) mod queue;
pub(crate) mod result;
pub(crate) mod routes;
pub(crate) mod storage;
pub(crate) mod target;
pub(crate) mod validation;
pub(crate) mod worker;
@@ -1,6 +1,6 @@
//! Tripo 生成的内部提交入口。
//!
//! 执行顺序固定:解析 JSON → 平台组合校验(含 provider 预检)→ 查价 → 入队。
//! 执行顺序固定:解析 JSON → 平台组合校验 → 输入预检 → 落点预检 → 查价 → 入队。
//! 任何一步失败都发生在扣费与 provider 调用之前;状态读取复用既有任务查询接口。
use axum::{
@@ -27,6 +27,7 @@ use super::errors::{map_pricing_error, map_pricing_store_error, map_request_erro
use super::image_source::preflight_image_source;
use super::job::Model3dJobKind;
use super::queue::{Model3dSubmissionResponse, enqueue_model3d_job};
use super::target::preflight_generation_target;
use super::validation::{validate_image_to_model_request, validate_text_to_model_request};
pub(crate) const TEXT_TO_MODEL_ROUTE: &str = "/api/assets/tripo/text-to-model";
@@ -62,6 +63,11 @@ pub(crate) async fn submit_text_to_model(
let query = validate_text_to_model_request(&payload)
.map_err(map_request_error)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
// 落点预检与参数校验一样前置于查价:跨 owner / 不存在的落点必须在这里就被拒,
// 不能等 worker 落库时才发现,那时 provider 预算已经花掉了。
preflight_generation_target(&state, authenticated.claims().user_id(), &payload.target)
.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(
@@ -95,6 +101,10 @@ pub(crate) async fn submit_image_to_model(
preflight_image_source(&state, authenticated.claims().user_id(), &payload.source)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
// 落点与图片输入一样,必须在查价、扣费与入队之前确认。
preflight_generation_target(&state, authenticated.claims().user_id(), &payload.target)
.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(
@@ -0,0 +1,107 @@
//! 3D 提交的落点预检。
//!
//! 与 `image_source` 的预检对称:在查价、扣费与入队之前,先按主键确认落点
//! (画布项目 / 素材库文件夹)属于当前 owner 且仍然存在。否则一次注定落库失败的提交
//! 会先成功入队、扣费并花掉 provider 预算,最后才在 worker 落库阶段失败。
//!
//! 跨 owner、已删除与不存在收敛成同一句 400,避免把「别人是否存在某个 ID」变成可探测信息;
//! 只有基础设施故障保留原状态码。worker 落库时会再确认同一事实。
use axum::http::StatusCode;
use serde_json::json;
use shared_contracts::model3d::common::Model3dGenerationTarget;
use spacetime_client::EditorProjectGetRecordInput;
use crate::{
editor_project::{current_utc_micros, map_editor_project_error},
http_error::AppError,
state::AppState,
};
use super::provider::TRIPO_PROVIDER;
/// 提交时的落点预检:只读元数据,不写记录,也不产生 provider 副作用。
pub(crate) async fn preflight_generation_target(
state: &AppState,
owner_user_id: &str,
target: &Model3dGenerationTarget,
) -> Result<(), AppError> {
match target {
Model3dGenerationTarget::ProjectResource { project_id, .. } => {
// 按 (project_id, owner) 定点读取:跨 owner 与已删除在 procedure 侧就是同一种失败。
state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.trim().to_string(),
owner_user_id: owner_user_id.to_string(),
})
.await
.map(|_| ())
.map_err(map_editor_project_error)
.map_err(collapse_target_unavailable)
}
Model3dGenerationTarget::AssetLibrary { folder_id, .. } => {
// 素材库目前没有「按 folder ID 定点读取」的 procedure,只能读整库再匹配;
// 落点是文件夹(不是素材),因此也不能用引用解析代替。等窄查询补齐后改用它。
let library = state
.spacetime_client()
.get_editor_asset_library(owner_user_id.to_string(), current_utc_micros())
.await
.map_err(map_editor_project_error)?;
let folder_id = folder_id.trim();
if library
.folders
.iter()
.any(|folder| folder.folder_id == folder_id)
{
Ok(())
} else {
Err(target_unavailable())
}
}
}
}
/// 未登记 / 跨 owner / 已删除都收敛成同一句 400;基础设施故障保留原状态码。
fn collapse_target_unavailable(error: AppError) -> AppError {
if error.status_code().is_client_error() {
target_unavailable()
} else {
error
}
}
fn target_unavailable() -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": TRIPO_PROVIDER,
"reason": "model3d-target-unavailable",
"field": "target",
"message": "生成结果的落点必须是当前账号已存在的画布项目或素材库文件夹。",
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_unavailable_is_stable_and_machine_readable() {
let error = target_unavailable();
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
let details = error.details().expect("details 必须存在");
assert_eq!(details["reason"], json!("model3d-target-unavailable"));
assert_eq!(details["field"], json!("target"));
}
/// 跨 owner / 不存在(4xx)对外只有一种说法;基础设施故障不能被误报成用户问题。
#[test]
fn only_client_errors_collapse_into_target_unavailable() {
let collapsed = collapse_target_unavailable(AppError::from_status(StatusCode::NOT_FOUND));
assert_eq!(collapsed.details(), target_unavailable().details());
let preserved =
collapse_target_unavailable(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR));
assert_eq!(preserved.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert_ne!(preserved.details(), target_unavailable().details());
}
}