wechatpay-axios-plugin
Version:
微信支付APIv2及v3 NodeJS SDK,支持CLI模式请求OpenAPI,支持v3证书下载,v2付款码支付、企业付款、退款,企业微信-企业支付-企业红包/向员工付款,v2&v3 Native支付、扫码支付、H5支付、JSAPI/小程序支付、合单支付...
340 lines (293 loc) • 13.4 kB
JavaScript
const assert = require('assert');
const debuglog = require('util').debuglog('wechatpay:decorator');
const { default: axios, mergeConfig, AxiosHeaders } = require('axios');
const Transformer = require('./transformer');
const Formatter = require('./formatter');
const utils = require('./utils');
const Hash = require('./hash');
const Rsa = require('./rsa');
const V2 = Symbol('XML PAYLOAD');
const V3 = Symbol('JSON PAYLOAD');
const MAXIMUM_CLOCK_OFFSET = 300;
const HTTP_WECHATPAY_NONCE = 'Wechatpay-Nonce';
const HTTP_WECHATPAY_SERIAL = 'Wechatpay-Serial';
const HTTP_WECHATPAY_SIGNATURE = 'Wechatpay-Signature';
const HTTP_WECHATPAY_TIMESTAMP = 'Wechatpay-Timestamp';
const ERR_INIT_MCHID_IS_MANDATORY = 'The merchant\' ID aka `mchid` is required, usually is numerical string.';
const ERR_INIT_SERIAL_IS_MANDATORY = 'The serial number of the merchant\'s certificate aka `serial` is required, usually hexadecial.';
const ERR_INIT_PRIVATEKEY_IS_MANDATORY = 'The merchant\'s private key aka `privateKey` is required, usual as pem format.';
const ERR_INIT_CERTS_IS_MANDATORY = 'The platform certificate(s) aka `certs` is required, paired as of `{$serial: $certificate}`.';
const ERR_INIT_CERTS_EXCLUDE_MCHSERIAL = 'The `certs` contains the merchant\'s certificate serial number which is not allowed here.';
const EV3_RES_HEADERS_INCOMPLATE = 'The response\'s Headers incomplete, must have(`%s`, `%s`, `%s` and `%s`).';
const EV3_RES_HEADER_TIMESTAMP_OFFSET = 'It\'s allowed time offset in ± %s seconds, the response(header:%s) was on %s, your\'s localtime on %s.';
const EV3_RES_HEADER_PLATFORM_SERIAL = 'Cannot found the serial(`%s`)\'s configuration, which\'s from the response(header:%s), your\'s %O.';
const EV3_RES_HEADER_SIGNATURE_DIGEST = 'Verify the response\'s data with: timestamp=%s, nonce=%s, signature=%s, cert={%s: ...} failed.';
const COMPLAINT = /\/v3\/merchant-service\/images\/(?!upload).{6,}/;
const DOWNLOADS = [
'/v3/new-tax-control-fapiao/download',
'/v3/transferdownload/elecvoucherfile',
'/v3/transferdownload/signfile',
'/v3/billdownload/file',
'/v3/global/statements',
'/v3/statements',
];
function isValidAsymmetricKey(thing) {
return ((utils.isString(thing) || utils.isBuffer(thing)) && thing.length > 0) || Rsa.isKeyObject(thing);
}
/**
* Decorate the `Axios` instance
*/
class Decorator {
/**
* @property {object} defaults - The defaults configuration whose pased in `Axios`.
*/
static get defaults() {
return {
baseURL: 'https://api.mch.weixin.qq.com/',
headers: {
Accept: 'application/json, text/plain, application/x-gzip, application/pdf, image/png, image/*;q=0.5',
'Content-Type': 'application/json; charset=utf-8',
'User-Agent': utils.userAgent(),
},
};
}
/**
* Create an APIv2's client
*
* @param {object} config - configuration
* @param {string} [config.mchid] - The merchant ID
* @param {string|Buffer} [config.secret] - The merchant secret key for APIv2
* @param {object} [config.merchant] - The merchant certificates, more @see {import('tls').createSecureContext}
* @param {string|Buffer} [config.merchant.cert] - The merchant certificate in PEM format
* @param {string|Buffer} [config.merchant.key] - The merchant private key in PEM format
* @param {string|Buffer} [config.merchant.pfx] - The merchant private key and certificate chain in PFX or PKCS12 format.
* @param {string|Buffer} [config.merchant.passphrase] - The merchant shared passphrase used for a single private key and/or a PFX.
*
* @returns {AxiosInstance} - The axios instance
*/
static xmlBased(config = {}) {
const { mchid, secret } = config;
let key;
if (secret && (utils.isString(secret) || utils.isBuffer(secret)) && secret.length === 32) {
key = Hash.keyObjectFrom(Buffer.from(secret));
// hidden the `secret` key string
Reflect.set(config, 'secret', key);
}
const trans = new Transformer(mchid, key);
const cfg = mergeConfig(mergeConfig(this.defaults, config), {
headers: {
'Content-Type': 'text/xml; charset=utf-8',
Accept: 'text/xml, text/plain, application/json, application/x-gzip',
},
responseType: 'text',
transformRequest: [].concat(trans.request),
transformResponse: [].concat(trans.response),
});
['serial', 'privateKey', 'certs'].forEach((prop) => Reflect.deleteProperty(cfg, prop));
const instance = axios.create(cfg);
instance.interceptors.response.use(this.responseDetectorInterceptor());
return instance;
}
/**
* APIv3's requestInterceptor
*
* @return {function} Named `signer` function
*/
static requestInterceptor() {
return function signer(config) {
const method = config.method.toUpperCase();
const payload = JSON.stringify(
// for media upload, while this instance had `meta` Object defined,
// let's checking whether or nor the real `data` is a `multipart/form-data` compatible instance
config.meta && utils.isProcessFormData(config.data) ? config.meta : config.data,
);
const nonce = Formatter.nonce();
const timestamp = Formatter.timestamp();
// `getUri` should missing some paths whose were on `baseURL`
const url = new URL(axios.getUri(config), config.baseURL);
debuglog('%o', url);
const signature = Rsa.sign(
Formatter.request(method, `${url.pathname}${url.search}`, timestamp, nonce, payload),
config.privateKey,
);
// A community user(JAVA) was reported there, while it contains a duplicated 'Authorization' header(mean dup-values asof the HTTP spec),
// The server side guns away with 500 status code. Let's force using the single calculated value anyway.
Reflect.set(config, 'headers', AxiosHeaders.from(config.headers).set(
'Authorization', Formatter.authorization(config.mchid, nonce, signature, timestamp, config.serial), true,
));
debuglog('Headers %o', config.headers);
return config;
};
}
static responseDetectorInterceptor() {
return function detector(thing) {
if (utils.isObject(thing) && (thing.data instanceof utils.BusinessError)) {
throw new axios.AxiosError(
thing.data.message, thing.data.code,
thing.config, thing.request,
{
status: thing.status,
statusText: thing.statusText,
headers: thing.headers,
data: thing.data.response.data,
config: thing.config,
request: thing.request,
},
);
}
return thing;
};
}
/**
* APIv3's responseVerifier
* @param {object} certs The platform public keys configuration, `{serial: publicKey}` pair
* @return {function} Named as `verifier` function
*/
static responseVerifier(certs = {}) {
return function verifier(data, headers, status) {
/** @since v0.9.0 only detect the basis pathname which may contains `/hk` or behind of the `reserved-proxy-url` */
const pathname = utils.isString(this.url) && utils.absPath(this.url);
if (DOWNLOADS.includes(pathname) || COMPLAINT.test(pathname)) { return data; }
/** @since v0.8.13 no need verification anymore while the HTTP status is not 20X code. */
if (status && this.validateStatus && !this.validateStatus(status)) { return data; }
const hdrs = AxiosHeaders.from(headers);
if (!(hdrs.has(HTTP_WECHATPAY_TIMESTAMP) && hdrs.has(HTTP_WECHATPAY_NONCE) && hdrs.has(HTTP_WECHATPAY_SERIAL) && hdrs.has(HTTP_WECHATPAY_SIGNATURE))) {
return utils.buildBusinessError(
{ data, headers, status },
EV3_RES_HEADERS_INCOMPLATE, 'EV3_RES_HEADERS_INCOMPLATE',
HTTP_WECHATPAY_NONCE, HTTP_WECHATPAY_SERIAL, HTTP_WECHATPAY_SIGNATURE, HTTP_WECHATPAY_TIMESTAMP,
);
}
const [
timestamp, nonce, serial, signature, localTimestamp,
] = [
hdrs.get(HTTP_WECHATPAY_TIMESTAMP), hdrs.get(HTTP_WECHATPAY_NONCE),
hdrs.get(HTTP_WECHATPAY_SERIAL), hdrs.get(HTTP_WECHATPAY_SIGNATURE),
Formatter.timestamp(),
];
if (Math.abs(localTimestamp - timestamp) > MAXIMUM_CLOCK_OFFSET) {
return utils.buildBusinessError(
{ data, headers, status },
EV3_RES_HEADER_TIMESTAMP_OFFSET, 'EV3_RES_HEADER_TIMESTAMP_OFFSET',
MAXIMUM_CLOCK_OFFSET, HTTP_WECHATPAY_TIMESTAMP, timestamp, localTimestamp,
);
}
if (!Object.prototype.hasOwnProperty.call(certs, serial)) {
return utils.buildBusinessError(
{ data, headers, status },
EV3_RES_HEADER_PLATFORM_SERIAL, 'EV3_RES_HEADER_PLATFORM_SERIAL',
serial, HTTP_WECHATPAY_SERIAL, Object.keys(certs),
);
}
if (!Rsa.verify(Formatter.response(timestamp, nonce, data), signature, certs[serial])) {
return utils.buildBusinessError(
{ data, headers, status },
EV3_RES_HEADER_SIGNATURE_DIGEST, 'EV3_RES_HEADER_SIGNATURE_DIGEST',
timestamp, nonce, signature, serial,
);
}
return data;
};
}
/**
* Create an APIv3's client
*
* @param {object} config - configuration
* @param {string} config.mchid - The merchant ID
* @param {string} config.serial - The serial number of the merchant certificate
* @param {string|Buffer} config.privateKey - The merchant private key certificate
* @param {object} config.certs - The wechatpay platform serial and certificate(s), `{serial: publicKey}` pair
*
* @returns {AxiosInstance} - The axios instance
*/
static jsonBased(config = {}) {
const {
mchid, serial, privateKey, certs = {},
} = config;
assert(
utils.isString(mchid),
ERR_INIT_MCHID_IS_MANDATORY,
);
assert(
utils.isString(serial),
ERR_INIT_SERIAL_IS_MANDATORY,
);
assert(
utils.isString(privateKey) || utils.isBuffer(privateKey) || Rsa.isKeyObject(privateKey),
ERR_INIT_PRIVATEKEY_IS_MANDATORY,
);
assert(
utils.isObject(certs) && Object.keys(certs).length > 0,
ERR_INIT_CERTS_IS_MANDATORY,
);
assert(
!Object.prototype.hasOwnProperty.call(certs, serial),
ERR_INIT_CERTS_EXCLUDE_MCHSERIAL,
);
if (isValidAsymmetricKey(privateKey)) {
Reflect.set(config, 'privateKey', Rsa.from(privateKey, Rsa.KEY_TYPE_PRIVATE));
}
const pubs = Object.fromEntries(Object.entries(certs).map(([id, val]) => [id, isValidAsymmetricKey(val) ? Rsa.from(val, Rsa.KEY_TYPE_PUBLIC) : val]));
Reflect.set(config, 'certs', pubs);
const cfg = mergeConfig(mergeConfig(this.defaults, config), {
transformResponse: [].concat(this.responseVerifier(pubs), axios.defaults.transformResponse),
});
['secret', 'merchant'].forEach((prop) => Reflect.deleteProperty(cfg, prop));
const instance = axios.create(cfg);
instance.interceptors.request.use(this.requestInterceptor());
instance.interceptors.response.use(this.responseDetectorInterceptor());
return instance;
}
/**
* Getter APIv2's client (xmlBased)
*
* @returns {AxiosInstance} - The axios instance
*/
get v2() { return this[V2]; }
/**
* Getter APIv3's client (jsonBased)
*
* @returns {AxiosInstance} - The axios instance
*/
get v3() { return this[V3]; }
/**
* Request the remote `pathname` by a HTTP `method` verb
*
* @param {string} [pathname] - The pathname string.
* @param {string} [method] - The method string.
* @param {object|Buffer} [data] - The data.
* @param {object} [config] - The config.
*
* @returns {PromiseLike} - The `AxiosPromise`
*/
request(pathname, method, data, config) {
const url = pathname.replace(/\{([^}]+)\}/g, (tmpl, named) => (Object.prototype.hasOwnProperty.call(config, named) ? config[named] : tmpl));
debuglog('prepared url: %s, method: %s, data: %o, config: %o', url, method, data, config);
const flag = url.startsWith('v2/');
return this[flag ? V2 : V3].request(mergeConfig(config || {}, { data, url: url.slice(flag ? 3 : 0), method }));
}
/**
* Decorate factory
* @param {object} config - configuration
* @param {string} config.mchid - The merchant ID
* @param {string} config.serial - The serial number of the merchant certificate
* @param {string|Buffer} config.privateKey - The merchant private key
* @param {object} config.certs - The platform public keys configuration, `{serial: publicKey}` pair
* @param {string} [config.secret] - The merchant secret key for APIv2
* @param {object} [config.merchant] - The merchant certificates, more @see {import('tls').createSecureContext}
* @param {string|Buffer} [config.merchant.cert] - The merchant certificate in PEM format
* @param {string|Buffer} [config.merchant.key] - The merchant private key in PEM format
* @param {string|Buffer} [config.merchant.pfx] - The merchant private key and certificate chain in PFX or PKCS12 format.
* @param {string|Buffer} [config.merchant.passphrase] - The merchant shared passphrase used for a single private key and/or a PFX.
* @constructor
*/
constructor(config = {}) {
const that = this.constructor;
Object.defineProperties(this, {
[V2]: { value: that.xmlBased(config) },
[V3]: { value: that.jsonBased(config) },
});
}
static get default() { return this; }
}
module.exports = Decorator;