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()) +}