feat(后台): 店铺密钥页支持配置静态代理 + 手动检测白名单——列表展示代理/失败次数,操作列加「检测白名单」按钮(绕过缓存真实请求并重置自动重试计数)
This commit is contained in:
@@ -4,8 +4,8 @@ import { formatDateTime } from '@/utils/datetime'
|
|||||||
* script 逻辑沿用现有 Vue 实现(编辑留空令牌=沿用);弹窗保留现有组件。 */
|
* script 逻辑沿用现有 Vue 实现(编辑留空令牌=沿用);弹窗保留现有组件。 */
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey } from './shop-key-api.ts'
|
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey, checkShopKeyWhitelist } from './shop-key-api.ts'
|
||||||
import { emptyShopKeyForm, normalizeZiniaoToken } from './shop-key-form-model.ts'
|
import { emptyShopKeyForm, normalizeZiniaoToken, validateProxyUrl } from './shop-key-form-model.ts'
|
||||||
import type { ShopKeyItem } from './shop-dto.ts'
|
import type { ShopKeyItem } from './shop-dto.ts'
|
||||||
import { whitelistStatusMeta } from './shop-key-model.ts'
|
import { whitelistStatusMeta } from './shop-key-model.ts'
|
||||||
import OldPagination from '@/components/OldPagination.vue'
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
@@ -30,6 +30,8 @@ const filteredRows = computed(() => {
|
|||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
/** 正在检测白名单的行 id,用于按钮 loading 态。 */
|
||||||
|
const checkingId = ref<number | null>(null)
|
||||||
const form = reactive(emptyShopKeyForm())
|
const form = reactive(emptyShopKeyForm())
|
||||||
|
|
||||||
function maskToken(token: string): string {
|
function maskToken(token: string): string {
|
||||||
@@ -46,6 +48,7 @@ function whitelistTooltip(row: ShopKeyItem): string {
|
|||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (row.ipWhitelistCheckedAt) parts.push(`检测时间:${formatDateTime(row.ipWhitelistCheckedAt)}`)
|
if (row.ipWhitelistCheckedAt) parts.push(`检测时间:${formatDateTime(row.ipWhitelistCheckedAt)}`)
|
||||||
if (row.ipWhitelistMessage) parts.push(row.ipWhitelistMessage)
|
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}`)
|
if (parts.length === 0) parts.push(`白名单状态:${whitelistStatusMeta(row.ipWhitelistStatus).label}`)
|
||||||
return parts.join('\n')
|
return parts.join('\n')
|
||||||
}
|
}
|
||||||
@@ -76,9 +79,30 @@ function openEdit(row: ShopKeyItem) {
|
|||||||
Object.assign(form, emptyShopKeyForm())
|
Object.assign(form, emptyShopKeyForm())
|
||||||
form.remarkName = row.remarkName
|
form.remarkName = row.remarkName
|
||||||
form.ziniaoAccountName = row.ziniaoAccountName
|
form.ziniaoAccountName = row.ziniaoAccountName
|
||||||
|
form.proxyUrl = row.proxyUrl || ''
|
||||||
dialogVisible.value = true
|
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() {
|
async function submit() {
|
||||||
const token = normalizeZiniaoToken(form.ziniaoToken)
|
const token = normalizeZiniaoToken(form.ziniaoToken)
|
||||||
const creating = editingId.value == null
|
const creating = editingId.value == null
|
||||||
@@ -90,6 +114,11 @@ async function submit() {
|
|||||||
ElMessage.warning('请完整填写紫鸟账号名称、紫鸟令牌')
|
ElMessage.warning('请完整填写紫鸟账号名称、紫鸟令牌')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const proxyError = validateProxyUrl(form.proxyUrl)
|
||||||
|
if (proxyError) {
|
||||||
|
ElMessage.warning(proxyError)
|
||||||
|
return
|
||||||
|
}
|
||||||
// 编辑留空令牌 = 沿用原令牌(令牌不明文展示)。
|
// 编辑留空令牌 = 沿用原令牌(令牌不明文展示)。
|
||||||
form.ziniaoToken = creating ? token : token || originalToken.value
|
form.ziniaoToken = creating ? token : token || originalToken.value
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -169,6 +198,7 @@ onMounted(load)
|
|||||||
<th style="width: 150px">备注名</th>
|
<th style="width: 150px">备注名</th>
|
||||||
<th style="width: 160px">紫鸟账号名称</th>
|
<th style="width: 160px">紫鸟账号名称</th>
|
||||||
<th style="width: 220px">紫鸟令牌</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: 150px">白名单状态 <span class="table-help" aria-hidden="true">?</span></th>
|
||||||
<th style="width: 170px">创建时间</th>
|
<th style="width: 170px">创建时间</th>
|
||||||
<th style="width: 170px">修改时间</th>
|
<th style="width: 170px">修改时间</th>
|
||||||
@@ -184,6 +214,9 @@ onMounted(load)
|
|||||||
<td>
|
<td>
|
||||||
<span class="mono-mask" :title="row.ziniaoToken">{{ maskToken(row.ziniaoToken) }}</span>
|
<span class="mono-mask" :title="row.ziniaoToken">{{ maskToken(row.ziniaoToken) }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="mono-mask" :title="row.proxyUrl || '直连'">{{ row.proxyUrl || '直连' }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
class="wh-pill"
|
class="wh-pill"
|
||||||
@@ -201,15 +234,23 @@ onMounted(load)
|
|||||||
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
<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>
|
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="8" class="empty-tip">加载中...</td>
|
<td colspan="9" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="8" class="empty-tip">{{ filterKeyword ? '暂无匹配密钥' : '暂无店铺密钥' }}</td>
|
<td colspan="9" class="empty-tip">{{ filterKeyword ? '暂无匹配密钥' : '暂无店铺密钥' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -233,6 +274,15 @@ onMounted(load)
|
|||||||
:placeholder="editingId == null ? '粘贴紫鸟令牌(将自动去除 Bearer 前缀)' : '留空则沿用原令牌;如需更换请粘贴新令牌(将自动去除 Bearer 前缀)'"
|
:placeholder="editingId == null ? '粘贴紫鸟令牌(将自动去除 Bearer 前缀)' : '留空则沿用原令牌;如需更换请粘贴新令牌(将自动去除 Bearer 前缀)'"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
@@ -348,6 +398,23 @@ h3 {
|
|||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
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 {
|
.btn-sm {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
@@ -364,7 +431,7 @@ h3 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
min-width: 1000px;
|
min-width: 1200px;
|
||||||
}
|
}
|
||||||
.shop-key-table-scroll th,
|
.shop-key-table-scroll th,
|
||||||
.shop-key-table-scroll td {
|
.shop-key-table-scroll td {
|
||||||
|
|||||||
@@ -22,9 +22,13 @@ export interface ShopKeyItem {
|
|||||||
ziniaoAccountName: string
|
ziniaoAccountName: string
|
||||||
/** 紫鸟令牌:敏感字段,列表展示必须掩码。 */
|
/** 紫鸟令牌:敏感字段,列表展示必须掩码。 */
|
||||||
ziniaoToken: SensitiveString
|
ziniaoToken: SensitiveString
|
||||||
|
/** 静态代理地址;为空表示直连。用于出口 IP 不在紫鸟白名单的 key。 */
|
||||||
|
proxyUrl: string
|
||||||
ipWhitelistStatus: IpWhitelistStatus
|
ipWhitelistStatus: IpWhitelistStatus
|
||||||
ipWhitelistCheckedAt?: string
|
ipWhitelistCheckedAt?: string
|
||||||
ipWhitelistMessage?: string
|
ipWhitelistMessage?: string
|
||||||
|
/** 连续白名单失败次数;达上限后不再自动重试,需人工检测。 */
|
||||||
|
ipWhitelistFailCount: number
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,3 +55,13 @@ export async function updateShopKey(id: number, form: ShopKeyFormValues): Promis
|
|||||||
export async function deleteShopKey(id: number): Promise<void> {
|
export async function deleteShopKey(id: number): Promise<void> {
|
||||||
await http.delete<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}`)
|
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 : '',
|
remarkName: typeof item.remarkName === 'string' ? item.remarkName : '',
|
||||||
ziniaoAccountName: typeof item.ziniaoAccountName === 'string' ? item.ziniaoAccountName : '',
|
ziniaoAccountName: typeof item.ziniaoAccountName === 'string' ? item.ziniaoAccountName : '',
|
||||||
ziniaoToken: typeof item.ziniaoToken === 'string' ? item.ziniaoToken : '',
|
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_REMARK_MAX_LEN = 128
|
||||||
export const SHOP_KEY_ACCOUNT_MAX_LEN = 128
|
export const SHOP_KEY_ACCOUNT_MAX_LEN = 128
|
||||||
export const SHOP_KEY_TOKEN_MAX_LEN = 512
|
export const SHOP_KEY_TOKEN_MAX_LEN = 512
|
||||||
|
export const SHOP_KEY_PROXY_MAX_LEN = 255
|
||||||
|
|
||||||
export interface ShopKeyFormValues {
|
export interface ShopKeyFormValues {
|
||||||
remarkName: string
|
remarkName: string
|
||||||
ziniaoAccountName: string
|
ziniaoAccountName: string
|
||||||
/** 紫鸟令牌:敏感输入,提交前需去除 "Bearer " 前缀与首尾空白。 */
|
/** 紫鸟令牌:敏感输入,提交前需去除 "Bearer " 前缀与首尾空白。 */
|
||||||
ziniaoToken: string
|
ziniaoToken: string
|
||||||
|
/** 静态代理地址(http://host:port 或 http://user:pass@host:port);留空直连。 */
|
||||||
|
proxyUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopKeyFormErrors {
|
export interface ShopKeyFormErrors {
|
||||||
remarkName?: string
|
remarkName?: string
|
||||||
ziniaoAccountName?: string
|
ziniaoAccountName?: string
|
||||||
ziniaoToken?: string
|
ziniaoToken?: string
|
||||||
|
proxyUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发送 POST/PUT /api/admin/shop-keys 的请求体(与 Java 请求 DTO camelCase 对齐)。 */
|
/** 发送 POST/PUT /api/admin/shop-keys 的请求体(与 Java 请求 DTO camelCase 对齐)。 */
|
||||||
@@ -23,10 +27,33 @@ export interface ShopKeyPayload {
|
|||||||
remarkName?: string
|
remarkName?: string
|
||||||
ziniaoAccountName: string
|
ziniaoAccountName: string
|
||||||
ziniaoToken: string
|
ziniaoToken: string
|
||||||
|
proxyUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyShopKeyForm(): ShopKeyFormValues {
|
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)。 */
|
/** 归一紫鸟令牌:去首尾空白并去除大小写不敏感的 "Bearer " 前缀(镜像后端 normalizeToken)。 */
|
||||||
@@ -61,6 +88,10 @@ export function validateShopKeyForm(form: ShopKeyFormValues): { valid: boolean;
|
|||||||
} else if (token.length > SHOP_KEY_TOKEN_MAX_LEN) {
|
} else if (token.length > SHOP_KEY_TOKEN_MAX_LEN) {
|
||||||
errors.ziniaoToken = `紫鸟令牌长度不能超过${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 }
|
return { valid: Object.keys(errors).length === 0, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,5 +103,7 @@ export function toShopKeyCreateRequest(form: ShopKeyFormValues): ShopKeyPayload
|
|||||||
ziniaoToken: normalizeZiniaoToken(form.ziniaoToken),
|
ziniaoToken: normalizeZiniaoToken(form.ziniaoToken),
|
||||||
}
|
}
|
||||||
if (remark) payload.remarkName = remark
|
if (remark) payload.remarkName = remark
|
||||||
|
const proxy = (form.proxyUrl || '').trim()
|
||||||
|
if (proxy) payload.proxyUrl = proxy
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,10 @@ export function toShopKeyItem(raw: unknown): ShopKeyItem | null {
|
|||||||
remarkName: text(record.remarkName ?? record.remark_name),
|
remarkName: text(record.remarkName ?? record.remark_name),
|
||||||
ziniaoAccountName: text(record.ziniaoAccountName ?? record.ziniao_account_name),
|
ziniaoAccountName: text(record.ziniaoAccountName ?? record.ziniao_account_name),
|
||||||
ziniaoToken: toSensitiveString(text(record.ziniaoToken ?? record.ziniao_token)),
|
ziniaoToken: toSensitiveString(text(record.ziniaoToken ?? record.ziniao_token)),
|
||||||
|
proxyUrl: text(record.proxyUrl ?? record.proxy_url),
|
||||||
ipWhitelistStatus: normalizeIpWhitelistStatus(record.ipWhitelistStatus ?? record.ip_whitelist_status),
|
ipWhitelistStatus: normalizeIpWhitelistStatus(record.ipWhitelistStatus ?? record.ip_whitelist_status),
|
||||||
ipWhitelistMessage: text(record.ipWhitelistMessage ?? record.ip_whitelist_message),
|
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)
|
const checkedAt = text(record.ipWhitelistCheckedAt ?? record.ip_whitelist_checked_at)
|
||||||
if (checkedAt) item.ipWhitelistCheckedAt = checkedAt
|
if (checkedAt) item.ipWhitelistCheckedAt = checkedAt
|
||||||
|
|||||||
Reference in New Issue
Block a user