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