feat(后台): 店铺密钥页支持配置静态代理 + 手动检测白名单——列表展示代理/失败次数,操作列加「检测白名单」按钮(绕过缓存真实请求并重置自动重试计数)

This commit is contained in:
2026-09-11 17:18:27 +08:00
parent 47459e2089
commit 9962d9797e
6 changed files with 123 additions and 6 deletions
@@ -4,8 +4,8 @@ import { formatDateTime } from '@/utils/datetime'
* script 逻辑沿用现有 Vue 实现(编辑留空令牌=沿用);弹窗保留现有组件。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey } from './shop-key-api.ts'
import { emptyShopKeyForm, normalizeZiniaoToken } from './shop-key-form-model.ts'
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey, checkShopKeyWhitelist } from './shop-key-api.ts'
import { emptyShopKeyForm, normalizeZiniaoToken, validateProxyUrl } from './shop-key-form-model.ts'
import type { ShopKeyItem } from './shop-dto.ts'
import { whitelistStatusMeta } from './shop-key-model.ts'
import OldPagination from '@/components/OldPagination.vue'
@@ -30,6 +30,8 @@ const filteredRows = computed(() => {
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
/** 正在检测白名单的行 id,用于按钮 loading 态。 */
const checkingId = ref<number | null>(null)
const form = reactive(emptyShopKeyForm())
function maskToken(token: string): string {
@@ -46,6 +48,7 @@ function whitelistTooltip(row: ShopKeyItem): string {
const parts: string[] = []
if (row.ipWhitelistCheckedAt) parts.push(`检测时间:${formatDateTime(row.ipWhitelistCheckedAt)}`)
if (row.ipWhitelistMessage) parts.push(row.ipWhitelistMessage)
if (row.ipWhitelistFailCount > 0) parts.push(`连续失败 ${row.ipWhitelistFailCount}`)
if (parts.length === 0) parts.push(`白名单状态:${whitelistStatusMeta(row.ipWhitelistStatus).label}`)
return parts.join('\n')
}
@@ -76,9 +79,30 @@ function openEdit(row: ShopKeyItem) {
Object.assign(form, emptyShopKeyForm())
form.remarkName = row.remarkName
form.ziniaoAccountName = row.ziniaoAccountName
form.proxyUrl = row.proxyUrl || ''
dialogVisible.value = true
}
/** 手动检测白名单:绕过缓存真实请求一次紫鸟,并重置自动重试次数。 */
async function checkWhitelist(row: ShopKeyItem) {
checkingId.value = row.id
try {
const updated = await checkShopKeyWhitelist(row.id)
if (updated.ipWhitelistStatus === 'ALLOWED') {
ElMessage.success('白名单检测通过')
} else if (updated.ipWhitelistStatus === 'BLOCKED') {
ElMessage.error(`白名单未放行:${updated.ipWhitelistMessage || '请求被拒绝'}`)
} else {
ElMessage.warning(updated.ipWhitelistMessage || '检测未通过')
}
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '检测失败')
} finally {
checkingId.value = null
}
}
async function submit() {
const token = normalizeZiniaoToken(form.ziniaoToken)
const creating = editingId.value == null
@@ -90,6 +114,11 @@ async function submit() {
ElMessage.warning('请完整填写紫鸟账号名称、紫鸟令牌')
return
}
const proxyError = validateProxyUrl(form.proxyUrl)
if (proxyError) {
ElMessage.warning(proxyError)
return
}
// 编辑留空令牌 = 沿用原令牌(令牌不明文展示)。
form.ziniaoToken = creating ? token : token || originalToken.value
saving.value = true
@@ -169,6 +198,7 @@ onMounted(load)
<th style="width: 150px">备注名</th>
<th style="width: 160px">紫鸟账号名称</th>
<th style="width: 220px">紫鸟令牌</th>
<th style="width: 190px">代理</th>
<th style="width: 150px">白名单状态 <span class="table-help" aria-hidden="true">?</span></th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">修改时间</th>
@@ -184,6 +214,9 @@ onMounted(load)
<td>
<span class="mono-mask" :title="row.ziniaoToken">{{ maskToken(row.ziniaoToken) }}</span>
</td>
<td>
<span class="mono-mask" :title="row.proxyUrl || '直连'">{{ row.proxyUrl || '直连' }}</span>
</td>
<td>
<span
class="wh-pill"
@@ -201,15 +234,23 @@ onMounted(load)
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button
class="btn btn-sm btn-ghost"
type="button"
:disabled="checkingId === row.id"
@click="checkWhitelist(row)"
>
{{ checkingId === row.id ? '检测中…' : '检测白名单' }}
</button>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="8" class="empty-tip">加载中...</td>
<td colspan="9" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="8" class="empty-tip">{{ filterKeyword ? '暂无匹配密钥' : '暂无店铺密钥' }}</td>
<td colspan="9" class="empty-tip">{{ filterKeyword ? '暂无匹配密钥' : '暂无店铺密钥' }}</td>
</tr>
</tbody>
</table>
@@ -233,6 +274,15 @@ onMounted(load)
:placeholder="editingId == null ? '粘贴紫鸟令牌(将自动去除 Bearer 前缀)' : '留空则沿用原令牌;如需更换请粘贴新令牌(将自动去除 Bearer 前缀)'"
/>
</el-form-item>
<el-form-item label="代理地址">
<el-input
v-model="form.proxyUrl"
placeholder="留空直连;形如 http://host:port 或 http://user:pass@host:port"
/>
<div class="form-hint">
仅当该账号的服务器出口 IP 不在紫鸟白名单时填写代理 IP 需已由紫鸟公司加白留空则直连
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
@@ -348,6 +398,23 @@ h3 {
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-ghost {
background: #ffffff;
border-color: #c7d7e5;
color: #4f78a5;
box-shadow: none;
}
.btn-ghost:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.form-hint {
margin-top: 4px;
color: #8293a5;
font-size: 12px;
line-height: 1.5;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
@@ -364,7 +431,7 @@ h3 {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
min-width: 1000px;
min-width: 1200px;
}
.shop-key-table-scroll th,
.shop-key-table-scroll td {
@@ -22,9 +22,13 @@ export interface ShopKeyItem {
ziniaoAccountName: string
/** 紫鸟令牌:敏感字段,列表展示必须掩码。 */
ziniaoToken: SensitiveString
/** 静态代理地址;为空表示直连。用于出口 IP 不在紫鸟白名单的 key。 */
proxyUrl: string
ipWhitelistStatus: IpWhitelistStatus
ipWhitelistCheckedAt?: string
ipWhitelistMessage?: string
/** 连续白名单失败次数;达上限后不再自动重试,需人工检测。 */
ipWhitelistFailCount: number
createdAt?: string
updatedAt?: string
}
@@ -55,3 +55,13 @@ export async function updateShopKey(id: number, form: ShopKeyFormValues): Promis
export async function deleteShopKey(id: number): Promise<void> {
await http.delete<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}`)
}
/** 手动检测 IP 白名单:POST /api/admin/shop-keys/{id}/check-whitelist;返回更新后的记录。 */
export async function checkShopKeyWhitelist(id: number): Promise<ShopKeyItem> {
const { data } = await http.post<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}/check-whitelist`)
const item = toShopKeyItem(unwrap<unknown>(data))
if (!item) {
throw new Error('白名单检测响应异常:未返回有效记录')
}
return item
}
@@ -16,5 +16,6 @@ export function buildShopKeyEditForm(item: ShopKeyItem | null | undefined): Shop
remarkName: typeof item.remarkName === 'string' ? item.remarkName : '',
ziniaoAccountName: typeof item.ziniaoAccountName === 'string' ? item.ziniaoAccountName : '',
ziniaoToken: typeof item.ziniaoToken === 'string' ? item.ziniaoToken : '',
proxyUrl: typeof item.proxyUrl === 'string' ? item.proxyUrl : '',
}
}
@@ -4,18 +4,22 @@
export const SHOP_KEY_REMARK_MAX_LEN = 128
export const SHOP_KEY_ACCOUNT_MAX_LEN = 128
export const SHOP_KEY_TOKEN_MAX_LEN = 512
export const SHOP_KEY_PROXY_MAX_LEN = 255
export interface ShopKeyFormValues {
remarkName: string
ziniaoAccountName: string
/** 紫鸟令牌:敏感输入,提交前需去除 "Bearer " 前缀与首尾空白。 */
ziniaoToken: string
/** 静态代理地址(http://host:port 或 http://user:pass@host:port);留空直连。 */
proxyUrl: string
}
export interface ShopKeyFormErrors {
remarkName?: string
ziniaoAccountName?: string
ziniaoToken?: string
proxyUrl?: string
}
/** 发送 POST/PUT /api/admin/shop-keys 的请求体(与 Java 请求 DTO camelCase 对齐)。 */
@@ -23,10 +27,33 @@ export interface ShopKeyPayload {
remarkName?: string
ziniaoAccountName: string
ziniaoToken: string
proxyUrl?: string
}
export function emptyShopKeyForm(): ShopKeyFormValues {
return { remarkName: '', ziniaoAccountName: '', ziniaoToken: '' }
return { remarkName: '', ziniaoAccountName: '', ziniaoToken: '', proxyUrl: '' }
}
/** 校验代理地址(镜像后端 normalizeProxyUrl):留空合法,非空须为 http(s)://host:port。 */
export function validateProxyUrl(value: string): string | null {
const proxy = (value || '').trim()
if (!proxy) return null
if (proxy.length > SHOP_KEY_PROXY_MAX_LEN) {
return `代理地址长度不能超过${SHOP_KEY_PROXY_MAX_LEN}个字符`
}
let parsed: URL
try {
parsed = new URL(proxy)
} catch {
return '代理地址格式不合法,应形如 http://host:port'
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return '代理地址仅支持 http/https 协议'
}
if (!parsed.hostname || !parsed.port) {
return '代理地址缺少主机或端口,应形如 http://host:port'
}
return null
}
/** 归一紫鸟令牌:去首尾空白并去除大小写不敏感的 "Bearer " 前缀(镜像后端 normalizeToken)。 */
@@ -61,6 +88,10 @@ export function validateShopKeyForm(form: ShopKeyFormValues): { valid: boolean;
} else if (token.length > SHOP_KEY_TOKEN_MAX_LEN) {
errors.ziniaoToken = `紫鸟令牌长度不能超过${SHOP_KEY_TOKEN_MAX_LEN}个字符`
}
const proxyError = validateProxyUrl(form.proxyUrl)
if (proxyError) {
errors.proxyUrl = proxyError
}
return { valid: Object.keys(errors).length === 0, errors }
}
@@ -72,5 +103,7 @@ export function toShopKeyCreateRequest(form: ShopKeyFormValues): ShopKeyPayload
ziniaoToken: normalizeZiniaoToken(form.ziniaoToken),
}
if (remark) payload.remarkName = remark
const proxy = (form.proxyUrl || '').trim()
if (proxy) payload.proxyUrl = proxy
return payload
}
@@ -55,8 +55,10 @@ export function toShopKeyItem(raw: unknown): ShopKeyItem | null {
remarkName: text(record.remarkName ?? record.remark_name),
ziniaoAccountName: text(record.ziniaoAccountName ?? record.ziniao_account_name),
ziniaoToken: toSensitiveString(text(record.ziniaoToken ?? record.ziniao_token)),
proxyUrl: text(record.proxyUrl ?? record.proxy_url),
ipWhitelistStatus: normalizeIpWhitelistStatus(record.ipWhitelistStatus ?? record.ip_whitelist_status),
ipWhitelistMessage: text(record.ipWhitelistMessage ?? record.ip_whitelist_message),
ipWhitelistFailCount: numberOrNull(record.ipWhitelistFailCount ?? record.ip_whitelist_fail_count) ?? 0,
}
const checkedAt = text(record.ipWhitelistCheckedAt ?? record.ip_whitelist_checked_at)
if (checkedAt) item.ipWhitelistCheckedAt = checkedAt