From f5c5a6e5b732e28d93ec47438afc9af5f72b3ce4 Mon Sep 17 00:00:00 2001 From: Husan API <2282059276@qq.com> Date: Wed, 4 Nov 2026 22:42:16 +0800 Subject: [PATCH] feat(proxies): test account egress and assign accounts in bulk (#607) * feat(proxies): test account egress and assign accounts in bulk Co-authored-by: Codex * fix(accounts): refine proxy controls and quota display Keep egress results with their proxy options and refresh stale proxy revisions before retrying. Compact account assignment and settings controls, and hide missing quota refresh timestamps. Co-authored-by: Codex * fix(proxies): reset account selection without closing dialog Co-authored-by: Codex * fix(proxies): clarify assignment reset and unavailable accounts Use a red reset button and disabled checkboxes for accounts already using the target proxy. Co-authored-by: Codex * feat(proxies): show account details in assignment dialog Add platform, subscription, and group columns, remove inline provider icons, and use the existing subtle danger style for reset. Co-authored-by: Codex --------- Co-authored-by: Codex Co-authored-by: Zyy --- .../crates/gateway-api/src/admin/proxies.rs | 6 +- .../crates/gateway-api/tests/admin/proxies.rs | 54 +++++- deploy/README.md | 7 + docs/api.md | 5 +- frontend/src/api/modules/proxies.ts | 3 +- frontend/src/i18n/locales/en/accounts.json | 7 +- frontend/src/i18n/locales/en/proxies.json | 13 ++ .../src/i18n/locales/zh-Hans/accounts.json | 7 +- .../src/i18n/locales/zh-Hans/proxies.json | 13 ++ .../src/i18n/locales/zh-Hant/accounts.json | 7 +- .../src/i18n/locales/zh-Hant/proxies.json | 13 ++ .../components/AccountBatchEditModal.vue | 2 +- .../accounts/components/AccountEditModal.vue | 12 +- .../accounts/components/AccountProxyField.vue | 68 +++++++- .../components/AccountProxyTestBadge.vue | 33 ++++ .../components/AccountSettingsFields.vue | 8 +- .../components/account-quota-panel/index.vue | 18 +- .../composables/useAccountProxyTest.ts | 81 +++++++++ .../proxies/components/ProxyAccountsModal.vue | 40 +++-- .../components/ProxyAssignAccountsModal.vue | 154 ++++++++++++++++++ .../composables/useProxyAccountAssignment.ts | 98 +++++++++++ .../proxies/composables/useProxyAccounts.ts | 6 + frontend/src/views/proxies/index.vue | 2 +- 23 files changed, 611 insertions(+), 46 deletions(-) create mode 100644 frontend/src/views/accounts/components/AccountProxyTestBadge.vue create mode 100644 frontend/src/views/accounts/composables/useAccountProxyTest.ts create mode 100644 frontend/src/views/proxies/components/ProxyAssignAccountsModal.vue create mode 100644 frontend/src/views/proxies/composables/useProxyAccountAssignment.ts 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() { - - - - + > + +