1. 先把题目拆开:这个系统到底要做什么
每年毕设开题,实验室预约系统这个题目都会准时出现。它全称通常是“SSM实验室预约系统小程序设计与实现”,后面可能还跟着一串数字,比如 75652 这种,不用太当真,多半是学校选题管理系统的入库编号。真正的核心就两件事:后端用 SSM 写一套实验室预约管理接口,前端用微信小程序做预约入口。把这两条线打通,就是一套标准的本科毕业设计项目。
这类项目我做过不少,也带过学弟学妹跑通过。先说结论:这个题目很适合作为全栈练手,但不适合只对着源码闷头改。你需要的不是把源码背下来,而是能讲清楚为什么要这样设计表、为什么同一个时段不能两个人预约、为什么小程序页面要分页加载。这篇文章我就按我平时带项目的思路,从需求拆解、技术选型、数据库设计、后端代码、小程序端实现,一直讲到源码包怎么消化和答辩怎么回答。
1.1 角色与核心流程
一套完整的实验室预约系统,按最常见的角色设计,至少要包含三类人:
- 学生:登录小程序,查看实验室列表、查看实验室详情和设备信息,选择日期和时段提交预约,查看自己的预约记录,取消未开始的预约。
- 管理员:维护实验室基本信息,审核预约,查看所有预约记录,发布公告。
- 教师:有的题目会把教师单独作为一个角色,用来审批预约;如果题目没强制要求,完全可以把审批功能合并到管理员,别给自己加工作量。
核心流程也非常清晰:学生提交预约 → 系统检查该时段是否冲突 → 生成待审核记录 → 管理员审核 → 学生看到通过结果 → 在规定时间到实验室 → 预约状态变为已完成。
这里我建议你把“预约”理解成一个资源占用过程,而不是简单的数据插入。实验室一个时段只能被一个预约占用,所以系统必须有一个机制来保证“同一时段只有一个人能约上”。这是整个项目的核心难点,后面我会专门展开讲。
1.2 功能边界:先做减法
很多学生拿到源码以后,第一反应是“功能太少了,我能不能加个论坛、加个消息推送、加个在线聊天”。我的建议是不要。毕设评分看的不是功能数量,而是完整度和逻辑严谨性。
一个合适的范围大概是:
- 实验室管理:增删改查、开放/关闭状态、容量、设备说明。
- 预约管理:学生提交预约,管理员审核,支持取消、驳回、备注。
- 用户体系:微信登录或账号密码登录,区分学生和管理员。
- 公告管理:管理员发布一些简单的通知。
- 个人中心:查看我的预约、退出登录。
不该做的就别做,比如支付、消息队列、物联网硬件联动。题目叫“实验室预约系统”,核心就是预约两个字。功能堆得太多,答辩时每一个点都可能被老师追问,反而容易露怯。
2. 技术选型逻辑:为什么选 SSM,以及它和 SpringBoot 的真实差距
说实话,放到现在的生产环境,新项目再用 SpringMVC + Spring + MyBatis 这套老组合已经不多见了,很多人会直接上 SpringBoot。但毕设题目既然写了 SSM,就按 SSM 做。理由很现实:课程里讲的是 SSM,答辩老师熟悉的也是 SSM,你换了框架反而容易被问“为什么不用题目要求的 SSM”。
2.1 SSM 三个框架各自的分工
SSM 不是三个并列的东西,而是三个层次分明的组件:
- Spring:核心容器。它负责管理 Service、Mapper 这些对象的创建和依赖注入,所以代码里不需要到处 new 对象,用注解声明就行。
- SpringMVC:负责 HTTP 层。浏览器的请求通过 DispatcherServlet 分发到 Controller,Controller 处理完再返回 JSON 或页面。
- MyBatis:负责数据库访问。你把 SQL 写在 Mapper 接口或者 XML 里,它负责把查询结果映射成 Java 对象。
它们的关系可以简单理解成:SpringMVC 接收请求,Spring 管理中间的业务对象和事务,MyBatis 操作数据库。三层各管各的,这就是为什么代码分层也要对应着来。
2.2 为什么小程序端适合这种题
微信小程序在毕设里特别常见,因为不需要上架应用商店,微信开发者工具里直接就能跑,提交作业也方便。而且小程序本身就是前后端分离的客户端,天然要求后端提供 JSON 接口,这对训练接口设计能力很有帮助。
后端只需要把接口写好,返回 JSON,小程序用 wx.request 调用。你不需要写复杂的前端页面,WXML 语法比 HTML 简单得多,样式也比传统 CSS 更容易上手。一个普通学生从零开始,两三周就能把页面结构搭完。
2.3 项目结构约定
无论源码包怎么给你,后端代码建议保持这样的包结构:
com.example.lab ├── controller # Controller 层,接收 HTTP 请求 ├── service # Service 接口 │ └── impl # Service 实现类 ├── mapper # MyBatis Mapper 接口 ├── entity # 数据库实体类 ├── dto # 接收前端参数的对象 ├── vo # 返回给前端展示的对象 └── config # 配置类很多人写毕设喜欢把逻辑全堆在 Controller 里,一个方法干所有事。这样代码能跑,但答辩时老师一看就知道分层意识不足。Controller 里只做参数接收和结果封装,业务判断放到 Service,SQL 放到 Mapper,三层各司其职。
3. 数据库设计:预约不冲突的关键全在表结构
我第一次做类似项目时,把预约表设计得很简单:一个 user_id、一个 lab_id、一个 start_time、一个 end_time,结果查冲突特别痛苦。后来我才想明白,预约系统不应该用“时间段”字段做自由匹配,而应该先把一天切成固定时间片,谁选了这个时间片,这个时间片就被占用。这样设计表结构,后面的冲突判断会简单很多。
3.1 核心表与字段
以最常见的需求为例,核心表大概有这几张:
| 表名 | 核心字段 | 说明 |
|---|---|---|
| user | id, username, password, real_name, role, phone, openid, create_time | role 用 0 学生 1 管理员 2 教师 |
| lab | id, lab_name, location, capacity, equipment, status, create_time | status 0 关闭 1 开放 |
| time_slot | id, start_time, end_time, sort_no | 固定时间片 |
| lab_slot | id, lab_id, slot_date, time_slot_id, status | 某实验室某天某个时间片的状态 |
| reservation | id, reservation_no, user_id, lab_id, lab_slot_id, slot_date, time_slot_id, purpose, status, audit_user, audit_time, create_time | 预约记录 |
| announcement | id, title, content, create_time | 公告 |
你可能会问,为什么要有 lab_slot 这张表,直接在 reservation 表里判断冲突不行吗?当然可以,但判断语句要处理时间区间重叠,逻辑比较绕。而 lab_slot 相当于把可用的实验室时段提前生成好,学生预约时就是“占一个已经存在的坑位”,没坑位就是冲突。
3.2 时间模型:自由时段 vs 固定时间片
自由时段的意思是学生可以自己选“8:30–9:20”这种任意区间。这种交互看起来灵活,但实现起来要处理两个区间是否重叠,而且容易出现脏数据。固定时间片的做法则是把一天切成几段,比如:
- 08:00–09:40
- 10:00–11:40
- 14:00–15:40
- 16:00–17:40
学生只能从这些时间片里选一个。这样设计有几个好处:
第一,页面展示简单,前端只需要把“可用/不可用”的格子画出来。第二,后端冲突判断简单,查 lab_slot 表里那条记录的状态即可。第三,数据库层面可以加唯一索引,防止同一天同一实验室同一个时间片被重复生成。
你甚至可以一段代码生成一周的 lab_slot 数据:
CREATE TABLE lab_slot ( id INT PRIMARY KEY AUTO_INCREMENT, lab_id INT NOT NULL, slot_date DATE NOT NULL, time_slot_id INT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0 可用 1 已占用', UNIQUE KEY uk_lab_date_slot (lab_id, slot_date, time_slot_id) );3.3 状态字段与审核信息
预约表里最重要的字段是 status,我推荐用整数表示,不要直接存中文。一套常见的状态设计如下:
| 值 | 含义 | 说明 |
|---|---|---|
| 0 | 待审核 | 学生提交成功后默认状态 |
| 1 | 已通过 | 管理员审核通过 |
| 2 | 已驳回 | 管理员审核不通过 |
| 3 | 已取消 | 学生取消预约 |
| 4 | 已完成 | 预约时间结束后变为完成 |
除了 status,还要有 audit_user 和 audit_time,记录谁审核的、什么时候审核的。很多源码里只放状态不放审核人,后面写“管理员操作记录”的时候就没法查。
4. 后端实现:Controller、Service、Mapper 三层的代码习惯
后端是整个系统的核心。源码包里通常已经写好了完整代码,但你不能只是跑通,得知道每个注解是干什么的,Controller 里为什么这样写,MyBatis 的 SQL 为什么要用 #{}。
4.1 SSM 常用注解一表看懂
你可以把下面这些注解当作基础清单,答辩时大概率会被问到:
| 注解 | 作用 |
|---|---|
| @Controller | 声明这是一个 SpringMVC 控制器 |
| @ResponseBody | 把方法返回值序列化成 JSON 写入响应体 |
| @RestController | @Controller + @ResponseBody 的组合 |
| @RequestMapping | 映射 URL 和 HTTP 方法 |
| @RequestParam | 绑定单个请求参数 |
| @PathVariable | 绑定 URL 路径变量 |
| @RequestBody | 把前端传的 JSON 绑定到对象 |
| @Service | 声明 Service 层组件 |
| @Repository | 声明 Mapper 层组件 |
| @Autowired | 自动注入依赖对象 |
| @Transactional | 声明事务,通常加在 Service 方法上 |
| @Param | 给 Mapper 接口参数命名 |
很多老项目习惯用 @Controller + @ResponseBody 组合,因为课程里一般这样教。如果你用 @RestController 也行,Spring 4 之后就支持。答辩时能说清两者关系就行。
4.2 Controller 层示例
Controller 不要写太多业务。它应该只做三件事:接收参数、调用 Service、把结果返回给前端。
@Controller @RequestMapping("/api/reservation") public class ReservationController { @Autowired private ReservationService reservationService; @RequestMapping(value = "/add", method = RequestMethod.POST) @ResponseBody public Result add(@RequestBody ReservationDTO dto, HttpSession session) { User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) { return Result.error("请先登录"); } dto.setUserId(loginUser.getId()); return reservationService.createReservation(dto); } }请注意,这里我把 userId 从 session 里取出来,而不是让前端传。这个细节很关键,否则学生可以伪造 userId 帮别人预约,属于越权漏洞。
DTO 也不要偷懒使用 Map 接收参数。DTO 能明确字段,后端可以校验必填项,前端一看就知道要传什么。
4.3 Service 层事务与 MyBatis 动态 SQL
业务判断必须放在 Service。比如创建预约时,要先检查 lab_slot 状态,再占用资源,再插入预约记录。这三个步骤必须在一个事务里,否则可能出现“占用成功但预约记录没插上”的数据不一致。
@Service public class ReservationServiceImpl implements ReservationService { @Autowired private LabSlotMapper labSlotMapper; @Autowired private ReservationMapper reservationMapper; @Transactional @Override public Result createReservation(ReservationDTO dto) { LabSlot slot = labSlotMapper.selectForUpdate(dto.getLabSlotId()); if (slot == null || slot.getStatus() == 1) { return Result.error("该时段已被预约"); } labSlotMapper.updateStatus(dto.getLabSlotId(), 1); Reservation reservation = new Reservation(); reservation.setReservationNo(generateNo()); reservation.setUserId(dto.getUserId()); reservation.setLabId(dto.getLabId()); reservation.setLabSlotId(dto.getLabSlotId()); reservation.setStatus(0); reservationMapper.insert(reservation); return Result.success(); } }selectForUpdate 的意思是查询时加行锁,两个学生同时预约同一个 lab_slot 时,第二个事务会等第一个事务提交后才查到最新状态,从而避免超卖。这个方法在毕设答辩里算是加分项。
MyBatis 里写动态查询时,推荐用 XML 里的 where + if:
<select id="selectByCondition" resultType="com.example.lab.entity.Reservation"> select * from reservation <where> <if test="userId != null"> and user_id = #{userId} </if> <if test="status != null"> and status = #{status} </if> <if test="labId != null"> and lab_id = #{labId} </if> </where> order by create_time desc </select>这里必须强调一点:参数占位符永远用 #{},不要用 ${}。因为 #{} 是预编译处理,能防止 SQL 注入;${} 是字符串拼接,用户输入可能变成 SQL 的一部分。
5. 小程序端实现与调试:从零到真机预览
小程序端看起来是纯页面的活,但实际写起来也一样有讲究。尤其是列表加载更多、动态标题、接口调试,这些关键词在网上一搜全是问题,其实都是基础功。
5.1 页面结构与路由设计
小程序端建议按功能分目录,不要所有页面都平铺在 pages 下面。一个合理的结构是:
pages/ ├── login/ # 登录页 ├── home/ # 首页 ├── lab/ │ ├── labList # 实验室列表 │ └── labDetail # 实验室详情 ├── reservation/ │ └── reserve # 提交预约页 ├── mine/ │ ├── mine # 个人中心 │ └── myReservation # 我的预约记录 └── admin/ └── auditList # 管理员审核列表在 app.json 里可以设置全局导航栏标题:
{ "pages": [ "pages/login/login", "pages/home/home" ], "window": { "navigationBarTitleText": "实验室预约系统", "navigationBarBackgroundColor": "#4A90D9", "navigationBarTextStyle": "white" } }5.2 列表加载更多:onReachBottom 的正确姿势
“小程序页面列表加载更多”是特别常见的需求。很多人一开始是写一个“加载更多”按钮,但更自然的交互是页面滚动到底部自动加载。微信小程序提供了 onReachBottom 生命周期函数,触发后请求下一页数据。
直接贴一段可用的实现:
Page({ data: { page: 1, pageSize: 10, list: [], isLastPage: false, isLoading: false }, onLoad() { this.loadList(); }, onReachBottom() { if (this.data.isLastPage || this.data.isLoading) { return; } this.setData({ page: this.data.page + 1 }); this.loadList(); }, loadList() { this.setData({ isLoading: true }); wx.request({ url: 'http://127.0.0.1:8080/lab/api/reservation/my', data: { page: this.data.page, pageSize: this.data.pageSize }, success: (res) => { const records = res.data.data.records || []; this.setData({ list: this.data.list.concat(records), isLastPage: records.length < this.data.pageSize }); }, complete: () => { this.setData({ isLoading: false }); } }); } });注意两点。第一,onReachBottom 里要先判断 isLastPage 和 isLoading,否则会重复请求。第二,success 回调里要用箭头函数,否则 this 指向会出错。
如果还做了下拉刷新,需要在 onPullDownRefresh 里把 page 重置为 1,把 list 清空,再重新加载。
5.3 动态标题和离开页面时的处理
小程序头部标题默认在 app.json 里写死,但有些页面需要动态改。比如实验室详情页,要根据不同实验室显示不同标题。小程序提供了 wx.setNavigationBarTitle 接口:
wx.setNavigationBarTitle({ title: '计算机实验室 A301' });再比如“微信小程序如何监听用户离开小程序”这类问题,其实就是 onHide 和 onShow 的使用场景。当用户切到后台再回来时,我的预约列表很可能已经发生变化,所以我会在 onShow 里重新拉一次数据,避免用户看到旧状态。
这不算复杂技术,但很多新手想不到,写进论文里也是一个体验细节。
5.4 用 Charles 抓包排查接口问题
后端接口写完,小程序连不上,是日常最容易遇到的问题。接口 404、500、参数对不上,只看控制台不够直观,我一般会用 Charles 抓包看请求。
开发阶段的步骤很简单:电脑和手机连同一个局域网,在微信开发者工具里勾选“不校验合法域名”来连接本地后端。真机调试时,把 Charles 的监听端口配置到手机 WiFi 的网络设置里,然后就能看到小程序发出的请求和响应。
抓到包以后重点看三样东西:请求 URL 是否正确、请求参数是否和后端 DTO 对应、响应 JSON 是否正常。我遇到最多的类型是 baseUrl 没改,后端在 8080,小程序却请求了 80 端口。
6. 预约状态机与并发:最容易丢分也最能讲出亮点的地方
实验室预约系统最核心的难点不是增删改查,而是“同一个实验室、同一天、同一个时间片,只能有一个预约”。如果这个问题答不好,分数会受影响;答好了,反而能成为整个答辩的亮点。
6.1 状态流转:不要把预约做成一条死流程
预约状态不能只设计成“预约成功”和“预约失败”,因为真实业务存在审核、驳回、取消。
我推荐的状态设计是:
| 状态值 | 状态名 | 触发动作 |
|---|---|---|
| 0 | 待审核 | 学生提交 |
| 1 | 已通过 | 管理员审核通过 |
| 2 | 已驳回 | 管理员审核不通过 |
| 3 | 已取消 | 学生取消 |
| 4 | 已完成 | 时间过后由系统更新 |
在代码里不要散落魔法数字,可以定义常量类:
public interface ReservationStatus { int PENDING = 0; int APPROVED = 1; int REJECTED = 2; int CANCELED = 3; int FINISHED = 4; }取消预约时要校验状态,只有待审核和已通过可以取消,已驳回和已完成不能取消。这就是一个简单的状态机约束。
6.2 并发冲突的两种解法
先说不推荐的解法:先查一遍该时段有没有预约,没有就插入。这种“先查后插”在高并发下会出问题,两个学生同时操作,两个事务都查到“没有预约”,然后都插入成功,就出现了超卖。
解决办法有很多种,我建议毕设用 lab_slot 加行锁的方式。核心是 lab_slot 表里每个时间片只有一条记录,预约时用 select ... for update 锁定这条记录:
SELECT id FROM lab_slot WHERE id = #{labSlotId} AND status = 0 FOR UPDATE;如果查不到记录,说明该时段已经被占用,直接返回“该时段已被预约”。如果能查到,占用这个 lab_slot,再插入预约记录。因为行锁的存在,第二个并发请求会等待第一个事务提交后,再执行查询,此时 status 已经是 1,自然就失败。
这种设计的思维是:先锁定资源,再创建业务记录。比单纯的查询判断要可靠得多。
6.3 前端后端的双重校验
很多学生在做预约页时,只在点击按钮后提示一下“预约成功”,后端再没校验。这不行。
前端校验的目的是提升用户体验,比如日期不能是过去日期、时间片不能重复选择、没有选择实验室就不能点提交。后端校验的目的是保证数据正确,比如用户是否登录、lab_slot 是否存在、状态是否是可用、用户是否已经预约过同一个时段。
记住一句话:前端校验可以没有,后端校验必须有。接口不能信任任何人传进来的参数。
7. 源码包拿到手后:跑通、改造、答辩全流程建议
题目里写了“赠源码”,拿到源码包之后,大多数人的第一反应是解压、导入、跑通。这个流程没错,但跑通只是开始。源码包的价值在于参考,不在于直接提交。
7.1 本地部署三步走
第一步,在 MySQL 里新建数据库,导入源码自带的 sql 脚本。注意检查数据库字符集,建议用 utf8mb4,否则中文容易乱码。
第二步,修改数据库连接配置。典型文件是 jdbc.properties 或者 application.properties:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/lab_reservation?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456第三步,用 IDEA 把后端跑在 Tomcat 上,然后在微信开发者工具里导入小程序项目,修改 baseUrl 指向本地后端。如果一切正常,就能看到小程序登录页。
想发给同学试用收集反馈,直接用微信开发者工具的“预览”功能生成二维码,同学微信扫码就可以打开体验版。不过真机访问本地后端会比较麻烦,要么把后端接口部署到公网服务器,要么使用内网穿透工具,这个看你自己手头有什么资源。
7.2 二次开发清单:把别人的源码变成你的
源码包再完整,也是别人的思路。我建议你在跑通后做至少两处二次开发,不要直接交原版。
比较实用的改造方向:
- 增加公告管理模块,管理员能发布公告,首页展示公告列表。
- 给“我的预约”增加状态筛选标签,比如待审核、已通过、已完成。
- 在预约表里增加取消原因字段,完善状态流转。
- 给管理员增加一个简单的统计页面,统计每天各实验室使用率。
每做一个改动,就把对应部分写进自己的论文“系统实现”章节。答辩老师看到的不再是一个陌生源码,而是你能自己改动的项目。
7.3 答辩时一定会被追着问的几个问题
最后整理几个我见过的高频答辩问题,每个都能在这篇文章里找到答案:
- 为什么用 SSM 不用 SpringBoot?因为课程体系和题目要求,SSM 分层清晰,SpringMVC 负责请求分发,Spring 负责对象管理和事务,MyBatis 负责 SQL 映射。
- 同一时间段两个人同时预约怎么办?lab_slot 表加行锁,select for update 锁住资源,第二个事务看到状态已占用,预约失败。
- SQL 注入怎么防范?MyBatis 使用 #{} 预编译参数,不用 ${} 拼接用户输入。
- 小程序怎么和后端通信?wx.request 发 HTTP 请求,后端返回 JSON,小程序解析后渲染页面。
- 用户没登录能不能调用预约接口?不能,用户信息从 session 中获取,拦截器统一校验登录状态。
这些问题是这类的标配。你不用背标准答案,只要真把项目跑通并自己改过,都能答得上来。
最后说一点我自己的经验。源码包是帮你节省时间的,不是帮你跳过思考的。拿到项目后,第一件事不是急着看代码,而是先把数据库脚本从头读一遍,看看每个表、每个字段为什么存在。尤其是 lab_slot 的设计逻辑、预约状态流转、并发冲突处理,这三个点你闭上眼睛都能画出来时,这个项目才算真正变成你自己的。后面哪怕再收到类似的预约系统题目,换个实验室、换个场地,你也能很快改出一套新系统。