From ab1043100152d68df41eaed913aa11a522a9c5a8 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 3 Aug 2026 21:45:33 +0800 Subject: [PATCH] feat(trade): connect WeChat refund API and callback flow Co-authored-by: Cursor --- packages/shared-types/src/wechat.ts | 10 + scripts/smoke-v3.mjs | 6 + server/dukang-api/.env.example | 1 + server/dukang-api/.env.production.example | 1 + server/dukang-api/.env.staging.example | 1 + .../src/callbacks/wechat-refund.controller.ts | 52 +++--- .../system-config/system-config.registry.ts | 1 + .../src/integrations/pay/pay.interface.ts | 5 + .../src/integrations/pay/pay.mock.provider.ts | 13 +- .../integrations/pay/pay.router.provider.ts | 10 +- .../integrations/pay/pay.wechat.provider.ts | 36 ++++ .../integrations/wechat/wechat-pay.util.ts | 9 + .../wechat/wechat.api.provider.ts | 115 ++++++++++++ .../wechat/wechat.disabled.provider.ts | 8 + .../integrations/wechat/wechat.interface.ts | 23 ++- .../wechat/wechat.mock.provider.ts | 8 + .../wechat/wechat.router.provider.ts | 11 ++ server/dukang-api/src/main.ts | 1 + .../src/modules/ops/admin-tickets.service.ts | 34 +--- .../src/modules/trade/trade.service.ts | 171 ++++++++++++++++++ 20 files changed, 455 insertions(+), 61 deletions(-) diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts index f305983..4b89643 100644 --- a/packages/shared-types/src/wechat.ts +++ b/packages/shared-types/src/wechat.ts @@ -21,6 +21,16 @@ export type WechatPayOrderResult = | { mode: 'mock'; externalNo: string; order?: Record } | { 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'; diff --git a/scripts/smoke-v3.mjs b/scripts/smoke-v3.mjs index 948b3c6..7ef7775 100644 --- a/scripts/smoke-v3.mjs +++ b/scripts/smoke-v3.mjs @@ -248,6 +248,12 @@ async function main() { token: admin.accessToken, 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'); const unknownPartner = await expectFail('PARTNER_H5', '/partner/auth/phone/check', { diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index fe659b6..4b98bfc 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -55,6 +55,7 @@ WX_API_V3_KEY= # 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空) WX_PLATFORM_CERT= 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,加密方式建议安全模式 # https://api.dukanghaoke.com/api/v1/callbacks/wechat/message WX_MINI_MSG_TOKEN= diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example index edc99a6..2dcec57 100644 --- a/server/dukang-api/.env.production.example +++ b/server/dukang-api/.env.production.example @@ -44,6 +44,7 @@ WX_MCH_PRIVATE_KEY= WX_API_V3_KEY= WX_PLATFORM_CERT= 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 WX_MINI_MSG_TOKEN= WX_MINI_MSG_AES_KEY= diff --git a/server/dukang-api/.env.staging.example b/server/dukang-api/.env.staging.example index bc8b5c6..a854b97 100644 --- a/server/dukang-api/.env.staging.example +++ b/server/dukang-api/.env.staging.example @@ -46,6 +46,7 @@ WX_MCH_PRIVATE_KEY= WX_API_V3_KEY= WX_PLATFORM_CERT= 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_AES_KEY= diff --git a/server/dukang-api/src/callbacks/wechat-refund.controller.ts b/server/dukang-api/src/callbacks/wechat-refund.controller.ts index 94b033a..b4f363c 100644 --- a/server/dukang-api/src/callbacks/wechat-refund.controller.ts +++ b/server/dukang-api/src/callbacks/wechat-refund.controller.ts @@ -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 { 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'; +type RawBodyRequest = Request & { body: Buffer }; + @Controller('callbacks/wechat') export class WechatRefundCallbackController { constructor( - private readonly prisma: PrismaService, + private readonly tradeService: TradeService, + @Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider, private readonly alert: AlertService, ) {} @Post('refund') async refundNotify( - @Req() req: Request, - @Headers() _headers: Record, + @Req() req: RawBodyRequest, + @Headers() headers: Record, @Res() res: Response, ) { + let outRefundNo: string | undefined; try { - const body = typeof req.body === 'object' ? req.body : {}; - const outRefundNo = String((body as Record).out_refund_no ?? ''); - const refundId = String((body as Record).refund_id ?? outRefundNo); - - const existing = await this.prisma.logThirdParty.findFirst({ - where: { provider: 'WECHAT_REFUND', externalNo: refundId, status: 'SUCCESS' }, + const rawBody = + (req as Request & { rawBody?: Buffer }).rawBody?.toString('utf8') ?? + (typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {})); + const notify = await this.wechat.parseRefundNotification(headers, rawBody); + outRefundNo = notify.outRefundNo; + 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: '成功' }); } catch (err) { const message = err instanceof Error ? err.message : '处理失败'; @@ -46,8 +42,8 @@ export class WechatRefundCallbackController { level: 'P0', category: 'pay', title: '退款回调处理失败', - detail: message, - dedupeKey: `refund_callback_fail|${message.slice(0, 40)}`, + detail: `${outRefundNo ? `退款单 ${outRefundNo}\n` : ''}${message}`, + dedupeKey: `refund_callback_fail|${outRefundNo ?? message.slice(0, 40)}`, dedupeTtlSec: 120, }); return res.status(500).json({ code: 'FAIL', message }); diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index f094d21..8a52177 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -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_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_REFUND_NOTIFY_URL', label: '退款回调 URL', group: G.wechat, type: 'string', requiresRestart: false }, { key: 'WX_MINI_MSG_TOKEN', label: '小程序消息推送 Token', diff --git a/server/dukang-api/src/integrations/pay/pay.interface.ts b/server/dukang-api/src/integrations/pay/pay.interface.ts index ecad49c..7c65814 100644 --- a/server/dukang-api/src/integrations/pay/pay.interface.ts +++ b/server/dukang-api/src/integrations/pay/pay.interface.ts @@ -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; + refundOrder(orderId: bigint, outRefundNo: string, reason?: string): Promise; } diff --git a/server/dukang-api/src/integrations/pay/pay.mock.provider.ts b/server/dukang-api/src/integrations/pay/pay.mock.provider.ts index 9c65272..d5939c3 100644 --- a/server/dukang-api/src/integrations/pay/pay.mock.provider.ts +++ b/server/dukang-api/src/integrations/pay/pay.mock.provider.ts @@ -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 { + if (!loadAppConfig().mockPay) { + throw new Error('Real WeChat refund requires PayWechatProvider'); + } + return { mode: 'mock', outRefundNo }; + } } diff --git a/server/dukang-api/src/integrations/pay/pay.router.provider.ts b/server/dukang-api/src/integrations/pay/pay.router.provider.ts index dedd4df..21f08a4 100644 --- a/server/dukang-api/src/integrations/pay/pay.router.provider.ts +++ b/server/dukang-api/src/integrations/pay/pay.router.provider.ts @@ -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 { return this.resolve().payOrder(orderId, openId, platform, payMethod); } + + refundOrder( + orderId: bigint, + outRefundNo: string, + reason?: string, + ): Promise { + return this.resolve().refundOrder(orderId, outRefundNo, reason); + } } diff --git a/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts b/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts index e42502e..131003a 100644 --- a/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts +++ b/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts @@ -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, + }; + } } diff --git a/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts b/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts index 5848ee8..fe92e72 100644 --- a/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts +++ b/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts @@ -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; diff --git a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts index 03715a1..9a3e735 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts @@ -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 = { + 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, + 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, key: string) { const raw = headers[key] ?? headers[key.toLowerCase()]; if (Array.isArray(raw)) return raw[0]; diff --git a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts index e1625e7..d368795 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts @@ -59,6 +59,14 @@ export class WechatDisabledProvider implements IWechatProvider { return this.disabled(); } + createDomesticRefund() { + return this.disabled(); + } + + parseRefundNotification() { + return this.disabled(); + } + getWxaCodeUnlimited(_input: WechatWxaCodeUnlimitedInput): Promise { return this.disabled(); } diff --git a/server/dukang-api/src/integrations/wechat/wechat.interface.ts b/server/dukang-api/src/integrations/wechat/wechat.interface.ts index 27736dd..45afe73 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.interface.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.interface.ts @@ -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; + /** 发起国内退款(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, + rawBody: string, + ): Promise; + /** 获取不限制的小程序码(PNG Buffer),须服务端调用 */ getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise; diff --git a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts index bf9ce80..0fd74b2 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts @@ -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 { const scene = (input.scene ?? '').trim() || 'mock'; diff --git a/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts index a8ece09..7adb21a 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts @@ -96,6 +96,17 @@ export class WechatRouterProvider implements IWechatProvider { return this.resolve().parsePayNotification(headers, rawBody); } + createDomesticRefund(params: Parameters[0]) { + return this.resolve().createDomesticRefund(params); + } + + parseRefundNotification( + headers: Record, + rawBody: string, + ) { + return this.resolve().parseRefundNotification(headers, rawBody); + } + getWxaCodeUnlimited(input: Parameters[0]) { return this.resolve().getWxaCodeUnlimited(input); } diff --git a/server/dukang-api/src/main.ts b/server/dukang-api/src/main.ts index a48a55d..758c77e 100644 --- a/server/dukang-api/src/main.ts +++ b/server/dukang-api/src/main.ts @@ -28,6 +28,7 @@ async function bootstrap() { verify: (req, _res, buf) => { if ( req.url?.includes('/callbacks/wechat/pay') || + req.url?.includes('/callbacks/wechat/refund') || req.url?.includes('/callbacks/wechat/message') ) { (req as { rawBody?: Buffer }).rawBody = buf; diff --git a/server/dukang-api/src/modules/ops/admin-tickets.service.ts b/server/dukang-api/src/modules/ops/admin-tickets.service.ts index c6da3cd..0a3765a 100644 --- a/server/dukang-api/src/modules/ops/admin-tickets.service.ts +++ b/server/dukang-api/src/modules/ops/admin-tickets.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; -import { BenefitService } from '../benefit/benefit.service'; import { TradeService } from '../trade/trade.service'; import { TicketService } from '../common/ticket.service'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; @@ -28,7 +27,6 @@ export class AdminTicketsService { private readonly prisma: PrismaService, private readonly ticketService: TicketService, private readonly tradeService: TradeService, - private readonly benefitService: BenefitService, private readonly partnerCityService: PartnerCityService, ) {} @@ -123,32 +121,8 @@ export class AdminTicketsService { return { order, warehouse }; } - private async executeRefund(orderId: bigint, remark: string) { - await this.prisma.order.update({ - 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 executeRefund(orderId: bigint, ticketId: bigint, remark: string) { + await this.tradeService.initiateRefund(orderId, ticketId, remark, 'HQ'); } private async executeReship(orderId: bigint) { @@ -169,7 +143,7 @@ export class AdminTicketsService { // 仅退款:立即退款 if (ticket.ticketType === 'REFUND') { - await this.executeRefund(ticket.refId, remark ?? '仅退款工单审批通过'); + await this.executeRefund(ticket.refId, ticket.id, remark ?? '仅退款工单审批通过'); return this.ticketService.updateExtraJson( id, this.appendLog(existingExtra, 'HQ', actorId, 'APPROVE_REFUND'), @@ -285,7 +259,7 @@ export class AdminTicketsService { await this.executeReship(ticket.refId); extra = this.appendLog(extra, opts.actorType, opts.actorId, 'CONFIRM_RESHIP'); } 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'); } else { throw new BadRequestException('工单类型不支持协同完成'); diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 13a6610..bd0c393 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -529,6 +529,177 @@ export class TradeService { 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) { const statuses = orderTabToStatuses(tab); const where = {