作者 | 文刀
來源 | 技術匯
支付系統是一個老生常談的話題,我也相信每個公司開發的支付系統不盡相同,因爲業務形態並不太一樣。在此,我並不想講一個大而全的支付系統,個人也沒有能力去闡述。
在我看來,一個支付系統應提供支付渠道管理,支付網關,基本支付/退款/轉賬能力,支付記錄/明細,及其相關的監控運維繫統。
至於所謂的賬務清算,對賬功能,賬戶體系,風控體系,現金流量管理,應該納入到「財務系統」,大概是大佬們談論的都是廣義的「支付系統」吧!
而我今天只談狹義的「支付系統」。
目前,支付的流程包含了三大部分:發起支付,發起退款,接收回調。
考慮到吞吐量的影響,將原先同步的編程方式改爲異步的編程方式,不出意外的話,將會使用到 Java8 的 ExecutorService 和 CompletableFuture。
此外,還用到了公司其他的現成的東西:RabbitMQ,Redis,MongoDB。
我是打算將這套支付系統設計成與具體業務無關,可以納入到公司的公共平臺系統中。
具體是如何做到的,請接着往下讀。
1.發起支付
這一部分講述的是客戶端和服務端如何配合完成一次支付請求。服務端必須要有一個意識,最終發起支付的還是客戶端,服務端提供一些必要的參數配置信息。
發起支付的架構圖如下所示:
跟着標註的序號,可以跟蹤到一個支付請求是如何發起的(Sequence Diagram就免了),流程描述如下:
01/12 Submit a pay task,當客戶端需要發起支付的時候,起始是向支付任務隊列裏面加入了一個新的支付任務,這個過程是異步實現的。先根據客戶端提交的參數,構造好一個新的支付任務;
02/12 Offer a task,開啓一個異步任務,做的事情就是向MQ中添加一個新的支付任務,等待被消費;
03/12 Pay task description,一旦異步任務被成功創建,將會把第一步構造好的支付任務信息直接return給客戶端;
04/12 Poll a task,與此同時,支付任務的消費者將新的支付任務poll下來進行執行;
05/12 Send a pay request,這一步需要根據實際情況而定。並不是所有的支付請求都要先經過第三方支付平臺,比如支付寶;而對於微信,則還需要憑支付參數申請一個 prepay_id,再經由客戶端發起支付;
06/12 Response,沒什麼好說的,第三方渠道返回的支付必要參數;
07/12 Cache result,至此,一個支付任務可以算是完成了,可以將任務的執行結果(無論成功與否)緩存在 Redis 中,隨時等待客戶端的回訪;
08/12 Query result,客戶端在提交支付任務後,間隔一定時間後(建議 2~3s),發起一個結果查詢的請求;
09/12 Query,直接進 Redis 查找結果;
10/12 Synchronize,這是一個異步的操作,將支付任務的執行結果“順便”同步到 MongoDB 中,並刪除 Redis 中緩存的任務執行結果。持久化到 MongoDB 主要是爲後續的容錯,重試,數據分析等提供落地的數據源;
11/12 Return,由 Redis 返回給應用服務器;
12/12 Return payment,應用服務器再將最終的支付對象返回給客戶端。
讓我們更深入一點,我們來看三張 Class Diagram:
01/04 先說說支付任務 (PayTask) 部分。PayTask 和 Payment 兩個都是 MongoDB 中的 Document 對象,但在任務執行期間,PayTask 是用 Redis 進行緩存的,方便客戶端隨時發起 Query,任務執行成功後,會生成 Payment 對象,最終 PayTask 和 Payment 都會持久化到 MongoDB 中。在 PayService 中,有對支付任務的一些基本操作,包括任務提交,取消,重試,構建等等。
02/04 再說說任務的執行 (runner)。這部分和 RabbitMQ 緊密相關,一旦一個支付任務形成了,就會放入任務執行隊列中,由消費者取出執行。在 TaskRunner 中,有兩個基本的接口方法:run(task)、retry(task),分別是執行任務和重試任務。在 AbstractPayTaskRunner 中已經封裝好了這兩個方法,繼承 AbstractPayTaskRunner 需要實現 doTask 方法,從返回值可以看出,這個過程是異步化的。關於 Retry 機制,用戶可以設置重試與否,一旦設置了 TaskInfo.needRetry=true(不出意外,默認就是允許重試),就啓用了 Retry 機制。還可以設置重試的次數 (TaskInfo.retryTimes),默認三次,分別間隔 1s,2s,3s,間隔時間以公差爲 1 的等差數列組成。當然不會讓用戶無限重試,系統內置有一個最大重試次數,最大重試次數內置爲 5 次。
爲什麼是 5 次?
你感受一下,1s,2s,3s,4s,5s,整個請求鏈條就被拉長到了 15s,這對客戶端簡直就是災難了!!
03/04 接着說一下支付渠道 (PayChannel)。這部分設計與具體的支付渠道對接聯繫比較緊密了,包括支付參數配置,支付參數處理,簽名/驗籤等等。
04/04 最後解釋一下支付參數 (PayParams)。
網絡異常[取消](javascript:void(0))[重新上傳](javascript:void(0))
大部分還是能看懂的,我解釋幾個關鍵的 property:
01/04 appId,這是爲了區分不同的產品所設置的。現實中,很有可能一個產品會申請與之對應的支付渠道,然後在支付平臺中創建應用,設置好對應的支付參數,系統將會分配一個 appId,憑此值就可以直接定位到各個支付參數。如果想再更完善一點,可以再區分一下測試環境和正式環境;
02/04 amount,這裏代表的是支付金額的意思,但是這套支付系統的金額單位統一設置成 人民幣【分】;
03/04 metadata,理論上,元數據這個字段沒啥限制,要是非要說有限制,那麼就是字段長度了——5000 個字符。這個字段的想象空間還是很大的:用於填寫豐富的交易相關信息,用於在增長智能系統產品中進行深入商業分析。包括交易行爲多維分析、人羣分析、產品轉化路徑、個性化推薦、智能補貼、定向推送等。看產品經理要怎麼玩了;
04/04 credential,這個字段非常非常重要,其中裝載的就是客戶端最終發起支付請求的憑證,會作爲 Payment 對象的一部分返回給客戶端;
MongoDB 的 document 字段設計
解釋一下爲什麼要用 MongoDB:
個人覺得,如果這個通用服務要得到較好的推廣(甚至是開源),用 MySQL 等關係型數據庫是不二之選,因爲一個完整實用的系統,必然是少不了數據庫的,如果一旦用了一些非傳統的東西,必然會提高一部分人的對接成本。有的人一看不符合團隊的技術棧,直接就不考慮了。
爲什麼我還是要用 MongoDB 呢?
01/03 團隊的技術棧裏面有這麼個東西,不用白不用;
02/03 MongoDB 普及程度實在是不要太高,還不用上點 NoSQL 的東西,感覺自己分分鐘被 OUT 掉了;
03/03 要存儲的數據結構需要支持動態擴展的特性,我就看中 MongoDB 的靈活性,如下是要存儲的數據結構:
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 這類字段,並沒有一個特別固定的規範,用 MySQL 要冗餘一下字段才行,或者針對每個渠道去分表,想想都覺得煩!
MySQL
因爲這套支付系統被設計成爲支持多應用,多渠道,所以此處用到 MySQL 存放一些應用配置。 E-R 圖免了,直接上數據庫表結構:
01/05 pay_channel:可供接入的支付渠道
02/05 app_settings:支付應用信息
03/05 app_channel:應用已接入的支付渠道
04/05 alipay_settings:支付寶參數設置
網絡異常[取消](javascript:void(0))[重新上傳](javascript:void(0))
05/05 wx_settings:微信app支付參數設置
如果想要增加支付渠道,只需要添加一張對應的支付參數設置表。
2.發起退款
不出意外,客戶在平臺的每筆訂單都可以發起退款,而且還能分批退,也就是同一個訂單,可以多次發起退款申請,只要保證退款總額不超出實付總額。 架構圖如下所示:
跟發起支付請求的流程有很多相似之處,不再一一解釋了,兩個關鍵的地方說明一下:
01/02 客戶端發起退款請求的時候,需要攜帶 payId,就是支付對象的 id。這就意味着,支付系統的調用方需要維護 payId 與 orderNo 的對應關係,務必在客戶端發起退款請求之前,獲取到正確的 payId;
02/02 承接上一步,這纔有了圖中的第 5、6 個步驟,從 MongoDB 中查詢之前的支付對象。第三方渠道通常會要求在退款的時候指定一個退款單號,因爲一筆訂單可以分多次退款,所以不建議將訂單號作爲退款單號使用。這裏的退款單號由支付系統生成並維護。
這部分的執行流程和之前類似,客戶端發起退款請求,形成一個退款任務 (RefundTask),放入任務隊列中,消費者取出並執行各自的業務邏輯,退款成功會生成 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.接受回調
這部分功能被設計成了事件驅動類型,所以 webhooks 當仁不讓。
因爲各個渠道的回調內容都不盡相同,所以這部分設計會按支付渠道切分。
架構圖如下:
用戶在支付完畢後,第三方支付渠道通過發起支付時指定的回調地址對商戶進行支付成功的異步通知。
這部分的執行流程和之前類似,在各自的 PayChannel 中解析好回調參數,形成一個回調事件 (Event),並持久化到 MongoDB 中,然後再生成一個回調任務 (EventTask),放入任務隊列中,消費者取出並執行各自的業務邏輯,這裏的消費者就是上游的業務服務系統。
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 字段:
如果是支付成功事件,則返回對應的 Payment 對象;
如果是退款成功時間,則返回對應的 Refund 對象。
總結
可能有的讀者通篇看下來,覺得這並不是什麼支付系統,僅僅是對接了一下第三方支付渠道,勉強算是支付渠道網關吧!
如果你有這種感受,我也是非常認同的。個人認爲這篇文章還是比較接地氣的,沒有太多理論的東西,看到的更多是實現層面的內容,就差貼代碼了!坦白地講,第三方支付渠道對接了不少次,卻並沒有像現在這樣系統地去設計,去總結。
我用過幾次 Ping++的產品,在企業級聚合支付領域,Ping++ 算是業界領先者了,所以,我的一些數據結構設計還是與其有幾分相似的,Ping++ 以後也會是我模仿和比較的對象。這次也是我的支付系統實現所邁出的第一步,今後也會不斷豐富,完善我自己的支付系統。
希望對你有所幫助!THANKS!
注:本文轉載自微信公衆號技術匯 (微信號:jishuhui_2015),作者文刀。關於支付、創業或者 Ping++,如果你還有任何想要探討的議題,歡迎隨時在微信後臺留言或投稿。