源碼深度拆解:從支付狀態(tài)機(jī)到冪等控制)
簡(jiǎn)介面向外賣代付場(chǎng)景的三合一代付系統(tǒng)源碼包整合美團(tuán)、京東、拼多多代付能力適合有PHP開(kāi)發(fā)基礎(chǔ)或需要搭建H5代付平臺(tái)的個(gè)人開(kāi)發(fā)者、站長(zhǎng)使用。系統(tǒng)自帶倒計(jì)時(shí)支持手機(jī)端H5自助下單商品代付并在代付頁(yè)面展示代付人頭像信息整體結(jié)構(gòu)注重耐用與穩(wěn)定性便于二次開(kāi)發(fā)或直接部署。整套源碼共2012個(gè)文件以PHP后端邏輯、JS交互腳本、HTML頁(yè)面、CSS樣式及PNG圖片素材為主另附SQL數(shù)據(jù)庫(kù)文件與環(huán)境配置壓縮包約45.88MB前端與后端文件分工明確可支撐完整代付業(yè)務(wù)流程。目前已有2136人學(xué)習(xí)下載適合用于代付業(yè)務(wù)系統(tǒng)搭建、源碼學(xué)習(xí)與功能二次開(kāi)發(fā)既能幫助開(kāi)發(fā)者熟悉H5下單、倒計(jì)時(shí)輪詢、代付人信息展示等實(shí)現(xiàn)思路也能為快速搭建可用系統(tǒng)提供基礎(chǔ)代碼參考價(jià)值較高。 拿到“美團(tuán)外賣代付系統(tǒng)源碼.zip”這個(gè)壓縮包的時(shí)候我第一反應(yīng)是這類源碼包網(wǎng)上確實(shí)不少但真正能一次跑通、邏輯嚴(yán)謹(jǐn)?shù)钠鋵?shí)不算多。代付功能在美團(tuán)外賣這類平臺(tái)里算是典型的社交化支付場(chǎng)景——用戶A去點(diǎn)餐訂單生成后自己不想付或者想讓別人請(qǐng)客就把一個(gè)代付鏈接甩到群聊里好友B點(diǎn)開(kāi)鏈接替他把錢付了。整個(gè)過(guò)程看起來(lái)只是“多加了一個(gè)支付入口”但真正落到系統(tǒng)設(shè)計(jì)上會(huì)牽扯出支付狀態(tài)機(jī)、冪等控制、回調(diào)亂序、分布式鎖、超時(shí)關(guān)單等一系列問(wèn)題。這篇文章就基于這份源碼從業(yè)務(wù)建模到核心代碼實(shí)現(xiàn)把代付系統(tǒng)的完整鏈路拆開(kāi)講清楚。如果你正準(zhǔn)備做電商、外賣、知識(shí)付費(fèi)這類需要“多人協(xié)助付款”場(chǎng)景的系統(tǒng)或者單純想看看一份可運(yùn)行的代付系統(tǒng)代碼到底長(zhǎng)什么樣這篇拆解應(yīng)該能幫你省下不少排查時(shí)間。1. 代付系統(tǒng)的業(yè)務(wù)邏輯與整體設(shè)計(jì)思路1.1 代付不是“支付模塊加個(gè)按鈕”那么簡(jiǎn)單普通支付的核心假設(shè)是“誰(shuí)下單誰(shuí)付款”支付系統(tǒng)只需要保證一個(gè)訂單對(duì)應(yīng)一個(gè)支付請(qǐng)求即可。代付則把這個(gè)假設(shè)打破了下單人是A實(shí)際掏錢的是B甚至可能是微信群里的任意一個(gè)人。于是系統(tǒng)里必須多出一個(gè)“代付單”的概念它是連接原訂單和實(shí)際支付人的橋梁。用個(gè)生活化的類比你去餐廳點(diǎn)了一桌菜賬單掛在12號(hào)桌但最后買單的可能是同桌的朋友。餐廳需要知道“12號(hào)桌的賬單由誰(shuí)在什么時(shí)間付掉了”。代付系統(tǒng)里的代付單就是那張寫著“這桌菜已被支付”的憑證。從技術(shù)上看這個(gè)模式的改變帶來(lái)兩個(gè)直接影響支付請(qǐng)求的發(fā)起方和訂單歸屬方不一致鑒權(quán)邏輯必須基于代付單而非訂單一個(gè)訂單可能對(duì)應(yīng)一次代付也可能在取消后重新發(fā)起代付狀態(tài)流轉(zhuǎn)比普通訂單更復(fù)雜。1.2 典型業(yè)務(wù)鏈路與狀態(tài)劃分先梳理這份源碼里預(yù)置的業(yè)務(wù)流程代付單從生到死大概是這樣的用戶A在外賣平臺(tái)下單選擇“找人代付”后端創(chuàng)建訂單同時(shí)創(chuàng)建一條代付記錄生成短鏈接或二維碼A分享給好友BB打開(kāi)H5頁(yè)面看到訂單金額、商家信息和“幫他付”按鈕B完成支付支付渠道回調(diào)平臺(tái)后端平臺(tái)更新代付單狀態(tài)同時(shí)聯(lián)動(dòng)更新原訂單狀態(tài)為已支付訂單進(jìn)入商家出餐流程。由此代付單的狀態(tài)可以定義為狀態(tài)含義觸發(fā)時(shí)機(jī)INIT已生成待支付創(chuàng)建代付單成功后PAYING支付中用戶拉起收銀臺(tái)SUCCESS代付成功支付回調(diào)校驗(yàn)通過(guò)FAILED支付失敗用戶取消或渠道返回失敗CANCELED代付已取消原訂單超時(shí)關(guān)閉或用戶主動(dòng)取消REFUNDING退款中訂單售后觸發(fā)代付退款REFUNDED已退款退款完成這里建議不要在狀態(tài)里加“EXPIRED”而是把超時(shí)狀態(tài)統(tǒng)一折算成CANCELED因?yàn)閷?duì)下游訂單系統(tǒng)來(lái)說(shuō)它只關(guān)心“代付沒(méi)成功訂單還能不能繼續(xù)流轉(zhuǎn)”。狀態(tài)越少后續(xù)做數(shù)據(jù)統(tǒng)計(jì)和排查越省事。1.3 功能范圍與模塊邊界這份源碼覆蓋的功能模塊大致如下代付單管理創(chuàng)建、查詢、狀態(tài)流轉(zhuǎn)、超時(shí)處理短鏈與分享生成短碼、頁(yè)面分享參數(shù)管理H5代付頁(yè)訂單信息展示、支付按鈕、支付結(jié)果回顯支付渠道適配微信支付/支付寶統(tǒng)一適配層回調(diào)處理支付結(jié)果通知的驗(yàn)簽與狀態(tài)同步定時(shí)任務(wù)超時(shí)代付單關(guān)閉、異常單巡檢售后聯(lián)動(dòng)訂單退款時(shí)反向處理代付單。我見(jiàn)過(guò)不少團(tuán)隊(duì)一開(kāi)始把代付邏輯塞在訂單Service里結(jié)果后續(xù)加個(gè)退款就牽一發(fā)動(dòng)全身。這份源碼把代付獨(dú)立成域?qū)ν庵槐┞禤ayOrderService和PayCallbackService兩個(gè)門面這個(gè)邊界劃分值得直接抄。2. 源碼結(jié)構(gòu)與前50%的踩坑點(diǎn)2.1 壓縮包解壓后的目錄怎么讀拿到源碼包先不要急著找controller先把工程結(jié)構(gòu)過(guò)一遍判斷它是什么技術(shù)棧、是否值得繼續(xù)看。這份源碼解壓后的結(jié)構(gòu)比較典型pay-server ├── pay-api # 對(duì)外RPC/Controller層 ├── pay-core # 領(lǐng)域核心代付單模型、狀態(tài)機(jī)、策略 ├── pay-infra # 基礎(chǔ)設(shè)施Redis、MQ、支付渠道客戶端 ├── pay-job # 定時(shí)任務(wù) ├── pay-console # 運(yùn)營(yíng)管理后臺(tái) └── sql └── pay_init.sql # 初始化表結(jié)構(gòu)分層依據(jù)是“依賴方向朝內(nèi)”controller依賴corecore依賴infra而不是controller直接打數(shù)據(jù)庫(kù)。這種結(jié)構(gòu)最大的好處是后續(xù)把代付功能從單體服務(wù)拆到獨(dú)立微服務(wù)時(shí)遷移成本很低只需要把core和infra打成jar包發(fā)布出去就行。2.2 技術(shù)選型為什么這么定這份源碼的技術(shù)棧是Spring Boot 2.7 MyBatis Plus開(kāi)發(fā)效率和團(tuán)隊(duì)上手成本平衡得好不比Spring Cloud那么重Redis存代付短碼映射、扛冪等、做分布式鎖RocketMQ接收支付回調(diào)、異步通知訂單系統(tǒng)MySQL存儲(chǔ)代付單主數(shù)據(jù)Nacos注冊(cè)中心和配置中心。選型層面沒(méi)有追求新奇但有幾個(gè)細(xì)節(jié)值得說(shuō)。支付回調(diào)為什么走M(jìn)Q而不是直接同步更新因?yàn)榍阑卣{(diào)的并發(fā)峰值不確定高峰期微信支付每秒可能推過(guò)來(lái)幾千個(gè)通知如果直接同步寫庫(kù)一旦代付單表出現(xiàn)鎖等待就會(huì)拖垮整個(gè)支付服務(wù)。先落MQ再由消費(fèi)端削峰寫入這是比較穩(wěn)妥的寫法。Redis存短碼映射也有講究。代付鏈接是短碼用戶打開(kāi)H5時(shí)帶著短碼進(jìn)來(lái)后端要拿短碼換代付單號(hào)這個(gè)讀頻率遠(yuǎn)高于寫頻率用Redis做二級(jí)緩存非常合適。緩存的過(guò)期時(shí)間可以設(shè)置成10分鐘比代付單本身的超時(shí)時(shí)間比如30分鐘短一些就算緩存被清理也不影響主流程只是多一次DB回源。關(guān)于技術(shù)棧這里多說(shuō)一句不要因?yàn)榭吹綗嵩~里有“thinkphpuniapp”就覺(jué)得連鎖平臺(tái)都得用PHP支付這類強(qiáng)一致性的核心系統(tǒng)用Java系生態(tài)的仍是多數(shù)。重點(diǎn)不在語(yǔ)言而在于狀態(tài)機(jī)、冪等、回調(diào)處理這些通用設(shè)計(jì)。3. 核心模塊實(shí)現(xiàn)從下單到代付成功的代碼路徑3.1 代付單生成與冪等控制先看創(chuàng)建代付單的入口代碼。這里比較忌諱的是直接在OrderService里寫insert正確做法是獨(dú)立一個(gè)PayOrderServiceService public class PayOrderServiceImpl implements PayOrderService { Autowired private PayOrderMapper payOrderMapper; Autowired private StringRedisTemplate redisTemplate; Override public String createPayOrder(String orderNo, Long amount, Long expireSeconds) { // 冪等檢查一個(gè)業(yè)務(wù)訂單只允許有一條有效代的代付單 PayOrder exist payOrderMapper.selectByOrderNo(orderNo); if (exist ! null) { return exist.getPayCode(); } String payCode ShortCodeGenerator.generate(); PayOrder payOrder new PayOrder(); payOrder.setOrderNo(orderNo); payOrder.setPayCode(payCode); payOrder.setAmount(amount); payOrder.setStatus(PayStatusEnum.INIT.getCode()); payOrder.setExpireTime(new Date(System.currentTimeMillis() expireSeconds * 1000)); payOrderMapper.insert(payOrder); redisTemplate.opsForValue().set(pay:code: payCode, payOrder.getOrderNo(), expireSeconds, TimeUnit.SECONDS); return payCode; } }冪等是怎么保證的這里有兩層第一層是查庫(kù)判斷order_no是否已經(jīng)存在第二層是數(shù)據(jù)庫(kù)對(duì)order_no加唯一索引。這樣即使并發(fā)下兩個(gè)請(qǐng)求同時(shí)進(jìn)來(lái)也只有一個(gè)insert能成功另一個(gè)會(huì)報(bào)DuplicateKeyException再return已存在的那條。不要只做第一層數(shù)據(jù)庫(kù)唯一索引是最后的兜底。短碼生成這里使用UUID轉(zhuǎn)Base62再截取8位的方式碰撞概率很低同時(shí)可以在代碼里加一次存在性校驗(yàn)如果撞了就重新生成。3.2 支付狀態(tài)機(jī)與回調(diào)處理我每次講代付都會(huì)強(qiáng)調(diào)回調(diào)處理是整條鏈路的核心也是最容易出低級(jí)bug的地方??催@段核心狀態(tài)流轉(zhuǎn)public void handlePaid(String payCode, String channelTrxNo, Long channelAmount) { PayOrder payOrder payOrderMapper.selectByPayCode(payCode); if (payOrder null) { throw new BizException(代付單不存在); } // 金額一致性校驗(yàn)防止渠道返回異常金額 if (!payOrder.getAmount().equals(channelAmount)) { log.error(pay amount mismatch, payCode{}, expect{}, actual{}, payCode, payOrder.getAmount(), channelAmount); throw new BizException(支付金額不一致); } // 狀態(tài)機(jī)控制只有 INIT/PAYING 能流轉(zhuǎn)到 SUCCESS int updated payOrderMapper.compareAndSetStatus(payCode, Arrays.asList(PayStatusEnum.INIT.getCode(), PayStatusEnum.PAYING.getCode()), PayStatusEnum.SUCCESS.getCode(), channelTrxNo); if (updated 1) { // 發(fā)送MQ通知訂單系統(tǒng) mqTemplate.send(pay_success_topic, payCode); } }關(guān)鍵在compareAndSetStatus這個(gè)方法它對(duì)應(yīng)的SQL是UPDATE pay_order SET status #{targetStatus}, channel_trx_no #{channelTrxNo}, paid_time NOW() WHERE pay_code #{payCode} AND status IN (0, 1)這個(gè)寫法比“先查詢?cè)賣pdate”要靠譜得多。因?yàn)镸ySQL的行鎖保證了一次只有一個(gè)請(qǐng)求能把狀態(tài)從INIT改成SUCCESS天然處理了并發(fā)重復(fù)回調(diào)的問(wèn)題?;卣{(diào)亂序也是個(gè)高頻坑。微信/支付寶的通知不保證順序可能出現(xiàn)“支付成功通知”先到隨后又收到一條“支付失敗通知”。所以狀態(tài)機(jī)里必須嚴(yán)格定義允許的流轉(zhuǎn)路徑INIT/PAYING可以到SUCCESSSUCCESS狀態(tài)收到支付失敗通知時(shí)直接忽略并返回成功應(yīng)答不能覆蓋成FAILED。3.3 超時(shí)關(guān)單與主動(dòng)對(duì)賬代付不是無(wú)限期有效的。這份源碼里超時(shí)關(guān)閉交給定時(shí)任務(wù)處理邏輯不復(fù)雜但很有代表性XxlJob(closeTimeoutPayOrder) public void closeTimeoutPayOrder() { ListPayOrder list payOrderMapper.selectTimeoutList( PayStatusEnum.INIT.getCode(), new Date()); for (PayOrder payOrder : list) { // CAS把INIT改成CANCELED避免把剛支付成功的單關(guān)掉 int updated payOrderMapper.closeTimeoutOrder(payOrder.getPayCode()); if (updated 1) { mqTemplate.send(pay_closed_topic, payOrder.getOrderNo()); } } }注意這里仍然是CAS操作而不是查出來(lái)是INIT就直接改。因?yàn)槎〞r(shí)任務(wù)掃描和用戶支付之間有時(shí)間差如果不帶狀態(tài)條件強(qiáng)更就可能出現(xiàn)“用戶剛付完錢定時(shí)任務(wù)把單關(guān)了”的事故。別小看這一步線上事故往往就出在這種邊界。另外還要提一個(gè)容易被忽略的點(diǎn)主動(dòng)對(duì)賬。支付渠道的回調(diào)雖然穩(wěn)定但不是100%可靠。源碼里有個(gè)dailyReconcile任務(wù)每天凌晨拉取微信/支付寶的賬單逐筆比對(duì)渠道側(cè)的交易記錄和本地代付單狀態(tài)。數(shù)據(jù)量不大時(shí)可以直接比對(duì)量大的話建議把渠道賬單寫入ES用交易流水號(hào)做關(guān)聯(lián)查詢。4. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄4.1 同一個(gè)代付鏈接被多人同時(shí)點(diǎn)擊支付這是代付場(chǎng)景里最高發(fā)的問(wèn)題。A分享了鏈接到500人的大群結(jié)果3個(gè)人同時(shí)點(diǎn)開(kāi)了支付頁(yè)分別完成支付。按業(yè)務(wù)定義一個(gè)代付單只能被支付一次多支付的錢要原路退回給后付的人。處理方案分兩層支付入口處加Redis分布式鎖鎖的key是payCode拿到鎖才允許創(chuàng)建支付單支付回調(diào)里依賴數(shù)據(jù)庫(kù)狀態(tài)CAS保證只會(huì)成功一個(gè)。兩層都做了就算極端情況有多個(gè)支付單創(chuàng)建到渠道側(cè)最終對(duì)賬也會(huì)發(fā)現(xiàn)“渠道側(cè)交易筆數(shù) 本地成功代付單數(shù)”再走退款補(bǔ)償。4.2 回調(diào)一直收不到訂單卡在支付中先別急著罵渠道。按下面順序排查確認(rèn)回調(diào)URL在公網(wǎng)可訪問(wèn)且沒(méi)有IP白名單攔截去支付渠道商戶平臺(tái)查這筆訂單的“支付通知記錄”看是否推送成功檢查MQ消費(fèi)日志確認(rèn)消息是否消費(fèi)成功如果消費(fèi)邏輯拋異常消息會(huì)被重試但重試次數(shù)耗盡后可能進(jìn)入死信隊(duì)列確認(rèn)本地服務(wù)沒(méi)有把回調(diào)請(qǐng)求處理超時(shí)返回非2xx渠道會(huì)認(rèn)為通知失敗并持續(xù)重推。結(jié)合我的經(jīng)驗(yàn)更多時(shí)候是MQ消費(fèi)端沒(méi)做冪等重復(fù)消費(fèi)導(dǎo)致數(shù)據(jù)錯(cuò)亂而不是回調(diào)真丟了。4.3 源碼跑不起來(lái)先看這幾個(gè)地方很多同學(xué)拿到zip后first step就是往IDE里導(dǎo)入然后懟依賴報(bào)錯(cuò)。這不要怪源碼先檢查三件事MySQL的init sql有沒(méi)有執(zhí)行成功編碼是不是UTF-8Nacos的namespace和group是否和配置文件一致微信支付/支付寶的證書(shū)路徑、商戶號(hào)、密鑰是不是都換成了自己的。還有就是確認(rèn)你是在自己私有的測(cè)試環(huán)境配置回調(diào)地址。不要直接在本地起服務(wù)就填內(nèi)網(wǎng)地址回調(diào)進(jìn)不來(lái)的。用內(nèi)網(wǎng)穿透工具把本地端口映射到公網(wǎng)然后到渠道側(cè)配置成這個(gè)公網(wǎng)地址就能聯(lián)調(diào)了。4.4 關(guān)于市場(chǎng)源碼包質(zhì)量的判斷這年頭網(wǎng)上掛著“系統(tǒng)源碼”的資源很多標(biāo)題寫得很誘人但實(shí)際質(zhì)量參差不齊。下載之后我一般先看三樣?xùn)|西是否包含初始化SQL腳本沒(méi)有SQL的源碼基本是半成品是否包含支付渠道的沙箱配置說(shuō)明沒(méi)有說(shuō)明的聯(lián)調(diào)成本會(huì)很高核心狀態(tài)機(jī)是否有明確注釋如果連狀態(tài)枚舉都找不到大概率是從某個(gè)單體項(xiàng)目里硬拆出來(lái)的小心后面返工。判斷標(biāo)準(zhǔn)就這么簡(jiǎn)單好的代付源碼把狀態(tài)流轉(zhuǎn)和冪等邏輯寫清楚比附帶多少炫酷管理后臺(tái)頁(yè)面都重要。5. 結(jié)合這份源碼你能往哪些方向擴(kuò)展5.1 把代付能力封裝成通用支付服務(wù)如果你所在的平臺(tái)不止外賣還有電商、拼團(tuán)、內(nèi)容付費(fèi)那代付邏輯完全可以抽出來(lái)做成pay-center通過(guò)配置決定哪些業(yè)務(wù)場(chǎng)景開(kāi)啟代付。這份源碼的分層已經(jīng)比較接近獨(dú)立支付服務(wù)了你只需要在PayOrder表里增加bizType字段區(qū)分不同業(yè)務(wù)來(lái)源即可。5.2 增加風(fēng)控與頻控代付很容易被刷最常見(jiàn)的是刷代付短碼和惡意下單不支付。建議在Redis里增加幾組頻控key同一個(gè)IP在1分鐘內(nèi)最多發(fā)起代付鏈接查詢20次同一個(gè)買家ID在10分鐘內(nèi)最多創(chuàng)建5個(gè)代付單同一微信號(hào)/支付寶賬號(hào)在1小時(shí)內(nèi)最多支付5筆代付訂單。這些頻控都很容易實(shí)現(xiàn)用Redis的INCR加過(guò)期時(shí)間就能做到不用引入專門的風(fēng)控系統(tǒng)。5.3 多支付渠道自動(dòng)降級(jí)源碼默認(rèn)支持微信支付和支付寶如果你想接入銀聯(lián)或蘋果IAP可以在支付渠道適配層增加一個(gè)router策略類根據(jù)客戶端來(lái)源、訂單金額、用戶偏好路由到不同渠道。支付渠道商不一定都穩(wěn)定加一個(gè)“失敗自動(dòng)切換備選渠道”的開(kāi)關(guān)也是可選的增強(qiáng)項(xiàng)。6. 實(shí)操心得跑通這個(gè)源碼后我的一些判斷整個(gè)過(guò)程跑下來(lái)我覺(jué)得這份源碼最有參考價(jià)值的不是某個(gè)炫技功能而是它把“多人支付同一筆訂單”這個(gè)看起來(lái)簡(jiǎn)單的業(yè)務(wù)做成了可追溯的狀態(tài)機(jī)。我在實(shí)際項(xiàng)目里看過(guò)太多把支付狀態(tài)存在訂單表里的設(shè)計(jì)出現(xiàn)退款時(shí)全是臨時(shí)加的if else那種代碼維護(hù)半年后自己都想重寫。如果你準(zhǔn)備在自己的項(xiàng)目里加代付功能不要一開(kāi)始就把微信支付、支付寶的所有功能接滿。先用一份這樣的源碼把模型理解透代付單、狀態(tài)機(jī)、冪等、回調(diào)、CAS更新、定時(shí)關(guān)單。把這六件事做扎實(shí)再疊加各種支付渠道就是體力活了。最后再分享一個(gè)小技巧接支付渠道回調(diào)時(shí)本地如果條件允許裝一個(gè)內(nèi)網(wǎng)穿透工具加上Postman的Webhook模擬器可以大幅縮短聯(lián)調(diào)時(shí)間。我就是靠這招把回調(diào)問(wèn)題定位從半小時(shí)縮短到5分鐘。本文還有配套的精品資源點(diǎn)擊獲取