feat: 上游 Key 唯一化、分组导入跳过、账号导入平台识别&远端校验&base_url 注入

- 上游 Key 命名改为 {prefix}-{upstream.id}-{safe_group_name}-{group_id}
- 唯一约束 (upstream_id, group_id, managed_prefix) 加 managed_prefix 列
- 上游检测成功时同步 Key 状态,远端已删/分组已删自动清理
- 重复分组导入跳过,目标网站已存在同名分组返回 exists
- 账号导入平台自动识别(auto/manual 模式)
- 全选可导入 Key 按钮 + 目标分组自动匹配
- 导入幂等:已导入过的 Key 校验远端账号,不存在则重建
- 新增同步接口 POST /sync-imported-upstream-keys
- account_exists() 通过拉取账号列表判断,避免 404 误判
- credentials.base_url 注入来源上游地址,避免 401
- 前端导入弹窗自动同步+刷新按钮+并发/优先级设置
- 新增 12 个测试覆盖同步、幂等、远端删除、校验失败路径
This commit is contained in:
liumangmang
2026-05-21 01:16:39 +08:00
parent 0a27bba296
commit 6044b00685
18 changed files with 3112 additions and 50 deletions
+152 -2
View File
@@ -127,6 +127,9 @@
</el-button>
<el-button size="small" text @click="testUpstream(row)" :loading="row._testing">测试</el-button>
<el-button size="small" text @click="checkNow(row)" :loading="row._checking">检测</el-button>
<el-button size="small" text @click="openKeyGenerate(row)" title="确保每个分组有一个 SmartUp Key">
<el-icon><Key /></el-icon>
</el-button>
<el-button size="small" text @click="openDetail(row)">
<el-icon><List /></el-icon>
详情
@@ -374,6 +377,26 @@
<span>{{ detailUpstream.last_error }}</span>
</div>
<div class="section-title">
<el-icon><Key /></el-icon>
已创建 Key
<span class="section-sub">最近 {{ generatedKeys.length }} </span>
</div>
<el-table :data="generatedKeys" v-loading="keysLoading" size="small" style="width: 100%" class="generated-key-table">
<el-table-column prop="group_name" label="分组" min-width="120" />
<el-table-column prop="key_name" label="名称" min-width="180" />
<el-table-column label="Key" min-width="140">
<template #default="{ row }"><span class="mono">{{ row.masked_key || '—' }}</span></template>
</el-table-column>
<el-table-column label="状态" width="96">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'import_failed' || row.status === 'failed' ? 'danger' : row.status === 'imported' ? 'success' : 'info'">
{{ keyStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div class="section-title">
<el-icon><Clock /></el-icon>
检测历史
@@ -440,6 +463,57 @@
</div>
</el-drawer>
<el-dialog v-model="keyDialogVisible" title="按分组创建 Key" width="620px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="上游">
<el-input :model-value="keyTarget?.name || ''" disabled />
</el-form-item>
<el-form-item label="选择分组">
<el-select v-model="keyForm.group_ids" multiple filterable style="width:100%" placeholder="不选则创建全部分组">
<el-option v-for="group in keyGroupOptions" :key="group.group_id" :label="`${group.group_name || group.group_id} (${group.rate || '—'})`" :value="group.group_id" />
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="名称前缀"><el-input v-model="keyForm.name_prefix" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="创建接口"><el-input v-model="keyForm.endpoint" /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="配额 USD0 不限)"><el-input-number v-model="keyForm.quota" :min="0" :precision="2" style="width:100%" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="有效天数"><el-input-number v-model="keyExpiresDays" :min="1" :disabled="!useKeyExpiry" style="width:100%" /></el-form-item>
</el-col>
</el-row>
<el-checkbox v-model="useKeyExpiry">设置过期时间</el-checkbox>
</el-form>
<div v-if="keyResults.length" class="result-panel">
<div class="result-title">操作结果</div>
<el-table :data="keyResults" size="small">
<el-table-column prop="group_name" label="分组" min-width="120" />
<el-table-column prop="key_name" label="名称" min-width="180" />
<el-table-column label="Key" min-width="160">
<template #default="{ row }"><span class="mono">{{ row.key_value || row.masked_key || '—' }}</span></template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag v-if="row.status === 'created'" size="small" type="success">新创建</el-tag>
<el-tag v-else-if="row.status === 'exists'" size="small" type="info">已存在</el-tag>
<el-tag v-else size="small" type="danger">失败</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="keyDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="generatingKeys" :disabled="generatingKeys" @click="generateKeys">确保 Key 存在</el-button>
</template>
</el-dialog>
<AuthCaptureDialog
v-model="authCaptureVisible"
:initial-url="authCaptureInitialUrl"
@@ -453,8 +527,8 @@ import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs'
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight, Pointer } from '@element-plus/icons-vue'
import { upstreamsApi, type UpstreamData } from '@/api'
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight, Pointer, Key } from '@element-plus/icons-vue'
import { upstreamsApi, type GeneratedUpstreamKey, type UpstreamData } from '@/api'
import AuthCaptureDialog from '@/components/AuthCaptureDialog.vue'
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
@@ -580,11 +654,30 @@ function handlePlatformChange(val: string) {
const detailVisible = ref(false)
const detailUpstream = ref<UpstreamData | null>(null)
const snapshots = ref<any[]>([])
const generatedKeys = ref<GeneratedUpstreamKey[]>([])
const snapshotLoading = ref(false)
const keysLoading = ref(false)
const expandedId = ref<number | null>(null)
const snapshotOffset = ref(0)
const snapshotLimit = 20
const keyDialogVisible = ref(false)
const keyTarget = ref<UpstreamData | null>(null)
const keyGroupOptions = ref<any[]>([])
const generatingKeys = ref(false)
const keyResults = ref<GeneratedUpstreamKey[]>([])
const useKeyExpiry = ref(false)
const keyExpiresDays = ref(30)
const keyForm = ref({
group_ids: [] as string[],
name_prefix: 'SmartUp',
quota: 0,
rate_limit_5h: 0,
rate_limit_1d: 0,
rate_limit_7d: 0,
endpoint: '/keys',
})
const metrics = computed(() => ({
total: list.value.length,
healthy: list.value.filter((item) => item.last_status === 'healthy').length,
@@ -641,6 +734,8 @@ function shrinkError(value: string) {
return value.length > 40 ? `${value.slice(0, 40)}` : value
}
const keyStatusLabel = (s: string) => ({ created: '已创建', imported: '已导入', import_failed: '导入失败', failed: '失败' }[s] || s)
async function loadList() {
tableLoading.value = true
try {
@@ -735,13 +830,26 @@ async function checkNow(row: any) {
function openDetail(row: UpstreamData) {
detailUpstream.value = row
snapshots.value = []
generatedKeys.value = []
snapshotOffset.value = 0
expandedId.value = null
detailVisible.value = true
}
async function loadGeneratedKeys() {
if (!detailUpstream.value) return
keysLoading.value = true
try {
const res = await upstreamsApi.generatedKeys(detailUpstream.value.id)
generatedKeys.value = res.data
} finally {
keysLoading.value = false
}
}
async function loadSnapshots() {
if (!detailUpstream.value) return
loadGeneratedKeys()
snapshotLoading.value = true
try {
const res = await upstreamsApi.listSnapshots(detailUpstream.value.id, snapshotLimit, snapshotOffset.value)
@@ -756,6 +864,48 @@ async function loadSnapshots() {
}
}
async function openKeyGenerate(row: UpstreamData) {
keyTarget.value = row
keyResults.value = []
keyForm.value = {
group_ids: [],
name_prefix: 'SmartUp',
quota: 0,
rate_limit_5h: 0,
rate_limit_1d: 0,
rate_limit_7d: 0,
endpoint: '/keys',
}
useKeyExpiry.value = false
keyExpiresDays.value = 30
try {
const res = await upstreamsApi.latestSnapshot(row.id)
keyGroupOptions.value = Object.values(res.data.snapshot?.groups || {})
} catch {
keyGroupOptions.value = []
ElMessage.warning('未找到快照,将由后端实时拉取分组')
}
keyDialogVisible.value = true
}
async function generateKeys() {
if (!keyTarget.value) return
generatingKeys.value = true
try {
const res = await upstreamsApi.generateKeysByGroups(keyTarget.value.id, {
...keyForm.value,
expires_in_days: useKeyExpiry.value ? keyExpiresDays.value : null,
})
keyResults.value = res.data.items
ElMessage[res.data.success ? 'success' : 'warning'](res.data.message)
if (detailUpstream.value?.id === keyTarget.value.id) await loadGeneratedKeys()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '创建 Key 失败')
} finally {
generatingKeys.value = false
}
}
function toggleExpand(snap: any) {
expandedId.value = expandedId.value === snap.id ? null : snap.id
}