微信公众号支付开发手记(node)

3,082 阅读10分钟

微信支付

前言

总结一下最近业务开发中对微信公众号支付的开发过程,微信支付的开发前提是已经具备可上线微信公众号开发的基础上进行的,如果你的开发阶段目前停留在起步,建议参考这篇文章开始

好了,来聊一聊微信支付。不论是今天的分享,还是网上其他的分享,开头总是在吐槽微信的文档。我也不例外,刚开始总是觉得文档写的不够具体,写的模棱两可。后来发现一个是自己太浮躁,不能沉下心去分析文档的细节,另一方面是习惯性先去网上找相关的教程,然后发现教程传递过来的第一感受就是——微信开发是个大坑。

网上的经验分享还是很有帮助的,但是首先要清楚地明白微信支付的整个流程以及自己目前的进展,这样才能有目的的去找到自己需要的东西。

微信支付开发和微信其他功能开发有一个共同点,就是需要耐心。而这似乎也是微信团队的初衷,通过零散的文档,不清晰的说明来过滤掉一批缺乏耐心的程序员(帮微信团队圆个场)。所以开始接触微信开发之前,会有人告诉你这里会有很多坑,会怎么样怎么样,其实完全不用担心,因为这里的坑并不是在考验技术。

关于微信支付的调试过程,由于微信支付对安全要求较高,不能做内网穿透本机测试,所以需要先将项目部署到线上,开发调试不是很方便。可能会有一些本地支付沙箱之类的工具,我没有去研究,希望有做过此类工作的小伙伴留个言提醒一下。而且微信支付是不能够通过微信开发者工具测试的,只能在真机上跑。如果在开发者工具上遇到报错,不妨在手机上跑一下。

微信支付文档分析

不管什么开发,都要从官方文档开始。关于公众号支付的部分,需要关注文档的两个部分,一个是“公众号支付”一个是“API列表”,其他部分不是重点。

公众号支付

  • 场景介绍
  • 案例介绍
  • 开发步骤
  • 业务流程
  • 获取微信版本号
  • 微信内H5调起支付
  • 收获地址共享
  • 支持常见问题

这一级的目录,重点是业务流程微信内H5调起支付,因为这两块内容搞明白了,整个支付流程就清晰了。

首先需要了解一下公众号支付的具体场景,也就是需要阅读第一部分“场景介绍”,了解一下这个场景是否符合具体业务。

如果这就是你要的,那就来到重点了,也就是“业务流程”。

看到业务流程的流程图,估计有计算机专业背景的朋友会很熟悉,也很容易理解。不理解也没关系,我这里准备了一副含有分析过程的流程图,结合实际业务,来帮助理解。

微信支付流程图分析

先来看黄色的甬道,这两块也就是实际的前台页面和后台服务,我们的阅读顺序是自上而下,流程图中的文字是微信官方提供的,右边的说明文字是我根据业务写的,看哪部分都可以。我把整个流程用颜色分为了三大块帮助理解。

从红色部分开始,红色部分主要工作是后台生成预付款单,然后通过回调信息将内容发送到前台。

接下来是蓝色部分,这一部分包含两块,一个是付款前,一个是付款后。

首先前台拿到红色部分由后台发来的信息,然后再微信内H5页面调起支付,此时页面的付款都是由微信来控制。

付款结束后,会发送两个回调,一个发送给后台服务,也就是图片蓝色部分中的绿色区块,告诉后台具体哪个订单现在的完成状态。另一个发送给客户前端,通知前端交易状态。

从这部分内容了解到,需要先了解统一下单API,之后是微信H5内调起支付,然后处理支付结果通知

开始开发

准备

微信服务配置

开始开发前,需要对现有项目设置支付目录和设置授权域名,具体可以参考这里另外需要注意的是,也是微信文档里没有提到的地方。需要在微信支付平台设置API密钥。需要提醒的是,微信支付平台的配置需要超级管理员账号登录才可以进行配置操作。

微信支付API密钥

收集信息

上一步配置完成后,需要收集一些信息为后续开发做准备。

  • token 微信公众号后台取得
  • appid 微信公众号后台取得
  • appsecret 微信公众号后台取得
  • encodingAESKey 微信公众号后台取得
  • mch_id 商户号(微信支付平台取得)
  • notifyUrl 微信支付回调地址(服务端后台接口:POST)
  • partnerKey 微信支付API密钥(微信支付平台取得)

准备好以上信息后,就可以开始着手写代码了。

开工

首先,需要准备一个前台界面,模拟用户访问商品页面点击购买。

image

后端部分

后台这边,node开发微信支付有很多现成的封装库可以使用,这里使用wechat-pay

首先在项目开始处初始化wechat-pay

下单
const Payment = require('wechat-pay').Payment;
const initConfig = {
  partnerKey: config.wechat.partnerKey,
  appId: config.wechat.appid,
  mchId: config.wechat.mch_id,
  notifyUrl: config.wechat.notifyUrl,
};
const payment = new Payment(initConfig);

然后编写前台界面用户点击购买后的接口业务代码:

async genAdvanceOrder(ctx) {
    try {
      // 1. 通过前台发来的商品ID查询商品
      const { user } = ctx.state;
      const { product_id, comment, school_id } = ctx.request.body;
      const product = await prodDao.findOneProduct(product_id);

      if (product.length <= 0) {
        return ctx.body = new Error(C.ERROR_CODE.QUERY_EMPTY, '没有找到商品');
      }
      
      
      // 2. 通过查询结果填写预付款单
      // 获取client ip地址
      const clientIp = getClientIp(ctx.req);
      const order = {
        body: product[0].course_title,        // 商品描述
        attach: product[0].comment,           // 商品附加数据
        out_trade_no: UUID.v1().replace(/\-/g, ''),   //  商户系统内部订单号,自己生成32位随机串 unique
        total_fee: product[0].price,          // 费用(单位:分) 
        spbill_create_ip: clientIp,           // 客户端IP
        openid: user.openid,                  // 用户openid
        trade_type: 'JSAPI'
      };

      // 3. 根据预付款单回调结果往数据库插入数据(判断错误码,修改订单状态)
      // 向微信请求生成预付款单
      let payargs = await payment.getBrandWCPayRequestParams(order);
      await orderDao.add({
        user_id: user.id,
        school_id: school_id, // TODO: 增加并分配学校ID,业务逻辑需要变动
        scene: product[0].scene,
        product_id: product_id,
        price: product[0].price,
        product_price: product[0].price,
        status: C.PAY_STATUS.NO_PAY,
        wx_open_id: user.openid,
        wx_out_trade_no: order.out_trade_no,
        wx_prepay_id: payargs.package.split('=')[1], // 取prepay_id
        comment: comment
      });

      // 4. 发送预付款单内容
      ctx.body = new Success(payargs);

    } catch (e) {
      ctx.body = new Error('', '未知错误', e)
    }
  }
  
function getClientIp(req) {
    const ip = req.headers['x-forwarded-for'] ||
    req.connection.remoteAddress ||
    req.socket.remoteAddress ||
    req.connection.socket.remoteAddress;
    return ip.replace(/:|\wf/g, '');
}

这里需要注意的是填写预付款单这里的操作。

out_trade_no是要自己生成32位随机字符串,相当于是保存在自己数据库中的订单唯一值,以后在微信回执付款信息时也会用到这个字段。

total_fee的单位是分,在开发过程中,也应当使用分作为数据库价钱的单位,这样可以有效避免浮点数精读损失问题。

spbill_create_ip是用户端下单设备的ip,是必填项,微信那边处于安全要求每份订单都必须要填写。这个也很好获取(上面代码中贴出来了),只不过要做一些处理,因为直接通过koa ctx.ip获取的地址可能会被Nginx或者其他服务器配置服务转发成127.0.0.1。这就不是我们需要的真实的客户端ip。最后要处理字符串前缀,一般直接拿到ip的格式是:fff:54.00.00.1

支付通知

这里就是之前的流程图中,蓝色区块中的绿色部分。可以对比流程图理解支付流程。

微信开发中,大量来自微信发送的通知都是xml格式,所以为了方便使用,需要先增加以下中间件来帮助开发。

const bodyParser = require('koa-bodyparser');
const xmlParser = require('koa-xml-body');

app.use(xmlParser({
  key: 'body'
}));
// app.use(bodyParser());
app.use(bodyParser({
  enableTypes: ['json', 'form', 'text'],
  extendTypes: {
    text: ['text/xml', 'application/xml']
  }
}));

之后是通知部分的代码:

async wxPayNotify(ctx) {
    // TODO: 安全验证 签名验证 并校验返回的订单金额是否与商户侧的订单金额一致
    /*
      // 微信发送通知的内容
      {
        appid: [ '**********' ],
        attach: [ '附加内容' ],
        bank_type: [ 'CFT' ],
        cash_fee: [ '1' ],
        fee_type: [ 'CNY' ],
        is_subscribe: [ 'Y' ],
        mch_id: [ '1498496372' ],
        nonce_str: [ '4KewHbQvsQPaGsaeoICLbKD1ySFDlPdL' ],
        openid: [ 'oZZUx0X2LSM1j652P6r2R*******' ],
        out_trade_no: [ '8ff9fdd0e33411e8a07c833c43c4e4e7' ],
        result_code: [ 'SUCCESS' ],
        return_code: [ 'SUCCESS' ],
        sign: [ '6A14538FE1651CECDFCDFE375383B9AA' ],
        time_end: [ '20181108165920' ],
        total_fee: [ '1' ],
        trade_type: [ 'JSAPI' ],
        transaction_id: [ '4200000235201811***********' ]
      }
    */
    try {

      // 1. 通过回调信息查询订单
      const content = ctx.request.body['xml'];
      const order = await orderDao.selectOne({ wx_out_trade_no: content['out_trade_no'][0] });

      if (!order) {
      //  TODO: 处理查询不到订单的通知
      }

      // 2. 安全验证,对比签名和订单金额
      if (checkWeChatPaySign(content) && order.price === parseInt(content['total_fee'][0])) {
        await orderDao.update({
          wx_notify_backup: JSON.stringify(content),
          status: C.PAY_STATUS.PAID,
          wx_transaction_id: content['transaction_id'][0]
        }, { wx_out_trade_no: content['out_trade_no'][0] })
      } else {
      //  TODO: 处理验证不通过的通知
      }

      // 3. 回调通知微信
      ctx.body = '<xml>' +
        '<return_code><![CDATA[SUCCESS]]></return_code>' +
        '<return_msg><![CDATA[OK]]></return_msg>' +
        '</xml>'
    } catch (e) {
    //  TODO: 收款回调出错通知
    }
  }
  
function checkWeChatPaySign(obj) {
  // 1.字典排序数据集合
  let arr = [];
  for (let [k, v] of Object.entries(obj)) {
    let string = '';
    // 排除 sign 字段
    if (k === 'sign') continue;
    string += k + '=' + v[0];
    arr.push(string);
  }
  // 按字典排序
  arr.sort();
  // 2.拼接上key得到stringSignTemp字符串
  arr.push('key=' + config.wechat.partnerKey);
  const stringSignTemp = arr.join('&');
  const md5String = md5(stringSignTemp).toUpperCase();
  // 3.比较md5String 与 sign字段
  return md5String === obj.sign[0];
}

这里比较不好理解的是验证签名,而微信文档也没有给出样例代码,所以比较混乱。而且拼签名拼串验证又容易出错,多一个空格少一个字符都不一样。这里就得结合官方给出的签名算法效验工具耐着性子调试了。

前端部分

然后就回到我们的前端部分。

由于微信H5支付是基于腾讯浏览器的,所以只有在手机微信中或者开发者工具中打开的网址,才能调用到WeixinJSBridge

我这边前端是拿Angular写的,不过代码不复杂,着重理解业务流程。

import { Component, OnInit } from '@angular/core';
import {UserService} from '../../../services/user.service';
import {OrderService} from '../../../services/order.service';
import {ActivatedRoute} from '@angular/router';

@Component({
  selector: 'app-wx-pay-test',
  templateUrl: './wx-pay-test.component.html',
  styleUrls: ['./wx-pay-test.component.scss']
})
export class WxPayTestComponent implements OnInit {
  wxBridge;
  logs = [];
  productId;

  constructor(
    private orderService: OrderService, // API
    private activateRouter: ActivatedRoute
  ) { }

  // 页面初始化就会执行的钩子函数, React 应该使用ComponentDidMount
  ngOnInit() {
    // 这里是为了获取WeixinJSBridge
    if (typeof window['WeixinJSBridge'] === 'undefined') {
      if (document.addEventListener ) {
        document.addEventListener('WeixinJSBridgeReady', this.onBridgeReady, false);
      } else if (document['attachEvent']) {
        document['attachEvent']('WeixinJSBridgeReady', this.onBridgeReady);
        document['attachEvent']('onWeixinJSBridgeReady', this.onBridgeReady);
      }
    } else {
      this.onBridgeReady();
    }
    // 获取url中的参数
    this.activateRouter.params.subscribe( params => {
      if (params.id) {
        this.productId = params.id;
      }
    });
  }

  onBridgeReady = () => {
    this.wxBridge = window['WeixinJSBridge'];
  }
  // 点击购买触发该函数
  genPreOrder(ev) {
    const that = this;
    // 1、向后台发送请求 对应后台 genAdvanceOrder
    this.orderService.genAdvanceOrder({
      product_id: this.productId
    }).then(data => {
      // 2、拿到服务端回执数据,调用invoke,请求微信支付
      that.wxBridge.invoke('getBrandWCPayRequest', data, function(res) {
        // 3、处理微信支付回执结果
        if (res.err_msg === 'get_brand_wcpay_request:ok') {
          // TODO: 显示支付成功页面
          alert('支付成功');
          // 这里可以跳转到订单完成页面向用户展示
        } else {
          // TODO: 显示支付失败页面
          alert('支付失败,请重试');
        }
      });
    }).catch(err => {
      console.log(err);
    });
  }
}

前端的代码还是相对容易理解的,前提是理解文章开头部分的流程图。知道每一步是处理什么问题,需要干什么。

在调用微信支付后,返回的结果代码中我只处理了支付成功的部分,但是回调还会有支付失败、超时等等,就不一一列举。

结语

文章到这里,大概也就讲清楚了微信公众号支付的整个环节。但是在流程图中最后一部分灰色区块的业务没有讲,因为觉得前两部分是最主要的,后面可以自行理解流程图,根据具体业务开发。

折腾微信支付这块内容大概也有几天了,总结一下整个开发流程,分享一下,希望能够帮助大家理解整个支付业务。

要说难吗其实也不难,主要就是考察耐心吧。