111 lines
4.3 KiB
TypeScript
111 lines
4.3 KiB
TypeScript
import { createDecipheriv, createHash, timingSafeEqual } from 'node:crypto';
|
||
|
||
/**
|
||
* 微信「消息推送」服务器地址相关的两个协议细节。
|
||
*
|
||
* 小程序后台填了 URL 之后,微信会先发一个 GET 来验签,验不过就一直显示
|
||
* 「服务器地址验证失败」,也就配不上发货推送。虚拟支付文档说的「发货推送 URL」
|
||
* 指的就是这个地址。
|
||
*/
|
||
|
||
/** 签名 = sha1(Token 与 URL 参数按字典序排序后拼接)。安全模式下把 Encrypt 也算进去。 */
|
||
export function wechatSignature(token: string, ...parts: string[]): string {
|
||
return createHash('sha1').update([token, ...parts].sort().join('')).digest('hex');
|
||
}
|
||
|
||
export function sameHex(a: string, b: string): boolean {
|
||
const x = Buffer.from(a);
|
||
const y = Buffer.from(b);
|
||
return x.length > 0 && x.length === y.length && timingSafeEqual(x, y);
|
||
}
|
||
|
||
/** 取 XML 标签文本(兼容 CDATA)。 */
|
||
export function xmlField(xml: string, field: string): string {
|
||
const match = new RegExp(`<${field}>(?:<!\\[CDATA\\[)?([\\s\\S]*?)(?:\\]\\]>)?</${field}>`).exec(xml);
|
||
return match ? match[1]!.trim() : '';
|
||
}
|
||
|
||
/**
|
||
* 取推送的事件名。发货/退款推送是 XML,iOS 退款问询是 JSON,两种都要认。
|
||
* 认不出来就返回空串——调用方据此记「无 Event」。
|
||
*/
|
||
export function eventOf(body: string): string {
|
||
const fromXml = xmlField(body, 'Event');
|
||
if (fromXml) return fromXml;
|
||
try {
|
||
const parsed = JSON.parse(body) as { Event?: unknown; event?: unknown };
|
||
const value = parsed.Event ?? parsed.event;
|
||
return typeof value === 'string' ? value : '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取推送里的任意字段,XML 和 JSON 都认。
|
||
*
|
||
* 为什么需要:文档把 iOS 退款问询的报文体写成了 JSON 对象,但实际推过来的是
|
||
* 明文模式的标准 XML 信封(`<pay_order_id><![CDATA[...]]></pay_order_id>`)。
|
||
* 只按 JSON 解析会抛异常,整个问询处理直接掉进兜底分支。
|
||
*/
|
||
export function fieldOf(body: string, field: string): string {
|
||
const fromXml = xmlField(body, field);
|
||
if (fromXml) return fromXml;
|
||
try {
|
||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||
const value = parsed[field];
|
||
return value === undefined || value === null ? '' : String(value);
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/** 客服消息的 MsgType 中文名,只用于日志展示。 */
|
||
const CUSTOMER_SERVICE_MSG_TYPES: Record<string, string> = {
|
||
text: '文字',
|
||
image: '图片',
|
||
miniprogrampage: '小程序卡片',
|
||
link: '链接',
|
||
};
|
||
|
||
export function customerServiceMsgTypeLabel(msgType: string): string {
|
||
if (!msgType) return '未知类型';
|
||
return CUSTOMER_SERVICE_MSG_TYPES[msgType] ?? msgType;
|
||
}
|
||
|
||
/**
|
||
* 是否是客服消息推送。
|
||
*
|
||
* 小程序后台只有一个「消息推送 URL」,所以用户点客服按钮
|
||
* (Event = user_enter_tempsession)和用户给客服发消息(MsgType = text / image …)
|
||
* 都会推到同一个地址,和支付推送混在一起,必须分开认。
|
||
*
|
||
* 支付推送没有 MsgType 字段;有 MsgType 且不是 event 的,就是用户发来的消息。
|
||
*/
|
||
export function isCustomerServicePush(body: string): boolean {
|
||
if (eventOf(body) === 'user_enter_tempsession') return true;
|
||
const msgType = fieldOf(body, 'MsgType');
|
||
return msgType !== '' && msgType !== 'event';
|
||
}
|
||
|
||
/** 把值塞进 CDATA 安全地用于回包。 */
|
||
export function cdata(value: string): string {
|
||
return `<![CDATA[${value.replace(/]]>/g, ']] >')}]]>`;
|
||
}
|
||
|
||
/**
|
||
* 安全模式:AES-256-CBC。
|
||
* 密钥 = Base64(EncodingAESKey + '='),IV 取密钥前 16 字节。
|
||
* 明文 = 16 字节随机数 + 4 字节长度 + msg + appId,填充是 PKCS#7(块长 32)。
|
||
* 填充长度可能是 17~32,OpenSSL 的自动去填充只认 ≤16,所以关掉自己剥。
|
||
*/
|
||
export function decryptWechatMessage(encodingAesKey: string, base64: string): string {
|
||
const key = Buffer.from(encodingAesKey + '=', 'base64');
|
||
const decipher = createDecipheriv('aes-256-cbc', key, key.subarray(0, 16));
|
||
decipher.setAutoPadding(false);
|
||
const raw = Buffer.concat([decipher.update(Buffer.from(base64, 'base64')), decipher.final()]);
|
||
const pad = raw[raw.length - 1]!;
|
||
const body = pad >= 1 && pad <= 32 ? raw.subarray(0, raw.length - pad) : raw;
|
||
return body.subarray(20, 20 + body.readUInt32BE(16)).toString('utf8');
|
||
}
|