Files
Genarrative/server-rs/crates/api-server/src/assets.rs
kdletters a2c71fcb3a
Some checks failed
CI / verify (push) Has been cancelled
chore: remove maincloud configuration
2026-05-02 17:04:11 +08:00

1440 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
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(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<RequestContext>,
) -> Result<Json<Value>, AppError> {
Err(
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "aliyun-sts",
"enabled": false,
"reason": "当前上传主链为服务器上传 OSSWeb 端只负责读取,不开放浏览器 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 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<u32>,
) -> 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<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"
);
}
#[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<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())
}
}