新增api-server Tripo3D图片输入解析与产物对象写入
- editor_project 暴露按注册 ID 定点解析资源与素材对象键的入口,只认本账号已登记记录 - image_source 把站内引用解析成对象键后读字节并上传 provider 换 file_token,不把签名地址交给第三方 - storage 负责产物 PUT、HEAD 复核与 asset_object 登记,模型与预览图共用同一条链路 - asset_object ID 使用 operation 稳定派生值,避免被生成结果事务的白名单校验拒绝 - 完整字节写入路径留 TODO,等待 platform-oss 提供流式上传
This commit is contained in:
@@ -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<Option<String>, 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<Option<String>, 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,
|
||||
|
||||
@@ -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<TripoImageInput, AppError> {
|
||||
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<String, AppError> {
|
||||
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": "图片输入必须是当前账号已登记的画布资源或素材。",
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -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<AppState> {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! 3D 产物的 OSS 写入与对象登记。
|
||||
//!
|
||||
//! 模型与预览图都是“服务端已经落地的正式对象”,所以这里只做三件事:PUT、HEAD 复核、
|
||||
//! 生成 `asset_object` 输入。资源 / 素材行由 worker 走既有原子落库路径写入,本文件
|
||||
//! 不碰队列、不碰扣费、也不认识 provider 响应。
|
||||
//!
|
||||
//! TODO(stream): 本期沿用完整字节写入(`Vec<u8>`)后一次性 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<u8>,
|
||||
content_type: &str,
|
||||
) -> Result<StoredModel3dArtifact, AppError> {
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user