聚合支付系统设计

Posted SuperAvalon

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了聚合支付系统设计相关的知识,希望对你有一定的参考价值。

未经许可,谢绝转载,违者必究

 

产品概述与整体设计

背景

如今,网购已经渗透到人们日常生活中的方方面面,做为网购的载体,互联网电商平台发展如火如荼,支付功能做为其不可或缺的一部分,实现起来,也有各种各样的方案。根据自己有限的认知,我主观上把目前行业内的支付实现方案做以下归类:

  • 持有支付业务许可证,又称支付牌照,自有支付品牌,比如阿里的支付宝、腾讯的微信支付(财付通)、京东的京东支付等;
  • 自建第三方支付聚合平台,对接第三方支付(支付宝、微信支付、中国银联、各大商业银行直连等),为其自有订单提供支付功能;
  • 一些研发资源有限的电商平台,选择市场中直接能提供全套聚合支付的支付平台,省去研发环节,能够以最短时间较低的成本为其平台提供支付功能。

我从开发者的角度,主要针对第二类,讲述怎么去构建商户自己的聚合支付平台,以及投产上线后所需要主意的事项,打造一套简单、稳定、高效的聚合支付平台。

一个完善的聚合支付系统,拥有支付网关、主动对账、退款网关、支付/退款状态查询等功能模块。
我会以LNMP架构为基础,细分成六个章节对每一部分做尽量详细的说明。

支付接口

聚合支付平台的核心,就是怎么合理的去管理接入的各种支付SDK,很多童鞋从官网下载到SDK,几乎不做任何逻辑修改,就直接放到项目的目录中使用,这样做虽然开发成本很低,但弊端颇多,首先要说的就是不易维护,各支付SDK代码结构、风格不一样,后期维护成功高;代码各自为政,没有统一的调用方法;配置分散,无法集中维护系统配置项;无法提供统一有效的日志数据等。因此,我建议首先定义一个Interface,代码如下:

<?php

namespace SuperAvalon\\Payment\\Interfaces;

/**
 * PaymentHandlerInterface
 *
 * PHP 7.3 compatibility interface
 *
 * @package	    SuperAvalon
 * @subpackage	Payment
 * @category	Payment Libraries
 * @Framework   Lumen/Laravel
 * @author	    Eric <think2017@gmail.com>
 * @Github	    https://github.com/SuperAvalon/Payment/
 * @Composer	https://packagist.org/packages/superavalon/payment
 */
interface PaymentHandlerInterface 

	public function doPay($payParams = array());
	public function doQuery($queryParams = array());
	public function doRefund($refundParams = array());
	public function doRefundQuery($queryParams = array());
	public function payNotifyHandler(&$input);
	public function refundNotifyHandler(&$input);

然后,每次接入新的支付方式的过程,其实就是实现该Interface的过程。

通常情况下,一种支付方式有一个class[将其class备注为支付类]来实现,但面对一种支付方式提供了多种支付场景,比如微信(提供了公众号支付、APP支付、扫码支付、H5支付、小程序支付、微信免密代扣等)、中国银联(提供了PC网关支付、WAP支付、APP支付、银联云闪付等),我们该怎么办,我建议针对每种不同的支付场景,都有单独的class来实现,理由如下:

  • 不同的支付场景,程序执行的流程也不一样,比如中国银联PC网关支付,是需要将支付报文通过客户端浏览器表单POST给银联支付网关,跳转至银联支付网页进行支付,而银联APP支付则是通过curl将支付报文提交给银联支付网关,再将其返回的tn码返回给商户APP,商户APP凭该tn码发起支付交易;
  • 对订单系统的订单支付方式展示更加准确,分配给商户不同购物平台(PC端、H5端、APP)的支付方式id是唯一的。如果商户系统不同支付场景所申请的商户号不一样,则需要在推送至财务系统的支付方式也不能重复,否则无法对账;
  • 支付类的代码逻辑只关注于自身的支付逻辑处理,不引入额外的判断流程。

那么,就有童鞋就会想到了,一个很头疼的问题,代码冗余。大部分第三方支付,虽然提供了不同支付场景,但基础接口都是一样的,只是部分参数不同,或支付流程上面的少许差别。这时候我们就要考虑好以第三方支付平台为单位来封装一个支付抽象类类,实现对第三方支付平台的所有api对接,不涉及到商户系统的业务流程,比如微信支付,我们创建一个WechatDriver抽象类,代码如下图:

支付抽象类

<?php

namespace SuperAvalon\\Payment;

use SuperAvalon\\Payment\\Utils\\PaymentUtils;
use SuperAvalon\\Payment\\Utils\\CommonUtils;
use SuperAvalon\\Payment\\Interfaces\\PaymentHandlerInterface;


/**
 * WechatDriver
 * 微信支付底层抽象类
 * PHP 7.3 compatibility interface
 *
 * @package	    SuperAvalon
 * @subpackage	Payment
 * @category	Payment Libraries
 * @Framework   Lumen/Laravel
 * @author	    Eric <think2017@gmail.com>
 * @Github	    https://github.com/SuperAvalon/Payment/
 * @Composer	https://packagist.org/packages/superavalon/payment
 */
abstract class WechatDriver implements PaymentHandlerInterface 
    
    use PaymentUtils, CommonUtils;
    
	protected $_config;
    
	protected $_extra;
    
	/**
	 * Class constructor
	 *
	 * @param	array	$apiConfig	Configuration parameters
	 * @return	void
	 */
	public function __construct(&$apiConfig)
	
		$this->_config =& $apiConfig;
	
    
    
    public function getExtraFields()
    
        return $this->_extra;
    
    
	/**
	 * 统一下单接口
	 *
	 * @param array $payParams 
	 * @return array $retval
	 *      code    int         状态码
	 *      type    string      支付凭证类型:prepay_id|code_url|mweb_url
	 *      data    string      支付凭证
	 */
	protected function unified_order($payParams)
	
		$apiParams = [];
        
		$apiParams['body']		    = $this->_config['mch_name'] . '-' . $payParams['subject'];
		$apiParams['appid']		    = $this->_config['app_id'];
		$apiParams['mch_id']	    = $this->_config['mch_id'];
		$apiParams['total_fee']	    = $payParams['total_fee'];
		$apiParams['trade_type']	= $payParams['trade_type'];
		$apiParams['out_trade_no']  = $payParams['trade_no'];
		$apiParams['notify_url']	= $this->_config['notify_url'];
		$apiParams['nonce_str']	    = $this->create_noncestr(32);
        
        if (isset($payParams['openid'])) 
            $apiParams['openid'] = $payParams['openid'];
        
        
        if (isset($payParams['scene_info'])) 
            $apiParams['scene_info'] = $payParams['scene_info'];
        
        
        if ($payParams['trade_type'] == 'NATIVE') 
            $apiParams['product_id'] = $payParams['product_id'];
        
        
        if (isset($payParams['spbill_create_ip'])) 
            $apiParams['spbill_create_ip'] = $payParams['spbill_create_ip'];
         else 
            $apiParams['spbill_create_ip'] = $this->get_client_ip();
        
        
        $retval = [];
        $apiParams['sign'] = $this->unified_sign($apiParams, $this->_config['sign_type']);
        
        $xmlInfo = $this->array_to_xml($apiParams);
        $sResponse = $this->request($this->_config['order_api'], $xmlInfo);
        $aResponse = $this->xml_to_array($sResponse);
        
        $this->write_log(__METHOD__, $payParams['trade_no'], $apiParams, $aResponse);
        
		if ($aResponse && $aResponse['return_code'] == 'SUCCESS' && $aResponse['result_code'] == 'SUCCESS') 
			if ($aResponse['trade_type'] == 'JSAPI' || $aResponse['trade_type'] == 'APP' || $aResponse['trade_type'] == 'WAP') 
				$retval = ['code' => 200, 'data' => ['type' => 'prepay_id', 'value' => $aResponse['prepay_id']]];
			 elseif ($aResponse['trade_type'] == 'NATIVE') 
                $retval = ['code' => 200, 'data' => ['type' => 'code_url', 'value' => $aResponse['code_url']]];
			 elseif ($aResponse['trade_type'] == 'MWEB') 
                $retval = ['code' => 200, 'data' => ['type' => 'mweb_url', 'value' => $aResponse['mweb_url']]];
			
		
        
        if (empty($retval)) 
            if (isset($aResponse['err_code']) && isset($aResponse['err_code_des'])) 
                $retval = ['code' => 500, 'api_err_code' => $aResponse['err_code'], 'api_error_msg' => $aResponse['err_code_des']];    
             elseif (isset($aResponse['return_code']) && isset($aResponse['return_msg'])) 
                $retval = ['code' => 500, 'api_err_code' => $aResponse['return_code'], 'api_error_msg' => $aResponse['return_msg']];    
            
        
		
		return $retval;
	
    
     
	/**
	 * 订单支付状态查询接口
	 *
	 * @param string $tradeNo   订单支付单号 
	 * @return array $retval
	 *      code    int         状态码
	 *      data    string      接口返回报文
	 */
    public function order_query($tradeNo)
    
		$apiParams = [];
        
        $apiParams['out_trade_no']	= $tradeNo;
		$apiParams['appid']		    = $this->_config['app_id'];
		$apiParams['mch_id']		= $this->_config['mch_id'];
		$apiParams['nonce_str']	    = $this->create_noncestr(32);
        $apiParams['sign']          = $this->unified_sign($apiParams, $this->_config['sign_type']);
        
        $xmlInfo = $this->array_to_xml($apiParams);
        $sResponse = $this->request($this->_config['query_api'], $xmlInfo);
        $aResponse = $this->xml_to_array($sResponse);
        
        $this->write_log(__METHOD__, $tradeNo, $apiParams, $aResponse);
        
        $postSign = $aResponse['sign'];
        unset($aResponse['sign']);
        
        $retval = [];
        $paySign = $this->unified_sign($aResponse, $this->_config['sign_type']);
        
        if (strtolower($paySign) != strtolower($postSign)) 
            return ['code' => 407, 'data' => $aResponse, 'msg' => 'Verify Error.'];
		
        
        if ($aResponse['return_code'] == 'SUCCESS' && $aResponse['result_code'] == 'SUCCESS') 
           $retval = ['code' => 200, 'data' => $aResponse, 'msg' => 'Success.'];
         else 
           $retval = ['code' => 403, 'data' => $aResponse, 'msg' => 'Error.'];
        
        
        return $retval;
    
    
     
	/**
	 * 微信支付/退款异步通知
	 *
	 * @param string $input     通知报文 
	 * @return array $retval
	 *      code    int         状态码
	 *      data    string      接口返回报文
	 */
	public function notify(&$input)
	
        $aResponse = (array)simplexml_load_string($input, 'SimpleXMLElement', LIBXML_NOCDATA);
		
        $postSign = $aResponse['sign'];
        unset($aResponse['sign']);
        
        $retval = [];
        $paySign = $this->unified_sign($aResponse, $this->_config['sign_type']);
        
        if (strtolower($paySign) != strtolower($postSign)) 
            return ['code' => 407, 'data' => $aResponse, 'msg' => 'Verify Error.'];
		

        if ($aResponse['return_code'] == 'SUCCESS' && $aResponse['result_code'] == 'SUCCESS') 
            $retval = ['code' => 200, 'data' => $aResponse, 'msg' => 'Success.'];
         else 
            $retval = ['code' => 403, 'data' => $aResponse, 'msg' => 'Error.'];
        
        
        return $retval;
	
    
    
	/**
	 * 退款申请接口
	 *
	 * @param array $refundParams   退款单数据 
	 *      trade_no    string      订单支付单号
	 *      refund_no    string     订单退款单号
	 *      total_fee    float      订单实付金额
	 *      refund_fee   float      退款申请金额
	 * @return array $retval
	 *      code    int         状态码
	 *      data    string      接口返回报文
	 */
    public function refund($refundParams)
            
        $apiParams = [];
        $totalFee = (int)bcmul($refundParams['total_fee'], 100);
        $refundFee = (int)bcmul($refundParams['refund_fee'], 100);

        $apiParams['out_trade_no']	= $refundParams['trade_no'];
        $apiParams['out_refund_no']	= $refundParams['refund_no'];
        $apiParams['total_fee']	    = $totalFee;
        $apiParams['refund_fee']	= $refundFee;
        $apiParams['appid']		    = $this->_config['app_id'];
        $apiParams['mch_id']		= $this->_config['mch_id'];
        $apiParams['nonce_str']	    = $this->create_noncestr(32);
        $apiParams['sign']          = $this->unified_sign($apiParams, $this->_config['sign_type']);

        $certFile = [
            'cert' => storage_path($this->_config['refund_ssl_cert']),
            'key' => storage_path($this->_config['refund_ssl_key']),
        ];
        
        $xmlInfo = $this->array_to_xml($apiParams);
        $sResponse = $this->request($this->_config['refund_api'], $xmlInfo, 10, [], $certFile);
        $aResponse = $this->xml_to_array($sResponse);
        
        $this->write_log(__METHOD__, $refundParams['trade_no'], $apiParams, $aResponse);
       
        $postSign = $aResponse['sign'];
        unset($aResponse['sign']);
        
        $retval = [];
        $paySign = $this->unified_sign($aResponse, $this->_config['sign_type']);
        
        if (strtolower($paySign) != strtolower($postSign)) 
            return ['code' => 407, 'data' => $aResponse, 'msg' => 'Verify Error.'];
		
        
        if ($aResponse['return_code'] == 'SUCCESS' && $aResponse['result_code'] == 'SUCCESS') 
            $retval = ['code' => 200, 'data' => $aResponse, 'msg' => 'Success.'];
         else 
            $retval = ['code' => 403, 'data' => $aResponse, 'msg' => 'Error.'];
        
        
        return $retval;
    
    
    
	/**
	 * 订单退款状态查询接口
	 *
	 * @param string $refundNo  订单退款单号 
	 * @return array $retval
	 *      code    int         状态码
	 *      data    string      接口返回报文
	 */
    public function refund_query($refundNo)
    
		$apiParams = [];
        
        $apiParams['out_refund_no']	= $refundNo;
		$apiParams['appid']		    = $this->_config['app_id'];
		$apiParams['mch_id']		= $this->_config['mch_id'];
		$apiParams['nonce_str']	    = $this->create_noncestr(32);
        $apiParams['sign']          = $this->unified_sign($apiParams, $this->_config['sign_type']);
        
        $xmlInfo = $this->array_to_xml($apiParams);
        $sResponse = $this->request($this->_config['refund_query_api'], $xmlInfo);
        $aResponse = $this->xml_to_array($sResponse);
        
        $this->write_log(__METHOD__, $refundNo, $apiParams, $aResponse);
        
        $postSign = $aResponse['sign'];
        unset($aResponse['sign']);
        
        $retval = [];
        $paySign = $this->unified_sign($aResponse, $this->_config['sign_type']);
        
        if (strtolower($paySign) != strtolower($postSign)) 
            return ['code' => 407, 'data' => $aResponse, 'msg' => 'Verify Error.'];
		
        
        if ($aResponse['return_code'] == 'SUCCESS' && $aResponse['result_code'] == 'SUCCESS') 
            $retval = ['code' => 200, 'data' => $aResponse, 'msg' => 'Success.'];
         else 
            $retval = ['code' => 403, 'data' => $aResponse, 'msg' => 'Error.'];
        
        
        return $retval;
    
    
    
	/**
	 * 下载对账单
	 * @param string $billDate   对账单日期 
	 * @param string $billType   对账单类型 
	 * @return array $retval
	 *      code    int         状态码
	 *      data    string      接口返回报文
	 */
    protected function downloadbill($billDate, $billType)
    
        $apiParams = [];

        $apiParams['bill_date']	    = $billDate;
        $apiParams['bill_type']	    = $billType;
        $apiParams['appid']		    = $this->_config['app_id'];
        $apiParams['mch_id']		= $this->_config['mch_id'];
        $apiParams['nonce_str']	    = $this->create_noncestr(32);
        $apiParams['sign']          = $this->unified_sign($apiParams, $this->_config['sign_type']);
        
        $xmlInfo = $this->array_to_xml($apiParams);
        $sResponse = $this->request($this->_config['down_bill_api'], $xmlInfo);
        $aResponse = $this->xml_to_array($sResponse);
        
        $postSign = $aResponse['sign'];
        unset($aResponse['sign']);
        
        $retval = [];
        $paySign = $this->unified_sign($aResponse, 'md5');
        
        if (strtolower($paySign) == strtolower($postSign)) 
			if ($aResponse['return_code'] == 'SUCCESS') 
                $retval = ['code' => 200, 'data' => $aResponse];
			
		
        
        if (empty($retval)) 
            $retval = ['code' => 500, 'data' => $aResponse];
        
        
        return $retval;
    
    
    
	/**
	 * 计算签名
     *
	 * @param	array	$data       签名数据
	 * @param	string	$signType   签名类型    md5/sha1
	 * @return	string  签名结果
	 */
    private function unified_sign($data, $signType = 'md5')
	
        $strInfo = '';
        ksort($data);
		foreach ($data as $key => $val) 
            if ($val === '') 
                continue;
            
			if ($strInfo) 
				$strInfo .= "&" . $key . "=" . $val;
			 else 
				$strInfo = $key . "=" . $val;
			
		
        
        if (strtolower($signType) == 'md5') 
            return strtoupper(md5($strInfo . '&key=' . $this->_config['secret_key']));
         elseif (strtolower($signType) == 'sha1') 
            return sha1($strInfo . '&key=' . $this->_config['secret_key']);
         else 
            return false;
        
    

支付实体类

有了上面的支付抽象类,针对每一种支付方法,都可以继承该抽象类,并拥有自己的独立的支付流程,比如:微信app支付,我们可以创建一个 WechatAppPayment 支付实体类,支付子类调用抽象类提供的各种底层api,来实现支付、查询、退款等功能,代码参考,

<?php

namespace SuperAvalon\\Payment;

use SuperAvalon\\Payment\\Interfaces\\PaymentHandlerInterface;

/**
 * WechatAppPayment
 * 微信app支付中间层
 * PHP 7.3 compatibility interface
 *
 * @package	    SuperAvalon
 * @subpackage	Payment
 * @category	Payment Libraries
 * @Framework   Lumen/Laravel
 * @author	    Eric <think2017@gmail.com>
 * @Github	    https://github.com/SuperAvalon/Payment/
 * @Composer	https://packagist.org/packages/superavalon/payment
 */
class WechatAppPayment extends WechatDriver implements PaymentHandlerInterface 
    
	protected $_config;
    
    protected $_extra = [];
    
	/**
	 * Class constructor
	 *
	 * @param	array	$apiParams	Configuration parameters
	 * @return	void
	 */
	public function __construct(&$apiConfig)
	
		$this->_config =& $apiConfig;
	
    
    
	/**
	 * 支付接口
	 *
	 * @param array $tradeData 支付订单参数
	 * @return array response
	 *      code    int         状态码
	 *      msg     string      接口消息
	 *      data    array      支付凭证类型:prepay_id
	 */
	public function doPay($tradeData = array())
	
		$totalFee = (int)bcmul($tradeData['trade_amount'], 100);
        
        $aResponse = $this->unified_order([
            'trade_type' => 'APP',
            'total_fee' => $totalFee,
            'trade_no' => $tradeData['payment_no'],
            'subject' => $tradeData['subject'] ?? '',
            'pay_body' => $tradeData['pay_body'],
        ]);
        
        if ($aResponse['code'] == 200) 
            return $this->retval(['code' => 200, 'data' => ['prepayid' => $aResponse['data']['value']], 'msg' => 'success.']);
         else 
            return $this->retval(['code' => 500, 'data' => null, 'msg' => 'unifiedorder error.', 'api_error' => $aResponse['api_error_msg']]);
        
	
    
    
	/**
	 * 订单查询接口
	 *
	 * @param array $queryParams 订单查询参数
	 *          string $trade_no 支付单号
	 * @return array 
	 */
    public function doQuery($queryParams = array())
    
        return $this->order_query($queryParams['trade_no']);
    
    
    
	/**
	 * 订单退款申请接口
	 *
	 * @param string $refundParams 退款单数据
	 * @return array 
	 */
    public function doRefund($refundParams = array())
    
        return $this->refund($refundParams);
    
    
	/**
	 * 订单退款状态查询接口
	 *
	 * @param string $refundNo 订单退款单号
	 * @return array 
	 */
    public function doRefundQuery($queryParams = array())
    
        return $this->refund_query($queryParams['refund_no']);
    
    
	/**
	 * 支付通知报文解析验签
	 *
	 * @param string $tradeNo 通知报文
	 * @return array 
	 */
    public function payNotifyHandler(&$input)
    
        return $this->notify($input);
    
    
	/**
	 * 退款通知报文解析验签
	 *
	 * @param string $input 通知报文
	 * @return array 
	 */
    public function refundNotifyHandler(&$input)
    
        return;
    

UML类图 

上面分别提到了 支付Interface、支付抽象类、支付实体类,它们之间的关系是怎样的,见下图

以下UML类图,只以微信、银联部分类为基础,可参考
 

对上图做简要说明,

PaymentHandlerInterface是所有支付类的接口,系统所有支付功能类都需要实现它;

Wechat_driver、Unionpay_driver为对接第三方支付接口的支付抽象类,需要实现第三方支付接口的所有API交互,为支付功能类提供功能方法;

Wechat_app_driver、Wechat_mweb_driver、Wechat_native_driver、Unionpay_app_driver、Unionpay_wap_driver为系统支付功能类,调用抽象类的各基础方法,为系统提供支付、查询、退款、退款查询等功能;

Common_utils、Payment_utils为系统工具类,Common_utils可提供诸如curl封装、日志函数、dns查询等系统可以通用的方法,Payment_utils可封装xml数据解析、各种加密解密函数等第三方支付平台所需的方法;

不定时更新,原著文章,谢绝转载,

 

以上是关于聚合支付系统设计的主要内容,如果未能解决你的问题,请参考以下文章

支付常见问题

php 处理微信账单

支付宝回调之后修改账单的状态

微信抢红包收支明细账单怎么列

聚合支付系统设计

聚合支付系统设计