From c67b081ec13d90cc982d045fc065211cd6438e44 Mon Sep 17 00:00:00 2001 From: Zyy Date: Fri, 16 Oct 2026 20:02:12 +0800 Subject: [PATCH] feat(settings): detect request location from server egress Reuse proxy egress detection for global request location drafts and preserve cached client identity previews. Co-authored-by: Codex --- .../apps/gateway/tests/bootstrap/server.rs | 2 +- .../crates/gateway-admin/src/ports/proxy.rs | 3 +- .../gateway-admin/src/use_case/proxies.rs | 12 +- .../gateway-admin/tests/use_case/proxies.rs | 2 +- .../crates/gateway-api/src/admin/proxies.rs | 2 +- .../crates/gateway-api/src/admin/settings.rs | 23 ++++ .../crates/gateway-api/tests/admin/proxies.rs | 14 ++- .../gateway-api/tests/admin/settings.rs | 47 +++++++ .../crates/gateway-host/src/proxy_probe.rs | 40 +++--- .../crates/gateway-host/tests/proxy_probe.rs | 118 ++++++++++++++++-- .../runtime/tests/support/environment.rs | 6 +- .../openai/tests/transport/profile/mod.rs | 16 +++ docs/api.md | 7 +- frontend/src/api/modules/settings/index.ts | 9 ++ .../RequestLocationDetectButton.vue | 65 ++++++++++ .../proxies/components/ProxyFormModal.vue | 61 +-------- .../components/RequestLocationCard.vue | 7 +- .../settings/composables/useSettingsForm.ts | 31 ++++- frontend/src/views/settings/index.vue | 16 ++- 19 files changed, 368 insertions(+), 113 deletions(-) create mode 100644 frontend/src/components/RequestLocationDetectButton.vue diff --git a/backend/apps/gateway/tests/bootstrap/server.rs b/backend/apps/gateway/tests/bootstrap/server.rs index dee48b8b5..620e275bc 100644 --- a/backend/apps/gateway/tests/bootstrap/server.rs +++ b/backend/apps/gateway/tests/bootstrap/server.rs @@ -78,7 +78,7 @@ async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() { }); let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip") .with_client_builder(provider_openai::build_reqwest_client_with_custom_ca); - let result = probe.test(&proxy, false).await; + let result = probe.test(Some(&proxy), false).await; assert!(result.success, "{}", result.message); assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8"); tokio::time::timeout(Duration::from_secs(5), server) diff --git a/backend/crates/gateway-admin/src/ports/proxy.rs b/backend/crates/gateway-admin/src/ports/proxy.rs index e03270189..9d0b9f7ba 100644 --- a/backend/crates/gateway-admin/src/ports/proxy.rs +++ b/backend/crates/gateway-admin/src/ports/proxy.rs @@ -64,5 +64,6 @@ pub struct ProxyImportReservation { #[async_trait] pub trait ProxyProbe: Send + Sync { - async fn test(&self, proxy: &OutboundProxy, detect_location: bool) -> ProxyTestResult; + /// 未指定代理时探测服务器直连出口,不继承进程代理环境变量 + async fn test(&self, proxy: Option<&OutboundProxy>, detect_location: bool) -> ProxyTestResult; } diff --git a/backend/crates/gateway-admin/src/use_case/proxies.rs b/backend/crates/gateway-admin/src/use_case/proxies.rs index 49eed24b6..84204925d 100644 --- a/backend/crates/gateway-admin/src/use_case/proxies.rs +++ b/backend/crates/gateway-admin/src/use_case/proxies.rs @@ -61,10 +61,10 @@ pub trait ProxiesService: Send + Sync { detect_location: bool, context: &MutationContext, ) -> Result; - /// 探测未保存的连接地址,不写入代理记录或修改账号绑定 + /// 探测未保存的连接地址或服务器直连出口,不写入配置或修改账号绑定 async fn probe( &self, - proxy: &OutboundProxy, + proxy: Option<&OutboundProxy>, detect_location: bool, ) -> Result; } @@ -194,7 +194,7 @@ impl ProxiesService for DefaultProxiesService { .transpose() .map_err(|_| AdminError::invalid("代理位置不合法"))?; command.test = if command.auto_location { - Some(self.probe(&command.proxy, true).await?) + Some(self.probe(Some(&command.proxy), true).await?) } else { None }; @@ -233,7 +233,7 @@ impl ProxiesService for DefaultProxiesService { if command.auto_location.unwrap_or(current.auto_location) && (!current.auto_location || proxy != ¤t.proxy) { - command.test = Some(self.probe(proxy, true).await?); + command.test = Some(self.probe(Some(proxy), true).await?); } } let result = self @@ -262,7 +262,7 @@ impl ProxiesService for DefaultProxiesService { async fn probe( &self, - proxy: &OutboundProxy, + proxy: Option<&OutboundProxy>, detect_location: bool, ) -> Result { let _permit = self.test_slots.try_acquire().map_err(|_| { @@ -292,7 +292,7 @@ impl ProxiesService for DefaultProxiesService { // 手动解析只为本次测试请求位置,不改变自动跟随的持久配置 let result = self .probe - .test(&record.proxy, record.auto_location || detect_location) + .test(Some(&record.proxy), record.auto_location || detect_location) .await; let mutation = self .store diff --git a/backend/crates/gateway-admin/tests/use_case/proxies.rs b/backend/crates/gateway-admin/tests/use_case/proxies.rs index 9438977f7..96675d5c7 100644 --- a/backend/crates/gateway-admin/tests/use_case/proxies.rs +++ b/backend/crates/gateway-admin/tests/use_case/proxies.rs @@ -106,7 +106,7 @@ impl ProxyStore for TestProxies { #[async_trait] impl ProxyProbe for TestProxies { - async fn test(&self, _: &OutboundProxy, _: bool) -> ProxyTestResult { + async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult { panic!("unexpected proxy probe") } } diff --git a/backend/crates/gateway-api/src/admin/proxies.rs b/backend/crates/gateway-api/src/admin/proxies.rs index b256b8757..b4b7c3c64 100644 --- a/backend/crates/gateway-api/src/admin/proxies.rs +++ b/backend/crates/gateway-api/src/admin/proxies.rs @@ -480,7 +480,7 @@ where let result = state .admin_services() .proxies() - .probe(&proxy, request.detect_location) + .probe(Some(&proxy), request.detect_location) .await .map_err(map_error)?; Ok(AdminResponse::new( diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index c936e8296..4a69395f8 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -275,6 +275,10 @@ where ) .route("/api/admin/settings/pricing/sync", post(sync_pricing::)) .route("/api/admin/settings", get(settings::)) + .route( + "/api/admin/settings/location/detect", + post(detect_location::), + ) .route( "/api/admin/settings/client-profiles/{provider}", get(client_profile_options::), @@ -648,6 +652,25 @@ fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>( serde_json::Map::::deserialize(deserializer).map(Some) } +async fn detect_location( + _auth: AdminAuth, + State(state): State, +) -> Result +where + S: SessionState + Send + Sync, +{ + let result = state + .admin_services() + .proxies() + .probe(None, true) + .await + .map_err(map_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(result.location), + )) +} + async fn client_profile_options( _auth: AdminAuth, Path(provider): Path, diff --git a/backend/crates/gateway-api/tests/admin/proxies.rs b/backend/crates/gateway-api/tests/admin/proxies.rs index 8462059ba..8ee41ca2e 100644 --- a/backend/crates/gateway-api/tests/admin/proxies.rs +++ b/backend/crates/gateway-api/tests/admin/proxies.rs @@ -299,11 +299,15 @@ pub(super) struct SuccessfulProbe; #[async_trait] impl ProxyProbe for SuccessfulProbe { - async fn test(&self, proxy: &OutboundProxy, detect_location: bool) -> ProxyTestResult { - assert_eq!( - proxy.expose_url(), - "http://test-user:private-password@proxy.example:8080/" - ); + async fn test(&self, proxy: Option<&OutboundProxy>, detect_location: bool) -> ProxyTestResult { + if let Some(proxy) = proxy { + assert_eq!( + proxy.expose_url(), + "http://test-user:private-password@proxy.example:8080/" + ); + } else { + assert!(detect_location); + } ProxyTestResult { location: if detect_location { ProxyLocationDetection::Detected { location: serde_json::from_value(json!({"country":"JP","region":"Tokyo","city":"Tokyo","timezone":"Asia/Tokyo"})).unwrap() } diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index 1c889dab6..66e53be26 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -78,6 +78,53 @@ fn update_body() -> Value { }) } +#[tokio::test] +async fn location_detection_requires_auth_and_leaves_saved_settings_unchanged() { + let fixture = AdminTestFixture::new().await; + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/settings/location/detect", + None, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + fixture.auth.insert_session("valid-session"); + let before = response_json( + app(fixture.state()) + .oneshot(request(Method::GET, "/api/admin/settings", None)) + .await + .unwrap(), + ) + .await; + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/settings/location/detect", + None, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response_json(response).await["data"], + json!({ + "status": "detected", + "location": {"country": "JP", "region": "Tokyo", "city": "Tokyo", "timezone": "Asia/Tokyo"} + }) + ); + let after = response_json( + app(fixture.state()) + .oneshot(request(Method::GET, "/api/admin/settings", None)) + .await + .unwrap(), + ) + .await; + assert_eq!(before, after); +} + #[tokio::test] async fn privacy_save_requires_provider_validation_and_preview_requires_admin_auth() { let fixture = AdminTestFixture::new().await; diff --git a/backend/crates/gateway-host/src/proxy_probe.rs b/backend/crates/gateway-host/src/proxy_probe.rs index 469bf455e..71c355bd9 100644 --- a/backend/crates/gateway-host/src/proxy_probe.rs +++ b/backend/crates/gateway-host/src/proxy_probe.rs @@ -1,4 +1,4 @@ -//! 使用与 Provider 请求一致的显式代理协议,执行有超时和响应大小限制的出口测试 +//! 使用与 Provider 请求一致的显式代理或直连路径,执行有超时和响应大小限制的出口测试 use std::{ net::{IpAddr, Ipv4Addr, Ipv6Addr}, @@ -77,22 +77,24 @@ impl HttpProxyProbe { async fn exit_ip_at( &self, - proxy: &OutboundProxy, + proxy: Option<&OutboundProxy>, endpoint: &str, ) -> Result { - let proxy = reqwest::Proxy::all(proxy.expose_url()).map_err(|_| "代理地址不合法")?; - let builder = reqwest::Client::builder() + let mut builder = reqwest::Client::builder() .no_proxy() - .proxy(proxy) .connect_timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(12)) .redirect(reqwest::redirect::Policy::none()); + if let Some(proxy) = proxy { + builder = builder + .proxy(reqwest::Proxy::all(proxy.expose_url()).map_err(|_| "代理地址不合法")?); + } let client = (self.build_client)(builder)?; let mut response = client.get(endpoint).send().await.map_err(|error| { if error.is_timeout() { - "代理连接超时" + "出口连接超时" } else { - "代理连接失败,请检查地址、认证和网络" + "出口连接失败,请检查网络或代理配置" } })?; if !response.status().is_success() { @@ -123,7 +125,7 @@ impl HttpProxyProbe { } impl HttpProxyProbe { - async fn test_connection(&self, proxy: &OutboundProxy) -> ProxyTestResult { + async fn test_connection(&self, proxy: Option<&OutboundProxy>) -> ProxyTestResult { let started = Instant::now(); let timeout_limit = Duration::from_secs(15); @@ -131,7 +133,7 @@ impl HttpProxyProbe { ProbeStrategy::Single(endpoint) => { let result = tokio::time::timeout(timeout_limit, self.exit_ip_at(proxy, endpoint)).await; - let result = result.unwrap_or(Err("代理连接超时")); + let result = result.unwrap_or(Err("出口连接超时")); let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); match result { @@ -219,7 +221,7 @@ impl HttpProxyProbe { let message = res_v4 .err() .or(res_v6.err()) - .unwrap_or("代理连接失败") + .unwrap_or("出口连接失败") .to_owned(); ProxyTestResult { location: ProxyLocationDetection::NotRequested, @@ -239,7 +241,7 @@ impl HttpProxyProbe { exit_ip: None, exit_ipv4: None, exit_ipv6: None, - message: "代理连接超时".to_owned(), + message: "出口连接超时".to_owned(), }, } } @@ -257,17 +259,17 @@ impl HttpProxyProbe { async fn location_for_ip( &self, - proxy: &OutboundProxy, + proxy: Option<&OutboundProxy>, ip: IpAddr, ) -> Result { - // 出口 IP 已由代理探测确认;按这个固定 IP 从服务端查询位置,避免代理出口屏蔽位置服务 + // 出口 IP 已由探测确认;按这个固定 IP 从服务端查询位置,避免代理出口屏蔽位置服务 // 部署环境无法直连位置服务时仍尝试原代理路径 match self.lookup_location(ip, None).await { Ok(location) => Ok(location), - Err(direct_error) => self - .lookup_location(ip, Some(proxy)) - .await - .or(Err(direct_error)), + Err(direct_error) if proxy.is_some() => { + self.lookup_location(ip, proxy).await.or(Err(direct_error)) + } + Err(error) => Err(error), } } @@ -348,7 +350,7 @@ impl HttpProxyProbe { async fn detect_location( &self, - proxy: &OutboundProxy, + proxy: Option<&OutboundProxy>, result: &ProxyTestResult, ) -> ProxyLocationDetection { let lookup = async |ip: Option| match ip { @@ -378,7 +380,7 @@ impl HttpProxyProbe { #[async_trait] impl ProxyProbe for HttpProxyProbe { - async fn test(&self, proxy: &OutboundProxy, detect_location: bool) -> ProxyTestResult { + async fn test(&self, proxy: Option<&OutboundProxy>, detect_location: bool) -> ProxyTestResult { let mut result = self.test_connection(proxy).await; if detect_location { result.location = if result.success { diff --git a/backend/crates/gateway-host/tests/proxy_probe.rs b/backend/crates/gateway-host/tests/proxy_probe.rs index 1b21820aa..495da13d4 100644 --- a/backend/crates/gateway-host/tests/proxy_probe.rs +++ b/backend/crates/gateway-host/tests/proxy_probe.rs @@ -1,4 +1,4 @@ -//! 验证代理探测的双栈地址、认证、证书与失败边界 +//! 验证代理及直连探测的双栈地址、认证、证书与失败边界 use gateway_admin::ports::proxy::ProxyProbe; use gateway_core::account::OutboundProxy; @@ -28,7 +28,7 @@ async fn proxy_probe_supports_ipv4_and_ipv6_proxies_and_exit_addresses() { OutboundProxy::parse(&format!("http://user:password@{}", proxy_server.address())) .unwrap(); let result = HttpProxyProbe::new("http://unresolvable.invalid/ip") - .test(&proxy, false) + .test(Some(&proxy), false) .await; assert!( result.success, @@ -54,7 +54,10 @@ async fn proxy_probe_rejects_auth_errors_redirects_and_invalid_or_oversized_resp .mount(&proxy_server) .await; let result = HttpProxyProbe::new("http://unresolvable.invalid/ip") - .test(&OutboundProxy::parse(&proxy_server.uri()).unwrap(), false) + .test( + Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()), + false, + ) .await; assert!(!result.success); assert!(result.exit_ip.is_none()); @@ -72,7 +75,9 @@ async fn unavailable_proxy_never_falls_back_to_direct_connection() { let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let proxy = OutboundProxy::parse(&format!("http://{}", unused.local_addr().unwrap())).unwrap(); drop(unused); - let result = HttpProxyProbe::new(target.uri()).test(&proxy, false).await; + let result = HttpProxyProbe::new(target.uri()) + .test(Some(&proxy), false) + .await; assert!(!result.success); } @@ -86,7 +91,7 @@ async fn invalid_certificate_configuration_should_not_fall_back_or_expose_detail .await; let result = HttpProxyProbe::new(target.uri()) .with_client_builder(|_| Err("private-certificate-path")) - .test(&OutboundProxy::parse(&target.uri()).unwrap(), false) + .test(Some(&OutboundProxy::parse(&target.uri()).unwrap()), false) .await; assert!(!result.success); assert!(!result.message.contains("private-certificate-path")); @@ -112,7 +117,7 @@ async fn dual_stack_proxy_probe_reports_both_addresses_when_available() { format!("{}/v4", proxy_server.uri()), format!("{}/v6", proxy_server.uri()), ) - .test(&proxy, false) + .test(Some(&proxy), false) .await; assert!(result.success); @@ -141,7 +146,7 @@ async fn dual_stack_proxy_probe_reports_single_stack_when_only_one_succeeds() { format!("{}/v4", proxy_server.uri()), format!("{}/v6", proxy_server.uri()), ) - .test(&proxy, false) + .test(Some(&proxy), false) .await; assert!(result.success); @@ -154,6 +159,85 @@ fn location_response(ip: &str, timezone: &str) -> serde_json::Value { json!({"success": true, "ip": ip, "country_code": "JP", "region": "Tokyo", "city": "Tokyo", "timezone": {"id": timezone}}) } +#[tokio::test] +async fn direct_location_uses_verified_addresses_and_rejects_conflicting_timezones() { + use gateway_admin::model::proxies::ProxyLocationDetection; + + for v6_timezone in [Some("Asia/Tokyo"), Some("America/New_York"), None] { + let server = MockServer::start().await; + for (endpoint, ip) in [("/v4", "203.0.113.8"), ("/v6", "2001:db8::8")] { + let response = if endpoint == "/v6" && v6_timezone.is_none() { + ResponseTemplate::new(503) + } else { + ResponseTemplate::new(200).set_body_json(json!({"ip": ip})) + }; + Mock::given(path(endpoint)) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + } + for (ip, timezone) in [ + ("203.0.113.8", Some("Asia/Tokyo")), + ("2001:db8::8", v6_timezone), + ] { + if let Some(timezone) = timezone { + Mock::given(path(format!("/geo/{ip}"))) + .respond_with( + ResponseTemplate::new(200).set_body_json(location_response(ip, timezone)), + ) + .expect(1) + .mount(&server) + .await; + } + } + let result = HttpProxyProbe::new_dual( + format!("{}/v4", server.uri()), + format!("{}/v6", server.uri()), + ) + .with_location_endpoint(format!("{}/geo", server.uri())) + .test(None, true) + .await; + assert!(result.success); + if v6_timezone == Some("America/New_York") { + assert_eq!(result.location, ProxyLocationDetection::Conflict); + } else { + let ProxyLocationDetection::Detected { location } = result.location else { + panic!("direct location missing") + }; + assert_eq!(location.timezone.name(), "Asia/Tokyo"); + assert_eq!(location.city, "Tokyo"); + } + } +} + +#[tokio::test] +async fn direct_location_failure_does_not_retry_the_same_path_or_return_a_location() { + use gateway_admin::model::proxies::ProxyLocationDetection; + + let server = MockServer::start().await; + Mock::given(path("/ip")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ip": "203.0.113.8"}))) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/geo/203.0.113.8")) + .respond_with(ResponseTemplate::new(429)) + .expect(1) + .mount(&server) + .await; + let result = HttpProxyProbe::new(format!("{}/ip", server.uri())) + .with_location_endpoint(format!("{}/geo", server.uri())) + .test(None, true) + .await; + assert_eq!( + result.location, + ProxyLocationDetection::Failed { + message: "位置查询服务限流,请稍后手动重试".to_owned() + } + ); +} + #[tokio::test] async fn location_lookup_uses_verified_exit_ip_without_requiring_proxy_access_to_geo_service() { use gateway_admin::model::proxies::ProxyLocationDetection; @@ -176,7 +260,10 @@ async fn location_lookup_uses_verified_exit_ip_without_requiring_proxy_access_to let result = HttpProxyProbe::new("http://unresolvable.invalid/ip") .with_location_endpoint(format!("{}/geo", location_server.uri())) - .test(&OutboundProxy::parse(&proxy_server.uri()).unwrap(), true) + .test( + Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()), + true, + ) .await; assert!(result.success); @@ -212,7 +299,10 @@ async fn location_lookup_uses_probed_ips_through_the_proxy_and_checks_both_timez "http://unresolvable.invalid/v6", ) .with_location_endpoint("http://unresolvable.invalid/geo/") - .test(&OutboundProxy::parse(&proxy_server.uri()).unwrap(), true) + .test( + Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()), + true, + ) .await; assert!(result.success); if conflict { @@ -255,7 +345,10 @@ async fn geolocation_failure_never_turns_connectivity_into_failure_or_uses_unver .await; let result = HttpProxyProbe::new("http://unresolvable.invalid/ip") .with_location_endpoint("http://unresolvable.invalid/geo") - .test(&OutboundProxy::parse(&proxy_server.uri()).unwrap(), true) + .test( + Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()), + true, + ) .await; assert!(result.success); assert!(matches!( @@ -280,7 +373,10 @@ async fn disabled_geolocation_never_calls_the_location_service() { .await; let result = HttpProxyProbe::new("http://unresolvable.invalid/ip") .with_location_endpoint("http://unresolvable.invalid/geo") - .test(&OutboundProxy::parse(&proxy_server.uri()).unwrap(), false) + .test( + Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()), + false, + ) .await; assert!(result.success); } diff --git a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs index 5b021c41a..cbd2632b4 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs @@ -824,7 +824,11 @@ impl PricingSource for UnusedAdminRuntime { #[async_trait] impl ProxyProbe for UnusedAdminRuntime { - async fn test(&self, _: &gateway_core::account::OutboundProxy, _: bool) -> ProxyTestResult { + async fn test( + &self, + _: Option<&gateway_core::account::OutboundProxy>, + _: bool, + ) -> ProxyTestResult { panic!("unexpected proxy probe") } } diff --git a/backend/crates/providers/openai/tests/transport/profile/mod.rs b/backend/crates/providers/openai/tests/transport/profile/mod.rs index 9ff8bb622..bbcc93fb1 100644 --- a/backend/crates/providers/openai/tests/transport/profile/mod.rs +++ b/backend/crates/providers/openai/tests/transport/profile/mod.rs @@ -252,6 +252,22 @@ async fn bundled_release_update_should_change_core_and_desktop_identity_atomical assert_eq!(status.latest, Some(release("26.810.41047", "6570"))); assert!(status.checked_at.is_some()); assert!(status.last_error.is_none()); + + // 设置页反复读取预设与预览时,只展示当前生效版本及最近检查时间 + let selection = + provider_openai::transport::profile::selection::ClientProfileSelection::default(); + let configuration = selection.document().unwrap(); + let first = state.preview_selection(&configuration).unwrap(); + assert_eq!( + first.expose_to_provider()["userAgent"], + selection.resolve(&state).unwrap().user_agent() + ); + assert!(first.expose_to_provider()["checkedAt"].is_string()); + for _ in 0..3 { + state.selection_options().unwrap(); + assert_eq!(state.preview_selection(&configuration).unwrap(), first); + } + assert_eq!(transport.core_probes(), 1); } #[tokio::test] diff --git a/docs/api.md b/docs/api.md index 8b27446ff..e13679dee 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1385,10 +1385,11 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly | --- | --- | --- | | `GET` | `/api/admin/settings` | 读取运行设置 | | `POST` | `/api/admin/settings/update` | 原子保存运行设置;身份配置按显式提供的 Provider 项更新 | +| `POST` | `/api/admin/settings/location/detect` | 无请求体,检测网关服务器直连出口的位置,不保存设置 | | `POST` | `/api/admin/settings/privacy/preview` | 使用管理员提供的样本预览隐私规则,不保存设置或样本 | | `GET` | `/api/admin/settings/client-downloads/codex-desktop/windows` | 提取 Codex Desktop Windows 离线安装直链;`refresh=true` 强制刷新进程内短缓存 | | `GET` | `/api/admin/settings/client-profiles/{provider}` | 读取 `openai` 或 `xai` 的可选配置和 `globalConfiguration` | -| `POST` | `/api/admin/settings/client-profiles/{provider}/preview` | `{ configuration }`,完整配置对象或 `null`(解析当前通用设置);只预览,不保存 | +| `POST` | `/api/admin/settings/client-profiles/{provider}/preview` | `{ configuration }`,完整配置对象或 `null`(解析当前通用设置);使用已加载版本与最近检查时间,不触发上游检查或保存 | | `GET` | `/api/admin/settings/admin-api-key` | 只返回管理 API Key 是否存在 | | `POST` | `/api/admin/settings/admin-api-key/delete` | 删除管理 API Key | | `POST` | `/api/admin/settings/admin-api-key/regenerate` | 重新生成并一次性返回完整管理 API Key | @@ -1397,6 +1398,10 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly 并发修改导致版本过期时返回 `409`,不保存当前请求;读取最新设置并确认差异后再提交,不能静默重试覆盖。 成功响应返回新版本,后续请求使用发布后的快照 +位置检测返回 `status: detected` 与 `location`,双栈出口时区不一致时返回 `conflict`, +无法获取出口或位置时返回 `failed` 与 `message`。检测忽略进程代理环境变量,复用代理探测的超时与并发限制; +前端仅在成功时填入草稿,仍需保存设置才会生效 + 设置更新字段按用途分组如下: | 用途 | 字段 | diff --git a/frontend/src/api/modules/settings/index.ts b/frontend/src/api/modules/settings/index.ts index 01fa75787..354a3d2c3 100644 --- a/frontend/src/api/modules/settings/index.ts +++ b/frontend/src/api/modules/settings/index.ts @@ -1,4 +1,5 @@ import type { RequestOptions } from '../../request' +import type { ProxyLocationDetection } from '../proxies' import type { CodexPrivacyPolicy } from './privacy' import type { ClientProfileSelection, ProviderRequestProfiles, ProviderRequestProfileUpdates, XaiClientProfileSelection } from './profiles' import request from '../../request' @@ -15,6 +16,14 @@ export interface RequestLocation { timezone: string } +export function detectRequestLocation() { + return request({ + url: '/api/admin/settings/location/detect', + method: 'POST', + timeout: 25000, + }) +} + export type AccountAffinity = 'relaxed' | 'preferred' | 'strict' export type RotationStrategy = 'smart' | 'quota_reset_priority' | 'round_robin' | 'sticky' diff --git a/frontend/src/components/RequestLocationDetectButton.vue b/frontend/src/components/RequestLocationDetectButton.vue new file mode 100644 index 000000000..6010f4817 --- /dev/null +++ b/frontend/src/components/RequestLocationDetectButton.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend/src/views/proxies/components/ProxyFormModal.vue b/frontend/src/views/proxies/components/ProxyFormModal.vue index 7dcca9b62..56e501ba1 100644 --- a/frontend/src/views/proxies/components/ProxyFormModal.vue +++ b/frontend/src/views/proxies/components/ProxyFormModal.vue @@ -1,8 +1,9 @@ @@ -11,8 +13,9 @@ const enabled = defineModel('enabled', { required: true })