From 66fd09eba6b0a96449effa9776cf7b37f5e1eb5f Mon Sep 17 00:00:00 2001 From: Zyy Date: Fri, 11 Sep 2026 16:08:14 +0800 Subject: [PATCH] feat(proxies): test connections before saving and refine controls Separate connection testing from saving in proxy forms. Probe unsaved URLs with the shared certificate configuration and test concurrency limit, without changing saved proxy settings or linked accounts. Keep saved authentication when testing an unchanged connection, use clearer placeholders, fix action column widths, and remove the redundant list refresh button. Validation: 5 proxy API tests, scoped strict Clippy, rustfmt, frontend lint/type checks/build, and browser checks passed. Codex CLI requests through real HTTP and SOCKS5 proxies completed with matching upstream text while Clash TUN remained enabled. Co-authored-by: Codex --- .../gateway-admin/src/use_case/proxies.rs | 15 +++- .../crates/gateway-api/src/admin/proxies.rs | 52 ++++++++++++-- .../crates/gateway-api/tests/admin/proxies.rs | 70 ++++++++++++++++++- frontend/src/api/modules/proxies.ts | 9 +++ .../src/components/base/BaseTable/columns.ts | 10 ++- .../proxies/components/ProxyFormModal.vue | 31 ++++---- frontend/src/views/proxies/index.vue | 49 +++++++++---- 7 files changed, 196 insertions(+), 40 deletions(-) diff --git a/backend/crates/gateway-admin/src/use_case/proxies.rs b/backend/crates/gateway-admin/src/use_case/proxies.rs index 367472928..691ff161c 100644 --- a/backend/crates/gateway-admin/src/use_case/proxies.rs +++ b/backend/crates/gateway-admin/src/use_case/proxies.rs @@ -2,7 +2,11 @@ use std::sync::Arc; use async_trait::async_trait; use futures::{StreamExt, stream}; -use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::SnapshotControl}; +use gateway_core::{ + account::{OutboundProxy, ProviderAccountId}, + routing::ProviderKind, + runtime::SnapshotControl, +}; use tokio::sync::Semaphore; use super::{map_store_error, publish_committed}; @@ -54,6 +58,8 @@ pub trait ProxiesService: Send + Sync { revision: Revision, context: &MutationContext, ) -> Result; + /// 探测未保存的连接地址,不写入代理记录或修改账号绑定。 + async fn probe(&self, proxy: &OutboundProxy) -> Result; } pub(crate) struct DefaultProxiesService { @@ -214,6 +220,13 @@ impl ProxiesService for DefaultProxiesService { Ok(result) } + async fn probe(&self, proxy: &OutboundProxy) -> Result { + let _permit = self.test_slots.try_acquire().map_err(|_| { + AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试") + })?; + Ok(self.probe.test(proxy).await) + } + async fn test( &self, id: &str, diff --git a/backend/crates/gateway-api/src/admin/proxies.rs b/backend/crates/gateway-api/src/admin/proxies.rs index 5516a030c..c662dcf2b 100644 --- a/backend/crates/gateway-api/src/admin/proxies.rs +++ b/backend/crates/gateway-api/src/admin/proxies.rs @@ -8,7 +8,8 @@ use axum::{ use gateway_admin::model::{ PageSize, Revision, proxies::{ - NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord, UpdateProxy, + NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord, + ProxyTestResult, UpdateProxy, }, }; use serde::{Deserialize, Serialize}; @@ -66,6 +67,12 @@ struct IdRequest { revision: u64, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProbeRequest { + proxy_url: AccountProxyUpdate, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ProxyTestView { @@ -75,6 +82,17 @@ struct ProxyTestView { message: String, } +impl From for ProxyTestView { + fn from(result: ProxyTestResult) -> Self { + Self { + success: result.success, + latency_ms: result.latency_ms, + exit_ip: result.exit_ip.map(|ip| ip.to_string()), + message: result.message, + } + } +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ProxyAccountView { @@ -115,12 +133,7 @@ impl From for ProxyView { revision: record.revision.get(), account_count: record.account_count, last_test_at: record.last_test_at.map(|at| at.to_rfc3339()), - last_test: record.last_test.map(|result| ProxyTestView { - success: result.success, - latency_ms: result.latency_ms, - exit_ip: result.exit_ip.map(|ip| ip.to_string()), - message: result.message, - }), + last_test: record.last_test.map(Into::into), created_at: record.created_at.to_rfc3339(), updated_at: record.updated_at.to_rfc3339(), } @@ -170,6 +183,7 @@ where .route("/api/admin/proxies/update", post(update::)) .route("/api/admin/proxies/delete", post(delete::)) .route("/api/admin/proxies/test", post(test::)) + .route("/api/admin/proxies/probe", post(probe::)) } fn revision(value: u64) -> Result { @@ -389,6 +403,30 @@ where )) } +async fn probe( + _: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result +where + S: AdminSessionState + Send + Sync, +{ + let proxy = request + .proxy_url + .0 + .ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?; + let result = state + .admin_services() + .proxies() + .probe(&proxy) + .await + .map_err(map_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(ProxyTestView::from(result)), + )) +} + async fn test( auth: AdminAuth, State(state): State, diff --git a/backend/crates/gateway-api/tests/admin/proxies.rs b/backend/crates/gateway-api/tests/admin/proxies.rs index dda99ec53..fc25d2a6c 100644 --- a/backend/crates/gateway-api/tests/admin/proxies.rs +++ b/backend/crates/gateway-api/tests/admin/proxies.rs @@ -304,6 +304,67 @@ async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_creden assert_eq!(listed["data"]["page"]["total"], 0); } +#[tokio::test] +async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let draft = json!({ + "proxyUrl": "http://test-user:private-password@proxy.example:8080" + }); + let (status, probed) = request( + &fixture, + "/api/admin/proxies/probe", + Some(draft.clone()), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + probed["data"], + json!({"success": true, "latencyMs": 15, "exitIp": "203.0.113.2", "message": "Connected"}) + ); + let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await; + assert_eq!(listed["data"]["page"]["total"], 0); + + let (_, created) = request( + &fixture, + "/api/admin/proxies/create", + Some(json!({"name": "Saved", "proxyUrl": "http://saved.example:8080"})), + true, + ) + .await; + let (status, _) = request(&fixture, "/api/admin/proxies/probe", Some(draft), true).await; + assert_eq!(status, StatusCode::OK); + let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await; + assert_eq!(listed["data"]["items"][0], created["data"]["record"]); +} + +#[tokio::test] +async fn proxy_probe_rejects_empty_or_invalid_addresses() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for (body, expected) in [ + (json!({"proxyUrl": ""}), StatusCode::BAD_REQUEST), + (json!({"proxyUrl": null}), StatusCode::UNPROCESSABLE_ENTITY), + (json!({}), StatusCode::UNPROCESSABLE_ENTITY), + ( + json!({"proxyUrl": "ftp://test-user:private-password@proxy.example"}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + json!({"proxyUrl": "http://proxy.example:8080", "name": "Not saved"}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ] { + assert_eq!( + request(&fixture, "/api/admin/proxies/probe", Some(body), true) + .await + .0, + expected + ); + } +} + #[tokio::test] async fn proxy_routes_require_auth_and_reject_invalid_input() { let fixture = AdminTestFixture::new().await; @@ -355,7 +416,14 @@ async fn proxy_routes_require_auth_and_reject_invalid_input() { .0, StatusCode::NOT_FOUND ); - for action in ["create", "update", "delete", "test", "accounts/remove"] { + for action in [ + "create", + "update", + "delete", + "test", + "probe", + "accounts/remove", + ] { assert_eq!( request( &fixture, diff --git a/frontend/src/api/modules/proxies.ts b/frontend/src/api/modules/proxies.ts index 1380685b0..e9b299fe5 100644 --- a/frontend/src/api/modules/proxies.ts +++ b/frontend/src/api/modules/proxies.ts @@ -104,3 +104,12 @@ export function testProxy(data: { id: string, revision: number }) { timeout: 25000, }) } + +export function probeProxy(data: { proxyUrl: string }) { + return request({ + url: '/api/admin/proxies/probe', + method: 'POST', + data, + timeout: 25000, + }) +} diff --git a/frontend/src/components/base/BaseTable/columns.ts b/frontend/src/components/base/BaseTable/columns.ts index 1728557b8..849f3a929 100644 --- a/frontend/src/components/base/BaseTable/columns.ts +++ b/frontend/src/components/base/BaseTable/columns.ts @@ -24,6 +24,7 @@ export interface BaseTableColumn { label?: string kind?: TableColumnKind size?: TableColumnSize + fixedWidth?: boolean align?: TableColumnAlign sortable?: boolean | string format?: (value: unknown, row: Row) => unknown @@ -212,11 +213,14 @@ export function columnStyle( column: ResolvedTableColumn, columns: ResolvedTableColumn[], ) { - const tableWidth = minimumTableWidth(columns) - const widthPercent = tableWidth > 0 ? (column.basisWidth / tableWidth) * 100 : 0 + const fixedWidth = columns.reduce((total, item) => total + (item.fixedWidth ? item.basisWidth : 0), 0) + const flexibleWidth = minimumTableWidth(columns) - fixedWidth + const ratio = flexibleWidth > 0 ? column.basisWidth / flexibleWidth : 0 return { - width: `${widthPercent}%`, + width: column.fixedWidth + ? `${column.basisWidth}px` + : `${ratio * 100}%`, minWidth: `${column.basisWidth}px`, } } diff --git a/frontend/src/views/proxies/components/ProxyFormModal.vue b/frontend/src/views/proxies/components/ProxyFormModal.vue index 82dfd1f51..f64ee0585 100644 --- a/frontend/src/views/proxies/components/ProxyFormModal.vue +++ b/frontend/src/views/proxies/components/ProxyFormModal.vue @@ -12,14 +12,17 @@ import BaseModal from '@/components/base/BaseModal/index.vue' const props = defineProps<{ proxy: OutboundProxyRecord | null saving: boolean + testing: boolean }>() const emit = defineEmits<{ - save: [testAfter: boolean] + save: [] + test: [] }>() const open = defineModel({ required: true }) const name = defineModel('name', { required: true }) const proxyUrl = defineModel('proxyUrl', { required: true }) const showSecret = shallowRef(false) +const busy = computed(() => props.saving || props.testing) const title = computed(() => props.proxy ? '编辑代理' : '新增代理') const connectionDescription = computed(() => props.proxy ? '留空保留当前连接和认证信息;填写新地址时,请包含所需的用户名和密码。' @@ -31,22 +34,22 @@ watch(open, () => {