对接微信支付
This commit is contained in:
@@ -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 { 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();
|
||||
|
||||
@@ -7,6 +7,14 @@ export class WechatDisabledProvider implements IWechatProvider {
|
||||
return false;
|
||||
}
|
||||
|
||||
isPayEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getMchId() {
|
||||
return '';
|
||||
}
|
||||
|
||||
private disabled(): never {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
@@ -34,4 +42,8 @@ export class WechatDisabledProvider implements IWechatProvider {
|
||||
createJsapiPrepay() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
parsePayNotification() {
|
||||
return this.disabled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,22 @@ export type WechatOAuthSession = {
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
export type WechatPayNotifyResult = {
|
||||
transactionId: string;
|
||||
outTradeNo: string;
|
||||
tradeState: string;
|
||||
amountFen: number;
|
||||
};
|
||||
|
||||
export interface IWechatProvider {
|
||||
isEnabled(): boolean;
|
||||
|
||||
/** 微信支付是否已配置(商户号 + 证书) */
|
||||
isPayEnabled(): boolean;
|
||||
|
||||
/** 当前商户号(用于日志/排查) */
|
||||
getMchId(): string;
|
||||
|
||||
/** 小程序 code2session */
|
||||
code2Session(code: string): Promise<WechatCodeSession>;
|
||||
|
||||
@@ -32,7 +45,7 @@ export interface IWechatProvider {
|
||||
/** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */
|
||||
getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise<string>;
|
||||
|
||||
/** 创建 JSAPI 预支付参数 */
|
||||
/** 创建 JSAPI 预支付参数(使用 WX_MCH_ID 统一下单) */
|
||||
createJsapiPrepay(params: {
|
||||
orderNo: string;
|
||||
description: string;
|
||||
@@ -40,4 +53,10 @@ export interface IWechatProvider {
|
||||
openId: string;
|
||||
notifyUrl: string;
|
||||
}): Promise<WechatJsapiPrepayParams>;
|
||||
|
||||
/** 解析并验签支付回调通知 */
|
||||
parsePayNotification(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
rawBody: string,
|
||||
): Promise<WechatPayNotifyResult>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user