feat(trade): connect WeChat refund API and callback flow
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,6 +21,16 @@ export type WechatPayOrderResult =
|
|||||||
| { mode: 'mock'; externalNo: string; order?: Record<string, unknown> }
|
| { mode: 'mock'; externalNo: string; order?: Record<string, unknown> }
|
||||||
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams; orderId: string };
|
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams; orderId: string };
|
||||||
|
|
||||||
|
/** 微信退款回调解密结果 */
|
||||||
|
export type WechatRefundNotifyResult = {
|
||||||
|
outRefundNo: string;
|
||||||
|
refundId: string;
|
||||||
|
status: 'SUCCESS' | 'PROCESSING' | 'ABNORMAL' | 'CLOSED';
|
||||||
|
amountFen: number;
|
||||||
|
outTradeNo?: string;
|
||||||
|
transactionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** 业务错误码:微信支付前需完成微信授权 */
|
/** 业务错误码:微信支付前需完成微信授权 */
|
||||||
export const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
|
export const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,12 @@ async function main() {
|
|||||||
token: admin.accessToken,
|
token: admin.accessToken,
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
|
const refundedOrder = await req('USER_H5', `/trade/orders/${order2.id}`, { token: userToken });
|
||||||
|
if (refundedOrder.status !== 'REFUNDED' || refundedOrder.payStatus !== 'REFUNDED') {
|
||||||
|
throw new Error(
|
||||||
|
`Refund order status expected REFUNDED/REFUNDED, got ${refundedOrder.status}/${refundedOrder.payStatus}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('11. Partner phone gate');
|
console.log('11. Partner phone gate');
|
||||||
const unknownPartner = await expectFail('PARTNER_H5', '/partner/auth/phone/check', {
|
const unknownPartner = await expectFail('PARTNER_H5', '/partner/auth/phone/check', {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ WX_API_V3_KEY=
|
|||||||
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
|
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
|
||||||
WX_PLATFORM_CERT=
|
WX_PLATFORM_CERT=
|
||||||
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
||||||
|
WX_REFUND_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/refund
|
||||||
# 小程序消息推送(发货管理确认收货/结算事件):后台 URL 填下方地址,数据格式建议 JSON,加密方式建议安全模式
|
# 小程序消息推送(发货管理确认收货/结算事件):后台 URL 填下方地址,数据格式建议 JSON,加密方式建议安全模式
|
||||||
# https://api.dukanghaoke.com/api/v1/callbacks/wechat/message
|
# https://api.dukanghaoke.com/api/v1/callbacks/wechat/message
|
||||||
WX_MINI_MSG_TOKEN=
|
WX_MINI_MSG_TOKEN=
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ WX_MCH_PRIVATE_KEY=
|
|||||||
WX_API_V3_KEY=
|
WX_API_V3_KEY=
|
||||||
WX_PLATFORM_CERT=
|
WX_PLATFORM_CERT=
|
||||||
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
||||||
|
WX_REFUND_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/refund
|
||||||
# 消息推送 URL: https://api.dukanghaoke.com/api/v1/callbacks/wechat/message
|
# 消息推送 URL: https://api.dukanghaoke.com/api/v1/callbacks/wechat/message
|
||||||
WX_MINI_MSG_TOKEN=
|
WX_MINI_MSG_TOKEN=
|
||||||
WX_MINI_MSG_AES_KEY=
|
WX_MINI_MSG_AES_KEY=
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ WX_MCH_PRIVATE_KEY=
|
|||||||
WX_API_V3_KEY=
|
WX_API_V3_KEY=
|
||||||
WX_PLATFORM_CERT=
|
WX_PLATFORM_CERT=
|
||||||
WX_PAY_NOTIFY_URL=https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
WX_PAY_NOTIFY_URL=https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
||||||
|
WX_REFUND_NOTIFY_URL=https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/refund
|
||||||
WX_MINI_MSG_TOKEN=
|
WX_MINI_MSG_TOKEN=
|
||||||
WX_MINI_MSG_AES_KEY=
|
WX_MINI_MSG_AES_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,40 @@
|
|||||||
import { Controller, Headers, Post, Req, Res } from '@nestjs/common';
|
import { Controller, Headers, Inject, Post, Req, Res } from '@nestjs/common';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { PrismaService } from '../common/prisma/prisma.module';
|
import { TradeService } from '../modules/trade/trade.service';
|
||||||
|
import { WECHAT_PROVIDER } from '../integrations/integrations.constants';
|
||||||
|
import type { IWechatProvider } from '../integrations/wechat/wechat.interface';
|
||||||
import { AlertService } from '../common/alert/alert.service';
|
import { AlertService } from '../common/alert/alert.service';
|
||||||
|
|
||||||
|
type RawBodyRequest = Request & { body: Buffer };
|
||||||
|
|
||||||
@Controller('callbacks/wechat')
|
@Controller('callbacks/wechat')
|
||||||
export class WechatRefundCallbackController {
|
export class WechatRefundCallbackController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly tradeService: TradeService,
|
||||||
|
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||||
private readonly alert: AlertService,
|
private readonly alert: AlertService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('refund')
|
@Post('refund')
|
||||||
async refundNotify(
|
async refundNotify(
|
||||||
@Req() req: Request,
|
@Req() req: RawBodyRequest,
|
||||||
@Headers() _headers: Record<string, string | string[] | undefined>,
|
@Headers() headers: Record<string, string | string[] | undefined>,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
|
let outRefundNo: string | undefined;
|
||||||
try {
|
try {
|
||||||
const body = typeof req.body === 'object' ? req.body : {};
|
const rawBody =
|
||||||
const outRefundNo = String((body as Record<string, unknown>).out_refund_no ?? '');
|
(req as Request & { rawBody?: Buffer }).rawBody?.toString('utf8') ??
|
||||||
const refundId = String((body as Record<string, unknown>).refund_id ?? outRefundNo);
|
(typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}));
|
||||||
|
const notify = await this.wechat.parseRefundNotification(headers, rawBody);
|
||||||
const existing = await this.prisma.logThirdParty.findFirst({
|
outRefundNo = notify.outRefundNo;
|
||||||
where: { provider: 'WECHAT_REFUND', externalNo: refundId, status: 'SUCCESS' },
|
await this.tradeService.handleRefundSuccess({
|
||||||
|
outRefundNo: notify.outRefundNo,
|
||||||
|
refundId: notify.refundId,
|
||||||
|
amountFen: notify.amountFen,
|
||||||
|
remark: '微信退款回调确认',
|
||||||
|
actorType: 'WECHAT_REFUND',
|
||||||
});
|
});
|
||||||
if (existing) {
|
|
||||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.logThirdParty.create({
|
|
||||||
data: {
|
|
||||||
provider: 'WECHAT_REFUND',
|
|
||||||
scene: 'ORDER_REFUND_CALLBACK',
|
|
||||||
refType: 'TICKET',
|
|
||||||
refId: BigInt(0),
|
|
||||||
externalNo: refundId,
|
|
||||||
status: 'SUCCESS',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : '处理失败';
|
const message = err instanceof Error ? err.message : '处理失败';
|
||||||
@@ -46,8 +42,8 @@ export class WechatRefundCallbackController {
|
|||||||
level: 'P0',
|
level: 'P0',
|
||||||
category: 'pay',
|
category: 'pay',
|
||||||
title: '退款回调处理失败',
|
title: '退款回调处理失败',
|
||||||
detail: message,
|
detail: `${outRefundNo ? `退款单 ${outRefundNo}\n` : ''}${message}`,
|
||||||
dedupeKey: `refund_callback_fail|${message.slice(0, 40)}`,
|
dedupeKey: `refund_callback_fail|${outRefundNo ?? message.slice(0, 40)}`,
|
||||||
dedupeTtlSec: 120,
|
dedupeTtlSec: 120,
|
||||||
});
|
});
|
||||||
return res.status(500).json({ code: 'FAIL', message });
|
return res.status(500).json({ code: 'FAIL', message });
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
{ key: 'WX_API_V3_KEY', label: 'APIv3 密钥', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
|
{ key: 'WX_API_V3_KEY', label: 'APIv3 密钥', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
|
||||||
{ key: 'WX_PLATFORM_CERT', label: '微信平台公钥证书', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
|
{ key: 'WX_PLATFORM_CERT', label: '微信平台公钥证书', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
|
||||||
{ key: 'WX_PAY_NOTIFY_URL', label: '支付回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
|
{ key: 'WX_PAY_NOTIFY_URL', label: '支付回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
|
||||||
|
{ key: 'WX_REFUND_NOTIFY_URL', label: '退款回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
|
||||||
{
|
{
|
||||||
key: 'WX_MINI_MSG_TOKEN',
|
key: 'WX_MINI_MSG_TOKEN',
|
||||||
label: '小程序消息推送 Token',
|
label: '小程序消息推送 Token',
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export type PayOrderResult =
|
|||||||
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams }
|
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams }
|
||||||
| { mode: 'native'; codeUrl: string; externalNo: string };
|
| { 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 {
|
export interface IPayProvider {
|
||||||
payOrder(
|
payOrder(
|
||||||
orderId: bigint,
|
orderId: bigint,
|
||||||
@@ -14,4 +18,5 @@ export interface IPayProvider {
|
|||||||
platform?: 'h5' | 'mini',
|
platform?: 'h5' | 'mini',
|
||||||
payMethod?: PayMethod,
|
payMethod?: PayMethod,
|
||||||
): Promise<PayOrderResult>;
|
): Promise<PayOrderResult>;
|
||||||
|
refundOrder(orderId: bigint, outRefundNo: string, reason?: string): Promise<RefundOrderResult>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { loadAppConfig } from '@dukang/shared-types';
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
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()
|
@Injectable()
|
||||||
export class PayMockProvider implements IPayProvider {
|
export class PayMockProvider implements IPayProvider {
|
||||||
@@ -27,4 +27,15 @@ export class PayMockProvider implements IPayProvider {
|
|||||||
}
|
}
|
||||||
return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
|
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 { Injectable } from '@nestjs/common';
|
||||||
import { loadAppConfig } from '@dukang/shared-types';
|
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 { PayMockProvider } from './pay.mock.provider';
|
||||||
import { PayWechatProvider } from './pay.wechat.provider';
|
import { PayWechatProvider } from './pay.wechat.provider';
|
||||||
|
|
||||||
@@ -24,4 +24,12 @@ export class PayRouterProvider implements IPayProvider {
|
|||||||
): Promise<PayOrderResult> {
|
): Promise<PayOrderResult> {
|
||||||
return this.resolve().payOrder(orderId, openId, platform, payMethod);
|
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 };
|
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 };
|
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 = {
|
export type WechatPayNotifyEnvelope = {
|
||||||
id: string;
|
id: string;
|
||||||
create_time: string;
|
create_time: string;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
normalizePemEnv,
|
normalizePemEnv,
|
||||||
verifyPaySignature,
|
verifyPaySignature,
|
||||||
type WechatPayNotifyEnvelope,
|
type WechatPayNotifyEnvelope,
|
||||||
|
type WechatRefundNotifyResource,
|
||||||
} from './wechat-pay.util';
|
} from './wechat-pay.util';
|
||||||
|
|
||||||
type TokenCache = { accessToken: string; expiresAt: number };
|
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 mchPrivateKey = normalizePemEnv(process.env.WX_MCH_PRIVATE_KEY);
|
||||||
private readonly apiV3Key = process.env.WX_API_V3_KEY ?? '';
|
private readonly apiV3Key = process.env.WX_API_V3_KEY ?? '';
|
||||||
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
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);
|
private readonly platformCert = normalizePemEnv(process.env.WX_PLATFORM_CERT);
|
||||||
|
|
||||||
constructor(
|
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) {
|
private headerValue(headers: Record<string, string | string[] | undefined>, key: string) {
|
||||||
const raw = headers[key] ?? headers[key.toLowerCase()];
|
const raw = headers[key] ?? headers[key.toLowerCase()];
|
||||||
if (Array.isArray(raw)) return raw[0];
|
if (Array.isArray(raw)) return raw[0];
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ export class WechatDisabledProvider implements IWechatProvider {
|
|||||||
return this.disabled();
|
return this.disabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createDomesticRefund() {
|
||||||
|
return this.disabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseRefundNotification() {
|
||||||
|
return this.disabled();
|
||||||
|
}
|
||||||
|
|
||||||
getWxaCodeUnlimited(_input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
getWxaCodeUnlimited(_input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||||
return this.disabled();
|
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 = {
|
export type WechatCodeSession = {
|
||||||
openId: string;
|
openId: string;
|
||||||
@@ -146,6 +150,23 @@ export interface IWechatProvider {
|
|||||||
rawBody: string,
|
rawBody: string,
|
||||||
): Promise<WechatPayNotifyResult>;
|
): 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),须服务端调用 */
|
/** 获取不限制的小程序码(PNG Buffer),须服务端调用 */
|
||||||
getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer>;
|
getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer>;
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ export class WechatMockProvider implements IWechatProvider {
|
|||||||
throw new NotImplementedException('FEATURE_DISABLED');
|
throw new NotImplementedException('FEATURE_DISABLED');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createDomesticRefund(): never {
|
||||||
|
throw new NotImplementedException('FEATURE_DISABLED');
|
||||||
|
}
|
||||||
|
|
||||||
|
parseRefundNotification(): never {
|
||||||
|
throw new NotImplementedException('FEATURE_DISABLED');
|
||||||
|
}
|
||||||
|
|
||||||
/** Mock:用普通二维码 PNG 占位,内容含 scene,便于本地联调上传 OSS */
|
/** Mock:用普通二维码 PNG 占位,内容含 scene,便于本地联调上传 OSS */
|
||||||
async getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
async getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||||
const scene = (input.scene ?? '').trim() || 'mock';
|
const scene = (input.scene ?? '').trim() || 'mock';
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ export class WechatRouterProvider implements IWechatProvider {
|
|||||||
return this.resolve().parsePayNotification(headers, rawBody);
|
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]) {
|
getWxaCodeUnlimited(input: Parameters<IWechatProvider['getWxaCodeUnlimited']>[0]) {
|
||||||
return this.resolve().getWxaCodeUnlimited(input);
|
return this.resolve().getWxaCodeUnlimited(input);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async function bootstrap() {
|
|||||||
verify: (req, _res, buf) => {
|
verify: (req, _res, buf) => {
|
||||||
if (
|
if (
|
||||||
req.url?.includes('/callbacks/wechat/pay') ||
|
req.url?.includes('/callbacks/wechat/pay') ||
|
||||||
|
req.url?.includes('/callbacks/wechat/refund') ||
|
||||||
req.url?.includes('/callbacks/wechat/message')
|
req.url?.includes('/callbacks/wechat/message')
|
||||||
) {
|
) {
|
||||||
(req as { rawBody?: Buffer }).rawBody = buf;
|
(req as { rawBody?: Buffer }).rawBody = buf;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { BenefitService } from '../benefit/benefit.service';
|
|
||||||
import { TradeService } from '../trade/trade.service';
|
import { TradeService } from '../trade/trade.service';
|
||||||
import { TicketService } from '../common/ticket.service';
|
import { TicketService } from '../common/ticket.service';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
@@ -28,7 +27,6 @@ export class AdminTicketsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly ticketService: TicketService,
|
private readonly ticketService: TicketService,
|
||||||
private readonly tradeService: TradeService,
|
private readonly tradeService: TradeService,
|
||||||
private readonly benefitService: BenefitService,
|
|
||||||
private readonly partnerCityService: PartnerCityService,
|
private readonly partnerCityService: PartnerCityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -123,32 +121,8 @@ export class AdminTicketsService {
|
|||||||
return { order, warehouse };
|
return { order, warehouse };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async executeRefund(orderId: bigint, remark: string) {
|
private async executeRefund(orderId: bigint, ticketId: bigint, remark: string) {
|
||||||
await this.prisma.order.update({
|
await this.tradeService.initiateRefund(orderId, ticketId, remark, 'HQ');
|
||||||
where: { id: orderId },
|
|
||||||
data: { status: 'REFUNDED', payStatus: 'REFUNDED' },
|
|
||||||
});
|
|
||||||
await this.benefitService.voidCouponsOnRefund(orderId);
|
|
||||||
await this.prisma.logThirdParty.create({
|
|
||||||
data: {
|
|
||||||
provider: 'WECHAT_REFUND',
|
|
||||||
scene: 'ORDER_REFUND',
|
|
||||||
refType: 'ORDER',
|
|
||||||
refId: orderId,
|
|
||||||
status: 'SUCCESS',
|
|
||||||
amount: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.prisma.commonEvent.create({
|
|
||||||
data: {
|
|
||||||
eventType: 'ORDER_STATUS',
|
|
||||||
refType: 'ORDER',
|
|
||||||
refId: orderId,
|
|
||||||
actorType: 'HQ',
|
|
||||||
status: 'REFUNDED',
|
|
||||||
remark,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async executeReship(orderId: bigint) {
|
private async executeReship(orderId: bigint) {
|
||||||
@@ -169,7 +143,7 @@ export class AdminTicketsService {
|
|||||||
|
|
||||||
// 仅退款:立即退款
|
// 仅退款:立即退款
|
||||||
if (ticket.ticketType === 'REFUND') {
|
if (ticket.ticketType === 'REFUND') {
|
||||||
await this.executeRefund(ticket.refId, remark ?? '仅退款工单审批通过');
|
await this.executeRefund(ticket.refId, ticket.id, remark ?? '仅退款工单审批通过');
|
||||||
return this.ticketService.updateExtraJson(
|
return this.ticketService.updateExtraJson(
|
||||||
id,
|
id,
|
||||||
this.appendLog(existingExtra, 'HQ', actorId, 'APPROVE_REFUND'),
|
this.appendLog(existingExtra, 'HQ', actorId, 'APPROVE_REFUND'),
|
||||||
@@ -285,7 +259,7 @@ export class AdminTicketsService {
|
|||||||
await this.executeReship(ticket.refId);
|
await this.executeReship(ticket.refId);
|
||||||
extra = this.appendLog(extra, opts.actorType, opts.actorId, 'CONFIRM_RESHIP');
|
extra = this.appendLog(extra, opts.actorType, opts.actorId, 'CONFIRM_RESHIP');
|
||||||
} else if (ticket.ticketType === 'DAMAGE_RETURN' || ticket.ticketType === 'RETURN_REFUND') {
|
} else if (ticket.ticketType === 'DAMAGE_RETURN' || ticket.ticketType === 'RETURN_REFUND') {
|
||||||
await this.executeRefund(ticket.refId, `${ticket.ticketType} 协同取回后完成退款`);
|
await this.executeRefund(ticket.refId, ticket.id, `${ticket.ticketType} 协同取回后完成退款`);
|
||||||
extra = this.appendLog(extra, opts.actorType, opts.actorId, 'CONFIRM_PICKUP_REFUND');
|
extra = this.appendLog(extra, opts.actorType, opts.actorId, 'CONFIRM_PICKUP_REFUND');
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestException('工单类型不支持协同完成');
|
throw new BadRequestException('工单类型不支持协同完成');
|
||||||
|
|||||||
@@ -529,6 +529,177 @@ export class TradeService {
|
|||||||
return { orderId: order.id.toString(), alreadyPaid: false };
|
return { orderId: order.id.toString(), alreadyPaid: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 总部审批退款 / 协同取回后退款:发起微信原路退(Mock 同步完成) */
|
||||||
|
async initiateRefund(
|
||||||
|
orderId: bigint,
|
||||||
|
ticketId: bigint,
|
||||||
|
remark: string,
|
||||||
|
actorType = 'HQ',
|
||||||
|
) {
|
||||||
|
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||||
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
|
|
||||||
|
if (order.payStatus === 'REFUNDED' || order.status === 'REFUNDED') {
|
||||||
|
return { orderId: order.id.toString(), alreadyRefunded: true };
|
||||||
|
}
|
||||||
|
if (order.payStatus === 'REFUNDING' || order.status === 'REFUNDING') {
|
||||||
|
throw new BadRequestException('订单已在退款处理中');
|
||||||
|
}
|
||||||
|
if (order.payStatus !== 'PAID') {
|
||||||
|
throw new BadRequestException('订单未支付,无法退款');
|
||||||
|
}
|
||||||
|
|
||||||
|
const outRefundNo = `RF-${order.orderNo}-${ticketId}`;
|
||||||
|
const pendingLog = await this.prisma.logThirdParty.findFirst({
|
||||||
|
where: {
|
||||||
|
provider: 'WECHAT_REFUND',
|
||||||
|
externalNo: outRefundNo,
|
||||||
|
status: { in: ['PENDING', 'SUCCESS'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (pendingLog?.status === 'SUCCESS') {
|
||||||
|
return { orderId: order.id.toString(), alreadyRefunded: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromStatus = order.status;
|
||||||
|
await this.prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: { status: 'REFUNDING', payStatus: 'REFUNDING' },
|
||||||
|
});
|
||||||
|
await this.prisma.commonEvent.create({
|
||||||
|
data: buildOrderStatusEvent({
|
||||||
|
orderId,
|
||||||
|
fromStatus,
|
||||||
|
toStatus: 'REFUNDING',
|
||||||
|
operator: actorType,
|
||||||
|
remark,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let refundResult;
|
||||||
|
try {
|
||||||
|
refundResult = await this.payProvider.refundOrder(orderId, outRefundNo, remark);
|
||||||
|
} catch (err) {
|
||||||
|
this.alert.notify({
|
||||||
|
level: 'P0',
|
||||||
|
category: 'pay',
|
||||||
|
title: '微信退款发起失败',
|
||||||
|
detail: `订单 ${order.orderNo}\n${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
dedupeKey: `refund_init_fail|${order.orderNo}`,
|
||||||
|
dedupeTtlSec: 120,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refundResult.mode === 'mock' || refundResult.status === 'SUCCESS') {
|
||||||
|
await this.handleRefundSuccess({
|
||||||
|
outRefundNo,
|
||||||
|
refundId: refundResult.mode === 'wechat' ? refundResult.refundId : `MOCK-REF-${outRefundNo}`,
|
||||||
|
amountFen: Math.round(Number(order.payAmount) * 100),
|
||||||
|
remark,
|
||||||
|
actorType,
|
||||||
|
});
|
||||||
|
return { orderId: order.id.toString(), alreadyRefunded: false, completed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
provider: 'WECHAT_REFUND',
|
||||||
|
scene: 'ORDER_REFUND',
|
||||||
|
refType: 'ORDER',
|
||||||
|
refId: orderId,
|
||||||
|
externalNo: refundResult.refundId ?? outRefundNo,
|
||||||
|
status: 'PENDING',
|
||||||
|
amount: order.payAmount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { orderId: order.id.toString(), alreadyRefunded: false, completed: false, outRefundNo };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 微信退款回调 / Mock 同步:幂等更新订单为已退款并作废权益 */
|
||||||
|
async handleRefundSuccess(params: {
|
||||||
|
outRefundNo: string;
|
||||||
|
refundId: string;
|
||||||
|
amountFen: number;
|
||||||
|
remark?: string;
|
||||||
|
actorType?: string;
|
||||||
|
}) {
|
||||||
|
const existingLog = await this.prisma.logThirdParty.findFirst({
|
||||||
|
where: {
|
||||||
|
provider: 'WECHAT_REFUND',
|
||||||
|
externalNo: params.refundId,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existingLog) {
|
||||||
|
return {
|
||||||
|
orderId: existingLog.refId?.toString() ?? '',
|
||||||
|
alreadyRefunded: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = params.outRefundNo.match(/^RF-(.+)-(\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new BadRequestException('退款单号格式无效');
|
||||||
|
}
|
||||||
|
const [, orderNo] = match;
|
||||||
|
const order = await this.prisma.order.findUnique({ where: { orderNo } });
|
||||||
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
|
|
||||||
|
if (order.payStatus === 'REFUNDED') {
|
||||||
|
return { orderId: order.id.toString(), alreadyRefunded: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedFen = Math.round(Number(order.payAmount) * 100);
|
||||||
|
if (params.amountFen > 0 && params.amountFen !== expectedFen) {
|
||||||
|
this.alert.notify({
|
||||||
|
level: 'P0',
|
||||||
|
category: 'pay',
|
||||||
|
title: '退款金额不一致',
|
||||||
|
detail: `订单 ${order.orderNo}\n期望 ${expectedFen} 分,回调 ${params.amountFen} 分`,
|
||||||
|
dedupeKey: `refund_amount_mismatch|${order.orderNo}`,
|
||||||
|
});
|
||||||
|
throw new BadRequestException('退款金额与订单不符');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromStatus = order.status;
|
||||||
|
const operator = params.actorType ?? 'WECHAT_REFUND';
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.order.findUnique({ where: { id: order.id } });
|
||||||
|
if (!current || current.payStatus === 'REFUNDED') return;
|
||||||
|
|
||||||
|
await tx.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { status: 'REFUNDED', payStatus: 'REFUNDED' },
|
||||||
|
});
|
||||||
|
await tx.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
provider: 'WECHAT_REFUND',
|
||||||
|
scene: 'ORDER_REFUND',
|
||||||
|
refType: 'ORDER',
|
||||||
|
refId: order.id,
|
||||||
|
externalNo: params.refundId,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
amount: order.payAmount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.commonEvent.create({
|
||||||
|
data: buildOrderStatusEvent({
|
||||||
|
orderId: order.id,
|
||||||
|
fromStatus,
|
||||||
|
toStatus: 'REFUNDED',
|
||||||
|
operator,
|
||||||
|
remark: params.remark ?? '退款成功',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.benefitService.voidCouponsOnRefund(order.id);
|
||||||
|
|
||||||
|
return { orderId: order.id.toString(), alreadyRefunded: false };
|
||||||
|
}
|
||||||
|
|
||||||
async listOrders(userId: bigint, tab = 'all', page = 1, pageSize = 20) {
|
async listOrders(userId: bigint, tab = 'all', page = 1, pageSize = 20) {
|
||||||
const statuses = orderTabToStatuses(tab);
|
const statuses = orderTabToStatuses(tab);
|
||||||
const where = {
|
const where = {
|
||||||
|
|||||||
Reference in New Issue
Block a user