feat(后台): 店铺密钥页支持配置静态代理 + 手动检测白名单——列表展示代理/失败次数,操作列加「检测白名单」按钮(绕过缓存真实请求并重置自动重试计数)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user