記一次支付系統(tǒng)的設(shè)計體驗(yàn)

轉(zhuǎn)載:記一次支付系統(tǒng)的設(shè)計體驗(yàn)

0、寫在前面的話

支付系統(tǒng)是一個老生常談的話題,我也相信每個公司開發(fā)的支付系統(tǒng)不盡相同,因?yàn)闃I(yè)務(wù)形態(tài)并不太一樣。

在此,我并不想講一個大而全的支付系統(tǒng),個人也沒有能力去闡述。

在我看來,一個支付系統(tǒng)應(yīng)提供支付渠道管理,支付網(wǎng)關(guān),基本支付/退款/轉(zhuǎn)賬能力,支付記錄/明細(xì),及其相關(guān)的監(jiān)控運(yùn)維系統(tǒng)。

至于所謂的賬務(wù)清算,對賬功能,賬戶體系,風(fēng)控體系,現(xiàn)金流量管理,應(yīng)該納入到「財務(wù)系統(tǒng)」,大概是大佬們談?wù)摰亩际菑V義的「支付系統(tǒng)」吧!

而我今天只談狹義的「支付系統(tǒng)」。

目前,支付的流程包含了三大部分:發(fā)起支付,發(fā)起退款,接收回調(diào)。

考慮到吞吐量的影響,將原先同步的編程方式改為異步的編程方式,不出意外的話,將會使用到Java8的ExecutorService和CompletableFuture。

此外,還用到了公司其他的現(xiàn)成的東西:RabbitMQ,Redis,MongoDB。

我是打算將這套支付系統(tǒng)設(shè)計成與具體業(yè)務(wù)無關(guān),可以納入到公司的公共平臺系統(tǒng)中。

具體是如何做到的,請接著往下讀。

1、發(fā)起支付

這一部分講述的是客戶端和服務(wù)端如何配合完成一次支付請求。服務(wù)端必須要有一個意識,最終發(fā)起支付的還是客戶端,服務(wù)端提供一些必要的參數(shù)配置信息。

發(fā)起支付的架構(gòu)圖如下所示:

發(fā)起支付架構(gòu)圖

跟著標(biāo)注的序號,可以跟蹤到一個支付請求是如何發(fā)起的(Sequence Diagram就免了),流程描述如下:

Submit a pay task,當(dāng)客戶端需要發(fā)起支付的時候,起始是向支付任務(wù)隊列里面加入了一個新的支付任務(wù),這個過程是異步實(shí)現(xiàn)的。先根據(jù)客戶端提交的參數(shù),構(gòu)造好一個新的支付任務(wù);

Offer a task,開啟一個異步任務(wù),做的事情就是向MQ中添加一個新的支付任務(wù),等待被消費(fèi);

Pay task description,一旦異步任務(wù)被成功創(chuàng)建,將會把第一步構(gòu)造好的支付任務(wù)信息直接return給客戶端;

Poll a task,與此同時,支付任務(wù)的消費(fèi)者將新的支付任務(wù)poll下來進(jìn)行執(zhí)行;

Send a pay request,這一步需要根據(jù)實(shí)際情況而定。并不是所有的支付請求都要先經(jīng)過第三方支付平臺,比如支付寶;而對于微信,則還需要憑支付參數(shù)申請一個prepay_id,再經(jīng)由客戶端發(fā)起支付;

Response,沒什么好說的,第三方渠道返回的支付必要參數(shù);

Cache result,至此,一個支付任務(wù)可以算是完成了,可以將任務(wù)的執(zhí)行結(jié)果(無論成功與否)緩存在Redis中,隨時等待客戶端的回訪;

Query result,客戶端在提交支付任務(wù)后,間隔一定時間后(建議2~3s),發(fā)起一個結(jié)果查詢的請求;

Query,直接進(jìn)Redis查找結(jié)果;

Synchronize,這是一個異步的操作,將支付任務(wù)的執(zhí)行結(jié)果“順便”同步到MongoDB中,并刪除Redis中緩存的任務(wù)執(zhí)行結(jié)果。持久化到MongoDB主要是為后續(xù)的容錯,重試,數(shù)據(jù)分析等提供落地的數(shù)據(jù)源;

Return,由Redis返回給應(yīng)用服務(wù)器;

Return payment,應(yīng)用服務(wù)器再將最終的支付對象返回給客戶端。

讓我們更深入一點(diǎn),我們來看三張Class Diagram:

① 先說說支付任務(wù)(PayTask)部分。PayTask和Payment兩個都是MongoDB中的Document對象,但在任務(wù)執(zhí)行期間,PayTask是用Redis進(jìn)行緩存的,方便客戶端隨時發(fā)起Query,任務(wù)執(zhí)行成功后,會生成Payment對象,最終PayTask和Payment都會持久化到MongoDB中。在PayService中,有對支付任務(wù)的一些基本操作,包括任務(wù)提交,取消,重試,構(gòu)建等等。

② 再說說任務(wù)的執(zhí)行(runner)。這部分和RabbitMQ緊密相關(guān),一旦一個支付任務(wù)形成了,就會放入任務(wù)執(zhí)行隊列中,由消費(fèi)者取出執(zhí)行。在TaskRunner中,有兩個基本的接口方法:run(task)、retry(task),分別是執(zhí)行任務(wù)和重試任務(wù)。在AbstractPayTaskRunner中已經(jīng)封裝好了這兩個方法,繼承AbstractPayTaskRunner需要實(shí)現(xiàn)doTask方法,從返回值可以看出,這個過程是異步化的。關(guān)于Retry機(jī)制,用戶可以設(shè)置重試與否,一旦設(shè)置了TaskInfo.needRetry=true(不出意外,默認(rèn)就是允許重試),就啟用了Retry機(jī)制。還可以設(shè)置重試的次數(shù)(TaskInfo.retryTimes),默認(rèn)三次,分別間隔1s,2s,3s,間隔時間以公差為1的等差數(shù)列組成。當(dāng)然不會讓用戶無限重試,系統(tǒng)內(nèi)置有一個最大重試次數(shù),最大重試次數(shù)內(nèi)置為5次。

為什么是5次?

你感受一下,1s,2s,3s,4s,5s,整個請求鏈條就被拉長到了15s,這對客戶端簡直就是災(zāi)難了??!

③ 接著說一下支付渠道(PayChannel)。這部分設(shè)計與具體的支付渠道對接聯(lián)系比較緊密了,包括支付參數(shù)配置,支付參數(shù)處理,簽名/驗(yàn)簽等等。

④ 最后解釋一下支付參數(shù)(PayParams)。

大部分還是能看懂的,我解釋幾個關(guān)鍵的property:

1) appId,這是為了區(qū)分不同的產(chǎn)品所設(shè)置的?,F(xiàn)實(shí)中,很有可能一個產(chǎn)品會申請與之對應(yīng)的支付渠道,然后在支付平臺中創(chuàng)建應(yīng)用,設(shè)置好對應(yīng)的支付參數(shù),系統(tǒng)將會分配一個appId,憑此值就可以直接定位到各個支付參數(shù)。如果想再更完善一點(diǎn),可以再區(qū)分一下測試環(huán)境和正式環(huán)境;

2) amount,這里代表的是支付金額的意思,但是這套支付系統(tǒng)的金額單位統(tǒng)一設(shè)置成 人民幣【分】;

3) metadata,理論上,元數(shù)據(jù)這個字段沒啥限制,要是非要說有限制,那么就是字段長度了——5000個字符。這個字段的想象空間還是很大的:用于填寫豐富的交易相關(guān)信息,用于在增長智能系統(tǒng)產(chǎn)品中進(jìn)行深入商業(yè)分析。包括交易行為多維分析、人群分析、產(chǎn)品轉(zhuǎn)化路徑、個性化推薦、智能補(bǔ)貼、定向推送等。看產(chǎn)品經(jīng)理要怎么玩了;

5) credential,這個字段非常非常重要,其中裝載的就是客戶端最終發(fā)起支付請求的憑證,會作為Payment對象的一部分返回給客戶端;

MongoDB的document字段設(shè)計

解釋一下為什么要用MongoDB:

個人覺得,如果這個通用服務(wù)要得到較好的推廣(甚至是開源),用MySQL等關(guān)系型數(shù)據(jù)庫是不二之選,因?yàn)橐粋€完整實(shí)用的系統(tǒng),必然是少不了數(shù)據(jù)庫的,如果一旦用了一些非傳統(tǒng)的東西,必然會提高一部分人的對接成本。有的人一看不符合團(tuán)隊的技術(shù)棧,直接就不考慮了。

為什么我還是要用MongoDB呢?

① 團(tuán)隊的技術(shù)棧里面有這么個東西,不用白不用;

② MongoDB普及程度實(shí)在是不要太高,還不用上點(diǎn)NoSQL的東西,感覺自己分分鐘被OUT掉了;

③ 要存儲的數(shù)據(jù)結(jié)構(gòu)需要支持動態(tài)擴(kuò)展的特性,我就看中MongoDB的靈活性,如下是要存儲的數(shù)據(jù)結(jié)構(gòu):

document_name = “Payment”

{"payId":"pay_Oyvrf9e9S1","method":"yoogurt.taxi.pay","version":"v1.0","timestamp":1473044885,"created":1473042835,"paid":false,"appId":"app_iPGa98ab9ev","channel":"wx","orderNo":"20161899798416","clientIp":"192.168.18.189","amount":10000,"subject":"充值訂單-¥100.0","body":"充值訂單-¥100.0","paidTime":null,"transactionNo":"","metadata":{"user_id":"170204469176","phone_number":"13811234567"},"credential":{"appId":"wx4932d1311e","partnerId":"1269774001","prepayId":"wx2016099","nonceStr":"1e99d8fe92ba","timeStamp":"1473042837","packageValue":"Sign=WXPay","sign":"1CECCEDEBE"},"extra":{},"statusCode":"","message":"","description":""}

其中,metadata,credential,extra這類字段,并沒有一個特別固定的規(guī)范,用MySQL要冗余一下字段才行,或者針對每個渠道去分表,想想都覺得煩!

MySQL

因?yàn)檫@套支付系統(tǒng)被設(shè)計成為支持多應(yīng)用,多渠道,所以此處用到MySQL存放一些應(yīng)用配置。 E-R圖免了,直接上數(shù)據(jù)庫表結(jié)構(gòu):

① pay_channel:可供接入的支付渠道

② app_settings:支付應(yīng)用信息

③ app_channel:應(yīng)用已接入的支付渠道

④ alipay_settings:支付寶參數(shù)設(shè)置

⑤ wx_settings:微信app支付參數(shù)設(shè)置

如果想要增加支付渠道,只需要添加一張對應(yīng)的支付參數(shù)設(shè)置表

2、發(fā)起退款

不出意外,客戶在平臺的每筆訂單都可以發(fā)起退款,而且還能分批退,也就是同一個訂單,可以多次發(fā)起退款申請,只要保證退款總額不超出實(shí)付總額。 架構(gòu)圖如下所示:

發(fā)起退款架構(gòu)圖

跟發(fā)起支付請求的流程有很多相似之處,不再一一解釋了,兩個關(guān)鍵的地方說明一下:

客戶端發(fā)起退款請求的時候,需要攜帶payId,就是支付對象的id。這就意味著,支付系統(tǒng)的調(diào)用方需要維護(hù)payId與orderNo的對應(yīng)關(guān)系,務(wù)必在客戶端發(fā)起退款請求之前,獲取到正確的payId;

承接上一步,這才有了圖中的第5、6個步驟,從MongoDB中查詢之前的支付對象。第三方渠道通常會要求在退款的時候指定一個退款單號,因?yàn)橐还P訂單可以分多次退款,所以不建議將訂單號作為退款單號使用。這里的退款單號由支付系統(tǒng)生成并維護(hù)。

這部分的執(zhí)行流程和之前類似,客戶端發(fā)起退款請求,形成一個退款任務(wù)(RefundTask),放入任務(wù)隊列中,消費(fèi)者取出并執(zhí)行各自的業(yè)務(wù)邏輯,退款成功會生成Refund對象,并持久化到MongoDB中。

MongoDB

document_name = "Refund"

{"payId":"pay_vfvS0m1","method":"yoogurt.taxi.pay","version":"v1.0","timestamp":1473044885,"created":1473042835,"refundId":"refund_kmrf9wSr1em","appId":"app_iGa8abLe9ev","orderNo":"20161899798416","clientIp":"192.168.18.189","amount":10000,"succeedTime":1473150835,"transactionNo":"64059968740554","refundStatus":"success","message":"","metadata":{"user_id":"170204469176","phone_number":"13811234567"},"description":""}

3、接收回調(diào)

這部分功能被設(shè)計成了事件驅(qū)動類型,所以webhooks當(dāng)仁不讓。

因?yàn)楦鱾€渠道的回調(diào)內(nèi)容都不盡相同,所以這部分設(shè)計會按支付渠道切分。

架構(gòu)圖如下:

處理回調(diào)事件

用戶在支付完畢后,第三方支付渠道通過發(fā)起支付時指定的回調(diào)地址對商戶進(jìn)行支付成功的異步通知。

這部分的執(zhí)行流程和之前類似,在各自的PayChannel中解析好回調(diào)參數(shù),形成一個回調(diào)事件(Event),并持久化到MongoDB中,然后再生成一個回調(diào)任務(wù)(EventTask),放入任務(wù)隊列中,消費(fèi)者取出并執(zhí)行各自的業(yè)務(wù)邏輯,這里的消費(fèi)者就是上游的業(yè)務(wù)服務(wù)系統(tǒng)。

MongoDB

document_name = “Event”

{"eventId":"evt_la06Co7wq","created":1427555016,"eventType":"pay.succeeded","data":{"payId":"pay_OvP88CSm1","method":"yoogurt.taxi.pay","version":"v1.0","timestamp":1473044885,"created":1473042835,"paid":false,"appId":"app_iGa9aLe9ev","channel":"wx","orderNo":"20161899798416","clientIp":"192.168.18.189","amount":10000,"subject":"用戶充值-¥100.0","body":"充值訂單-¥100.0","paidTime":null,"transactionNo":"","statusCode":"","message":"","metadata":{"user_id":"170204469176","phone_number":"13811234567"},"credential":{"appId":"wx4932b511e","partnerId":"1269774001","prepayId":"wx201609051039","nonceStr":"1e9d8fddad","timeStamp":"1473042837","packageValue":"Sign=WXPay","sign":"1C0K3C95AKB"},"extra":{},"description":""},"retryTimes":0}

特別說明一下data字段:

如果是支付成功事件,則返回對應(yīng)的Payment對象;

如果是退款成功時間,則返回對應(yīng)的Refund對象

總結(jié)

可能有的讀者通篇看下來,覺得這并不是什么支付系統(tǒng),僅僅是對接了一下第三方支付渠道,勉強(qiáng)算是支付渠道網(wǎng)關(guān)吧!

如果你有這種感受,我也是非常認(rèn)同的。

個人認(rèn)為這篇文章還是比較接地氣的,沒有太多理論的東西,看到的更多是實(shí)現(xiàn)層面的內(nèi)容,就差貼代碼了!

坦白地講,第三方支付渠道對接了不少次,卻并沒有像現(xiàn)在這樣系統(tǒng)地去設(shè)計,去總結(jié)。

我用過幾次ping++的產(chǎn)品,在企業(yè)級聚合支付領(lǐng)域,ping++算是業(yè)界領(lǐng)先者了,所以,我的一些數(shù)據(jù)結(jié)構(gòu)設(shè)計還是與其有幾分相似的,ping++以后也會是我模仿和比較的對象。

這次也是我的支付系統(tǒng)實(shí)現(xiàn)所邁出的第一步,今后也會不斷豐富,完善我自己的支付系統(tǒng)。

希望對你有所幫助!

THANKS!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容