From e973fc516eb3615c0d38f6c295f7d7358977239c Mon Sep 17 00:00:00 2001 From: Zyy Date: Fri, 11 Sep 2026 03:23:31 +0800 Subject: [PATCH] feat(accounts): separate setup from account import Share account settings across import, single-account editing, and batch editing. Commit credentials and import settings in the same transaction. Improve modal actions and fix the flash at the end of light/dark theme transitions. Reauthenticate each execution against the current key policy so existing WebSocket connections apply updated RPM, concurrency limits, and enabled state. Add regression coverage for policy updates and unavailable snapshots. Validation: all 1,950 backend tests, workspace Clippy, frontend lint/typecheck/build, migration checks, Compose validation, and workflow lint passed. Completed 24 real requests with a free account using gpt-5.6-luna to verify daily/weekly budgets, RPM, concurrency, reconciliation, WebSocket policy updates, and budget persistence across restarts and usage-log cleanup. Co-authored-by: Codex --- .../gateway-admin/src/model/accounts.rs | 9 + .../src/model/provider_credentials.rs | 16 +- .../crates/gateway-admin/src/use_case/mod.rs | 12 +- .../gateway-admin/src/use_case/openai.rs | 12 +- .../crates/gateway-admin/src/use_case/xai.rs | 12 +- .../gateway-admin/tests/use_case/accounts.rs | 33 ++ .../gateway-admin/tests/use_case/openai.rs | 48 +++ .../gateway-admin/tests/use_case/xai.rs | 7 + .../src/admin/accounts/credentials.rs | 47 +++ .../gateway-api/tests/admin/accounts/mod.rs | 57 ++++ .../gateway-core/src/engine/execution.rs | 15 +- .../gateway-core/tests/engine/execution.rs | 82 ++++- .../provider_accounts/admin_adapter.rs | 26 +- .../src/postgres/provider_accounts/mod.rs | 13 +- .../postgres/provider_accounts/repository.rs | 27 +- .../src/postgres/provider_accounts/rows.rs | 4 + .../tests/postgres/provider_accounts/mod.rs | 117 +++++++ .../crates/providers/openai/tests/admin.rs | 1 + backend/crates/providers/xai/tests/admin.rs | 3 + docs/api.md | 15 +- docs/architecture.md | 5 +- frontend/src/api/modules/accounts.ts | 9 + frontend/src/stores/modules/theme.ts | 25 +- .../components/AccountGroupFormModal.vue | 2 +- .../components/AccountBatchEditModal.vue | 64 +--- .../components/AccountConnectionTestModal.vue | 2 +- .../AccountImportFields.vue | 68 ++++ .../AccountCreateModal/AccountOAuthFields.vue | 93 ++++++ .../AccountProviderChooser.vue | 17 +- .../AccountCreateModal/AccountSetupFields.vue | 38 +++ .../components/AccountCreateModal/index.vue | 297 ++++++------------ .../components/AccountCreateModal/model.ts | 51 ++- .../AccountCreateModal/presenter.ts | 50 +-- .../accounts/components/AccountEditModal.vue | 63 +--- .../components/AccountProfileModal/index.vue | 2 +- .../accounts/components/AccountProxyField.vue | 3 +- .../AccountQuotaPanel/ResetCredits.vue | 2 +- .../components/AccountSettingsFields.vue | 72 +++++ .../composables/useAccountOnboarding.ts | 65 +++- frontend/src/views/accounts/index.vue | 4 +- .../api-keys/components/ApiKeyCreateModal.vue | 2 +- .../components/ApiKeyReconcileModal.vue | 2 +- 42 files changed, 1068 insertions(+), 424 deletions(-) create mode 100644 frontend/src/views/accounts/components/AccountCreateModal/AccountImportFields.vue create mode 100644 frontend/src/views/accounts/components/AccountCreateModal/AccountOAuthFields.vue create mode 100644 frontend/src/views/accounts/components/AccountCreateModal/AccountSetupFields.vue create mode 100644 frontend/src/views/accounts/components/AccountSettingsFields.vue diff --git a/backend/crates/gateway-admin/src/model/accounts.rs b/backend/crates/gateway-admin/src/model/accounts.rs index fe3e7601d..0e287609a 100644 --- a/backend/crates/gateway-admin/src/model/accounts.rs +++ b/backend/crates/gateway-admin/src/model/accounts.rs @@ -18,6 +18,15 @@ pub use gateway_core::account::{ QuotaState, resolve_account_status, }; +/// 导入时统一应用的账号调度与分组设置;缺省时保留原有导入语义。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountImportSettings { + pub enabled: bool, + pub concurrency_limit: Option, + pub weight: AccountWeight, + pub group_ids: Vec, +} + /// 账号列表排序字段。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccountSortField { diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index bbf1d7da6..255a95fd6 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -16,7 +16,9 @@ use uuid::Uuid; use super::{ AdminError, MutationActor, MutationContext, PageSize, Revision, - accounts::{AccountRecord, AccountSummary, AccountUsage, CredentialState}, + accounts::{ + AccountImportSettings, AccountRecord, AccountSummary, AccountUsage, CredentialState, + }, }; /// Provider-owned JSON;公共层只搬运且 Debug 不输出值。 @@ -106,6 +108,7 @@ pub struct CredentialDetails { /// Provider 正式文档批量导入命令。 pub struct ImportCredentials { + pub settings: Option, pub context: MutationContext, pub document: ProviderDocument, } @@ -172,6 +175,7 @@ pub struct PreparedCredentialImport { /// Admin 交给 Store 的导入事务命令。 #[derive(Debug, Clone, PartialEq)] pub struct CredentialImportCommit { + pub settings: Option, pub prepared: PreparedCredentialImport, } @@ -480,6 +484,7 @@ pub enum AuthorizationCredentialCommit { /// Admin 交给 Store 的 OAuth 原子事务命令。 #[derive(Debug, Clone, PartialEq)] pub struct AuthorizationCommit { + pub settings: Option, pub pending: PendingAuthorizationMutation, pub credential: AuthorizationCredentialCommit, } @@ -514,7 +519,10 @@ impl PreparedAuthorizationCommit { self } - pub(crate) fn into_commit(self) -> AuthorizationCommitSettlement { + pub(crate) fn into_commit( + self, + settings: Option, + ) -> AuthorizationCommitSettlement { let (credential, guard) = match self.credential { PreparedAuthorizationCredential::Create(credential) => { (AuthorizationCredentialCommit::Create(credential), None) @@ -529,6 +537,7 @@ impl PreparedAuthorizationCommit { }; AuthorizationCommitSettlement { command: AuthorizationCommit { + settings, pending: self.pending, credential, }, @@ -542,7 +551,7 @@ impl PreparedAuthorizationCommit { credential_guard, authorization_guard, .. - } = self.into_commit(); + } = self.into_commit(None); drop(credential_guard); if let Some(guard) = authorization_guard { guard.abort().await?; @@ -554,6 +563,7 @@ impl PreparedAuthorizationCommit { /// 完成 Provider OAuth 流程。 #[derive(Clone, PartialEq, Eq)] pub struct CompleteAuthorization { + pub settings: Option, pub context: MutationContext, pub flow_id: String, pub callback_url: String, diff --git a/backend/crates/gateway-admin/src/use_case/mod.rs b/backend/crates/gateway-admin/src/use_case/mod.rs index c2b11bbba..f2395b5f1 100644 --- a/backend/crates/gateway-admin/src/use_case/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/mod.rs @@ -222,14 +222,24 @@ async fn validate_authorization_commit( async fn commit_authorization( accounts: &dyn AccountStore, prepared: PreparedAuthorizationCommit, + settings: Option, context: &MutationContext, resource: &'static str, ) -> Result { + if settings.is_some() + && matches!( + &prepared.credential, + PreparedAuthorizationCredential::Reauthorize(_) + ) + { + prepared.abort().await?; + return Err(AdminError::invalid("重新授权不能修改账号设置")); + } let crate::model::provider_credentials::AuthorizationCommitSettlement { command, credential_guard, authorization_guard, - } = prepared.into_commit(); + } = prepared.into_commit(settings); match accounts.commit_authorization(command, context).await { Ok(result) => { if let Some(guard) = credential_guard { diff --git a/backend/crates/gateway-admin/src/use_case/openai.rs b/backend/crates/gateway-admin/src/use_case/openai.rs index 85f47638f..09a602780 100644 --- a/backend/crates/gateway-admin/src/use_case/openai.rs +++ b/backend/crates/gateway-admin/src/use_case/openai.rs @@ -142,7 +142,13 @@ impl OpenAiService for DefaultOpenAiService { )?; let result = self .accounts - .commit_credential_import(CredentialImportCommit { prepared }, &context) + .commit_credential_import( + CredentialImportCommit { + prepared, + settings: command.settings, + }, + &context, + ) .await .map_err(|error| map_store_error(error, "OpenAI credential import"))?; self.provider @@ -173,9 +179,10 @@ impl OpenAiService for DefaultOpenAiService { async fn complete_authorization( &self, - command: CompleteAuthorization, + mut command: CompleteAuthorization, ) -> Result { let context = command.context.clone(); + let settings = command.settings.take(); let prepared = self .provider .complete_authorization(command) @@ -191,6 +198,7 @@ impl OpenAiService for DefaultOpenAiService { let result = commit_authorization( self.accounts.as_ref(), prepared, + settings, &context, "OpenAI authorization", ) diff --git a/backend/crates/gateway-admin/src/use_case/xai.rs b/backend/crates/gateway-admin/src/use_case/xai.rs index 82e452977..f7faf523f 100644 --- a/backend/crates/gateway-admin/src/use_case/xai.rs +++ b/backend/crates/gateway-admin/src/use_case/xai.rs @@ -95,7 +95,13 @@ impl XaiService for DefaultXaiService { )?; let result = self .accounts - .commit_credential_import(CredentialImportCommit { prepared }, &context) + .commit_credential_import( + CredentialImportCommit { + prepared, + settings: command.settings, + }, + &context, + ) .await .map_err(|error| map_store_error(error, "xAI credential import"))?; publish_committed(self.snapshot.as_ref(), result.config_revision).await?; @@ -132,9 +138,10 @@ impl XaiService for DefaultXaiService { async fn complete_authorization( &self, - command: CompleteAuthorization, + mut command: CompleteAuthorization, ) -> Result { let context = command.context.clone(); + let settings = command.settings.take(); let prepared = self .provider .complete_authorization(command) @@ -150,6 +157,7 @@ impl XaiService for DefaultXaiService { let result = commit_authorization( self.accounts.as_ref(), prepared, + settings, &context, "xAI authorization", ) diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index a6a823fd3..d9bdabdcd 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -441,6 +441,7 @@ pub(super) struct FakeAccountStore { account_after_probe: Mutex>, fail_commit: Mutex, audit_requests: Mutex>, + import_settings: Mutex>>, quota_window_usage: Mutex>, quota_window_queries: Mutex>, } @@ -457,6 +458,7 @@ impl FakeAccountStore { account_after_probe: Mutex::new(None), fail_commit: Mutex::new(false), audit_requests: Mutex::new(Vec::new()), + import_settings: Mutex::new(Vec::new()), quota_window_usage: Mutex::new(Vec::new()), quota_window_queries: Mutex::new(Vec::new()), }) @@ -466,6 +468,15 @@ impl FakeAccountStore { *self.fail_commit.lock().expect("store failure") = true; } + pub(super) fn import_settings( + &self, + ) -> Vec> { + self.import_settings + .lock() + .expect("import settings") + .clone() + } + pub(super) fn audit_requests(&self) -> Vec { self.audit_requests.lock().expect("audit requests").clone() } @@ -672,6 +683,10 @@ impl AccountStore for FakeAccountStore { command: CredentialImportCommit, context: &MutationContext, ) -> AdminStoreResult { + self.import_settings + .lock() + .expect("import settings") + .push(command.settings); self.record("store.commit_import"); self.record_context(context); self.require_commit()?; @@ -691,6 +706,10 @@ impl AccountStore for FakeAccountStore { command: AuthorizationCommit, context: &MutationContext, ) -> AdminStoreResult { + self.import_settings + .lock() + .expect("import settings") + .push(command.settings); self.record("store.commit_authorization"); self.record_context(context); self.require_commit()?; @@ -2204,3 +2223,17 @@ fn store_unavailable() -> AdminStoreError { fn unsupported() -> ProviderAdminError { ProviderAdminError::new(ProviderAdminErrorKind::Unsupported) } + +pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings { + gateway_admin::model::accounts::AccountImportSettings { + enabled: false, + concurrency_limit: Some( + gateway_core::account::AccountConcurrencyLimit::new(3).expect("concurrency"), + ), + weight: gateway_core::account::AccountWeight::new(7).expect("weight"), + group_ids: vec![ + gateway_core::routing::AccountGroupId::new("grp_00000000000000000000000000000091") + .expect("group ID"), + ], + } +} diff --git a/backend/crates/gateway-admin/tests/use_case/openai.rs b/backend/crates/gateway-admin/tests/use_case/openai.rs index 685b64043..ba22e59d0 100644 --- a/backend/crates/gateway-admin/tests/use_case/openai.rs +++ b/backend/crates/gateway-admin/tests/use_case/openai.rs @@ -50,6 +50,7 @@ async fn openai_import_should_prepare_before_atomic_store_commit() { services .openai() .import_document(ImportCredentials { + settings: Some(super::accounts::import_settings()), context: context("import-openai"), document: document(), }) @@ -75,6 +76,10 @@ async fn openai_import_should_prepare_before_atomic_store_commit() { rolling_usage: None, }] ); + assert_eq!( + store.import_settings(), + [Some(super::accounts::import_settings())] + ); assert_eq!(store.audit_requests(), ["import-openai"]); } @@ -102,6 +107,7 @@ async fn openai_import_should_expose_only_explicit_public_errors_without_committ let error = services .openai() .import_document(ImportCredentials { + settings: None, context: context("import-openai-pat-failure"), document: document(), }) @@ -126,6 +132,7 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() { let result = services .openai() .import_document(ImportCredentials { + settings: None, context: context("import-openai-batch"), document: document(), }) @@ -169,6 +176,7 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() { let result = services .openai() .import_document(ImportCredentials { + settings: None, context: context("import-openai-quota-failure"), document: document(), }) @@ -204,6 +212,7 @@ async fn openai_authorization_create_should_observe_initial_quota() { services .openai() .complete_authorization(CompleteAuthorization { + settings: Some(super::accounts::import_settings()), context: context("oauth-start-openai-create"), flow_id: "flow-test".to_owned(), callback_url: "http://localhost/callback?code=test&state=test".to_owned(), @@ -231,6 +240,10 @@ async fn openai_authorization_create_should_observe_initial_quota() { rolling_usage: None, }] ); + assert_eq!( + store.import_settings(), + [Some(super::accounts::import_settings())] + ); assert_eq!(store.audit_requests(), ["oauth-start-openai-create"]); } @@ -255,6 +268,7 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota let result = services .openai() .complete_authorization(CompleteAuthorization { + settings: None, context: context("oauth-start-openai-quota-failure"), flow_id: "flow-test".to_owned(), callback_url: "http://localhost/callback?code=test&state=test".to_owned(), @@ -277,6 +291,7 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() { store.fail_next_commit(); let services = service(provider.clone(), store).await; let command = || CompleteAuthorization { + settings: None, context: context("oauth-store-retry"), flow_id: "flow-test".to_owned(), callback_url: "http://localhost/callback?code=test&state=test".to_owned(), @@ -333,6 +348,7 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() { services .openai() .import_document(ImportCredentials { + settings: None, context: context("import-openai-error"), document: document(), }) @@ -377,6 +393,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances let result = services .openai() .complete_authorization(CompleteAuthorization { + settings: None, context: context("oauth-complete-openai"), flow_id: "flow-test".to_owned(), callback_url: "http://localhost/callback?code=test&state=test".to_owned(), @@ -428,3 +445,34 @@ fn deletion(account_id: &str) -> CredentialDeletion { account_ids: vec![ProviderAccountId::new(account_id).expect("account ID")], } } + +#[tokio::test] +async fn reauthorization_with_import_settings_releases_claim_without_committing() { + let events = events(); + let provider = FakeProviderAdmin::new("openai", events.clone()); + let store = FakeAccountStore::new("openai", events.clone()); + let services = service(provider, store.clone()).await; + services + .openai() + .start_authorization(StartAuthorization { + outbound_proxy: None, + context: context("reauthorization-settings"), + name: "existing account".to_owned(), + reauthorization: Some(ProviderAccountId::new("acct_test").expect("account ID")), + }) + .await + .expect("start reauthorization"); + let error = services + .openai() + .complete_authorization(CompleteAuthorization { + settings: Some(super::accounts::import_settings()), + context: context("reauthorization-settings"), + flow_id: "flow-test".to_owned(), + callback_url: "http://localhost/callback?code=test&state=test".to_owned(), + }) + .await + .expect_err("reauthorization must preserve settings"); + assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid); + assert!(!recorded(&events).contains(&"store.commit_authorization")); + assert!(store.import_settings().is_empty()); +} diff --git a/backend/crates/gateway-admin/tests/use_case/xai.rs b/backend/crates/gateway-admin/tests/use_case/xai.rs index a306ad2c3..a58f98d6b 100644 --- a/backend/crates/gateway-admin/tests/use_case/xai.rs +++ b/backend/crates/gateway-admin/tests/use_case/xai.rs @@ -20,6 +20,7 @@ async fn xai_import_should_prepare_before_atomic_store_commit() { services .xai() .import_document(ImportCredentials { + settings: Some(super::accounts::import_settings()), context: context("import-xai"), document: document(), }) @@ -42,6 +43,10 @@ async fn xai_import_should_prepare_before_atomic_store_commit() { rolling_usage: None, }] ); + assert_eq!( + store.import_settings(), + [Some(super::accounts::import_settings())] + ); assert_eq!(store.audit_requests(), ["import-xai"]); } @@ -56,6 +61,7 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() { services .xai() .import_document(ImportCredentials { + settings: None, context: context("import-xai-batch"), document: document(), }) @@ -90,6 +96,7 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() { let result = services .xai() .import_document(ImportCredentials { + settings: None, context: context("import-xai-quota-failure"), document: document(), }) diff --git a/backend/crates/gateway-api/src/admin/accounts/credentials.rs b/backend/crates/gateway-api/src/admin/accounts/credentials.rs index bd9efc9f5..9e06c9ab4 100644 --- a/backend/crates/gateway-api/src/admin/accounts/credentials.rs +++ b/backend/crates/gateway-api/src/admin/accounts/credentials.rs @@ -18,16 +18,51 @@ impl AccountProvider { } } +/// 导入统一设置,复用编辑账号的调度和分组约束。 +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AccountImportSettingsRequest { + pub enabled: bool, + #[serde(deserialize_with = "deserialize_required_nullable")] + pub concurrency_limit: Option, + pub weight: u64, + pub group_ids: Vec, +} + +impl AccountImportSettingsRequest { + fn validate(&self) -> Result<(), WireValidationError> { + parse_concurrency_limit(self.concurrency_limit)?; + parse_account_weight(self.weight)?; + validate_wire_group_ids(&self.group_ids)?; + Ok(()) + } + + fn into_settings( + self, + ) -> Result { + Ok(gateway_admin::model::accounts::AccountImportSettings { + enabled: self.enabled, + concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?, + weight: parse_account_weight(self.weight)?, + group_ids: validate_wire_group_ids(&self.group_ids)?, + }) + } +} + /// Provider-owned 账号导入请求;公共 API 不解释 `data` 内部字段。 #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AccountImportRequest { + pub settings: Option, pub provider: String, pub data: Value, } impl AccountImportRequest { pub fn validate(&self) -> Result<(), WireValidationError> { + if let Some(settings) = &self.settings { + settings.validate()?; + } AccountProvider::parse(&self.provider)?; if !self.data.is_object() || serde_json::to_vec(&self.data) @@ -47,6 +82,10 @@ impl AccountImportRequest { Ok(( provider, ImportCredentials { + settings: self + .settings + .map(AccountImportSettingsRequest::into_settings) + .transpose()?, context, document: provider_document(self.data, "data")?, }, @@ -99,6 +138,7 @@ impl StartAccountAuthorizationRequest { #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CompleteAccountAuthorizationRequest { + pub settings: Option, pub provider: String, pub flow_id: String, pub callback_url: String, @@ -106,6 +146,9 @@ pub struct CompleteAccountAuthorizationRequest { impl CompleteAccountAuthorizationRequest { pub fn validate(&self) -> Result<(), WireValidationError> { + if let Some(settings) = &self.settings { + settings.validate()?; + } let provider = AccountProvider::parse(&self.provider)?; match provider { AccountProvider::OpenAi => { @@ -130,6 +173,10 @@ impl CompleteAccountAuthorizationRequest { Ok(( provider, CompleteAuthorization { + settings: self + .settings + .map(AccountImportSettingsRequest::into_settings) + .transpose()?, context, flow_id: self.flow_id, callback_url: self.callback_url, diff --git a/backend/crates/gateway-api/tests/admin/accounts/mod.rs b/backend/crates/gateway-api/tests/admin/accounts/mod.rs index 4e7896c70..f4e57df01 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/mod.rs @@ -756,3 +756,60 @@ mod actions { ); } } + +mod import_settings { + use gateway_api::admin::accounts::{AccountImportRequest, CompleteAccountAuthorizationRequest}; + use serde_json::json; + + #[test] + fn import_and_oauth_apply_the_same_settings_validation() { + for (field, value) in [ + ("concurrencyLimit", json!(0)), + ("concurrencyLimit", json!(4_294_967_296_u64)), + ("weight", json!(0)), + ("weight", json!(101)), + ("groupIds", json!(["invalid-group"])), + ] { + let mut settings = + json!({"enabled": false, "concurrencyLimit": null, "weight": 1, "groupIds": []}); + settings[field] = value; + let import: AccountImportRequest = serde_json::from_value( + json!({"provider": "openai", "data": {}, "settings": settings}), + ) + .expect("import request"); + let oauth: CompleteAccountAuthorizationRequest = serde_json::from_value(json!({"provider": "xai", "flowId": "flow-test", "callbackUrl": "code", "settings": settings})).expect("OAuth request"); + assert_eq!( + import.validate().expect_err("invalid settings").field(), + field + ); + assert_eq!( + oauth.validate().expect_err("invalid settings").field(), + field + ); + } + } + + #[test] + fn import_settings_require_a_complete_explicit_configuration() { + let settings = + json!({"enabled": false, "concurrencyLimit": null, "weight": 100, "groupIds": []}); + let request: AccountImportRequest = + serde_json::from_value(json!({"provider": "openai", "data": {}, "settings": settings})) + .expect("settings"); + assert!(request.validate().is_ok()); + for field in ["enabled", "concurrencyLimit", "weight", "groupIds"] { + let mut incomplete = settings.clone(); + incomplete + .as_object_mut() + .expect("settings object") + .remove(field); + assert!( + serde_json::from_value::( + json!({"provider": "openai", "data": {}, "settings": incomplete}) + ) + .is_err(), + "missing {field}" + ); + } + } +} diff --git a/backend/crates/gateway-core/src/engine/execution.rs b/backend/crates/gateway-core/src/engine/execution.rs index 7d134d42b..4a6647231 100644 --- a/backend/crates/gateway-core/src/engine/execution.rs +++ b/backend/crates/gateway-core/src/engine/execution.rs @@ -334,9 +334,18 @@ impl DefaultExecutionService { &self, mut request: PendingStartExecution, ) -> Result { - request.client.policy.authorize().map_err(|_| { - GatewayError::new(GatewayErrorKind::PolicyDenied, "client API key is disabled") - })?; + // 长连接每次执行都重新鉴权并冻结当前策略,确保限额和授权变更对新请求生效。 + request.client = self + .authenticate(request.client.policy.plaintext_key().expose_for_auth()) + .map_err(|error| match error { + ClientAuthenticationError::InvalidKey => { + GatewayError::new(GatewayErrorKind::Unauthorized, "client API key is invalid") + } + ClientAuthenticationError::SnapshotUnavailable => GatewayError::new( + GatewayErrorKind::Internal, + "runtime snapshot is unavailable", + ), + })?; let started_at = SystemTime::now(); let deadline_at = started_at .checked_add(MODEL_REQUEST_DEADLINE) diff --git a/backend/crates/gateway-core/tests/engine/execution.rs b/backend/crates/gateway-core/tests/engine/execution.rs index fe7e67570..783d890a6 100644 --- a/backend/crates/gateway-core/tests/engine/execution.rs +++ b/backend/crates/gateway-core/tests/engine/execution.rs @@ -15,14 +15,16 @@ use gateway_core::error::GatewayError; #[derive(Default)] struct Admissions { active: Arc, + limits: Mutex>, } impl ClientAdmissionPort for Admissions { fn admit( &self, - _: ClientAdmissionRequest, + request: ClientAdmissionRequest, ) -> BoxFuture<'_, Result> { - Box::pin(async { + Box::pin(async move { + self.limits.lock().unwrap().push(request.limits); assert!(!self.active.swap(true, Ordering::SeqCst)); Ok(ClientAdmissionDecision::Granted) }) @@ -127,6 +129,72 @@ fn budget_rejection_releases_client_concurrency_without_creating_a_charge() { } } +#[test] +fn reused_client_uses_updated_limits_for_each_execution() { + let snapshots = RuntimeSnapshotHandle::new(start_snapshot()); + let admissions = Arc::new(Admissions::default()); + let service = DefaultExecutionService::new( + snapshots.clone(), + Arc::new(TrackingExecutionStore::default()), + ProviderRegistry::default(), + admissions.clone(), + Arc::new(UnusedCircuits), + Arc::new(UnusedContinuation), + Arc::new(RecordingClientApiKeyUsage::default()), + ); + let client = service.authenticate("sk_start_test").unwrap(); + let limited = RateLimits { + max_concurrency: 1, + requests_per_minute: 1, + }; + for (revision, limits) in [(2, limited), (3, RateLimits::unlimited())] { + snapshots.publish(start_snapshot_with_policy(revision, true, limits)); + let mut next = request(&service, ClientTransport::WebSocket); + next.client = client.clone(); + let started = block_on(service.start(next)).expect("new execution"); + block_on(started.session.detach_finalize()); + } + assert_eq!( + *admissions.limits.lock().unwrap(), + vec![limited, RateLimits::unlimited()] + ); +} + +#[test] +fn reused_client_cannot_start_after_key_disable_or_snapshot_suspension() { + for suspend in [false, true] { + let snapshots = RuntimeSnapshotHandle::new(start_snapshot()); + let admissions = Arc::new(Admissions::default()); + let service = DefaultExecutionService::new( + snapshots.clone(), + Arc::new(TrackingExecutionStore::default()), + ProviderRegistry::default(), + admissions.clone(), + Arc::new(UnusedCircuits), + Arc::new(UnusedContinuation), + Arc::new(RecordingClientApiKeyUsage::default()), + ); + let next = request(&service, ClientTransport::WebSocket); + if suspend { + snapshots.suspend(); + } else { + snapshots.publish(start_snapshot_with_policy( + 2, + false, + RateLimits::unlimited(), + )); + } + let result = block_on(service.start(next)); + let expected = if suspend { + GatewayErrorKind::Internal + } else { + GatewayErrorKind::Unauthorized + }; + assert!(matches!(result, Err(error) if error.kind() == expected)); + assert!(admissions.limits.lock().unwrap().is_empty()); + } +} + #[test] fn cancellation_and_pre_send_failure_settle_zero_and_release_concurrency_for_all_transports() { let admissions = Arc::new(Admissions::default()); @@ -1082,11 +1150,15 @@ fn client_snapshot() -> RuntimeSnapshot { } fn start_snapshot() -> RuntimeSnapshot { + start_snapshot_with_policy(1, true, RateLimits::unlimited()) +} + +fn start_snapshot_with_policy(revision: u64, enabled: bool, limits: RateLimits) -> RuntimeSnapshot { let provider = ProviderKind::new("openai").expect("provider kind"); let capabilities = ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000)); RuntimeSnapshot::new( - ConfigRevision::new(1).expect("config revision"), + ConfigRevision::new(revision).expect("config revision"), AccountSelectionPolicy::new( RotationStrategy::Smart, std::num::NonZeroU32::new(1).expect("concurrency"), @@ -1102,8 +1174,8 @@ fn start_snapshot() -> RuntimeSnapshot { ClientApiKeyId::new("key_start_test").expect("client API key ID"), PlaintextClientApiKey::new("sk_start_test").expect("plaintext client API key"), account_scope(&provider, "acct_start"), - true, - RateLimits::unlimited(), + enabled, + limits, )], ) .expect("start snapshot") diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index a53ba6572..4f06f43ab 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -213,6 +213,7 @@ impl PgAdminAccountStore { async fn commit_prepared_import( &self, prepared: PreparedCredentialImport, + settings: Option, context: &MutationContext, action: &str, ) -> AdminStoreResult { @@ -223,9 +224,15 @@ impl PgAdminAccountStore { .map(prepared_account) .collect::>>() .map_err(|error| admin_store_error(ENTITY, error))?; + let mut changed_fields = vec!["credentials".to_owned()]; + if settings.is_some() { + changed_fields + .extend(["enabled", "concurrency_limit", "weight", "group_ids"].map(str::to_owned)); + } let imported = self .accounts .import_provider_accounts(ImportProviderAccounts { + settings, scope: ProviderAccountAdminScope { provider_kind: provider_kind.clone(), }, @@ -235,7 +242,7 @@ impl PgAdminAccountStore { action, "provider_account", &provider_kind, - vec!["credentials".to_owned()], + changed_fields, ), }) .await @@ -615,8 +622,13 @@ impl AccountStore for PgAdminAccountStore { command: CredentialImportCommit, context: &MutationContext, ) -> AdminStoreResult { - self.commit_prepared_import(command.prepared, context, "import_document") - .await + self.commit_prepared_import( + command.prepared, + command.settings, + context, + "import_document", + ) + .await } async fn commit_authorization( @@ -635,6 +647,7 @@ impl AccountStore for PgAdminAccountStore { provider_kind: credential.provider_kind.clone(), credentials: vec![credential], }, + command.settings, context, "authorize", ) @@ -666,6 +679,13 @@ impl AccountStore for PgAdminAccountStore { }) } AuthorizationCredentialCommit::Reauthorize(prepared) => { + if command.settings.is_some() { + return Err(AdminStoreError::new( + AdminStoreErrorKind::Invalid, + ENTITY, + "reauthorization cannot change account settings", + )); + } self.commit_prepared_rotation(prepared, context, "reauthorize") .await } diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs index 4bd3fc9fc..0a2e6782f 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs @@ -9,12 +9,13 @@ use gateway_admin::{ MutationContext, Revision as AdminRevision, account_groups::AccountGroupRef, accounts::{ - AccountCost, AccountGroupFilter, AccountListQuery as AdminAccountListQuery, - AccountModelUsage, AccountPage, AccountPageItem, AccountRecord, AccountRequestBucket, - AccountSort as AdminAccountSort, AccountSortField as AdminAccountSortField, - AccountSummary, AccountUpdateResult, AccountUsage, AccountUsageWindowQuery, - AccountUsageWindowResult, AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, - SortDirection as AdminSortDirection, UpdateAccount, + AccountCost, AccountGroupFilter, AccountImportSettings, + AccountListQuery as AdminAccountListQuery, AccountModelUsage, AccountPage, + AccountPageItem, AccountRecord, AccountRequestBucket, AccountSort as AdminAccountSort, + AccountSortField as AdminAccountSortField, AccountSummary, AccountUpdateResult, + AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult, AccountsUpdateResult, + BatchUpdateAccounts, DeleteAccounts, SortDirection as AdminSortDirection, + UpdateAccount, }, observability::{ CostCoverage as AdminCostCoverage, DecimalAmount as AdminDecimalAmount, TimeRange, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs index 265a28e4f..d8da8566c 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs @@ -448,6 +448,29 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository { account_ids .push(upsert_provider_account_in_transaction(&mut transaction, account).await?); } + if let Some(settings) = &command.settings { + let unique_ids = account_ids + .iter() + .cloned() + .collect::>() + .into_iter() + .collect::>(); + update_provider_accounts_scheduling_in_transaction( + &mut transaction, + &unique_ids, + settings.enabled, + settings.concurrency_limit, + settings.weight, + None, + ) + .await?; + replace_account_group_assignments_in_transaction( + &mut transaction, + &unique_ids, + &settings.group_ids, + ) + .await?; + } append_admin_audit_event_in_transaction(&mut transaction, command.audit, revision) .await?; Ok(ProviderAccountAdminImport { @@ -846,7 +869,7 @@ pub(crate) async fn update_provider_accounts_scheduling_in_transaction( ) -> StoreResult<()> { let updated = sqlx::query_scalar::<_, String>( "update provider_accounts - set enabled = $2, concurrency_limit = $3, weight = $4, updated_at = now(), + 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 where id = any($1::text[]) returning id", @@ -949,7 +972,7 @@ fn validate_batch_update_account_ids(account_ids: &[String]) -> StoreResult<()> Ok(()) } -fn validate_batch_update_group_ids(group_ids: &[AccountGroupId]) -> StoreResult<()> { +pub(super) fn validate_batch_update_group_ids(group_ids: &[AccountGroupId]) -> StoreResult<()> { const MAX_BATCH_UPDATE_GROUPS: usize = 1000; if group_ids.len() > MAX_BATCH_UPDATE_GROUPS { return Err(invalid("account batch update contains too many group IDs")); diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs index 69ade944d..ae9d1998e 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs @@ -232,6 +232,7 @@ impl NewProviderAccount { #[derive(Clone)] pub struct ImportProviderAccounts { + pub settings: Option, pub scope: ProviderAccountAdminScope, pub accounts: Vec, pub audit: AdminAuditEvent, @@ -263,6 +264,9 @@ impl fmt::Debug for ImportProviderAccounts { impl ImportProviderAccounts { pub fn validate(&self) -> StoreResult<()> { + if let Some(settings) = &self.settings { + repository::validate_batch_update_group_ids(&settings.group_ids)?; + } self.scope.validate()?; if self.accounts.is_empty() || self.accounts.len() > MAX_ADMIN_IMPORT_BATCH { return Err(invalid( diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index 1db5b5adb..4283d53a4 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -1545,6 +1545,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it() }; let imported = repository .import_provider_accounts(ImportProviderAccounts { + settings: None, scope: scope.clone(), accounts: vec![account("acct_admin_upsert", "user-admin-upsert")], audit: audit("audit_admin_upsert_create", "import", "acct_admin_upsert"), @@ -1567,6 +1568,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it() updated.credential_state = CredentialState::Banned; let imported = repository .import_provider_accounts(ImportProviderAccounts { + settings: None, scope: scope.clone(), accounts: vec![updated], audit: audit("audit_admin_upsert_update", "import", "acct_admin_upsert"), @@ -1601,6 +1603,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it() repository .import_provider_accounts(ImportProviderAccounts { + settings: None, scope, accounts: vec![account("acct_admin_upsert", "another-user")], audit: audit("audit_admin_upsert_rebind", "import", "acct_admin_upsert"), @@ -1638,6 +1641,12 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser let result = admin_account_store(&database.pool) .commit_authorization( AuthorizationCommit { + settings: Some(gateway_admin::model::accounts::AccountImportSettings { + enabled: false, + concurrency_limit: None, + weight: gateway_core::account::AccountWeight::new(9).expect("weight"), + group_ids: Vec::new(), + }), pending: PendingAuthorizationMutation::new( provider_kind.clone(), AuthorizationMutationTarget::Create { @@ -1679,6 +1688,8 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser ), ("acct_authorization_existing", Some(2)), ); + let settings: (bool, Option, i16) = sqlx::query_as("select enabled, concurrency_limit, weight from provider_accounts where id = 'acct_authorization_existing'").fetch_one(&database.pool).await.expect("OAuth settings"); + assert_eq!(settings, (false, None, 9)); database.close().await; } @@ -1872,6 +1883,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() { let imported = repository .import_provider_accounts(ImportProviderAccounts { + settings: None, scope: scope.clone(), accounts: vec![ account("acct_admin_a", "user-admin-a"), @@ -1899,6 +1911,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() { repository .import_provider_accounts(ImportProviderAccounts { + settings: None, scope: scope.clone(), accounts: vec![ account("acct_admin_transient", "user-admin-transient"), @@ -2087,6 +2100,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi }; let imported = repository .import_provider_accounts(ImportProviderAccounts { + settings: None, scope: scope.clone(), accounts: vec![account("acct_grouped_import", "user-grouped-import")], audit: audit("audit_grouped_import", "import", "acct_grouped_import"), @@ -2120,6 +2134,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi .expect("set account scheduling before reimport"); repository .import_provider_accounts(ImportProviderAccounts { + settings: None, scope: scope.clone(), accounts: vec![account("acct_reimport_candidate", "user-grouped-import")], audit: audit("audit_grouped_reimport", "import", "acct_grouped_import"), @@ -2187,6 +2202,7 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() { }; repository .import_provider_accounts(ImportProviderAccounts { + settings: None, scope: scope.clone(), accounts: vec![account("acct_rotation_quota", "user-rotation-quota")], audit: audit( @@ -2328,6 +2344,7 @@ async fn disabled_account_preserves_user_state_during_refresh_writes() { disabled.credential_state = CredentialState::Expired; repository .import_provider_accounts(ImportProviderAccounts { + settings: None, scope: scope.clone(), accounts: vec![disabled], audit: audit( @@ -2616,3 +2633,103 @@ async fn proxy_edit_preserves_an_inflight_token_refresh() { "http://127.0.0.1:18080/" ); } + +#[tokio::test] +async fn account_import_settings_apply_atomically_to_new_and_existing_identities() { + use gateway_admin::model::accounts::AccountImportSettings; + use gateway_core::account::{AccountConcurrencyLimit, AccountWeight}; + const GROUP_ID: &str = "grp_00000000000000000000000000000091"; + let Some(database) = TestDatabase::create("import_settings").await else { + return; + }; + let repository = PgProviderAccountRepository::new(database.pool.clone()); + repository + .insert_provider_account(account("acct_existing_settings", "existing-settings-user")) + .await + .expect("seed account"); + sqlx::query( + "insert into account_groups (id, name, color, created_at, updated_at) values ($1, 'Import settings', '#2563EBFF', now(), now())", + ) + .bind(GROUP_ID) + .execute(&database.pool) + .await + .expect("seed group"); + let settings = AccountImportSettings { + enabled: false, + concurrency_limit: Some(AccountConcurrencyLimit::new(3).expect("concurrency")), + weight: AccountWeight::new(7).expect("weight"), + group_ids: vec![AccountGroupId::new(GROUP_ID).expect("group ID")], + }; + let result = repository + .import_provider_accounts(ImportProviderAccounts { + settings: Some(settings.clone()), + scope: ProviderAccountAdminScope { + provider_kind: "openai".to_owned(), + }, + accounts: vec![ + account("acct_new_settings", "new-settings-user"), + account("acct_existing_candidate", "existing-settings-user"), + account("acct_duplicate_candidate", "existing-settings-user"), + ], + audit: audit("audit_import_settings", "import", "provider_accounts"), + }) + .await + .expect("import with settings"); + assert_eq!(result.config_revision.get(), 2); + for id in ["acct_new_settings", "acct_existing_settings"] { + let row: (bool, Option, i16) = sqlx::query_as( + "select enabled, concurrency_limit, weight from provider_accounts where id = $1", + ) + .bind(id) + .fetch_one(&database.pool) + .await + .expect("saved settings"); + assert_eq!(row, (false, Some(3), 7)); + assert_eq!(account_group_ids(&database.pool, id).await, [GROUP_ID]); + } + let before = repository + .load_provider_account("acct_existing_settings") + .await + .expect("existing account"); + let failed = repository + .import_provider_accounts(ImportProviderAccounts { + settings: Some(AccountImportSettings { + group_ids: vec![ + AccountGroupId::new("grp_00000000000000000000000000000092") + .expect("missing group"), + ], + ..settings + }), + scope: ProviderAccountAdminScope { + provider_kind: "openai".to_owned(), + }, + accounts: vec![ + account("acct_rollback_settings", "rollback-settings-user"), + account("acct_rollback_candidate", "existing-settings-user"), + ], + audit: audit( + "audit_import_settings_failed", + "import", + "provider_accounts", + ), + }) + .await; + assert!(failed.is_err()); + assert_eq!(current_revision(&database.pool).await, 2); + assert_eq!( + account_count(&database.pool, "acct_rollback_settings").await, + 0 + ); + assert_eq!( + repository + .load_provider_account("acct_existing_settings") + .await + .expect("unchanged account"), + before + ); + assert_eq!( + account_group_ids(&database.pool, "acct_existing_settings").await, + [GROUP_ID] + ); + database.close().await; +} diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index efd0ed384..8e61ba2bd 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -443,6 +443,7 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne let error = bundle .admin_provider() .complete_authorization(CompleteAuthorization { + settings: None, context: MutationContext { actor: MutationActor::AdminSession { admin_user_id: "different-owner".to_owned(), diff --git a/backend/crates/providers/xai/tests/admin.rs b/backend/crates/providers/xai/tests/admin.rs index 42a5cfdaa..9e8948413 100644 --- a/backend/crates/providers/xai/tests/admin.rs +++ b/backend/crates/providers/xai/tests/admin.rs @@ -217,6 +217,7 @@ async fn xai_admin_provider_restores_full_pending_envelope_and_binds_owner() { let wrong_owner = bundle .admin_provider() .complete_authorization(CompleteAuthorization { + settings: None, context: MutationContext { actor: MutationActor::AdminSession { admin_user_id: "different-owner".to_owned(), @@ -237,6 +238,7 @@ async fn xai_admin_provider_restores_full_pending_envelope_and_binds_owner() { let invalid_callback = bundle .admin_provider() .complete_authorization(CompleteAuthorization { + settings: None, context: owner, flow_id: flow_id.to_owned(), callback_url: format!( @@ -259,6 +261,7 @@ async fn xai_admin_provider_restores_full_pending_envelope_and_binds_owner() { let mismatched_envelope = bundle .admin_provider() .complete_authorization(CompleteAuthorization { + settings: None, context: MutationContext { actor: MutationActor::AdminSession { admin_user_id: "admin-owner".to_owned(), diff --git a/docs/api.md b/docs/api.md index db18f0039..4a88f7405 100644 --- a/docs/api.md +++ b/docs/api.md @@ -171,7 +171,7 @@ 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 }` | 导入或按上游身份更新账号;新账号保持未分组,已有账号保留所属分组 | +| `POST` | `/api/admin/accounts/import` | `{ provider, data, settings? }` | 导入或按上游身份更新账号,可同时应用调度与分组设置 | | `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 | @@ -187,7 +187,7 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。 | `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/complete` | `{ provider, flowId, callbackUrl }` | 消费 OAuth callback;新账号保持未分组,重新授权保留所属分组 | +| `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl, settings? }` | 消费 OAuth callback;首次授权可附带账号设置,重新授权保留原设置 | 账号列表支持以下稳定值: @@ -268,8 +268,14 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。 RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token 写入日志、issue、fixture 或文档。 -账号导入与 OAuth complete 不接收 `groupIds`。首次创建的账号保持未分组;按既有上游身份重新导入、 -重新授权以及普通 credential refresh/rotation 均保留已有分组。分组关系只通过账号编辑维护。 +账号导入与首次 OAuth complete 可附带 `settings: { enabled, concurrencyLimit, weight, groupIds }`。 +提供 `settings` 时四项均必填,`concurrencyLimit: null` 继承运行参数,否则为 1–4294967295 的整数; +`weight` 为 1–100,`groupIds` 为完整分组集合。设置应用于本次导入的全部账号,包括匹配到的已有账号, +与凭据在同一事务内提交;分组不存在时整次回滚。省略 `settings` 时新账号使用默认设置并保持未分组, +已有账号保留原有分组、权重与并发设置。重新授权不接受 `settings`,普通 credential refresh/rotation 也保留账号设置。 + +管理端先配置账号设置,再选择 OAuth、AT/RT 或账号文件完成导入。返回设置保留输入;更改出站配置会使 +旧 OAuth 链接失效。文件中显式的出站配置优先于表单代理,未指定时使用表单代理。 账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。 OpenAI 的 CPR 导出保持 OAuth 账号的既有 token 与过期时间字段。 @@ -450,6 +456,7 @@ PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用 日窗口按北京时间零点重置;周窗口从首次准入当天零点起持续七天,到期后在下一次使用时重新开启。 费用按请求完成时间归属窗口。并发按同一 Key 的执行中请求累计,包含 SSE 与每个 WebSocket `response.create`;空闲连接不占名额,内部重试不重复占用。 +修改 Key 策略对既有 WebSocket 连接的下一次请求同样生效,已开始的请求保持原有快照。 任一已结算金额达到限额后拒绝新请求,已准入请求可完成并使金额超过阈值。 HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly_budget_exceeded`, diff --git a/docs/architecture.md b/docs/architecture.md index b58d89bb1..ebf13a0b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -234,7 +234,9 @@ Client Key 与账号分组形成授权范围: - 分组可以包含多个 Provider,账号也可以属于多个分组。 账号选择综合启停状态、credential/quota 事实、Redis cooldown、并发上限、权重、请求间隔和会话亲和。 -账号编辑在一个事务中替换完整调度事实;导入、重新授权和后台刷新不会覆盖已有分组、权重或并发设置。 +账号编辑在一个事务中替换完整调度事实。导入与首次 OAuth 可携带统一账号设置,由 Admin 传递给 Store, +与凭据在同一事务中提交;Provider 仍独占凭据解析。未附带设置的导入、重新授权和后台刷新保留已有分组、 +权重与并发设置。管理端导入和账号编辑共用设置表单,凭据输入独立于设置。 Continuation 仍受原请求的 Client Key、账号范围、Provider 和发送/交付边界约束: @@ -255,6 +257,7 @@ Core 负责准入与结算时序,Store 持久化费用账本,Admin 负责限 费用归属请求完成时间,跨日请求计入完成日,延迟写入或核账仍保留原完成时间。 - 准入检查已结算金额,不预占未来费用。达到任一金额阈值后拒绝新请求,已准入请求仍可完成并使总额超过阈值。 - SSE 持有并发名额直到终态;WebSocket 每个 `response.create` 独立准入,空闲连接不占名额。 + Core 在每次请求开始时重新鉴权并冻结当前策略,既有连接也应用已发布的限额与授权范围变更。 换号和内部重试复用同一名额,Key 与账号并发上限同时生效;预算拒绝或启动失败释放名额。 每次计费请求在发送上游前持久化待结算记录。完成、失败、取消和断连使用同一结算路径,以网关请求 ID diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 16e8ba208..21775943e 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -312,7 +312,15 @@ interface AccountDeleteParams { accountIds: string[] } +interface AccountImportSettings { + enabled: boolean + concurrencyLimit: number | null + weight: number + groupIds: string[] +} + interface AccountImportParam { + settings?: AccountImportSettings provider: string data: unknown } @@ -325,6 +333,7 @@ interface AccountOAuthStartParam { } interface AccountOAuthCompleteParam { + settings?: AccountImportSettings provider: string flowId: string callbackUrl: string diff --git a/frontend/src/stores/modules/theme.ts b/frontend/src/stores/modules/theme.ts index d8d65403e..fc4029c3d 100644 --- a/frontend/src/stores/modules/theme.ts +++ b/frontend/src/stores/modules/theme.ts @@ -156,22 +156,18 @@ export const useThemeStore = defineStore( return } - let cleaned = false - const cleanupTransition = (): void => { - if (cleaned) - return - cleaned = true - requestAnimationFrame(() => { + let activeAnimation: Animation | undefined + void transition.finished + .catch(() => undefined) + .finally(() => { + // 旧快照移除后再取消裁剪,避免深色图层在最后一帧重新铺满页面。 + activeAnimation?.cancel() document.documentElement.classList.remove('theme-view-transition-shrink') themeTransitioning.value = false }) - } - const cleanupTimer = window.setTimeout(cleanupTransition, 1200) - void transition.finished.catch(() => undefined) - let activeAnimation: Animation | undefined void transition.ready .then(() => { - const animation = document.documentElement.animate( + activeAnimation = document.documentElement.animate( { clipPath: shrinkingDarkLayer ? [ @@ -192,15 +188,8 @@ export const useThemeStore = defineStore( : '::view-transition-new(root)', }, ) - activeAnimation = animation - return animation.finished.catch(() => undefined) }) .catch(() => undefined) - .finally(() => { - activeAnimation?.cancel() - window.clearTimeout(cleanupTimer) - cleanupTransition() - }) } function runFallbackThemeTransition(theme: ResolvedTheme): void { diff --git a/frontend/src/views/account-groups/components/AccountGroupFormModal.vue b/frontend/src/views/account-groups/components/AccountGroupFormModal.vue index 149e13f92..d89b1a148 100644 --- a/frontend/src/views/account-groups/components/AccountGroupFormModal.vue +++ b/frontend/src/views/account-groups/components/AccountGroupFormModal.vue @@ -64,7 +64,7 @@ const description = computed(() => props.group