feat: add oss direct upload adapter

This commit is contained in:
2026-04-21 14:36:34 +08:00
parent 39eb7a513c
commit 5675c40119
20 changed files with 1308 additions and 53 deletions

View File

@@ -0,0 +1,206 @@
use std::collections::BTreeMap;
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
};
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPostObjectRequest};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectUploadTicketRequest {
pub legacy_prefix: String,
#[serde(default)]
pub path_segments: Vec<String>,
pub file_name: String,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub access: Option<OssObjectAccess>,
#[serde(default)]
pub metadata: BTreeMap<String, String>,
#[serde(default)]
pub max_size_bytes: Option<u64>,
#[serde(default)]
pub expire_seconds: Option<u64>,
#[serde(default)]
pub success_action_status: Option<u16>,
}
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::Public),
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),
json!({
"upload": signed,
}),
))
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[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()),
oss_public_base_url: Some("https://cdn.genarrative.local".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"]["objectKey"],
Value::String("generated-characters/hero_001/visual/asset_01/master.png".to_string())
);
assert_eq!(
payload["data"]["upload"]["publicUrl"],
Value::String(
"https://cdn.genarrative.local/generated-characters/hero_001/visual/asset_01/master.png"
.to_string()
)
);
assert_eq!(
payload["data"]["upload"]["formFields"]["OSSAccessKeyId"],
Value::String("test-access-key-id".to_string())
);
}
}