159 lines
5.1 KiB
TypeScript
159 lines
5.1 KiB
TypeScript
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
|
||
|
||
/** 微信消息推送:Token/timestamp/nonce[/Encrypt] 字典序拼接后 SHA1 */
|
||
export function wechatMsgSha1(...parts: string[]): string {
|
||
return createHash('sha1').update([...parts].sort().join('')).digest('hex');
|
||
}
|
||
|
||
export function verifyWechatUrlSignature(
|
||
token: string,
|
||
timestamp: string,
|
||
nonce: string,
|
||
signature: string,
|
||
): boolean {
|
||
if (!token || !timestamp || !nonce || !signature) return false;
|
||
return wechatMsgSha1(token, timestamp, nonce) === signature;
|
||
}
|
||
|
||
export function verifyWechatMsgSignature(
|
||
token: string,
|
||
timestamp: string,
|
||
nonce: string,
|
||
encrypt: string,
|
||
msgSignature: string,
|
||
): boolean {
|
||
if (!token || !timestamp || !nonce || !encrypt || !msgSignature) return false;
|
||
return wechatMsgSha1(token, timestamp, nonce, encrypt) === msgSignature;
|
||
}
|
||
|
||
function decodeAesKey(encodingAESKey: string): Buffer {
|
||
const key = Buffer.from(`${encodingAESKey.trim()}=`, 'base64');
|
||
if (key.length !== 32) {
|
||
throw new Error(`EncodingAESKey 无效(解码后应为 32 字节,实际 ${key.length})`);
|
||
}
|
||
return key;
|
||
}
|
||
|
||
/**
|
||
* 解密微信安全模式 Encrypt 字段。
|
||
* FullStr = random(16) + msg_len(4 BE) + msg + appid
|
||
*/
|
||
export function decryptWechatEncrypt(
|
||
encryptBase64: string,
|
||
encodingAESKey: string,
|
||
expectedAppId?: string,
|
||
): string {
|
||
const aesKey = decodeAesKey(encodingAESKey);
|
||
const iv = aesKey.subarray(0, 16);
|
||
const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
|
||
const decrypted = Buffer.concat([
|
||
decipher.update(Buffer.from(encryptBase64, 'base64')),
|
||
decipher.final(),
|
||
]);
|
||
if (decrypted.length < 20) {
|
||
throw new Error('解密结果过短');
|
||
}
|
||
const msgLen = decrypted.readUInt32BE(16);
|
||
const msgStart = 20;
|
||
const msgEnd = msgStart + msgLen;
|
||
if (msgEnd > decrypted.length) {
|
||
throw new Error('解密消息长度非法');
|
||
}
|
||
const msg = decrypted.subarray(msgStart, msgEnd).toString('utf8');
|
||
const appId = decrypted.subarray(msgEnd).toString('utf8');
|
||
if (expectedAppId && appId && appId !== expectedAppId) {
|
||
throw new Error(`appid 不匹配: got=${appId}`);
|
||
}
|
||
return msg;
|
||
}
|
||
|
||
/** 加密回包(一般回复 success 明文即可,此函数供需要加密回包时使用) */
|
||
export function encryptWechatReply(
|
||
plain: string,
|
||
encodingAESKey: string,
|
||
appId: string,
|
||
): string {
|
||
const aesKey = decodeAesKey(encodingAESKey);
|
||
const iv = aesKey.subarray(0, 16);
|
||
const random = randomBytes(16);
|
||
const msg = Buffer.from(plain, 'utf8');
|
||
const msgLen = Buffer.alloc(4);
|
||
msgLen.writeUInt32BE(msg.length, 0);
|
||
const full = Buffer.concat([random, msgLen, msg, Buffer.from(appId, 'utf8')]);
|
||
const cipher = createCipheriv('aes-256-cbc', aesKey, iv);
|
||
return Buffer.concat([cipher.update(full), cipher.final()]).toString('base64');
|
||
}
|
||
|
||
/** 简易 XML 标签提取(微信推送字段无嵌套结构) */
|
||
export function parseSimpleXml(xml: string): Record<string, string> {
|
||
const out: Record<string, string> = {};
|
||
const re = /<([A-Za-z0-9_]+)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/g;
|
||
let m: RegExpExecArray | null;
|
||
while ((m = re.exec(xml))) {
|
||
out[m[1]] = (m[2] ?? m[3] ?? '').trim();
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function parseWechatPushBody(raw: string): Record<string, unknown> {
|
||
const trimmed = raw.trim();
|
||
if (!trimmed) return {};
|
||
if (trimmed.startsWith('{')) {
|
||
return JSON.parse(trimmed) as Record<string, unknown>;
|
||
}
|
||
return parseSimpleXml(trimmed);
|
||
}
|
||
|
||
export type WechatTradeManageEvent = {
|
||
event: string;
|
||
toUserName?: string;
|
||
fromUserName?: string;
|
||
createTime?: number;
|
||
transactionId?: string;
|
||
merchantId?: string;
|
||
subMerchantId?: string;
|
||
merchantTradeNo?: string;
|
||
payTime?: number;
|
||
shippedTime?: number;
|
||
estimatedSettlementTime?: number;
|
||
/** 1 手动确认;2 自动确认(结算推送才有) */
|
||
confirmReceiveMethod?: number;
|
||
confirmReceiveTime?: number;
|
||
settlementTime?: number;
|
||
msg?: string;
|
||
raw: Record<string, unknown>;
|
||
};
|
||
|
||
function num(v: unknown): number | undefined {
|
||
if (v == null || v === '') return undefined;
|
||
const n = typeof v === 'number' ? v : Number(v);
|
||
return Number.isFinite(n) ? n : undefined;
|
||
}
|
||
|
||
function str(v: unknown): string | undefined {
|
||
if (v == null) return undefined;
|
||
const s = String(v).trim();
|
||
return s || undefined;
|
||
}
|
||
|
||
export function normalizeTradeManageEvent(body: Record<string, unknown>): WechatTradeManageEvent {
|
||
return {
|
||
event: str(body.Event ?? body.event) || '',
|
||
toUserName: str(body.ToUserName),
|
||
fromUserName: str(body.FromUserName),
|
||
createTime: num(body.CreateTime),
|
||
transactionId: str(body.transaction_id),
|
||
merchantId: str(body.merchant_id),
|
||
subMerchantId: str(body.sub_merchant_id),
|
||
merchantTradeNo: str(body.merchant_trade_no),
|
||
payTime: num(body.pay_time),
|
||
shippedTime: num(body.shipped_time),
|
||
estimatedSettlementTime: num(body.estimated_settlement_time),
|
||
confirmReceiveMethod: num(body.confirm_receive_method),
|
||
confirmReceiveTime: num(body.confirm_receive_time),
|
||
settlementTime: num(body.settlement_time),
|
||
msg: str(body.msg),
|
||
raw: body,
|
||
};
|
||
}
|