简介:这套源码是一份基于SSM框架的校园食堂预约就餐小程序完整项目,适合Java开发者、毕业设计选题学生以及需要快速搭建预约场景的团队参考。项目围绕用户登录、菜品浏览、在线预约、订单处理和座位选择展开,利用Spring管理业务对象、SpringMVC实现前后端交互、MyBatis完成数据持久化,分层结构让维护与二次开发更轻松。压缩包共860个文件,约28.65MB,其中114个java源码与49个json配置构成后端逻辑,147个vue组件配合39个wxml、40个wxss承载小程序界面,还有162个svg、127个png等素材保证视觉效果,附带sql脚本和部署批处理,便于直接导入运行。该资源已有150人学习下载,适合作为完整项目模板研读。开发者不仅能学到SSM与小程序联调的方法,还能借鉴预约时间冲突检测、座位状态管理等核心逻辑,为校园或企业食堂的点餐系统开发提供落地基础。
1. 食堂校园预约就餐小程序与SSM:一个压缩包背后的完整技术栈
很多人的U盘里都躺着像“weixin245食堂校园预约就餐小程序ssm.rar”这样的文件。它看起来像是一个毕设项目,但拆开看,本质上是一个“微信小程序 + SSM后端”的经典前后端分离项目。所谓SSM,是Spring、SpringMVC、MyBatis三个框架的组合,负责提供预约订单的创建、查询、取消等接口;小程序端则负责展示食堂窗口、时段、剩余座位,并引导用户完成预约。这种模式在校园场景里解决的核心痛点是错峰就餐:学生提前预约时段,食堂按预约量备餐,避免高峰期排队拥堵。对开发者来说,它麻雀虽小,却包含了微信登录、数据库设计、事务控制、定时任务等大量可复用技术点,适合用作毕业设计,也适合改造成企业食堂或园区餐饮的预约系统。
2. SSM后端与小程序端的接口契约:先设计表和接口,再写代码
2.1 为什么这个项目选SSM而不是Spring Boot
校园预约就餐场景,后端需要处理用户、食堂窗口、预约时段、预约订单四类核心数据。SSM是Java Web的老牌组合,在教务系统、图书管理这类项目中大量出现。Spring的IOC/DI管理Service层依赖,SpringMVC把前端请求映射到Controller,MyBatis用XML写SQL,能精细控制预约更新语句。相比Spring Boot的自动配置,SSM让新手更清楚每个组件的装配过程;对于5年以上开发者,看到SSM就知道要手动维护配置文件,也意味着改造成本低,可以平滑迁移到Spring Boot。预约就餐业务一旦上线,并发集中在中午11点前,预约接口需要防止同一用户重复提交,MyBatis的update ... where配合数据库行锁能直接实现原子操作。
2.2 数据库表设计:用户、窗口、时段、预约单
在写任何代码之前,先把表结构定下来。这里给出四张表的核心建表SQL。
CREATE TABLE canteen_user ( id INT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL UNIQUE COMMENT '微信用户唯一标识', student_no VARCHAR(20) DEFAULT NULL COMMENT '学号', user_name VARCHAR(50) DEFAULT NULL COMMENT '姓名', role TINYINT DEFAULT 0 COMMENT '0学生 1食堂管理员', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_openid (openid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE canteen_window ( id INT PRIMARY KEY AUTO_INCREMENT, window_name VARCHAR(100) NOT NULL COMMENT '窗口名称', canteen_name VARCHAR(100) NOT NULL COMMENT '所属食堂', status TINYINT DEFAULT 1 COMMENT '1营业 0休息', KEY idx_canteen (canteen_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE meal_time ( id INT PRIMARY KEY AUTO_INCREMENT, start_time TIME NOT NULL COMMENT '开始时间', end_time TIME NOT NULL COMMENT '结束时间', max_count INT NOT NULL COMMENT '时段最大预约数', KEY idx_start_time (start_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE reservation_order ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, window_id INT NOT NULL, time_id INT NOT NULL, reserve_date DATE NOT NULL COMMENT '预约日期', status TINYINT DEFAULT 0 COMMENT '0待就餐 1已就餐 2已取消 3超时未到', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_date (user_id, reserve_date), KEY idx_time_status (time_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;canteen_user表用openid做唯一约束,避免同一微信用户重复绑定。meal_time表只存时间为每天复用的时段模板,具体某一天是否已约满,由reservation_order表按reserve_date统计决定。idx_user_date用于快速查询某用户某一天的全部预约,idx_time_status则支撑“某个时段已预约人数”的统计查询。这里需要注意,预约人数统计时状态要排除“已取消”,但包含“已就餐”,因为备餐数量按最终到场人数计算。
2.3 接口定义与统一返回结构
后端接口采用REST风格,统一返回Result<T>结构,这样小程序端可以统一处理成功与失败分支。
public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 200; r.msg = "ok"; r.data = data; return r; } public static <T> Result<T> error(String msg) { Result<T> r = new Result<>(); r.code = 500; r.msg = msg; return r; } // getter/setter 省略 }接口映射关系如下表。
| 接口路径 | 方法 | 功能 | 关键入参 |
|---|---|---|---|
| /api/user/login | POST | 微信登录换取openid | code |
| /api/window/list | GET | 窗口列表 | canteenName |
| /api/time/list | GET | 某日时段列表 | date |
| /api/reserve/add | POST | 提交预约 | userId, windowId, timeId, date |
| /api/reserve/my | GET | 我的预约 | userId |
| /api/reserve/cancel | POST | 取消预约 | id |
| /api/reserve/verify | POST | 管理员扫码核销 | orderId, operatorId |
这里没有把openid直接暴露给前端,而是登录后由后端返回userId与一个临时token,后续请求带着token走拦截器。这样做的好处是,后端可以控制token有效期,避免openid在传输过程中被滥用。接口契约确定后,前后端可以并行开发,小程序端用Mock数据调试,后端用Postman验证。
3. 用SSM实现预约核心链路:从Controller到Mapper的完整代码
3.1 预约接口的Controller层与参数校验
预约接口是系统最关键的一个入口,Controller层只做参数接收和基础校验,业务逻辑下沉到Service。
@Controller @RequestMapping("/api/reserve") public class ReserveController { @Autowired private ReserveService reserveService; @ResponseBody @RequestMapping(value = "/add", method = RequestMethod.POST) public Result<String> add(@RequestBody ReserveAddParam param) { if (param.getUserId() == null || param.getWindowId() == null || param.getTimeId() == null || param.getReserveDate() == null) { return Result.error("参数不完整"); } return reserveService.addReservation(param); } }@RequestBody要求小程序端以application/json方式提交数据。字段校验没有用JSR-303,因为SSM项目里引入@Valid往往需要额外的依赖,手动判断更直观。ReserveAddParam是一个普通的POJO,字段名需要与JSON键名保持一致,例如reserveDate对应reserve_date的驼峰写法,SpringMVC默认开启驼峰映射。
3.2 Service层处理“同一时段不能重复预约”
Service层要解决两个并发问题:一是同一用户同一天不能重复预约,二是某个时段不能超过设定人数上限。先看核心代码。
@Service public class ReserveServiceImpl implements ReserveService { @Autowired private ReservationMapper reservationMapper; @Autowired private MealTimeMapper mealTimeMapper; @Override @Transactional(rollbackFor = Exception.class) public Result<String> addReservation(ReserveAddParam param) { // 1. 检查同一天同一用户是否已有有效预约 int activeCount = reservationMapper.countActiveByUserAndDate( param.getUserId(), param.getReserveDate()); if (activeCount > 0) { return Result.error("你今天已经预约过,不能重复预约"); } // 2. 锁住meal_time记录,防止超额 MealTime mealTime = mealTimeMapper.selectByIdForUpdate(param.getTimeId()); if (mealTime == null) { return Result.error("时段不存在"); } int used = reservationMapper.countByTimeAndDate( param.getTimeId(), param.getReserveDate()); if (used >= mealTime.getMaxCount()) { return Result.error("该时段已约满"); } // 3. 插入预约记录 ReservationOrder order = new ReservationOrder(); order.setUserId(param.getUserId()); order.setWindowId(param.getWindowId()); order.setTimeId(param.getTimeId()); order.setReserveDate(param.getReserveDate()); order.setStatus(0); reservationMapper.insert(order); return Result.success("预约成功"); } }@Transactional(rollbackFor = Exception.class)让方法内所有操作共享一个事务。第2步的selectByIdForUpdate会对meal_time表对应行加排他锁,直到事务提交才释放;countByTimeAndDate统计到的used一定是最新值,因此不会出现两个请求同时读到剩余1个名额然后都放行的情况。这里的“同一用户重复预约”检查依赖第一步查询,如果并发场景下同一用户同时提交两次,第一步可能都通过,第二步插入时仍会出现两条有效订单。更稳妥的做法是在reservation_order表增加一个user_id + reserve_date + status的约束字段,但status会变化,所以实际项目中会在用户表或单独表记录“当日已预约”标记,这里不再展开。
3.3 MyBatis Mapper与动态SQL
MyBatis的XML写法比注解更适合这种带条件统计的SQL,下面给出两个关键查询的映射。
<select id="selectByIdForUpdate" resultType="com.example.entity.MealTime"> SELECT id, start_time, end_time, max_count FROM meal_time WHERE id = #{id} FOR UPDATE </select> <select id="countByTimeAndDate" resultType="int"> SELECT COUNT(*) FROM reservation_order WHERE time_id = #{timeId} AND reserve_date = #{date} AND status IN (0, 1) </select>FOR UPDATE是MySQL InnoDB引擎提供的行级锁,必须放在事务里才有效。status IN (0, 1)的意思是“待就餐”与“已就餐”都占用名额,而“已取消”不占用。这样设计省去了释放名额的额外逻辑:用户取消预约后,订单状态变为2,再次统计时就自动少一人。
3.4 数据一致性与事务配置的坑
SSM的事务开关在applicationContext.xml中配置。
<tx:annotation-driven transaction-manager="transactionManager" proxy-target-class="true"/>proxy-target-class="true"指定使用CGLIB代理,这样即使Service没有实现接口也能被代理。事务失效最常见的三个原因:方法不是public、异常被catch后没有重新抛出、在同一个类内部通过this.xxx()调用方法导致代理不生效。预约场景里,如果addReservation内部又调用了同一个类的checkLimit()方法,checkLimit()上的事务注解不会生效,数据库锁也就不会按预期释放。
提示:如果使用
return Result.error(...)提前返回,此时方法正常结束没有抛异常,事务会提交;如果插入操作因为重复键等异常抛出,事务才会回滚。
4. 小程序端从登录到核销:前后端联调的关键实现
4.1 微信登录换取openid的正确姿势
小程序端调用wx.login时拿到的code有效期只有5分钟且只能使用一次,后端拿到code后调用微信接口换取openid和session_key。在小程序端,登录流程通常放在app.js的onLaunch里。
App({ onLaunch() { wx.login({ success: (res) => { wx.request({ url: 'https://yourdomain.com/api/user/login', method: 'POST', data: { code: res.code }, success: (resp) => { const { token, userId } = resp.data.data; wx.setStorageSync('token', token); wx.setStorageSync('userId', userId); } }); } }); } });后端在/api/user/login接口里通过openid查canteen_user表,如果没有记录就插入并返回新的userId,同时生成一个随机token存到Redis并设置过期时间。小程序端把token放进Storage,后续请求通过自定义请求头Authorization携带。不要直接把openid返回给前端,因为前端一旦拿到openid就可以冒充其他用户访问接口。
4.2 预约页面的wxml与js代码
预约页面的核心是展示当日时段列表和剩余数量,用户点击按钮后提交预约。下面是最小可运行版本。
<view wx:for="{{timeList}}" wx:key="id" class="time-item"> <view>{{item.startTime}} - {{item.endTime}}</view> <view>剩余{{item.remainCount}}份</view> <button size="mini" type="primary" >Page({ data: { userId: '', reserveDate: '2026-05-20', timeList: [] }, onLoad() { this.setData({ userId: wx.getStorageSync('userId') }); this.loadTimeList(); }, loadTimeList() { wx.request({ url: 'https://yourdomain.com/api/time/list', data: { date: this.data.reserveDate }, success: (res) => { const list = res.data.data.map(item => { item.remainCount = item.maxCount - item.usedCount; return item; }); this.setData({ timeList: list }); } }); }, doReserve(e) { const timeId = Number(e.currentTarget.dataset.id); wx.request({ url: 'https://yourdomain.com/api/reserve/add', method: 'POST', data: { userId: this.data.userId, windowId: 1, timeId: timeId, reserveDate: this.data.reserveDate }, success: (res) => { wx.showToast({ title: res.data.msg, icon: 'none' }); if (res.data.code === 200) { this.loadTimeList(); } } }); } });Number(e.currentTarget.dataset.id)很重要,>Page({ onShow() { const userId = wx.getStorageSync('userId'); wx.request({ url: 'https://yourdomain.com/api/reserve/my', data: { userId: userId }, success: (res) => { this.setData({ orderList: res.data.data }); } }); } });
onShow比onLoad更适合做数据刷新,因为从预约页面跳转回来时页面不会重新加载,但onShow一定会触发。预约成功后,在前一个页面的success回调里执行wx.navigateBack(),返回列表页后就会自动刷新。如果项目中多个页面都要读“当天是否已预约”,不要每次都依赖接口,可以在预约成功时把reserveDate写入Storage,下次进入预约页先检查本地状态,减少无谓请求。
5. 预约系统的常见坑和优化技巧:超时取消、幂等、服务降级
5.1 定时任务处理“到点未到”
用户预约了12:00的时段但没去取餐,不能一直占着名额。在SSM中开启定时扫描只需两步:在spring-mvc.xml中加<task:annotation-driven/>,然后在Service类上写@Scheduled方法。
@Scheduled(cron = "0 * * * * ?") public void autoTimeoutOrder() { Date now = new Date(); List<Long> ids = reservationMapper.findTimeoutIds(now); if (ids != null && !ids.isEmpty()) { reservationMapper.batchUpdateStatus(ids, 3); } }findTimeoutIds的SQL条件是status=0 AND CONCAT(reserve_date, ' ', end_time) < NOW(),也就是预约日期加上时段结束时间早于当前时间。这里用end_time而不是start_time,是给用户留出取餐缓冲时间,否则高峰期刚结束就被判定超时,容易引发投诉。定时任务默认是单线程执行,扫描耗时超过1分钟会拖到下一次执行,所以批量更新要用IN语句一次提交。
5.2 用自定义注解实现预约接口的轻量幂等
预约按钮被用户连点两次,或者小程序wx.request超时后自动重试,都会造成重复下单。最实用的办法是前端生成requestId,后端用Redis的SETNX做幂等标记。
@Aspect @Component public class IdempotentAspect { @Autowired private StringRedisTemplate redisTemplate; @Around("@annotation(idempotent)") public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable { String key = "idempotent:" + idempotent.key(); Boolean absent = redisTemplate.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(30)); if (!Boolean.TRUE.equals(absent)) { return Result.error("请勿重复提交"); } try { return pjp.proceed(); } finally { redisTemplate.delete(key); } } }使用SETNX时要设置过期时间,否则Redis中会积压大量无用key。这里在finally中删除key允许30秒后同一个requestId可以再次使用,适合“同一请求只允许短时间重复提交”的场景。如果业务要求“同一用户全天只能约一次”,就不应该使用redisTemplate.delete,而是把userId作为key并设置有效期到当天24点。
5.3 用JMeter验证并发预约是否可靠
把Service和Mapper写完,需要用并发请求验证“约满不超卖”。JMeter中创建线程组:线程数20,循环10次,HTTP请求POST /api/reserve/add,参数通过CSV数据文件传入不同的userId。预约接口返回的msg如果是“该时段已约满”,在断言中允许出现;如果出现“预约成功”的次数超过meal_time.max_count,就说明锁没有生效。
压测时注意,FOR UPDATE行锁会让其他线程进入阻塞等待,若事务执行时间过长,JMeter里会出现大量超时。这时要先排查Service中是否有慢查询,而不是急着加缓存。一个简单的判据:单机500并发以内,FOR UPDATE + 快速INSERT方案足够稳定;如果并发再高,就要改成“先扣减meal_time表的reserve_count字段”的原子更新,把行锁持有时间压缩到一条SQL以内。
本文还有配套的精品资源,点击获取