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