mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
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 <noreply@openai.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -61,10 +61,10 @@ pub trait ProxiesService: Send + Sync {
|
||||
detect_location: bool,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyRecord, AdminError>;
|
||||
/// 探测未保存的连接地址,不写入代理记录或修改账号绑定
|
||||
/// 探测未保存的连接地址或服务器直连出口,不写入配置或修改账号绑定
|
||||
async fn probe(
|
||||
&self,
|
||||
proxy: &OutboundProxy,
|
||||
proxy: Option<&OutboundProxy>,
|
||||
detect_location: bool,
|
||||
) -> Result<ProxyTestResult, AdminError>;
|
||||
}
|
||||
@@ -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<ProxyTestResult, AdminError> {
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -275,6 +275,10 @@ where
|
||||
)
|
||||
.route("/api/admin/settings/pricing/sync", post(sync_pricing::<S>))
|
||||
.route("/api/admin/settings", get(settings::<S>))
|
||||
.route(
|
||||
"/api/admin/settings/location/detect",
|
||||
post(detect_location::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/client-profiles/{provider}",
|
||||
get(client_profile_options::<S>),
|
||||
@@ -648,6 +652,25 @@ fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>(
|
||||
serde_json::Map::<String, serde_json::Value>::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
async fn detect_location<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
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<S>(
|
||||
_auth: AdminAuth,
|
||||
Path(provider): Path<String>,
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<IpAddr, &'static str> {
|
||||
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<RequestLocation, &'static str> {
|
||||
// 出口 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<IpAddr>| 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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
+6
-1
@@ -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`。检测忽略进程代理环境变量,复用代理探测的超时与并发限制;
|
||||
前端仅在成功时填入草稿,仍需保存设置才会生效
|
||||
|
||||
设置更新字段按用途分组如下:
|
||||
|
||||
| 用途 | 字段 |
|
||||
|
||||
@@ -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<ProxyLocationDetection>({
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIconButton } from '@codex-proxy/ui'
|
||||
import { LocateFixed } from '@lucide/vue'
|
||||
|
||||
defineProps<{ loading: boolean, disabled?: boolean }>()
|
||||
const emit = defineEmits<{ detect: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZIconButton
|
||||
size="default"
|
||||
:aria-label="loading ? '正在解析出口位置' : '解析出口位置并填入表单'"
|
||||
:title="loading ? '正在解析出口位置' : '解析出口位置并填入表单'"
|
||||
:aria-busy="loading"
|
||||
:disabled="disabled || loading"
|
||||
@click="emit('detect')"
|
||||
>
|
||||
<span class="relative grid size-7 place-items-center text-cp-link">
|
||||
<span v-if="loading" class="location-ripple" aria-hidden="true" />
|
||||
<span v-if="loading" class="location-ripple location-ripple-delayed" aria-hidden="true" />
|
||||
<LocateFixed class="relative z-10 size-4" aria-hidden="true" />
|
||||
</span>
|
||||
</ZIconButton>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.location-ripple {
|
||||
position: absolute;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
animation: location-ripple 1.8s ease-out infinite forwards;
|
||||
}
|
||||
|
||||
.location-ripple-delayed {
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
|
||||
@keyframes location-ripple {
|
||||
from {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(1.55);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.location-ripple {
|
||||
animation: none;
|
||||
opacity: 0.4;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.location-ripple-delayed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { OutboundProxyRecord, OutboundProxyTest, RequestLocation } from '@/api'
|
||||
import { ZButton, ZDialog, ZForm, ZFormItem, ZIconButton, ZInput, ZSwitch } from '@codex-proxy/ui'
|
||||
import { LocateFixed, Save, Wifi } from '@lucide/vue'
|
||||
import { ZButton, ZDialog, ZForm, ZFormItem, ZInput, ZSwitch } from '@codex-proxy/ui'
|
||||
import { Save, Wifi } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import RequestLocationDetectButton from '@/components/RequestLocationDetectButton.vue'
|
||||
import RequestLocationFields from '@/components/RequestLocationFields.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -55,20 +56,7 @@ const connectionDescription = computed(() => props.proxy
|
||||
</ZFormItem>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<ZSwitch v-model="customLocation" label="自定义时区位置" :disabled="busy" />
|
||||
<ZIconButton
|
||||
size="default"
|
||||
:aria-label="detectingLocation ? '正在解析出口位置' : '解析出口位置并填入表单'"
|
||||
:title="detectingLocation ? '正在解析出口位置' : '解析出口位置并填入表单'"
|
||||
:aria-busy="detectingLocation"
|
||||
:disabled="busy"
|
||||
@click="emit('detectLocation')"
|
||||
>
|
||||
<span class="relative grid size-7 place-items-center text-cp-link">
|
||||
<span v-if="detectingLocation" class="location-ripple" aria-hidden="true" />
|
||||
<span v-if="detectingLocation" class="location-ripple location-ripple-delayed" aria-hidden="true" />
|
||||
<LocateFixed class="relative z-10 size-4" aria-hidden="true" />
|
||||
</span>
|
||||
</ZIconButton>
|
||||
<RequestLocationDetectButton :loading="detectingLocation" :disabled="busy" @detect="emit('detectLocation')" />
|
||||
</div>
|
||||
<p v-if="testResult?.success === false" class="m-0 text-cp-sm text-cp-error-text" role="alert">
|
||||
连接测试失败:{{ testResult.message }}
|
||||
@@ -100,44 +88,3 @@ const connectionDescription = computed(() => props.proxy
|
||||
</template>
|
||||
</ZDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.location-ripple {
|
||||
position: absolute;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
animation: location-ripple 1.8s ease-out infinite forwards;
|
||||
}
|
||||
|
||||
.location-ripple-delayed {
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
|
||||
@keyframes location-ripple {
|
||||
from {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(1.55);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.location-ripple {
|
||||
animation: none;
|
||||
opacity: 0.4;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.location-ripple-delayed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { RequestLocation } from '@/api'
|
||||
import { ZCard, ZForm, ZSwitch } from '@codex-proxy/ui'
|
||||
import RequestLocationDetectButton from '@/components/RequestLocationDetectButton.vue'
|
||||
import RequestLocationFields from '@/components/RequestLocationFields.vue'
|
||||
|
||||
defineProps<{ disabled: boolean }>()
|
||||
defineProps<{ disabled: boolean, detectingLocation: boolean }>()
|
||||
const emit = defineEmits<{ detectLocation: [] }>()
|
||||
const location = defineModel<RequestLocation>({ required: true })
|
||||
const enabled = defineModel<boolean>('enabled', { required: true })
|
||||
</script>
|
||||
@@ -11,8 +13,9 @@ const enabled = defineModel<boolean>('enabled', { required: true })
|
||||
<template>
|
||||
<ZCard title="请求位置覆盖" description="覆盖 Codex 请求中的时区与位置字段,账号出站代理的自定义位置优先">
|
||||
<template #body>
|
||||
<div>
|
||||
<div class="flex max-w-6xl items-center justify-between gap-3">
|
||||
<ZSwitch v-model="enabled" label="启用位置覆盖" :disabled="disabled" />
|
||||
<RequestLocationDetectButton :loading="detectingLocation" :disabled="disabled" @detect="emit('detectLocation')" />
|
||||
</div>
|
||||
<ZForm class="mt-4 max-w-6xl">
|
||||
<RequestLocationFields v-model="location" :disabled="disabled || !enabled" />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ZNotification } from '@codex-proxy/ui'
|
||||
import { cloneDeep, isEqual } from 'es-toolkit'
|
||||
|
||||
import { computed, reactive, ref, shallowRef } from 'vue'
|
||||
import { getSettings, updateSettings } from '@/api'
|
||||
import { detectRequestLocation, getSettings, updateSettings } from '@/api'
|
||||
import { ApiError } from '@/api/request'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
import { normalizeRequestLocation, requestLocationError } from '@/utils/location'
|
||||
@@ -19,6 +19,8 @@ export function useSettingsForm() {
|
||||
const loading = shallowRef(true)
|
||||
const saveAction = useAsyncAction()
|
||||
const saving = saveAction.loading
|
||||
const locationAction = useAsyncAction()
|
||||
const detectingLocation = locationAction.loading
|
||||
const error = shallowRef('')
|
||||
const mappings = ref<Array<{ requestedModel: string, upstreamModel: string }>>([])
|
||||
const smartSchedulingDefaults = shallowRef<SmartSchedulingConfig>()
|
||||
@@ -69,7 +71,7 @@ export function useSettingsForm() {
|
||||
&& !isEqual({ form, mappings: mappings.value }, saved.value))
|
||||
|
||||
function resetSettings() {
|
||||
if (!saved.value || saving.value)
|
||||
if (!saved.value || saving.value || detectingLocation.value)
|
||||
return
|
||||
const initial = cloneDeep(saved.value)
|
||||
Object.assign(form, initial.form)
|
||||
@@ -157,6 +159,8 @@ export function useSettingsForm() {
|
||||
}
|
||||
|
||||
async function loadSettings(silent = false) {
|
||||
if (detectingLocation.value)
|
||||
return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
@@ -170,6 +174,25 @@ export function useSettingsForm() {
|
||||
}
|
||||
}
|
||||
|
||||
async function detectLocation() {
|
||||
if (saving.value || loading.value || error.value)
|
||||
return
|
||||
await locationAction.run(async () => {
|
||||
const result = await detectRequestLocation()
|
||||
if (result.status === 'detected') {
|
||||
form.requestLocation = { ...result.location }
|
||||
form.requestLocationEnabled = true
|
||||
ZNotification.success({ message: '已填入出口位置' })
|
||||
}
|
||||
else if (result.status === 'conflict') {
|
||||
ZNotification.warning({ message: 'IPv4 与 IPv6 出口时区不一致,请手动填写' })
|
||||
}
|
||||
else {
|
||||
ZNotification.warning({ message: result.status === 'failed' ? result.message : '未获取到出口位置' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function addMapping() {
|
||||
mappings.value = [...mappings.value, { requestedModel: '', upstreamModel: '' }]
|
||||
}
|
||||
@@ -207,7 +230,7 @@ export function useSettingsForm() {
|
||||
if (!smartScheduling)
|
||||
return
|
||||
const savedSettings = saved.value
|
||||
if (saving.value || loading.value || !savedSettings)
|
||||
if (saving.value || loading.value || detectingLocation.value || !savedSettings)
|
||||
return
|
||||
const { refreshMarginSeconds, refreshConcurrency, maxConcurrentPerAccount, openaiGuardianReservedConcurrency, openaiAccountAffinity, maxAccountRotations, openaiSessionAffinityTtlHours, rotationStrategy, maxWaitingPerKey, maxWaitingPerAccount, concurrencyWaitTimeoutSeconds, responsesMaxDecompressedBodyMiB, accountAutoFreezeThreshold, accountAutoFreezeWindowSeconds, accountAutoFreezeDurationSeconds } = form
|
||||
if (!openaiAccountAffinity || maxAccountRotations === null || openaiSessionAffinityTtlHours === null || refreshMarginSeconds === null || refreshConcurrency === null || maxConcurrentPerAccount === null || openaiGuardianReservedConcurrency === null || !rotationStrategy || maxWaitingPerKey === null || maxWaitingPerAccount === null || concurrencyWaitTimeoutSeconds === null) {
|
||||
@@ -341,6 +364,8 @@ export function useSettingsForm() {
|
||||
return {
|
||||
loading,
|
||||
saving,
|
||||
detectingLocation,
|
||||
detectLocation,
|
||||
hasChanges,
|
||||
resetSettings,
|
||||
error,
|
||||
|
||||
@@ -51,6 +51,8 @@ function switchSection(value = 'runtime'): void {
|
||||
const {
|
||||
loading,
|
||||
saving,
|
||||
detectingLocation,
|
||||
detectLocation,
|
||||
hasChanges,
|
||||
resetSettings,
|
||||
error,
|
||||
@@ -81,7 +83,7 @@ const {
|
||||
loadSettings,
|
||||
} = useSettingsForm()
|
||||
|
||||
const disabled = computed(() => saving.value || loading.value || !!error.value)
|
||||
const disabled = computed(() => saving.value || loading.value || detectingLocation.value || !!error.value)
|
||||
|
||||
watch(section, (value) => {
|
||||
visited.add(value)
|
||||
@@ -113,10 +115,10 @@ watch(section, (value) => {
|
||||
/>
|
||||
<div v-if="isBasicSection || hasChanges" class="ml-auto flex items-center justify-end gap-2">
|
||||
<span v-if="hasChanges" class="mr-1 size-1.5 shrink-0 rounded-full bg-cp-warning" aria-hidden="true" />
|
||||
<ZIconButton v-if="hasChanges" aria-label="撤销全部基础设置更改" variant="solid" :disabled="saving || loading" @click="resetSettings">
|
||||
<ZIconButton v-if="hasChanges" aria-label="撤销全部基础设置更改" variant="solid" :disabled="saving || loading || detectingLocation" @click="resetSettings">
|
||||
<Undo2 class="size-4" />
|
||||
</ZIconButton>
|
||||
<ZButton type="primary" :loading="saving" :disabled="loading || !hasChanges || !!error" @click="saveSettings">
|
||||
<ZButton type="primary" :loading="saving" :disabled="disabled || !hasChanges" @click="saveSettings">
|
||||
<template #icon>
|
||||
<Save class="size-4" />
|
||||
</template>
|
||||
@@ -193,7 +195,13 @@ watch(section, (value) => {
|
||||
:active="section === 'upstream'"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<RequestLocationCard v-model="form.requestLocation" v-model:enabled="form.requestLocationEnabled" :disabled="disabled" />
|
||||
<RequestLocationCard
|
||||
v-model="form.requestLocation"
|
||||
v-model:enabled="form.requestLocationEnabled"
|
||||
:disabled="disabled"
|
||||
:detecting-location="detectingLocation"
|
||||
@detect-location="detectLocation"
|
||||
/>
|
||||
<PrivacyPolicyCard v-model="form.codexPrivacyPolicy" :disabled="disabled" />
|
||||
<ModelAliasesCard
|
||||
:mappings="mappings"
|
||||
|
||||
Reference in New Issue
Block a user