阶段2:新增主站 Client Builder 工厂及验收测试

新增 AGC 主站专用 reqwest Client Builder 工厂。

通过 default_headers 统一注入 X-Genarrative-Client: agc。

保留调用方对 timeout、connect timeout、redirect 和 no_proxy 的配置能力。

验证请求级 Authorization 和 Idempotency-Key 继续生效。

更新 Issue 226 实施方案与分阶段验收计划,明确阶段 3 才迁移业务 Client 创建点。
This commit is contained in:
2026-09-02 02:28:18 +00:00
parent 1e435190d7
commit d78338a9d8
4 changed files with 132 additions and 2 deletions
@@ -0,0 +1,120 @@
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client";
const AGC_CLIENT_MARKER_VALUE: &str = "agc";
pub(crate) fn agc_main_site_client_builder() -> reqwest::ClientBuilder {
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static(AGC_CLIENT_MARKER_HEADER),
HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE),
);
reqwest::Client::builder().default_headers(headers)
}
#[cfg(test)]
mod tests {
use super::{agc_main_site_client_builder, AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE};
use reqwest::header::AUTHORIZATION;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::time::Duration;
fn read_http_request(stream: &mut TcpStream) -> String {
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("set HTTP fixture read timeout");
let mut bytes = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let read = stream.read(&mut buffer).expect("read HTTP fixture request");
assert!(read > 0, "HTTP fixture request closed before headers");
bytes.extend_from_slice(&buffer[..read]);
if bytes.windows(4).any(|value| value == b"\r\n\r\n") {
break;
}
}
String::from_utf8_lossy(&bytes).into_owned()
}
fn request_header(request: &str, expected_name: &str) -> Option<String> {
request.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case(expected_name)
.then(|| value.trim().to_string())
})
}
fn spawn_http_fixture(listener: TcpListener) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept HTTP fixture request");
let request = read_http_request(&mut stream);
stream
.write_all(
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.expect("write HTTP fixture response");
request
})
}
#[tokio::test]
async fn factory_sets_the_agc_marker_as_a_default_header() {
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 response = client
.get(format!("http://{address}/api/auth/me"))
.send()
.await
.expect("send 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())
);
assert!(request_header(&request, AUTHORIZATION.as_str()).is_none());
assert!(request_header(&request, "idempotency-key").is_none());
}
#[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");
let address = listener.local_addr().expect("read HTTP fixture address");
let fixture = spawn_http_fixture(listener);
let client = agc_main_site_client_builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build()
.expect("build configured AGC main-site client");
let response = client
.post(format!("http://{address}/api/editor/images/generations"))
.bearer_auth("fixture-token")
.header("Idempotency-Key", "fixture-id")
.send()
.await
.expect("send configured 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())
);
assert_eq!(
request_header(&request, AUTHORIZATION.as_str()),
Some("Bearer fixture-token".to_string())
);
assert_eq!(
request_header(&request, "idempotency-key"),
Some("fixture-id".to_string())
);
}
}
@@ -245,6 +245,7 @@ mod debug;
mod delegation;
mod git_inspect;
mod goal;
mod http_client;
mod image_inspect;
mod isolated_agent;
mod patchset;
@@ -6,7 +6,7 @@
- `#226 添加客户端特殊标识`:本方案实际实施范围
- `#225 添加客户端埋点统计`:主站接收、落库和后台统计,本文只冻结交接契约,不在本次实施
状态:阶段 1 已完成,待阶段 2 实施<br>
状态:阶段 2 已完成,待阶段 3 实施<br>
本方案不包含代码修改
## 1. 一句话交付结果
@@ -6,7 +6,7 @@
- `#226 添加客户端特殊标识`:本计划全部实施范围
- `#225 添加客户端埋点统计`:只接收交接契约,不在本计划实现
当前状态:阶段 1 已完成;阶段 2 尚未开始。阶段 0 的证据与验收记录见
当前状态:阶段 2 已完成;阶段 3 尚未开始。阶段 0 的证据与验收记录见
[【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md)。
## 1. 交付目标
@@ -172,6 +172,15 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder;
- factory 的职责没有扩展到业务编排。
- 现有测试或新增测试能锁定 Header 默认值。
### 6.4 阶段 2 实施记录
- 新增 `src-tauri/src/http_client.rs`,提供 `agc_main_site_client_builder()`
- factory 仅通过 `default_headers` 注入 `x-genarrative-client: agc`,不接管认证、幂等、请求体、重试或错误解析。
- 调用方仍可在返回的 builder 上继续配置 connect timeout、request timeout、redirect policy 和 `no_proxy`
- 新增两个本地 TCP fixture 单元测试,验证真实发送请求带标记、请求级 Authorization/Idempotency-Key 保留,且 factory 不自动注入认证信息。
- 阶段 2 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture`2 tests passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 通过。
- 阶段 2 未迁移任何业务 Client 创建点;迁移属于阶段 3。
## 7. 阶段 3:主站 Client 创建点迁移
### 7.1 工作内容