修复AGC主站同名标记覆盖
Project CI / Repository checks (pull_request) Successful in 3m34s
Project CI / Frontend tests (pull_request) Successful in 3m49s
Project CI / Backend tests (pull_request) Successful in 7m48s
Project CI / Native shell tests (pull_request) Successful in 15m47s

增加统一请求终结器,覆盖调用方传入的同名客户端标记

补齐账号态、External Key态、生成轮询和资源编辑主站请求

保持OSS、签名下载、Provider、搜索、loopback和更新下载边界不变

补充同名Header覆盖回归测试并同步Issue226方案与验收文档
This commit is contained in:
2026-09-02 05:26:01 +00:00
parent fa448bba59
commit 658f8958df
11 changed files with 257 additions and 161 deletions
@@ -2493,12 +2493,14 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
percent_encode_query_component(&remote_art_spec.canvas_project_id)
));
access.validate_frozen_session()?;
let response = client
.get(format!("{api_base_url}{route}"))
.bearer_auth(&api_key)
.send()
.await
.map_err(|error| format!("读取陶泥儿画布资源失败:{error}"))?;
let response = crate::http_client::with_agc_main_site_marker(
client
.get(format!("{api_base_url}{route}"))
.bearer_auth(&api_key),
)
.send()
.await
.map_err(|error| format!("读取陶泥儿画布资源失败:{error}"))?;
access.validate_frozen_session()?;
let status = response.status();
if !status.is_success() {
@@ -1818,21 +1818,23 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
let fingerprint = format!("{}\0{}", source_asset_id, asset_name);
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
let route = "/api/external/v1/editor/images/background-removals";
let response = client
.post(format!("{}{}", api_base_url, route))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(&json!({
"sourceImageSrc": source_resource_id,
"projectId": manifest.project_id,
"assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id,
"assetLabel": asset_name,
"sourceResourceId": source_resource_id,
}))
.send()
.await
.map_err(|error| format!("抠图服务提交失败:{error}"))?;
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!("{}{}", api_base_url, route))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(&json!({
"sourceImageSrc": source_resource_id,
"projectId": manifest.project_id,
"assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id,
"assetLabel": asset_name,
"sourceResourceId": source_resource_id,
})),
)
.send()
.await
.map_err(|error| format!("抠图服务提交失败:{error}"))?;
let status = response.status();
let payload = response
.json::<Value>()
@@ -685,7 +685,7 @@ pub(crate) async fn external_editor_json_request(
request: reqwest::RequestBuilder,
action: &str,
) -> Result<serde_json::Value, String> {
let response = request
let response = crate::http_client::with_agc_main_site_marker(request)
.send()
.await
.map_err(|error| format!("{action}失败:{error}"))?;
@@ -963,22 +963,24 @@ pub(crate) async fn submit_external_generation_request(
}
let request_body_json = serde_json::to_string(&request_body)
.map_err(|error| format!("序列化平台图片生成请求失败:{error}"))?;
let response = client
.post(format!(
"{api_base_url}{}",
resolve_platform_editor_api_route(endpoint)
))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(request_body_json)
.send()
.await
.map_err(|error| {
format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后未取得确定响应:{error}"
)
})?;
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!(
"{api_base_url}{}",
resolve_platform_editor_api_route(endpoint)
))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(request_body_json),
)
.send()
.await
.map_err(|error| {
format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后未取得确定响应:{error}"
)
})?;
if response.status().is_server_error() {
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 HTTP {},服务端是否已产生副作用未知",
@@ -380,15 +380,15 @@ async fn create_private_external_editor_api_credentials_from_platform_session(
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| format!("创建本机开发者 Key 客户端失败:{error}"))?;
let response = client
.post(format!("{api_base_url}/api/profile/api-keys"))
.bearer_auth(&session.access_token)
.json(&serde_json::json!({ "name": DIRECT_EXTERNAL_EDITOR_API_KEY_NAME }))
.send()
.await
.map_err(|error| {
format!("创建本机陶泥儿开发者 Key 未取得确定响应;不会自动重试:{error}")
})?;
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!("{api_base_url}/api/profile/api-keys"))
.bearer_auth(&session.access_token)
.json(&serde_json::json!({ "name": DIRECT_EXTERNAL_EDITOR_API_KEY_NAME })),
)
.send()
.await
.map_err(|error| format!("创建本机陶泥儿开发者 Key 未取得确定响应;不会自动重试:{error}"))?;
let status = response.status();
if !status.is_success() {
return Err(match status {
@@ -720,12 +720,12 @@ pub(crate) async fn sync_canvas_project_assets_at(
percent_encode_query_component(canvas_project_id)
))
);
let project_response = client
.get(project_url)
.bearer_auth(&api_key)
.send()
.await
.map_err(|error| format!("读取画板项目失败:{error}"))?;
let project_response = crate::http_client::with_agc_main_site_marker(
client.get(project_url).bearer_auth(&api_key),
)
.send()
.await
.map_err(|error| format!("读取画板项目失败:{error}"))?;
let project_status = project_response.status();
if !project_status.is_success() {
return Err(format!(
@@ -1243,12 +1243,11 @@ pub(crate) async fn resolve_external_asset_signed_url(
api_key: &str,
read_url: String,
) -> Result<String, String> {
let response = client
.get(read_url)
.bearer_auth(api_key)
.send()
.await
.map_err(|error| format!("换签画板资产失败:{error}"))?;
let response =
crate::http_client::with_agc_main_site_marker(client.get(read_url).bearer_auth(api_key))
.send()
.await
.map_err(|error| format!("换签画板资产失败:{error}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!("换签画板资产失败:HTTP {}", status.as_u16()));
@@ -26,17 +26,33 @@ fn agc_main_site_redirect_policy() -> reqwest::redirect::Policy {
})
}
pub(crate) fn agc_main_site_client_builder() -> reqwest::ClientBuilder {
fn agc_main_site_marker_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static(AGC_CLIENT_MARKER_HEADER),
HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE),
);
headers
}
pub(crate) fn agc_main_site_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder()
.default_headers(headers)
.default_headers(agc_main_site_marker_headers())
.redirect(agc_main_site_redirect_policy())
}
/// Finalize a request sent through the AGC main-site client.
///
/// `ClientBuilder::default_headers` only fills a missing request header. A
/// request-level header with the same name would otherwise win, so use
/// `RequestBuilder::headers` here to replace any caller-provided value with
/// the reserved AGC marker after all business headers have been configured.
pub(crate) fn with_agc_main_site_marker(
request: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
request.headers(agc_main_site_marker_headers())
}
#[cfg(test)]
mod tests {
use super::{
@@ -157,6 +173,30 @@ mod tests {
assert!(request_header(&request, "idempotency-key").is_none());
}
#[tokio::test]
async fn request_finalizer_overrides_a_caller_provided_marker() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture");
let address = listener.local_addr().expect("read HTTP fixture address");
let fixture = spawn_http_fixture(listener);
let client = agc_main_site_client_builder()
.build()
.expect("build AGC main-site client");
let request = client
.get(format!("http://{address}/api/auth/me"))
.header(AGC_CLIENT_MARKER_HEADER, "spoofed-value");
let response = super::with_agc_main_site_marker(request)
.send()
.await
.expect("send finalized request");
let request = fixture.join().expect("join HTTP fixture");
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
assert_eq!(
request_header(&request, AGC_CLIENT_MARKER_HEADER),
Some(AGC_CLIENT_MARKER_VALUE.to_string())
);
}
#[tokio::test]
async fn factory_keeps_request_headers_and_transport_options_configurable() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture");
@@ -2089,7 +2089,7 @@ async fn try_confirm_uploaded_reference(
asset_kind: &str,
) -> Result<Option<String>, ReferencePreparationError> {
let endpoint = resolve_platform_editor_api_route("/api/external/v1/assets/objects/confirm");
let response = authorize_canvas_request(
let response = crate::http_client::with_agc_main_site_marker(authorize_canvas_request(
client
.post(format!("{api_base_url}{endpoint}"))
.json(&serde_json::json!({
@@ -2102,7 +2102,7 @@ async fn try_confirm_uploaded_reference(
"accessPolicy": "private",
})),
api_mode,
)
))
.send()
.await
.map_err(|_| ReferencePreparationError::ConfirmFailed)?;
@@ -1619,22 +1619,24 @@ async fn request_resource_edit_upload_ticket(
.as_ref()
.ok_or_else(|| "源媒体上传缺少文件内容".to_string())?;
access.validate_frozen_session()?;
let response = client
.post(format!(
"{}{}",
access.api_base_url(),
access.api_route("/api/external/v1/assets/direct-upload-tickets")
))
.bearer_auth(access.bearer_token())
.json(&resource_edit_upload_ticket_payload(
input,
source,
binding_key_sha256,
bytes.len(),
))
.send()
.await
.map_err(|_| "result-unknown: 创建源资源上传凭证未取得确定响应".to_string())?;
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!(
"{}{}",
access.api_base_url(),
access.api_route("/api/external/v1/assets/direct-upload-tickets")
))
.bearer_auth(access.bearer_token())
.json(&resource_edit_upload_ticket_payload(
input,
source,
binding_key_sha256,
bytes.len(),
)),
)
.send()
.await
.map_err(|_| "result-unknown: 创建源资源上传凭证未取得确定响应".to_string())?;
let mut post_response_session_error = access.validate_frozen_session().err();
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
if let Some(error) = post_response_session_error {
@@ -1801,25 +1803,27 @@ async fn confirm_resource_edit_source(
.as_ref()
.ok_or_else(|| "确认源媒体上传缺少文件内容".to_string())?;
access.validate_frozen_session()?;
let response = client
.post(format!(
"{}{}",
access.api_base_url(),
access.api_route("/api/external/v1/assets/objects/confirm")
))
.bearer_auth(access.bearer_token())
.json(&serde_json::json!({
"bucket": bucket,
"objectKey": object_key,
"contentType": source.media_type,
"contentLength": bytes.len(),
"contentHash": source.source_sha256,
"assetKind": source.asset_kind,
"accessPolicy": "private",
}))
.send()
.await
.map_err(|_| "result-unknown: 确认源资源上传未取得确定响应".to_string())?;
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!(
"{}{}",
access.api_base_url(),
access.api_route("/api/external/v1/assets/objects/confirm")
))
.bearer_auth(access.bearer_token())
.json(&serde_json::json!({
"bucket": bucket,
"objectKey": object_key,
"contentType": source.media_type,
"contentLength": bytes.len(),
"contentHash": source.source_sha256,
"assetKind": source.asset_kind,
"accessPolicy": "private",
})),
)
.send()
.await
.map_err(|_| "result-unknown: 确认源资源上传未取得确定响应".to_string())?;
let mut post_response_session_error = access.validate_frozen_session().err();
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
if let Some(error) = post_response_session_error {
@@ -1902,34 +1906,36 @@ async fn register_resource_edit_source_image(
percent_encode_query_component(remote_project_id)
);
access.validate_frozen_session()?;
let response = client
.post(format!(
"{}{}",
access.api_base_url(),
access.api_route(&endpoint)
))
.bearer_auth(access.bearer_token())
.header(
"Idempotency-Key",
format!("game-creator-resource-{binding_key}"),
)
.json(&serde_json::json!({
"imageSrc": format!("/{}", object_key.trim_start_matches('/')),
"objectKey": object_key,
"assetObjectId": asset_object_id,
"width": decoded.width(),
"height": decoded.height(),
"sourceType": "uploaded",
"assetKind": source.asset_kind,
"generationInputs": {
"source": RESOURCE_EDIT_QUEUE_SOURCE,
"localAssetId": local_asset_id,
"sourceSha256": source.source_sha256,
},
}))
.send()
.await
.map_err(|_| "result-unknown: 登记源图片项目资源未取得确定响应".to_string())?;
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!(
"{}{}",
access.api_base_url(),
access.api_route(&endpoint)
))
.bearer_auth(access.bearer_token())
.header(
"Idempotency-Key",
format!("game-creator-resource-{binding_key}"),
)
.json(&serde_json::json!({
"imageSrc": format!("/{}", object_key.trim_start_matches('/')),
"objectKey": object_key,
"assetObjectId": asset_object_id,
"width": decoded.width(),
"height": decoded.height(),
"sourceType": "uploaded",
"assetKind": source.asset_kind,
"generationInputs": {
"source": RESOURCE_EDIT_QUEUE_SOURCE,
"localAssetId": local_asset_id,
"sourceSha256": source.source_sha256,
},
})),
)
.send()
.await
.map_err(|_| "result-unknown: 登记源图片项目资源未取得确定响应".to_string())?;
let mut post_response_session_error = access.validate_frozen_session().err();
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
if let Some(error) = post_response_session_error {
@@ -2485,19 +2491,21 @@ async fn submit_resource_edit_remote(
}
}
access.validate_frozen_session()?;
let response = client
.post(format!(
"{}{}",
access.api_base_url(),
access.api_route(endpoint)
))
.bearer_auth(access.bearer_token())
.header("Idempotency-Key", &ledger.idempotency_key)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body_value.to_string())
.send()
.await
.map_err(|error| format!("result-unknown: 资源编辑请求已发出但未取得确定响应:{error}"))?;
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!(
"{}{}",
access.api_base_url(),
access.api_route(endpoint)
))
.bearer_auth(access.bearer_token())
.header("Idempotency-Key", &ledger.idempotency_key)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body_value.to_string()),
)
.send()
.await
.map_err(|error| format!("result-unknown: 资源编辑请求已发出但未取得确定响应:{error}"))?;
let mut post_response_session_error = access.validate_frozen_session().err();
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
@@ -2608,12 +2616,12 @@ async fn wait_for_resource_edit_remote(
access.validate_frozen_session()?;
tokio::time::sleep(Duration::from_millis(poll_after_ms)).await;
access.validate_frozen_session()?;
let response = client
.get(&status_url)
.bearer_auth(access.bearer_token())
.send()
.await
.map_err(|_| "result-unknown: 查询资源编辑任务失败".to_string())?;
let response = crate::http_client::with_agc_main_site_marker(
client.get(&status_url).bearer_auth(access.bearer_token()),
)
.send()
.await
.map_err(|_| "result-unknown: 查询资源编辑任务失败".to_string())?;
access.validate_frozen_session()?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err(editor_api_authentication_error());
@@ -15,11 +15,14 @@
这不是把 factory 改成全局 `Policy::none()`。同 origin 的 redirect 仍保留原有默认限制、方法和请求体处理;调用点显式设置的 `Policy::none()` 继续有效。
另针对评审发现的同名 Header 覆盖缺口,补充了主站请求终结器:`default_headers` 继续负责普通请求的默认注入;所有已审计的 AGC 主站请求在发送前统一调用 `with_agc_main_site_marker`,使用 `RequestBuilder::headers` 替换调用方可能传入的同名 Header,确保最终值固定为 `agc`
## 2. 修改范围
修改 AGC Rust HTTP Client factory 和其定向测试:
修改 AGC Rust HTTP Client factory、主站请求终结器、已审计主站直发调用点和定向测试:
- `apps/ai-game-creator-shell/src-tauri/src/http_client.rs`
- 已审计主站请求所在的 `assets.rs``commands.rs``canvas_generation.rs``direct_runtime.rs``direct_tool_bridge.rs``project/asset_canvas/generation.rs``project/resource_editor.rs`
- 本地实施方案、阶段计划和本复审记录
不修改:
@@ -38,7 +41,18 @@
使用 `Url::origin()` 比较,不比较 URL 字符串前缀;路径、查询参数和 fragment 不参与 origin 判断。
## 4. 测试证据
## 4. 同名 Header 覆盖契约
`reqwest``ClientBuilder::default_headers` 只会为请求补充缺失字段,请求级同名 Header 默认优先。因此不能把 `default_headers` 单独当作“不可伪造”的约束。
当前实现由两层组成:
- factory 设置 `default_headers`,覆盖普通未显式设置 Header 的请求;
- `with_agc_main_site_marker(request)` 在主站请求发送前用 `RequestBuilder::headers` 替换同名字段。`external_editor_json_request` 已内置该终结器,直接 `.send()` 的主站路径也显式经过该终结器。
这样既保留方案一的 Client factory 形态,又满足“调用方传入同名 Header 时最终仍为 `X-Genarrative-Client: agc`”的冻结契约。
## 5. 测试证据
新增或调整 `http_client` 定向测试,覆盖:
@@ -46,15 +60,16 @@
- 同 origin 302:下一跳实际收到 `X-Genarrative-Client: agc`,最终响应成功。
- 跨 origin 302:当前响应为 302,外部 listener 没有收到连接。
- 显式 `Policy::none()`:仍然覆盖 factory 默认策略。
- 请求级伪造同名 Header:终结器覆盖调用方值,服务端只收到 `agc`
验证命令及结果:
```text
cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture
6 tests passed
8 tests passed
```
## 5. 对 #225 的交接
## 6. 对 #225 的交接
本修复不改变 Header 契约:
@@ -64,6 +79,6 @@ X-Genarrative-Client: agc
主站只会收到客户端实际发出的主站请求;跨 origin 3xx 不会产生第二个第三方请求,因此不会出现客户端把该标记发送到 OSS/第三方 origin 的情况。#225 不需要回改 tracking、数据库或后台设计。
## 6. 后续限制
## 7. 后续限制
`agc_main_site_client_builder()` 仍返回原始 `reqwest::ClientBuilder`,理论上调用方可以再次覆盖 redirect policy。当前已审计生产调用点只有显式 `Policy::none()`,没有重新启用任意 origin follow 的调用。若未来需要类型级不可绕过,再单独评估 `AgcMainSiteClient` wrapper,不在本次复审修复中扩大范围。
`agc_main_site_client_builder()` 仍返回原始 `reqwest::ClientBuilder`,理论上调用方可以再次覆盖 redirect policy,或新增请求时绕过终结器。当前已审计生产调用点均已经过终结器,只有显式 `Policy::none()` 的 redirect 覆盖,没有重新启用任意 origin follow 的调用。若未来需要类型级不可绕过,再单独评估 `AgcMainSiteClient` wrapper,不在本次复审修复中扩大范围。
@@ -112,6 +112,7 @@ TS 主站请求
Rust 主站请求
→ 主站 Client Builder factory
→ default_headers 注入 X-Genarrative-Client: agc
→ 请求发送前由主站请求终结器覆盖同名 Header
→ 默认只跟随同 origin 重定向,跨 origin 返回当前 3xx
→ 保留调用方原有 Client 配置
@@ -160,6 +161,8 @@ Rust 第三方请求
- 设置只允许同 origin 的默认重定向策略;同源时委托 reqwest 默认策略,跨 origin 时停止。
- 不负责 token、API Key、幂等键、请求体、重试或错误解析。
由于 `reqwest::ClientBuilder::default_headers` 只补充请求中缺失的字段,主站请求在发送前还必须经过 `with_agc_main_site_marker(request)`。该终结器使用 `RequestBuilder::headers` 替换调用方可能传入的同名 Header,保证最终值固定为 `agc``external_editor_json_request` 已内置该步骤,直接 `.send()` 的主站路径显式调用该终结器。
示意结构:
```rust
@@ -188,7 +191,7 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder {
}
```
实际实现可放在 AGC Rust 已有的客户端公共模块中;不新增完整 `AgcMainSiteClient` RequestBuilder 包装器,不在本 Issue 提前实现方案二。
实际实现可放在 AGC Rust 已有的客户端公共模块中;本 Issue 只增加轻量请求终结器,不新增完整 `AgcMainSiteClient` RequestBuilder 包装器,不提前实现方案二。
### 6.2 保留现有 Client 配置
@@ -230,7 +233,7 @@ let client = agc_main_site_client_builder()
- 主站 API Client 的 `Client::builder()` 改用 AGC 主站 builder。
- 生产主站 API 中使用 `Client::new()` 的位置改用 AGC 主站 builder,并保留原 timeout 等配置。
- 只访问主站的专用 Client 可以直接使用默认 Header。
- 只访问主站的专用 Client 仍使用 factory 的默认 Header,并在最终 `.send()` 前经过 `with_agc_main_site_marker`;不能依赖 `default_headers` 覆盖请求级同名 Header。
- 一个 Client 如果同时访问主站和第三方地址,不得直接改成主站 Client;应拆分用途或保持第三方 Client 独立。
### 6.4 不应替换的 Client
@@ -270,6 +273,13 @@ let client = agc_main_site_client_builder()
- External v1 endpoint、账号态路由映射、请求体、认证、幂等、异步轮询和结果未知语义均未改变。
- 阶段 5 验证:`background_agent_runtime_can_generate_platform_art_asset``sync_canvas_project_assets_with_developer_key_uses_external_route_and_marker``generation_submit_response_loss_is_not_retried` 均通过;Rust fmt、编码检查和 `git diff --check` 通过。
### 6.8 同名 Header 覆盖复审修复记录(2026-09-02
- 评审确认 `reqwest``default_headers` 只补充缺失字段,请求级同名 Header 会优先,不能单独满足“标记值固定为 `agc`”的契约。
- 新增 `crate::http_client::with_agc_main_site_marker`,在主站请求发送前用 `RequestBuilder::headers` 替换 `X-Genarrative-Client``external_editor_json_request` 内置该终结器,直接发送的主站请求也显式接入。
- 已补齐账号态、External Key 态、生成/轮询、资源编辑、素材读取、对象确认和抠图等主站直发路径;OSS/签名下载、Provider、搜索、loopback 和更新下载仍保持独立 Client。
- 新增回归测试验证调用方传入伪造同名 Header 时最终只发送 `X-Genarrative-Client: agc``http_client` 定向测试共 8 个通过。
## 7. 调用语义和现有路由边界
本次不改变 `ExternalEditorBindingAccess` 的职责:
@@ -121,7 +121,7 @@ Tauri HTTP 插件和 Rust `reqwest` 不受浏览器 CORS 限制,但仍应遵
### 4.1 核心思路
为 Rust 层建立一个只允许服务于主站 API 的 `reqwest::Client` 构造函数,并通过 `default_headers` 自动加入客户端标记。
为 Rust 层建立一个只允许服务于主站 API 的 `reqwest::Client` 构造函数,并通过 `default_headers` 自动加入客户端标记;请求发送前再由轻量终结器覆盖同名 Header,确保调用方不能伪造标记值
第三方上传、签名下载、Provider 和搜索继续使用现有独立 Client,不带标记。
@@ -135,11 +135,25 @@ fn build_agc_main_site_client(timeout: Duration) -> Result<reqwest::Client, Stri
reqwest::header::HeaderValue::from_static("agc"),
);
let default_policy = reqwest::redirect::Policy::default();
let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| {
let same_origin = attempt
.previous()
.first()
.map(|initial| initial.origin() == attempt.url().origin())
.unwrap_or(false);
if same_origin {
default_policy.redirect(attempt)
} else {
attempt.stop()
}
});
reqwest::Client::builder()
.default_headers(headers)
.connect_timeout(Duration::from_secs(10))
.timeout(timeout)
.redirect(reqwest::redirect::Policy::none())
.redirect(redirect_policy)
.build()
.map_err(|error| format!("创建 AGC 主站 HTTP 客户端失败:{error}"))
}
@@ -150,14 +164,16 @@ fn build_agc_main_site_client(timeout: Duration) -> Result<reqwest::Client, Stri
```rust
let client = build_agc_main_site_client(Duration::from_secs(60))?;
let response = client
.get(format!("{api_base_url}{route}"))
.bearer_auth(token)
let response = with_agc_main_site_marker(
client
.get(format!("{api_base_url}{route}"))
.bearer_auth(token),
)
.send()
.await?;
```
业务请求不再逐个调用 `.header("X-Genarrative-Client", "agc")`
业务请求不再逐个调用 `.header("X-Genarrative-Client", "agc")`;由统一终结器在发送前覆盖调用方同名值
### 4.2 预计改动范围
@@ -184,7 +200,7 @@ let response = client
- 实现简单,符合当前仓库优先简单设计的原则。
- 业务请求代码基本不变。
- Header 只配置一次
- Header 由 factory 默认注入,终结器在发送前统一覆盖同名值
- 不需要新增 HTTP middleware 框架或复杂泛型包装。
- 便于为普通请求和长时间生成提交提供不同 timeout,但共享同一标记配置。
- 可以通过单元测试检查 Client 发出的请求自动带标记。
@@ -192,6 +208,7 @@ let response = client
### 4.4 缺点和风险
- `default_headers` 对该 Client 发出的所有请求生效,不会再次判断目标域名。
- `default_headers` 不能覆盖请求级同名 Header;新增的主站直发路径若绕过请求终结器,仍可能发送错误值。
- 如果后续有人误用主站 Client 请求 OSS、签名 URL 或 Provider,标记会被带到第三方。
- 当前存在多个不同 timeout 的 Client,需要提供少量参数或几个明确的工厂函数。
- 仍需要替换现有主站 Client 的创建点,无法做到完全零调用点改动。
@@ -205,6 +222,7 @@ let response = client
- 签名下载继续由 `build_external_asset_download_client` 单独创建。
- OSS 上传继续使用单独 Client。
- 工厂名称明确包含 `main_site`,避免误用。
- 所有 AGC 主站请求的最终 `.send()` 前统一经过 `with_agc_main_site_marker`;新增直发路径必须同步接入该终结器。
- 定向测试断言主站 mock 收到标记、第三方 mock 未收到标记。
## 5. 方案二:按目标 origin 的请求包装器
@@ -97,7 +97,7 @@ X-Genarrative-Client: agc
|---|---|---|
| TS 统一出口正向测试 | `npm exec vitest run apps/ai-game-creator-shell/tests/clientHttp.test.ts` | 通过,14 tests passed |
| AGC TS 类型和配置检查 | `npm run ai-game-creator-shell:typecheck` | 通过;skill-pack/config 检查通过 |
| Rust 主站 Client factory 与 origin-safe redirect policy | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture` | 通过,7 tests passed;同源跟随、跨 origin 阻断、链式重定向显式 `Policy::none()` 均覆盖 |
| Rust 主站 Client factory、请求终结器与 origin-safe redirect policy | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture` | 通过,8 tests passed;同源跟随、跨 origin 阻断、链式重定向显式 `Policy::none()` 和请求级同名 Header 覆盖均覆盖 |
| 第三方请求负向矩阵 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml omits_agc_marker -- --nocapture` | 通过,4 tests passed |
| loopback 认证/请求边界 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml loopback_proxy_strips_false_codex_limit_headers_and_requires_bearer -- --nocapture` | 通过,1 test passed |
| 账号态主站请求捕获 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml background_agent_runtime_can_generate_platform_art_asset -- --nocapture` | 通过,1 test passed |