
简介这是一套面向校园食堂场景的预约就餐小程序完整源码采用Java语言与SSMSpring、SpringMVC、MyBatis框架开发适合计算机专业学生做毕业设计参考也适合想入门微信小程序与Java Web的开发者练手。项目围绕用户登录、菜品浏览、在线预约、订单管理等核心功能展开通过在线预约缓解食堂高峰期排队问题涵盖前后端分离、数据持久化、时间冲突检测与座位选择等典型业务逻辑。压缩包共860个文件约28.65MB包含114个Java源文件、147个Vue组件、40个wxss与39个wxml小程序页面文件以及png、svg、jpg等图片素材和sql脚本、json配置、bat启动脚本前端资源与后端代码分层清晰。目前已有151人学习下载。源码标明可直接运行读者可据此理解完整项目的目录组织与分层设计并在此基础上进行二次开发或功能扩展。1. 食堂预约就餐小程序从「排队半小时」到「到点即取」的完整落地思路每到中午十一点半某高校食堂窗口前的队伍能拐两个弯学生排二十分钟队、吃十分钟饭、再花十分钟找座一顿饭耗掉四十分钟是常态。食堂预约就餐小程序要解决的就是这个场景学生提前在小程序里选好菜品、下单支付、拿到取餐号到点直接去窗口报号取走把排队时间压缩到两三分钟。这套方案的核心不是「点餐」本身而是预约时段 库存扣减 取餐核销三件事的闭环。标题里的 SSM 是 Spring SpringMVC MyBatis 这套经典 Java Web 组合配合微信小程序做前端数据库用 MySQL整体属于典型的轻量级校园信息化项目。它适合谁适合想练手一个完整前后端分离项目的高年级本科生、刚入行的 Java 开发者以及需要给学校做一套低成本就餐预约系统的技术团队。下面我按「先跑通、再调优、最后避坑」的顺序把整套东西拆开讲。2. 技术选型与数据库设计为什么是 SSM 而不是 SpringBoot2.1 SSM 与 SpringBoot 在小程序后端场景下的取舍很多人第一反应是「都什么年代了还用 SSM直接 SpringBoot 不香吗」。这话对但要看场景。SSM 和 SpringBoot 的本质区别在于SpringBoot 帮你做了自动配置和起步依赖SSM 需要你手动配applicationContext.xml、spring-mvc.xml、mybatis-config.xml这一堆 XML。对于食堂预约这种业务逻辑不复杂、但需要理解 IoC 和 AOP 底层机制的项目SSM 反而能让你把「一个请求从 Controller 到 Service 到 Mapper 到底经过了什么」看得清清楚楚。我一般会这样判断如果项目要快速上线、团队已经熟悉 SpringBoot那就用 SpringBoot如果是教学场景、或者你需要对每个 Bean 的生命周期有完全掌控SSM 更合适。食堂预约小程序的功能模块无非是用户管理、菜品管理、订单管理、取餐核销这几块SSM 完全扛得住并发量在校园场景下几千人同时段下单用 Tomcat 默认线程池 MySQL 连接池就能撑住。选型确定后依赖版本要锁死。常见做法是用 Maven 管理核心依赖包括spring-webmvc 5.x、mybatis 3.5.x、mysql-connector-java 8.x、druid连接池、fastjson或jackson做 JSON 序列化。版本不要追新SSM 这套东西版本兼容性很敏感Spring 5.3.x 配 MyBatis 3.5.x 是经过大量项目验证的组合。2.2 数据库表结构设计与建表 SQL食堂预约的核心表就五张用户表、菜品表、预约时段表、订单表、取餐记录表。下面直接给建表 SQL每张表的字段都做了注释说明。-- 用户表存储学生和食堂管理员信息 CREATE TABLE t_user ( id INT NOT NULL AUTO_INCREMENT COMMENT 主键, openid VARCHAR(64) DEFAULT NULL COMMENT 微信openid小程序登录用, username VARCHAR(32) NOT NULL COMMENT 学号/工号, password VARCHAR(128) NOT NULL COMMENT 密码存MD5加盐值, real_name VARCHAR(32) DEFAULT NULL COMMENT 真实姓名, phone VARCHAR(16) DEFAULT NULL COMMENT 手机号, role TINYINT DEFAULT 0 COMMENT 角色0学生 1食堂管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username), KEY idx_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 菜品表每个菜品关联所属窗口和库存 CREATE TABLE t_dish ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT 菜品名称, price DECIMAL(8,2) NOT NULL COMMENT 单价, window_no VARCHAR(16) NOT NULL COMMENT 窗口编号, stock INT NOT NULL DEFAULT 0 COMMENT 当日库存, image_url VARCHAR(255) DEFAULT NULL COMMENT 菜品图片, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, PRIMARY KEY (id), KEY idx_window (window_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品表; -- 预约时段表控制每个时段的最大接单量 CREATE TABLE t_time_slot ( id INT NOT NULL AUTO_INCREMENT, slot_name VARCHAR(32) NOT NULL COMMENT 如11:30-12:00, start_time TIME NOT NULL, end_time TIME NOT NULL, max_order INT NOT NULL DEFAULT 100 COMMENT 该时段最大订单数, current_order INT NOT NULL DEFAULT 0 COMMENT 当前已下单数, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约时段表; -- 订单表核心业务表 CREATE TABLE t_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号用时间戳随机数生成, user_id INT NOT NULL COMMENT 下单用户, slot_id INT NOT NULL COMMENT 预约时段, total_amount DECIMAL(10,2) NOT NULL COMMENT 总金额, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已取餐 3已取消, pickup_code VARCHAR(8) DEFAULT NULL COMMENT 取餐码4位数字, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id), KEY idx_slot (slot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表; -- 订单明细表一个订单对应多个菜品 CREATE TABLE t_order_item ( id BIGINT NOT NULL AUTO_INCREMENT, order_id BIGINT NOT NULL, dish_id INT NOT NULL, dish_name VARCHAR(64) NOT NULL COMMENT 冗余存名称防止菜品改名, price DECIMAL(8,2) NOT NULL, quantity INT NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;建表逻辑说明用户表用openid做微信登录关联username做学号登录两条路都留着。菜品表的stock字段是当日库存每天凌晨需要定时任务重置。时段表的current_order和max_order配合做限流防止某个时段爆单导致食堂做不过来。订单表里pickup_code是取餐码学生到窗口报这个码管理员在后台核销。订单明细表冗余存了dish_name和price这是血泪经验——菜品改价改名后历史订单不能跟着变否则对账会疯。注意t_time_slot表的current_order字段在高并发下必须用数据库行锁或 Redis 原子操作来更新直接UPDATE ... SET current_order current_order 1在并发时会有超卖风险后面避坑章节会细讲。3. 后端核心接口实现从下单到取餐的完整链路3.1 下单接口的库存扣减与事务控制下单是整个系统最核心也最容易出问题的环节。一个下单请求要完成校验时段是否还有名额、校验菜品库存、扣减库存、生成订单、生成订单明细、更新时段计数。这六步必须在一个事务里任何一步失败都要回滚。// OrderServiceImpl.java 核心下单逻辑 Service public class OrderServiceImpl implements OrderService { Autowired private DishMapper dishMapper; Autowired private OrderMapper orderMapper; Autowired private OrderItemMapper orderItemMapper; Autowired private TimeSlotMapper timeSlotMapper; Override Transactional(rollbackFor Exception.class) // 任何异常都回滚 public OrderResult createOrder(Integer userId, Integer slotId, ListOrderItemDTO items) { // 第一步检查时段是否还有名额用行锁防止并发超卖 TimeSlot slot timeSlotMapper.selectByIdForUpdate(slotId); if (slot null) { throw new BizException(预约时段不存在); } if (slot.getCurrentOrder() slot.getMaxOrder()) { throw new BizException(该时段已约满请选择其他时段); } // 第二步逐个校验并扣减菜品库存 BigDecimal totalAmount BigDecimal.ZERO; for (OrderItemDTO item : items) { // 用乐观锁扣库存stock quantity 才更新 int affected dishMapper.reduceStock(item.getDishId(), item.getQuantity()); if (affected 0) { throw new BizException(菜品[ item.getDishName() ]库存不足); } Dish dish dishMapper.selectById(item.getDishId()); totalAmount totalAmount.add(dish.getPrice().multiply(new BigDecimal(item.getQuantity()))); } // 第三步更新时段计数 timeSlotMapper.incrementOrderCount(slotId); // 第四步生成订单主记录 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setSlotId(slotId); order.setTotalAmount(totalAmount); order.setStatus(0); // 待支付 order.setPickupCode(generatePickupCode()); orderMapper.insert(order); // 第五步批量插入订单明细 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); } return new OrderResult(order.getOrderNo(), order.getPickupCode(), totalAmount); } }对应的 Mapper XML 里reduceStock的 SQL 必须带条件!-- DishMapper.xml 乐观锁扣库存 -- update idreduceStock UPDATE t_dish SET stock stock - #{quantity} WHERE id #{dishId} AND stock #{quantity} AND status 1 /update !-- TimeSlotMapper.xml 行锁查询 -- select idselectByIdForUpdate resultTypeTimeSlot SELECT * FROM t_time_slot WHERE id #{id} FOR UPDATE /select逻辑说明selectByIdForUpdate用了FOR UPDATE行锁保证同一时段不会被并发下单撑爆。reduceStock的WHERE stock #{quantity}是乐观锁思路更新影响行数为 0 就说明库存不够直接抛异常回滚。Transactional(rollbackFor Exception.class)里的rollbackFor必须写否则遇到非 RuntimeException 不会回滚这是新手最容易翻车的地方。参数说明slotId是预约时段 ID前端传过来items是菜品列表每个元素包含dishId、quantity、dishName、price。generateOrderNo建议用yyyyMMddHHmmss 4位随机数保证唯一且可读。generatePickupCode用 4 位数字每天重置避免取餐码太长学生记不住。3.2 微信小程序登录与用户身份绑定小程序端登录不能直接用账号密码标准做法是wx.login()拿 code后端拿 code 去换 openid。这里给出后端换取 openid 并绑定用户的代码。// WxLoginController.java RestController RequestMapping(/api/wx) public class WxLoginController { Autowired private UserService userService; // 小程序端调用 wx.login() 拿到 code 后传过来 PostMapping(/login) public Result login(RequestBody WxLoginDTO dto) { // 用 code 调用微信接口换 openid String url https://api.weixin.qq.com/sns/jscode2session ?appid appId secret appSecret js_code dto.getCode() grant_typeauthorization_code; String resp HttpUtil.get(url); JSONObject json JSON.parseObject(resp); String openid json.getString(openid); if (openid null) { return Result.fail(微信登录失败); } // 查用户是否存在不存在则自动注册 User user userService.getByOpenid(openid); if (user null) { user new User(); user.setOpenid(openid); user.setRole(0); userService.insert(user); } // 生成 token 返回给小程序后续请求带 token String token JwtUtil.sign(user.getId(), user.getRole()); return Result.ok(token); } }逻辑说明appId和appSecret放在配置文件里不要硬编码在代码中。jscode2session接口返回的openid是用户唯一标识session_key用于解密用户信息但预约场景下用不到手机号解密所以只取openid就够了。JWT token 里存userId和role后续接口用拦截器校验 token 并注入当前用户。参数说明code是小程序端wx.login()返回的临时凭证五分钟内有效且只能用一次。appId和appSecret在小程序管理后台获取。JWT 的过期时间建议设 7 天太短学生老要重新登录太长有安全风险。3.3 取餐核销接口与状态机流转取餐核销是订单状态的最后一次流转已支付 → 已取餐。管理员在后台输入取餐码或扫码系统校验后更新状态。// PickupController.java PostMapping(/pickup) public Result pickup(RequestParam String pickupCode, RequestParam Integer slotId) { // 查该时段下取餐码对应的已支付订单 Order order orderMapper.selectByPickupCode(pickupCode, slotId); if (order null) { return Result.fail(取餐码无效或订单不存在); } if (order.getStatus() ! 1) { return Result.fail(订单状态异常当前状态 order.getStatus()); } // 更新为已取餐 int affected orderMapper.updateStatus(order.getId(), 1, 2); if (affected 0) { return Result.fail(核销失败请重试); } return Result.ok(核销成功); }!-- OrderMapper.xml 状态流转必须带原状态条件 -- update idupdateStatus UPDATE t_order SET status #{newStatus}, pay_time NOW() WHERE id #{orderId} AND status #{oldStatus} /update逻辑说明updateStatus的WHERE status #{oldStatus}是状态机安全的关键防止重复核销。如果两个管理员同时扫同一个码只有一个能更新成功另一个返回 0 行受影响提示核销失败。取餐码按slotId隔离不同时段的取餐码可以重复避免码太长。4. 避坑与排查食堂预约系统最容易翻车的五个地方4.1 时段超卖为什么current_order总是超过max_order现象压测时发现某个时段明明设了 100 单上限结果实际生成了 103 单。原因UPDATE t_time_slot SET current_order current_order 1这条 SQL 在并发下多个事务同时读到相同的current_order值然后各自加一写回导致计数丢失。这是典型的读-改-写竞态。解决两个方案。方案一是用SELECT ... FOR UPDATE行锁在事务开始时锁住该时段记录串行化下单。方案二是用 Redis 的INCR原子操作做计数下单前先INCR返回值超过max_order就DECR回滚并拒绝。校园场景并发不高方案一足够但要注意FOR UPDATE必须在事务内才生效且锁的粒度要控制好别把整张表锁了。4.2 库存扣减为负数乐观锁条件写漏了现象菜品库存出现 -3 这种负数。原因UPDATE t_dish SET stock stock - #{quantity} WHERE id #{dishId}没加AND stock #{quantity}库存不够时照样扣。解决必须加上AND stock #{quantity}然后判断affected rows为 0 就抛异常回滚。另外库存字段用INT UNSIGNED也能兜底但会报错而不是静默变负看你怎么选。4.3 订单号重复时间戳精度不够现象同一秒内两个订单号一模一样插入时报唯一键冲突。原因订单号用了yyyyMMddHHmmss格式同一秒内多个请求生成相同订单号。解决订单号加随机后缀比如yyyyMMddHHmmss 4位随机数或者用System.nanoTime()的后几位。更稳妥的做法是用数据库自增 ID 拼接时间戳保证绝对唯一。4.4 小程序端请求超时Tomcat 默认连接数不够现象中午高峰期小程序转圈圈后端日志显示请求排队。原因Tomcat 默认maxThreads是 200acceptCount是 100校园场景下瞬时并发可能超过这个数。解决在server.xml里调大maxThreads到 500acceptCount到 200同时把数据库连接池的maxActive调到 50 左右。但别盲目调大先看 MySQL 的max_connections撑不撑得住。4.5 取餐码被猜中4 位数字太容易碰撞现象有学生反映自己的取餐码被别人先核销了。原因4 位数字只有 10000 种组合同一时段内如果订单量大碰撞概率不低加上有人恶意试码。解决取餐码改成 6 位或者用「时段 ID 后两位 4 位随机数」的组合。核销时除了取餐码还要校验slotId跨时段核销直接拒绝。更严格的做法是核销时要求管理员输入学生学号后四位做二次确认。5. 进阶技巧用定时任务和缓存把系统打磨到「无感」5.1 每日库存重置的定时任务菜品库存是「当日有效」的每天凌晨必须重置。用 Spring 的Scheduled就能搞定不需要引入 Quartz 这种重家伙。Component public class DailyResetTask { Autowired private DishMapper dishMapper; Autowired private TimeSlotMapper timeSlotMapper; // 每天凌晨 3 点重置库存和时段计数 Scheduled(cron 0 0 3 * * ?) public void resetDailyData() { // 把所有上架菜品的库存重置为预设值 dishMapper.resetAllStock(); // 时段计数归零 timeSlotMapper.resetAllOrderCount(); System.out.println(每日库存和时段计数已重置); } }update idresetAllStock UPDATE t_dish SET stock default_stock WHERE status 1 /update逻辑说明Scheduled的 cron 表达式0 0 3 * * ?表示每天 3 点执行。菜品表需要加一个default_stock字段存预设库存重置时直接赋回去。时段计数归零后新的一天重新开始计数。注意定时任务要加日志不然重置失败了你都不知道。5.2 用 Redis 缓存菜品列表减少数据库压力菜品列表是读多写少的数据每次小程序刷新都查数据库没必要。用 Redis 缓存设置 5 分钟过期。public ListDish getDishList(String windowNo) { String cacheKey dish:list: windowNo; // 先查缓存 String cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return JSON.parseArray(cached, Dish.class); } // 缓存没有则查数据库 ListDish list dishMapper.selectByWindow(windowNo); // 写入缓存5分钟过期 redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(list), 5, TimeUnit.MINUTES); return list; }逻辑说明缓存 key 按窗口编号区分避免不同窗口的菜品混在一起。过期时间设 5 分钟平衡数据新鲜度和数据库压力。菜品增删改时要主动删除对应缓存否则会出现「后台改了菜价小程序还是旧价」的玄学问题。5.3 压测验证用 JMeter 模拟 500 人同时下单系统上线前必须压测。用 JMeter 建一个线程组500 个线程每个线程循环 3 次模拟 500 人同时下单。重点观察三个指标下单接口的 TP99 响应时间、数据库连接池的活跃连接数、有没有出现超卖。压测时把日志级别调到 DEBUG观察reduceStock的 affected rows 是否正常。如果 TP99 超过 2 秒先看慢 SQL 日志大概率是FOR UPDATE锁等待时间太长。这时候可以考虑把时段锁改成 Redis 原子计数减少数据库锁竞争。我自己的习惯是每次改完下单逻辑必跑一轮 200 并发的压测确认没有超卖、没有死锁、响应时间在 1 秒以内才敢提交代码。这套系统上线后食堂高峰期排队时间从 25 分钟降到了 4 分钟左右学生反馈「终于不用站着吃饭了」。希望帮到你。本文还有配套的精品资源点击获取