盤:從訂單流轉(zhuǎn)到支付對接的Java后端實戰(zhàn))
2. 復(fù)盤蒼穹外賣知識點第一期做后端開發(fā)的幾乎沒人不知道蒼穹外賣這個實戰(zhàn)項目。我當(dāng)年刷完它之后最大的感受不是“我會寫接口了”而是終于明白了“一個真實的外賣系統(tǒng)到底是怎么從零到一被拆出來、做出來、然后扛住線上壓力的”。最近有不少同學(xué)問我這個項目值不值得刷、該怎么刷所以我打算出幾期復(fù)盤把里面那些“文檔里不會寫透”的知識點掰開了聊一聊。先說結(jié)論這個項目最核心的四個關(guān)鍵字是管理端、用戶端、訂單流轉(zhuǎn)、支付對接。這四個點基本涵蓋了Java后端面試?yán)锇俜种耸母哳l考點從基礎(chǔ)的MVC分層、MyBatis-Plus操作到稍微進(jìn)階一點的Redis緩存、Spring Cache、WebSocket再到真正能拉開差距的Spring Task定時任務(wù)、微信支付流程項目里都有涉及。這一期我們先從宏觀架構(gòu)和數(shù)據(jù)庫設(shè)計講起慢慢往里鉆。1. 內(nèi)容整體設(shè)計與思路拆解1.1 為什么每個外賣項目都長這樣如果你手頭有蒼穹外賣的源碼打開之后第一反應(yīng)大概率是分模塊太多了。admin端、user端、common公共模塊三個大模塊堆在一起剛開始很容易懵。但你別怕這種結(jié)構(gòu)恰恰是真實企業(yè)項目的縮影。你可以把這三層理解成一個餐廳的運轉(zhuǎn)邏輯common模塊相當(dāng)于餐廳的“后廚標(biāo)準(zhǔn)操作規(guī)程”所有菜怎么做、配料怎么切標(biāo)準(zhǔn)都在這里admin模塊是“店長后臺”管菜單、管員工、管營業(yè)額user端是“顧客點餐小程序”用戶能看到什么菜、能下單、能催單。三者之間的依賴關(guān)系也很清晰user依賴commonadmin也依賴common但admin和user互不依賴。這種設(shè)計能帶來一個很實際的好處——改小程序邏輯的時候絕不可能影響到管理后臺反之亦然。如果你在一個工程里寫過那種“五百個類堆在一起”的代碼再回頭看你就會特別感激這種拆法。1.2 從零設(shè)計外賣系統(tǒng)先解決哪些問題如果你要自己從零做一個外賣項目通常面臨的是下面這幾個問題用戶身份怎么區(qū)分、菜品怎么分類上架、購物車怎么做、訂單狀態(tài)怎么流轉(zhuǎn)、超時未支付怎么處理。蒼穹外賣面對這些問題的解法在業(yè)界其實是比較標(biāo)準(zhǔn)的方案業(yè)務(wù)問題設(shè)計方案關(guān)鍵點用戶身份JWT登錄、雙端Token校驗管理端與用戶端各自持有一套攔截器與登錄邏輯菜品展示分類表與菜品表分離采用邏輯外鍵關(guān)聯(lián)方便后續(xù)擴(kuò)展口味、標(biāo)簽、起售停售狀態(tài)購物車用戶維度持久化到數(shù)據(jù)庫不是Redis臨時緩存避免丟數(shù)據(jù)引發(fā)客訴訂單流轉(zhuǎn)狀態(tài)機(jī)機(jī)制待付款 → 待接單 → 待送達(dá) → 已完成各狀態(tài)之間有明確流轉(zhuǎn)條件超時訂單Spring Task定時掃描對待付款訂單做超時取消防止庫存與運力被無效占用這些方案單拎出來看都是常規(guī)操作但把它們放在同一個業(yè)務(wù)閉環(huán)里協(xié)同工作你才能體會到“設(shè)計一個系統(tǒng)”和“寫一個接口”的差別。2. 核心細(xì)節(jié)解析與實操要點2.1 數(shù)據(jù)庫設(shè)計先從兩張“不起眼”的表說起很多同學(xué)刷項目時最容易忽略的就是建表腳本。因為代碼能跑他就不去關(guān)心表結(jié)構(gòu)為什么這么建。但這恰恰是蒼穹外賣里最值錢的部分之一。以菜品表dish和口味表dish_flavor為例。你去看單表結(jié)構(gòu)dish表里只存了菜品的基本信息菜品名、分類id、價格、圖片、描述、狀態(tài)、創(chuàng)建時間、創(chuàng)建人等。而口味表單獨存了菜品對應(yīng)口味名字、值、菜品id。為什么要拆兩張表因為一道菜可能又加辣又去冰又加蔥這些多值屬性如果塞在dish表里要么搞出逗號分隔的一列要么就得冗余好幾列。拆一張子表用外鍵關(guān)聯(lián)查詢時再join回來這才是關(guān)系型數(shù)據(jù)庫的正確姿勢。我自己踩過一個坑之前做項目圖省事把所有口味都塞到一個字段里用JSON字符串存儲。前期數(shù)據(jù)量小倒沒啥感覺等用戶量上來想要“按口味篩選菜品”的時候才發(fā)現(xiàn)字符串里的內(nèi)容根本沒有辦法走索引只能全表掃描。后來老老實實拆表篩選查詢的速度馬上就提上來了。這里有個細(xì)節(jié)值得注意實體類里對時間字段的處理。蒼穹外賣的公共字段創(chuàng)建時間、修改時間、創(chuàng)建人、修改人是通過MyBatis-Plus的字段填充自動處理的。你在插入或更新數(shù)據(jù)時不需要手動set這些字段只需要在對應(yīng)的實體屬性上加上TableField(fill FieldFill.INSERT)這類注解。這個設(shè)計很成熟能避免大量重復(fù)代碼而且能保證每一條數(shù)據(jù)都有完整的審計信息。2.2 JWT雙端登錄一個系統(tǒng)里藏著兩套令牌體系蒼穹外賣里最繞不開的一個知識點是JWT。光會調(diào)接口還不夠你必須清楚這兩套Token為什么要分開。管理端的用戶是員工對應(yīng)的是employee表用戶端的用戶是C端顧客對應(yīng)的是user表。兩端登錄接口各自簽發(fā)各自的Token在網(wǎng)關(guān)層攔截的時候也各自校驗各自的密鑰。我見過不少人在簡歷里寫“熟悉JWT”問他如果管理端員工Token泄露了用戶端能用嗎他答不上來。答案很簡單不能。因為JWT的載荷里包含了身份標(biāo)識和權(quán)限范圍而接收方在驗簽時會先確認(rèn)這是不是自己簽發(fā)的Token。兩端密鑰獨立就意味著即使某一端的密鑰泄露另一端也不會立即失控。在自測時候有個非常實用的工具是Swagger。蒼穹外賣里管理端的Controller都加了接口文檔注解你本地跑起來之后可以直接在Swagger頁面上做登錄把返回的Token復(fù)制出來然后在每個請求的Header里帶上它。只要Token沒過期你就能在瀏覽器里直接模擬所有管理端操作。這種方式調(diào)起B(yǎng)ug來比Postman一個個手填要快得多。3. 實操過程與核心環(huán)節(jié)實現(xiàn)3.1 菜品管理模塊從新增到起售停售的鏈路菜品管理這個模塊是蒼穹外賣里最適合用來打通“前后端分離開發(fā)思維”的案例。先看新增菜品的請求參數(shù)結(jié)構(gòu){ name: 宮保雞丁, categoryId: 12, price: 28.00, image: https://xxx.com/1.jpg, description: 微辣花生米脆香, flavors: [ { name: 辣度, value: [微辣, 中辣, 特辣] }, { name: 忌口, value: [不要蔥, 不要蒜] } ] }注意這個參數(shù)結(jié)構(gòu)里面已經(jīng)隱含了一個重要信息接收對象不能直接用dish實體做入?yún)?。因為dish實體里沒有口味列表這個字段。所以蒼穹外賣里專門定義了一個DishDTO它不但包含dish實體的所有字段還在內(nèi)部放了一個ListDishFlavor用來接收前端傳過來的口味數(shù)組。這一層封裝不是多余的它解決的是前端傳參和數(shù)據(jù)庫結(jié)構(gòu)不完全一致的問題。在實際企業(yè)開發(fā)中這種DTO對象你幾乎天天都要寫。很多人忽略DTO與實體分離的意義直接把前端的參數(shù)懟到實體上遇到“實體字段比參數(shù)少”或者“參數(shù)需要幾個實體拼接”的時候代碼就開始變形。而DTO能讓你把接口入?yún)⒌目趶焦潭ㄏ聛砟呐潞蠖藢嶓w重構(gòu)了只要DTO不變前端就不需要跟著動。新增菜品的完整鏈路是Controller接收DTOService層手動把DTO中的基礎(chǔ)字段拷貝到dish實體調(diào)用dishMapper插入菜品基本信息遍歷flavors列表逐個插入dish_flavor表事務(wù)控制任何一步失敗則整體回滾。這里最容易被忽視的是事務(wù)??赡苡腥擞X得插入兩張表很平常但如果不用事務(wù)在執(zhí)行過程中某一條insert語句報錯就會留下“菜品存在但口味沒有”的臟數(shù)據(jù)。臟數(shù)據(jù)在管理端會造成菜品編輯時口味為空在用戶端會引發(fā)查不到完整菜品的尷尬。蒼穹外賣里在Service層的add方法上加Transactional就是為了避免這種不一致。再聊一下“起售停售”這個功能。別看它只是一個開關(guān)底層邏輯其實涉及緩存一致性。菜品起售時要把狀態(tài)改成1停售時改成0。但如果用戶端使用了Redis緩存菜品列表這里就必須聯(lián)動清理緩存。蒼穹外賣的處理方式是調(diào)用Spring Cache的CacheEvict在修改菜品狀態(tài)時把對應(yīng)分類下的緩存數(shù)據(jù)淘汰掉。這么做有個好處是下次用戶端請求過來緩存為空查詢數(shù)據(jù)庫生成新緩存展示出來的就是最新狀態(tài)代價是首次請求會略微變慢但相比起用戶端每次都被舊數(shù)據(jù)坑到這個代價完全可接受。3.2 購物車為什么存數(shù)據(jù)庫而不是Redis購物車這個功能看起來簡單但設(shè)計思路很有講究。蒼穹外賣選擇把購物車綁定到用戶id上單獨建一張shopping_cart表。每一行記錄用戶加入的一個商品、數(shù)量、口味信息。之前有人問我“購物車這種高頻操作為什么不用Redis緩存頻繁寫庫不是浪費性能嗎”我當(dāng)時給他打了個比方購物車就像你去超市購物手里推的購物車你肯定不希望超市因為系統(tǒng)故障把你購物車?yán)锏臇|西清空。對于外賣場景也一樣用戶花了十分鐘慢慢挑好的菜結(jié)果因為緩存過期就不見了這種體驗是致命的。所以購物車必須持久化用戶下次登錄還能看到這才符合業(yè)務(wù)預(yù)期。這里有一個非常隱蔽的坑用戶往購物車?yán)锒啻渭油粋€商品時是插入新記錄還是增加數(shù)量蒼穹外賣的做法是先按userId dishId 口味查詢?nèi)绻呀?jīng)存在就更新數(shù)量加一如果不存在才插入新紀(jì)錄。這段代碼的邏輯雖然簡單但它體現(xiàn)了一個原則購物車的唯一性由“用戶商品具體口味”共同決定。同一個宮保雞丁一份不要蔥和一份不要蒜其實是兩條購物車記錄。因為數(shù)據(jù)庫里加入了購物車表所以支付模塊取消訂單時也可以反過來回滾購物車狀態(tài)。這種數(shù)據(jù)閉環(huán)靠純緩存方案很難做完整。3.3 訂單狀態(tài)流轉(zhuǎn)與超時取消Spring Task 的真正價值訂單模塊是蒼穹外賣里技術(shù)密度最高的部分。一張訂單表里包含了訂單號、下單人、下單時間、支付狀態(tài)、訂單狀態(tài)、預(yù)計送達(dá)時間、菜品詳情快照、地址快照等字段。對你沒看錯里面存的是“快照”。這里解釋一下“快照”的概念。比如用戶下單時選的宮保雞丁是28元但商家在用戶下單后把價格調(diào)成了35元。那這筆訂單應(yīng)該按哪個價格結(jié)算答案必須是下單時的28元。所以訂單表不能只存菜品id它還必須在生成訂單那一刻把菜品的名稱、價格、圖片全部冗余拷貝過來。這就是快照。這個設(shè)計可能在學(xué)校項目里不多見但在真實的電商系統(tǒng)里極其常見——它保護(hù)的是交易契約的穩(wěn)定性。訂單狀態(tài)流轉(zhuǎn)的核心邏輯都集中在OrderServiceImpl里。常見狀態(tài)包括待付款1待接單2已接單/配送中3已完成4已取消5退款6每個狀態(tài)只能通過特定動作轉(zhuǎn)換比如用戶支付后從待付款變待接單商家接單后變配送中送達(dá)后自動完成。如果你在寫狀態(tài)更新時不加條件限制直接set一個新狀態(tài)那么客戶端只要多傳一個狀態(tài)值訂單就能被惡意跳過流程。所以蒼穹外賣里的狀態(tài)更新基本都是UPDATE orders SET status ? WHERE id ? AND status ?這種樂觀鎖風(fēng)格的條件更新這也是應(yīng)付面試時一個很值得說的細(xì)節(jié)。超時未支付的處理則是Spring Task在項目里最直接的落地場景。每過一定時間系統(tǒng)自動執(zhí)行一次掃描任務(wù)把當(dāng)前時間減去下單時間超過15分鐘且狀態(tài)仍為“待付款”的訂單批量改成“已取消”。這種定時任務(wù)的成本很低也不追求實時性用Spring自帶的調(diào)度機(jī)制就夠用完全不需要上消息隊列。這里還牽扯到一個“庫存恢復(fù)”的隱藏邏輯用戶下單時鎖定的庫存在訂單取消之后必須釋放。雖然蒼穹外賣里商品庫存的概念沒有電商項目那么重但這個思路是一致的——取消訂單不只是改一個狀態(tài)背后關(guān)聯(lián)的資源釋放動作也得跟上。3.4 微信支付模擬沒有真實商戶號怎么聯(lián)調(diào)這是很多同學(xué)卡住的地方。因為微信支付需要真實的商戶號、證書和回調(diào)域名本地開發(fā)環(huán)境根本沒法完成完整流程。蒼穹外賣這里做了一個很務(wù)實的折中支付功能只需要生成支付參數(shù)真正發(fā)起支付時跳到模擬頁面并把支付結(jié)果手動置為成功。但在知識點復(fù)盤時我們依然要理解微信支付的完整時序用戶下單后后端調(diào)用微信支付的下單接口傳入訂單號、金額、回調(diào)地址微信返回一個支付鏈接或支付參數(shù)前端拉起支付用戶支付完成后微信服務(wù)器異步通知后端回調(diào)接口后端校驗簽名、金額訂單號確認(rèn)無誤后修改訂單狀態(tài)為已支付并向微信返回應(yīng)答。本地沒有真實商戶號時調(diào)試重點放到第5步的回調(diào)邏輯。你可以用內(nèi)網(wǎng)穿透工具把本地服務(wù)暴露到公網(wǎng)再用第三方支付測試工具模擬微信的異步通知。只要你能正確解析回調(diào)里的XML并完成驗簽這筆訂單就算通了。等以后真拿到商戶號你只需要換成真實的HTTP請求調(diào)用業(yè)務(wù)邏輯幾乎不用動。4. 常見問題與排查技巧實錄4.1 JWT攔截器放行了不該放行的路徑這是所有人都會遇到的第一個問題。明明配了攔截器但某些匿名接口比如用戶端登錄、管理端登錄、用戶端菜品瀏覽依然被攔截前端一片401。排查思路很簡單打開攔截器配置類把excludePathPatterns里配的路徑和Controller上RequestMapping的路徑逐個比對。很多人栽在這里是因為路徑寫得不一致比如Controller是/user/user但攔截器里配的是/user這當(dāng)然放行不了。經(jīng)驗之談配置放行列表時一定要把完整路徑寫對盡量以/user/**這種通配符覆蓋子路徑。如果本地自測時覺得麻煩可以先臨時注釋掉攔截邏輯跑通了再加回來但記得最后一定要恢復(fù)。4.2 菜品列表查不出來先確認(rèn)Redis里有沒有臟緩存用戶端菜品分類查詢用的是Spring Cache Redis。最典型的Bug場景是管理端修改了菜品用戶端看到的還是老樣子。原因基本集中在緩存沒有失效。檢查步驟三步走確認(rèn)管理端修改菜品時有沒有觸發(fā)CacheEvict確認(rèn)CacheEvict的key和用戶端查詢時Cacheable的key是否一致直接進(jìn)Redis用keys命令查看所有相關(guān)緩存比對數(shù)據(jù)時間和菜品最新更新時間。這里有個坑是key生成策略。Spring Cache默認(rèn)的key是“參數(shù)組合”如果管理端清除緩存用的key和用戶端讀取用的key完全不是一回事那緩存永遠(yuǎn)清不掉。解決辦法就是統(tǒng)一定義緩存key的前綴和拼接規(guī)則比如dish_category_分類id不要圖省事兒不寫key直接用默認(rèn)規(guī)則。4.3 超時訂單沒被掃描到先查time條件Spring Task定時任務(wù)跑了日志也打了但數(shù)據(jù)庫里過期的訂單就是沒被取消。大多數(shù)情況下是SQL里的時間比較寫錯了。比如你把“下單時間小于當(dāng)前時間減15分鐘”寫成了“下單時間大于”或者直接拿字符串比較時間都會導(dǎo)致篩選結(jié)果為空。在本地復(fù)現(xiàn)這個功能時我建議你造一條訂單把下單時間改成20分鐘以前然后手動執(zhí)行一次定時方法觀察日志和數(shù)據(jù)庫。如果按真實場景等15分鐘調(diào)試效率太低了。4.4 菜品口味新增失敗多半是事務(wù)沒生效在一個Service方法里同時插入dish和dish_flavor如果插入口味時報錯而菜品卻插入成功大概率是事務(wù)沒包住。通常原因有兩個類沒有被Spring容器管理即缺Service注解方法被同類內(nèi)部調(diào)用導(dǎo)致AOP代理失效。解決方式也很簡單在public方法上加Transactional并確保調(diào)用方和被調(diào)用方在Spring容器中不要用this內(nèi)部調(diào)用。要是你為了啟動速度把某些Service排除出了包掃描范圍那事務(wù)自然也不生效。4.5 支付回調(diào)驗簽失敗先看證書和密鑰格式模擬聯(lián)調(diào)中回調(diào)驗簽失敗的當(dāng)場不必急著懷疑代碼邏輯更常見的情況是你配置的API密鑰是微信商戶平臺的老版密鑰不是V3版本的APIv3密鑰或者證書解出來多了一行換行符。最簡單有效的方式是先把完整的回調(diào)報文原樣打日志人工核對該報文里的字段再單獨測試驗簽函數(shù)是否能對已知簽名的數(shù)據(jù)返回true。驗簽單獨通過之后再串進(jìn)回調(diào)接口里看整體流程。5. 避坑經(jīng)驗分享個人實操心得刷蒼穹外賣這種項目切忌眼高手低。代碼輪子很容易敲出來但真正值的是把每條鏈路背后的“為什么”想明白。我個人的實操心得可以濃縮成三條第一先跑通再重構(gòu)最后刪了重寫一遍。你跟著教程敲完整個項目只能算入門把它從自己腦子里按記憶重新實現(xiàn)一遍才算消化。刪掉自己剛寫完的代碼這件事聽起來很刺激但效果出奇地好。第二遍重寫的時候你會開始考慮異常處理、狀態(tài)枚舉、DTO設(shè)計這些東西因為有第一遍的全局視角你會更自然地做出更好的設(shè)計決策。第二每做完一個模塊就畫一張模塊關(guān)系圖。不一定是正式文檔可以是草稿紙上的圈圈線線。這個動作能幫你把Controller、Service、Mapper、數(shù)據(jù)庫表之間的關(guān)系印在腦子里。面試官問“你做過的項目有哪些模塊”你不至于只記得一堆方法名而是能講清楚模塊間怎么協(xié)作。第三不要跳過支付回調(diào)的模擬。很多人覺得本地沒有商戶號支付這塊看看視頻就好。但你一旦跳過后面面試被追問支付流程就會露怯?;ㄒ粋€下午用模擬工具走通一遍回調(diào)收益遠(yuǎn)比你想象的大。以后再聊到具體的模塊時我們可以一個個把它們打透。比如訂單狀態(tài)機(jī)可以再細(xì)化出“取消訂單”的庫存回補(bǔ)邏輯比如用戶端菜品瀏覽如何結(jié)合Redis做分類分頁緩存比如管理端營業(yè)統(tǒng)計里的數(shù)據(jù)如何用SQL一次性聚合出來。慢慢來把每一期都吃透面試和實操就都不怕了。