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}>(?:)?${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 信封(``)。
* 只按 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;
const value = parsed[field];
return value === undefined || value === null ? '' : String(value);
} catch {
return '';
}
}
/** 客服消息的 MsgType 中文名,只用于日志展示。 */
const CUSTOMER_SERVICE_MSG_TYPES: Record = {
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 `/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');
}