1
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
extract::{Extension, Query, State},
|
||||
http::StatusCode,
|
||||
http::{StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, INITIAL_ASSET_OBJECT_VERSION,
|
||||
@@ -42,6 +44,8 @@ const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [
|
||||
"square_hole_shape_image",
|
||||
"square_hole_hole_image",
|
||||
];
|
||||
const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
const ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS: u64 = 300;
|
||||
|
||||
pub async fn create_direct_upload_ticket(
|
||||
State(state): State<AppState>,
|
||||
@@ -150,6 +154,94 @@ pub async fn get_asset_read_url(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_asset_read_bytes(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<GetReadUrlQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
// 中文注释:浏览器可以用签名 URL 渲染图片,但不能稳定跨域 fetch 私有 OSS 字节;Rodin 图生模型参考图转 Data URL 走同源中转。
|
||||
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: Some(
|
||||
query
|
||||
.expire_seconds
|
||||
.unwrap_or(ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS),
|
||||
),
|
||||
})
|
||||
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
|
||||
|
||||
let upstream = reqwest::Client::new()
|
||||
.get(signed.signed_url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_asset_read_bytes_upstream_error(error.to_string()))?;
|
||||
let upstream_status = upstream.status();
|
||||
let content_type = upstream
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
if upstream_status == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": "资源不存在",
|
||||
"objectKey": signed.object_key,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if !upstream_status.is_success() {
|
||||
return Err(map_asset_read_bytes_upstream_error(format!(
|
||||
"OSS 读取返回非成功状态:{}",
|
||||
upstream_status.as_u16()
|
||||
)));
|
||||
}
|
||||
if upstream
|
||||
.content_length()
|
||||
.is_some_and(|size| size > ASSET_READ_BYTES_MAX_SIZE_BYTES)
|
||||
{
|
||||
return Err(map_asset_read_bytes_too_large());
|
||||
}
|
||||
|
||||
let bytes = upstream
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| map_asset_read_bytes_upstream_error(error.to_string()))?;
|
||||
if bytes.len() as u64 > ASSET_READ_BYTES_MAX_SIZE_BYTES {
|
||||
return Err(map_asset_read_bytes_too_large());
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CACHE_CONTROL, "private, max-age=60")
|
||||
.body(Body::from(bytes))
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "asset-read-bytes",
|
||||
"message": format!("构造资源内容响应失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_asset_history(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -517,6 +609,23 @@ fn map_confirm_asset_object_error(error: SpacetimeClientError) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_asset_read_bytes_upstream_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取资源内容失败:{message}"),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_asset_read_bytes_too_large() -> AppError {
|
||||
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!(
|
||||
"资源内容超过读取上限:{}MB",
|
||||
ASSET_READ_BYTES_MAX_SIZE_BYTES / 1024 / 1024
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -866,6 +975,51 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_bytes_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("GET")
|
||||
.uri("/api/assets/read-bytes?legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png")
|
||||
.header("x-genarrative-response-envelope", "1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_bytes_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-bytes")
|
||||
.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"));
|
||||
|
||||
Reference in New Issue
Block a user