Files
YelayMaths/yelaymaths_api/src/common/wechat-msg.ts
T

111 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
}