Java实验教学管理系统设计与实现:从业务建模到并发控制 简介这是一份基于Java核心技术的实验教学管理系统完整源码包面向教育机构、项目开发者以及正在学习Java与Vue全栈的读者。系统围绕用户管理、课程安排、实验报告提交等核心业务设计帮助提升实验教学管理效率也可作为课程设计或毕业设计参考。压缩包共131个文件约1.95MB包含73个Java源文件、12个XML配置、12个Vue组件、12个JavaScript文件以及SQL数据库脚本、properties和JSON配置、可运行的JAR包各类文件分工明确便于部署和二次开发。已有320人学习下载。通过源码可学习前后端分离的开发模式理解Vue组件化与Java后端分层设计其中的示例类文件展示了具体实验场景的实现配合数据库脚本和配置资源能够直接用于搭建实验教学管理平台同时可作为Java服务端开发与Vue前端工程化的综合案例。1. “实验教学管理系统”到底解决什么问题一门实验课背后的管理账如果你只把“实验教学管理系统”当成又一个 CRUD 课程设计那这篇笔记你可以关掉了。真正做过的人清楚一套能用的实验教学管理系统骨子里是替实验课老师把三本账算清楚学生有没有来做实验、报告有没有交、成绩怎么评。这三个问题在表格时代靠的是老师手动点名、收纸质报告、在 Excel 里折算平时分一学期 200 个学生光催报告就能耗掉老师一整个周末。系统要做的不是把表搬上线而是把整个实验课从「报名—签到—做实验—交报告—批改—归档」这段长流程的每一步都留下可追溯的痕迹。这篇文章就是顺着这个标题把一套基于 Java 的解决方案怎么设计、怎么落地讲透。适合两类人一类是拿它当毕业设计或课程设计的 Java 初学者另一类是学校里真正要搭实验教学信息化、但不想从零踩坑的工程师。我会按「业务建模 → 技术选型 → 代码实现 → 避坑」推着走把表结构、核心流程和参数设置都给你能直接抄的版本。顺带说一句这个题目在大二大三的 Java 课程设计里出现频率极高但网上流传的源码大多是单表 CRUD 堆出来的演示品离“能交付给实验室用”差着十万八千里。我们按能真跑的方案来。2. 先别写代码把实验课的业务对象拆成一张可落地的数据模型很多课程设计翻车的根源不是代码写不出来而是数据模型没想清楚就开始建表。实验教学管理系统和普通的图书管理系统有个本质差异它管理的不只是静态的数据而是一条有状态流转的业务流。学生报名一个实验项目、老师开放预约批次、学生签到入场、提交报告、老师批改打分每一步都是一次状态变更。所以建模的第一步不是画 ER 图而是先把业务角色和核心对象梳理出来。2.1 六个核心业务对象与它们的状态机我通常会把这套系统的业务对象收敛为六张主表外加若干关联表用户学生/教师/管理员、实验课程、实验项目、预约批次、实验报告、成绩记录。这里每个对象都不能只存基本信息必须带上状态字段——状态字段是一个被无数课程设计忽略、但在实战里价值非常大的设计。以预约批次为例它的生命周期至少包含OPEN可预约→LOCKED预约截止开始签到→RUNNING实验进行中→FINISHED实验结束等待报告→ARCHIVED归档完成。这个状态机在后续写业务逻辑时能帮你挡住大量非法操作比如截止后不允许再预约、实验未结束不能提交报告、成绩发布后不能随意修改。如果你不建这个字段后面要么写一堆散乱的 if 判断要么干脆不管导致一个批次可以被反复预约、报告可以无限次覆盖提交。2.2 数据库表设计以预约批次为核心的主表与索引策略下面这套建表 SQL 是我在课程设计里比较推荐的最小可用版本它覆盖了「实验课程 → 实验项目 → 预约批次 → 预约记录 → 实验报告」的完整链路-- 实验课程表 CREATE TABLE lab_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, teacher_id BIGINT NOT NULL, semester VARCHAR(20) NOT NULL, -- 2024-2025-1 status TINYINT NOT NULL DEFAULT 1, -- 1启用 0停用 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_teacher_semester (teacher_id, semester) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; -- 实验项目表 CREATE TABLE lab_project ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, project_name VARCHAR(150) NOT NULL, description TEXT, max_capacity INT NOT NULL DEFAULT 30, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_course_id (course_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; -- 预约批次表 CREATE TABLE lab_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, project_id BIGINT NOT NULL, batch_no VARCHAR(30) NOT NULL, -- 批次号如 20241015-01 start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, capacity INT NOT NULL DEFAULT 30, status VARCHAR(20) NOT NULL DEFAULT OPEN, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_project_time (project_id, start_time) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;这里要特别说明两个设计决策。第一为什么不把容量字段直接放在lab_project而放在lab_session因为同一个实验项目通常会开放多个时间段每个批次的容量可能不同——周一早上 30 人周三下午 20 人这是常态。第二复合索引idx_teacher_semester和idx_project_time是给查询用的教师查“我这学期的课”、学生查“最近可预约的实验”这两个是最频繁且高筛选度的查询条件。等到数据量超过几万条时这两个索引能明显压住回表次数。课程设计里很多人省掉索引数据量小无所谓但交到实验室用一学期后查询报表会越来越慢。2.3 学生预约记录与报告表一对多还是多对多学生和预约批次之间是多对多关系——一个学生可以预约多个批次一个批次有多个学生。所以必须建一张中间表lab_reservation同时把状态和结果挂在中间表上这是关系型数据库建模的标准做法。但很多初学者在这里犯迷糊喜欢把预约记录直接塞进lab_session表里加一个student_ids的字段这是完全错误的会导致后续统计、签到、权限判断全部失控。-- 预约记录表学生-批次中间表 CREATE TABLE lab_reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL, student_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT BOOKED, -- BOOKED / CHECKED_IN / CANCELED checkin_time DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_session_student (session_id, student_id), KEY idx_student_status (student_id, status) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; -- 实验报告表 CREATE TABLE lab_report ( id BIGINT PRIMARY KEY AUTO_INCREMENT, reservation_id BIGINT NOT NULL, file_path VARCHAR(255) NOT NULL, file_size INT NOT NULL, score DECIMAL(5, 2), grade_level VARCHAR(10), -- 优秀/良好/合格/不合格供成绩单映射 submit_time DATETIME, update_time DATETIME, UNIQUE KEY uk_reservation (reservation_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;lab_report和lab_reservation是一对一因为一个学生在一个批次里只交一份实验报告允许覆盖更新但一条记录就够。这里用UNIQUE KEY uk_reservation就把“一学生一批次只能有一条报告”的约束落在数据库层比在 Service 层写 if 判断要可靠得多。两个值得提的点。一是lab_report表里加了grade_level这个字段在成绩归档时非常关键——很多学校的实验成绩不是直接记分数而是按等级折算到课程总评。二是lab_reservation的状态里只管签到和取消批次的整体状态放在lab_session.status两份状态各管各的互不越权。我在实际交付项目时经常看到有人把签到状态放在批次上结果一个批次 30 个学生最后一个签到的把整个批次状态覆盖了这就是典型的职责错位。3. 技术选型与工程骨架用 Spring Boot MyBatis Plus 搭出可维护的起点数据模型定下来之后技术栈的取舍直接决定了这个项目代码写起来是享受还是坐牢。市面上这个题目的源码最泛滥的是 JSP Servlet JDBC 的老古董组合第二泛滥的是 Spring Boot JPA 但把业务全写在 Controller 里的面条代码。我个人的推荐是Spring Boot MyBatis Plus MySQL Redis可选前端用 Thymeleaf 服务端渲染或者 Vue Element UI 都行。3.1 为什么是这个组合从课程设计和真实交付两个维度说先说结论再说理由。Spring Boot 解决了配置地狱内嵌 Tomcat 一键启动这对课程设计来说是决定性的——你不可能让答辩老师在现场给你配一套 Tomcat 数据源。MyBatis Plus 则把单表 CRUD 的样板代码压缩到极低你不用为每张表写一套selectByCondition、insertBatch。但更重要的原因是这套组合的知识能够平滑迁移到真实的实验室管理系统、后台管理系统甚至是微服务的局部模块。JDBC 那套东西学完就只能做课设Spring Boot MyBatis Plus 学完能直接上手公司项目。但这里必须踩一脚刹车。MyBatis Plus 的BaseMapper只提供单表操作跨表查询、复杂报表、动态条件你依然要老老实实写 XML。所以选它不等于放弃 SQL 能力反而要求你把 SQL 写得更熟练。我在代码里所有涉及多表联查的地方都会用自定义 SQL 而不是 Plus 的QueryWrapper原因有二一是联查 SQL 用字符串拼更容易出错XML 里编译器能帮你早点发现语法问题二是后期调索引、优化执行计划的时候XML 里的 SQL 可以直接复制到 Navicat 里跑不用在 Java 代码里翻来覆去找。3.2 标准工程结构从 Controller 到 Mapper 的四层分包下面这个工程结构是我做了几个类似系统之后固定下来的它足够规范也不过度设计src/main/java/com/example/labms ├── controller -- 请求入口只做参数校验和结果封装 ├── service -- 业务逻辑层事务边界在这里 │ └── impl ├── mapper -- MyBatis 数据访问接口 ├── entity -- 数据库实体映射 ├── dto -- 前端请求/响应对象避免实体直接暴露 ├── config -- 配置类WebMvc、拦截器、跨域 ├── common -- 统一返回值、异常处理、常量定义 └── utils -- 文件存储、时间处理等通用工具在开始写业务之前先把统一返回值、全局异常处理和分页参数封装好这三样能让你后面省掉大量重复代码。尤其是全局异常处理课程设计里最常见的翻车现场就是前端拿到一个堆栈跟踪页面用户体验极差。用RestControllerAdvice统一捕获业务异常并转成 JSON 返回这是基本功。3.3 最小可运行工程启动类与 application.yml 的完整参数说明很多人拿到骨架代码第一步就栽在配置上。这里给一份我常用且有注释的配置直接复制即可跑通server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/lab_ms?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root servlet: multipart: max-file-size: 50MB # 实验报告文件上限 max-request-size: 200MB # 一次请求多个文件的总上限 thymeleaf: cache: false # 开发阶段关缓存改页面不用重启 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印 SQL排错利器 map-underscore-to-camel-case: true # 下划线转驼峰映射 global-config: db-config: logic-delete-field: deleted # 全局逻辑删除字段名避免硬删除数据 logic-delete-value: 1 logic-not-delete-value: 0参数说明集中在三处。第一serverTimezoneAsia/Shanghai如果不加MySQL 8.x 驱动会报时区异常这是新人必踩的坑。第二max-file-size和max-request-size要一起调只调前者的话多文件上传还是会触发MaxUploadSizeExceededException。第三MyBatis Plus 的逻辑删除建议从一开始就开实验报告和学生选课记录都是需要留痕的数据物理删除会把老师批改过的成绩单直接抹掉后期回溯完全没有依据。4. 三个核心流程的实现细节预约报名、报告提交与成绩归档数据模型有了、工程骨架起来了现在到了整个系统最见真功夫的部分。实验教学管理系统表面上是多个模块的拼装但真正称得上“核心”的只有三个流程学生预约实验批次、提交实验报告、教师批改与成绩归档。把这三条链路彻底跑通其余模块都是围绕它们打的辅助。4.1 学生预约批次用数据库锁挡住超卖预约流程的第一版实现很容易写成这样先查lab_session的剩余名额判断大于 0 就插入一条lab_reservation。这个逻辑在单机、低并发下没问题但实验课开放选课的时间是高度集中的——周一早上 8 点同时可能有几百个学生点点点剩余名额的判断在毫秒级并发下会出现严重的超卖。课程设计可以不追究但真上线一定会出事故。正确做法是利用数据库的行锁和唯一约束做原子化校验。下面这段代码同时处理了并发扣减和重复预约两个问题Transactional(rollbackFor Exception.class) public void bookSession(Long sessionId, Long studentId) { // 1. 对批次行加悲观锁后续事务必须等当前事务提交后才能读这行 LabSession session labSessionMapper.selectForUpdate(sessionId); if (session null) { throw new BizException(预约批次不存在); } if (!OPEN.equals(session.getStatus())) { throw new BizException(该批次当前不可预约); } // 2. 统计已预约人数排除已取消的 Long bookedCount reservationMapper.countBySessionIdAndStatus( sessionId, ReservationStatus.BOOKED.name()); if (bookedCount session.getCapacity()) { throw new BizException(该批次名额已满); } // 3. 插入预约记录uk_session_student 唯一索引兜底 try { LabReservation record new LabReservation(); record.setSessionId(sessionId); record.setStudentId(studentId); record.setStatus(ReservationStatus.BOOKED.name()); reservationMapper.insert(record); } catch (DuplicateKeyException e) { throw new BizException(请勿重复预约该批次); } }这里对应的selectForUpdate语句要写详细点它决定了锁能否生效SELECT id, project_id, batch_no, capacity, status, start_time, end_time FROM lab_session WHERE id #{sessionId} FOR UPDATE;这段逻辑有三个值得琢磨的参数和细节。第一Transactional必须存在否则selectForUpdate的行锁在每条 SQL 执行完就会释放整个并发控制形同虚设。第二countBySessionIdAndStatus只统计状态是BOOKED的记录把CANCELED排除在外。第三唯一索引uk_session_student是最后一道防线即使并发窗口下两个事务同时通过了容量校验第二个插入也会撞上唯一索引抛出DuplicateKeyException被捕获后转成友好的业务提示。三层防护叠满才能说这个逻辑稳了。4.2 报告提交与覆盖更新文件存储和版本管理的取舍报告提交模块牵扯两个问题文件存哪里、多次提交怎么处理。文件存储的策略我在这个规模的系统里一律推荐存本地磁盘 MySQL 记录元数据不要直接往数据库里塞BLOB也不要一上来就上 OSS——本地磁盘对课程设计和校级小型部署都是最优解。数据库只保存文件的相对路径、大小和上传时间。多次提交的处理则更考验设计判断。实验课的惯例是允许学生在截止日期前反复修改报告——老师不想看学生因为交错了版本而挂科。所以报告表上设置的是submit_timeupdate_time两个字段每次重新上传直接覆盖更新记录不保留历史版本。但系统里要有操作日志至少记录最后三版的操作人、操作时间和文件名方便师生出现争执时有据可查。控制器接收文件的代码有一个关键参数要说明PostMapping(/submit) public R submitReport(RequestParam(reservationId) Long reservationId, RequestParam(file) MultipartFile file) { // 1. 校验预约记录存在的状态 LabReservation reservation reservationMapper.selectById(reservationId); if (reservation null || !CHECKED_IN.equals(reservation.getStatus())) { throw new BizException(当前状态不允许提交报告请先完成签到); } // 2. 校验文件大小这里单位为字节 long MAX_SIZE 50L * 1024 * 1024; // 50MB if (file.getSize() MAX_SIZE) { throw new BizException(文件大小不能超过50MB); } // 3. 按 [学号_批次号_时间戳] 生成存储文件名避免文件名冲突 String originalName file.getOriginalFilename(); String ext originalName.substring(originalName.lastIndexOf(.)); String storedName String.format(%s_%d_%d%s, reservation.getStudentId(), reservation.getSessionId(), System.currentTimeMillis(), ext); String dirPath uploadPath / reservation.getSessionId(); // 实际写入磁盘并落库更新 submit_time / update_time fileStorageService.store(file, dirPath, storedName); reportService.saveOrUpdateReport(reservationId, dirPath / storedName, file.getSize()); return R.success(提交成功); }文件名校验是个隐藏坑只取白名单内的扩展名.doc、.docx、.pdf、.zip对用户上传的文件名做一次清洗过滤防止路径穿越。这里我用的是substring(originalName.lastIndexOf(.))防御性更好一点的做法是再用正则校验文件名中不含../和..\\这种路径穿越虽然旧但在学生群体里传出过真实案例。文件覆盖机制下的一个边界问题是老师已经开始批改的报告学生还在偷偷重新上传批改结果会不会被后面的覆盖打乱解决方法是给lab_report增加一个locked字段老师一旦点开批改入口就置为1此时学生提交接口直接拒绝。这个细节来自真实的使用反馈你不加的话实验室老师会投诉“批到一半学生改了答卷”。4.3 成绩批改与归档事务边界从单条记录扩到整张成绩单最后一个核心流程是教师的批改与归档。这里的事务不像预约那么重并发但它的复杂度在于一条实验成绩的变更往往要联动更新课程总评、触发成绩单重新计算甚至关联到学生是否获得该门课的学分。如果事务边界只划在“更新分数”这一步其他数据就会陷入不一致。下面这段是成绩批改并自动重新计算总评的核心实现Transactional(rollbackFor Exception.class) public void gradeReport(Long reportId, BigDecimal score, String comment) { // 1. 更新报告分数同时把 grade_level 一起算好存起来 LabReport report reportMapper.selectById(reportId); if (report null) { throw new BizException(报告不存在); } report.setScore(score); report.setGradeLevel(mapScoreToLevel(score)); // 分数 → 等级映射 reportMapper.updateById(report); // 2. 重算该学生对应实验课程的总评 LabReservation reservation reservationMapper.selectById(report.getReservationId()); Long studentId reservation.getStudentId(); Long sessionId reservation.getSessionId(); // 3. 这门课总评 所有已完成报告的算术平均 ListLabReport allReports reportMapper.selectByStudentAndCourse(studentId, sessionId); double avg allReports.stream() .mapToDouble(r - r.getScore().doubleValue()) .average() .orElse(0.0); labScoreService.updateTotalScore(studentId, sessionId, BigDecimal.valueOf(avg)); }mapScoreToLevel的映射规则建议做成可配置项而不是写死在代码里不同学校对“优秀”“合格”的分数线要求不同常见的是 90/80/70/60 四档切分private String mapScoreToLevel(BigDecimal score) { double s score.doubleValue(); if (s 90) return 优秀; if (s 80) return 良好; if (s 70) return 合格; if (s 60) return 及格; return 不及格; }事务把“改报告分数、写等级、重算总评”三件事绑在一起任何一步抛异常都会回滚这是成绩数据不出错的底线。同时建议在lab_score表上加updated_at字段并配合一个简单的时间戳缓存避免每次学生查成绩都实时聚合所有报告算平均。数据量一旦到了几千条实时聚合的响应时间会从毫秒级恶化到秒级预计算并定时刷新的策略在管理系统里足够用。5. 从 SQL 到并发五个高频踩坑与排查记录这一章把我做这个方向时实际遇到、以及带学生做课程设计时反复出现的典型问题列出来。每一条都是“现象 → 原因 → 解决”的结构你可以当作排错手册来翻。5.1 现象MyBatis Plus 的 updateById 把不需要更新的字段也更新了有同学反馈调用updateById后一个字段明明没有传新值数据库里的旧值却被覆盖掉了。排查下来是实体类里有一个字段值为null的字段而 MyBatis Plus 默认策略是NOT_NULL才更新——这个默认配置按理不会出事。翻车最深的是另一个场景字段值是空字符串NOT_NULL策略不会拦截空串于是空串被写进了数据库。比如student.major字段没填时传了一条原本有专业信息的记录就被清空了。原因在实体类没做字段级别的更新策略标注。解决方式有两种一是在application.yml里把全局更新策略改为not_empty同时忽略 null 和空串二是在具体字段上用TableField(updateStrategy FieldStrategy.NOT_EMPTY)单独控制。我一般推荐第二种因为全局改会影响所有实体的插入行为。// 单独控制避免影响全局 TableField(updateStrategy FieldStrategy.NOT_EMPTY) private String major;这个问题的隐蔽性在于它不是报错而是静默地覆盖数据。建议在做完报告提交功能后专门写一个“只改成绩”的单元测试验证其他字段不被触碰。5.2 现象定时清理过期批次的任务把未开始的批次误杀了系统里通常会有一个定时任务逻辑是把所有已过end_time且没有报告的批次置为FINISHED。跑了一阵子后老师发现有些刚开放预约的批次消失了。排查代码后发现定时任务的 SQL 条件写的是WHERE end_time NOW()看起来没问题但问题出在没过滤状态——一个批次如果被人为修改过时间老师临时改课它的end_time可能短暂小于当前时间而其状态还是OPEN就被定时任务顺手关闭了。解决方法是定时任务的条件必须同时包含状态和结束时间两条缺一不可“限定status必须为RUNNING或CHECKED_IN才允许自动流转”。这个问题的通用教训是所有定时任务的 WHERE 条件都要把状态字段带进去不要依赖单一时间条件。Scheduled(cron 0 0 2 * * *) // 每天凌晨2点执行 public void archiveFinishedSessions() { // 只在 RUNNING/CHECKED_IN 状态下流转且 end_time 严格小于当前时间 sessionMapper.archiveExpired( LocalDateTime.now(), Arrays.asList(RUNNING, CHECKED_IN)); }5.3 现象学生签到后再取消预约导致实际到场人数被低估预约功能开放了取消入口之后某个批次显示已约 28 人、实际到场 30 人——签到人数超出了容量。原因是一条预约记录的状态从BOOKED更新成CANCELED后countBySessionIdAndStatus不再统计它但该学生已经提前完成了签到checkin_time已经写入。取消操作没有校验“已经签到的不允许取消”也没有在取消时把签到状态一并处理。解决方法是把状态机约束写进 Service而不是依赖前端按钮。已签到的记录取消接口必须拒绝如果确需取消要联动把CHECKED_IN改回CANCELED并清除签到时间。这个场景提醒我们状态的合法流转路径要画在纸上再写代码尤其是从CHECKED_IN出发的路径一定要逐条确认。public void cancelReservation(Long reservationId) { LabReservation r reservationMapper.selectById(reservationId); if (CHECKED_IN.equals(r.getStatus())) { throw new BizException(已签到学生不能取消预约); } r.setStatus(CANCELED); reservationMapper.updateById(r); }5.4 现象上传的 PDF 报告在浏览器里打开是乱码系统里上传的实验报告一部分是 Word 文档一部分是 PDF。前端预览时PDF 打开正常Word 下载后用 WPS 打开出现了文件名乱码。定位后发现是下载时响应头里Content-Disposition的文件名没有做编码处理浏览器拿到后按 UTF-8 解码失败部分系统按 GBK 解码于是乱码。解决方法是给文件名参数做一次 URL 编码前端收到后再解码。具体到 Spring 里可以直接用URLEncoder.encode对文件名做编码再拼进响应头。这个问题不算难但排查起来特别坑——它只在部分浏览器和部分操作系统上复现不统一处理的话会被当成“玄学”。// 注意filename 部分要encode否则中文名下载时乱码 response.setHeader(Content-Disposition, attachment; filename\ URLEncoder.encode(fileName, UTF-8) \);5.5 现象跨天预约的批次在“今日待办”里消失教师首页有一个“今日待办实验批次”的列表查询条件是start_time 今天00:00 AND start_time 今天23:59。有老师反映一个晚上 22:00 开始、第二天凌晨 1:00 结束的实验批次在“今日待办”里始终看不到。原因是这个批次的start_time是当天但end_time跨天了。简单的日期边界查询把它排除了。解决方式是把“今日待办”的查询条件改为“批次起止时间与今天有交集”而不是“开始时间落在今天”。SELECT * FROM lab_session WHERE status IN (OPEN, LOCKED, RUNNING) AND start_time 2024-12-02 23:59:59 -- 下一个周期边界 AND end_time 2024-12-02 00:00:00; -- 与今天有交集这种时间交集查询虽然比直接的日期过滤多写两个条件但在实验课排课经常跨午夜的场景下非做不可。顺带一提时间边界查询的通用规则是凡是统计“某天”的数据统一用[当天00:00, 次日00:00)左闭右开区间SQL 里写成与避免 23:59:59 与 00:00:00 的交接缝隙出漏数据。6. 进阶权限模型、消息通知与数据统计的实用写法核心流程跑通之后系统已经能用了。但站在“能交付”和“好用”之间还有一道坎权限控制是否严谨、消息通知是否到位、统计数据是否拿得出手。这三块往往是课程设计和真实项目拉开差距的地方。6.1 基于拦截器的粗粒度权限 基于注解的细粒度权限这套系统的角色只有三种学生、教师、管理员。我不用 Spring Security 和 Shiro 这种重型框架——对于校级小规模系统它们的过滤器链和配置复杂度带来的收益有限。用拦截器做角色判断就足够了。Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { User currentUser (User) request.getSession().getAttribute(currentUser); if (currentUser null) { response.sendRedirect(/login); return false; } // 教师访问学生接口直接拦截 if (STUDENT.equals(currentUser.getRole()) request.getRequestURI().startsWith(/teacher/)) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return false; } return true; }细粒度的按钮级权限则用一个自定义注解加上简单的切面实现不用引入完整的权限框架。注解值里写权限标识切面里检查当前用户的权限列表。这套方法写起来约 50 行代码但能挡住绝大多数越权操作。6.2 消息通知实验批次变更时把通知发到学生端实验课最怕两件事临时调课和预约截止提醒。把这两条消息触达做到系统里体验会立竿见影。实现上不需要引入消息队列用 Spring 自带的事件机制就能把业务代码和通知逻辑解耦。// 发布批次变更事件 applicationEventPublisher.publishEvent(new SessionChangedEvent(session)); // 监听器里统一处理站内信 EventListener public void onSessionChanged(SessionChangedEvent event) { LabSession session event.getSession(); ListLabReservation reservations reservationMapper.selectBySessionId(session.getId()); for (LabReservation r : reservations) { notificationService.send(实验批次 session.getBatchNo() 时间有调整请留意最新安排, r.getStudentId()); } }这里的价值在于将来如果实验室要接入钉钉、企业微信或者短信渠道只需要修改监听器里notificationService的实现业务代码一行都不用动。6.3 三个最常用的统计报表完成率、评分分布与周活跃度系统交付后学院领导大概率会问三句话这学期实验课到底开了多少门学生到位率是多少成绩分布合不合理所以统计模块别做成大屏炫技先把这三个朴素的报表做好。第一个是实验完成率报表按课程分组统计已提交报告人数除以已预约人数第二个是成绩分布报表按优秀/良好/合格/不合格切片统计每个课程的各等级人数第三个是实验室使用活跃度按周维度统计预约人次和实际签到人次。这三张报表都用简单的GROUP BY HAVING实现。SELECT c.course_name, COUNT(DISTINCT r.student_id) AS total_students, COUNT(DISTINCT CASE WHEN rp.id IS NOT NULL THEN r.student_id END) AS submitted_students, ROUND( COUNT(DISTINCT CASE WHEN rp.id IS NOT NULL THEN r.student_id END) / COUNT(DISTINCT r.student_id) * 100, 2 ) AS completion_rate FROM lab_course c JOIN lab_project p ON p.course_id c.id JOIN lab_session s ON s.project_id p.id JOIN lab_reservation r ON r.session_id s.id LEFT JOIN lab_report rp ON rp.reservation_id r.id WHERE c.semester #{semester} GROUP BY c.id, c.course_name ORDER BY completion_rate DESC;之所以用LEFT JOIN而不是INNER JOIN是因为没有提交报告的学生也要出现在统计里否则完成率会被分母缺失虚高。这个细节如果漏掉报表会给出一个漂亮但失真的完成率被领导拿去一核对就穿帮。6.4 最后一个技巧把系统运行的“后悔药”留足我给这类系统做的最后一个兜底动作是给所有核心业务表都加上created_at、updated_at字段并用 MyBatis Plus 的自动填充功能统一维护。这个习惯让我在排查上面那五个坑的时候能直接从updated_at倒推是哪次操作改动了数据——没有这个字段出问题时只能对着数据库发呆。一个小习惯关键时候救大命。这个方向做到这里已经从“能跑的课程设计”升级成“能交付给实验室用一年的系统”。用它应付答辩绰绰有余拿到真实环境里也不会辜负实验室老师的信任。希望这篇笔记能帮你把每一步都走扎实少熬夜、少踩坑。本文还有配套的精品资源点击获取