From e809cdd8503e8326db1388723ed8fb60d6232ce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 14:24:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9Eapi-server=20Tripo3D=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=BE=93=E5=85=A5=E8=A7=A3=E6=9E=90=E4=B8=8E=E4=BA=A7?= =?UTF-8?q?=E7=89=A9=E5=AF=B9=E8=B1=A1=E5=86=99=E5=85=A5=20-=20editor=5Fpr?= =?UTF-8?q?oject=20=E6=9A=B4=E9=9C=B2=E6=8C=89=E6=B3=A8=E5=86=8C=20ID=20?= =?UTF-8?q?=E5=AE=9A=E7=82=B9=E8=A7=A3=E6=9E=90=E8=B5=84=E6=BA=90=E4=B8=8E?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E5=AF=B9=E8=B1=A1=E9=94=AE=E7=9A=84=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E5=8F=AA=E8=AE=A4=E6=9C=AC=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=B7=B2=E7=99=BB=E8=AE=B0=E8=AE=B0=E5=BD=95=20-=20image=5Fsou?= =?UTF-8?q?rce=20=E6=8A=8A=E7=AB=99=E5=86=85=E5=BC=95=E7=94=A8=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E6=88=90=E5=AF=B9=E8=B1=A1=E9=94=AE=E5=90=8E=E8=AF=BB?= =?UTF-8?q?=E5=AD=97=E8=8A=82=E5=B9=B6=E4=B8=8A=E4=BC=A0=20provider=20?= =?UTF-8?q?=E6=8D=A2=20file=5Ftoken=EF=BC=8C=E4=B8=8D=E6=8A=8A=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E5=9C=B0=E5=9D=80=E4=BA=A4=E7=BB=99=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E6=96=B9=20-=20storage=20=E8=B4=9F=E8=B4=A3=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=20PUT=E3=80=81HEAD=20=E5=A4=8D=E6=A0=B8=E4=B8=8E=20asset=5Fobj?= =?UTF-8?q?ect=20=E7=99=BB=E8=AE=B0=EF=BC=8C=E6=A8=A1=E5=9E=8B=E4=B8=8E?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E5=9B=BE=E5=85=B1=E7=94=A8=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E9=93=BE=E8=B7=AF=20-=20asset=5Fobject=20ID=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20operation=20=E7=A8=B3=E5=AE=9A=E6=B4=BE?= =?UTF-8?q?=E7=94=9F=E5=80=BC=EF=BC=8C=E9=81=BF=E5=85=8D=E8=A2=AB=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=BB=93=E6=9E=9C=E4=BA=8B=E5=8A=A1=E7=9A=84=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E6=A0=A1=E9=AA=8C=E6=8B=92=E7=BB=9D=20-=20?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E5=AD=97=E8=8A=82=E5=86=99=E5=85=A5=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E7=95=99=20TODO=EF=BC=8C=E7=AD=89=E5=BE=85=20platform?= =?UTF-8?q?-oss=20=E6=8F=90=E4=BE=9B=E6=B5=81=E5=BC=8F=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../crates/api-server/src/editor_project.rs | 45 ++++ .../api-server/src/tripo3d/image_source.rs | 70 ++++++ .../crates/api-server/src/tripo3d/mod.rs | 2 + .../crates/api-server/src/tripo3d/storage.rs | 200 ++++++++++++++++++ 4 files changed, 317 insertions(+) create mode 100644 server-rs/crates/api-server/src/tripo3d/image_source.rs create mode 100644 server-rs/crates/api-server/src/tripo3d/storage.rs diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index dc301e400..ee78451b3 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -13256,6 +13256,51 @@ async fn resolve_editor_reference_object_key( } } +/// 按画布项目资源 ID 定点解析出当前 owner 已登记的对象键。 +/// +/// 只认本账号已登记的 `resource_id`:未登记、属于他人、或该资源没有可用对象键都返回 +/// `Ok(None)`,由调用方决定对外文案。读取失败仍按项目读取错误向上抛。 +pub(crate) async fn find_editor_registered_resource_object_key( + state: &AppState, + owner_user_id: &str, + resource_id: &str, +) -> Result, AppError> { + let resource_id = resource_id.trim(); + if resource_id.is_empty() { + return Ok(None); + } + let projects = state + .spacetime_client() + .list_editor_projects(owner_user_id.trim().to_string()) + .await + .map_err(map_editor_project_error)?; + Ok(find_editor_reference_resource_object_key_by_registered_id( + projects.as_slice(), + resource_id, + )) +} + +/// 按账号素材 ID 定点解析出当前 owner 已登记的对象键;语义与资源版本一致。 +pub(crate) async fn find_editor_registered_asset_object_key( + state: &AppState, + owner_user_id: &str, + asset_id: &str, +) -> Result, AppError> { + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + return Ok(None); + } + let library = state + .spacetime_client() + .get_editor_asset_library(owner_user_id.trim().to_string(), current_utc_micros()) + .await + .map_err(map_editor_project_error)?; + Ok(find_editor_reference_asset_object_key_by_registered_id( + library.assets.as_slice(), + asset_id, + )) +} + async fn find_editor_reference_object_key_by_registered_id( state: &AppState, owner_user_id: &str, diff --git a/server-rs/crates/api-server/src/tripo3d/image_source.rs b/server-rs/crates/api-server/src/tripo3d/image_source.rs new file mode 100644 index 000000000..6bf02c3ba --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/image_source.rs @@ -0,0 +1,70 @@ +//! 图生 3D 的图片输入解析。 +//! +//! 请求只给站内的画布资源 ID 或账号素材 ID,provider 需要的图片地址由这里解析: +//! 先按 ID 定点确认归属并取出对象键,再从私有 OSS 读出字节,最后上传 provider 换 +//! `file_token`。**不把带签名的临时地址交给第三方**,也不接受 URL / data URL。 + +use axum::http::StatusCode; +use platform_tripo::{TripoImageInput, TripoProviderClient}; +use serde_json::json; +use shared_contracts::model3d::common::Model3dGenerationSource; + +use crate::{ + editor_project::{ + find_editor_registered_asset_object_key, find_editor_registered_resource_object_key, + read_editor_reference_image_object_with_client, + }, + http_error::AppError, + state::AppState, +}; + +use super::{errors::map_provider_error, provider::TRIPO_PROVIDER}; + +/// 把请求里的站内图片引用解析成 provider 可读的图片输入。 +pub(crate) async fn resolve_image_input( + state: &AppState, + client: &TripoProviderClient, + owner_user_id: &str, + source: &Model3dGenerationSource, +) -> Result { + let object_key = resolve_object_key(state, owner_user_id, source).await?; + let image = read_editor_reference_image_object_with_client( + state, + object_key.as_str(), + state.editor_oss_http_client(), + ) + .await?; + client + .upload_image( + image.bytes, + image.file_name.as_str(), + image.mime_type.as_str(), + ) + .await + .map_err(map_provider_error) +} + +async fn resolve_object_key( + state: &AppState, + owner_user_id: &str, + source: &Model3dGenerationSource, +) -> Result { + 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? + } + }; + // 未登记、跨 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": "图片输入必须是当前账号已登记的画布资源或素材。", + })) + }) +} diff --git a/server-rs/crates/api-server/src/tripo3d/mod.rs b/server-rs/crates/api-server/src/tripo3d/mod.rs index d3ac4a911..68459b5e1 100644 --- a/server-rs/crates/api-server/src/tripo3d/mod.rs +++ b/server-rs/crates/api-server/src/tripo3d/mod.rs @@ -9,11 +9,13 @@ use axum::Router; use crate::state::AppState; pub(crate) mod errors; +pub(crate) mod image_source; pub(crate) mod job; pub(crate) mod pricing; pub(crate) mod provider; pub(crate) mod queue; pub(crate) mod routes; +pub(crate) mod storage; pub(crate) mod validation; pub(crate) fn router(state: AppState) -> Router { diff --git a/server-rs/crates/api-server/src/tripo3d/storage.rs b/server-rs/crates/api-server/src/tripo3d/storage.rs new file mode 100644 index 000000000..c56bded92 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/storage.rs @@ -0,0 +1,200 @@ +//! 3D 产物的 OSS 写入与对象登记。 +//! +//! 模型与预览图都是“服务端已经落地的正式对象”,所以这里只做三件事:PUT、HEAD 复核、 +//! 生成 `asset_object` 输入。资源 / 素材行由 worker 走既有原子落库路径写入,本文件 +//! 不碰队列、不碰扣费、也不认识 provider 响应。 +//! +//! TODO(stream): 本期沿用完整字节写入(`Vec`)后一次性 PUT,模型产物约几十 MB; +//! 后续 `platform-oss` 支持流式 / 分片上传后,这里只保留对象键与元数据构造。 + +use std::collections::BTreeMap; + +use axum::http::StatusCode; +use module_assets::{ + AssetObjectAccessPolicy, AssetObjectUpsertInput, build_asset_object_upsert_input, +}; +use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use shared_contracts::editor_generation::editor_generation_stable_asset_object_id; + +use crate::{ + editor_project::{ + EditorGenerationCaller, current_utc_micros, map_editor_asset_field_error, + sanitize_editor_storage_segment, + }, + http_error::AppError, + platform_errors::map_oss_error, + state::AppState, +}; + +use super::{errors::oss_unavailable, job::MODEL3D_PROVIDER_KIND, provider::TRIPO_PROVIDER}; + +/// 3D 结果的语义类别;编辑器画布不消费它,但资源 / 素材行必须带上。 +pub(crate) const MODEL3D_ASSET_KIND: &str = "model3d"; + +/// `asset_object.entity_kind`:说明这个对象属于 3D 生成产物。 +const MODEL3D_OBJECT_ENTITY_KIND: &str = "model3d"; + +const MODEL3D_OBJECT_PATH_SEGMENT: &str = "model3d"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Model3dArtifactSlot { + Model, + Preview, +} + +impl Model3dArtifactSlot { + const fn file_stem(self) -> &'static str { + match self { + Self::Model => "model", + Self::Preview => "preview", + } + } + + /// provider 未给出可用 content type 时的兜底扩展名。 + const fn fallback_extension(self) -> &'static str { + match self { + Self::Model => "glb", + Self::Preview => "webp", + } + } +} + +/// 已落地的产物:对象键与对象元数据来自 HEAD 复核,不是 PUT 的入参原样回填。 +pub(crate) struct StoredModel3dArtifact { + pub(crate) object_key: String, + pub(crate) content_type: String, + pub(crate) content_length: u64, + pub(crate) sha256: String, + pub(crate) asset_object: AssetObjectUpsertInput, +} + +pub(crate) async fn store_model3d_artifact( + state: &AppState, + caller: &EditorGenerationCaller, + slot: Model3dArtifactSlot, + bytes: Vec, + content_type: &str, +) -> Result { + let operation = caller.operation.as_ref().ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": TRIPO_PROVIDER, + "message": "写入 3D 产物时缺少稳定 operation 上下文。", + })) + })?; + let owner_user_id = caller.owner_user_id.as_str(); + let job_id = operation.operation_id.as_str(); + let oss_client = state + .oss_client() + .ok_or_else(|| oss_unavailable("OSS 未完成环境变量配置,无法写入 3D 产物。"))?; + let http_client = state.editor_oss_http_client(); + let content_type = normalize_artifact_content_type(content_type, slot); + let sha256 = sha256_hex(bytes.as_slice()); + let file_name = format!( + "{}.{}", + slot.file_stem(), + artifact_extension(content_type.as_str(), slot) + ); + let put_result = oss_client + .put_object( + http_client, + OssPutObjectRequest { + prefix: LegacyAssetPrefix::CharacterDrafts, + path_segments: vec![ + "editor".to_string(), + MODEL3D_OBJECT_PATH_SEGMENT.to_string(), + sanitize_editor_storage_segment(job_id, "task"), + ], + file_name, + content_type: Some(content_type.clone()), + access: OssObjectAccess::Private, + metadata: BTreeMap::from([ + ("asset_kind".to_string(), MODEL3D_ASSET_KIND.to_string()), + ("owner_user_id".to_string(), owner_user_id.to_string()), + ( + "entity_kind".to_string(), + MODEL3D_OBJECT_ENTITY_KIND.to_string(), + ), + ("entity_id".to_string(), job_id.to_string()), + ("slot".to_string(), slot.file_stem().to_string()), + ("provider".to_string(), MODEL3D_PROVIDER_KIND.to_string()), + ]), + body: bytes, + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let head = oss_client + .head_object( + http_client, + OssHeadObjectRequest { + object_key: put_result.object_key.clone(), + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let now_micros = current_utc_micros(); + // asset_object 的 ID 必须是 (owner, operation_kind, operation_id, slot) 的稳定派生值, + // 否则 SpacetimeDB 侧会以“生成结果 asset object ID 不是 operation 稳定 ID”拒绝整笔提交。 + let asset_object = build_asset_object_upsert_input( + editor_generation_stable_asset_object_id( + owner_user_id, + operation.operation_kind.as_str(), + job_id, + slot.file_stem(), + ), + head.bucket, + head.object_key.clone(), + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(content_type.clone())), + head.content_length, + Some(sha256.clone()), + MODEL3D_ASSET_KIND.to_string(), + Some(job_id.to_string()), + Some(owner_user_id.to_string()), + None, + Some(job_id.to_string()), + now_micros, + ) + .map_err(map_editor_asset_field_error)?; + Ok(StoredModel3dArtifact { + object_key: head.object_key, + content_type, + content_length: head.content_length, + sha256, + asset_object, + }) +} + +fn normalize_artifact_content_type(raw: &str, slot: Model3dArtifactSlot) -> String { + let normalized = raw + .split(';') + .next() + .map(str::trim) + .unwrap_or_default() + .to_ascii_lowercase(); + if normalized.is_empty() || normalized == "application/octet-stream" { + return match slot { + Model3dArtifactSlot::Model => "model/gltf-binary".to_string(), + Model3dArtifactSlot::Preview => "image/webp".to_string(), + }; + } + normalized +} + +fn artifact_extension(content_type: &str, slot: Model3dArtifactSlot) -> &'static str { + match content_type { + "model/gltf-binary" => "glb", + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpg", + "image/webp" => "webp", + _ => slot.fallback_extension(), + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +}