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:
Logan.Li committed 2026-11-01 05:59:51 +08:00
1 parent 630f2819cb
commit 7d53c6ed3c
21 files changed
+3090 -6

No files matched your search

+46
View File
@@ -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,
})
}
+3 -5
View File
@@ -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>
+1 -1
View File
@@ -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' },
},
{
+342
View File
@@ -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.
+420
View File
@@ -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>
+214
View File
@@ -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>