feat(trade): WeChat trade-manage push + delay shipping upload
Receive trade_manage_order_settlement to sync confirm-receive/settle; wait ~65s and retry on 10060001 before upload_shipping_info. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user