feat(trade): connect WeChat refund API and callback flow
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,10 @@ export type PayOrderResult =
|
||||
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams }
|
||||
| { mode: 'native'; codeUrl: string; externalNo: string };
|
||||
|
||||
export type RefundOrderResult =
|
||||
| { mode: 'mock'; outRefundNo: string }
|
||||
| { mode: 'wechat'; outRefundNo: string; refundId?: string; status: 'PROCESSING' | 'SUCCESS' };
|
||||
|
||||
export interface IPayProvider {
|
||||
payOrder(
|
||||
orderId: bigint,
|
||||
@@ -14,4 +18,5 @@ export interface IPayProvider {
|
||||
platform?: 'h5' | 'mini',
|
||||
payMethod?: PayMethod,
|
||||
): Promise<PayOrderResult>;
|
||||
refundOrder(orderId: bigint, outRefundNo: string, reason?: string): Promise<RefundOrderResult>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import type { IPayProvider, PayMethod, PayOrderResult } from './pay.interface';
|
||||
import type { IPayProvider, PayMethod, PayOrderResult, RefundOrderResult } from './pay.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PayMockProvider implements IPayProvider {
|
||||
@@ -27,4 +27,15 @@ export class PayMockProvider implements IPayProvider {
|
||||
}
|
||||
return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
|
||||
}
|
||||
|
||||
async refundOrder(
|
||||
_orderId: bigint,
|
||||
outRefundNo: string,
|
||||
_reason?: string,
|
||||
): Promise<RefundOrderResult> {
|
||||
if (!loadAppConfig().mockPay) {
|
||||
throw new Error('Real WeChat refund requires PayWechatProvider');
|
||||
}
|
||||
return { mode: 'mock', outRefundNo };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import type { IPayProvider, PayMethod, PayOrderResult } from './pay.interface';
|
||||
import type { IPayProvider, PayMethod, PayOrderResult, RefundOrderResult } from './pay.interface';
|
||||
import { PayMockProvider } from './pay.mock.provider';
|
||||
import { PayWechatProvider } from './pay.wechat.provider';
|
||||
|
||||
@@ -24,4 +24,12 @@ export class PayRouterProvider implements IPayProvider {
|
||||
): Promise<PayOrderResult> {
|
||||
return this.resolve().payOrder(orderId, openId, platform, payMethod);
|
||||
}
|
||||
|
||||
refundOrder(
|
||||
orderId: bigint,
|
||||
outRefundNo: string,
|
||||
reason?: string,
|
||||
): Promise<RefundOrderResult> {
|
||||
return this.resolve().refundOrder(orderId, outRefundNo, reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,4 +72,40 @@ export class PayWechatProvider implements IPayProvider {
|
||||
});
|
||||
return { mode: 'jsapi', prepay };
|
||||
}
|
||||
|
||||
async refundOrder(
|
||||
orderId: bigint,
|
||||
outRefundNo: string,
|
||||
reason?: string,
|
||||
) {
|
||||
if (loadAppConfig().mockPay) {
|
||||
return { mode: 'mock' as const, outRefundNo };
|
||||
}
|
||||
if (!this.wechat.isPayEnabled()) {
|
||||
throw new Error('微信支付未配置:请关闭 MOCK_PAY 并配置 WX_MCH_ID 等商户参数');
|
||||
}
|
||||
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw new Error('订单不存在');
|
||||
|
||||
const amountFen = Math.round(Number(order.payAmount) * 100);
|
||||
const notifyUrl = process.env.WX_REFUND_NOTIFY_URL ?? '';
|
||||
|
||||
this.logger.log(`create refund order=${order.orderNo} outRefundNo=${outRefundNo}`);
|
||||
const result = await this.wechat.createDomesticRefund({
|
||||
orderNo: order.orderNo,
|
||||
transactionId: order.payExternalNo ?? undefined,
|
||||
outRefundNo,
|
||||
amountFen,
|
||||
totalFen: amountFen,
|
||||
reason,
|
||||
notifyUrl,
|
||||
});
|
||||
return {
|
||||
mode: 'wechat' as const,
|
||||
outRefundNo: result.outRefundNo,
|
||||
refundId: result.refundId,
|
||||
status: result.status === 'SUCCESS' ? 'SUCCESS' as const : 'PROCESSING' as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ export type WechatPayNotifyResource = {
|
||||
amount?: { total?: number; payer_total?: number };
|
||||
};
|
||||
|
||||
export type WechatRefundNotifyResource = {
|
||||
refund_id: string;
|
||||
out_refund_no: string;
|
||||
transaction_id?: string;
|
||||
out_trade_no?: string;
|
||||
refund_status: 'SUCCESS' | 'PROCESSING' | 'ABNORMAL' | 'CLOSED';
|
||||
amount?: { refund?: number; total?: number; payer_refund?: number; payer_total?: number };
|
||||
};
|
||||
|
||||
export type WechatPayNotifyEnvelope = {
|
||||
id: string;
|
||||
create_time: string;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
normalizePemEnv,
|
||||
verifyPaySignature,
|
||||
type WechatPayNotifyEnvelope,
|
||||
type WechatRefundNotifyResource,
|
||||
} from './wechat-pay.util';
|
||||
|
||||
type TokenCache = { accessToken: string; expiresAt: number };
|
||||
@@ -43,6 +44,7 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
private readonly mchPrivateKey = normalizePemEnv(process.env.WX_MCH_PRIVATE_KEY);
|
||||
private readonly apiV3Key = process.env.WX_API_V3_KEY ?? '';
|
||||
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
||||
private readonly refundNotifyUrl = process.env.WX_REFUND_NOTIFY_URL ?? '';
|
||||
private readonly platformCert = normalizePemEnv(process.env.WX_PLATFORM_CERT);
|
||||
|
||||
constructor(
|
||||
@@ -651,6 +653,119 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
};
|
||||
}
|
||||
|
||||
async createDomesticRefund(params: {
|
||||
orderNo: string;
|
||||
transactionId?: string;
|
||||
outRefundNo: string;
|
||||
amountFen: number;
|
||||
totalFen: number;
|
||||
reason?: string;
|
||||
notifyUrl: string;
|
||||
}) {
|
||||
if (!this.isPayEnabled()) {
|
||||
throw new InternalServerErrorException(
|
||||
'微信支付未配置:请关闭 MOCK_PAY 并配置 WX_MCH_ID、WX_MCH_SERIAL_NO、WX_MCH_PRIVATE_KEY、WX_API_V3_KEY',
|
||||
);
|
||||
}
|
||||
const notifyUrl = params.notifyUrl || this.refundNotifyUrl;
|
||||
if (!notifyUrl) {
|
||||
throw new InternalServerErrorException('请配置 WX_REFUND_NOTIFY_URL');
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
out_refund_no: params.outRefundNo,
|
||||
reason: params.reason ?? '用户申请退款',
|
||||
notify_url: notifyUrl,
|
||||
amount: {
|
||||
refund: params.amountFen,
|
||||
total: params.totalFen,
|
||||
currency: 'CNY',
|
||||
},
|
||||
};
|
||||
if (params.transactionId) {
|
||||
body.transaction_id = params.transactionId;
|
||||
} else {
|
||||
body.out_trade_no = params.orderNo;
|
||||
}
|
||||
const path = '/v3/refund/domestic/refunds';
|
||||
const payload = JSON.stringify(body);
|
||||
const auth = this.signPayRequest('POST', path, payload);
|
||||
const res = await this.fetchPayJson<{
|
||||
refund_id?: string;
|
||||
out_refund_no?: string;
|
||||
status?: 'SUCCESS' | 'PROCESSING' | 'ABNORMAL' | 'CLOSED';
|
||||
}>(`https://api.mch.weixin.qq.com${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: auth,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
if (!res.refund_id || !res.out_refund_no) {
|
||||
throw new InternalServerErrorException('微信退款申请失败');
|
||||
}
|
||||
this.logger.log(
|
||||
`refund ok mchid=${this.mchId} orderNo=${params.orderNo} outRefundNo=${params.outRefundNo} status=${res.status}`,
|
||||
);
|
||||
return {
|
||||
refundId: res.refund_id,
|
||||
outRefundNo: res.out_refund_no,
|
||||
status: res.status ?? 'PROCESSING',
|
||||
};
|
||||
}
|
||||
|
||||
async parseRefundNotification(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
rawBody: string,
|
||||
) {
|
||||
if (!this.isPayEnabled()) {
|
||||
throw new BadRequestException('微信支付未启用');
|
||||
}
|
||||
const signature = this.headerValue(headers, 'wechatpay-signature');
|
||||
const timestamp = this.headerValue(headers, 'wechatpay-timestamp');
|
||||
const nonce = this.headerValue(headers, 'wechatpay-nonce');
|
||||
if (!signature || !timestamp || !nonce) {
|
||||
throw new BadRequestException('微信回调签名头缺失');
|
||||
}
|
||||
if (this.platformCert) {
|
||||
const valid = verifyPaySignature({
|
||||
platformPublicKeyPem: this.platformCert,
|
||||
timestamp,
|
||||
nonce,
|
||||
body: rawBody,
|
||||
signature,
|
||||
});
|
||||
if (!valid) {
|
||||
throw new BadRequestException('微信回调验签失败');
|
||||
}
|
||||
} else {
|
||||
this.logger.warn('WX_PLATFORM_CERT 未配置,跳过回调 RSA 验签(仅建议开发环境)');
|
||||
}
|
||||
|
||||
const envelope = JSON.parse(rawBody) as WechatPayNotifyEnvelope;
|
||||
if (envelope.event_type !== 'REFUND.SUCCESS') {
|
||||
throw new BadRequestException(`忽略的事件类型: ${envelope.event_type}`);
|
||||
}
|
||||
const resource = decryptPayResource(
|
||||
this.apiV3Key,
|
||||
envelope.resource.associated_data ?? '',
|
||||
envelope.resource.nonce,
|
||||
envelope.resource.ciphertext,
|
||||
) as unknown as WechatRefundNotifyResource;
|
||||
if (resource.refund_status !== 'SUCCESS') {
|
||||
throw new BadRequestException(`退款未成功: ${resource.refund_status}`);
|
||||
}
|
||||
return {
|
||||
outRefundNo: resource.out_refund_no,
|
||||
refundId: resource.refund_id,
|
||||
status: resource.refund_status,
|
||||
amountFen: resource.amount?.refund ?? resource.amount?.payer_refund ?? 0,
|
||||
outTradeNo: resource.out_trade_no,
|
||||
transactionId: resource.transaction_id,
|
||||
};
|
||||
}
|
||||
|
||||
private headerValue(headers: Record<string, string | string[] | undefined>, key: string) {
|
||||
const raw = headers[key] ?? headers[key.toLowerCase()];
|
||||
if (Array.isArray(raw)) return raw[0];
|
||||
|
||||
@@ -59,6 +59,14 @@ export class WechatDisabledProvider implements IWechatProvider {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
createDomesticRefund() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
parseRefundNotification() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
getWxaCodeUnlimited(_input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { WechatJssdkConfig, WechatJsapiPrepayParams } from '@dukang/shared-types';
|
||||
import type {
|
||||
WechatJssdkConfig,
|
||||
WechatJsapiPrepayParams,
|
||||
WechatRefundNotifyResult,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export type WechatCodeSession = {
|
||||
openId: string;
|
||||
@@ -146,6 +150,23 @@ export interface IWechatProvider {
|
||||
rawBody: string,
|
||||
): Promise<WechatPayNotifyResult>;
|
||||
|
||||
/** 发起国内退款(API v3) */
|
||||
createDomesticRefund(params: {
|
||||
orderNo: string;
|
||||
transactionId?: string;
|
||||
outRefundNo: string;
|
||||
amountFen: number;
|
||||
totalFen: number;
|
||||
reason?: string;
|
||||
notifyUrl: string;
|
||||
}): Promise<{ refundId: string; outRefundNo: string; status: 'PROCESSING' | 'SUCCESS' | 'ABNORMAL' | 'CLOSED' }>;
|
||||
|
||||
/** 解析并验签退款回调通知 */
|
||||
parseRefundNotification(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
rawBody: string,
|
||||
): Promise<WechatRefundNotifyResult>;
|
||||
|
||||
/** 获取不限制的小程序码(PNG Buffer),须服务端调用 */
|
||||
getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer>;
|
||||
|
||||
|
||||
@@ -88,6 +88,14 @@ export class WechatMockProvider implements IWechatProvider {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
createDomesticRefund(): never {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
parseRefundNotification(): never {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
/** Mock:用普通二维码 PNG 占位,内容含 scene,便于本地联调上传 OSS */
|
||||
async getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||
const scene = (input.scene ?? '').trim() || 'mock';
|
||||
|
||||
@@ -96,6 +96,17 @@ export class WechatRouterProvider implements IWechatProvider {
|
||||
return this.resolve().parsePayNotification(headers, rawBody);
|
||||
}
|
||||
|
||||
createDomesticRefund(params: Parameters<IWechatProvider['createDomesticRefund']>[0]) {
|
||||
return this.resolve().createDomesticRefund(params);
|
||||
}
|
||||
|
||||
parseRefundNotification(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
rawBody: string,
|
||||
) {
|
||||
return this.resolve().parseRefundNotification(headers, rawBody);
|
||||
}
|
||||
|
||||
getWxaCodeUnlimited(input: Parameters<IWechatProvider['getWxaCodeUnlimited']>[0]) {
|
||||
return this.resolve().getWxaCodeUnlimited(input);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user