餐饮系统源码实战:Vue+Spring Boot+Redis+MyBatis-plus
2026/8/31 1:41:01 网站建设 项目流程

简介:这是一套面向餐饮企业数字化转型的全栈开发实战源码,适用于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 和 JPA

JPA 的优点是开发者不需要写 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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;超时 → 状态4
1已支付支付回调后厨接单 → 状态2
2制作中后厨标记出菜完成 → 状态3
3已完成出菜完成收银结账,流转结束
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 端点击“结算”,后端下单接口做的事比想象中要多:

  1. 根据桌台 ID 和门店 ID 生成订单主表记录,状态为“待支付”
  2. 把购物车的菜品明细批量写入订单明细表
  3. 扣减菜品库存(可选)
  4. 计算订单总金额、优惠金额、实付金额
  5. 更新桌台状态为“已开台”
  6. 清空 Redis 中的购物车
  7. 如果是正餐模式,直接返回“下单成功,请呼叫服务员结账”;如果是快餐模式,则返回支付参数

这些步骤必须被包裹在同一个事务里,任何一步失败,整个订单都不能落库。

@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. 批量保存订单明细 List<OrderItem> 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; } }

使用的时候:

Page<OrderVO> page = new Page<>(current, size); LambdaQueryWrapper<Orders> 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_timeupdate_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),当用户把“宫保鸡丁”删掉后,再新增同名菜品,数据库插入时就会因为唯一索引冲突而失败,因为那条旧数据只是被逻辑删除了,物理上还在表里。

我的处理方式有两种:

  • 唯一索引改用name+deleted联合索引
  • 删除前把name改成“原名称_删除时间戳”

这个项目里我选的是第二种,因为改造最小,也不用担心 MySQL 里对同一字段重复索引的效率问题。

5.4 updateById 不能把字段更新成 null?这是个经典坑

这个坑在热搜词里都排得上号了:MyBatis-plus 的updateById默认不更新null字段。什么意思呢?就是你调用updateById时,如果实体对象的某个字段是null,MP 会自动忽略它,不会生成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 CopyOnWriteArraySet<Session> 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_files

location / { 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接口里定义了createOrderqueryOrderrefund三个方法,目前微信支付走的是默认实现,将来接支付宝或者抖音支付,只需要新增一个实现类
  • 打印机对接:订单完成后会触发OrderPaidEvent,这是一个 Spring 事件,你可以监听这个事件,对接飞鹅云、芯烨等云打印机
  • 会员储值:储值流水记录在member_recharge_log表里,将来对接营销插件可以直接复用这些流水数据

我个人对接云打印机时踩过最大的坑是:打印内容里不能有特殊字符,尤其是菜品名称里带括号和百分号时,有些打印机的指令解析会乱码。后来是把打印文本做了转义处理,才算彻底解决。

7. 源码之外的一些体会

最后说几句心里话。做这套系统之前,我一直觉得“定制化软件”就是给客户换换颜色、改改 Logo。真正做完之后才发现,定制化的核心价值是在理解业务规则的基础上,把技术方案做成适合这个业务节奏的样子。

比如“桌台并桌”这个需求,普通 CRUD 系统里就是一个UPDATE语句。但在真实餐馆里,并桌意味着两个桌台的订单要合并、原本的桌台要释放、顾客要重新选桌、后厨的出菜单要改桌号,这些动作牵一发动全身。

再比如订单状态,如果状态机设计得不够严谨,就会出现“后厨已经出菜了,收银员却点了退款”这种操作冲突。我在状态机校验里加了保护,只有“待支付”状态才能取消,“已支付”后只能走退款流程,从机制上杜绝了误操作。

这套源码里的很多东西,单看技术并不难,难的是把这些技术点串成一个能真实跑起来的业务流程。你把这篇拆解文章看完之后,建议再拿着源码走一遍完整流程:扫码进 H5,选菜、加购、下单,打开后厨大屏看实时推送,再到收银端结账、关桌。走完一遍,你对这套技术栈在餐饮业务里的定位就有了真正的体感。

后续如果你想升级,我比较推荐从三个方向入手:一是把单店版改成多店连锁版,这套表结构已经预留了store_id,改造工作量不大;二是把菜单缓存改成 Canal + MQ 的异步淘汰方案,彻底解决缓存一致性隐患;三是给后厨端增加语音播报和菜品超时预警,这两个功能在真实餐厅里非常受欢迎,也最能体现定制化的价值。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询