feat(trade): connect WeChat refund API and callback flow

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 21:45:33 +08:00
parent e93e4b8f84
commit ab10431001
20 changed files with 455 additions and 61 deletions
@@ -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);
}