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}>(?:)?`).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'); }