diff --git a/backend/crates/gateway-api/src/admin/proxies.rs b/backend/crates/gateway-api/src/admin/proxies.rs index 44ebe3d9e..ac4ea2104 100644 --- a/backend/crates/gateway-api/src/admin/proxies.rs +++ b/backend/crates/gateway-api/src/admin/proxies.rs @@ -537,14 +537,10 @@ async fn probe( where S: SessionState + Send + Sync, { - let proxy = request - .proxy_url - .0 - .ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?; let result = state .admin_services() .proxies() - .probe(Some(&proxy), request.detect_location) + .probe(request.proxy_url.0.as_ref(), request.detect_location) .await .map_err(map_error)?; Ok(AdminResponse::new( diff --git a/backend/crates/gateway-api/tests/admin/proxies.rs b/backend/crates/gateway-api/tests/admin/proxies.rs index e2c3d38b2..52360c935 100644 --- a/backend/crates/gateway-api/tests/admin/proxies.rs +++ b/backend/crates/gateway-api/tests/admin/proxies.rs @@ -354,9 +354,12 @@ impl ProxyProbe for SuccessfulProbe { proxy.expose_url(), "http://test-user:private-password@proxy.example:8080/" ); - } else { - assert!(detect_location); } + let exit_ip = if proxy.is_some() { + "203.0.113.2" + } else { + "203.0.113.3" + }; ProxyTestResult { location: if detect_location { ProxyLocationDetection::Detected { location: serde_json::from_value(json!({"country":"JP","region":"Tokyo","city":"Tokyo","timezone":"Asia/Tokyo"})).unwrap() } @@ -365,8 +368,8 @@ impl ProxyProbe for SuccessfulProbe { }, success: true, latency_ms: 15, - exit_ip: Some("203.0.113.2".parse().unwrap()), - exit_ipv4: Some("203.0.113.2".parse().unwrap()), + exit_ip: Some(exit_ip.parse().unwrap()), + exit_ipv4: Some(exit_ip.parse().unwrap()), exit_ipv6: None, message: "Connected".to_owned(), } @@ -567,11 +570,50 @@ async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records } #[tokio::test] -async fn proxy_probe_rejects_empty_or_invalid_addresses() { +async fn proxy_probe_checks_direct_egress_without_changing_saved_proxies() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let (_, created) = request( + &fixture, + "/api/admin/proxies/create", + Some(json!([{"name": "Saved", "proxyUrl": "http://saved.example:8080"}])), + true, + ) + .await; + for detect_location in [false, true] { + let (status, probed) = request( + &fixture, + "/api/admin/proxies/probe", + Some(json!({"proxyUrl": "", "detectLocation": detect_location})), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(probed["data"]["success"], true); + assert_eq!(probed["data"]["exitIpv4"], "203.0.113.3"); + assert_eq!( + probed["data"]["location"]["status"], + if detect_location { + "detected" + } else { + "notRequested" + } + ); + } + let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await; + assert_eq!(listed["data"]["page"]["total"], 1); + assert_eq!( + listed["data"]["items"][0], + created["data"][0]["data"]["record"] + ); +} + +#[tokio::test] +async fn proxy_probe_rejects_missing_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": " "}), StatusCode::UNPROCESSABLE_ENTITY), (json!({"proxyUrl": null}), StatusCode::UNPROCESSABLE_ENTITY), (json!({}), StatusCode::UNPROCESSABLE_ENTITY), ( diff --git a/deploy/README.md b/deploy/README.md index bb2c863f5..7441d2bcb 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -430,6 +430,13 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet` “修改未添加项”只带回明确未添加的条目;连接测试失败的代理已经保存,可在列表中重新测试。 请求中断或结果无法确认时暂停后续提交,添加结果待确认的条目可能已保存,应先刷新列表核对,不能直接重复添加 +创建或编辑账号时,在“出站隧道”选择直连或已保存的代理,点击右侧的“测试出口”图标。 +耗时显示在对应选项右侧,悬浮可查看 IPv4 / IPv6,测试失败通过通知提示。 +禁用代理的账号使用服务器直连出口。出口测试不保存账号设置,也不验证账号权限或额度;账号可用性使用账号连接测试 + +在代理列表点击关联账号数量,再点击“分配账号”,搜索并跨页勾选账号后点击右上角的“分配”,一次最多 1000 个。 +已使用此代理的账号不可重复选择;所选账号原有代理会被替换,凭据、分组与调度设置保留。禁用代理须先启用 + ### 插件命令行 使用与网关相同的工作目录和 `deploy/config.yaml`,读取制品已接受且当前已启用的插件实例: diff --git a/docs/api.md b/docs/api.md index e04391e2c..61cc1174d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1257,10 +1257,13 @@ Search 的选号限制不约束插件在选号后主动改写正文。 | `POST` | `/api/admin/proxies/accounts/remove` | `{ proxyId, accountId }` | `{ configRevision }` | | `POST` | `/api/admin/proxies/create` | `[{ name, proxyUrl, location?, autoLocation?, maxWebsocketConnections? }]` | 逐项结果数组,成功项的 `data` 为 `{ record, configRevision }` | | `POST` | `/api/admin/proxies/update` | `{ id, revision, name, enabled?, proxyUrl?, location?, autoLocation?, maxWebsocketConnections? }` | `{ record, configRevision }` | -| `POST` | `/api/admin/proxies/probe` | `{ proxyUrl, detectLocation? }` | 测试未保存的地址,返回连通性及可选位置结果,不创建代理 | +| `POST` | `/api/admin/proxies/probe` | `{ proxyUrl, detectLocation? }` | 测试未保存的地址或服务器直连出口,返回连通性及可选位置结果,不创建代理 | | `POST` | `/api/admin/proxies/test` | `[{ id, revision, detectLocation? }]` | 逐项结果数组,成功项的 `data` 为最新代理记录 | | `POST` | `/api/admin/proxies/delete` | `{ id, revision }` | `{ configRevision }` | +`probe` 的 `proxyUrl` 必填,空字符串表示服务器直连;非空值须为合法代理 URL,`null`、省略或非法地址均拒绝。 +探测不创建代理或修改账号绑定 + 添加与测试统一接收 1–100 项数组,单条操作传一个元素,响应 `data` 按输入顺序返回等长数组。 每项为 `{ status: "succeeded", data }`、`{ status: "failed", httpStatus, code, message, data: null }` 或 `{ status: "skipped" }`。整个请求的 JSON、数组形状、数量或重复测试 ID 校验失败时返回 4xx,不执行任何项; diff --git a/frontend/src/api/modules/proxies.ts b/frontend/src/api/modules/proxies.ts index 63442ad01..05560657f 100644 --- a/frontend/src/api/modules/proxies.ts +++ b/frontend/src/api/modules/proxies.ts @@ -187,11 +187,12 @@ export function testProxy(data: ProxyTestParam[], options: RequestOptions = {}) }) } -export function probeProxy(data: ProxyProbeParam) { +export function probeProxy(data: ProxyProbeParam, options: RequestOptions = {}) { return request({ url: '/api/admin/proxies/probe', method: 'POST', data, timeout: 25000, + ...options, }) } diff --git a/frontend/src/i18n/locales/en/accounts.json b/frontend/src/i18n/locales/en/accounts.json index 47b1d7a8d..6c163e04b 100644 --- a/frontend/src/i18n/locales/en/accounts.json +++ b/frontend/src/i18n/locales/en/accounts.json @@ -232,7 +232,12 @@ "disabledSuffix": "(disabled)", "testFailed": "(test failed)", "notTested": "(not tested)", - "outboundTunnel": "Outbound tunnel" + "outboundTunnel": "Outbound tunnel", + "testExit": "Test egress", + "selectToTest": "Select a direct connection or managed proxy to test egress", + "disabledUsesDirect": "The proxy is disabled, so the test uses the server’s direct connection", + "connected": "Egress connected · {latencyMs} ms", + "testIncomplete": "No test result received, run the test again" }, "scheduling": { "preserve": "Keep unchanged", diff --git a/frontend/src/i18n/locales/en/proxies.json b/frontend/src/i18n/locales/en/proxies.json index f71c47da8..3ac7d3046 100644 --- a/frontend/src/i18n/locales/en/proxies.json +++ b/frontend/src/i18n/locales/en/proxies.json @@ -161,6 +161,19 @@ "disableDescription": "New requests from linked accounts will use direct connections, proxy settings and bindings are kept and resume when reenabled", "delete": "Delete “{name}”?", "deleteDescription": "Linked accounts will be unlinked and new requests will use direct connections" + }, + "assignment": { + "title": "Assign accounts", + "currentProxy": "Current proxy", + "noAccounts": "No accounts available", + "searchAccounts": "Search accounts to assign", + "selectPage": "Select eligible accounts on this page", + "selectAccount": "Select account {name}", + "alreadyAssigned": "Already using this proxy", + "selectionLimit": "Assign up to {count} accounts at a time", + "enableFirst": "Enable this proxy before assigning accounts", + "assignAction": "Assign ({count})", + "assigned": "Assigned {count} account to “{name}” | Assigned {count} accounts to “{name}”" } } } diff --git a/frontend/src/i18n/locales/zh-Hans/accounts.json b/frontend/src/i18n/locales/zh-Hans/accounts.json index 70429c016..bc9f9560a 100644 --- a/frontend/src/i18n/locales/zh-Hans/accounts.json +++ b/frontend/src/i18n/locales/zh-Hans/accounts.json @@ -232,7 +232,12 @@ "disabledSuffix": "(已禁用)", "testFailed": "(测试失败)", "notTested": "(未测试)", - "outboundTunnel": "出站隧道" + "outboundTunnel": "出站隧道", + "testExit": "测试出口", + "selectToTest": "选择直连或受管理的代理后可测试出口", + "disabledUsesDirect": "代理已禁用,将测试服务器直连出口", + "connected": "出口连通 · {latencyMs} ms", + "testIncomplete": "未取得测试结果,请重新测试" }, "scheduling": { "scheduling": "调度", diff --git a/frontend/src/i18n/locales/zh-Hans/proxies.json b/frontend/src/i18n/locales/zh-Hans/proxies.json index a8eeb4c22..a096cad24 100644 --- a/frontend/src/i18n/locales/zh-Hans/proxies.json +++ b/frontend/src/i18n/locales/zh-Hans/proxies.json @@ -161,6 +161,19 @@ "disableDescription": "关联账号的新请求将改为直连,代理配置和绑定会保留,重新启用后恢复使用", "delete": "确定删除“{name}”吗?", "deleteDescription": "将自动解除关联账号,关联账号的新请求将改为直连" + }, + "assignment": { + "title": "分配账号", + "currentProxy": "当前代理", + "noAccounts": "暂无账号", + "searchAccounts": "搜索待分配账号", + "selectPage": "选择当前页可分配账号", + "selectAccount": "选择账号 {name}", + "alreadyAssigned": "已使用此代理", + "selectionLimit": "一次最多分配 {count} 个账号", + "enableFirst": "启用此代理后可分配账号", + "assignAction": "分配 ({count})", + "assigned": "已将 {count} 个账号分配至“{name}”" } } } diff --git a/frontend/src/i18n/locales/zh-Hant/accounts.json b/frontend/src/i18n/locales/zh-Hant/accounts.json index 45d6fe173..0dbfbd295 100644 --- a/frontend/src/i18n/locales/zh-Hant/accounts.json +++ b/frontend/src/i18n/locales/zh-Hant/accounts.json @@ -232,7 +232,12 @@ "disabledSuffix": "(已禁用)", "testFailed": "(測試失敗)", "notTested": "(未測試)", - "outboundTunnel": "出站隧道" + "outboundTunnel": "出站隧道", + "testExit": "測試出口", + "selectToTest": "選擇直連或受管理的代理後可測試出口", + "disabledUsesDirect": "代理已禁用,將測試服務器直連出口", + "connected": "出口連通 · {latencyMs} ms", + "testIncomplete": "未取得測試結果,請重新測試" }, "scheduling": { "preserve": "保留", diff --git a/frontend/src/i18n/locales/zh-Hant/proxies.json b/frontend/src/i18n/locales/zh-Hant/proxies.json index 6a1539325..d663fcf3f 100644 --- a/frontend/src/i18n/locales/zh-Hant/proxies.json +++ b/frontend/src/i18n/locales/zh-Hant/proxies.json @@ -161,6 +161,19 @@ "disableDescription": "關聯賬號的新請求將改爲直連,代理配置和綁定會保留,重新啓用後恢復使用", "delete": "確定刪除“{name}”嗎?", "deleteDescription": "將自動解除關聯賬號,關聯賬號的新請求將改爲直連" + }, + "assignment": { + "title": "分配賬號", + "currentProxy": "當前代理", + "noAccounts": "暫無賬號", + "searchAccounts": "搜索待分配賬號", + "selectPage": "選擇當前頁可分配賬號", + "selectAccount": "選擇賬號 {name}", + "alreadyAssigned": "已使用此代理", + "selectionLimit": "一次最多分配 {count} 個賬號", + "enableFirst": "啟用此代理後可分配賬號", + "assignAction": "分配 ({count})", + "assigned": "已將 {count} 個賬號分配至“{name}”" } } } diff --git a/frontend/src/views/accounts/components/AccountBatchEditModal.vue b/frontend/src/views/accounts/components/AccountBatchEditModal.vue index 1ac85ad0b..2a16d56b6 100644 --- a/frontend/src/views/accounts/components/AccountBatchEditModal.vue +++ b/frontend/src/views/accounts/components/AccountBatchEditModal.vue @@ -95,7 +95,7 @@ const schedulingMode = computed({ v-model:proxy-id="proxyId" preserve-model-access - :account-id="catalogAccountId" + :model-account-id="catalogAccountId" :groups="groups" :groups-loading="groupsLoading" :disabled="saving" diff --git a/frontend/src/views/accounts/components/AccountEditModal.vue b/frontend/src/views/accounts/components/AccountEditModal.vue index ea3009cbb..44bad0432 100644 --- a/frontend/src/views/accounts/components/AccountEditModal.vue +++ b/frontend/src/views/accounts/components/AccountEditModal.vue @@ -105,10 +105,6 @@ async function requestSave() { - - - - + > + +