新增api-server Tripo3D提交路由与入队

- tripo3d 目录按 job 身份、provider 配置、错误映射、入队与路由拆分小文件
- 两个内部路由要求 Bearer 与 Idempotency-Key,顺序固定为解析、组合校验、查价、入队
- 入队复用编辑器生成队列的幂等 dedupe key,同键同请求返回原 operation,同键不同请求 409
- Tripo job 走既有 editor-canvas 来源与单次尝试口径,不新建队列、不新增状态
- 提交响应返回 operation、job kind 与既有任务查询状态地址,不暴露 provider 信息
- 补齐 ExternalGenerationJobRecord 测试夹具新增的 checkpoint 字段
This commit is contained in:
2026-09-21 13:46:41 +08:00
parent d012392779
commit dd8f48bad9
12 changed files with 715 additions and 7 deletions
+1
View File
@@ -49,6 +49,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::editor_project::router(state.clone()))
.merge(modules::platform::router(state.clone()))
.merge(modules::external_generation::router(state.clone()))
.merge(crate::tripo3d::router(state.clone()))
.merge(modules::platform_support::router(state.clone()))
.merge(modules::raw::router(state.clone()))
.merge(modules::project_snapshots::router(state.clone()))
+2 -1
View File
@@ -1804,7 +1804,8 @@ mod tests {
DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS, DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS,
DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS,
DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS,
DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode,
DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, DEFAULT_TRIPO_BASE_URL,
DEFAULT_TRIPO_REQUEST_TIMEOUT_MS, DEFAULT_TRIPO_RETRIES, ExternalGenerationMode,
LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role,
tiantoken_api_key, tiantoken_base_url,
};
@@ -33,7 +33,8 @@ const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker";
const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024;
const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation";
const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation";
const EXTERNAL_API_REQUEST_FINGERPRINT_FIELD: &str = "_externalApiRequestFingerprint";
/// 队列 payload 由平台注入的幂等指纹字段;worker 侧解析请求前必须先摘掉它。
pub(crate) const EXTERNAL_API_REQUEST_FINGERPRINT_FIELD: &str = "_externalApiRequestFingerprint";
pub(crate) const GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX: &str =
"game-creator-client-generation";
pub(crate) const GAME_CREATOR_CLIENT_GENERATION_SOURCE: &str = "ai-game-creator-client";
@@ -78,7 +79,7 @@ where
.await
}
fn build_editor_generation_dedupe_key(
pub(crate) fn build_editor_generation_dedupe_key(
namespace: &str,
owner_user_id: &str,
job_kind: &str,
@@ -771,6 +772,8 @@ mod tests {
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: phase.map(ToOwned::to_owned),
provider_kind: None,
provider_task_id: None,
}
}
@@ -20708,6 +20708,8 @@ mod tests {
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: Some("processing".to_string()),
provider_kind: None,
provider_task_id: None,
}
}
@@ -1383,6 +1383,8 @@ mod tests {
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: None,
provider_kind: None,
provider_task_id: None,
}
}
@@ -2930,6 +2930,8 @@ mod tests {
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: Some("generating".to_string()),
provider_kind: None,
provider_task_id: None,
}
}
}
@@ -0,0 +1,99 @@
//! Tripo 生成对外错误的统一映射。
//!
//! 映射目标只有三档:参数问题 400、幂等冲突 409、provider / 配置问题 5xx。
//! 任何分支都不把 provider task id、带签名的临时地址或 SDK 类型带进响应。
use axum::http::StatusCode;
use platform_tripo::TripoError;
use serde_json::json;
use crate::{editor_generation_config::EditorGenerationPricingError, http_error::AppError};
use super::pricing::Model3dPricingError;
use super::provider::TRIPO_PROVIDER;
use super::validation::Model3dRequestError;
pub(crate) fn map_request_error(error: Model3dRequestError) -> AppError {
let field = error.field();
let mut details = json!({
"provider": TRIPO_PROVIDER,
"reason": "invalid-model3d-request",
"message": error.to_string(),
});
if let Some(field) = field
&& let Some(object) = details.as_object_mut()
{
object.insert("field".to_string(), json!(field));
}
AppError::from_status(StatusCode::BAD_REQUEST).with_details(details)
}
/// 定价未配置属于部署口径:接口直接拒绝,不扣费、不入队。
pub(crate) fn map_pricing_error(error: Model3dPricingError) -> AppError {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": TRIPO_PROVIDER,
"reason": "model3d-pricing-unavailable",
"message": error.to_string(),
}))
}
/// 定价快照本身读取失败属于服务端问题,与“3D 定价未配置”分开报。
pub(crate) fn map_pricing_store_error(error: EditorGenerationPricingError) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
}
/// provider 错误归一:参数类回 400,其余按“上游不可用/上游拒绝”分流。
pub(crate) fn map_provider_error(error: TripoError) -> AppError {
let (status, reason) = match &error {
TripoError::InvalidParameters { .. } | TripoError::SdkInvalidArgument(_) => {
(StatusCode::BAD_REQUEST, "tripo-invalid-parameters")
}
TripoError::Api { status, .. } if matches!(status, Some(400..=499)) => {
(StatusCode::BAD_REQUEST, "tripo-api-rejected")
}
TripoError::TaskFailure { .. } => (StatusCode::BAD_GATEWAY, "tripo-task-failed"),
TripoError::OutputSchema { .. } => (StatusCode::BAD_GATEWAY, "tripo-output-schema"),
TripoError::Request { .. } | TripoError::Sdk { .. } | TripoError::Api { .. } => {
(StatusCode::BAD_GATEWAY, "tripo-upstream-error")
}
};
AppError::from_status(status).with_details(json!({
"provider": TRIPO_PROVIDER,
"reason": reason,
"message": error.to_string(),
}))
}
pub(crate) fn oss_unavailable(message: &str) -> AppError {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"message": message,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_parameter_errors_map_to_bad_request() {
let error = map_provider_error(TripoError::InvalidParameters {
field: Some(platform_tripo::TripoField::Model),
reason: platform_tripo::TripoValidationReason::Required,
message: "model is required".to_string(),
});
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
}
#[test]
fn provider_transport_errors_map_to_bad_gateway() {
let error = map_provider_error(TripoError::Request {
message: "transport".to_string(),
status: None,
});
assert_eq!(error.status_code(), StatusCode::BAD_GATEWAY);
}
}
@@ -0,0 +1,183 @@
//! Tripo 3D 生成的队列身份。
//!
//! 两个端点是两个独立 job kind:队列行、worker 分支与 operation receipt 都按 job kind
//! 区分,结果类型也按端点严格区分,不做“一个 kind 带 mode 字段”的变形。
use serde_json::Value;
use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest;
use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest;
use super::pricing::Model3dEndpoint;
pub(crate) const MODEL3D_TEXT_TO_MODEL_JOB_KIND: &str = "model3d_text_to_model";
pub(crate) const MODEL3D_IMAGE_TO_MODEL_JOB_KIND: &str = "model3d_image_to_model";
/// Tripo submit 无法幂等重放:任何 attempt 失败都必须终态收口,所以固定单次尝试。
///
/// 崩溃后租约到期也按 attempt 耗尽结算为失败并冲正扣费,不存在“重新 claim 再 submit 一次”
/// 的窗口;checkpoint 只用于对账与防御性地拒绝二次 submit。
pub(crate) const MODEL3D_JOB_MAX_ATTEMPTS: u32 = 1;
/// 复用编辑器生成队列的 source module:现有 worker claim 只接受这一来源,本能力不自造队列。
pub(crate) const MODEL3D_JOB_SOURCE_MODULE: &str = "editor-canvas";
/// checkpoint 的 provider 标识;不含 provider task id,只说明 checkpoint 来自哪个 provider。
pub(crate) const MODEL3D_PROVIDER_KIND: &str = "tripo";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Model3dJobKind {
TextToModel,
ImageToModel,
}
impl Model3dJobKind {
pub(crate) const ALL: [Self; 2] = [Self::TextToModel, Self::ImageToModel];
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::TextToModel => MODEL3D_TEXT_TO_MODEL_JOB_KIND,
Self::ImageToModel => MODEL3D_IMAGE_TO_MODEL_JOB_KIND,
}
}
pub(crate) fn from_job_kind(job_kind: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|kind| kind.as_str() == job_kind.trim())
}
pub(crate) const fn endpoint(self) -> Model3dEndpoint {
match self {
Self::TextToModel => Model3dEndpoint::TextToModel,
Self::ImageToModel => Model3dEndpoint::ImageToModel,
}
}
/// 任务列表里对用户可见的阶段名;provider 与实际模型档位不出现在这里。
pub(crate) const fn request_label(self) -> &'static str {
match self {
Self::TextToModel => "文生 3D 模型",
Self::ImageToModel => "图生 3D 模型",
}
}
}
pub(crate) fn is_model3d_job_kind(job_kind: &str) -> bool {
Model3dJobKind::from_job_kind(job_kind).is_some()
}
/// 队列里保存的请求:就是 API 请求本身,不含任何平台执行中间态。
///
/// provider task id 属于 checkpoint 字段,不写入请求 payload,因此不参与请求指纹。
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Model3dJobRequest {
TextToModel(Model3dTextToModelRequest),
ImageToModel(Model3dImageToModelRequest),
}
impl Model3dJobRequest {
pub(crate) const fn job_kind(&self) -> Model3dJobKind {
match self {
Self::TextToModel(_) => Model3dJobKind::TextToModel,
Self::ImageToModel(_) => Model3dJobKind::ImageToModel,
}
}
/// 任务审计用的来源实体:项目落点用项目 ID,素材库落点用目录 ID。
pub(crate) fn source_entity_id(&self) -> String {
match self {
Self::TextToModel(request) => target_source_entity_id(&request.target),
Self::ImageToModel(request) => target_source_entity_id(&request.target),
}
}
}
fn target_source_entity_id(
target: &shared_contracts::model3d::common::Model3dGenerationTarget,
) -> String {
match target {
shared_contracts::model3d::common::Model3dGenerationTarget::ProjectResource {
project_id,
..
} => project_id.trim().to_string(),
shared_contracts::model3d::common::Model3dGenerationTarget::AssetLibrary {
folder_id,
..
} => folder_id.trim().to_string(),
}
}
/// 从队列 payload 还原请求。
///
/// 队列 payload 比 API 请求多一个幂等指纹字段,且请求类型是 `deny_unknown_fields`
/// 这里先摘掉平台自己注入的字段再反序列化,保证契约的严格性不被队列实现细节削弱。
pub(crate) fn parse_model3d_job_request(
job_kind: &str,
request_payload_json: &str,
) -> Result<Model3dJobRequest, String> {
let job_kind = Model3dJobKind::from_job_kind(job_kind)
.ok_or_else(|| format!("未知的 3D 生成任务类型:{job_kind}"))?;
let mut payload = serde_json::from_str::<Value>(request_payload_json)
.map_err(|error| format!("3D 生成任务参数不是合法 JSON{error}"))?;
if let Some(object) = payload.as_object_mut() {
object.remove(crate::editor_generation_queue::EXTERNAL_API_REQUEST_FINGERPRINT_FIELD);
}
match job_kind {
Model3dJobKind::TextToModel => serde_json::from_value(payload)
.map(Model3dJobRequest::TextToModel)
.map_err(|error| format!("文生 3D 任务参数解析失败:{error}")),
Model3dJobKind::ImageToModel => serde_json::from_value(payload)
.map(Model3dJobRequest::ImageToModel)
.map_err(|error| format!("图生 3D 任务参数解析失败:{error}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn job_kind_mapping_is_total_and_reversible() {
for kind in Model3dJobKind::ALL {
assert_eq!(Model3dJobKind::from_job_kind(kind.as_str()), Some(kind));
}
assert_eq!(
Model3dJobKind::from_job_kind("editor_image_generation"),
None
);
}
#[test]
fn job_request_is_parsed_after_stripping_platform_fingerprint() {
let payload = json!({
"generation": {
"prompt": "一只木箱",
"model": "v3.1-20260211",
"texture": true,
"pbr": true,
"textureQuality": "standard",
"geometryQuality": "standard",
"quad": false,
"smartLowPoly": false,
"generateParts": false
},
"target": { "kind": "projectResource", "projectId": "project-1" },
"_externalApiRequestFingerprint": "fingerprint-1"
})
.to_string();
let request =
parse_model3d_job_request(MODEL3D_TEXT_TO_MODEL_JOB_KIND, &payload).expect("应可解析");
assert_eq!(request.job_kind(), Model3dJobKind::TextToModel);
assert_eq!(request.source_entity_id(), "project-1");
}
#[test]
fn job_request_rejects_payload_without_fingerprint_stripping_support() {
let payload = json!({ "prompt": "一只木箱" }).to_string();
assert!(parse_model3d_job_request(MODEL3D_TEXT_TO_MODEL_JOB_KIND, &payload).is_err());
assert!(parse_model3d_job_request("unknown_job_kind", &payload).is_err());
}
}
+13 -4
View File
@@ -4,9 +4,18 @@
//! 异步执行复用现有 `external_generation_job` 队列与查询接口,不再造第二套任务模型;
//! 与历史 Hyper3D 能力(`platform-hyper3d`、`/api/assets/hyper3d/*`)没有任何共用。
// 里程碑二的路由与 worker 接上之前,这两个模块只有测试消费者;
// 接入后应删除这层豁免,让未使用项重新暴露出来。
#[allow(dead_code)]
use axum::Router;
use crate::state::AppState;
pub(crate) mod errors;
pub(crate) mod job;
pub(crate) mod pricing;
#[allow(dead_code)]
pub(crate) mod provider;
pub(crate) mod queue;
pub(crate) mod routes;
pub(crate) mod validation;
pub(crate) fn router(state: AppState) -> Router<AppState> {
routes::router(state)
}
@@ -0,0 +1,85 @@
//! Tripo provider 客户端的构造入口。
//!
//! 这里只做“配置是否可用”的判断:缺地址或密钥属于部署问题,返回 503,
//! 调用方据此在扣费、入队与任何 provider 调用之前停止。
use std::time::Duration;
use axum::http::StatusCode;
use platform_tripo::{TripoError, TripoProviderClient, TripoSettings};
use serde_json::json;
use crate::{config::AppConfig, http_error::AppError};
pub(crate) const TRIPO_PROVIDER: &str = "tripo-3d";
const TRIPO_USER_AGENT: &str = "genarrative-api-server-tripo/1";
pub(crate) fn tripo_settings(config: &AppConfig) -> Result<TripoSettings, AppError> {
let base_url = config.tripo_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(not_configured(
"TRIPO_BASE_URL 未配置,无法调用 Tripo 3D 生成服务。",
));
}
let api_key = config
.tripo_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| not_configured("TRIPO_API_KEY 未配置,无法调用 Tripo 3D 生成服务。"))?;
Ok(TripoSettings::new(
api_key.to_string(),
base_url.to_string(),
Duration::from_millis(config.tripo_request_timeout_ms.max(1)),
config.tripo_retries,
TRIPO_USER_AGENT.to_string(),
))
}
pub(crate) fn tripo_provider_client(config: &AppConfig) -> Result<TripoProviderClient, AppError> {
let settings = tripo_settings(config)?;
TripoProviderClient::new(settings).map_err(map_provider_client_error)
}
fn not_configured(message: &str) -> AppError {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": TRIPO_PROVIDER,
"reason": "tripo-not-configured",
"message": message,
}))
}
fn map_provider_client_error(error: TripoError) -> AppError {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": TRIPO_PROVIDER,
"reason": "tripo-client-init-failed",
"message": error.to_string(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_api_key_is_reported_as_unavailable() {
let mut config = AppConfig::default();
config.tripo_api_key = None;
let error = tripo_settings(&config).expect_err("缺 API Key 必须拒绝");
assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE);
config.tripo_api_key = Some(" ".to_string());
assert!(tripo_settings(&config).is_err(), "空白密钥同样必须拒绝");
}
#[test]
fn configured_settings_trim_trailing_slash() {
let mut config = AppConfig::default();
config.tripo_base_url = "https://openapi.tripo3d.com/v3/".to_string();
config.tripo_api_key = Some("test-key".to_string());
let settings = tripo_settings(&config).expect("配置完整应可构造");
assert_eq!(settings.base_url, "https://openapi.tripo3d.com/v3");
assert_eq!(settings.api_key, "test-key");
}
}
@@ -0,0 +1,102 @@
//! Tripo 生成的入队与提交响应。
//!
//! 复用编辑器生成队列的既有幂等能力:`Idempotency-Key` 决定 dedupe key,同键同请求
//! 返回原 operation,同键不同请求 409。这里不额外造队列,也不复制扣费逻辑。
use serde::Serialize;
use shared_contracts::external_generation::ExternalGenerationJobStatusRecord;
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::ExternalGenerationJobRecord;
use crate::{
editor_generation_queue::{
build_editor_generation_dedupe_key, editor_generation_queue_state,
enqueue_editor_generation_job_with_identity,
},
http_error::AppError,
state::AppState,
};
use super::job::{MODEL3D_JOB_SOURCE_MODULE, Model3dJobKind};
/// 3D 生成自己的幂等命名空间,避免与图片 / 视频生成共用 dedupe key。
const MODEL3D_GENERATION_DEDUPE_PREFIX: &str = "model3d-generation";
/// 提交成功后的 operation 描述;状态仍然由既有任务查询接口提供权威读取。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Model3dSubmissionResponse {
pub(crate) operation_id: String,
pub(crate) job_kind: String,
pub(crate) status: ExternalGenerationJobStatusRecord,
pub(crate) status_url: String,
}
impl Model3dSubmissionResponse {
pub(crate) fn from_job(job: ExternalGenerationJobRecord) -> Self {
let status = editor_generation_queue_state(job.clone());
Self {
operation_id: job.job_id.clone(),
job_kind: job.job_kind,
status,
status_url: model3d_job_status_url(job.job_id.as_str()),
}
}
}
pub(crate) fn model3d_job_status_url(job_id: &str) -> String {
format!("/api/runtime/external-generation/jobs/{job_id}")
}
pub(crate) async fn enqueue_model3d_job<T>(
state: &AppState,
owner_user_id: &str,
kind: Model3dJobKind,
source_entity_id: &str,
price_mud_points: u64,
payload: &T,
idempotency_key: &str,
) -> Result<ExternalGenerationJobRecord, AppError>
where
T: Serialize,
{
let dedupe_key = build_editor_generation_dedupe_key(
MODEL3D_GENERATION_DEDUPE_PREFIX,
owner_user_id,
kind.as_str(),
idempotency_key,
);
enqueue_editor_generation_job_with_identity(
state,
owner_user_id,
kind.as_str(),
source_entity_id.to_string(),
kind.request_label(),
price_mud_points,
payload,
build_prefixed_uuid_id("task-"),
dedupe_key,
)
.await
}
/// 队列来源模块必须是既有编辑器生成队列;worker claim 只认这一来源。
pub(crate) const MODEL3D_QUEUE_SOURCE_MODULE: &str = MODEL3D_JOB_SOURCE_MODULE;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_url_points_at_existing_queue_query() {
assert_eq!(
model3d_job_status_url("task-1"),
"/api/runtime/external-generation/jobs/task-1"
);
}
#[test]
fn queue_source_module_stays_on_editor_generation_queue() {
assert_eq!(MODEL3D_QUEUE_SOURCE_MODULE, "editor-canvas");
}
}
@@ -0,0 +1,219 @@
//! Tripo 生成的内部提交入口。
//!
//! 执行顺序固定:解析 JSON → 平台组合校验(含 provider 预检)→ 查价 → 入队。
//! 任何一步失败都发生在扣费与 provider 调用之前;状态读取复用既有任务查询接口。
use axum::{
Json, Router,
extract::{State, rejection::JsonRejection},
http::{HeaderMap, StatusCode},
response::Response,
routing::post,
};
use serde::de::DeserializeOwned;
use serde_json::json;
use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest;
use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest;
use crate::{
api_response::json_success_body,
auth::{AuthenticatedAccessToken, require_bearer_auth},
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
use super::errors::{map_pricing_error, map_pricing_store_error, map_request_error};
use super::job::Model3dJobKind;
use super::queue::{Model3dSubmissionResponse, enqueue_model3d_job};
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";
pub(crate) const IMAGE_TO_MODEL_ROUTE: &str = "/api/assets/tripo/image-to-model";
pub(crate) fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
TEXT_TO_MODEL_ROUTE,
post(submit_text_to_model).route_layer(axum::middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
IMAGE_TO_MODEL_ROUTE,
post(submit_image_to_model).route_layer(axum::middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}
pub(crate) async fn submit_text_to_model(
State(state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
payload: Result<Json<Model3dTextToModelRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<serde_json::Value>), Response> {
let payload = parse_json_payload(&request_context, payload)?;
let idempotency_key = require_idempotency_key(&headers)?;
let query = validate_text_to_model_request(&payload)
.map_err(map_request_error)
.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(
&state,
authenticated.claims().user_id(),
Model3dJobKind::TextToModel,
source_entity_id.as_str(),
price_mud_points,
&payload,
idempotency_key,
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
Ok(accepted_response(&request_context, job))
}
pub(crate) async fn submit_image_to_model(
State(state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
payload: Result<Json<Model3dImageToModelRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<serde_json::Value>), Response> {
let payload = parse_json_payload(&request_context, payload)?;
let idempotency_key = require_idempotency_key(&headers)?;
let query = validate_image_to_model_request(&payload)
.map_err(map_request_error)
.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(
&state,
authenticated.claims().user_id(),
Model3dJobKind::ImageToModel,
source_entity_id.as_str(),
price_mud_points,
&payload,
idempotency_key,
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
Ok(accepted_response(&request_context, job))
}
fn accepted_response(
request_context: &RequestContext,
job: spacetime_client::ExternalGenerationJobRecord,
) -> (StatusCode, Json<serde_json::Value>) {
(
StatusCode::ACCEPTED,
json_success_body(
Some(request_context),
Model3dSubmissionResponse::from_job(job),
),
)
}
async fn resolve_price_mud_points(
state: &AppState,
request_context: &RequestContext,
query: &super::pricing::Model3dPricingQuery,
) -> Result<u64, Response> {
let pricing = state
.editor_generation_pricing()
.await
.map_err(map_pricing_store_error)
.map_err(|error| error.into_response_with_context(Some(request_context)))?;
match pricing.model3d_price(query) {
Ok(price_mud_points) => Ok(u64::from(price_mud_points)),
Err(error) => {
Err(map_pricing_error(error).into_response_with_context(Some(request_context)))
}
}
}
fn parse_json_payload<T: DeserializeOwned>(
request_context: &RequestContext,
payload: Result<Json<T>, JsonRejection>,
) -> Result<T, Response> {
payload.map(|Json(payload)| payload).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_details(json!({
"provider": super::provider::TRIPO_PROVIDER,
"message": error.body_text(),
}))
.into_response_with_context(Some(request_context))
})
}
fn target_source_entity_id(
target: &shared_contracts::model3d::common::Model3dGenerationTarget,
) -> String {
match target {
shared_contracts::model3d::common::Model3dGenerationTarget::ProjectResource {
project_id,
..
} => project_id.trim().to_string(),
shared_contracts::model3d::common::Model3dGenerationTarget::AssetLibrary {
folder_id,
..
} => folder_id.trim().to_string(),
}
}
/// 两个提交都必须显式给出 `Idempotency-Key`;缺失或格式非法一律 400,不静默生成键。
fn require_idempotency_key(headers: &HeaderMap) -> Result<&str, Response> {
let value = headers
.get("idempotency-key")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_details(json!({
"provider": super::provider::TRIPO_PROVIDER,
"message": "3D 生成提交必须携带 Idempotency-Key 请求头。",
}))
.into_response_with_context(None)
})?;
if value.len() > 128 || !value.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_details(json!({
"provider": super::provider::TRIPO_PROVIDER,
"message": "Idempotency-Key 必须是 1-128 个可打印 ASCII 字符,且不能包含空格。",
}))
.into_response_with_context(None));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
#[test]
fn idempotency_key_is_required_and_validated() {
let missing = HeaderMap::new();
assert!(require_idempotency_key(&missing).is_err());
let mut headers = HeaderMap::new();
headers.insert("idempotency-key", HeaderValue::from_static("issue-1"));
assert_eq!(
require_idempotency_key(&headers).expect("合法键应通过"),
"issue-1"
);
let mut blank = HeaderMap::new();
blank.insert("idempotency-key", HeaderValue::from_static(" "));
assert!(require_idempotency_key(&blank).is_err());
let mut spaced = HeaderMap::new();
spaced.insert("idempotency-key", HeaderValue::from_static("a b"));
assert!(require_idempotency_key(&spaced).is_err());
}
}