From 029f3124bf07e0e71ccc04131e5149a5e2bb1c10 Mon Sep 17 00:00:00 2001 From: Zyy Date: Fri, 11 Sep 2026 14:43:31 +0800 Subject: [PATCH] feat(proxies): paginate linked accounts and support removal Load linked accounts on demand with shared plan metadata and guarded removal to direct connections. Preserve credentials, scheduling settings, and groups when clearing a proxy binding. Align proxy forms and tables with shared components, integrate endpoint details into outbound selectors, and consolidate probe results into the latency column. Validation: 11 proxy regressions, strict workspace Clippy, rustfmt, frontend lint/type checks/build, and local browser checks passed. Co-authored-by: Codex --- backend/crates/gateway-admin/src/lib.rs | 1 + .../crates/gateway-admin/src/model/proxies.rs | 28 +- .../gateway-admin/src/ports/provider.rs | 13 + .../crates/gateway-admin/src/ports/proxy.rs | 17 +- .../gateway-admin/src/use_case/accounts.rs | 12 +- .../gateway-admin/src/use_case/proxies.rs | 103 +++++- .../gateway-admin/tests/use_case/proxies.rs | 92 +++++- .../crates/gateway-api/src/admin/proxies.rs | 135 +++++++- .../crates/gateway-api/tests/admin/proxies.rs | 172 +++++++++- .../gateway-store/src/postgres/proxies.rs | 180 ++++++++++- .../tests/postgres/provider_accounts/mod.rs | 12 +- .../gateway-store/tests/postgres/proxies.rs | 298 +++++++++++++++++- docs/api.md | 12 +- docs/architecture.md | 4 + frontend/src/api/modules/proxies.ts | 67 +++- frontend/src/components/base/BaseSelect.vue | 21 +- .../accounts/components/AccountProxyField.vue | 37 +-- .../proxies/components/ProxyAccountsModal.vue | 131 ++++++++ .../proxies/components/ProxyFormModal.vue | 78 +++++ .../proxies/composables/useProxyAccounts.ts | 89 ++++++ frontend/src/views/proxies/index.vue | 222 ++++++------- 21 files changed, 1516 insertions(+), 208 deletions(-) create mode 100644 frontend/src/views/proxies/components/ProxyAccountsModal.vue create mode 100644 frontend/src/views/proxies/components/ProxyFormModal.vue create mode 100644 frontend/src/views/proxies/composables/useProxyAccounts.ts diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index b5d6ea3df..20750fd98 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -301,6 +301,7 @@ pub async fn initialize( store.proxies(), proxy_probe, snapshot.clone(), + registry.clone(), )), auth, accounts, diff --git a/backend/crates/gateway-admin/src/model/proxies.rs b/backend/crates/gateway-admin/src/model/proxies.rs index 56fd4e006..591498e07 100644 --- a/backend/crates/gateway-admin/src/model/proxies.rs +++ b/backend/crates/gateway-admin/src/model/proxies.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use gateway_core::account::OutboundProxy; -use super::{PageSize, Revision}; +use super::{PageSize, Revision, account_groups::AccountGroupRef}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum AccountProxySelection { @@ -29,6 +29,30 @@ pub struct ProxyListQuery { pub struct ProxyAccountRef { pub id: String, pub name: String, + pub email: Option, + pub provider_kind: String, + pub authentication_kind: String, + pub plan_type: Option, + pub plan_type_display: Option, + pub groups: Vec, + pub enabled: bool, +} + +/// 按代理查询关联账号,分页与搜索均在存储层执行。 +#[derive(Debug, Clone)] +pub struct ProxyAccountListQuery { + pub proxy_id: String, + pub page: u32, + pub page_size: PageSize, + pub search: String, +} + +#[derive(Debug, Clone)] +pub struct ProxyAccountPage { + pub items: Vec, + pub total: u64, + pub page: u32, + pub page_size: u16, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -45,7 +69,7 @@ pub struct ProxyRecord { pub name: String, pub proxy: OutboundProxy, pub revision: Revision, - pub accounts: Vec, + pub account_count: u64, pub last_test_at: Option>, pub last_test: Option, pub created_at: DateTime, diff --git a/backend/crates/gateway-admin/src/ports/provider.rs b/backend/crates/gateway-admin/src/ports/provider.rs index 20e54562b..53172d5d2 100644 --- a/backend/crates/gateway-admin/src/ports/provider.rs +++ b/backend/crates/gateway-admin/src/ports/provider.rs @@ -252,6 +252,19 @@ impl ProviderAdminRegistry { .ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)) } + /// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照。 + pub(crate) fn resolve_account_plan( + &self, + provider_kind: &str, + plan_type: &mut Option, + quota: Option<&ProviderQuota>, + ) -> Option { + if let Some(quota) = quota { + quota.fill_missing_plan_type(plan_type); + } + self.plan_type_display(provider_kind, plan_type.as_deref()) + } + /// 账号页和 Dashboard 共用的大驼峰套餐展示名称,不修改原始套餐值。 pub(crate) fn plan_type_display( &self, diff --git a/backend/crates/gateway-admin/src/ports/proxy.rs b/backend/crates/gateway-admin/src/ports/proxy.rs index d58ad8429..c796ec7aa 100644 --- a/backend/crates/gateway-admin/src/ports/proxy.rs +++ b/backend/crates/gateway-admin/src/ports/proxy.rs @@ -1,12 +1,12 @@ use async_trait::async_trait; -use gateway_core::account::OutboundProxy; +use gateway_core::account::{OutboundProxy, ProviderAccountId}; use super::store::AdminStoreResult; use crate::model::{ MutationContext, Revision, proxies::{ - ImportProxyBinding, NewProxy, ProxyListQuery, ProxyMutation, ProxyPage, ProxyRecord, - ProxyTestResult, UpdateProxy, + ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery, + ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy, }, }; @@ -15,7 +15,18 @@ pub trait ProxyStore: Send + Sync { /// 在凭据交换到提交期间保护选定代理的连接配置和测试结果。 async fn reserve_import(&self, id: &str) -> AdminStoreResult; async fn list(&self, query: ProxyListQuery) -> AdminStoreResult; + async fn list_accounts( + &self, + query: ProxyAccountListQuery, + ) -> AdminStoreResult; async fn get(&self, id: &str) -> AdminStoreResult; + /// 仅在账号仍绑定指定代理时解除关联,并清除账号保存的连接地址。 + async fn remove_account( + &self, + proxy_id: &str, + account_id: &ProviderAccountId, + context: &MutationContext, + ) -> AdminStoreResult; async fn create( &self, command: NewProxy, diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs index 50695ee6c..d07affc13 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts.rs @@ -280,11 +280,11 @@ impl DefaultAccountsService { ) .await?; let usage = quota.representative_window_usage().cloned(); - quota.fill_missing_plan_type(&mut stored.account.plan_type); Ok(AccountDirectoryItem { - plan_type_display: self.providers.plan_type_display( + plan_type_display: self.providers.resolve_account_plan( stored.account.provider_kind.as_str(), - stored.account.plan_type.as_deref(), + &mut stored.account.plan_type, + Some("a), ), projection: stored.projection, usage, @@ -373,11 +373,11 @@ impl AccountsService for DefaultAccountsService { .zip(quotas) .map(|(mut item, quota)| { let usage = quota.representative_window_usage().cloned(); - quota.fill_missing_plan_type(&mut item.account.plan_type); AccountDirectoryItem { - plan_type_display: self.providers.plan_type_display( + plan_type_display: self.providers.resolve_account_plan( item.account.provider_kind.as_str(), - item.account.plan_type.as_deref(), + &mut item.account.plan_type, + Some("a), ), usage, account: item.account, diff --git a/backend/crates/gateway-admin/src/use_case/proxies.rs b/backend/crates/gateway-admin/src/use_case/proxies.rs index a119c05aa..367472928 100644 --- a/backend/crates/gateway-admin/src/use_case/proxies.rs +++ b/backend/crates/gateway-admin/src/use_case/proxies.rs @@ -1,18 +1,37 @@ use std::sync::Arc; use async_trait::async_trait; -use gateway_core::runtime::SnapshotControl; +use futures::{StreamExt, stream}; +use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::SnapshotControl}; use tokio::sync::Semaphore; use super::{map_store_error, publish_committed}; use crate::{ - model::{AdminError, AdminErrorKind, MutationContext, Revision, proxies::*}, - ports::proxy::{ProxyProbe, ProxyStore}, + model::{ + AdminError, AdminErrorKind, MutationContext, Revision, + provider_credentials::{ProviderQuotaRequest, explicit_plan_type}, + proxies::*, + }, + ports::{ + provider::ProviderAdminRegistry, + proxy::{ProxyProbe, ProxyStore}, + store::AdminStoreErrorKind, + }, }; #[async_trait] pub trait ProxiesService: Send + Sync { async fn list(&self, query: ProxyListQuery) -> Result; + async fn list_accounts( + &self, + query: ProxyAccountListQuery, + ) -> Result; + async fn remove_account( + &self, + proxy_id: &str, + account_id: &str, + context: &MutationContext, + ) -> Result; async fn create( &self, command: NewProxy, @@ -41,6 +60,7 @@ pub(crate) struct DefaultProxiesService { store: Arc, probe: Arc, snapshot: Arc, + providers: ProviderAdminRegistry, test_slots: Semaphore, } @@ -49,11 +69,13 @@ impl DefaultProxiesService { store: Arc, probe: Arc, snapshot: Arc, + providers: ProviderAdminRegistry, ) -> Self { Self { store, probe, snapshot, + providers, test_slots: Semaphore::new(4), } } @@ -61,6 +83,81 @@ impl DefaultProxiesService { #[async_trait] impl ProxiesService for DefaultProxiesService { + async fn remove_account( + &self, + proxy_id: &str, + account_id: &str, + context: &MutationContext, + ) -> Result { + if proxy_id.is_empty() || proxy_id.len() > 128 || proxy_id.chars().any(char::is_control) { + return Err(AdminError::invalid("代理 ID 不合法")); + } + let account_id = ProviderAccountId::new(account_id.to_owned()) + .map_err(|_| AdminError::invalid("账号 ID 不合法"))?; + let revision = self + .store + .remove_account(proxy_id, &account_id, context) + .await + .map_err(|error| { + if error.kind() == AdminStoreErrorKind::Conflict { + AdminError::conflict("账号的代理绑定已变化,请刷新后重试") + } else { + map_store_error(error, "proxy account") + } + })?; + publish_committed(self.snapshot.as_ref(), revision).await?; + Ok(revision) + } + + async fn list_accounts( + &self, + query: ProxyAccountListQuery, + ) -> Result { + if query.proxy_id.is_empty() + || query.proxy_id.len() > 128 + || query.proxy_id.chars().any(char::is_control) + || query.page == 0 + || query.search.len() > 256 + || query.search.chars().any(char::is_control) + { + return Err(AdminError::invalid("关联账号查询参数不合法")); + } + let mut page = self + .store + .list_accounts(query) + .await + .map_err(|error| map_store_error(error, "proxy"))?; + page.items = stream::iter(page.items) + .map(|mut account| async { + // 与账号目录一致,缺失套餐时读取已有额度快照;限制并发且不触发上游刷新。 + let mut cached_quota = None; + if explicit_plan_type(account.plan_type.as_deref()).is_none() + && let Ok(kind) = ProviderKind::new(account.provider_kind.clone()) + && let Ok(provider) = self.providers.require(&kind) + && let Ok(account_id) = ProviderAccountId::new(account.id.clone()) + && let Ok(quota) = provider + .quota(ProviderQuotaRequest { + account_id, + refresh: false, + rolling_usage: None, + }) + .await + { + cached_quota = Some(quota); + } + account.plan_type_display = self.providers.resolve_account_plan( + &account.provider_kind, + &mut account.plan_type, + cached_quota.as_ref(), + ); + account + }) + .buffered(8) + .collect() + .await; + Ok(page) + } + async fn list(&self, query: ProxyListQuery) -> Result { if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control) { diff --git a/backend/crates/gateway-admin/tests/use_case/proxies.rs b/backend/crates/gateway-admin/tests/use_case/proxies.rs index 38bf02b2a..42b4c0708 100644 --- a/backend/crates/gateway-admin/tests/use_case/proxies.rs +++ b/backend/crates/gateway-admin/tests/use_case/proxies.rs @@ -6,11 +6,12 @@ use gateway_admin::{ store::AdminStoreResult, }, }; -use gateway_core::account::OutboundProxy; +use gateway_core::account::{OutboundProxy, ProviderAccountId}; #[derive(Default)] pub(super) struct TestProxies { pub events: Option, + pub accounts: Option>, } struct ImportGuard(super::accounts::EventLog); @@ -25,6 +26,15 @@ impl Drop for ImportGuard { #[async_trait] impl ProxyStore for TestProxies { + async fn remove_account( + &self, + _: &str, + _: &ProviderAccountId, + _: &MutationContext, + ) -> AdminStoreResult { + Err(super::unavailable("proxy")) + } + async fn reserve_import( &self, id: &str, @@ -45,6 +55,21 @@ impl ProxyStore for TestProxies { async fn list(&self, _: ProxyListQuery) -> AdminStoreResult { Err(super::unavailable("proxy")) } + async fn list_accounts( + &self, + query: ProxyAccountListQuery, + ) -> AdminStoreResult { + let items = self + .accounts + .clone() + .ok_or_else(|| super::unavailable("proxy"))?; + Ok(ProxyAccountPage { + total: items.len() as u64, + items, + page: query.page, + page_size: query.page_size.get(), + }) + } async fn get(&self, _: &str) -> AdminStoreResult { Err(super::unavailable("proxy")) } @@ -80,6 +105,70 @@ impl ProxyProbe for TestProxies { } } +#[tokio::test] +async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() { + use super::accounts::{FakeProviderAdmin, events}; + use gateway_admin::model::{PageSize, provider_credentials::ProviderQuota}; + use std::sync::Arc; + + for (stored, cached, expected) in [ + (None, Some("free"), Some("free")), + (Some("unknown"), Some("free"), Some("free")), + (Some("plus"), Some("free"), Some("plus")), + (None, None, None), + ] { + let provider = FakeProviderAdmin::new("openai", events()); + provider.set_quota(ProviderQuota { + plan_type: cached.map(str::to_owned), + observed_at: None, + refresh_token_expires_at: None, + windows: vec![], + limit_reached: false, + provider_data: None, + }); + let services = super::AdminHarness::new() + .provider(provider.clone()) + .proxies(Arc::new(TestProxies { + accounts: Some(vec![ProxyAccountRef { + id: "acct_plan".to_owned(), + name: "套餐测试".to_owned(), + email: None, + provider_kind: "openai".to_owned(), + authentication_kind: "oauth".to_owned(), + plan_type: stored.map(str::to_owned), + plan_type_display: None, + groups: vec![], + enabled: true, + }]), + ..Default::default() + })) + .build() + .await; + let result = services + .proxies() + .list_accounts(ProxyAccountListQuery { + proxy_id: "proxy_plan".to_owned(), + page: 1, + page_size: PageSize::new(20).unwrap(), + search: String::new(), + }) + .await + .unwrap(); + assert_eq!(result.items[0].plan_type.as_deref(), expected); + assert_eq!( + result.items[0].plan_type_display.as_deref(), + match expected { + Some("free") => Some("OpenaiDisplayFree"), + Some("plus") => Some("OpenaiDisplayPlus"), + _ => None, + } + ); + let requests = provider.quota_requests(); + assert_eq!(requests.len(), usize::from(stored != Some("plus"))); + assert!(requests.iter().all(|request| !request.refresh)); + } +} + #[tokio::test] async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_errors() { use super::accounts::{ @@ -105,6 +194,7 @@ async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_err .accounts(store) .proxies(Arc::new(TestProxies { events: Some(events.clone()), + ..Default::default() })) .build() .await; diff --git a/backend/crates/gateway-api/src/admin/proxies.rs b/backend/crates/gateway-api/src/admin/proxies.rs index 65e00a2a8..5516a030c 100644 --- a/backend/crates/gateway-api/src/admin/proxies.rs +++ b/backend/crates/gateway-api/src/admin/proxies.rs @@ -7,13 +7,16 @@ use axum::{ }; use gateway_admin::model::{ PageSize, Revision, - proxies::{NewProxy, ProxyListQuery, ProxyMutation, ProxyRecord, UpdateProxy}, + proxies::{ + NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord, UpdateProxy, + }, }; use serde::{Deserialize, Serialize}; use super::{ AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState, - PageMeta, accounts::AccountProxyUpdate, + PageMeta, + accounts::{AccountGroupRefView, AccountProxyUpdate}, }; #[derive(Debug, Deserialize)] @@ -24,6 +27,22 @@ struct ListQuery { search: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AccountsQuery { + proxy_id: String, + page: Option, + page_size: Option, + search: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RemoveAccountRequest { + proxy_id: String, + account_id: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct CreateRequest { @@ -57,9 +76,17 @@ struct ProxyTestView { } #[derive(Serialize)] +#[serde(rename_all = "camelCase")] struct ProxyAccountView { id: String, name: String, + email: Option, + provider: String, + authentication_kind: String, + plan_type: Option, + plan_type_display: Option, + groups: Vec, + enabled: bool, } #[derive(Serialize)] @@ -70,7 +97,7 @@ struct ProxyView { endpoint: String, has_authentication: bool, revision: u64, - accounts: Vec, + account_count: u64, last_test_at: Option, last_test: Option, created_at: String, @@ -86,14 +113,7 @@ impl From for ProxyView { has_authentication: record.proxy.expose_url() != endpoint, endpoint, revision: record.revision.get(), - accounts: record - .accounts - .into_iter() - .map(|account| ProxyAccountView { - id: account.id, - name: account.name, - }) - .collect(), + 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, @@ -113,6 +133,12 @@ struct ProxyPageView { page: PageMeta, } +#[derive(Serialize)] +struct ProxyAccountPageView { + items: Vec, + page: PageMeta, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct MutationView { @@ -135,6 +161,11 @@ where { Router::new() .route("/api/admin/proxies", get(list::)) + .route("/api/admin/proxies/accounts", get(list_accounts::)) + .route( + "/api/admin/proxies/accounts/remove", + post(remove_account::), + ) .route("/api/admin/proxies/create", post(create::)) .route("/api/admin/proxies/update", post(update::)) .route("/api/admin/proxies/delete", post(delete::)) @@ -186,6 +217,64 @@ where )) } +async fn list_accounts( + _: AdminAuth, + State(state): State, + AdminQuery(query): AdminQuery, +) -> Result +where + S: AdminSessionState + Send + Sync, +{ + let result = state + .admin_services() + .proxies() + .list_accounts(ProxyAccountListQuery { + proxy_id: query.proxy_id, + page: query.page.unwrap_or(1), + page_size: PageSize::new(query.page_size.unwrap_or(20)) + .map_err(|_| AdminError::bad_request("分页大小不合法"))?, + search: query.search.unwrap_or_default().trim().to_owned(), + }) + .await + .map_err(map_error)?; + let total_pages = result.total.div_ceil(u64::from(result.page_size)); + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(ProxyAccountPageView { + items: result + .items + .into_iter() + .map(|account| ProxyAccountView { + id: account.id, + name: account.name, + email: account.email, + provider: account.provider_kind, + authentication_kind: account.authentication_kind, + plan_type: account.plan_type, + plan_type_display: account.plan_type_display, + groups: account + .groups + .into_iter() + .map(|group| AccountGroupRefView { + id: group.id.to_string(), + name: group.name, + color: group.color.as_str().to_owned(), + enabled: group.enabled, + }) + .collect(), + enabled: account.enabled, + }) + .collect(), + page: PageMeta::new( + result.page, + u32::from(result.page_size), + result.total, + u32::try_from(total_pages).unwrap_or(u32::MAX), + ), + }), + )) +} + async fn create( auth: AdminAuth, State(state): State, @@ -216,6 +305,30 @@ where )) } +async fn remove_account( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result +where + S: AdminSessionState + Send + Sync, +{ + let revision = state + .admin_services() + .proxies() + .remove_account( + &request.proxy_id, + &request.account_id, + &auth.context().mutation_context(), + ) + .await + .map_err(map_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({"configRevision": revision.get()})), + )) +} + async fn update( 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 9fa3cfce5..dda99ec53 100644 --- a/backend/crates/gateway-api/tests/admin/proxies.rs +++ b/backend/crates/gateway-api/tests/admin/proxies.rs @@ -13,7 +13,7 @@ use gateway_admin::{ store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, }, }; -use gateway_core::account::OutboundProxy; +use gateway_core::account::{OutboundProxy, ProviderAccountId}; use serde_json::{Value, json}; use tower::ServiceExt as _; @@ -28,6 +28,22 @@ fn missing() -> AdminStoreError { #[async_trait] impl ProxyStore for MemoryProxies { + async fn remove_account( + &self, + proxy_id: &str, + account_id: &ProviderAccountId, + _: &MutationContext, + ) -> AdminStoreResult { + if proxy_id != "proxy_test" || account_id.as_str() != "acct_linked" { + return Err(AdminStoreError::new( + AdminStoreErrorKind::Conflict, + "proxy account", + "changed binding", + )); + } + Ok(Revision::new(4).unwrap()) + } + async fn reserve_import( &self, _: &str, @@ -46,6 +62,36 @@ impl ProxyStore for MemoryProxies { async fn get(&self, _: &str) -> AdminStoreResult { self.0.lock().unwrap().clone().ok_or_else(missing) } + async fn list_accounts( + &self, + query: ProxyAccountListQuery, + ) -> AdminStoreResult { + if !self + .0 + .lock() + .unwrap() + .as_ref() + .is_some_and(|record| record.id == query.proxy_id) + { + return Err(missing()); + } + Ok(ProxyAccountPage { + items: vec![ProxyAccountRef { + id: "acct_linked".to_owned(), + name: "工作账号".to_owned(), + email: Some("work@example.invalid".to_owned()), + provider_kind: "openai".to_owned(), + authentication_kind: "oauth".to_owned(), + plan_type: Some("plus".to_owned()), + plan_type_display: None, + groups: vec![], + enabled: true, + }], + total: 21, + page: query.page, + page_size: query.page_size.get(), + }) + } async fn create( &self, command: NewProxy, @@ -56,7 +102,7 @@ impl ProxyStore for MemoryProxies { name: command.name, proxy: command.proxy, revision: Revision::new(1).unwrap(), - accounts: vec![], + account_count: 0, last_test: None, last_test_at: None, created_at: Utc::now(), @@ -180,6 +226,29 @@ async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_creden "http://proxy.example:8080/" ); assert!(created["data"]["record"].get("proxyUrl").is_none()); + assert_eq!(created["data"]["record"]["accountCount"], 0); + assert!(created["data"]["record"].get("accounts").is_none()); + + let (status, linked) = request( + &fixture, + "/api/admin/proxies/accounts?proxyId=proxy_test&page=2&pageSize=20&search=work", + None, + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + linked["data"]["page"], + json!({"page":2,"pageSize":20,"total":21,"totalPages":2}) + ); + assert_eq!( + linked["data"]["items"], + json!([{ + "id":"acct_linked", "name":"工作账号", "email":"work@example.invalid", + "provider":"openai", "enabled":true, "authenticationKind":"oauth", + "planType":"plus", "planTypeDisplay":"Plus", "groups":[] + }]) + ); let (status, tested) = request( &fixture, @@ -243,7 +312,50 @@ async fn proxy_routes_require_auth_and_reject_invalid_input() { request(&fixture, "/api/admin/proxies", None, false).await.0, StatusCode::UNAUTHORIZED ); - for action in ["create", "update", "delete", "test"] { + assert_eq!( + request( + &fixture, + "/api/admin/proxies/accounts?proxyId=proxy_test", + None, + false + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + for query in [ + "", + "proxyId=", + "proxyId=proxy_test&page=0", + "proxyId=proxy_test&pageSize=0", + "proxyId=proxy_test&pageSize=201", + "proxyId=proxy_test&search=%00", + "proxyId=proxy_test&unknown=1", + ] { + assert_eq!( + request( + &fixture, + &format!("/api/admin/proxies/accounts?{query}"), + None, + true + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + } + assert_eq!( + request( + &fixture, + "/api/admin/proxies/accounts?proxyId=missing", + None, + true + ) + .await + .0, + StatusCode::NOT_FOUND + ); + for action in ["create", "update", "delete", "test", "accounts/remove"] { assert_eq!( request( &fixture, @@ -284,3 +396,57 @@ async fn proxy_routes_require_auth_and_reject_invalid_input() { .await; assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); } + +#[tokio::test] +async fn proxy_account_removal_validates_binding_and_input() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for (body, expected) in [ + ( + json!({"proxyId":"", "accountId":"acct_linked"}), + StatusCode::BAD_REQUEST, + ), + ( + json!({"proxyId":"proxy_test", "accountId":""}), + StatusCode::BAD_REQUEST, + ), + ( + json!({"proxyId":"proxy_test", "accountId":"invalid"}), + StatusCode::BAD_REQUEST, + ), + ( + json!({"proxyId":"proxy_test", "accountId":"acct_linked", "enabled":false}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ] { + assert_eq!( + request( + &fixture, + "/api/admin/proxies/accounts/remove", + Some(body), + true + ) + .await + .0, + expected + ); + } + let (status, result) = request( + &fixture, + "/api/admin/proxies/accounts/remove", + Some(json!({"proxyId":"proxy_test", "accountId":"acct_linked"})), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(result["data"], json!({"configRevision":4})); + let (status, result) = request( + &fixture, + "/api/admin/proxies/accounts/remove", + Some(json!({"proxyId":"proxy_previous", "accountId":"acct_linked"})), + true, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(result["message"], "账号的代理绑定已变化,请刷新后重试"); +} diff --git a/backend/crates/gateway-store/src/postgres/proxies.rs b/backend/crates/gateway-store/src/postgres/proxies.rs index c04cb371d..de95320d0 100644 --- a/backend/crates/gateway-store/src/postgres/proxies.rs +++ b/backend/crates/gateway-store/src/postgres/proxies.rs @@ -1,16 +1,23 @@ //! 命名代理持有认证信息,账号保留解析后的 URL 供 Provider 传输使用。 -use std::sync::Arc; +use std::{collections::BTreeMap, sync::Arc}; use async_trait::async_trait; use gateway_admin::{ - model::{MutationContext, Revision as AdminRevision, proxies::*}, + model::{ + MutationContext, Revision as AdminRevision, + account_groups::{AccountGroupColor, AccountGroupRef}, + proxies::*, + }, ports::{ proxy::{ProxyImportGuard, ProxyImportReservation, ProxyStore}, store::{AdminStoreError, AdminStoreResult}, }, }; -use gateway_core::account::OutboundProxy; +use gateway_core::{ + account::{OutboundProxy, ProviderAccountId}, + routing::AccountGroupId, +}; use sqlx::{PgConnection, PgPool, Postgres, QueryBuilder, Row as _, Transaction, postgres::PgRow}; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; @@ -22,8 +29,7 @@ use crate::{ const ENTITY: &str = "outbound proxy"; const SELECT: &str = "select p.*, - array(select a.id from provider_accounts a where a.outbound_proxy_id = p.id order by a.id) as account_ids, - array(select a.name from provider_accounts a where a.outbound_proxy_id = p.id order by a.id) as account_names + (select count(*) from provider_accounts a where a.outbound_proxy_id = p.id) as account_count from outbound_proxies p"; #[derive(Clone)] @@ -91,8 +97,6 @@ fn record(row: PgRow) -> StoreResult { let success: Option = row.try_get("last_test_success").map_err(|_| invalid())?; let ip: Option = row.try_get("last_test_ip").map_err(|_| invalid())?; let latency: Option = row.try_get("last_test_latency_ms").map_err(|_| invalid())?; - let ids: Vec = row.try_get("account_ids").map_err(|_| invalid())?; - let names: Vec = row.try_get("account_names").map_err(|_| invalid())?; Ok(ProxyRecord { id: row.try_get("id").map_err(|_| invalid())?, name: row.try_get("name").map_err(|_| invalid())?, @@ -106,11 +110,11 @@ fn record(row: PgRow) -> StoreResult { .map_err(|_| invalid())?, ) .map_err(|_| invalid())?, - accounts: ids - .into_iter() - .zip(names) - .map(|(id, name)| ProxyAccountRef { id, name }) - .collect(), + account_count: u64::try_from( + row.try_get::("account_count") + .map_err(|_| invalid())?, + ) + .map_err(|_| invalid())?, last_test_at: row.try_get("last_test_at").map_err(|_| invalid())?, last_test: success .map(|success| -> StoreResult<_> { @@ -223,6 +227,158 @@ async fn audit( #[async_trait] impl ProxyStore for PgProxyRepository { + async fn remove_account( + &self, + proxy_id: &str, + account_id: &ProviderAccountId, + context: &MutationContext, + ) -> AdminStoreResult { + let mut transaction = self + .pool + .begin() + .await + .map_err(|_| store_error(unavailable()))?; + let revision = bump_config_revision_in_transaction(&mut transaction) + .await + .map_err(store_error)?; + // 在同一条更新中校验绑定,避免旧弹窗清除账号后来选择的其他代理。 + let updated = sqlx::query( + "update provider_accounts + set outbound_proxy_id = null, outbound_proxy_url = null, + updated_at = greatest(now(), updated_at) + where id = $1 and outbound_proxy_id = $2", + ) + .bind(account_id.as_str()) + .bind(proxy_id) + .execute(&mut *transaction) + .await + .map_err(|_| store_error(unavailable()))?; + if updated.rows_affected() != 1 { + return Err(store_error(conflict(proxy_id))); + } + append_admin_audit_event_in_transaction( + &mut transaction, + mutation_audit( + context, + "update", + "provider_account", + account_id.as_str(), + vec!["outbound_proxy".to_owned()], + ), + revision, + ) + .await + .map_err(store_error)?; + transaction + .commit() + .await + .map_err(|_| store_error(unavailable()))?; + admin_revision(revision) + } + + async fn list_accounts( + &self, + query: ProxyAccountListQuery, + ) -> AdminStoreResult { + if query.page == 0 { + return Err(store_error(invalid())); + } + let mut transaction = self + .pool + .begin() + .await + .map_err(|_| store_error(unavailable()))?; + // 数量和当前页共用只读快照,避免绑定变化使同一次响应的分页事实不一致。 + sqlx::query("set transaction isolation level repeatable read, read only") + .execute(&mut *transaction) + .await + .map_err(|_| store_error(unavailable()))?; + let total: i64 = sqlx::query_scalar( + "select count(a.id) from outbound_proxies p + left join provider_accounts a on a.outbound_proxy_id = p.id + and (strpos(lower(a.name), lower($2)) > 0 + or strpos(lower(coalesce(a.email, '')), lower($2)) > 0) + where p.id = $1 group by p.id", + ) + .bind(&query.proxy_id) + .bind(&query.search) + .fetch_optional(&mut *transaction) + .await + .map_err(|_| store_error(unavailable()))? + .ok_or_else(|| { + store_error(StoreError::NotFound { + entity: ENTITY, + id: query.proxy_id.clone(), + }) + })?; + let mut items = sqlx::query( + "select id, name, email, provider_kind, authentication_kind, plan_type, enabled from provider_accounts + where outbound_proxy_id = $1 and (strpos(lower(name), lower($2)) > 0 + or strpos(lower(coalesce(email, '')), lower($2)) > 0) + order by name, id limit $3 offset $4", + ) + .bind(&query.proxy_id) + .bind(&query.search) + .bind(i64::from(query.page_size.get())) + .bind(i64::from(query.page - 1) * i64::from(query.page_size.get())) + .fetch_all(&mut *transaction) + .await + .map_err(|_| store_error(unavailable()))? + .into_iter() + .map(|row| { + Ok(ProxyAccountRef { + id: row.try_get("id").map_err(|_| invalid())?, + name: row.try_get("name").map_err(|_| invalid())?, + email: row.try_get("email").map_err(|_| invalid())?, + provider_kind: row.try_get("provider_kind").map_err(|_| invalid())?, + authentication_kind: row.try_get("authentication_kind").map_err(|_| invalid())?, + plan_type: row.try_get("plan_type").map_err(|_| invalid())?, + plan_type_display: None, + groups: Vec::new(), + enabled: row.try_get("enabled").map_err(|_| invalid())?, + }) + }) + .collect::>>() + .map_err(store_error)?; + if !items.is_empty() { + // 仅批量加载当前页的分组,并沿用同一快照,避免逐账号查询。 + let account_ids: Vec<&str> = items.iter().map(|account| account.id.as_str()).collect(); + let rows = sqlx::query_as::<_, (String, String, String, String, bool)>( + "select m.provider_account_id, g.id, g.name, g.color, g.enabled + from account_group_accounts m join account_groups g on g.id = m.account_group_id + where m.provider_account_id = any($1::text[]) + order by m.provider_account_id, g.id", + ) + .bind(&account_ids) + .fetch_all(&mut *transaction) + .await + .map_err(|_| store_error(unavailable()))?; + let mut groups = BTreeMap::>::new(); + for (account_id, group_id, name, color, enabled) in rows { + groups.entry(account_id).or_default().push(AccountGroupRef { + id: AccountGroupId::new(group_id).map_err(|_| store_error(invalid()))?, + name, + color: AccountGroupColor::parse(&color) + .ok_or_else(|| store_error(invalid()))?, + enabled, + }); + } + for account in &mut items { + account.groups = groups.remove(&account.id).unwrap_or_default(); + } + } + transaction + .commit() + .await + .map_err(|_| store_error(unavailable()))?; + Ok(ProxyAccountPage { + items, + total: u64::try_from(total).map_err(|_| store_error(invalid()))?, + page: query.page, + page_size: query.page_size.get(), + }) + } + async fn reserve_import(&self, id: &str) -> AdminStoreResult { let slot = self .import_slots diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index 3208a9353..44945719a 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -1778,7 +1778,17 @@ async fn authorization_import_rejects_a_saved_proxy_changed_during_oauth() { .await .unwrap(); assert_eq!( - proxies.get(&saved.id).await.unwrap().accounts[0].id, + proxies + .list_accounts(gateway_admin::model::proxies::ProxyAccountListQuery { + proxy_id: saved.id.clone(), + page: 1, + page_size: gateway_admin::model::PageSize::new(20).unwrap(), + search: String::new(), + }) + .await + .unwrap() + .items[0] + .id, "acct_oauth_proxy" ); assert_eq!( diff --git a/backend/crates/gateway-store/tests/postgres/proxies.rs b/backend/crates/gateway-store/tests/postgres/proxies.rs index c52e43d60..ef4e7fa12 100644 --- a/backend/crates/gateway-store/tests/postgres/proxies.rs +++ b/backend/crates/gateway-store/tests/postgres/proxies.rs @@ -9,7 +9,7 @@ use gateway_admin::{ store::{AccountStore, AdminStoreErrorKind}, }, }; -use gateway_core::account::{AccountWeight, OutboundProxy}; +use gateway_core::account::{AccountWeight, OutboundProxy, ProviderAccountId}; use gateway_store::postgres::{ PgProviderAccountRepository, PgProxyRepository, ProviderAccountRepository, }; @@ -43,6 +43,298 @@ fn update(account_id: &str, selection: AccountProxySelection) -> UpdateAccount { } } +#[tokio::test] +async fn proxy_account_removal_preserves_settings_and_rejects_changed_bindings() { + let Some(database) = TestDatabase::create("proxy_account_remove").await else { + return; + }; + let store = PgProxyRepository::new(database.pool.clone()); + let context = context(); + let saved = store + .create( + NewProxy { + name: "解绑测试".to_owned(), + proxy: OutboundProxy::parse("http://user:secret@127.0.0.1:17890").unwrap(), + }, + &context, + ) + .await + .unwrap() + .record; + sqlx::query( + "insert into provider_accounts + (id, provider_kind, name, email, authentication_kind, provider_credentials_json, + has_refresh_token, enabled, concurrency_limit, weight, plan_type, + credential_observed_at, created_at, updated_at, outbound_proxy_id, outbound_proxy_url) + values ('acct_remove', 'openai', '解绑账号', 'remove@example.invalid', 'oauth', + '{\"access_token\":\"preserve-test-secret\"}'::jsonb, false, false, 3, 7, 'plus', + now(), now(), now(), $1, $2)", + ) + .bind(&saved.id) + .bind(saved.proxy.expose_url()) + .execute(&database.pool) + .await + .unwrap(); + sqlx::query( + "insert into account_groups (id, name, color, enabled, created_at, updated_at) + values ('grp_00000000000000000000000000000001', '保留分组', '#123456FF', true, now(), now())", + ).execute(&database.pool).await.unwrap(); + sqlx::query( + "insert into account_group_accounts (account_group_id, provider_account_id, created_at) + values ('grp_00000000000000000000000000000001', 'acct_remove', now())", + ) + .execute(&database.pool) + .await + .unwrap(); + let account_id = ProviderAccountId::new("acct_remove").unwrap(); + let snapshot = "select to_jsonb(a) - 'updated_at' - 'outbound_proxy_id' - 'outbound_proxy_url' + from provider_accounts a where id = 'acct_remove'"; + let before: serde_json::Value = sqlx::query_scalar(snapshot) + .fetch_one(&database.pool) + .await + .unwrap(); + let revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id = 1") + .fetch_one(&database.pool) + .await + .unwrap(); + let audit_count: i64 = sqlx::query_scalar("select count(*) from admin_audit_events") + .fetch_one(&database.pool) + .await + .unwrap(); + + assert_eq!( + store + .remove_account("proxy_previous", &account_id, &context) + .await + .unwrap_err() + .kind(), + AdminStoreErrorKind::Conflict + ); + assert_eq!(store.get(&saved.id).await.unwrap().account_count, 1); + let unchanged: (Option, Option) = sqlx::query_as( + "select outbound_proxy_id, outbound_proxy_url from provider_accounts where id = 'acct_remove'", + ).fetch_one(&database.pool).await.unwrap(); + assert_eq!( + unchanged, + ( + Some(saved.id.clone()), + Some(saved.proxy.expose_url().to_owned()) + ) + ); + let current_revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id = 1") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(current_revision, revision); + + let committed = store + .remove_account(&saved.id, &account_id, &context) + .await + .unwrap(); + assert_eq!(committed.get(), u64::try_from(revision + 1).unwrap()); + let after: serde_json::Value = sqlx::query_scalar(snapshot) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(after, before); + let direct: (Option, Option) = sqlx::query_as( + "select outbound_proxy_id, outbound_proxy_url from provider_accounts where id = 'acct_remove'", + ).fetch_one(&database.pool).await.unwrap(); + assert_eq!(direct, (None, None)); + let group_count: i64 = sqlx::query_scalar( + "select count(*) from account_group_accounts where provider_account_id = 'acct_remove'", + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(group_count, 1); + assert_eq!(store.get(&saved.id).await.unwrap().account_count, 0); + assert_eq!( + store + .list_accounts(ProxyAccountListQuery { + proxy_id: saved.id.clone(), + page: 1, + page_size: PageSize::new(20).unwrap(), + search: String::new(), + }) + .await + .unwrap() + .total, + 0 + ); + + assert_eq!( + store + .remove_account(&saved.id, &account_id, &context) + .await + .unwrap_err() + .kind(), + AdminStoreErrorKind::Conflict + ); + let current_audits: i64 = sqlx::query_scalar("select count(*) from admin_audit_events") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(current_audits, audit_count + 1); + let current_revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id = 1") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(u64::try_from(current_revision).unwrap(), committed.get()); + database.close().await; +} + +#[tokio::test] +async fn proxy_accounts_paginate_thousands_of_accounts_and_search_without_loading_the_catalog() { + let Some(database) = TestDatabase::create("proxy_account_pagination").await else { + return; + }; + let store = PgProxyRepository::new(database.pool.clone()); + let context = context(); + let saved = store + .create( + NewProxy { + name: "分页测试".to_owned(), + proxy: OutboundProxy::parse("http://127.0.0.1:17890").unwrap(), + }, + &context, + ) + .await + .unwrap() + .record; + let empty = store + .create( + NewProxy { + name: "无关联账号".to_owned(), + proxy: OutboundProxy::parse("http://127.0.0.1:17891").unwrap(), + }, + &context, + ) + .await + .unwrap() + .record; + // 相同名称验证稳定排序;账号仅写入隔离测试数据库,不接触真实账号池。 + sqlx::query( + "insert into provider_accounts + (id, provider_kind, name, email, authentication_kind, provider_credentials_json, + has_refresh_token, enabled, credential_observed_at, created_at, updated_at, + outbound_proxy_id, outbound_proxy_url) + select 'acct_page_' || lpad(n::text, 4, '0'), + case when n % 2 = 0 then 'xai' else 'openai' end, '共享账号', + 'page_' || lpad(n::text, 4, '0') || '@example.invalid', 'oauth', + '{\"access_token\":\"page-test-secret\"}'::jsonb, false, n % 2 = 0, + now(), now(), now(), $1, $2 + from generate_series(1, 1005) as n", + ) + .bind(&saved.id) + .bind(saved.proxy.expose_url()) + .execute(&database.pool) + .await + .unwrap(); + sqlx::query("update provider_accounts set plan_type = 'plus' where id = 'acct_page_0001'") + .execute(&database.pool) + .await + .unwrap(); + sqlx::query( + "insert into account_groups (id, name, color, enabled, created_at, updated_at) + values ('grp_00000000000000000000000000000001', '工作组', '#123456FF', false, now(), now()), + ('grp_00000000000000000000000000000002', '次页分组', '#654321FF', true, now(), now())", + ).execute(&database.pool).await.unwrap(); + sqlx::query( + "insert into account_group_accounts (account_group_id, provider_account_id, created_at) + values ('grp_00000000000000000000000000000001', 'acct_page_0001', now()), + ('grp_00000000000000000000000000000002', 'acct_page_0021', now())", + ) + .execute(&database.pool) + .await + .unwrap(); + PgProviderAccountRepository::new(database.pool.clone()) + .insert_provider_account(account("acct_unrelated", "unrelated-user")) + .await + .unwrap(); + let query = |page, search: &str| ProxyAccountListQuery { + proxy_id: saved.id.clone(), + page, + page_size: PageSize::new(20).unwrap(), + search: search.to_owned(), + }; + let first = store.list_accounts(query(1, "")).await.unwrap(); + assert_eq!((first.total, first.items.len()), (1005, 20)); + assert_eq!(first.items[0].id, "acct_page_0001"); + assert_eq!( + first.items[0].email.as_deref(), + Some("page_0001@example.invalid") + ); + assert_eq!(first.items[0].provider_kind, "openai"); + assert_eq!(first.items[0].authentication_kind, "oauth"); + assert_eq!(first.items[0].plan_type.as_deref(), Some("plus")); + assert_eq!(first.items[0].groups.len(), 1); + assert_eq!(first.items[0].groups[0].name, "工作组"); + assert_eq!(first.items[0].groups[0].color.as_str(), "#123456FF"); + assert!(!first.items[0].groups[0].enabled); + assert!(first.items[1].groups.is_empty()); + assert!(!first.items[0].enabled); + let second = store.list_accounts(query(2, "")).await.unwrap(); + assert_eq!(second.items[0].id, "acct_page_0021"); + assert_eq!(second.items[0].groups[0].name, "次页分组"); + assert!( + first + .items + .iter() + .all(|left| second.items.iter().all(|right| left.id != right.id)) + ); + let last = store.list_accounts(query(51, "")).await.unwrap(); + assert_eq!((last.total, last.items.len()), (1005, 5)); + assert_eq!(last.items[4].id, "acct_page_1005"); + let filtered = store.list_accounts(query(1, "PAGE_004")).await.unwrap(); + assert_eq!((filtered.total, filtered.items.len()), (10, 10)); + assert_eq!(filtered.items[0].id, "acct_page_0040"); + assert_eq!(store.list_accounts(query(1, "%")).await.unwrap().total, 0); + assert!( + store + .list_accounts(query(52, "")) + .await + .unwrap() + .items + .is_empty() + ); + assert_eq!( + store + .list_accounts(ProxyAccountListQuery { + proxy_id: empty.id, + ..query(1, "") + }) + .await + .unwrap() + .total, + 0 + ); + assert_eq!( + store + .list_accounts(ProxyAccountListQuery { + proxy_id: "missing".to_owned(), + ..query(1, "") + }) + .await + .unwrap_err() + .kind(), + AdminStoreErrorKind::NotFound + ); + let catalog = store + .list(ProxyListQuery { + page: 1, + page_size: PageSize::new(20).unwrap(), + search: "分页测试".to_owned(), + }) + .await + .unwrap(); + assert_eq!(catalog.items[0].account_count, 1005); + database.close().await; +} + #[tokio::test] async fn import_reservation_blocks_proxy_mutations_until_rotated_credentials_are_committed() { use gateway_store::postgres::{ @@ -218,7 +510,7 @@ async fn managed_proxies_persist_bind_update_all_accounts_and_protect_stale_test .await .unwrap(); } - assert_eq!(store.get(&created.id).await.unwrap().accounts.len(), 2); + assert_eq!(store.get(&created.id).await.unwrap().account_count, 2); assert_eq!( store .delete(&created.id, created.revision, &context) @@ -334,7 +626,7 @@ async fn legacy_urls_join_one_catalog_entry_and_invalid_batch_rolls_back() { .await .unwrap(); assert_eq!(page.total, 1); - assert_eq!(page.items[0].accounts.len(), 2); + assert_eq!(page.items[0].account_count, 2); let admin = admin_account_store(&database.pool); assert!( admin diff --git a/docs/api.md b/docs/api.md index bd1f42a4b..9bc3dddd3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -219,16 +219,26 @@ All endpoints require admin authentication and redact proxy credentials from res | 方法 / Method | 路径 / Path | 请求 / Request | 结果 / Result | | --- | --- | --- | --- | | `GET` | `/api/admin/proxies` | `page`、`pageSize`(1-200)、`search`(名称) | `{ items, page }` | +| `GET` | `/api/admin/proxies/accounts` | `proxyId`、`page`、`pageSize`(1-200)、`search`(账号名称或邮箱) | `{ items, page }` | +| `POST` | `/api/admin/proxies/accounts/remove` | `{ proxyId, accountId }` | `{ configRevision }` | | `POST` | `/api/admin/proxies/create` | `{ name, proxyUrl }` | `201 { record, configRevision }` | | `POST` | `/api/admin/proxies/update` | `{ id, revision, name, proxyUrl? }` | `{ record, configRevision }` | | `POST` | `/api/admin/proxies/test` | `{ id, revision }` | 最新代理记录 / Proxy record with test result | | `POST` | `/api/admin/proxies/delete` | `{ id, revision }` | `{ configRevision }` | -`record` 包含 `id`、`name`、`endpoint`、`hasAuthentication`、`revision`、`accounts: [{ id, name }]`、 +`record` 包含 `id`、`name`、`endpoint`、`hasAuthentication`、`revision`、`accountCount`、 `lastTestAt`、`lastTest: { success, latencyMs, exitIp, message }`、`createdAt`、`updatedAt`。 未测试时 `lastTestAt` / `lastTest` 为 `null`。连通性失败返回 HTTP 200 和 `lastTest.success=false`; 记录版本过期、重复 URL、删除已绑定的代理返回 409,并发测试满载返回 429。 +代理列表只返回关联账号数量。关联账号按需查询,每项包含 `id`、`name`、`email`、`provider`、`enabled`、 +`authenticationKind`、`planType`、`planTypeDisplay` 和 `groups: [{ id, name, color, enabled }]`, +不返回账号凭据。默认每页 20 条,按名称、ID 稳定排序;搜索不区分大小写,匹配名称或邮箱的字面子串。 +不存在的代理返回 404,未绑定账号或没有匹配结果时返回空页。数量与当前页来自同一个数据库只读快照。 + +移除关联账号只清除指定账号的代理绑定与连接地址,使其改为直连,保留凭据、调度参数与分组。 +若账号已不再绑定请求中的代理,则返回 409;成功后在同一事务中更新配置版本与审计,并发布运行时快照。 + 更新省略 `proxyUrl` 保留认证;连接配置改变时清除测试结果并更新所有绑定账号。 Omit `proxyUrl` to preserve credentials. Connection changes invalidate the previous test and update all bound accounts. Tests persist only when the requested revision still matches. Connectivity failures use HTTP 200 with diff --git a/docs/architecture.md b/docs/architecture.md index b86cb208a..3e4184c13 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -191,6 +191,10 @@ Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 ca 代理独立保存和测试,通过 `outboundProxyId` 绑定账号;账号保留解析后的 URL 供 Provider 使用。 连接配置改变时在同一事务内同步关联账号并清除旧测试结果,已绑定的代理不可删除。 +关联账号支持单独移除:在原子更新中校验当前代理 ID,只清除账号的代理 ID 和 URL, +保留账号其他设置;提交配置版本与审计后复用快照发布流程,使后续请求使用直连。 +代理目录只携带关联数量;账号明细通过独立分页读端口按需查询,复用代理 ID 索引, +在同一个只读快照中统计并读取有界页面,不在目录中聚合完整账号数组。 文件及 AT/RT 导入在上游凭据交换前取得 PostgreSQL 会话级共享咨询锁,直到凭据提交后释放。 代理修改、删除和测试结果写入通过同一资源的事务级独占锁检查,导入期间直接返回冲突。 锁连接不占用提交所需的连接池槽位,也不持有空闲事务;每进程最多增加四个导入保护连接。 diff --git a/frontend/src/api/modules/proxies.ts b/frontend/src/api/modules/proxies.ts index 9ee7d5fc9..1380685b0 100644 --- a/frontend/src/api/modules/proxies.ts +++ b/frontend/src/api/modules/proxies.ts @@ -1,3 +1,4 @@ +import type { AccountGroupRef } from './account-groups' import request from '../request' export interface OutboundProxyTest { @@ -13,7 +14,7 @@ export interface OutboundProxyRecord { endpoint: string hasAuthentication: boolean revision: number - accounts: { id: string, name: string }[] + accountCount: number lastTestAt: string | null lastTest: OutboundProxyTest | null createdAt: string @@ -25,27 +26,81 @@ interface ProxyPage { page: { page: number, pageSize: number, total: number, totalPages: number } } +export interface OutboundProxyAccount { + id: string + name: string + email: string | null + provider: string + authenticationKind: string + planType: string | null + planTypeDisplay: string | null + groups: AccountGroupRef[] + enabled: boolean +} + +interface ProxyAccountPage { + items: OutboundProxyAccount[] + page: ProxyPage['page'] +} + +export function getProxyAccounts(params: { proxyId: string, page: number, pageSize: number, search?: string }) { + return request({ + url: '/api/admin/proxies/accounts', + method: 'GET', + params, + }) +} + +export function removeProxyAccount(data: { proxyId: string, accountId: string }) { + return request<{ configRevision: number }>({ + url: '/api/admin/proxies/accounts/remove', + method: 'POST', + data, + }) +} + interface ProxyMutation { record: OutboundProxyRecord configRevision: number } export function getProxies(params: { page: number, pageSize: number, search?: string }) { - return request({ url: '/api/admin/proxies', method: 'GET', params }) + return request({ + url: '/api/admin/proxies', + method: 'GET', + params, + }) } export function createProxy(data: { name: string, proxyUrl: string }) { - return request({ url: '/api/admin/proxies/create', method: 'POST', data }) + return request({ + url: '/api/admin/proxies/create', + method: 'POST', + data, + }) } export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }) { - return request({ url: '/api/admin/proxies/update', method: 'POST', data }) + return request({ + url: '/api/admin/proxies/update', + method: 'POST', + data, + }) } export function deleteProxy(data: { id: string, revision: number }) { - return request<{ configRevision: number }>({ url: '/api/admin/proxies/delete', method: 'POST', data }) + return request<{ configRevision: number }>({ + url: '/api/admin/proxies/delete', + method: 'POST', + data, + }) } export function testProxy(data: { id: string, revision: number }) { - return request({ url: '/api/admin/proxies/test', method: 'POST', data, timeout: 25000 }) + return request({ + url: '/api/admin/proxies/test', + method: 'POST', + data, + timeout: 25000, + }) } diff --git a/frontend/src/components/base/BaseSelect.vue b/frontend/src/components/base/BaseSelect.vue index 383a22a22..322a0d378 100644 --- a/frontend/src/components/base/BaseSelect.vue +++ b/frontend/src/components/base/BaseSelect.vue @@ -12,6 +12,7 @@ type SelectSize = 'sm' | 'md' | 'lg' export interface SelectOption { label: string value: string + description?: string disabled?: boolean } @@ -303,9 +304,17 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr @click="toggleMenu" @keydown="handleTriggerKeydown" > - + {{ selectedOption?.label ?? placeholder }} + + {{ selectedOption.description }} + - {{ option.label }} + {{ option.label }} + + {{ option.description }} + -import { ExternalLink, RefreshCw } from '@lucide/vue' import { computed } from 'vue' import BaseFormItem from '@/components/base/BaseForm/FormItem.vue' -import BaseIconButton from '@/components/base/BaseIconButton.vue' import BaseSelect from '@/components/base/BaseSelect.vue' import { useProxyCatalog } from '@/composables/useProxyCatalog' const props = withDefaults(defineProps<{ accountId?: string, endpoint?: string | null, disabled?: boolean, preserve?: boolean, error?: string }>(), { preserve: true }) const mode = defineModel('mode', { required: true }) const proxyId = defineModel('proxyId', { required: true }) -const { proxies, loading, loadProxies } = useProxyCatalog() -const current = computed(() => proxies.value.find(proxy => proxy.accounts.some(account => account.id === props.accountId))) +const { proxies, loading } = useProxyCatalog() +// 目录不再携带账号明细;脱敏地址无法证明绑定关系,保持项只展示账号已有地址。 const options = computed(() => [ - ...(props.preserve ? [{ label: current.value ? `保持当前:${current.value.name}` : '保持当前', value: 'preserve' }] : []), - { label: '直连', value: 'direct' }, + ...(props.preserve + ? [{ + label: '保持当前', + value: 'preserve', + description: props.endpoint || (props.accountId ? '直连' : '保留各账号的连接设置'), + }] + : []), + { label: '直连', value: 'direct', description: '不使用代理' }, ...proxies.value.map(proxy => ({ label: `${proxy.name}${proxy.lastTest?.success ? '' : proxy.lastTest ? '(测试失败)' : '(未测试)'}`, value: proxy.id, + description: proxy.endpoint, disabled: proxy.lastTest?.success !== true, })), ]) @@ -31,21 +36,7 @@ const selection = computed({ diff --git a/frontend/src/views/proxies/components/ProxyAccountsModal.vue b/frontend/src/views/proxies/components/ProxyAccountsModal.vue new file mode 100644 index 000000000..bfb5cbf2d --- /dev/null +++ b/frontend/src/views/proxies/components/ProxyAccountsModal.vue @@ -0,0 +1,131 @@ + + + diff --git a/frontend/src/views/proxies/components/ProxyFormModal.vue b/frontend/src/views/proxies/components/ProxyFormModal.vue new file mode 100644 index 000000000..82dfd1f51 --- /dev/null +++ b/frontend/src/views/proxies/components/ProxyFormModal.vue @@ -0,0 +1,78 @@ + + + diff --git a/frontend/src/views/proxies/composables/useProxyAccounts.ts b/frontend/src/views/proxies/composables/useProxyAccounts.ts new file mode 100644 index 000000000..3133b5924 --- /dev/null +++ b/frontend/src/views/proxies/composables/useProxyAccounts.ts @@ -0,0 +1,89 @@ +import { watchDebounced } from '@vueuse/core' +import { computed, shallowRef, watch } from 'vue' +import { getProxyAccounts, removeProxyAccount } from '@/api' +import { toast } from '@/components/base/BaseToast' +import { useAsyncAction } from '@/composables/useAsyncAction' +import { usePagedQuery } from '@/composables/usePagedQuery' +import { errorMessage } from '@/utils/async' + +export function useProxyAccounts(options: { + isOpen: () => boolean + proxyId: () => string | undefined + onRemoved: () => void +}) { + const search = shallowRef('') + const removeAction = useAsyncAction() + const query = usePagedQuery({ + initialPageSize: 20, + load: pagination => getProxyAccounts({ + ...pagination, + proxyId: options.proxyId() ?? '', + search: search.value.trim() || undefined, + }), + onError: error => toast.error(errorMessage(error, '关联账号加载失败')), + }) + const pagination = computed(() => ({ + currentPage: query.page.value, + pageSize: query.pageSize.value, + total: query.total.value, + })) + + function load() { + if (options.isOpen() && options.proxyId()) + void query.execute() + } + + function setPage(page: number) { + query.page.value = page + query.items.value = [] + load() + } + + function setPageSize(size: number) { + query.pageSize.value = size + setPage(1) + } + + async function removeAccount(accountId: string) { + const proxyId = options.proxyId() + if (!proxyId) + return false + return await removeAction.run(async () => { + await removeProxyAccount({ proxyId, accountId }) + options.onRemoved() + toast.success('账号已移出当前代理,改为直连') + if (options.isOpen() && options.proxyId() === proxyId) { + query.invalidate() + query.items.value = query.items.value.filter(account => account.id !== accountId) + query.total.value = Math.max(0, query.total.value - 1) + // 复用分页查询,删除末页最后一项时自动回到有效页。 + await query.execute() + } + return true + }, { errorText: '移除关联账号失败' }) ?? false + } + + watch([options.isOpen, options.proxyId], () => { + // 关闭或切换代理后丢弃旧请求,避免上一条代理的账号覆盖新列表。 + query.invalidate() + query.items.value = [] + query.total.value = 0 + query.error.value = '' + query.page.value = 1 + search.value = '' + load() + }, { immediate: true }) + watchDebounced(search, () => setPage(1), { debounce: 300 }) + + return { + accounts: query.items, + loading: query.loading, + error: query.error, + pagination, + search, + setPage, + setPageSize, + removing: removeAction.loading, + removeAccount, + } +} diff --git a/frontend/src/views/proxies/index.vue b/frontend/src/views/proxies/index.vue index bb6be2601..62cba4a0a 100644 --- a/frontend/src/views/proxies/index.vue +++ b/frontend/src/views/proxies/index.vue @@ -1,18 +1,15 @@