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, auth::AuthenticatedAccessToken, 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, Extension(request_context): Extension, Json(payload): Json, ) -> Result, 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, Extension(request_context): Extension, Query(query): Query, ) -> Result, 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, Extension(request_context): Extension, Extension(authenticated): Extension, Query(query): Query, ) -> Result, 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(build_asset_history_list_input(asset_kind, query.limit)) .await .map_err(map_confirm_asset_object_error)?; let owner_user_id = authenticated.claims().user_id().to_string(); Ok(json_success_body( Some(&request_context), AssetHistoryListResponse { assets: entries .into_iter() // 中文注释:旧 wasm 的历史素材 procedure 仍按类型返回,HTTP 门面必须兜底做账号隔离。 .filter(|entry| { is_asset_history_owned_by( entry.owner_user_id.as_deref(), owner_user_id.as_str(), ) }) .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, ) -> Result, 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, Extension(request_context): Extension, Json(payload): Json, ) -> Result, 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, Extension(request_context): Extension, Json(payload): Json, ) -> Result, 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 { 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 is_asset_history_owned_by(entry_owner_user_id: Option<&str>, owner_user_id: &str) -> bool { let owner_user_id = owner_user_id.trim(); !owner_user_id.is_empty() && entry_owner_user_id .map(str::trim) .filter(|value| !value.is_empty()) == Some(owner_user_id) } fn build_asset_history_list_input( asset_kind: String, limit: Option, ) -> module_assets::AssetHistoryListInput { module_assets::AssetHistoryListInput { asset_kind, limit: limit.unwrap_or(120).clamp(1, 120), } } 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 { 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; #[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" ); } #[test] fn asset_history_owner_filter_keeps_only_authenticated_owner_assets() { assert!(super::is_asset_history_owned_by( Some("user-current"), "user-current" )); assert!(!super::is_asset_history_owned_by( Some("user-other"), "user-current" )); assert!(!super::is_asset_history_owned_by(None, "user-current")); assert!(!super::is_asset_history_owned_by(Some("user-current"), "")); } #[test] fn asset_history_input_clamps_limit_for_spacetime_query() { let input = super::build_asset_history_list_input("puzzle_cover_image".to_string(), Some(240)); assert_eq!(input.asset_kind, "puzzle_cover_image"); assert_eq!(input.limit, 120); } #[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 = 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>(()) } .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 = 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>(()) } .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> { 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, ) -> Result<(), Box> { 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, key: &str, ) -> Result> { 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, Box> { 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> { 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> { 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> { 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> { if (200..300).contains(&status) { return Ok(()); } Err(std::io::Error::other(format!("{message},实际状态码为 {status}")).into()) } }