☰
基于SpringBoot的餐饮管理系统设计与实现——从需求到答辩全流程指南
2026/9/29 15:47:01 网站建设 项目流程

做毕设选“Java餐饮管理系统”的人一直不少,这个题目几乎每年都出现在各大高校的选题榜前几名。原因很直白:业务场景足够贴近生活,点餐、购物车、订单、支付、报表这一整套流程和真实商业系统几乎没有差别,但技术复杂度又刚好控制在本科生能驾驭的范围内。往浅了做,用 JSP+Servlet 也能交出完整页面;往深了做,SpringBoot、Redis、JWT、并发控制、事务一致性这些点全都塞得进去,属于典型的“下限低、上限高”题目。

这篇文章我就用最近帮一个学弟完成“基于Java的餐厅运营与点餐服务平台/Java驱动的智慧食堂数字化管理系统”的过程作为主线,从需求分析、技术选型、表结构设计、核心功能实现,到真正开发时踩过的一堆坑,再到答辩和求职面试怎么把这个项目讲出亮点,完整捋一遍。适合正在定题、想用 SpringBoot 把老题目重新包装、或者想拿一个高完成度项目去找实习的同学参考。下面全是实操经验,不绕弯子。

1. 需求分析与模块拆分:先想清楚再做码

很多人拿到题目第一反应是打开 IDEA 直接建工程,这是毕设翻车的第一大原因。餐饮管理系统表面上是“点餐”两个字,实际牵扯到角色权限、桌台状态、菜品上下架、订单状态流转、库存变动、财务统计,任何一个环节想得不清楚,后面写代码就是反复返工。我劝学弟先花一周时间把需求理成一张功能清单,再谈技术。

1.1 用户角色与业务场景

我习惯把餐饮系统的使用者拆成三类,每一类对应独立的业务场景:

  • 顾客端:浏览菜品、按分类筛选、搜索、加入购物车、下单、模拟支付、查看历史订单、评价菜品。如果做的是扫码点餐,还需要考虑桌台绑定。
  • 前台/服务员端:开台、换台、点菜、催菜、收银结账、订单状态更新。服务员是操作最频繁的角色,所有高频操作都要控制在两步以内,这个体验原则也可以写进答辩PPT里。
  • 管理员端:菜品分类管理、菜品上下架、图片上传、库存管理、员工账号管理、会员管理、销售报表查看。管理员关心的是经营数据,不是点餐细节。

这里有个非常重要的取舍:毕设不要轻易把“外卖配送”“多门店连锁”“骑手调度”加进来,因为那会把业务范围撑得很大,最后每一项都做得浅。我的建议是做“堂食+自提”的轻量模式,把点餐下单这条主链路做透,再把并发扣库存、订单事务这两个点做深,答辩的时候反而更好讲。这也正是“餐厅运营与点餐服务平台”这个定位的精髓——核心是运营数据的闭环,不是功能的堆砌。

1.2 功能清单与工作量预估

功能模块具体内容预计工作量优先级
用户与权限用户注册登录、JWT会话、后台员工权限1-2天高
菜品管理分类维护、菜品CRUD、图片上传、上下架2天高
桌台管理桌号维护、桌台状态流转0.5天中
购物车加购、改数量、删除、清空、库存预校验1-2天高
订单模块下单事务、订单状态机、模拟支付、退单3天高
库存管理菜品库存、扣减、预警1天中
会员积分积分累计、消费抵用1天低
统计报表日/周销售曲线、菜品销量排行2天中
系统辅助全局异常、日志、统一返回结构1天高

我估算工作量是按一个熟悉 SpringBoot 基本语法的大学生每天写6小时算的。为什么购物车和订单要预留那么多时间?因为这两个模块涉及的数据状态最多,购物车要联动库存和菜品上下架状态,订单要处理事务回滚和并发问题,这些不是靠复制粘贴能快速搞定的。很多学弟喜欢在选题后直接找网上的老项目改,但如果连功能清单都没列清楚,改了半个月还是不知道哪些代码该删、哪些该留。

1.3 为什么不建议一上来就做微服务

你们在技术选型时很容易被网上文章带偏,看到“高并发”“分布式”“微服务”就兴奋。餐饮管理系统这种业务,用户量级就是一个小型食堂、一个小餐厅,单体应用完全扛得住。微服务要拆分成服务注册、配置中心、网关、链路追踪,一套下来光环境搭建就够折腾两星期,最后查询报表还要跨服务调用,事务一致性更难保证。我的原则是:毕设的技术难度要匹配业务复杂度,把单体写扎实,比硬上微服务拿个半成品强得多。如果面试被问“为什么不用微服务”,你就说“当前业务规模下单体架构足够,过度设计会增加维护成本”,这本身就是一种架构思维的体现。

2. 技术选型与项目骨架搭建:SSM还是SpringBoot

这一步直接决定后面开发的效率。我帮学弟定下的技术栈是:SpringBoot + MyBatis-Plus + MySQL + Redis + Vue。如果你对前端不太熟,也可以把 Vue 换成 Thymeleaf 模板引擎,服务端渲染,代码量更少,答辩演示更稳定。下面把选型理由说清楚,这些都是面试时可以讲的点。

2.1 主流方案对比

方案技术组合优点缺点适用场景
AJSP + Servlet + JDBC简单直接,专业课熟悉代码冗余,连接管理混乱,难以扩展课程设计,不建议毕设
BSSM经典框架组合,分层清晰XML配置繁琐,整合门槛高想巩固框架原理的同学
CSpringBoot + MyBatis-Plus配置少、开发快、生态成熟封装太多,原理要额外补当前最推荐的毕设方案
DSpringBoot + Redis + MQ性能强、可讲亮点学习成本高,容易烂尾基础很好的同学

我给学弟选 C 方案,理由有三条。第一,SpringBoot 的自动配置把 SpringMVC、事务管理、JSON 转换这些繁琐配置全部简化了,能把精力集中在业务逻辑上。第二,MyBatis-Plus 对单表 CRUD 做了极强的封装,BaseMapper 直接提供 insert、selectPage、updateById,再也不用像 MyBatis 那样为每个简单查询写 XML。第三,社区资料极多,遇到问题搜一下就有答案,适合毕设周期。

前端用 Vue + Element-UI,后端只提供 JSON 接口,这种前后端分离模式更贴近公司真实开发,也便于讲解“统一返回结构”和“跨域处理”。如果怕环境配置麻烦,用 Thymeleaf 也可以,但没有前后端分离那样容易扩展成小程序端。

2.2 工程结构与分层思想

项目包名我建议用 com.example.restaurant,下面按职责分包,看起目录就懂架构:

com.example.restaurant ├── config # 配置类:跨域、MyBatis-Plus分页、JWT拦截器 ├── controller # 接口层:只做参数接收和结果返回 ├── service # 业务层:业务规则、事务控制 │ └── impl ├── mapper # 数据访问层:继承BaseMapper ├── entity # 数据库实体 ├── dto # 入参对象 ├── vo # 出参对象 ├── common # 统一返回Result、常量、枚举 └── exception # 自定义异常与全局异常处理器

这个分层不是随便分的,每一层都有明确职责。Controller 层不要写任何业务代码,它只做三件事:接收参数、调用 Service、返回 Result。Service 层承载业务规则,比如下单时要校验库存、生成订单号、扣减库存、清空购物车,这些操作必须在一个事务方法里完成。Mapper 层就是数据库操作,MyBatis-Plus 让单表操作几乎不用写 SQL。这样分层带来的好处是:面试官问你“订单模块怎么设计的”,你能清晰地讲出数据走向 request → controller → service → mapper → DB,而不是含糊地说“就写在那个类里了”。

统一返回结构是我要求学弟必须做的一件事。定义一个 Result<T>,包含 code、msg、data 三个字段,所有接口都返回这个对象。这样做的好处有三个:前端处理逻辑统一,不必为每个接口单独判断数据结构;全局异常处理器可以把异常统一包装成 Result 返回,前端不会突然收到一堆看不懂的报错;答辩时讲接口设计规范也有话说。

2.3 编码环境与JDK版本问题

环境这块我多说几句,因为每年都有人卡在第一步。JDK 我推荐用 8 或 11,不是越新越好,而是很多老项目、教学视频都是基于 JDK8 写的,你遇到问题去搜,答案匹配度最高。如果你机器上装了 JDK17,一定要检查 IDEA 的 Project Structure 里 Project SDK 和 Maven 的 Java Compiler 版本是不是一致,否则就会出现“警告: 源发行版 17 需要目标发行版 17”这类编译错误,这个我后面在坑里细说。

环境变量配置也是新手常踩的点。安装 JDK 后要配置 JAVA_HOME 和 PATH,IDEA 内部其实可以自动识别,但如果你在命令行里 mvn 打包,环境变量配不对就会报“mvn不是内部或外部命令”。最稳的办法是在命令行执行 java -version 和 mvn -version,确认版本一致再继续。我不建议在 CLASSPATH 上花太多心思,现代 Java 开发很少手动配 CLASSPATH,把 JAVA_HOME 和 PATH 弄对就够用了。

3. 数据库设计与核心表结构:表设计决定系统上限

很多毕设项目死在第二阶段:代码写了一堆,数据库就三四张表,点餐记录和菜品信息全塞在一起,查个销量报表要嵌套三层子查询。数据库是系统的地基,表设计不合理,后面每个功能都会变扭。我用了一晚上帮学弟把表结构重新梳理了一遍,下面直接说核心。

3.1 核心表清单与字段设计

表名用途关键字段
sys_user后台管理员/员工id, username, password, real_name, role
userC端顾客id, openid, nickname, phone, points
category菜品分类id, name, sort, status
dish菜品id, category_id, name, price, image, description, stock, version, status
table_info桌台id, table_no, capacity, status
cart购物车id, user_id, dish_id, quantity, checked
orders订单主表id, order_no, user_id, table_id, total_amount, pay_amount, status, pay_status, create_time
order_detail订单明细id, order_id, dish_id, dish_name, price, quantity, subtotal
member会员信息id, user_id, level, points, total_consume
operation_log操作日志id, user_id, action, detail, create_time

为什么需要这两张订单表?因为订单主表和订单明细表是一对多关系。一张订单里可能点了五个菜,如果把菜品信息直接冗余在主表里,改价格、统计销量都会乱套。主表记录订单整体状态和金额,明细表记录每一道菜的快照信息——注意,这里存的是 dish_name 和 price 快照,不是只存 dish_id。为什么?因为菜品价格后来可能调整,但历史订单必须保持当时的下单价格,这是财务报表和退款纠纷的依据。

3.2 关键字段的设计原因

有几个字段设计是必须要能讲出道理的,写文档和答辩都用得上。

金额字段一律用 DECIMAL(10,2),不能使用 FLOAT/DOUBLE。这是经典面试题“Java中浮点数精度丢失”的数据库版本。FLOAT 是二进制浮点,0.1 + 0.2 会得到 0.30000000000000004,而金额计算差一分钱都是事故。DECIMAL 是定点数,MySQL 内部按字符串存储,计算精确。Java 侧对应使用 BigDecimal,不要用 Double 接收金额参数。

状态字段我用 TINYINT 加常量类,而不是直接存中文。比如订单状态 order_status:0待支付、1已支付、2制作中、3已完成、4已取消、5退款中。用数字的好处是存储空间小、查询快、方便扩展状态机,但代码里不能到处写魔法数字,必须定义 OrderStatus 常量类。这不仅是代码规范问题,也是面试官常问的“如何避免魔法值”。

逻辑删除字段 deleted 我基本每张表都加了。为什么不物理删除?因为餐饮系统的订单、菜品、用户数据都有统计价值,物理删了之后日报表、销量排行都对不上。但逻辑删会带来一个坑:如果菜品名有唯一索引,删除后再添加同名菜品会报唯一键冲突。解决办法是把唯一索引改成 (name, deleted) 联合索引,或者干脆不设唯一索引、靠代码判断。这个细节我在第5章还会提到。

version 乐观锁字段是给库存表、订单表用的。它的存在是为了应对两个人同时下单抢最后一份菜的场景,详细原理见第4章。你可以在答辩时说:“这个字段是我专门用来解决并发超卖问题的”,这句话本身就是加分项。

3.3 核心建表SQL参考

下面给出三张核心表的建表 SQL,其他表照着这个思路写就行。注意字符集、存储引擎和时间字段默认值。

CREATE TABLE `dish` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '菜品ID', `category_id` BIGINT NOT NULL COMMENT '分类ID', `name` VARCHAR(50) NOT NULL COMMENT '菜品名称', `price` DECIMAL(10,2) NOT NULL COMMENT '单价', `image` VARCHAR(255) DEFAULT NULL COMMENT '图片地址', `stock` INT NOT NULL DEFAULT 0 COMMENT '库存', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0正常 1删除', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品表'; CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单号', `user_id` BIGINT DEFAULT NULL, `table_id` BIGINT DEFAULT NULL, `total_amount` DECIMAL(10,2) NOT NULL COMMENT '总金额', `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付...', `pay_status` TINYINT NOT NULL DEFAULT 0, `pay_time` DATETIME DEFAULT NULL, `deleted` TINYINT NOT NULL DEFAULT 0, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; CREATE TABLE `order_detail` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `dish_id` BIGINT NOT NULL, `dish_name` VARCHAR(50) NOT NULL COMMENT '菜品快照名', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时单价快照', `quantity` INT NOT NULL, `subtotal` DECIMAL(10,2) NOT NULL, PRIMARY KEY (`id`), KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';

为什么 order_no 要单独建唯一索引?因为订单号要面向用户展示、客服查单,必须保证全局唯一且高效率查询。生成订单号我用的是“yyyyMMddHHmmss + 用户ID后四位 + 随机数”,简单可靠,不需要引入雪花算法。雪花算法适合分布式系统生成全局唯一ID,但单体 MySQL 下时间戳+随机数配合唯一索引已经足够。真要考虑高并发,可以谈“Snowflake”的原理,但不一定非要写进代码。

4. 核心功能模块实现:从点餐到报表的闭环

技术栈和表结构定了,开发就按模块推进。下面挑我最想让读者抄作业的四个核心环节展开,分别是登录认证、购物车与菜品、下单事务与防超卖、报表统计。这些代码不是完整源码,但把核心逻辑写透了,你照着搭脚手架就能跑通。

4.1 登录认证与权限控制

C 端用户可以用简单的手机号+验证码,也可以做微信授权登录(需要小程序)。后台员工用账号密码登录。我这里采用 JWT 做前后端分离的会话管理,不用 Session。为什么不用 Session?因为 Session 依赖服务端内存,前后端分离部署时可能有多台实例,Session 同步麻烦;JWT 是无状态的,token 本身携带用户信息,适合接口化开发。

核心流程是:用户登录成功后,后端签发一个 JWT token,前端每次请求在 Header 里带Authorization: Bearer <token>,后端拦截器解析 token,把 userId 和 role 放到 ThreadLocal 里,供 Service 层取用。

@Component public class JwtInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; public JwtInterceptor(JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains("/login")) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); if (jwtUtil.validateToken(token)) { Long userId = jwtUtil.getUserId(token); String role = jwtUtil.getRole(token); UserContext.set(userId, role); return true; } } response.setStatus(401); return false; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }

注意两个细节:一是密码绝对不能明文存数据库,我用 BCrypt 加密,每次登录把用户输入的密码 BCrypt 哈希后和库里比对,即使数据库泄露,攻击者也拿不到明文密码。二是 ThreadLocal 用完必须清除,否则 Tomcat 线程池复用线程会把上一个用户的身份带到下一次请求,这是很严重的安全漏洞,也是 Spring 源码里 RequestContextHolder 也在做同样事情的原因。

4.2 菜品管理、购物车与分页

菜品列表是系统最常用的接口,支撑菜单页。用 MyBatis-Plus 的分页插件非常省事:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

Service 里只需要:

public PageResult<DishVO> pageDish(DishQueryDTO dto) { LambdaQueryWrapper<Dish> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.hasText(dto.getCategory()), Dish::getCategoryId, dto.getCategory()) .eq(Dish::getStatus, 1) .eq(Dish::getDeleted, 0) .orderByDesc(Dish::getCreateTime); Page<Dish> page = dishMapper.selectPage(new Page<>(dto.getPageNum(), dto.getPageSize()), wrapper); // 转VO,返回分页结果 }

购物车我强烈建议做后端表,而不是存前端 localStorage。后端表的好处:换设备数据不丢、菜品库存和上架状态可以实时校验、结账时直接读购物车表生成订单更可靠。代价是每次改数量都要请求后端,但并发量低,完全不是问题。

加购物车的一个核心校验是:如果菜品已经下架或库存为0,要直接抛出业务异常,不能把无效菜品加进购物车。这个判断放在 Service 层而不是 Controller,属于“业务规则要下沉”。

4.3 下单事务与库存防超卖

这是整个系统最核心、也是最值得在答辩时全力展开的模块。用户点完菜点“结账”,后端要做的事情包括:校验桌台和购物车、生成订单主表、批量生成订单明细、扣减库存、清空购物车、标记待支付。这五个操作必须是一个原子操作,任何一个失败都不能留下半截数据。实现方式就是 @Transactional。

@Service public class OrderServiceImpl implements OrderService { @Override @Transactional(rollbackFor = Exception.class) public OrderCreateVO createOrder(OrderCreateDTO dto) { // 1. 校验桌台状态 TableInfo table = tableInfoMapper.selectById(dto.getTableId()); if (table == null || !TableStatus.FREE.equals(table.getStatus())) { throw new BizException("桌台不可用"); } // 2. 查询购物车列表 List<Cart> cartList = cartMapper.selectList( new LambdaQueryWrapper<Cart>().eq(Cart::getUserId, dto.getUserId())); if (cartList.isEmpty()) { throw new BizException("购物车为空"); } // 3. 计算总金额,同时校验菜品状态与库存 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderDetail> detailList = new ArrayList<>(); for (Cart cart : cartList) { Dish dish = dishMapper.selectById(cart.getDishId()); if (dish == null || dish.getStatus() != 1 || dish.getDeleted() != 0) { throw new BizException("菜品不存在或已下架:" + cart.getDishId()); } // 乐观锁扣减库存:stock >= 数量才更新成功 int rows = dishMapper.deductStock(cart.getDishId(), cart.getQuantity()); if (rows == 0) { throw new BizException("菜品库存不足:" + dish.getName()); } BigDecimal subtotal = dish.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity())); totalAmount = totalAmount.add(subtotal); detailList.add(buildDetail(dish, cart.getQuantity(), subtotal)); } // 4. 生成订单号并插入订单主表 String orderNo = OrderNoGenerator.generate(dto.getUserId()); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(dto.getUserId()); order.setTableId(dto.getTableId()); order.setTotalAmount(totalAmount); order.setOrderStatus(OrderStatus.UNPAID); orderMapper.insert(order); // 5. 批量插入明细 detailList.forEach(d -> { d.setOrderId(order.getId()); orderDetailMapper.insert(d); }); // 6. 清空购物车 cartMapper.delete( new LambdaQueryWrapper<Cart>().eq(Cart::getUserId, dto.getUserId())); return new OrderCreateVO(order.getId(), orderNo, totalAmount); } }

对应 Mapper 里的乐观锁扣减 SQL 长这样:

UPDATE dish SET stock = stock - #{quantity}, version = version + 1 WHERE id = #{dishId} AND stock >= #{quantity} AND status = 1

这个 SQL 妙在把条件放在 WHERE 里。如果库存不足,WHERE 匹配不到行,影响行数为 0,业务就能感知到并抛异常。如果两个用户同时抢最后一份菜,数据库的行锁保证只有一个更新成功,另一个影响行数为 0,进入“库存不足”分支。这种方案不需要 SELECT ... FOR UPDATE,也不需要分布式锁,最适合单体项目。

关于 @Transactional 有几个坑必须提醒:事务默认只对 RuntimeException 回滚,如果代码里抛出的是检查异常,事务不会回滚,所以我在注解里写了 rollbackFor = Exception.class。还有一个经典失效场景是同类内部调用,比如 Controller 调 Service 的 A 方法,A 方法内部又调同一个类的 B 方法,B 上的 @Transactional 不会生效,因为 Spring 的事务是通过代理类实现的,内部调用走的是 this,不是代理对象。解决办法是把 B 拆到另一个 Service 类,或者把事务边界放在 A 方法上。

4.4 报表统计与分析

报表模块是餐饮系统的门面,也是很多老师爱看的功能。我用 ECharts 做前端展示,后端只需要提供两个核心接口:按日销售额统计、菜品销量排行。

按日销售额统计的核心 SQL:

SELECT DATE(create_time) AS day, SUM(total_amount) AS amount FROM orders WHERE order_status = 3 AND create_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE(create_time) ORDER BY day

菜品销量排行:

SELECT d.id, d.name, SUM(od.quantity) AS total_sales FROM order_detail od LEFT JOIN dish d ON od.dish_id = d.id LEFT JOIN orders o ON od.order_id = o.id WHERE o.order_status = 3 GROUP BY d.id, d.name ORDER BY total_sales DESC LIMIT 10

只统计已完成订单(order_status=3),因为待支付订单还没有产生实际收入。这个过滤条件是学弟容易漏掉的,漏掉之后报表数字会虚高。查询量上来之后,记得在 create_time、order_status 上建联合索引,否则全表扫描会把数据库拖慢。报表这类接口读多写少,如果以后数据量大,可以把统计结果用定时任务汇总到一张报表表中,或者加 Redis 缓存当日数据,但毕设阶段做好 SQL 就够了。

5. 开发中常见的坑与解决实录:把这些写进项目总结里

这部分全是真金白银。学弟在开发过程里踩过的坑,我几乎都陪他排查了一遍,下面挑最有代表性的五个写出来,每个都可以直接抄进项目文档的“疑难问题”章节。

5.1 BigDecimal、日期与JSON序列化问题

第一个坑:后端返回的金额 BigDecimal 传到前端,有时会变成长长的科学计数法,或者直接丢精度。根因是 Jackson 默认把 BigDecimal 序列化为数字,较大的小数可能被转成科学计数法。解决办法是全局配置 BigDecimal 的序列化器,让它输出字符串:

@Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> { builder.serializerByType(BigDecimal.class, ToStringSerializer.instance); builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer( DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; }

第二个坑:LocalDateTime 默认序列化成数组格式[2025,1,1,12,0,0],前端根本没法用。所以要单独定义 LocalDateTime 的序列化器和反序列化器,统一日期格式。这些配置看起来小,但直接影响前端联调效率,建议项目第一天就配上。

5.2 并发超卖与事务失效的排查

测试库存防超卖时,我用 JMeter 开 20 个线程同时点最后一份菜,结果发现库存变成负数了。排查过程很有代表性:先检查 SQL,发现 UPDATE 语句没有加 stock >= #{quantity} 条件,等于无条件扣减;修好后再测,数据库层面正常了。之后又发现一个问题:扣库存和插入订单明细不在同一个事务里,扣库存成功、插入明细失败时库存被白白扣掉。解决方式就是上面第4章那套:把所有写操作包在同一个事务方法里,并且扣减失败要抛 RuntimeException 触发回滚。

如果你在答辩时被问到“怎么保证数据一致性”,不要只背 ACID,要结合这个项目讲:我用事务保证订单明细和库存操作的原子性,用乐观锁防止库存超卖,用数据库唯一索引保证订单号不重复。这一套组合拳讲下来,比干巴巴背“原子性一致性隔离性持久性”有力得多。

5.3 数据库乱码、时区与连接池问题

学弟第一次启动项目,数据库中文全部乱码,排查半天发现是连接 URL 少了编码参数。现在 MySQL 8 的全套连接参数建议这样写:

jdbc:mysql://localhost:3306/restaurant?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true

characterEncoding=utf8 控制中文不乱码,serverTimezone 解决数据库和服务器时区不对导致的 8 小时时间差。allowPublicKeyRetrieval=true 是 MySQL 8 用 caching_sha2_password 认证时需要的参数。这些参数在答辩时不用讲得太深,但能解决启动失败问题。

如果启动时报HikariPool-1 - Exception during pool initialization,先检查 MySQL 服务有没有启动、账号密码是否正确、驱动依赖版本是否匹配。最常见的是 pom 里引入了 MySQL 5 的驱动而连接的是 MySQL 8,或者反过来,驱动版本和数据库版本不匹配。

5.4 Maven编译版本不匹配与Java环境变量

热词里那条“java: 警告: 源发行版 17 需要目标发行版 17”我太熟悉了。这种报错的本质是 IDEA 的 Project SDK 是 JDK17,但 Maven 的 compiler 插件还按 JDK8 编译,或者 Maven 用的 JDK 和 Project SDK 版本不一致。最简单粗暴的解决办法是在 pom.xml 里显式指定编译版本:

<properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> </properties>

如果是在命令行打包,还要保证java -version和mvn -version指向同一个 JDK。很多同学电脑里既装了 JRE 又装了多个 JDK,环境变量 PATH 指到旧的 JRE,命令行和 IDEA 里看到的版本不一样,就会出现各种诡异问题。这也是我反复强调“统一 JDK 版本”的原因。

5.5 跨域、404与打包部署问题

前后端分离项目联调时第一个报错就是跨域。我在后端写一个 WebMvcConfigurer 配置类,允许本机前端地址访问,并放行 OPTIONS 预检请求。否则前端浏览器发出预检请求会被拦截器当成正常请求拦截,导致“CORS 请求未通过”的诡异报错。

如果前端用 Vue Router 的 history 模式,部署上线后刷新页面会 404,因为 nginx 找不到对应的静态资源路径。解决办法是 nginx 里配置 try_files 把所有路由回退到 index.html,或者干脆改用 hash 模式,URL 会带个 #,不美观但省心。毕设演示一般用 hash 模式就够了,因为我吃过 history 模式的亏,现场演示时刷新就白屏,很尴尬。

打包部署我推荐 SpringBoot 的可执行 jar,用mvn clean package打出来,服务器上java -jar restaurant.jar一条命令启动。数据库脚本作为 init.sql 保存,服务器上手动执行一次。端口的话,8080 很容易被占用,启动失败时就netstat -ano | findstr 8080查占用进程。

6. 毕设答辩与面试怎么讲这个项目:让代码变成你的加分项

项目写完只是完成了一半,另一半是让别人看到它的价值。我带学弟准备了答题思路和面试问题梳理,发现只要把项目里的几个核心决策讲透,面试官就不会揪着八股文穷追猛打。这一章直接给可复用的素材。

6.1 五分钟演示脚本与项目亮点整理

演示顺序建议固定:登录系统 → 管理员维护菜品 → 顾客扫码点餐 → 加购物车 → 下单 → 模拟支付 → 查看订单状态 → 后台看销售报表。整个过程控制在五分钟内,重点是下单和支付环节,因为这里有事务回滚和状态流转,最能体现业务完整性。

讲解项目时用 STAR 法则:背景是学校食堂需要一套数字化点餐系统;你的角色是独立完成前后端开发和数据库设计;难点是并发减库存、订单状态一致性、报表统计;方案是事务+乐观锁+统一状态机;结果是系统可以稳定支撑日订单数百单模拟量。这样讲,面试官立刻就能抓住重点。

被问“项目最大的亮点是什么”时,不要回答“我用了SpringBoot、Redis”这种技术名词堆砌。我建议的答案是:“细节上,我设计了订单主表和明细表隔离、菜品金额快照机制,保证历史订单财务数据准确;并发上,我用数据库乐观锁解决了超卖问题;工程上,我做了全局异常处理和统一返回结构,前后端联调效率很高。”这三个点既有业务思考又有技术深度,比单纯背框架名强得多。

6.2 高频Java面试题与项目的挂钩方式

很多同学背了一堆八股文,面试时却不会结合项目讲。我整理了五类高频题目和对应的话术。

面向对象三特性在项目中的体现。封装:用户、订单、菜品都封装成实体类,外部只能通过 Service 方法访问;继承:基础实体 BaseEntity 包含 id、createTime、updateTime,所有实体继承它;多态:支付方式设计成 PayStrategy 接口,微信支付、余额支付各自实现,下单时根据支付类型动态调用。这样答完,面试官就相信你真的在项目里用过面向对象,而不是只会默写定义。

HashMap 与数据结构题。可以结合项目里“菜单热点数据缓存”来说:我说过不用 HashMap 做全局缓存,因为它是线程不安全的,并发读写会丢数据;单机可以用 ConcurrentHashMap,生产环境更适合 Caffeine。如果被追问 HashMap 底层原理,就讲数组+链表/红黑树、put 流程、扩容机制、为什么 HashMap 非线程安全。这个题几乎是 Java 面试必问,一定要准备。

String、StringBuilder、StringBuffer 的区别。结合项目:订单明细数量多时,批量拼接 SQL 或日志千万不能在循环里用String +=,因为 String 不可变,每次拼接都创建新对象,O(n²) 性能问题。用 StringBuilder 做局部字符串拼接,StringBuffer 因为方法加了 synchronized,不需要多线程拼接时没必要用它。这题简单,但答得接地气反而加分。

如何保证数据一致性。这个题我用项目里的下单流程完整回答:步骤是校验库存、生成订单、扣库存、清购物车,全部包在同一个事务里,任何一个步骤失败整体回滚;库存扣减用乐观锁条件更新防止超卖;订单号用唯一索引兜底。然后再补一句“如果以后拆微服务,订单和库存分库,就需要引入分布式事务方案,比如 Seata 或者本地消息表”,这句话证明你有架构视野,但当前项目规模单体事务足够。

排序算法题。菜品销量排序用到过 Collections.sort 配合 Comparator 对 List<DishSalesVO> 按销量排序,面试如果让你手写,至少能写出冒泡和快排。冒泡排序虽然不高效但容易讲清楚,快排的核心是选定 pivot 分区递归。我建议项目答辩前把这两个排序的代码过一遍,因为“java排序”是高频搜索词,很容易被问到。

Java 基础与学习路线的建议。如果面试官问“Java学习怎么规划”,你可以说:先搞清数据类型、集合、面向对象,然后学并发和 JVM,接着上手 SpringBoot,最后通过餐饮系统这个项目把知识串起来。这个回答既展示技术深度,也让对方看到你有明确的学习路径。

6.3 功能扩展思路

项目做完之后,学弟问我还能加什么。我给的扩展方向按投入产出比排序:第一,接入支付宝沙箱支付,体验真实支付回调流程,支付回调的幂等性又是一个亮点;第二,增加优惠券模块,涉及满减规则、有效期、库存,扩展业务广度;第三,基于历史订单做“猜你喜欢”,最简单的实现是用关联规则或者协同过滤的 ItemCF,讲起来很高端;第四,如果非要往架构方向谈,可以把订单服务和库存服务拆开,用 Redis 中间件解耦,但我不建议在毕设阶段真的做。

这些扩展点不一定要全部实现,哪怕只实现一个支付沙箱,项目完成度和面试谈资都会提升一大截。关键在于,每个扩展都能对应到一个明确的技术问题:支付回调怎么保证幂等?优惠券超发怎么防止?推荐算法怎么冷启动?带着问题去实现,比瞎加功能有用得多。


最后说点个人体会。这个项目从我接手帮学弟到最后跑通,前后大概四周,投入最大的是表结构设计和下单事务那一块。学弟后来拿着这个项目去面试暑期实习,面试官问到“怎么解决超卖”时,他把 optimistic lock 和事务回滚讲得清清楚楚,当场就被夸“项目思路很完整”。我觉得毕设项目的价值从来不在技术多新,而在于每个设计点你都能讲出“为什么”。你在文档里把表字段为什么用 DECIMAL、订单为什么要主表明细分离、状态为什么用常量类、扣库存为什么要带条件更新写清楚,答辩老师想不给高分都难。

再分享一个小技巧:写项目文档时,专门留一个“设计决策记录”章节,每做一个关键选择就把当时考虑的两三个备选方案和最终理由记下来。比如“购物车为什么用后端表而不是localStorage”,记录完你就会发现,自己的项目比那些只说“我实现了什么功能”的同学高出一个档次。如果时间紧张,优先把下单、支付、报表这条主链路跑得毫无破绽,再去加其他花活,这是我最想提醒各位的一句话。

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

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

立即咨询