之前我们完成了开发微信支付时需要做的准备,还实施了一个扫码支付功能。 介绍了微信支付的基本流程,还有相关的一些概念。扫码支付比较适用于桌面端的应用,因为支付的时候需要用到微信 App 扫二维码。下面再介绍一种适用于移动端的微信支付方法,就是 H5 支付。用户在移动设备的浏览器上提交支付请求,会调开微信 App 进行支付,支付完成以后又会被重定向到原来的支付页面。
文章有配套视频《微信支付:H5 移动端支付》,订阅宁皓网可以在线学习所有相关的课程。
支付流程
- 用户在手机浏览器上,在应用的支付页面提交支付。
- 应用请求微信的统一下单接口。
- 微信支付系统返回跳转链接。
- 应用页面重定向到微信支付返回的跳转链接。
- 链接会调开用户的微信 App。
- 用户在微信 App 确认并完成支付。
- 用户被重定向到原来申请支付时的页面。
- 页面可以引导用户查询微信支付订单状态。
开通 H5 支付
登录到微信支付后台,在产品中心,可以开通微信支付里的 H5 支付功能。
项目代码
应用的代码在 ninghao/ninghao-sandbox-v2 这个仓库的 wxpay-h5 这个分支上。
路由
start/routes.js
Route.get('checkout', 'CheckoutController.render') Route.post('wxpay/notify', 'CheckoutController.wxPayNotify') Route.post('checkout/pay', 'CheckoutController.pay') Route.post('checkout/query', 'CheckoutController.query') Route.get('checkout/completed', 'CheckoutController.completed')
在《微信支付:开发准备与实施扫码支付细节手册(下)》里添加的两条路由:
- checkout:结账页面。
- wxpay/notify:处理微信支付发送过来的支付结果通知。
相比之前,我们在应用里又添加了几条新的路由。
- checkout/pay:用户点击支付页面上的确认支付按钮,会请求这个地址。它做的事主要是去组织 H5 支付需要的数据,然后请求微信支付统一下单接口,返回跳转链接。
- checkout/query:支付完成以后,可以查询微信支付交易状态。比如我们可以在支付页面上显示一个对话框,提示用户查询微信交易的状态。
- checkout/completed:如果查询的交易状态是 SUCCESS,可以把用户带到这个页面上,提示用户完成了交易。
支付
结账页面视图
重新设计一下 checkout 页面视图,在实施扫码支付功能的时候,结账页面上会显示支付用的二维码。使用 H5 支付的时候,我们可以在这个结账页面上显示订单相关的信息,还有一个结账按钮,按下去可以用 Ajax 方式请求支付。
resources/views/commerce/checkout.edge
<div class="container"> <div class="row justify-content-center"> <div class="col-md-4"> <div class="card text-center mt-5 mx-3"> <div class="card-body"> <img src="https://ninghao.net/%7B%7B%20assetsUrl%28%27wxpay.png%27%29%20%7D%7D" alt="" class="w-50"> <div class="card-price my-5 pb-5"> <p class="card-amount"><small>¥</small>0.03</p> <p class="card-text text-muted"><small>订单金额</small></p> </div> <button data-csrf="{{ csrfToken }}" id="pay" class="btn btn-primary btn-block">确认支付</button> </div> </div> </div> </div> </div>
要注意的是在 确认支付 按钮上添加的 id 属性,等会儿我们可以在页面使用的自定义脚本里面,使用这个 id 属性的值来定位到这个按钮元素(#pay)。还有在按钮上的 data-csrf 属性,对应的值绑定了一个 csrfToken,在页面自定义脚本里面,可以利用这个自定义的 data 属性,得到 csrfToken 的值。应用(Adonis.js)对 POST 类型的请求会做 CSRF 保护,所以提交这种请求的时候,要带着应用生成的 CSRF Token 的值。
样式
视图需要点自定义的样式,在视图使用的布局(layouts.main)里面链接了一个自定义样式表(main.css),在这个样式表里你可以添加自定义的样式。
public/main.css
.card-price { font-family: "Century Gothic", sans-serif; font-weight: bold; } .card-amount small { font-size: 16px; } .card-amount { font-size: 32px; padding: 0; margin: 0; }
功能
在之前我们实施扫码支付的时候,功能代码主要都放在了 CheckoutController 这个控制器里了。
- wxPaySign:用来生成微信支付签名。
- wxPayNotify:处理微信支付发送过来的支付结果通知。
- render:请求统一下单接口,生产二维码图像,交给 checkout 页面视图使用。
下面我们要对 render 方法做一些调整,只让它返回 checkout 页面视图,把其余的代码分割成几个方法。
render
显示结账页面。
render ({ view }) { return view.render('commerce.checkout') }
orderToXML
要发送给微信支付接口的数据需要转换成 xml 格式,单独创建一个方法可以把 object 数据转换成 xml 格式。
orderToXML (order, sign) { order = { xml: { ...order, sign } } // 转换成 xml 格式 const xmlOrder = convert.js2xml(order, { compact: true }) return xmlOrder }
xmlToJS
从微信支付那里得到的数据,格式是 xml,要在应用里使用的话需要转换成 object。这块代码可能会重复用到,所以单独定义一个 xmlToJS 方法,功能就是把 xml 数据转换成 Object。
xmlToJS (xmlData) { const _data = convert.xml2js(xmlData, { compact: true, cdataKey: 'value', textKey: 'value' }).xml const data = Object.keys(_data).reduce((accumulator, key) => { accumulator[key] = _data[key].value return accumulator }, {}) return data }
pay
然后再把之前在 render 方法里的其余的代码放到 pay 这个方法里面。它是 checkout/pay 路由使用的处理请求用的方法。
async pay ({ request, session }) { logger.info('请求支付 ------------------------') // 公众账号 ID const appid = Config.get('wxpay.appid') // 商户号 const mch_id = Config.get('wxpay.mch_id') // 密钥 const key = Config.get('wxpay.key') // 商户订单号 const out_trade_no = moment().local().format('YYYYMMDDHHmmss') session.put('out_trade_no', out_trade_no) // 商品描述 const body = 'ninghao' // 商品价格 const total_fee = 3 // 支付类型 const trade_type = 'MWEB' // 用户 IP const spbill_create_ip = request.header('x-real-ip') // 商品 ID const product_id = 1 // 通知地址 const notify_url = Config.get('wxpay.notify_url') // 随机字符 const nonce_str = randomString.generate(32) // 统一下单接口 const unifiedOrderApi = Config.get('wxpay.api.unifiedorder') let order = { appid, mch_id, out_trade_no, body, total_fee, trade_type, product_id, notify_url, nonce_str, spbill_create_ip } const sign = this.wxPaySign(order, key) const xmlOrder = this.orderToXML(order, sign) // 调用统一下单接口 const wxPayResponse = await axios.post(unifiedOrderApi, xmlOrder) const data = this.xmlToJS(wxPayResponse.data) logger.debug(data) return data.mweb_url }
trade_type 设置的是交易类型,跟扫码支付(NATIVE)不同的是,我们把它设置成了 MWEB:
trade_type = 'MWEB'
还有在方法里,我们把生成的订单号保存在了用户的 session 里面了,这样在后面调用微信支付查询订单接口的时候,可以从 session 里面读取订单号,然后组织好查询需要带的数据。
session.put('out_trade_no', out_trade_no)
请求支付
在结账页面上有个 确认支付 按钮,按一下它可以请求支付。可以使用表单或者 Ajax 的形式去执行这个动作。下面我们要使用 Ajax 的方法去请求支付,在页面使用的自定义脚本文件里面,可以添加几行代码:
public/main.js
(function() { 'use strict' // 自定义脚本 }())
把代码放在上面的立即执行函数(IIFE)里面:
const _csrf = $('#pay').data('csrf') $('#pay').click(() => { $.ajax({ url: '/checkout/pay', method: 'POST', data: { _csrf }, success: (response) => { console.log(response) if (response) { window.location.href = response } }, error: (error) => { console.log(error) } }) })
先从 #pay 按钮上得到了 csrfToken 的值。然后找到页面上的 #pay 元素,监听它的点击事件(click)。点击了 确认支付 按钮,就会使用 jQuery 的 Ajax 方法去发出请求。如果你做的是前端应用,可以使用其它的 HTTP 客户端,比如 axios(浏览器与 Node.js 都可以使用它)。效果都差不多,就是对指定的地址发出各种不同类型的 HTTP 请求。
请求成功会调用 success 方法,得到的响应叫 response,它的值应该就是支付跳转用的地址。下面这行代码,会把用户带到得到的这个支付跳转地址上:
window.location.href = response
在 checkout 页面点一下 确认支付 按钮,就会请求 checkout/pay 这个地址,这个地址请求的处理方法用的是 CheckoutController 里的 pay 方法。在这个方法里面,我们组织好了请求带的数据,然后对微信支付系统的统一下单接口发出请求,并且返回了请求得到的 mweb_url,它的值就是支付要跳转的地址。
打开应用日志 app.log,里面会出现请求统一下单接口返回的数据, mweb_url 就是我们需要用的那个跳转地址:
23:45:56 DEBUG - { return_code: 'SUCCESS', return_msg: 'OK', appid: 'wx58263139db20f28e', mch_id: '1528508902', nonce_str: 'fnAUH4QVn7L2yrQo', sign: '9FF86FF3EA9C99D78A2C832638E62649', result_code: 'SUCCESS', prepay_id: 'wx20180202234556a1bff266860143681661', trade_type: 'MWEB', mweb_url: 'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx20180202234556a1bff266860143681661&package=3740852957' }
试验
在手机设备上打开支付页面,按一下 确认支付,应该就会调用微信支付 App 进行支付了。
查询订单状态
使用我们自己系统内部的订单号(out_trade_no),或者微信支付生成的订单号(transaction_id),调用微信支付订单查询接口,可以查询交易的状态。
查询对话框视图
用户使用 H5 方式支付完成以后会被重定向到原来发起支付时的页面。就是我们的结账页面,在这个页面上,可以显示一个对话框,提示用户查询交易状态(trade_state),如果状态是 SUCCESS,可以再把用户带到一个完成页面。
resources/views/commerce/checkout.edge
<div class="modal" id="modal-query"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">支付结果</h5> <button class="close" type="button" data-dismiss="modal"> <span>×</span> </button> </div> <div class="modal-body"> <p>支付完成以后,请再确定一下支付结果。</p> </div> <div class="modal-footer"> <button id="order-query" class="btn btn-primary btn-block" data-csrf="{{ csrfToken }}"> 支付成功 </button> </div> </div> </div> </div>
视图用到了 Bootstrap 框架提供的 Modal 组件。
页面脚本
按下结账页面的 确认支付,成功得到了响应以后,可以再弹出对话框视图。支付完成以后再次跳转到这个结账页面的时候,iOS 设备会刷新这个页面,所以我们需要一种方法记住对话框的开启状态。这里我们把这个开启状态保存在了用户设备的 LocalStorage 里面。
public/main.js
// 找到页面上的对话框元素 const modalQuery = $('#modal-query') // 对话框完全隐藏时,设置它的开启状态为 hide modalQuery.on('hidden.bs.modal', () => { localStorage.setItem('#modal-query', 'hide') }) // 页面显示时,读取对话框的开启状态 const modalQueryState = localStorage.getItem('#modal-query') // 如果对话框开启状态为 show,我们就显示它 if (modalQueryState === 'show') { modalQuery.modal() } // 执行查询订单,交易状态为成功,就把用户带到 /checkout/completed 页面。 $('#order-query').click(() => { $.ajax({ url: '/checkout/query', method: 'POST', data: { _csrf }, success: (response) => { switch (response.trade_state) { case 'SUCCESS': window.location.href = '/checkout/completed' break default: console.log(response) } }, error: (error) => { console.log(error) } }) })
完成页面视图
resources/views/commerce/completed.edge
@layout('layouts.main') @section('content') <div class="container"> <div class="jumbotron mt-3"> <h1 class="dispay-4">成功啦!</h1> <p class="lead">您的订单已经完成了。</p> <p class="head"> <a href="https://ninghao.net/checkout" class="btn btn-primary my-2">返回</a> </p> </div> </div> @endsection
订单查询功能
实现订单查询功能,主要的代码放在了 CheckoutController 里的 query 这个方法里面,它是 checkout/query 地址的处理方法。按下对话框上的 支付成功 按钮,请求的就是这个地址。
query
在方法里,准备好调用微信支付订单查询接口需要的数据,注意我们在用户的 Session 里面读取了之前在 pay 方法里保存的 out_trade_no 的值。在实际的应用中,我们的应用收到了微信支付结果以后,可以把结果里的微信支付订单号(transaction_id)保存在数据库里。这样在查询交易状态的时候,也可以根据这个微信支付订单号去查询交易状态。
async query ({ session }) { logger.info('请求查询 -----------------------') // 公众账号 ID const appid = Config.get('wxpay.appid') // 商户号 const mch_id = Config.get('wxpay.mch_id') // 密钥 const key = Config.get('wxpay.key') // 商户订单号 const out_trade_no = session.get('out_trade_no') // 随机字符 const nonce_str = randomString.generate(32) // 查询订单接口 const orderQueryApi = Config.get('wxpay.api.orderquery') const order = { appid, mch_id, out_trade_no, nonce_str } const sign = this.wxPaySign(order, key) const xmlOrder = this.orderToXML(order, sign) const wxPayQueryResponse = await axios.post(orderQueryApi, xmlOrder) const result = this.xmlToJS(wxPayQueryResponse.data) logger.debug(result) return result }
completed
添加一个 completed 方法,显示一个 commerce.completed 视图。
completed ({ view }) { return view.render('commerce.completed') }
数据
调用订单查询接口返回的数据:
09:25:42 DEBUG - { return_code: 'SUCCESS', return_msg: 'OK', appid: 'wx58263139db20f28e', mch_id: '1228508902', nonce_str: 'QqwBIqZDv4ogsgrg', sign: '68C38CB0738F20D8B2DB6F135121DCA6', result_code: 'SUCCESS', openid: 'osbKIjtJPwZzfMea5X7Q_q2tH_EU', is_subscribe: 'Y', trade_type: 'MWEB', bank_type: 'CFT', total_fee: '3', fee_type: 'CNY', transaction_id: '4200000063201802046578989993', out_trade_no: '20180204092514', attach: undefined, time_end: '20180204092536', trade_state: 'SUCCESS', cash_fee: '3' }