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