feat(accounts): make repeated static account setup safer

Admins often need another account with the same provider and routing configuration. Duplicate on the server so credentials never return to the browser, preserve exact group priorities atomically, start the copy paused, and recover the same copy after ambiguous idempotency-store failures.

Constraint: Admin account responses redact credentials, so duplication must remain server-side

Constraint: OAuth and setup-token credentials rotate and must not be shared across account rows

Rejected: Copy raw account JSON to the clipboard | exposes credentials outside the server

Rejected: Duplicate rotating credentials | account-scoped refresh locks can race token rotation

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep copies paused, avoid automatic upstream probes, and exclude rotating credential types unless token ownership is redesigned

Tested: Targeted Go tests, Go vet, server build; frontend lint, typecheck, Vitest suite, production build; integration test compiled

Not-tested: Docker-backed PostgreSQL execution because Docker is unavailable

Related: Wei-Shaw/sub2api#1379

Related: Wei-Shaw/sub2api#2928
This commit is contained in:
yan9651688
2026-07-15 10:51:13 +08:00
parent cf6aa1a5c3
commit 60ff61132d
21 changed files with 1142 additions and 43 deletions
@@ -22,6 +22,10 @@
<Icon name="clock" size="sm" class="text-orange-500" />
{{ t('admin.scheduledTests.schedule') }}
</button>
<button v-if="canDuplicate" @click="$emit('duplicate', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="copy" size="sm" class="text-sky-500" />
{{ t('admin.accounts.duplicateAccount') }}
</button>
<!-- 影子账号不持凭据:重授权/刷新 token 对其无效(后端拒绝),故隐藏(外审 G4)。 -->
<template v-if="(account.type === 'oauth' || account.type === 'setup-token') && !isShadow">
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
@@ -64,8 +68,12 @@ import { Icon } from '@/components/icons'
import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota', 'set-privacy', 'create-spark-shadow'])
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'duplicate', 'reauth', 'refresh-token', 'recover-state', 'reset-quota', 'set-privacy', 'create-spark-shadow'])
const { t } = useI18n()
const canDuplicate = computed(() => {
if (!props.account || props.account.parent_account_id != null) return false
return ['apikey', 'upstream', 'bedrock', 'service_account'].includes(props.account.type)
})
const isRateLimited = computed(() => {
if (props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) {
return true
@@ -49,6 +49,55 @@ const getBodyText = () => document.body.textContent ?? ''
const getBodyButtons = () => Array.from(document.body.querySelectorAll('button'))
describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('普通账号显示「复制账号」按钮', () => {
const account = makeAccount({ platform: 'anthropic', type: 'apikey', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
expect(getBodyText()).toContain('admin.accounts.duplicateAccount')
wrapper.unmount()
})
it('影子账号隐藏「复制账号」按钮', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: 42 })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
expect(getBodyText()).not.toContain('admin.accounts.duplicateAccount')
wrapper.unmount()
})
it.each(['oauth', 'setup-token'] as const)('%s 账号隐藏「复制账号」按钮,避免共享可轮换令牌', (type) => {
const account = makeAccount({ platform: 'openai', type, parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
expect(getBodyText()).not.toContain('admin.accounts.duplicateAccount')
wrapper.unmount()
})
it('点击「复制账号」触发 duplicate 事件并携带 account', async () => {
const account = makeAccount({ platform: 'anthropic', type: 'apikey', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
const duplicateBtn = getBodyButtons().find(b => b.textContent?.includes('admin.accounts.duplicateAccount'))
expect(duplicateBtn).toBeDefined()
duplicateBtn!.click()
await wrapper.vm.$nextTick()
const emitted = wrapper.emitted('duplicate')
expect(emitted).toBeTruthy()
expect(emitted![0][0]).toMatchObject({ id: account.id, name: account.name })
wrapper.unmount()
})
it('OpenAI OAuth 母账号(无 parent_account_id)显示「创建 spark 影子」按钮', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {