feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../integrations/integrations.module';
|
||||
import { TradeModule } from '../modules/trade/trade.module';
|
||||
import { PrismaModule } from '../common/prisma/prisma.module';
|
||||
import { WechatPayCallbackController } from './wechat-pay.controller';
|
||||
import { WechatRefundCallbackController } from './wechat-refund.controller';
|
||||
import { WechatMessageCallbackController } from './wechat-message.controller';
|
||||
import { DeliveryCallbackController } from './delivery-track.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, TradeModule, PrismaModule],
|
||||
controllers: [
|
||||
WechatPayCallbackController,
|
||||
WechatRefundCallbackController,
|
||||
WechatMessageCallbackController,
|
||||
DeliveryCallbackController,
|
||||
],
|
||||
})
|
||||
export class CallbacksModule {}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { TradeService } from '../modules/trade/trade.service';
|
||||
import { CourierService } from '../integrations/courier/courier.service';
|
||||
import { PrismaService } from '../common/prisma/prisma.module';
|
||||
import { logCourierCall } from '../integrations/courier/courier-log.util';
|
||||
|
||||
@Controller('callbacks/delivery')
|
||||
export class DeliveryCallbackController {
|
||||
constructor(
|
||||
private readonly tradeService: TradeService,
|
||||
private readonly courier: CourierService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Post('track')
|
||||
async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) {
|
||||
const baseLog = {
|
||||
scene: 'TRACK_CALLBACK',
|
||||
requestUrl: '/api/v1/callbacks/delivery/track',
|
||||
requestBody: body as Record<string, unknown>,
|
||||
};
|
||||
|
||||
if (!body.orderId && !body.orderNo) {
|
||||
await logCourierCall(this.prisma, {
|
||||
...baseLog,
|
||||
status: 'FAILED',
|
||||
errorMessage: '缺少 orderId / orderNo',
|
||||
});
|
||||
return this.courier.buildTrackCallbackResponse(false);
|
||||
}
|
||||
|
||||
const order = body.orderId
|
||||
? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } })
|
||||
: await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } });
|
||||
|
||||
if (!order) {
|
||||
await logCourierCall(this.prisma, {
|
||||
...baseLog,
|
||||
status: 'FAILED',
|
||||
errorMessage: '订单不存在',
|
||||
externalNo: body.orderNo,
|
||||
});
|
||||
return this.courier.buildTrackCallbackResponse(false);
|
||||
}
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
SHIPPED: 'SHIPPING',
|
||||
OUT_WAREHOUSE: 'OUT_WAREHOUSE',
|
||||
DELIVERED: 'COMPLETED',
|
||||
COMPLETED: 'COMPLETED',
|
||||
};
|
||||
const target = statusMap[body.status ?? ''] ?? body.status;
|
||||
let applied = false;
|
||||
if (target && target !== order.status) {
|
||||
await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK');
|
||||
applied = true;
|
||||
}
|
||||
|
||||
const response = this.courier.buildTrackCallbackResponse(true);
|
||||
await logCourierCall(this.prisma, {
|
||||
...baseLog,
|
||||
responseBody: response,
|
||||
status: 'SUCCESS',
|
||||
externalNo: order.orderNo,
|
||||
ref: { refType: 'ORDER', refId: order.id },
|
||||
errorMessage: applied ? undefined : '状态未变更',
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
decryptWechatEncrypt,
|
||||
normalizeTradeManageEvent,
|
||||
parseWechatPushBody,
|
||||
verifyWechatMsgSignature,
|
||||
verifyWechatUrlSignature,
|
||||
} from '../integrations/wechat/wechat-msg-crypto.util';
|
||||
import { WechatTradeManageService } from '../integrations/wechat/wechat-trade-manage.service';
|
||||
|
||||
/**
|
||||
* 小程序消息推送(发货信息管理事件)。
|
||||
* 后台配置 URL:https://{host}/api/v1/callbacks/wechat/message
|
||||
* 需配置 WX_MINI_MSG_TOKEN;安全模式另需 WX_MINI_MSG_AES_KEY。
|
||||
*/
|
||||
@Controller('callbacks/wechat')
|
||||
export class WechatMessageCallbackController {
|
||||
private readonly logger = new Logger(WechatMessageCallbackController.name);
|
||||
|
||||
constructor(private readonly tradeManage: WechatTradeManageService) {}
|
||||
|
||||
@Get('message')
|
||||
verify(
|
||||
@Query('signature') signature: string,
|
||||
@Query('timestamp') timestamp: string,
|
||||
@Query('nonce') nonce: string,
|
||||
@Query('echostr') echostr: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const token = (process.env.WX_MINI_MSG_TOKEN || '').trim();
|
||||
if (!token) {
|
||||
this.logger.error('WX_MINI_MSG_TOKEN 未配置,无法完成消息推送 URL 验证');
|
||||
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('msg token not configured');
|
||||
}
|
||||
if (!verifyWechatUrlSignature(token, timestamp, nonce, signature)) {
|
||||
this.logger.warn('wechat message URL verify failed');
|
||||
return res.status(HttpStatus.FORBIDDEN).send('invalid signature');
|
||||
}
|
||||
return res.status(HttpStatus.OK).send(echostr ?? '');
|
||||
}
|
||||
|
||||
@Post('message')
|
||||
async receive(
|
||||
@Req() req: Request & { rawBody?: Buffer },
|
||||
@Query('signature') signature: string,
|
||||
@Query('timestamp') timestamp: string,
|
||||
@Query('nonce') nonce: string,
|
||||
@Query('msg_signature') msgSignature: string,
|
||||
@Query('encrypt_type') encryptType: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const token = (process.env.WX_MINI_MSG_TOKEN || '').trim();
|
||||
if (!token) {
|
||||
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('msg token not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
const raw =
|
||||
req.rawBody?.toString('utf8') ??
|
||||
(typeof req.body === 'string'
|
||||
? req.body
|
||||
: Buffer.isBuffer(req.body)
|
||||
? req.body.toString('utf8')
|
||||
: JSON.stringify(req.body ?? {}));
|
||||
|
||||
let body = parseWechatPushBody(raw);
|
||||
const encrypt = typeof body.Encrypt === 'string' ? body.Encrypt : undefined;
|
||||
const secure = encryptType === 'aes' || !!encrypt;
|
||||
|
||||
if (secure) {
|
||||
if (!encrypt) {
|
||||
return res.status(HttpStatus.BAD_REQUEST).send('missing Encrypt');
|
||||
}
|
||||
const aesKey = (process.env.WX_MINI_MSG_AES_KEY || '').trim();
|
||||
if (!aesKey) {
|
||||
this.logger.error('安全模式推送但未配置 WX_MINI_MSG_AES_KEY');
|
||||
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('aes key not configured');
|
||||
}
|
||||
if (
|
||||
!verifyWechatMsgSignature(token, timestamp, nonce, encrypt, msgSignature || String(body.MsgSignature || ''))
|
||||
) {
|
||||
return res.status(HttpStatus.FORBIDDEN).send('invalid msg_signature');
|
||||
}
|
||||
const appId = (process.env.WX_MINI_APP_ID || process.env.WX_APP_ID || '').trim();
|
||||
const plain = decryptWechatEncrypt(encrypt, aesKey, appId || undefined);
|
||||
body = parseWechatPushBody(plain);
|
||||
} else if (!verifyWechatUrlSignature(token, timestamp, nonce, signature)) {
|
||||
return res.status(HttpStatus.FORBIDDEN).send('invalid signature');
|
||||
}
|
||||
|
||||
// 云托管探活
|
||||
if (String(body.action || '') === 'CheckContainerPath') {
|
||||
return res.status(HttpStatus.OK).send('success');
|
||||
}
|
||||
|
||||
const evt = normalizeTradeManageEvent(body);
|
||||
if (evt.event) {
|
||||
await this.tradeManage.handleEvent(evt);
|
||||
}
|
||||
return res.status(HttpStatus.OK).send('success');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`wechat message push failed: ${message}`);
|
||||
// 仍回 success,避免微信疯狂重试;错误已记日志
|
||||
return res.status(HttpStatus.OK).send('success');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Controller, Headers, Inject, Post, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { TradeService } from '../modules/trade/trade.service';
|
||||
import { WECHAT_PROVIDER } from '../integrations/integrations.constants';
|
||||
import type { IWechatProvider } from '../integrations/wechat/wechat.interface';
|
||||
import { PayRedeemAnomalyService } from '../common/alert/pay-redeem-anomaly.service';
|
||||
import { AlertService } from '../common/alert/alert.service';
|
||||
|
||||
type RawBodyRequest = Request & { body: Buffer };
|
||||
|
||||
@Controller('callbacks/wechat')
|
||||
export class WechatPayCallbackController {
|
||||
constructor(
|
||||
private readonly tradeService: TradeService,
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
|
||||
private readonly alert: AlertService,
|
||||
) {}
|
||||
|
||||
@Post('pay')
|
||||
async payNotify(
|
||||
@Req() req: RawBodyRequest,
|
||||
@Headers() headers: Record<string, string | string[] | undefined>,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
let orderNo: string | undefined;
|
||||
try {
|
||||
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.parsePayNotification(headers, rawBody);
|
||||
orderNo = notify.outTradeNo;
|
||||
await this.tradeService.handlePaySuccess({
|
||||
orderNo: notify.outTradeNo,
|
||||
transactionId: notify.transactionId,
|
||||
amountFen: notify.amountFen,
|
||||
});
|
||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '处理失败';
|
||||
this.payRedeemAnomaly.onPayFail(message, { orderNo });
|
||||
this.alert.notify({
|
||||
level: 'P0',
|
||||
category: 'pay',
|
||||
title: '支付回调处理失败',
|
||||
detail: `${orderNo ? `订单 ${orderNo}\n` : ''}${message}`,
|
||||
dedupeKey: `pay_callback_fail|${orderNo ?? message.slice(0, 40)}`,
|
||||
dedupeTtlSec: 120,
|
||||
});
|
||||
return res.status(500).json({ code: 'FAIL', message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Controller, Headers, Inject, Post, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
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 tradeService: TradeService,
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||
private readonly alert: AlertService,
|
||||
) {}
|
||||
|
||||
@Post('refund')
|
||||
async refundNotify(
|
||||
@Req() req: RawBodyRequest,
|
||||
@Headers() headers: Record<string, string | string[] | undefined>,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
let outRefundNo: string | undefined;
|
||||
try {
|
||||
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',
|
||||
});
|
||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '处理失败';
|
||||
this.alert.notify({
|
||||
level: 'P0',
|
||||
category: 'pay',
|
||||
title: '退款回调处理失败',
|
||||
detail: `${outRefundNo ? `退款单 ${outRefundNo}\n` : ''}${message}`,
|
||||
dedupeKey: `refund_callback_fail|${outRefundNo ?? message.slice(0, 40)}`,
|
||||
dedupeTtlSec: 120,
|
||||
});
|
||||
return res.status(500).json({ code: 'FAIL', message });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user