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
-
+
-
+
返回
diff --git a/frontend/src/views/accounts/components/AccountSettingsFields.vue b/frontend/src/views/accounts/components/AccountSettingsFields.vue
new file mode 100644
index 000000000..7f0f34921
--- /dev/null
+++ b/frontend/src/views/accounts/components/AccountSettingsFields.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+ 调度
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/accounts/composables/useAccountOnboarding.ts b/frontend/src/views/accounts/composables/useAccountOnboarding.ts
index 49cde9f20..f65ce22f5 100644
--- a/frontend/src/views/accounts/composables/useAccountOnboarding.ts
+++ b/frontend/src/views/accounts/composables/useAccountOnboarding.ts
@@ -11,7 +11,7 @@ import { useAsyncAction } from '@/composables/useAsyncAction'
import { errorMessage } from '@/utils/async'
import { isRecord } from '@/utils/object'
import { formatProviderLabel, isSupportedProvider } from '@/utils/providers'
-import { emptyAccountCreateForm } from '../components/AccountCreateModal/model'
+import { accountImportSettings, accountProxyError, emptyAccountCreateForm } from '../components/AccountCreateModal/model'
type AccountRow = Awaited>['items'][number]
type ImportProvider = 'openai' | 'xai'
@@ -57,6 +57,9 @@ export function useAccountOnboarding(options: {
await creatingAccountAction.run(
async () => {
+ const proxyError = accountProxyError(createForm.value)
+ if (proxyError)
+ throw new Error(proxyError)
const message = createForm.value.provider === 'batch'
? await importMixedAccountDocument()
: await importAccountDocument()
@@ -72,10 +75,11 @@ export function useAccountOnboarding(options: {
await authorizingOAuthAction.run(
async () => {
- const input = await newAccountInput()
+ const input = newAccountInput()
const account = reauthorizingAccount.value
- if (!account && createForm.value.proxyMode === 'proxy' && !createForm.value.proxyUrl.trim())
- throw new Error('请输入代理 URL')
+ const proxyError = accountProxyError(createForm.value)
+ if (!account && proxyError)
+ throw new Error(proxyError)
const result = await startAccountOAuth({
...input,
outboundProxyUrl: !account && createForm.value.proxyMode === 'proxy' ? createForm.value.proxyUrl.trim() : undefined,
@@ -117,6 +121,7 @@ export function useAccountOnboarding(options: {
provider: createForm.value.provider,
flowId: createForm.value.oauthFlowId,
callbackUrl,
+ settings: reauthorizingAccount.value ? undefined : accountImportSettings(createForm.value),
})
await finishCreate(
reauthorizingAccount.value
@@ -145,14 +150,14 @@ export function useAccountOnboarding(options: {
createForm.value = {
...emptyAccountCreateForm(),
provider: account.provider,
- name: account.name,
+ step: 'import',
mode: 'oauth',
}
showCreateModal.value = true
void handleAuthorizeOAuth()
}
- async function newAccountInput() {
+ function newAccountInput() {
const account = reauthorizingAccount.value
return {
provider: createForm.value.provider,
@@ -162,16 +167,22 @@ export function useAccountOnboarding(options: {
async function importAccountDocument() {
const provider = requireImportProvider(createForm.value.provider)
+ const mode = createForm.value.mode
+ if (mode === 'oauth')
+ throw new Error('请选择凭据导入方式')
const documents = accountImportDocuments(
provider,
- createForm.value.mode,
- createForm.value.importText,
+ mode,
+ createForm.value.importTexts[mode],
)
let importedCount = 0
for (const entry of documents) {
const result = await importAccounts({
provider,
- data: entry.document,
+ settings: accountImportSettings(createForm.value),
+ data: createForm.value.proxyMode === 'proxy'
+ ? withDefaultImportProxy(entry.document, createForm.value.proxyUrl.trim())
+ : entry.document,
})
importedCount += result.importedCount
}
@@ -179,7 +190,7 @@ export function useAccountOnboarding(options: {
}
async function importMixedAccountDocument() {
- const documents = parseMixedImportDocuments(parseImportJson(createForm.value.importText))
+ const documents = parseMixedImportDocuments(parseImportJson(createForm.value.importTexts.json))
let importedCount = 0
const failures: string[] = []
@@ -187,7 +198,10 @@ export function useAccountOnboarding(options: {
try {
const result = await importAccounts({
provider: entry.provider,
- data: entry.document,
+ settings: accountImportSettings(createForm.value),
+ data: createForm.value.proxyMode === 'proxy'
+ ? withDefaultImportProxy(entry.document, createForm.value.proxyUrl.trim())
+ : entry.document,
})
importedCount += result.importedCount
}
@@ -219,11 +233,26 @@ export function useAccountOnboarding(options: {
createForm.value = {
...createForm.value,
mode: createForm.value.provider === 'batch' ? 'json' : 'oauth',
+ importTexts: { access_token: '', refresh_token: '', json: '' },
oauthFlowId: '',
oauthAuthUrl: '',
oauthCallback: '',
}
},
+ { flush: 'sync' },
+ )
+
+ watch(
+ [
+ () => createForm.value.proxyMode,
+ () => createForm.value.proxyMode === 'proxy' ? createForm.value.proxyUrl.trim() : '',
+ ],
+ () => {
+ createForm.value.oauthFlowId = ''
+ createForm.value.oauthAuthUrl = ''
+ createForm.value.oauthCallback = ''
+ },
+ { flush: 'sync' },
)
return {
@@ -239,6 +268,20 @@ export function useAccountOnboarding(options: {
}
}
+function withDefaultImportProxy(document: Record, proxyUrl: string): Record {
+ if (isRecord(document.data) && Array.isArray(document.data.accounts))
+ return { ...document, data: withDefaultImportProxy(document.data, proxyUrl) }
+ if (Array.isArray(document.accounts)) {
+ return {
+ ...document,
+ accounts: document.accounts.map(account => isRecord(account) ? withDefaultImportProxy(account, proxyUrl) : account),
+ }
+ }
+ if (['outboundProxyUrl', 'outbound_proxy_url', 'proxy_key'].some(key => Object.hasOwn(document, key)))
+ return document
+ return { ...document, outboundProxyUrl: proxyUrl }
+}
+
function parseImportJson(value: string) {
try {
return JSON.parse(value)
diff --git a/frontend/src/views/accounts/index.vue b/frontend/src/views/accounts/index.vue
index 0a356e28b..7f4e5bea0 100644
--- a/frontend/src/views/accounts/index.vue
+++ b/frontend/src/views/accounts/index.vue
@@ -87,7 +87,7 @@ const {
} = useAccountMutations({
accounts,
selectedIds,
- reload: loadAccounts,
+ reload: () => Promise.all([loadAccounts(), loadGroups()]),
replaceAccount,
})
@@ -335,6 +335,8 @@ const {
v-model="showCreateModal"
v-model:form="createForm"
:account="reauthorizingAccount"
+ :groups="groups"
+ :groups-loading="groupsLoading"
:oauth-loading="authorizingOAuth"
:reauthorizing="Boolean(reauthorizingAccount)"
:saving="creatingAccount"
diff --git a/frontend/src/views/api-keys/components/ApiKeyCreateModal.vue b/frontend/src/views/api-keys/components/ApiKeyCreateModal.vue
index 3928d615c..b496ee9e1 100644
--- a/frontend/src/views/api-keys/components/ApiKeyCreateModal.vue
+++ b/frontend/src/views/api-keys/components/ApiKeyCreateModal.vue
@@ -130,7 +130,7 @@ const title = computed(() => props.editing ? '编辑 API Key' : '创建 API Key'
-
+
取消
{
暂无待核账请求
-
+
取消