合并 master 最新 OSS 上传优化

合入 origin/master 的动画生成 OSS 上传优化
保留现役编辑器 Agent LLM client 与退役隔离边界
保留角色动画 OSS 客户端复用与并发限制
This commit is contained in:
2026-07-20 20:08:55 +08:00
8 changed files with 1317 additions and 27 deletions
@@ -16,6 +16,16 @@
---
## 2026-07-20 角色动画帧 OSS 请求使用专用连接池、并发保护与结构化重试
- 背景:角色动作逐帧流水线会同时发起源帧 PUT、透明帧 PUT 和最终帧 HEAD;原路径每次请求新建 `reqwest::Client`,且 OSS 请求错误丢失 HTTP 状态和 timeout/connect/transport 分类,多个动画任务叠加时无法在进程级限制 OSS 在途请求,也无法安全区分 PUT 与 HEAD 的失败。
- 决策:`AppState` 仅为角色动画帧初始化一次 OSS HTTP Client 和 8 路 `Semaphore`。全帧 Future 仍保持 `buffer_unordered(frame_count.max(1))`,BgFilter、阿里云抠图和本地处理不占 OSS permit;每次 PUT/HEAD 网络 attempt 单独获取 permit,退避期间释放。`platform-oss` 保留 `OssErrorKind::Request`,但在 `OssError::Request` 中保留 operation、status、timeout、connect、transport、OSS code、OSS request-id 和原脱敏 message,并为动画帧提供 3 次 attempt、250ms/500ms 退避的 PUT/HEAD 独立重试。仅无响应传输错误、timeout、OSS PutObject 的 `400 + RequestTimeout`、PUT 400 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 5xx 可重试;动作帧 PUT 对 400 错误体最多读取 16 KiB,只提取 `Code` 和响应头优先的 `x-oss-request-id`,不记录完整 XML;错误体读取超时/断流时保留已读字节,已解析出的 `Code` 优先生效。除 `RequestTimeout` 与该错误体读取失败情形外的确定性 4xx、配置、签名、URL、空请求体、抠图和素材登记错误不重试。重试体在 platform-oss 内一次转为可复用 `Bytes`,每次重新签名和构造 Request,不复制整帧字节。
- 失败语义:最终帧 PUT 成功后才执行 HEAD;HEAD 失败只重试 HEAD,不重复 PUT。任一帧最终失败仍排空已启动的 Future、整段动作退款并禁止发布缺帧动画,帧结果继续按原始序号排序。
- 影响范围:`state.rs``platform-oss/lib.rs``character_animation_assets.rs`、对应 Cargo 依赖和架构 / 运维文档;不改变其他 OSS 调用方、BgFilter/阿里云降级、worker、计费退款、SpacetimeDB schema/DTO 或前端接口。
- 验证方式:`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml``cargo test -p api-server character_animation --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
---
## 2026-07-18 图片生成 K 档由 provider 直接生成
- 背景:旧 gpt-image-2 尺寸表会把 2K 竖版回落到 `1024x1536`,图标入口又使用固定 `360x360 / 512x512` 占位;角色去背景结果变小时还会直接放大整张透明成品,导致 UI 显示的 2K 与模型实际生成清晰度不一致。
File diff suppressed because one or more lines are too long
@@ -407,6 +407,10 @@ curl -fsS --max-time 5 http://127.0.0.1/ >/dev/null
curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null
```
### 角色动画帧 OSS 排障
角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 使用 `AppState` 内同一个 OSS HTTP Client/连接池,并受进程级 8 路 OSS permit 保护;BgFilter、阿里云抠图和本地处理不占用该 permit。每个 OSS attempt 最多 3 次(首次 + 2 次重试),退避为 250ms、500ms;只重试 timeout、无 HTTP 响应传输错误、OSS PutObject 的 `400 + RequestTimeout`、PUT 400 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 500599。动作帧 PUT 收到 400 时只读取最多 16 KiB OSS 错误 XML,提取 `Code``RequestId``oss_request_id` 优先使用响应头 `x-oss-request-id`,XML 字段只作回退。错误体读取超时/断流不再按确定性 400 处理:已解析出的 `Code` 优先生效;未解析出 `Code` 时按读取失败原因置 `timeout`/`transport` 并重试,message 追加「错误响应体读取失败」。日志字段包括 `frame_index``object_key``operation=source_put|final_put|final_head``attempt``max_attempts``retryable``will_retry``retry_delay_ms``permit_wait_ms``timeout``connect``transport``oss_code``oss_request_id``status``elapsed_ms``请求 OSS 失败` 时,`timeout/connect/transport=true` 表示传输类失败;`status=400, oss_code=RequestTimeout, timeout=true``status=429``500599` 表示暂时性失败,PUT 的 `status=400``oss_code` 为空且 `timeout=true``transport=true`(message 含「错误响应体读取失败」)同样是暂时性失败。除 `RequestTimeout` 和该错误体读取失败两类例外外,其他 400、401/403/404、配置、URL 和签名错误是确定性失败,不会重试。最终帧 HEAD 失败只会重试 HEAD,不会重复 PUT;如果任一帧最终失败,确认整段动作已排空已启动 Future,并检查任务按现有契约退款且没有发布缺帧动画。
## 生产运维
生产部署当前口径:
@@ -600,7 +604,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
- api-server 会随 metrics 发送进程级指标:`process.memory.usage``process.memory.virtual``process.cpu.time``genarrative.process.cpu.usage_percent``process.thread.count``genarrative.process.memory.private`Windows 额外发送 `process.windows.handle.count`Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight``genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model、request_params 和 raw_excerpt;图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event``event_key = external_api_call_failure``module_key = external-api``scope_kind = module``scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id``metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object``sign_get_object_url``head_object``put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss``operation` 过滤,再看 `object_key` / `key_prefix``status``status_class``error_kind``content_length``content_type``elapsed_ms`;日志不得包含 AccessKey、policy、signature、Authorization header完整 signed URL。排查 generated 图片重复下载时,先确认前端输入是否为 `/generated-*` legacy path 或可归一化的 `https://*.oss-*.aliyuncs.com/generated-*`;正确链路应先调 `/api/assets/read-url`,再由浏览器请求 signed URL,且同一路径、同一 `refreshKey` 版本和未临近过期的 signed URL 应复用。新上传 generated 私有对象应带 `Cache-Control: public, max-age=31536000, immutable`;旧对象若只有 `ETag` / `Last-Modified`,浏览器会走 304 协商缓存而不是长期强缓存,可通过刷新 OSS 元数据或 CDN 配置补齐。
- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object``sign_get_object_url``head_object``put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss``operation` 过滤,再看 `object_key` / `key_prefix``status``status_class``error_kind``content_length``content_type``elapsed_ms`角色动画逐帧额外按 `frame_index``operation=source_put|final_put|final_head``attempt/max_attempts``will_retry``oss_code``oss_request_id` 对齐同一对象的请求尝试。`请求 OSS 失败` 时,`timeout/connect/transport=true` 表示传输类失败,OSS PutObject 的 `status=400, oss_code=RequestTimeout, timeout=true``status=429``500599` 表示暂时性失败,PUT 的 `status=400``oss_code` 为空且 `timeout=true``transport=true`(message 含「错误响应体读取失败」,即 400 错误体读取超时/断流)也会重试;除这两类例外外,其他 400、401/403/404、配置、URL 和签名错误是确定性失败,不会重试。最终帧 HEAD 失败只会重试 HEAD,不会重复 PUT。日志不得包含 AccessKey、policy、signature、Authorization header完整 signed URL 或 OSS 错误响应体;`oss_request_id` 只用于关联 OSS 服务端排障。排查 generated 图片重复下载时,先确认前端输入是否为 `/generated-*` legacy path 或可归一化的 `https://*.oss-*.aliyuncs.com/generated-*`;正确链路应先调 `/api/assets/read-url`,再由浏览器请求 signed URL,且同一路径、同一 `refreshKey` 版本和未临近过期的 signed URL 应复用。新上传 generated 私有对象应带 `Cache-Control: public, max-age=31536000, immutable`;旧对象若只有 `ETag` / `Last-Modified`,浏览器会走 304 协商缓存而不是长期强缓存,可通过刷新 OSS 元数据或 CDN 配置补齐。
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*``read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id``http.request.method``http.route``url.scheme``url.path``http.response.status_code``status_class``latency_ms``slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
+1
View File
@@ -4125,6 +4125,7 @@ name = "platform-oss"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"bytes",
"hmac",
"reqwest",
"serde",
@@ -27,7 +27,7 @@ use module_assets::{
};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
OssRequestAttemptContext, OssSignedGetObjectUrlRequest,
};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -2420,8 +2420,10 @@ async fn process_and_persist_editor_character_animation_frame(
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<ProcessedEditorCharacterAnimationFrame, AppError> {
// 中文注释:每一帧只要求自己的绿幕源图先落 OSS,不再等待整批源图全部上传完成。
let source_put = put_character_animation_object(
let source_put = put_character_animation_frame_object(
state,
frame_index + 1,
"source_put",
LegacyAssetPrefix::Animations,
vec![
"editor".to_string(),
@@ -2469,8 +2471,10 @@ async fn process_and_persist_editor_character_animation_frame(
false,
)?;
let content_type = finalized.mime_type.clone();
let put_result = put_character_animation_object(
let put_result = put_character_animation_frame_object(
state,
frame_index + 1,
"final_put",
LegacyAssetPrefix::Animations,
vec![
"editor".to_string(),
@@ -2497,6 +2501,7 @@ async fn process_and_persist_editor_character_animation_frame(
task_id,
put_result.object_key.clone(),
content_type,
frame_index + 1,
)
.await?;
@@ -2754,6 +2759,39 @@ async fn publish_single_animation_action(
})
}
async fn put_character_animation_frame_object(
state: &AppState,
frame_index: usize,
operation: &'static str,
prefix: LegacyAssetPrefix,
path_segments: Vec<String>,
file_name: String,
content_type: String,
body: Vec<u8>,
metadata: BTreeMap<String, String>,
) -> Result<platform_oss::OssPutObjectResponse, AppError> {
require_oss_client(state)?
.put_object_with_retry(
state.character_animation_oss_http_client(),
OssPutObjectRequest {
prefix,
path_segments,
file_name,
content_type: Some(content_type),
access: OssObjectAccess::Private,
metadata,
body,
},
state.character_animation_oss_io_limiter(),
OssRequestAttemptContext {
frame_index,
operation,
},
)
.await
.map_err(map_character_animation_oss_error)
}
async fn put_character_animation_object(
state: &AppState,
prefix: LegacyAssetPrefix,
@@ -2886,13 +2924,27 @@ async fn confirm_editor_character_animation_frame_asset_object(
task_id: &str,
object_key: String,
content_type: String,
frame_index: usize,
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
confirm_editor_character_animation_asset_object(
let oss_client = require_oss_client(state)?;
let head = oss_client
.head_object_with_retry(
state.character_animation_oss_http_client(),
OssHeadObjectRequest { object_key },
state.character_animation_oss_io_limiter(),
OssRequestAttemptContext {
frame_index,
operation: "final_head",
},
)
.await
.map_err(map_character_animation_oss_error)?;
confirm_editor_character_animation_asset_object_from_head(
state,
owner_user_id,
source_layer_id,
task_id,
object_key,
head,
content_type,
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
)
@@ -2913,6 +2965,27 @@ async fn confirm_editor_character_animation_asset_object(
.head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key })
.await
.map_err(map_character_animation_oss_error)?;
confirm_editor_character_animation_asset_object_from_head(
state,
owner_user_id,
source_layer_id,
task_id,
head,
content_type,
asset_kind,
)
.await
}
async fn confirm_editor_character_animation_asset_object_from_head(
state: &AppState,
owner_user_id: &str,
source_layer_id: &str,
task_id: &str,
head: platform_oss::OssHeadObjectResponse,
content_type: String,
asset_kind: &str,
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
let now_micros = current_utc_micros();
let record = state
.spacetime_client()
@@ -6218,13 +6291,13 @@ mod tests {
"async fn process_and_persist_editor_character_animation_frame",
"async fn publish_animation_set",
&[
"put_character_animation_object",
"put_character_animation_frame_object",
"green-screen-frame",
"remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"source_put.object_key.as_str()",
"bgfilter_request_timeout_ms",
"finalize_animation_frame_payload",
"put_character_animation_object",
"put_character_animation_frame_object",
"animation_frame",
],
);
+47
View File
@@ -48,6 +48,7 @@ use crate::work_author::{
};
const ADMIN_ROLE: &str = "admin";
pub(crate) const CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY: usize = 8;
pub type HttpRequestPermitPool = Semaphore;
@@ -259,6 +260,8 @@ pub struct AppStateInner {
editor_agent_llm_client: Option<LlmClient>,
matting_client: Option<MattingClient>,
editor_bgfilter_http_client: reqwest::Client,
character_animation_oss_http_client: reqwest::Client,
character_animation_oss_io_limiter: Arc<Semaphore>,
#[cfg(any())]
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
@@ -502,6 +505,9 @@ impl AppState {
let editor_agent_llm_client = build_editor_agent_llm_client(&config)?;
let matting_client = build_matting_client(&config)?;
let editor_bgfilter_http_client = build_editor_bgfilter_http_client(&config)?;
let character_animation_oss_http_client = build_character_animation_oss_http_client()?;
let character_animation_oss_io_limiter =
Arc::new(Semaphore::new(CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY));
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
let (profile_recharge_order_updates, _) = broadcast::channel(128);
@@ -544,6 +550,8 @@ impl AppState {
editor_agent_llm_client,
matting_client,
editor_bgfilter_http_client,
character_animation_oss_http_client,
character_animation_oss_io_limiter,
#[cfg(any())]
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
#[cfg(any())]
@@ -1260,6 +1268,14 @@ impl AppState {
&self.editor_bgfilter_http_client
}
pub fn character_animation_oss_http_client(&self) -> &reqwest::Client {
&self.character_animation_oss_http_client
}
pub fn character_animation_oss_io_limiter(&self) -> Arc<Semaphore> {
self.character_animation_oss_io_limiter.clone()
}
#[cfg(any())]
pub fn creative_agent_executor(&self) -> Arc<MockLangChainRustAgentExecutor> {
self.creative_agent_executor.clone()
@@ -1991,6 +2007,21 @@ fn build_editor_bgfilter_http_client(
})
}
fn build_character_animation_oss_http_client() -> Result<reqwest::Client, AppStateInitError> {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.timeout(std::time::Duration::from_secs(60))
.pool_idle_timeout(std::time::Duration::from_secs(300))
.pool_max_idle_per_host(8)
.tcp_keepalive(std::time::Duration::from_secs(60))
.build()
.map_err(|error| {
AppStateInitError::DependencyUnavailable(format!(
"初始化角色动画 OSS HTTP Client 失败:{error}"
))
})
}
fn build_wechat_client(config: &AppConfig) -> WechatClient {
WechatClient::new(WechatConfig {
app_id: config.wechat_mini_program_app_id.clone(),
@@ -2120,6 +2151,22 @@ mod tests {
use super::*;
#[test]
fn app_state_reuses_character_animation_oss_client_and_eight_permits() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert!(std::ptr::eq(
state.character_animation_oss_http_client(),
state.character_animation_oss_http_client(),
));
assert_eq!(
state
.character_animation_oss_io_limiter()
.available_permits(),
CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY
);
}
#[test]
fn editor_generation_pricing_typed_record_round_trips() {
let expected = crate::editor_generation_config::parse_editor_generation_pricing_json(
+3 -1
View File
@@ -6,13 +6,15 @@ license.workspace = true
[dependencies]
base64 = { workspace = true }
bytes = { workspace = true }
hmac = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"] }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
time = { workspace = true, features = ["formatting"] }
tokio = { workspace = true, features = ["sync", "time"] }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }
tokio = { workspace = true, features = ["macros", "rt", "net", "io-util"] }
File diff suppressed because it is too large Load Diff