use axum::{ Json, body::Body, extract::{Extension, Query, State}, http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; 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, DirectUploadObjectAccess, DirectUploadTicketFormFields, DirectUploadTicketPayload, GetAssetReadUrlResponse, GetReadUrlQuery, }; use spacetime_client::SpacetimeClientError; use crate::{ api_response::json_success_body, auth::{AuthenticatedAccessToken, optional_access_token_from_headers}, http_error::AppError, platform_errors::map_oss_error, request_context::RequestContext, state::AppState, tracking::{TrackingClientMarker, TrackingEventDraft, record_tracking_event_after_success}, }; // 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。 const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 9] = [ "character_visual", "scene_image", "puzzle_cover_image", "match3d_cover_image", "match3d_item_image", "square_hole_cover_image", "square_hole_background_image", "square_hole_shape_image", "square_hole_hole_image", ]; // 中文注释:同源字节读取同时服务图片转 Data URL 与 Match3D 私有 GLB 预览,Rodin GLB 可能明显超过图片上限。 const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 120 * 1024 * 1024; const ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS: u64 = 300; const PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS: u64 = 600; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum AssetReadAuthorization { Anonymous, Owner(String), Admin, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AssetReadAccessScope { Public, Privileged, } #[derive(Clone, Debug, PartialEq, Eq)] struct AssetReadTarget { object_key: String, is_legacy_public_path: bool, } #[derive(Debug)] struct AssetReadAuthorizationContext { authorization: AssetReadAuthorization, authenticated: Option, } pub async fn create_direct_upload_ticket( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, client_marker: Option>, Json(payload): Json, ) -> Result, AppError> { create_direct_upload_ticket_for_owner( &state, &request_context, authenticated.claims().user_id(), Some(authenticated.claims().user_id()), client_marker.map(|Extension(marker)| marker), payload, ) .await } pub(crate) async fn create_direct_upload_ticket_for_owner( state: &AppState, request_context: &RequestContext, owner_user_id: &str, tracking_user_id: Option<&str>, client_marker: Option, payload: CreateDirectUploadTicketRequest, ) -> Result, 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, })) })?; if matches!( legacy_prefix, LegacyAssetPrefix::EditorAgent | LegacyAssetPrefix::AgcErrorReports ) { return Err( 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 .map(direct_upload_access_to_oss) .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(), })) })?; let upload = direct_upload_ticket_payload_from_oss(signed); record_asset_tracking_event( state, request_context, tracking_user_id, owner_user_id, "asset_upload_ticket_create", client_marker, json!({ "asset": { "operation": "asset_upload_ticket_create", "operationFamily": "upload_ticket", "objectKey": upload.object_key.clone(), "legacyPublicPath": upload.legacy_public_path.clone(), "bucket": upload.bucket.clone(), "contentType": upload.content_type.clone(), "access": upload.access, "keyPrefix": upload.key_prefix.clone(), "maxSizeBytes": upload.max_size_bytes, "successActionStatus": upload.success_action_status, } }), ) .await; Ok(json_success_body( Some(request_context), CreateDirectUploadTicketResponse { upload }, )) } pub async fn get_asset_read_url( State(state): State, Extension(request_context): Extension, headers: HeaderMap, Query(query): Query, ) -> Result { let authorization_context = resolve_public_asset_read_authorization( &state, &request_context, headers, "/api/assets/read-url", ) .await?; let response = get_asset_read_url_with_query( &state, &request_context, query, authorization_context.authorization, ) .await? .into_response(); Ok(attach_asset_read_authentication( response, authorization_context.authenticated, )) } pub(crate) async fn get_asset_read_url_with_query( state: &AppState, request_context: &RequestContext, query: GetReadUrlQuery, authorization: AssetReadAuthorization, ) -> Result, 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 target = resolve_asset_read_target(&query)?; let access_scope = authorize_asset_read_target(state, oss_client.config_bucket(), &target, &authorization) .await?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { object_key: target.object_key, expire_seconds: clamp_public_asset_read_expire_seconds( query.expire_seconds, access_scope, ), }) .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: asset_read_url_payload_from_oss(signed), }, )) } fn direct_upload_access_to_oss(value: DirectUploadObjectAccess) -> OssObjectAccess { match value { DirectUploadObjectAccess::Public => OssObjectAccess::Public, DirectUploadObjectAccess::Private => OssObjectAccess::Private, } } fn direct_upload_access_from_oss(value: OssObjectAccess) -> DirectUploadObjectAccess { match value { OssObjectAccess::Public => DirectUploadObjectAccess::Public, OssObjectAccess::Private => DirectUploadObjectAccess::Private, } } fn direct_upload_ticket_payload_from_oss( value: platform_oss::OssPostObjectResponse, ) -> DirectUploadTicketPayload { DirectUploadTicketPayload { signature_version: value.signature_version.to_string(), provider: value.provider.to_string(), bucket: value.bucket, endpoint: value.endpoint, host: value.host, object_key: value.object_key, legacy_public_path: value.legacy_public_path, content_type: value.content_type, access: direct_upload_access_from_oss(value.access), key_prefix: value.key_prefix, expires_at: value.expires_at, max_size_bytes: value.max_size_bytes, success_action_status: value.success_action_status, form_fields: direct_upload_ticket_form_fields_from_oss(value.form_fields), } } fn direct_upload_ticket_form_fields_from_oss( value: platform_oss::OssPostObjectFormFields, ) -> DirectUploadTicketFormFields { DirectUploadTicketFormFields { key: value.key, policy: value.policy, signature_version: value.signature_version, credential: value.credential, date: value.date, signature: value.signature, success_action_status: value.success_action_status, content_type: value.content_type, cache_control: value.cache_control, metadata: value.metadata, } } fn asset_read_url_payload_from_oss( value: platform_oss::OssSignedGetObjectUrlResponse, ) -> AssetReadUrlPayload { AssetReadUrlPayload { provider: value.provider.to_string(), bucket: value.bucket, endpoint: value.endpoint, host: value.host, object_key: value.object_key, expires_at: value.expires_at, signed_url: value.signed_url, } } pub async fn get_asset_read_bytes( State(state): State, Extension(request_context): Extension, headers: HeaderMap, Query(query): Query, ) -> Result { // 中文注释:浏览器可以用签名 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 target = resolve_asset_read_target(&query)?; let authorization_context = resolve_public_asset_read_authorization( &state, &request_context, headers, "/api/assets/read-bytes", ) .await?; let access_scope = authorize_asset_read_target( &state, oss_client.config_bucket(), &target, &authorization_context.authorization, ) .await?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { object_key: target.object_key, expire_seconds: clamp_public_asset_read_expire_seconds( Some( query .expire_seconds .unwrap_or(ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS), ), access_scope, ), }) .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()); } let response = 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}"), })) })?; Ok(attach_asset_read_authentication( response, authorization_context.authenticated, )) } pub async fn get_asset_history( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Query(query): Query, ) -> Result, 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, ) -> Result, 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, Extension(request_context): Extension, Extension(authenticated): Extension, client_marker: Option>, Json(payload): Json, ) -> Result, AppError> { confirm_asset_object_for_owner( &state, &request_context, authenticated.claims().user_id(), Some(authenticated.claims().user_id()), client_marker.map(|Extension(marker)| marker), payload, ) .await } pub(crate) async fn confirm_asset_object_for_owner( state: &AppState, request_context: &RequestContext, owner_user_id: &str, tracking_user_id: Option<&str>, client_marker: Option, payload: ConfirmAssetObjectRequest, ) -> Result, 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, owner_user_id) .await .map_err(map_confirm_asset_object_prepare_error)?, ) .await .map_err(map_confirm_asset_object_error)?; let 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, }; record_asset_tracking_event( state, request_context, tracking_user_id, owner_user_id, "asset_upload_confirm", client_marker, json!({ "asset": { "operation": "asset_upload_confirm", "operationFamily": "object_confirm", "assetObjectId": asset_object.asset_object_id, "assetKind": asset_object.asset_kind, "objectKey": asset_object.object_key, "bucket": asset_object.bucket, "accessPolicy": asset_object.access_policy, "contentType": asset_object.content_type, "contentLength": asset_object.content_length, "version": asset_object.version, "sourceJobId": asset_object.source_job_id, "ownerUserId": asset_object.owner_user_id, "profileId": asset_object.profile_id, "entityId": asset_object.entity_id, } }), ) .await; Ok(json_success_body( Some(request_context), ConfirmAssetObjectResponse { asset_object }, )) } pub async fn bind_asset_object_to_entity( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, client_marker: Option>, Json(payload): Json, ) -> Result, 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)?; let 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, }; record_asset_tracking_event( &state, &request_context, Some(authenticated.claims().user_id()), authenticated.claims().user_id(), "asset_bind", client_marker.map(|Extension(marker)| marker), json!({ "asset": { "operation": "asset_bind", "operationFamily": "object_bind", "bindingId": asset_binding.binding_id, "assetObjectId": asset_binding.asset_object_id, "assetKind": asset_binding.asset_kind, "entityKind": asset_binding.entity_kind, "entityId": asset_binding.entity_id, "slot": asset_binding.slot, "ownerUserId": asset_binding.owner_user_id, "profileId": asset_binding.profile_id, } }), ) .await; Ok(json_success_body( Some(&request_context), BindAssetObjectResponse { asset_binding }, )) } async fn record_asset_tracking_event( state: &AppState, request_context: &RequestContext, tracking_user_id: Option<&str>, owner_user_id: &str, event_key: &'static str, client_marker: Option, metadata: Value, ) { let draft = build_asset_tracking_event_draft( request_context, tracking_user_id, owner_user_id, event_key, client_marker, metadata, ); record_tracking_event_after_success(state, request_context, draft).await; } fn build_asset_tracking_event_draft( request_context: &RequestContext, tracking_user_id: Option<&str>, owner_user_id: &str, event_key: &'static str, client_marker: Option, metadata: Value, ) -> TrackingEventDraft { let mut draft = TrackingEventDraft::new(event_key, "asset"); draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; draft.scope_id = owner_user_id.trim().to_string(); draft.user_id = tracking_user_id.map(|user_id| user_id.trim().to_string()); draft.owner_user_id = Some(owner_user_id.trim().to_string()); let metadata = apply_asset_tracking_client_marker(metadata, client_marker); draft.metadata = if client_marker.is_some() { apply_asset_tracking_route_metadata(metadata, request_context) } else { metadata }; draft } fn apply_asset_tracking_client_marker( mut metadata: Value, client_marker: Option, ) -> Value { if matches!(client_marker, Some(TrackingClientMarker::Agc)) && let Some(object) = metadata.as_object_mut() { object.insert("client".to_string(), json!("agc")); } metadata } fn apply_asset_tracking_route_metadata( mut metadata: Value, request_context: &RequestContext, ) -> Value { let Some(object) = metadata.as_object_mut() else { return metadata; }; let Some((method, request_uri)) = request_context.operation().split_once(' ') else { return metadata; }; let route = request_uri.split('?').next().unwrap_or(request_uri); object .entry("route".to_string()) .or_insert_with(|| json!(route)); object .entry("method".to_string()) .or_insert_with(|| json!(method)); object .entry("status".to_string()) .or_insert_with(|| json!(200)); object .entry("operation".to_string()) .or_insert_with(|| json!(request_context.operation())); metadata } fn resolve_asset_read_target(query: &GetReadUrlQuery) -> Result { if let Some(object_key) = query .object_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { return Ok(AssetReadTarget { object_key: object_key.trim_start_matches('/').to_string(), is_legacy_public_path: false, }); } let object_key = query .legacy_public_path .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.trim_start_matches('/').to_string()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "field": "objectKey", "reason": "必须提供 objectKey 或 legacyPublicPath", })) })?; Ok(AssetReadTarget { object_key, is_legacy_public_path: true, }) } async fn resolve_public_asset_read_authorization( state: &AppState, request_context: &RequestContext, headers: HeaderMap, path: &str, ) -> Result { let authenticated = optional_access_token_from_headers( state, path.to_string(), headers, request_context.request_id().to_string(), ) .await?; let authorization = authenticated .as_ref() .map(|authenticated| { AssetReadAuthorization::Owner(authenticated.claims().user_id().to_string()) }) .unwrap_or(AssetReadAuthorization::Anonymous); Ok(AssetReadAuthorizationContext { authorization, authenticated, }) } fn attach_asset_read_authentication( mut response: Response, authenticated: Option, ) -> Response { if let Some(authenticated) = authenticated { response.extensions_mut().insert(authenticated); } response } async fn authorize_asset_read_target( state: &AppState, configured_bucket: &str, target: &AssetReadTarget, authorization: &AssetReadAuthorization, ) -> Result { if matches!(authorization, AssetReadAuthorization::Admin) { return Ok(AssetReadAccessScope::Privileged); } let (asset_object, public_work_granted) = state .spacetime_client() .get_asset_read_access_by_location(module_assets::AssetObjectLocationInput { bucket: configured_bucket.to_string(), object_key: target.object_key.clone(), }) .await .map_err(map_asset_read_authorization_error)?; resolve_asset_read_access( configured_bucket, target, authorization, asset_object.as_ref(), public_work_granted, ) } fn resolve_asset_read_access( configured_bucket: &str, target: &AssetReadTarget, authorization: &AssetReadAuthorization, asset_object: Option<&module_assets::AssetObjectRecord>, public_work_granted: bool, ) -> Result { if let Some(asset_object) = asset_object { return require_asset_object_read_access( asset_object, configured_bucket, target.object_key.as_str(), authorization, public_work_granted, ); } // 当前启用的活动卡可能来自历史上传,尚无 asset_object metadata; // SpacetimeDB 只会对配置中的 exact object key 派生该授权,禁用或换图后立即失效。 if public_work_granted { return Ok(AssetReadAccessScope::Public); } // 已登记对象始终服从 metadata ACL;只有没有 metadata 的历史资源才走公开前缀兼容。 if target.is_legacy_public_path && is_supported_legacy_public_object_key(&target.object_key) { return Ok(AssetReadAccessScope::Public); } Err(asset_read_not_found()) } fn require_asset_object_read_access( asset_object: &module_assets::AssetObjectRecord, configured_bucket: &str, object_key: &str, authorization: &AssetReadAuthorization, public_work_granted: bool, ) -> Result { if !asset_object_storage_matches(asset_object, configured_bucket, object_key) { return Err(asset_read_not_found()); } if asset_object_owner_matches(asset_object, authorization) { return Ok(AssetReadAccessScope::Privileged); } if asset_object.access_policy == AssetObjectAccessPolicy::PublicRead || public_work_granted { return Ok(AssetReadAccessScope::Public); } Err(asset_read_not_found()) } fn clamp_public_asset_read_expire_seconds( requested_expire_seconds: Option, access_scope: AssetReadAccessScope, ) -> Option { match access_scope { AssetReadAccessScope::Public => Some( requested_expire_seconds .unwrap_or(PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS) .min(PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS), ), AssetReadAccessScope::Privileged => requested_expire_seconds, } } fn asset_object_storage_matches( asset_object: &module_assets::AssetObjectRecord, configured_bucket: &str, object_key: &str, ) -> bool { asset_object.bucket.trim() == configured_bucket.trim() && asset_object.object_key.trim().trim_start_matches('/') == object_key } fn asset_object_owner_matches( asset_object: &module_assets::AssetObjectRecord, authorization: &AssetReadAuthorization, ) -> bool { match authorization { AssetReadAuthorization::Owner(owner_user_id) => asset_object .owner_user_id .as_deref() .map(str::trim) .is_some_and(|value| value == owner_user_id.trim()), AssetReadAuthorization::Admin => true, AssetReadAuthorization::Anonymous => false, } } fn is_supported_legacy_public_object_key(object_key: &str) -> bool { LegacyAssetPrefix::from_object_key(object_key) .is_some_and(|prefix| platform_oss::LEGACY_PUBLIC_PREFIXES.contains(&prefix.as_str())) } fn map_asset_read_authorization_error(error: SpacetimeClientError) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": format!("资源访问权限校验失败:{error}"), })) } fn asset_read_not_found() -> AppError { AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ "provider": "asset-object", "message": "资源不存在或无权访问", })) } 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, ) -> 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, authenticated_owner_user_id: &str, ) -> Result { 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 authenticated_owner_user_id = authenticated_owner_user_id.trim(); let owner_user_id = if authenticated_owner_user_id.is_empty() { None } else { Some(authenticated_owner_user_id.to_string()) }; 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, 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 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}; 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::{HeaderMap, HeaderValue, Request, StatusCode, header::AUTHORIZATION}, response::IntoResponse, }; use hmac::{Hmac, Mac}; use http_body_util::BodyExt; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, verify_access_token, }; use reqwest::{Method, multipart}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use shared_kernel::new_uuid_simple_string; use time::OffsetDateTime; use tower::ServiceExt; use crate::tracking::TrackingClientMarker; use crate::{ app::build_router, auth::AuthenticatedAccessToken, config::AppConfig, request_context::RequestContext, state::AppState, }; type HmacSha256 = Hmac; #[test] fn asset_tracking_metadata_receives_only_valid_agc_marker() { let request_context = crate::request_context::RequestContext::new( "request-225-asset".to_string(), "POST /api/external/v1/assets/objects/confirm?x=1".to_string(), std::time::Duration::ZERO, false, ); let marked = super::apply_asset_tracking_client_marker( json!({"asset": {"operation": "asset_upload_confirm"}}), Some(TrackingClientMarker::Agc), ); assert_eq!(marked["client"], "agc"); assert_eq!(marked["asset"]["operation"], "asset_upload_confirm"); let marked = super::apply_asset_tracking_route_metadata(marked, &request_context); assert_eq!(marked["route"], "/api/external/v1/assets/objects/confirm"); assert_eq!(marked["method"], "POST"); assert_eq!(marked["status"], 200); assert_eq!( marked["operation"], "POST /api/external/v1/assets/objects/confirm?x=1" ); let unmarked = super::apply_asset_tracking_client_marker( json!({"asset": {"operation": "asset_upload_confirm"}}), None, ); assert!(unmarked.get("client").is_none()); } #[test] fn external_asset_tracking_keeps_owner_without_forging_user() { let request_context = crate::request_context::RequestContext::new( "request-225-owner".to_string(), "POST /api/external/v1/assets/objects/confirm".to_string(), std::time::Duration::ZERO, false, ); let draft = super::build_asset_tracking_event_draft( &request_context, None, "owner-225", "asset_upload_confirm", Some(TrackingClientMarker::Agc), json!({"asset": {"operation": "asset_upload_confirm"}}), ); assert_eq!(draft.user_id, None); assert_eq!(draft.owner_user_id.as_deref(), Some("owner-225")); assert_eq!( draft.scope_kind, module_runtime::RuntimeTrackingScopeKind::User ); assert_eq!(draft.scope_id, "owner-225"); assert_eq!(draft.metadata["client"], "agc"); } #[tokio::test] async fn public_asset_read_response_carries_verified_bearer_for_tracking() { let state = AppState::new(AppConfig::default()).expect("state should build"); let token = seed_authenticated_token(&state, "13800138126", "sess_asset_read_tracking").await; let request_context = RequestContext::new( "request-225-asset-read".to_string(), "GET /api/assets/read-url".to_string(), std::time::Duration::ZERO, false, ); let mut headers = HeaderMap::new(); headers.insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {token}")) .expect("authorization header should build"), ); let authorization_context = super::resolve_public_asset_read_authorization( &state, &request_context, headers, "/api/assets/read-url", ) .await .expect("valid bearer should authenticate"); let expected_user_id = verify_access_token(&token, state.auth_jwt_config()) .expect("test bearer should verify") .user_id() .to_string(); assert!(matches!( authorization_context.authorization, super::AssetReadAuthorization::Owner(ref user_id) if user_id == &expected_user_id )); let authenticated = authorization_context .authenticated .as_ref() .expect("verified bearer should be retained for tracking"); assert_eq!(authenticated.claims().user_id(), expected_user_id); let response = super::attach_asset_read_authentication( StatusCode::OK.into_response(), authorization_context.authenticated, ); let response_authenticated = response .extensions() .get::() .expect("asset read response should expose verified bearer to tracking"); assert_eq!(response_authenticated.claims().user_id(), expected_user_id); } #[test] fn anonymous_asset_read_response_does_not_add_authentication_extension() { let response = super::attach_asset_read_authentication(StatusCode::OK.into_response(), None); assert!( response .extensions() .get::() .is_none() ); } fn asset_object_record( access_policy: module_assets::AssetObjectAccessPolicy, owner_user_id: Option<&str>, ) -> module_assets::AssetObjectRecord { module_assets::AssetObjectRecord { asset_object_id: "assetobj_read_auth_test".to_string(), bucket: "genarrative-assets".to_string(), object_key: "generated-characters/read-auth-test/master.png".to_string(), access_policy, content_type: Some("image/png".to_string()), content_length: 1, content_hash: None, version: 1, source_job_id: None, owner_user_id: owner_user_id.map(ToOwned::to_owned), profile_id: None, entity_id: None, asset_kind: "character_visual".to_string(), created_at: "2026-07-10T00:00:00Z".to_string(), updated_at: "2026-07-10T00:00:00Z".to_string(), } } #[test] fn private_asset_read_requires_matching_owner() { let record = asset_object_record( module_assets::AssetObjectAccessPolicy::Private, Some("user-owner"), ); assert!(matches!( super::require_asset_object_read_access( &record, "genarrative-assets", record.object_key.as_str(), &super::AssetReadAuthorization::Owner("user-owner".to_string()), false, ), Ok(super::AssetReadAccessScope::Privileged) )); for authorization in [ super::AssetReadAuthorization::Anonymous, super::AssetReadAuthorization::Owner("user-other".to_string()), ] { let error = super::require_asset_object_read_access( &record, "genarrative-assets", record.object_key.as_str(), &authorization, false, ) .expect_err("private asset should reject non-owner"); assert_eq!(error.status_code(), StatusCode::NOT_FOUND); } } #[test] fn public_asset_read_allows_anonymous_but_rejects_storage_mismatch() { let record = asset_object_record(module_assets::AssetObjectAccessPolicy::PublicRead, None); assert!(matches!( super::require_asset_object_read_access( &record, "genarrative-assets", record.object_key.as_str(), &super::AssetReadAuthorization::Anonymous, false, ), Ok(super::AssetReadAccessScope::Public) )); assert_eq!( super::require_asset_object_read_access( &record, "another-bucket", record.object_key.as_str(), &super::AssetReadAuthorization::Anonymous, false, ) .expect_err("bucket mismatch should be hidden") .status_code(), StatusCode::NOT_FOUND ); } #[test] fn published_visible_work_reference_grants_private_asset_read() { let record = asset_object_record( module_assets::AssetObjectAccessPolicy::Private, Some("user-owner"), ); assert!(matches!( super::require_asset_object_read_access( &record, "genarrative-assets", record.object_key.as_str(), &super::AssetReadAuthorization::Anonymous, true, ), Ok(super::AssetReadAccessScope::Public) )); assert_eq!( super::require_asset_object_read_access( &record, "another-bucket", record.object_key.as_str(), &super::AssetReadAuthorization::Anonymous, true, ) .expect_err("public work grant must not bypass storage identity") .status_code(), StatusCode::NOT_FOUND ); } #[test] fn current_campaign_grant_allows_exact_object_key_without_legacy_metadata() { let target = super::AssetReadTarget { object_key: "generated-character-drafts/editor/showcase-campaign/current/image.png" .to_string(), is_legacy_public_path: false, }; assert!(matches!( super::resolve_asset_read_access( "genarrative-assets", &target, &super::AssetReadAuthorization::Anonymous, None, true, ), Ok(super::AssetReadAccessScope::Public) )); assert_eq!( super::resolve_asset_read_access( "genarrative-assets", &target, &super::AssetReadAuthorization::Anonymous, None, false, ) .expect_err("unregistered object key without an exact grant should stay hidden") .status_code(), StatusCode::NOT_FOUND ); } #[test] fn public_asset_read_url_expiry_is_capped_but_privileged_expiry_is_preserved() { assert_eq!( super::clamp_public_asset_read_expire_seconds( None, super::AssetReadAccessScope::Public, ), Some(super::PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS) ); assert_eq!( super::clamp_public_asset_read_expire_seconds( Some(86_400), super::AssetReadAccessScope::Public, ), Some(super::PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS) ); assert_eq!( super::clamp_public_asset_read_expire_seconds( Some(86_400), super::AssetReadAccessScope::Privileged, ), Some(86_400) ); } #[test] fn legacy_public_fallback_only_accepts_curated_prefixes() { assert!(super::is_supported_legacy_public_object_key( "generated-characters/hero/master.png" )); assert!(!super::is_supported_legacy_public_object_key( "generated-editor-videos/private/preview.mp4" )); assert!(!super::is_supported_legacy_public_object_key( "private-backups/database.dump" )); } #[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( "match3d_cover_image" )); assert!(super::is_supported_asset_history_kind("match3d_item_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、match3d_cover_image、match3d_item_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 state = AppState::new(AppConfig::default()).expect("state should build"); let token = seed_authenticated_token(&state, "13800138121", "sess_assets_missing_oss").await; let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/direct-upload-tickets") .header("authorization", format!("Bearer {token}")) .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 state = AppState::new(config).expect("state should build"); let token = seed_authenticated_token(&state, "13800138120", "sess_assets_direct_upload").await; let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/direct-upload-tickets") .header("authorization", format!("Bearer {token}")) .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 direct_upload_ticket_accepts_asset_canvas_reference_namespace() { 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 state = AppState::new(config).expect("state should build"); let token = seed_authenticated_token(&state, "13800138122", "sess_asset_canvas_reference_ticket") .await; let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/direct-upload-tickets") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "1") .body(Body::from( json!({ "legacyPrefix": "generated-character-drafts", "pathSegments": [ "editor", "asset-canvas-references", "project-123", "draft-456", "generation-789" ], "fileName": "reference-sha256.png", "contentType": "image/png", "access": "private", "maxSizeBytes": 4096, "successActionStatus": 204 }) .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"); let expected_object_key = "generated-character-drafts/editor/asset-canvas-references/project-123/draft-456/generation-789/reference-sha256.png"; assert_eq!(payload["data"]["upload"]["objectKey"], expected_object_key); assert_eq!( payload["data"]["upload"]["formFields"]["key"], expected_object_key ); assert_eq!(payload["data"]["upload"]["access"], "private"); assert_eq!(payload["data"]["upload"]["successActionStatus"], 204); assert_eq!(payload["data"]["upload"]["maxSizeBytes"], 4096); } #[tokio::test] async fn read_url_fails_closed_when_asset_metadata_authority_is_unavailable() { 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-characters%2Fhero_001%2Fvisual%2Fasset_01%2Fmaster.png") .header("x-genarrative-response-envelope", "1") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); 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("spacetimedb".to_string()) ); } #[tokio::test] async fn legacy_transition_does_not_bypass_asset_metadata_authority() { 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::BAD_GATEWAY); 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("spacetimedb".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 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 state = AppState::new(AppConfig::default()).expect("state should build"); let token = seed_authenticated_token(&state, "13800138122", "sess_assets_sts_disabled").await; let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/sts-upload-credentials") .header("authorization", format!("Bearer {token}")) .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 state = AppState::new(AppConfig::default()).expect("state should build"); let token = seed_authenticated_token(&state, "13800138123", "sess_assets_confirm_missing_oss") .await; let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/objects/confirm") .header("authorization", format!("Bearer {token}")) .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 state = AppState::new(config).expect("state should build"); let token = seed_authenticated_token(&state, "13800138124", "sess_assets_bucket_mismatch").await; let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/objects/confirm") .header("authorization", format!("Bearer {token}")) .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 state = AppState::new(AppConfig::default()).expect("state should build"); let token = seed_authenticated_token(&state, "13800138125", "sess_assets_missing_slot").await; let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/objects/bind") .header("authorization", format!("Bearer {token}")) .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 = 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>(()) } .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 = 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>(()) } .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> { 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, ) -> Result<(), Box> { 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, key: &str, ) -> Result> { 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, Box> { 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) } async fn seed_authenticated_token( state: &AppState, phone_number: &str, session_seed: &str, ) -> String { let user = state .seed_test_phone_user_with_password(phone_number, "secret123") .await; let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: user.id.clone(), session_id: state.seed_test_refresh_session_for_user(&user, session_seed), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: user.token_version, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some(user.display_name.clone()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), ) .expect("claims should build"); sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } fn build_object_url( config: &AppConfig, object_key: &str, ) -> Result> { 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> { 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> { let date = format_oss_v4_signature_scope_date(signed_at); 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 { format!( "{}T{:02}{:02}{:02}Z", format_oss_v4_signature_scope_date(signed_at), signed_at.hour(), signed_at.minute(), signed_at.second() ) } fn format_oss_v4_signature_scope_date(signed_at: time::OffsetDateTime) -> String { format!( "{:04}{:02}{:02}", signed_at.year(), signed_at.month() as u8, signed_at.day() ) } 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> { 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, Box> { 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, Box> { 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::() } fn encode_oss_url_path(path: &str) -> String { path.split('/') .map(encode_oss_url_query_value) .collect::>() .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> { if (200..300).contains(&status) { return Ok(()); } Err(std::io::Error::other(format!("{message},实际状态码为 {status}")).into()) } }