230 lines
5.0 KiB
TypeScript
230 lines
5.0 KiB
TypeScript
import {
|
|
createDecipheriv,
|
|
createSign,
|
|
createVerify,
|
|
randomBytes,
|
|
} from "node:crypto";
|
|
|
|
const WECHAT_PAY_API_BASE = "https://api.mch.weixin.qq.com";
|
|
|
|
type WechatNativeOrderParams = {
|
|
appId: string;
|
|
mchId: string;
|
|
privateKey: string;
|
|
serialNo: string;
|
|
description: string;
|
|
outTradeNo: string;
|
|
notifyUrl: string;
|
|
amountFen: number;
|
|
attach?: string;
|
|
};
|
|
|
|
type WechatNativeOrderResponse = {
|
|
code_url: string;
|
|
};
|
|
|
|
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;
|
|
};
|
|
};
|
|
|
|
export class WechatPayError extends Error {
|
|
status: number;
|
|
|
|
constructor(message: string, status = 500) {
|
|
super(message);
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
function normalizePem(pem: string) {
|
|
return pem.replace(/\\n/g, "\n").trim();
|
|
}
|
|
|
|
function buildWechatPaySignature({
|
|
method,
|
|
path,
|
|
timestamp,
|
|
nonce,
|
|
body,
|
|
privateKey,
|
|
}: {
|
|
method: string;
|
|
path: string;
|
|
timestamp: string;
|
|
nonce: string;
|
|
body: string;
|
|
privateKey: string;
|
|
}) {
|
|
const message = `${method}\n${path}\n${timestamp}\n${nonce}\n${body}\n`;
|
|
const signer = createSign("RSA-SHA256");
|
|
signer.update(message);
|
|
signer.end();
|
|
return signer.sign(normalizePem(privateKey), "base64");
|
|
}
|
|
|
|
function buildWechatAuthorization({
|
|
mchId,
|
|
serialNo,
|
|
timestamp,
|
|
nonce,
|
|
signature,
|
|
}: {
|
|
mchId: string;
|
|
serialNo: string;
|
|
timestamp: string;
|
|
nonce: string;
|
|
signature: string;
|
|
}) {
|
|
return `WECHATPAY2-SHA256-RSA2048 mchid=\"${mchId}\",nonce_str=\"${nonce}\",timestamp=\"${timestamp}\",serial_no=\"${serialNo}\",signature=\"${signature}\"`;
|
|
}
|
|
|
|
export function generateWechatOutTradeNo() {
|
|
const suffix = randomBytes(4).toString("hex");
|
|
return `LVT${Date.now()}${suffix}`.slice(0, 32);
|
|
}
|
|
|
|
export async function createWechatNativeOrder(params: WechatNativeOrderParams) {
|
|
const path = "/v3/pay/transactions/native";
|
|
const timestamp = `${Math.floor(Date.now() / 1000)}`;
|
|
const nonce = randomBytes(16).toString("hex");
|
|
const body = JSON.stringify({
|
|
appid: params.appId,
|
|
mchid: params.mchId,
|
|
description: params.description,
|
|
out_trade_no: params.outTradeNo,
|
|
notify_url: params.notifyUrl,
|
|
amount: {
|
|
total: params.amountFen,
|
|
currency: "CNY",
|
|
},
|
|
attach: params.attach,
|
|
});
|
|
|
|
const signature = buildWechatPaySignature({
|
|
method: "POST",
|
|
path,
|
|
timestamp,
|
|
nonce,
|
|
body,
|
|
privateKey: params.privateKey,
|
|
});
|
|
|
|
const authorization = buildWechatAuthorization({
|
|
mchId: params.mchId,
|
|
serialNo: params.serialNo,
|
|
timestamp,
|
|
nonce,
|
|
signature,
|
|
});
|
|
|
|
const response = await fetch(`${WECHAT_PAY_API_BASE}${path}`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: authorization,
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body,
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new WechatPayError(
|
|
typeof data?.message === "string" ? data.message : "Failed to create WeChat native order",
|
|
response.status
|
|
);
|
|
}
|
|
|
|
if (!data?.code_url) {
|
|
throw new WechatPayError("WeChat order response missing code_url", 502);
|
|
}
|
|
|
|
return data as WechatNativeOrderResponse;
|
|
}
|
|
|
|
export function verifyWechatPaySignature({
|
|
body,
|
|
timestamp,
|
|
nonce,
|
|
signature,
|
|
platformPublicKey,
|
|
}: {
|
|
body: string;
|
|
timestamp: string;
|
|
nonce: string;
|
|
signature: string;
|
|
platformPublicKey: string;
|
|
}) {
|
|
const message = `${timestamp}\n${nonce}\n${body}\n`;
|
|
const verifier = createVerify("RSA-SHA256");
|
|
verifier.update(message);
|
|
verifier.end();
|
|
return verifier.verify(normalizePem(platformPublicKey), signature, "base64");
|
|
}
|
|
|
|
export function decryptWechatPayResource({
|
|
ciphertext,
|
|
nonce,
|
|
associatedData,
|
|
apiV3Key,
|
|
}: {
|
|
ciphertext: string;
|
|
nonce: string;
|
|
associatedData?: string;
|
|
apiV3Key: string;
|
|
}) {
|
|
const key = Buffer.from(apiV3Key, "utf8");
|
|
|
|
if (key.length !== 32) {
|
|
throw new WechatPayError("WECHAT_PAY_API_V3_KEY must be 32 bytes", 500);
|
|
}
|
|
|
|
const data = Buffer.from(ciphertext, "base64");
|
|
const authTag = data.subarray(data.length - 16);
|
|
const encryptedData = data.subarray(0, data.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 plaintext = Buffer.concat([decipher.update(encryptedData), decipher.final()]).toString("utf8");
|
|
|
|
return plaintext;
|
|
}
|
|
|
|
export function parseWechatNotifyPayload({
|
|
body,
|
|
apiV3Key,
|
|
}: {
|
|
body: string;
|
|
apiV3Key: string;
|
|
}) {
|
|
const envelope = JSON.parse(body) as WechatPayNotifyEnvelope;
|
|
const resource = envelope.resource;
|
|
|
|
const plaintext = decryptWechatPayResource({
|
|
ciphertext: resource.ciphertext,
|
|
nonce: resource.nonce,
|
|
associatedData: resource.associated_data,
|
|
apiV3Key,
|
|
});
|
|
|
|
return {
|
|
envelope,
|
|
transaction: JSON.parse(plaintext) as Record<string, unknown>,
|
|
};
|
|
}
|