简介:这是一份面向计算机专业毕设与课程作业的智能点餐系统完整源码包,定位为学习型项目参考,涵盖了用户点餐、菜单管理、订单处理、支付集成、库存管理、数据分析及后台管理等典型模块,适合需要快速理解真实业务系统结构、提升前后端开发能力的开发者。压缩包共259个文件,主要包括102个Java源码、81个XML配置与布局文件、60张PNG界面原型图,以及Gradle构建脚本、依赖库等资源,整体体积仅1.73MB,目录清晰、便于按模块检索。当前已有109人学习或下载这一资料。通过研读代码可掌握Android客户端与服务器端的数据交互方式、数据库表设计、接口调用流程等关键实践,还能借鉴工程分层、异常处理与资源整合的常规做法,是一份能直接启发毕业设计与课设实现的参考资料。
1. 智能点餐系统是什么:先想清楚再动手,别一上来就写代码
看到「智能点餐系统」这个标题,大部分人的第一反应是「点餐加个推荐算法」,但真正让这类毕设和课程作业翻车的,通常不是算法,而是订单状态对不上、金额算错、并发出问题这些基础环节。很多 A 同学在开题时把「智能」两个字想得太大,最后交付时却被导师问一句「你的系统哪里有智能」,答不上来。其实这个标题的合理预期是:一个能跑通点餐、下单、支付回调、订单流转、菜品推荐的完整 Web 系统,「智能」体现在推荐、库存提醒和数据分析这三个可解释、可演示的模块上,而不是蹭 AI 热点去搞什么深度学习识别菜品。
这篇内容适合两类人:一类是要交毕设或课程作业、需要在一到两个月内拿出可演示系统的同学;另一类是已经在写 CRUD 但想了解订单状态机、金额精度和推荐接口怎么落地的开发者。本文会按「先拆模块、再定技术、后写代码、最后排坑」的顺序,用可复现的方式讲清楚这套系统从零到一的过程。你不需要有高深算法基础,但需要能把 Spring Boot 项目跑起来、会基本的 SQL 和 Vue 语法,剩下的我会给出完整路径。
2. 技术选型与模块拆分:用 Spring Boot + Vue 把「智能」落成可交付的课设
2.1 为什么选 Spring Boot + Vue,而不是 SSM 或纯 JSP
智能点餐系统作为毕设和课程作业最常见的提交形式,选型的第一标准不是「技术新」,而是「能在演示环境稳定跑起来、代码量可控、老师能看懂」。Spring Boot 比 SSM 省掉了大量 XML 配置,内嵌 Tomcat,打成一个 jar 就能跑,对不熟悉服务器部署的同学最友好。前端用 Vue + Element UI 是一套非常成熟的中后台方案,表格、表单、弹窗组件齐全,做管理端和点餐端都顺手。如果你只写过 JSP,也不是不能用,但演示时页面样式和交互感会明显吃亏,给老师的印象分就不一样。
后端结构建议按模块分包,而不是按三层架构平铺。具体来说:controller 只做参数接收和结果封装,service 写业务规则,mapper 用 MyBatis-Plus 做单表 CRUD,复杂查询写 XML。推荐模块独立成一个 service,不要和订单业务耦合。这样的好处是,你回答老师提问「系统怎么设计的」时,能清晰说出每个模块的职责,而不是含混地说「就是 MVC」。
项目骨架可以按下面的模块来拆:
- 桌台管理:桌号、状态(空闲/占用)、容纳人数
- 菜品管理:分类、名称、价格、图片、库存、销量
- 订单模块:购物车、下单、支付回调、订单状态流转
- 推荐模块:基于菜品关联热度或用户历史行为的 TopN 推荐
- 数据看板:菜品销量排行、营业额趋势、库存预警
这里有个取舍要注意:如果课设周期只有三四周,砍掉数据看板,保留前四个模块就够了。推荐模块必须要做,因为「智能点餐」的「智能」主要靠它撑起来。
2.2 数据库设计:五张核心表,重点在订单表和订单明细表
数据库设计是这套系统的地基。很多同学喜欢把订单菜品直接存成一个字符串字段,比如dish_names="鱼香肉丝x2,米饭x1",这样做演示时没问题,但老师一问「怎么统计菜品销量排行」,就答不上来了。正确的做法是把订单拆成两张表:订单主表存总额、状态、桌号、下单时间;订单明细表存每个菜品 ID、单价、数量、小计金额。这两张表通过 order_id 关联,统计销量时直接 group by dish_id 聚合明细表。
核心表结构见下面 SQL,字段命名统一用下划线风格,注释写清楚。这里给到可直接执行的建表语句:
CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号,格式:yyyyMMddHHmmss+4位随机', `table_id` bigint NOT NULL COMMENT '桌台ID', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额,单位元', `status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态:0已下单 1已支付 2制作中 3配送中 4已完成 5已取消', `remark` varchar(255) DEFAULT NULL COMMENT '备注', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL COMMENT '支付时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_table_id` (`table_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; CREATE TABLE `order_items` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_id` bigint NOT NULL COMMENT '订单主表ID', `dish_id` bigint NOT NULL COMMENT '菜品ID', `dish_name` varchar(64) NOT NULL COMMENT '冗余菜品名称,防止菜品表被删后无法追溯', `price` decimal(10,2) NOT NULL COMMENT '下单时单价,快照', `quantity` int NOT NULL COMMENT '数量', `subtotal` decimal(10,2) NOT NULL COMMENT '小计金额', PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';建表时有三个设计细节需要特别说明。第一,order_items 表里冗余了一份 dish_name 和 price 快照,这在毕设答辩时是加分项,因为你能回答「菜品改价后历史订单怎么追溯」这个问题。第二,订单状态用 tinyint 存数字而不是字符串,配合状态机代码一起解释,逻辑更严谨。第三,订单编号用唯一索引而不是主键自增,这是为了让订单号看起来像真实业务编号,后面排障时也方便关联日志。
菜品表的设计要比你想的更细一点。除了 id、name、price、category_id、image 之外,建议加上stock库存字段和sold_count销量字段。这两个字段会在库存校验和推荐模块里用上。库存字段初始值从 1 到 999 都可以,演示时不用太纠结数据,但字段一定要有,不然老师问「库存怎么扣减的」会答不上来。
2.3 模块边界:把「智能」拆成三个可演示的子功能
「智能」这个词最容易做成黑匣子。我的建议是把它拆成三个可解释、可演示的能力:推荐、库存预警、销量分析。推荐的落地做法是给用户展示「猜你喜欢」或「本店热卖」列表;库存预警是在菜品库存低于阈值时,后台管理系统展示红色提示;销量分析是一个简单的排行榜接口,按日、周、月聚合订单明细,返回前 N 名菜品。
这三个能力的技术难度梯度很清晰。销量分析最简单,一条 group by SQL 就能搞定。库存预警需要一个定时任务或在下单后触发检查。推荐则可以做两个版本:协同过滤算法的 MVP 版本,或者简化版的「基于菜品共现频率」推荐。这里特别提醒:毕业论文里可以写协同过滤、矩阵分解这些词,但代码实现尽量用简化版本。因为答辩老师大概率会问「这个算法的原理是什么」,如果你实现了复杂的 SVD 但解释不清楚,反而减分。简化版「用户点了 A 菜品,同时常点 B 菜品,则给新用户推荐 B」的逻辑,既好解释又有效果。
接口设计上,后端要提供下面这些 REST 接口:
| 模块 | 接口路径 | 方法 | 说明 |
|---|---|---|---|
| 点餐 | /api/order/create | POST | 创建订单 |
| 点餐 | /api/order/pay/callback | POST | 模拟支付回调 |
| 推荐 | /api/recommend/topn | GET | 返回推荐菜品列表 |
| 菜品 | /api/dish/list | GET | 分页查询菜品 |
| 数据 | /api/stats/rank | GET | 菜品销量排行 |
接口设计有个关键原则:推荐接口的入参只有user_id和limit两个参数,不要暴露算法细节,内部实现无论怎么改,前端都不受影响。这样你在后期调优推荐策略时,不需要改动控制层。
3. 两条核心代码链路:点餐下单闭环与「智能」推荐接口的实现
3.1 点餐下单:从购物车到订单状态机的完整实现
订单创建是整个系统最核心的代码链路,也是面试和答辩时最容易展开讲的部分。我先给出一个可用的 Service 层代码逻辑,重点在于事务控制、状态流转和库存扣减。
下单流程分四步:校验桌台和菜品 → 计算金额 → 扣减库存 → 生成订单及明细。下面这段代码是下单服务的关键实现,使用 MyBatis-Plus 的 LambdaQueryWrapper 做条件查询:
@Override @Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateDTO dto) { // 1. 校验桌台状态 TableInfo table = tableMapper.selectById(dto.getTableId()); if (table == null || table.getStatus() != 0) { throw new BizException("桌台不存在或已占用"); } // 2. 组装明细并试算金额 List<OrderItem> items = new ArrayList<>(); BigDecimal total = BigDecimal.ZERO; for (OrderItemDTO item : dto.getItems()) { Dish dish = dishMapper.selectById(item.getDishId()); if (dish == null || dish.getStatus() != 1) { throw new BizException("菜品不存在或已下架"); } // 库存扣减前的可用校验 if (dish.getStock() < item.getQuantity()) { throw new BizException("菜品[" + dish.getName() + "]库存不足"); } BigDecimal subtotal = dish.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity())); total = total.add(subtotal); OrderItem orderItem = new OrderItem(); orderItem.setDishId(dish.getId()); orderItem.setDishName(dish.getName()); orderItem.setPrice(dish.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItem.setSubtotal(subtotal); items.add(orderItem); } // 3. 生成订单主记录,初始状态为「已下单」 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setTableId(dto.getTableId()); order.setTotalAmount(total); order.setStatus(0); orderMapper.insert(order); // 4. 批量插入明细,并扣减库存 for (OrderItem item : items) { item.setOrderId(order.getId()); } orderItemMapper.insertBatch(items); dishMapper.deductStock(dto.getItems()); // 5. 将桌台置为占用状态 tableMapper.updateStatus(dto.getTableId(), 1); return new OrderVO(order.getId(), order.getOrderNo(), total); }这段代码有几个关键参数和逻辑解释。@Transactional(rollbackFor = Exception.class)表示任何运行时异常都会回滚整个事务,防止出现「订单创建成功但库存没扣」的不一致状态。金额计算用的是 BigDecimal 而不是 double,这是金额精度问题的根本解法,后面避坑章节会展开讲。步骤 4 的库存扣减单独抽了个deductStock方法,内部用的是UPDATE dish SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity},这是典型的乐观锁防超卖写法,代码里加了受影响行数校验,返回 0 就抛出库存不足异常。
订单编号生成我一般会做一个工具方法,格式是yyyyMMddHHmmss加四位随机数,保证并发下不重复。用DateTimeFormatter格式化当前时间,再拼上ThreadLocalRandom生成的 1000 到 9999 的随机数即可。不要用 UUID,订单号太长,演示和日志检索都不方便。
3.2 模拟支付回调与库存「回滚」逻辑
毕设系统通常不会真接支付宝和微信支付,因为需要商户号资质。常见做法是做一个模拟收银台页面:前端点「确认支付」后,后端直接调/api/order/pay/callback接口,把订单状态从未支付改为已支付。这个模拟接口虽然简单,但状态机的校验逻辑不能省。下面给出支付回调的实现:
@Override @Transactional(rollbackFor = Exception.class) public void payCallback(PayCallbackDTO dto) { // 1. 根据订单号查询 Order order = orderMapper.selectByOrderNo(dto.getOrderNo()); if (order == null) { throw new BizException("订单不存在"); } // 2. 重复支付判重:只有状态为「已下单」才能流转到「已支付」 if (order.getStatus() != 0) { throw new BizException("订单状态异常,当前状态:" + order.getStatus()); } // 3. 更新状态并记录支付时间 order.setStatus(1); order.setPayTime(LocalDateTime.now()); orderMapper.updateById(order); // 4. 桌台仍保持占用状态,不需要释放 }这里的核心是状态机校验:订单只能从已下单流转到已支付。如果前端重复点击支付按钮或回调接口被重复调用,第二次请求会因为状态不是 0 而抛出异常。异常信息里带上当前状态值,方便排障时定位是「哪一步状态不对」,而不是笼统的「支付失败」。
订单取消和支付超时是另一组需要处理的状态流转。常见做法是:用户未支付时,订单允许取消,取消后恢复库存并把桌台释放为空闲。我把这个逻辑放在订单模块里单独一个方法cancelOrder,内部做两件事:把订单状态改成「已取消」;把订单明细对应的菜品库存加回去、桌台状态改回空闲。这个「回滚」逻辑在答辩时经常被问到,务必实现完整。
烹饪和配送这两个状态在课设里可以简化处理:提供一个「订单状态推进」接口,由管理员或商家端手动点击「开始制作」「完成配送」按钮来推进状态。如果不想做过多的按钮,可以直接在做数据填充时把订单前置成「已完成」状态,演示排行榜时用。核心演示流程保持在「下单 → 支付 → 状态流转 → 数据可见」这条链路上就够了。
3.3 推荐模块:基于菜品共现频率的 TopN 推荐(算法原理 + 代码)
推荐模块是「智能」两字的直接体现,也是你这套系统区别于普通 CRUD 的关键。我强烈推荐实现「基于菜品共现」的简化版协同过滤,原因有三:不需要用户画像数据;冷启动问题不明显;代码量只有几十行,答辩时能讲清楚。
算法的核心思路是:统计「有多少订单同时包含菜品 A 和菜品 B」,订单里出现的次数越多,说明这两个菜品的关联度越高。当用户点了菜品 A 时,按关联度从高到低推荐其他菜品。下面给出核心统计 SQL 和 Java 实现:
// 第一步:查出所有订单包含的菜品ID集合 List<Map<String, Object>> orderDishes = orderItemMapper.selectOrderDishMapping(); // 每行数据:order_id, dish_id // 第二步:构建共现矩阵,使用 HashMap 嵌套计数 Map<Long, Map<Long, Integer>> coMatrix = new HashMap<>(); for (Map<String, Object> row : orderDishes) { Long orderId = (Long) row.get("order_id"); Long dishId = (Long) row.get("dish_id"); coMatrix.computeIfAbsent(dishId, k -> new HashMap<>()); // 这里略去了同订单内两两累加的遍历细节 // 完整实现是:先收集同一订单的 dishId 列表,再做双重循环累加 } // 第三步:用户点过的菜品作为输入,取关联度 TopN List<Long> userDishIds = userHistoryMapper.selectRecentDishIds(userId); Map<Long, Integer> scoreMap = new HashMap<>(); for (Long dishId : userDishIds) { Map<Long, Integer> related = coMatrix.getOrDefault(dishId, Collections.emptyMap()); for (Map.Entry<Long, Integer> entry : related.entrySet()) { scoreMap.merge(entry.getKey(), entry.getValue(), Integer::sum); } } // 排除用户已经点过的菜品,按分数排序取前 N 个这段代码的完整版里,第二步的「同订单内两两累加」需要额外实现。具体写法是:按订单 ID 分组,拿到每个订单里的所有菜品 ID 列表,然后对这个列表做双重循环,coMatrix[dish[i]][dish[j]]加一,且跳过i == j的情况。这个双重循环的量级在演示数据下非常小,哪怕你灌了 5000 条订单数据,也就是一两秒的事,不需要额外优化。
推荐接口的关键参数是limit,默认取 5 个。如果用户没有历史订单,可以用「销量最高的前 N 个菜品」作为兜底推荐,这就解释了为什么菜品表要有 sold_count 字段。推荐接口的内聚性很好,后面想换成基于用户相似度的算法,只需要替换 service 内部实现,Controller 层完全不用动。这就是把「智能」模块独立拆出来的价值。
4. 三个必调参数与一组关键配置:金额精度、Tomcat 并发与前后端联调
4.1 金额计算必须用 BigDecimal:三个典型场景演示
金额精度问题是智能点餐系统里最容易出 bug 又最不容易被发现的坑。首选建议是后端所有金额相关的 Java 变量类型都用BigDecimal,数据库字段用decimal(10,2)而不是float或double。为什么 double 不行?因为0.1 + 0.2在二进制浮点数下等于0.30000000000000004,如果订单里有两个菜品分别是 9.9 元和 0.2 元,总额会显示成 10.100000000000001 元,非常影响演示效果。
我一般会在工程里放一个MoneyUtil工具类,封装金额计算的标准写法:
public class MoneyUtil { /** 统一使用这个 scale 做金额精度控制 */ public static final int SCALE = 2; /** 加法 */ public static BigDecimal add(BigDecimal a, BigDecimal b) { return a.setScale(SCALE, RoundingMode.HALF_UP) .add(b.setScale(SCALE, RoundingMode.HALF_UP)); } /** 金额格式化:去掉末尾多余的0,但保留两位小数 */ public static String format(BigDecimal amount) { return amount.setScale(SCALE, RoundingMode.HALF_UP).toString(); } }在这个工具类的使用中,RoundingMode.HALF_UP是四舍五入模式。这里有个实际业务决策要说明:单价乘数量的时候,可能存在 2 位以上小数吗?菜品的单价一般精确到分,数量是整数,乘积不会超过 2 位小数。但如果你做了折扣、满减、优惠券这些营销功能,金额精度问题就会明显放大。我的建议是:毕设里如果要加优惠模块,统一用「满 100 减 10 元」这种整型优惠,不要搞 88 折这种会产生无限循环小数的规则,省去一堆验证的麻烦。
前端展示金额时,Vue 里不要直接用{{ item.price * item.quantity }}这种 JS 浮点乘法,会出现19.899999这样的渲染结果。正确做法是后端在返回 VO 时就把subtotal和totalAmount算好,前端只负责展示字符串。如果你一定要在前端计算,可以用Math.round(val * 100) / 100先做一次处理,但这是临时方案,不推荐。
4.2 下单接口的超时与并发参数:T-SQL 之外的两个配置点
毕设系统不需要高并发,但答辦演示时如果老师同时打开多个页面操作,就会出现连接超时或 500 错误,非常影响观感。你需要先确认三件事:本地 MySQL 的连接池上限、Tomcat 的最大线程数、以及 MyBatis 的连接超时设置。默认配置下,HikariCP 连接池的 maximum-pool-size 是 10,Tomcat 默认线程数是 200,看起来够用,但实际会因为慢查询把连接占满。
我建议在application.yml里显式配置这些参数,而不是用默认值:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 mvc: async: request-timeout: 60000 server: tomcat: threads: max: 100 min-spare: 20 accept-count: 200参数说明:maximum-pool-size设为 20,是因为本地 MySQL 默认最大连接数是 151,留出余量;connection-timeout设 30 秒,避免秒杀式的请求在连接等待时报错。Tomcat 线程数降到 100,对课设系统来说不会有性能影响,但排队策略更清晰。accept-count设 200 表示队列里最多能排 200 个请求,超出后请求会被拒绝并快速返回错误,而不是无限堆积导致浏览器转圈。
有一个和连接池强相关的坑:如果你在代码里使用了Thread.sleep(5000)模拟耗时操作,HikariCP 的连接会被占用 5 秒,20 个连接 5 秒内满,后续请求全部等待。我见过不少同学的系统在演示时卡死,就是因为某个接口里加了无意义的 sleep 来「模拟网络延迟」。把这类无意义 sleep 全部删掉,是比较好的习惯。
4.3 前后端联调三个关键配置:跨域、时间格式、接口前缀
前后端分离的项目,联调阶段最容易在三个细节上卡住。第一是跨域。Vue 开发服务器默认跑在 8080 端口,Spring Boot 跑在 8081 端口,前端请求后端必然跨域,需要在后端写一个 CORS 配置类。第二是时间格式。Java 后端的LocalDateTime默认序列化成"2025-01-15T10:30:00",Vue 的 Element UI 的 date-picker 不认这个格式,展示会出错。第三是接口前缀。前后端要统一约定请求路径前缀/api,避免将来部署时 Nginx 转发规则混乱。
跨域配置可以直接用 Spring Boot 的 WebMvcConfigurer 来实现:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }时间格式统一用@JsonFormat注解:
public class OrderVO { @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime; }这里的timezone = "GMT+8"很重要,很多同学不加这个参数,结果数据库存的是北京时间,后端返回的也是北京时间,但前端显示却少了 8 小时。原因是 Java 默认序列化用的是 JVM 时区,不是东八区。显式指定GMT+8可以彻底规避这个和本地环境相关的时区问题。接口前缀的约定建议写成开发规范文档的一部分,同一份文档里把字段命名风格(驼峰)和响应包装结构({ code, message, data })也一并固定下来。
5. 避坑清单:智能点餐系统最常见的 5 个翻车点(含复现场景与修复)
5.1 翻车点一:下单成功后菜品库存变成负数
现象:在管理端看到某个菜品的库存字段是 -5。
原因:下单扣减库存的 SQL 写成了没有条件的裸更新,例如UPDATE dish SET stock = stock - #{quantity},在高并发或重复点击的场景下,库存会被扣到负数而不报错。我见过某位同学的代码,前端点了两次「提交订单」,后端没有做幂等处理,两次请求都扣了库存。
解决:把库存扣减 SQL 改成带条件的乐观锁写法,并且用受影响行数做校验。MyBatis-Plus 里可以这么写:
@Update("UPDATE dish SET stock = stock - #{quantity}, sold_count = sold_count + #{quantity} " + "WHERE id = #{dishId} AND stock >= #{quantity}") int deductStock(@Param("dishId") Long dishId, @Param("quantity") Integer quantity);这段 SQL 的执行逻辑是:stock 大于等于本次要扣的数量才会执行,否则会影响 0 行。调用方要判断deductStock的返回值,如果返回 0 就抛出库存不足异常并让整个事务回滚。这是最基础且有效的防超卖写法,答辦时解释起来很清晰。
5.2 翻车点二:金额显示成 9.999999 或 13.999999999
现象:订单总额显示为10.099999999,或者详情页的小计金额和总数对不上。
原因:代码里用了 double 或者 float 做金额累加。9.9 + 0.2在二进制浮点运算下的结果不精确,这是语言层面的限制,和 MySQL 无关。
解决:全局搜索项目中所有double和float类型变量,凡是和金额相关的全部改为BigDecimal。数据库字段类型确认是decimal(10,2)。如果你用了 Lombok,实体类里金额字段的 getter 返回值类型是BigDecimal,前端拿到的 JSON 也会变成字符串"10.10"而不是数字,这是正常的。我见过有同学因为前端显示成字符串觉得不对,又去转成数字,结果精度问题回来,记住 JSON 序列化 BigDecimal 成字符串是安全的行为,不要人为转回数字。
5.3 翻车点三:拿不到 userId 导致推荐接口报错
现象:前端调用推荐接口时,后端报user_id不能为空的参数异常。
原因:很多同学在设计点餐系统时根本没有登录体系,前端直接下单,推荐接口不知道给谁推荐。或者用户登录后在 localStorage 里存了 userId,但刷新页面后 token 过期,前端没做重新登录跳转,带着值为 null 的 userId 去请求接口。
解决:在推荐接口里做两级兜底。一级是用户 ID 不存在时,不报错而是返回销量最高的菜品列表;二级是用户没有任何历史下单记录时,也返回销量榜。这样接口永远不会因为参数问题而崩溃,演示时即使登录态丢失,页面也有数据展示。代码上这样写:
if (userId == null) { return dishMapper.selectTopSold(limit); }这个「降级返回」的思路在答辩时反而会成为亮点,因为你可以说「系统对冷启动用户有专门的处理策略」。
5.4 翻车点四:订单状态流转错乱,已取消的订单还能去支付
现象:订单已经取消,但用户在前端仍能发起支付请求,后端也成功把状态改成了「已支付」。
原因:状态机校验只写了「状态等于某值才允许流转」,但没有写「状态不等某值则拒绝」。比如支付回调里只判断了状态是否为 0,如果订单已取消、状态是 5,那么状态既不是 0,代码又没有 else 分支抛异常,请求就静默通过了。
解决:所有状态流转操作,统一走一个状态机校验工具类。检查点有两个:当前状态必须是「允许出发的状态」;目标状态必须是「当前状态的下一个合法状态」。一个最小实现:
private static final Map<Integer, List<Integer>> STATE_MACHINE = new HashMap<>(); static { // 已下单 -> 已支付 / 已取消 STATE_MACHINE.put(0, Arrays.asList(1, 5)); // 已支付 -> 制作中 STATE_MACHINE.put(1, Collections.singletonList(2)); // 制作中 -> 配送中 STATE_MACHINE.put(2, Collections.singletonList(3)); // 配送中 -> 已完成 STATE_MACHINE.put(3, Collections.singletonList(4)); } public static void checkTransition(Integer from, Integer to) { List<Integer> allowed = STATE_MACHINE.get(from); if (allowed == null || !allowed.contains(to)) { throw new BizException("非法订单状态流转:" + from + " -> " + to); } }这段代码把状态流转规则集中放在一个地方,避免在 Service 里散落 if-else。这也是代码评审和答辩时能被快速理解的好设计。随手补充一个经验:状态机数字含义要固定,不要在中途调整数字和状态的对应关系,否则历史数据会全部错乱。
5.5 翻车点五:MySQL 时报错「Data too long for column 'remark'」
现象:用户在下单备注里输入了很长一段文字,提交后后端报 500,日志显示Data too long for column 'remark'。
原因:数据库remark字段设置的是varchar(50)或varchar(100),前端输入框没有做 maxlength 限制。这类错误在演示时非常尴尬,因为问题不是代码逻辑,而是字段长度校验缺失。
解决:双重校验。前端在输入框上设置maxlength="200",后端在 DTO 上加上@Size(max = 200)注解。数据库字段统一升级到varchar(255),防止意外超长。另一个容易踩同样的坑的字段是「订单备注」和「收货地址」,这两个自由度比较高的数据,字段长度不要抠得太紧,255 是可以接受的长度。后端校验注解加上后,即使绕过前端直接调接口,也会返回参数校验错误,而不是数据库异常。
6. 进阶验证:用一段测试脚本把整个系统钉在可用状态
当你把系统开发完,接下来要做的是「把它钉在可用状态」——也就是用自动化脚本验证核心链路,而不是在答辦前手工点一遍。手工点菜容易漏掉并发场景和状态边界,我用 python 脚本模拟真实用户操作已经成了交付前的固定动作。
下面这段脚本用 requests 库模拟完整流程:下单两个菜品 → 模拟支付 → 确认状态推进 → 查询销量榜看数据是否更新。注意脚本里的接口地址请改成你自己的后端地址。
import requests import random import time import json BASE_URL = "http://localhost:8081/api" def test_full_order_flow(): # 1. 选两个菜品并下单 dish_list = requests.get(f"{BASE_URL}/dish/list", params={"current": 1, "size": 10}).json() dishes = dish_list["data"]["records"][:2] order_items = [ {"dishId": dishes[0]["id"], "quantity": 1}, {"dishId": dishes[1]["id"], "quantity": 2} # 第二个菜点2份,测数量计算 ] create_resp = requests.post( f"{BASE_URL}/order/create", json={"tableId": 1, "items": order_items}, headers={"Content-Type": "application/json"} ) assert create_resp.status_code == 200, f"创建订单失败: {create_resp.text}" order_no = create_resp.json()["data"]["orderNo"] # 2. 模拟支付回调 pay_resp = requests.post( f"{BASE_URL}/order/pay/callback", json={"orderNo": order_no} ) assert pay_resp.json()["code"] == 200, f"支付回调失败: {pay_resp.text}" # 3. 查询订单状态,确认已支付 time.sleep(0.5) # 等待数据库写入完成 order_resp = requests.get(f"{BASE_URL}/order/detail", params={"orderNo": order_no}) data = order_resp.json()["data"] assert data["status"] == 1, f"订单状态应为已支付(1),实际是 {data['status']}" # 4. 校验金额:两份0.2元的菜 + 一份9.9元的菜 expected_total = round(dishes[0]["price"] * 1 + dishes[1]["price"] * 2, 2) assert float(data["totalAmount"]) == expected_total, f"金额不一致: {data['totalAmount']} vs {expected_total}" # 5. 触发一次推荐接口 rec_resp = requests.get(f"{BASE_URL}/recommend/topn", params={"userId": 1, "limit": 5}) assert rec_resp.status_code == 200, "推荐接口调用失败" assert len(rec_resp.json()["data"]) > 0, "推荐接口未返回数据" print("核心链路验证通过,金额、状态、推荐全部正常") if __name__ == "__main__": test_full_order_flow()这段脚本里的断言很关键。第一步断言创建订单返回 200,第二步断言支付回调返回 code 200,第三步断言状态恰好是 1,第四步断言金额精确等于「1 份菜 A + 2 份菜 B」的和。如果中间任何一步失败,脚本会打印出响应的 text,你可以直接定位是哪一环的问题。脚本里的 expected_total 用round(..., 2)做了一次修正,是因为 Python 的浮点乘法和 JAVA 后端返回的 BigDecimal 字符串转 float 后可能有一丝的偏差。这是脚本侧的处理,不影响后端精度。
除了这条核心链路,我建议再补两个边界脚本:一个是不存在的桌台下单,期望返回业务异常;另一个是重复支付同一个订单,期望第二次支付失败。把这两个用例也写成断言后,答辦前跑一遍,三类错误都会被系统拦下。
这个测试脚本放在src/test目录旁边即可,不需要引入 JUnit,直接用 Python 跑。答辦的时候如果老师问「你怎么保证系统稳定」,你可以说出「用脚本模拟了核心链路,包括异常场景」,这比口头说「我测过了」要有说服力得多。
如果条件允许,再做一个简单的数据初始化脚本,插入 20 个菜品、5 个桌台和 50 条已完成的订单历史数据。因为推荐算法是建立在共现矩阵上的,没有历史订单数据,推荐模块在冷启动时只能返回销量榜,等于「智能」能力没有被展示出来。预先灌数据不仅能让你演示时直接看到推荐效果,还能顺便验证销量分析模块的 SQL 是否正确。
项目的收尾阶段,我只建议做一件事:把项目重新 clone 到一个新目录,按 README 从头跑一遍。从建库、导 SQL、启动后端、启动前端到脚本验证,一条线执行完。这个过程往往会暴露你早就忘了的环境依赖问题,比如 MySQL 版本、JDK 版本、Node 版本,早发现早解决。
我自己的经验是,这类系统的完成度不在于代码多华丽,而在于「演示时不出错」和「被问倒时有话讲」。把状态机校验、金额精度、库存防超卖、推荐兜底这四个点做扎实,哪怕推荐算法只是基于共现频率的简化版本,已经足够在一群 CRUD 系统里展现出「智能点餐」该有的样子。希望这些流程和踩坑记录能帮你用更少的返工把项目跑通,答辦顺利。
本文还有配套的精品资源,点击获取