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:
Zyy
2026-10-16 20:02:12 +08:00
co-authored by Codex
parent 1ce9710afa
commit c67b081ec1
19 changed files with 368 additions and 113 deletions
@@ -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 != &current.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;
+21 -19
View File
@@ -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 {
+107 -11
View File
@@ -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
View File
@@ -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,
+12 -4
View File
@@ -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"