mirror of
https://gitee.com/bootx/dax-pay-ui
synced 2026-08-12 07:25:39 +08:00
refactor(admin): 支付调试页改为直调 unipay 模拟商户 API
新增无鉴权 unipay 客户端;组参补齐 reqTime/nonceStr 后签名并 POST /unipay/pay。 移除 develop:trade:pay 后门依赖,结果展示完整 DaxResult。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
60
apps/daxpay-admin/src/api/payment/unipay/unipay-request.ts
Normal file
60
apps/daxpay-admin/src/api/payment/unipay/unipay-request.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
apps/daxpay-admin/src/api/payment/unipay/unipay-trade.api.ts
Normal file
19
apps/daxpay-admin/src/api/payment/unipay/unipay-trade.api.ts
Normal 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);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "复制成功"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user