feat(oauth): 允许替换已失效的活跃 OAuth 账号并同步 status_snapshot

- 活跃但 token 已过期或刷新失败的重复账号视为可替换
- 清除失效标记、刷新配额时同步更新 status_snapshot.oauth
- oauth_invalid 清除接口同时识别 invalid_at 与 invalid_reason 两种标记
- 批量导入任务状态区分 created_count / replaced_count,前端据此展示新增/替换统计
- 补充重复替换场景的集成测试,用量测试等待超时从 10s 提升到 30s 以适应并行压力
This commit is contained in:
fawney19
2026-04-20 22:59:02 +08:00
parent cf7d129595
commit c5c56ff92f
15 changed files with 965 additions and 44 deletions

View File

@@ -28,6 +28,7 @@ export interface ProviderOAuthCompleteResponseWithKey {
expires_at?: number | null
has_refresh_token: boolean
email?: string | null
replaced?: boolean
}
export interface OAuthBatchImportResultItem {
@@ -49,6 +50,8 @@ export interface OAuthBatchImportTaskStartResponse {
processed: number
success: number
failed: number
created_count?: number
replaced_count?: number
progress_percent: number
message?: string | null
}
@@ -62,6 +65,8 @@ export interface OAuthBatchImportTaskStatusResponse {
processed: number
success: number
failed: number
created_count?: number
replaced_count?: number
progress_percent: number
message?: string | null
error?: string | null

View File

@@ -717,6 +717,51 @@ function getImportTaskStatusText(status: OAuthBatchImportTaskStatus): string {
}
}
function getOAuthSuccessMessage(
action: '授权' | '导入',
options?: { email?: string | null; replaced?: boolean }
): string {
const email = typeof options?.email === 'string' ? options.email.trim() : ''
const replaced = options?.replaced === true
if (email) {
return replaced
? `${action}成功: ${email}(已替换旧账号)`
: `${action}成功: ${email}`
}
return replaced
? `${action}成功,已替换旧账号`
: `${action}成功,账号已添加`
}
function getBatchImportSuccessMessage(task: OAuthBatchImportTaskStatusResponse): string {
const replacedCount = Math.max(task.replaced_count ?? 0, 0)
const createdCount = Math.max(task.created_count ?? task.success - replacedCount, 0)
const parts: string[] = []
if (createdCount > 0) {
parts.push(`新增 ${createdCount}`)
}
if (replacedCount > 0) {
parts.push(`替换 ${replacedCount}`)
}
if (task.failed > 0) {
parts.push(`失败 ${task.failed}`)
}
if (parts.length === 0) {
return task.failed > 0 ? `批量导入完成:失败 ${task.failed}` : '批量导入完成'
}
if (task.failed === 0 && createdCount > 0 && replacedCount === 0) {
return `批量导入成功:${createdCount} 个账号已添加`
}
if (task.failed === 0 && createdCount === 0 && replacedCount > 0) {
return `批量导入成功:已替换 ${replacedCount} 个旧账号`
}
const prefix = task.failed > 0 ? '批量导入完成' : '批量导入成功'
return `${prefix}${parts.join('')}`
}
function scheduleImportPoll(taskId: string, delayMs = 1200) {
stopImportPolling()
importPollTimer = setTimeout(() => {
@@ -736,11 +781,7 @@ async function pollImportTaskStatus(taskId: string) {
stopImportPolling()
importing.value = false
if (task.success > 0) {
if (task.failed > 0) {
success(`批量导入完成:成功 ${task.success} 个,失败 ${task.failed}`)
} else {
success(`批量导入成功:${task.success} 个账号已添加`)
}
success(getBatchImportSuccessMessage(task))
emit('saved')
handleClose()
} else {
@@ -863,12 +904,12 @@ async function handleCompleteOAuth() {
const requestId = ++oauthCompleteRequestId
oauth.value.completing = true
try {
await completeProviderLevelOAuth(props.providerId, {
const result = await completeProviderLevelOAuth(props.providerId, {
callback_url: oauth.value.callback_url.trim(),
proxy_node_id: selectedProxyNodeId.value || undefined,
})
if (requestId !== oauthCompleteRequestId) return
success('授权成功,账号已添加')
success(getOAuthSuccessMessage('授权', result))
emit('saved')
handleClose()
} catch (err: unknown) {
@@ -966,6 +1007,8 @@ async function handleImport() {
processed: task.processed,
success: task.success,
failed: task.failed,
created_count: task.created_count ?? 0,
replaced_count: task.replaced_count ?? 0,
progress_percent: task.progress_percent,
message: task.message || null,
error: null,
@@ -984,11 +1027,11 @@ async function handleImport() {
showError('无法解析输入内容,请检查格式', '格式错误')
return
}
await importProviderRefreshToken(props.providerId, {
const result = await importProviderRefreshToken(props.providerId, {
...parsed,
proxy_node_id: proxyNodeId,
})
success('导入成功,账号已添加')
success(getOAuthSuccessMessage('导入', result))
emit('saved')
handleClose()
}
@@ -1073,7 +1116,7 @@ async function pollDevice() {
stopDevicePolling()
totp.stop()
device.value.status = 'authorized'
success(result.email ? `授权成功: ${result.email}` : '授权成功,账号已添加')
success(getOAuthSuccessMessage('授权', result))
emit('saved')
handleClose()
return