Files
Genarrative/server-rs/crates/platform-oss/src/lib.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

1999 lines
68 KiB
Rust

use std::{collections::BTreeMap, error::Error, fmt, time::Instant};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::{info, warn};
type HmacSha256 = Hmac<Sha256>;
pub const DEFAULT_POST_EXPIRE_SECONDS: u64 = 10 * 60;
pub const DEFAULT_READ_EXPIRE_SECONDS: u64 = 10 * 60;
pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024;
pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200;
pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024;
pub const DEFAULT_IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256";
const OSS_V4_REQUEST: &str = "aliyun_v4_request";
const OSS_V4_SERVICE: &str = "oss";
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
const OSS_PROVIDER: &str = "aliyun-oss";
pub const LEGACY_PUBLIC_PREFIXES: [&str; 14] = [
"generated-character-drafts",
"generated-characters",
"generated-animations",
"generated-big-fish-assets",
"generated-square-hole-assets",
"generated-wooden-fish-assets",
"generated-match3d-assets",
"generated-puzzle-assets",
"generated-puzzle-clear-assets",
"generated-jump-hop-assets",
"generated-custom-world-scenes",
"generated-custom-world-covers",
"generated-bark-battle-assets",
"generated-qwen-sprites",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OssObjectAccess {
Public,
Private,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LegacyAssetPrefix {
CharacterDrafts,
Characters,
Animations,
BigFishAssets,
SquareHoleAssets,
WoodenFishAssets,
Match3DAssets,
PuzzleAssets,
PuzzleClearAssets,
JumpHopAssets,
CustomWorldScenes,
CustomWorldCovers,
BarkBattleAssets,
QwenSprites,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssConfig {
bucket: String,
endpoint: String,
access_key_id: String,
access_key_secret: String,
default_read_expire_seconds: u64,
default_post_expire_seconds: u64,
default_post_max_size_bytes: u64,
default_success_action_status: u16,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssPostObjectRequest {
pub prefix: LegacyAssetPrefix,
pub path_segments: Vec<String>,
pub file_name: String,
pub content_type: Option<String>,
pub access: OssObjectAccess,
pub metadata: BTreeMap<String, String>,
pub max_size_bytes: Option<u64>,
pub expire_seconds: Option<u64>,
pub success_action_status: Option<u16>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssSignedGetObjectUrlRequest {
pub object_key: String,
pub expire_seconds: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssHeadObjectRequest {
pub object_key: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssPutObjectRequest {
pub prefix: LegacyAssetPrefix,
pub path_segments: Vec<String>,
pub file_name: String,
pub content_type: Option<String>,
pub access: OssObjectAccess,
pub metadata: BTreeMap<String, String>,
pub body: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OssPostObjectResponse {
#[serde(rename = "signatureVersion")]
pub signature_version: &'static str,
pub provider: &'static str,
pub bucket: String,
pub endpoint: String,
pub host: String,
#[serde(rename = "objectKey")]
pub object_key: String,
#[serde(rename = "legacyPublicPath")]
pub legacy_public_path: String,
#[serde(rename = "contentType", skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
pub access: OssObjectAccess,
#[serde(rename = "keyPrefix")]
pub key_prefix: String,
#[serde(rename = "expiresAt")]
pub expires_at: String,
#[serde(rename = "maxSizeBytes")]
pub max_size_bytes: u64,
#[serde(rename = "successActionStatus")]
pub success_action_status: u16,
#[serde(rename = "formFields")]
pub form_fields: OssPostObjectFormFields,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OssSignedGetObjectUrlResponse {
pub provider: &'static str,
pub bucket: String,
pub endpoint: String,
pub host: String,
#[serde(rename = "objectKey")]
pub object_key: String,
#[serde(rename = "expiresAt")]
pub expires_at: String,
#[serde(rename = "signedUrl")]
pub signed_url: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssHeadObjectResponse {
pub bucket: String,
pub object_key: String,
pub content_length: u64,
pub content_type: Option<String>,
pub etag: Option<String>,
pub last_modified: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OssPutObjectResponse {
pub provider: &'static str,
pub bucket: String,
pub endpoint: String,
pub host: String,
#[serde(rename = "objectKey")]
pub object_key: String,
#[serde(rename = "legacyPublicPath")]
pub legacy_public_path: String,
#[serde(rename = "contentType", skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(rename = "contentLength")]
pub content_length: u64,
pub access: OssObjectAccess,
#[serde(skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
#[serde(rename = "lastModified", skip_serializing_if = "Option::is_none")]
pub last_modified: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OssPostObjectFormFields {
pub key: String,
pub policy: String,
#[serde(rename = "x-oss-signature-version")]
pub signature_version: String,
#[serde(rename = "x-oss-credential")]
pub credential: String,
#[serde(rename = "x-oss-date")]
pub date: String,
#[serde(rename = "x-oss-signature")]
pub signature: String,
#[serde(rename = "success_action_status")]
pub success_action_status: String,
#[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(rename = "Cache-Control", skip_serializing_if = "Option::is_none")]
pub cache_control: Option<String>,
#[serde(flatten)]
pub metadata: BTreeMap<String, String>,
}
#[derive(Clone, Debug)]
pub struct OssClient {
config: OssConfig,
}
#[derive(Debug, PartialEq, Eq)]
pub enum OssError {
InvalidConfig(String),
InvalidRequest(String),
ObjectNotFound(String),
Request(String),
SerializePolicy(String),
Sign(String),
}
// 平台 OSS 错误只先归类,不在 platform 层绑定 HTTP status。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OssErrorKind {
InvalidConfig,
InvalidRequest,
ObjectNotFound,
Request,
SerializePolicy,
Sign,
}
impl LegacyAssetPrefix {
pub fn parse(raw: &str) -> Option<Self> {
let normalized = raw
.trim()
.trim_start_matches('/')
.trim_end_matches('/')
.trim_end_matches('*')
.trim_end_matches('/');
match normalized {
"generated-character-drafts" => Some(Self::CharacterDrafts),
"generated-characters" => Some(Self::Characters),
"generated-animations" => Some(Self::Animations),
"generated-big-fish-assets" => Some(Self::BigFishAssets),
"generated-square-hole-assets" => Some(Self::SquareHoleAssets),
"generated-wooden-fish-assets" => Some(Self::WoodenFishAssets),
"generated-match3d-assets" => Some(Self::Match3DAssets),
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
"generated-puzzle-clear-assets" => Some(Self::PuzzleClearAssets),
"generated-jump-hop-assets" => Some(Self::JumpHopAssets),
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
"generated-bark-battle-assets" => Some(Self::BarkBattleAssets),
"generated-qwen-sprites" => Some(Self::QwenSprites),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::CharacterDrafts => "generated-character-drafts",
Self::Characters => "generated-characters",
Self::Animations => "generated-animations",
Self::BigFishAssets => "generated-big-fish-assets",
Self::SquareHoleAssets => "generated-square-hole-assets",
Self::WoodenFishAssets => "generated-wooden-fish-assets",
Self::Match3DAssets => "generated-match3d-assets",
Self::PuzzleAssets => "generated-puzzle-assets",
Self::PuzzleClearAssets => "generated-puzzle-clear-assets",
Self::JumpHopAssets => "generated-jump-hop-assets",
Self::CustomWorldScenes => "generated-custom-world-scenes",
Self::CustomWorldCovers => "generated-custom-world-covers",
Self::BarkBattleAssets => "generated-bark-battle-assets",
Self::QwenSprites => "generated-qwen-sprites",
}
}
pub fn as_public_path_prefix(&self) -> String {
format!("/{}", self.as_str())
}
pub fn from_object_key(raw: &str) -> Option<Self> {
let normalized = raw.trim().trim_start_matches('/').trim();
let prefix = normalized.split('/').next()?;
Self::parse(prefix)
}
}
impl OssConfig {
#[allow(clippy::too_many_arguments)]
pub fn new(
bucket: String,
endpoint: String,
access_key_id: String,
access_key_secret: String,
default_read_expire_seconds: u64,
default_post_expire_seconds: u64,
default_post_max_size_bytes: u64,
default_success_action_status: u16,
) -> Result<Self, OssError> {
let bucket = normalize_required_value(bucket, "OSS bucket 不能为空")?;
let endpoint = normalize_endpoint(&endpoint)?;
let access_key_id = normalize_required_value(access_key_id, "OSS AccessKeyId 不能为空")?;
let access_key_secret =
normalize_required_value(access_key_secret, "OSS AccessKeySecret 不能为空")?;
if default_read_expire_seconds == 0 {
return Err(OssError::InvalidConfig(
"OSS 私有读签名有效期必须大于 0".to_string(),
));
}
if default_post_expire_seconds == 0 {
return Err(OssError::InvalidConfig(
"OSS PostObject 签名有效期必须大于 0".to_string(),
));
}
if default_post_max_size_bytes == 0 {
return Err(OssError::InvalidConfig(
"OSS PostObject 最大上传大小必须大于 0".to_string(),
));
}
if !(100..=999).contains(&default_success_action_status) {
return Err(OssError::InvalidConfig(
"OSS success_action_status 必须是三位 HTTP 状态码".to_string(),
));
}
Ok(Self {
bucket,
endpoint,
access_key_id,
access_key_secret,
default_read_expire_seconds,
default_post_expire_seconds,
default_post_max_size_bytes,
default_success_action_status,
})
}
pub fn upload_host(&self) -> String {
format!("https://{}.{}", self.bucket, self.endpoint)
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn bucket(&self) -> &str {
&self.bucket
}
pub fn access_key_id(&self) -> &str {
&self.access_key_id
}
pub fn access_key_secret(&self) -> &str {
&self.access_key_secret
}
}
impl OssClient {
pub fn new(config: OssConfig) -> Self {
Self { config }
}
pub fn config_bucket(&self) -> &str {
self.config.bucket()
}
pub fn sign_post_object(
&self,
request: OssPostObjectRequest,
) -> Result<OssPostObjectResponse, OssError> {
let started_at = Instant::now();
let requested_prefix = request.prefix.as_str();
let requested_content_type = request
.content_type
.as_deref()
.map(str::trim)
.unwrap_or("")
.to_string();
let requested_metadata_count = request.metadata.len();
let result = (|| {
let max_size_bytes = request
.max_size_bytes
.unwrap_or(self.config.default_post_max_size_bytes);
let expire_seconds = request
.expire_seconds
.unwrap_or(self.config.default_post_expire_seconds);
let success_action_status = request
.success_action_status
.unwrap_or(self.config.default_success_action_status);
if max_size_bytes == 0 {
return Err(OssError::InvalidRequest(
"maxSizeBytes 必须大于 0".to_string(),
));
}
if expire_seconds == 0 {
return Err(OssError::InvalidRequest(
"expireSeconds 必须大于 0".to_string(),
));
}
if !(100..=999).contains(&success_action_status) {
return Err(OssError::InvalidRequest(
"successActionStatus 必须是三位 HTTP 状态码".to_string(),
));
}
let sanitized_segments = request
.path_segments
.iter()
.map(|segment| sanitize_path_segment(segment))
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let file_name = sanitize_file_name(&request.file_name)?;
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
let legacy_public_path = format!("/{}", object_key);
let content_type = normalize_optional_value(request.content_type);
let metadata = normalize_metadata(request.metadata)?;
let cache_control = Some(DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string());
let expires_at = OffsetDateTime::now_utc()
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
|_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
)?))
.ok_or_else(|| {
OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string())
})?;
let expires_at = expires_at.format(&Rfc3339).map_err(|error| {
OssError::SerializePolicy(format!("格式化过期时间失败:{error}"))
})?;
let signed_at = OffsetDateTime::now_utc();
let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
let signature_date = build_v4_signature_date(signed_at)?;
let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
let policy_json = build_policy_json(
&self.config.bucket,
&object_key,
&expires_at,
max_size_bytes,
success_action_status,
content_type.as_deref(),
cache_control.as_deref(),
&metadata,
&credential,
&signature_date,
);
let policy = serde_json::to_string(&policy_json).map_err(|error| {
OssError::SerializePolicy(format!("序列化 policy 失败:{error}"))
})?;
let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes());
let signature = sign_v4_content(
&self.config.access_key_secret,
&signature_scope,
&encoded_policy,
)?;
Ok(OssPostObjectResponse {
signature_version: "v4",
provider: OSS_PROVIDER,
bucket: self.config.bucket.clone(),
endpoint: self.config.endpoint.clone(),
host: self.config.upload_host(),
object_key: object_key.clone(),
legacy_public_path,
content_type: content_type.clone(),
access: request.access,
key_prefix: build_key_prefix(request.prefix, &sanitized_segments),
expires_at,
max_size_bytes,
success_action_status,
form_fields: OssPostObjectFormFields {
key: object_key,
policy: encoded_policy,
signature_version: OSS_V4_ALGORITHM.to_string(),
credential,
date: signature_date,
signature,
success_action_status: success_action_status.to_string(),
content_type,
cache_control,
metadata,
},
})
})();
match &result {
Ok(response) => info!(
provider = OSS_PROVIDER,
operation = "sign_post_object",
bucket = %response.bucket,
endpoint = %response.endpoint,
object_key = %response.object_key,
key_prefix = %response.key_prefix,
access = oss_access_label(response.access),
content_type = %response.content_type.as_deref().unwrap_or(""),
max_size_bytes = response.max_size_bytes,
success_action_status = response.success_action_status,
metadata_count = response.form_fields.metadata.len(),
expires_at = %response.expires_at,
elapsed_ms = elapsed_ms(started_at),
"OSS PostObject 签名完成"
),
Err(error) => warn!(
provider = OSS_PROVIDER,
operation = "sign_post_object",
bucket = %self.config.bucket(),
endpoint = %self.config.endpoint(),
key_prefix = requested_prefix,
content_type = %requested_content_type,
metadata_count = requested_metadata_count,
error_kind = oss_error_kind_label(error),
message = %error,
elapsed_ms = elapsed_ms(started_at),
"OSS PostObject 签名失败"
),
}
result
}
// 私有 bucket 的对象读取统一走短期签名 URL,避免把长期主凭证下发给浏览器。
pub fn sign_get_object_url(
&self,
request: OssSignedGetObjectUrlRequest,
) -> Result<OssSignedGetObjectUrlResponse, OssError> {
let started_at = Instant::now();
let requested_object_key = request
.object_key
.trim()
.trim_start_matches('/')
.trim()
.to_string();
let result = (|| {
let expire_seconds = request
.expire_seconds
.unwrap_or(self.config.default_read_expire_seconds);
if expire_seconds == 0 {
return Err(OssError::InvalidRequest(
"expireSeconds 必须大于 0".to_string(),
));
}
let object_key = normalize_object_key(&request.object_key)?;
let expires_at = OffsetDateTime::now_utc()
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
|_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
)?))
.ok_or_else(|| {
OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string())
})?;
let expires_at_text = expires_at
.format(&Rfc3339)
.map_err(|error| OssError::Sign(format!("格式化过期时间失败:{error}")))?;
let signed_at = OffsetDateTime::now_utc();
let signed_at_text = build_v4_signature_date(signed_at)?;
let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
let mut query = BTreeMap::from([
("x-oss-additional-headers".to_string(), "host".to_string()),
(
"x-oss-signature-version".to_string(),
OSS_V4_ALGORITHM.to_string(),
),
("x-oss-credential".to_string(), credential),
("x-oss-date".to_string(), signed_at_text),
("x-oss-expires".to_string(), expire_seconds.to_string()),
]);
let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key));
let object_url_path = format!("/{}", encode_url_path(&object_key));
let additional_headers = "host";
let canonical_headers =
format!("host:{}.{}\n", self.config.bucket(), self.config.endpoint());
let canonical_query = build_canonical_query_string(&query);
let canonical_request = build_v4_canonical_request(
Method::GET.as_str(),
&canonical_uri,
&canonical_query,
&canonical_headers,
additional_headers,
OSS_UNSIGNED_PAYLOAD,
);
let string_to_sign = build_v4_string_to_sign(
query["x-oss-date"].as_str(),
&signature_scope,
&canonical_request,
);
let signature = sign_v4_content(
&self.config.access_key_secret,
&signature_scope,
&string_to_sign,
)?;
query.insert("x-oss-signature".to_string(), signature);
let signed_url = format!(
"{}{}?{}",
self.config.upload_host(),
object_url_path,
build_canonical_query_string(&query)
);
Ok(OssSignedGetObjectUrlResponse {
provider: OSS_PROVIDER,
bucket: self.config.bucket.clone(),
endpoint: self.config.endpoint.clone(),
host: self.config.upload_host(),
object_key,
expires_at: expires_at_text,
signed_url,
})
})();
match &result {
Ok(response) => info!(
provider = OSS_PROVIDER,
operation = "sign_get_object_url",
bucket = %response.bucket,
endpoint = %response.endpoint,
object_key = %response.object_key,
expires_at = %response.expires_at,
elapsed_ms = elapsed_ms(started_at),
"OSS GetObject 读签名完成"
),
Err(error) => warn!(
provider = OSS_PROVIDER,
operation = "sign_get_object_url",
bucket = %self.config.bucket(),
endpoint = %self.config.endpoint(),
object_key = %requested_object_key,
error_kind = oss_error_kind_label(error),
message = %error,
elapsed_ms = elapsed_ms(started_at),
"OSS GetObject 读签名失败"
),
}
result
}
// 上传完成确认前,服务端必须自己探测一次对象,不能只相信客户端回传的 object_key。
pub async fn head_object(
&self,
client: &reqwest::Client,
request: OssHeadObjectRequest,
) -> Result<OssHeadObjectResponse, OssError> {
let started_at = Instant::now();
let requested_object_key = request
.object_key
.trim()
.trim_start_matches('/')
.trim()
.to_string();
let mut response_status = None;
let result = async {
let object_key = normalize_object_key(&request.object_key)?;
let target_url =
build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err(
|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")),
)?;
let response = send_signed_request(
client,
&self.config,
Method::HEAD,
Some(&object_key),
target_url,
)
.await?;
response_status = Some(response.status().as_u16());
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(OssError::ObjectNotFound(format!(
"OSS 对象不存在:{}",
request.object_key
)));
}
if !response.status().is_success() {
return Err(OssError::Request(format!(
"OSS HEAD Object 失败,状态码:{}",
response.status()
)));
}
let headers = response.headers();
let content_length = headers
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0);
let content_type = headers
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
let etag = headers
.get(reqwest::header::ETAG)
.and_then(|value| value.to_str().ok())
.map(|value| value.trim_matches('"').to_string());
let last_modified = headers
.get(reqwest::header::LAST_MODIFIED)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
Ok(OssHeadObjectResponse {
bucket: self.config.bucket.clone(),
object_key,
content_length,
content_type,
etag,
last_modified,
})
}
.await;
match &result {
Ok(response) => info!(
provider = OSS_PROVIDER,
operation = "head_object",
bucket = %response.bucket,
endpoint = %self.config.endpoint(),
object_key = %response.object_key,
status = response_status.unwrap_or(reqwest::StatusCode::OK.as_u16()),
status_class = http_status_class_from_option(response_status),
content_length = response.content_length,
content_type = %response.content_type.as_deref().unwrap_or(""),
etag_present = response.etag.is_some(),
last_modified_present = response.last_modified.is_some(),
elapsed_ms = elapsed_ms(started_at),
"OSS HEAD Object 完成"
),
Err(error) => warn!(
provider = OSS_PROVIDER,
operation = "head_object",
bucket = %self.config.bucket(),
endpoint = %self.config.endpoint(),
object_key = %requested_object_key,
status = response_status.unwrap_or_default(),
status_class = http_status_class_from_option(response_status),
error_kind = oss_error_kind_label(error),
message = %error,
elapsed_ms = elapsed_ms(started_at),
"OSS HEAD Object 失败"
),
}
result
}
// AI 生成资源默认由服务端上传 OSS,Web 端只拿签名读地址,不直接持有写权限。
pub async fn put_object(
&self,
client: &reqwest::Client,
request: OssPutObjectRequest,
) -> Result<OssPutObjectResponse, OssError> {
let started_at = Instant::now();
let requested_prefix = request.prefix.as_str();
let requested_content_type = request
.content_type
.as_deref()
.map(str::trim)
.unwrap_or("")
.to_string();
let requested_content_length = request.body.len();
let requested_metadata_count = request.metadata.len();
let mut response_status = None;
let result = async {
if request.body.is_empty() {
return Err(OssError::InvalidRequest(
"服务端上传对象内容不能为空".to_string(),
));
}
let sanitized_segments = request
.path_segments
.iter()
.map(|segment| sanitize_path_segment(segment))
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let file_name = sanitize_file_name(&request.file_name)?;
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
let content_type = normalize_optional_value(request.content_type);
let headers = build_put_object_headers(request.metadata)?;
let target_url =
build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err(
|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")),
)?;
let content_length = u64::try_from(request.body.len())
.map_err(|_| OssError::InvalidRequest("上传对象大小超出可支持范围".to_string()))?;
let builder = signed_request_builder(
client,
&self.config,
Method::PUT,
Some(&object_key),
target_url,
content_type.as_deref(),
&headers,
)?
.header(reqwest::header::CONTENT_LENGTH, content_length)
.body(request.body);
let response = builder
.send()
.await
.map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))?;
response_status = Some(response.status().as_u16());
if !response.status().is_success() {
return Err(OssError::Request(format!(
"OSS PutObject 失败,状态码:{}",
response.status()
)));
}
let headers = response.headers();
let etag = headers
.get(reqwest::header::ETAG)
.and_then(|value| value.to_str().ok())
.map(|value| value.trim_matches('"').to_string());
let last_modified = headers
.get(reqwest::header::LAST_MODIFIED)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
Ok(OssPutObjectResponse {
provider: OSS_PROVIDER,
bucket: self.config.bucket.clone(),
endpoint: self.config.endpoint.clone(),
host: self.config.upload_host(),
legacy_public_path: format!("/{object_key}"),
object_key,
content_type,
content_length,
access: request.access,
etag,
last_modified,
})
}
.await;
match &result {
Ok(response) => info!(
provider = OSS_PROVIDER,
operation = "put_object",
bucket = %response.bucket,
endpoint = %response.endpoint,
object_key = %response.object_key,
access = oss_access_label(response.access),
status = response_status.unwrap_or(reqwest::StatusCode::OK.as_u16()),
status_class = http_status_class_from_option(response_status),
content_length = response.content_length,
content_type = %response.content_type.as_deref().unwrap_or(""),
etag_present = response.etag.is_some(),
last_modified_present = response.last_modified.is_some(),
elapsed_ms = elapsed_ms(started_at),
"OSS PutObject 上传完成"
),
Err(error) => warn!(
provider = OSS_PROVIDER,
operation = "put_object",
bucket = %self.config.bucket(),
endpoint = %self.config.endpoint(),
key_prefix = requested_prefix,
content_length = requested_content_length,
content_type = %requested_content_type,
metadata_count = requested_metadata_count,
status = response_status.unwrap_or_default(),
status_class = http_status_class_from_option(response_status),
error_kind = oss_error_kind_label(error),
message = %error,
elapsed_ms = elapsed_ms(started_at),
"OSS PutObject 上传失败"
),
}
result
}
}
impl fmt::Display for OssError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidConfig(message)
| Self::InvalidRequest(message)
| Self::ObjectNotFound(message)
| Self::Request(message)
| Self::SerializePolicy(message)
| Self::Sign(message) => f.write_str(message),
}
}
}
impl Error for OssError {}
impl OssError {
pub fn kind(&self) -> OssErrorKind {
match self {
Self::InvalidConfig(_) => OssErrorKind::InvalidConfig,
Self::InvalidRequest(_) => OssErrorKind::InvalidRequest,
Self::ObjectNotFound(_) => OssErrorKind::ObjectNotFound,
Self::Request(_) => OssErrorKind::Request,
Self::SerializePolicy(_) => OssErrorKind::SerializePolicy,
Self::Sign(_) => OssErrorKind::Sign,
}
}
}
fn elapsed_ms(started_at: Instant) -> u64 {
started_at.elapsed().as_millis().min(u64::MAX as u128) as u64
}
fn oss_access_label(access: OssObjectAccess) -> &'static str {
match access {
OssObjectAccess::Public => "public",
OssObjectAccess::Private => "private",
}
}
fn oss_error_kind_label(error: &OssError) -> &'static str {
match error.kind() {
OssErrorKind::InvalidConfig => "invalid_config",
OssErrorKind::InvalidRequest => "invalid_request",
OssErrorKind::ObjectNotFound => "object_not_found",
OssErrorKind::Request => "request",
OssErrorKind::SerializePolicy => "serialize_policy",
OssErrorKind::Sign => "sign",
}
}
fn http_status_class_from_option(status: Option<u16>) -> &'static str {
status.map(http_status_class).unwrap_or("unknown")
}
fn http_status_class(status: u16) -> &'static str {
match status {
100..=199 => "1xx",
200..=299 => "2xx",
300..=399 => "3xx",
400..=499 => "4xx",
500..=599 => "5xx",
_ => "unknown",
}
}
fn build_policy_json(
bucket: &str,
object_key: &str,
expires_at: &str,
max_size_bytes: u64,
success_action_status: u16,
content_type: Option<&str>,
cache_control: Option<&str>,
metadata: &BTreeMap<String, String>,
credential: &str,
signature_date: &str,
) -> Value {
let mut conditions = vec![
json!({ "bucket": bucket }),
json!(["eq", "$key", object_key]),
json!(["content-length-range", 1, max_size_bytes]),
json!([
"eq",
"$success_action_status",
success_action_status.to_string()
]),
json!(["eq", "$x-oss-signature-version", OSS_V4_ALGORITHM]),
json!(["eq", "$x-oss-credential", credential]),
json!(["eq", "$x-oss-date", signature_date]),
];
if let Some(content_type) = content_type {
conditions.push(json!(["eq", "$content-type", content_type]));
}
if let Some(cache_control) = cache_control {
conditions.push(json!(["eq", "$Cache-Control", cache_control]));
}
for (key, value) in metadata {
conditions.push(json!(["eq", format!("${key}"), value]));
}
json!({
"expiration": expires_at,
"conditions": conditions,
})
}
fn build_object_url(
bucket: &str,
endpoint: &str,
object_key: &str,
) -> Result<reqwest::Url, String> {
let mut url = reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))
.map_err(|error| error.to_string())?;
url = url
.join(object_key.trim_start_matches('/'))
.map_err(|error| error.to_string())?;
Ok(url)
}
fn build_object_key(
prefix: LegacyAssetPrefix,
path_segments: &[String],
file_name: &str,
) -> String {
let mut parts = Vec::with_capacity(path_segments.len() + 2);
parts.push(prefix.as_str().to_string());
parts.extend(path_segments.iter().cloned());
parts.push(file_name.to_string());
parts.join("/")
}
fn normalize_object_key(raw: &str) -> Result<String, OssError> {
let normalized = raw.trim().trim_start_matches('/').trim().to_string();
if normalized.is_empty() {
return Err(OssError::InvalidRequest("objectKey 不能为空".to_string()));
}
if LegacyAssetPrefix::from_object_key(&normalized).is_none() {
return Err(OssError::InvalidRequest(
"objectKey 必须落在受支持的 generated-* 前缀下".to_string(),
));
}
let segments = normalized.split('/').collect::<Vec<_>>();
if segments.len() < 2 {
return Err(OssError::InvalidRequest(
"objectKey 至少需要包含前缀和文件名".to_string(),
));
}
for segment in &segments {
if segment.is_empty() || *segment == "." || *segment == ".." {
return Err(OssError::InvalidRequest(
"objectKey 包含非法路径片段".to_string(),
));
}
if segment.contains('\\') {
return Err(OssError::InvalidRequest(
"objectKey 不能包含反斜杠".to_string(),
));
}
}
Ok(normalized)
}
fn build_key_prefix(prefix: LegacyAssetPrefix, path_segments: &[String]) -> String {
let mut parts = Vec::with_capacity(path_segments.len() + 1);
parts.push(prefix.as_str().to_string());
parts.extend(path_segments.iter().cloned());
parts.join("/")
}
fn normalize_metadata(
metadata: BTreeMap<String, String>,
) -> Result<BTreeMap<String, String>, OssError> {
let mut normalized = BTreeMap::new();
for (key, value) in metadata {
let key = key.trim();
let value = value.trim();
if key.is_empty() || value.is_empty() {
continue;
}
let key = normalize_metadata_key(key);
normalized.insert(key, value.to_string());
}
let total_bytes = normalized
.iter()
.map(|(key, value)| key.len() + value.len())
.sum::<usize>();
if total_bytes > DEFAULT_METADATA_TOTAL_BYTES_LIMIT {
return Err(OssError::InvalidRequest(format!(
"x-oss-meta-* 总大小不能超过 {} 字节",
DEFAULT_METADATA_TOTAL_BYTES_LIMIT
)));
}
Ok(normalized)
}
fn build_put_object_headers(
metadata: BTreeMap<String, String>,
) -> Result<BTreeMap<String, String>, OssError> {
// 中文注释:生成资产 object key 含会话与 asset id,内容不可变,适合交给浏览器/CDN 长缓存。
let mut headers = BTreeMap::from([(
"Cache-Control".to_string(),
DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(),
)]);
headers.extend(normalize_metadata(metadata)?);
Ok(headers)
}
fn normalize_metadata_key(raw: &str) -> String {
let stripped = raw
.trim()
.trim_start_matches("x-oss-meta-")
.trim()
.to_ascii_lowercase();
let sanitized = stripped
.chars()
.map(|character| match character {
'a'..='z' | '0'..='9' | '-' => character,
'_' | ' ' | '/' | '.' => '-',
_ => '-',
})
.collect::<String>();
let sanitized = collapse_dashes(&sanitized);
format!(
"x-oss-meta-{}",
if sanitized.is_empty() {
"metadata".to_string()
} else {
sanitized
}
)
}
fn sanitize_path_segment(raw: &str) -> String {
let normalized = raw
.trim()
.to_ascii_lowercase()
.chars()
.map(|character| match character {
'a'..='z' | '0'..='9' | '-' | '_' => character,
_ => '-',
})
.collect::<String>();
collapse_dashes(&normalized)
}
fn sanitize_file_name(raw: &str) -> Result<String, OssError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(OssError::InvalidRequest("fileName 不能为空".to_string()));
}
let file_name = trimmed.rsplit(['/', '\\']).next().unwrap_or(trimmed).trim();
if file_name.is_empty() {
return Err(OssError::InvalidRequest("fileName 不能为空".to_string()));
}
let (raw_stem, raw_extension) = match file_name.rsplit_once('.') {
Some((stem, extension)) if !stem.trim().is_empty() && !extension.trim().is_empty() => {
(stem, Some(extension))
}
_ => (file_name, None),
};
let stem = raw_stem
.trim()
.to_ascii_lowercase()
.chars()
.map(|character| match character {
'a'..='z' | '0'..='9' | '-' | '_' => character,
_ => '-',
})
.collect::<String>();
let stem = collapse_dashes(&stem);
if stem.is_empty() {
return Err(OssError::InvalidRequest("fileName 主体不合法".to_string()));
}
let extension = raw_extension
.map(|extension| {
extension
.trim()
.to_ascii_lowercase()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
})
.filter(|extension| !extension.is_empty());
Ok(match extension {
Some(extension) => format!("{stem}.{extension}"),
None => stem,
})
}
fn normalize_required_value(value: String, message: &str) -> Result<String, OssError> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(OssError::InvalidConfig(message.to_string()));
}
Ok(value)
}
fn normalize_optional_value(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() { None } else { Some(value) }
})
}
fn normalize_endpoint(raw: &str) -> Result<String, OssError> {
let endpoint = raw
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_matches('/')
.to_string();
if endpoint.is_empty() {
return Err(OssError::InvalidConfig("OSS endpoint 不能为空".to_string()));
}
Ok(endpoint)
}
fn collapse_dashes(value: &str) -> String {
value
.chars()
.fold(
(String::new(), false),
|(mut output, last_is_dash), character| {
let is_dash = character == '-';
if is_dash && last_is_dash {
return (output, true);
}
output.push(character);
(output, is_dash)
},
)
.0
.trim_matches('-')
.to_string()
}
async fn send_signed_request(
client: &reqwest::Client,
config: &OssConfig,
method: Method,
object_key: Option<&str>,
target_url: reqwest::Url,
) -> Result<reqwest::Response, OssError> {
signed_request_builder(
client,
config,
method,
object_key,
target_url,
None,
&BTreeMap::new(),
)?
.send()
.await
.map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))
}
fn signed_request_builder(
client: &reqwest::Client,
config: &OssConfig,
method: Method,
object_key: Option<&str>,
target_url: reqwest::Url,
content_type: Option<&str>,
oss_headers: &BTreeMap<String, String>,
) -> Result<reqwest::RequestBuilder, OssError> {
let signed_at = OffsetDateTime::now_utc();
let signed_at_text = build_v4_signature_date(signed_at)?;
let signature_scope = build_v4_signature_scope(config.endpoint(), signed_at)?;
let object_path = object_key.map(str::trim).filter(|value| !value.is_empty());
let canonical_uri = build_v4_canonical_uri(config.bucket(), object_path);
let body_sha256 = OSS_UNSIGNED_PAYLOAD.to_string();
let mut signed_headers = BTreeMap::from([
(
"host".to_string(),
format!("{}.{}", config.bucket(), config.endpoint()),
),
("x-oss-content-sha256".to_string(), body_sha256.clone()),
("x-oss-date".to_string(), signed_at_text.clone()),
]);
if let Some(content_type) = content_type {
signed_headers.insert("content-type".to_string(), content_type.to_string());
}
for (key, value) in oss_headers {
signed_headers.insert(key.to_ascii_lowercase(), value.trim().to_string());
}
let canonical_headers = build_v4_canonical_headers(&signed_headers);
let additional_headers = build_v4_additional_headers(&signed_headers);
let canonical_request = build_v4_canonical_request(
method.as_str(),
&canonical_uri,
"",
&canonical_headers,
&additional_headers,
&body_sha256,
);
let string_to_sign =
build_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request);
let signature = sign_v4_content(
config.access_key_secret(),
&signature_scope,
&string_to_sign,
)?;
let mut builder = client
.request(method, target_url)
.header("x-oss-content-sha256", body_sha256)
.header("x-oss-date", signed_at_text)
.header(
"Authorization",
format!(
"{OSS_V4_ALGORITHM} Credential={}/{},AdditionalHeaders={},Signature={}",
config.access_key_id(),
signature_scope,
additional_headers,
signature
),
);
if let Some(content_type) = content_type {
builder = builder.header(reqwest::header::CONTENT_TYPE, content_type);
}
for (key, value) in oss_headers {
builder = builder.header(key.as_str(), value.as_str());
}
Ok(builder)
}
fn build_v4_signature_scope(endpoint: &str, signed_at: OffsetDateTime) -> Result<String, OssError> {
let date = format_v4_signature_scope_date(signed_at);
let region = extract_oss_region(endpoint)?;
Ok(format!("{date}/{region}/{OSS_V4_SERVICE}/{OSS_V4_REQUEST}"))
}
fn build_v4_signature_date(signed_at: OffsetDateTime) -> Result<String, OssError> {
// 中文注释:time::Time 的 Display 在小时小于 10 时不会稳定补零,OSS V4 必须使用固定宽度 UTC 时间。
Ok(format!(
"{}T{:02}{:02}{:02}Z",
format_v4_signature_scope_date(signed_at),
signed_at.hour(),
signed_at.minute(),
signed_at.second()
))
}
fn format_v4_signature_scope_date(signed_at: OffsetDateTime) -> String {
format!(
"{:04}{:02}{:02}",
signed_at.year(),
signed_at.month() as u8,
signed_at.day()
)
}
fn build_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_url_query_value(bucket),
encode_url_path(object_key.trim_start_matches('/'))
),
None => format!("/{}/", encode_url_query_value(bucket)),
}
}
fn extract_oss_region(endpoint: &str) -> Result<String, OssError> {
endpoint
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.split('.')
.next()
.and_then(|segment| segment.strip_prefix("oss-"))
.map(str::to_string)
.filter(|region| !region.is_empty())
.ok_or_else(|| {
OssError::InvalidConfig(format!("OSS endpoint 无法解析 region,当前值:{endpoint}"))
})
}
fn sign_v4_content(
access_key_secret: &str,
signature_scope: &str,
content: &str,
) -> Result<String, OssError> {
let signing_key = build_v4_signing_key(access_key_secret, signature_scope)?;
Ok(hex_sha256_hmac(&signing_key, content.as_bytes()))
}
fn build_v4_signing_key(
access_key_secret: &str,
signature_scope: &str,
) -> Result<Vec<u8>, OssError> {
let mut parts = signature_scope.split('/');
let date = parts
.next()
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少日期".to_string()))?;
let region = parts
.next()
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少 region".to_string()))?;
let service = parts
.next()
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少 service".to_string()))?;
let request = parts
.next()
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少 request".to_string()))?;
let date_key = hmac_sha256_raw(format!("aliyun_v4{access_key_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>, OssError> {
let mut signer = HmacSha256::new_from_slice(key)
.map_err(|error| OssError::Sign(format!("初始化 HMAC-SHA256 失败:{error}")))?;
signer.update(content.as_bytes());
Ok(signer.finalize().into_bytes().to_vec())
}
fn hex_sha256_hmac(key: &[u8], content: &[u8]) -> String {
let mut signer = HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts keys of any size");
signer.update(content);
hex_lower(&signer.finalize().into_bytes())
}
fn build_v4_canonical_request(
method: &str,
canonical_uri: &str,
canonical_query: &str,
canonical_headers: &str,
signed_headers: &str,
payload_hash: &str,
) -> String {
format!(
"{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
)
}
fn build_v4_string_to_sign(
signature_date: &str,
signature_scope: &str,
canonical_request: &str,
) -> String {
format!(
"{OSS_V4_ALGORITHM}\n{signature_date}\n{signature_scope}\n{}",
sha256_hex(canonical_request.as_bytes())
)
}
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 build_v4_canonical_headers(headers: &BTreeMap<String, String>) -> String {
headers
.iter()
.map(|(key, value)| format!("{}:{}\n", key.to_ascii_lowercase(), value.trim()))
.collect::<String>()
}
fn build_v4_additional_headers(headers: &BTreeMap<String, String>) -> String {
let mut additional_headers = headers
.keys()
.map(|key| key.to_ascii_lowercase())
.filter(|key| key != "content-type" && key != "content-md5" && !key.starts_with("x-oss-"))
.collect::<Vec<_>>();
additional_headers.sort();
additional_headers.join(";")
}
fn build_canonical_query_string(params: &BTreeMap<String, String>) -> String {
params
.iter()
.map(|(key, value)| {
format!(
"{}={}",
encode_url_query_value(key),
encode_url_query_value(value)
)
})
.collect::<Vec<_>>()
.join("&")
}
fn encode_url_path(path: &str) -> String {
path.split('/')
.map(encode_url_query_value)
.collect::<Vec<_>>()
.join("/")
}
fn encode_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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oss_error_kind_is_stable_for_adapter_mapping() {
assert_eq!(
OssError::InvalidConfig("bad config".to_string()).kind(),
OssErrorKind::InvalidConfig
);
assert_eq!(
OssError::ObjectNotFound("missing".to_string()).kind(),
OssErrorKind::ObjectNotFound
);
assert_eq!(
OssError::Request("network".to_string()).kind(),
OssErrorKind::Request
);
}
#[test]
fn structured_log_labels_are_stable() {
assert_eq!(
oss_error_kind_label(&OssError::InvalidRequest("bad input".to_string())),
"invalid_request"
);
assert_eq!(oss_access_label(OssObjectAccess::Private), "private");
assert_eq!(http_status_class(204), "2xx");
assert_eq!(http_status_class(404), "4xx");
assert_eq!(http_status_class_from_option(None), "unknown");
}
fn build_client() -> OssClient {
OssClient::new(
OssConfig::new(
"genarrative-assets".to_string(),
"oss-cn-shanghai.aliyuncs.com".to_string(),
"test-access-key-id".to_string(),
"test-access-key-secret".to_string(),
DEFAULT_READ_EXPIRE_SECONDS,
DEFAULT_POST_EXPIRE_SECONDS,
DEFAULT_POST_MAX_SIZE_BYTES,
DEFAULT_SUCCESS_ACTION_STATUS,
)
.expect("OSS config should be valid"),
)
}
#[test]
fn parse_legacy_prefix_accepts_public_style_path() {
assert_eq!(
LegacyAssetPrefix::parse("/generated-characters/*"),
Some(LegacyAssetPrefix::Characters)
);
assert_eq!(
LegacyAssetPrefix::parse("/generated-puzzle-assets/*"),
Some(LegacyAssetPrefix::PuzzleAssets)
);
assert_eq!(
LegacyAssetPrefix::parse("/generated-match3d-assets/*"),
Some(LegacyAssetPrefix::Match3DAssets)
);
assert_eq!(
LegacyAssetPrefix::parse("/generated-wooden-fish-assets/*"),
Some(LegacyAssetPrefix::WoodenFishAssets)
);
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-puzzle-assets"));
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-match3d-assets"));
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-bark-battle-assets"));
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-wooden-fish-assets"));
assert_eq!(LegacyAssetPrefix::parse("unknown"), None);
}
#[test]
fn build_v4_signature_date_zero_pads_single_digit_time_parts() {
let signed_at =
OffsetDateTime::from_unix_timestamp(1_771_477_389).expect("timestamp should be valid");
assert_eq!(
build_v4_signature_date(signed_at).expect("date should format"),
"20260219T050309Z"
);
assert_eq!(
build_v4_signature_scope("oss-cn-shanghai.aliyuncs.com", signed_at)
.expect("scope should format"),
"20260219/cn-shanghai/oss/aliyun_v4_request"
);
}
#[test]
fn sign_post_object_returns_bucket_and_object_key_for_private_storage_truth() {
let client = build_client();
let mut metadata = BTreeMap::new();
metadata.insert("asset-kind".to_string(), "character-visual".to_string());
metadata.insert("origin".to_string(), "browser-upload".to_string());
let response = client
.sign_post_object(OssPostObjectRequest {
prefix: LegacyAssetPrefix::Characters,
path_segments: vec![
"Hero_001".to_string(),
"Visual".to_string(),
"Asset_01".to_string(),
],
file_name: "Master.PNG".to_string(),
content_type: Some("image/png".to_string()),
access: OssObjectAccess::Public,
metadata,
max_size_bytes: Some(5 * 1024 * 1024),
expire_seconds: Some(300),
success_action_status: Some(200),
})
.expect("post object signature should build");
assert_eq!(
response.object_key,
"generated-characters/hero_001/visual/asset_01/master.png"
);
assert_eq!(
response.legacy_public_path,
"/generated-characters/hero_001/visual/asset_01/master.png"
);
assert_eq!(response.bucket, "genarrative-assets".to_string());
assert_eq!(
response.form_fields.signature_version,
OSS_V4_ALGORITHM.to_string()
);
assert!(
response
.form_fields
.credential
.starts_with("test-access-key-id/")
);
assert!(
response
.form_fields
.credential
.ends_with("/cn-shanghai/oss/aliyun_v4_request")
);
assert_eq!(response.form_fields.date.len(), "20260507T120000Z".len());
assert_eq!(
response.form_fields.metadata.get("x-oss-meta-asset-kind"),
Some(&"character-visual".to_string())
);
}
#[test]
fn sign_post_object_embeds_policy_constraints() {
let client = build_client();
let response = client
.sign_post_object(OssPostObjectRequest {
prefix: LegacyAssetPrefix::QwenSprites,
path_segments: vec!["_drafts".to_string(), "master".to_string()],
file_name: "candidate-01.png".to_string(),
content_type: Some("image/png".to_string()),
access: OssObjectAccess::Private,
metadata: BTreeMap::new(),
max_size_bytes: Some(1024),
expire_seconds: Some(60),
success_action_status: Some(200),
})
.expect("post object signature should build");
let decoded_policy = BASE64_STANDARD
.decode(response.form_fields.policy.as_bytes())
.expect("policy should be valid base64");
let policy: Value =
serde_json::from_slice(&decoded_policy).expect("policy should be valid json");
assert_eq!(
policy["conditions"][0]["bucket"],
Value::String("genarrative-assets".to_string())
);
assert_eq!(
policy["conditions"][1],
json!([
"eq",
"$key",
"generated-qwen-sprites/_drafts/master/candidate-01.png"
])
);
assert_eq!(
policy["conditions"][2],
json!(["content-length-range", 1, 1024])
);
assert_eq!(
policy["conditions"][3],
json!(["eq", "$success_action_status", "200"])
);
assert_eq!(
policy["conditions"][4],
json!(["eq", "$x-oss-signature-version", "OSS4-HMAC-SHA256"])
);
assert_eq!(
policy["conditions"][5],
json!(["eq", "$x-oss-credential", response.form_fields.credential])
);
assert_eq!(
policy["conditions"][6],
json!(["eq", "$x-oss-date", response.form_fields.date])
);
assert_eq!(
policy["conditions"][7],
json!(["eq", "$content-type", "image/png"])
);
assert_eq!(
policy["conditions"][8],
json!(["eq", "$Cache-Control", DEFAULT_IMMUTABLE_CACHE_CONTROL])
);
assert_eq!(
response.form_fields.cache_control,
Some(DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string())
);
assert_eq!(response.bucket, "genarrative-assets".to_string());
}
#[test]
fn sanitize_file_name_rejects_empty_input() {
let error = sanitize_file_name(" ").expect_err("empty file name should fail");
assert_eq!(
error,
OssError::InvalidRequest("fileName 不能为空".to_string())
);
}
#[test]
fn sign_get_object_url_returns_signed_private_read_url() {
let client = build_client();
let response = client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: "generated-characters/hero_001/visual/asset_01/master.png".to_string(),
expire_seconds: Some(300),
})
.expect("signed get url should build");
assert_eq!(response.bucket, "genarrative-assets".to_string());
assert_eq!(
response.object_key,
"generated-characters/hero_001/visual/asset_01/master.png".to_string()
);
assert!(response
.signed_url
.starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/generated-characters/hero_001/visual/asset_01/master.png?"));
assert!(
response
.signed_url
.contains("x-oss-signature-version=OSS4-HMAC-SHA256")
);
assert!(
response
.signed_url
.contains("x-oss-credential=test-access-key-id%2F")
);
assert!(response.signed_url.contains("&x-oss-expires=300"));
assert!(response.signed_url.contains("&x-oss-signature="));
}
#[test]
fn sign_get_object_url_uses_square_hole_object_key_without_bucket_prefix() {
let client = OssClient::new(
OssConfig::new(
"xushi-dev".to_string(),
"oss-cn-shanghai.aliyuncs.com".to_string(),
"test-access-key-id".to_string(),
"test-access-key-secret".to_string(),
DEFAULT_READ_EXPIRE_SECONDS,
DEFAULT_POST_EXPIRE_SECONDS,
DEFAULT_POST_MAX_SIZE_BYTES,
DEFAULT_SUCCESS_ACTION_STATUS,
)
.expect("OSS config should be valid"),
);
let response = client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: "generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png".to_string(),
expire_seconds: Some(300),
})
.expect("square hole object key should build signed url");
assert_eq!(response.bucket, "xushi-dev".to_string());
assert_eq!(
response.object_key,
"generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png".to_string()
);
assert!(response
.signed_url
.starts_with("https://xushi-dev.oss-cn-shanghai.aliyuncs.com/generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png?"));
}
#[test]
fn sign_get_object_url_rejects_unsupported_prefix() {
let client = build_client();
let error = client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: "workflow-cache/task-1.json".to_string(),
expire_seconds: Some(300),
})
.expect_err("unsupported prefix should fail");
assert_eq!(
error,
OssError::InvalidRequest("objectKey 必须落在受支持的 generated-* 前缀下".to_string())
);
}
#[test]
fn legacy_prefix_can_be_resolved_from_object_key() {
assert_eq!(
LegacyAssetPrefix::from_object_key(
"generated-custom-world-scenes/profile_01/landmark_01/scene.png"
),
Some(LegacyAssetPrefix::CustomWorldScenes)
);
assert_eq!(
LegacyAssetPrefix::from_object_key(
"generated-wooden-fish-assets/session/profile/hit_object/asset/image.png"
),
Some(LegacyAssetPrefix::WoodenFishAssets)
);
assert_eq!(
LegacyAssetPrefix::from_object_key("workflow-cache/demo.json"),
None
);
}
#[test]
fn put_object_request_reuses_generated_object_key_contract() {
let request = OssPutObjectRequest {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec!["Profile 001".to_string(), "asset_01".to_string()],
file_name: "Cover.PNG".to_string(),
content_type: Some(" image/png ".to_string()),
access: OssObjectAccess::Private,
metadata: BTreeMap::from([
("asset_kind".to_string(), "custom_world_cover".to_string()),
("source job id".to_string(), "job_001".to_string()),
]),
body: b"cover-bytes".to_vec(),
};
let sanitized_segments = request
.path_segments
.iter()
.map(|segment| sanitize_path_segment(segment))
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let file_name = sanitize_file_name(&request.file_name).expect("file name should sanitize");
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
let metadata = normalize_metadata(request.metadata).expect("metadata should normalize");
assert_eq!(
object_key,
"generated-custom-world-covers/profile-001/asset_01/cover.png"
);
assert_eq!(
metadata.get("x-oss-meta-asset-kind"),
Some(&"custom_world_cover".to_string())
);
assert_eq!(
metadata.get("x-oss-meta-source-job-id"),
Some(&"job_001".to_string())
);
}
#[test]
fn canonicalized_oss_headers_matches_sorted_v4_header_shape() {
let headers = BTreeMap::from([
(
"Cache-Control".to_string(),
DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(),
),
(
"x-oss-meta-source-job-id".to_string(),
" job_001 ".to_string(),
),
(
"x-oss-meta-asset-kind".to_string(),
"character_visual".to_string(),
),
]);
assert_eq!(
build_v4_canonical_headers(&headers),
"cache-control:public, max-age=31536000, immutable\nx-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n"
);
}
#[test]
fn additional_headers_include_plain_headers_and_skip_oss_managed_headers() {
let headers = BTreeMap::from([
(
"host".to_string(),
"genarrative-assets.oss-cn-beijing.aliyuncs.com".to_string(),
),
("content-type".to_string(), "image/png".to_string()),
(
"Cache-Control".to_string(),
DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(),
),
("x-oss-date".to_string(), "20260507T120000Z".to_string()),
(
"x-oss-meta-asset-kind".to_string(),
"puzzle-cover".to_string(),
),
]);
assert_eq!(build_v4_additional_headers(&headers), "cache-control;host");
}
#[test]
fn put_object_headers_include_immutable_cache_control_for_generated_assets() {
let headers = build_put_object_headers(BTreeMap::from([(
"asset-kind".to_string(),
"puzzle-cover".to_string(),
)]))
.expect("headers should build");
assert_eq!(
headers.get("Cache-Control"),
Some(&DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string())
);
assert_eq!(
headers.get("x-oss-meta-asset-kind"),
Some(&"puzzle-cover".to_string())
);
}
#[tokio::test]
async fn put_object_rejects_empty_body_before_calling_oss() {
let client = build_client();
let error = client
.put_object(
&reqwest::Client::new(),
OssPutObjectRequest {
prefix: LegacyAssetPrefix::Characters,
path_segments: vec!["hero".to_string()],
file_name: "master.png".to_string(),
content_type: Some("image/png".to_string()),
access: OssObjectAccess::Private,
metadata: BTreeMap::new(),
body: Vec::new(),
},
)
.await
.expect_err("empty server upload should fail before network");
assert_eq!(
error,
OssError::InvalidRequest("服务端上传对象内容不能为空".to_string())
);
}
}