- 免充值产品测试验收用例的wechatpay-axios-plugin教学帖
社区内大佬们都有贡献 「免充值代金券」 的测试用例实现,这篇文章也来凑一下热闹,顺带帮你把一下为啥要做这个 「测试验收」 以及 「验收注意」 细节。 引言 官方用例文档链接 一共25页,非常详细,照着描一步步做,很快就能验收完。本篇作为「教学帖」,试着让友商们理解,这个验收的重要性,如果希望能够获取帮助直接验收,请阅读代金券接口升级验收脚本 用例组合1001+1002+1003+1004+1005。 安装nodejs sdk 本篇以 [代码]wechatpay-axios-plugin[代码] 这款npm开发包展开,详细介绍可阅读 从APIv3到APIv2再到企业微信,这款微信支付开发包的README你应该来读一读。 [代码]npm install wechatpay-axios-plugin@"v0.5.5" [代码] v0.6系列做了[代码]返回数据签名强校验[代码],以下示例代码需要做特殊处理,本篇以v0.5.5展开。 获取沙箱密钥 先要理解,沙箱环境是个仿真环境,不是生产环境,友商朋友们应该做环境隔离。起步需要商户使用生产环境的 [代码]API密钥[代码],去获取[代码]沙箱密钥[代码],后续所有[代码]沙箱环境[代码]操作都要使用由[代码]沙箱密钥[代码]生成的[代码]数据签名sign[代码]。 [代码]const { Wechatpay, Formatter } = require('wechatpay-axios-plugin'); const mchid = '你的商户号'; const secret = '你的32字节的`API密钥`字符串'; const appid = '你的APPID字符串'; const mch_id = mchid; const noop = {serial: 'any', privateKey: 'any', certs: {any: undefined}}; // 实例化一个对象 let wxpay = new Wechatpay({ secret, mchid, ...noop }); const { data: { sandbox_signkey } } = await wxpay.v2.sandboxnew.pay.getsignkey({ mch_id, nonce_str: Formatter.nonce() }); [代码] 1001 付款码(刷卡)支付 订单金额 [代码]5.01[代码] 元,其中 [代码]0.01[代码] 元使用免充值券,用户实际支付 [代码]5.00[代码] 元。验证商户具备正确解析及识别免充值代金券字段的能力。 1001.1 请求支付 [代码]// 重新实例化一个沙箱环境的对象 wxpay = new Wechatpay({ secret: sandbox_signkey, mchid, ...noop }); // 模拟一个商户订单号 let out_trade_no = `SD${+new Date()}_501`; const { data: { coupon_fee, settlement_total_fee, total_fee } } = await wxpay .v2.sandboxnew.pay.micropay({ appid, mch_id, nonce_str: Formatter.nonce(), out_trade_no, body: 'dummybot', total_fee: 501, spbill_create_ip: '127.0.0.1', auth_code: '120061098828009406' }); console.table({ 代金券金额: coupon_fee, 应结订单金额: settlement_total_fee, 订单金额: total_fee }); [代码] 打印日志应如下: [代码]┌──────┬─────┐ │ (index) │ Values │ ├──────┼─────┤ │ 代金券金额 │ '1' │ │应结订单金额 │ '500' │ │ 订单金额 │ '501' │ └──────┴─────┘ [代码] 1001.2 获取支付结果 [代码]const { data: { settlement_total_fee, total_fee, coupon_fee, coupon_fee_0, coupon_type_0, coupon_count, } } = await wxpay .v2.sandboxnew.pay.orderquery({ appid, mch_id, nonce_str: Formatter.nonce(), out_trade_no, }); console.table({ 代金券金额: coupon_fee, 应结订单金额: settlement_total_fee, 订单金额: total_fee, 单个代金券支付金额: coupon_fee_0, 代金券类型: coupon_type_0, 代金券使用数量: coupon_count }); [代码] 打印日志应如下: [代码]┌──────────┬──────┐ │ (index) │ Values │ ├──────────┼──────┤ │ 代金券金额 │ '1' │ │ 应结订单金额 │ '500' │ │ 订单金额 │ '501' │ │单个代金券支付金额 │ '1' │ │ 代金券类型 │ 'NO_CASH'│ │ 代金券使用数量 │ '1' │ └──────────┴──────┘ [代码] 1002 付款码(刷卡)支付退款 订单金额 [代码]5.02[代码] 元,其中 [代码]0.01[代码] 元使用免充值代金劵,实际支付 [代码]5.01[代码] 元,退款查询升级。 1002.1 请求支付 [代码]//模拟重置一个商户订单号 out_trade_no = `SD${+new Date()}_502`; const { data: { coupon_fee, settlement_total_fee, total_fee, } } = await wxpay .v2.sandboxnew.pay.micropay({ appid, mch_id, nonce_str: Formatter.nonce(), out_trade_no, body: 'dummybot', total_fee: 502, spbill_create_ip: '127.0.0.1', auth_code: '120061098828009406' }); console.table({ 代金券金额: coupon_fee, 应结订单金额: settlement_total_fee, 订单金额: total_fee }); [代码] 打印日志应如下: [代码]┌────────┬─────┐ │ (index) │ Values │ ├────────┼─────┤ │ 代金券金额 │ '1' │ │ 应结订单金额 │ '501' │ │ 订单金额 │ '502' │ └────────┴─────┘ [代码] 1002.2 获取支付结果 [代码]const { data: { settlement_total_fee, total_fee, coupon_fee, coupon_fee_0, coupon_type_0, coupon_count } } = await wxpay .v2.sandboxnew.pay.orderquery({ appid, mch_id, nonce_str: Formatter.nonce(), out_trade_no, }); console.table({ 商户订单号: out_trade_no, 代金券金额: coupon_fee, 应结订单金额: settlement_total_fee, 订单金额: total_fee, 单个代金券支付金额: coupon_fee_0, 代金券类型: coupon_type_0, 代金券使用数量: coupon_count }); [代码] 打印日志应如下: [代码]┌──────────┬────────────┐ │ (index) │ Values │ ├──────────┼────────────┤ │ 商户订单号 │'SD1618966329677_502'│ │ 代金券金额 │ '1' │ │ 应结订单金额 │ '501' │ │ 订单金额 │ '502' │ │单个代金券支付金额│ '1' │ │ 代金券类型 │ 'NO_CASH' │ │ 代金券使用数量 │ '1' │ └──────────┴─────────────┘ [代码] 1002.3 请求退款 [代码]const { data: { cash_refund_fee, cash_fee, refund_fee, total_fee } } = await wxpay .v2.sandboxnew.pay.refund({ appid, mch_id, nonce_str: Formatter.nonce(), out_trade_no, out_refund_no: `RD${out_trade_no}`, total_fee: 502, refund_fee: 501, }); console.table({ 退款金额: refund_fee, 标价金额: total_fee, 现金支付金额: cash_fee, 现金退款金额: cash_refund_fee, }); [代码] 打印日志应如下: [代码]┌────────┬─────┐ │ (index) │ Values │ ├────────┼────┤ │ 退款金额 │ '502' │ │ 标价金额 │ '502' │ │ 现金支付金额 │ '501' │ │ 现金退款金额 │ '501' │ └───────┴─────┘ [代码] 1002.4 获取退款结果 [代码]const { data: { settlement_total_fee, total_fee, cash_fee, settlement_refund_fee, coupon_refund_fee_0, coupon_type_0_0, coupon_refund_fee_0_0, refund_fee_0, coupon_refund_count_0, } } = await wxpay .v2.sandboxnew.pay.refundquery({ appid, mch_id, out_trade_no, nonce_str: Formatter.nonce(), }); console.table({ 应结订单金额: settlement_total_fee, 订单金额: total_fee, 现金支付金额: cash_fee, 退款金额: settlement_refund_fee, 总代金券退款金额: coupon_refund_fee_0, 代金券类型: coupon_type_0_0, 总代金券退款金额: coupon_refund_fee_0_0, 申请退款金额: refund_fee_0, 退款代金券使用数量: coupon_refund_count_0, }); [代码] 1003 JSAPI/APP/Native支付 订单金额 [代码]5.51[代码] 元,其中 [代码]0.01[代码] 元使用免充值券,实际支付 [代码]5.50[代码] 元。 验证正常支付流程,商户使用免充值代金券支付。 1003.1 统一下单 [代码]//模拟重置一个商户订单号 out_trade_no = `SD${+new Date()}_551`; const {data: { prepay_id } } = await wxpay .v2.sandboxnew.pay.unifiedorder({ appid, mch_id, out_trade_no, nonce_str: Formatter.nonce(), body: 'dummybot', total_fee: 551, notify_url: 'https://www.weixin.qq.com/wxpay/pay.php', spbill_create_ip: '127.0.0.1', trade_type: 'JSAPI' }); console.table({ 预支付交易会话标识: prepay_id }); [代码] 1003.2 获取支付结果 [代码]const { data: { out_trade_no, total_fee, cash_fee, coupon_fee, coupon_count, coupon_fee_0, coupon_type_0, coupon_fee_0 } } = wxpay .v2.sandboxnew.pay.orderquery({ appid, mch_id, out_trade_no, nonce_str: Formatter.nonce(), }); console.table({ out_trade_no, total_fee, cash_fee, coupon_fee, coupon_count, coupon_fee_0, coupon_type_0, coupon_fee_0 }); [代码] 1004 JSAPI/APP/Native支付退款 订单金额 [代码]5.52[代码] 元,其中 [代码]0.01[代码] 元使用免充值券,实际支付 [代码]5.51[代码] 元。 1004.1 统一下单 [代码]//模拟重置一个商户订单号 out_trade_no = `SD${+new Date()}_551`; const {data: { prepay_id } } = await wxpay .v2.sandboxnew.pay.unifiedorder({ appid, mch_id, out_trade_no, nonce_str: Formatter.nonce(), body: 'dummybot', total_fee: 552, notify_url: 'https://www.weixin.qq.com/wxpay/pay.php', spbill_create_ip: '127.0.0.1', trade_type: 'JSAPI' }); console.table({ 预支付交易会话标识: prepay_id }); [代码] 1004.2 获取支付结果 [代码]const { data: { out_trade_no, total_fee, cash_fee, coupon_fee, coupon_count, coupon_fee_0, coupon_type_0, coupon_fee_0 } } = wxpay .v2.sandboxnew.pay.orderquery({ appid, mch_id, out_trade_no, nonce_str: Formatter.nonce(), }); console.table({ out_trade_no, total_fee, cash_fee, coupon_fee, coupon_count, coupon_fee_0, coupon_type_0, coupon_fee_0 }); [代码] 1004.3 请求退款 [代码]const { data: { cash_refund_fee, cash_fee, refund_fee, total_fee } } = await wxpay .v2.sandboxnew.pay.refund({ appid, mch_id, nonce_str: Formatter.nonce(), out_trade_no, out_refund_no: `RD${out_trade_no}`, total_fee: 552, refund_fee: 551, }); console.table({ 退款金额: refund_fee, 标价金额: total_fee, 现金支付金额: cash_fee, 现金退款金额: cash_refund_fee, }); [代码] 打印日志应如下: [代码]┌───────┬────┐ │ (index) │ Values│ ├───────┼────┤ │ 退款金额 │ '552' │ │ 标价金额 │ '552'│ │现金支付金额│ '551' │ │现金退款金额│ '551' │ └──────┴────┘ [代码] 1004.4 获取退款结果 [代码]const { data: { settlement_total_fee, total_fee, cash_fee, settlement_refund_fee, coupon_refund_fee_0, coupon_type_0_0, coupon_refund_fee_0_0, refund_fee_0, coupon_refund_count_0, } } = await wxpay .v2.sandboxnew.pay.refundquery({ appid, mch_id, out_trade_no, nonce_str: Formatter.nonce(), }); console.table({ 应结订单金额: settlement_total_fee 订单金额: total_fee 现金支付金额: cash_fee, 退款金额: settlement_refund_fee 总代金券退款金额: coupon_refund_fee_0 代金券类型: coupon_type_0_0, 总代金券退款金额: coupon_refund_fee_0_0 申请退款金额: refund_fee_0 退款代金券使用数量: coupon_refund_count_0, }); [代码] 1005 交易对账单下载 使用了免充值券的订单,免充值券部分的金额不计入结算金额。验证商户对账能正确理解到这一点,对账无误。这里预期会返回 [代码]1269[代码] 条明细数据。 汇总结果:总交易单数,应结订单总金额,退款总金额,充值券退款总金额,手续费总金额,订单总金额,申请退款金额。 这里数据应为: [代码]1269, `10.79, `5.93, `0.24, `0.0,`11.27, `6.37 [代码] 以生产标准为例,查询当前时间偏移两天以前的账单: [代码]const { data } = await wxpay .v2.sandboxnew.pay.downloadbill({ appid, mch_id, bill_type: 'ALL', bill_date: ( new Date(+new Date() + (8 - 48)*3600*1000) ).toISOString().slice(0, 10), nonce_str: Formatter.nonce(), }, { transformResponse: [] }); console.table(data); [代码] 打印日志形如: [代码]交易时间,公众账号ID,商户号,子商户号,设备号,微信订单号,商户订单号,用户标识,交易类型,交易状态,付款银行,货币种类,应结订单金额,代金券金额,微信退款单号,商户退款单号,退款金额,充值券退款金额,退款类型,退款状态,商品名称,商户数据包,手续费,费率,订单金额,申请退款金额,费率备注 `2016-05-04 02:18:18,`wxf7c30a8258df4208,`10014843,`0,`harryma007,`4.00123E+27,`autotest_20160501030456_45023,`oT2kauIMXH398DZBeJ4m22CuSDQ0,`NATIVE,`REFUND,`PAB_DEBIT,`CNY,`0,`0,`2.00123E+27,`REF4001232001201605015390231647,`0.01,`0,`ORIGINAL,`PROCESSING,`body中文测试,`attach中文测试,`0,`0.60%,`0,`0.01,` `2016-05-04 02:18:18,`wxf7c30a8258df4208,`10014843,`0,`harryma007,`4.00123E+27,`autotest_20160501060418_79156,`oT2kauIMXH398DZBeJ4m22CuSDQ0,`NATIVE,`REFUND,`PAB_DEBIT,`CNY,`0,`0,`2.00123E+27,`REF4001232001201605015391766944,`0.01,`0,`ORIGINAL,`PROCESSING,`body中文测试,`attach中文测试,`0,`0.60%,`0,`0.01,` ... 14:51:51,`wxf7c30a8258df4208,`10014843,`0,`harryma8888,`4.00968E+27,`wxautotest1462344441,`oT2kauGtJag902bjdvevrJbpGuxo,`NATIVE,`SUCCESS,`CMBC_CREDIT,`CNY,`0.05,`0.01,`0,`0,`0,`0,`中文[body],`测试中文[attach],`0,`0.60%,`0.05,`0,` 总交易单数,应结订单总金额,退款总金额,充值券退款总金额,手续费总金额,订单总金额,申请退款总金额 1269,`10.79,`5.93,`0.24,`0,`11.27,`6.37 [代码] 一铲到底 以下是一键验收全流程的一个[代码]魔性 chain[代码]到底的实现,感谢您阅读至此。 [图片] 本文如果对你开通 「免充代金券」 功能有帮助,那就来个赞呗。
2021-05-10 - 2021-05-10
- 真香:一行命令即可体验「微信支付」全系接口能力
你没看错,这款唯二的「Play the OpenAPI requests over command line」——「以命令行方式与微信支付接口交互」姗姗来了,旨在提供一种简洁高效的接入方式,希望能对开发者有所帮助。 nodejs版的 [代码]wechatpay-axios-plugin[代码] 已经进入[代码]v0.5[代码]版,v0.5做了这些改变: 全部代码(包括265条测试用例)使用 [代码]eslint-config-airbnb-base[代码] 代码风格校验; 使用 [代码]yargs[代码] 包替换了 [代码]commander[代码],重构了「平台证书下载器」工具,使其降级为此命令行一个特殊方法; 降级 [代码]form-data[代码] 及 [代码]yargs[代码] 包为 [代码]peerDependencies[代码],没有这俩包,90%+ 的接口是可以正常工作的,缩减依赖; 新增 [代码]bin/cli.js[代码],开发可以仅用一条命令就能跑得欢实不要不要的了; 使用 开始使用前,需要开发者自行安装[代码]cli[代码]模式依赖的npm包,即 [代码]npm i yargs[代码] 帮助手册 [代码]./node_modules/.bin/wxpay --help[代码] [代码]wxpay <command> Commands: wxpay crt The WeChatPay APIv3s Certificate Downloader wxpay req <uri> Play the OpenAPI requests over command line Options: --version Show version number [boolean] --help Show help [boolean] -u, --baseURL The baseURL [string] [default: "https://api.mch.weixin.qq.com/"] for more information visit "https://github.com/TheNorthMemory/wechatpay-axios-plugin" [代码] [代码]./node_modules/.bin/wxpay crt --help[代码] [代码]wxpay crt The WeChatPay APIv3s Certificate Downloader cert -m, --mchid The merchants ID. [string] [required] -s, --serialno The serial number. [string] [required] -f, --privatekey Path of the merchants private key certificate. [string] [required] -k, --key The secret key string of the merchants APIv3. [string] [required] -o, --output Path to output the platform certificate(s) [string] [default: "/tmp"] Options: --version Show version number [boolean] --help Show help [boolean] -u, --baseURL The baseURL [string] [default: "https://api.mch.weixin.qq.com/"] [代码] [代码]./node_modules/.bin/wxpay req --help[代码] [代码]wxpay req <uri> Play the WeChatPay OpenAPI requests over command line request <uri> -c, --config The configuration [required] -b, --binary Point out the response as `arraybuffer` [boolean] -m, --method The request HTTP verb [default: "POST"] -h, --headers Special request HTTP header(s) -d, --data The request HTTP body -p, --params The request HTTP query parameter(s) Options: --version Show version number [boolean] --help Show help [boolean] -u, --baseURL The baseURL [string] [default: "https://api.mch.weixin.qq.com/"] [代码] 证书下载 [代码]wxpay crt -m N -s S -f F.pem -k K -o .[代码] [代码]The WeChatPay Platform Certificate#0 serial=HEXADECIAL notBefore=Wed, 22 Apr 2020 01:43:19 GMT notAfter=Mon, 21 Apr 2025 01:43:19 GMT Saved to: wechatpay_HEXADECIAL.pem You may confirm the above infos again even if this library already did(by Rsa.verify): openssl x509 -in wechatpay_HEXADECIAL.pem -noout -serial -dates [代码] v3版Native下单 [代码]wxpay req v3/pay/transactions/native \ -c.mchid 1230000109 \ -c.serial HEXADECIAL \ -c.privateKey /path/your/merchant/mchid.key \ -c.certs.HEXADECIAL /path/the/platform/certificates/HEXADECIAL.pem \ -d.appid wxd678efh567hg6787 \ -d.mchid 1230000109 \ -d.description 'Image形象店-深圳腾大-QQ公仔' \ -d.out_trade_no '1217752501201407033233368018' \ -d.notify_url 'https://www.weixin.qq.com/wxpay/pay.php' \ -d.amount.total 100 \ -d.amount.currency CNY [代码] v3版查询订单 [代码]wxpay req v3/pay/transactions/id/1217752501201407033233368018 \ -c.mchid 1230000109 \ -c.serial HEXADECIAL \ -c.privateKey /path/your/merchant/mchid.key \ -c.certs.HEXADECIAL /path/the/platform/certificates/HEXADECIAL.pem \ -m get \ -p.mchid 1230000109 [代码] v3版关闭订单 [代码]wxpay req v3/pay/transactions/out-trade-no/1217752501201407033233368018 \ -c.mchid 1230000109 \ -c.serial HEXADECIAL \ -c.privateKey /path/your/merchant/mchid.key \ -c.certs.HEXADECIAL /path/the/platform/certificates/HEXADECIAL.pem \ -d.mchid 1230000109 [代码] v3版申请对账单 [代码]wxpay req v3/bill/tradebill \ -c.mchid 1230000109 \ -c.serial HEXADECIAL \ -c.privateKey /path/your/merchant/mchid.key \ -c.certs.HEXADECIAL /path/the/platform/certificates/HEXADECIAL.pem \ -m get \ -p.bill_date '2021-02-12' \ -p.bill_type 'ALL' [代码] v2版付款码付 [代码]wxpay req v2/pay/micropay \ -c.mchid 1230000109 \ -c.serial any \ -c.privateKey any \ -c.certs.any \ -c.secret your_merchant_secret_key_string \ -d.appid wxd678efh567hg6787 \ -d.mch_id 1230000109 \ -d.device_info 013467007045764 \ -d.nonce_str 5K8264ILTKCH16CQ2502SI8ZNMTM67VS \ -d.detail 'Image形象店-深圳腾大-QQ公仔' \ -d.spbill_create_ip 8.8.8.8 \ -d.out_trade_no '1217752501201407033233368018' \ -d.total_fee 100 \ -d.fee_type CNY \ -d.auth_code 120061098828009406 [代码] [代码]auth_code[代码] 输入配合扫码枪,体验就飞起来了~ v2版付款码查询openid [代码]wxpay req v2/tools/authcodetoopenid \ -c.mchid 1230000109 \ -c.serial any \ -c.privateKey any \ -c.certs.any \ -c.secret your_merchant_secret_key_string \ -d.appid wxd678efh567hg6787 \ -d.mch_id 1230000109 \ -d.nonce_str 5K8264ILTKCH16CQ2502SI8ZNMTM67VS \ -d.auth_code 120061098828009406 [代码] 设计 主程序 [代码]bin/cli.js[代码] 上,加入了一个中间件,代码如下: [代码].middleware((argv) => { if (argv.c && argv.c.privateKey && argv.c.privateKey !== 'any') { /* eslint-disable-next-line no-param-reassign */ argv.config.privateKey = readFileSync(argv.c.privateKey); } if (argv.c && argv.c.certs && Object.keys(argv.c.certs)[0] !== 'any') { /* eslint-disable-next-line no-return-assign, no-param-reassign, no-sequences */ argv.config.certs = Object.entries(argv.config.certs).reduce((o, [k, v]) => (o[k] = readFileSync(v), o), {}); } if (argv.c && argv.c.merchant) { if (argv.c.merchant.cert && argv.c.merchant.cert !== 'any') { /* eslint-disable-next-line no-param-reassign */ argv.config.merchant.cert = readFileSync(argv.c.merchant.cert); } if (argv.c.merchant.key && argv.c.merchant.key !== 'any') { /* eslint-disable-next-line no-param-reassign */ argv.config.merchant.key = readFileSync(argv.c.merchant.key); } if (argv.c.merchant.pfx && argv.c.merchant.pfx !== 'any') { /* eslint-disable-next-line no-param-reassign */ argv.config.merchant.pfx = readFileSync(argv.c.merchant.pfx); } } }, true) [代码] 其作用就是,把v3所需的证书以文件形式自动加载,供后续 [代码]cli/request.js[代码]方法直接使用,[代码]req[代码]方法核心代码也就10行! [代码]handler(argv) { const { baseURL, uri, config, method, data, params, headers, } = argv; const responseType = argv.binary ? 'arraybuffer' : undefined; const structure = [{ params, headers, responseType }]; if (data) { structure.unshift(data); } (new Wechatpay({ baseURL, ...config }))[uri][method](...structure) /* eslint-disable-next-line no-console */ .then(console.info).catch(console.error); }, [代码] 代码以 MIT 开放源码公开在 https://github.com/TheNorthMemory/wechatpay-axios-plugin ,如果喜欢,就来个 Star 。 后记 Q: 哇!这么简单? A: 不就是为了简单么。。。
2021-06-10 - wechatpay-axios-plugin 开发系列之「起步」
从github issues可以感知到,本SDK已有案例被应用到云开发及自主开发环境,从开发者角度来看,此类库还有些难以架弩,本系列将作为开发对接指导,希望能对开发有所帮助。 [图片] 起步 说起开发,最忌讳的是急急火火的先堆代码,作为一款有性格的SDK,源码全程是英文注释,README我可以用母语来书写,有些同学可能对此不适应,编码讲究的是效率,这里就不展开了。 这里还有一些思考,是关于「起步」,即:如何快速的对接「微信支付」接口,下面我们来展开一下 前置条件 注册微信支付商户号 登录商户平台,设置[代码]API密钥[代码] 使用官方专用证书生成工具,生成商户证书 设置API密钥及[代码]APIv3密钥[代码] 有一个nodejs开发环境,版本最低要求是10.15.0,windows、macos、linux操作系统均支持 懂得基本的npm包管理 开始 基于一些考虑,如云开发生产环境,依赖项决定了启动速度,这里需要区分一下生产环境及开发环境。面向生产环境,此款类库仅严格依赖两个包:[代码]axios[代码]及[代码]node-xml2js[代码],衍生总计仅需6个包;面向开发环境需要额外增加[代码]yargs[代码]包。此文以开发环境展开。 准备环境 准备一个工作目录,使用 [代码]npm init[代码] 先构建一个基础环境,如已有环境,则略过此步。 安装软件 基础包 [代码]npm install wechatpay-axios-plugin --save[代码] CLI依赖包 [代码]npm install yargs --save-dev[代码] 如需媒体上传,如「制券」及「拓展商户」,则需再安装 [代码]form-data[代码] 依赖 [代码]npm install form-data --save[代码] 平台证书 APIv3开发多了一个步骤,就是需要自行下载「平台证书」,本类库提供有下载器,执行命令如下: [代码]./node_modules/.bin/wxpay crt -m {商户号} -s {商户证书序列号} -f {商户私钥证书路径} -k {APIv3密钥(32字节)} -o {保存地址}[代码] 花括弧内为变量,按需自行替换,执行结果类似如下: [代码]The WeChatPay Platform Certificate#0 serial=HEXADECIAL notBefore=Wed, 22 Apr 2020 01:43:19 GMT notAfter=Mon, 21 Apr 2025 01:43:19 GMT Saved to: wechatpay_HEXADECIAL.pem You may confirm the above infos again even if this library already did(by Rsa.verify): openssl x509 -in wechatpay_HEXADECIAL.pem -noout -serial -dates [代码] 快速体验 接续上一篇: 一行命令即可体验「微信支付」全系接口能力,现在就可以在开发环境上,验证及调试官方接口了,额外地, v0.5.1做了优化,命令行现在可以这么执行了: [代码]./node_modules/.bin/wxpay v2/pay/micropay \ -c.mchid 1230000109 \ -c.serial any \ -c.privateKey any \ -c.certs.any \ -c.secret your_merchant_secret_key_string \ -d.appid wxd678efh567hg6787 \ -d.mch_id 1230000109 \ -d.device_info 013467007045764 \ -d.nonce_str 5K8264ILTKCH16CQ2502SI8ZNMTM67VS \ -d.detail 'Image形象店-深圳腾大-QQ公仔' \ -d.spbill_create_ip 8.8.8.8 \ -d.out_trade_no '1217752501201407033233368018' \ -d.total_fee 100 \ -d.fee_type CNY \ -d.auth_code 120061098828009406 [代码] 以上述命令行为例,此命令请求的是v2版的「付款码支付」接口,[代码]any[代码] 为CLI模式保留字,意思即无需此参数(不能不给),[代码]1230000109[代码] 替换为你的商户号,[代码]your_merchant_secret_key_string[代码] 替换为你的[代码]API密钥[代码],这条命令就可以验证支付能力了。屏幕打印的日志类似如下: [代码]{ status: 200, statusText: 'OK', headers: { server: 'nginx', date: 'Tue, 30 Mar 2021 00:49:30 GMT', 'content-type': 'text/plain', 'content-length': '105', connection: 'keep-alive', 'keep-alive': 'timeout=8', 'request-id': '089AEB89830610A10518B9BF8C5820EA442882F601-47002, 089AEB89830610F6041896C2EEA30620F98004289CF805-0', 'mmlas-verifyresult': 'CAA=' }, config: { url: '/pay/micropay', method: 'post', data: '<xml><appid>wxd678efh567hg6787</appid><mch_id>1230000109</mch_id><device_info>013467007045764</device_info><nonce_str>5K8264ILTKCH16CQ2502SI8ZNMTM67VS</nonce_str><detail>Image形象店-深圳腾大-QQ公仔</detail><spbill_create_ip>8.8.8.8</spbill_create_ip><out_trade_no>1217752501201407033233368018</out_trade_no><total_fee>100</total_fee><fee_type>CNY</fee_type><auth_code>120061098828009406</auth_code><sign>C5524E8CEB11D1060A9FF0E696F52F51</sign></xml>', headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'text/xml; charset=utf-8', 'User-Agent': 'wechatpay-axios-plugin/0.5.1 axios/0.21.1 node/14.14.0 darwin/x64', 'Content-Length': 458 }, baseURL: 'https://api.mch.weixin.qq.com/', transformRequest: [ [Function: signer], [Function: toXml] ], transformResponse: [ [Function: toObject], [Function: verifier] ], timeout: 0, adapter: [Function: httpAdapter], responseType: 'text', xsrfCookieName: 'XSRF-TOKEN', xsrfHeaderName: 'X-XSRF-TOKEN', maxContentLength: -1, maxBodyLength: -1, httpsAgent: Agent { _events: [Object: null prototype], _eventsCount: 2, _maxListeners: undefined, defaultPort: 443, protocol: 'https:', options: [Object], requests: {}, sockets: {}, freeSockets: [Object], keepAliveMsecs: 1000, keepAlive: true, maxSockets: Infinity, maxFreeSockets: 256, scheduling: 'fifo', maxTotalSockets: Infinity, totalSocketCount: 0, maxCachedSessions: 100, _sessionCache: [Object], [Symbol(kCapture)]: false }, validateStatus: [Function: validateStatus], mchid: 1230000109, serial: 'any', privateKey: 'any', certs: { any: true }, secret: 'your_*********************tring' }, request: <ref *1> ClientRequest { _events: [Object: null prototype] { socket: [Function (anonymous)], abort: [Function (anonymous)], aborted: [Function (anonymous)], connect: [Function (anonymous)], error: [Function (anonymous)], timeout: [Function (anonymous)], prefinish: [Function: requestOnPrefinish] }, _eventsCount: 7, _maxListeners: undefined, outputData: [], outputSize: 0, writable: true, destroyed: true, _last: false, chunkedEncoding: false, shouldKeepAlive: true, _defaultKeepAlive: true, useChunkedEncodingByDefault: true, sendDate: false, _removedConnection: false, _removedContLen: false, _removedTE: false, _contentLength: null, _hasBody: true, _trailer: '', finished: true, _headerSent: true, socket: TLSSocket { _tlsOptions: [Object], _secureEstablished: true, _securePending: false, _newSessionPending: false, _controlReleased: true, secureConnecting: false, _SNICallback: null, servername: 'api.mch.weixin.qq.com', alpnProtocol: false, authorized: true, authorizationError: null, encrypted: true, _events: [Object: null prototype], _eventsCount: 9, connecting: false, _hadError: false, _parent: null, _host: 'api.mch.weixin.qq.com', _readableState: [ReadableState], _maxListeners: undefined, _writableState: [WritableState], allowHalfOpen: false, _sockname: null, _pendingData: null, _pendingEncoding: '', server: undefined, _server: null, ssl: [TLSWrap], _requestCert: true, _rejectUnauthorized: true, parser: null, _httpMessage: null, timeout: 0, [Symbol(res)]: [TLSWrap], [Symbol(verified)]: true, [Symbol(pendingSession)]: null, [Symbol(async_id_symbol)]: -1, [Symbol(kHandle)]: [TLSWrap], [Symbol(kSetNoDelay)]: false, [Symbol(lastWriteQueueSize)]: 0, [Symbol(timeout)]: null, [Symbol(kBuffer)]: null, [Symbol(kBufferCb)]: null, [Symbol(kBufferGen)]: null, [Symbol(kCapture)]: false, [Symbol(kBytesRead)]: 0, [Symbol(kBytesWritten)]: 0, [Symbol(connect-options)]: [Object], [Symbol(RequestTimeout)]: undefined }, _header: 'POST /pay/micropay HTTP/1.1\r\n' + 'Accept: application/json, text/plain, */*\r\n' + 'Content-Type: text/xml; charset=utf-8\r\n' + 'User-Agent: wechatpay-axios-plugin/0.5.1 axios/0.21.1 node/14.14.0 darwin/x64\r\n' + 'Content-Length: 458\r\n' + 'Host: api.mch.weixin.qq.com\r\n' + 'Connection: keep-alive\r\n' + '\r\n', _keepAliveTimeout: 0, _onPendingData: [Function: noopPendingOutput], agent: Agent { _events: [Object: null prototype], _eventsCount: 2, _maxListeners: undefined, defaultPort: 443, protocol: 'https:', options: [Object], requests: {}, sockets: {}, freeSockets: [Object], keepAliveMsecs: 1000, keepAlive: true, maxSockets: Infinity, maxFreeSockets: 256, scheduling: 'fifo', maxTotalSockets: Infinity, totalSocketCount: 0, maxCachedSessions: 100, _sessionCache: [Object], [Symbol(kCapture)]: false }, socketPath: undefined, method: 'POST', maxHeaderSize: undefined, insecureHTTPParser: undefined, path: '/pay/micropay', _ended: true, res: IncomingMessage { _readableState: [ReadableState], _events: [Object: null prototype], _eventsCount: 3, _maxListeners: undefined, socket: null, httpVersionMajor: 1, httpVersionMinor: 1, httpVersion: '1.1', complete: true, headers: [Object], rawHeaders: [Array], trailers: {}, rawTrailers: [], aborted: false, upgrade: false, url: '', method: null, statusCode: 200, statusMessage: 'OK', client: [TLSSocket], _consuming: false, _dumped: false, req: [Circular *1], responseUrl: 'https://api.mch.weixin.qq.com/pay/micropay', redirects: [], [Symbol(kCapture)]: false, [Symbol(RequestTimeout)]: undefined }, aborted: false, timeoutCb: null, upgradeOrConnect: false, parser: null, maxHeadersCount: null, reusedSocket: false, host: 'api.mch.weixin.qq.com', protocol: 'https:', _redirectable: Writable { _writableState: [WritableState], _events: [Object: null prototype], _eventsCount: 2, _maxListeners: undefined, _options: [Object], _ended: true, _ending: true, _redirectCount: 0, _redirects: [], _requestBodyLength: 458, _requestBodyBuffers: [], _onNativeResponse: [Function (anonymous)], _currentRequest: [Circular *1], _currentUrl: 'https://api.mch.weixin.qq.com/pay/micropay', [Symbol(kCapture)]: false }, [Symbol(kCapture)]: false, [Symbol(kNeedDrain)]: false, [Symbol(corked)]: 0, [Symbol(kOutHeaders)]: [Object: null prototype] { accept: [Array], 'content-type': [Array], 'user-agent': [Array], 'content-length': [Array], host: [Array] } }, data: { return_code: 'FAIL', return_msg: '缺少参数' } } [代码] 当你遇到困难,需要社区帮助的时候,类库也做了安全规范,把[代码]secret[代码]做了脱敏处理,上述日志你可以无脑地直接拷贝粘贴过来(再无脑也需要注意格式,以[代码]javascript[代码]代码形式粘贴,会方便阅读)。 海外主体 同样适用,在CLI驱动时,仅需在给一个 [代码]--baseURL[代码] 参数即可,如香港: 下载平台证书 [代码]./node_modules/.bin/wxpay crt \ --baseURL 'https://apihk.mch.weixin.qq.com' \ -m {商户号} \ -s {商户证书序列号} \ -f {商户私钥证书路径} \ -k {APIv3密钥(32字节)} \ -o {保存地址} [代码] 付款码支付 [代码]./node_modules/.bin/wxpay v2/pay/micropay \ --baseURL 'https://apihk.mch.weixin.qq.com' \ -c.mchid 1230000109 \ -c.serial any \ -c.privateKey any \ -c.certs.any \ -c.secret your_merchant_secret_key_string \ -d.appid wxd678efh567hg6787 \ -d.mch_id 1230000109 \ -d.device_info 013467007045764 \ -d.nonce_str 5K8264ILTKCH16CQ2502SI8ZNMTM67VS \ -d.detail 'Image形象店-深圳腾大-QQ公仔' \ -d.spbill_create_ip 8.8.8.8 \ -d.out_trade_no '1217752501201407033233368018' \ -d.total_fee 100 \ -d.fee_type CNY \ -d.auth_code 120061098828009406 [代码] 官方接口至少70%以上的接口,均可以在CLI模式上做快速体验,验证入参出参、调试,甚至可以作为CI工具集,希望能为你的开发带来帮助。 下一回 正统WEB编程。
2021-03-30 - 如何用十个月时间,做出一款迄今为止无法超越的微信支付SDK基础开发包
20年中在对接商家券的时候,翻了官方PHP SDK的牌子,0.1系缺个媒体文件上传比较优雅的解决方案,于是顺手就为其提了PR。之后,我就在思考,动态编程语言讲究的快,是不是应该可以在做点儿好玩的,有意义的,可以加速开发对接的,不被人抱怨的开发包呢。索性,仗着多年的从业经验,尝试从底层开始写,不断迭代,于是就有了这么一款小巧,低依赖,符合规范,面向对象模式,命令行模式,适用涵盖Windows、macOS、Linux的微信支付nodejs版基础开发包。 下面我以版本迭代时间线为过程,来讲讲这款SDK开发包的开发故事。 v0.0.1 初始版本 这个版本,发布于2020年6月26号,核心是3个类,4个文件共计11个方法,已经是完整实现了APIv3的技术规范:[代码]AES-GCM[代码]对称加解密,[代码]RSA-OAEP[代码]非对称加解密及验签规范。[代码]RSA-OAEP[代码]的原生公钥加密、私钥解密方法封装,是翻过无数遍nodejs的文档,最后精炼到各5行代码共计10行,这应该是nodejs中,[代码]RSA-OAEP[代码]加解密原生实现的最早封装。 v0.1.0 正式版本 这个系列,发布于2020年7月2号。在申城最热的一段时间,晚上睡不着就捋代码呗,哔哩啪啦猛敲代码(其实也没多少行代码,全被精简了),迭代了共计十个版本,于是就有了这么一个正式版。 v0.2 面向对象模式开发 这个系列,发布于2020年7月7号,止于2020年9月9号,四个版本。重要功能就是可以顺溜地使用动态对象来对接APIv3接口了,同时降低NodeJS版本兼容至10.15(这个是云开发的环境版本)。详细可阅读微信支付APIv3的Nodejs版SDK,让开发变得简单不再繁琐。 v0.3 支持APIv2调用 这个系列,发布于2020年9月11号,止于2021年1月22号。是个被“逼”出来的系列。这个系列加入了APIv2版的接口驱动,重要功能就是对「付款码支付」及「退款」能力的支持。详细可阅读适合云开发的微信支付v2及v3版Nodejs SDK,这就不展开了。在这一段时间里,有太多事情要跟进,所以一直没有太多时间写代码。展期了好久好久才进入了重要的下一重构时刻。 v0.4 同质化v2&v3 OOP开发 这个系列,发布于2021年2月28号,止于2021年3月25日,五个版本。这个系列的由来是因一位同学提的issue展开的,于是做了超量重构,不仅改写了入口程序,还加强了APIv2版的同质化调用,详细可阅读微信支付开发,可以简化到复制+粘贴这种地步 及纯属意外,还可以这么起底微信支付接口对接。 [图片] 有那么一瞬间,我以为这个版本就会是终点了,可以进入LTS版仅需长期维护了。殊不知,CLI工具集还有一漏勺没有完善好,那好吧,那就再来一波加强重组呗。 v0.5 命令行工具集 这个系列,发布于2021年3月27号,加强了命令行工具集,请求微信支付官方接口,现在可以在命令行上跑起来了。英文slogan就是: Play the OpenAPI requests over command line。 这得解释解释,为啥用“Play”呢,而不是其他词儿呢?其实这个功能早在20年10月,chain支付宝OpenAPI接口时,脑阔开了个洞,想出来的。微信支付的CLI工具已经迟到好久了,现在好了,两款驰名的支付平台,都可以如丝质般顺溜,搞起CI了。这就是play的由来——为开发而生: commit to play for running。 说说功能点,这个系列就加强了命令行方式与接口交互能力,同时降级了自0.0.4版就一直存在的 「平台证书」下载器 为工具集内一条特殊命令。详细玩法可阅读 开发系列之「起步」 及 真香:一行命令即可体验「微信支付」全系接口能力。 另外,这个加强重构版的CLI交互工具,从设计一开始就支持扩展能力,有兴趣的同学完全可以基于[代码]bin/cli/request.js[代码]构建出更多子命令,不干扰纯应用,跑着欢实就好。 NEXT 如果还有NEXT,那可能就是得把tsd文件翻新一下,[代码]ProxyConstructor[代码]那块是[代码]TypeScript[代码] 自4.0引入,动态属性类型签名的高级甚高级用法,还不太会,学习中,如果有娴熟的同学,可以提PR贡献一下。 哦,对了,文章如果看着还不错,SDK用着还顺手,那就star一下repo吧 https://github.com/TheNorthMemory/wechatpay-axios-plugin MIT开源,纯免费,随便用。做开源不易,况且还是为国民应用做底层基础开发包就更是难啊,平时又太忙,只能是按需来做,有空再续。
2021-05-10 - AES-GCM在Node10解密消息时有可能崩溃,使用云开发环境的同学需要进来看一下
背景知识 [代码]AES-GCM[代码]是微信支付APIv3的加解密方案之一,定义可见rfc5116,v3使用的是[代码]aead_aes_256_gcm[代码]。稍微补充一个[代码]aead[代码]的的描述,[代码]aead[代码]加密方式与其他对称加密方式主要不同的地方就是: 它每一段密文必定有对应的校验码,通过核对校验码来判断密文是否完整。 APIv3回调通知和平台证书下载文档上有介绍[代码]AES-GCM[代码]的使用场景。nodejs原生[代码]crypto[代码]模块,在处理[代码]GCM[代码]模式解密时,从变更历史上看,[代码]Node11[代码]加入了强制校验[代码]auth_tag[代码](authentication tag)长度规则,[代码]Node10[代码]目前全系列还没有合并这个向前兼容规则,详情可见 https://github.com/nodejs/node/pull/20039 。 测试代码 先上一段测试用js代码,来复现 nodejs#20039 上连带反馈的问题: [代码]const crypto = require('crypto') const decrypt = (ciphertext, key, iv, aad = '') => { const buf = Buffer.from(ciphertext, 'base64') const tag = buf.slice(-16) const payload = buf.slice(0, -16) const decipher = crypto.createDecipheriv( 'aes-256-gcm', key, iv ).setAuthTag(tag).setAAD(Buffer.from(aad)) return Buffer.concat([ decipher.update(payload, 'hex'), decipher.final() ]).toString('utf8') } const mockupIv = 'abcdef0123456789' const mockupKey = 'abcdef0123456789abcdef0123456789' try { decrypt('', mockupKey, mockupIv) } catch {} [代码] 上述代码,在node10.15-10.24,均抛出如下不可捕获的错误(fatal error),程序会直接挂掉,在12-15之间,可以正常运行。 错误日志 类似如下: [代码]node[97219]: ../src/node_crypto.cc:3047:CipherBase::UpdateResult node::crypto::CipherBase::Update(const char *, int, unsigned char **, int *): Assertion `MaybePassAuthTagToOpenSSL()' failed. 1: 0x100d69661 node::Abort() (.cold.1) [/Users/james/.nvm/versions/node/v10.24.0/bin/node] 2: 0x10003aeb4 node_module_register [/Users/james/.nvm/versions/node/v10.24.0/bin/node] 3: 0x100039fb9 node::AddEnvironmentCleanupHook(v8::Isolate*, void (*)(void*), void*) [/Users/james/.nvm/versions/node/v10.24.0/bin/node] 4: 0x100112fae node::StringBytes::InlineDecoder::Decode(node::Environment*, v8::Local<v8::String>, v8::Local<v8::Value>, node::encoding) [/Users/james/.nvm/versions/node/v10.24.0/bin/node] 5: 0x1001119dc node::crypto::CipherBase::Update(v8::FunctionCallbackInfo<v8::Value> const&) [/Users/james/.nvm/versions/node/v10.24.0/bin/node] 6: 0x1002386c3 v8::internal::FunctionCallbackArguments::Call(v8::internal::CallHandlerInfo*) [/Users/james/.nvm/versions/node/v10.24.0/bin/node] 7: 0x100237bae v8::internal::MaybeHandle<v8::internal::Object> v8::internal::(anonymous namespace)::HandleApiCallHelper<false>(v8::internal::Isolate*, v8::internal::Handle<v8::internal::HeapObject>, v8::internal::Handle<v8::internal::HeapObject>, v8::internal::Handle<v8::internal::FunctionTemplateInfo>, v8::internal::Handle<v8::internal::Object>, v8::internal::BuiltinArguments) [/Users/james/.nvm/versions/node/v10.24.0/bin/node] 8: 0x10023728a v8::internal::Builtin_Impl_HandleApiCall(v8::internal::BuiltinArguments, v8::internal::Isolate*) [/Users/james/.nvm/versions/node/v10.24.0/bin/node] 9: 0x37d3d8d5bf3d 10: 0x37d3d8d118d5 11: 0x37d3d8d0a5c3 12: 0x37d3d8d118d5 13: 0x37d3d8d0a5c3 [1] 97218 abort npm test [代码] 上述错误日志,发生在我本地的[代码]Node10[代码]环境中。我花了几个小时,翻了好几遍github issues,最后找到了 nodejs#20039 pull requests,通读下来并反复测试了10.19-10.24版本,均无法正常捕获,这应该是上述pr没合并至[代码]Node10[代码]系列所致。 产生条件 稍微分析一下,可能产生致命错误的条件: 密文为空字符串时,程序会崩 密文为 [代码]Cg==[代码](base64空字符串) CLI会有 Warning DEP0090 弹出 (node:987) [DEP0090] DeprecationWarning: Permitting authentication tag lengths of 1 bytes is deprecated. Valid GCM tag lengths are 4, 8, 12, 13, 14, 15, 16. 微信支付官方文档在解密示例代码 常量定义了这个[代码]auth_tag[代码]长度为128位16字节,匹配rfc5116规范并且取的是最大值。 这下问题来了,万一无法正常获取到待解密字符串或者获取到的是空字符串,[代码]GCM[代码]模式校验码位又必须是16字节,业务逻辑又强依赖解密后字符串(验签证书是v3通讯强依赖)这崩掉了,着急上火的可真就是摊上事儿了! 向前兼容方案 找到问题关键点,那就打个业务逻辑补丁:应用端,对输入待解密字符串,做长度校验,长度为0的,不进入解密函数;或者可以采用如下向前兼容js patch补丁: [代码]- ).setAuthTag(tag).setAAD(Buffer.from(aad)) + ) + + // Restrict valid GCM tag length, patches for Node < 11.0.0 + // more @see https://github.com/nodejs/node/pull/20039 + const tagLen = tag.length + if (tagLen > 16 || (tagLen < 12 && tagLen != 8 && tagLen != 4)) { + let backport = new TypeError(`Invalid authentication tag length: ${tagLen}`) + backport.code = 'ERR_CRYPTO_INVALID_AUTH_TAG' + throw backport + } + decipher.setAuthTag(tag).setAAD(Buffer.from(aad)) [代码] 上述代码取自 wechatpay-axios-plugin@aa36a56,也已随源码用例覆盖[代码]Node[代码]10-15版本,均达预期,可安全使用。 可能的影响面 小程序云开发标配目前是[代码]Node10[代码],不清楚云开发团队在处理[代码]消息通知及关键信息解密[代码]时,是否采用的是轻量化如nodejs原生[代码]crypto[代码]这样的解决方案,这个就需要云产品团队相关的同学进来看看,评估一下有无风险点了。 对自主对接云开发的开发者来说,建议尽快给打下业务逻辑补丁或者程序解密补丁,避免不可预期的错误发生(虽然极小概率,但支付的事,可真不是小事儿)。 题外话 建议云开发平台,能够升级一下[代码]Node10[代码]至最新[代码]lts[代码]运行时,一并建议能同时支持[代码]Node12[代码]、[代码]Node14[代码]运行时。
2021-03-10 - 微信支付开发,可以简化到复制+粘贴这种地步
距离上一版(0.3)过去了有几个月了,这几个月没少折腾微信团队,春节前后“突然”利好不断,许多几乎不可能的事项都有显著改善,这也许就是认(一)真(本)做(正)事(经)的回报吧。2月下旬的时候,有一小伙伴,开了个issue#10 反馈,lib在变量重入的时候,存在bug,无法正确获取订单信息。仔细推演之后,决定重构一下,把树形结构[代码]Wechatpay SDK[代码]起个底儿,许多(BT)用法,真的是事后测试用例覆盖时才发现,啪一下吧。 0.4版做了这些改进 [代码]README[代码]文档中文化 重构 [代码]Wechatpay[代码] 类,同时支持 [代码]APIv2&v3[代码] 链式调用 改变 [代码]Wechatpay.client[代码] 返回值为[代码]Wechatpay.client.v3[代码],[代码]Wechatpay.client.v2[代码] 为 [代码]xmlBased[代码] 接口客户端 废弃 [代码]withEntities[代码] 方法,其在链式多次调用时,有可能达不到预期 解决 [代码]AES-GCM[代码]在[代码]Node10[代码]上的解密兼容性问题,程序在[代码]Node10[代码]上有可能崩溃,建议[代码]Node10[代码]用户升级至此版本 支持 企业微信-企业支付 链式调用,需要额外注入签名规则,见用法示例 优化[代码]Wechatpay[代码]在多次实例化时赋值[代码]Symbol(CLIENT)[代码]异常问题,增加[代码]wechatpay.test.js[代码]测试用例覆盖 链式chain调用 这个想法是自0.2版开始,是把[代码]URL.pathname[代码]以[代码]/[代码]做切分,映射成对象属性,0.4版做了极致优化,支持APIv2的[代码]pathname[代码]映射,效果较上一贴 微信支付APIv3的Nodejs版SDK,打印的树形更优美了,效果如下: [代码][Function (anonymous)] { v2: [Function: /v2] { risk: [Function: /v2/risk] { getpublickey: [Function: /v2/risk/getpublickey] }, pay: [Function: /v2/pay] { micropay: [Function: /v2/pay/micropay] }, secapi: [Function: /v2/secapi] { pay: [Function: /v2/secapi/pay] { refund: [Function: /v2/secapi/pay/refund] } }, mmpaymkttransfers: [Function: /v2/mmpaymkttransfers] { sendredpack: [Function: /v2/mmpaymkttransfers/sendredpack], promotion: [Function: /v2/mmpaymkttransfers/promotion] { transfers: [Function: /v2/mmpaymkttransfers/promotion/transfers], paywwsptrans2pocket: [Function: /v2/mmpaymkttransfers/promotion/paywwsptrans2pocket] }, sendworkwxredpack: [Function: /v2/mmpaymkttransfers/sendworkwxredpack] } }, v3: [Function: /v3] { pay: [Function: /v3/pay] { transactions: [Function: /v3/pay/transactions] { native: [Function: /v3/pay/transactions/native], id: [Function: /v3/pay/transactions/id] { '{transaction_id}': [Function: /v3/pay/transactions/id/{transaction_id}] }, outTradeNo: [Function: /v3/pay/transactions/out-trade-no] { '{out_trade_no}': [Function: /v3/pay/transactions/out-trade-no/{out_trade_no}] } } favor: [Function: /v3/marketing/favor] { media: [Function: /v3/marketing/favor/media] { imageUpload: [Function: /v3/marketing/favor/media/image-upload] }, stocks: [Function: /v3/marketing/favor/stocks] { '$stock_id$': [Function: /v3/marketing/favor/stocks/{stock_id}] { useFlow: [Function: /v3/marketing/favor/stocks/{stock_id}/use-flow] } } }, partnerships: [Function: /v3/marketing/partnerships] { build: [Function: /v3/marketing/partnerships/build] } }, smartguide: [Function: /v3/smartguide] { guides: [Function: /v3/smartguide/guides] { '$guide_id$': [Function: /v3/smartguide/guides/{guide_id}] { assign: [Function: /v3/smartguide/guides/{guide_id}/assign] } } } } } [代码] 上述 [代码]/v2[代码] 树节点,是本SDK“创造”出来的,用以区分APIv3调用,默认不是 [代码]/v2[代码]开头的节点,均以APIv3方式调用; 这么做是因为,微信支付APIv3版,[代码]URL.pathname[代码] 目前已知至少有一个不是以 [代码]/v3[代码] 开头的; [代码]/v2[代码] 官方又没用到,咱就先占着用吧,所以如是APIv2版的调用,可以这么 [代码]chain[代码], [代码]v2.pay.micropay = /v2/pay/micropay[代码] 即以XML负载形式请求远端接口; 较0.2版,每个级联对象为APIv2做了改变,每个节点本身是个[代码]Function[代码],链接的是HTTP POST方法,入参接受两个参数 [代码](data, config)[代码],返回值是[代码]Promise[代码]对象; 另外每个节点同时隐式内置了[代码]GET/POST/PUT/PATCH/DELETE[代码] 操作方法链,支持全大写及全小写(未来有可能会删除)两种编码方式,完美支持APIv3的多HTTP verbs协议(目前统计出来,有至少5处蓝瘦的地方,有空了再说)。 怎么用 一句话可能就说完了,实例化后,按[代码]pathname[代码]拆分,然后再执行文档上说的HTTP verbs方法,附带所需参数,然后就没有然后了~ 实例化 [代码]const {Wechatpay, Formatter} = require('wechatpay-axios-plugin') const wxpay = new Wechatpay({ mchid: 'your_merchant_id', serial: 'serial_number_of_your_merchant_public_cert', privateKey: '-----BEGIN PRIVATE KEY-----' + '...' + '-----END PRIVATE KEY-----', certs: { 'serial_number': '-----BEGIN CERTIFICATE-----' + '...' + '-----END CERTIFICATE-----', }, // APIv2参数 >= 0.4.0 开始支持 secret: 'your_merchant_secret_key_string', // 注: 如果不涉及资金变动,如仅收款,merchant参数可选,仅需 `secret` 一个参数,注意其为v2版的。 merchant: { cert: '-----BEGIN CERTIFICATE-----' + '...' + '-----END CERTIFICATE-----', key: '-----BEGIN PRIVATE KEY-----' + '...' + '-----END PRIVATE KEY-----', // or // passphrase: 'your_merchant_id', // pfx: fs.readFileSync('/your/merchant/cert/apiclient_cert.p12'), }, }) [代码] APIv3 Native下单 [代码]wxpay.v3.pay.transactions.native .post({/*文档参数放这里就好*/}) .then(({data: {code_url}}) => console.info(code_url)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] APIv3 查询订单 [代码]wxpay.v3.pay.transactions.id['{transaction_id}'] .get({params: {mchid: '1230000109'}, transaction_id: '1217752501201407033233368018'}) .then(({data}) => console.info(data)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] APIv3 关单 [代码]wxpay.v3.pay.transactions.outTradeNo['1217752501201407033233368018'] .post({mchid: '1230000109'}) .then(({status, statusText}) => console.info(status, statusText)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] APIv3 对账单下载及解析 [代码]const assert = require('assert') const {Hash: {sha1}} = require('wechatpay-axios-plugin') wxpay.v3.bill.tradebill.get({ params: { bill_date: '2021-02-12', bill_type: 'ALL', } }).then(({data: {download_url, hash_value}}) => wxpay.v3.billdownload.file.get({ params: (new URL(download_url)).searchParams, signed: hash_value, responseType: 'arraybuffer', })).then(res => { assert(sha1(res.data.toString()) === res.config.signed, 'verify the SHA1 digest failed.') console.info(Formatter.castCsvBill(res.data)) }).catch(error => { console.error(error) }) [代码] APIv2 Native下单 [代码]wxpay.v2.pay.unifiedorder .post({/*文档参数放这里就好*/}) .then(({data: {code_url}}) => console.info(code_url)) .catch(console.error) [代码] APIv2 付款码(刷卡)支付 [代码]wxpay.v2.pay.micropay({ appid: 'wx8888888888888888', mch_id: '1900000109', nonce_str: Formatter.nonce(), sign_type: 'HMAC-SHA256', body: 'image形象店-深圳腾大-QQ公仔', out_trade_no: '1217752501201407033233368018', total_fee: 888, fee_type: 'CNY', spbill_create_ip: '8.8.8.8', auth_code: '120061098828009406', }) .then(res => console.info(res.data)) .catch(console.error) [代码] APIv2 现金红包 [代码]wxpay.v2.mmpaymkttransfers.sendredpack({ nonce_str: Formatter.nonce(), mch_billno: '10000098201411111234567890', mch_id: '10000098', wxappid: 'wx8888888888888888', send_name: '鹅企支付', re_openid: 'oxTWIuGaIt6gTKsQRLau2M0yL16E', total_amount: 1000, total_num: 1, wishing: 'HAPPY BIRTHDAY', client_ip: '192.168.0.1', act_name: '回馈活动', remark: '会员回馈活动', scene_id: 'PRODUCT_4', }) .then(res => console.info(res.data)) .catch(console.error) [代码] APIv2 企业付款到零钱 [代码]wxpay.v2.mmpaymkttransfers.promotion.transfers({ appid: 'wx8888888888888888', mch_id: '1900000109', partner_trade_no: '10000098201411111234567890', openid: 'oxTWIuGaIt6gTKsQRLau2M0yL16E', check_name: 'FORCE_CHECK', re_user_name: '王小王', amount: 10099, desc: '理赔', spbill_create_ip: '192.168.0.1', nonce_str: Formatter.nonce(), }) .then(res => console.info(res.data)) .catch(console.error) [代码] APIv2 企业付款到银行卡-获取RSA公钥 这个用法,是测试用例发掘出来的,注意请求带了第二个参数 [代码]{baseURL:'https://fraud.mch.weixin.qq.com'}[代码] [代码]wxpay.v2.risk.getpublickey({ mch_id: '1900000109', sign_type: 'MD5', nonce_str: Formatter.nonce(), }, { baseURL: 'https://fraud.mch.weixin.qq.com' }) .then(res => console.info(res.data)) .catch(console.error) [代码] 企业微信 企业微信的企业支付,数据请求包需要额外的签名,仅需做如下简单扩展适配,即可支持;以下签名注入函数所需的两个参数[代码]agentId[代码] [代码]agentSecret[代码]来自企业微信工作台,以下为示例值。 [代码]const agentId = 1001001 const agentSecret = 'from_wework_agent_special_string' const {Hash} = require('wechatpay-axios-plugin') [代码] APIv2 企业红包-注入签名规则 [代码]Wechatpay.client.v2.defaults.transformRequest.unshift(function workwxredpack(data, headers) { const {act_name, mch_billno, mch_id, nonce_str, re_openid, total_amount, wxappid} = data if (!(act_name && mch_billno && mch_id && nonce_str && re_openid && total_amount && wxappid)) { return data } data.workwx_sign = Hash.md5( Formatter.queryStringLike(Formatter.ksort({ act_name, mch_billno, mch_id, nonce_str, re_openid, total_amount, wxappid })), agentSecret, agentId ).toUpperCase() return data }) [代码] APIv2 发放企业红包 [代码]wxpay.v2.mmpaymkttransfers.sendworkwxredpack({ mch_billno: '123456', wxappid: 'wx8888888888888888', sender_name: 'XX活动', sender_header_media_id: '1G6nrLmr5EC3MMb_-zK1dDdzmd0p7cNliYu9V5w7o8K0', re_openid: 'oxTWIuGaIt6gTKsQRLau2M0yL16E', total_amount: 1000, wishing: '感谢您参加猜灯谜活动,祝您元宵节快乐!', act_name: '猜灯谜抢红包活动', remark: '猜越多得越多,快来抢!', mch_id: '1900000109', nonce_str: Formatter.nonce(), }) .then(res => console.info(res.data)) .catch(console.error) [代码] APIv2及APIv3之间的编程体验,几乎是完全一致的了~ 更多用法示例,已经完全中文化发在github上,地址是:https://github.com/TheNorthMemory/wechatpay-axios-plugin 花式开发玩法,下一耙再叙~
2021-03-08 - 纯属意外,还可以这么起底微信支付接口对接
上一扒讲到的,从设计初是按照[代码]URL.pathname[代码]切分出实体entity做属性树扩展,map服务端服务接口;重构完后,再写测试用例的时候,竟然发现了有如下“变态”用法,这不得不也得分享出来,以供絮叨。 再提一次树 以目标函数 [代码][Function: /v2/pay/micropay][代码] 为例,这里有一种更简洁的构建方式,即[代码]Object.property[代码]方式构建,可以把层叠树平展展成类似一层[代码]key/value[代码]结构,树构建代码如下: [代码]// 实例化不表了,这里直接以wxpay代指实例化handler wxpay['v3/pay/transactions/native'] wxpay['v3/pay/transactions/id/{transaction_id}'] wxpay['v3/pay/transactions/out-trade-no/{out_trade_no}'] wxpay['v3/pay/partner/transactions/native'] wxpay['v3/marketing/busifavor/users/{openid}/coupons/{coupon_code}/appids/{appid}'] wxpay['v3/marketing/favor/stocks/{stock_id}/use-flow'] wxpay['v3/marketing/partnerships/build'] wxpay['v3/marketing/favor/media/image-upload'] wxpay['v2/pay/micropay'] wxpay['v2/risk/getpublickey'] wxpay['v2/secapi/pay/refund'] wxpay['v2/mmpaymkttransfers/promotion/transfers'] wxpay['v2/mmpaymkttransfers/promotion/paywwsptrans2pocket'] wxpay['v2/mmpaymkttransfers/sendworkwxredpack'] // ... ;console.info(wxpay) [代码] 打印出的结构如下: [代码][Function (anonymous)] { 'v3/pay/transactions/native': [Function: /v3/pay/transactions/native], 'v3/pay/transactions/id/{transaction_id}': [Function: /v3/pay/transactions/id/{transaction_id}], 'v3/pay/transactions/out-trade-no/{out_trade_no}': [Function: /v3/pay/transactions/out-trade-no/{out_trade_no}], 'v3/pay/partner/transactions/native': [Function: /v3/pay/partner/transactions/native], 'v3/marketing/busifavor/users/{openid}/coupons/{coupon_code}/appids/{appid}': [Function: /v3/marketing/busifavor/users/{openid}/coupons/{coupon_code}/appids/{appid}], 'v3/marketing/favor/stocks/{stock_id}/use-flow': [Function: /v3/marketing/favor/stocks/{stock_id}/use-flow], 'v3/marketing/partnerships/build': [Function: /v3/marketing/partnerships/build], 'v3/marketing/favor/media/image-upload': [Function: /v3/marketing/favor/media/image-upload], 'v3/bill/tradebill': [Function: /v3/bill/tradebill], 'v3/billdownload/file': [Function: /v3/billdownload/file], 'v2/pay/micropay': [Function: /v2/pay/micropay], 'v2/risk/getpublickey': [Function: /v2/risk/getpublickey], 'v2/secapi/pay/refund': [Function: /v2/secapi/pay/refund], 'v2/mmpaymkttransfers/promotion/transfers': [Function: /v2/mmpaymkttransfers/promotion/transfers], 'v2/mmpaymkttransfers/promotion/paywwsptrans2pocket': [Function: /v2/mmpaymkttransfers/promotion/paywwsptrans2pocket], 'v2/mmpaymkttransfers/sendworkwxredpack': [Function: /v2/mmpaymkttransfers/sendworkwxredpack] } [代码] 毫无疑问,这种方式结构看起来相对更简洁一些,相应地,编程语法糖,可以这么写了~ APIv3 Native下单 [代码]wxpay['v3/pay/transactions/native']({ /*文档参数放这里就好*/ }) .then(({data: {code_url}}) => console.info(code_url)) .catch(console.error) [代码] APIv3 查询订单 [代码]wxpay['v3/pay/transactions/id/{transaction_id}'].get({ params: {mchid: '1230000109'}, transaction_id: '1217752501201407033233368018' }) .then(({data}) => console.info(data)) .catch(console.error) [代码] APIv3 关单 [代码]wxpay['v3/pay/transactions/out-trade-no/{out_trade_no}']({ mchid: '1230000109' }, { out_trade_no: '1217752501201407033233368018' } ) .then(({status, statusText}) => console.info(status, statusText)) .catch(console.error) [代码] APIv3 对账单下载及解析 [代码]const assert = require('assert') const {Hash: {sha1}} = require('wechatpay-axios-plugin') wxpay['v3/bill/tradebill'].get({ params: { bill_date: '2021-02-12', bill_type: 'ALL', } }).then(({data: {download_url, hash_value}}) => wxpay['v3/billdownload/file'].get({ params: (new URL(download_url)).searchParams, signed: hash_value, responseType: 'arraybuffer', })).then(res => { assert(sha1(res.data.toString()) === res.config.signed, 'verify the SHA1 digest failed.') console.info(Formatter.castCsvBill(res.data)) }).catch(console.error) [代码] APIv2 付款码(刷卡)支付 [代码]wxpay['v2/pay/micropay']({ appid: 'wx8888888888888888', mch_id: '1900000109', nonce_str: Formatter.nonce(), sign_type: 'HMAC-SHA256', body: 'image形象店-深圳腾大-QQ公仔', out_trade_no: '1217752501201407033233368018', total_fee: 888, fee_type: 'CNY', spbill_create_ip: '8.8.8.8', auth_code: '120061098828009406', }) .then(res => console.info(res.data)) .catch(console.error) [代码] 现金红包 [代码]wxpay['v2/mmpaymkttransfers/sendredpack']({ nonce_str: Formatter.nonce(), mch_billno: '10000098201411111234567890', mch_id: '10000098', wxappid: 'wx8888888888888888', send_name: '鹅企支付', re_openid: 'oxTWIuGaIt6gTKsQRLau2M0yL16E', total_amount: 1000, total_num: 1, wishing: 'HAPPY BIRTHDAY', client_ip: '192.168.0.1', act_name: '回馈活动', remark: '会员回馈活动', scene_id: 'PRODUCT_4', }) .then(res => console.info(res.data)) .catch(console.error) [代码] 企业付款到零钱 [代码]wxpay['v2/mmpaymkttransfers/promotion/transfers']({ appid: 'wx8888888888888888', mch_id: '1900000109', partner_trade_no: '10000098201411111234567890', openid: 'oxTWIuGaIt6gTKsQRLau2M0yL16E', check_name: 'FORCE_CHECK', re_user_name: '王小王', amount: 10099, desc: '理赔', spbill_create_ip: '192.168.0.1', nonce_str: Formatter.nonce(), }) .then(res => console.info(res.data)) .catch(console.error) [代码] 企业付款到银行卡-获取RSA公钥 这个用法,是测试用例发掘出来的,注意请求带了第二个参数 [代码]{baseURL:'https://fraud.mch.weixin.qq.com'}[代码] [代码]wxpay['v2/risk/getpublickey']({ mch_id: '1900000109', sign_type: 'MD5', nonce_str: Formatter.nonce(), }, { baseURL: 'https://fraud.mch.weixin.qq.com' }) .then(res => console.info(res.data)) .catch(console.error) [代码] 发放企业红包 [代码]wxpay['v2/mmpaymkttransfers/sendworkwxredpack']({ mch_billno: '123456', wxappid: 'wx8888888888888888', sender_name: 'XX活动', sender_header_media_id: '1G6nrLmr5EC3MMb_-zK1dDdzmd0p7cNliYu9V5w7o8K0', re_openid: 'oxTWIuGaIt6gTKsQRLau2M0yL16E', total_amount: 1000, wishing: '感谢您参加猜灯谜活动,祝您元宵节快乐!', act_name: '猜灯谜抢红包活动', remark: '猜越多得越多,快来抢!', mch_id: '1900000109', nonce_str: Formatter.nonce(), }) .then(res => console.info(res.data)) .catch(console.error) [代码] 这么搞下去。。。还愁啥对接啊!一致开发体验,尽在: https://github.com/TheNorthMemory/wechatpay-axios-plugin
2021-03-09 - 微信支付APIv3图片、视频上传类接口nodejs纯语言无依赖版参考实现
受官方技术助手伙伴在某次问答亲测有效代码启发,以下ES2015代码为v3上传文件文档补充实现而做,供其他开发语言参考实现。 上传媒体文件API的是基于 RFC2388 协议,圈定的范围是,有大小限制的图片(2M)或者视频(5M)类型单文件,按API接口标准,需要对文件的meta{filename,sha256}信息做数据签名,并且这个meta还须随请求载核一同发送给服务端,难就难在这里了,唉嘘~ 官方文档小分队犯了一个错误,就是试图用文本语言来表达非字符内容,整得一众开发者迷途了,社区反馈波澜滔滔。这支小分队应该每人扣一个长鹅抱宠,捐给像俺这样努力帮扶开发者的贡献者(😄)。其实rfc2388标准上有写,这个协议就是扩展HTTP文本协议,用来传输非字符内容的,引述如下: multipart/form-data can be used for forms that are presented using representations other than HTML (spreadsheets, Portable Document Format, etc), and for transport using other means than electronic mail or HTTP. This document defines the representation of form values independently of the application for which it is used. 上述引述内容,提到3种文件,HTML文件还算是字符型文件,表格及PDF文件就已经算是二进制文件了,文件内容人类得借助专用软件翻译,才能转成可被识别内容(肉眼能直接识别的是大牛,不再此列)。受官方技术助手伙伴在某次问答亲测有效代码截图启示,特意又研读了几遍RFC协议,国庆档给抽出成无依赖ES2015版本,已内置于另一款著名支付产品SDK包中,亲测可用,以下版本是微信支付社区特供,单文件、无依赖,适合云开发集成使用。 废话说了一箩筐,还是上代码吧: [代码]const {extname} = require('path') /** * Simple and lite of `multipart/form-data` implementation, most similar to `form-data` * * ```js * (new Form) * .append('a', 1) * .append('b', '2') * .append('c', Buffer.from('31')) * .append('d', JSON.stringify({}), 'any.json') * .append('e', require('fs').readFileSync('/path/your/file.jpg'), 'file.jpg') * .getBuffer() * ``` */ class Form { /** * Create a `multipart/form-data` buffer container for the file uploading. * * @constructor */ constructor() { Object.defineProperties(this, { /** * built-in mime-type mapping * @type {Object<string,string>} */ mimeTypes: { value: { bmp: `image/bmp`, gif: `image/gif`, png: `image/png`, jpg: `image/jpeg`, jpe: `image/jpeg`, jpeg: `image/jpeg`, mp4: `video/mp4`, mpeg: `video/mpeg`, json: `application/json`, }, configurable: false, enumerable: false, writable: true, }, /** * @type {Buffer} */ dashDash: { value: Buffer.from(`--`), configurable: false, enumerable: false, writable: false, }, /** * @type {Buffer} */ boundary: { value: Buffer.from(`${`-`.repeat(26)}${`0`.repeat(24).replace(/0/g, () => Math.random()*10|0)}`), configurable: false, enumerable: false, writable: false, }, /** * @type {Buffer} */ CRLF: { value: Buffer.from(`\r\n`), configurable: false, enumerable: false, writable: false, }, /** * The Form's data storage * @type {array<Buffer>} */ data: { value: [], configurable: false, enumerable: true, writable: true, }, /** * The entities' value indices whose were in `this.data` * @type {Object<string, number>} */ indices: { value: {}, configurable: false, enumerable: true, writable: true, }, }) } /** * To retrieve the `data` buffer * * @return {Buffer} - The payload buffer */ getBuffer() { return Buffer.concat([ this.dashDash, this.boundary, this.CRLF, ...this.data.slice(0, -2), this.boundary, this.dashDash, this.CRLF, ]) } /** * To retrieve the `Content-Type` multipart/form-data header * * @return {Object<string, string>} - The `Content-Type` header With `this.boundary` */ getHeaders() { return { 'Content-Type': `multipart/form-data; boundary=${this.boundary}` } } /** * Append a customized mime-type(s) * * @param {Object<string,string>} things - The mime-type * * @return {Form} - The `Form` class instance self */ appendMimeTypes(things) { Object.assign(this.mimeTypes, things) return this } /** * Append data wrapped by `boundary` * * @param {string} field - The field * @param {string|Buffer} value - The value * @param {String} [filename] - Optional filename, when provided, then append the `Content-Type` after of the `Content-Disposition` * * @return {Form} - The `Form` class instance self */ append(field, value, filename = '') { const {data, dashDash, boundary, CRLF, mimeTypes, indices} = this data.push(Buffer.from(`Content-Disposition: form-data; name="${field}"${filename && Buffer.isBuffer(value) ? `; filename="${filename}"` : ``}`)) data.push(CRLF) if (filename || Buffer.isBuffer(value)) { data.push(Buffer.from(`Content-Type: ${mimeTypes[extname(filename).substring(1).toLowerCase()] || `application/octet-stream`}`)) data.push(CRLF) } data.push(CRLF) indices[field] = data.push(Buffer.isBuffer(value) ? value : Buffer.from(String(value))) data.push(CRLF) data.push(dashDash) data.push(boundary) data.push(CRLF) return this } } module.exports = Form module.exports.default = Form [代码] 测试用例如下: [代码]lib/form ✓ should be class `Form` new Form ✓ should instanceOf Form and have properties `data` and `indices` ✓ The `mimeTypes` property should be there and only allowed append(cannot deleted) ✓ The `dashDash` Buffer property should be there and cannot be deleted/modified ✓ The `boundary` Buffer property should be there and cannot be deleted/modified ✓ The `CRLF` Buffer property should be there and cannot be deleted/modified ✓ The `data` property should be instanceOf Array and cannot deleted ✓ The `indices` property should be instanceOf Object and cannot deleted ✓ Method `getBuffer()` should returns a Buffer instance and had fixed length(108) default ✓ Method `getHeaders()` should returns a Object[`Content-type`] with `multipart/form-data; boundary=` ✓ Method `appendMimeTypes()` should returns the Form instance ✓ Method `appendMimeTypes({any: 'mock'})` should returns the Form instance, and affected `form.data` property ✓ Method `append()` should returns the Form instance, and affected `form.data` property ✓ Method `append()` should append name="undefined" disposition onto the `form.data` property ✓ Method `append({}, 1)` should append name="[object Object]" disposition onto the `form.data` property ✓ Method `append('meta', JSON.stringify({}), 'meta.json')` should append a `Content-Type: application/json` onto the `form.data` property ✓ Method `append('image_content', Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'), 'demo.gif')` should append a `Content-Type: image/gif` onto the `form.data` property [代码] API文档如下 Simple and lite of [代码]multipart/form-data[代码] implementation, most similar to [代码]form-data[代码] [代码](new Form) .append('a', 1) .append('b', '2') .append('c', Buffer.from('31')) .append('d', JSON.stringify({}), 'any.json') .append('e', require('fs').readFileSync('/path/your/file.jpg'), 'file.jpg') .getBuffer() [代码] Form new Form() .getBuffer() ⇒ [代码]Buffer[代码] .getHeaders() ⇒ [代码]Object.<string, string>[代码] .appendMimeTypes(things) ⇒ [代码]Form[代码] .append(field, value, [filename]) ⇒ [代码]Form[代码] new Form() Create a [代码]multipart/form-data[代码] buffer container for the file uploading. form.getBuffer() ⇒ [代码]Buffer[代码] To retrieve the [代码]data[代码] buffer Kind: instance method of [代码]Form[代码] Returns: [代码]Buffer[代码] - - The payload buffer form.getHeaders() ⇒ [代码]Object.<string, string>[代码] To retrieve the [代码]Content-Type[代码] multipart/form-data header Kind: instance method of [代码]Form[代码] Returns: [代码]Object.<string, string>[代码] - - The [代码]Content-Type[代码] header With [代码]this.boundary[代码] form.appendMimeTypes(things) ⇒ [代码]Form[代码] Append a customized mime-type(s) Kind: instance method of [代码]Form[代码] Returns: [代码]Form[代码] - - The [代码]Form[代码] class instance self Param Type Description things [代码]Object.<string, string>[代码] The mime-type form.append(field, value, [filename]) ⇒ [代码]Form[代码] Append data wrapped by [代码]boundary[代码] Kind: instance method of [代码]Form[代码] Returns: [代码]Form[代码] - - The [代码]Form[代码] class instance self Param Type Description field [代码]string[代码] The field value [代码]string[代码] | [代码]Buffer[代码] The value [filename] [代码]String[代码] Optional filename, when provided, then append the [代码]Content-Type[代码] after of the [代码]Content-Disposition[代码] mimeTypes : [代码]Object.<string, string>[代码] built-in mime-type mapping Kind: global variable dashDash : [代码]Buffer[代码] Kind: global variable boundary : [代码]Buffer[代码] Kind: global variable CRLF : [代码]Buffer[代码] Kind: global variable data : [代码]array.<Buffer>[代码] The Form’s data storage Kind: global variable indices : [代码]Object.<string, number>[代码] The entities’ value indices whose were in [代码]this.data[代码] 此类内置常用的几种文件类型([代码]append[代码]第三入参以文件名后缀比对),已经够用了,视频仅内置了两种,对于 官方接口支持的[代码]avi[代码], [代码]wmv[代码], [代码]mov[代码], [代码]mkv[代码], [代码]flv[代码], [代码]f4v[代码], [代码]m4v[代码], [代码]rmvb[代码],开发者可用透过 [代码]appendMimeTypes[代码] 方法,自行扩展以符合 RFC2388 规范。 图片上传接口,用法 [代码]const form = new Form form.append( 'file', require('fs').readFileSync('/path/your/file.jpg'), 'file.jpg' ) .append( 'meta', JSON.stringify({ filename:'file.jpg', sha256:'779a563f99f824975b3651bfd8597555e69fb135925e460dae3996d47c415fb0' }), 'meta.json' ) [代码] 整个需要发送的表单体就准备妥当了,然后按照v3开发规范,该数据签名的签名,想用什么[代码]client[代码]提交就用什么[代码]client[代码],然后就没然后了。。。 以我习惯用的 [代码]axios[代码] 为例,数据提交类似如下: [代码]const axios = require('axios') //伪代码, baseURL 要按实际接口地址赋值, Authorization 要按v3规范赋值 axios.create({baseURL}).post( form.getBuffer(), {headers: { Authorization: 'WECHATPAY2-SHA256-RSA2048 ', Accept: 'application/json', ...form.getHeaders(), }} ) .catch(({response: {headers, data}}) => ({headers, data})) .then(({headers, data}) => ({headers, data})) .then(console.log) [代码] 写到最后 Form类文件以MIT开源,文章转载请注明出处「来自微信开发者社区」。
2020-10-24 - 适合云开发的微信支付v2及v3版Nodejs SDK
接续微信支付APIv3的Nodejs版SDK,在2020这个时间节点,之所以再造一遍微信支付v2(相对于APIv3来说)的轮子,实属是无奈之举,线下交易场景常见的付款码支付及退款功能,官方当下还没有开放出来v3版的,只能借助v2接口来处理;[代码]wechatpay-axios-plugin[代码] 从一开始目标就是为云原生而设计,遂再造一遍轮子,也抽出一些共生方法,为v3而用。 设计思路 此类库核心部件是利用了Axios的transform功能,数据在内部流转过程中,会经过 [代码]transformRequest[代码] 及 [代码]transformResponse[代码] 处理,通过构造两个自定义transformer,完整实现v2版的技术规格要求,从而完成 HTTP 请求/响应 处理。 Transformer.request 方法返回值是个数组,含两个方法 [代码][signer, toXml][代码],字面意思即,对输入数据签名,然后转换成xml; Transformer.response 方法返回值是个数组,含两个方法 [代码][toObject, verifier][代码],字面意思即,返回值做数据转换为对象,然后校验签名; 证书设置 凡是涉及资金变动的接口,均需要商户证书,此实现同时支持 [代码]pem[代码] 及 [代码]p12[代码] 格式的证书,使用方法见随包README: [代码]const {Wechatpay, Formatter: fmt} = require('wechatpay-axios-plugin') const client = Wechatpay.xmlBased({ secret: 'your_merchant_secret_key_string', merchant: { cert: '-----BEGIN CERTIFICATE-----' + '...' + '-----END CERTIFICATE-----', key: '-----BEGIN PRIVATE KEY-----' + '...' + '-----END PRIVATE KEY-----', // or // passphrase: 'your_merchant_id', // pfx: fs.readFileSync('/your/merchant/cert/apiclient_cert.p12'), }, }) [代码] 实力化一个 [代码]client[代码] 的最小参数为 [代码]secret[代码],即所谓的 密钥,字符串形式,32字节长度。 自定义打印日志 按需,如果需要检测类库的数据情况,在实例化完成后,可以加入如下类似两段代码,即可以打印出日志;当然也可以按需把日志输出至文件等,抛砖引玉而已。 [代码]//在格式转换完后,打印日志 client.defaults.transformRequest.push(data => (console.log(data), data)) //在请求返回,先行打印日志 client.defaults.transformResponse.unshift(data => (console.log(data), data)) [代码] 使用示例 实例化对象 [代码]secret[代码] 所对应的商户类型,可以是服务商、普通商户、特约商户,入参按照官方文档,手捋填入即可,以下几个方法,均测试过,正常运转。 申请退款 [代码]client.post('/secapi/pay/refund', { appid: 'wx8888888888888888', mch_id: '1900000109', out_trade_no: '1217752501201407033233368018', out_refund_no: '1217752501201407033233368018', total_fee: 100, refund_fee: 100, refund_fee_type: 'CNY', nonce_str: fmt.nonce(), }).then(res => console.info(res.data)).catch(({response}) => console.error(response)) [代码] [代码]//log输入 { return_code: 'SUCCESS', return_msg: 'OK', appid: 'wx8888888888888888', mch_id: '1365319302', nonce_str: 'X8bpYtUJbPHK0Fyd', sign: '12BDC0390958455875108947AD51D897', result_code: 'SUCCESS', transaction_id: '4200000684202009114087736848', out_trade_no: '1217752501201407033233368018', out_refund_no: '1217752501201407033233368018', refund_id: '50300005642020091102621479983', refund_channel: '', refund_fee: '100', coupon_refund_fee: '0', total_fee: '100', cash_fee: '100', coupon_refund_count: '0', cash_refund_fee: '100' } [代码] 付款码支付 [代码]client.post('/pay/micropay', { appid: 'wx8888888888888888', mch_id: '1900000109', nonce_str: fmt.nonce(), sign_type: 'HMAC-SHA256', body: 'image形象店-深圳腾大-QQ公仔', out_trade_no: '1217752501201407033233368018', total_fee: 888, fee_type: 'CNY', spbill_create_ip: '8.8.8.8', auth_code: '120061098828009406', }).then(res => console.info(res.data)).catch(({response}) => console.error(response)) [代码] 现金红包 [代码]client.post('/mmpaymkttransfers/sendredpack', { nonce_str: fmt.nonce(), mch_billno: '10000098201411111234567890', mch_id: '10000098', wxappid: 'wx8888888888888888', send_name: '鹅企支付', re_openid: 'oxTWIuGaIt6gTKsQRLau2M0yL16E', total_amount: 1000, total_num: 1, wishing: 'HAPPY BIRTHDAY', client_ip: '192.168.0.1', act_name: '回馈活动', remark: '会员回馈活动', scene_id: 'PRODUCT_4', }).then(res => console.info(res.data)).catch(({response}) => console.error(response)) [代码] 企业付款 [代码]client.post('/mmpaymkttransfers/promotion/transfers', { appid: 'wx8888888888888888', mch_id: '1900000109', partner_trade_no: '10000098201411111234567890', openid: 'oxTWIuGaIt6gTKsQRLau2M0yL16E', check_name: 'FORCE_CHECK', re_user_name: '王小王', amount: 10099, desc: '理赔', spbill_create_ip: '192.168.0.1', nonce_str: fmt.nonce(), }).then(res => console.info(res.data)).catch(({response}) => console.error(response)) [代码] 和v3一起用 [代码]const wxpay = new Wechatpay({ mchid: 'your_merchant_id', serial: 'serial_number_of_your_merchant_public_cert', privateKey: '-----BEGIN PRIVATE KEY-----' + '...' + '-----END PRIVATE KEY-----', certs: { 'serial_number': '-----BEGIN CERTIFICATE-----' + '...' + '-----END CERTIFICATE-----', } }) [代码] Native下单 [代码]wxpay.v3.pay.transactions.native .post({/*文档参数放这里就好*/}) .then(({data: {code_url}}) => console.info(code_url)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] 查询订单 [代码]wxpay.v3.pay.transactions.id['{transaction_id}'] .withEntities({transaction_id: '1217752501201407033233368018'}) .get({params: {mchid: '1230000109'}}) .then(({data}) => console.info(data)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] 关单 [代码]wxpay.v3.pay.transactions.outTradeNo['1217752501201407033233368018'] .post({mchid: '1230000109'}) .then(({status, statusText}) => console.info(status, statusText)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] 创建商家券 [代码]wxpay.v3.marketing.busifavor.stocks .post({/*商家券创建条件*/}) .then(({data}) => console.info(data)) .catch(({response: {status, statusText, data}}) => console.error(status, statusText, data)) [代码] 查询用户单张券详情 [代码];(async () => { try { const {data: detail} = await wxpay.v3.marketing.busifavor.users.$openid$.coupons['{coupon_code}'].appids['wx233544546545989'] .withEntities({openid: '2323dfsdf342342', coupon_code: '123446565767'}) .get() console.info(detail) } catch({response: {status, statusText, data}}) { console.error(status, statusText, data) } } [代码] 上传图片 [代码]const FormData = require('form-data') const {createReadStream} = require('fs') const imageMeta = { filename: 'hellowechatpay.png', // easy calculated by the command `sha256sum hellowechatpay.png` on OSX // or by require('wechatpay-axios-plugin').Hash.sha256(filebuffer) sha256: '1a47b1eb40f501457eaeafb1b1417edaddfbe7a4a8f9decec2d330d1b4477fbe', } const imageData = new FormData() imageData.append('meta', JSON.stringify(imageMeta), {contentType: 'application/json'}) imageData.append('file', createReadStream('./hellowechatpay.png')) Wechatpay.client.post('/v3/marketing/favor/media/image-upload', imageData, { meta: imageMeta, headers: imageData.getHeaders() }).then(res => { console.info(res.data.media_url) }).catch(error => { console.error(error) }) [代码] 下载账单并格式化 [代码]const assert = require('assert') const {Hash: {sha1}} = require('wechatpay-axios-plugin') Wechatpay.client.get('/v3/bill/tradebill', { params: { bill_date: '2020-06-01', bill_type: 'ALL', } }).then(({data: {download_url, hash_value}}) => Wechatpay.client.get(download_url, { signed: hash_value, responseType: 'arraybuffer', })).then(res => { assert(sha1(res.data) === res.config.signed, 'verify the SHA1 digest failed.') console.info(fmt.castCsvBill(res.data)) }).catch(error => { console.error(error) }) [代码] 委托营销 [代码](async () => { try { const res = await Wechatpay.client.post(`/v3/marketing/partnerships/build`, { partner: { type, appid }, authorized_data: { business_type, stock_id } }, { headers: { [`Idempotency-Key`]: 12345 } }) console.info(res.data) } catch (error) { console.error(error) } })() [代码] 查询投诉信息并解密 [代码];(async () => { try { const res = await Wechatpay.client.get('/v3/merchant-service/complaints', {params: { limit: 50, offset: 0, begin_date: (new Date(+new Date - 29*86400*1000)).toJSON().slice(0, 10), end_date: (new Date).toJSON().slice(0, 10), }}) // decrypt the `Sensitive Information` res.data.data.map(row => (row.payer_phone = rsa.decrypt(row.payer_phone, merchantPrivateKey), row)) console.info(res.data) } catch({response: {status, statusText, data, headers}, request, config}) { console.error(status, statusText, data) } })() [代码] TODO v2版的[代码]AES-256-ECB/PKCS7Padding[代码]未做封装,这个不难,npm上也有许多优秀的类库可用,暂且先这样。 写到最后 MIT开放源码@npm, github ,可用于企业商业用途。
2020-10-16 - 微信支付APIv3的Nodejs版SDK,让开发变得简单不再繁琐
在向云端推送这个 [代码]wechatpay-axios-plugin[代码] 业务实现时,发现0.1系列还不够好用,还需要进行更多层级的包裹包装,遂再次做了重大更新,让SDK使用起来更简单、飘逸。 先看官方文档,每一个接口,文档都至少标示了[代码]请求URL[代码] [代码]请求方式[代码] [代码]请求参数[代码] [代码]返回参数[代码] 这几个要素,[代码]URL[代码] 可以拆分成 [代码]Base[代码] 及 [代码]URI[代码],按照这种思路,封装SDK其实完全就可以不用动脑,即,对[代码]URI[代码]资源的 [代码]POST[代码] 或 [代码]GET[代码] 请求(条件带上[代码]参数[代码]),取得[代码]返回参数[代码]。 更近一步,我们设想一下,如果把众多接口的[代码]URI[代码]按照斜线([代码]/[代码] [代码]slash[代码])分割,然后组织在一起,是不是就可以构建出一颗树,这颗树的每个节点(实体[代码]Entity[代码])都存在有若干个方法([代码]HTTP METHODs[代码]),这是不是就能把接口[代码]SDK实现[代码]更简单化了?! 例如: /v3/certificates /v3/bill/tradebill /v3/ecommerce/fund/withdraw /v3/ecommerce/profitsharing/orders /v3/marketing/busifavor/users/{openid}/coupons/{coupon_code}/appids/{appid} 树形化即: [代码]v3 ├── certificates ├── bill │ └── tradebill ├── ecommerce │ ├── fund │ │ └── withdraw │ └── profitsharing │ └── orders └── marketing └── busifavor └── users └── {openid} └── coupons └── {coupon_code} └── appids └── {appid} [代码] 按照这种树形构想,我们来看下需要做的[代码]封装实现[代码]工作: 把实体对象,按照实体的排列顺序,映射出请求的URI; 每个对象实体,包含有若干操作方法,其中可选带参数发起RPC请求; 随官方放出更多的接口,SDK需要能够弹性扩容; wechatpay-axios-plugin~0.2.0 版本实现了上述这3个目标,代码包如下截屏: [图片] 我们用伪代码来校验看一下这个[代码]封装实现[代码]: [代码]require('util').inspect.defaultOptions.depth = 10; const { Wechatpay } = require('wechatpay-axios-plugin'); const wxpay = new Wechatpay({mchid: '1', serial: '2', privateKey: '3', certs: {'4': '5'}}); wxpay.v3.certificates; wxpay.v3.bill.tradebill; wxpay.v3.ecommerce.fund.withdraw; wxpay.v3.marketing.busifavor.users['{openid}'].coupons.$coupon_code$.appids['wx233544546545989']; console.info(wxpay); //以下是输出内容 { entities: [], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload], v3: { entities: [ 'v3' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload], certificates: { entities: [ 'v3', 'certificates' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload] }, bill: { entities: [ 'v3', 'bill' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload], tradebill: { entities: [ 'v3', 'bill', 'tradebill' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload] } }, ecommerce: { entities: [ 'v3', 'ecommerce' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload], fund: { entities: [ 'v3', 'ecommerce', 'fund' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload], withdraw: { entities: [ 'v3', 'ecommerce', 'fund', 'withdraw' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload] } } }, marketing: { entities: [ 'v3', 'marketing' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload], busifavor: { entities: [ 'v3', 'marketing', 'busifavor' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload], users: { entities: [ 'v3', 'marketing', 'busifavor', 'users' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload], '{openid}': { entities: [ 'v3', 'marketing', 'busifavor', 'users', '{openid}' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload], coupons: { entities: [ 'v3', 'marketing', 'busifavor', 'users', '{openid}', 'coupons' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload], '$coupon_code$': { entities: [ 'v3', 'marketing', 'busifavor', 'users', '{openid}', 'coupons', '{coupon_code}' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload], appids: { entities: [ 'v3', 'marketing', 'busifavor', 'users', '{openid}', 'coupons', '{coupon_code}', 'appids' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload], wx233544546545989: { entities: [ 'v3', 'marketing', 'busifavor', 'users', '{openid}', 'coupons', '{coupon_code}', 'appids', 'wx233544546545989' ], withEntities: [Function: withEntities], get: [AsyncFunction: get], post: [AsyncFunction: post], upload: [AsyncFunction: upload] } } } } } } } } } } [代码] 注: API树实体节点,存储在每个 [代码]entities[代码] 属性上,方便后续的[代码]get[代码], [代码]post[代码] 抑或 [代码]upload[代码] 方法调用调用前,反构成最终请求的[代码]URI[代码];特别地,对于动态树实体节点来说,每个实体节点均提供了 [代码]withEntities[代码] 方法,用来在最终请求前,把动态实体节点替换成实际的值。 正常用法示例如下: [代码]const {Wechatpay} = require('wechatpay-axios-plugin'); const wxpay = new Wechatpay({/*初始化参数,README有*/}, {/*可选调整axios的参数*/}); //拿证书 wxpay.v3.certificates.get(); //带参申请交易账单 wxpay.v3.bill.tradebill.get({params: {bill_date}}); //带参发起账户余额提现 wxpay.v3.ecommerce.fund.withdraw.post({sub_mchid, out_request_no, amount, remark, bank_memo}); //查询用户单张券详情 wxpay.v3.marketing.busifavor.users['{openid}'].coupons.$coupon_code$.appids['wx233544546545989'].withEntities({openid, coupon_code}).get(); [代码] 请求APIv3是不是就“丧心病狂”般的简单了?! 详细功能说明及用法示例,npmjs及github的README均有。 如果喜欢,就给来个 赞 及 Star 吧。
2020-07-17