4e21ca937a
Deploy Documentation / deploy (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / build-cli (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
Canary / bundle-desktop (push) Has been cancelled
Canary / bundle-desktop-intel (push) Has been cancelled
Canary / bundle-desktop-linux (push) Has been cancelled
Canary / bundle-desktop-windows (push) Has been cancelled
Canary / bundle-desktop-windows-cuda (push) Has been cancelled
Canary / Release (push) Has been cancelled
Unused Dependencies / machete (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Check Rust Code Format (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
CI / Build Rust Project on Windows (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
CI / H5 Plaza Tests and Build (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled
Live Provider Tests / goose server HTTP integration tests (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Fork goose with custom MCP widgets, platform extensions (aider, git, web, search), MindSpace H5 backend/frontend, Plaza/Ops UIs, and deploy scripts for tkmind.cn. Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
7.1 KiB
JavaScript
211 lines
7.1 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import { fetch } from 'undici';
|
|
|
|
const API_BASE = 'https://api.mch.weixin.qq.com';
|
|
|
|
function readPrivateKey(config) {
|
|
if (config.privateKeyPem) return config.privateKeyPem;
|
|
if (config.privateKeyPath && fs.existsSync(config.privateKeyPath)) {
|
|
return fs.readFileSync(config.privateKeyPath, 'utf8');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readPlatformCert(config) {
|
|
if (config.platformCertPem) return config.platformCertPem;
|
|
if (config.platformCertPath && fs.existsSync(config.platformCertPath)) {
|
|
return fs.readFileSync(config.platformCertPath, 'utf8');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function loadWechatPayConfig() {
|
|
const enabled = process.env.H5_WECHAT_PAY_ENABLED === '1';
|
|
const appId = process.env.H5_WECHAT_APP_ID?.trim() ?? '';
|
|
const mchId = process.env.H5_WECHAT_MCH_ID?.trim() ?? '';
|
|
const apiV3Key = process.env.H5_WECHAT_API_V3_KEY?.trim() ?? '';
|
|
const serialNo = process.env.H5_WECHAT_SERIAL_NO?.trim() ?? '';
|
|
const privateKeyPath = process.env.H5_WECHAT_PRIVATE_KEY_PATH?.trim() ?? '';
|
|
const privateKeyPem = process.env.H5_WECHAT_PRIVATE_KEY?.replace(/\\n/g, '\n').trim() ?? '';
|
|
const platformCertPath = process.env.H5_WECHAT_PLATFORM_CERT_PATH?.trim() ?? '';
|
|
const platformCertPem =
|
|
process.env.H5_WECHAT_PLATFORM_CERT?.replace(/\\n/g, '\n').trim() ?? '';
|
|
const notifyUrl = process.env.H5_WECHAT_NOTIFY_URL?.trim() ?? '';
|
|
const publicBaseUrl = process.env.H5_PUBLIC_BASE_URL?.trim() ?? '';
|
|
|
|
const resolvedNotifyUrl =
|
|
notifyUrl ||
|
|
(publicBaseUrl ? `${publicBaseUrl.replace(/\/$/, '')}/webhooks/wechat-pay/notify` : '');
|
|
|
|
const privateKey = readPrivateKey({ privateKeyPath, privateKeyPem });
|
|
const platformCert = readPlatformCert({ platformCertPath, platformCertPem });
|
|
|
|
const configured = Boolean(appId && mchId && apiV3Key && serialNo && privateKey && resolvedNotifyUrl);
|
|
|
|
return {
|
|
enabled: enabled && configured,
|
|
appId,
|
|
mchId,
|
|
apiV3Key,
|
|
serialNo,
|
|
privateKey,
|
|
platformCert,
|
|
notifyUrl: resolvedNotifyUrl,
|
|
skipNotifyVerify: process.env.H5_WECHAT_SKIP_NOTIFY_VERIFY === '1',
|
|
};
|
|
}
|
|
|
|
function randomNonce(length = 32) {
|
|
return crypto.randomBytes(length).toString('hex').slice(0, length);
|
|
}
|
|
|
|
function signMessage(message, privateKeyPem) {
|
|
return crypto.createSign('RSA-SHA256').update(message).sign(privateKeyPem, 'base64');
|
|
}
|
|
|
|
function buildAuthorization({ mchId, serialNo, privateKey }, method, pathWithQuery, body) {
|
|
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
const nonce = randomNonce();
|
|
const payload = body ?? '';
|
|
const message = `${method}\n${pathWithQuery}\n${timestamp}\n${nonce}\n${payload}\n`;
|
|
const signature = signMessage(message, privateKey);
|
|
const authorization = [
|
|
'WECHATPAY2-SHA256-RSA2048',
|
|
`mchid="${mchId}"`,
|
|
`nonce_str="${nonce}"`,
|
|
`signature="${signature}"`,
|
|
`timestamp="${timestamp}"`,
|
|
`serial_no="${serialNo}"`,
|
|
].join(',');
|
|
return { authorization, timestamp, nonce, signature };
|
|
}
|
|
|
|
async function wechatRequest(config, method, path, bodyObj) {
|
|
const body = bodyObj ? JSON.stringify(bodyObj) : '';
|
|
const { authorization } = buildAuthorization(config, method, path, body);
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
method,
|
|
headers: {
|
|
Authorization: authorization,
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: body || undefined,
|
|
});
|
|
const text = await res.text();
|
|
let data = null;
|
|
try {
|
|
data = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
data = { raw: text };
|
|
}
|
|
if (!res.ok) {
|
|
const detail = data?.message ?? data?.detail ?? text;
|
|
throw new Error(`微信支付请求失败 (${res.status}): ${detail}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
export function createWechatPayClient(config) {
|
|
if (!config?.enabled) {
|
|
return {
|
|
enabled: false,
|
|
async createNativeOrder() {
|
|
throw new Error('微信支付未配置');
|
|
},
|
|
async createH5Order() {
|
|
throw new Error('微信支付未配置');
|
|
},
|
|
async verifyNotify() {
|
|
throw new Error('微信支付未配置');
|
|
},
|
|
};
|
|
}
|
|
|
|
const createNativeOrder = async ({ outTradeNo, description, amountCents, clientIp }) => {
|
|
const data = await wechatRequest(config, 'POST', '/v3/pay/transactions/native', {
|
|
appid: config.appId,
|
|
mchid: config.mchId,
|
|
description,
|
|
out_trade_no: outTradeNo,
|
|
notify_url: config.notifyUrl,
|
|
amount: { total: amountCents, currency: 'CNY' },
|
|
scene_info: clientIp ? { payer_client_ip: clientIp } : undefined,
|
|
});
|
|
return { codeUrl: data.code_url };
|
|
};
|
|
|
|
const createH5Order = async ({ outTradeNo, description, amountCents, clientIp }) => {
|
|
const data = await wechatRequest(config, 'POST', '/v3/pay/transactions/h5', {
|
|
appid: config.appId,
|
|
mchid: config.mchId,
|
|
description,
|
|
out_trade_no: outTradeNo,
|
|
notify_url: config.notifyUrl,
|
|
amount: { total: amountCents, currency: 'CNY' },
|
|
scene_info: {
|
|
payer_client_ip: clientIp || '127.0.0.1',
|
|
h5_info: { type: 'Wap', app_name: 'TKMind', app_url: process.env.H5_PUBLIC_BASE_URL ?? 'https://go.tkmind.cn' },
|
|
},
|
|
});
|
|
return { h5Url: data.h5_url };
|
|
};
|
|
|
|
const verifyNotify = ({ headers, body }) => {
|
|
const timestamp = headers['wechatpay-timestamp'] ?? headers['Wechatpay-Timestamp'];
|
|
const nonce = headers['wechatpay-nonce'] ?? headers['Wechatpay-Nonce'];
|
|
const signature = headers['wechatpay-signature'] ?? headers['Wechatpay-Signature'];
|
|
const serial = headers['wechatpay-serial'] ?? headers['Wechatpay-Serial'];
|
|
|
|
if (!timestamp || !nonce || !signature) {
|
|
throw new Error('微信支付回调缺少签名头');
|
|
}
|
|
|
|
const bodyText = typeof body === 'string' ? body : JSON.stringify(body);
|
|
const message = `${timestamp}\n${nonce}\n${bodyText}\n`;
|
|
|
|
if (!config.skipNotifyVerify) {
|
|
if (!config.platformCert) {
|
|
throw new Error('未配置微信支付平台证书,无法验签');
|
|
}
|
|
const verified = crypto
|
|
.createVerify('RSA-SHA256')
|
|
.update(message)
|
|
.verify(config.platformCert, signature, 'base64');
|
|
if (!verified) {
|
|
throw new Error('微信支付回调验签失败');
|
|
}
|
|
}
|
|
|
|
const payload = typeof body === 'string' ? JSON.parse(body) : body;
|
|
const resource = payload?.resource;
|
|
if (!resource?.ciphertext || !resource?.nonce) {
|
|
throw new Error('微信支付回调数据不完整');
|
|
}
|
|
|
|
const cipherBuf = Buffer.from(resource.ciphertext, 'base64');
|
|
const authTag = cipherBuf.subarray(cipherBuf.length - 16);
|
|
const dataBuf = cipherBuf.subarray(0, cipherBuf.length - 16);
|
|
const decipher = crypto.createDecipheriv(
|
|
'aes-256-gcm',
|
|
Buffer.from(config.apiV3Key, 'utf8'),
|
|
Buffer.from(resource.nonce, 'utf8'),
|
|
);
|
|
decipher.setAuthTag(authTag);
|
|
if (resource.associated_data) {
|
|
decipher.setAAD(Buffer.from(resource.associated_data, 'utf8'));
|
|
}
|
|
const plain = Buffer.concat([decipher.update(dataBuf), decipher.final()]).toString('utf8');
|
|
const transaction = JSON.parse(plain);
|
|
return { serial, transaction };
|
|
};
|
|
|
|
return {
|
|
enabled: true,
|
|
createNativeOrder,
|
|
createH5Order,
|
|
verifyNotify,
|
|
};
|
|
}
|