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