mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: integrate managed proxies and WebSocket fixes (#60)
Integrate managed account proxies and the non-streaming WebSocket fix. Include shared CA trust, credential import protection, paginated linked account management, and the reviewed interface refinements. Preserve the original contribution and the separate proxy and UI commits. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
|
||||
## 功能
|
||||
|
||||
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;支持独立出站代理及 sub2api OpenAI 账号代理导入。
|
||||
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;代理管理支持独立保存、连接测试和账号绑定,以及 sub2api OpenAI 账号代理导入。
|
||||
- **访问控制**:通过账号分组分配密钥权限,设置日/周 USD 限额、并发和请求频率。
|
||||
- **客户端接入**:支持 Codex CLI、桌面端与 Responses API 客户端,提供 HTTP 流式响应和 WebSocket。
|
||||
- **请求诊断**:查看请求记录、用量、费用、延迟和错误详情。
|
||||
|
||||
Generated
+1
@@ -744,6 +744,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -182,6 +182,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
|
||||
&[
|
||||
"client_distribution",
|
||||
"config",
|
||||
"proxy_probe",
|
||||
"serve",
|
||||
"system_update",
|
||||
"workers",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<dyn ProxiesService>,
|
||||
auth: Arc<dyn AuthService>,
|
||||
accounts: Arc<dyn AccountsService>,
|
||||
account_groups: Arc<dyn AccountGroupService>,
|
||||
@@ -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<Arc<dyn ProviderAdmin>>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
probes: (Arc<dyn AccountProbe>, Arc<dyn ports::proxy::ProxyProbe>),
|
||||
client_distribution: Arc<dyn ClientDistributionResolver>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
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,
|
||||
|
||||
@@ -221,7 +221,7 @@ pub struct UpdateAccount {
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub outbound_proxy: Option<Option<gateway_core::account::OutboundProxy>>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// 账号更新结果。
|
||||
@@ -239,7 +239,7 @@ pub struct BatchUpdateAccounts {
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub outbound_proxy: Option<Option<gateway_core::account::OutboundProxy>>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// 批量账号更新结果。
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ pub struct CredentialDetails {
|
||||
|
||||
/// Provider 正式文档批量导入命令。
|
||||
pub struct ImportCredentials {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub context: MutationContext,
|
||||
pub document: ProviderDocument,
|
||||
@@ -132,6 +133,7 @@ pub struct CredentialImportResult {
|
||||
|
||||
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文。
|
||||
pub struct PrepareCredentialImport {
|
||||
pub default_outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub document: ProviderDocument,
|
||||
}
|
||||
|
||||
@@ -175,6 +177,7 @@ pub struct PreparedCredentialImport {
|
||||
/// Admin 交给 Store 的导入事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialImportCommit {
|
||||
pub outbound_proxy: Option<super::proxies::ImportProxyBinding>,
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub prepared: PreparedCredentialImport,
|
||||
}
|
||||
@@ -255,6 +258,7 @@ pub struct PendingAuthorizationMutation {
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
outbound_proxy_id: Option<String>,
|
||||
}
|
||||
|
||||
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<String>) -> 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<String>,
|
||||
#[serde(default)]
|
||||
outbound_proxy_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -418,7 +439,7 @@ pub struct StartAuthorization {
|
||||
pub context: MutationContext,
|
||||
pub name: String,
|
||||
pub reauthorization: Option<ProviderAccountId>,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// OAuth 流程启动结果。
|
||||
|
||||
@@ -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<String>,
|
||||
pub provider_kind: String,
|
||||
pub authentication_kind: String,
|
||||
pub plan_type: Option<String>,
|
||||
pub plan_type_display: Option<String>,
|
||||
pub groups: Vec<AccountGroupRef>,
|
||||
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<ProxyAccountRef>,
|
||||
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<std::net::IpAddr>,
|
||||
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<DateTime<Utc>>,
|
||||
pub last_test: Option<ProxyTestResult>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyPage {
|
||||
pub items: Vec<ProxyRecord>,
|
||||
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<OutboundProxy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyMutation {
|
||||
pub config_revision: Revision,
|
||||
pub record: ProxyRecord,
|
||||
}
|
||||
@@ -3,5 +3,6 @@
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod provider;
|
||||
pub mod proxy;
|
||||
pub mod store;
|
||||
pub mod system;
|
||||
|
||||
@@ -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<String>,
|
||||
quota: Option<&ProviderQuota>,
|
||||
) -> Option<String> {
|
||||
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,
|
||||
|
||||
@@ -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<ProxyImportReservation>;
|
||||
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage>;
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> AdminStoreResult<ProxyAccountPage>;
|
||||
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord>;
|
||||
/// 仅在账号仍绑定指定代理时解除关联,并清除账号保存的连接地址。
|
||||
async fn remove_account(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
account_id: &ProviderAccountId,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
async fn create(
|
||||
&self,
|
||||
command: NewProxy,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyMutation>;
|
||||
async fn update(
|
||||
&self,
|
||||
command: UpdateProxy,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyMutation>;
|
||||
async fn delete(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
async fn record_test(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
result: ProxyTestResult,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyRecord>;
|
||||
}
|
||||
|
||||
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则。
|
||||
pub trait ProxyImportGuard: Send + Sync {}
|
||||
|
||||
pub struct ProxyImportReservation {
|
||||
pub binding: ImportProxyBinding,
|
||||
pub guard: Box<dyn ProxyImportGuard>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyProbe: Send + Sync {
|
||||
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult;
|
||||
}
|
||||
@@ -382,6 +382,7 @@ pub struct AdminAccountStorePorts {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
runtime: Arc<dyn AccountRuntimeStore>,
|
||||
groups: Arc<dyn AccountGroupStore>,
|
||||
proxies: Arc<dyn super::proxy::ProxyStore>,
|
||||
}
|
||||
|
||||
impl AdminAccountStorePorts {
|
||||
@@ -390,11 +391,13 @@ impl AdminAccountStorePorts {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
runtime: Arc<dyn AccountRuntimeStore>,
|
||||
groups: Arc<dyn AccountGroupStore>,
|
||||
proxies: Arc<dyn super::proxy::ProxyStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
runtime,
|
||||
groups,
|
||||
proxies,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,6 +450,11 @@ impl AdminStorePorts {
|
||||
self.accounts.groups.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn proxies(&self) -> Arc<dyn super::proxy::ProxyStore> {
|
||||
self.accounts.proxies.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auth(&self) -> Arc<dyn AuthStore> {
|
||||
self.auth.clone()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Option<crate::ports::proxy::ProxyImportReservation>, 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<PendingAuthorizationMutation, AdminError> {
|
||||
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(
|
||||
|
||||
@@ -58,6 +58,7 @@ pub trait OpenAiService: Send + Sync {
|
||||
pub(crate) struct DefaultOpenAiService {
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
@@ -66,11 +67,13 @@ impl DefaultOpenAiService {
|
||||
pub(crate) fn new(
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
accounts,
|
||||
proxies,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
@@ -128,9 +131,20 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
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<AuthorizationStarted, AdminError> {
|
||||
let pending = pending_authorization(
|
||||
self.accounts.as_ref(),
|
||||
self.proxies.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&command,
|
||||
"OpenAI credential",
|
||||
|
||||
@@ -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<ProxyPage, AdminError>;
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> Result<ProxyAccountPage, AdminError>;
|
||||
async fn remove_account(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
account_id: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError>;
|
||||
async fn create(
|
||||
&self,
|
||||
command: NewProxy,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyMutation, AdminError>;
|
||||
async fn update(
|
||||
&self,
|
||||
command: UpdateProxy,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyMutation, AdminError>;
|
||||
async fn delete(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError>;
|
||||
async fn test(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyRecord, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultProxiesService {
|
||||
store: Arc<dyn ProxyStore>,
|
||||
probe: Arc<dyn ProxyProbe>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
providers: ProviderAdminRegistry,
|
||||
test_slots: Semaphore,
|
||||
}
|
||||
|
||||
impl DefaultProxiesService {
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn ProxyStore>,
|
||||
probe: Arc<dyn ProxyProbe>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
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<Revision, AdminError> {
|
||||
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<ProxyAccountPage, AdminError> {
|
||||
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<ProxyPage, AdminError> {
|
||||
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<ProxyMutation, AdminError> {
|
||||
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<ProxyMutation, AdminError> {
|
||||
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<Revision, AdminError> {
|
||||
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<ProxyRecord, AdminError> {
|
||||
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<String, AdminError> {
|
||||
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())
|
||||
}
|
||||
@@ -49,6 +49,7 @@ pub trait XaiService: Send + Sync {
|
||||
pub(crate) struct DefaultXaiService {
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
@@ -57,11 +58,13 @@ impl DefaultXaiService {
|
||||
pub(crate) fn new(
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
accounts,
|
||||
proxies,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
@@ -81,9 +84,20 @@ impl XaiService for DefaultXaiService {
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
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<AuthorizationStarted, AdminError> {
|
||||
let pending = pending_authorization(
|
||||
self.accounts.as_ref(),
|
||||
self.proxies.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&command,
|
||||
"xAI credential",
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<dyn AccountStore>,
|
||||
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
|
||||
account_runtime: Arc<dyn AccountRuntimeStore>,
|
||||
account_groups: Arc<dyn AccountGroupStore>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
@@ -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<dyn gateway_admin::ports::proxy::ProxyStore>,
|
||||
) -> Self {
|
||||
self.proxies = proxies;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn system(mut self, system: Arc<dyn SystemOperations>) -> 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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<super::accounts::EventLog>,
|
||||
pub accounts: Option<Vec<ProxyAccountRef>>,
|
||||
}
|
||||
|
||||
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<Revision> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
|
||||
async fn reserve_import(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> AdminStoreResult<gateway_admin::ports::proxy::ProxyImportReservation> {
|
||||
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<ProxyPage> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> AdminStoreResult<ProxyAccountPage> {
|
||||
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<ProxyRecord> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
async fn update(&self, _: UpdateProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
async fn record_test(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: ProxyTestResult,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyRecord> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -53,6 +53,7 @@ impl AccountImportSettingsRequest {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountImportRequest {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub settings: Option<AccountImportSettingsRequest>,
|
||||
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<String>,
|
||||
pub provider: String,
|
||||
pub name: String,
|
||||
pub account_id: Option<String>,
|
||||
@@ -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<String>,
|
||||
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
|
||||
pub account_id: String,
|
||||
pub enabled: bool,
|
||||
@@ -209,7 +219,10 @@ impl UpdateAccountRequest {
|
||||
pub(super) fn into_command(self) -> Result<UpdateAccount, WireValidationError> {
|
||||
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)?,
|
||||
|
||||
@@ -17,6 +17,33 @@ impl<'de> Deserialize<'de> for AccountProxyUpdate {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn proxy_selection(
|
||||
id: Option<String>,
|
||||
url: Option<AccountProxyUpdate>,
|
||||
) -> Result<Option<gateway_admin::model::proxies::AccountProxySelection>, 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<String>,
|
||||
pub outbound_proxy_url: Option<AccountProxyUpdate>,
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
@@ -84,7 +112,7 @@ impl BatchUpdateAccountsRequest {
|
||||
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
|
||||
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)?,
|
||||
|
||||
@@ -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::<S>())
|
||||
.merge(proxies::router::<S>())
|
||||
.merge(accounts::router::<S>())
|
||||
.merge(auth::router::<S>())
|
||||
.merge(backups::router::<S>())
|
||||
|
||||
@@ -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<u32>,
|
||||
page_size: Option<u16>,
|
||||
search: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct AccountsQuery {
|
||||
proxy_id: String,
|
||||
page: Option<u32>,
|
||||
page_size: Option<u16>,
|
||||
search: Option<String>,
|
||||
}
|
||||
|
||||
#[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<AccountProxyUpdate>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProxyAccountView {
|
||||
id: String,
|
||||
name: String,
|
||||
email: Option<String>,
|
||||
provider: String,
|
||||
authentication_kind: String,
|
||||
plan_type: Option<String>,
|
||||
plan_type_display: Option<String>,
|
||||
groups: Vec<AccountGroupRefView>,
|
||||
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<String>,
|
||||
last_test: Option<ProxyTestView>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
impl From<ProxyRecord> 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<ProxyView>,
|
||||
page: PageMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProxyAccountPageView {
|
||||
items: Vec<ProxyAccountView>,
|
||||
page: PageMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MutationView {
|
||||
record: ProxyView,
|
||||
config_revision: u64,
|
||||
}
|
||||
|
||||
impl From<ProxyMutation> for MutationView {
|
||||
fn from(value: ProxyMutation) -> Self {
|
||||
Self {
|
||||
record: value.record.into(),
|
||||
config_revision: value.config_revision.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/proxies", get(list::<S>))
|
||||
.route("/api/admin/proxies/accounts", get(list_accounts::<S>))
|
||||
.route(
|
||||
"/api/admin/proxies/accounts/remove",
|
||||
post(remove_account::<S>),
|
||||
)
|
||||
.route("/api/admin/proxies/create", post(create::<S>))
|
||||
.route("/api/admin/proxies/update", post(update::<S>))
|
||||
.route("/api/admin/proxies/delete", post(delete::<S>))
|
||||
.route("/api/admin/proxies/test", post(test::<S>))
|
||||
}
|
||||
|
||||
fn revision(value: u64) -> Result<Revision, AdminError> {
|
||||
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<S>(
|
||||
_: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<ListQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
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<S>(
|
||||
_: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
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<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<CreateRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
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<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<RemoveAccountRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
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<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<UpdateRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
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<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<IdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
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<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<IdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
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)),
|
||||
))
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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<Option<ProxyRecord>>);
|
||||
|
||||
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<Revision> {
|
||||
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<gateway_admin::ports::proxy::ProxyImportReservation> {
|
||||
Err(missing())
|
||||
}
|
||||
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
|
||||
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<ProxyRecord> {
|
||||
self.0.lock().unwrap().clone().ok_or_else(missing)
|
||||
}
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> AdminStoreResult<ProxyAccountPage> {
|
||||
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<ProxyMutation> {
|
||||
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<ProxyMutation> {
|
||||
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<Revision> {
|
||||
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<ProxyRecord> {
|
||||
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<Value>,
|
||||
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::<AdminTestState>()
|
||||
.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"], "账号的代理绑定已变化,请刷新后重试");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<E>(
|
||||
&self,
|
||||
build_client: impl Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, E>
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
) -> Arc<dyn gateway_admin::ports::proxy::ProxyProbe> {
|
||||
Arc::new(proxy_probe::HttpProxyProbe::default().with_client_builder(build_client))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn connection_lifecycle(&self) -> Arc<dyn ConnectionLifecycle> {
|
||||
self.connections.clone()
|
||||
|
||||
@@ -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<ProxyClientBuilder>,
|
||||
}
|
||||
|
||||
type ProxyClientBuilder =
|
||||
dyn Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, &'static str> + 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<String>) -> Self {
|
||||
Self {
|
||||
endpoint: endpoint.into(),
|
||||
build_client: Arc::new(|builder| builder.build().map_err(|_| "无法创建代理连接")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 由组合根注入与 Provider 请求一致的证书信任策略。
|
||||
#[must_use]
|
||||
pub fn with_client_builder<E>(
|
||||
mut self,
|
||||
build: impl Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, E> + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
self.build_client = Arc::new(move |builder| {
|
||||
build(builder).map_err(|_| "无法创建代理连接,请检查证书信任配置")
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
async fn exit_ip(&self, proxy: &OutboundProxy) -> Result<IpAddr, &'static str> {
|
||||
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::<Response>(&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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod client_distribution;
|
||||
mod config;
|
||||
mod logging;
|
||||
mod proxy_probe;
|
||||
mod serve;
|
||||
mod system_update;
|
||||
mod workers;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -106,6 +106,7 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
|
||||
)),
|
||||
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()),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -216,6 +216,7 @@ impl PgAdminAccountStore {
|
||||
settings: Option<AccountImportSettings>,
|
||||
context: &MutationContext,
|
||||
action: &str,
|
||||
outbound_proxy: Option<gateway_admin::model::proxies::ImportProxyBinding>,
|
||||
) -> AdminStoreResult<CredentialImportResult> {
|
||||
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] =
|
||||
|
||||
@@ -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<AccountConcurrencyLimit>,
|
||||
weight: AccountWeight,
|
||||
outbound_proxy: Option<&Option<gateway_core::account::OutboundProxy>>,
|
||||
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"))?
|
||||
|
||||
@@ -233,6 +233,7 @@ impl NewProviderAccount {
|
||||
#[derive(Clone)]
|
||||
pub struct ImportProviderAccounts {
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub outbound_proxy: Option<gateway_admin::model::proxies::ImportProxyBinding>,
|
||||
pub scope: ProviderAccountAdminScope,
|
||||
pub accounts: Vec<NewProviderAccount>,
|
||||
pub audit: AdminAuditEvent,
|
||||
@@ -313,7 +314,7 @@ impl fmt::Debug for RotateProviderAccount {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BatchUpdateProviderAccountsAdmin {
|
||||
pub outbound_proxy: Option<Option<gateway_core::account::OutboundProxy>>,
|
||||
pub outbound_proxy: Option<gateway_admin::model::proxies::AccountProxySelection>,
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
|
||||
@@ -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<Semaphore>,
|
||||
}
|
||||
|
||||
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<ProxyRecord> {
|
||||
let success: Option<bool> = row.try_get("last_test_success").map_err(|_| invalid())?;
|
||||
let ip: Option<String> = row.try_get("last_test_ip").map_err(|_| invalid())?;
|
||||
let latency: Option<i64> = 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::<String, _>("proxy_url")
|
||||
.map_err(|_| invalid())?,
|
||||
)
|
||||
.map_err(|_| invalid())?,
|
||||
revision: AdminRevision::new(
|
||||
u64::try_from(row.try_get::<i64, _>("revision").map_err(|_| invalid())?)
|
||||
.map_err(|_| invalid())?,
|
||||
)
|
||||
.map_err(|_| invalid())?,
|
||||
account_count: u64::try_from(
|
||||
row.try_get::<i64, _>("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::<Option<String>, _>("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<String>, Option<OutboundProxy>)> {
|
||||
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<bool>)>(
|
||||
"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<AdminRevision> {
|
||||
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<ProxyAccountPage> {
|
||||
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::<StoreResult<Vec<_>>>()
|
||||
.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::<String, Vec<AccountGroupRef>>::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<ProxyImportReservation> {
|
||||
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<ProxyPage> {
|
||||
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::<Postgres>::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::<StoreResult<Vec<_>>>()
|
||||
.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<ProxyRecord> {
|
||||
let mut builder = QueryBuilder::<Postgres>::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<ProxyMutation> {
|
||||
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<ProxyMutation> {
|
||||
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<AdminRevision> {
|
||||
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<ProxyRecord> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<String>, Option<String>) = 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<String>, Option<String>) = 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<String>, Option<String>, 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<serde_json::Value> =
|
||||
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<String>>(
|
||||
"select outbound_proxy_id from provider_accounts where id = 'acct_direct'"
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
@@ -291,9 +291,10 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
) -> Result<PreparedCredentialImport, ProviderAdminError> {
|
||||
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));
|
||||
|
||||
@@ -824,6 +824,14 @@ impl CodexCredentialAdminService {
|
||||
pub async fn prepare_import_document(
|
||||
&self,
|
||||
payload: Value,
|
||||
) -> Result<PreparedCodexAccountImport, CodexCredentialAdminError> {
|
||||
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<PreparedCodexAccountImport, CodexCredentialAdminError> {
|
||||
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<Vec<ParsedCodexImportAccount>, 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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,后续条目的任何失败都不得
|
||||
|
||||
@@ -162,6 +162,13 @@ impl GrokOAuthImportDocument {
|
||||
/// 独立代理 URL 随账号导入;其他展示 metadata 不参与认证。
|
||||
/// 实际 token 仍须通过官方 refresh/user-info 验证;API Key 不能混入 OAuth 条目。
|
||||
pub fn parse_json(document: &[u8]) -> Result<Self, GrokOAuthImportError> {
|
||||
Self::parse_json_with_proxy(document, None)
|
||||
}
|
||||
|
||||
pub fn parse_json_with_proxy(
|
||||
document: &[u8],
|
||||
default_proxy: Option<&gateway_core::account::OutboundProxy>,
|
||||
) -> Result<Self, GrokOAuthImportError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
+59
-5
@@ -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. 运行设置
|
||||
|
||||
| 方法 | 路由 | 说明 |
|
||||
|
||||
+19
-1
@@ -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 密钥,并声明服务端托管账号认证。
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ProxyAccountPage>({
|
||||
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<ProxyPage>({
|
||||
url: '/api/admin/proxies',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function createProxy(data: { name: string, proxyUrl: string }) {
|
||||
return request<ProxyMutation>({
|
||||
url: '/api/admin/proxies/create',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }) {
|
||||
return request<ProxyMutation>({
|
||||
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<OutboundProxyRecord>({
|
||||
url: '/api/admin/proxies/test',
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 25000,
|
||||
})
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<span class="min-w-0 truncate" :class="selectedOption?.description ? 'max-w-1/2 shrink-0' : 'flex-1'">
|
||||
{{ selectedOption?.label ?? placeholder }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedOption?.description"
|
||||
:title="selectedOption.description"
|
||||
class="min-w-0 flex-1 truncate text-xs font-normal"
|
||||
:class="disabled ? 'text-cp-text-disabled' : 'text-cp-text-tertiary'"
|
||||
>
|
||||
{{ selectedOption.description }}
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 transition-transform"
|
||||
:class="
|
||||
@@ -355,7 +364,15 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
@mousedown.prevent
|
||||
@click="chooseOption(option, index)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
|
||||
<span class="min-w-0 truncate" :class="option.description ? 'max-w-1/2 shrink-0' : 'flex-1'">{{ option.label }}</span>
|
||||
<span
|
||||
v-if="option.description"
|
||||
:title="option.description"
|
||||
class="min-w-0 flex-1 truncate text-xs font-normal"
|
||||
:class="option.disabled ? 'text-cp-text-disabled' : 'text-cp-text-tertiary'"
|
||||
>
|
||||
{{ option.description }}
|
||||
</span>
|
||||
<Check
|
||||
v-if="option.value === model"
|
||||
class="shrink-0 text-cp-primary-text"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { OutboundProxyRecord } from '@/api'
|
||||
import { onMounted, shallowRef } from 'vue'
|
||||
import { getProxies } from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { errorMessage } from '@/utils/async'
|
||||
|
||||
export function useProxyCatalog() {
|
||||
const proxies = shallowRef<OutboundProxyRecord[]>([])
|
||||
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 }
|
||||
}
|
||||
@@ -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(() => {
|
||||
<template>
|
||||
<aside
|
||||
ref="sidebarEl"
|
||||
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--cp-layout-sider-bg) px-4 shadow-cp-layout-sider"
|
||||
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--cp-layout-sider-bg) shadow-cp-layout-sider"
|
||||
:class="[
|
||||
mobile ? 'flex' : 'hidden min-[961px]:flex',
|
||||
isCollapsed ? 'w-22 basis-22 items-center' : 'w-62.75 basis-62.75',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="mt-6 grid h-12 grid-cols-[44px_minmax(0,1fr)] items-center"
|
||||
:class="isCollapsed ? 'w-11 justify-start' : 'w-full gap-3'"
|
||||
class="mx-4 mt-6 grid h-12 shrink-0 grid-cols-[44px_minmax(0,1fr)] items-center"
|
||||
:class="isCollapsed ? 'w-11 justify-start' : 'self-stretch gap-3'"
|
||||
>
|
||||
<BaseMotionIcon
|
||||
variant="brand"
|
||||
@@ -355,44 +358,48 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav class="relative mt-6 grid gap-3" :class="isCollapsed ? 'w-11.5' : 'w-full'" aria-label="主导航">
|
||||
<span
|
||||
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-cp bg-cp-menu-item-selected-bg transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||
:style="activeNavIndicatorStyle"
|
||||
>
|
||||
<span
|
||||
ref="navSignal"
|
||||
class="absolute inset-y-0 left-0 w-2/3 [background:linear-gradient(90deg,transparent,color-mix(in_srgb,var(--cp-color-info)_9%,transparent),transparent)]"
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-cp border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
|
||||
:class="[
|
||||
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
|
||||
isActive(item.path)
|
||||
? navFeedbackMuted
|
||||
? 'bg-transparent font-bold text-cp-text transition-none'
|
||||
: 'bg-transparent font-bold text-cp-text transition-colors duration-200'
|
||||
: navFeedbackMuted
|
||||
? 'bg-transparent font-semibold text-cp-text-secondary transition-none'
|
||||
: 'bg-transparent font-semibold text-cp-text-secondary transition-colors duration-200 hover:bg-cp-fill-quaternary hover:text-cp-text',
|
||||
]"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="shrink-0" :size="20" />
|
||||
<span
|
||||
class="sidebar-label overflow-hidden whitespace-nowrap transition-[opacity,transform] duration-200"
|
||||
:class="isCollapsed ? 'pointer-events-none w-0' : 'w-auto'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
<BaseScrollbar class="my-6 w-full flex-1">
|
||||
<div class="px-4">
|
||||
<nav class="relative grid gap-3" :class="isCollapsed ? 'mx-auto w-11.5' : 'w-full'" aria-label="主导航">
|
||||
<span
|
||||
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-cp bg-cp-menu-item-selected-bg transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||
:style="activeNavIndicatorStyle"
|
||||
>
|
||||
<span
|
||||
ref="navSignal"
|
||||
class="absolute inset-y-0 left-0 w-2/3 [background:linear-gradient(90deg,transparent,color-mix(in_srgb,var(--cp-color-info)_9%,transparent),transparent)]"
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-cp border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
|
||||
:class="[
|
||||
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
|
||||
isActive(item.path)
|
||||
? navFeedbackMuted
|
||||
? 'bg-transparent font-bold text-cp-text transition-none'
|
||||
: 'bg-transparent font-bold text-cp-text transition-colors duration-200'
|
||||
: navFeedbackMuted
|
||||
? 'bg-transparent font-semibold text-cp-text-secondary transition-none'
|
||||
: 'bg-transparent font-semibold text-cp-text-secondary transition-colors duration-200 hover:bg-cp-fill-quaternary hover:text-cp-text',
|
||||
]"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="shrink-0" :size="20" />
|
||||
<span
|
||||
class="sidebar-label overflow-hidden whitespace-nowrap transition-[opacity,transform] duration-200"
|
||||
:class="isCollapsed ? 'pointer-events-none w-0' : 'w-auto'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
|
||||
<div class="mt-auto mb-6" :class="isCollapsed ? 'w-11' : 'w-full'">
|
||||
<div class="mx-4 mb-6 shrink-0" :class="isCollapsed ? 'w-11' : 'self-stretch'">
|
||||
<div
|
||||
class="bg-cp-fill-quaternary"
|
||||
:class="isCollapsed ? 'grid gap-1 rounded-cp p-1' : 'flex h-11 items-center justify-between rounded-cp-lg px-2'"
|
||||
|
||||
@@ -20,6 +20,11 @@ export const routes: RouteRecordRaw[] = [
|
||||
name: 'accounts',
|
||||
component: () => import('@/views/accounts/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'proxies',
|
||||
name: 'proxies',
|
||||
component: () => import('@/views/proxies/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'account-groups',
|
||||
name: 'account-groups',
|
||||
|
||||
@@ -21,7 +21,7 @@ const enabled = defineModel<boolean>('enabled', { required: true })
|
||||
const concurrencyLimit = defineModel<string>('concurrencyLimit', { required: true })
|
||||
const weight = defineModel<string>('weight', { required: true })
|
||||
const proxyMode = defineModel<string>('proxyMode', { required: true })
|
||||
const proxyUrl = defineModel<string>('proxyUrl', { required: true })
|
||||
const proxyId = defineModel<string>('proxyId', { required: true })
|
||||
const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: true })
|
||||
</script>
|
||||
|
||||
@@ -39,7 +39,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
v-model:weight="weight"
|
||||
v-model:selected-group-ids="selectedGroupIds"
|
||||
v-model:proxy-mode="proxyMode"
|
||||
v-model:proxy-url="proxyUrl"
|
||||
v-model:proxy-id="proxyId"
|
||||
:groups="groups"
|
||||
:groups-loading="groupsLoading"
|
||||
:disabled="saving"
|
||||
|
||||
@@ -27,7 +27,7 @@ const form = defineModel<AccountCreateForm>({ required: true })
|
||||
v-model:weight="form.weight"
|
||||
v-model:selected-group-ids="form.groupIds"
|
||||
v-model:proxy-mode="form.proxyMode"
|
||||
v-model:proxy-url="form.proxyUrl"
|
||||
v-model:proxy-id="form.proxyId"
|
||||
:groups="groups"
|
||||
:groups-loading="groupsLoading"
|
||||
:preserve-proxy="false"
|
||||
|
||||
@@ -81,7 +81,7 @@ function continueToImport() {
|
||||
:disabled="busy"
|
||||
:groups="groups"
|
||||
:groups-loading="groupsLoading"
|
||||
:proxy-error="form.proxyUrl.trim() ? proxyError : undefined"
|
||||
:proxy-error="form.proxyId.trim() ? proxyError : undefined"
|
||||
/>
|
||||
<p v-if="view.configuring && !scheduling.valid" class="m-0 text-xs text-cp-error" role="alert">
|
||||
{{ scheduling.message }}
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface AccountCreateForm {
|
||||
oauthAuthUrl: string
|
||||
oauthCallback: string
|
||||
proxyMode: string
|
||||
proxyUrl: string
|
||||
proxyId: string
|
||||
}
|
||||
|
||||
export function emptyAccountCreateForm(): AccountCreateForm {
|
||||
@@ -34,25 +34,15 @@ export function emptyAccountCreateForm(): AccountCreateForm {
|
||||
oauthAuthUrl: '',
|
||||
oauthCallback: '',
|
||||
proxyMode: 'direct',
|
||||
proxyUrl: '',
|
||||
proxyId: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function accountProxyError(form: AccountCreateForm): string | undefined {
|
||||
if (form.proxyMode !== 'proxy')
|
||||
return undefined
|
||||
if (!form.proxyUrl.trim())
|
||||
return '请输入代理 URL'
|
||||
try {
|
||||
const url = new URL(form.proxyUrl.trim())
|
||||
if (!['http:', 'https:', 'socks5:', 'socks5h:'].includes(url.protocol) || !url.hostname)
|
||||
return '请输入 HTTP、HTTPS 或 SOCKS5 代理地址'
|
||||
if (url.protocol.startsWith('socks5') && !url.port)
|
||||
return 'SOCKS5 代理地址需要填写端口'
|
||||
}
|
||||
catch {
|
||||
return '请输入有效的代理 URL'
|
||||
}
|
||||
if (!form.proxyId.trim())
|
||||
return '请选择已通过测试的代理'
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const enabled = defineModel<boolean>('enabled', { required: true })
|
||||
const concurrencyLimit = defineModel<string>('concurrencyLimit', { required: true })
|
||||
const weight = defineModel<string>('weight', { required: true })
|
||||
const proxyMode = defineModel<string>('proxyMode', { required: true })
|
||||
const proxyUrl = defineModel<string>('proxyUrl', { required: true })
|
||||
const proxyId = defineModel<string>('proxyId', { required: true })
|
||||
const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: true })
|
||||
</script>
|
||||
|
||||
@@ -61,11 +61,12 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
v-model:weight="weight"
|
||||
v-model:selected-group-ids="selectedGroupIds"
|
||||
v-model:proxy-mode="proxyMode"
|
||||
v-model:proxy-url="proxyUrl"
|
||||
v-model:proxy-id="proxyId"
|
||||
:groups="groups"
|
||||
:groups-loading="groupsLoading"
|
||||
:disabled="saving"
|
||||
:endpoint="account.outboundProxyEndpoint"
|
||||
:account-id="account.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,50 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseSelect from '@/components/base/BaseSelect.vue'
|
||||
import { useProxyCatalog } from '@/composables/useProxyCatalog'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
endpoint?: string | null
|
||||
disabled?: boolean
|
||||
preserve?: boolean
|
||||
error?: string
|
||||
}>(),
|
||||
{ preserve: true },
|
||||
)
|
||||
const props = withDefaults(defineProps<{ accountId?: string, endpoint?: string | null, disabled?: boolean, preserve?: boolean, error?: string }>(), { preserve: true })
|
||||
const mode = defineModel<string>('mode', { required: true })
|
||||
const url = defineModel<string>('url', { required: true })
|
||||
const proxyId = defineModel<string>('proxyId', { required: true })
|
||||
const { proxies, loading } = useProxyCatalog()
|
||||
// 目录不再携带账号明细;脱敏地址无法证明绑定关系,保持项只展示账号已有地址。
|
||||
const options = computed(() => [
|
||||
...(props.preserve ? [{ label: '保持当前', value: 'preserve' }] : []),
|
||||
{ label: '直连', value: 'direct' },
|
||||
{ label: '指定代理', value: 'proxy' },
|
||||
...(props.preserve
|
||||
? [{
|
||||
label: '保持当前',
|
||||
value: 'preserve',
|
||||
description: props.endpoint || (props.accountId ? '直连' : '保留各账号的连接设置'),
|
||||
}]
|
||||
: []),
|
||||
{ label: '直连', value: 'direct', description: '不使用代理' },
|
||||
...proxies.value.map(proxy => ({
|
||||
label: `${proxy.name}${proxy.lastTest?.success ? '' : proxy.lastTest ? '(测试失败)' : '(未测试)'}`,
|
||||
value: proxy.id,
|
||||
description: proxy.endpoint,
|
||||
disabled: proxy.lastTest?.success !== true,
|
||||
})),
|
||||
])
|
||||
const selection = computed({
|
||||
get: () => mode.value === 'proxy' ? proxyId.value : mode.value,
|
||||
set: (value: string) => {
|
||||
const nextMode = value === 'preserve' || value === 'direct' ? value : 'proxy'
|
||||
mode.value = nextMode
|
||||
proxyId.value = nextMode === 'proxy' ? value : ''
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-5">
|
||||
<BaseFormItem label="出站隧道">
|
||||
<BaseSelect
|
||||
v-model="mode"
|
||||
class="w-full"
|
||||
:options="options"
|
||||
:disabled="disabled"
|
||||
aria-label="出站隧道"
|
||||
/>
|
||||
<p v-if="endpoint && mode === 'preserve'" class="mt-2 mb-0 font-mono text-xs break-all text-cp-text-quaternary">
|
||||
{{ endpoint }}
|
||||
</p>
|
||||
</BaseFormItem>
|
||||
<BaseFormItem v-if="mode === 'proxy'" label="代理 URL" required :error="error">
|
||||
<BaseInput
|
||||
v-model="url"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
aria-label="代理 URL"
|
||||
placeholder="例如:http://127.0.0.1:7890"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
</div>
|
||||
<BaseFormItem label="出站隧道" :error="error">
|
||||
<BaseSelect v-model="selection" class="w-full" :options="options" :disabled="disabled || loading" aria-label="出站隧道" />
|
||||
</BaseFormItem>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ withDefaults(defineProps<{
|
||||
groupsLoading: boolean
|
||||
disabled: boolean
|
||||
endpoint?: string | null
|
||||
accountId?: string
|
||||
preserveProxy?: boolean
|
||||
proxyError?: string
|
||||
}>(), { preserveProxy: true })
|
||||
@@ -19,7 +20,7 @@ const enabled = defineModel<boolean>('enabled', { required: true })
|
||||
const concurrencyLimit = defineModel<string>('concurrencyLimit', { required: true })
|
||||
const weight = defineModel<string>('weight', { required: true })
|
||||
const proxyMode = defineModel<string>('proxyMode', { required: true })
|
||||
const proxyUrl = defineModel<string>('proxyUrl', { required: true })
|
||||
const proxyId = defineModel<string>('proxyId', { required: true })
|
||||
const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: true })
|
||||
</script>
|
||||
|
||||
@@ -67,6 +68,6 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
<AccountProxyField v-model:mode="proxyMode" v-model:url="proxyUrl" :preserve="preserveProxy" :error="proxyError" :endpoint="endpoint" :disabled="disabled" />
|
||||
<AccountProxyField v-model:mode="proxyMode" v-model:proxy-id="proxyId" :preserve="preserveProxy" :error="proxyError" :endpoint="endpoint" :account-id="accountId" :disabled="disabled" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<string[]>([])
|
||||
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,
|
||||
|
||||
@@ -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<string[]>([])
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown>, proxyUrl: string): Record<string, unknown> {
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import type { OutboundProxyAccount, OutboundProxyRecord } from '@/api'
|
||||
import { Search, Unlink } from '@lucide/vue'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
import AccountGroupMarks from '@/components/AccountGroupMarks.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination.vue'
|
||||
import { defineTableColumns } from '@/components/base/BaseTable/columns'
|
||||
import BaseTable from '@/components/base/BaseTable/index.vue'
|
||||
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
|
||||
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
|
||||
import { stablePresetVisualToneClass } from '@/views/accounts/utils/visualTone'
|
||||
import { useProxyAccounts } from '../composables/useProxyAccounts'
|
||||
|
||||
const props = defineProps<{
|
||||
proxy: OutboundProxyRecord | null
|
||||
}>()
|
||||
const emit = defineEmits<{ removed: [] }>()
|
||||
const open = defineModel<boolean>({ required: true })
|
||||
const pendingRemove = shallowRef<OutboundProxyAccount | null>(null)
|
||||
const showRemove = computed({
|
||||
get: () => pendingRemove.value !== null,
|
||||
set: (value: boolean) => {
|
||||
if (!value)
|
||||
pendingRemove.value = null
|
||||
},
|
||||
})
|
||||
const { accounts, loading, error, pagination, search, setPage, setPageSize, removing, removeAccount } = useProxyAccounts({
|
||||
isOpen: () => open.value,
|
||||
proxyId: () => props.proxy?.id,
|
||||
onRemoved: () => emit('removed'),
|
||||
})
|
||||
const columns = defineTableColumns<OutboundProxyAccount>([
|
||||
{ key: 'identity', label: '账号', kind: 'identity', size: '3xl' },
|
||||
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'md', align: 'center' },
|
||||
{ key: 'plan', label: '套餐', kind: 'custom', size: 'sm', align: 'center' },
|
||||
{ key: 'groups', label: '分组', kind: 'custom', size: 'lg', align: 'center' },
|
||||
{ key: 'actions', label: '操作', kind: 'actions', size: 'xs', align: 'center' },
|
||||
])
|
||||
const rows = computed(() => accounts.value.map(account => ({
|
||||
...account,
|
||||
displayName: account.email && account.name === account.email
|
||||
? account.email.split('@')[0]
|
||||
: account.name,
|
||||
initial: Array.from(account.name.trim())[0]?.toUpperCase() || '—',
|
||||
avatarTone: stablePresetVisualToneClass(account.id),
|
||||
})))
|
||||
const description = computed(() => props.proxy
|
||||
? props.proxy.name
|
||||
: undefined)
|
||||
const emptyText = computed(() => error.value
|
||||
|| (search.value.trim() ? '没有找到匹配的账号,请尝试其他名称或邮箱' : '暂无关联账号'))
|
||||
const tableHeight = computed(() => {
|
||||
// 空列表与少量账号保留最小高度,多行限制高度并在表格内滚动。
|
||||
const count = accounts.value.length || (loading.value
|
||||
? Math.min(pagination.value.total || props.proxy?.accountCount || 0, pagination.value.pageSize)
|
||||
: 0)
|
||||
return `min(55dvh, max(20rem, min(30rem, ${44 + count * 64}px)))`
|
||||
})
|
||||
|
||||
async function confirmRemove() {
|
||||
const account = pendingRemove.value
|
||||
if (account && await removeAccount(account.id))
|
||||
pendingRemove.value = null
|
||||
}
|
||||
|
||||
watch([open, () => props.proxy?.id], () => {
|
||||
pendingRemove.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal v-model="open" title="关联账号" :description="description" size="lg" :dismissible="!removing">
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<BaseInput v-model="search" class="mb-4 shrink-0 sm:w-80" :disabled="removing" aria-label="搜索关联账号" placeholder="搜索账号名称或邮箱...">
|
||||
<template #prefix>
|
||||
<Search class="size-4.5 text-cp-text-tertiary" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
<BaseTable :key="proxy?.id" class="min-h-0 shrink-0 [--cp-table-row-height:64px]" :style="{ height: tableHeight }" :columns="columns" :rows="rows" :loading="loading" :empty-text="emptyText">
|
||||
<template #identity="{ row }">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-cp font-extrabold" :class="row.avatarTone" aria-hidden="true">
|
||||
{{ row.initial }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-cp font-heavy text-cp-text" :title="row.name">
|
||||
{{ row.displayName }}
|
||||
</div>
|
||||
<div v-if="row.email" class="mt-0.5 truncate font-mono text-cp-xs font-emphasis text-cp-text-quaternary" :title="row.email">
|
||||
{{ row.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #provider="{ row }">
|
||||
<ProviderIconGroup :provider="row.provider" :authentication-kind="row.authenticationKind" />
|
||||
</template>
|
||||
<template #plan="{ row }">
|
||||
<AccountPlanBadge v-if="row.planType && row.planTypeDisplay" :plan-type="row.planType" :plan-type-display="row.planTypeDisplay" />
|
||||
<span v-else class="text-cp-xs text-cp-text-quaternary">—</span>
|
||||
</template>
|
||||
<template #groups="{ row }">
|
||||
<div class="flex justify-center">
|
||||
<AccountGroupMarks :groups="row.groups || []" />
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<BaseIconButton
|
||||
size="sm"
|
||||
label="从当前代理移除账号"
|
||||
:disabled="removing"
|
||||
:loading="removing && pendingRemove?.id === row.id"
|
||||
@click="pendingRemove = row"
|
||||
>
|
||||
<Unlink class="size-3.5 text-cp-error" />
|
||||
</BaseIconButton>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<BaseTablePagination :pagination="pagination" :loading="loading || removing" @page-change="setPage" @page-size-change="setPageSize" />
|
||||
</div>
|
||||
</BaseModal>
|
||||
<BaseConfirmModal v-model="showRemove" title="移除关联账号" confirm-text="移除" :loading="removing" @confirm="confirmRemove">
|
||||
<p class="m-0 break-words">
|
||||
将“{{ pendingRemove?.name }}”从“{{ proxy?.name }}”移除后,该账号将改为直连。
|
||||
</p>
|
||||
</BaseConfirmModal>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import type { OutboundProxyRecord } from '@/api'
|
||||
import { Eye, EyeOff, Save, Wifi } from '@lucide/vue'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
proxy: OutboundProxyRecord | null
|
||||
saving: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
save: [testAfter: boolean]
|
||||
}>()
|
||||
const open = defineModel<boolean>({ required: true })
|
||||
const name = defineModel<string>('name', { required: true })
|
||||
const proxyUrl = defineModel<string>('proxyUrl', { required: true })
|
||||
const showSecret = shallowRef(false)
|
||||
const title = computed(() => props.proxy ? '编辑代理' : '新增代理')
|
||||
const connectionDescription = computed(() => props.proxy
|
||||
? '留空保留当前连接和认证信息;填写新地址时,请包含所需的用户名和密码。'
|
||||
: '支持 HTTP、HTTPS、SOCKS5 和 SOCKS5H,可在地址中包含用户名和密码。')
|
||||
|
||||
watch(open, () => {
|
||||
showSecret.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal v-model="open" :title="title" size="md" :dismissible="!saving">
|
||||
<BaseForm class="grid gap-5">
|
||||
<BaseFormItem label="代理名称" required>
|
||||
<BaseInput v-model="name" maxlength="100" :disabled="saving" aria-label="代理名称" placeholder="例如:本机代理" />
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="代理 URL" :required="!proxy" :description="connectionDescription">
|
||||
<BaseInput
|
||||
v-model="proxyUrl"
|
||||
:type="showSecret ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
:disabled="saving"
|
||||
aria-label="代理 URL"
|
||||
:placeholder="proxy?.endpoint ?? 'socks5h://user:password@host:1080'"
|
||||
>
|
||||
<template #suffix>
|
||||
<BaseIconButton :label="showSecret ? '隐藏代理地址' : '显示代理地址'" :disabled="saving" @click="showSecret = !showSecret">
|
||||
<EyeOff v-if="showSecret" class="size-4" />
|
||||
<Eye v-else class="size-4" />
|
||||
</BaseIconButton>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
<p v-if="proxy?.accountCount && proxyUrl.trim()" class="m-0 text-cp-sm text-cp-warning-text">
|
||||
将更新 {{ proxy.accountCount }} 个关联账号的出口。
|
||||
</p>
|
||||
</BaseForm>
|
||||
<template #footer>
|
||||
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
|
||||
取消
|
||||
</BaseButton>
|
||||
<BaseButton variant="secondary" :loading="saving" @click="emit('save', false)">
|
||||
<template #icon>
|
||||
<Save class="size-4" />
|
||||
</template>
|
||||
保存代理
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" :loading="saving" @click="emit('save', true)">
|
||||
<template #icon>
|
||||
<Wifi class="size-4" />
|
||||
</template>
|
||||
保存并测试
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import type { OutboundProxyRecord } from '@/api'
|
||||
import { LockKeyhole, Pencil, Plus, RefreshCw, Search, Trash2, Users, Wifi } from '@lucide/vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { computed, onMounted, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { createProxy, deleteProxy, getProxies, testProxy, updateProxy } from '@/api'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BasePageHeader from '@/components/base/BasePageHeader.vue'
|
||||
import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination.vue'
|
||||
import { defineTableColumns } from '@/components/base/BaseTable/columns'
|
||||
import BaseTable from '@/components/base/BaseTable/index.vue'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
import { usePagedQuery } from '@/composables/usePagedQuery'
|
||||
import { errorMessage } from '@/utils/async'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import ProxyAccountsModal from './components/ProxyAccountsModal.vue'
|
||||
import ProxyFormModal from './components/ProxyFormModal.vue'
|
||||
|
||||
const search = shallowRef('')
|
||||
const query = usePagedQuery({
|
||||
initialPageSize: 20,
|
||||
load: pagination => getProxies({ ...pagination, search: search.value.trim() || undefined }),
|
||||
onError: error => toast.error(errorMessage(error, '代理列表加载失败')),
|
||||
})
|
||||
const { items: proxies, loading } = query
|
||||
const pagination = computed(() => ({ currentPage: query.page.value, pageSize: query.pageSize.value, total: query.total.value }))
|
||||
const columns = defineTableColumns<OutboundProxyRecord>([
|
||||
{ key: 'identity', label: '代理', kind: 'identity' },
|
||||
{ key: 'exitIp', label: '出口 IP', kind: 'custom' },
|
||||
{ key: 'latency', label: '耗时', kind: 'custom', size: 'sm' },
|
||||
{ key: 'accounts', label: '关联账号', kind: 'custom', size: 'sm' },
|
||||
{ key: 'testedAt', label: '测试时间', kind: 'datetime' },
|
||||
{ key: 'actions', label: '操作', kind: 'actions' },
|
||||
])
|
||||
const showForm = shallowRef(false)
|
||||
const editing = shallowRef<OutboundProxyRecord | null>(null)
|
||||
const form = reactive({ name: '', proxyUrl: '' })
|
||||
const saveAction = useAsyncAction()
|
||||
const { loading: saving } = saveAction
|
||||
const deleteAction = useAsyncAction()
|
||||
const { loading: deleting } = deleteAction
|
||||
const showDelete = shallowRef(false)
|
||||
const pendingDelete = shallowRef<OutboundProxyRecord | null>(null)
|
||||
const testingIds = ref(new Set<string>())
|
||||
const showAccounts = shallowRef(false)
|
||||
const inspected = shallowRef<OutboundProxyRecord | null>(null)
|
||||
|
||||
function openForm(proxy: OutboundProxyRecord | null = null) {
|
||||
editing.value = proxy
|
||||
form.name = proxy?.name ?? ''
|
||||
form.proxyUrl = ''
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function checkProxy(proxy: OutboundProxyRecord) {
|
||||
if (testingIds.value.has(proxy.id))
|
||||
return
|
||||
testingIds.value.add(proxy.id)
|
||||
try {
|
||||
const result = await testProxy({ id: proxy.id, revision: proxy.revision })
|
||||
if (result.lastTest?.success)
|
||||
toast.success(`${result.name}:连接成功`)
|
||||
else
|
||||
toast.error(result.lastTest?.message ?? '代理测试失败')
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessage(error, '代理测试失败'))
|
||||
}
|
||||
finally {
|
||||
testingIds.value.delete(proxy.id)
|
||||
await query.execute({ silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function save(testAfter: boolean) {
|
||||
if (saving.value)
|
||||
return
|
||||
const name = form.name.trim()
|
||||
const proxyUrl = form.proxyUrl.trim()
|
||||
if (!name || (!editing.value && !proxyUrl)) {
|
||||
toast.warning('请填写代理名称和连接地址')
|
||||
return
|
||||
}
|
||||
await saveAction.run(async () => {
|
||||
// 编辑时留空保留已保存的地址和认证,不能用脱敏地址覆盖原连接。
|
||||
const result = editing.value
|
||||
? await updateProxy({ id: editing.value.id, revision: editing.value.revision, name, proxyUrl: proxyUrl || undefined })
|
||||
: await createProxy({ name, proxyUrl })
|
||||
showForm.value = false
|
||||
form.proxyUrl = ''
|
||||
toast.success('代理已保存')
|
||||
search.value = ''
|
||||
query.page.value = 1
|
||||
await query.execute()
|
||||
if (testAfter)
|
||||
await checkProxy(result.record)
|
||||
}, { errorText: '代理保存失败' })
|
||||
}
|
||||
|
||||
function requestDelete(proxy: OutboundProxyRecord) {
|
||||
pendingDelete.value = proxy
|
||||
showDelete.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const proxy = pendingDelete.value
|
||||
if (!proxy || deleting.value)
|
||||
return
|
||||
await deleteAction.run(async () => {
|
||||
await deleteProxy({ id: proxy.id, revision: proxy.revision })
|
||||
showDelete.value = false
|
||||
await query.execute()
|
||||
toast.success('代理已删除')
|
||||
}, { errorText: '代理删除失败,请确认没有账号使用该代理' })
|
||||
}
|
||||
|
||||
function setPage(page: number) {
|
||||
query.page.value = page
|
||||
void query.execute()
|
||||
}
|
||||
|
||||
function setPageSize(size: number) {
|
||||
query.pageSize.value = size
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
watch(showForm, (open) => {
|
||||
if (!open) {
|
||||
form.proxyUrl = ''
|
||||
}
|
||||
})
|
||||
watchDebounced(search, () => setPage(1), { debounce: 300 })
|
||||
onMounted(() => void query.execute())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<BasePageHeader
|
||||
class="h-17"
|
||||
title="代理管理"
|
||||
description="管理账号使用的代理,测试连接并查看出口 IP"
|
||||
/>
|
||||
<BaseCard class="mt-5 flex h-[calc(100dvh-136px)] min-h-125 flex-col">
|
||||
<template #header>
|
||||
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<BaseInput v-model="search" class="sm:w-80" aria-label="搜索代理" placeholder="搜索代理名称...">
|
||||
<template #prefix>
|
||||
<Search class="size-4.5 text-cp-text-tertiary" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 sm:ml-auto">
|
||||
<BaseIconButton label="刷新代理列表" :loading="loading" @click="query.execute()">
|
||||
<RefreshCw class="size-4" />
|
||||
</BaseIconButton>
|
||||
<BaseButton variant="primary" @click="openForm()">
|
||||
<template #icon>
|
||||
<Plus class="size-4" />
|
||||
</template>
|
||||
新增代理
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<BaseTable class="min-h-0 flex-1" :columns="columns" :rows="proxies" :loading="loading" :empty-text="search.trim() ? '没有找到匹配的代理,请尝试其他名称' : '暂无代理,请点击新增代理添加'">
|
||||
<template #identity="{ row }">
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<strong class="truncate text-cp text-cp-text" :title="row.name">{{ row.name }}</strong>
|
||||
<span class="flex min-w-0 items-center gap-1 text-cp-xs font-emphasis text-cp-text-quaternary">
|
||||
<LockKeyhole v-if="row.hasAuthentication" class="size-3 shrink-0" aria-label="已保存代理认证" />
|
||||
<span class="truncate font-mono" :title="row.endpoint">{{ row.endpoint }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #exitIp="{ row }">
|
||||
<span class="break-all font-mono text-cp-xs">{{ row.lastTest?.exitIp ?? '-' }}</span>
|
||||
</template>
|
||||
<template #latency="{ row }">
|
||||
<span v-if="testingIds.has(row.id)" class="text-cp-text-secondary">测试中</span>
|
||||
<span v-else-if="row.lastTest?.success" class="tabular-nums text-cp-success">
|
||||
{{ row.lastTest.latencyMs }} ms
|
||||
</span>
|
||||
<span v-else-if="row.lastTest" class="text-cp-error" :title="`${row.lastTest.message}(耗时 ${row.lastTest.latencyMs} ms)`">失败</span>
|
||||
<span v-else class="text-cp-text-quaternary">未测试</span>
|
||||
</template>
|
||||
<template #accounts="{ row }">
|
||||
<button type="button" class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm border-0 bg-transparent p-0 text-cp-sm text-cp-text-secondary outline-none transition-colors hover:text-cp-primary-text focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container" :aria-label="`查看 ${row.name} 的 ${row.accountCount} 个关联账号`" @click="inspected = row; showAccounts = true">
|
||||
<Users class="size-3.5" aria-hidden="true" />
|
||||
<span class="font-mono tabular-nums">{{ row.accountCount }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template #testedAt="{ row }">
|
||||
{{ row.lastTestAt ? formatDateTime(row.lastTestAt) : '-' }}
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<BaseIconButton size="sm" label="测试代理" :loading="testingIds.has(row.id)" :disabled="testingIds.has(row.id)" @click="checkProxy(row)">
|
||||
<Wifi class="size-3.5 text-cp-link" />
|
||||
</BaseIconButton>
|
||||
<BaseIconButton size="sm" label="编辑代理" :disabled="testingIds.has(row.id)" @click="openForm(row)">
|
||||
<Pencil class="size-3.5 text-cp-link" />
|
||||
</BaseIconButton>
|
||||
<BaseIconButton size="sm" :label="row.accountCount ? '代理正在被账号使用' : '删除代理'" :disabled="row.accountCount > 0 || testingIds.has(row.id)" @click="requestDelete(row)">
|
||||
<Trash2 class="size-3.5 text-cp-error" />
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<BaseTablePagination :pagination="pagination" :loading="loading" @page-change="setPage" @page-size-change="setPageSize" />
|
||||
</div>
|
||||
</template>
|
||||
</BaseCard>
|
||||
|
||||
<ProxyFormModal
|
||||
v-model="showForm"
|
||||
v-model:name="form.name"
|
||||
v-model:proxy-url="form.proxyUrl"
|
||||
:proxy="editing"
|
||||
:saving="saving"
|
||||
@save="save"
|
||||
/>
|
||||
<BaseConfirmModal v-model="showDelete" title="删除代理" destructive :loading="deleting" @confirm="confirmDelete">
|
||||
<p class="m-0">
|
||||
确定删除“{{ pendingDelete?.name }}”吗?
|
||||
</p>
|
||||
</BaseConfirmModal>
|
||||
<ProxyAccountsModal v-model="showAccounts" :proxy="inspected" @removed="query.execute({ silent: true })" />
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user