对接微信支付
This commit is contained in:
@@ -6,6 +6,19 @@ import { request } from '../lib/api';
|
|||||||
import { buildOrderConfirmUrl } from '../lib/navigation';
|
import { buildOrderConfirmUrl } from '../lib/navigation';
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitOrderPaid(orderId: string, maxAttempts = 15) {
|
||||||
|
for (let i = 0; i < maxAttempts; i += 1) {
|
||||||
|
const order = await request<{ payStatus?: string }>('USER_H5', `/trade/orders/${orderId}`);
|
||||||
|
if (order.payStatus === 'PAID') return true;
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PayPage() {
|
export default function PayPage() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const orderId = params.get('orderId') || '';
|
const orderId = params.get('orderId') || '';
|
||||||
@@ -35,6 +48,10 @@ export default function PayPage() {
|
|||||||
setMockMode(false);
|
setMockMode(false);
|
||||||
await weixinSdk.init();
|
await weixinSdk.init();
|
||||||
await weixinSdk.pay(result.prepay);
|
await weixinSdk.pay(result.prepay);
|
||||||
|
const paid = await waitOrderPaid(orderId);
|
||||||
|
if (!paid) {
|
||||||
|
alert('支付结果确认中,请稍后在订单列表查看');
|
||||||
|
}
|
||||||
navigate('/orders?tab=pending_ship');
|
navigate('/orders?tab=pending_ship');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -60,7 +77,7 @@ export default function PayPage() {
|
|||||||
<p className="text-muted body-md" style={{ marginTop: 8 }}>
|
<p className="text-muted body-md" style={{ marginTop: 8 }}>
|
||||||
{mockMode
|
{mockMode
|
||||||
? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台'
|
? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台'
|
||||||
: '请在微信内完成支付'}
|
: '请在微信内完成支付,支付成功后自动跳转'}
|
||||||
</p>
|
</p>
|
||||||
<p className="label-md text-muted" style={{ marginTop: 24 }}>订单号 {orderId}</p>
|
<p className="label-md text-muted" style={{ marginTop: 24 }}>订单号 {orderId}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface AppConfig {
|
|||||||
wechatAuthEnabled: boolean;
|
wechatAuthEnabled: boolean;
|
||||||
wechatPayEnabled: boolean;
|
wechatPayEnabled: boolean;
|
||||||
wxAppId: string;
|
wxAppId: string;
|
||||||
|
wxMchId: string;
|
||||||
/** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */
|
/** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */
|
||||||
ossEnabled: boolean;
|
ossEnabled: boolean;
|
||||||
}
|
}
|
||||||
@@ -25,6 +26,7 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
|
|||||||
wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true',
|
wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true',
|
||||||
wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false',
|
wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false',
|
||||||
wxAppId: e.WX_APP_ID ?? '',
|
wxAppId: e.WX_APP_ID ?? '',
|
||||||
|
wxMchId: e.WX_MCH_ID ?? '',
|
||||||
ossEnabled: e.OSS_ENABLED === 'true',
|
ossEnabled: e.OSS_ENABLED === 'true',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ WX_MCH_ID=
|
|||||||
WX_MCH_SERIAL_NO=
|
WX_MCH_SERIAL_NO=
|
||||||
WX_MCH_PRIVATE_KEY=
|
WX_MCH_PRIVATE_KEY=
|
||||||
WX_API_V3_KEY=
|
WX_API_V3_KEY=
|
||||||
|
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
|
||||||
|
WX_PLATFORM_CERT=
|
||||||
WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay
|
WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay
|
||||||
|
|
||||||
# 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
|
# 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { AnalyticsModule } from './modules/analytics/analytics.module';
|
|||||||
import { JobsModule } from './jobs/jobs.module';
|
import { JobsModule } from './jobs/jobs.module';
|
||||||
import { OpsModule } from './modules/ops/ops.module';
|
import { OpsModule } from './modules/ops/ops.module';
|
||||||
import { CommonModule } from './modules/common/common.module';
|
import { CommonModule } from './modules/common/common.module';
|
||||||
|
import { CallbacksModule } from './callbacks/callbacks.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -40,6 +41,7 @@ import { CommonModule } from './modules/common/common.module';
|
|||||||
JobsModule,
|
JobsModule,
|
||||||
OpsModule,
|
OpsModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
CallbacksModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IntegrationsModule } from '../integrations/integrations.module';
|
||||||
|
import { TradeModule } from '../modules/trade/trade.module';
|
||||||
|
import { WechatPayCallbackController } from './wechat-pay.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [IntegrationsModule, TradeModule],
|
||||||
|
controllers: [WechatPayCallbackController],
|
||||||
|
})
|
||||||
|
export class CallbacksModule {}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
type RawBodyRequest = Request & { body: Buffer };
|
||||||
|
|
||||||
|
@Controller('callbacks/wechat')
|
||||||
|
export class WechatPayCallbackController {
|
||||||
|
constructor(
|
||||||
|
private readonly tradeService: TradeService,
|
||||||
|
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('pay')
|
||||||
|
async payNotify(
|
||||||
|
@Req() req: RawBodyRequest,
|
||||||
|
@Headers() headers: Record<string, string | string[] | undefined>,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
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 : '处理失败';
|
||||||
|
return res.status(500).json({ code: 'FAIL', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,10 @@ import type { IOssProvider } from './oss/oss.interface';
|
|||||||
provide: WECHAT_PROVIDER,
|
provide: WECHAT_PROVIDER,
|
||||||
useFactory: (api: WechatApiProvider, disabled: WechatDisabledProvider): IWechatProvider => {
|
useFactory: (api: WechatApiProvider, disabled: WechatDisabledProvider): IWechatProvider => {
|
||||||
const cfg = loadAppConfig();
|
const cfg = loadAppConfig();
|
||||||
return cfg.wechatAuthEnabled && cfg.wxAppId ? api : disabled;
|
const enabled =
|
||||||
|
(cfg.wechatAuthEnabled || cfg.wechatPayEnabled) &&
|
||||||
|
(!!cfg.wxAppId || !!process.env.WX_MCH_ID);
|
||||||
|
return enabled ? api : disabled;
|
||||||
},
|
},
|
||||||
inject: [WechatApiProvider, WechatDisabledProvider],
|
inject: [WechatApiProvider, WechatDisabledProvider],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger } 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 { WECHAT_PROVIDER } from '../integrations.constants';
|
import { WECHAT_PROVIDER } from '../integrations.constants';
|
||||||
@@ -7,6 +7,7 @@ import type { IPayProvider, PayOrderResult } from './pay.interface';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PayWechatProvider implements IPayProvider {
|
export class PayWechatProvider implements IPayProvider {
|
||||||
|
private readonly logger = new Logger(PayWechatProvider.name);
|
||||||
private readonly config = loadAppConfig();
|
private readonly config = loadAppConfig();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -18,17 +19,18 @@ export class PayWechatProvider implements IPayProvider {
|
|||||||
if (this.config.mockPay) {
|
if (this.config.mockPay) {
|
||||||
return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
|
return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
|
||||||
}
|
}
|
||||||
|
if (!this.wechat.isPayEnabled()) {
|
||||||
|
throw new Error('微信支付未配置:请设置 WECHAT_PAY_ENABLED=true 与 WX_MCH_ID 等商户参数');
|
||||||
|
}
|
||||||
if (!openId) {
|
if (!openId) {
|
||||||
throw new Error('微信支付需要用户 openId,请先完成微信授权登录');
|
throw new Error('微信支付需要用户 openId,请先完成微信授权登录');
|
||||||
}
|
}
|
||||||
if (!this.wechat.isEnabled()) {
|
|
||||||
throw new Error('微信能力未启用,请配置 WECHAT_AUTH_ENABLED 与 WX_APP_ID/SECRET');
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||||
if (!order) throw new Error('订单不存在');
|
if (!order) throw new Error('订单不存在');
|
||||||
|
|
||||||
const amountFen = Math.round(Number(order.payAmount) * 100);
|
const amountFen = Math.round(Number(order.payAmount) * 100);
|
||||||
|
this.logger.log(`create JSAPI prepay order=${order.orderNo} mchid=${this.wechat.getMchId()}`);
|
||||||
const prepay = await this.wechat.createJsapiPrepay({
|
const prepay = await this.wechat.createJsapiPrepay({
|
||||||
orderNo: order.orderNo,
|
orderNo: order.orderNo,
|
||||||
description: `杜康好客订单 ${order.orderNo}`,
|
description: `杜康好客订单 ${order.orderNo}`,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { createDecipheriv, createVerify, timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
|
export type WechatPayNotifyResource = {
|
||||||
|
transaction_id: string;
|
||||||
|
out_trade_no: string;
|
||||||
|
trade_state: string;
|
||||||
|
trade_state_desc?: string;
|
||||||
|
amount?: { total?: number; payer_total?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WechatPayNotifyEnvelope = {
|
||||||
|
id: string;
|
||||||
|
create_time: string;
|
||||||
|
event_type: string;
|
||||||
|
resource_type: string;
|
||||||
|
summary: string;
|
||||||
|
resource: {
|
||||||
|
algorithm: string;
|
||||||
|
ciphertext: string;
|
||||||
|
associated_data?: string;
|
||||||
|
nonce: string;
|
||||||
|
original_type?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function decryptPayResource(
|
||||||
|
apiV3Key: string,
|
||||||
|
associatedData: string,
|
||||||
|
nonce: string,
|
||||||
|
ciphertext: string,
|
||||||
|
): WechatPayNotifyResource {
|
||||||
|
const key = Buffer.from(apiV3Key, 'utf8');
|
||||||
|
const buf = Buffer.from(ciphertext, 'base64');
|
||||||
|
const authTag = buf.subarray(buf.length - 16);
|
||||||
|
const data = buf.subarray(0, buf.length - 16);
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(nonce, 'utf8'));
|
||||||
|
if (associatedData) {
|
||||||
|
decipher.setAAD(Buffer.from(associatedData, 'utf8'));
|
||||||
|
}
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
const decoded = Buffer.concat([decipher.update(data), decipher.final()]);
|
||||||
|
return JSON.parse(decoded.toString('utf8')) as WechatPayNotifyResource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPaySignature(params: {
|
||||||
|
platformPublicKeyPem: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce: string;
|
||||||
|
body: string;
|
||||||
|
signature: string;
|
||||||
|
}): boolean {
|
||||||
|
const message = `${params.timestamp}\n${params.nonce}\n${params.body}\n`;
|
||||||
|
const verifier = createVerify('RSA-SHA256');
|
||||||
|
verifier.update(message);
|
||||||
|
verifier.end();
|
||||||
|
const ok = verifier.verify(params.platformPublicKeyPem, params.signature, 'base64');
|
||||||
|
if (!ok) return false;
|
||||||
|
const ts = Number(params.timestamp);
|
||||||
|
if (!Number.isFinite(ts)) return false;
|
||||||
|
const skewMs = Math.abs(Date.now() - ts * 1000);
|
||||||
|
return skewMs <= 5 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeEqual(a: string, b: string): boolean {
|
||||||
|
const ba = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
if (ba.length !== bb.length) return false;
|
||||||
|
return timingSafeEqual(ba, bb);
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { createDecipheriv, createHash, createSign, randomBytes, randomUUID } from 'crypto';
|
import { createDecipheriv, createHash, createSign, randomBytes, randomUUID } from 'crypto';
|
||||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||||
import { loadAppConfig } from '@dukang/shared-types';
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
import { RedisService } from '../../common/redis/redis.service';
|
import { RedisService } from '../../common/redis/redis.service';
|
||||||
import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
|
import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
|
||||||
|
import {
|
||||||
|
decryptPayResource,
|
||||||
|
verifyPaySignature,
|
||||||
|
type WechatPayNotifyEnvelope,
|
||||||
|
} from './wechat-pay.util';
|
||||||
|
|
||||||
type TokenCache = { accessToken: string; expiresAt: number };
|
type TokenCache = { accessToken: string; expiresAt: number };
|
||||||
type TicketCache = { ticket: string; expiresAt: number };
|
type TicketCache = { ticket: string; expiresAt: number };
|
||||||
@@ -21,6 +26,7 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n');
|
private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n');
|
||||||
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 platformCert = (process.env.WX_PLATFORM_CERT ?? '').replace(/\\n/g, '\n');
|
||||||
|
|
||||||
constructor(private readonly redis: RedisService) {}
|
constructor(private readonly redis: RedisService) {}
|
||||||
|
|
||||||
@@ -28,6 +34,21 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret;
|
return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isPayEnabled() {
|
||||||
|
return (
|
||||||
|
this.config.wechatPayEnabled &&
|
||||||
|
!!this.appId &&
|
||||||
|
!!this.mchId &&
|
||||||
|
!!this.mchSerialNo &&
|
||||||
|
!!this.mchPrivateKey &&
|
||||||
|
!!this.apiV3Key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMchId() {
|
||||||
|
return this.mchId;
|
||||||
|
}
|
||||||
|
|
||||||
buildOAuthUrl(redirectUri: string, state: string, scope = 'snsapi_userinfo') {
|
buildOAuthUrl(redirectUri: string, state: string, scope = 'snsapi_userinfo') {
|
||||||
const qs = new URLSearchParams({
|
const qs = new URLSearchParams({
|
||||||
appid: this.appId,
|
appid: this.appId,
|
||||||
@@ -131,10 +152,15 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
openId: string;
|
openId: string;
|
||||||
notifyUrl: string;
|
notifyUrl: string;
|
||||||
}) {
|
}) {
|
||||||
if (!this.mchId || !this.mchPrivateKey || !this.apiV3Key) {
|
if (!this.isPayEnabled()) {
|
||||||
throw new InternalServerErrorException('微信支付商户配置不完整');
|
throw new InternalServerErrorException(
|
||||||
|
'微信支付未配置:请设置 WECHAT_PAY_ENABLED=true、WX_MCH_ID、WX_MCH_SERIAL_NO、WX_MCH_PRIVATE_KEY、WX_API_V3_KEY',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const notifyUrl = params.notifyUrl || this.notifyUrl;
|
const notifyUrl = params.notifyUrl || this.notifyUrl;
|
||||||
|
if (!notifyUrl) {
|
||||||
|
throw new InternalServerErrorException('请配置 WX_PAY_NOTIFY_URL');
|
||||||
|
}
|
||||||
const body = {
|
const body = {
|
||||||
appid: this.appId,
|
appid: this.appId,
|
||||||
mchid: this.mchId,
|
mchid: this.mchId,
|
||||||
@@ -147,7 +173,9 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
const path = '/v3/pay/transactions/jsapi';
|
const path = '/v3/pay/transactions/jsapi';
|
||||||
const payload = JSON.stringify(body);
|
const payload = JSON.stringify(body);
|
||||||
const auth = this.signPayRequest('POST', path, payload);
|
const auth = this.signPayRequest('POST', path, payload);
|
||||||
const res = await this.fetchJson<{ prepay_id?: string }>(`https://api.mch.weixin.qq.com${path}`, {
|
const res = await this.fetchPayJson<{ prepay_id?: string }>(
|
||||||
|
`https://api.mch.weixin.qq.com${path}`,
|
||||||
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -155,10 +183,12 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
Authorization: auth,
|
Authorization: auth,
|
||||||
},
|
},
|
||||||
body: payload,
|
body: payload,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
if (!res.prepay_id) {
|
if (!res.prepay_id) {
|
||||||
throw new InternalServerErrorException('微信预支付下单失败');
|
throw new InternalServerErrorException('微信预支付下单失败');
|
||||||
}
|
}
|
||||||
|
this.logger.log(`JSAPI prepay ok mchid=${this.mchId} orderNo=${params.orderNo}`);
|
||||||
const timeStamp = String(Math.floor(Date.now() / 1000));
|
const timeStamp = String(Math.floor(Date.now() / 1000));
|
||||||
const nonceStr = randomUUID().replace(/-/g, '');
|
const nonceStr = randomUUID().replace(/-/g, '');
|
||||||
const packageStr = `prepay_id=${res.prepay_id}`;
|
const packageStr = `prepay_id=${res.prepay_id}`;
|
||||||
@@ -177,6 +207,61 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async parsePayNotification(
|
||||||
|
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 !== 'TRANSACTION.SUCCESS') {
|
||||||
|
throw new BadRequestException(`忽略的事件类型: ${envelope.event_type}`);
|
||||||
|
}
|
||||||
|
const resource = decryptPayResource(
|
||||||
|
this.apiV3Key,
|
||||||
|
envelope.resource.associated_data ?? '',
|
||||||
|
envelope.resource.nonce,
|
||||||
|
envelope.resource.ciphertext,
|
||||||
|
);
|
||||||
|
if (resource.trade_state !== 'SUCCESS') {
|
||||||
|
throw new BadRequestException(`交易未成功: ${resource.trade_state}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
transactionId: resource.transaction_id,
|
||||||
|
outTradeNo: resource.out_trade_no,
|
||||||
|
tradeState: resource.trade_state,
|
||||||
|
amountFen: resource.amount?.total ?? resource.amount?.payer_total ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private headerValue(headers: Record<string, string | string[] | undefined>, key: string) {
|
||||||
|
const raw = headers[key] ?? headers[key.toLowerCase()];
|
||||||
|
if (Array.isArray(raw)) return raw[0];
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
private async getAccessToken(): Promise<string> {
|
private async getAccessToken(): Promise<string> {
|
||||||
const cached = await this.redis.getJson<TokenCache>(ACCESS_TOKEN_KEY);
|
const cached = await this.redis.getJson<TokenCache>(ACCESS_TOKEN_KEY);
|
||||||
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
|
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
|
||||||
@@ -234,6 +319,24 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
return `WECHATPAY2-SHA256-RSA2048 mchid="${this.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${this.mchSerialNo}"`;
|
return `WECHATPAY2-SHA256-RSA2048 mchid="${this.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${this.mchSerialNo}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchPayJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
const text = await res.text();
|
||||||
|
let data: T & { code?: string; message?: string };
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text) as T & { code?: string; message?: string };
|
||||||
|
} catch {
|
||||||
|
this.logger.error(`WeChat Pay invalid JSON (${res.status}): ${text.slice(0, 300)}`);
|
||||||
|
throw new InternalServerErrorException('微信支付接口响应异常');
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = data.message || data.code || text.slice(0, 200);
|
||||||
|
this.logger.error(`WeChat Pay API ${res.status}: ${detail}`);
|
||||||
|
throw new InternalServerErrorException(`微信支付下单失败: ${detail}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
private async fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
private async fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(url, init);
|
const res = await fetch(url, init);
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ export class WechatDisabledProvider implements IWechatProvider {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isPayEnabled() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMchId() {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
private disabled(): never {
|
private disabled(): never {
|
||||||
throw new NotImplementedException('FEATURE_DISABLED');
|
throw new NotImplementedException('FEATURE_DISABLED');
|
||||||
}
|
}
|
||||||
@@ -34,4 +42,8 @@ export class WechatDisabledProvider implements IWechatProvider {
|
|||||||
createJsapiPrepay() {
|
createJsapiPrepay() {
|
||||||
return this.disabled();
|
return this.disabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parsePayNotification() {
|
||||||
|
return this.disabled();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,22 @@ export type WechatOAuthSession = {
|
|||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WechatPayNotifyResult = {
|
||||||
|
transactionId: string;
|
||||||
|
outTradeNo: string;
|
||||||
|
tradeState: string;
|
||||||
|
amountFen: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface IWechatProvider {
|
export interface IWechatProvider {
|
||||||
isEnabled(): boolean;
|
isEnabled(): boolean;
|
||||||
|
|
||||||
|
/** 微信支付是否已配置(商户号 + 证书) */
|
||||||
|
isPayEnabled(): boolean;
|
||||||
|
|
||||||
|
/** 当前商户号(用于日志/排查) */
|
||||||
|
getMchId(): string;
|
||||||
|
|
||||||
/** 小程序 code2session */
|
/** 小程序 code2session */
|
||||||
code2Session(code: string): Promise<WechatCodeSession>;
|
code2Session(code: string): Promise<WechatCodeSession>;
|
||||||
|
|
||||||
@@ -32,7 +45,7 @@ export interface IWechatProvider {
|
|||||||
/** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */
|
/** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */
|
||||||
getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise<string>;
|
getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise<string>;
|
||||||
|
|
||||||
/** 创建 JSAPI 预支付参数 */
|
/** 创建 JSAPI 预支付参数(使用 WX_MCH_ID 统一下单) */
|
||||||
createJsapiPrepay(params: {
|
createJsapiPrepay(params: {
|
||||||
orderNo: string;
|
orderNo: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -40,4 +53,10 @@ export interface IWechatProvider {
|
|||||||
openId: string;
|
openId: string;
|
||||||
notifyUrl: string;
|
notifyUrl: string;
|
||||||
}): Promise<WechatJsapiPrepayParams>;
|
}): Promise<WechatJsapiPrepayParams>;
|
||||||
|
|
||||||
|
/** 解析并验签支付回调通知 */
|
||||||
|
parsePayNotification(
|
||||||
|
headers: Record<string, string | string[] | undefined>,
|
||||||
|
rawBody: string,
|
||||||
|
): Promise<WechatPayNotifyResult>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { json } from 'express';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
|
||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
app.set('trust proxy', true);
|
app.set('trust proxy', true);
|
||||||
app.enableCors({ origin: true, credentials: true });
|
app.enableCors({ origin: true, credentials: true });
|
||||||
|
app.use(
|
||||||
|
json({
|
||||||
|
verify: (req, _res, buf) => {
|
||||||
|
if (req.url?.includes('/callbacks/wechat/pay')) {
|
||||||
|
(req as { rawBody?: Buffer }).rawBody = buf;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.useGlobalInterceptors(new ResponseInterceptor());
|
app.useGlobalInterceptors(new ResponseInterceptor());
|
||||||
|
|||||||
@@ -222,6 +222,87 @@ export class TradeService {
|
|||||||
return this.getOrder(userId, orderId);
|
return this.getOrder(userId, orderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 微信支付回调:幂等更新订单为已支付并发券 */
|
||||||
|
async handlePaySuccess(params: {
|
||||||
|
orderNo: string;
|
||||||
|
transactionId: string;
|
||||||
|
amountFen: number;
|
||||||
|
}) {
|
||||||
|
const order = await this.prisma.order.findUnique({ where: { orderNo: params.orderNo } });
|
||||||
|
if (!order) {
|
||||||
|
throw new NotFoundException('订单不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.payStatus === 'PAID') {
|
||||||
|
return { orderId: order.id.toString(), alreadyPaid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedFen = Math.round(Number(order.payAmount) * 100);
|
||||||
|
if (params.amountFen > 0 && params.amountFen !== expectedFen) {
|
||||||
|
throw new BadRequestException('支付金额与订单不符');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLog = await this.prisma.logThirdParty.findFirst({
|
||||||
|
where: {
|
||||||
|
provider: 'WECHAT_PAY',
|
||||||
|
externalNo: params.transactionId,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existingLog) {
|
||||||
|
return { orderId: order.id.toString(), alreadyPaid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.order.findUnique({ where: { id: order.id } });
|
||||||
|
if (!current || current.payStatus === 'PAID') return;
|
||||||
|
|
||||||
|
await tx.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: {
|
||||||
|
status: 'PENDING_SHIP',
|
||||||
|
payStatus: 'PAID',
|
||||||
|
paidAt: now,
|
||||||
|
payExternalNo: params.transactionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
provider: 'WECHAT_PAY',
|
||||||
|
scene: 'ORDER_PAY',
|
||||||
|
refType: 'ORDER',
|
||||||
|
refId: order.id,
|
||||||
|
externalNo: params.transactionId,
|
||||||
|
amount: order.payAmount,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.commonEvent.create({
|
||||||
|
data: buildOrderStatusEvent({
|
||||||
|
orderId: order.id,
|
||||||
|
fromStatus: 'PENDING_PAY',
|
||||||
|
toStatus: 'PENDING_SHIP',
|
||||||
|
operator: 'WECHAT_PAY',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||||
|
if (!delivery) {
|
||||||
|
await tx.orderDelivery.create({
|
||||||
|
data: { orderId: order.id, provider: 'MANUAL' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } });
|
||||||
|
if (refreshed?.payStatus === 'PAID') {
|
||||||
|
await this.benefitService.grantOnOrderPaid(order.id);
|
||||||
|
await this.deliveryProvider.scheduleAutoAdvance(order.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { orderId: order.id.toString(), alreadyPaid: 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