feat: add oss direct upload adapter

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

View File

@@ -94,6 +94,19 @@ VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
# 阿里云 OSS 配置。
# Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量,
# 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。
ALIYUN_OSS_BUCKET=""
ALIYUN_OSS_ENDPOINT="oss-cn-shanghai.aliyuncs.com"
ALIYUN_OSS_ACCESS_KEY_ID=""
ALIYUN_OSS_ACCESS_KEY_SECRET=""
# 可选:如已接入 CDN可填 CDN 域名;未填写时将回退为 bucket 直连域名。
ALIYUN_OSS_PUBLIC_BASE_URL=""
ALIYUN_OSS_POST_EXPIRE_SECONDS="600"
ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520"
ALIYUN_OSS_SUCCESS_ACTION_STATUS="200"
# Optional model name for custom-world scene image generation.
DASHSCOPE_IMAGE_MODEL="wan2.7-image"

View File

@@ -133,10 +133,11 @@
1. 先做 `M0`,冻结基线,避免迁移过程中口径漂移。
2. 再做 `M1 + M2`,先把 Axum 壳与鉴权打稳。
3. 再做 `M3`优先跑通快照、设置、profile
4. 再做 `M4`把 story action 主循环真正迁走
5. 然后`M5`迁 custom world 与 agent
6. 后做 `M6 + M7`,收口 assets、部署与切流
3. 当前执行顺序允许前置 `M6` 的 OSS 基础设施与直传票据能力,为后续各阶段复用统一资产入口
4. 再做 `M3`优先跑通快照、设置、profile
5. `M4`把 story action 主循环真正迁走
6. 后做 `M5`,迁 custom world 与 agent
7. 最后收口 `M6` 余下资产绑定、`M7` 部署与切流。
## 5. 最终验收清单

View File

@@ -7,22 +7,32 @@
## 1. OSS 基础设施
- [ ] 创建 OSS bucket 方案
- [ ] 设计对象键前缀
- [ ] 设计 `object_key -> cdn_url` 解析策略
- [ ] 设计 public / private 对象访问策略
- [x] 创建 OSS bucket 方案
- [x] 设计对象键前缀
- [x] 设计 `object_key -> cdn_url` 解析策略
- [x] 设计 public / private 对象访问策略
- [ ] 设计签名 URL 输出策略
- [ ] 设计 `x-oss-meta-*` 元数据规范
- [x] 设计 `x-oss-meta-*` 元数据规范
- [ ] 设计内容 hash / 版本字段规范
## 2. 上传与对象确认
- [ ] 实现浏览器 `PostObject` 直传签名接口
- [x] 实现浏览器 `PostObject` 直传签名接口
- [ ] 实现 STS 临时授权接口
- [ ] 实现服务端上传 helper
- [ ] 实现上传完成后的对象确认接口
- [ ] 实现对象绑定业务实体 reducer
补充说明:
1.`2026-04-21` 起,当前重写节奏允许在 `M3/M4/M5` 之前先前置落地 `M6` 的 OSS 基础设施。
2. 当前已在 `server-rs/crates/platform-oss``server-rs/crates/api-server` 落下最小可用链路:
- `PostObject` 直传签名能力
- `/api/assets/direct-upload-tickets`
- 兼容旧 `/generated-*` 前缀的对象键规划
- `.env/.env.local` 的 OSS 环境变量加载
3. 当前仍未进入 `STS`、服务端上传 helper、对象确认与 `SpacetimeDB` 绑定阶段。
## 3. 资产任务系统
- [ ] 设计 `asset_job`

View File

@@ -39,6 +39,7 @@
1. `editor` 挂载面在历史系统中真实存在,但已被确认为遗留无用能力。
2. `editor` 仅保留为历史基线对照,不纳入本轮 `server-rs` 重写验收。
3. 当前执行顺序允许在 `M3 / M4 / M5` 前,先前置 `assets / OSS` 的基础设施接入,以便后续 runtime、custom world、agent 统一复用同一资产入口。
当前后端内部模块也不能“凭感觉重设计”,而要按现有职责做映射:
@@ -162,6 +163,11 @@ Aliyun OSS
3. Axum 保持当前 story / custom-world-agent 的 SSE 体验。
4. SpacetimeDB 先做后端内部真相源。
补充执行口径:
1. 虽然总体里程碑仍保留 `M6` 编号,但 `OSS` 的平台适配、浏览器直传票据与旧 `/generated-*` 路径兼容能力允许提前于 `M3 / M4 / M5` 落地。
2. 提前落地的目标是先收口统一资产入口,不是提前把全部资产业务状态迁完。
第二阶段再按模块把只读页改成直接订阅 SpacetimeDB。
### 5.2 命令与读模型分离
@@ -593,6 +599,25 @@ workflow-cache/{workflow_type}/{workflow_id}.json
- content-length-range
- success_action_status
当前已落地的最小实现补充:
1. `server-rs/crates/platform-oss` 已提供 `PostObject` 直传签名能力。
2. `server-rs/crates/api-server` 已暴露 `POST /api/assets/direct-upload-tickets`
3. 该接口当前输出:
- `objectKey`
- `legacyPublicPath`
- `publicUrl`
- `formFields`
- `expiresAt`
4. 当前签名链路优先兼容旧公开前缀:
- `/generated-character-drafts/*`
- `/generated-characters/*`
- `/generated-animations/*`
- `/generated-custom-world-scenes/*`
- `/generated-custom-world-covers/*`
- `/generated-qwen-sprites/*`
5. `STS`、服务端上传 helper、对象确认与业务绑定仍在后续阶段补齐。
## 11.3 元数据与标签
建议所有业务对象写入统一元数据:

107
server-rs/Cargo.lock generated
View File

@@ -22,8 +22,10 @@ name = "api-server"
version = "0.1.0"
dependencies = [
"axum",
"dotenvy",
"http-body-util",
"platform-auth",
"platform-oss",
"serde",
"serde_json",
"shared-logging",
@@ -111,6 +113,15 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -139,6 +150,25 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -148,6 +178,23 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -208,6 +255,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -255,6 +312,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.4.0"
@@ -527,6 +593,18 @@ dependencies = [
"urlencoding",
]
[[package]]
name = "platform-oss"
version = "0.1.0"
dependencies = [
"base64",
"hmac",
"serde",
"serde_json",
"sha1",
"time",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
@@ -682,6 +760,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -738,6 +827,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
@@ -946,6 +1041,12 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -987,6 +1088,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"

View File

@@ -5,6 +5,7 @@
resolver = "2"
members = [
"crates/api-server",
"crates/platform-oss",
"crates/platform-auth",
"crates/shared-logging",
]

View File

@@ -56,6 +56,7 @@
1. `crates/spacetime-module` 的表、reducer、view 聚合入口
2. `module-auth` 的身份表、JWT 与 refresh cookie 主链
3. `platform-oss` 的浏览器直传签名、旧 `/generated-*` 前缀映射与对象 URL 解析能力
## 3. 已冻结边界
@@ -66,6 +67,7 @@
3. 外部副作用统一收口在 Axum / crate 内应用层 / infra。
4. `crates/api-server` 只组合与暴露协议,不直接吞并业务模块实现。
5. `crates/spacetime-module` 只负责汇总各模块 crate 的表、reducer、view。
6. 当前允许在 `M3 / M4 / M5` 前先行落地 `OSS` 基础设施,但不因此跳过后续资产状态建模与绑定迁移。
## 4. SpacetimeDB 实施约束

View File

@@ -6,7 +6,9 @@ license.workspace = true
[dependencies]
axum = "0.8"
dotenvy = "0.15"
platform-auth = { path = "../platform-auth" }
platform-oss = { path = "../platform-oss" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-logging = { path = "../shared-logging" }

View File

@@ -27,15 +27,15 @@ pub fn json_success_body<T>(request_context: Option<&RequestContext>, data: T) -
where
T: Serialize,
{
if let Some(context) = request_context {
if context.wants_envelope() {
return Json(json!({
"ok": true,
"data": data,
"error": null,
"meta": build_api_response_meta(Some(context)),
}));
}
if let Some(context) = request_context
&& context.wants_envelope()
{
return Json(json!({
"ok": true,
"data": data,
"error": null,
"meta": build_api_response_meta(Some(context)),
}));
}
Json(serde_json::to_value(data).unwrap_or(Value::Null))

View File

@@ -1,8 +1,16 @@
use axum::{Router, body::Body, extract::Extension, http::Request, middleware, routing::get};
use axum::{
Router,
body::Body,
extract::Extension,
http::Request,
middleware,
routing::{get, post},
};
use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer};
use tracing::{Level, info_span};
use crate::{
assets::create_direct_upload_ticket,
auth::{
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
require_bearer_auth,
@@ -37,6 +45,10 @@ pub fn build_router(state: AppState) -> Router {
attach_refresh_session_token,
)),
)
.route(
"/api/assets/direct-upload-tickets",
post(create_direct_upload_ticket),
)
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
.layer(middleware::from_fn(normalize_error_response))
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。

View File

@@ -0,0 +1,206 @@
use std::collections::BTreeMap;
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
};
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPostObjectRequest};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectUploadTicketRequest {
pub legacy_prefix: String,
#[serde(default)]
pub path_segments: Vec<String>,
pub file_name: String,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub access: Option<OssObjectAccess>,
#[serde(default)]
pub metadata: BTreeMap<String, String>,
#[serde(default)]
pub max_size_bytes: Option<u64>,
#[serde(default)]
pub expire_seconds: Option<u64>,
#[serde(default)]
pub success_action_status: Option<u16>,
}
pub async fn create_direct_upload_ticket(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<CreateDirectUploadTicketRequest>,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let legacy_prefix = LegacyAssetPrefix::parse(&payload.legacy_prefix).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "legacyPrefix",
"supported": platform_oss::LEGACY_PUBLIC_PREFIXES,
}))
})?;
let signed = oss_client
.sign_post_object(OssPostObjectRequest {
prefix: legacy_prefix,
path_segments: payload.path_segments,
file_name: payload.file_name,
content_type: payload.content_type,
access: payload.access.unwrap_or(OssObjectAccess::Public),
metadata: payload.metadata,
max_size_bytes: payload.max_size_bytes,
expire_seconds: payload.expire_seconds,
success_action_status: payload.success_action_status,
})
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
})?;
Ok(json_success_body(
Some(&request_context),
json!({
"upload": signed,
}),
))
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/direct-upload-tickets")
.header("content-type", "application/json")
.body(Body::from(
json!({
"legacyPrefix": "/generated-characters/*",
"pathSegments": ["hero", "visual", "asset-01"],
"fileName": "master.png"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["code"],
Value::String("SERVICE_UNAVAILABLE".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("aliyun-oss".to_string())
);
}
#[tokio::test]
async fn direct_upload_ticket_returns_signed_payload_when_oss_configured() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
oss_public_base_url: Some("https://cdn.genarrative.local".to_string()),
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/direct-upload-tickets")
.header("content-type", "application/json")
.header("x-request-id", "req-oss-ticket")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"legacyPrefix": "/generated-characters/*",
"pathSegments": ["hero_001", "visual", "asset_01"],
"fileName": "master.png",
"contentType": "image/png",
"metadata": {
"asset-kind": "character-visual"
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["data"]["upload"]["objectKey"],
Value::String("generated-characters/hero_001/visual/asset_01/master.png".to_string())
);
assert_eq!(
payload["data"]["upload"]["publicUrl"],
Value::String(
"https://cdn.genarrative.local/generated-characters/hero_001/visual/asset_01/master.png"
.to_string()
)
);
assert_eq!(
payload["data"]["upload"]["formFields"]["OSSAccessKeyId"],
Value::String("test-access-key-id".to_string())
);
}
}

View File

@@ -14,6 +14,14 @@ pub struct AppConfig {
pub refresh_cookie_secure: bool,
pub refresh_cookie_same_site: String,
pub refresh_session_ttl_days: u32,
pub oss_bucket: Option<String>,
pub oss_endpoint: Option<String>,
pub oss_access_key_id: Option<String>,
pub oss_access_key_secret: Option<String>,
pub oss_public_base_url: Option<String>,
pub oss_post_expire_seconds: u64,
pub oss_post_max_size_bytes: u64,
pub oss_success_action_status: u16,
}
impl Default for AppConfig {
@@ -30,6 +38,14 @@ impl Default for AppConfig {
refresh_cookie_secure: false,
refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30,
oss_bucket: None,
oss_endpoint: None,
oss_access_key_id: None,
oss_access_key_secret: None,
oss_public_base_url: None,
oss_post_expire_seconds: 10 * 60,
oss_post_max_size_bytes: 20 * 1024 * 1024,
oss_success_action_status: 200,
}
}
}
@@ -38,22 +54,22 @@ impl AppConfig {
pub fn from_env() -> Self {
let mut config = Self::default();
if let Ok(bind_host) = env::var("GENARRATIVE_API_HOST") {
if !bind_host.trim().is_empty() {
config.bind_host = bind_host;
}
if let Ok(bind_host) = env::var("GENARRATIVE_API_HOST")
&& !bind_host.trim().is_empty()
{
config.bind_host = bind_host;
}
if let Ok(bind_port) = env::var("GENARRATIVE_API_PORT") {
if let Ok(parsed_port) = bind_port.parse::<u16>() {
config.bind_port = parsed_port;
}
if let Ok(bind_port) = env::var("GENARRATIVE_API_PORT")
&& let Ok(parsed_port) = bind_port.parse::<u16>()
{
config.bind_port = parsed_port;
}
if let Ok(log_filter) = env::var("GENARRATIVE_API_LOG") {
if !log_filter.trim().is_empty() {
config.log_filter = log_filter;
}
if let Ok(log_filter) = env::var("GENARRATIVE_API_LOG")
&& !log_filter.trim().is_empty()
{
config.log_filter = log_filter;
}
if let Some(jwt_issuer) =
@@ -99,6 +115,30 @@ impl AppConfig {
config.refresh_session_ttl_days = refresh_session_ttl_days;
}
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]);
config.oss_access_key_secret = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_SECRET"]);
config.oss_public_base_url = read_first_non_empty_env(&["ALIYUN_OSS_PUBLIC_BASE_URL"]);
if let Some(oss_post_expire_seconds) =
read_first_duration_seconds_env(&["ALIYUN_OSS_POST_EXPIRE_SECONDS"])
{
config.oss_post_expire_seconds = oss_post_expire_seconds;
}
if let Some(oss_post_max_size_bytes) =
read_first_positive_u64_env(&["ALIYUN_OSS_POST_MAX_SIZE_BYTES"])
{
config.oss_post_max_size_bytes = oss_post_max_size_bytes;
}
if let Some(oss_success_action_status) =
read_first_positive_u16_env(&["ALIYUN_OSS_SUCCESS_ACTION_STATUS"])
{
config.oss_success_action_status = oss_success_action_status;
}
config
}
@@ -144,6 +184,22 @@ fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
})
}
fn read_first_positive_u64_env(keys: &[&str]) -> Option<u64> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_positive_u64(&value))
})
}
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_positive_u16(&value))
})
}
fn parse_duration_seconds(raw: &str) -> Option<u64> {
let raw = raw.trim();
if raw.is_empty() {
@@ -185,3 +241,21 @@ fn parse_positive_u32(raw: &str) -> Option<u32> {
Some(value)
}
fn parse_positive_u64(raw: &str) -> Option<u64> {
let value = raw.trim().parse::<u64>().ok()?;
if value == 0 {
return None;
}
Some(value)
}
fn parse_positive_u16(raw: &str) -> Option<u16> {
let value = raw.trim().parse::<u16>().ok()?;
if value == 0 {
return None;
}
Some(value)
}

View File

@@ -39,6 +39,11 @@ impl AppError {
self.code
}
pub fn with_details(mut self, details: Value) -> Self {
self.details = Some(details);
self
}
pub fn into_response_with_context(self, request_context: Option<&RequestContext>) -> Response {
let status_code = self.status_code;
let payload = self.to_payload();
@@ -70,6 +75,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),
StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"),
StatusCode::SERVICE_UNAVAILABLE => ("SERVICE_UNAVAILABLE", "服务暂不可用"),
_ if status_code.is_client_error() => ("BAD_REQUEST", "请求参数不合法"),
_ => ("INTERNAL_SERVER_ERROR", "服务器内部错误"),
}

View File

@@ -1,5 +1,6 @@
mod api_response;
mod app;
mod assets;
mod auth;
mod config;
mod error_middleware;
@@ -17,6 +18,10 @@ use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// 运行本地开发与联调时,优先从仓库根目录的 .env / .env.local 加载变量,避免手工逐项导出 OSS 配置。
let _ = dotenvy::from_filename(".env");
let _ = dotenvy::from_filename(".env.local");
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
let config = AppConfig::from_env();
init_tracing(&config.log_filter)?;
@@ -25,7 +30,7 @@ async fn main() -> Result<(), std::io::Error> {
let listener = TcpListener::bind(bind_address).await?;
let state = AppState::new(config)
.map_err(|error| std::io::Error::other(format!("初始化鉴权配置失败:{error}")))?;
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
let router = build_router(state);
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听");

View File

@@ -19,12 +19,12 @@ pub async fn propagate_request_id_header(request: Request, next: Next) -> Respon
let request_context = request.extensions().get::<RequestContext>().cloned();
let mut response = next.run(request).await;
if let Some(request_id) = request_id {
if let Ok(header_value) = HeaderValue::from_str(&request_id) {
response
.headers_mut()
.insert(HeaderName::from_static(X_REQUEST_ID_HEADER), header_value);
}
if let Some(request_id) = request_id
&& let Ok(header_value) = HeaderValue::from_str(&request_id)
{
response
.headers_mut()
.insert(HeaderName::from_static(X_REQUEST_ID_HEADER), header_value);
}
if let Ok(header_value) = HeaderValue::from_str(API_VERSION) {
@@ -37,12 +37,12 @@ pub async fn propagate_request_id_header(request: Request, next: Next) -> Respon
.insert(HeaderName::from_static(ROUTE_VERSION_HEADER), header_value);
}
if let Some(request_context) = request_context {
if let Ok(header_value) = HeaderValue::from_str(&request_context.elapsed().to_string()) {
response
.headers_mut()
.insert(HeaderName::from_static(RESPONSE_TIME_HEADER), header_value);
}
if let Some(request_context) = request_context
&& let Ok(header_value) = HeaderValue::from_str(&request_context.elapsed().to_string())
{
response
.headers_mut()
.insert(HeaderName::from_static(RESPONSE_TIME_HEADER), header_value);
}
response

View File

@@ -3,6 +3,7 @@ use std::{error::Error, fmt};
use platform_auth::{
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
};
use platform_oss::{OssClient, OssConfig, OssError};
use crate::config::AppConfig;
@@ -14,12 +15,14 @@ pub struct AppState {
pub config: AppConfig,
auth_jwt_config: JwtConfig,
refresh_cookie_config: RefreshCookieConfig,
oss_client: Option<OssClient>,
}
#[derive(Debug)]
pub enum AppStateInitError {
Jwt(JwtError),
RefreshCookie(RefreshCookieError),
Oss(OssError),
}
impl AppState {
@@ -40,11 +43,13 @@ impl AppState {
refresh_cookie_same_site,
config.refresh_session_ttl_days,
)?;
let oss_client = build_oss_client(&config)?;
Ok(Self {
config,
auth_jwt_config,
refresh_cookie_config,
oss_client,
})
}
@@ -55,6 +60,10 @@ impl AppState {
pub fn refresh_cookie_config(&self) -> &RefreshCookieConfig {
&self.refresh_cookie_config
}
pub fn oss_client(&self) -> Option<&OssClient> {
self.oss_client.as_ref()
}
}
impl fmt::Display for AppStateInitError {
@@ -62,6 +71,7 @@ impl fmt::Display for AppStateInitError {
match self {
Self::Jwt(error) => write!(f, "{error}"),
Self::RefreshCookie(error) => write!(f, "{error}"),
Self::Oss(error) => write!(f, "{error}"),
}
}
}
@@ -79,3 +89,34 @@ impl From<RefreshCookieError> for AppStateInitError {
Self::RefreshCookie(value)
}
}
impl From<OssError> for AppStateInitError {
fn from(value: OssError) -> Self {
Self::Oss(value)
}
}
fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
let has_any_oss_field = config.oss_bucket.is_some()
|| config.oss_endpoint.is_some()
|| config.oss_access_key_id.is_some()
|| config.oss_access_key_secret.is_some()
|| config.oss_public_base_url.is_some();
if !has_any_oss_field {
return Ok(None);
}
let oss_config = OssConfig::new(
config.oss_bucket.clone().unwrap_or_default(),
config.oss_endpoint.clone().unwrap_or_default(),
config.oss_access_key_id.clone().unwrap_or_default(),
config.oss_access_key_secret.clone().unwrap_or_default(),
config.oss_public_base_url.clone(),
config.oss_post_expire_seconds,
config.oss_post_max_size_bytes,
config.oss_success_action_status,
)?;
Ok(Some(OssClient::new(oss_config)))
}

View File

@@ -14,7 +14,10 @@
## 2. 当前阶段说明
当前提交仅完成目录占位,不提前进入生成链路、对象确认与兼容接口实现。
当前提交尚未进入完整资产状态建模,但已完成与本模块直接相关的前置基础设施:
1. `api-server` 已具备 `POST /api/assets/direct-upload-tickets`
2. `platform-oss` 已具备旧 `/generated-*` 前缀兼容的 `PostObject` 签名能力
后续与本 package 直接相关的任务包括:

View File

@@ -0,0 +1,13 @@
[package]
name = "platform-oss"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
base64 = "0.22"
hmac = "0.12"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha1 = "0.10"
time = { version = "0.3", features = ["formatting"] }

View File

@@ -1,4 +1,4 @@
# platform-oss 平台适配 package 占位说明
# platform-oss 平台适配 package 说明
日期:`2026-04-20`
@@ -13,14 +13,20 @@
## 2. 当前阶段说明
当前提交仅完成目录占位,不提前进入 OSS SDK、上传策略与对象读写实现。
当前提交已落地最小可用 OSS 基础设施:
后续与本 package 直接相关的任务包括:
1. `PostObject` 浏览器直传签名
2.`/generated-*` 公开前缀到 OSS `object_key` 的兼容映射
3. `object_key -> publicUrl` 解析
4. `x-oss-meta-*` 元数据归一化与大小限制校验
5. `content-type``content-length-range``success_action_status` policy 条件生成
1. 落地 `PostObject`、STS、服务端上传适配
2. 落地对象确认、签名 URL 与 CDN URL 解析适配
3. 落地 `x-oss-meta-*` 元数据与对象标签适配
4. 对齐旧 `/generated-*` 路径兼容策略
当前仍未落地的内容:
1. `STS` 临时授权
2. 服务端上传 helper
3. 私有对象签名 URL
4. 对象确认与业务绑定
## 3. 边界约束

View File

@@ -0,0 +1,728 @@
use std::{collections::BTreeMap, error::Error, fmt};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha1::Sha1;
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
type HmacSha1 = Hmac<Sha1>;
pub const DEFAULT_POST_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 LEGACY_PUBLIC_PREFIXES: [&str; 6] = [
"generated-character-drafts",
"generated-characters",
"generated-animations",
"generated-custom-world-scenes",
"generated-custom-world-covers",
"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,
CustomWorldScenes,
CustomWorldCovers,
QwenSprites,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssConfig {
bucket: String,
endpoint: String,
access_key_id: String,
access_key_secret: String,
public_base_url: Option<String>,
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, 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 = "publicUrl", skip_serializing_if = "Option::is_none")]
pub public_url: Option<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 OssPostObjectFormFields {
pub key: String,
pub policy: String,
#[serde(rename = "OSSAccessKeyId")]
pub oss_access_key_id: String,
#[serde(rename = "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(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),
SerializePolicy(String),
Sign(String),
}
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-custom-world-scenes" => Some(Self::CustomWorldScenes),
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
"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::CustomWorldScenes => "generated-custom-world-scenes",
Self::CustomWorldCovers => "generated-custom-world-covers",
Self::QwenSprites => "generated-qwen-sprites",
}
}
pub fn as_public_path_prefix(&self) -> String {
format!("/{}", self.as_str())
}
}
impl OssConfig {
#[allow(clippy::too_many_arguments)]
pub fn new(
bucket: String,
endpoint: String,
access_key_id: String,
access_key_secret: String,
public_base_url: Option<String>,
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 不能为空")?;
let public_base_url = normalize_optional_base_url(public_base_url);
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,
public_base_url,
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
}
}
impl OssClient {
pub fn new(config: OssConfig) -> Self {
Self { config }
}
pub fn sign_post_object(
&self,
request: OssPostObjectRequest,
) -> Result<OssPostObjectResponse, OssError> {
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 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 policy_json = build_policy_json(
&self.config.bucket,
&object_key,
&expires_at,
max_size_bytes,
success_action_status,
content_type.as_deref(),
&metadata,
);
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_policy(&self.config.access_key_secret, &encoded_policy)?;
let public_url = match request.access {
OssObjectAccess::Public => Some(self.public_url_for(&object_key)),
OssObjectAccess::Private => None,
};
Ok(OssPostObjectResponse {
signature_version: "v1",
provider: "aliyun-oss",
bucket: self.config.bucket.clone(),
endpoint: self.config.endpoint.clone(),
host: self.config.upload_host(),
object_key: object_key.clone(),
legacy_public_path,
public_url,
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,
oss_access_key_id: self.config.access_key_id.clone(),
signature,
success_action_status: success_action_status.to_string(),
content_type,
metadata,
},
})
}
fn public_url_for(&self, object_key: &str) -> String {
let base_url = self
.config
.public_base_url
.clone()
.unwrap_or_else(|| self.config.upload_host());
format!("{}/{}", base_url.trim_end_matches('/'), object_key)
}
}
impl fmt::Display for OssError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidConfig(message)
| Self::InvalidRequest(message)
| Self::SerializePolicy(message)
| Self::Sign(message) => f.write_str(message),
}
}
}
impl Error for OssError {}
fn build_policy_json(
bucket: &str,
object_key: &str,
expires_at: &str,
max_size_bytes: u64,
success_action_status: u16,
content_type: Option<&str>,
metadata: &BTreeMap<String, String>,
) -> 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()
]),
];
if let Some(content_type) = content_type {
conditions.push(json!(["eq", "$content-type", content_type]));
}
for (key, value) in metadata {
conditions.push(json!(["eq", format!("${key}"), value]));
}
json!({
"expiration": expires_at,
"conditions": conditions,
})
}
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 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 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_optional_base_url(value: Option<String>) -> Option<String> {
normalize_optional_value(value).map(|value| value.trim_end_matches('/').to_string())
}
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()
}
fn sign_policy(access_key_secret: &str, encoded_policy: &str) -> Result<String, OssError> {
let mut signer = HmacSha1::new_from_slice(access_key_secret.as_bytes())
.map_err(|error| OssError::Sign(format!("初始化 HMAC-SHA1 失败:{error}")))?;
signer.update(encoded_policy.as_bytes());
Ok(BASE64_STANDARD.encode(signer.finalize().into_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
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(),
Some("https://cdn.genarrative.local".to_string()),
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("unknown"), None);
}
#[test]
fn sign_post_object_returns_legacy_compatible_key_and_urls() {
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.public_url.as_deref(),
Some(
"https://cdn.genarrative.local/generated-characters/hero_001/visual/asset_01/master.png"
)
);
assert_eq!(
response.form_fields.oss_access_key_id,
"test-access-key-id".to_string()
);
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", "$content-type", "image/png"])
);
assert!(response.public_url.is_none());
}
#[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())
);
}
}