Merge pull request #4317 from yan9651688/feat/account-one-click-copy

feat(accounts): add safe one-click account duplication
This commit is contained in:
Wesley Liddick
2026-07-15 14:23:35 +08:00
committed by GitHub
22 changed files with 1402 additions and 49 deletions
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn()
}))
vi.mock('@/api/client', () => ({
apiClient: { post }
}))
import { duplicate } from '@/api/admin/accounts'
describe('admin account duplicate API', () => {
beforeEach(() => {
sessionStorage.clear()
post.mockReset()
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)' } })
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
})
it('sends a stable idempotency key with the duplicate request', async () => {
const account = await duplicate(42)
expect(post).toHaveBeenCalledWith('/admin/accounts/42/duplicate', undefined, {
headers: {
'Idempotency-Key': 'account-duplicate-42-11111111-1111-4111-8111-111111111111'
}
})
expect(account).toEqual({ id: 43, name: 'primary (Copy)' })
})
it('reuses the operation key after an ambiguous failed request', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(99)).rejects.toThrow('network timeout')
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
await duplicate(99)
expect(post).toHaveBeenCalledTimes(2)
const firstHeaders = post.mock.calls[0][2].headers
const secondHeaders = post.mock.calls[1][2].headers
expect(secondHeaders).toEqual(firstHeaders)
})
it('reuses the operation key after a page reload', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(77)).rejects.toThrow('network timeout')
const firstHeaders = post.mock.calls[0][2].headers
vi.resetModules()
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
const { duplicate: duplicateAfterReload } = await import('@/api/admin/accounts')
await duplicateAfterReload(77)
expect(post).toHaveBeenCalledTimes(2)
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
expect(sessionStorage.length).toBe(0)
})
})
+45
View File
@@ -138,6 +138,50 @@ export async function create(accountData: CreateAccountRequest): Promise<Account
return data
}
/**
* Duplicate an account while keeping credentials on the server.
* @param id - Source account ID
* @returns Newly created account
*/
const duplicateOperationKeys = new Map<number, string>()
function duplicateOperationStorageKey(id: number): string {
return `sub2api:admin:account-duplicate:${id}`
}
function getStoredDuplicateOperationKey(id: number): string | null {
try {
return globalThis.sessionStorage?.getItem(duplicateOperationStorageKey(id)) ?? null
} catch {
return null
}
}
function storeDuplicateOperationKey(id: number, key: string | null): void {
try {
if (key) globalThis.sessionStorage?.setItem(duplicateOperationStorageKey(id), key)
else globalThis.sessionStorage?.removeItem(duplicateOperationStorageKey(id))
} catch {
// In-memory retry protection still works when browser storage is unavailable.
}
}
export async function duplicate(id: number): Promise<Account> {
let idempotencyKey = duplicateOperationKeys.get(id) ?? getStoredDuplicateOperationKey(id)
if (!idempotencyKey) {
const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
idempotencyKey = `account-duplicate-${id}-${requestID}`
}
duplicateOperationKeys.set(id, idempotencyKey)
storeDuplicateOperationKey(id, idempotencyKey)
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/duplicate`, undefined, {
headers: { 'Idempotency-Key': idempotencyKey }
})
duplicateOperationKeys.delete(id)
storeDuplicateOperationKey(id, null)
return data
}
/**
* Update account
* @param id - Account ID
@@ -809,6 +853,7 @@ export const accountsAPI = {
listWithEtag,
getById,
create,
duplicate,
update,
checkMixedChannelRisk,
delete: deleteAccount,
@@ -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, {
@@ -350,6 +350,9 @@ export default {
createSparkShadowConfirm: 'Create a spark shadow account linked to "{name}"? It shares the parent\'s credentials and serves only spark models.',
createSparkShadowSuccess: 'Spark shadow account created',
createSparkShadowFailed: 'Failed to create spark shadow account',
duplicateAccount: 'Duplicate Account',
duplicateSuccess: 'Account duplicated as "{name}" and paused. Review its credentials before enabling it.',
duplicateFailed: 'Failed to duplicate account',
resetStatus: 'Reset Status',
statusReset: 'Account status reset successfully',
failedToResetStatus: 'Failed to reset account status',
@@ -453,6 +453,9 @@ export default {
createSparkShadowConfirm: '为「{name}」创建链接型 Spark 影子账号?影子共享母账号凭据、仅服务 spark 模型。',
createSparkShadowSuccess: 'Spark 影子账号已创建',
createSparkShadowFailed: '创建 Spark 影子账号失败',
duplicateAccount: '复制账号',
duplicateSuccess: '账号已复制为「{name}」,已暂停调度,请确认凭据后再启用',
duplicateFailed: '复制账号失败',
resetStatus: '重置状态',
statusReset: '账号状态已重置',
failedToResetStatus: '重置账号状态失败',
+16 -1
View File
@@ -399,7 +399,7 @@
<AccountTestModal :show="showTest" :account="testingAcc" @close="closeTestModal" />
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
<ScheduledTestsPanel :show="showSchedulePanel" :account-id="scheduleAcc?.id ?? null" :model-options="scheduleModelOptions" @close="closeSchedulePanel" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" @create-spark-shadow="handleCreateSparkShadow" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @duplicate="handleDuplicateAccount" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" @create-spark-shadow="handleCreateSparkShadow" />
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
<BulkEditAccountModal
@@ -1707,6 +1707,21 @@ const handleSchedule = async (a: Account) => {
}
const closeSchedulePanel = () => { showSchedulePanel.value = false; scheduleAcc.value = null; scheduleModelOptions.value = [] }
const handleReAuth = (a: Account) => { reAuthAcc.value = a; showReAuth.value = true }
const duplicatingAccountIDs = new Set<number>()
const handleDuplicateAccount = async (a: Account) => {
if (duplicatingAccountIDs.has(a.id)) return
duplicatingAccountIDs.add(a.id)
try {
const duplicate = await adminAPI.accounts.duplicate(a.id)
appStore.showSuccess(t('admin.accounts.duplicateSuccess', { name: duplicate.name }))
reload()
} catch (error: any) {
console.error('Failed to duplicate account:', error)
appStore.showError(error?.message || t('admin.accounts.duplicateFailed'))
} finally {
duplicatingAccountIDs.delete(a.id)
}
}
const handleRefresh = async (a: Account) => {
try {
const updated = await adminAPI.accounts.refreshCredentials(a.id)
@@ -14,6 +14,7 @@ const {
getBatchTodayStats,
getAllProxies,
getAllGroups,
duplicateAccount,
createSparkShadow,
showSuccess,
showError
@@ -23,6 +24,7 @@ const {
getBatchTodayStats: vi.fn(),
getAllProxies: vi.fn(),
getAllGroups: vi.fn(),
duplicateAccount: vi.fn(),
createSparkShadow: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn()
@@ -34,6 +36,7 @@ vi.mock('@/api/admin', () => ({
list: listAccounts,
listWithEtag,
getBatchTodayStats,
duplicate: duplicateAccount,
createSparkShadow,
delete: vi.fn(),
batchClearError: vi.fn(),
@@ -102,7 +105,7 @@ const mountView = () =>
describe('admin AccountsView — 外审 F2:spark 影子创建接线', () => {
beforeEach(() => {
localStorage.clear()
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, createSparkShadow, showSuccess, showError]) {
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, duplicateAccount, createSparkShadow, showSuccess, showError]) {
fn.mockReset()
}
listAccounts.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
@@ -110,6 +113,7 @@ describe('admin AccountsView — 外审 F2:spark 影子创建接线', () => {
getBatchTodayStats.mockResolvedValue({ stats: {} })
getAllProxies.mockResolvedValue([])
getAllGroups.mockResolvedValue([])
duplicateAccount.mockResolvedValue({ id: 998, name: 'parent-acc (Copy)' })
createSparkShadow.mockResolvedValue({ id: 999, name: 'parent-acc (Spark)' })
})
@@ -117,6 +121,51 @@ describe('admin AccountsView — 外审 F2:spark 影子创建接线', () => {
vi.unstubAllGlobals()
})
it('AccountActionMenu 的 duplicate 事件一键复制账号并刷新列表', async () => {
const wrapper = mountView()
await flushPromises()
wrapper.findComponent(AccountActionMenu).vm.$emit('duplicate', { id: 42, name: 'parent-acc' })
await flushPromises()
expect(duplicateAccount).toHaveBeenCalledTimes(1)
expect(duplicateAccount).toHaveBeenCalledWith(42)
expect(showSuccess).toHaveBeenCalledWith('admin.accounts.duplicateSuccess')
expect(listAccounts.mock.calls.length).toBeGreaterThan(1)
wrapper.unmount()
})
it('同一账号复制请求未完成时忽略重复点击', async () => {
let resolveDuplicate!: (account: { id: number; name: string }) => void
duplicateAccount.mockImplementationOnce(() => new Promise(resolve => { resolveDuplicate = resolve }))
const wrapper = mountView()
await flushPromises()
const menu = wrapper.findComponent(AccountActionMenu)
menu.vm.$emit('duplicate', { id: 42, name: 'parent-acc' })
menu.vm.$emit('duplicate', { id: 42, name: 'parent-acc' })
await flushPromises()
expect(duplicateAccount).toHaveBeenCalledTimes(1)
resolveDuplicate({ id: 998, name: 'parent-acc (Copy)' })
await flushPromises()
wrapper.unmount()
})
it('复制失败时显示后端错误', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
duplicateAccount.mockRejectedValueOnce(new Error('duplicate failed'))
const wrapper = mountView()
await flushPromises()
wrapper.findComponent(AccountActionMenu).vm.$emit('duplicate', { id: 42, name: 'parent-acc' })
await flushPromises()
expect(showError).toHaveBeenCalledWith('duplicate failed')
consoleError.mockRestore()
wrapper.unmount()
})
it('AccountActionMenu 的 create-spark-shadow 事件触发 createSparkShadow API + 成功提示', async () => {
const wrapper = mountView()
await flushPromises()
@@ -208,7 +257,7 @@ const mountViewWithRow = () =>
describe('admin AccountsView — 影子行 parent_* OR 兜底展示', () => {
beforeEach(() => {
localStorage.clear()
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, createSparkShadow, showSuccess, showError]) {
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, duplicateAccount, createSparkShadow, showSuccess, showError]) {
fn.mockReset()
}
listWithEtag.mockResolvedValue({ notModified: true, etag: null, data: null })