餐饮系统源码实战:Vue+Spring Boot+Redis+MyBatis-plus
简介这是一套面向餐饮企业数字化转型的全栈开发实战源码适用于Java后端与Vue前端开发者学习企业级应用架构设计。项目采用Vue.js构建响应式移动端界面Spring Boot搭建高可用管理后台结合Redis缓存优化高频菜品与订单查询MyBatis-plus简化数据库CRUD操作切实解决餐厅分类、菜品/套餐管理、订单流转及员工协同等核心业务痛点。压缩包共384个文件含69个Java后端逻辑文件如DishController、SetmealServiceImpl等、45个JavaScript前端组件、44个HTML页面、41个CSS样式文件及89个PNG资源图另有db_reggie.sql数据库脚本与pom.xml依赖配置结构完整、开箱即用。资源包大小为178.69MB目录层次清晰src、static、templates等模块划分明确便于理解前后端分离架构与典型餐饮业务建模逻辑。目前已有156人学习下载适合中高级开发者深入掌握Redis缓存策略、MyBatis-plus条件构造器、Vue路由守卫与状态管理等关键技术实践。 开头我就不铺垫了直接说结论如果你想拿一套能跑的餐饮项目源码又看得懂代码甚至还想着以后改吧改吧接私活用Vue Spring Boot Redis MyBatis-plus 这套组合是目前最合适的搭配之一。我前阵子刚完成一个餐饮行业定制化软件从需求对接到最终交付源码前前后后踩了不少坑也沉淀出一些值得分享的经验。这篇文章就围绕这套技术栈把需求拆解、表设计、核心链路、缓存策略、ORM 用法、前后端联调这些环节完整过一遍希望能给你省点试错的时间。这套源码包含三个前端工程顾客扫码点餐端、收银管理端、后厨大屏端和一个 Spring Boot 后端服务覆盖了点餐、下单、支付回调、菜品管理、桌台状态流转、会员储值、营业报表、后厨实时推送这些典型餐饮业务场景。适合正在做毕设、准备接餐饮类外包、或者刚进餐饮软件公司想快速上手业务的新人开发者。1. 先搞清楚“定制化”定制的是什么餐饮业务拆解与需求边界很多做开发的朋友一听到“餐饮系统”脑子里第一反应就是“这不就是个 CRUD 吗菜单表加订单表完事”。真做过之后你会发现餐饮项目的难点从来不在增删改查而在于业务规则特别细不同业态的流程差异又很大。1.1 其实每种餐饮业态的流程差异很大我这次服务的客户是一家走中餐快炒路线的餐馆堂食为主带少量包间没有外卖。他们的核心场景是顾客扫码进 H5 点餐后厨按单做菜出菜后服务员划菜顾客吃完后前台结账顺便还能存点钱办个会员。听起来简单对吧但你往下抠细节就发现问题了火锅店和中餐店的点餐流程就不一样火锅店经常是先上锅底再慢慢加菜加菜时后厨需要单独打单提醒快餐店是点完必须先支付后厨才接单正餐店则经常是“先下单、后结账”甚至允许服务员手动抹零大份/小份这种规格如果在菜品表里直接加字段后面加“微辣/中辣/特辣”就不好扩展了所以需要独立的规格表这些都是定制化要尝到甜头的地方也是市面上一堆“餐饮万能模板系统”做不好体验的原因。1.2 我这个版本最终落地了哪些功能在需求评审阶段我把客户要求收敛成三个端 一个后台服务顾客扫码点餐端Vue 2 Vant扫码进入、菜品分类展示、按规格加购、购物车结算、在线支付微信支付 JSAPI 模式、订单状态查询收银管理端Vue 2 Element UI桌台开台/换桌/并桌、订单查询与结账、会员开卡与储值、菜品上下架、分类排序、营业统计报表后厨大屏端Vue 2 WebSocket按分类实时展示新订单、标记制作完成、出菜提醒后端服务Spring Boot 2.7 Redis MyBatis-plus MySQL 8.0这套功能看起来不多但已经覆盖了一个中小型餐馆从顾客进店到结账离店的全流程而且代码完全可改目录结构也是按业务模块拆的。1.3 需求边界什么该做什么坚决不碰做定制化项目最怕的就是需求蔓延。我在这套系统里也刻意做了一些边界控制不做复杂的进销存库存只在点餐时做扣减校验不做排班考勤不做多门店连锁管理表里预留了store_id字段但逻辑上先按单店实现对于拿来学习或者二次开发的读者这个边界控制非常关键。因为一旦把进货、库存、员工管理全揉进来整个项目的阅读难度会直线上升你就很难一眼看清核心链路是怎么跑的。2. 技术选型为什么会是这四件套Vue、Spring Boot、Redis、MyBatis-plus技术选型这件事很多人是在“跟风”但我更关注的是这套组合能不能让一个 5~10 人规模的开发团队在两个月内交付、并且后续有人维护。我的答案是能而且很稳。2.1 前端为什么选 Vue 而不是 React 或者纯 JSP这个项目里有三个前端端顾客 H5、收银 PC、后厨大屏。如果再用传统的 JSP jQuery 那套三个端维护起来就是三座山。用 Vue 的好处是组件化开发像“菜品卡片”“订单状态标签”这些组件可以在不同端里复用只是样式层换一下。至于为什么不选 React团队熟悉度是一方面另一方面 Vue 2 的生态足够成熟。Element UI 做后台管理、Vant 做移动端 H5这两个开源组件库能覆盖 80% 的界面需求省掉大量造轮子的时间。如果你现在才刚开始学习直接上 Vue 3 Element Plus 也是可以的但要注意源码里如果是 Vue 2 的写法升级时主要工作集中在filter移除、v-model绑定方式和部分$set的替代写法上。2.2 后端为什么是 Spring Boot 而不是那些更“轻”的框架Spring Boot 在这类项目里的优势不是“性能最强”而是生态完整。你需要做参数校验时有spring-boot-starter-validation需要做定时任务时有Scheduled需要做接口文档时有knife4j需要对接微信支付时有现成的 SDK 示例。而且餐饮系统最关键的“事务管理”Spring Boot 的Transactional用起来是真的很省心。一个方法里既扣库存又生成订单任何一步失败都能整体回滚这比在业务代码里手写事务控制要靠谱得多。2.3 MyBatis-plus 为什么替代了原生 MyBatis 和 JPAJPA 的优点是开发者不需要写 SQL但餐饮项目里经常有“按桌台查最近订单”“统计营业报表”这类多表关联查询JPA 的复杂查询写起来非常别扭。原生 MyBatis 写 SQL 很灵活但所有单表增删改查都要手写 XML开发效率太低。MyBatis-plus 正好卡在中间单表 CRUD 自动生成复杂查询走自定义 XML。加上分页插件、逻辑删除、自动填充这些内置功能改动量非常小。举一个实际例子保存订单明细时原生的做法是先在 Service 里for循环插入每条 SQL 都是一次网络往返。MyBatis-plus 的saveBatch可以直接批量插入在点餐这种需要一次性写入多个菜品的场景下性能差别还是很明显的。2.4 Redis 在这个组合里到底承担了什么角色Redis 在系统里的角色可以总结为四件事缓存、分布式锁、购物车临时存储、验证码存储。缓存菜品分类和菜品列表减少对 MySQL 的重复查询分布式锁防止同一桌台、同一用户短时间内重复提交订单购物车用 Hash 结构存用户临时选择的菜品顾客离开页面也不丢失验证码短信验证码设置 5 分钟过期天然适合 Redis 的EXPIRE这四类场景覆盖面已经很全面了足以体现 Redis 在业务里的价值也适合作为学习时的参考范例。3. 从点餐流程到数据模型核心表设计与订单状态机的落地数据库表设计是餐饮系统的地基地基不稳后面写多少代码都要返工。这个项目里我一共设计了 19 张表下面挑最核心的几张说清楚。3.1 点餐流程推导表结构角色、动作、对象我设计表结构的习惯是先列角色和动作再画流程最后推导表关系。这个系统里主要是三种角色顾客浏览菜单、加购、下单收银员开台、结账、会员操作后厨查看订单、制作完成核心流程是开台 → 顾客扫码 → 点餐 → 下单 → 后厨制作 → 划菜 → 结账。顺着这个流程表就出来了-- 门店表 CREATE TABLE store ( id bigint NOT NULL COMMENT 门店ID, store_name varchar(50) NOT NULL COMMENT 门店名称, address varchar(255) DEFAULT NULL, phone varchar(20) DEFAULT NULL, status tinyint DEFAULT 1, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT门店表;菜品和分类的关系是最常见的父子关系分类表用parent_id支持二级分类例如“热菜”下面再分“荤菜”“素菜”菜品表和分类通过category_id关联。这里有一个容易忽略的细节同一道菜在不同门店可能价格不同。虽然这个版本按单店处理但菜品表我还是加了store_id字段就是为了后续扩展多门店时不至于推倒重来。3.2 订单主表 明细表为什么必须拆开订单和订单明细用两张表是餐饮项目必须遵守的规矩。原因很简单订单主表存的是概括信息订单号、桌台、状态、应付金额、实付金额、下单时间订单明细表存的是具体菜品菜品名称、单价、数量、规格如果只做一张表那么一个点了 8 个菜的订单就会产生 8 行记录每一行都带着订单号、桌台、顾客信息数据冗余不说查询“这张桌台一共消费了多少钱”都要做GROUP BY聚合。实际项目里主表和明细表的核心字段这样设计CREATE TABLE orders ( id bigint NOT NULL, order_no varchar(32) NOT NULL COMMENT 订单号前端展示用, store_id bigint NOT NULL, table_id bigint DEFAULT NULL COMMENT 桌台ID, member_id bigint DEFAULT NULL COMMENT 会员ID, status tinyint NOT NULL DEFAULT 0 COMMENT 订单状态0待支付 1已支付 2制作中 3已完成 4已取消 5已退款, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, discount_amount decimal(10,2) DEFAULT 0.00 COMMENT 优惠金额, pay_amount decimal(10,2) NOT NULL COMMENT 实付金额, remark varchar(255) DEFAULT NULL COMMENT 顾客备注, create_time datetime NOT NULL, update_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_order_no (order_no), KEY idx_table_id (table_id), KEY idx_store_id (store_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表;订单明细表则简单得多除了订单 ID、菜品 ID、菜品名称冗余字段防止菜品改名后订单显示错乱、数量、单价、规格信息之外基本没有别的。这里特别想提醒一个点菜品名称和单价一定要冗余到订单明细表里。因为菜品表的数据是会被修改的今天这道菜卖 38 元明天可能改成 42 元如果你在订单明细里没有冗余历史订单的金额就全乱套了。3.3 商品规格与库存定制化里最容易忽略的一环中餐里“大份/小份”“微辣/中辣/特辣”很常见。如果给菜品表加一列spicy_level那么以后加“去葱”呢加“少盐”呢加“加一份米饭”呢加得过来吗我的做法是独立的规格组和规格项表CREATE TABLE dish_spec_group ( id bigint NOT NULL, dish_id bigint NOT NULL COMMENT 菜品ID, group_name varchar(20) NOT NULL COMMENT 规格组名大份/小份、辣度, required tinyint DEFAULT 1 COMMENT 是否必选 ); CREATE TABLE dish_spec_item ( id bigint NOT NULL, group_id bigint NOT NULL, item_name varchar(20) NOT NULL COMMENT 规格项大份、中份、小份, extra_price decimal(10,2) DEFAULT 0.00 COMMENT 加价金额 );这样设计以后前端加购时可以把选中的规格项 ID 拼成一个字符串存到购物车和订单明细里后端取出后解析展示即可。顾客点了“大份加辣”实际支付金额就是基础价格 大份加价金额。这个结构看着简单但解决了“菜品多规格扩展”这个经典问题后续你如果想加“加冰/去冰/常温”只需要在后台配新规格组代码不用动。库存方面中餐店对实时库存的敏感度没有零售和茶饮那么高所以我没有做强事务扣减而是在下单时做了一次“菜品上下架状态 沽清状态”校验。后厨在后台手工把某道菜标记为“沽清”之后顾客端会在 5 分钟内看到这道菜变为“已售完”。3.4 订单状态机待支付、制作中、已完成、已退款要转得起来订单状态是整个系统的核心状态机设计不好前后端就会各写一套逻辑最后状态完全对不上。这版系统里我把订单状态收敛为六个状态码状态名称触发动作后续动作0待支付顾客提交订单支付成功 → 状态1超时 → 状态41已支付支付回调后厨接单 → 状态22制作中后厨标记出菜完成 → 状态33已完成出菜完成收银结账流转结束4已取消顾客取消或超时流转结束5已退款商家退款流转结束后端代码里不直接写状态数字而是用一个枚举类public enum OrderStatusEnum { UNPAID(0, 待支付), PAID(1, 已支付), MAKING(2, 制作中), DONE(3, 已完成), CANCELED(4, 已取消), REFUNDED(5, 已退款); private final int code; private final String desc; // 构造函数和 getter 略 }而且我专门封装了一个OrderStatusMachine校验工具每次更新状态时都强制判断当前状态是否允许转移到目标状态。比如“待支付”可以直接到“已取消”但“已完成”不能跳到“制作中”这样可以防止接口被调用时把订单状态改乱了。4. 核心下单链路事务边界、防重复提交与缓存一致性我接下来说的是整套系统里最有含金量的部分——下单接口。这部分代码虽然不长但需要同时处理事务、并发、缓存一致性非常考验基本功。4.1 一次下单操作包含哪些步骤顾客在 H5 端点击“结算”后端下单接口做的事比想象中要多根据桌台 ID 和门店 ID 生成订单主表记录状态为“待支付”把购物车的菜品明细批量写入订单明细表扣减菜品库存可选计算订单总金额、优惠金额、实付金额更新桌台状态为“已开台”清空 Redis 中的购物车如果是正餐模式直接返回“下单成功请呼叫服务员结账”如果是快餐模式则返回支付参数这些步骤必须被包裹在同一个事务里任何一步失败整个订单都不能落库。Transactional(rollbackFor Exception.class) public SubmitOrderResult submitOrder(SubmitOrderDTO dto) { // 1. 校验桌台和门店状态 Table table tableMapper.selectById(dto.getTableId()); if (table null || table.getStatus() ! TableStatusEnum.EMPTY.getCode()) { throw new BizException(桌台不存在或已被占用); } // 2. 使用Redis分布式锁防止同一桌台重复下单 String lockKey lock:order: dto.getTableId(); boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (!locked) { throw new BizException(当前桌台正在下单请勿重复操作); } try { // 3. 创建订单主表 Orders order buildOrder(dto); ordersMapper.insert(order); // 4. 批量保存订单明细 ListOrderItem itemList buildOrderItems(dto.getItems(), order.getId()); orderItemMapper.insertBatch(itemList); // 5. 扣库存/校验沽清状态 checkAndDeductStock(dto.getItems()); // 6. 更新桌台状态 tableMapper.updateStatus(dto.getTableId(), TableStatusEnum.OCCUPIED.getCode()); // 7. 清空购物车 cartService.clearCart(dto.getUserId(), dto.getStoreId()); return new SubmitOrderResult(order.getId(), order.getOrderNo()); } finally { redisTemplate.delete(lockKey); } }这段代码里几个细节值得说道说道Transactional保证了 MySQL 层面的原子性Redis 分布式锁保证了同一桌台、同一时刻只有一个下单请求能进入事务try/finally确保锁在事务结束后被释放而不是在事务内提前释放订单号由后端生成采用“门店ID 日期 随机数”的方式保证前端展示时足够友好4.2 Redis 分布式锁防止同一桌台重复下单为什么要加这把锁因为一套真实的餐饮系统里同桌顾客可能同时用两台手机扫码或者顾客手抖连续点了两次“提交订单”。如果没有锁就会生成两个一模一样的待支付订单收银员结账时就会蒙圈。Redis 的setIfAbsent命令是原子的天然适合实现这种简易分布式锁。我设置 5 秒过期时间是防止某个线程在锁期间异常退出、锁一直不释放导致后续订单全部被卡住。如果你要拿这套源码学习建议再看看 Redisson 的RLock它对锁的续期和自动释放处理得更优雅。但作为单店系统Redis 原生 API 足够用了。4.3 库存扣减是先查库还是先扣缓存菜品库存这块我最终选择了“查数据库 定时同步缓存”的折中方案。因为中餐店的菜品沽清是低频操作后厨手动改动次数一天可能也就十几次完全不需要像秒杀系统那样在 Redis 里做库存预扣。具体流程是后厨在后台把菜品状态改为“沽清”后先更新 MySQL再删除 Redis 里的菜品缓存。顾客端下一次拉取菜单时发现缓存不存在就会回源数据库重建缓存从而拿到最新状态。如果你要扩展成茶饮、咖啡这类高频点单且对库存比较敏感的业态可以用 Redis 的DECR命令做实时扣减再通过定时任务把最终结果回写到 MySQL。这是另一个话题了但前提是你要能接受极端情况下 Redis 数据和 MySQL 不一致的风险。4.4 缓存一致性修改菜品后菜单为什么还是旧的这个坑我踩过不止一次。第一次上线时运营在后台把“宫保鸡丁”改成了 36 元但顾客端小程序里依然显示 32 元查了半天才发现是 Redis 缓存没清。菜单缓存的 key 是menu:store:{storeId}后台保存菜品时会redisTemplate.delete这个 key。但问题在于如果删除缓存的那一行代码在事务里而事务回滚了缓存就删早了。更麻烦的是如果并发请求在删除缓存后、数据库事务还没提交前重新加载了旧数据进缓存就会导致缓存再次变脏。我在源码里采用的方案是“延迟双删”Transactional(rollbackFor Exception.class) public void updateDish(DishDTO dto) { // 1. 更新数据库 dishMapper.updateById(dto); // 2. 立即删除缓存 redisTemplate.delete(menu:store: dto.getStoreId()); } // 在Controller层调用完事务方法后异步延迟再删一次 public void updateDishWithCache(DishDTO dto) { dishService.updateDish(dto); // 500ms后再次删除缓存 CompletableFuture.runAsync(() - { try { Thread.sleep(500); redisTemplate.delete(menu:store: dto.getStoreId()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); }延迟双删不能 100% 保证一致但在餐饮这种业务场景里已经足够应对 99% 的情况了。如果你们内部对一致性要求特别高可以引入 Canal 监听 MySQL binlog 来删缓存这套源码里没有实现但表结构和代码已经预留了改造空间。5. MyBatis-plus 实战分页、自动填充、逻辑删除与那些容易翻车的细节MyBatis-plus 用好了是效率神器用不好就是埋坑专业户。我在这套项目里几乎用遍了它的常用功能下面这些点是从实际代码里抽出来的经验。5.1 配置一个分页插件只需要三步但数据库方言别搞错分页是后台管理系统的刚需。MyBatis-plus 分页插件配置非常简单Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }使用的时候PageOrderVO page new Page(current, size); LambdaQueryWrapperOrders wrapper new LambdaQueryWrapper(); wrapper.eq(Orders::getStoreId, storeId) .orderByDesc(Orders::getCreateTime); orderMapper.selectPage(page, wrapper); return new PageResult(page.getTotal(), page.getRecords());有个细节容易被新手忽略插件的DbType.MYSQL必须和实际数据库匹配。如果数据库是 PostgreSQL这里却配了DbType.MYSQL分页 SQL 就会生成LIMIT ?,?而 PostgreSQL 的语法是LIMIT ? OFFSET ?直接报错。5.2 自动填充创建时间、更新时间再也不用手写了一张表基本都有create_time和update_time字段。如果每次插入或更新都在代码里手动set不仅啰嗦还容易漏。MyBatis-plus 的自动填充功能可以完美解决Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }实体类上对应TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;5.3 逻辑删除与唯一索引的冲突逻辑删除就是给数据打上删除标记而不是真的从数据库里删掉。MyBatis-plus 里只需要在实体类字段上加TableLogic然后配置mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0但这里藏着一个坑如果你在菜品表上建了唯一索引uk_dish_name(name)当用户把“宫保鸡丁”删掉后再新增同名菜品数据库插入时就会因为唯一索引冲突而失败因为那条旧数据只是被逻辑删除了物理上还在表里。我的处理方式有两种唯一索引改用namedeleted联合索引删除前把name改成“原名称_删除时间戳”这个项目里我选的是第二种因为改造最小也不用担心 MySQL 里对同一字段重复索引的效率问题。5.4 updateById 不能把字段更新成 null这是个经典坑这个坑在热搜词里都排得上号了MyBatis-plus 的updateById默认不更新null字段。什么意思呢就是你调用updateById时如果实体对象的某个字段是nullMP 会自动忽略它不会生成SET xxx NULL这条 SQL。这在大多数场景下是好事省得误更新字段。但如果你真的想把某个字段置空比如把桌台的current_order_id清掉直接用updateById是没用的数据库里的值还是原样。解决方案有三种按推荐程度排序// 方式一LambdaUpdateWrapper 显式 set null推荐 lambdaUpdate() .eq(Table::getId, tableId) .set(Table::getCurrentOrderId, null) .update(); // 方式二实体类字段上加 updateStrategy TableField(updateStrategy FieldStrategy.IGNORED) private Long currentOrderId; // 方式三全局配置 mybatis-plus: global-config: db-config: update-strategy: ignored方式一最推荐因为它只影响这一次操作不会让整个实体类都变成“无条件更新”安全性更高。5.5 多门店隔离自定义多租户插件这个项目虽然是单店版但我在设计的时候就考虑到后续可能做连锁所以用了 MyBatis-plus 的多租户插件来做门店数据隔离。Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { Override public Expression getTenantId() { // 从当前登录用户上下文获取门店ID Long storeId LoginUserContext.getStoreId(); return new LongValue(storeId null ? 0L : storeId); } Override public String getTenantIdColumn() { return store_id; } Override public boolean ignoreTable(String tableName) { // 字典表、门店表这些全局表不参与隔离 return store.equals(tableName) || dict.equals(tableName); } })); return interceptor; }有了这个插件你写 SQL 的时候不需要手动加WHERE store_id ?MP 会自动拼接到每个查询上。但也要注意多租户插件对自定义 SQL 的解析有时会出问题遇到复杂 SQL 时需要检查生成的日志。6. Vue 前端与后厨联动联调、权限、部署遇到的坑前端部分我分三个端来讲三个端都基于 Vue 2但业务差异很大。这里只说那些真正卡住过我的问题。6.1 三个前端工程怎么组织项目结构我用了 monorepo 的方式一个仓库里套三个子工程frontend/ ├── h5/ # 顾客扫码点餐端Vue2 Vant Vuex Vue Router ├── admin/ # 收银管理端Vue2 Element UI Vuex Vue Router └── kitchen/ # 后厨大屏端Vue2 WebSocket为什么不用一个仓库一个项目因为顾客 H5 和后厨大屏的部署环境完全不同H5 要部署到公网域名下、后厨大屏只在局域网平板里跑。拆开后打包体积小互不影响部署更灵活。6.2 axios 封装与 Token 刷新三个前端都需要和后端打交道所以 axios 必须是统一封装。这个项目的封装要点是import axios from axios const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000 }) // 请求拦截器自动携带Token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) // 响应拦截器统一处理业务码 service.interceptors.response.use( response { const res response.data if (res.code ! 200) { if (res.code 401) { // Token失效跳转登录页 localStorage.removeItem(token) window.location.href /login } return Promise.reject(new Error(res.message || 请求失败)) } return res.data }, error { return Promise.reject(error) } )统一封装的意义在于后端返回的 JSON 结构一旦确定前端所有页面就只需要关心res.data不需要每个页面都写if (res.code 200)这种重复判断。6.3 后厨大屏的 WebSocket 推送后厨端的核心体验是顾客下单后后厨大屏要立刻弹出新订单提醒而不是靠前端轮询每 5 秒刷一次。我用的是 Spring Boot 的WebSocket 后厨端原生 WebSocket 客户端。后端在订单状态变为“已支付”时通过会话推送一条 JSON 消息给后厨端public class KitchenWebSocket { private static final CopyOnWriteArraySetSession SESSIONS new CopyOnWriteArraySet(); public static void sendNewOrder(OrderMessage msg) { String json JSON.toJSONString(msg); for (Session session : SESSIONS) { try { session.getBasicRemote().sendText(json); } catch (IOException e) { e.printStackTrace(); } } } }后厨端收到消息后把订单渲染到对应分类的列表顶部并播放提示音。当厨师点击“制作完成”后前端再调用 REST 接口更新订单状态同时通过sendNewOrder反向通知收银端刷新订单列表。这里有一个比较隐蔽的问题WebSocket 连接是长连接如果服务端和客户端之间有 Nginx 代理Nginx 默认 60 秒就会断开空闲连接。所以 Nginx 配置里要加上proxy_read_timeout 3600s; proxy_send_timeout 3600s;否则就会出现“后厨大屏刚开始用着正常过一会儿就收不到消息了”的诡异现象排查半天才发现是代理断的。6.4 Nginx 部署刷新 404 与接口跨域前端打包之后部署到 Nginx最常见的问题有两个第一个问题Vue Router 使用 history 模式刷新页面 404。解决方式是配置try_fileslocation / { root /usr/share/nginx/html/h5; index index.html; try_files $uri $uri/ /index.html; }第二个问题后端接口跨域。后端的实现方式是用 CORS 过滤器Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }如果生产环境前后端用同一个域名部署跨域问题基本不会出现。但如果 H5 和后端接口分属不同域名CORS 配置就必不可少。6.5 和第三方对接时预留的扩展点餐饮系统免不了要对接第三方服务。这个项目里预留了几个扩展点方便你后续改造支付模块PayService接口里定义了createOrder、queryOrder、refund三个方法目前微信支付走的是默认实现将来接支付宝或者抖音支付只需要新增一个实现类打印机对接订单完成后会触发OrderPaidEvent这是一个 Spring 事件你可以监听这个事件对接飞鹅云、芯烨等云打印机会员储值储值流水记录在member_recharge_log表里将来对接营销插件可以直接复用这些流水数据我个人对接云打印机时踩过最大的坑是打印内容里不能有特殊字符尤其是菜品名称里带括号和百分号时有些打印机的指令解析会乱码。后来是把打印文本做了转义处理才算彻底解决。7. 源码之外的一些体会最后说几句心里话。做这套系统之前我一直觉得“定制化软件”就是给客户换换颜色、改改 Logo。真正做完之后才发现定制化的核心价值是在理解业务规则的基础上把技术方案做成适合这个业务节奏的样子。比如“桌台并桌”这个需求普通 CRUD 系统里就是一个UPDATE语句。但在真实餐馆里并桌意味着两个桌台的订单要合并、原本的桌台要释放、顾客要重新选桌、后厨的出菜单要改桌号这些动作牵一发动全身。再比如订单状态如果状态机设计得不够严谨就会出现“后厨已经出菜了收银员却点了退款”这种操作冲突。我在状态机校验里加了保护只有“待支付”状态才能取消“已支付”后只能走退款流程从机制上杜绝了误操作。这套源码里的很多东西单看技术并不难难的是把这些技术点串成一个能真实跑起来的业务流程。你把这篇拆解文章看完之后建议再拿着源码走一遍完整流程扫码进 H5选菜、加购、下单打开后厨大屏看实时推送再到收银端结账、关桌。走完一遍你对这套技术栈在餐饮业务里的定位就有了真正的体感。后续如果你想升级我比较推荐从三个方向入手一是把单店版改成多店连锁版这套表结构已经预留了store_id改造工作量不大二是把菜单缓存改成 Canal MQ 的异步淘汰方案彻底解决缓存一致性隐患三是给后厨端增加语音播报和菜品超时预警这两个功能在真实餐厅里非常受欢迎也最能体现定制化的价值。本文还有配套的精品资源点击获取

相关新闻