
简介这份资源是基于SSM框架的实验室耗材管理系统完整源码面向计算机专业毕业设计学生与Java Web初学者可解决毕设选题、课程设计或实训项目缺少可运行案例的问题。系统采用JSP前端、SSM后端与MySQL数据库压缩包内共2个文件包含1个zip项目压缩包和1个sql数据库脚本整体约11.65MB导入后即可运行省去从零搭建环境的时间。功能覆盖学生、教师、管理员三类角色学生与教师可查询材料编号、耗材名称、规格、所属专业及登记员提交申请并跟踪审核状态支持按编号或名称检索及删除无用申请管理员则负责学生信息等后台管理登录模块支持修改密码与个人资料。目前已有68人学习下载适合需要完整数据库文件、清晰分层结构与现成业务逻辑的读者可直接用于论文撰写、功能演示与二次开发参考。1. 实验室耗材管理为什么总在“对不上账”上翻车试剂盒明明入库了 200 盒月底盘点只剩 173 盒系统里却显示 198 盒某课题组领走两包移液枪头登记表上写的是“领用”库存扣减却走了“报损”通道。这类场景在高校和第三方检测实验室里几乎每周都在上演。基于SSM的实验室耗材管理系统源码要解决的正是这种“耗材流向黑匣子”问题——它把采购入库、领用出库、库存预警、过期提醒这几条链路用一套 Java Web 系统串起来让每一盒耗材从进实验室到被消耗都有迹可循。这套源码的技术底座是 SSM也就是 Spring SpringMVC MyBatis 三个框架的组合。选它而不是 SpringBoot 的原因很实际很多高校课程设计、毕业设计、以及部分中小实验室的内部工具仍然跑在传统 Tomcat 容器上SSM 的 XML 配置方式虽然啰嗦但胜在结构清晰、依赖少、改起来不需要理解自动装配那一套玄学。如果你手上有 Java Web 基础能看懂 web.xml 和 applicationContext.xml这套源码就是可以直接拿来改的骨架。适合谁读正在做 Java 课程设计或毕设、需要一套能跑通且有业务深度的管理系统源码的人实验室管理员想自己搭一套内部耗材台账的人以及想通过一个完整项目理解 SSM 三层架构怎么落地的人。接下来我会按“先跑通、再拆解、后避坑”的顺序把入库、领用、库存扣减、权限控制这几块讲透每一步都给出可复现的配置和代码结构。2. 把 SSM 耗材系统在本地跑起来环境、建库与启动顺序2.1 环境版本怎么选才不翻车SSM 项目最怕的就是版本打架。我一般会锁定一套经过验证的组合而不是追最新版。JDK 用 1.8Tomcat 用 8.5 或 9.0MySQL 用 5.7 或 8.0Maven 用 3.6 以上。Spring 全家桶统一用 5.2.x 或 5.3.xMyBatis 用 3.5.xmybatis-spring 用 2.0.x。这几个版本之间的兼容性在大量课程设计项目里被反复验证过出问题的概率最低。数据库驱动要注意MySQL 8.0 必须用com.mysql.cj.jdbc.Driver并且连接串要带时区和 SSL 参数否则启动就报时区错误。MySQL 5.7 用com.mysql.jdbc.Driver也能跑但建议统一按 8.0 的写法配省得换环境时改来改去。组件推荐版本说明JDK1.8SSM 项目主流选择避免高版本模块化问题Tomcat8.5 / 9.09.0 注意 javax 与 jakarta 包名差异MySQL5.7 / 8.08.0 需改驱动类名和连接参数Spring5.2.x / 5.3.x与 MyBatis 整合稳定MyBatis3.5.x配合 mybatis-spring 2.0.xMaven3.6依赖管理注意阿里云镜像加速2.2 建库建表耗材、库存、领用三张核心表这套系统的数据模型不复杂但字段设计直接决定后面库存扣减会不会出问题。核心表我一般保留三张lab_consumable耗材基础信息、lab_stock库存流水、lab_requisition领用申请。耗材表存名称、规格、单位、预警阈值库存表存每次入库和出库的数量变化领用表存谁领的、领多少、什么时候领、审批状态。-- 耗材基础信息表 CREATE TABLE lab_consumable ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 耗材名称, spec VARCHAR(100) COMMENT 规格型号, unit VARCHAR(20) COMMENT 计量单位, warn_threshold INT DEFAULT 10 COMMENT 库存预警阈值, expire_date DATE COMMENT 有效期, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 库存流水表正数入库负数出库 CREATE TABLE lab_stock ( id INT PRIMARY KEY AUTO_INCREMENT, consumable_id INT NOT NULL, change_num INT NOT NULL COMMENT 变动数量入库为正出库为负, change_type VARCHAR(20) NOT NULL COMMENT IN/OUT/ADJUST, operator VARCHAR(50), remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_consumable (consumable_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 领用申请表 CREATE TABLE lab_requisition ( id INT PRIMARY KEY AUTO_INCREMENT, consumable_id INT NOT NULL, user_name VARCHAR(50) NOT NULL, req_num INT NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待审 1通过 2驳回, apply_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME, INDEX idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表时有两个点容易被忽略。第一lab_stock的change_num用正负号区分出入库而不是用两个字段分别存这样算当前库存只需要SUM(change_num)逻辑简单且不容易漏。第二lab_requisition的status用 TINYINT 而不是字符串查询和索引效率更高前端展示时再映射成“待审/通过/驳回”。2.3 启动顺序与最小验证代码拉下来之后不要急着改业务。先按这个顺序验证环境第一步用 Maven 执行mvn clean package确认依赖全部下载成功、编译无报错第二步把生成的 war 包丢进 Tomcat 的 webapps 目录启动 Tomcat看控制台有没有 Spring 容器初始化异常第三步访问登录页用默认账号通常是 admin/123456 这类登录能进主页说明 SSM 整合没问题第四步手动在数据库插一条耗材记录刷新页面看列表能不能查出来这一步验证的是 MyBatis 映射和数据库连接。# 编译打包 mvn clean package -DskipTests # 部署到 Tomcat假设 Tomcat 在 /opt/tomcat cp target/lab-consumable.war /opt/tomcat/webapps/ # 启动 Tomcat 并观察日志 /opt/tomcat/bin/startup.sh tail -f /opt/tomcat/logs/catalina.out如果启动时报NoClassDefFoundError或ClassNotFoundException九成是 Maven 依赖没打进去检查 pom.xml 里 spring-webmvc、mybatis-spring、mysql-connector-java 这几个坐标是否完整。如果页面能打开但查询报 500看日志里是不是Invalid bound statement那是 MyBatis 的 mapper XML 没被扫描到检查mybatis-config.xml或 Spring 配置里的mapperLocations路径。3. 入库、领用与库存扣减SSM 三层架构怎么落地3.1 Controller 层入库和领用接口怎么写入库和领用是这套系统里最核心的两个写操作。Controller 层的职责很薄接收参数、做基础校验、调用 Service、返回结果。我一般把入库和领用分成两个独立的 Controller 方法而不是塞进一个“库存变更”接口里因为两者的业务校验逻辑差别很大——入库要校验耗材是否存在、数量是否为正领用要校验库存是否充足、申请人是否有权限。Controller RequestMapping(/stock) public class StockController { Autowired private StockService stockService; // 入库耗材ID、数量、操作人 PostMapping(/in) ResponseBody public MapString, Object stockIn(RequestParam Integer consumableId, RequestParam Integer num, RequestParam String operator) { MapString, Object result new HashMap(); if (num null || num 0) { result.put(code, 400); result.put(msg, 入库数量必须大于0); return result; } try { stockService.stockIn(consumableId, num, operator); result.put(code, 200); result.put(msg, 入库成功); } catch (Exception e) { result.put(code, 500); result.put(msg, e.getMessage()); } return result; } // 领用耗材ID、领用人、数量 PostMapping(/out) ResponseBody public MapString, Object stockOut(RequestParam Integer consumableId, RequestParam String userName, RequestParam Integer num) { MapString, Object result new HashMap(); try { stockService.stockOut(consumableId, userName, num); result.put(code, 200); result.put(msg, 领用成功); } catch (Exception e) { result.put(code, 500); result.put(msg, e.getMessage()); } return result; } }这里用ResponseBody返回 JSON前端用 Ajax 调用。参数校验放在 Controller 做基础判断真正的库存充足性校验放在 Service 层因为那需要查数据库。注意入库和领用都传了consumableId而不是耗材名称这是为了避免同名耗材导致的歧义。3.2 Service 层库存扣减的原子性怎么保证库存扣减最容易出的问题是并发。两个人同时领同一批耗材如果先查库存再扣减中间没有锁就会出现超领。我一般用两种方式解决一是数据库行锁在查询库存时加FOR UPDATE二是用乐观锁版本号。对于实验室这种并发量不高的场景行锁足够用实现也简单。Service public class StockServiceImpl implements StockService { Autowired private StockMapper stockMapper; Autowired private RequisitionMapper requisitionMapper; Override Transactional(rollbackFor Exception.class) public void stockOut(Integer consumableId, String userName, Integer num) { // 加行锁查询当前库存 Integer currentStock stockMapper.selectStockForUpdate(consumableId); if (currentStock null || currentStock num) { throw new RuntimeException(库存不足当前库存 currentStock); } // 写领用记录 Requisition req new Requisition(); req.setConsumableId(consumableId); req.setUserName(userName); req.setReqNum(num); req.setStatus(1); // 直接通过简化流程 requisitionMapper.insert(req); // 写库存流水出库为负数 stockMapper.insertStock(consumableId, -num, OUT, userName, 领用出库); } Override Transactional(rollbackFor Exception.class) public void stockIn(Integer consumableId, Integer num, String operator) { stockMapper.insertStock(consumableId, num, IN, operator, 采购入库); } }Transactional注解保证领用记录和库存流水要么都成功要么都回滚。selectStockForUpdate对应的 SQL 是SELECT SUM(change_num) FROM lab_stock WHERE consumable_id ? FOR UPDATE注意FOR UPDATE在SUM场景下锁的是扫描到的行如果表数据量大建议改成先查耗材主表加锁再算流水汇总。3.3 Mapper 层MyBatis 映射与动态 SQLMapper 层是 SSM 里最容易写错的地方。耗材列表查询通常需要支持按名称模糊搜索、按库存状态筛选这时候就要用 MyBatis 的动态 SQL。我一般把查询条件写成一个ConsumableQuery对象在 XML 里用if标签拼接。select idselectByCondition resultTypecom.lab.entity.Consumable SELECT c.*, IFNULL(SUM(s.change_num), 0) AS currentStock FROM lab_consumable c LEFT JOIN lab_stock s ON c.id s.consumable_id where if testname ! null and name ! AND c.name LIKE CONCAT(%, #{name}, %) /if if testwarnOnly ! null and warnOnly true AND IFNULL(SUM(s.change_num), 0) lt; c.warn_threshold /if /where GROUP BY c.id ORDER BY c.create_time DESC /select这里用LEFT JOIN加SUM算出实时库存而不是在耗材表里冗余一个库存字段。冗余字段虽然查询快但每次出入库都要同步更新一旦漏更新就对不上账。用流水汇总的方式库存永远等于所有流水的代数和天然不会错。代价是查询稍慢但实验室耗材表通常几千条以内完全扛得住。warnOnly这个参数用来筛选“低于预警阈值”的耗材前端可以做一个“库存预警”按钮传warnOnlytrue就只显示需要补货的条目。注意 XML 里要写成lt;这是 MyBatis XML 的硬性要求不写就报解析错误。4. 权限、审批与过期提醒让系统真正能用的三个补丁4.1 用拦截器做登录校验和角色区分一套实验室耗材系统如果谁都能改库存那跟 Excel 没区别。SSM 里做权限最轻量的方式是 HandlerInterceptor。登录拦截器校验 session 里有没有用户信息角色拦截器判断当前用户是不是管理员。普通用户只能提交领用申请和查看自己的记录管理员才能审批和直接出入库。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); Object user session.getAttribute(loginUser); if (user null) { // 未登录重定向到登录页 response.sendRedirect(request.getContextPath() /login); return false; } return true; } }拦截器注册在 SpringMVC 配置里用mvc:interceptor指定拦截路径和排除路径。登录页、静态资源、登录接口要排除否则会死循环。角色判断可以在拦截器里根据 session 中的role字段做二次判断也可以放到具体 Controller 方法里用注解实现看项目复杂度决定。4.2 领用审批流从“直接扣减”改成“先审后扣”很多课程设计版本的耗材系统领用是直接扣库存的。实际实验室里领用通常需要导师或管理员审批。改造思路是领用申请先写lab_requisition表状态为 0待审不写库存流水管理员审批通过后状态改为 1同时写一条出库流水驳回则状态改为 2不动物流。这样库存扣减只发生在审批通过那一刻责任清晰。审批接口的 Service 方法要加事务先更新申请状态再写库存流水两步都成功才提交。如果审批时发现库存已经不够了要回滚并提示管理员。这个逻辑用Transactional包住即可注意异常要往外抛不能吞掉。4.3 过期提醒用定时任务扫有效期耗材过期是实验室的隐形成本。系统里lab_consumable表有expire_date字段可以写一个 Spring 定时任务每天凌晨扫一遍把 30 天内过期的耗材查出来写进提醒表或者直接发邮件。SSM 里用Scheduled注解需要开启task:annotation-driven/cron 表达式按需配。Component public class ExpireRemindTask { Autowired private ConsumableMapper consumableMapper; // 每天凌晨2点执行 Scheduled(cron 0 0 2 * * ?) public void checkExpire() { ListConsumable list consumableMapper.selectExpiringSoon(30); for (Consumable c : list) { // 这里可以写提醒表或发邮件 System.out.println(即将过期 c.getName() 有效期至 c.getExpireDate()); } } }selectExpiringSoon的 SQL 用DATEDIFF(expire_date, NOW()) 30 AND expire_date NOW()只查还没过期但快过期的。已经过期的单独查前端用红色标记。定时任务在生产环境要注意别和业务高峰撞上凌晨 2 点是比较稳妥的选择。5. 避坑与排查SSM 耗材系统最常见的 5 个翻车现场5.1 库存扣成负数并发下SUM查询没加锁现象两个人同时领同一耗材库存 10各领 6结果两条都成功库存变成 -2。原因Service 里先SELECT SUM再INSERT两个线程都读到 10都判断充足都写入 -6。解决在查询库存的 SQL 上加FOR UPDATE或者把扣减逻辑改成UPDATE lab_stock SET change_num change_num - ? WHERE consumable_id ? AND (SELECT ...) ?这种数据库层面的原子操作。行锁最简单但要注意事务隔离级别用READ COMMITTED以上。5.2 中文乱码从数据库到前端全链路排查现象耗材名称存进去是“移液枪头”页面上显示“???”。原因可能是数据库字符集不是 utf8mb4也可能是 JDBC 连接串没加characterEncodingutf8还可能是 Tomcat 的server.xml里 Connector 没配URIEncodingUTF-8。解决按数据库 → 连接串 → Tomcat → 前端页面编码的顺序逐层检查。建库时用CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci连接串加useUnicodetruecharacterEncodingutf8Tomcat 8 以上默认 UTF-8 一般不用改。5.3 MyBatis 映射失败Invalid bound statement的三种原因现象启动不报错一调查询就抛Invalid bound statement (not found)。原因一是 mapper XML 的 namespace 和接口全限定名不一致二是 XML 文件没放在 Maven 的 resources 目录下编译后没被打进 classpath三是 Spring 配置里mapperLocations路径写错。解决检查 namespace 是否等于接口包名加类名在 pom.xml 的build里配resources把src/main/java下的 XML 也打包确认mapperLocations用classpath*:mapper/*.xml这种通配写法。5.4 事务不回滚异常被 catch 吞掉现象领用出库时库存流水写失败了但领用记录还是插进去了数据不一致。原因Service 方法里 try-catch 把异常吞了Spring 的Transactional默认只对RuntimeException回滚被 catch 后没重新抛出事务感知不到异常。解决要么不在 Service 里 catch让异常抛到 Controller 统一处理要么 catch 后throw new RuntimeException(e)。另外Transactional要加rollbackFor Exception.class覆盖受检异常。5.5 定时任务不执行注解没生效或 cron 写错现象配了Scheduled但每天凌晨什么都没发生。原因Spring 配置里没加task:annotation-driven/或者类没被 Spring 扫描到或者 cron 表达式写成了 6 位Spring 的 cron 是 6 位不是 Linux 的 5 位。解决确认 Spring 配置文件有 task 命名空间和注解驱动确认定时任务类在 component-scan 范围内cron 用0 0 2 * * ?这种 6 位格式秒 分 时 日 月 周。6. 从能跑到好用库存对账与数据校验的两个进阶技巧6.1 用对账 SQL 定期校验库存一致性系统跑一段时间后最怕的是流水和实际对不上。我习惯加一个对账功能把lab_stock按耗材分组求和和耗材表里如果有的冗余库存字段比对或者和手工盘点数比对。下面这条 SQL 能直接查出所有库存为负或异常的耗材。-- 查出库存为负的耗材说明扣减逻辑有 bug SELECT c.id, c.name, SUM(s.change_num) AS stock FROM lab_consumable c LEFT JOIN lab_stock s ON c.id s.consumable_id GROUP BY c.id HAVING stock 0; -- 查出有领用记录但库存流水缺失的异常单 SELECT r.* FROM lab_requisition r LEFT JOIN lab_stock s ON r.consumable_id s.consumable_id AND s.change_type OUT AND s.create_time BETWEEN r.apply_time AND DATE_ADD(r.apply_time, INTERVAL 1 MINUTE) WHERE r.status 1 AND s.id IS NULL;第一条 SQL 是兜底检查只要出现负数就说明并发或事务有问题必须回头查代码。第二条是核对审批通过的领用单有没有对应的出库流水防止审批环节漏写。这两条我一般做成管理后台的一个“数据体检”按钮管理员点一下就能看到异常列表。6.2 库存预警阈值别拍脑袋用消耗速度反推很多系统里warn_threshold是随便填的 10 或 20结果要么天天报警要么快断货了才提醒。更合理的做法是根据过去 30 天的平均消耗速度来动态算阈值 日均消耗 × 补货周期 安全库存。补货周期从采购申请到入库大概几天安全库存留 20% 缓冲。-- 按过去30天消耗速度推荐预警阈值 SELECT c.id, c.name, ROUND(SUM(ABS(s.change_num)) / 30.0, 2) AS daily_usage, CEIL(SUM(ABS(s.change_num)) / 30.0 * 7 * 1.2) AS suggest_threshold FROM lab_consumable c JOIN lab_stock s ON c.id s.consumable_id WHERE s.change_type OUT AND s.create_time DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY c.id;这条 SQL 假设补货周期 7 天、安全系数 1.2算出来的suggest_threshold可以直接更新回耗材表。跑一段时间后你会发现高频消耗的枪头、离心管阈值会自动调高低频的试剂盒阈值会降下来报警就准多了。6.3 我踩过的最大一个坑别在库存表上做物理删除早期版本里我为了“清理数据”把作废的入库记录直接从lab_stock删了。结果库存汇总瞬间少了一大截因为那条记录虽然作废但它的数量已经参与过之前的汇总。血泪经验库存流水表永远只增不删作废用一条反向流水冲抵而不是DELETE。如果非要标记加一个is_valid字段做逻辑删除所有汇总 SQL 都带上WHERE is_valid 1。这个习惯让我后来做任何账务类系统都没再翻过车。这套 SSM 耗材系统源码的价值不在于代码多复杂而在于它把“入库-领用-审批-预警-对账”这条链路完整跑通了。你可以在此基础上加扫码入库、企业微信通知、耗材分类统计但底层的数据一致性逻辑别动。先把库存流水这条线守住了上层功能怎么加都不会乱。希望帮到你。本文还有配套的精品资源点击获取