feat(payment): 后台「支付配置」支持微信支付与支付宝凭据管理
后端: - 新增 payment_configs 单行配置表;APIv3 密钥、应用私钥/公钥以 AES-256-GCM 密文入库(enc:v1 前缀,主密钥取 PAYMENT_SECRET_KEY,缺省回退 JWT_SECRET 派生) - 证书/私钥落 PAYMENT_CERT_DIR(默认 /data/payment-certs),目录 0700 / 文件 0600,不经任何静态路由暴露 - 接口:GET/PUT /api/admin/payment/config、POST /upload(PEM 校验 + 微信证书序列号自动回填)、POST /test(真打官方接口:微信 /v3/certificates 签名校验、支付宝 alipay.trade.query 签名校验)、POST /wechat/platform-certs(APIv3 密钥解密平台证书并落盘) - 未配置凭据时测试返回 skipped 而不是 500;敏感字段在返回体里一律脱敏 前端: - /payments 改为「通用设置 + 微信支付 / 支付宝 / Waffo 海外支付」页签(原 Waffo 页整页迁到 views/payments/Waffo.vue) - 微信:mchid、AppID、APIv3 密钥(密码框 + 掩码回显 + 更换/清除)、证书序列号、支付方式多选、三项证书上传、自动获取平台证书 - 支付宝:app_id、应用私钥(粘贴/上传)、支付宝公钥、网关、RSA2、回调地址 - 菜单归入「审计与设置 → 支付配置」
This commit is contained in:
1 parent
630f2819cb
commit
7d53c6ed3c
21 files changed
+3090
-6
No files matched your search
@@ -0,0 +1,46 @@
|
||||
import request from './request'
|
||||
|
||||
// 支付通道配置(微信支付 / 支付宝)API // Logan.Li
|
||||
|
||||
// 读取配置(密钥一律脱敏返回)
|
||||
export function getPaymentConfig() {
|
||||
return request({ url: '/admin/payment/config', method: 'get' })
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
export function updatePaymentConfig(data) {
|
||||
return request({ url: '/admin/payment/config', method: 'put', data })
|
||||
}
|
||||
|
||||
// 上传证书 / 私钥(kind: wechat_cert | wechat_key | wechat_platform_cert | alipay_private_key | alipay_public_key)
|
||||
export function uploadPaymentFile(kind, file) {
|
||||
const form = new FormData()
|
||||
form.append('kind', kind)
|
||||
form.append('file', file)
|
||||
return request({
|
||||
url: '/admin/payment/upload',
|
||||
method: 'post',
|
||||
data: form,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000,
|
||||
})
|
||||
}
|
||||
|
||||
// 连通性测试(channel: wechat | alipay | all)
|
||||
export function testPaymentConfig(channel = 'all') {
|
||||
return request({
|
||||
url: '/admin/payment/test',
|
||||
method: 'post',
|
||||
data: { channel },
|
||||
timeout: 60000,
|
||||
})
|
||||
}
|
||||
|
||||
// 自动获取并保存微信支付平台证书
|
||||
export function fetchWechatPlatformCerts() {
|
||||
return request({
|
||||
url: '/admin/payment/wechat/platform-certs',
|
||||
method: 'post',
|
||||
timeout: 60000,
|
||||
})
|
||||
}
|
||||
@@ -51,11 +51,6 @@
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item index="/payments">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
<template #title>支付</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/sync">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<template #title>节点同步</template>
|
||||
@@ -69,6 +64,9 @@
|
||||
<el-menu-item index="/logs">
|
||||
<el-icon><Document /></el-icon>操作日志
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/payments">
|
||||
<el-icon><Wallet /></el-icon>支付配置
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/settings">
|
||||
<el-icon><Setting /></el-icon>系统设置
|
||||
</el-menu-item>
|
||||
|
||||
@@ -58,7 +58,7 @@ const routes = [
|
||||
{
|
||||
path: 'payments',
|
||||
name: 'Payments',
|
||||
component: () => import('@/views/Payments.vue'),
|
||||
component: () => import('@/views/payments/index.vue'),
|
||||
meta: { title: '支付配置', icon: 'Wallet' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="应用私钥与支付宝公钥均可粘贴或上传文件;保存后只显示指纹,不再回显内容。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<el-card shadow="never" v-loading="loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="head-left">
|
||||
<span class="title">支付宝(开放平台 · RSA2)</span>
|
||||
<el-tag v-if="view.ready" size="small" type="success">已就绪</el-tag>
|
||||
<el-tag v-else size="small" type="warning">待完善 {{ view.missing.length }} 项</el-tag>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :loading="testing" @click="runTest">测试连接</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="view.missing.length"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<template #title>还缺:{{ view.missing.join('、') }}</template>
|
||||
</el-alert>
|
||||
|
||||
<el-form :model="form" label-width="150px" style="max-width: 900px">
|
||||
<el-form-item label="启用支付宝">
|
||||
<el-switch v-model="form.enabled" />
|
||||
<span class="hint inline">关闭后下单接口不会走支付宝通道</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="app_id">
|
||||
<el-input v-model="form.app_id" placeholder="开放平台 → 应用详情 → APPID" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用私钥">
|
||||
<div class="secret-row">
|
||||
<el-tag :type="view.private_key_configured ? 'success' : 'warning'" size="small">
|
||||
{{ view.private_key_configured
|
||||
? `已配置(${view.private_key_source === 'upload' ? '文件上传' : '粘贴'} · ${view.private_key_masked})`
|
||||
: '未配置' }}
|
||||
</el-tag>
|
||||
<el-button size="small" @click="togglePrivate">{{ showPrivate ? '取消' : (view.private_key_configured ? '更换' : '粘贴') }}</el-button>
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
accept=".pem,.key,.txt"
|
||||
:on-change="(f) => uploadFile('alipay_private_key', f)"
|
||||
>
|
||||
<el-button size="small" :loading="uploadingKind === 'alipay_private_key'">上传文件</el-button>
|
||||
</el-upload>
|
||||
<el-button
|
||||
v-if="view.private_key_configured"
|
||||
size="small"
|
||||
type="danger"
|
||||
link
|
||||
@click="clearPrivate = true"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-input
|
||||
v-if="showPrivate"
|
||||
v-model="privateKeyInput"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="粘贴应用私钥(PEM 或裸 Base64),保存后不再回显"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
<div v-if="clearPrivate" class="hint warn">已标记清除,点「保存配置」后生效。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="支付宝公钥">
|
||||
<div class="secret-row">
|
||||
<el-tag :type="view.public_key_configured ? 'success' : 'warning'" size="small">
|
||||
{{ view.public_key_configured ? `已配置(${view.public_key_masked})` : '未配置' }}
|
||||
</el-tag>
|
||||
<el-button size="small" @click="togglePublic">{{ showPublic ? '取消' : (view.public_key_configured ? '更换' : '粘贴') }}</el-button>
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
accept=".pem,.pub,.txt"
|
||||
:on-change="(f) => uploadFile('alipay_public_key', f)"
|
||||
>
|
||||
<el-button size="small" :loading="uploadingKind === 'alipay_public_key'">上传文件</el-button>
|
||||
</el-upload>
|
||||
<el-button
|
||||
v-if="view.public_key_configured"
|
||||
size="small"
|
||||
type="danger"
|
||||
link
|
||||
@click="clearPublic = true"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-input
|
||||
v-if="showPublic"
|
||||
v-model="publicKeyInput"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="粘贴支付宝公钥(不是应用公钥),保存后不再回显"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
<div class="hint">
|
||||
开放平台「开发设置 → 接口加签方式」里的<b>支付宝公钥</b>,填成应用公钥会导致回调验签失败。
|
||||
</div>
|
||||
<div v-if="clearPublic" class="hint warn">已标记清除,点「保存配置」后生效。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="网关地址">
|
||||
<el-input v-model="form.gateway" placeholder="https://openapi.alipay.com/gateway.do" />
|
||||
<div class="hint">沙箱环境请改为沙箱网关,正式环境保持默认值。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="签名类型">
|
||||
<el-input :model-value="form.sign_type" disabled />
|
||||
<div class="hint">固定 RSA2(SHA256withRSA),证书模式与密钥模式均适用。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="回调地址">
|
||||
<el-input :model-value="view.notify_url" readonly>
|
||||
<template #append>
|
||||
<el-button @click="copy(view.notify_url)">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="hint">需与开放平台应用里配置的「异步通知地址」一致。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="testResults.length" shadow="never" style="margin-top: 16px">
|
||||
<template #header><span class="title">连通性测试结果</span></template>
|
||||
<div v-for="r in testResults" :key="r.channel" class="test-row">
|
||||
<el-tag :type="r.status === 'ok' ? 'success' : (r.status === 'skipped' ? 'info' : 'danger')" size="small">
|
||||
{{ r.channel }}
|
||||
</el-tag>
|
||||
<span class="test-msg">{{ r.message }}</span>
|
||||
<span class="hint">{{ r.http_status ? `HTTP ${r.http_status} · ${r.latency_ms}ms` : '' }}</span>
|
||||
</div>
|
||||
<pre v-for="r in testResults.filter((x) => x.detail)" :key="`d-${r.channel}`" class="debug-box">{{ r.detail }}</pre>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getPaymentConfig,
|
||||
updatePaymentConfig,
|
||||
uploadPaymentFile,
|
||||
testPaymentConfig,
|
||||
} from '@/api/payment'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const uploadingKind = ref('')
|
||||
const showPrivate = ref(false)
|
||||
const showPublic = ref(false)
|
||||
const privateKeyInput = ref('')
|
||||
const publicKeyInput = ref('')
|
||||
const clearPrivate = ref(false)
|
||||
const clearPublic = ref(false)
|
||||
const testResults = ref([])
|
||||
|
||||
const view = reactive({
|
||||
enabled: false,
|
||||
app_id: '',
|
||||
private_key_configured: false,
|
||||
private_key_masked: '',
|
||||
private_key_source: '',
|
||||
public_key_configured: false,
|
||||
public_key_masked: '',
|
||||
gateway: 'https://openapi.alipay.com/gateway.do',
|
||||
sign_type: 'RSA2',
|
||||
notify_url: '',
|
||||
ready: false,
|
||||
missing: [],
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
app_id: '',
|
||||
gateway: 'https://openapi.alipay.com/gateway.do',
|
||||
sign_type: 'RSA2',
|
||||
})
|
||||
|
||||
function applyView(data) {
|
||||
Object.assign(view, data || {})
|
||||
form.enabled = !!view.enabled
|
||||
form.app_id = view.app_id || ''
|
||||
form.gateway = view.gateway || 'https://openapi.alipay.com/gateway.do'
|
||||
form.sign_type = view.sign_type || 'RSA2'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getPaymentConfig()
|
||||
applyView(res.data?.alipay)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function togglePrivate() {
|
||||
showPrivate.value = !showPrivate.value
|
||||
if (showPrivate.value) clearPrivate.value = false
|
||||
}
|
||||
|
||||
function togglePublic() {
|
||||
showPublic.value = !showPublic.value
|
||||
if (showPublic.value) clearPublic.value = false
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (clearPrivate.value && privateKeyInput.value.trim()) {
|
||||
ElMessage.warning('「清除私钥」与「粘贴新私钥」冲突,请二选一')
|
||||
return
|
||||
}
|
||||
if (clearPublic.value && publicKeyInput.value.trim()) {
|
||||
ElMessage.warning('「清除公钥」与「粘贴新公钥」冲突,请二选一')
|
||||
return
|
||||
}
|
||||
// 敏感字段变更需二次确认 // Logan.Li
|
||||
if (clearPrivate.value || privateKeyInput.value.trim() || clearPublic.value || publicKeyInput.value.trim()) {
|
||||
try {
|
||||
await ElMessageBox.confirm('将修改支付宝密钥类字段(保存后不可回显),确定继续?', '确认修改密钥', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认修改',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
alipay: {
|
||||
enabled: form.enabled,
|
||||
app_id: form.app_id,
|
||||
gateway: form.gateway,
|
||||
sign_type: form.sign_type,
|
||||
},
|
||||
}
|
||||
if (clearPrivate.value) payload.alipay.clear_private_key = true
|
||||
if (privateKeyInput.value.trim()) payload.alipay.private_key = privateKeyInput.value.trim()
|
||||
if (clearPublic.value) payload.alipay.clear_public_key = true
|
||||
if (publicKeyInput.value.trim()) payload.alipay.public_key = publicKeyInput.value.trim()
|
||||
|
||||
const res = await updatePaymentConfig(payload)
|
||||
applyView(res.data?.alipay)
|
||||
showPrivate.value = false
|
||||
showPublic.value = false
|
||||
privateKeyInput.value = ''
|
||||
publicKeyInput.value = ''
|
||||
clearPrivate.value = false
|
||||
clearPublic.value = false
|
||||
ElMessage.success('支付宝配置已保存')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(kind, uploadFileRef) {
|
||||
const raw = uploadFileRef?.raw
|
||||
if (!raw) return
|
||||
const label = kind === 'alipay_private_key' ? '应用私钥' : '支付宝公钥'
|
||||
try {
|
||||
await ElMessageBox.confirm(`将上传并覆盖服务端当前的「${label}」,确定继续?`, '确认上传', {
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
uploadingKind.value = kind
|
||||
try {
|
||||
const res = await uploadPaymentFile(kind, raw)
|
||||
ElMessage.success(res.data?.message || '上传成功')
|
||||
await load()
|
||||
} finally {
|
||||
uploadingKind.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
testing.value = true
|
||||
try {
|
||||
const res = await testPaymentConfig('alipay')
|
||||
testResults.value = res.data?.results || []
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text || '')
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选中复制')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.head-left { display: flex; align-items: center; gap: 10px; }
|
||||
.title { font-weight: 600; }
|
||||
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.secret-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.hint { margin-top: 6px; font-size: 12px; color: #909399; line-height: 1.6; }
|
||||
.hint.inline { margin-top: 0; margin-left: 10px; }
|
||||
.hint.warn { color: #e6a23c; }
|
||||
.test-row { display: flex; align-items: center; gap: 10px; font-size: 13px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||
.test-msg { color: #303133; }
|
||||
.debug-box {
|
||||
margin: 8px 0 0;
|
||||
padding: 10px 12px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
overflow: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
</style>
|
||||
File renamed without changes.
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="凭据仅在本机服务端加密保存,页面不再回显明文。证书序列号会在上传 apiclient_cert.pem 后自动回填。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<el-card shadow="never" v-loading="loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="head-left">
|
||||
<span class="title">微信支付(APIv3)</span>
|
||||
<el-tag v-if="view.ready" size="small" type="success">已就绪</el-tag>
|
||||
<el-tag v-else size="small" type="warning">待完善 {{ view.missing.length }} 项</el-tag>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :loading="testing" @click="runTest">测试连接</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="view.missing.length"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<template #title>
|
||||
还缺:{{ view.missing.join('、') }}
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-form :model="form" label-width="150px" style="max-width: 900px">
|
||||
<el-form-item label="启用微信支付">
|
||||
<el-switch v-model="form.enabled" />
|
||||
<span class="hint inline">关闭后下单接口不会走微信支付通道</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="商户号 mchid">
|
||||
<el-input v-model="form.mch_id" placeholder="商户平台 → 账户中心 → 商户信息" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="AppID">
|
||||
<el-input v-model="form.app_id" placeholder="公众号 / 开放平台网站应用的 AppID" clearable />
|
||||
<div class="hint">
|
||||
必须已在商户平台「产品中心 → AppID 账号管理」绑定,否则下单会报
|
||||
<code>APPID_MCHID_NOT_MATCH</code>。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="APIv3 密钥">
|
||||
<div class="secret-row">
|
||||
<el-tag :type="view.api_v3_key_configured ? 'success' : 'warning'" size="small">
|
||||
{{ view.api_v3_key_configured ? `已配置(${view.api_v3_key_masked})` : '未配置' }}
|
||||
</el-tag>
|
||||
<el-button size="small" @click="showKeyInput = !showKeyInput">
|
||||
{{ showKeyInput ? '取消' : (view.api_v3_key_configured ? '更换' : '填写') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="view.api_v3_key_configured"
|
||||
size="small"
|
||||
type="danger"
|
||||
link
|
||||
@click="clearKey = true"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-input
|
||||
v-if="showKeyInput"
|
||||
v-model="keyInput"
|
||||
type="password"
|
||||
show-password
|
||||
maxlength="32"
|
||||
placeholder="32 位密钥(商户平台 → 账户中心 → API安全 → 设置APIv3密钥)"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
<div v-if="clearKey" class="hint warn">已标记清除,点「保存配置」后生效。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="证书序列号">
|
||||
<el-input v-model="form.serial_no" placeholder="商户 API 证书序列号(上传证书后自动回填)" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="支付方式">
|
||||
<el-checkbox-group v-model="form.pay_methods">
|
||||
<el-checkbox value="native">Native 扫码</el-checkbox>
|
||||
<el-checkbox value="h5">H5 支付</el-checkbox>
|
||||
<el-checkbox value="jsapi">JSAPI(微信内)</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="回调地址">
|
||||
<el-input :model-value="view.notify_url" readonly>
|
||||
<template #append>
|
||||
<el-button @click="copy(view.notify_url)">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="hint">下单流程接入后由微信异步通知,务必在商户平台保持可公网访问。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" style="margin-top: 16px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="title">证书与私钥</span>
|
||||
<span class="hint inline">文件落在服务端受保护目录(0600),不对外暴露</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="upload-grid">
|
||||
<div v-for="item in uploadItems" :key="item.kind" class="upload-cell">
|
||||
<div class="upload-head">
|
||||
<b>{{ item.title }}</b>
|
||||
<el-tag :type="item.file.configured ? 'success' : 'info'" size="small">
|
||||
{{ item.file.configured ? '已上传' : '未上传' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="upload-desc">{{ item.desc }}</p>
|
||||
<div v-if="item.file.configured" class="file-meta">
|
||||
<div><span>文件</span>{{ item.file.file_name }}</div>
|
||||
<div><span>指纹</span><code>{{ item.file.fingerprint || '-' }}</code></div>
|
||||
<div v-if="item.file.not_after"><span>有效期至</span>{{ item.file.not_after }}</div>
|
||||
<div v-if="item.file.parse_error" class="warn"><span>提示</span>{{ item.file.parse_error }}</div>
|
||||
</div>
|
||||
<div class="upload-actions">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
accept=".pem,.crt,.cer,.key,.txt"
|
||||
:on-change="(f) => onPick(item.kind, f)"
|
||||
>
|
||||
<el-button size="small" :loading="uploadingKind === item.kind">
|
||||
{{ item.file.configured ? '重新上传' : '选择文件上传' }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-tooltip
|
||||
:content="view.ready ? '调用 APIv3 拉取平台证书并落盘' : `凭据未配置完整(还缺:${view.missing.join('、')}),无法自动获取`"
|
||||
placement="top"
|
||||
>
|
||||
<span v-if="item.kind === 'wechat_platform_cert'">
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!view.ready"
|
||||
:loading="fetchingCerts"
|
||||
@click="fetchCerts"
|
||||
>
|
||||
自动获取平台证书
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="platformCerts.length" class="cert-list">
|
||||
<div class="cert-list-title">平台证书(微信返回 {{ platformCerts.length }} 张)</div>
|
||||
<el-table :data="platformCerts" size="small" border>
|
||||
<el-table-column prop="serial_no" label="序列号" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="subject" label="主题" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="not_after" label="到期" width="170" />
|
||||
<el-table-column label="落盘" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_saved" size="small" type="success">已保存</el-tag>
|
||||
<span v-else class="hint">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="testResults.length" shadow="never" style="margin-top: 16px">
|
||||
<template #header><span class="title">连通性测试结果</span></template>
|
||||
<div v-for="r in testResults" :key="r.channel" class="test-row">
|
||||
<el-tag :type="r.status === 'ok' ? 'success' : (r.status === 'skipped' ? 'info' : 'danger')" size="small">
|
||||
{{ r.channel }}
|
||||
</el-tag>
|
||||
<span class="test-msg">{{ r.message }}</span>
|
||||
<span class="hint">{{ r.http_status ? `HTTP ${r.http_status} · ${r.latency_ms}ms` : '' }}</span>
|
||||
</div>
|
||||
<pre v-for="r in testResults.filter((x) => x.detail)" :key="`d-${r.channel}`" class="debug-box">{{ r.detail }}</pre>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getPaymentConfig,
|
||||
updatePaymentConfig,
|
||||
uploadPaymentFile,
|
||||
testPaymentConfig,
|
||||
fetchWechatPlatformCerts,
|
||||
} from '@/api/payment'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const fetchingCerts = ref(false)
|
||||
const uploadingKind = ref('')
|
||||
const showKeyInput = ref(false)
|
||||
const keyInput = ref('')
|
||||
const clearKey = ref(false)
|
||||
const testResults = ref([])
|
||||
const platformCerts = ref([])
|
||||
|
||||
const view = reactive({
|
||||
enabled: false,
|
||||
mch_id: '',
|
||||
app_id: '',
|
||||
api_v3_key_configured: false,
|
||||
api_v3_key_masked: '',
|
||||
serial_no: '',
|
||||
pay_methods: ['native'],
|
||||
notify_url: '',
|
||||
cert_file: {},
|
||||
key_file: {},
|
||||
platform_cert_file: {},
|
||||
ready: false,
|
||||
missing: [],
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
mch_id: '',
|
||||
app_id: '',
|
||||
serial_no: '',
|
||||
pay_methods: ['native'],
|
||||
})
|
||||
|
||||
const uploadItems = computed(() => [
|
||||
{
|
||||
kind: 'wechat_cert',
|
||||
title: 'apiclient_cert.pem',
|
||||
desc: '商户 API 证书(公开证书),用于标识商户身份。',
|
||||
file: view.cert_file || {},
|
||||
},
|
||||
{
|
||||
kind: 'wechat_key',
|
||||
title: 'apiclient_key.pem',
|
||||
desc: '商户 API 私钥,用于请求签名,上传后只存不显。',
|
||||
file: view.key_file || {},
|
||||
},
|
||||
{
|
||||
kind: 'wechat_platform_cert',
|
||||
title: '平台证书',
|
||||
desc: '微信支付平台证书,用于回调验签,可用上面的凭据自动获取。',
|
||||
file: view.platform_cert_file || {},
|
||||
},
|
||||
])
|
||||
|
||||
// 把接口返回的脱敏视图同步到本地 // Logan.Li
|
||||
function applyView(data) {
|
||||
Object.assign(view, data || {})
|
||||
form.enabled = !!view.enabled
|
||||
form.mch_id = view.mch_id || ''
|
||||
form.app_id = view.app_id || ''
|
||||
form.serial_no = view.serial_no || ''
|
||||
form.pay_methods = Array.isArray(view.pay_methods) && view.pay_methods.length ? view.pay_methods : ['native']
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getPaymentConfig()
|
||||
applyView(res.data?.wechat)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (clearKey.value && keyInput.value) {
|
||||
ElMessage.warning('「清除密钥」与「填写新密钥」冲突,请二选一')
|
||||
return
|
||||
}
|
||||
if (keyInput.value && keyInput.value.length !== 32) {
|
||||
ElMessage.warning('APIv3 密钥必须为 32 位')
|
||||
return
|
||||
}
|
||||
// 敏感字段变更需二次确认 // Logan.Li
|
||||
if (clearKey.value || keyInput.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
clearKey.value
|
||||
? '将清除已保存的 APIv3 密钥,清除后微信支付不可用,确定继续?'
|
||||
: '即将覆盖已保存的 APIv3 密钥(保存后仅显示后 4 位),确定继续?',
|
||||
'确认修改密钥',
|
||||
{ type: 'warning', confirmButtonText: '确认修改', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
wechat: {
|
||||
enabled: form.enabled,
|
||||
mch_id: form.mch_id,
|
||||
app_id: form.app_id,
|
||||
serial_no: form.serial_no,
|
||||
pay_methods: form.pay_methods,
|
||||
},
|
||||
}
|
||||
if (clearKey.value) payload.wechat.clear_api_v3_key = true
|
||||
if (keyInput.value) payload.wechat.api_v3_key = keyInput.value
|
||||
|
||||
const res = await updatePaymentConfig(payload)
|
||||
applyView(res.data?.wechat)
|
||||
showKeyInput.value = false
|
||||
keyInput.value = ''
|
||||
clearKey.value = false
|
||||
ElMessage.success('微信支付配置已保存')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 上传证书/私钥(先确认再传,避免误覆盖) // Logan.Li
|
||||
async function onPick(kind, uploadFile) {
|
||||
const raw = uploadFile?.raw
|
||||
if (!raw) return
|
||||
const label = uploadItems.value.find((i) => i.kind === kind)?.title || kind
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将上传并覆盖服务端当前的「${label}」,确定继续?`,
|
||||
'确认上传',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
uploadingKind.value = kind
|
||||
try {
|
||||
const res = await uploadPaymentFile(kind, raw)
|
||||
ElMessage.success(res.data?.message || '上传成功')
|
||||
await load()
|
||||
} finally {
|
||||
uploadingKind.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCerts() {
|
||||
fetchingCerts.value = true
|
||||
try {
|
||||
const res = await fetchWechatPlatformCerts()
|
||||
platformCerts.value = res.data?.items || []
|
||||
ElMessage.success(res.message || '平台证书已获取')
|
||||
await load()
|
||||
} finally {
|
||||
fetchingCerts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
testing.value = true
|
||||
try {
|
||||
const res = await testPaymentConfig('wechat')
|
||||
testResults.value = res.data?.results || []
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text || '')
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选中复制')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.head-left { display: flex; align-items: center; gap: 10px; }
|
||||
.title { font-weight: 600; }
|
||||
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.secret-row { display: flex; align-items: center; gap: 10px; }
|
||||
.hint { margin-top: 6px; font-size: 12px; color: #909399; line-height: 1.6; }
|
||||
.hint.inline { margin-top: 0; margin-left: 10px; }
|
||||
.hint.warn { color: #e6a23c; }
|
||||
.warn { color: #e6a23c; }
|
||||
.upload-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
||||
.upload-cell { border: 1px solid #ebeef5; border-radius: 8px; padding: 14px 16px; background: #fafbfc; }
|
||||
.upload-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.upload-head b { font-size: 13px; color: #303133; }
|
||||
.upload-desc { margin: 8px 0 10px; font-size: 12px; line-height: 1.6; color: #909399; min-height: 38px; }
|
||||
.file-meta { font-size: 12px; color: #606266; line-height: 1.9; }
|
||||
.file-meta span { display: inline-block; width: 60px; color: #a8abb2; }
|
||||
.file-meta code { font-size: 12px; color: #409eff; }
|
||||
.upload-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.cert-list { margin-top: 16px; }
|
||||
.cert-list-title { font-size: 13px; color: #606266; margin-bottom: 8px; }
|
||||
.test-row { display: flex; align-items: center; gap: 10px; font-size: 13px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||
.test-msg { color: #303133; }
|
||||
.debug-box {
|
||||
margin: 8px 0 0;
|
||||
padding: 10px 12px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
overflow: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="payment-page">
|
||||
<!-- 通用设置:测试/正式模式 + 全局保存 + 连通性测试(微信/支付宝) -->
|
||||
<el-card shadow="never" class="common-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="head-left">
|
||||
<span class="title">通用设置</span>
|
||||
<el-tag size="small" :type="mode === 'live' ? 'danger' : 'success'">
|
||||
{{ mode === 'live' ? '正式模式 live' : '测试模式 test' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="modeDirty" size="small" type="warning">有未保存改动</el-tag>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :loading="testing" @click="runTestAll">测试连接</el-button>
|
||||
<el-button type="primary" :loading="savingMode" @click="saveMode">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mode-row">
|
||||
<span :class="{ muted: isLive }">测试模式</span>
|
||||
<el-switch
|
||||
v-model="isLive"
|
||||
inline-prompt
|
||||
active-text="正式"
|
||||
inactive-text="测试"
|
||||
style="margin: 0 12px"
|
||||
/>
|
||||
<span :class="{ muted: !isLive }">正式模式</span>
|
||||
<span class="hint inline">
|
||||
仅为全局标记,供后续下单流程判断是否使用沙箱;页面填写与上传不受影响。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="notify-grid">
|
||||
<div>
|
||||
<span class="label">微信支付回调(只读)</span>
|
||||
<code>{{ wechatNotify }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">支付宝回调(只读)</span>
|
||||
<code>{{ alipayNotify }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="testResults.length" class="test-panel">
|
||||
<div v-for="r in testResults" :key="r.channel" class="test-row">
|
||||
<el-tag
|
||||
:type="r.status === 'ok' ? 'success' : (r.status === 'skipped' ? 'info' : 'danger')"
|
||||
size="small"
|
||||
>
|
||||
{{ r.channel }}
|
||||
</el-tag>
|
||||
<span class="test-msg">{{ r.message }}</span>
|
||||
<span class="hint inline">
|
||||
{{ r.http_status ? `HTTP ${r.http_status} · ${r.latency_ms}ms` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<pre
|
||||
v-for="r in testResults.filter((x) => x.detail)"
|
||||
:key="`d-${r.channel}`"
|
||||
class="debug-box"
|
||||
>{{ r.detail }}</pre>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-tabs v-model="activeTab" class="payment-tabs" @tab-change="syncQuery">
|
||||
<el-tab-pane label="微信支付" name="wechat" lazy>
|
||||
<WechatPay />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="支付宝" name="alipay" lazy>
|
||||
<Alipay />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Waffo 海外支付" name="waffo" lazy>
|
||||
<Waffo />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import WechatPay from './WechatPay.vue'
|
||||
import Alipay from './Alipay.vue'
|
||||
import Waffo from './Waffo.vue'
|
||||
import { getPaymentConfig, updatePaymentConfig, testPaymentConfig } from '@/api/payment'
|
||||
|
||||
// 支付配置总入口:国内(微信 / 支付宝)与海外(Waffo)分页签,避免菜单堆叠。
|
||||
// 顶部「通用设置」承载测试/正式模式与全局连通性测试。
|
||||
// 支持 /payments?tab=wechat 深链,便于从文档或告警里直达。 // Logan.Li
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const TABS = ['wechat', 'alipay', 'waffo']
|
||||
const initial = typeof route.query.tab === 'string' ? route.query.tab : ''
|
||||
const activeTab = ref(TABS.includes(initial) ? initial : 'wechat')
|
||||
|
||||
const mode = ref('test')
|
||||
const savedMode = ref('test')
|
||||
const savingMode = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResults = ref([])
|
||||
const wechatNotify = ref('https://licence.rlwj.cn/api/pay/wechat/notify')
|
||||
const alipayNotify = ref('https://licence.rlwj.cn/api/pay/alipay/notify')
|
||||
|
||||
const isLive = computed({
|
||||
get: () => mode.value === 'live',
|
||||
set: (v) => {
|
||||
mode.value = v ? 'live' : 'test'
|
||||
},
|
||||
})
|
||||
const modeDirty = computed(() => mode.value !== savedMode.value)
|
||||
|
||||
// 读取全局配置(模式 + 回调地址) // Logan.Li
|
||||
async function loadCommon() {
|
||||
const res = await getPaymentConfig()
|
||||
const data = res.data || {}
|
||||
mode.value = data.mode === 'live' ? 'live' : 'test'
|
||||
savedMode.value = mode.value
|
||||
if (data.wechat?.notify_url) wechatNotify.value = data.wechat.notify_url
|
||||
if (data.alipay?.notify_url) alipayNotify.value = data.alipay.notify_url
|
||||
}
|
||||
|
||||
async function saveMode() {
|
||||
if (!modeDirty.value) {
|
||||
ElMessage.info('模式未变更')
|
||||
return
|
||||
}
|
||||
savingMode.value = true
|
||||
try {
|
||||
const res = await updatePaymentConfig({ mode: mode.value })
|
||||
mode.value = res.data?.mode === 'live' ? 'live' : 'test'
|
||||
savedMode.value = mode.value
|
||||
ElMessage.success('模式已保存')
|
||||
} finally {
|
||||
savingMode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 全局测试:一次性探测微信与支付宝(未配置的渠道返回 skipped,不报错) // Logan.Li
|
||||
async function runTestAll() {
|
||||
testing.value = true
|
||||
try {
|
||||
const res = await testPaymentConfig('all')
|
||||
testResults.value = res.data?.results || []
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function syncQuery(name) {
|
||||
const query = { ...route.query, tab: name }
|
||||
router.replace({ path: route.path, query })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(tab) => {
|
||||
if (typeof tab === 'string' && TABS.includes(tab) && tab !== activeTab.value) {
|
||||
activeTab.value = tab
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(loadCommon)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.payment-page {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px 16px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.common-card { margin: 8px 0 16px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.head-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.title { font-weight: 600; }
|
||||
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.mode-row { display: flex; align-items: center; flex-wrap: wrap; font-size: 13px; color: #303133; }
|
||||
.muted { color: #a8abb2; }
|
||||
.hint { color: #909399; font-size: 12px; }
|
||||
.hint.inline { margin-left: 10px; }
|
||||
.notify-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.notify-grid .label { display: block; font-size: 12px; color: #909399; margin-bottom: 4px; }
|
||||
.notify-grid code { font-size: 12px; color: #409eff; word-break: break-all; }
|
||||
.test-panel { margin-top: 14px; border-top: 1px dashed #ebeef5; padding-top: 12px; }
|
||||
.test-row { display: flex; align-items: center; gap: 10px; font-size: 13px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||
.test-msg { color: #303133; }
|
||||
.debug-box {
|
||||
margin: 8px 0 0;
|
||||
padding: 10px 12px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
overflow: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
.payment-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.payment-tabs :deep(.el-tabs__item) {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user