mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #4317 from yan9651688/feat/account-one-click-copy
feat(accounts): add safe one-click account duplication
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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: '重置账号状态失败',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user