diff --git a/README.md b/README.md index ae59aafc0..8add28386 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ## 功能 -- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;支持独立出站代理及 sub2api OpenAI 账号代理导入。 +- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;代理管理支持独立保存、连接测试和账号绑定,以及 sub2api OpenAI 账号代理导入。 - **访问控制**:通过账号分组分配密钥权限,设置日/周 USD 限额、并发和请求频率。 - **客户端接入**:支持 Codex CLI、桌面端与 Responses API 客户端,提供 HTTP 流式响应和 WebSocket。 - **请求诊断**:查看请求记录、用量、费用、延迟和错误详情。 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d950f6fa7..344983e7a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -744,6 +744,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "tokio-rustls", ] [[package]] diff --git a/backend/apps/gateway/Cargo.toml b/backend/apps/gateway/Cargo.toml index 6f9131b78..fdfca651c 100644 --- a/backend/apps/gateway/Cargo.toml +++ b/backend/apps/gateway/Cargo.toml @@ -28,6 +28,7 @@ config = { version = "0.15.23", default-features = false, features = ["yaml"] } serde_json = "1" syn = { version = "3.0.3", features = ["full", "visit"] } tempfile = "3.27.0" +tokio-rustls = { version = "=0.26.4", default-features = false } [lints] workspace = true diff --git a/backend/apps/gateway/src/bootstrap.rs b/backend/apps/gateway/src/bootstrap.rs index 582fe4c52..b01a91c75 100644 --- a/backend/apps/gateway/src/bootstrap.rs +++ b/backend/apps/gateway/src/bootstrap.rs @@ -78,7 +78,10 @@ pub async fn run() -> Result<(), BootstrapError> { store.admin_ports(), vec![openai.admin_provider(), xai.admin_provider()], core.snapshot_control(), - core.account_probe(), + ( + core.account_probe(), + host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca), + ), host.client_distribution_resolver(), host.system_operations(), ) diff --git a/backend/apps/gateway/tests/architecture.rs b/backend/apps/gateway/tests/architecture.rs index 356a2af43..bba1cda1b 100644 --- a/backend/apps/gateway/tests/architecture.rs +++ b/backend/apps/gateway/tests/architecture.rs @@ -182,6 +182,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[ &[ "client_distribution", "config", + "proxy_probe", "serve", "system_update", "workers", diff --git a/backend/apps/gateway/tests/bootstrap.rs b/backend/apps/gateway/tests/bootstrap.rs index 1b57b9211..4f694a115 100644 --- a/backend/apps/gateway/tests/bootstrap.rs +++ b/backend/apps/gateway/tests/bootstrap.rs @@ -9,6 +9,151 @@ const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222"; const ADMIN_PASSWORD: &str = "test-admin-password"; const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD"; +#[tokio::test] +async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() { + use gateway_admin::ports::proxy::ProxyProbe; + use gateway_core::account::OutboundProxy; + use gateway_host::proxy_probe::HttpProxyProbe; + use std::{sync::Arc, time::Duration}; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio_rustls::{ + TlsAcceptor, + rustls::{ + ServerConfig, + pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _}, + }, + }; + + const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY"; + let Ok(directory) = std::env::var(CHILD_ENV) else { + let directory = tempfile::tempdir().unwrap(); + generate_proxy_test_certificates(directory.path()); + // 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置。 + for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] { + let mut child = Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "bootstrap::proxy_probe_should_use_provider_custom_ca_for_https_proxies", + "--nocapture", + ]) + .env(CHILD_ENV, directory.path()) + .env_remove("CODEX_CA_CERTIFICATE") + .env_remove("SSL_CERT_FILE") + .env(ca_env, directory.path().join("ca.pem")); + if ca_env == "CODEX_CA_CERTIFICATE" { + child.env( + "SSL_CERT_FILE", + directory.path().join("missing-fallback.pem"), + ); + } + let output = child.output().unwrap(); + assert!( + output.status.success(), + "{ca_env}: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + return; + }; + provider_openai::ensure_rustls_provider(); + let directory = std::path::Path::new(&directory); + let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap(); + let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap(); + let acceptor = TlsAcceptor::from(Arc::new( + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![certificate], key) + .unwrap(), + )); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = + OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap(); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut stream = acceptor.accept(socket).await.unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + assert!(request.len() < 8192); + request.push(stream.read_u8().await.unwrap()); + } + assert!(request.starts_with(b"GET http://unresolvable.invalid/ip ")); + let body = "{\"ip\":\"203.0.113.8\"}"; + stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + stream.shutdown().await.unwrap(); + }); + let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip") + .with_client_builder(provider_openai::build_reqwest_client_with_custom_ca); + let result = probe.test(&proxy).await; + assert!(result.success, "{}", result.message); + assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8"); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap(); +} + +fn generate_proxy_test_certificates(directory: &std::path::Path) { + let openssl = |args: &[&str]| { + let output = Command::new("openssl") + .args(args) + .current_dir(directory) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + openssl(&[ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + "ca.key", + "-out", + "ca.pem", + "-days", + "1", + "-subj", + "/CN=Proxy Test CA", + ]); + openssl(&[ + "req", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + "server.key", + "-out", + "server.csr", + "-subj", + "/CN=localhost", + ]); + fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap(); + openssl(&[ + "x509", + "-req", + "-in", + "server.csr", + "-CA", + "ca.pem", + "-CAkey", + "ca.key", + "-CAcreateserial", + "-out", + "server.pem", + "-days", + "1", + "-extfile", + "extensions", + ]); +} + #[test] fn config_loader_should_load_complete_terminal_example() { parse_config(&valid_config()).expect("terminal config example"); diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index ccf107ca7..20750fd98 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -25,7 +25,7 @@ pub use use_case::{ account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService, backup::BackupService, client_distribution::ClientDistributionService, client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService, - settings::SettingsService, system::SystemService, xai::XaiService, + proxies::ProxiesService, settings::SettingsService, system::SystemService, xai::XaiService, }; use model::{AdminError, AdminErrorKind}; @@ -149,6 +149,7 @@ pub enum AdminConfigError { /// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。 #[derive(Clone)] pub struct AdminServices { + proxies: Arc, auth: Arc, accounts: Arc, account_groups: Arc, @@ -163,6 +164,11 @@ pub struct AdminServices { } impl AdminServices { + #[must_use] + pub fn proxies(&self) -> &dyn ProxiesService { + self.proxies.as_ref() + } + #[must_use] pub fn auth(&self) -> &dyn AuthService { self.auth.as_ref() @@ -247,10 +253,11 @@ pub async fn initialize( store: AdminStorePorts, providers: Vec>, snapshot: Arc, - probe: Arc, + probes: (Arc, Arc), client_distribution: Arc, system: Arc, ) -> Result { + let (probe, proxy_probe) = probes; config .resolve_and_validate(Path::new(".")) .map_err(|error| AdminError::invalid(error.to_string()))?; @@ -290,6 +297,12 @@ pub async fn initialize( backup_ports.object_store(), ); let services = AdminServices { + proxies: Arc::new(use_case::proxies::DefaultProxiesService::new( + store.proxies(), + proxy_probe, + snapshot.clone(), + registry.clone(), + )), auth, accounts, account_groups: Arc::new(DefaultAccountGroupService::new( @@ -316,11 +329,13 @@ pub async fn initialize( openai: Arc::new(DefaultOpenAiService::new( openai, store.accounts(), + store.proxies(), snapshot.clone(), )), xai: Arc::new(DefaultXaiService::new( xai, store.accounts(), + store.proxies(), snapshot.clone(), )), backups, diff --git a/backend/crates/gateway-admin/src/model/accounts.rs b/backend/crates/gateway-admin/src/model/accounts.rs index 0e287609a..eb682b6d9 100644 --- a/backend/crates/gateway-admin/src/model/accounts.rs +++ b/backend/crates/gateway-admin/src/model/accounts.rs @@ -221,7 +221,7 @@ pub struct UpdateAccount { pub concurrency_limit: Option, pub weight: AccountWeight, pub group_ids: Vec, - pub outbound_proxy: Option>, + pub outbound_proxy: Option, } /// 账号更新结果。 @@ -239,7 +239,7 @@ pub struct BatchUpdateAccounts { pub concurrency_limit: Option, pub weight: AccountWeight, pub group_ids: Vec, - pub outbound_proxy: Option>, + pub outbound_proxy: Option, } /// 批量账号更新结果。 diff --git a/backend/crates/gateway-admin/src/model/mod.rs b/backend/crates/gateway-admin/src/model/mod.rs index c2fda018b..6fb10719f 100644 --- a/backend/crates/gateway-admin/src/model/mod.rs +++ b/backend/crates/gateway-admin/src/model/mod.rs @@ -10,6 +10,7 @@ pub mod client_distribution; pub mod client_keys; pub mod observability; pub mod provider_credentials; +pub mod proxies; pub mod settings; pub mod system; diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index 255a95fd6..8c080c54f 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -108,6 +108,7 @@ pub struct CredentialDetails { /// Provider 正式文档批量导入命令。 pub struct ImportCredentials { + pub outbound_proxy_id: Option, pub settings: Option, pub context: MutationContext, pub document: ProviderDocument, @@ -132,6 +133,7 @@ pub struct CredentialImportResult { /// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文。 pub struct PrepareCredentialImport { + pub default_outbound_proxy: Option, pub document: ProviderDocument, } @@ -175,6 +177,7 @@ pub struct PreparedCredentialImport { /// Admin 交给 Store 的导入事务命令。 #[derive(Debug, Clone, PartialEq)] pub struct CredentialImportCommit { + pub outbound_proxy: Option, pub settings: Option, pub prepared: PreparedCredentialImport, } @@ -255,6 +258,7 @@ pub struct PendingAuthorizationMutation { target: AuthorizationMutationTarget, owner_binding: AuthorizationOwnerBinding, outbound_proxy: Option, + outbound_proxy_id: Option, } impl PendingAuthorizationMutation { @@ -269,6 +273,7 @@ impl PendingAuthorizationMutation { target, owner_binding, outbound_proxy: None, + outbound_proxy_id: None, } } @@ -299,6 +304,16 @@ impl PendingAuthorizationMutation { pub fn outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> { self.outbound_proxy.as_ref() } + + #[must_use] + pub fn with_outbound_proxy_id(mut self, id: Option) -> Self { + self.outbound_proxy_id = id; + self + } + + pub fn outbound_proxy_id(&self) -> Option<&str> { + self.outbound_proxy_id.as_deref() + } } impl PendingAuthorizationMutation { @@ -338,6 +353,9 @@ impl PendingAuthorizationMutation { Value::String(proxy.expose_url().to_owned()), ); } + if let Some(id) = &self.outbound_proxy_id { + document.insert("outbound_proxy_id".to_owned(), Value::String(id.clone())); + } document } @@ -382,7 +400,8 @@ impl PendingAuthorizationMutation { started_request_id: document.started_request_id, }, ) - .with_outbound_proxy(proxy)) + .with_outbound_proxy(proxy) + .with_outbound_proxy_id(document.outbound_proxy_id)) } } @@ -395,6 +414,8 @@ struct StoredAuthorizationMutationV1 { started_request_id: String, #[serde(default)] outbound_proxy_url: Option, + #[serde(default)] + outbound_proxy_id: Option, } #[derive(Deserialize)] @@ -418,7 +439,7 @@ pub struct StartAuthorization { pub context: MutationContext, pub name: String, pub reauthorization: Option, - pub outbound_proxy: Option, + pub outbound_proxy: Option, } /// OAuth 流程启动结果。 diff --git a/backend/crates/gateway-admin/src/model/proxies.rs b/backend/crates/gateway-admin/src/model/proxies.rs new file mode 100644 index 000000000..591498e07 --- /dev/null +++ b/backend/crates/gateway-admin/src/model/proxies.rs @@ -0,0 +1,105 @@ +//! 可复用的账号出口配置,以及脱敏后的连通性测试结果。 + +use chrono::{DateTime, Utc}; +use gateway_core::account::OutboundProxy; + +use super::{PageSize, Revision, account_groups::AccountGroupRef}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountProxySelection { + Direct, + Url(OutboundProxy), + Saved(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportProxyBinding { + pub id: String, + pub proxy: OutboundProxy, +} + +#[derive(Debug, Clone)] +pub struct ProxyListQuery { + pub page: u32, + pub page_size: PageSize, + pub search: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +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)] +pub struct ProxyTestResult { + pub success: bool, + pub latency_ms: u64, + pub exit_ip: Option, + pub message: String, +} + +#[derive(Debug, Clone)] +pub struct ProxyRecord { + pub id: String, + pub name: String, + pub proxy: OutboundProxy, + pub revision: Revision, + pub account_count: u64, + pub last_test_at: Option>, + pub last_test: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct ProxyPage { + pub items: Vec, + pub total: u64, + pub page: u32, + pub page_size: u16, +} + +#[derive(Debug, Clone)] +pub struct NewProxy { + pub name: String, + pub proxy: OutboundProxy, +} + +#[derive(Debug, Clone)] +pub struct UpdateProxy { + pub id: String, + pub revision: Revision, + pub name: String, + pub proxy: Option, +} + +#[derive(Debug, Clone)] +pub struct ProxyMutation { + pub config_revision: Revision, + pub record: ProxyRecord, +} diff --git a/backend/crates/gateway-admin/src/ports/mod.rs b/backend/crates/gateway-admin/src/ports/mod.rs index 2b3101dde..fca5cbf02 100644 --- a/backend/crates/gateway-admin/src/ports/mod.rs +++ b/backend/crates/gateway-admin/src/ports/mod.rs @@ -3,5 +3,6 @@ pub mod backup; pub mod client_distribution; pub mod provider; +pub mod proxy; pub mod store; pub mod system; 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 new file mode 100644 index 000000000..c796ec7aa --- /dev/null +++ b/backend/crates/gateway-admin/src/ports/proxy.rs @@ -0,0 +1,66 @@ +use async_trait::async_trait; +use gateway_core::account::{OutboundProxy, ProviderAccountId}; + +use super::store::AdminStoreResult; +use crate::model::{ + MutationContext, Revision, + proxies::{ + ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery, + ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy, + }, +}; + +#[async_trait] +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, + context: &MutationContext, + ) -> AdminStoreResult; + async fn update( + &self, + command: UpdateProxy, + context: &MutationContext, + ) -> AdminStoreResult; + async fn delete( + &self, + id: &str, + revision: Revision, + context: &MutationContext, + ) -> AdminStoreResult; + async fn record_test( + &self, + id: &str, + revision: Revision, + result: ProxyTestResult, + context: &MutationContext, + ) -> AdminStoreResult; +} + +/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则。 +pub trait ProxyImportGuard: Send + Sync {} + +pub struct ProxyImportReservation { + pub binding: ImportProxyBinding, + pub guard: Box, +} + +#[async_trait] +pub trait ProxyProbe: Send + Sync { + async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult; +} diff --git a/backend/crates/gateway-admin/src/ports/store.rs b/backend/crates/gateway-admin/src/ports/store.rs index 181cfe95f..94f169957 100644 --- a/backend/crates/gateway-admin/src/ports/store.rs +++ b/backend/crates/gateway-admin/src/ports/store.rs @@ -382,6 +382,7 @@ pub struct AdminAccountStorePorts { accounts: Arc, runtime: Arc, groups: Arc, + proxies: Arc, } impl AdminAccountStorePorts { @@ -390,11 +391,13 @@ impl AdminAccountStorePorts { accounts: Arc, runtime: Arc, groups: Arc, + proxies: Arc, ) -> Self { Self { accounts, runtime, groups, + proxies, } } } @@ -447,6 +450,11 @@ impl AdminStorePorts { self.accounts.groups.clone() } + #[must_use] + pub fn proxies(&self) -> Arc { + self.accounts.proxies.clone() + } + #[must_use] pub fn auth(&self) -> Arc { self.auth.clone() 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/mod.rs b/backend/crates/gateway-admin/src/use_case/mod.rs index f2395b5f1..6afc9288a 100644 --- a/backend/crates/gateway-admin/src/use_case/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/mod.rs @@ -8,6 +8,7 @@ pub mod client_distribution; pub mod client_keys; pub mod observability; pub mod openai; +pub mod proxies; pub mod settings; pub mod system; pub mod xai; @@ -109,13 +110,27 @@ async fn required_credential( .ok_or_else(|| AdminError::not_found("Provider 凭据不存在")) } +async fn import_proxy_binding( + proxies: &dyn crate::ports::proxy::ProxyStore, + id: Option<&str>, +) -> Result, AdminError> { + let Some(id) = id else { return Ok(None) }; + let reservation = proxies + .reserve_import(id) + .await + .map_err(|error| map_store_error(error, "proxy"))?; + Ok(Some(reservation)) +} + async fn pending_authorization( accounts: &dyn AccountStore, + proxies: &dyn crate::ports::proxy::ProxyStore, provider_kind: &ProviderKind, command: &StartAuthorization, resource: &'static str, ) -> Result { - let mut proxy = command.outbound_proxy.clone(); + let proxy; + let mut proxy_id = None; let target = match &command.reauthorization { Some(account_id) => { proxy = required_credential(accounts, provider_kind, account_id, resource) @@ -126,9 +141,27 @@ async fn pending_authorization( account_id: account_id.clone(), } } - None => AuthorizationMutationTarget::Create { - name: command.name.clone(), - }, + None => { + use crate::model::proxies::AccountProxySelection; + proxy = match &command.outbound_proxy { + Some(AccountProxySelection::Url(value)) => Some(value.clone()), + Some(AccountProxySelection::Saved(id)) => { + let record = proxies + .get(id) + .await + .map_err(|error| map_store_error(error, "proxy"))?; + if !record.last_test.is_some_and(|test| test.success) { + return Err(AdminError::conflict("请先测试代理连接")); + } + proxy_id = Some(record.id); + Some(record.proxy) + } + Some(AccountProxySelection::Direct) | None => None, + }; + AuthorizationMutationTarget::Create { + name: command.name.clone(), + } + } }; Ok(PendingAuthorizationMutation::new( provider_kind.clone(), @@ -137,7 +170,8 @@ async fn pending_authorization( &command.context, ), ) - .with_outbound_proxy(proxy)) + .with_outbound_proxy(proxy) + .with_outbound_proxy_id(proxy_id)) } fn validate_prepared_import( diff --git a/backend/crates/gateway-admin/src/use_case/openai.rs b/backend/crates/gateway-admin/src/use_case/openai.rs index 09a602780..cf2bc1e59 100644 --- a/backend/crates/gateway-admin/src/use_case/openai.rs +++ b/backend/crates/gateway-admin/src/use_case/openai.rs @@ -58,6 +58,7 @@ pub trait OpenAiService: Send + Sync { pub(crate) struct DefaultOpenAiService { provider: Arc, accounts: Arc, + proxies: Arc, snapshot: Arc, } @@ -66,11 +67,13 @@ impl DefaultOpenAiService { pub(crate) fn new( provider: Arc, accounts: Arc, + proxies: Arc, snapshot: Arc, ) -> Self { Self { provider, accounts, + proxies, snapshot, } } @@ -128,9 +131,20 @@ impl OpenAiService for DefaultOpenAiService { command: ImportCredentials, ) -> Result { let context = command.context; + let proxy_reservation = super::import_proxy_binding( + self.proxies.as_ref(), + command.outbound_proxy_id.as_deref(), + ) + .await?; + let outbound_proxy = proxy_reservation + .as_ref() + .map(|reservation| reservation.binding.clone()); let prepared = self .provider .prepare_import(PrepareCredentialImport { + default_outbound_proxy: outbound_proxy + .as_ref() + .map(|binding| binding.proxy.clone()), document: command.document, }) .await @@ -144,6 +158,7 @@ impl OpenAiService for DefaultOpenAiService { .accounts .commit_credential_import( CredentialImportCommit { + outbound_proxy, prepared, settings: command.settings, }, @@ -151,6 +166,7 @@ impl OpenAiService for DefaultOpenAiService { ) .await .map_err(|error| map_store_error(error, "OpenAI credential import"))?; + drop(proxy_reservation); self.provider .account_facts_changed(&result.credential_ids) .await; @@ -166,6 +182,7 @@ impl OpenAiService for DefaultOpenAiService { ) -> Result { let pending = pending_authorization( self.accounts.as_ref(), + self.proxies.as_ref(), self.provider.provider_kind(), &command, "OpenAI credential", diff --git a/backend/crates/gateway-admin/src/use_case/proxies.rs b/backend/crates/gateway-admin/src/use_case/proxies.rs new file mode 100644 index 000000000..367472928 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/proxies.rs @@ -0,0 +1,248 @@ +use std::sync::Arc; + +use async_trait::async_trait; +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, + 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, + context: &MutationContext, + ) -> Result; + async fn update( + &self, + command: UpdateProxy, + context: &MutationContext, + ) -> Result; + async fn delete( + &self, + id: &str, + revision: Revision, + context: &MutationContext, + ) -> Result; + async fn test( + &self, + id: &str, + revision: Revision, + context: &MutationContext, + ) -> Result; +} + +pub(crate) struct DefaultProxiesService { + store: Arc, + probe: Arc, + snapshot: Arc, + providers: ProviderAdminRegistry, + test_slots: Semaphore, +} + +impl DefaultProxiesService { + pub(crate) fn new( + store: Arc, + probe: Arc, + snapshot: Arc, + providers: ProviderAdminRegistry, + ) -> Self { + Self { + store, + probe, + snapshot, + providers, + test_slots: Semaphore::new(4), + } + } +} + +#[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) + { + return Err(AdminError::invalid("代理查询参数不合法")); + } + self.store + .list(query) + .await + .map_err(|error| map_store_error(error, "proxy")) + } + + async fn create( + &self, + mut command: NewProxy, + context: &MutationContext, + ) -> Result { + command.name = validate_name(&command.name)?; + let result = self + .store + .create(command, context) + .await + .map_err(|error| map_store_error(error, "proxy"))?; + publish_committed(self.snapshot.as_ref(), result.config_revision).await?; + Ok(result) + } + + async fn update( + &self, + mut command: UpdateProxy, + context: &MutationContext, + ) -> Result { + command.name = validate_name(&command.name)?; + let result = self + .store + .update(command, context) + .await + .map_err(|error| map_store_error(error, "proxy"))?; + publish_committed(self.snapshot.as_ref(), result.config_revision).await?; + Ok(result) + } + + async fn delete( + &self, + id: &str, + revision: Revision, + context: &MutationContext, + ) -> Result { + let result = self + .store + .delete(id, revision, context) + .await + .map_err(|error| map_store_error(error, "proxy"))?; + publish_committed(self.snapshot.as_ref(), result).await?; + Ok(result) + } + + async fn test( + &self, + id: &str, + revision: Revision, + context: &MutationContext, + ) -> Result { + let _permit = self.test_slots.try_acquire().map_err(|_| { + AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试") + })?; + let record = self + .store + .get(id) + .await + .map_err(|error| map_store_error(error, "proxy"))?; + if record.revision != revision { + return Err(AdminError::conflict("代理已被修改,请刷新后重新测试")); + } + let result = self.probe.test(&record.proxy).await; + self.store + .record_test(id, revision, result, context) + .await + .map_err(|error| map_store_error(error, "proxy")) + } +} + +fn validate_name(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value.chars().count() > 100 || value.chars().any(char::is_control) { + return Err(AdminError::invalid("代理名称需要 1 至 100 个字符")); + } + Ok(value.to_owned()) +} diff --git a/backend/crates/gateway-admin/src/use_case/xai.rs b/backend/crates/gateway-admin/src/use_case/xai.rs index f7faf523f..b042ce6a4 100644 --- a/backend/crates/gateway-admin/src/use_case/xai.rs +++ b/backend/crates/gateway-admin/src/use_case/xai.rs @@ -49,6 +49,7 @@ pub trait XaiService: Send + Sync { pub(crate) struct DefaultXaiService { provider: Arc, accounts: Arc, + proxies: Arc, snapshot: Arc, } @@ -57,11 +58,13 @@ impl DefaultXaiService { pub(crate) fn new( provider: Arc, accounts: Arc, + proxies: Arc, snapshot: Arc, ) -> Self { Self { provider, accounts, + proxies, snapshot, } } @@ -81,9 +84,20 @@ impl XaiService for DefaultXaiService { command: ImportCredentials, ) -> Result { let context = command.context; + let proxy_reservation = super::import_proxy_binding( + self.proxies.as_ref(), + command.outbound_proxy_id.as_deref(), + ) + .await?; + let outbound_proxy = proxy_reservation + .as_ref() + .map(|reservation| reservation.binding.clone()); let prepared = self .provider .prepare_import(PrepareCredentialImport { + default_outbound_proxy: outbound_proxy + .as_ref() + .map(|binding| binding.proxy.clone()), document: command.document, }) .await @@ -97,6 +111,7 @@ impl XaiService for DefaultXaiService { .accounts .commit_credential_import( CredentialImportCommit { + outbound_proxy, prepared, settings: command.settings, }, @@ -104,6 +119,7 @@ impl XaiService for DefaultXaiService { ) .await .map_err(|error| map_store_error(error, "xAI credential import"))?; + drop(proxy_reservation); publish_committed(self.snapshot.as_ref(), result.config_revision).await?; // 导入已经提交,额度属于可重建观察事实;单个账号查询失败由周期任务重试。 for account_id in &result.credential_ids { @@ -125,6 +141,7 @@ impl XaiService for DefaultXaiService { ) -> Result { let pending = pending_authorization( self.accounts.as_ref(), + self.proxies.as_ref(), self.provider.provider_kind(), &command, "xAI credential", diff --git a/backend/crates/gateway-admin/tests/model/provider_credentials.rs b/backend/crates/gateway-admin/tests/model/provider_credentials.rs index 0f9122891..3e54df85f 100644 --- a/backend/crates/gateway-admin/tests/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/tests/model/provider_credentials.rs @@ -97,7 +97,21 @@ fn pending_mutation_v1_should_round_trip_all_targets_and_owners() { PendingAuthorizationMutation::from_storage_v1(Value::Object( expected.to_storage_v1() )), - Ok(expected), + Ok(expected.clone()), + ); + let with_proxy = expected + .with_outbound_proxy(Some( + gateway_core::account::OutboundProxy::parse( + "http://user:secret@proxy.example:8080", + ) + .unwrap(), + )) + .with_outbound_proxy_id(Some("proxy_saved".to_owned())); + assert_eq!( + PendingAuthorizationMutation::from_storage_v1(Value::Object( + with_proxy.to_storage_v1() + )), + Ok(with_proxy) ); } } diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index 18172dc64..b5b693b50 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -5,6 +5,7 @@ mod backup; mod client_keys; mod observability; mod openai; +mod proxies; mod settings; mod system; mod xai; @@ -82,6 +83,7 @@ pub(super) struct AdminHarness { default_password: String, session_ttl_minutes: u64, accounts: Arc, + proxies: Arc, account_runtime: Arc, account_groups: Arc, auth: Arc, @@ -101,6 +103,7 @@ impl AdminHarness { default_password: "strong-test-password".to_owned(), session_ttl_minutes: 60, accounts: unavailable.clone(), + proxies: Arc::new(proxies::TestProxies::default()), account_runtime: unavailable.clone(), account_groups: Arc::new(UnavailableAccountGroupStore), auth: Arc::new(BootstrapAuthStore::default()), @@ -179,6 +182,14 @@ impl AdminHarness { self } + pub(super) fn proxies( + mut self, + proxies: Arc, + ) -> Self { + self.proxies = proxies; + self + } + pub(super) fn system(mut self, system: Arc) -> Self { self.system = system; self @@ -196,6 +207,7 @@ impl AdminHarness { self.accounts, self.account_runtime, self.account_groups, + self.proxies, ), self.auth, self.client_keys, @@ -205,7 +217,7 @@ impl AdminHarness { ), self.providers, Arc::new(NoopSnapshot), - self.probe, + (self.probe, Arc::new(proxies::TestProxies::default())), Arc::new(NoopClientDistribution), self.system, ) diff --git a/backend/crates/gateway-admin/tests/use_case/openai.rs b/backend/crates/gateway-admin/tests/use_case/openai.rs index ba22e59d0..bee8fff33 100644 --- a/backend/crates/gateway-admin/tests/use_case/openai.rs +++ b/backend/crates/gateway-admin/tests/use_case/openai.rs @@ -50,6 +50,7 @@ async fn openai_import_should_prepare_before_atomic_store_commit() { services .openai() .import_document(ImportCredentials { + outbound_proxy_id: None, settings: Some(super::accounts::import_settings()), context: context("import-openai"), document: document(), @@ -107,6 +108,7 @@ async fn openai_import_should_expose_only_explicit_public_errors_without_committ let error = services .openai() .import_document(ImportCredentials { + outbound_proxy_id: None, settings: None, context: context("import-openai-pat-failure"), document: document(), @@ -132,6 +134,7 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() { let result = services .openai() .import_document(ImportCredentials { + outbound_proxy_id: None, settings: None, context: context("import-openai-batch"), document: document(), @@ -176,6 +179,7 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() { let result = services .openai() .import_document(ImportCredentials { + outbound_proxy_id: None, settings: None, context: context("import-openai-quota-failure"), document: document(), @@ -348,6 +352,7 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() { services .openai() .import_document(ImportCredentials { + outbound_proxy_id: None, settings: None, context: context("import-openai-error"), document: document(), diff --git a/backend/crates/gateway-admin/tests/use_case/proxies.rs b/backend/crates/gateway-admin/tests/use_case/proxies.rs new file mode 100644 index 000000000..42b4c0708 --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/proxies.rs @@ -0,0 +1,227 @@ +use async_trait::async_trait; +use gateway_admin::{ + model::{MutationContext, Revision, proxies::*}, + ports::{ + proxy::{ProxyProbe, ProxyStore}, + store::AdminStoreResult, + }, +}; +use gateway_core::account::{OutboundProxy, ProviderAccountId}; + +#[derive(Default)] +pub(super) struct TestProxies { + pub events: Option, + pub accounts: Option>, +} + +struct ImportGuard(super::accounts::EventLog); + +impl gateway_admin::ports::proxy::ProxyImportGuard for ImportGuard {} + +impl Drop for ImportGuard { + fn drop(&mut self) { + self.0.lock().unwrap().push("proxy.release"); + } +} + +#[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, + ) -> AdminStoreResult { + let events = self + .events + .as_ref() + .ok_or_else(|| super::unavailable("proxy"))?; + events.lock().unwrap().push("proxy.reserve"); + Ok(gateway_admin::ports::proxy::ProxyImportReservation { + binding: ImportProxyBinding { + id: id.to_owned(), + proxy: OutboundProxy::parse("http://127.0.0.1:8080").unwrap(), + }, + guard: Box::new(ImportGuard(events.clone())), + }) + } + 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")) + } + async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult { + Err(super::unavailable("proxy")) + } + async fn update(&self, _: UpdateProxy, _: &MutationContext) -> AdminStoreResult { + Err(super::unavailable("proxy")) + } + async fn delete( + &self, + _: &str, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + Err(super::unavailable("proxy")) + } + async fn record_test( + &self, + _: &str, + _: Revision, + _: ProxyTestResult, + _: &MutationContext, + ) -> AdminStoreResult { + Err(super::unavailable("proxy")) + } +} + +#[async_trait] +impl ProxyProbe for TestProxies { + async fn test(&self, _: &OutboundProxy) -> ProxyTestResult { + panic!("unexpected proxy probe") + } +} + +#[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::{ + FakeAccountStore, FakeProviderAdmin, context, document, events, recorded, + }; + use gateway_admin::model::provider_credentials::ImportCredentials; + use gateway_admin::ports::provider::ProviderAdminErrorKind; + use std::sync::Arc; + + for kind in ["openai", "xai"] { + for failure in [None, Some("prepare"), Some("commit")] { + let events = events(); + let provider = FakeProviderAdmin::new(kind, events.clone()); + let store = FakeAccountStore::new(kind, events.clone()); + if failure == Some("prepare") { + provider.fail_next(ProviderAdminErrorKind::Unavailable); + } + if failure == Some("commit") { + store.fail_next_commit(); + } + let services = super::AdminHarness::new() + .provider(provider) + .accounts(store) + .proxies(Arc::new(TestProxies { + events: Some(events.clone()), + ..Default::default() + })) + .build() + .await; + let command = ImportCredentials { + outbound_proxy_id: Some("proxy_import".to_owned()), + settings: None, + context: context("reserved-import"), + document: document(), + }; + let result = if kind == "openai" { + services.openai().import_document(command).await + } else { + services.xai().import_document(command).await + }; + assert_eq!(result.is_err(), failure.is_some()); + let events = recorded(&events); + let expected = if failure == Some("prepare") { + vec!["proxy.reserve", "provider.prepare_import", "proxy.release"] + } else { + vec![ + "proxy.reserve", + "provider.prepare_import", + "store.commit_import", + "proxy.release", + ] + }; + assert_eq!(&events[..expected.len()], expected); + } + } +} diff --git a/backend/crates/gateway-admin/tests/use_case/xai.rs b/backend/crates/gateway-admin/tests/use_case/xai.rs index a58f98d6b..1164c5c50 100644 --- a/backend/crates/gateway-admin/tests/use_case/xai.rs +++ b/backend/crates/gateway-admin/tests/use_case/xai.rs @@ -20,6 +20,7 @@ async fn xai_import_should_prepare_before_atomic_store_commit() { services .xai() .import_document(ImportCredentials { + outbound_proxy_id: None, settings: Some(super::accounts::import_settings()), context: context("import-xai"), document: document(), @@ -61,6 +62,7 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() { services .xai() .import_document(ImportCredentials { + outbound_proxy_id: None, settings: None, context: context("import-xai-batch"), document: document(), @@ -96,6 +98,7 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() { let result = services .xai() .import_document(ImportCredentials { + outbound_proxy_id: None, settings: None, context: context("import-xai-quota-failure"), document: document(), diff --git a/backend/crates/gateway-api/src/admin/accounts/credentials.rs b/backend/crates/gateway-api/src/admin/accounts/credentials.rs index 9e06c9ab4..2f9d4d5c3 100644 --- a/backend/crates/gateway-api/src/admin/accounts/credentials.rs +++ b/backend/crates/gateway-api/src/admin/accounts/credentials.rs @@ -53,6 +53,7 @@ impl AccountImportSettingsRequest { #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AccountImportRequest { + pub outbound_proxy_id: Option, pub settings: Option, pub provider: String, pub data: Value, @@ -60,6 +61,9 @@ pub struct AccountImportRequest { impl AccountImportRequest { pub fn validate(&self) -> Result<(), WireValidationError> { + if let Some(id) = &self.outbound_proxy_id { + require_wire_id(id, "outboundProxyId")?; + } if let Some(settings) = &self.settings { settings.validate()?; } @@ -82,6 +86,7 @@ impl AccountImportRequest { Ok(( provider, ImportCredentials { + outbound_proxy_id: self.outbound_proxy_id, settings: self .settings .map(AccountImportSettingsRequest::into_settings) @@ -96,6 +101,7 @@ impl AccountImportRequest { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct StartAccountAuthorizationRequest { + pub outbound_proxy_id: Option, pub provider: String, pub name: String, pub account_id: Option, @@ -129,7 +135,10 @@ impl StartAccountAuthorizationRequest { context, name: self.name, reauthorization, - outbound_proxy: self.outbound_proxy_url.and_then(|value| value.0), + outbound_proxy: super::wire::proxy_selection( + self.outbound_proxy_id, + self.outbound_proxy_url, + )?, }, )) } @@ -188,6 +197,7 @@ impl CompleteAccountAuthorizationRequest { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateAccountRequest { + pub outbound_proxy_id: Option, pub outbound_proxy_url: Option, pub account_id: String, pub enabled: bool, @@ -209,7 +219,10 @@ impl UpdateAccountRequest { pub(super) fn into_command(self) -> Result { self.validate()?; Ok(UpdateAccount { - outbound_proxy: self.outbound_proxy_url.map(|value| value.0), + outbound_proxy: super::wire::proxy_selection( + self.outbound_proxy_id, + self.outbound_proxy_url, + )?, account_id: self.account_id, enabled: self.enabled, concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?, diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index 80b7742de..e630a868f 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -17,6 +17,33 @@ impl<'de> Deserialize<'de> for AccountProxyUpdate { } } +pub(super) fn proxy_selection( + id: Option, + url: Option, +) -> Result, WireValidationError> { + use gateway_admin::model::proxies::AccountProxySelection; + match (id, url) { + (Some(_), Some(_)) => Err(WireValidationError::new("outboundProxyId")), + (Some(id), None) if id.is_empty() => Ok(Some(AccountProxySelection::Direct)), + (Some(id), None) => { + if id.len() > 128 + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(WireValidationError::new("outboundProxyId")); + } + Ok(Some(AccountProxySelection::Saved(id))) + } + (None, Some(value)) => Ok(Some( + value + .0 + .map_or(AccountProxySelection::Direct, AccountProxySelection::Url), + )), + (None, None) => Ok(None), + } +} + /// 账号列表查询参数。 #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -34,6 +61,7 @@ pub struct ListQuery { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct BatchUpdateAccountsRequest { + pub outbound_proxy_id: Option, pub outbound_proxy_url: Option, pub account_ids: Vec, pub enabled: bool, @@ -84,7 +112,7 @@ impl BatchUpdateAccountsRequest { pub(super) fn into_command(self) -> Result { self.validate()?; Ok(BatchUpdateAccounts { - outbound_proxy: self.outbound_proxy_url.map(|value| value.0), + outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?, account_ids: self.account_ids, enabled: self.enabled, concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?, diff --git a/backend/crates/gateway-api/src/admin/mod.rs b/backend/crates/gateway-api/src/admin/mod.rs index c2d6689ef..7da028332 100644 --- a/backend/crates/gateway-api/src/admin/mod.rs +++ b/backend/crates/gateway-api/src/admin/mod.rs @@ -16,6 +16,7 @@ pub mod client_keys; mod extract; pub mod observability; pub mod presenter; +pub mod proxies; pub mod settings; pub mod system; pub mod wire; @@ -34,6 +35,7 @@ where { Router::new() .merge(account_groups::router::()) + .merge(proxies::router::()) .merge(accounts::router::()) .merge(auth::router::()) .merge(backups::router::()) diff --git a/backend/crates/gateway-api/src/admin/proxies.rs b/backend/crates/gateway-api/src/admin/proxies.rs new file mode 100644 index 000000000..5516a030c --- /dev/null +++ b/backend/crates/gateway-api/src/admin/proxies.rs @@ -0,0 +1,414 @@ +use axum::{ + Router, + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, +}; +use gateway_admin::model::{ + PageSize, Revision, + proxies::{ + NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord, UpdateProxy, + }, +}; +use serde::{Deserialize, Serialize}; + +use super::{ + AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState, + PageMeta, + accounts::{AccountGroupRefView, AccountProxyUpdate}, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ListQuery { + page: Option, + page_size: Option, + 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 { + name: String, + proxy_url: AccountProxyUpdate, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct UpdateRequest { + id: String, + revision: u64, + name: String, + proxy_url: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct IdRequest { + id: String, + revision: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProxyTestView { + success: bool, + latency_ms: u64, + exit_ip: Option, + message: String, +} + +#[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)] +#[serde(rename_all = "camelCase")] +struct ProxyView { + id: String, + name: String, + endpoint: String, + has_authentication: bool, + revision: u64, + account_count: u64, + last_test_at: Option, + last_test: Option, + created_at: String, + updated_at: String, +} + +impl From for ProxyView { + fn from(record: ProxyRecord) -> Self { + let endpoint = record.proxy.endpoint(); + Self { + id: record.id, + name: record.name, + has_authentication: record.proxy.expose_url() != endpoint, + endpoint, + 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, + }), + created_at: record.created_at.to_rfc3339(), + updated_at: record.updated_at.to_rfc3339(), + } + } +} + +#[derive(Serialize)] +struct ProxyPageView { + items: Vec, + page: PageMeta, +} + +#[derive(Serialize)] +struct ProxyAccountPageView { + items: Vec, + page: PageMeta, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MutationView { + record: ProxyView, + config_revision: u64, +} + +impl From for MutationView { + fn from(value: ProxyMutation) -> Self { + Self { + record: value.record.into(), + config_revision: value.config_revision.get(), + } + } +} + +pub fn router() -> Router +where + S: AdminSessionState + Clone + Send + Sync + 'static, +{ + 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::)) + .route("/api/admin/proxies/test", post(test::)) +} + +fn revision(value: u64) -> Result { + if value > i64::MAX as u64 { + return Err(AdminError::bad_request("代理版本不合法")); + } + Revision::new(value).map_err(|_| AdminError::bad_request("代理版本不合法")) +} + +fn map_error(error: gateway_admin::model::AdminError) -> AdminError { + super::wire::map_admin_service_error(error) +} + +async fn list( + _: AdminAuth, + State(state): State, + AdminQuery(query): AdminQuery, +) -> Result +where + S: AdminSessionState + Send + Sync, +{ + let result = state + .admin_services() + .proxies() + .list(ProxyListQuery { + 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(ProxyPageView { + items: result.items.into_iter().map(Into::into).collect(), + page: PageMeta::new( + result.page, + u32::from(result.page_size), + result.total, + u32::try_from(total_pages).unwrap_or(u32::MAX), + ), + }), + )) +} + +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, + 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() + .create( + NewProxy { + name: request.name, + proxy, + }, + &auth.context().mutation_context(), + ) + .await + .map_err(map_error)?; + Ok(AdminResponse::new( + StatusCode::CREATED, + AdminEnvelope::ok(MutationView::from(result)), + )) +} + +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, + AdminJson(request): AdminJson, +) -> Result +where + S: AdminSessionState + Send + Sync, +{ + let proxy = request + .proxy_url + .map(|value| { + value + .0 + .ok_or_else(|| AdminError::bad_request("代理 URL 不能为空")) + }) + .transpose()?; + let result = state + .admin_services() + .proxies() + .update( + UpdateProxy { + id: request.id, + revision: revision(request.revision)?, + name: request.name, + proxy, + }, + &auth.context().mutation_context(), + ) + .await + .map_err(map_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(MutationView::from(result)), + )) +} + +async fn delete( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result +where + S: AdminSessionState + Send + Sync, +{ + let result = state + .admin_services() + .proxies() + .delete( + &request.id, + revision(request.revision)?, + &auth.context().mutation_context(), + ) + .await + .map_err(map_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({"configRevision": result.get()})), + )) +} + +async fn test( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result +where + S: AdminSessionState + Send + Sync, +{ + let result = state + .admin_services() + .proxies() + .test( + &request.id, + revision(request.revision)?, + &auth.context().mutation_context(), + ) + .await + .map_err(map_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(ProxyView::from(result)), + )) +} diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index cdc9e339f..18c5c6e5f 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -82,6 +82,7 @@ mod auth; mod client_keys; mod errors; mod observability; +mod proxies; mod settings; mod system; mod wire; @@ -124,7 +125,12 @@ impl AdminTestFixture { dashboard_summary_range: Arc::clone(&dashboard_summary_range), }); let stores = AdminStorePorts::new( - AdminAccountStorePorts::new(unused.clone(), unused.clone(), account_groups.clone()), + AdminAccountStorePorts::new( + unused.clone(), + unused.clone(), + account_groups.clone(), + Arc::new(proxies::MemoryProxies::default()), + ), auth.clone(), client_keys.clone(), unused, @@ -144,7 +150,7 @@ impl AdminTestFixture { stores, providers, Arc::new(NoopSnapshot), - Arc::new(NoopProbe), + (Arc::new(NoopProbe), Arc::new(proxies::SuccessfulProbe)), Arc::new(StaticClientDistribution), system, ) diff --git a/backend/crates/gateway-api/tests/admin/proxies.rs b/backend/crates/gateway-api/tests/admin/proxies.rs new file mode 100644 index 000000000..dda99ec53 --- /dev/null +++ b/backend/crates/gateway-api/tests/admin/proxies.rs @@ -0,0 +1,452 @@ +use std::sync::Mutex; + +use async_trait::async_trait; +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use chrono::Utc; +use gateway_admin::{ + model::{MutationContext, Revision, proxies::*}, + ports::{ + proxy::{ProxyProbe, ProxyStore}, + store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, + }, +}; +use gateway_core::account::{OutboundProxy, ProviderAccountId}; +use serde_json::{Value, json}; +use tower::ServiceExt as _; + +use super::{AdminTestFixture, AdminTestState}; + +#[derive(Default)] +pub(super) struct MemoryProxies(Mutex>); + +fn missing() -> AdminStoreError { + AdminStoreError::new(AdminStoreErrorKind::NotFound, "proxy", "missing proxy") +} + +#[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, + ) -> AdminStoreResult { + Err(missing()) + } + async fn list(&self, query: ProxyListQuery) -> AdminStoreResult { + let items: Vec<_> = self.0.lock().unwrap().iter().cloned().collect(); + Ok(ProxyPage { + total: u64::try_from(items.len()).unwrap(), + items, + page: query.page, + page_size: query.page_size.get(), + }) + } + 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, + _: &MutationContext, + ) -> AdminStoreResult { + let record = ProxyRecord { + id: "proxy_test".to_owned(), + name: command.name, + proxy: command.proxy, + revision: Revision::new(1).unwrap(), + account_count: 0, + last_test: None, + last_test_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + *self.0.lock().unwrap() = Some(record.clone()); + Ok(ProxyMutation { + config_revision: record.revision, + record, + }) + } + async fn update( + &self, + command: UpdateProxy, + _: &MutationContext, + ) -> AdminStoreResult { + let mut stored = self.0.lock().unwrap(); + let record = stored.as_mut().ok_or_else(missing)?; + record.name = command.name; + if let Some(proxy) = command.proxy { + record.proxy = proxy; + } + record.revision = Revision::new(record.revision.get() + 1).unwrap(); + Ok(ProxyMutation { + config_revision: record.revision, + record: record.clone(), + }) + } + async fn delete( + &self, + _: &str, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + self.0.lock().unwrap().take().ok_or_else(missing)?; + Ok(Revision::new(3).unwrap()) + } + async fn record_test( + &self, + _: &str, + _: Revision, + result: ProxyTestResult, + _: &MutationContext, + ) -> AdminStoreResult { + let mut stored = self.0.lock().unwrap(); + let record = stored.as_mut().ok_or_else(missing)?; + record.last_test = Some(result); + record.last_test_at = Some(Utc::now()); + Ok(record.clone()) + } +} + +pub(super) struct SuccessfulProbe; + +#[async_trait] +impl ProxyProbe for SuccessfulProbe { + async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult { + assert_eq!( + proxy.expose_url(), + "http://test-user:private-password@proxy.example:8080/" + ); + ProxyTestResult { + success: true, + latency_ms: 15, + exit_ip: Some("203.0.113.2".parse().unwrap()), + message: "Connected".to_owned(), + } + } +} + +async fn request( + fixture: &AdminTestFixture, + path: &str, + body: Option, + authenticated: bool, +) -> (StatusCode, Value) { + let mut builder = Request::builder() + .header("x-request-id", "req_proxy_tests") + .uri(path) + .method(if body.is_some() { "POST" } else { "GET" }); + if authenticated { + builder = builder.header(header::COOKIE, "cpr_admin_session=valid-session"); + } + let body = body.map_or_else(Body::empty, |body| Body::from(body.to_string())); + let response = gateway_api::admin::proxies::router::() + .with_state(fixture.state()) + .oneshot( + builder + .header(header::CONTENT_TYPE, "application/json") + .body(body) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); + let text = std::str::from_utf8(&body).unwrap(); + assert!(!text.contains("private-password")); + assert!(!text.contains("test-user")); + (status, serde_json::from_slice(&body).unwrap()) +} + +#[tokio::test] +async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_credentials() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let (status, created) = request( + &fixture, + "/api/admin/proxies/create", + Some(json!({ + "name": " Office ", "proxyUrl": "http://test-user:private-password@proxy.example:8080" + })), + true, + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(created["data"]["record"]["name"], "Office"); + assert_eq!(created["data"]["record"]["hasAuthentication"], true); + assert_eq!( + created["data"]["record"]["endpoint"], + "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, + "/api/admin/proxies/test", + Some(json!({"id": "proxy_test", "revision": 1})), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(tested["data"]["lastTest"]["exitIp"], "203.0.113.2"); + let (_, listed) = request( + &fixture, + "/api/admin/proxies?page=1&pageSize=20", + None, + true, + ) + .await; + assert_eq!(listed["data"]["items"][0], tested["data"]); + + let (status, _) = request( + &fixture, + "/api/admin/proxies/update", + Some(json!({"id": "proxy_test", "revision": 1, "name": "Renamed"})), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, _) = request( + &fixture, + "/api/admin/proxies/test", + Some(json!({"id": "proxy_test", "revision": 1})), + true, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + let (status, _) = request( + &fixture, + "/api/admin/proxies/test", + Some(json!({"id": "proxy_test", "revision": 2})), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, _) = request( + &fixture, + "/api/admin/proxies/delete", + Some(json!({"id": "proxy_test", "revision": 2})), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await; + assert_eq!(listed["data"]["page"]["total"], 0); +} + +#[tokio::test] +async fn proxy_routes_require_auth_and_reject_invalid_input() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + assert_eq!( + request(&fixture, "/api/admin/proxies", None, false).await.0, + StatusCode::UNAUTHORIZED + ); + 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, + &format!("/api/admin/proxies/{action}"), + Some(json!({})), + false + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + } + for query in ["page=0", "pageSize=0", "pageSize=201", "unknown=1"] { + assert_eq!( + request(&fixture, &format!("/api/admin/proxies?{query}"), None, true) + .await + .0, + StatusCode::BAD_REQUEST + ); + } + for body in [ + json!({"name":" ","proxyUrl":"http://proxy.example:8080"}), + json!({"name":"Invalid","proxyUrl":""}), + ] { + assert_eq!( + request(&fixture, "/api/admin/proxies/create", Some(body), true) + .await + .0, + StatusCode::BAD_REQUEST + ); + } + let (status, _) = request( + &fixture, + "/api/admin/proxies/create", + Some(json!({"name":"Invalid","proxyUrl":"ftp://test-user:private-password@proxy.example"})), + true, + ) + .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-api/tests/architecture.rs b/backend/crates/gateway-api/tests/architecture.rs index 68ec244fd..6a549410d 100644 --- a/backend/crates/gateway-api/tests/architecture.rs +++ b/backend/crates/gateway-api/tests/architecture.rs @@ -50,6 +50,7 @@ fn source_tree_should_match_frozen_machine_manifest() { "src/admin/observability/routes.rs", "src/admin/observability/wire.rs", "src/admin/presenter.rs", + "src/admin/proxies.rs", "src/admin/settings.rs", "src/admin/system.rs", "src/admin/wire.rs", @@ -96,6 +97,7 @@ fn test_tree_should_match_frozen_rust_mirror() { "tests/admin/observability/mod.rs", "tests/admin/observability/query.rs", "tests/admin/observability/response.rs", + "tests/admin/proxies.rs", "tests/admin/settings.rs", "tests/admin/system.rs", "tests/admin/wire.rs", diff --git a/backend/crates/gateway-host/Cargo.toml b/backend/crates/gateway-host/Cargo.toml index adc0c71b9..15a21f413 100644 --- a/backend/crates/gateway-host/Cargo.toml +++ b/backend/crates/gateway-host/Cargo.toml @@ -17,7 +17,7 @@ futures = "0.3.32" gateway-admin = { path = "../gateway-admin" } gateway-core = { path = "../gateway-core" } hex = "0.4.3" -reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] } +reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots", "socks"] } scraper = "0.27.0" semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } diff --git a/backend/crates/gateway-host/src/lib.rs b/backend/crates/gateway-host/src/lib.rs index ad9d214e8..92d523855 100644 --- a/backend/crates/gateway-host/src/lib.rs +++ b/backend/crates/gateway-host/src/lib.rs @@ -3,6 +3,7 @@ pub mod client_distribution; pub mod config; mod logging; +pub mod proxy_probe; pub mod serve; pub mod system_update; pub mod workers; @@ -81,6 +82,17 @@ impl HostBundle { self.client_distribution.clone() } + #[must_use] + pub fn proxy_probe( + &self, + build_client: impl Fn(reqwest::ClientBuilder) -> Result + + Send + + Sync + + 'static, + ) -> Arc { + Arc::new(proxy_probe::HttpProxyProbe::default().with_client_builder(build_client)) + } + #[must_use] pub fn connection_lifecycle(&self) -> Arc { self.connections.clone() diff --git a/backend/crates/gateway-host/src/proxy_probe.rs b/backend/crates/gateway-host/src/proxy_probe.rs new file mode 100644 index 000000000..772486503 --- /dev/null +++ b/backend/crates/gateway-host/src/proxy_probe.rs @@ -0,0 +1,105 @@ +//! 使用与 Provider 请求一致的显式代理协议,执行有超时和响应大小限制的出口测试。 + +use std::{ + net::IpAddr, + sync::Arc, + time::{Duration, Instant}, +}; + +use async_trait::async_trait; +use gateway_admin::{model::proxies::ProxyTestResult, ports::proxy::ProxyProbe}; +use gateway_core::account::OutboundProxy; +use serde::Deserialize; + +pub struct HttpProxyProbe { + endpoint: String, + build_client: Arc, +} + +type ProxyClientBuilder = + dyn Fn(reqwest::ClientBuilder) -> Result + Send + Sync; + +impl Default for HttpProxyProbe { + fn default() -> Self { + Self::new("https://api.ipify.org?format=json") + } +} + +impl HttpProxyProbe { + #[must_use] + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + build_client: Arc::new(|builder| builder.build().map_err(|_| "无法创建代理连接")), + } + } + + /// 由组合根注入与 Provider 请求一致的证书信任策略。 + #[must_use] + pub fn with_client_builder( + mut self, + build: impl Fn(reqwest::ClientBuilder) -> Result + Send + Sync + 'static, + ) -> Self { + self.build_client = Arc::new(move |builder| { + build(builder).map_err(|_| "无法创建代理连接,请检查证书信任配置") + }); + self + } + + async fn exit_ip(&self, proxy: &OutboundProxy) -> Result { + let proxy = reqwest::Proxy::all(proxy.expose_url()).map_err(|_| "代理地址不合法")?; + let builder = reqwest::Client::builder() + .no_proxy() + .proxy(proxy) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(12)) + .redirect(reqwest::redirect::Policy::none()); + let client = (self.build_client)(builder)?; + let mut response = client.get(&self.endpoint).send().await.map_err(|error| { + if error.is_timeout() { + "代理连接超时" + } else { + "代理连接失败,请检查地址、认证和网络" + } + })?; + if !response.status().is_success() { + return Err( + if response.status() == reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED { + "代理认证失败" + } else { + "出口检测服务返回错误状态" + }, + ); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| "出口检测响应读取失败")? + { + if body.len() + chunk.len() > 1024 { + return Err("出口检测响应过大"); + } + body.extend_from_slice(&chunk); + } + #[derive(Deserialize)] + struct Response { + ip: IpAddr, + } + serde_json::from_slice::(&body) + .map(|response| response.ip) + .map_err(|_| "出口检测响应不合法") + } +} + +#[async_trait] +impl ProxyProbe for HttpProxyProbe { + async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult { + let started = Instant::now(); + let result = tokio::time::timeout(Duration::from_secs(15), self.exit_ip(proxy)).await; + let result = result.unwrap_or(Err("代理连接超时")); + ProxyTestResult { + success: result.is_ok(), + latency_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + exit_ip: result.as_ref().ok().copied(), + message: result.map_or_else(str::to_owned, |_| "连接成功".to_owned()), + } + } +} diff --git a/backend/crates/gateway-host/tests/main.rs b/backend/crates/gateway-host/tests/main.rs index 650ad1be0..689ff5d3f 100644 --- a/backend/crates/gateway-host/tests/main.rs +++ b/backend/crates/gateway-host/tests/main.rs @@ -1,6 +1,7 @@ mod client_distribution; mod config; mod logging; +mod proxy_probe; mod serve; mod system_update; mod workers; diff --git a/backend/crates/gateway-host/tests/proxy_probe.rs b/backend/crates/gateway-host/tests/proxy_probe.rs new file mode 100644 index 000000000..6845b9726 --- /dev/null +++ b/backend/crates/gateway-host/tests/proxy_probe.rs @@ -0,0 +1,78 @@ +use gateway_admin::ports::proxy::ProxyProbe; +use gateway_core::account::OutboundProxy; +use gateway_host::proxy_probe::HttpProxyProbe; +use serde_json::json; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{any, header}, +}; + +#[tokio::test] +async fn proxy_probe_sends_authentication_through_explicit_proxy() { + let proxy_server = MockServer::start().await; + Mock::given(header("proxy-authorization", "Basic dXNlcjpwYXNzd29yZA==")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ip": "203.0.113.8"}))) + .expect(1) + .mount(&proxy_server) + .await; + let proxy = + OutboundProxy::parse(&format!("http://user:password@{}", proxy_server.address())).unwrap(); + let result = HttpProxyProbe::new("http://unresolvable.invalid/ip") + .test(&proxy) + .await; + assert!(result.success); + assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8"); +} + +#[tokio::test] +async fn proxy_probe_rejects_auth_errors_redirects_and_invalid_or_oversized_responses() { + for response in [ + ResponseTemplate::new(407), + ResponseTemplate::new(302).insert_header("Location", "http://127.0.0.1/"), + ResponseTemplate::new(200).set_body_json(json!({"ip": "not-an-ip"})), + ResponseTemplate::new(200).set_body_string("a".repeat(1025)), + ] { + let proxy_server = MockServer::start().await; + Mock::given(any()) + .respond_with(response) + .expect(1) + .mount(&proxy_server) + .await; + let result = HttpProxyProbe::new("http://unresolvable.invalid/ip") + .test(&OutboundProxy::parse(&proxy_server.uri()).unwrap()) + .await; + assert!(!result.success); + assert!(result.exit_ip.is_none()); + } +} + +#[tokio::test] +async fn unavailable_proxy_never_falls_back_to_direct_connection() { + let target = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ip":"203.0.113.8"}))) + .expect(0) + .mount(&target) + .await; + let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = OutboundProxy::parse(&format!("http://{}", unused.local_addr().unwrap())).unwrap(); + drop(unused); + let result = HttpProxyProbe::new(target.uri()).test(&proxy).await; + assert!(!result.success); +} + +#[tokio::test] +async fn invalid_certificate_configuration_should_not_fall_back_or_expose_details() { + let target = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&target) + .await; + let result = HttpProxyProbe::new(target.uri()) + .with_client_builder(|_| Err("private-certificate-path")) + .test(&OutboundProxy::parse(&target.uri()).unwrap()) + .await; + assert!(!result.success); + assert!(!result.message.contains("private-certificate-path")); +} diff --git a/backend/crates/gateway-store/src/bundle.rs b/backend/crates/gateway-store/src/bundle.rs index 1a954f502..9f6c839db 100644 --- a/backend/crates/gateway-store/src/bundle.rs +++ b/backend/crates/gateway-store/src/bundle.rs @@ -106,6 +106,7 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult { )), admin_account_runtime, Arc::new(postgres::PgAccountGroupRepository::new(pool.clone())), + Arc::new(postgres::PgProxyRepository::new(pool.clone())), ), Arc::new(AdminAuthStoreAdapter { security: postgres::PgAdminSecurityAuditRepository::new(pool.clone()), diff --git a/backend/crates/gateway-store/src/postgres/mod.rs b/backend/crates/gateway-store/src/postgres/mod.rs index 9c2550681..ca4307df0 100644 --- a/backend/crates/gateway-store/src/postgres/mod.rs +++ b/backend/crates/gateway-store/src/postgres/mod.rs @@ -22,6 +22,7 @@ mod execution_buffer; mod observability; mod ops_events; mod provider_accounts; +mod proxies; mod retention; mod runtime_settings; mod snapshot; @@ -38,6 +39,7 @@ pub use execution_buffer::*; pub use observability::*; pub use ops_events::*; pub use provider_accounts::*; +pub use proxies::PgProxyRepository; pub use retention::*; pub use runtime_settings::*; pub use snapshot::*; diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index 4f06f43ab..2b98ec716 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -216,6 +216,7 @@ impl PgAdminAccountStore { settings: Option, context: &MutationContext, action: &str, + outbound_proxy: Option, ) -> AdminStoreResult { let provider_kind = prepared.provider_kind.as_str().to_owned(); let accounts = prepared @@ -233,6 +234,7 @@ impl PgAdminAccountStore { .accounts .import_provider_accounts(ImportProviderAccounts { settings, + outbound_proxy, scope: ProviderAccountAdminScope { provider_kind: provider_kind.clone(), }, @@ -627,6 +629,7 @@ impl AccountStore for PgAdminAccountStore { command.settings, context, "import_document", + command.outbound_proxy, ) .await } @@ -650,6 +653,16 @@ impl AccountStore for PgAdminAccountStore { command.settings, context, "authorize", + command + .pending + .outbound_proxy_id() + .zip(command.pending.outbound_proxy()) + .map( + |(id, proxy)| gateway_admin::model::proxies::ImportProxyBinding { + id: id.to_owned(), + proxy: proxy.clone(), + }, + ), ) .await?; let [account_id]: [CoreProviderAccountId; 1] = diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs index d8da8566c..b98911cbe 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs @@ -133,15 +133,28 @@ impl ProviderAccountRepository for PgProviderAccountRepository { } else { CredentialState::Unknown }; + let mut transaction = self + .pool + .begin() + .await + .map_err(|_| postgres_unavailable("begin provider account insert"))?; + let proxy_id = match account.outbound_proxy.as_ref() { + Some(proxy) => Some( + super::super::proxies::ensure_proxy_for_url(&mut transaction, proxy, None) + .await? + .0, + ), + None => None, + }; sqlx::query( "insert into provider_accounts ( - outbound_proxy_url, id, provider_kind, name, email, upstream_user_id, + outbound_proxy_url, outbound_proxy_id, id, provider_kind, name, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision, has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, credential_state, provider_quota_json, credential_observed_at, quota_access_observed_at, quota_observed_at, created_at, updated_at ) values ( - $18, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13, + $18, $19, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13, $14, $15, $16, null, $17, null, null, now(), greatest(now(), $17) )", ) @@ -163,9 +176,14 @@ impl ProviderAccountRepository for PgProviderAccountRepository { .bind(credential_state.as_str()) .bind(account.credential_observed_at) .bind(account.outbound_proxy.as_ref().map(|proxy| proxy.expose_url())) - .execute(&self.pool) + .bind(proxy_id) + .execute(&mut *transaction) .await .map_err(|_| postgres_unavailable("insert provider account"))?; + transaction + .commit() + .await + .map_err(|_| postgres_unavailable("commit provider account insert"))?; Ok(()) } @@ -443,6 +461,22 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository { .map_err(|_| postgres_unavailable("begin provider account admin import"))?; let result = async { let revision = bump_config_revision_in_transaction(&mut transaction).await?; + if let Some(binding) = &command.outbound_proxy { + let (_, current) = super::super::proxies::resolve_proxy_selection( + &mut transaction, + &gateway_admin::model::proxies::AccountProxySelection::Saved( + binding.id.clone(), + ), + ) + .await?; + if current.as_ref() != Some(&binding.proxy) { + return Err(StoreError::Conflict { + entity: "outbound proxy", + id: binding.id.clone(), + kind: ConflictKind::StaleRevision, + }); + } + } let mut account_ids = Vec::with_capacity(command.accounts.len()); for account in &command.accounts { account_ids @@ -701,15 +735,23 @@ pub(crate) async fn upsert_provider_account_in_transaction( } else { CredentialState::Unknown }; + let proxy_id = match account.outbound_proxy.as_ref() { + Some(proxy) => Some( + super::super::proxies::ensure_proxy_for_url(transaction, proxy, None) + .await? + .0, + ), + None => None, + }; let imported_id = sqlx::query_scalar::<_, String>( "insert into provider_accounts ( - outbound_proxy_url, id, provider_kind, name, email, upstream_user_id, + outbound_proxy_url, outbound_proxy_id, id, provider_kind, name, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision, has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, credential_state, provider_quota_json, credential_observed_at, quota_access_observed_at, quota_observed_at, created_at, updated_at ) values ( - $18, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13, + $18, $19, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13, $14, $15, $16, null, $17, null, null, now(), greatest(now(), $17) ) on conflict ( @@ -723,6 +765,7 @@ pub(crate) async fn upsert_provider_account_in_transaction( authentication_kind = excluded.authentication_kind, provider_credentials_json = excluded.provider_credentials_json, outbound_proxy_url = coalesce(excluded.outbound_proxy_url, provider_accounts.outbound_proxy_url), + outbound_proxy_id = coalesce(excluded.outbound_proxy_id, provider_accounts.outbound_proxy_id), credential_revision = provider_accounts.credential_revision + 1, has_refresh_token = excluded.has_refresh_token, access_token_expires_at = excluded.access_token_expires_at, @@ -759,6 +802,7 @@ pub(crate) async fn upsert_provider_account_in_transaction( .bind(credential_state.as_str()) .bind(account.credential_observed_at) .bind(account.outbound_proxy.as_ref().map(|proxy| proxy.expose_url())) + .bind(proxy_id) .fetch_optional(&mut **transaction) .await .map_err(|error| { @@ -865,12 +909,19 @@ pub(crate) async fn update_provider_accounts_scheduling_in_transaction( enabled: bool, concurrency_limit: Option, weight: AccountWeight, - outbound_proxy: Option<&Option>, + outbound_proxy: Option<&gateway_admin::model::proxies::AccountProxySelection>, ) -> StoreResult<()> { + let (proxy_id, proxy) = match outbound_proxy { + Some(selection) => { + super::super::proxies::resolve_proxy_selection(transaction, selection).await? + } + None => (None, None), + }; let updated = sqlx::query_scalar::<_, String>( "update provider_accounts set enabled = $2, concurrency_limit = $3, weight = $4, updated_at = greatest(now(), updated_at), - outbound_proxy_url = case when $5 then $6 else outbound_proxy_url end + outbound_proxy_url = case when $5 then $6 else outbound_proxy_url end, + outbound_proxy_id = case when $5 then $7 else outbound_proxy_id end where id = any($1::text[]) returning id", ) @@ -879,11 +930,8 @@ pub(crate) async fn update_provider_accounts_scheduling_in_transaction( .bind(concurrency_limit.map(|limit| i64::from(limit.get()))) .bind(i16::try_from(weight.get()).map_err(|_| invalid("invalid weight"))?) .bind(outbound_proxy.is_some()) - .bind( - outbound_proxy - .and_then(Option::as_ref) - .map(|proxy| proxy.expose_url()), - ) + .bind(proxy.as_ref().map(gateway_core::account::OutboundProxy::expose_url)) + .bind(proxy_id) .fetch_all(&mut **transaction) .await .map_err(|_| postgres_unavailable("set provider accounts state in admin transaction"))? diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs index ae9d1998e..c963a8462 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs @@ -233,6 +233,7 @@ impl NewProviderAccount { #[derive(Clone)] pub struct ImportProviderAccounts { pub settings: Option, + pub outbound_proxy: Option, pub scope: ProviderAccountAdminScope, pub accounts: Vec, pub audit: AdminAuditEvent, @@ -313,7 +314,7 @@ impl fmt::Debug for RotateProviderAccount { #[derive(Debug, Clone)] pub struct BatchUpdateProviderAccountsAdmin { - pub outbound_proxy: Option>, + pub outbound_proxy: Option, pub account_ids: Vec, pub enabled: bool, pub concurrency_limit: Option, diff --git a/backend/crates/gateway-store/src/postgres/proxies.rs b/backend/crates/gateway-store/src/postgres/proxies.rs new file mode 100644 index 000000000..de95320d0 --- /dev/null +++ b/backend/crates/gateway-store/src/postgres/proxies.rs @@ -0,0 +1,678 @@ +//! 命名代理持有认证信息,账号保留解析后的 URL 供 Provider 传输使用。 + +use std::{collections::BTreeMap, sync::Arc}; + +use async_trait::async_trait; +use gateway_admin::{ + model::{ + MutationContext, Revision as AdminRevision, + account_groups::{AccountGroupColor, AccountGroupRef}, + proxies::*, + }, + ports::{ + proxy::{ProxyImportGuard, ProxyImportReservation, ProxyStore}, + store::{AdminStoreError, AdminStoreResult}, + }, +}; +use gateway_core::{ + account::{OutboundProxy, ProviderAccountId}, + routing::AccountGroupId, +}; +use sqlx::{PgConnection, PgPool, Postgres, QueryBuilder, Row as _, Transaction, postgres::PgRow}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +use super::{append_admin_audit_event_in_transaction, bump_config_revision_in_transaction}; +use crate::{ + ConflictKind, Revision, StoreError, StoreResult, admin_revision, admin_store_error, + mutation_audit, postgres_unavailable, +}; + +const ENTITY: &str = "outbound proxy"; +const SELECT: &str = "select p.*, + (select count(*) from provider_accounts a where a.outbound_proxy_id = p.id) as account_count + from outbound_proxies p"; + +#[derive(Clone)] +pub struct PgProxyRepository { + pool: PgPool, + import_slots: Arc, +} + +impl PgProxyRepository { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { + pool, + import_slots: Arc::new(Semaphore::new(4)), + } + } +} + +struct PgProxyImportGuard { + // 独立会话持有咨询锁,避免空闲事务超时,也不给提交事务占用的连接池制造死锁。 + // 每个仓储最多增加四个会话;取消请求或进程退出时,关闭连接会自动释放锁。 + _connection: PgConnection, + _slot: OwnedSemaphorePermit, +} + +impl ProxyImportGuard for PgProxyImportGuard {} + +async fn exclude_active_imports( + transaction: &mut Transaction<'_, Postgres>, + id: &str, +) -> StoreResult<()> { + let acquired: bool = + sqlx::query_scalar("select pg_try_advisory_xact_lock(hashtextextended($1, 739219))") + .bind(id) + .fetch_one(&mut **transaction) + .await + .map_err(|_| unavailable())?; + if !acquired { + return Err(conflict(id)); + } + Ok(()) +} + +fn store_error(error: StoreError) -> AdminStoreError { + admin_store_error(ENTITY, error) +} +fn unavailable() -> StoreError { + postgres_unavailable("outbound proxy operation") +} +fn conflict(id: &str) -> StoreError { + StoreError::Conflict { + entity: ENTITY, + id: id.to_owned(), + kind: ConflictKind::InvalidTransition, + } +} +fn invalid() -> StoreError { + StoreError::InvalidData { + entity: ENTITY, + message: "invalid proxy record".to_owned(), + } +} + +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())?; + Ok(ProxyRecord { + id: row.try_get("id").map_err(|_| invalid())?, + name: row.try_get("name").map_err(|_| invalid())?, + proxy: OutboundProxy::parse( + &row.try_get::("proxy_url") + .map_err(|_| invalid())?, + ) + .map_err(|_| invalid())?, + revision: AdminRevision::new( + u64::try_from(row.try_get::("revision").map_err(|_| invalid())?) + .map_err(|_| invalid())?, + ) + .map_err(|_| invalid())?, + 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<_> { + Ok(ProxyTestResult { + success, + latency_ms: u64::try_from(latency.ok_or_else(invalid)?) + .map_err(|_| invalid())?, + exit_ip: ip.map(|ip| ip.parse().map_err(|_| invalid())).transpose()?, + message: row + .try_get::, _>("last_test_message") + .map_err(|_| invalid())? + .unwrap_or_default(), + }) + }) + .transpose()?, + created_at: row.try_get("created_at").map_err(|_| invalid())?, + updated_at: row.try_get("updated_at").map_err(|_| invalid())?, + }) +} + +async fn lock_url( + transaction: &mut Transaction<'_, Postgres>, + proxy: &OutboundProxy, +) -> StoreResult<()> { + sqlx::query("select pg_advisory_xact_lock(hashtextextended($1, 739218))") + .bind(proxy.expose_url()) + .execute(&mut **transaction) + .await + .map_err(|_| unavailable())?; + Ok(()) +} + +/// 导入和旧版 URL 写入在账号事务内登记到共享代理目录。 +pub(crate) async fn ensure_proxy_for_url( + transaction: &mut Transaction<'_, Postgres>, + proxy: &OutboundProxy, + name: Option<&str>, +) -> StoreResult<(String, bool)> { + lock_url(transaction, proxy).await?; + if let Some(id) = sqlx::query_scalar::<_, String>("select id from outbound_proxies where proxy_url = $1 order by created_at, id limit 1 for share") + .bind(proxy.expose_url()).fetch_optional(&mut **transaction).await.map_err(|_| unavailable())? { + return Ok((id, false)); + } + let id = format!("proxy_{}", uuid::Uuid::now_v7().simple()); + let generated_name = format!("Imported proxy {}", &id[id.len() - 8..]); + sqlx::query("insert into outbound_proxies (id, name, proxy_url) values ($1, $2, $3)") + .bind(&id) + .bind(name.unwrap_or(&generated_name)) + .bind(proxy.expose_url()) + .execute(&mut **transaction) + .await + .map_err(|_| unavailable())?; + Ok((id, true)) +} + +pub(crate) async fn resolve_proxy_selection( + transaction: &mut Transaction<'_, Postgres>, + selection: &AccountProxySelection, +) -> StoreResult<(Option, Option)> { + match selection { + AccountProxySelection::Direct => Ok((None, None)), + AccountProxySelection::Url(proxy) => { + let (id, _) = ensure_proxy_for_url(transaction, proxy, None).await?; + Ok((Some(id), Some(proxy.clone()))) + } + AccountProxySelection::Saved(id) => { + let (value, tested) = sqlx::query_as::<_, (String, Option)>( + "select proxy_url, last_test_success from outbound_proxies where id = $1 for share", + ) + .bind(id) + .fetch_optional(&mut **transaction) + .await + .map_err(|_| unavailable())? + .ok_or_else(|| StoreError::NotFound { + entity: ENTITY, + id: id.clone(), + })?; + if tested != Some(true) { + return Err(conflict(id)); + } + Ok(( + Some(id.clone()), + Some(OutboundProxy::parse(&value).map_err(|_| invalid())?), + )) + } + } +} + +async fn audit( + transaction: &mut Transaction<'_, Postgres>, + context: &MutationContext, + action: &str, + id: &str, + fields: &[&str], + revision: Revision, +) -> StoreResult<()> { + append_admin_audit_event_in_transaction( + transaction, + mutation_audit( + context, + action, + "outbound_proxy", + id, + fields.iter().map(|value| (*value).to_owned()).collect(), + ), + revision, + ) + .await +} + +#[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 + .clone() + .try_acquire_owned() + .map_err(|_| store_error(conflict(id)))?; + let mut connection = self + .pool + .acquire() + .await + .map_err(|_| store_error(unavailable()))? + .detach(); + let acquired: bool = + sqlx::query_scalar("select pg_try_advisory_lock_shared(hashtextextended($1, 739219))") + .bind(id) + .fetch_one(&mut connection) + .await + .map_err(|_| store_error(unavailable()))?; + if !acquired { + return Err(store_error(conflict(id))); + } + let record = self.get(id).await?; + if !record.last_test.is_some_and(|test| test.success) { + return Err(store_error(conflict(id))); + } + Ok(ProxyImportReservation { + binding: ImportProxyBinding { + id: record.id, + proxy: record.proxy, + }, + guard: Box::new(PgProxyImportGuard { + _connection: connection, + _slot: slot, + }), + }) + } + + async fn list(&self, query: ProxyListQuery) -> AdminStoreResult { + if query.page == 0 { + return Err(store_error(invalid())); + } + let total: i64 = sqlx::query_scalar( + "select count(*) from outbound_proxies where strpos(lower(name), lower($1)) > 0", + ) + .bind(&query.search) + .fetch_one(&self.pool) + .await + .map_err(|_| store_error(unavailable()))?; + let mut builder = QueryBuilder::::new(SELECT); + builder + .push(" where strpos(lower(p.name), lower(") + .push_bind(&query.search) + .push(")) > 0 order by p.created_at desc, p.id limit ") + .push_bind(i64::from(query.page_size.get())) + .push(" offset ") + .push_bind(i64::from(query.page - 1) * i64::from(query.page_size.get())); + let items = builder + .build() + .fetch_all(&self.pool) + .await + .map_err(|_| store_error(unavailable()))? + .into_iter() + .map(record) + .collect::>>() + .map_err(store_error)?; + Ok(ProxyPage { + items, + total: u64::try_from(total).map_err(|_| store_error(invalid()))?, + page: query.page, + page_size: query.page_size.get(), + }) + } + + async fn get(&self, id: &str) -> AdminStoreResult { + let mut builder = QueryBuilder::::new(SELECT); + builder.push(" where p.id = ").push_bind(id); + let row = builder + .build() + .fetch_optional(&self.pool) + .await + .map_err(|_| store_error(unavailable()))? + .ok_or_else(|| { + store_error(StoreError::NotFound { + entity: ENTITY, + id: id.to_owned(), + }) + })?; + record(row).map_err(store_error) + } + + async fn create( + &self, + command: NewProxy, + 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 (id, created) = + ensure_proxy_for_url(&mut transaction, &command.proxy, Some(&command.name)) + .await + .map_err(store_error)?; + if !created { + return Err(store_error(conflict(&id))); + } + audit( + &mut transaction, + context, + "create", + &id, + &["name", "proxy_url"], + revision, + ) + .await + .map_err(store_error)?; + transaction + .commit() + .await + .map_err(|_| store_error(unavailable()))?; + Ok(ProxyMutation { + config_revision: admin_revision(revision)?, + record: self.get(&id).await?, + }) + } + + async fn update( + &self, + command: UpdateProxy, + context: &MutationContext, + ) -> AdminStoreResult { + let mut transaction = self + .pool + .begin() + .await + .map_err(|_| store_error(unavailable()))?; + exclude_active_imports(&mut transaction, &command.id) + .await + .map_err(store_error)?; + let revision = bump_config_revision_in_transaction(&mut transaction) + .await + .map_err(store_error)?; + if let Some(proxy) = &command.proxy { + lock_url(&mut transaction, proxy) + .await + .map_err(store_error)?; + let duplicate: bool = sqlx::query_scalar( + "select exists(select 1 from outbound_proxies where proxy_url = $1 and id <> $2)", + ) + .bind(proxy.expose_url()) + .bind(&command.id) + .fetch_one(&mut *transaction) + .await + .map_err(|_| store_error(unavailable()))?; + if duplicate { + return Err(store_error(conflict(&command.id))); + } + } + let changed = sqlx::query( + "update outbound_proxies set name = $3, proxy_url = coalesce($4, proxy_url), revision = revision + 1, updated_at = now(), + last_test_at = case when $4 is not null and $4 <> proxy_url then null else last_test_at end, + last_test_success = case when $4 is not null and $4 <> proxy_url then null else last_test_success end, + last_test_latency_ms = case when $4 is not null and $4 <> proxy_url then null else last_test_latency_ms end, + last_test_ip = case when $4 is not null and $4 <> proxy_url then null else last_test_ip end, + last_test_message = case when $4 is not null and $4 <> proxy_url then null else last_test_message end + where id = $1 and revision = $2") + .bind(&command.id).bind(i64::try_from(command.revision.get()).map_err(|_| store_error(invalid()))?) + .bind(&command.name).bind(command.proxy.as_ref().map(OutboundProxy::expose_url)) + .execute(&mut *transaction).await.map_err(|_| store_error(unavailable()))?; + if changed.rows_affected() != 1 { + return Err(store_error(conflict(&command.id))); + } + sqlx::query("update provider_accounts a set outbound_proxy_url = p.proxy_url, updated_at = greatest(now(), a.updated_at) from outbound_proxies p where p.id = $1 and a.outbound_proxy_id = p.id and a.outbound_proxy_url is distinct from p.proxy_url") + .bind(&command.id).execute(&mut *transaction).await.map_err(|_| store_error(unavailable()))?; + audit( + &mut transaction, + context, + "update", + &command.id, + &["name", "proxy_url"], + revision, + ) + .await + .map_err(store_error)?; + transaction + .commit() + .await + .map_err(|_| store_error(unavailable()))?; + Ok(ProxyMutation { + config_revision: admin_revision(revision)?, + record: self.get(&command.id).await?, + }) + } + + async fn delete( + &self, + id: &str, + revision: AdminRevision, + context: &MutationContext, + ) -> AdminStoreResult { + let mut transaction = self + .pool + .begin() + .await + .map_err(|_| store_error(unavailable()))?; + exclude_active_imports(&mut transaction, id) + .await + .map_err(store_error)?; + let config_revision = bump_config_revision_in_transaction(&mut transaction) + .await + .map_err(store_error)?; + let result = sqlx::query("delete from outbound_proxies where id = $1 and revision = $2") + .bind(id) + .bind(i64::try_from(revision.get()).map_err(|_| store_error(invalid()))?) + .execute(&mut *transaction) + .await + .map_err(|error| { + // PostgreSQL 18 为 RESTRICT 返回不同于普通外键违规的错误码。 + if error.as_database_error().is_some_and(|error| { + error.is_foreign_key_violation() || error.code().as_deref() == Some("23001") + }) { + store_error(conflict(id)) + } else { + store_error(unavailable()) + } + })?; + if result.rows_affected() != 1 { + return Err(store_error(conflict(id))); + } + audit( + &mut transaction, + context, + "delete", + id, + &[], + config_revision, + ) + .await + .map_err(store_error)?; + transaction + .commit() + .await + .map_err(|_| store_error(unavailable()))?; + admin_revision(config_revision) + } + + async fn record_test( + &self, + id: &str, + revision: AdminRevision, + result: ProxyTestResult, + context: &MutationContext, + ) -> AdminStoreResult { + let mut transaction = self + .pool + .begin() + .await + .map_err(|_| store_error(unavailable()))?; + exclude_active_imports(&mut transaction, id) + .await + .map_err(store_error)?; + let updated = sqlx::query("update outbound_proxies set last_test_at = now(), last_test_success = $3, last_test_latency_ms = $4, last_test_ip = $5, last_test_message = $6 where id = $1 and revision = $2") + .bind(id).bind(i64::try_from(revision.get()).map_err(|_| store_error(invalid()))?) + .bind(result.success).bind(i64::try_from(result.latency_ms).map_err(|_| store_error(invalid()))?) + .bind(result.exit_ip.map(|ip| ip.to_string())).bind(result.message) + .execute(&mut *transaction).await.map_err(|_| store_error(unavailable()))?; + if updated.rows_affected() != 1 { + return Err(store_error(conflict(id))); + } + let current: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id = 1") + .fetch_one(&mut *transaction) + .await + .map_err(|_| store_error(unavailable()))?; + let current = Revision::new(u64::try_from(current).map_err(|_| store_error(invalid()))?) + .map_err(store_error)?; + audit( + &mut transaction, + context, + "test", + id, + &["last_test"], + current, + ) + .await + .map_err(store_error)?; + transaction + .commit() + .await + .map_err(|_| store_error(unavailable()))?; + self.get(id).await + } +} diff --git a/backend/crates/gateway-store/tests/postgres/mod.rs b/backend/crates/gateway-store/tests/postgres/mod.rs index 14484a076..10c8abacf 100644 --- a/backend/crates/gateway-store/tests/postgres/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/mod.rs @@ -22,6 +22,7 @@ mod health; mod observability; mod ops_events; mod provider_accounts; +mod proxies; mod query_budget; mod retention; mod runtime_settings; @@ -235,6 +236,7 @@ async fn connect_and_migrate_should_apply_all_migrations_once_and_reopen_cleanly "client_key_charge_events", "model_requests", "ops_events", + "outbound_proxies", "provider_accounts", "runtime_settings", ] 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 4283d53a4..44945719a 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -1224,7 +1224,7 @@ async fn account_proxy_edits_preserve_credentials_and_clear_egress_without_audit store .update_account( UpdateAccount { - outbound_proxy: Some(Some( + outbound_proxy: Some(gateway_admin::model::proxies::AccountProxySelection::Url( gateway_core::account::OutboundProxy::parse( "socks5h://next:new-secret@127.0.0.1:1080", ) @@ -1240,7 +1240,7 @@ async fn account_proxy_edits_preserve_credentials_and_clear_egress_without_audit store .update_account( UpdateAccount { - outbound_proxy: Some(None), + outbound_proxy: Some(gateway_admin::model::proxies::AccountProxySelection::Direct), ..command }, &context, @@ -1546,6 +1546,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it() let imported = repository .import_provider_accounts(ImportProviderAccounts { settings: None, + outbound_proxy: None, scope: scope.clone(), accounts: vec![account("acct_admin_upsert", "user-admin-upsert")], audit: audit("audit_admin_upsert_create", "import", "acct_admin_upsert"), @@ -1569,6 +1570,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it() let imported = repository .import_provider_accounts(ImportProviderAccounts { settings: None, + outbound_proxy: None, scope: scope.clone(), accounts: vec![updated], audit: audit("audit_admin_upsert_update", "import", "acct_admin_upsert"), @@ -1604,6 +1606,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it() repository .import_provider_accounts(ImportProviderAccounts { settings: None, + outbound_proxy: None, scope, accounts: vec![account("acct_admin_upsert", "another-user")], audit: audit("audit_admin_upsert_rebind", "import", "acct_admin_upsert"), @@ -1693,6 +1696,111 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser database.close().await; } +#[tokio::test] +async fn authorization_import_rejects_a_saved_proxy_changed_during_oauth() { + use gateway_admin::{ + model::proxies::{NewProxy, ProxyTestResult, UpdateProxy}, + ports::proxy::ProxyStore, + }; + let Some(database) = TestDatabase::create("oauth_proxy_guard").await else { + return; + }; + let proxies = gateway_store::postgres::PgProxyRepository::new(database.pool.clone()); + let context = MutationContext { + actor: MutationActor::System, + request_id: "oauth-proxy".to_owned(), + }; + let original = gateway_core::account::OutboundProxy::parse("http://127.0.0.1:8080").unwrap(); + let saved = proxies + .create( + NewProxy { + name: "OAuth".to_owned(), + proxy: original.clone(), + }, + &context, + ) + .await + .unwrap() + .record; + let success = ProxyTestResult { + success: true, + latency_ms: 1, + exit_ip: Some("203.0.113.5".parse().unwrap()), + message: "Connected".to_owned(), + }; + proxies + .record_test(&saved.id, saved.revision, success.clone(), &context) + .await + .unwrap(); + let replacement = gateway_core::account::OutboundProxy::parse("http://127.0.0.1:9090").unwrap(); + let edited = proxies + .update( + UpdateProxy { + id: saved.id.clone(), + revision: saved.revision, + name: saved.name, + proxy: Some(replacement.clone()), + }, + &context, + ) + .await + .unwrap() + .record; + proxies + .record_test(&edited.id, edited.revision, success, &context) + .await + .unwrap(); + let repository = PgProviderAccountRepository::new(database.pool.clone()); + let mut candidate = account("acct_oauth_proxy", "oauth-user"); + candidate.outbound_proxy = Some(original); + let command = |candidate: NewProviderAccount| ImportProviderAccounts { + settings: None, + outbound_proxy: Some(gateway_admin::model::proxies::ImportProxyBinding { + id: saved.id.clone(), + proxy: candidate.outbound_proxy.clone().unwrap(), + }), + scope: ProviderAccountAdminScope { + provider_kind: "openai".to_owned(), + }, + accounts: vec![candidate], + audit: audit("audit_oauth_proxy", "authorize", "acct_oauth_proxy"), + }; + assert!( + repository + .import_provider_accounts(command(candidate.clone())) + .await + .is_err() + ); + assert_eq!(account_count(&database.pool, "acct_oauth_proxy").await, 0); + candidate.outbound_proxy = Some(replacement); + repository + .import_provider_accounts(command(candidate)) + .await + .unwrap(); + assert_eq!( + 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!( + sqlx::query_scalar::<_, i64>("select count(*) from outbound_proxies") + .fetch_one(&database.pool) + .await + .unwrap(), + 1 + ); + database.close().await; +} + #[tokio::test] async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() { let Some(database) = TestDatabase::create("provider_account_core_refresh").await else { @@ -1884,6 +1992,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() { let imported = repository .import_provider_accounts(ImportProviderAccounts { settings: None, + outbound_proxy: None, scope: scope.clone(), accounts: vec![ account("acct_admin_a", "user-admin-a"), @@ -1912,6 +2021,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() { repository .import_provider_accounts(ImportProviderAccounts { settings: None, + outbound_proxy: None, scope: scope.clone(), accounts: vec![ account("acct_admin_transient", "user-admin-transient"), @@ -2101,6 +2211,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi let imported = repository .import_provider_accounts(ImportProviderAccounts { settings: None, + outbound_proxy: None, scope: scope.clone(), accounts: vec![account("acct_grouped_import", "user-grouped-import")], audit: audit("audit_grouped_import", "import", "acct_grouped_import"), @@ -2135,6 +2246,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi repository .import_provider_accounts(ImportProviderAccounts { settings: None, + outbound_proxy: None, scope: scope.clone(), accounts: vec![account("acct_reimport_candidate", "user-grouped-import")], audit: audit("audit_grouped_reimport", "import", "acct_grouped_import"), @@ -2203,6 +2315,7 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() { repository .import_provider_accounts(ImportProviderAccounts { settings: None, + outbound_proxy: None, scope: scope.clone(), accounts: vec![account("acct_rotation_quota", "user-rotation-quota")], audit: audit( @@ -2345,6 +2458,7 @@ async fn disabled_account_preserves_user_state_during_refresh_writes() { repository .import_provider_accounts(ImportProviderAccounts { settings: None, + outbound_proxy: None, scope: scope.clone(), accounts: vec![disabled], audit: audit( @@ -2398,7 +2512,7 @@ async fn disabled_account_preserves_user_state_during_refresh_writes() { database.close().await; } -fn account(id: &str, upstream_user_id: &str) -> NewProviderAccount { +pub(super) fn account(id: &str, upstream_user_id: &str) -> NewProviderAccount { NewProviderAccount { outbound_proxy: None, id: id.to_owned(), @@ -2458,7 +2572,7 @@ fn plaintext_credential(marker: &str) -> PlaintextCredential { ) } -fn audit(id: &str, action: &str, entity_ref: &str) -> AdminAuditEvent { +pub(super) fn audit(id: &str, action: &str, entity_ref: &str) -> AdminAuditEvent { AdminAuditEvent { id: id.to_owned(), actor_kind: AdminAuditActorKind::System, @@ -2604,7 +2718,7 @@ async fn proxy_edit_preserves_an_inflight_token_refresh() { concurrency_limit: None, weight: gateway_core::account::AccountWeight::DEFAULT, group_ids: vec![], - outbound_proxy: Some(Some( + outbound_proxy: Some(gateway_admin::model::proxies::AccountProxySelection::Url( gateway_core::account::OutboundProxy::parse("http://127.0.0.1:18080").unwrap(), )), }, @@ -2663,6 +2777,7 @@ async fn account_import_settings_apply_atomically_to_new_and_existing_identities let result = repository .import_provider_accounts(ImportProviderAccounts { settings: Some(settings.clone()), + outbound_proxy: None, scope: ProviderAccountAdminScope { provider_kind: "openai".to_owned(), }, @@ -2693,6 +2808,7 @@ async fn account_import_settings_apply_atomically_to_new_and_existing_identities .expect("existing account"); let failed = repository .import_provider_accounts(ImportProviderAccounts { + outbound_proxy: None, settings: Some(AccountImportSettings { group_ids: vec![ AccountGroupId::new("grp_00000000000000000000000000000092") diff --git a/backend/crates/gateway-store/tests/postgres/proxies.rs b/backend/crates/gateway-store/tests/postgres/proxies.rs new file mode 100644 index 000000000..ef4e7fa12 --- /dev/null +++ b/backend/crates/gateway-store/tests/postgres/proxies.rs @@ -0,0 +1,707 @@ +use gateway_admin::{ + model::{ + MutationActor, MutationContext, PageSize, + accounts::{BatchUpdateAccounts, UpdateAccount}, + proxies::*, + }, + ports::{ + proxy::ProxyStore, + store::{AccountStore, AdminStoreErrorKind}, + }, +}; +use gateway_core::account::{AccountWeight, OutboundProxy, ProviderAccountId}; +use gateway_store::postgres::{ + PgProviderAccountRepository, PgProxyRepository, ProviderAccountRepository, +}; + +use super::{TestDatabase, admin_account_store, provider_accounts::account}; + +fn context() -> MutationContext { + MutationContext { + actor: MutationActor::System, + request_id: "managed-proxy-test".to_owned(), + } +} + +fn success() -> ProxyTestResult { + ProxyTestResult { + success: true, + latency_ms: 10, + exit_ip: Some("203.0.113.5".parse().unwrap()), + message: "Connected".to_owned(), + } +} + +fn update(account_id: &str, selection: AccountProxySelection) -> UpdateAccount { + UpdateAccount { + account_id: account_id.to_owned(), + enabled: true, + concurrency_limit: None, + weight: AccountWeight::DEFAULT, + group_ids: vec![], + outbound_proxy: Some(selection), + } +} + +#[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::{ + ImportProviderAccounts, ProviderAccountAdminRepository, ProviderAccountAdminScope, + }; + let Some(database) = TestDatabase::create("proxy_import_reservation").await else { + return; + }; + let store = PgProxyRepository::new(database.pool.clone()); + let other_process = PgProxyRepository::new(database.pool.clone()); + let context = context(); + let saved = store + .create( + NewProxy { + name: "导入出口".to_owned(), + proxy: OutboundProxy::parse("http://127.0.0.1:8080").unwrap(), + }, + &context, + ) + .await + .unwrap() + .record; + assert!(store.reserve_import(&saved.id).await.is_err()); + store + .record_test(&saved.id, saved.revision, success(), &context) + .await + .unwrap(); + let reservation = store.reserve_import(&saved.id).await.unwrap(); + let replacement = UpdateProxy { + id: saved.id.clone(), + revision: saved.revision, + name: saved.name, + proxy: Some(OutboundProxy::parse("http://127.0.0.1:9090").unwrap()), + }; + assert!( + other_process + .update(replacement.clone(), &context) + .await + .is_err() + ); + assert!( + other_process + .delete(&saved.id, saved.revision, &context) + .await + .is_err() + ); + assert!( + other_process + .record_test( + &saved.id, + saved.revision, + ProxyTestResult { + success: false, + ..success() + }, + &context + ) + .await + .is_err() + ); + + // 模拟上游已轮换的新凭据;持有保护时,另一个连接仍能完成导入事务。 + let mut candidate = account("acct_reserved_import", "reserved-import-user"); + candidate.outbound_proxy = Some(reservation.binding.proxy.clone()); + let repository = PgProviderAccountRepository::new(database.pool.clone()); + let audit = + super::provider_accounts::audit("audit_reserved_import", "import", "acct_reserved_import"); + tokio::time::timeout( + std::time::Duration::from_secs(5), + repository.import_provider_accounts(ImportProviderAccounts { + settings: None, + outbound_proxy: Some(reservation.binding.clone()), + scope: ProviderAccountAdminScope { + provider_kind: "openai".to_owned(), + }, + accounts: vec![candidate], + audit, + }), + ) + .await + .unwrap() + .unwrap(); + drop(reservation); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if other_process + .update(replacement.clone(), &context) + .await + .is_ok() + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert!( + repository + .load_provider_account("acct_reserved_import") + .await + .unwrap() + .is_some() + ); + database.close().await; +} + +#[tokio::test] +async fn managed_proxies_persist_bind_update_all_accounts_and_protect_stale_tests() { + let Some(database) = TestDatabase::create("managed_proxies").await else { + return; + }; + let accounts = PgProviderAccountRepository::new(database.pool.clone()); + for id in ["acct_one", "acct_two"] { + accounts + .insert_provider_account(account(id, id)) + .await + .unwrap(); + } + let store = PgProxyRepository::new(database.pool.clone()); + let admin = admin_account_store(&database.pool); + let context = context(); + let old_proxy = OutboundProxy::parse("http://user:secret@127.0.0.1:8080").unwrap(); + let created = store + .create( + NewProxy { + name: "Office".to_owned(), + proxy: old_proxy.clone(), + }, + &context, + ) + .await + .unwrap() + .record; + assert_eq!(store.get(&created.id).await.unwrap().proxy, old_proxy); + assert!( + store + .create( + NewProxy { + name: "Duplicate".to_owned(), + proxy: old_proxy.clone() + }, + &context + ) + .await + .is_err() + ); + let selection = AccountProxySelection::Saved(created.id.clone()); + assert!( + admin + .update_account(update("acct_one", selection.clone()), &context) + .await + .is_err() + ); + let read = || async { + sqlx::query_as::<_, (String, Option, Option, i64)>("select id, outbound_proxy_id, outbound_proxy_url, credential_revision from provider_accounts order by id") + .fetch_all(&database.pool).await.unwrap() + }; + assert!( + read() + .await + .iter() + .all(|row| row.1.is_none() && row.2.is_none() && row.3 == 1) + ); + let tested = store + .record_test(&created.id, created.revision, success(), &context) + .await + .unwrap(); + assert!(tested.last_test_at.is_some()); + for id in ["acct_one", "acct_two"] { + admin + .update_account(update(id, selection.clone()), &context) + .await + .unwrap(); + } + assert_eq!(store.get(&created.id).await.unwrap().account_count, 2); + assert_eq!( + store + .delete(&created.id, created.revision, &context) + .await + .unwrap_err() + .kind(), + AdminStoreErrorKind::Conflict + ); + assert!( + read() + .await + .iter() + .all(|row| row.1.as_deref() == Some(created.id.as_str()) + && row.2.as_deref() == Some(old_proxy.expose_url()) + && row.3 == 1) + ); + + let renamed = store + .update( + UpdateProxy { + id: created.id.clone(), + revision: created.revision, + name: "Renamed".to_owned(), + proxy: None, + }, + &context, + ) + .await + .unwrap() + .record; + assert_eq!(renamed.proxy, old_proxy); + assert_eq!(renamed.last_test, Some(success())); + assert!(read().await.iter().all(|row| row.3 == 1)); + let new_proxy = OutboundProxy::parse("socks5h://next:new-secret@127.0.0.1:1080").unwrap(); + let edited = store + .update( + UpdateProxy { + id: created.id.clone(), + revision: renamed.revision, + name: renamed.name, + proxy: Some(new_proxy.clone()), + }, + &context, + ) + .await + .unwrap() + .record; + assert!(edited.last_test.is_none()); + assert!(edited.last_test_at.is_none()); + assert!( + read() + .await + .iter() + .all(|row| row.2.as_deref() == Some(new_proxy.expose_url()) && row.3 == 1) + ); + assert!( + store + .record_test(&created.id, created.revision, success(), &context) + .await + .is_err() + ); + assert!(store.get(&created.id).await.unwrap().last_test.is_none()); + store + .record_test(&created.id, edited.revision, success(), &context) + .await + .unwrap(); + + for id in ["acct_one", "acct_two"] { + admin + .update_account(update(id, AccountProxySelection::Direct), &context) + .await + .unwrap(); + } + assert!( + read() + .await + .iter() + .all(|row| row.1.is_none() && row.2.is_none() && row.3 == 1) + ); + store + .delete(&created.id, edited.revision, &context) + .await + .unwrap(); + assert!(store.get(&created.id).await.is_err()); + let audits: Vec = + sqlx::query_scalar("select to_jsonb(a) from admin_audit_events a") + .fetch_all(&database.pool) + .await + .unwrap(); + assert!(!serde_json::to_string(&audits).unwrap().contains("secret")); + database.close().await; +} + +#[tokio::test] +async fn legacy_urls_join_one_catalog_entry_and_invalid_batch_rolls_back() { + let Some(database) = TestDatabase::create("proxy_catalog_legacy").await else { + return; + }; + let accounts = PgProviderAccountRepository::new(database.pool.clone()); + let proxy = OutboundProxy::parse("http://user:secret@127.0.0.1:8080").unwrap(); + for id in ["acct_one", "acct_two"] { + let mut seed = account(id, id); + seed.outbound_proxy = Some(proxy.clone()); + accounts.insert_provider_account(seed).await.unwrap(); + } + let store = PgProxyRepository::new(database.pool.clone()); + let page = store + .list(ProxyListQuery { + page: 1, + page_size: PageSize::new(20).unwrap(), + search: String::new(), + }) + .await + .unwrap(); + assert_eq!(page.total, 1); + assert_eq!(page.items[0].account_count, 2); + let admin = admin_account_store(&database.pool); + assert!( + admin + .batch_update_accounts( + BatchUpdateAccounts { + account_ids: vec!["acct_one".to_owned(), "acct_missing".to_owned()], + enabled: false, + concurrency_limit: None, + weight: AccountWeight::DEFAULT, + group_ids: vec![], + outbound_proxy: Some(AccountProxySelection::Url( + OutboundProxy::parse("http://127.0.0.1:9090").unwrap() + )), + }, + &context() + ) + .await + .is_err() + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("select count(*) from outbound_proxies") + .fetch_one(&database.pool) + .await + .unwrap(), + 1 + ); + assert_eq!( + sqlx::query_as::<_, (bool, String)>( + "select enabled, outbound_proxy_url from provider_accounts where id = 'acct_one'" + ) + .fetch_one(&database.pool) + .await + .unwrap(), + (true, proxy.expose_url().to_owned()) + ); + database.close().await; +} + +#[tokio::test] +async fn migration_backfills_shared_proxies_without_changing_credentials() { + let Some(database) = TestDatabase::create("proxy_backfill").await else { + return; + }; + let accounts = PgProviderAccountRepository::new(database.pool.clone()); + for id in ["acct_one", "acct_two", "acct_direct"] { + accounts + .insert_provider_account(account(id, id)) + .await + .unwrap(); + } + sqlx::raw_sql("alter table provider_accounts drop column outbound_proxy_id; drop table outbound_proxies; + update provider_accounts set outbound_proxy_url = 'http://user:secret@127.0.0.1:8080/' where id <> 'acct_direct';") + .execute(&database.pool).await.unwrap(); + sqlx::raw_sql(include_str!( + "../../../../migrations/0005_managed_outbound_proxies.sql" + )) + .execute(&database.pool) + .await + .unwrap(); + assert_eq!( + sqlx::query_scalar::<_, i64>("select count(*) from outbound_proxies") + .fetch_one(&database.pool) + .await + .unwrap(), + 1 + ); + assert_eq!(sqlx::query_scalar::<_, i64>("select count(*) from provider_accounts a join outbound_proxies p on a.outbound_proxy_id = p.id where a.outbound_proxy_url = p.proxy_url and a.credential_revision = 1").fetch_one(&database.pool).await.unwrap(), 2); + assert!( + sqlx::query_scalar::<_, Option>( + "select outbound_proxy_id from provider_accounts where id = 'acct_direct'" + ) + .fetch_one(&database.pool) + .await + .unwrap() + .is_none() + ); + database.close().await; +} diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 9614986b8..0a57d313f 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -291,9 +291,10 @@ impl ProviderAdmin for OpenAiAdminProvider { ) -> Result { let prepared = self .credentials - .prepare_import_document(Value::Object( - command.document.into_provider_data().into_inner(), - )) + .prepare_import_document_with_proxy( + Value::Object(command.document.into_provider_data().into_inner()), + command.default_outbound_proxy.as_ref(), + ) .await .inspect_err(|error| { log_import_failure("prepare_document", credential_admin_error_code(error)); diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index 6b25284c1..0ac823dc4 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -824,6 +824,14 @@ impl CodexCredentialAdminService { pub async fn prepare_import_document( &self, payload: Value, + ) -> Result { + self.prepare_import_document_with_proxy(payload, None).await + } + + pub async fn prepare_import_document_with_proxy( + &self, + payload: Value, + default_proxy: Option<&gateway_core::account::OutboundProxy>, ) -> Result { if serde_json::to_vec(&payload) .map_err(|_| CodexCredentialAdminError::InvalidInput)? @@ -832,7 +840,7 @@ impl CodexCredentialAdminService { { return Err(CodexCredentialAdminError::InvalidInput); } - let candidates = parse_import_document(&payload)?; + let candidates = parse_import_document(&payload, default_proxy)?; if candidates.is_empty() || candidates.len() > MAX_BATCH { return Err(CodexCredentialAdminError::InvalidInput); } @@ -1041,6 +1049,7 @@ fn log_manual_refresh_failure(account_id: &ProviderAccountId, error: &RefreshFai fn parse_import_document( payload: &Value, + default_proxy: Option<&gateway_core::account::OutboundProxy>, ) -> Result, CodexCredentialAdminError> { let payload = payload .get("data") @@ -1052,7 +1061,14 @@ fn parse_import_document( continue; } let mut account = parse_oauth_import_account(value)?; - account.outbound_proxy = import_proxy(payload, value)?; + account.outbound_proxy = if ["outboundProxyUrl", "outbound_proxy_url", "proxy_key"] + .iter() + .any(|field| value.get(field).is_some()) + { + import_proxy(payload, value)? + } else { + default_proxy.cloned() + }; accounts.push(account); } Ok(accounts) diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index 9dd6b44d4..dbc98ec8d 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -36,7 +36,7 @@ pub use provider::{ pub mod credential; pub mod transport; -pub use transport::tls::ensure_rustls_provider; +pub use transport::tls::{build_reqwest_client_with_custom_ca, ensure_rustls_provider}; pub use transport::{ CodexCanonicalDecoder, CodexCanonicalError, CodexRequestEncodeError, OpenAiBillingUsage, encode_generate_request, openai_billing_breakdown, diff --git a/backend/crates/providers/openai/src/transport/client.rs b/backend/crates/providers/openai/src/transport/client.rs index eacc51a12..742528c2a 100644 --- a/backend/crates/providers/openai/src/transport/client.rs +++ b/backend/crates/providers/openai/src/transport/client.rs @@ -820,6 +820,12 @@ pub(super) async fn read_error_response_body( pub(super) fn websocket_upstream_request(request: &CodexResponsesRequest) -> CodexResponsesRequest { let mut request = request.clone(); + // 上游通过流式事件执行,下游仍按客户端原来的偏好返回响应。 + if !request.stream() { + request + .body_mut() + .insert("stream".to_owned(), Value::Bool(true)); + } project_websocket_client_metadata(&mut request); request } diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 8e61ba2bd..ed4fdbe14 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -984,6 +984,7 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit( let admin = bundle.admin_provider(); let import_error = admin .prepare_import(PrepareCredentialImport { + default_outbound_proxy: None, document: ProviderDocument::new(OpaqueProviderData::new(Map::new())), }) .await @@ -1025,6 +1026,7 @@ async fn initialized_provider_reports_a_safe_pat_format_error_before_network_acc let error = bundle .admin_provider() .prepare_import(PrepareCredentialImport { + default_outbound_proxy: None, document: ProviderDocument::new(OpaqueProviderData::new(Map::from_iter([( "accessToken".to_owned(), json!("at-sensitive-token with whitespace"), diff --git a/backend/crates/providers/openai/tests/credential/admin.rs b/backend/crates/providers/openai/tests/credential/admin.rs index fcb8a661b..2cdd68d05 100644 --- a/backend/crates/providers/openai/tests/credential/admin.rs +++ b/backend/crates/providers/openai/tests/credential/admin.rs @@ -56,6 +56,54 @@ async fn sub2api_import_resolves_distinct_proxy_bindings_and_encodes_credentials assert!(!format!("{prepared:?}").contains("p:a/ss")); } +#[tokio::test] +async fn import_default_proxy_preserves_explicit_account_exits() { + let service = CodexCredentialAdminService::new( + Arc::new(UnusedRefresher), + Arc::new(TestLeaseCoordinator::default()), + runtime_policy(), + ); + let default_proxy = + gateway_core::account::OutboundProxy::parse("http://default.example:8080").unwrap(); + for (field, value, expected) in [ + ( + None, + serde_json::Value::Null, + Some("http://default.example:8080/"), + ), + ( + Some("outboundProxyUrl"), + serde_json::json!("socks5h://override.example:1080"), + Some("socks5h://override.example:1080"), + ), + (Some("outbound_proxy_url"), serde_json::json!(""), None), + (Some("outboundProxyUrl"), serde_json::Value::Null, None), + (Some("proxy_key"), serde_json::Value::Null, None), + ] { + let mut account = serde_json::json!({"access_token": test_jwt(serde_json::json!({ + "https://api.openai.com/auth": {"chatgpt_user_id": "default-proxy-user"} + }))}); + if let Some(field) = field { + account[field] = value; + } + let prepared = service + .prepare_import_document_with_proxy( + serde_json::json!({"data": {"accounts": [account]}}), + Some(&default_proxy), + ) + .await + .unwrap(); + assert_eq!( + prepared.accounts()[0] + .account + .outbound_proxy() + .map(|proxy| proxy.endpoint()) + .as_deref(), + expected + ); + } +} + #[tokio::test] async fn sub2api_invalid_proxy_bindings_are_rejected_before_any_token_refresh() { let service = CodexCredentialAdminService::new( @@ -746,7 +794,7 @@ async fn oauth_import_rejects_legacy_bare_token_field() { #[tokio::test] async fn pat_import_uses_account_proxy_without_direct_fallback() { - for status in [200, 503] { + for (status, use_default) in [(200, false), (503, false), (200, true), (503, true)] { let origin = MockServer::start().await; let proxy = MockServer::start().await; Mock::given(method("GET")) @@ -756,11 +804,13 @@ async fn pat_import_uses_account_proxy_without_direct_fallback() { .expect(1) .mount(&proxy) .await; + let default_proxy = gateway_core::account::OutboundProxy::parse(&proxy.uri()).unwrap(); + let mut document = serde_json::json!({"accessToken": "at-proxy-token"}); + if !use_default { + document["outboundProxyUrl"] = serde_json::json!(proxy.uri()); + } let imported = pat_service(&origin) - .prepare_import_document(serde_json::json!({ - "accessToken": "at-proxy-token", - "outboundProxyUrl": proxy.uri(), - })) + .prepare_import_document_with_proxy(document, use_default.then_some(&default_proxy)) .await; if status == 200 { let prepared = imported.expect("PAT validated through account proxy"); diff --git a/backend/crates/providers/openai/tests/transport/websocket/mod.rs b/backend/crates/providers/openai/tests/transport/websocket/mod.rs index ea738bf2b..800f7c6ff 100644 --- a/backend/crates/providers/openai/tests/transport/websocket/mod.rs +++ b/backend/crates/providers/openai/tests/transport/websocket/mod.rs @@ -193,6 +193,74 @@ fn websocket_connection_should_prepare_response_create_payload_text() { assert!(payload.get("store").is_none()); } +#[tokio::test] +async fn backend_websocket_should_stream_upstream_for_non_streaming_client_request() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_codex_test_websocket(stream).await; + let Some(Ok(Message::Text(payload))) = websocket.next().await else { + panic!("client should send response.create"); + }; + let payload: Value = serde_json::from_str(payload.as_str()).unwrap(); + websocket + .send(Message::Text( + json!({ + "type": "response.completed", + "response": { + "id": "resp_non_streaming", + "object": "response", + "status": "completed", + "output": [], + "usage": {"input_tokens": 3, "output_tokens": 1, "total_tokens": 4} + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + payload + }); + let request = CodexResponsesRequest::from_body( + json!({ + "model": "gpt-test", + "instructions": "collect this response", + "input": [], + "stream": false, + "previous_response_id": "resp_previous" + }) + .as_object() + .unwrap() + .clone(), + ); + let backend = CodexBackendClient::new( + reqwest::Client::builder().no_proxy().build().unwrap(), + format!("http://{address}"), + test_wire_profile(), + ); + let response = timeout( + Duration::from_secs(5), + backend.create_response( + &request, + request_context("req_non_streaming_ws", Some("acct-non-streaming")), + ), + ) + .await + .unwrap() + .expect("upstream WebSocket response"); + let payload = server.await.unwrap(); + assert_eq!(response.transport, CodexBackendTransport::WebSocket); + assert!(response.body.contains("resp_non_streaming")); + assert_eq!(payload["stream"], true); + assert_eq!(payload["previous_response_id"], "resp_previous"); + assert!( + !request.stream(), + "downstream delivery remains non-streaming" + ); +} + #[test] fn websocket_connection_should_prepare_capture_payload_with_canonical_field_order() { let mut request = codex_request_with_prompt_cache_key( diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index 961ab2c6f..54d7fdce3 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -429,8 +429,11 @@ impl ProviderAdmin for XaiAdminProvider { command.document.into_provider_data().into_inner(), )) .map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?; - let document = GrokOAuthImportDocument::parse_json(&document) - .map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?; + let document = GrokOAuthImportDocument::parse_json_with_proxy( + &document, + command.default_outbound_proxy.as_ref(), + ) + .map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?; let mut subjects = BTreeSet::new(); let mut credentials = Vec::new(); // 逐条目独立成败:verify 会真实轮换 RT,后续条目的任何失败都不得 diff --git a/backend/crates/providers/xai/src/credential/import.rs b/backend/crates/providers/xai/src/credential/import.rs index 8794603f0..4c698191a 100644 --- a/backend/crates/providers/xai/src/credential/import.rs +++ b/backend/crates/providers/xai/src/credential/import.rs @@ -162,6 +162,13 @@ impl GrokOAuthImportDocument { /// 独立代理 URL 随账号导入;其他展示 metadata 不参与认证。 /// 实际 token 仍须通过官方 refresh/user-info 验证;API Key 不能混入 OAuth 条目。 pub fn parse_json(document: &[u8]) -> Result { + Self::parse_json_with_proxy(document, None) + } + + pub fn parse_json_with_proxy( + document: &[u8], + default_proxy: Option<&gateway_core::account::OutboundProxy>, + ) -> Result { if document.is_empty() || document.len() > MAX_IMPORT_DOCUMENT_BYTES { return Err(GrokOAuthImportError::InvalidField("document")); } @@ -174,7 +181,13 @@ impl GrokOAuthImportDocument { let now = Utc::now(); let mut entries = Vec::with_capacity(accounts.len()); for (index, account) in accounts.into_iter().enumerate() { - if let Some(entry) = parse_account_entry(account, index, now)? { + if let Some(mut entry) = parse_account_entry(account, index, now)? { + if !["outboundProxyUrl", "outbound_proxy_url", "proxy_key"] + .iter() + .any(|field| account.get(field).is_some()) + { + entry.outbound_proxy = default_proxy.cloned(); + } entries.push(entry); } } diff --git a/backend/crates/providers/xai/tests/admin.rs b/backend/crates/providers/xai/tests/admin.rs index bbfa3f616..a76d8aaf6 100644 --- a/backend/crates/providers/xai/tests/admin.rs +++ b/backend/crates/providers/xai/tests/admin.rs @@ -413,6 +413,7 @@ async fn xai_admin_provider_rejects_unprepared_mutations_before_store_commit() { let import_error = admin .prepare_import(PrepareCredentialImport { + default_outbound_proxy: None, document: ProviderDocument::new(OpaqueProviderData::new(Map::new())), }) .await diff --git a/backend/crates/providers/xai/tests/credential/import.rs b/backend/crates/providers/xai/tests/credential/import.rs index 0f32ad5b3..f6d0c70fd 100644 --- a/backend/crates/providers/xai/tests/credential/import.rs +++ b/backend/crates/providers/xai/tests/credential/import.rs @@ -204,6 +204,45 @@ fn strict_oauth_account_document_should_parse_without_exposing_credentials() { let _candidate = entry.into_candidate(); } +#[test] +fn import_default_proxy_preserves_explicit_account_exits() { + let default_proxy = + gateway_core::account::OutboundProxy::parse("http://default.example:8080").unwrap(); + for (field, value, expected) in [ + ( + None, + serde_json::Value::Null, + Some("http://default.example:8080/"), + ), + ( + Some("outboundProxyUrl"), + serde_json::json!("socks5h://override.example:1080"), + Some("socks5h://override.example:1080"), + ), + (Some("outbound_proxy_url"), serde_json::json!(""), None), + (Some("outboundProxyUrl"), serde_json::Value::Null, None), + (Some("proxy_key"), serde_json::Value::Null, None), + ] { + let mut document: serde_json::Value = + serde_json::from_slice(&oauth_account_document()).unwrap(); + if let Some(field) = field { + document["accounts"][0][field] = value; + } + let parsed = GrokOAuthImportDocument::parse_json_with_proxy( + &serde_json::to_vec(&document).unwrap(), + Some(&default_proxy), + ) + .unwrap(); + assert_eq!( + parsed.into_entries()[0] + .outbound_proxy() + .map(|proxy| proxy.endpoint()) + .as_deref(), + expected + ); + } +} + #[test] fn oauth_account_document_should_reject_api_key_credential_field() { let mut document: serde_json::Value = diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index ce50996c3..26cb42d5b 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -2,3 +2,4 @@ b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.s 78c427c17d8b5759405f1629a80ceb624012cb6b31371b762078b000266d86c6 0002_client_key_budgets.sql 46f493ff410cf77550114542e0418475876ee2b2c975436290f0f72440e098c4 0003_account_outbound_proxy.sql 5e4bfd0b47a4de5e99b7ae1d89e0294de61856a40fba3480572db765147c3b3a 0004_remove_client_charge_reconciliation.sql +2741fabb6ec23e7a19962e364ffdc054f81584c868e24453e33644b96ccca19b 0005_managed_outbound_proxies.sql diff --git a/backend/migrations/0005_managed_outbound_proxies.sql b/backend/migrations/0005_managed_outbound_proxies.sql new file mode 100644 index 000000000..a1da877d7 --- /dev/null +++ b/backend/migrations/0005_managed_outbound_proxies.sql @@ -0,0 +1,26 @@ +create table outbound_proxies ( + id text primary key, + name text not null check (char_length(name) between 1 and 100), + proxy_url text not null, + revision bigint not null default 1 check (revision > 0), + last_test_at timestamptz, + last_test_success boolean, + last_test_latency_ms bigint, + last_test_ip text, + last_test_message text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table provider_accounts add column outbound_proxy_id text + references outbound_proxies(id) on delete restrict; +create index provider_accounts_outbound_proxy_id_idx on provider_accounts(outbound_proxy_id); + +insert into outbound_proxies (id, name, proxy_url) +select 'proxy_' || gen_random_uuid()::text, + 'Imported proxy ' || row_number() over (order by proxy_url), proxy_url +from (select distinct outbound_proxy_url as proxy_url from provider_accounts + where outbound_proxy_url is not null) existing; + +update provider_accounts a set outbound_proxy_id = p.id +from outbound_proxies p where p.proxy_url = a.outbound_proxy_url; diff --git a/docs/api.md b/docs/api.md index d806c3511..9bc3dddd3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -171,12 +171,12 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。 | `GET` | `/api/admin/accounts` | `page`、`pageSize`、`provider`、`groupId`、`search`、`status`、排序字段 | 分页查询账号与汇总 | | `GET` | `/api/admin/accounts/detail` | `accountId` | 查询账号详情、额度和本地用量 | | `GET` | `/api/admin/accounts/export` | `accountIds`、`confirm=export_sensitive_accounts` | 显式导出最多 200 个账号的敏感 Provider 文档 | -| `POST` | `/api/admin/accounts/import` | `{ provider, data, settings? }` | 导入或按上游身份更新账号,可同时应用调度与分组设置 | +| `POST` | `/api/admin/accounts/import` | `{ provider, data, settings?, outboundProxyId? }` | 导入或按上游身份更新账号,可同时应用调度、分组设置与默认代理 | | `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 | | `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 管理员显式清除该账号的本地错误/额度/cooldown 事实并重新启用,不访问上游 | | `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token | -| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, outboundProxyUrl? }` | 一次更新账号调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 | -| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled, concurrencyLimit, weight, groupIds, outboundProxyUrl? }` | 一次事务统一更新所选账号的调度字段、完整分组集合与可选代理 | +| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, outboundProxyId?, outboundProxyUrl? }` | 一次更新账号调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 | +| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled, concurrencyLimit, weight, groupIds, outboundProxyId?, outboundProxyUrl? }` | 一次事务统一更新所选账号的调度字段、完整分组集合与可选代理 | | `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 | | `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 | | `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 访问 Provider 并刷新额度,同时同步额度所属状态 | @@ -186,7 +186,7 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。 | `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 | | `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖 cache | | `GET` | `/api/admin/accounts/connection-test` | `accountId`、`modelId` | 通过 SSE 返回实时连接测试事件,不作为业务 Responses 用量记录 | -| `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, accountId?, outboundProxyUrl? }` | 创建 OpenAI 或 xAI OAuth flow;`accountId` 表示重新授权 | +| `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, accountId?, outboundProxyId?, outboundProxyUrl? }` | 创建 OpenAI 或 xAI OAuth flow;`accountId` 表示重新授权 | | `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl, settings? }` | 消费 OAuth callback;首次授权可附带账号设置,重新授权保留原设置 | 账号列表支持以下稳定值: @@ -203,13 +203,63 @@ OpenAI 的 `self_serve_business_prolite` 等 Team 套餐显示为 `Business`; 账号套餐为空或 `unknown` 时,后端优先用已保存的上游额度响应 中的明确套餐值补全 `planType` 和 `planTypeDisplay`;两处均无套餐信息时才显示“未知套餐”。 -`outboundProxyUrl` 接受 HTTP、HTTPS、SOCKS5、SOCKS5H 代理 URL,可带用户名和密码。 +`outboundProxyId` 绑定已保存且最近测试成功的代理;省略或 `null` 保留当前绑定,空字符串清除绑定。 +`outboundProxyUrl` 兼容 HTTP、HTTPS、SOCKS5、SOCKS5H 代理 URL,可带用户名和密码;不能与 ID 同时设置。 编辑时省略或 `null` 表示保持原配置,空字符串表示清除代理并直连。列表和详情只返回 不含认证信息的 `outboundProxyEndpoint`(直连时为 `null`);只有显式敏感导出包含完整 URL。 指定代理后,推理、OAuth 服务端交换/刷新及账号辅助请求使用同一出口;代理失败不会退回直连。 浏览器打开的第三方 OAuth 授权页仍使用浏览器自身网络。 账号出口与连接隔离见 [架构说明](architecture.md#账号出站代理)。 +### 独立代理管理 / Managed Proxies + +所有端点要求管理员身份。所有响应只返回去掉认证信息的 `endpoint`,不会返回完整 URL。 +All endpoints require admin authentication and redact proxy credentials from responses. + +| 方法 / 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`、`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 +`lastTest.success=false`; stale revisions, duplicate URLs and deleting an in-use proxy return 409. +The test concurrency limit returns 429. + +测试固定经代理访问 `https://api.ipify.org?format=json`,超时 15 秒,每进程最多同时测试 4 条。 +探测器复用 OpenAI 的证书信任配置:优先读取非空的 `CODEX_CA_CERTIFICATE`, +其次读取 `SSL_CERT_FILE`,并保留系统根证书;证书配置错误不会回退为不验证证书。 +出口测试通过不表示 Provider 账号权限或额度可用;账号可用性使用账号连接测试。 +导入请求可以携带顶层 `outboundProxyId`,在令牌交换前解析为默认出口;文件中显式的代理配置优先。 +文件及 AT/RT 导入从凭据交换到落库期间保护所选代理;此时修改、删除或写入测试结果返回 409, +避免已轮换的凭据因代理状态变化而丢失。完成导入或请求取消后自动释放保护。 +OAuth 等待回调期间不持有保护;提交仍拒绝已删除、连接配置改变或测试失败的代理。 + +Tests reach `https://api.ipify.org?format=json` through the configured proxy, with a 15-second timeout +and four concurrent tests per process. Provider access still requires the account connection test. +Imports accept a top-level `outboundProxyId` as the default exit before token exchange; explicit per-account +settings in the document take precedence. Credential imports reserve their selected proxy until commit; +concurrent proxy mutations return 409. OAuth commits still reject a deleted, changed or failed proxy. + ### 账号连接测试 SSE `GET /api/admin/accounts/connection-test` 固定探测请求指定的账号,不参与普通账号轮换。成功流沿用 @@ -468,6 +518,10 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly 自动结算按网关请求 ID 幂等执行。账本独立于使用统计日志,记录保留至删除 Key, 不受 `usageRetentionDays` 影响。 +English: Daily and weekly budgets use automatically recorded costs. Missing usage or interrupted requests +do not block a Key, and no manual reconciliation is required. New requests receive `429` once recorded +costs reach the daily or weekly limit; requests already admitted can finish above that threshold. + ## 8. 运行设置 | 方法 | 路由 | 说明 | diff --git a/docs/architecture.md b/docs/architecture.md index 4b6e63c3b..3e4184c13 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -189,11 +189,29 @@ Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 ca 连接池按出口隔离。修改代理推进配置 revision,使后续请求使用新出口;已执行请求可沿原连接完成。 代理变更不推进 `credential_revision`,不会使进行中的令牌刷新因凭据版本冲突而丢失结果。 -导入器在认证交换前解析并校验账号出口。sub2api 的代理引用转换为账号上的代理 URL,不创建共享代理资源; +代理独立保存和测试,通过 `outboundProxyId` 绑定账号;账号保留解析后的 URL 供 Provider 使用。 +连接配置改变时在同一事务内同步关联账号并清除旧测试结果,已绑定的代理不可删除。 +关联账号支持单独移除:在原子更新中校验当前代理 ID,只清除账号的代理 ID 和 URL, +保留账号其他设置;提交配置版本与审计后复用快照发布流程,使后续请求使用直连。 +代理目录只携带关联数量;账号明细通过独立分页读端口按需查询,复用代理 ID 索引, +在同一个只读快照中统计并读取有界页面,不在目录中聚合完整账号数组。 +文件及 AT/RT 导入在上游凭据交换前取得 PostgreSQL 会话级共享咨询锁,直到凭据提交后释放。 +代理修改、删除和测试结果写入通过同一资源的事务级独占锁检查,导入期间直接返回冲突。 +锁连接不占用提交所需的连接池槽位,也不持有空闲事务;每进程最多增加四个导入保护连接。 +请求取消或进程退出时关闭会话并释放锁。探测器由组合根注入 OpenAI 的 HTTP 客户端构建函数, +复用其自定义 CA 加载与证书校验规则,Host 不依赖具体 Provider 包。 +迁移 `0005_managed_outbound_proxies.sql` 将已有 URL 按完整连接信息合并为共享代理,保留认证及账号绑定。 + +导入器在认证交换前解析并校验账号出口。sub2api 的代理引用按完整 URL 登记为共享代理并绑定账号; 其 SOCKS5 配置按远端 DNS 语义转换为 SOCKS5H。账号导入只迁移账号及出口,不迁移下游 Key、余额或历史用量。 代理认证信息仅通过敏感账号导出返回,列表、详情、Debug 和普通审计不得暴露;数据库及备份按凭据保护。 输入字段、协议支持与导入校验见 [账号 API](api.md#5-账号)。 +Managed proxies store names, credentials and connectivity results independently. Accounts bind by ID and retain +the resolved URL for provider transports. Connection edits update all bound accounts atomically and invalidate +old tests; in-use proxies cannot be deleted. Migration 0005 preserves existing credentials and account bindings. +Proxy changes advance the runtime configuration revision without invalidating in-flight token refreshes. + ### Codex 原生生图与认证 管理端导出的 Codex 配置使用代理 Bearer 密钥,并声明服务端托管账号认证。 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 5769fca9c..514a4c0b5 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -3,7 +3,6 @@ export * from './modules/account-groups' // 账号管理 export * from './modules/accounts' - // API Keys 管理 export * from './modules/api-keys' @@ -16,6 +15,8 @@ export * from './modules/backups' // Dashboard 聚合 export * from './modules/dashboard' +export * from './modules/proxies' + // 设置管理 export * from './modules/settings' diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 21775943e..b1aaabe11 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -291,6 +291,7 @@ interface AccountResetCreditConsumeParam extends AccountIdParam { interface AccountUpdateParam { outboundProxyUrl?: string + outboundProxyId?: string accountId: string enabled: boolean concurrencyLimit: number | null @@ -300,6 +301,7 @@ interface AccountUpdateParam { interface AccountBatchUpdateParam { outboundProxyUrl?: string + outboundProxyId?: string accountIds: string[] enabled: boolean concurrencyLimit: number | null @@ -320,6 +322,7 @@ interface AccountImportSettings { } interface AccountImportParam { + outboundProxyId?: string settings?: AccountImportSettings provider: string data: unknown @@ -327,6 +330,7 @@ interface AccountImportParam { interface AccountOAuthStartParam { outboundProxyUrl?: string + outboundProxyId?: string provider: string name: string accountId?: string diff --git a/frontend/src/api/modules/proxies.ts b/frontend/src/api/modules/proxies.ts new file mode 100644 index 000000000..1380685b0 --- /dev/null +++ b/frontend/src/api/modules/proxies.ts @@ -0,0 +1,106 @@ +import type { AccountGroupRef } from './account-groups' +import request from '../request' + +export interface OutboundProxyTest { + success: boolean + latencyMs: number + exitIp: string | null + message: string +} + +export interface OutboundProxyRecord { + id: string + name: string + endpoint: string + hasAuthentication: boolean + revision: number + accountCount: number + lastTestAt: string | null + lastTest: OutboundProxyTest | null + createdAt: string + updatedAt: string +} + +interface ProxyPage { + items: OutboundProxyRecord[] + 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, + }) +} + +export function createProxy(data: { name: string, proxyUrl: string }) { + 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, + }) +} + +export function deleteProxy(data: { id: string, revision: number }) { + 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, + }) +} diff --git a/frontend/src/components/base/BaseModal/useModalDrag.ts b/frontend/src/components/base/BaseModal/useModalDrag.ts index 50f515010..992cf0066 100644 --- a/frontend/src/components/base/BaseModal/useModalDrag.ts +++ b/frontend/src/components/base/BaseModal/useModalDrag.ts @@ -33,10 +33,9 @@ export function useModalDrag( if (!target.value) return - target.value.style.transform - = offset.x === 0 && offset.y === 0 - ? '' - : `translate3d(${offset.x}px, ${offset.y}px, 0)` + // 使用相对定位保留普通绘制,避免变换合成层在表格单元格之间产生像素缝。 + target.value.style.left = offset.x === 0 ? '' : `${offset.x}px` + target.value.style.top = offset.y === 0 ? '' : `${offset.y}px` } function removeDragListeners() { 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 }} + ([]) + const loading = shallowRef(false) + + async function loadProxies() { + if (loading.value) + return + loading.value = true + try { + const first = await getProxies({ page: 1, pageSize: 200 }) + const items = [...first.items] + for (let page = 2; page <= first.page.totalPages; page += 1) { + items.push(...(await getProxies({ page, pageSize: 200 })).items) + } + proxies.value = items + } + catch (error) { + toast.error(errorMessage(error, '代理列表加载失败')) + } + finally { + loading.value = false + } + } + + onMounted(() => void loadProxies()) + return { proxies, loading, loadProxies } +} diff --git a/frontend/src/layout/components/AppSidebar.vue b/frontend/src/layout/components/AppSidebar.vue index 4b3980095..209261c34 100644 --- a/frontend/src/layout/components/AppSidebar.vue +++ b/frontend/src/layout/components/AppSidebar.vue @@ -8,6 +8,7 @@ import { LayoutDashboard, LogOut, Moon, + Network, Palette, PanelLeftClose, PanelLeftOpen, @@ -24,6 +25,7 @@ import { useRoute, useRouter } from 'vue-router' import AppBrandMark from '@/components/AppBrandMark.vue' import BaseIconButton from '@/components/base/BaseIconButton.vue' import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue' +import BaseScrollbar from '@/components/base/BaseScrollbar.vue' import { useAuthStore } from '@/stores/modules/auth' import { useSystemUpdateStore } from '@/stores/modules/system-update' import { useThemeStore } from '@/stores/modules/theme' @@ -58,6 +60,7 @@ const preferredMotion = usePreferredReducedMotion() const navItems = [ { label: '概览', icon: LayoutDashboard, path: '/' }, { label: '账号管理', icon: Users, path: '/accounts' }, + { label: '代理管理', icon: Network, path: '/proxies' }, { label: '分组管理', icon: FolderTree, path: '/account-groups' }, { label: 'API 密钥', icon: KeyRound, path: '/api-keys' }, { label: '使用统计', icon: ChartNoAxesColumn, path: '/usage' }, @@ -316,15 +319,15 @@ onBeforeUnmount(() => { diff --git a/frontend/src/views/accounts/composables/useAccountBatchEditor.ts b/frontend/src/views/accounts/composables/useAccountBatchEditor.ts index ea4378e3f..be76d3928 100644 --- a/frontend/src/views/accounts/composables/useAccountBatchEditor.ts +++ b/frontend/src/views/accounts/composables/useAccountBatchEditor.ts @@ -21,7 +21,7 @@ export function useAccountBatchEditor(options: { const concurrencyLimit = shallowRef('') const weight = shallowRef('1') const proxyMode = shallowRef('preserve') - const proxyUrl = shallowRef('') + const proxyId = shallowRef('') const selectedGroupIds = ref([]) const saveAction = useAsyncAction() const saving = saveAction.loading @@ -33,7 +33,7 @@ export function useAccountBatchEditor(options: { schedulingEnabled.value = accounts.every(account => account.enabled) proxyMode.value = 'preserve' - proxyUrl.value = '' + proxyId.value = '' concurrencyLimit.value = sharedConcurrencyLimit(accounts) weight.value = sharedWeight(accounts) selectedGroupIds.value = sharedGroupIds(accounts) @@ -44,8 +44,8 @@ export function useAccountBatchEditor(options: { if (saving.value || options.selectedIds.value.size === 0) return const scheduling = parseAccountSchedulingForm(concurrencyLimit.value, weight.value) - if (proxyMode.value === 'proxy' && !proxyUrl.value.trim()) { - toast.warning('请输入代理 URL') + if (proxyMode.value === 'proxy' && !proxyId.value.trim()) { + toast.warning('请选择已通过测试的代理') return } if (!scheduling.valid) { @@ -57,7 +57,7 @@ export function useAccountBatchEditor(options: { const accountIds = selectedAccounts().map(account => account.id) await batchUpdateAccounts({ accountIds, - outboundProxyUrl: proxyMode.value === 'preserve' ? undefined : proxyMode.value === 'direct' ? '' : proxyUrl.value.trim(), + outboundProxyId: proxyMode.value === 'preserve' ? undefined : proxyMode.value === 'direct' ? '' : proxyId.value.trim(), enabled: schedulingEnabled.value, concurrencyLimit: scheduling.values.concurrencyLimit, weight: scheduling.values.weight, @@ -99,7 +99,7 @@ export function useAccountBatchEditor(options: { return schedulingEnabled.value = true proxyMode.value = 'preserve' - proxyUrl.value = '' + proxyId.value = '' concurrencyLimit.value = '' weight.value = '1' selectedGroupIds.value = [] @@ -111,7 +111,7 @@ export function useAccountBatchEditor(options: { concurrencyLimit, weight, proxyMode, - proxyUrl, + proxyId, selectedGroupIds, saving, open, diff --git a/frontend/src/views/accounts/composables/useAccountEditor.ts b/frontend/src/views/accounts/composables/useAccountEditor.ts index e2a755fe2..4edb27d8c 100644 --- a/frontend/src/views/accounts/composables/useAccountEditor.ts +++ b/frontend/src/views/accounts/composables/useAccountEditor.ts @@ -20,7 +20,7 @@ export function useAccountEditor(options: { const concurrencyLimit = shallowRef('') const weight = shallowRef('1') const proxyMode = shallowRef('preserve') - const proxyUrl = shallowRef('') + const proxyId = shallowRef('') const selectedGroupIds = ref([]) const saveAction = useAsyncAction() const saving = saveAction.loading @@ -34,7 +34,7 @@ export function useAccountEditor(options: { function open(account: AccountRow) { editingAccountId.value = account.id proxyMode.value = 'preserve' - proxyUrl.value = '' + proxyId.value = '' schedulingEnabled.value = account.enabled concurrencyLimit.value = concurrencyLimitInput(account.concurrencyLimit) weight.value = String(account.weight) @@ -47,8 +47,8 @@ export function useAccountEditor(options: { if (!accountId || saving.value) return const scheduling = parseAccountSchedulingForm(concurrencyLimit.value, weight.value) - if (proxyMode.value === 'proxy' && !proxyUrl.value.trim()) { - toast.warning('请输入代理 URL') + if (proxyMode.value === 'proxy' && !proxyId.value.trim()) { + toast.warning('请选择已通过测试的代理') return } if (!scheduling.valid) { @@ -59,7 +59,7 @@ export function useAccountEditor(options: { await saveAction.run(async () => { await updateAccount({ accountId, - outboundProxyUrl: proxyMode.value === 'preserve' ? undefined : proxyMode.value === 'direct' ? '' : proxyUrl.value.trim(), + outboundProxyId: proxyMode.value === 'preserve' ? undefined : proxyMode.value === 'direct' ? '' : proxyId.value.trim(), enabled: schedulingEnabled.value, concurrencyLimit: scheduling.values.concurrencyLimit, weight: scheduling.values.weight, @@ -76,7 +76,7 @@ export function useAccountEditor(options: { return editingAccountId.value = null proxyMode.value = 'preserve' - proxyUrl.value = '' + proxyId.value = '' schedulingEnabled.value = true concurrencyLimit.value = '' weight.value = '1' @@ -90,7 +90,7 @@ export function useAccountEditor(options: { concurrencyLimit, weight, proxyMode, - proxyUrl, + proxyId, selectedGroupIds, saving, open, diff --git a/frontend/src/views/accounts/composables/useAccountOnboarding.ts b/frontend/src/views/accounts/composables/useAccountOnboarding.ts index f65ce22f5..c24d44ef8 100644 --- a/frontend/src/views/accounts/composables/useAccountOnboarding.ts +++ b/frontend/src/views/accounts/composables/useAccountOnboarding.ts @@ -82,7 +82,7 @@ export function useAccountOnboarding(options: { throw new Error(proxyError) const result = await startAccountOAuth({ ...input, - outboundProxyUrl: !account && createForm.value.proxyMode === 'proxy' ? createForm.value.proxyUrl.trim() : undefined, + outboundProxyId: !account && createForm.value.proxyMode === 'proxy' ? createForm.value.proxyId.trim() : undefined, ...(account ? { accountId: account.id, @@ -180,9 +180,8 @@ export function useAccountOnboarding(options: { const result = await importAccounts({ provider, settings: accountImportSettings(createForm.value), - data: createForm.value.proxyMode === 'proxy' - ? withDefaultImportProxy(entry.document, createForm.value.proxyUrl.trim()) - : entry.document, + outboundProxyId: createForm.value.proxyMode === 'proxy' ? createForm.value.proxyId.trim() : undefined, + data: entry.document, }) importedCount += result.importedCount } @@ -199,9 +198,8 @@ export function useAccountOnboarding(options: { const result = await importAccounts({ provider: entry.provider, settings: accountImportSettings(createForm.value), - data: createForm.value.proxyMode === 'proxy' - ? withDefaultImportProxy(entry.document, createForm.value.proxyUrl.trim()) - : entry.document, + outboundProxyId: createForm.value.proxyMode === 'proxy' ? createForm.value.proxyId.trim() : undefined, + data: entry.document, }) importedCount += result.importedCount } @@ -245,7 +243,7 @@ export function useAccountOnboarding(options: { watch( [ () => createForm.value.proxyMode, - () => createForm.value.proxyMode === 'proxy' ? createForm.value.proxyUrl.trim() : '', + () => createForm.value.proxyMode === 'proxy' ? createForm.value.proxyId.trim() : '', ], () => { createForm.value.oauthFlowId = '' @@ -268,20 +266,6 @@ export function useAccountOnboarding(options: { } } -function withDefaultImportProxy(document: Record, proxyUrl: string): Record { - if (isRecord(document.data) && Array.isArray(document.data.accounts)) - return { ...document, data: withDefaultImportProxy(document.data, proxyUrl) } - if (Array.isArray(document.accounts)) { - return { - ...document, - accounts: document.accounts.map(account => isRecord(account) ? withDefaultImportProxy(account, proxyUrl) : account), - } - } - if (['outboundProxyUrl', 'outbound_proxy_url', 'proxy_key'].some(key => Object.hasOwn(document, key))) - return document - return { ...document, outboundProxyUrl: proxyUrl } -} - function parseImportJson(value: string) { try { return JSON.parse(value) diff --git a/frontend/src/views/accounts/index.vue b/frontend/src/views/accounts/index.vue index 7f4e5bea0..8a5cce259 100644 --- a/frontend/src/views/accounts/index.vue +++ b/frontend/src/views/accounts/index.vue @@ -129,7 +129,7 @@ const { concurrencyLimit: batchConcurrencyLimit, weight: batchWeight, proxyMode: batchProxyMode, - proxyUrl: batchProxyUrl, + proxyId: batchProxyId, selectedGroupIds: batchGroupIds, saving: savingBatchEdit, open: openBatchEdit, @@ -148,7 +148,7 @@ const { concurrencyLimit: editingConcurrencyLimit, weight: editingWeight, proxyMode: editingProxyMode, - proxyUrl: editingProxyUrl, + proxyId: editingProxyId, selectedGroupIds: editingGroupIds, saving: savingAccountEdit, open: openAccountEdit, @@ -350,7 +350,7 @@ const { v-model:concurrency-limit="editingConcurrencyLimit" v-model:weight="editingWeight" v-model:proxy-mode="editingProxyMode" - v-model:proxy-url="editingProxyUrl" + v-model:proxy-id="editingProxyId" v-model:selected-group-ids="editingGroupIds" :account="editingAccount" :groups="groups" @@ -365,7 +365,7 @@ const { v-model:concurrency-limit="batchConcurrencyLimit" v-model:weight="batchWeight" v-model:proxy-mode="batchProxyMode" - v-model:proxy-url="batchProxyUrl" + v-model:proxy-id="batchProxyId" v-model:selected-group-ids="batchGroupIds" :selected-count="selectedIds.size" :groups="groups" 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 new file mode 100644 index 000000000..62cba4a0a --- /dev/null +++ b/frontend/src/views/proxies/index.vue @@ -0,0 +1,235 @@ + + +