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:
@@ -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="配额 USD(0 不限)"><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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user