对接微信支付

This commit is contained in:
2026-07-01 22:41:49 +08:00
parent b78ef8798c
commit ff4622ff6c
14 changed files with 389 additions and 19 deletions
@@ -1,8 +1,13 @@
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 { RedisService } from '../../common/redis/redis.service';
import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
import {
decryptPayResource,
verifyPaySignature,
type WechatPayNotifyEnvelope,
} from './wechat-pay.util';
type TokenCache = { accessToken: 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 apiV3Key = process.env.WX_API_V3_KEY ?? '';
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) {}
@@ -28,6 +34,21 @@ export class WechatApiProvider implements IWechatProvider {
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') {
const qs = new URLSearchParams({
appid: this.appId,
@@ -131,10 +152,15 @@ export class WechatApiProvider implements IWechatProvider {
openId: string;
notifyUrl: string;
}) {
if (!this.mchId || !this.mchPrivateKey || !this.apiV3Key) {
throw new InternalServerErrorException('微信支付商户配置不完整');
if (!this.isPayEnabled()) {
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;
if (!notifyUrl) {
throw new InternalServerErrorException('请配置 WX_PAY_NOTIFY_URL');
}
const body = {
appid: this.appId,
mchid: this.mchId,
@@ -147,18 +173,22 @@ export class WechatApiProvider implements IWechatProvider {
const path = '/v3/pay/transactions/jsapi';
const payload = JSON.stringify(body);
const auth = this.signPayRequest('POST', path, payload);
const res = await this.fetchJson<{ prepay_id?: string }>(`https://api.mch.weixin.qq.com${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: auth,
const res = await this.fetchPayJson<{ prepay_id?: string }>(
`https://api.mch.weixin.qq.com${path}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: auth,
},
body: payload,
},
body: payload,
});
);
if (!res.prepay_id) {
throw new InternalServerErrorException('微信预支付下单失败');
}
this.logger.log(`JSAPI prepay ok mchid=${this.mchId} orderNo=${params.orderNo}`);
const timeStamp = String(Math.floor(Date.now() / 1000));
const nonceStr = randomUUID().replace(/-/g, '');
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> {
const cached = await this.redis.getJson<TokenCache>(ACCESS_TOKEN_KEY);
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}"`;
}
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> {
const res = await fetch(url, init);
const text = await res.text();