收紧编辑器图片持久化的上传边界

OSS 共享客户端扩展为读写共用并按上传方向放宽整体超时
画板生成图片持久化的 PUT 与 HEAD 改走共享客户端
补齐持久化写路径的共享客户端回归断言

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 05:40:51 +00:00
parent 823df0f0f7
commit 56b65a430d
2 changed files with 44 additions and 21 deletions
@@ -8614,14 +8614,18 @@ async fn persist_editor_generated_image_data(
}))
})?;
let persisted_mime_type = prepared.format.mime_type.clone();
let http_client = reqwest::Client::new();
// 中文注释:写路径此前也是每次新建 client,PUT 与 HEAD 都没有超时——请求可以在
// 上传阶段无限期挂住,而这一段发生在 CPU 处理之后,任何按处理预算派生的 deadline
// 都已经不适用(余额多半为零,传进来只会把算完的结果丢掉)。这里的界只能来自
// 客户端级超时,与读路径共用同一个进程级客户端。
let http_client = state.editor_oss_http_client();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.put_object(http_client, prepared.request)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let head = oss_client
.head_object(
&http_client,
http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
@@ -9025,7 +9029,7 @@ async fn read_editor_reference_image_object(
// 中文注释:共享客户端自带 connect / total 超时,GET 不再可能无界挂起;
// 顺带复用连接池,避免每次读参考图都重新做一次 TLS 握手。
let mut response = state
.editor_oss_read_http_client()
.editor_oss_http_client()
.get(signed.signed_url.as_str())
.send()
.await
@@ -14050,7 +14054,7 @@ mod tests {
"bytes.extend_from_slice",
// 中文注释:必须走进程级共享客户端。它自带 connect / total 超时,
// 是所有 OSS 读取调用方(含没有 deadline 可传的降级路径)的兜底上界。
".editor_oss_read_http_client()",
".editor_oss_http_client()",
],
);
assert_function_not_contains(
@@ -14064,6 +14068,21 @@ mod tests {
"reqwest::Client::new()",
],
);
// 中文注释:写路径与读路径同一条不变式。persist 发生在 CPU 处理之后,处理预算
// 已经不适用,客户端级超时是它唯一的界;退回裸客户端会让 PUT / HEAD 重新无界,
// 而这一段是全部 9 条编辑器图片持久化流程共用的。
assert_function_contains(
source,
"async fn persist_editor_generated_image_data",
"async fn persist_editor_provider_source_image",
&["state.editor_oss_http_client()"],
);
assert_function_not_contains(
source,
"async fn persist_editor_generated_image_data",
"async fn persist_editor_provider_source_image",
&["reqwest::Client::new()"],
);
}
#[test]
+20 -16
View File
@@ -272,7 +272,7 @@ pub struct AppStateInner {
bgfilter_image_validation_limiter: Arc<Semaphore>,
character_animation_oss_http_client: reqwest::Client,
character_animation_oss_io_limiter: Arc<Semaphore>,
editor_oss_read_http_client: reqwest::Client,
editor_oss_http_client: reqwest::Client,
#[cfg(any())]
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
@@ -531,7 +531,7 @@ impl AppState {
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 editor_oss_read_http_client = build_editor_oss_read_http_client()?;
let editor_oss_http_client = build_editor_oss_http_client()?;
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
let (profile_recharge_order_updates, _) = broadcast::channel(128);
@@ -579,7 +579,7 @@ impl AppState {
bgfilter_image_validation_limiter,
character_animation_oss_http_client,
character_animation_oss_io_limiter,
editor_oss_read_http_client,
editor_oss_http_client,
#[cfg(any())]
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
#[cfg(any())]
@@ -1320,8 +1320,8 @@ impl AppState {
self.character_animation_oss_io_limiter.clone()
}
pub fn editor_oss_read_http_client(&self) -> &reqwest::Client {
&self.editor_oss_read_http_client
pub fn editor_oss_http_client(&self) -> &reqwest::Client {
&self.editor_oss_http_client
}
#[cfg(any())]
@@ -2096,23 +2096,27 @@ fn build_character_animation_oss_http_client() -> Result<reqwest::Client, AppSta
})
}
// 中文注释:编辑器参考图 / 派生图的 OSS GET 此前每次调用都 `reqwest::Client::new()`
// 既没有任何超时也没有连接复用——一个半开或黑洞的连接可以无限期挂住,同时占着
// HTTP 准入许可和已经读入的最多 32 MiB 图片缓冲。这里收口成进程级共享客户端,
// 给整段 GET(含 body 流式读)一个绝对上界,作为所有调用方的兜底。
// 中文注释:编辑器图的 OSS 读写此前每次调用都 `reqwest::Client::new()`既没有任何
// 超时也没有连接复用——一个半开或黑洞的连接可以无限期挂住,同时占着 HTTP 准入许可和
// 已经读入的最多 32 MiB 图片缓冲。这里收口成进程级共享客户端,给整段请求(含 body
// 流式读)一个绝对上界,作为所有调用方的兜底。读写共用一个客户端,两个方向都受同一份
// EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES 约束,合用也能让 GET / PUT / HEAD 复用连接池。
//
// connect 取 10s:同区域 OSS 建连是百毫秒级,30s 只会让不可达端点多占 20s 槽位。
// total 取 60s需要容纳 EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES 上限对象在慢链路上读完。
fn build_editor_oss_read_http_client() -> Result<reqwest::Client, AppStateInitError> {
// total 取 120s按较慢的写方向定尺寸——32 MiB 上传在 60s 内要求持续约 4.4 Mbps
// 留一倍余量避免误伤正常流量。读方向不因此变松:完美像素的 GET 另受 30s 处理预算约束,
// 客户端超时只是兜底。
fn build_editor_oss_http_client() -> Result<reqwest::Client, AppStateInitError> {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(60))
.timeout(std::time::Duration::from_secs(120))
.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 客户端失败:{error}"
"初始化编辑器 OSS HTTP 客户端失败:{error}"
))
})
}
@@ -2265,12 +2269,12 @@ mod tests {
}
#[test]
fn app_state_reuses_editor_oss_read_client() {
fn app_state_reuses_editor_oss_client() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert!(std::ptr::eq(
state.editor_oss_read_http_client(),
state.editor_oss_read_http_client(),
state.editor_oss_http_client(),
state.editor_oss_http_client(),
));
}