☰
Java高校校园点餐系统实战:Spring Boot+MyBatis从架构到避坑
2026/10/7 12:52:58 网站建设 项目流程

简介:这份资源是面向高校计算机相关专业学生与Java初学者的一套校园点餐系统完整项目,可作为课程设计、毕业设计或Java Web练手参考。系统以Java为开发技术,采用B/S结构与MySQL数据库,围绕管理员、用户、食堂三类角色展开,涵盖个人中心、用户管理、食堂菜单管理、菜系分类管理、消息留言与留言板管理、订单管理、我的收藏、购物车及前台首页等功能模块,基本覆盖了点餐业务从浏览、下单到后台维护的完整流程。压缩包为zip格式,整体约55.69MB,文件类型明细上游暂未提供,但可预期包含源码、数据库脚本与项目说明等常见内容。目前已有246人学习下载,适合希望理解Java动态页面设计与后台数据库交互、需要一套可运行参考方案来梳理需求分析与系统设计思路的读者。

1. 从食堂窗口到宿舍楼:一套 Java 高校校园点餐系统到底在解决什么

中午十二点下课铃一响,三万人同时涌向两个食堂,窗口前排队的队伍能拐三个弯——这是几乎所有高校都逃不掉的场景。Java 高校校园点餐系统要干的事很具体:把"人到窗口才能点"变成"人在教室就能下单",让后厨按订单节奏出餐,让学生到店即取或由配送员送到宿舍楼下。它不是一个通用外卖平台,用户群体封闭、配送半径通常不超过两公里、营业时间跟着课表走,这些约束决定了它的技术选型和商业外卖系统完全不同。

这套系统适合三类人动手:一是计算机专业做课程设计或毕业设计的学生,需要一套能跑通、能讲清楚、能应付答辩的完整工程;二是高校信息化部门的开发者,想给本校食堂做一套轻量级订餐工具;三是想练手 Spring Boot 全栈的 Java 初学者,拿一个业务闭环完整的项目把 CRUD、事务、缓存、定时任务串一遍。下面按"先想清楚架构、再动手写核心模块、最后处理那些只有真跑起来才会暴露的坑"这条线展开,中间会给可直接抄的代码和参数配置。

2. 技术选型与库表设计:为什么这套系统不该照搬外卖平台架构

2.1 技术栈怎么定:Spring Boot + MyBatis 的最小可用组合

高校点餐系统的并发特征很特殊:峰值集中在四个时间点(早中晚三餐加夜宵),其余时间几乎没流量。这意味着不需要微服务、不需要分库分表,一套单体 Spring Boot 应用加一个 MySQL 实例足够撑住。我一般会这样定技术栈:

层次选型理由
后端框架Spring Boot 2.7.x生态成熟,starter 依赖开箱即用,答辩时评委也认
持久层MyBatis-Plus单表 CRUD 不用写 XML,复杂查询再手写 SQL,比纯 JPA 可控
数据库MySQL 8.0窗口函数、CTE 都能用,JSON 字段存菜品规格方便
缓存Redis存购物车、菜品库存、验证码,减轻数据库压力
前端Vue 3 + Element Plus学生端和管理端共用组件库,开发快
定时任务Spring Task订单超时取消、每日营业统计,轻量够用

有人会问为什么不直接上 Spring Cloud。血泪经验是:课程设计或小规模部署场景下,微服务带来的运维复杂度远超收益,一个 Nacos 注册中心挂掉就能让你排查一整天。单体应用拆好包结构(controller / service / mapper / entity / dto),后期真要拆也不难。

依赖引入用 Maven,核心几个 starter 如下:

<!-- pom.xml 关键依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>

逻辑说明:spring-boot-starter-web提供内嵌 Tomcat 和 MVC 能力;MyBatis-Plus 的 starter 会自动装配 SqlSessionFactory,省去手写配置类;Redis starter 默认用 Lettuce 连接池。参数上注意 MyBatis-Plus 版本要和 Spring Boot 版本匹配,2.7.x 配 3.5.x 是稳的组合,版本错配会出现NoSuchMethodError这类启动失败。

2.2 库表设计:六张核心表撑起整个业务闭环

高校点餐的数据模型比外卖简单,但有几个字段必须提前想清楚,否则后期改表很痛苦。核心表如下:

-- 用户表:学生和商家共用,用 role 区分 CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `open_id` VARCHAR(64) DEFAULT NULL COMMENT '微信openId,用于免登录', `student_no` VARCHAR(20) DEFAULT NULL COMMENT '学号,校内身份标识', `nickname` VARCHAR(32) NOT NULL, `phone` VARCHAR(11) NOT NULL, `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0学生 1商家 2配送员 3管理员', `dorm_building` VARCHAR(32) DEFAULT NULL COMMENT '宿舍楼栋,用于配送分组', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_student_no` (`student_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 菜品表:库存和上下架状态是关键 CREATE TABLE `dish` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `shop_id` BIGINT NOT NULL, `name` VARCHAR(64) NOT NULL, `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0 COMMENT '每日库存,凌晨重置', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '0下架 1上架', `category` VARCHAR(32) DEFAULT NULL, `image_url` VARCHAR(255) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表:状态机是核心 CREATE TABLE `orders` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT NOT NULL, `shop_id` BIGINT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2制作中 3待取餐 4已完成 5已取消', `pickup_code` VARCHAR(6) DEFAULT NULL COMMENT '取餐码', `remark` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `pay_time` DATETIME DEFAULT NULL, UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_status` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细表 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 COMMENT '冗余存名称,防止菜品改名', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时价格快照', `quantity` INT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 购物车表 CREATE TABLE `cart` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `dish_id` BIGINT NOT NULL, `quantity` INT NOT NULL DEFAULT 1, UNIQUE KEY `uk_user_dish` (`user_id`, `dish_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 配送表 CREATE TABLE `delivery` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `rider_id` BIGINT DEFAULT NULL, `dorm_building` VARCHAR(32) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待接单 1配送中 2已送达', UNIQUE KEY `uk_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

设计要点说明:order_item里冗余存dish_name和price是刻意为之,菜品改名或调价后历史订单不能跟着变,这是订单系统的铁律。orders表的status字段用 TINYINT 而不是字符串,查询效率高且状态流转清晰。idx_user_status联合索引服务于"查我的待支付订单"这类高频查询。cart表用uk_user_dish唯一键保证同一用户同一菜品只有一条记录,加购时用INSERT ... ON DUPLICATE KEY UPDATE处理。

注意:stock字段的扣减必须用UPDATE dish SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty}这种带条件的原子更新,不能先查再改,否则并发下必然超卖。

3. 下单主链路实现:从加购到支付成功的完整代码

3.1 购物车与库存预占:Redis 和 MySQL 怎么分工

购物车数据读写频繁但允许丢失,放 Redis 最合适;库存是钱相关,必须落 MySQL。我一般这样分工:加购时先写 Redis Hash(key 为cart:{userId},field 为 dishId,value 为数量),下单时再把 Redis 购物车转成订单,同时扣 MySQL 库存。

@Service public class CartService { @Autowired private StringRedisTemplate redisTemplate; private static final String CART_KEY = "cart:%d"; // 加购:Redis Hash 自增 public void addToCart(Long userId, Long dishId, int quantity) { String key = String.format(CART_KEY, userId); redisTemplate.opsForHash().increment(key, dishId.toString(), quantity); // 设置过期时间,避免僵尸购物车占内存 redisTemplate.expire(key, 7, TimeUnit.DAYS); } // 获取购物车全部条目 public Map<Object, Object> getCart(Long userId) { return redisTemplate.opsForHash().entries(String.format(CART_KEY, userId)); } }

逻辑说明:opsForHash().increment是原子操作,多个请求同时加购同一菜品不会丢数量。expire设 7 天,学生放假回来购物车清空是合理的。参数上CART_KEY用%d占位符拼 userId,避免 key 冲突。这里没做数量上限校验,实际要在加购前查菜品是否上架、库存是否充足,否则用户能加购一个已下架的菜。

3.2 创建订单:事务、订单号生成与库存扣减

下单是整个系统最容易翻车的地方,必须在一个事务里完成:校验库存、扣库存、写订单、写明细、清购物车。任何一步失败都要回滚。

@Service public class OrderService { @Autowired private DishMapper dishMapper; @Autowired private OrderMapper orderMapper; @Autowired private OrderItemMapper orderItemMapper; @Autowired private StringRedisTemplate redisTemplate; @Transactional(rollbackFor = Exception.class) public String createOrder(Long userId, Long shopId, List<OrderItemDTO> items) { // 1. 生成订单号:时间戳 + 用户ID后四位 + 随机数 String orderNo = System.currentTimeMillis() + String.format("%04d", userId % 10000) + ThreadLocalRandom.current().nextInt(1000, 9999); BigDecimal total = BigDecimal.ZERO; // 2. 逐个扣库存,扣失败直接抛异常回滚 for (OrderItemDTO item : items) { int affected = dishMapper.deductStock(item.getDishId(), item.getQuantity()); if (affected == 0) { throw new BizException("菜品[" + item.getDishName() + "]库存不足"); } total = total.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } // 3. 写订单主表 Orders order = new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setShopId(shopId); order.setTotalAmount(total); order.setStatus(0); // 待支付 orderMapper.insert(order); // 4. 写订单明细 for (OrderItemDTO item : items) { OrderItem oi = new OrderItem(); oi.setOrderId(order.getId()); oi.setDishId(item.getDishId()); oi.setDishName(item.getDishName()); oi.setPrice(item.getPrice()); oi.setQuantity(item.getQuantity()); orderItemMapper.insert(oi); } // 5. 清空 Redis 购物车 redisTemplate.delete("cart:" + userId); return orderNo; } }

对应的 Mapper 扣库存 SQL:

<update id="deductStock"> UPDATE dish SET stock = stock - #{quantity} WHERE id = #{dishId} AND stock >= #{quantity} AND status = 1 </update>

逻辑说明:deductStock的WHERE stock >= #{quantity}是防超卖的关键,数据库行锁保证并发下只有一个请求能扣成功。@Transactional(rollbackFor = Exception.class)必须显式指定rollbackFor,否则默认只回滚RuntimeException,受检异常不会触发回滚,这是新手最常踩的坑。订单号用时间戳加随机数,单机场景够用;如果多实例部署,要换成雪花算法或 Redis 自增,否则可能撞号。

参数上ThreadLocalRandom比Random在高并发下性能更好,避免多线程竞争同一个种子。total用BigDecimal而不是double,金额计算绝不能用浮点数。

3.3 支付回调与订单状态机

支付成功后第三方会回调你的接口,这里要做两件事:验签和幂等。幂等靠订单状态判断——已经是"已支付"的订单再次回调直接返回成功,不重复处理。

@PostMapping("/pay/callback") public String payCallback(@RequestBody PayCallbackDTO dto) { // 1. 验签(伪代码,按实际支付渠道实现) if (!payChannel.verifySign(dto)) { return "FAIL"; } // 2. 查订单 Orders order = orderMapper.selectByOrderNo(dto.getOrderNo()); if (order == null) return "FAIL"; // 3. 幂等:已支付直接返回 if (order.getStatus() >= 1) return "SUCCESS"; // 4. 更新状态,用乐观锁防止并发 int affected = orderMapper.updateStatus(order.getId(), 0, 1); if (affected == 0) return "SUCCESS"; // 被其他线程处理了 // 5. 生成取餐码,通知商家 String pickupCode = String.format("%04d", ThreadLocalRandom.current().nextInt(1, 9999)); orderMapper.updatePickupCode(order.getId(), pickupCode); return "SUCCESS"; }

状态更新的 SQL 用条件更新实现乐观锁:

<update id="updateStatus"> UPDATE orders SET status = #{newStatus}, pay_time = NOW() WHERE id = #{id} AND status = #{oldStatus} </update>

逻辑说明:WHERE status = #{oldStatus}保证只有状态没变时才更新,返回影响行数为 0 说明已被处理,直接返回成功即可。取餐码用四位随机数,同一时间段内可能重复,实际要加唯一约束或按商家维度生成。回调接口必须返回支付渠道约定的格式(这里是 "SUCCESS"),否则渠道会不断重试。

4. 避坑与排查:那些只有真跑起来才会暴露的问题

4.1 库存扣成负数:并发下的超卖

现象:压测时发现某个菜品库存显示 -3,明明下单前查过还有货。原因:用了"先 SELECT 查库存,再 UPDATE 扣减"的写法,两个请求同时查到库存为 1,都判断充足,然后都执行扣减。解决:改成UPDATE dish SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty},靠数据库行锁和 WHERE 条件保证原子性,判断影响行数是否为 0 来决定是否抛异常。

4.2 订单重复创建:用户手抖点了两次提交

现象:同一个用户同一批菜品生成了两笔订单。原因:前端按钮没做防抖,或者网络慢用户重复点击。解决:前端提交后立即禁用按钮;后端在创建订单前用 Redis 做分布式锁,key 为order:lock:{userId},SETNX加过期时间,拿到锁才处理,处理完释放。更彻底的做法是给订单表加"用户+时间窗口"的唯一约束,但实现复杂,一般用锁就够了。

4.3 定时任务把营业中的订单误取消

现象:用户刚下单还没支付,几分钟后被系统自动取消了,但用户其实正在付款。原因:超时取消任务的判断条件只看了create_time,没排除正在支付中的订单。解决:取消条件加上status = 0(仅待支付),并且给用户支付留足时间,一般设 15 分钟。任务执行频率别太高,每分钟扫一次即可,扫描时用LIMIT分批,避免一次性锁太多行。

@Scheduled(cron = "0 * * * * ?") public void cancelTimeoutOrders() { // 只取消 15 分钟前创建且仍待支付的订单 List<Orders> list = orderMapper.selectTimeoutOrders( LocalDateTime.now().minusMinutes(15), 0, 100); for (Orders o : list) { orderMapper.updateStatus(o.getId(), 0, 5); // 0待支付 -> 5已取消 // 回滚库存 orderItemMapper.selectByOrderId(o.getId()) .forEach(item -> dishMapper.restoreStock(item.getDishId(), item.getQuantity())); } }

4.4 Redis 和数据库库存不一致

现象:Redis 里显示有库存,下单却提示库存不足。原因:Redis 库存是缓存,扣减后没同步,或者同步失败。解决:库存以 MySQL 为准,Redis 只做展示层的缓存,且设置较短的过期时间(比如 30 秒),下单时直接查 MySQL。不要试图用 Redis 做库存扣减的唯一依据,除非你有一套可靠的消息队列保证最终一致,那对高校项目来说过度设计了。

4.5 中文乱码:从数据库到前端全链路

现象:菜品名"红烧肉"在数据库里正常,接口返回变成"???"。原因:数据库连接 URL 没指定字符集,或者表字符集不是 utf8mb4。解决:JDBC URL 加useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai,建表时统一用CHARSET=utf8mb4,Spring Boot 配置里加spring.http.encoding.charset=UTF-8。三个地方都对了才不会乱码。

5. 让系统扛住饭点洪峰:几个能立刻用上的调优技巧

饭点十分钟内的请求量可能占全天的 60%,这套系统能不能用,就看这十分钟稳不稳。第一个技巧是接口限流,用 Redis 做计数器,对下单接口按用户维度限流,比如每个用户 10 秒内最多提交 3 次:

public boolean tryAcquire(Long userId) { String key = "limit:order:" + userId; Long count = redisTemplate.opsForValue().increment(key); if (count == 1) { redisTemplate.expire(key, 10, TimeUnit.SECONDS); } return count <= 3; }

increment返回 1 说明是第一次,此时设置过期时间,后续请求只自增不重设,保证窗口滑动正确。这个逻辑简单但有效,能挡住脚本刷单和用户误操作。

第二个技巧是热点菜品缓存。销量前十的菜品信息(名称、价格、图片)放 Redis,key 为dish:hot:{shopId},过期时间 5 分钟。查询时先查缓存,未命中再查库并回填。注意库存字段不要缓存,或者缓存了也要在扣减后主动删除,否则用户看到有货下单却失败,体验极差。

第三个技巧是订单查询走覆盖索引。用户查"我的订单"是最频繁的操作,orders表的idx_user_status联合索引要保证查询能覆盖user_id和status,避免回表。如果列表还要显示菜品名,考虑在订单主表冗余一个dish_summary字段(如"红烧肉等3件"),省去关联查询。

最后一个习惯:上线前一定用 JMeter 压一遍下单接口。我一般设 200 并发跑 1 分钟,观察三个指标——库存有没有扣成负数、订单号有没有重复、响应时间 P99 是否超过 2 秒。这三个指标过了,饭点基本就稳了。压测时记得把日志级别调到 WARN,否则光打日志就能把磁盘写满。

这套系统我前后改过三版,第一版没做库存原子扣减,上线第一天就超卖了四十多份,后厨做不出来只能挨个打电话道歉。从那以后我养成了一个习惯:凡是涉及"钱"和"库存"的写操作,先问自己一句——并发下会不会出错。希望这些经验能帮你少走点弯路,把系统稳稳当当地跑起来。

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

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

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

立即咨询