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, () => {