diff --git a/apps/ai-game-creator-shell/src-tauri/src/http_client.rs b/apps/ai-game-creator-shell/src-tauri/src/http_client.rs new file mode 100644 index 000000000..c4601f1bf --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/http_client.rs @@ -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 { + 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 { + 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()) + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 245918c85..4a4897bec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -245,6 +245,7 @@ mod debug; mod delegation; mod git_inspect; mod goal; +mod http_client; mod image_inspect; mod isolated_agent; mod patchset; diff --git a/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md b/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md index cc2327c85..449122a9e 100644 --- a/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md +++ b/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md @@ -6,7 +6,7 @@ - `#226 添加客户端特殊标识`:本方案实际实施范围 - `#225 添加客户端埋点统计`:主站接收、落库和后台统计,本文只冻结交接契约,不在本次实施 -状态:阶段 1 已完成,待阶段 2 实施
+状态:阶段 2 已完成,待阶段 3 实施
本方案不包含代码修改 ## 1. 一句话交付结果 diff --git a/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md b/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md index a8e0d9588..ad4ed2b44 100644 --- a/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md +++ b/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md @@ -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 工作内容