refactor(admin): 支付调试页改为直调 unipay 模拟商户 API

新增无鉴权 unipay 客户端;组参补齐 reqTime/nonceStr 后签名并 POST /unipay/pay。
移除 develop:trade:pay 后门依赖,结果展示完整 DaxResult。
This commit is contained in:
DaxPay Dev
2026-07-13 09:41:46 +08:00
parent b3dd7b963c
commit 0d61a3c5ea
7 changed files with 233 additions and 64 deletions

View File

@@ -5,6 +5,8 @@ import { defHttp } from '#/api/request';
/**
* 交易开发调试 API
*
* 仅组参辅助与签名, 真实支付见 unipay-trade.api (POST /unipay/pay)
*/
export const DevelopTradeApi = {
/**
@@ -14,13 +16,6 @@ export const DevelopTradeApi = {
return defHttp.post({ url: '/admin/develop/trade/sign', data });
},
/**
* 支付调试(真实发起)
*/
pay(data: DevelopParam): Promise<Result<DevelopPayResult>> {
return defHttp.post({ url: '/admin/develop/trade/pay', data });
},
/**
* 已启用渠道支付方式目录(供调试页支付方式下拉)
*/
@@ -57,7 +52,7 @@ export interface DevelopParam {
privateKey?: string;
}
/** 支付参数 */
/** 支付参数(与 unipay NormalPayParam 对齐) */
export interface PayParam {
/** 商户号 */
mchNo: string;
@@ -89,6 +84,14 @@ export interface PayParam {
returnUrl?: string;
/** 过期时间(北京时间 yyyy-MM-dd HH:mm:ss) */
expiredTime?: string;
/** 客户端 IP(可选, 未传时由 unipay 从 HTTP 请求提取) */
clientIp?: string;
/** 随机串 */
nonceStr?: string;
/** 请求时间(北京时间 yyyy-MM-dd HH:mm:ss) */
reqTime?: string;
/** 签名 */
sign?: string;
}
/** 支付结果 */
@@ -114,11 +117,3 @@ export interface DevelopSignResult {
/** 签名值 */
sign?: string;
}
/** 支付调试结果 */
export interface DevelopPayResult {
/** 支付结果 */
payResult?: PayResult;
/** 响应签名(平台私钥签名) */
sign?: string;
}

View File

@@ -0,0 +1,60 @@
/**
* unipay 轻量 HTTP 客户端
*
* 用于管理端「交易调试」等场景模拟商户直调统一支付接口。
* 与 defHttp/requestClient 隔离:
* - 不注入 Accesstoken / x-client-code / nonce
* - 不挂登录失效拦截器(避免业务错误误踢登录)
* - 业务 code !== 0 时仍原样返回 body, 由页面展示完整 DaxResult
*/
import type { RequestClientOptions } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
import { RequestClient } from '@vben/request';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
/** unipay 通用响应(与后端 DaxResult 对齐) */
export interface DaxResult<T = unknown> {
code: number;
msg?: string;
data?: T;
sign?: string;
resTime?: string;
traceId?: string;
}
function createUnipayClient(baseURL: string, options?: RequestClientOptions) {
const client = new RequestClient({
...options,
baseURL,
// 通道调用可能较慢
timeout: 60_000,
responseReturn: 'body',
});
// 始终返回响应体; 业务失败(code!=0)不 throw, 交给调用方展示
client.addResponseInterceptor({
fulfilled: (response) => response.data,
});
return client;
}
const unipayClient = createUnipayClient(apiURL);
/**
* POST unipay 接口, 返回完整 DaxResult(含业务失败)
*/
export async function unipayPost<T = unknown>(url: string, data?: unknown): Promise<DaxResult<T>> {
// RequestClient 在 HTTP 非 2xx 时 throw response.data; 尽量当成 DaxResult 透出
try {
return await unipayClient.post<DaxResult<T>>(url, data);
} catch (error: unknown) {
// 后端部分异常可能以非 2xx + body 形式返回
if (error && typeof error === 'object' && 'code' in error) {
return error as DaxResult<T>;
}
throw error;
}
}

View File

@@ -0,0 +1,19 @@
/**
* 统一支付(unipay)交易接口 — 商户侧契约
*
* 调试页通过此 API 模拟真实商户 HTTP 调用, 不走 admin 特权通道。
*/
import type { DaxResult } from './unipay-request';
import type { PayParam, PayResult } from '#/api/payment/develop/developTrade.api';
import { unipayPost } from './unipay-request';
/**
* 统一支付下单
*
* POST /unipay/pay
* 请求体须含完整商户签名字段(reqTime/nonceStr/sign 等)
*/
export function uniPay(data: PayParam): Promise<DaxResult<PayResult>> {
return unipayPost<PayResult>('/unipay/pay', data);
}

View File

@@ -205,11 +205,10 @@ export const PermCodes = {
/** 开发调试(独立顶级域) */
Develop: {
/** 支付调试 menuCode=develop:trade */
/** 支付调试 menuCode=develop:trade(真实支付走 unipay, 无 develop:trade:pay 后门) */
Trade: {
VIEW: 'develop:trade:view',
SIGN: 'develop:trade:sign',
PAY: 'develop:trade:pay',
},
/** 签名调试 menuCode=develop:sign */
Sign: {

View File

@@ -52,8 +52,8 @@
"trade": {
"page": {
"title": "Payment Develop",
"tag": "Real Trade",
"warning": "Clicking \"Submit\" sends a real payment request to the system. Please confirm the params."
"tag": "Simulated Merchant API",
"warning": "This page only builds params and signs them. Submit simulates a merchant call to /unipay/pay (not an admin privilege path). Confirm params carefully."
},
"card": {
"basic": "Basic Info",
@@ -113,7 +113,7 @@
},
"privateKey": {
"modalTitle": "Set Merchant Private Key",
"modalTip": "Enter the merchant RSA private key for server-side signing (stored in browser local cache only).",
"modalTip": "Enter the merchant RSA private key for request signing only (stored in browser local cache; not used for admin privilege pay).",
"placeholder": "Enter the merchant private key",
"savedTip": "Private key saved",
"clearedTip": "Private key cleared",
@@ -124,12 +124,14 @@
"clearConfirmContent": "Are you sure you want to clear the merchant private key? It will need to be re-entered."
},
"result": {
"modalTitle": "Payment Result",
"modalTitle": "Payment Result (unipay)",
"payLink": "Payment Link",
"jsapiParam": "JSAPI Params",
"formData": "Form Data",
"markCode": "Mark Code",
"copyData": "Copy Data",
"copyData": "Copy Pay Body",
"copyFull": "Copy Full Response",
"rawResponse": "Full DaxResult Response",
"empty": "No data",
"field": {
"orderNo": "System Order No."
@@ -146,6 +148,8 @@
},
"msg": {
"signSuccess": "Sign generated successfully",
"paySuccess": "Payment request succeeded",
"payFail": "Payment request failed",
"inputPrivateKey": "Please set the merchant private key first",
"copySuccess": "Copied"
}

View File

@@ -52,8 +52,8 @@
"trade": {
"page": {
"title": "支付调试",
"tag": "真实交易",
"warning": "点击「提交支付」将向支付系统发起真实交易请求,请确认参数无误。"
"tag": "模拟商户 API",
"warning": "本页仅组装参数并签名,提交后将模拟商户调用 /unipay/pay 发起真实交易(非管理端特权通道),请确认参数无误。"
},
"card": {
"basic": "基础信息",
@@ -113,7 +113,7 @@
},
"privateKey": {
"modalTitle": "设置商户私钥",
"modalTip": "请输入商户 RSA 私钥,用于在服务端计算签名(仅保存在浏览器本地缓存中)。",
"modalTip": "请输入商户 RSA 私钥,用于生成请求签名(仅保存在浏览器本地缓存中,不会用于管理端特权下单)。",
"placeholder": "请输入商户私钥",
"savedTip": "私钥保存成功",
"clearedTip": "私钥已清除",
@@ -124,12 +124,14 @@
"clearConfirmContent": "确定要清除已设置的商户私钥吗?清除后需重新输入。"
},
"result": {
"modalTitle": "支付结果",
"modalTitle": "支付结果 (unipay)",
"payLink": "支付链接",
"jsapiParam": "JSAPI 参数",
"formData": "表单数据",
"markCode": "标识码",
"copyData": "复制数",
"copyData": "复制支付参数",
"copyFull": "复制完整响应",
"rawResponse": "完整 DaxResult 响应",
"empty": "暂无数据",
"field": {
"orderNo": "系统订单号"
@@ -146,6 +148,8 @@
},
"msg": {
"signSuccess": "签名生成成功",
"paySuccess": "支付请求成功",
"payFail": "支付请求失败",
"inputPrivateKey": "请先设置商户私钥",
"copySuccess": "复制成功"
}

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { FormInstance } from 'antdv-next';
import type { DevelopPayResult, PayParam } from '#/api/payment/develop/developTrade.api';
import type { PayParam, PayResult } from '#/api/payment/develop/developTrade.api';
import type { DaxResult } from '#/api/payment/unipay/unipay-request';
import type { LabelValue } from '#/types/web';
import { computed, onMounted, reactive, ref } from 'vue';
@@ -14,6 +15,7 @@
import { DevelopTradeApi } from '#/api/payment/develop/developTrade.api';
import { MchAppInfoApi } from '#/api/payment/merchant/mch-app-info.api';
import { MerchantApi } from '#/api/payment/merchant/merchant.api';
import { uniPay } from '#/api/payment/unipay/unipay-trade.api';
import { QrCode } from '#/components/qrcode';
import { useMessage } from '#/hooks/useMessage';
@@ -87,9 +89,9 @@
const channelMchNoOptions = ref<LabelValue[]>([]);
const capabilityOptions = ref<LabelValue[]>([]);
// ===== 调试结果 =====
// ===== 调试结果(完整 unipay DaxResult) =====
const resultVisible = ref(false);
const resultData = ref<DevelopPayResult>({});
const resultData = ref<DaxResult<PayResult>>({ code: 0 });
// ===== 签名预览(请求预览卡内联展示) =====
const signPreview = reactive({
@@ -234,29 +236,64 @@
}
// ===== 实时请求预览 =====
/** 按当前模式组装提交参数(各模式只透传自身字段, product/method 由后端派生) */
function buildPayload(): PayParam {
const payload: PayParam = { ...form };
if (routeMode.value === 'route') {
// 路由模式: 不传通道商户/能力, 由路由引擎决定
payload.channelMchNo = '';
payload.capability = '';
} else {
// 直传模式: method 由后端从(通道商户, 能力)反推, 不透传
payload.method = '';
}
return payload;
/**
* 生成东八区请求时间字面量 yyyy-MM-dd HH:mm:ss
* (与 PaymentCommonParam.reqTime 文档一致, 禁止用 toISOString 的 UTC)
*/
function formatReqTimeCst(): string {
// sv-SE 在指定时区下格式接近 ISO 本地: YYYY-MM-DD HH:mm:ss
return new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' });
}
/** 当前请求 JSON 预览(剔除空值) */
const requestPreview = computed(() => {
/** 生成随机 nonce(16 位字母数字) */
function genNonceStr(): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let s = '';
for (let i = 0; i < 16; i++) {
s += chars[Math.floor(Math.random() * chars.length)];
}
return s;
}
/** 剔除空串/null/undefined, 避免参与签名或污染请求体 */
function cleanPayload(raw: PayParam): PayParam {
const cleaned: Record<string, any> = {};
for (const [k, v] of Object.entries(buildPayload())) {
for (const [k, v] of Object.entries(raw)) {
if (v !== '' && v != null) {
cleaned[k] = v;
}
}
return JSON.stringify(cleaned, null, 2);
return cleaned as PayParam;
}
/**
* 按当前模式组装提交参数(含公共字段 reqTime/nonceStr)
* 各模式只透传自身字段; 直传模式 method 由 unipay 路由反推
*/
function buildPayload(): PayParam {
const payload: PayParam = {
...form,
// 每次组参刷新公共字段, 贴近真实商户 SDK
reqTime: formatReqTimeCst(),
nonceStr: genNonceStr(),
// 签名由后续步骤写入, 预览阶段不带旧 sign
sign: undefined,
};
if (routeMode.value === 'route') {
// 路由模式: 不传通道商户/能力, 由路由引擎决定
payload.channelMchNo = undefined;
payload.capability = undefined;
} else {
// 直传模式: method 由后端从(通道商户, 能力)反推, 不透传
payload.method = undefined;
}
return cleanPayload(payload);
}
/** 当前请求 JSON 预览(剔除空值) */
const requestPreview = computed(() => {
return JSON.stringify(buildPayload(), null, 2);
});
/** 生成签名预览(内联展示, 不弹结果) */
@@ -293,7 +330,10 @@
}
// ===== 提交 =====
/** 发起真实支付调试 */
/**
* 模拟商户调用 unipay 发起支付
* 1. admin 仅签名 2. 浏览器 POST /unipay/pay
*/
async function handlePay() {
// 表单字段校验(含私钥自定义规则)
try {
@@ -304,12 +344,33 @@
}
loading.value = true;
try {
const { data } = await DevelopTradeApi.pay({
param: buildPayload(),
// 组参(含 reqTime/nonceStr)
const payload = buildPayload();
// 管理端签名(与 Java PaySignUtil 一致; 失败由 defHttp toast 并 throw)
const { data: signRes } = await DevelopTradeApi.sign({
param: payload,
privateKey: privateKey.value,
});
resultData.value = data ?? {};
resultVisible.value = true;
payload.sign = signRes?.sign;
signPreview.signStr = signRes?.signStr ?? '';
signPreview.sign = signRes?.sign ?? '';
// 直调统一支付(无 Accesstoken, 完整商户契约)
// unipayPost 已将业务失败(code!=0)转为返回值, 仅网络层异常会 throw
try {
const dax = await uniPay(payload);
resultData.value = dax ?? { code: -1 };
resultVisible.value = true;
if (dax?.code === 0) {
message.success($t('payment.develop.trade.msg.paySuccess'));
} else {
// 业务失败仍展示完整 DaxResult, 便于联调
message.warning(dax?.msg || $t('payment.develop.trade.msg.payFail'));
}
} catch {
// unipay 网络/非业务异常
message.error($t('payment.develop.trade.msg.payFail'));
}
} finally {
loading.value = false;
}
@@ -343,10 +404,12 @@
}
// ===== 结果展示 =====
/** unipay 业务 data */
const payResult = computed(() => resultData.value.data);
/** 结果中的支付参数体类型 */
const payBodyType = computed(() => resultData.value.payResult?.payBodyType ?? '');
const payBodyType = computed(() => payResult.value?.payBodyType ?? '');
/** 结果中的支付参数体 */
const payBody = computed(() => resultData.value.payResult?.payBody ?? '');
const payBody = computed(() => payResult.value?.payBody ?? '');
/** JSAPI 参数对象(供 JsonViewer 展示) */
const jsapiObject = computed(() => {
const body = payBody.value;
@@ -361,11 +424,16 @@
/** 结果弹窗是否展示支付参数体 */
const hasPayBody = computed(() => !!payBody.value);
/** 复制结果数据 */
/** 复制支付参数体 */
function copyResultData() {
copyText(payBody.value);
}
/** 复制完整 DaxResult JSON */
function copyFullResult() {
copyText(JSON.stringify(resultData.value, null, 2));
}
onMounted(() => {
const saved = localStorage.getItem(PRIVATE_KEY_STORAGE_KEY);
if (saved) {
@@ -700,14 +768,25 @@
/>
</a-modal>
<!-- 调试结果弹窗 -->
<!-- 调试结果弹窗(完整 unipay DaxResult) -->
<a-modal
v-model:open="resultVisible"
:title="$t('payment.develop.trade.result.modalTitle')"
:footer="null"
:width="600"
:width="640"
destroy-on-hidden
>
<!-- 响应摘要 -->
<div class="mb-4 flex flex-wrap items-center gap-2">
<a-tag :color="resultData.code === 0 ? 'success' : 'error'">
code: {{ resultData.code }}
</a-tag>
<span class="text-sm text-muted-foreground">{{ resultData.msg }}</span>
<span v-if="resultData.traceId" class="text-xs text-muted-foreground">
traceId: {{ resultData.traceId }}
</span>
</div>
<template v-if="hasPayBody">
<div class="flex flex-col items-center">
<!-- 扫码支付/支付链接: qr_code + link 渲染二维码 -->
@@ -758,16 +837,25 @@
</div>
</div>
</template>
<!-- 复制数据 -->
<a-button block type="primary" @click="copyResultData">
<template #icon><IconifyIcon icon="ant-design:copy-outlined" /></template>
{{ $t('payment.develop.trade.result.copyData') }}
</a-button>
</div>
</template>
<a-empty v-else :description="$t('payment.develop.trade.result.empty')" />
<!-- 完整 DaxResult(联调对照文档) -->
<div class="mb-1 mt-2 text-xs font-medium text-muted-foreground">
{{ $t('payment.develop.trade.result.rawResponse') }}
</div>
<JsonViewer class="json-viewer-box mb-3" :value="resultData" :expand-depth="2" boxed copyable />
<div class="flex gap-2">
<a-button v-if="hasPayBody" block type="primary" @click="copyResultData">
<template #icon><IconifyIcon icon="ant-design:copy-outlined" /></template>
{{ $t('payment.develop.trade.result.copyData') }}
</a-button>
<a-button block @click="copyFullResult">
<template #icon><IconifyIcon icon="ant-design:copy-outlined" /></template>
{{ $t('payment.develop.trade.result.copyFull') }}
</a-button>
</div>
</a-modal>
</div>
</template>