Files
Genarrative/server-rs/crates/api-server/src/assets.rs
T
2026-04-27 14:23:19 +08:00

1393 lines
51 KiB
Rust

use axum::{
Json,
extract::{Extension, Query, State},
http::StatusCode,
};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, INITIAL_ASSET_OBJECT_VERSION,
build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id,
generate_asset_object_id, normalize_optional_value, validate_asset_object_fields,
};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPostObjectRequest,
OssSignedGetObjectUrlRequest,
};
use serde_json::{Value, json};
use shared_contracts::assets::{
AssetBindingPayload, AssetHistoryEntryPayload, AssetHistoryListResponse, AssetHistoryQuery,
AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest, BindAssetObjectResponse,
ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, ConfirmAssetObjectResponse,
CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadTicketPayload,
GetAssetReadUrlResponse, GetReadUrlQuery,
};
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] = [
"character_visual",
"scene_image",
"puzzle_cover_image",
];
pub async fn create_direct_upload_ticket(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<CreateDirectUploadTicketRequest>,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let legacy_prefix = LegacyAssetPrefix::parse(&payload.legacy_prefix).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "legacyPrefix",
"supported": platform_oss::LEGACY_PUBLIC_PREFIXES,
}))
})?;
let signed = oss_client
.sign_post_object(OssPostObjectRequest {
prefix: legacy_prefix,
path_segments: payload.path_segments,
file_name: payload.file_name,
content_type: payload.content_type,
access: payload.access.unwrap_or(OssObjectAccess::Private),
metadata: payload.metadata,
max_size_bytes: payload.max_size_bytes,
expire_seconds: payload.expire_seconds,
success_action_status: payload.success_action_status,
})
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
})?;
Ok(json_success_body(
Some(&request_context),
CreateDirectUploadTicketResponse {
upload: DirectUploadTicketPayload::from(signed),
},
))
}
pub async fn get_asset_read_url(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Query(query): Query<GetReadUrlQuery>,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let object_key = resolve_object_key_from_query(&query).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "objectKey",
"reason": "必须提供 objectKey 或 legacyPublicPath",
}))
})?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key,
expire_seconds: query.expire_seconds,
})
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
})?;
Ok(json_success_body(
Some(&request_context),
GetAssetReadUrlResponse {
read: AssetReadUrlPayload::from(signed),
},
))
}
pub async fn get_asset_history(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Query(query): Query<AssetHistoryQuery>,
) -> Result<Json<Value>, AppError> {
let asset_kind = query.kind.trim().to_string();
if !is_supported_asset_history_kind(asset_kind.as_str()) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "kind",
"message": supported_asset_history_kind_message(),
})),
);
}
let entries = state
.spacetime_client()
.list_asset_history(module_assets::AssetHistoryListInput {
asset_kind,
limit: query.limit.unwrap_or(120).clamp(1, 120),
})
.await
.map_err(map_confirm_asset_object_error)?;
Ok(json_success_body(
Some(&request_context),
AssetHistoryListResponse {
assets: entries
.into_iter()
.map(|entry| AssetHistoryEntryPayload {
owner_label: format_asset_owner_label(entry.owner_user_id.as_deref()),
asset_object_id: entry.asset_object_id,
asset_kind: entry.asset_kind,
image_src: entry.image_src,
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
entity_id: entry.entity_id,
created_at: entry.created_at,
updated_at: entry.updated_at,
})
.collect(),
},
))
}
pub async fn create_sts_upload_credentials(
Extension(_request_context): Extension<RequestContext>,
) -> Result<Json<Value>, AppError> {
Err(
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "aliyun-sts",
"enabled": false,
"reason": "当前上传主链为服务器上传 OSS,Web 端只负责读取,不开放浏览器 STS 写权限",
"fallback": "/api/assets/direct-upload-tickets",
})),
)
}
pub async fn confirm_asset_object(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<ConfirmAssetObjectRequest>,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let result = state
.spacetime_client()
.confirm_asset_object(
build_confirm_asset_object_upsert_input(oss_client, payload)
.await
.map_err(map_confirm_asset_object_prepare_error)?,
)
.await
.map_err(map_confirm_asset_object_error)?;
Ok(json_success_body(
Some(&request_context),
ConfirmAssetObjectResponse {
asset_object: AssetObjectPayload {
asset_object_id: result.asset_object_id,
bucket: result.bucket,
object_key: result.object_key,
access_policy: result.access_policy.as_str().to_string(),
content_type: result.content_type,
content_length: result.content_length,
content_hash: result.content_hash,
version: result.version,
source_job_id: result.source_job_id,
owner_user_id: result.owner_user_id,
profile_id: result.profile_id,
entity_id: result.entity_id,
asset_kind: result.asset_kind,
created_at: result.created_at,
updated_at: result.updated_at,
},
},
))
}
pub async fn bind_asset_object_to_entity(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<BindAssetObjectRequest>,
) -> Result<Json<Value>, AppError> {
let now_micros = current_utc_micros();
let input = build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
payload.asset_object_id,
payload.entity_kind,
payload.entity_id,
payload.slot,
payload.asset_kind,
payload.owner_user_id,
payload.profile_id,
now_micros,
)
.map_err(map_asset_entity_binding_prepare_error)?;
let result = state
.spacetime_client()
.bind_asset_object_to_entity(input)
.await
.map_err(map_confirm_asset_object_error)?;
Ok(json_success_body(
Some(&request_context),
BindAssetObjectResponse {
asset_binding: AssetBindingPayload {
binding_id: result.binding_id,
asset_object_id: result.asset_object_id,
entity_kind: result.entity_kind,
entity_id: result.entity_id,
slot: result.slot,
asset_kind: result.asset_kind,
owner_user_id: result.owner_user_id,
profile_id: result.profile_id,
created_at: result.created_at,
updated_at: result.updated_at,
},
},
))
}
fn resolve_object_key_from_query(query: &GetReadUrlQuery) -> Option<String> {
if let Some(object_key) = query
.object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(object_key.trim_start_matches('/').to_string());
}
query
.legacy_public_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.trim_start_matches('/').to_string())
}
fn format_asset_owner_label(owner_user_id: Option<&str>) -> String {
let Some(owner_user_id) = owner_user_id
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return "未记录账号".to_string();
};
format!("账号 {owner_user_id}")
}
fn is_supported_asset_history_kind(asset_kind: &str) -> bool {
SUPPORTED_ASSET_HISTORY_KINDS.contains(&asset_kind)
}
fn supported_asset_history_kind_message() -> String {
format!(
"历史素材类型只支持 {}",
SUPPORTED_ASSET_HISTORY_KINDS.join("、")
)
}
async fn build_confirm_asset_object_upsert_input(
oss_client: &platform_oss::OssClient,
payload: ConfirmAssetObjectRequest,
) -> Result<module_assets::AssetObjectUpsertInput, ConfirmAssetObjectPrepareError> {
let configured_bucket = oss_client.config_bucket().to_string();
let resolved_bucket = payload
.bucket
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(configured_bucket.as_str())
.to_string();
if resolved_bucket != configured_bucket {
return Err(ConfirmAssetObjectPrepareError::BucketMismatch);
}
validate_asset_object_fields(
&resolved_bucket,
&payload.object_key,
&payload.asset_kind,
INITIAL_ASSET_OBJECT_VERSION,
)
.map_err(ConfirmAssetObjectPrepareError::Field)?;
let head = oss_client
.head_object(
&reqwest::Client::new(),
OssHeadObjectRequest {
object_key: payload.object_key,
},
)
.await
.map_err(ConfirmAssetObjectPrepareError::Oss)?;
if let Some(expected_length) = payload.content_length
&& expected_length != head.content_length
{
return Err(ConfirmAssetObjectPrepareError::ContentLengthMismatch);
}
let now_micros = current_utc_micros();
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
resolved_bucket,
head.object_key,
payload
.access_policy
.map(map_confirm_asset_object_access_policy)
.unwrap_or(AssetObjectAccessPolicy::Private),
head.content_type
.or_else(|| normalize_optional_value(payload.content_type)),
head.content_length,
normalize_optional_value(payload.content_hash),
payload.asset_kind,
payload.source_job_id,
payload.owner_user_id,
payload.profile_id,
payload.entity_id,
now_micros,
)
.map_err(ConfirmAssetObjectPrepareError::Field)
}
fn map_confirm_asset_object_prepare_error(error: ConfirmAssetObjectPrepareError) -> AppError {
match error {
ConfirmAssetObjectPrepareError::BucketMismatch
| ConfirmAssetObjectPrepareError::ContentLengthMismatch
| ConfirmAssetObjectPrepareError::Field(_) => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
ConfirmAssetObjectPrepareError::Oss(platform_oss::OssError::ObjectNotFound(_)) => {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
ConfirmAssetObjectPrepareError::Oss(_) => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
})),
}
}
fn map_asset_entity_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
}))
}
fn map_confirm_asset_object_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
#[derive(Debug)]
enum ConfirmAssetObjectPrepareError {
BucketMismatch,
ContentLengthMismatch,
Field(AssetObjectFieldError),
Oss(platform_oss::OssError),
}
impl std::fmt::Display for ConfirmAssetObjectPrepareError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BucketMismatch => f.write_str("bucket 与当前服务端 OSS bucket 不一致"),
Self::ContentLengthMismatch => {
f.write_str("客户端声明的 contentLength 与 OSS 实际对象大小不一致")
}
Self::Field(error) => write!(f, "{error}"),
Self::Oss(error) => write!(f, "{error}"),
}
}
}
fn map_confirm_asset_object_access_policy(
value: ConfirmAssetObjectAccessPolicy,
) -> AssetObjectAccessPolicy {
match value {
ConfirmAssetObjectAccessPolicy::Private => AssetObjectAccessPolicy::Private,
ConfirmAssetObjectAccessPolicy::PublicRead => AssetObjectAccessPolicy::PublicRead,
}
}
#[cfg(test)]
mod tests {
use std::{
collections::BTreeMap,
error::Error,
fs,
path::{Path, PathBuf},
time::SystemTime,
};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use http_body_util::BodyExt;
use httpdate::fmt_http_date;
use reqwest::{Method, multipart};
use serde_json::{Value, json};
use sha1::Sha1;
use shared_kernel::new_uuid_simple_string;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
type HmacSha1 = Hmac<Sha1>;
#[test]
fn asset_history_kind_support_includes_puzzle_cover_image() {
assert!(super::is_supported_asset_history_kind("character_visual"));
assert!(super::is_supported_asset_history_kind("scene_image"));
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
assert!(!super::is_supported_asset_history_kind("puzzle_preview_image"));
}
#[test]
fn asset_history_kind_message_lists_all_supported_kinds() {
assert_eq!(
super::supported_asset_history_kind_message(),
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image"
);
}
#[tokio::test]
async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/direct-upload-tickets")
.header("content-type", "application/json")
.body(Body::from(
json!({
"legacyPrefix": "/generated-characters/*",
"pathSegments": ["hero", "visual", "asset-01"],
"fileName": "master.png"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["code"],
Value::String("SERVICE_UNAVAILABLE".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("aliyun-oss".to_string())
);
}
#[tokio::test]
async fn direct_upload_ticket_returns_signed_payload_when_oss_configured() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/direct-upload-tickets")
.header("content-type", "application/json")
.header("x-request-id", "req-oss-ticket")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"legacyPrefix": "/generated-characters/*",
"pathSegments": ["hero_001", "visual", "asset_01"],
"fileName": "master.png",
"contentType": "image/png",
"metadata": {
"asset-kind": "character-visual"
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["data"]["upload"]["bucket"],
Value::String("genarrative-assets".to_string())
);
assert_eq!(
payload["data"]["upload"]["objectKey"],
Value::String("generated-characters/hero_001/visual/asset_01/master.png".to_string())
);
assert_eq!(
payload["data"]["upload"]["access"],
Value::String("private".to_string())
);
assert_eq!(
payload["data"]["upload"]["formFields"]["OSSAccessKeyId"],
Value::String("test-access-key-id".to_string())
);
assert!(payload["data"]["upload"].get("publicUrl").is_none());
}
#[tokio::test]
async fn read_url_returns_signed_private_object_url_when_oss_configured() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/assets/read-url?objectKey=generated-characters/hero_001/visual/asset_01/master.png")
.header("x-genarrative-response-envelope", "1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["data"]["read"]["objectKey"],
Value::String("generated-characters/hero_001/visual/asset_01/master.png".to_string())
);
assert!(
payload["data"]["read"]["signedUrl"]
.as_str()
.is_some_and(|value| value.contains("OSSAccessKeyId=test-access-key-id"))
);
}
#[tokio::test]
async fn read_url_accepts_legacy_public_path_for_transition() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/assets/read-url?legacyPublicPath=%2Fgenerated-custom-world-scenes%2Fprofile_01%2Flandmark_01%2Fscene.png")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["read"]["objectKey"],
Value::String(
"generated-custom-world-scenes/profile_01/landmark_01/scene.png".to_string()
)
);
}
#[tokio::test]
async fn read_url_rejects_missing_identifier() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/assets/read-url")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn sts_upload_credentials_are_disabled_for_browser_writes() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/sts-upload-credentials")
.header("x-genarrative-response-envelope", "1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("aliyun-sts".to_string())
);
assert_eq!(payload["error"]["details"]["enabled"], Value::Bool(false));
assert!(payload["error"]["details"].get("credentials").is_none());
}
#[tokio::test]
async fn confirm_asset_object_returns_service_unavailable_when_oss_missing() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/objects/confirm")
.header("content-type", "application/json")
.body(Body::from(
json!({
"objectKey": "generated-characters/hero_001/visual/asset_404/master.png",
"assetKind": "character_visual"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn confirm_asset_object_rejects_bucket_mismatch_before_calling_oss() {
let config = AppConfig {
oss_bucket: Some("xushi-dev".to_string()),
oss_endpoint: Some("oss-cn-beijing.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/objects/confirm")
.header("content-type", "application/json")
.body(Body::from(
json!({
"bucket": "another-bucket",
"objectKey": "generated-characters/hero_001/visual/asset_404/master.png",
"assetKind": "character_visual"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"bucket 不一致应在发 OSS 请求前直接被拒绝"
);
}
#[tokio::test]
async fn bind_asset_object_rejects_missing_slot_before_calling_spacetime() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/objects/bind")
.header("content-type", "application/json")
.body(Body::from(
json!({
"assetObjectId": "assetobj_001",
"entityKind": "character",
"entityId": "hero_001",
"slot": " ",
"assetKind": "character_visual"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("asset-entity-binding".to_string())
);
}
#[tokio::test]
#[ignore = "需要本地 SpacetimeDB genarrative-dev 已启动并发布当前模块"]
async fn bind_asset_object_rejects_missing_asset_object_in_spacetime() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/objects/bind")
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"assetObjectId": "assetobj_missing_for_binding_test",
"entityKind": "character",
"entityId": "hero_001",
"slot": "primary_visual",
"assetKind": "character_visual"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
#[tokio::test]
#[ignore = "需要仓库根目录 .env / .env.local 中的真实 OSS 配置"]
async fn oss_live_roundtrip_works_with_private_bucket() {
let config = load_live_oss_config().expect("live OSS config should load");
let client = reqwest::Client::new();
let mut uploaded_object_key: Option<String> = None;
let test_result = async {
let bucket_head = send_signed_oss_request(&client, &config, Method::HEAD, None).await?;
ensure_success_status(bucket_head.status().as_u16(), "bucket HEAD 应成功")?;
let app = build_router(AppState::new(config.clone()).expect("state should build"));
let run_id = new_uuid_simple_string();
let file_name = format!("oss-live-{run_id}.txt");
let file_content = format!("Genarrative OSS Rust live test {run_id}");
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/direct-upload-tickets")
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"legacyPrefix": "/generated-character-drafts/*",
"pathSegments": ["rust-live-test", run_id],
"fileName": file_name,
"contentType": "text/plain",
"metadata": {
"origin": "cargo-test",
"asset-kind": "manual-test"
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
if response.status() != StatusCode::OK {
return Err(std::io::Error::other(format!(
"直传票据接口返回了非预期状态码:{}",
response.status()
))
.into());
}
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
let upload = payload["data"]["upload"].clone();
let upload_host = upload["host"]
.as_str()
.ok_or_else(|| std::io::Error::other("upload.host 缺失"))?
.to_string();
let object_key = upload["objectKey"]
.as_str()
.ok_or_else(|| std::io::Error::other("upload.objectKey 缺失"))?
.to_string();
uploaded_object_key = Some(object_key.clone());
let mut form = multipart::Form::new();
for (key, value) in read_form_fields(&upload)? {
form = form.text(key, value);
}
form = form.part(
"file",
multipart::Part::text(file_content.clone())
.file_name("oss-live-test.txt")
.mime_str("text/plain")?,
);
let upload_response = client.post(upload_host).multipart(form).send().await?;
ensure_success_status(upload_response.status().as_u16(), "PostObject 上传应成功")?;
let public_response = client
.head(build_object_url(&config, &object_key)?)
.send()
.await?;
if public_response.status().as_u16() != 403 {
return Err(std::io::Error::other(format!(
"私有对象匿名读取应返回 403,实际为 {}",
public_response.status()
))
.into());
}
let read_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/api/assets/read-url?objectKey={object_key}"))
.header("x-genarrative-response-envelope", "1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
if read_response.status() != StatusCode::OK {
return Err(std::io::Error::other(format!(
"私有读签名接口返回了非预期状态码:{}",
read_response.status()
))
.into());
}
let read_body = read_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let read_payload: Value =
serde_json::from_slice(&read_body).expect("response body should be valid json");
let signed_url = read_payload["data"]["read"]["signedUrl"]
.as_str()
.ok_or_else(|| std::io::Error::other("read.signedUrl 缺失"))?;
let signed_read = client.get(signed_url).send().await?;
ensure_success_status(signed_read.status().as_u16(), "签名读应成功")?;
let signed_content = signed_read.text().await?;
if signed_content != file_content {
return Err(std::io::Error::other("签名读回来的对象内容与上传内容不一致").into());
}
Ok::<(), Box<dyn Error>>(())
}
.await;
if let Some(object_key) = uploaded_object_key.as_deref() {
let delete_result =
send_signed_oss_request(&client, &config, Method::DELETE, Some(object_key)).await;
if let Ok(response) = delete_result {
ensure_success_status(response.status().as_u16(), "测试对象删除应成功")
.expect("cleanup should succeed");
}
}
test_result.expect("live OSS roundtrip should succeed");
}
#[tokio::test]
#[ignore = "需要仓库根目录 .env / .env.local 中的真实 OSS 配置"]
async fn confirm_asset_object_live_roundtrip_persists_confirmed_record() {
let config = load_live_oss_config().expect("live OSS config should load");
let client = reqwest::Client::new();
let mut uploaded_object_key: Option<String> = None;
let test_result = async {
let app = build_router(AppState::new(config.clone()).expect("state should build"));
let run_id = new_uuid_simple_string();
let file_content = format!("Genarrative confirm asset object live test {run_id}");
let ticket_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/direct-upload-tickets")
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"legacyPrefix": "/generated-characters/*",
"pathSegments": ["confirm-live-test", run_id],
"fileName": "master.txt",
"contentType": "text/plain",
"metadata": {
"origin": "cargo-test",
"asset-kind": "character-visual"
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
let ticket_body = ticket_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let ticket_payload: Value =
serde_json::from_slice(&ticket_body).expect("response body should be valid json");
let upload = ticket_payload["data"]["upload"].clone();
let object_key = upload["objectKey"]
.as_str()
.ok_or_else(|| std::io::Error::other("upload.objectKey 缺失"))?
.to_string();
let upload_host = upload["host"]
.as_str()
.ok_or_else(|| std::io::Error::other("upload.host 缺失"))?
.to_string();
uploaded_object_key = Some(object_key.clone());
let mut form = multipart::Form::new();
for (key, value) in read_form_fields(&upload)? {
form = form.text(key, value);
}
form = form.part(
"file",
multipart::Part::text(file_content)
.file_name("master.txt")
.mime_str("text/plain")?,
);
let upload_response = client.post(upload_host).multipart(form).send().await?;
ensure_success_status(upload_response.status().as_u16(), "PostObject 上传应成功")?;
let confirm_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/objects/confirm")
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"objectKey": object_key,
"assetKind": "character_visual",
"accessPolicy": "private"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
if confirm_response.status() != StatusCode::OK {
return Err(std::io::Error::other(format!(
"对象确认接口返回了非预期状态码:{}",
confirm_response.status()
))
.into());
}
let confirm_body = confirm_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let confirm_payload: Value =
serde_json::from_slice(&confirm_body).expect("response body should be valid json");
assert!(
confirm_payload["data"]["assetObject"]["assetObjectId"]
.as_str()
.is_some_and(|value| value.starts_with("assetobj_"))
);
assert_eq!(
confirm_payload["data"]["assetObject"]["bucket"],
Value::String(
config
.oss_bucket
.clone()
.expect("live config should have bucket")
)
);
assert_eq!(
confirm_payload["data"]["assetObject"]["accessPolicy"],
Value::String("private".to_string())
);
let asset_object_id = confirm_payload["data"]["assetObject"]["assetObjectId"]
.as_str()
.ok_or_else(|| std::io::Error::other("assetObjectId 缺失"))?
.to_string();
let bind_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/objects/bind")
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"assetObjectId": asset_object_id,
"entityKind": "character",
"entityId": format!("hero_{run_id}"),
"slot": "primary_visual",
"assetKind": "character_visual"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
if bind_response.status() != StatusCode::OK {
return Err(std::io::Error::other(format!(
"对象绑定接口返回了非预期状态码:{}",
bind_response.status()
))
.into());
}
let bind_body = bind_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let bind_payload: Value =
serde_json::from_slice(&bind_body).expect("response body should be valid json");
assert!(
bind_payload["data"]["assetBinding"]["bindingId"]
.as_str()
.is_some_and(|value| value.starts_with("assetbind_"))
);
assert_eq!(
bind_payload["data"]["assetBinding"]["assetObjectId"],
Value::String(asset_object_id)
);
assert_eq!(
bind_payload["data"]["assetBinding"]["slot"],
Value::String("primary_visual".to_string())
);
Ok::<(), Box<dyn Error>>(())
}
.await;
if let Some(object_key) = uploaded_object_key.as_deref() {
let delete_result =
send_signed_oss_request(&client, &config, Method::DELETE, Some(object_key)).await;
if let Ok(response) = delete_result {
ensure_success_status(response.status().as_u16(), "测试对象删除应成功")
.expect("cleanup should succeed");
}
}
test_result.expect("live asset confirm roundtrip should succeed");
}
fn load_live_oss_config() -> Result<AppConfig, Box<dyn Error>> {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("..")
.canonicalize()?;
let mut env_map = BTreeMap::new();
read_env_file(&repo_root.join(".env"), &mut env_map)?;
read_env_file(&repo_root.join(".env.local"), &mut env_map)?;
Ok(AppConfig {
oss_bucket: Some(read_required_env(&env_map, "ALIYUN_OSS_BUCKET")?),
oss_endpoint: Some(read_required_env(&env_map, "ALIYUN_OSS_ENDPOINT")?),
oss_access_key_id: Some(read_required_env(&env_map, "ALIYUN_OSS_ACCESS_KEY_ID")?),
oss_access_key_secret: Some(read_required_env(
&env_map,
"ALIYUN_OSS_ACCESS_KEY_SECRET",
)?),
..AppConfig::default()
})
}
fn read_env_file(
path: &Path,
target: &mut BTreeMap<String, String>,
) -> Result<(), Box<dyn Error>> {
if !path.exists() {
return Ok(());
}
let content = fs::read_to_string(path)?;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let value = value.trim().trim_matches('"').to_string();
target.insert(key.trim().to_string(), value);
}
Ok(())
}
fn read_required_env(
env_map: &BTreeMap<String, String>,
key: &str,
) -> Result<String, Box<dyn Error>> {
env_map
.get(key)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| std::io::Error::other(format!("缺少 {key}")).into())
}
fn read_form_fields(upload: &Value) -> Result<Vec<(String, String)>, Box<dyn Error>> {
let form_fields = upload["formFields"]
.as_object()
.ok_or_else(|| std::io::Error::other("upload.formFields 缺失"))?;
let mut fields = Vec::with_capacity(form_fields.len());
for (key, value) in form_fields {
let value = value
.as_str()
.ok_or_else(|| std::io::Error::other(format!("formFields.{key} 不是字符串")))?;
fields.push((key.clone(), value.to_string()));
}
Ok(fields)
}
fn build_object_url(
config: &AppConfig,
object_key: &str,
) -> Result<reqwest::Url, Box<dyn Error>> {
let bucket = config
.oss_bucket
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss bucket"))?;
let endpoint = config
.oss_endpoint
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss endpoint"))?;
let mut url = reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))?;
url = url.join(object_key.trim_start_matches('/'))?;
Ok(url)
}
async fn send_signed_oss_request(
client: &reqwest::Client,
config: &AppConfig,
method: Method,
object_key: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error>> {
let bucket = config
.oss_bucket
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss bucket"))?;
let endpoint = config
.oss_endpoint
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss endpoint"))?;
let access_key_id = config
.oss_access_key_id
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss access key id"))?;
let access_key_secret = config
.oss_access_key_secret
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss access key secret"))?;
let date = fmt_http_date(SystemTime::now());
let canonical_resource = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => format!("/{bucket}/{}", object_key.trim_start_matches('/')),
None => format!("/{bucket}/"),
};
let string_to_sign = format!("{}\n\n\n{}\n{}", method.as_str(), date, canonical_resource);
let signature = sign_oss_string(access_key_secret, &string_to_sign)?;
let target_url = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => build_object_url(config, object_key)?,
None => reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))?,
};
let response = client
.request(method, target_url)
.header("Date", date)
.header("Authorization", format!("OSS {access_key_id}:{signature}"))
.send()
.await?;
Ok(response)
}
fn sign_oss_string(secret: &str, content: &str) -> Result<String, Box<dyn Error>> {
let mut signer = HmacSha1::new_from_slice(secret.as_bytes())?;
signer.update(content.as_bytes());
Ok(BASE64_STANDARD.encode(signer.finalize().into_bytes()))
}
fn ensure_success_status(status: u16, message: &str) -> Result<(), Box<dyn Error>> {
if (200..300).contains(&status) {
return Ok(());
}
Err(std::io::Error::other(format!("{message},实际状态码为 {status}")).into())
}
}