如何可靠的对接微信、支付宝条码支付

场景
餐厅提供了网络点餐服务,用户通过微信能很方便的进行点餐并支付,享受餐厅提供的各种餐饮服务。其中可靠的支付服务是其中的核心环节之一,如果支付出了问题,对餐厅或用户都是一个损失,甚至会引起纠纷。如何避免发生这样的问题或者是把发生这样问题的概率降到最低,那就需要结合业务特点和使用场景来仔细分析隐藏的问题。
 
下面以微信支付中的2种支付场景来解析一下对接过程中遇到的问题以及如何解决
 
条码支付
对于支付宝和微信的条码支付,都是没有支付成功回调的。这点必须注意,那么基于这个特点,服务器对接了条码支付,就需要考虑到如何可靠的获得支付结果,避免支付单边产生。
 
下面来举例几种做法,一一来说明各种问题
 
假设wechat.scanPay() 一次就能确定支付结果,不考虑返回用户正在支付中的场景,后面会提供方案

 
做法1
同步请求
public order scanPay()throws PayException{
     try{
          beginTransaction
          order = createOrder
          result = wechat.scanPay
          order.makeSuccess
          commit
     }
     catch(Exception e){
          rollback
          throw new PayException("pay fail")
     }     
     return order
}

 

问题解析
  • 到微信的请求在自身数据库的事务过程中,会增加事务操作时间,微信支付并没有想象中的稳定。
  • 当微信支付请求成功,执行数据库代码时会发生异常,有可能是代码bug,也有可能是数据库操作和提交事务都会发生,如果遇到数据库问题,整个事务不会提交,直接回滚,因此刚刚创建的订单就无法与微信支付对应上,对于接下来的交易也会被中断。网上好多人给出的解决方案是把代码进行try catch包起来,一旦遇到异常,catch然后记录日志等其他补救操作。如果这样做了,也许你一辈子也不会遇到问题,但是问题仍然存在:如果catch块的代码由于网络等其他问题也出错了,那职能靠人工去找数据了,代码不能保证一定能执行到catch块,例如:断电、机器重启、进程被干掉等。
  • 对于数据库事务都会设定最长超时,如果微信请求过程较长,有可能造成事务超时,无法提交。情况与上面描述就类似了

 
改进后的做法2
同步请求
public order scanPay()throws PayException{
     try{
          beginTransaction
          order = createOrder
          order.makePaying
          commit
     }
     catch(Exception e){
          rollback
          throw new PayException("pay fail")
     }     
     try{
          result = wechat.scanPay
          if (result == fail) {
               .....
          }
          .......
     }
     catch(Exception e){
          order.makeFail
          throw new PayException("pay fail")
     }
     try{
          beginTransaction
          order.makePaySuccess
          commit
     }
     catch(Exception e){
          rollback
          throw new PayException("pay fail")
     }
     return order
} 
 
比做法1进步了,至少事务中不会有微信支付请求
 
问题解析
  • 创建订单成功后请求微信支付失败,请求微信支付成功后更新订单状态失败。以上2个情况都无法让代码继续运行,try catch 微信支付异常,然后做处理,照样解决不了问题。

如果是同步的请求方式,这个问题也能解决,但是解决的方式不太漂亮会影响用户体验。最好的方式就是采用消息队列进行异步的处理
 
改造支付为异步请求

请求分为多个阶段,其中MQ表示的是消息队列。
 
我们对消息队列的要求是不能丢失消息,保证至少发送消息一次。只要不删除消息,消息就会被再次发送。
MQ的可靠性不再本文范围之内,请自行Google 可靠消息队列
 
//创建订单,然后返回给调用者
public order createOrder(){
     order = createOrder
     return order
}
 
//调用者发起支付请求,由于异步操作,不会返回任何结果
public void reqeustScanPay(orderId,amount,code){
     mqclient.sendMessage(order);
}
 
//调用者轮询支付结果
public void checkOrder(orderId,firstQueryTime) throws PayException{
     currentTime = System.currentTime
     if (currentTime > firstQueryTime + 2min) {
          //轮询2分钟还没有获得结果,终止轮询,订单等待支付服务自动处理
     }
     order = getOrderbyId(orderId)
     if (order == null) {
          throw new PayException("order not exist")
     }
     if (order.isPaying) {
          //如果是支付中结束本次查询
     }
     else if (order.isPaySuccess) {
          //支付成功....
          //终止轮询
     }
     else if (order.isPayFail) {
          //支付失败....
          //终止轮询
     }
}

 

发送的支付消息开始异步消费
 
消息会处理以下几个方法:
 
//当接收到请求扫码支付时调用。扫码返回结果可能有多种,需要一一判断,根据不同条件来决定继续查询或者是撤销支付或支付成功、失败
public void processScanPay(message){
     try{
          result = wechat.scanPay
          if (result == userpaying) {
               send query memsage into MQ 延迟3s接收消息
               delete message
               return
          }
          else if (result == error){
               send cancel message into MQ
               delete message
               return
          }
          else if (result == duplicate pay){
               send paySuccess message into MQ
               delete message
               return
          }
          else if (result == paySuccess){
               send paySuccess message into MQ
               delete message
               return
          }
          //其他情况不举例了,根据实际情况来发送查询或者撤销消息
     }
     catch(Exception e){
          send query memsage into MQ 延迟3s接收消息
          delete message
     }
}
 
//当支付结果不明确或状态为支付中、用户正在输入密码时调用。微信支付有可能不会立刻返回支付结果,例如用户需要输入密码等情况,就需要多次查询才可以获得结果,但是查询也需要有限制,例如2分钟如果超过,就强制撤销支付,这样就不会造成支付单边
public void processQueryPay(message){
     orderId = message.orderId
     firstTime = message.firstTime
     currentTime = time.currentTime
     if (currentTime - firstTime > 2min) {
          send cancel message into MO
          delete message
          return
     }
     result = wechat.query(orderId)
     if (result == userpaying) {
          //用户还在输入密码,或者微信正在跟银行交互
          send query memsage into MQ  延迟3s接收消息
          delete message
          return
     }
     else if (result == paySuccess) {
          //支付成功是最终结果
          send success message into MQ
          delete message
          return
     }
     else if (result == payFail) {
          //支付失败,是最终结果
          send fail message into MQ
          delete message
          return
     }
     else if (result == unknown) {
          //微信系统异常经常遇到,因此对未知结果进行再次查询处理
          send query memsage into MQ  延迟3s接收消息
          delete message
          return
     }
     else if (result == orderNotExist) {
          //为什么订单会不存在,刚才明明发起了支付?原因就是发起支付后,如果timeout,请求有可能没有被微信处理或者根本没有接受到请求
          send cancel message into MO
          delete message
          return
     }
 
     //其他情况不举例了
}
//接收到支付结果时调用
public void processPay(paySuccess){
     if (paySuccess) {
          beginTransaction
               lock order
               order.makePaySuccess
          commit
     }
     else{
          beginTransaction
               lock order
               order.makePayFail
          commit
     }
     delete message
}
 
//撤销支付,直到撤销成功才删除消息,否则有可能出现支付单边
public void processCancel(){
     orderId=message.orderId
     wechat.cancel(orderId)
     delete message
}

 

方案说明
  1. 在创建订单阶段,如果订单创建失败,前端直接会返回错误给用户,提示支付失败,不影响资金变动
  2. 在创建订单阶段,如果请求超时,同样可以当做失败来处理。但是这个超时的请求会带来2种 可能性:第一种:请求收到了,但无法在指定时间内响应,订单会创建成功或者创建失败。第二种,请求没有收到,订单也没有创建成功。
  3. 在请求支付阶段,请求失败,表示发送消息失败。不影响资金变动
  4. 在请求支付阶段,请求成功,表示发送消息成功。消息发送成功,只要你代码没有bug,消息一定会正常收到,然后进行支付处理
  5. 在请求支付阶段,如果请求超时,也会有2种可能。第一种:请求收到了,无法在指定时间相应,消息发送成功。第二种:请求收到了,发送消息失败。都没有关系,客户端只要继续轮询支付结果就可以。
  6. 轮询支付结果阶段,任何的超时,都忽略,只要重复轮询就可以,除非获得明确的响应结果。当然也不可能无限制的轮询,可以在指定之间范围之内,通常我们是在1-2分钟之内。如果超过这个时间还没有获得准确的支付结果,那就可以直接请求撤销本次支付
  7. 轮询支付结果阶段,还会出现的一种状况就是客户端crash崩溃退出,无法继续刚才的轮询。因此,支付的任何情况都不能把可靠性交给客户端来保证。需要服务端来保证支付的最终结果
  8. 服务端其实也存在一个轮询,但是这个轮询不是foreach。试想一下,如果是foreach等待结果,那一次请求的时间就不可控了,当支付并发量大了之后,服务器就无法接收新的请求了。因此通过消息队列来实现轮询,这样每次都是非常短时间的数据处理,然后发送一个continue的消息,接收后再次进行后面的处理。这样的好处就是每次请求都是短时间,不会阻塞服务器的其他请求
  9. 任何网络请求都有可能超时,超时后必须查询才可能获得结果,但是也不一定会立刻获得结果,必须进行多次查询,因此还是通过消息队列来实现foreach的循环调用
  10. 为什么要延迟3s接收消息,当用户需要输入密码时,间隔3s,基本时间就足够了,如果间隔太小,查询过多也没有意义,此时间可以根据实际情况调整
 
会出现的问题
微信扫码支付超时,然后发起查询,返回结果告知订单不存在,当时考虑订单不存在就不处理了,但实际上,过了一会订单支付成功。因为在查询的时候,支付的请求还没到达,之后支付的请求也到达了。因此在这个场景下,对于不存在的订单也必须进行一次撤销操作。这时,撤销的功能就非常重要了,它能最彻底的防止支付单边。
 
微信如何设计的撤销
 
先看一个错误的设计
public void cancelOrder(orderId)throw CancelException{
     beginTransaction
     order = db.getOrderId(orderId)
     if (order == null) {
          throw CancelException("order not exist")
     }
     order.makeCancel
     commit
}

 

当扫码支付的请求没有到达时,如果使用上面的撤销我们得到的结果只能是订单不存在,并没有把订单进行撤销。但是支付请求有可能是在撤销请求之后到达,这时支付就会成功,这与我们期待的结果是不一样,我们希望用户的支付是无效的。
 
正确的设计
 
//根据订单号进行支付撤销,不管订单是否存在,只要不是关闭或者失败状态,都可以进行撤销
public void cancelOrder(orderId)throws CancelException{
     beginTransaction
     order = db.getOrderId(orderId)
     if (order != null) {
        if (order.isPayFail) {
               throw new CancelException("order is fail");
          }
          else if (order.isClosed) {
               throw new CancelException("order is fail");     
          }
          .......
          order.makeCancel//把钱退回
     }
     makeCancelByOrderId(orderId)
     commit
}

 

那么 makeCancelByOrderId(orderId) 里面做了什么呢,这里大致的表结构如下
 
table:tb_order_cancel{
     orderId varchar(50), //orderId是唯一索引或主键
     createTime bigint     
}
 
//记录了被撤销的订单号
public void makeCancelByOrderId(orderId){
     try{
          insert into tb_order_cancel value(orderId,createTime);
     }catch(DuplicateKeyException ignore){
          //ignore exception
     }
}
 
支付请求到达时,会先判断是否订单号已经被撤销,如果被撤销,就不再进行支付
public order scanPay(orderId,amount) throws PayException{
     cancelOrder = select from tb_order_cancel
     if (cancelOrder != null) {
          //说明被撤销了,不应该完成支付
          throw new PayException("order was canceled")
     }
     do pay ......
}

 

不管是支付还是撤销请求,谁先到达,最终都会被撤销,并把钱退回用户微信
 
以上的方法基本能覆盖了条码支付的所有问题。这个只是其中的一部分,还有退款、撤销业务其实只要按照以上的思路来做,就没什么问题了。
 
开发完成后,需要模拟各种网络情况和返回结果进行测试。这个测试条件比较复杂,只能一个个模拟。
针对于支付这里,我开发了一套基于aop的动态模拟功能,提供给测试人员。测试人员通过界面输入各种条件,就能模拟对应的问题进行测试。目前这个代码还在改进,暂时不会开放出来。
 
总结
  1. MQ是基础的保障设施,如果没有它,啥也干不了。当然我们可以用定时任务来替代,不过这个方案在数据和并发少的时候还行,一旦大了,那就不好办了。
  2. 经过网络的请求,timeout必须要考虑。timeout会产生2个结果,一个是对方收到了,一个是对方没有收到
  3. 如果要做到无单边,就一定要配合查询、撤销操作。你也可以等定时自动对账来找出差错,这个没问,但是如果能及时退钱,就避免了用户和商户的纠纷。以前我们的系统就是没有及时退回,用户经常投诉,无奈只能及时人工解决。自从方案换了之后,基本上一旦遇到问题1分钟之内,都会把钱及时退回,不用人工操作。
  4. 一定要考虑到任何事情都是有可能发生,但是需要坚信2个前提:消息队列是可靠的,数据库事务提交后持久化是可靠的。如果不考虑全面,我只能告诉你,当你遇到支付宝和微信bug N个小时的时候,你就知道人工搞数据是多么惨了
  5. 支付中的订单允许再次发起支付
 
下一篇,我会讲一下如果可靠的对接微信网页支付,网页支付的例子我会增加组合支付的部分,扫码支付这里的组合支付这次先忽略掉。
 
 

如何可靠的对接微信、支付宝条码支付

上一篇:习题:寻找字符串


下一篇:每日一题:1005. K 次取反后最大化的数组和