點(diǎn)餐源代碼完整鏈路解析:從掃碼到出單的工程實(shí)踐)
簡(jiǎn)介這是一份面向Android初學(xué)者與餐飲類應(yīng)用開發(fā)練習(xí)者的移動(dòng)點(diǎn)餐項(xiàng)目源碼包含ph2移動(dòng)客戶端與ph2web服務(wù)端兩部分可用于理解從界面交互到后臺(tái)數(shù)據(jù)交換的完整鏈路。客戶端側(cè)涉及菜單展示、購物車與訂單提交、網(wǎng)絡(luò)通信及本地?cái)?shù)據(jù)保存等典型環(huán)節(jié)服務(wù)端側(cè)則覆蓋請(qǐng)求處理、數(shù)據(jù)庫管理與接口設(shè)計(jì)等思路適合作為課程實(shí)驗(yàn)或自學(xué)練手素材。資源包共149個(gè)文件以class編譯文件、png圖片資源、java源碼與xml布局為主另含jar依賴、apk安裝包及少量配置與緩存文件整體約2.68MB結(jié)構(gòu)緊湊便于快速導(dǎo)入查看。目前已有164人學(xué)習(xí)下載可幫助讀者對(duì)照源碼梳理Android應(yīng)用的基本開發(fā)流程理解模塊劃分、界面與邏輯分離以及客戶端與服務(wù)端協(xié)作方式為后續(xù)獨(dú)立完成類似點(diǎn)餐或信息管理類項(xiàng)目提供可參考的實(shí)現(xiàn)框架。1. 移動(dòng)點(diǎn)餐源代碼從掃碼到出單一套能跑通的完整鏈路長(zhǎng)什么樣很多人第一次接觸移動(dòng)點(diǎn)餐源代碼腦子里浮現(xiàn)的是“一個(gè)點(diǎn)菜的 App 界面”。真上手才發(fā)現(xiàn)它其實(shí)是一條從顧客掃碼、選菜、下單到后廚出單、前臺(tái)結(jié)賬、后臺(tái)改價(jià)的完整數(shù)據(jù)鏈路界面只是最外面那層皮。我見過不少團(tuán)隊(duì)花兩周把菜單頁做得挺漂亮結(jié)果卡在“同一桌兩個(gè)人同時(shí)加菜訂單金額對(duì)不上”這種問題上返工比開發(fā)還久。這套源碼真正要解決的是讓顧客自助下單、讓訂單準(zhǔn)確落到后廚、讓老板能隨時(shí)改菜價(jià)和庫存同時(shí)保證多端數(shù)據(jù)一致。它適合想自建點(diǎn)餐系統(tǒng)的中小餐飲團(tuán)隊(duì)、做私域外賣的開發(fā)者以及需要把點(diǎn)餐能力嵌進(jìn)現(xiàn)有小程序的工程師。下面按“先立住結(jié)構(gòu)、再動(dòng)手復(fù)現(xiàn)、最后講坑”的順序拆開講。2. 移動(dòng)點(diǎn)餐源代碼的骨架四張核心表撐起整條鏈路一套能用的移動(dòng)點(diǎn)餐系統(tǒng)剝掉前端花哨的交互后端本質(zhì)就是圍繞四類數(shù)據(jù)在轉(zhuǎn)菜品、桌臺(tái)、訂單、訂單明細(xì)。把這四張表的關(guān)系理清楚后面寫接口、做并發(fā)控制、算金額才有依據(jù)。很多人一上來就寫頁面寫到“加菜”功能時(shí)發(fā)現(xiàn)訂單結(jié)構(gòu)沒設(shè)計(jì)好只能推倒重來這就是沒先立骨架的代價(jià)。2.1 菜品、桌臺(tái)、訂單、明細(xì)四張表怎么設(shè)計(jì)菜品表存的是“賣什么”桌臺(tái)表存的是“在哪吃”訂單表存的是“這一單的整體狀態(tài)”明細(xì)表存的是“這一單里每道菜的數(shù)量和當(dāng)時(shí)的價(jià)格”。關(guān)鍵點(diǎn)在于明細(xì)表必須冗余存下單時(shí)的單價(jià)不能只存菜品 ID 去關(guān)聯(lián)查價(jià)。原因很簡(jiǎn)單老板今天把宮保雞丁從 28 改成 32昨天的訂單不能跟著變。這是血淚經(jīng)驗(yàn)早期圖省事只存 ID月底對(duì)賬時(shí)金額全亂。下面是一份可直接建表的最小 SQL字段做了精簡(jiǎn)但保留了關(guān)鍵約束-- 菜品表菜單的基礎(chǔ)數(shù)據(jù) CREATE TABLE dish ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, -- 菜名 price DECIMAL(10,2) NOT NULL, -- 當(dāng)前售價(jià)單位元 stock INT NOT NULL DEFAULT 0, -- 剩余庫存-1 表示不限量 status TINYINT NOT NULL DEFAULT 1, -- 1 上架 0 下架 category_id BIGINT NOT NULL, -- 分類用于前端分組 INDEX idx_category (category_id) ); -- 桌臺(tái)表每張桌子一個(gè)二維碼掃碼后帶出 table_id CREATE TABLE dining_table ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL, -- 如 A01、大廳3號(hào) qr_token VARCHAR(64) NOT NULL, -- 二維碼里的隨機(jī)串防止猜 ID status TINYINT NOT NULL DEFAULT 0, -- 0 空閑 1 就餐中 UNIQUE KEY uk_token (qr_token) ); -- 訂單表一桌一次就餐對(duì)應(yīng)一條主訂單 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, table_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0, -- 0 待支付 1 已支付 2 已出單 3 已完成 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_table_status (table_id, status) ); -- 訂單明細(xì)每道菜一行冗余存下單時(shí)單價(jià) CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, dish_name VARCHAR(64) NOT NULL, -- 冗余菜名防止菜品被刪后查不到 unit_price DECIMAL(10,2) NOT NULL, -- 下單那一刻的價(jià)格 quantity INT NOT NULL, INDEX idx_order (order_id) );邏輯說明dish和dining_table是基礎(chǔ)數(shù)據(jù)orders和order_item是交易數(shù)據(jù)。order_item里冗余dish_name和unit_price是為了讓歷史訂單永遠(yuǎn)可讀、可對(duì)賬哪怕菜品后來改名或下架。參數(shù)上price用DECIMAL(10,2)而不是FLOAT因?yàn)楦↑c(diǎn)數(shù)算錢會(huì)出現(xiàn)0.10.20.30000000000000004這種玄學(xué)問題對(duì)賬時(shí)能讓人崩潰。qr_token用隨機(jī)串而不是直接暴露桌臺(tái) ID是防止有人手動(dòng)改 URL 去操作別人的桌臺(tái)。2.2 掃碼到下單的接口調(diào)用順序前端掃碼后拿到的不是桌臺(tái) ID而是qr_token。整個(gè)下單流程分四步解析 token 換桌臺(tái)信息、拉取菜單、提交訂單、返回訂單號(hào)。這四步的順序不能亂尤其是“提交訂單”必須在服務(wù)端重新算一遍金額不能信前端傳來的總價(jià)。// 前端掃碼后解析出 token調(diào)用后端換取桌臺(tái) async function scanAndLoadMenu(qrToken) { // 第一步用 token 換桌臺(tái)信息同時(shí)校驗(yàn)桌臺(tái)是否可用 const tableRes await fetch(/api/table/resolve?token${qrToken}); const table await tableRes.json(); if (table.status ! 0) { throw new Error(該桌臺(tái)正在就餐中請(qǐng)聯(lián)系服務(wù)員); } // 第二步拉取上架菜品按分類分組 const menuRes await fetch(/api/dish/list?status1); const menu await menuRes.json(); return { table, menu }; } // 提交訂單只傳 dish_id 和 quantity價(jià)格由服務(wù)端算 async function submitOrder(tableId, items) { const res await fetch(/api/order/create, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ table_id: tableId, items: items.map(i ({ dish_id: i.dishId, quantity: i.quantity })) }) }); return res.json(); // 返回 order_id 和 total_amount }邏輯說明scanAndLoadMenu先校驗(yàn)桌臺(tái)狀態(tài)避免兩個(gè)人同時(shí)掃同一張桌子造成重復(fù)開臺(tái)。submitOrder只傳菜品 ID 和數(shù)量單價(jià)和總價(jià)全部由服務(wù)端根據(jù)當(dāng)前dish.price計(jì)算這是防止前端篡改價(jià)格的關(guān)鍵。參數(shù)上items里不要帶price字段帶了也忽略服務(wù)端以數(shù)據(jù)庫為準(zhǔn)。如果菜品在下單瞬間被下架或庫存不足服務(wù)端要返回明確的錯(cuò)誤碼前端提示“XX 已售罄”而不是籠統(tǒng)報(bào)錯(cuò)。2.3 服務(wù)端算價(jià)與庫存扣減的原子操作算價(jià)和扣庫存必須放在同一個(gè)事務(wù)里否則會(huì)出現(xiàn)“錢算對(duì)了但庫存沒扣”或者“庫存扣了但訂單沒生成”的臟數(shù)據(jù)。常見做法是用數(shù)據(jù)庫事務(wù)包住“校驗(yàn)庫存、扣減、寫訂單、寫明細(xì)”四步。# 服務(wù)端創(chuàng)建訂單事務(wù)內(nèi)完成算價(jià)與扣庫存 def create_order(table_id, items): with db.transaction(): total Decimal(0.00) order_id db.insert(orders, { table_id: table_id, status: 0, total_amount: 0 }) for item in items: # 加行鎖防止并發(fā)超賣 dish db.query_one( SELECT id, name, price, stock FROM dish WHERE id%s FOR UPDATE, item[dish_id] ) if dish is None or dish[status] ! 1: raise BizError(菜品已下架) # stock 為 -1 表示不限量否則校驗(yàn)并扣減 if dish[stock] ! -1: if dish[stock] item[quantity]: raise BizError(f{dish[name]} 庫存不足) db.execute( UPDATE dish SET stock stock - %s WHERE id %s, item[quantity], dish[id] ) line_total dish[price] * item[quantity] total line_total db.insert(order_item, { order_id: order_id, dish_id: dish[id], dish_name: dish[name], unit_price: dish[price], quantity: item[quantity] }) db.execute( UPDATE orders SET total_amount %s WHERE id %s, total, order_id ) return {order_id: order_id, total_amount: str(total)}邏輯說明FOR UPDATE是行級(jí)鎖保證同一道菜在并發(fā)下單時(shí)庫存扣減不會(huì)超賣。stock -1表示不限量適合那些現(xiàn)做現(xiàn)賣、不嚴(yán)格控量的菜品。total用Decimal累加避免浮點(diǎn)誤差。整個(gè)函數(shù)在一個(gè)事務(wù)里任何一步拋異常都會(huì)回滾不會(huì)留下半截訂單。參數(shù)上items的quantity要做正整數(shù)校驗(yàn)防止傳 0 或負(fù)數(shù)刷單。3. 把源碼跑起來本地環(huán)境搭建與最小可運(yùn)行驗(yàn)證拿到一套移動(dòng)點(diǎn)餐源代碼別急著改業(yè)務(wù)先讓它在本地跑通“掃碼-點(diǎn)菜-下單”這條最小鏈路。跑通之后再動(dòng)代碼心里才有底。這一章按環(huán)境準(zhǔn)備、啟動(dòng)、驗(yàn)證三步走每步都給可抄的命令。3.1 依賴清單與數(shù)據(jù)庫初始化一套典型的移動(dòng)點(diǎn)餐源碼后端常見是 Python 或 Node.js前端是小程序或 H5數(shù)據(jù)庫用 MySQL。先確認(rèn)版本版本不對(duì)會(huì)出現(xiàn)各種奇怪的連接錯(cuò)誤。組件建議版本用途MySQL5.7 或 8.0存菜品、訂單數(shù)據(jù)Python3.8后端服務(wù)若為 Python 棧Node.js16前端構(gòu)建或 Node 后端Redis5.0緩存菜單、防重復(fù)下單數(shù)據(jù)庫初始化就是把上一章的四張表建出來再插幾條測(cè)試數(shù)據(jù)-- 插入測(cè)試菜品 INSERT INTO dish (name, price, stock, status, category_id) VALUES (宮保雞丁, 28.00, 100, 1, 1), (麻婆豆腐, 18.00, 100, 1, 1), (米飯, 2.00, -1, 1, 2); -- 插入測(cè)試桌臺(tái)qr_token 用隨機(jī)串 INSERT INTO dining_table (name, qr_token, status) VALUES (A01, t_9f3a2b7c1d, 0), (A02, t_4e8c6a0b5f, 0);邏輯說明stock -1的米飯表示不限量適合主食類。qr_token用t_前綴加隨機(jī)串方便識(shí)別也方便排查。插入數(shù)據(jù)后用SELECT * FROM dish確認(rèn)一下避免字符集問題導(dǎo)致中文亂碼。3.2 啟動(dòng)后端與前端的最小命令后端啟動(dòng)前先配好數(shù)據(jù)庫連接通常是一個(gè).env文件或配置項(xiàng)。改完配置直接啟動(dòng)# 安裝依賴以 Python 為例 pip install -r requirements.txt # 初始化數(shù)據(jù)庫表結(jié)構(gòu) python manage.py migrate # 啟動(dòng)后端服務(wù)監(jiān)聽 8000 端口 python app.py --host 0.0.0.0 --port 8000前端如果是小程序用開發(fā)者工具導(dǎo)入項(xiàng)目目錄改app.js里的baseUrl指向http://localhost:8000。如果是 H5直接npm install npm run dev邏輯說明--host 0.0.0.0是為了讓同一局域網(wǎng)內(nèi)的手機(jī)也能訪問方便真機(jī)掃碼測(cè)試。baseUrl必須和實(shí)際后端地址一致端口寫錯(cuò)是最常見的“請(qǐng)求失敗”原因。啟動(dòng)后先訪問http://localhost:8000/api/dish/list?status1能看到 JSON 菜單就說明后端通了。3.3 用一條 curl 驗(yàn)證下單鏈路是否打通不依賴前端直接用 curl 模擬一次下單能最快定位問題出在后端還是前端。# 模擬掃碼換桌臺(tái) curl http://localhost:8000/api/table/resolve?tokent_9f3a2b7c1d # 模擬提交訂單1 份宮保雞丁 2 份米飯 curl -X POST http://localhost:8000/api/order/create \ -H Content-Type: application/json \ -d { table_id: 1, items: [ {dish_id: 1, quantity: 1}, {dish_id: 3, quantity: 2} ] }邏輯說明第一條命令驗(yàn)證 token 能否正確解析出桌臺(tái)。第二條命令驗(yàn)證下單接口預(yù)期返回order_id和total_amount按測(cè)試數(shù)據(jù)算應(yīng)該是28 2*2 32.00。如果返回金額不對(duì)去查order_item表里unit_price是否存對(duì)如果報(bào)庫存不足去查dish.stock是否被之前的測(cè)試扣掉了。參數(shù)上table_id要和dining_table.id對(duì)應(yīng)別把 token 當(dāng) ID 傳。4. 避坑與排查移動(dòng)點(diǎn)餐源碼落地時(shí)最容易翻車的五件事這套源碼在本地跑通不難難的是放到真實(shí)餐廳環(huán)境里不出亂子。下面五條是我和同行踩過的坑每條按“現(xiàn)象、原因、解決”寫清楚照著排查能省不少時(shí)間。4.1 同一桌重復(fù)開臺(tái)訂單金額翻倍現(xiàn)象顧客掃了兩次碼或者兩個(gè)人同時(shí)掃結(jié)果生成了兩條訂單后廚收到兩份一樣的菜。原因dining_table.status沒有在開臺(tái)時(shí)及時(shí)置為“就餐中”或者置狀態(tài)和創(chuàng)建訂單不在一個(gè)事務(wù)里。解決在create_order開頭加一步“開臺(tái)”操作用UPDATE dining_table SET status1 WHERE id%s AND status0根據(jù)影響行數(shù)判斷是否開臺(tái)成功影響行數(shù)為 0 說明已被別人開臺(tái)直接返回錯(cuò)誤。這一步要和訂單創(chuàng)建放在同一事務(wù)。4.2 菜品改價(jià)后歷史訂單金額跟著變現(xiàn)象老板把某道菜降價(jià)昨天的訂單在后臺(tái)重新打開時(shí)金額變了。原因order_item只存了dish_id展示時(shí)實(shí)時(shí)關(guān)聯(lián)dish.price。解決下單時(shí)把unit_price和dish_name冗余進(jìn)order_item展示歷史訂單只讀明細(xì)表不再關(guān)聯(lián)菜品表。已經(jīng)上線的系統(tǒng)如果沒存可以寫一個(gè)一次性腳本按訂單創(chuàng)建時(shí)間回填當(dāng)時(shí)的價(jià)格但回填數(shù)據(jù)未必準(zhǔn)所以最好一開始就設(shè)計(jì)對(duì)。4.3 并發(fā)下單導(dǎo)致庫存超賣現(xiàn)象庫存設(shè)了 10 份結(jié)果賣出 12 份。原因扣庫存時(shí)先查后改兩個(gè)請(qǐng)求同時(shí)查到庫存 10各自扣 1都以為還剩 9。解決用SELECT ... FOR UPDATE加行鎖或者用UPDATE dish SET stock stock - %s WHERE id %s AND stock %s這種帶條件的原子更新根據(jù)影響行數(shù)判斷是否扣成功。前者適合事務(wù)內(nèi)多步操作后者適合簡(jiǎn)單扣減。4.4 二維碼被猜出別人桌的訂單被操作現(xiàn)象有人手動(dòng)改 URL 里的桌臺(tái) ID給別的桌加菜或結(jié)賬。原因二維碼里直接用了自增 ID容易被遍歷。解決二維碼里放隨機(jī)qr_token服務(wù)端用 token 換桌臺(tái)不暴露真實(shí) ID。token 要足夠長(zhǎng)建議 16 位以上隨機(jī)串并且每張桌子獨(dú)立不要所有桌子共用一個(gè)。已經(jīng)用了 ID 的系統(tǒng)可以加一層簽名校驗(yàn)但改 token 更徹底。4.5 后廚出單延遲顧客催菜現(xiàn)象顧客下單成功后廚半天沒收到單。原因出單邏輯寫在下單接口的同步流程里打印機(jī)卡紙或網(wǎng)絡(luò)抖動(dòng)導(dǎo)致整個(gè)請(qǐng)求超時(shí)訂單狀態(tài)卡在“待出單”。解決下單和出單解耦下單成功后往消息隊(duì)列或一張print_task表里寫一條待打印記錄后廚端輪詢或訂閱。打印失敗要能重試并且在前臺(tái)能看到“出單失敗”的告警而不是靜默失敗。參數(shù)上重試次數(shù)建議 3 次間隔 5 秒超過就人工介入。5. 進(jìn)階技巧用訂單狀態(tài)機(jī)管住整條鏈路別讓狀態(tài)亂跳前面講的都是單點(diǎn)問題真正讓一套移動(dòng)點(diǎn)餐源碼從“能跑”變成“好維護(hù)”的是訂單狀態(tài)機(jī)。我見過太多系統(tǒng)訂單狀態(tài)用幾個(gè)布爾字段拼is_paid、is_printed、is_finished各管各的結(jié)果出現(xiàn)“已支付但未出單卻顯示已完成”這種自相矛盾的狀態(tài)。后來我統(tǒng)一改成單字段狀態(tài)機(jī)所有流轉(zhuǎn)走一個(gè)入口問題少了一大半。狀態(tài)機(jī)的最小實(shí)現(xiàn)思路是定義合法狀態(tài)和合法流轉(zhuǎn)任何狀態(tài)變更都走transition函數(shù)非法流轉(zhuǎn)直接拒絕。下面是一個(gè)可直接用的 Python 版本# 訂單狀態(tài)機(jī)只允許合法流轉(zhuǎn) ORDER_STATES { 0: 待支付, 1: 已支付, 2: 已出單, 3: 已完成, 4: 已取消 } # 定義每個(gè)狀態(tài)可以流轉(zhuǎn)到哪些狀態(tài) TRANSITIONS { 0: [1, 4], # 待支付 - 已支付 或 已取消 1: [2, 4], # 已支付 - 已出單 或 已取消退款 2: [3], # 已出單 - 已完成 3: [], # 已完成是終態(tài) 4: [] # 已取消是終態(tài) } def transition(order_id, to_state): order db.query_one(SELECT id, status FROM orders WHERE id%s FOR UPDATE, order_id) if order is None: raise BizError(訂單不存在) from_state order[status] if to_state not in TRANSITIONS.get(from_state, []): raise BizError(f非法流轉(zhuǎn){ORDER_STATES[from_state]} - {ORDER_STATES[to_state]}) db.execute(UPDATE orders SET status%s WHERE id%s, to_state, order_id) return True邏輯說明TRANSITIONS是白名單只允許列出的流轉(zhuǎn)。FOR UPDATE保證并發(fā)下狀態(tài)不會(huì)跳變。參數(shù)上狀態(tài)值用整數(shù)而不是字符串省空間也快但要在代碼里維護(hù)一份映射別在數(shù)據(jù)庫里存中文。這個(gè)狀態(tài)機(jī)的好處是任何“訂單怎么變成這樣了”的疑問都能通過查狀態(tài)流轉(zhuǎn)日志回答。建議再加一張order_status_log表每次transition寫一條記錄包含from_state、to_state、操作人、時(shí)間。出問題時(shí)翻日志比猜快得多。我自己的習(xí)慣是任何涉及金額和狀態(tài)的系統(tǒng)先把狀態(tài)機(jī)畫出來再寫代碼。畫不出來說明業(yè)務(wù)還沒想清楚這時(shí)候?qū)懘a就是給自己埋雷。這套移動(dòng)點(diǎn)餐源碼的骨架不復(fù)雜難的是把并發(fā)、對(duì)賬、出單這些邊界情況都兜住。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取