新增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:
2026-09-21 14:24:41 +08:00
parent a8799adf5d
commit e809cdd850
4 changed files with 317 additions and 0 deletions
@@ -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())
}