diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 4c34bda86..32da03181 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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())) diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index ae144dd7b..24b4def19 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -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, }; diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index b615a870b..b60012be7 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -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, } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index b3af452cf..dc301e400 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, } } diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 6c5498870..70c6f6e8c 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -1383,6 +1383,8 @@ mod tests { notification_acknowledged_at: None, notification_acknowledged_at_micros: None, phase: None, + provider_kind: None, + provider_task_id: None, } } diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 73f65a096..c2fa99485 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -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, } } } diff --git a/server-rs/crates/api-server/src/tripo3d/errors.rs b/server-rs/crates/api-server/src/tripo3d/errors.rs new file mode 100644 index 000000000..48bb71169 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/errors.rs @@ -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); + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/job.rs b/server-rs/crates/api-server/src/tripo3d/job.rs new file mode 100644 index 000000000..962d399ee --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/job.rs @@ -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::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 { + let job_kind = Model3dJobKind::from_job_kind(job_kind) + .ok_or_else(|| format!("未知的 3D 生成任务类型:{job_kind}"))?; + let mut payload = serde_json::from_str::(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()); + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/mod.rs b/server-rs/crates/api-server/src/tripo3d/mod.rs index ea3c9d05e..d3ac4a911 100644 --- a/server-rs/crates/api-server/src/tripo3d/mod.rs +++ b/server-rs/crates/api-server/src/tripo3d/mod.rs @@ -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 { + routes::router(state) +} diff --git a/server-rs/crates/api-server/src/tripo3d/provider.rs b/server-rs/crates/api-server/src/tripo3d/provider.rs new file mode 100644 index 000000000..d0428717d --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/provider.rs @@ -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 { + 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 { + 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"); + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/queue.rs b/server-rs/crates/api-server/src/tripo3d/queue.rs new file mode 100644 index 000000000..29426d0f8 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/queue.rs @@ -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( + state: &AppState, + owner_user_id: &str, + kind: Model3dJobKind, + source_entity_id: &str, + price_mud_points: u64, + payload: &T, + idempotency_key: &str, +) -> Result +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"); + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/routes.rs b/server-rs/crates/api-server/src/tripo3d/routes.rs new file mode 100644 index 000000000..436c70256 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/routes.rs @@ -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 { + 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, + axum::extract::Extension(request_context): axum::extract::Extension, + axum::extract::Extension(authenticated): axum::extract::Extension, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result<(StatusCode, Json), 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, + axum::extract::Extension(request_context): axum::extract::Extension, + axum::extract::Extension(authenticated): axum::extract::Extension, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result<(StatusCode, Json), 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) { + ( + 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 { + 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( + request_context: &RequestContext, + payload: Result, JsonRejection>, +) -> Result { + 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()); + } +}