From 5675c4011926c3a6f23f5831be24e517ec9de0b0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 21 Apr 2026 14:36:34 +0800 Subject: [PATCH] feat: add oss direct upload adapter --- .env.example | 13 + .../00_MASTER_TASKLIST.md | 9 +- .../05_M6_ASSETS_OSS_EDITOR.md | 22 +- ...M_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md | 25 + server-rs/Cargo.lock | 107 +++ server-rs/Cargo.toml | 1 + server-rs/README.md | 2 + server-rs/crates/api-server/Cargo.toml | 2 + .../crates/api-server/src/api_response.rs | 18 +- server-rs/crates/api-server/src/app.rs | 14 +- server-rs/crates/api-server/src/assets.rs | 206 +++++ server-rs/crates/api-server/src/config.rs | 98 ++- server-rs/crates/api-server/src/http_error.rs | 6 + server-rs/crates/api-server/src/main.rs | 7 +- .../crates/api-server/src/response_headers.rs | 24 +- server-rs/crates/api-server/src/state.rs | 41 + server-rs/crates/module-assets/README.md | 5 +- server-rs/crates/platform-oss/Cargo.toml | 13 + server-rs/crates/platform-oss/README.md | 20 +- server-rs/crates/platform-oss/src/lib.rs | 728 ++++++++++++++++++ 20 files changed, 1308 insertions(+), 53 deletions(-) create mode 100644 server-rs/crates/api-server/src/assets.rs create mode 100644 server-rs/crates/platform-oss/Cargo.toml create mode 100644 server-rs/crates/platform-oss/src/lib.rs diff --git a/.env.example b/.env.example index 4a2bcfbe..1d858d81 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/backend-rewrite-tasklist/00_MASTER_TASKLIST.md b/backend-rewrite-tasklist/00_MASTER_TASKLIST.md index 84c7133b..cce7fee6 100644 --- a/backend-rewrite-tasklist/00_MASTER_TASKLIST.md +++ b/backend-rewrite-tasklist/00_MASTER_TASKLIST.md @@ -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. 最终验收清单 diff --git a/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md b/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md index 240923cc..c714278e 100644 --- a/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md +++ b/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md @@ -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` diff --git a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md index f6bdb979..7e4d825c 100644 --- a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md +++ b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md @@ -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 元数据与标签 建议所有业务对象写入统一元数据: diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 6b2151de..245ccc49 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -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" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index d0fd77c7..91b9e57c 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -5,6 +5,7 @@ resolver = "2" members = [ "crates/api-server", + "crates/platform-oss", "crates/platform-auth", "crates/shared-logging", ] diff --git a/server-rs/README.md b/server-rs/README.md index 7406209f..acb92adc 100644 --- a/server-rs/README.md +++ b/server-rs/README.md @@ -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 实施约束 diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 080be28b..1a9bca9c 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -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" } diff --git a/server-rs/crates/api-server/src/api_response.rs b/server-rs/crates/api-server/src/api_response.rs index accf6d4e..7dcdfee2 100644 --- a/server-rs/crates/api-server/src/api_response.rs +++ b/server-rs/crates/api-server/src/api_response.rs @@ -27,15 +27,15 @@ pub fn json_success_body(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)) diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d5a51583..be1670d1 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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)) // 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。 diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs new file mode 100644 index 00000000..e53b8943 --- /dev/null +++ b/server-rs/crates/api-server/src/assets.rs @@ -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, + pub file_name: String, + #[serde(default)] + pub content_type: Option, + #[serde(default)] + pub access: Option, + #[serde(default)] + pub metadata: BTreeMap, + #[serde(default)] + pub max_size_bytes: Option, + #[serde(default)] + pub expire_seconds: Option, + #[serde(default)] + pub success_action_status: Option, +} + +pub async fn create_direct_upload_ticket( + State(state): State, + Extension(request_context): Extension, + Json(payload): Json, +) -> Result, AppError> { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + + let legacy_prefix = LegacyAssetPrefix::parse(&payload.legacy_prefix).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "legacyPrefix", + "supported": platform_oss::LEGACY_PUBLIC_PREFIXES, + })) + })?; + + 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()) + ); + } +} diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 7176e05d..87be9d0d 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -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, + pub oss_endpoint: Option, + pub oss_access_key_id: Option, + pub oss_access_key_secret: Option, + pub oss_public_base_url: Option, + 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::() { - config.bind_port = parsed_port; - } + if let Ok(bind_port) = env::var("GENARRATIVE_API_PORT") + && let Ok(parsed_port) = bind_port.parse::() + { + 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 { }) } +fn read_first_positive_u64_env(keys: &[&str]) -> Option { + 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 { + keys.iter().find_map(|key| { + env::var(key) + .ok() + .and_then(|value| parse_positive_u16(&value)) + }) +} + fn parse_duration_seconds(raw: &str) -> Option { let raw = raw.trim(); if raw.is_empty() { @@ -185,3 +241,21 @@ fn parse_positive_u32(raw: &str) -> Option { Some(value) } + +fn parse_positive_u64(raw: &str) -> Option { + let value = raw.trim().parse::().ok()?; + if value == 0 { + return None; + } + + Some(value) +} + +fn parse_positive_u16(raw: &str) -> Option { + let value = raw.trim().parse::().ok()?; + if value == 0 { + return None; + } + + Some(value) +} diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index 1987b3f2..3df80540 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -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", "服务器内部错误"), } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index f791ded8..6d1edfa5 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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 初始化并开始监听"); diff --git a/server-rs/crates/api-server/src/response_headers.rs b/server-rs/crates/api-server/src/response_headers.rs index b4b647c9..6d373222 100644 --- a/server-rs/crates/api-server/src/response_headers.rs +++ b/server-rs/crates/api-server/src/response_headers.rs @@ -19,12 +19,12 @@ pub async fn propagate_request_id_header(request: Request, next: Next) -> Respon let request_context = request.extensions().get::().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 diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 4d4672e2..865cb98a 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -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, } #[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 for AppStateInitError { Self::RefreshCookie(value) } } + +impl From for AppStateInitError { + fn from(value: OssError) -> Self { + Self::Oss(value) + } +} + +fn build_oss_client(config: &AppConfig) -> Result, 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))) +} diff --git a/server-rs/crates/module-assets/README.md b/server-rs/crates/module-assets/README.md index 6b72841c..6696de87 100644 --- a/server-rs/crates/module-assets/README.md +++ b/server-rs/crates/module-assets/README.md @@ -14,7 +14,10 @@ ## 2. 当前阶段说明 -当前提交仅完成目录占位,不提前进入生成链路、对象确认与兼容接口实现。 +当前提交尚未进入完整资产状态建模,但已完成与本模块直接相关的前置基础设施: + +1. `api-server` 已具备 `POST /api/assets/direct-upload-tickets` +2. `platform-oss` 已具备旧 `/generated-*` 前缀兼容的 `PostObject` 签名能力 后续与本 package 直接相关的任务包括: diff --git a/server-rs/crates/platform-oss/Cargo.toml b/server-rs/crates/platform-oss/Cargo.toml new file mode 100644 index 00000000..72dc64e2 --- /dev/null +++ b/server-rs/crates/platform-oss/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/platform-oss/README.md b/server-rs/crates/platform-oss/README.md index d6d7c954..7656b6c5 100644 --- a/server-rs/crates/platform-oss/README.md +++ b/server-rs/crates/platform-oss/README.md @@ -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. 边界约束 diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs new file mode 100644 index 00000000..47bb730a --- /dev/null +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -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; + +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, + 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, + pub file_name: String, + pub content_type: Option, + pub access: OssObjectAccess, + pub metadata: BTreeMap, + pub max_size_bytes: Option, + pub expire_seconds: Option, + pub success_action_status: Option, +} + +#[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, + #[serde(rename = "contentType", skip_serializing_if = "Option::is_none")] + pub content_type: Option, + 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, + #[serde(flatten)] + pub metadata: BTreeMap, +} + +#[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 { + 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, + default_post_expire_seconds: u64, + default_post_max_size_bytes: u64, + default_success_action_status: u16, + ) -> Result { + 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 { + 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::>(); + 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, +) -> 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, +) -> Result, 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::(); + + 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::(); + 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::(); + + collapse_dashes(&normalized) +} + +fn sanitize_file_name(raw: &str) -> Result { + 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::(); + 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::() + }) + .filter(|extension| !extension.is_empty()); + + Ok(match extension { + Some(extension) => format!("{stem}.{extension}"), + None => stem, + }) +} + +fn normalize_required_value(value: String, message: &str) -> Result { + 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) -> Option { + 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) -> Option { + normalize_optional_value(value).map(|value| value.trim_end_matches('/').to_string()) +} + +fn normalize_endpoint(raw: &str) -> Result { + 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 { + 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()) + ); + } +}