ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Spring Boot+Vue 开放实验室管理系统:排课冲突检测与设备联动

Spring Boot+Vue 开放实验室管理系统:排课冲突检测与设备联动 简介在高校实验室信息化场景中排课与设备管理本质上是资源占用、时间区间校验和权限控制问题。通过 Spring Boot 分层架构、MyBatis 持久化与 MySQL 时间重叠查询可以支撑实验室、设备、开课申请等核心数据的一致管理结合事务与行锁可避免并发提交造成重复占用并以 Session 验证码完善登录鉴权。这类技术组合既适合 Spring Boot 毕业设计也适合实验室预约、设备借用、教学排课等应用。开放实验室管理系统把前后端分离、冲突检测、选课作业和统计报表串成可落地实践重点在业务规则与工程边界而非页面展示。1. 开放实验室管理系统到底在解决哪些排课与设备冲突问题很多高校实验室的日常排课还停留在 Excel 加聊天群确认的阶段教师想开一门实验课要先挨个问哪个实验室在某个时间段空着学生选课名单和实验室容量对不上设备借出记录与实验室占用记录分属两张表期末统计使用时长只能靠人工翻记录。这套基于 Spring Boot 的开放实验室管理系统把实验室信息、实验设备、开课申请、选课、作业成果提交和实验室统计收进同一套数据模型让冲突在提交申请的那一刻被检测出来。它适合两类人一是做 Java 方向毕业设计、需要一份能跑通前后端分离的参考项目二是实验室管理员想用一套轻量系统替换线下登记。真正要花心思的地方不在于首页 banner 多好看而在于时间重叠判断、事务边界和登录后的权限跳转能不能扛住日常使用。2. 从 Spring Boot 分层到 MySQL 表结构开放实验室管理系统的领域建模2.1 B/S 与前后端分离下模块边界应该这样切这个系统采用 B/S 结构浏览器访问服务端集中处理业务逻辑。前端用 Vue 做组件化页面后端用 Spring Boot 提供 REST 接口开发工具可以用 Eclipse也可以用 IDEA——如果你习惯 IDEA 创建 Spring Boot 项目选 Spring Initializr勾选 Spring Web、MyBatis Framework、MySQL Driver 即可。前后端分离的好处是页面更新不要求每个客户端装软件后端改完接口前端改调用地址即可B/S 模式在实验室这种多终端访问场景下比 C/S 更省维护成本。模块切分不要按“管理员页面/用户页面”来分那只是视图层。后端建议按业务域拆 ControllerAuthController 负责登录鉴权StudentController 和 TeacherController 管人员LabRoomController 管实验室DeviceController 管设备ApplyCourseController 管开课申请CourseSelectController 管选课HomeworkController 管作业成果提交NewsController 管新闻数据StatController 管统计。对应前端路由按角色授权显示管理员看到全部菜单教师看到开课申请和作业批改学生看到选课和作业提交。前端路由后端 Controller主要数据表关键参数/loginAuthControlleralluserusername、password、captcha/lab/listLabRoomControllerlab_roomroomNo、capacity、status/device/listDeviceControllerdevicedeviceNo、labId、type/apply/submitApplyCourseControllerapply_courselabId、startTime、endTime/select/courseCourseSelectControllercourse_selectstudentId、courseId/stat/labStatControllerapply_course、lab_roomstartDate、endDate这个表格的意义在于前端每加一个页面后端要找到对应的 Controller 和表避免接口和模块错位。springboot 配置里建议把 context-path 统一定为 /api前端 axios 的 baseURL 直接写 /api代理层再转发到 8080这样本地联调不会出现路径拼接不一致。2.2 实验室、设备、用户三张核心表的字段类型怎么定原始文档里给了 Alluser、公告、News 三张表字段类型用 VARCHAR 存时间的情况比较多。实际落地时时间字段用 DATETIME手机号用 VARCHAR(20)不要用 INT因为号码可能有前导零和长度溢出。下面这段 SQL 在 MySQL 8 里可以直接执行字符集用 utf8mb4兼容中文与常见符号。-- 用户表管理员、教师、学生共用用 role 区分 CREATE TABLE alluser ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT 加密后的密码, real_name VARCHAR(50) DEFAULT NULL COMMENT 姓名, gender VARCHAR(10) DEFAULT NULL COMMENT 性别, phone VARCHAR(20) DEFAULT NULL COMMENT 联系电话, role VARCHAR(20) NOT NULL DEFAULT student COMMENT admin/teacher/student, status TINYINT NOT NULL DEFAULT 1 COMMENT 1 正常 0 停用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 实验室表容量和状态决定能不能被申请 CREATE TABLE lab_room ( id BIGINT NOT NULL AUTO_INCREMENT, room_no VARCHAR(32) NOT NULL COMMENT 实验室编号, room_name VARCHAR(64) NOT NULL COMMENT 实验室名称, capacity INT NOT NULL DEFAULT 0 COMMENT 容纳人数, location VARCHAR(120) DEFAULT NULL COMMENT 位置, status TINYINT NOT NULL DEFAULT 1 COMMENT 1 开放 0 维护, PRIMARY KEY (id), UNIQUE KEY uk_room_no (room_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 开课申请表时间用 DATETIME冲突检测才有意义 CREATE TABLE apply_course ( id BIGINT NOT NULL AUTO_INCREMENT, course_name VARCHAR(120) NOT NULL, teacher_id BIGINT NOT NULL, lab_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0 待审 1 通过 2 驳回 3 撤销, apply_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_lab_time (lab_id, start_time, end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明alluser 表的 username 建唯一索引避免同一个登录名插入两次lab_room 的 room_no 也唯一实验室编号在现实中本来就是业务唯一键。apply_course 在 lab_id、start_time、end_time 上建联合索引冲突检测查询会走这个索引否则申请记录一多就全表扫描。参数说明status 用 TINYINT 比用 VARCHAR 省空间比较时也不会因为大小写或空格产生歧义。如果原文档的 News 表用IDname、VarChar 50这种写法迁移到 MySQL 时统一改成小写下划线命名字段长度按实际内容定正文类字段用 TEXT避免插入长新闻时被截断。2.3 Spring Boot 三层结构落一个“实验室列表”接口springboot 自动装配原理让 DataSource、MyBatis 的 SqlSessionFactory 在依赖引入后自动就绪配置文件里只需要给连接信息。下面以实验室列表为例包结构建议com.lab.controller、com.lab.service、com.lab.mapper、com.lab.entity。实体类用 Lombok 减少 getter/setter没有 Lombok 就手动补全。// LabRoom.java package com.lab.entity; import lombok.Data; Data public class LabRoom { private Long id; private String roomNo; private String roomName; private Integer capacity; private String location; private Integer status; }// LabRoomController.java package com.lab.controller; import com.lab.entity.LabRoom; import com.lab.service.LabRoomService; import org.springframework.web.bind.annotation.*; import java.util.List; RestController RequestMapping(/lab) public class LabRoomController { private final LabRoomService labRoomService; public LabRoomController(LabRoomService labRoomService) { this.labRoomService labRoomService; } // 查询开放中的实验室支持按名称模糊匹配 GetMapping(/list) public ListLabRoom list(RequestParam(required false) String keyword) { return labRoomService.listOpenRooms(keyword); } }逻辑说明Controller 只负责接收参数和返回数据用构造器注入 Service避免字段注入在单元测试里不好替换。RequestParam(required false)表示 keyword 可以不传不传时返回全部开放实验室。Service 层做业务判断调用 Mapper 的selectOpenRooms。Mapper XML 里写 SQL 时用if testkeyword ! null and keyword ! 拼接AND room_name LIKE CONCAT(%, #{keyword}, %)注意用#{}而不是${}防止 SQL 注入。参数说明status 1表示开放维护中的实验室不在列表里显示前端再从接口拿数据渲染表格。springboot 配置片段server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://localhost:3306/open_lab?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.lab.entity configuration: map-underscore-to-camel-case: true这里map-underscore-to-camel-case: true让room_no自动映射到roomNo省去在 XML 里写 resultMap。参数说明serverTimezoneAsia/Shanghai避免时间字段差 8 小时MySQL 8 必须显式指定时区不然连接会报错。context-path: /api与前端代理对应前端请求/api/lab/list反向代理把/api转发到后端。3. 开课申请与设备联动Spring Boot MyBatis 的事务与冲突校验3.1 申请状态机与时间重叠检测 SQL开课申请是这套系统的核心业务。教师选择实验室、填写课程名、起止时间提交后进入待审状态管理员审核通过后该时间段被占用驳回或撤销后释放。状态流转建议固定为0 待审、1 通过、2 驳回、3 撤销。不要用字符串描述状态UI 显示时再映射成中文。冲突检测的判断条件是时间区间重叠已有申请的start_time 新申请 end_time且end_time 新申请 start_time且实验室相同且状态为待审或通过。下面这段 SQL 放在 ApplyCourseMapper.xml 里。!-- 查询同一实验室在给定时间段内是否已有有效申请 -- select idcountConflict resultTypeint SELECT COUNT(1) FROM apply_course WHERE lab_id #{labId} AND status IN (0, 1) AND start_time lt; #{endTime} AND end_time gt; #{startTime} /select逻辑说明status IN (0, 1)把待审和已通过都算作占用避免两个待审申请最终同时通过。start_time endTime和end_time startTime这个写法能覆盖三种重叠情况新申请完全包含已有申请、已有申请包含新申请、部分交叉。参数说明#{labId}、#{startTime}、#{endTime}都是预编译参数MyBatis 会把它们转成 JDBC 的?不要为了拼 SQL 方便改成${}。设备联动是另一个容易出问题的地方。如果开课申请同时要借用设备设备表要有lab_id字段表示设备归属哪个实验室申请通过时把设备状态置为“占用”撤销时恢复“可用”。这一步必须和申请状态在同一个事务里否则会出现申请撤销了但设备还显示占用的脏数据。3.2 事务边界、并发提交与行锁处理只靠上面的冲突检测还不够两个教师几乎同时提交同一实验室同一时间段的申请两条 SQL 都查到 COUNT 为 0然后都插入成功冲突就漏过去了。常见做法是在 Service 方法上加Transactional并对实验室行加锁让第二个请求排队等待第一次判断结束。Service public class ApplyCourseServiceImpl implements ApplyCourseService { private final ApplyCourseMapper applyCourseMapper; private final LabRoomMapper labRoomMapper; public ApplyCourseServiceImpl(ApplyCourseMapper applyCourseMapper, LabRoomMapper labRoomMapper) { this.applyCourseMapper applyCourseMapper; this.labRoomMapper labRoomMapper; } Override Transactional(rollbackFor Exception.class) public void submit(ApplyForm form) { if (!form.getEndTime().isAfter(form.getStartTime())) { throw new BizException(结束时间必须晚于开始时间); } // 锁定实验室记录防止并发下两个申请同时通过冲突检测 LabRoom lab labRoomMapper.selectForUpdate(form.getLabId()); if (lab null || lab.getStatus() ! 1) { throw new BizException(实验室不存在或未开放); } int conflict applyCourseMapper.countConflict( form.getLabId(), form.getStartTime(), form.getEndTime()); if (conflict 0) { throw new BizException(该时间段实验室已被占用); } applyCourseMapper.insert(form); } }逻辑说明Transactional(rollbackFor Exception.class)表示遇到任何异常都回滚不只是运行时异常。先校验时间先后再用selectForUpdate锁住实验室行相当于给同一实验室的并发申请排了个队锁释放前另一个事务无法拿到同一行的锁也就不能同时通过冲突检测。参数说明BizException是自定义业务异常配合全局异常处理器返回统一错误码。要特别注意事务方法不要写成 privateSpring 的代理无法拦截 private 方法事务会失效。还有一层保险是数据库唯一约束如果业务允许把“实验室 开始时间”做成唯一键重复插入会直接报错回滚但开放实验的时间段是连续区间唯一键不能解决所有重叠情况所以行锁加冲突检测更通用。3.3 参数校验与统一返回注解和异常处理前端传参不可信时间格式、必填字段、实验室 ID 都要在后端校验。用Valid加 JSR-303 注解能在进入 Service 之前拦掉大部分脏数据。字段校验注解作用courseNameNotBlank课程名不能为空labIdNotNull必须选择实验室startTimeFuture开始时间必须在未来endTimeFuture结束时间必须在未来capacityMin(1)人数至少为 1Data public class ApplyForm { NotBlank(message 课程名不能为空) private String courseName; NotNull(message 实验室不能为空) private Long labId; Future(message 开始时间必须晚于当前时间) JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime startTime; Future(message 结束时间必须晚于当前时间) JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime endTime; }逻辑说明JsonFormat指定前端传的时间字符串格式和时区没有它LocalDateTime 默认按 ISO 格式解析前端传2025-03-01 08:00:00就会报 400。参数说明Future只校验单个字段是否在未来不校验结束时间是否晚于开始时间后者放在 Service 里做因为需要两个字段一起比较。统一返回格式用 Result 类前端只判断 code 是否为 200Data public class ResultT { private int code; private String msg; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.setCode(200); r.setMsg(success); r.setData(data); return r; } public static T ResultT fail(String msg) { ResultT r new Result(); r.setCode(500); r.setMsg(msg); return r; } }全局异常处理用RestControllerAdvice捕获BizException和MethodArgumentNotValidException前者返回业务错误信息后者把校验失败的 message 取出来返回给前端。这样 Vue 侧只需要弹一个提示不需要为每个接口写错误分支。4. 登录鉴权、验证码与前后端联调Vue 页面怎么接住 Spring Boot 接口4.1 登录流程与 Session 存验证码的取舍登录流程在后台看是一串明确步骤前端请求验证码接口后端生成随机字符和图片把字符存进 session把图片以 base64 返回用户提交用户名、密码、验证码后端先从 session 取出验证码比对再查 alluser 表验证密码成功后将用户信息写入 session跳转到该角色对应的页面失败则返回错误信息。session 与 token 的取舍可以用一张表说清对比项Session CookieToken如 JWT服务端存储需要 session 存储服务端不存储验证码绑定天然绑定当前会话需要额外缓存跨域携带要配置 credentials放 header 即可适合场景单体后台管理同域访问多端、分布式部署这个系统是单体 Spring Boot 加 Vue部署时常用 Nginx 把前端和后端放同域session 方案够用。要注意跨域时 axios 必须设置withCredentials: true后端也要允许携带 cookie否则 session 里存的验证码在登录请求里取不到表现就是“验证码始终错误”。4.2 验证码生成与登录拦截器代码验证码生成用 Java 的 BufferedImage 和 Graphics2D不引第三方也能实现。返回给前端的字段包括 base64 图片验证码字符放 session、登录时再取出来比对。RestController RequestMapping(/auth) public class AuthController { GetMapping(/captcha) public ResultMapString, String captcha(HttpSession session) throws IOException { int width 120, height 40; BufferedImage image new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g image.createGraphics(); g.setColor(Color.WHITE); g.fillRect(0, 0, width, height); String chars ABCDEFGHJKLMNPQRSTUVWXYZ23456789; StringBuilder code new StringBuilder(); Random random new Random(); for (int i 0; i 4; i) { char c chars.charAt(random.nextInt(chars.length())); code.append(c); g.setColor(new Color(random.nextInt(120), random.nextInt(120), random.nextInt(120))); g.setFont(new Font(Arial, Font.BOLD, 26)); g.drawString(String.valueOf(c), 18 i * 24, 30); } g.dispose(); session.setAttribute(captcha, code.toString().toUpperCase()); ByteArrayOutputStream bos new ByteArrayOutputStream(); ImageIO.write(image, png, bos); String base64 Base64.getEncoder().encodeToString(bos.toByteArray()); MapString, String data new HashMap(); data.put(image, data:image/png;base64, base64); return Result.ok(data); } }逻辑说明验证码字符去掉容易混淆的 I、O、0、1减少用户输错。存 session 时统一存大写登录校验时把用户输入转大写再比对。参数说明随机颜色范围控制在 0 到 120 之间保证深色文字在白色背景上可见session.setAttribute的 key 用captcha登录接口按同一个 key 取值不要两处写错。登录接口校验顺序是先取 session 中的验证码为空或过期直接返回“验证码已失效”比对失败返回“验证码错误”再查用户密码建议用 BCrypt 加密存储不要用明文。登录成功后把用户 id、角色放进 session拦截器根据role放行对应路径。拦截器配置Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/auth/login, /auth/captcha, /error); } }逻辑说明拦截器拦截所有请求放行登录、验证码和错误页。LoginInterceptor里判断 session 是否存在用户信息没有就返回 401前端拦截 401 后跳回登录页。参数说明excludePathPatterns的路径要和 context-path 拼起来看如果 context-path 是/api拦截器路径写/**会匹配到/api/**排除路径也按同样规则理解不要写成/api/auth/login导致排除失效。4.3 Vue 侧 axios 封装与跨域代理前端封装 axios统一加 baseURL、超时和响应拦截。学 springboot vue 前后端分离的项目最容易踩的联调坑是路径和跨域代理配好就少一半问题。// src/utils/request.js import axios from axios const service axios.create({ baseURL: /api, // 开发环境由 vue.config.js 代理到 8080 timeout: 8000, withCredentials: true // 携带 session cookie }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { alert(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } return res.data }, error { if (error.response error.response.status 401) { window.location.href /login } return Promise.reject(error) } ) export default service逻辑说明baseURL: /api让所有请求都走统一前缀代理层只需要把/api转发到后端。withCredentials: true保证跨域时携带 cookie配合后端CrossOrigin(allowCredentials true)或全局 CORS 配置使用。响应拦截里判断code ! 200并提示后端抛出的业务错误会走这里前端页面不需要每个接口重复写错误处理。Vue CLI 的vue.config.js代理module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: /api } } } } }参数说明changeOrigin: true让代理请求的 Host 变成目标地址避免后端因 Host 校验拒绝。pathRewrite这里保持原样因为后端 context-path 也是/api前端请求/api/lab/list会被转发到http://localhost:8080/api/lab/list路径刚好对上。如果后端没有 context-path就要把pathRewrite写成{^/api: }这个细节和 springboot 配置里的server.servlet.context-path必须一一对应。4.4 联调常见报错定位表现象可能原因排查动作404 Not Found路径前缀不一致核对 baseURL、context-path、代理 pathRewrite401 Unauthorizedsession 丢失或未带 cookie检查 withCredentials 和后端 CORS 配置400 Bad Request时间格式或校验未通过看响应 message核对 JsonFormat 与 Valid500 冲突检测报错字段名映射不上检查 map-underscore-to-camel-case 和 SQL 别名验证码一直错误验证码存 session 但登录请求没带 session打开浏览器 Application 面板看 JSESSIONID这张表里的问题大部分在浏览器 Network 面板就能看到第一个请求和第二个请求的 cookie 是否一致。调试时先看接口有没有到达后端再看参数有没有解析成功最后看数据库有没有写进去按顺序排查比反复改代码快。5. 实验室统计与边界测试把聚合 SQL 和 MockMvc 用例跑通5.1 实验室使用时长统计 SQL实验室统计是管理员最常用的页面。按实验室汇总使用次数和总时长只统计已通过的开课申请用 TIMESTAMPDIFF 计算小时数。SELECT l.room_no, l.room_name, COUNT(a.id) AS used_times, IFNULL(SUM(TIMESTAMPDIFF(HOUR, a.start_time, a.end_time)), 0) AS total_hours FROM lab_room l LEFT JOIN apply_course a ON a.lab_id l.id AND a.status 1 AND a.start_time #{startDate} AND a.end_time lt; #{endDate} GROUP BY l.id, l.room_no, l.room_name ORDER BY total_hours DESC;逻辑说明用 LEFT JOIN 保证没有申请的实验室也会出现在结果里使用次数为 0不会因为 INNER JOIN 被过滤掉。IFNULL把空值转成 0前端图表不用再判断 null。参数说明#{startDate}和#{endDate}由前端日期选择器传入统计区间包含边界如果跨天申请很多TIMESTAMPDIFF 按小时取整会有偏差可以改用 MINUTE 再除以 60精度自己定。GROUP BY 里把 l.id 放进去避免同名实验室被合并。5.2 MockMvc 边界用例功能测试不要只测“正常添加成功”。上面这些业务规则至少要有边界用例开始时间等于结束时间、开始时间晚于结束时间、同一实验室同一时间段重复申请、实验室处于维护状态、容量为 0。下面这段测试用 MockMvc 模拟登录后的请求验证冲突检测返回的 code。SpringBootTest AutoConfigureMockMvc class ApplyCourseControllerTest { Autowired private MockMvc mockMvc; Test void submit_conflictTime_shouldReturnFail() throws Exception { String body {\courseName\:\数据结构实验\,\labId\:1, \startTime\:\2025-06-01 08:00:00\, \endTime\:\2025-06-01 10:00:00\}; mockMvc.perform(post(/apply/submit) .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(jsonPath($.code).value(500)); } }逻辑说明测试类用SpringBootTest启动完整上下文AutoConfigureMockMvc注入 MockMvc不启动真实服务器也能发请求。断言用jsonPath取返回 JSON 里的 code比断言字符串更稳。参数说明这段测试依赖数据库里已经存在 id 为 1 的实验室和一条同时间段的申请测试前用 SQL 准备数据、测试后清理不要依赖线上库。单元测试和集成测试的分工是Service 层用 Mockito 测冲突判断逻辑Controller 层用 MockMvc 测参数绑定和异常返回前端用真实接口走一遍登录到提交申请的链路。统计页面还有一个容易被忽视的边界跨月统计时申请开始时间在上个月、结束时间在这个月按上述条件过滤会把整条记录排除。如果业务要求按实际使用时长分摊就需要把申请时间按天拆开或者用额外的统计表这一层是否要做取决于管理的精细度。本文还有配套的精品资源点击获取
返回列表