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 committed 2026-07-15 10:51:13 +08:00
1 parent cf6aa1a5c3
commit 60ff61132d
21 files changed
+1142 -43

No files matched your search

+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 })