产品概述与整体设计
本文转自:https://blog.csdn.net/think2017/article/details/79820786
背景
如今,网购已经渗透到人们日常生活中的方方面面,做为网购的载体,互联网电商平台发展如火如荼,支付功能做为其不可或缺的一部分,实现起来,也有各种各样的方案。根据自己有限的认知,我主观上把目前行业内的支付实现方案做以下归类:
- 持有支付业务许可证,又称支付牌照,自有支付品牌,比如阿里的支付宝、腾讯的微信支付(财付通)、京东的京东支付等;
- 自建第三方支付聚合平台,对接第三方支付(支付宝、微信支付、中国银联、各大商业银行直连等),为其自有订单提供支付功能;
- 一些研发资源有限的电商平台,选择市场中直接能提供全套聚合支付的支付平台,省去研发环节,能够以最短时间较低的成本为其平台提供支付功能。
我从开发者的角度,主要针对第二类,讲述怎么去构建商户自己的聚合支付平台,以及投产上线后所需要主意的事项,打造一套简单、稳定、高效的聚合支付平台。
整体设计
一个完善的聚合支付系统,拥有支付网关、主动对账、退款网关、支付/退款状态查询等功能模块。我会以LNMP架构为基础,细分成六个章节对每一部分做尽量详细的说明。
聚合支付平台的核心,就是怎么合理的去管理接入的各种支付SDK,很多童鞋从官网下载到SDK,几乎不做任何逻辑修改,就直接放到项目的目录中使用,这样做虽然开发成本很低,但弊端颇多,首先要说的就是不易维护,各支付SDK代码结构、风格不一样,后期维护成功高;代码各自为政,没有统一的调用方法;配置分散,无法集中维护系统配置项;无法提供统一有效的日志数据等。因此,我建议首先定义一个Interface,如下图:
通常情况下,一种支付方式有一个class[将其class备注为支付类]来实现,但面对一种支付方式提供了多种支付场景,比如微信(提供了公众号支付、APP支付、扫码支付、H5支付、小程序支付、微信免密代扣等)、中国银联(提供了PC网关支付、WAP支付、APP支付、银联云闪付等),我们该怎么办,我建议针对每种不同的支付场景,都有单独的class来实现,理由如下:
- 不同的支付场景,程序执行的流程也不一样,比如中国银联PC网关支付,是需要将支付报文通过客户端浏览器表单POST给银联支付网关,跳转至银联支付网页进行支付,而银联APP支付则是通过curl将支付报文提交给银联支付网关,再将其返回的tn码返回给商户APP,商户APP凭该tn码发起支付交易;
- 对订单系统的订单支付方式展示更加准确,分配给商户不同购物平台(PC端、H5端、APP)的支付方式id是唯一的。如果商户系统不同支付场景所申请的商户号不一样,则需要在推送至财务系统的支付方式也不能重复,否则无法对账;
- 支付类的代码逻辑只关注于自身的支付逻辑处理,不引入额外的判断流程。
那么,就有童鞋就会想到了,一个很头疼的问题,代码冗余。大部分第三方支付,虽然提供了不同支付场景,但基础接口都是一样的,只是部分参数不同,或支付流程上面的少许差别。这时候我们就要考虑好以第三方支付平台为单位来封装一个支付基类,该支付基类实现其所必须的接口调用,比如微信支付,代码如下图:
对上图做简要说明,PaymentHandlerInterface是所有支付类的接口,WechatPayment是所有微信支付类的基类,WechatAPPPayment、WechatJSAPIPayment、WechatNativePayment都是提供支付服务的支付类,都需要继承WechatPayment并实现PaymentHandlerInterface接口。同理,系统如果需要接入银联在线支付,那么就需要按照开发文档实现一个ChinaPayPayment做为银联在线支付的基类,然后分别开发出具体支付场景的支付类,比如ChinaPayAPPPayment(银联app支付)、ChinaPayWAPPayment(银联wap支付)、ChinaPayPCPayment(银联pc支付),这三个支付类需要继承ChinaPayPayment并实现PaymentHandlerInterface接口。
支付网关与异步通知设计
支付网关
用户下单成功后,要经过收银台发起支付流程,支付网关就是用户发起支付流程的入口地址。支付网关需要接收订单的部分数据(订单号、待支付金额、商品描述信息等)和交易数据(支付方式、交易起止时间、回调地址等)以及签名,支付网关接收到收银台的支付请求后,验证并处理支付请求数据,再根据支付方式获取支付实例(比如WechatAPPPayment对象),发起支付(执行doPay)。 支付交易流水表,以下重要字段:
Name | Field | remark | |
系统订单号 | order_id | 商户订单系统的真实订单号 | |
trade_no | 传给第三方平台的订单号 | ||
out_trade_no | 第三方平台返回的交易流水号 | ||
total_fee | 订单支付金额 | ||
pay_status | enum(wait、success、Failed) | ||
sync_status | 支付时间 | pay_time | |
sync_time |
- 支付网关用来接收来自订单系统的支付请求,由于考虑到系统做活动要支撑比平日多出几倍甚至几十倍的QPS,在支付网关,就要考虑用消息队列中间件来缓存请求的支付数据,再有后端消费进程数据写入db,比如使用Redis List做队列服务,Redis Hash表做缓存。不建议单条订单数据直接做为key存储至Redis,Redis实例如果keys总量太大,会导致查询性能骤降。因为无法直接对Redis Hash表中field设置过期时间,我们需要写脚本按规则去清理老数据以腾空间。
- 如果系统使用PHP来编程,某些商业银行直连支付,建行app支付、招行一网通支付的验签流程需要通过JavaBridge来调用银行提供的jar包,完成签名,PHP实例化java类库性能较差。因此,要避免每次网关支付请求的初始化过程来引入这些jar包, 需要加载的时候再实例化。
- 商户支付系统在向第三方支付平台发起支付请求时,商户订单号字段不能直接使用商户订单系统的真实订单号orderid,要重新生成一个支付单号tradeno,为什么要这样做,有以下几个原因:
- 微信支付,不是直接call支付网关,而是先请求统一下单接口,获取预支付交易会话标识,然后有这个会话标识发起支付请求。那么问题来了,如果用户在app下单选择微信app支付,获取到会话标识后,没有完成支付,然后在商户的公众号平台发现这个订单,再次支付,系统会切换到微信公众号支付,这时候统一下单接口会报错,因为该订单已经申请过会话标识了。因此,需要重新生成一个新的支付单号,再来调用统一下单接口;
- 招商银行网关支付,对商户订单号的格式要求为6位或10位数字,因此,所生成的支付单号比较特殊,要按其要求生成;
- 对于拥有账户余额模块的商户平台,收银台一般都支持第三方支付和余额抵扣组合使用,已完成订单支付。当用户在多次选择或取消余额部分抵扣,导致订单支付金额变动时,已经通过接口申请到支付凭证的,订单无法再次申请支付凭证,需要重新生成一个新的支付单号使用。
支付异步通知
支付通知,是用来接收来自银行或者第三方支付平台的订单支付结果通知,分为两种,一种是同步通知(又称前台通知),一种是异步通知(又称后台通知),简单的说,商户支付系统收到支付同步通知并且支付状态为已支付,我们需要将订单支付状态修改为支付确认中,商户支付系统收到支付异步通知并且支付状态为支付成功,我们需要将订单支付状态修改为已支付。再次强调下,商户支付系统要以异步通知的结果为准。
@H_404_33@异步通知设计,需要注意以下几点:- 商户支付系统对已接入的第三方支付平台提供的通知地址不要重复,商户支付系统通过判断请求的URI就清楚的知道这是来自哪个平台哪一种支付方式的支付通知,不要尝试对已接入的第三方支付平台只提供一个公用的URL,然后通过解析报文格式来判断是来自哪个平台哪一种支付方式,这样做是混乱且不可控的;
- 支付系统拿到支付通知报文之后,首先解析成统一的格式,比如array,然后要验证签名是否正确、还要验证支付金额是否一致,最后再去判断订单支付状态;
- 支付系统对所有金额(float类型)的判断要先使用BC数学函数转换为int类型,再做判断,避免因PHP浮点数精度问题导致金额校验失败;
- 对已通过验证的订单数据,可以将其push到支付成功队列,有独立的常驻进程(队列消费进程)去通知订单中心,为什么这样做,有以下三个原因:
- 来自第三方支付平台的异步通知,是有第三方支付平台的服务端主动发起,目的只有一个,商户支付系统拿到支付通知报文并验证通过后,要立刻对其返回正确的回执,因此我们尽量不引入除接收支付报文之外的其他逻辑。我们商户服务器在接受到通知请求后,处理好数据并将其push到队列,是相对性能开销最小且稳定的做法,可以最快速度给第三方支付平台服务器以正确回执,且第三方支付平台不受商户自身业务处理流程的影响;
- 如果在异步通知处理流程中,直接发起对商户订单系统的通知,如遇到极端情况,商户订单系统出现异常或者响应过慢,势必会影响到商户支付系统对第三方支付平台的回执,虽然部分第三方支付平台有重发机制,但基于性能以及订单到账效率考虑,我们商户方尽可能做到一次就响应成功;
- 通过消息队列这一中间件,消费进程在对商户订单系统通知支付结果的过程中,如遇异常,没有收到商户订单系统的成功应答,那么我们可以将数据push到异常处理队列中,再有异常处理队列的消费进程定时去消费这部分数据,直到消费进程收到商户订单系统的成功应答为止。
退款网关与退款状态查询设计
背景
退款业务,相对于支付业务,部分需求方(包括产品、市场的同事)认为退款业务不是那么紧急或重要。从业务角度分析,没有支付业务,用户无法支付或支付优惠活动无法开展,但没有退款功能,则不影响用户下单支付和开展优惠活动。用户申请退款,财务可登录第三方支付平台提供的商户管理系统进行人工退款操作。因此,目前应该还有许多电商平台的退款业务都是财务人工操作的,当公司订单到了一定规模,人工退款操作则是不可行的。这时候,则需要一一对接退款业务。
从系统安全角度分析,退款业务的重要性甚至比支付业务更要高,因为退款业务可以理解为是商户自己向用户付钱,如果多付,所造成的公司财务损失,几乎不可能追回。常见的风险有:订单申请了部分退款,由于各种原因造成多退的情况;系统退款由于操作人疏忽或其他原因造成不该退款的订单退款给用户的情况。以上两种情况,我身边确实有这样的案例,最大的一次损失,是一个下午,给公司造成了80多万的损失。鉴于此,在设计退款模块的童鞋,逻辑一定要缜密,不要有疏忽、漏洞等隐患。
退款网关
对用户主动取消的已付款订单、或者因为库存不足、无法配送等各种原因需要撤销订单的,都需要给用户进行退款操作,退款形式有原路退款、银行转账、退余额等,目前主流的都是进行原路退款。退款网关不能有用户直接访问,订单要有退款申请与审批流程,一般是在订单管理系统控制,有订单管理系统调用退款网关,发起退款请求,聚合支付系统要对退款网关做好身份验证及安全防范。
聚合支付平台退款部分,也需要异步通知处理队列,消费队列接受来自第三方异步通知、crontab主动查询或人工查询到已退款成功的订单退款数据,将其统一处理,更新退款单状态、通知订单系统等操作。
退款交易流水表,主要字段展示
refund_id | 传给第三方平台的退款订单号 | |||||||
out_refund_id | 一般指订单系统传来的退款单号 | |||||||
refund_no | 第三方支付平台的退款流水号 | |||||||
payment_id | 退款发起平台 | platform | 订单支付金额 | 退款金额 | refund_fee | 退款类型 | refund_type | 全额、部分 |
refund_status | 退款申请时间 | create_time | notify_time | 同步订单系统时间 |
注意点
- 不管来自哪里的退款申请,都要先查询订单支付状态,直接调用第三方支付的查单接口去查最新的订单状态、可退金额等信息,并以此为准;
- 退款金额校验,如果是全额退款,则只需验证退款金额等于支付金额,方可调用退款接口进行退款;如果是部分退款,则要计算已经成功退款的金额总额,以及已经退款申请成功,但还没收到第三方的退款成功通知这部分的金额总额,订单支付金额减掉这两部分的总金额之后的金额是可退金额;
- 对于部分退款申请处理,如本次已经申请成功了,还在处理中的退款,不要重复申请退款;
退款异步通知
同支付异步通知一样,退款异步通知也建议使用队列进行解耦,收到第三方的退款通知,只需要验签和金额校验后,则将报文数据push到退款通知队列中,有后端消费进程去更新退款单的退款状态、通知订单系统的退款状态等后续处理流程。
支付/退款状态查询
针对上一章节所遇到的问题,虽然主动对账可以处理掉绝大部分已支付订单被挂起的问题,但难免有漏网之鱼,没有被及时处理的订单,用户肯定不干,要投诉平台。鉴于此,我们开发人员需要给客服或者财务同事提供一个后台查询系统,用于处理客诉中这类问题的订单。该模块需要支持两点: