Files
Genarrative/server-rs/crates/api-server/src/assets.rs
T
kdletters d3a3238028 修复生成图片OSS签名缓存链路
前端将完整阿里云OSS generated 地址归一为 legacy path 后走 read-url 换签。

platform-oss 为 generated 私有对象 PostObject 和 PutObject 写入 immutable Cache-Control。

补齐 shared-contracts 与 api-server 直传票据字段映射。

更新后端、运维文档和 Hermes 团队记忆,明确不使用服务端磁盘缓存兜底。
2026-06-07 22:46:48 +08:00

1968 lines
71 KiB
Rust

use axum::{
Json,
body::Body,
extract::{Extension, Query, State},
http::{StatusCode, header},
response::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,
http_error::AppError,
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
tracking::{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;
pub async fn create_direct_upload_ticket(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
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
.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,
&authenticated,
"asset_upload_ticket_create",
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<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: 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<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>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Query(query): Query<AssetHistoryQuery>,
) -> Result<Json<Value>, AppError> {
let asset_kind = query.kind.trim().to_string();
if !is_supported_asset_history_kind(asset_kind.as_str()) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "kind",
"message": supported_asset_history_kind_message(),
})),
);
}
let entries = state
.spacetime_client()
.list_asset_history(build_asset_history_list_input(asset_kind, query.limit))
.await
.map_err(map_confirm_asset_object_error)?;
let owner_user_id = authenticated.claims().user_id().to_string();
Ok(json_success_body(
Some(&request_context),
AssetHistoryListResponse {
assets: entries
.into_iter()
// 中文注释:旧 wasm 的历史素材 procedure 仍按类型返回,HTTP 门面必须兜底做账号隔离。
.filter(|entry| {
is_asset_history_owned_by(
entry.owner_user_id.as_deref(),
owner_user_id.as_str(),
)
})
.map(|entry| AssetHistoryEntryPayload {
owner_label: format_asset_owner_label(entry.owner_user_id.as_deref()),
asset_object_id: entry.asset_object_id,
asset_kind: entry.asset_kind,
image_src: entry.image_src,
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
entity_id: entry.entity_id,
created_at: entry.created_at,
updated_at: entry.updated_at,
})
.collect(),
},
))
}
pub async fn create_sts_upload_credentials(
Extension(_request_context): Extension<RequestContext>,
) -> Result<Json<Value>, AppError> {
Err(
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "aliyun-sts",
"enabled": false,
"reason": "当前上传主链为服务器上传 OSS,Web 端只负责读取,不开放浏览器 STS 写权限",
"fallback": "/api/assets/direct-upload-tickets",
})),
)
}
pub async fn confirm_asset_object(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
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,
authenticated.claims().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,
&authenticated,
"asset_upload_confirm",
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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
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)?;
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,
&authenticated,
"asset_bind",
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,
authenticated: &AuthenticatedAccessToken,
event_key: &'static str,
metadata: Value,
) {
let user_id = authenticated.claims().user_id().to_string();
let mut draft = TrackingEventDraft::user(event_key, "asset", user_id.as_str());
draft.metadata = metadata;
record_tracking_event_after_success(state, request_context, draft).await;
}
fn resolve_object_key_from_query(query: &GetReadUrlQuery) -> Option<String> {
if let Some(object_key) = query
.object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(object_key.trim_start_matches('/').to_string());
}
query
.legacy_public_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.trim_start_matches('/').to_string())
}
fn format_asset_owner_label(owner_user_id: Option<&str>) -> String {
let Some(owner_user_id) = owner_user_id
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return "未记录账号".to_string();
};
format!("账号 {owner_user_id}")
}
fn is_supported_asset_history_kind(asset_kind: &str) -> bool {
SUPPORTED_ASSET_HISTORY_KINDS.contains(&asset_kind)
}
fn is_asset_history_owned_by(entry_owner_user_id: Option<&str>, owner_user_id: &str) -> bool {
let owner_user_id = owner_user_id.trim();
!owner_user_id.is_empty()
&& entry_owner_user_id
.map(str::trim)
.filter(|value| !value.is_empty())
== Some(owner_user_id)
}
fn build_asset_history_list_input(
asset_kind: String,
limit: Option<u32>,
) -> module_assets::AssetHistoryListInput {
module_assets::AssetHistoryListInput {
asset_kind,
limit: limit.unwrap_or(120).clamp(1, 120),
}
}
fn supported_asset_history_kind_message() -> String {
format!(
"历史素材类型只支持 {}",
SUPPORTED_ASSET_HISTORY_KINDS.join("、")
)
}
async fn build_confirm_asset_object_upsert_input(
oss_client: &platform_oss::OssClient,
payload: ConfirmAssetObjectRequest,
authenticated_owner_user_id: &str,
) -> 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 owner_user_id = normalize_optional_value(payload.owner_user_id).or_else(|| {
let owner = authenticated_owner_user_id.trim();
if owner.is_empty() {
None
} else {
Some(owner.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::{Request, StatusCode},
};
use hmac::{Hmac, Mac};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_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::{app::build_router, config::AppConfig, state::AppState};
type HmacSha256 = Hmac<Sha256>;
#[test]
fn asset_history_kind_support_includes_puzzle_cover_image() {
assert!(super::is_supported_asset_history_kind("character_visual"));
assert!(super::is_supported_asset_history_kind("scene_image"));
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
assert!(super::is_supported_asset_history_kind(
"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 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 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 read_url_returns_signed_private_object_url_when_oss_configured() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/assets/read-url?objectKey=generated-characters/hero_001/visual/asset_01/master.png")
.header("x-genarrative-response-envelope", "1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["data"]["read"]["objectKey"],
Value::String("generated-characters/hero_001/visual/asset_01/master.png".to_string())
);
assert!(
payload["data"]["read"]["signedUrl"]
.as_str()
.is_some_and(|value| value.contains("x-oss-signature-version=OSS4-HMAC-SHA256"))
);
}
#[tokio::test]
async fn read_url_accepts_legacy_public_path_for_transition() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/assets/read-url?legacyPublicPath=%2Fgenerated-custom-world-scenes%2Fprofile_01%2Flandmark_01%2Fscene.png")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["read"]["objectKey"],
Value::String(
"generated-custom-world-scenes/profile_01/landmark_01/scene.png".to_string()
)
);
}
#[tokio::test]
async fn read_url_rejects_missing_identifier() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/assets/read-url")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn 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"));
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)
}
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<reqwest::Url, Box<dyn Error>> {
let bucket = config
.oss_bucket
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss bucket"))?;
let endpoint = config
.oss_endpoint
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss endpoint"))?;
let mut url = reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))?;
url = url.join(object_key.trim_start_matches('/'))?;
Ok(url)
}
async fn send_signed_oss_request(
client: &reqwest::Client,
config: &AppConfig,
method: Method,
object_key: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error>> {
let bucket = config
.oss_bucket
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss bucket"))?;
let endpoint = config
.oss_endpoint
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss endpoint"))?;
let access_key_id = config
.oss_access_key_id
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss access key id"))?;
let access_key_secret = config
.oss_access_key_secret
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss access key secret"))?;
let signed_at = time::OffsetDateTime::now_utc();
let signed_at_text = build_oss_v4_signature_date(signed_at);
let signature_scope = build_oss_v4_signature_scope(endpoint, signed_at)?;
let object_path = object_key.map(str::trim).filter(|value| !value.is_empty());
let canonical_uri = build_oss_v4_canonical_uri(bucket, object_path);
let payload_hash = "UNSIGNED-PAYLOAD";
let canonical_headers = format!(
"host:{bucket}.{endpoint}\nx-oss-content-sha256:{payload_hash}\nx-oss-date:{signed_at_text}\n"
);
let additional_headers = "host";
let canonical_request = format!(
"{}\n{}\n\n{}\n{}\n{}",
method.as_str(),
canonical_uri,
canonical_headers,
additional_headers,
payload_hash
);
let string_to_sign =
build_oss_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request);
let signature = sign_oss_v4_content(access_key_secret, &signature_scope, &string_to_sign)?;
let target_url = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => build_object_url(config, object_key)?,
None => reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))?,
};
let response = client
.request(method, target_url)
.header("x-oss-content-sha256", payload_hash)
.header("x-oss-date", signed_at_text)
.header(
"Authorization",
format!(
"OSS4-HMAC-SHA256 Credential={access_key_id}/{signature_scope},AdditionalHeaders={additional_headers},Signature={signature}"
),
)
.send()
.await?;
Ok(response)
}
fn build_oss_v4_signature_scope(
endpoint: &str,
signed_at: time::OffsetDateTime,
) -> Result<String, Box<dyn Error>> {
let date = 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<String, Box<dyn Error>> {
let signing_key = build_oss_v4_signing_key(secret, signature_scope)?;
let mut signer = HmacSha256::new_from_slice(&signing_key)?;
signer.update(content.as_bytes());
Ok(hex_lower(&signer.finalize().into_bytes()))
}
fn build_oss_v4_signing_key(
secret: &str,
signature_scope: &str,
) -> Result<Vec<u8>, Box<dyn Error>> {
let mut parts = signature_scope.split('/');
let date = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少日期"))?;
let region = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 region"))?;
let service = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 service"))?;
let request = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 request"))?;
let date_key = hmac_sha256_raw(format!("aliyun_v4{secret}").as_bytes(), date)?;
let region_key = hmac_sha256_raw(&date_key, region)?;
let service_key = hmac_sha256_raw(&region_key, service)?;
hmac_sha256_raw(&service_key, request)
}
fn hmac_sha256_raw(key: &[u8], content: &str) -> Result<Vec<u8>, Box<dyn Error>> {
let mut signer = HmacSha256::new_from_slice(key)?;
signer.update(content.as_bytes());
Ok(signer.finalize().into_bytes().to_vec())
}
fn sha256_hex(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
hex_lower(&hasher.finalize())
}
fn hex_lower(bytes: &[u8]) -> String {
bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn encode_oss_url_path(path: &str) -> String {
path.split('/')
.map(encode_oss_url_query_value)
.collect::<Vec<_>>()
.join("/")
}
fn encode_oss_url_query_value(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char)
}
_ => {
use std::fmt::Write as _;
let _ = write!(&mut encoded, "%{byte:02X}");
}
}
}
encoded
}
fn ensure_success_status(status: u16, message: &str) -> Result<(), Box<dyn Error>> {
if (200..300).contains(&status) {
return Ok(());
}
Err(std::io::Error::other(format!("{message},实际状态码为 {status}")).into())
}
}