1593 lines
58 KiB
Rust
1593 lines
58 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, auth::AuthenticatedAccessToken, http_error::AppError,
|
||
platform_errors::map_oss_error, request_context::RequestContext, state::AppState,
|
||
};
|
||
|
||
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
|
||
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [
|
||
"character_visual",
|
||
"scene_image",
|
||
"puzzle_cover_image",
|
||
"square_hole_cover_image",
|
||
"square_hole_background_image",
|
||
"square_hole_shape_image",
|
||
"square_hole_hole_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": "当前上传主链为服务器上传 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 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(error) => map_oss_error(error, "aliyun-oss"),
|
||
}
|
||
}
|
||
|
||
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},
|
||
};
|
||
|
||
use axum::{
|
||
body::Body,
|
||
http::{Request, StatusCode},
|
||
};
|
||
use hmac::{Hmac, Mac};
|
||
use http_body_util::BodyExt;
|
||
use reqwest::{Method, multipart};
|
||
use serde_json::{Value, json};
|
||
use sha2::{Digest, Sha256};
|
||
use shared_kernel::new_uuid_simple_string;
|
||
use tower::ServiceExt;
|
||
|
||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||
|
||
type HmacSha256 = Hmac<Sha256>;
|
||
|
||
#[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(
|
||
"square_hole_cover_image"
|
||
));
|
||
assert!(super::is_supported_asset_history_kind(
|
||
"square_hole_background_image"
|
||
));
|
||
assert!(super::is_supported_asset_history_kind(
|
||
"square_hole_shape_image"
|
||
));
|
||
assert!(super::is_supported_asset_history_kind(
|
||
"square_hole_hole_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、square_hole_cover_image、square_hole_background_image、square_hole_shape_image、square_hole_hole_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"]["x-oss-signature-version"],
|
||
Value::String("OSS4-HMAC-SHA256".to_string())
|
||
);
|
||
assert!(
|
||
payload["data"]["upload"]["formFields"]["x-oss-credential"]
|
||
.as_str()
|
||
.is_some_and(|value| value.starts_with("test-access-key-id/"))
|
||
);
|
||
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("x-oss-signature-version=OSS4-HMAC-SHA256"))
|
||
);
|
||
}
|
||
|
||
#[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 signed_at = time::OffsetDateTime::now_utc();
|
||
let signed_at_text = build_oss_v4_signature_date(signed_at);
|
||
let signature_scope = build_oss_v4_signature_scope(endpoint, signed_at)?;
|
||
let object_path = object_key.map(str::trim).filter(|value| !value.is_empty());
|
||
let canonical_uri = build_oss_v4_canonical_uri(bucket, object_path);
|
||
let payload_hash = "UNSIGNED-PAYLOAD";
|
||
let canonical_headers =
|
||
format!("host:{bucket}.{endpoint}\nx-oss-content-sha256:{payload_hash}\nx-oss-date:{signed_at_text}\n");
|
||
let additional_headers = "host";
|
||
let canonical_request = format!(
|
||
"{}\n{}\n\n{}\n{}\n{}",
|
||
method.as_str(),
|
||
canonical_uri,
|
||
canonical_headers,
|
||
additional_headers,
|
||
payload_hash
|
||
);
|
||
let string_to_sign =
|
||
build_oss_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request);
|
||
let signature = sign_oss_v4_content(access_key_secret, &signature_scope, &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("x-oss-content-sha256", payload_hash)
|
||
.header("x-oss-date", signed_at_text)
|
||
.header(
|
||
"Authorization",
|
||
format!(
|
||
"OSS4-HMAC-SHA256 Credential={access_key_id}/{signature_scope},AdditionalHeaders={additional_headers},Signature={signature}"
|
||
),
|
||
)
|
||
.send()
|
||
.await?;
|
||
|
||
Ok(response)
|
||
}
|
||
|
||
fn build_oss_v4_signature_scope(
|
||
endpoint: &str,
|
||
signed_at: time::OffsetDateTime,
|
||
) -> Result<String, Box<dyn Error>> {
|
||
let date = signed_at.date().to_string().replace('-', "");
|
||
let region = endpoint
|
||
.trim()
|
||
.split('.')
|
||
.next()
|
||
.and_then(|segment| segment.strip_prefix("oss-"))
|
||
.ok_or_else(|| std::io::Error::other("OSS endpoint 无法解析 region"))?;
|
||
|
||
Ok(format!("{date}/{region}/oss/aliyun_v4_request"))
|
||
}
|
||
|
||
fn build_oss_v4_signature_date(signed_at: time::OffsetDateTime) -> String {
|
||
let date = signed_at.date().to_string().replace('-', "");
|
||
let time = signed_at
|
||
.time()
|
||
.to_string()
|
||
.split('.')
|
||
.next()
|
||
.unwrap_or("00:00:00")
|
||
.replace(':', "");
|
||
|
||
debug_assert_eq!(time.len(), 6);
|
||
format!("{date}T{time}Z")
|
||
}
|
||
|
||
fn build_oss_v4_canonical_uri(bucket: &str, object_key: Option<&str>) -> String {
|
||
match object_key.map(str::trim).filter(|value| !value.is_empty()) {
|
||
Some(object_key) => format!(
|
||
"/{}/{}",
|
||
encode_oss_url_query_value(bucket),
|
||
encode_oss_url_path(object_key.trim_start_matches('/'))
|
||
),
|
||
None => format!("/{}/", encode_oss_url_query_value(bucket)),
|
||
}
|
||
}
|
||
|
||
fn build_oss_v4_string_to_sign(
|
||
signature_date: &str,
|
||
signature_scope: &str,
|
||
canonical_request: &str,
|
||
) -> String {
|
||
format!(
|
||
"OSS4-HMAC-SHA256\n{signature_date}\n{signature_scope}\n{}",
|
||
sha256_hex(canonical_request.as_bytes())
|
||
)
|
||
}
|
||
|
||
fn sign_oss_v4_content(
|
||
secret: &str,
|
||
signature_scope: &str,
|
||
content: &str,
|
||
) -> Result<String, Box<dyn Error>> {
|
||
let signing_key = build_oss_v4_signing_key(secret, signature_scope)?;
|
||
let mut signer = HmacSha256::new_from_slice(&signing_key)?;
|
||
signer.update(content.as_bytes());
|
||
Ok(hex_lower(&signer.finalize().into_bytes()))
|
||
}
|
||
|
||
fn build_oss_v4_signing_key(
|
||
secret: &str,
|
||
signature_scope: &str,
|
||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||
let mut parts = signature_scope.split('/');
|
||
let date = parts
|
||
.next()
|
||
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少日期"))?;
|
||
let region = parts
|
||
.next()
|
||
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 region"))?;
|
||
let service = parts
|
||
.next()
|
||
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 service"))?;
|
||
let request = parts
|
||
.next()
|
||
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 request"))?;
|
||
|
||
let date_key = hmac_sha256_raw(format!("aliyun_v4{secret}").as_bytes(), date)?;
|
||
let region_key = hmac_sha256_raw(&date_key, region)?;
|
||
let service_key = hmac_sha256_raw(®ion_key, service)?;
|
||
hmac_sha256_raw(&service_key, request)
|
||
}
|
||
|
||
fn hmac_sha256_raw(key: &[u8], content: &str) -> Result<Vec<u8>, Box<dyn Error>> {
|
||
let mut signer = HmacSha256::new_from_slice(key)?;
|
||
signer.update(content.as_bytes());
|
||
Ok(signer.finalize().into_bytes().to_vec())
|
||
}
|
||
|
||
fn sha256_hex(content: &[u8]) -> String {
|
||
let mut hasher = Sha256::new();
|
||
hasher.update(content);
|
||
hex_lower(&hasher.finalize())
|
||
}
|
||
|
||
fn hex_lower(bytes: &[u8]) -> String {
|
||
bytes
|
||
.iter()
|
||
.map(|byte| format!("{byte:02x}"))
|
||
.collect::<String>()
|
||
}
|
||
|
||
fn encode_oss_url_path(path: &str) -> String {
|
||
path.split('/')
|
||
.map(encode_oss_url_query_value)
|
||
.collect::<Vec<_>>()
|
||
.join("/")
|
||
}
|
||
|
||
fn encode_oss_url_query_value(value: &str) -> String {
|
||
let mut encoded = String::with_capacity(value.len());
|
||
for byte in value.bytes() {
|
||
match byte {
|
||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||
encoded.push(byte as char)
|
||
}
|
||
_ => {
|
||
use std::fmt::Write as _;
|
||
|
||
let _ = write!(&mut encoded, "%{byte:02X}");
|
||
}
|
||
}
|
||
}
|
||
encoded
|
||
}
|
||
|
||
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())
|
||
}
|
||
}
|