基于SSM的实验室设备管理系统:从设备台账到全流程审批
2026/9/16 18:10:57 网站建设 项目流程

简介:一套基于SSM架构的实验室设备管理系统毕业设计资料,适合计算机相关专业学生用于课程设计、毕业设计或Java Web开发练习。项目采用面向对象设计思想,完成设备与用户等核心实体的封装,并针对数据库操作做了安全性与扩展性优化,技术栈涵盖Spring、Tomcat与MySQL。压缩包共65个文件,大小约15.85MB,其中包含38个PNG截图、4个JPEG图片及17个XML配置/文档文件,另有Word文档、嵌入式对象等素材,可清晰看到系统运行界面、项目配置和参考文档。配套资料内含项目完整代码、数据库脚本及报告,可直接导入开发环境运行调试,也可参照代码结构快速理解SSM项目的分层写法。目前已有912人学习浏览,适合希望快速搭建同类管理系统或参考完整毕设写法的开发者。

1. 基于SSM的实验室设备管理系统:把设备台账从Excel里解放出来

基于SSM的实验室设备管理系统,在毕业设计里出现频率极高:业务边界清晰,管理对象是设备、借用记录、维修记录,数据关系不复杂,却要有完整的增删改查和状态流转,把Spring、SpringMVC、MyBatis三个框架各自擅长的部分都压进了同一套代码。系统的重点不是把前端做得花哨,而是把设备台账从Excel挪进数据库,让每次借用、归还、审批都留下可追溯的数据痕迹。

使用者分三类:管理员维护台账、处理借用审批;实验员提交申请、登记维修;学生只读查询。对毕业生来说,这套题的价值是把SSM的依赖注入、请求映射、动态SQL串成一套能跑的真代码,论文里的E-R图和模块图都能对上。

2. 实验室设备管理系统的SSM框架分层与容器配置

2.1 按设备业务划分SSM三个层次的工作边界

Spring负责管理对象和事务,SpringMVC负责请求分发和参数绑定,MyBatis负责SQL执行和结果映射。落到设备管理这个域里,边界要具体到类和方法的级别才算数。常见做法是工程分三层:EquipController只做参数接收、简单校验和JSON返回,不写任何状态判断;EquipService处理借用审批、归还入库这类流程,事务边界和状态校验都在这层;EquipMapper接口配XML文件,只关心SQL和结果集映射。

如果设备状态判断写在Controller里,后续调整审批流程时要同时改两层;如果Service里直接拼SQL,MyBatis的动态SQL就白配了。分层不是写论文用的概念,是让改需求时只动一个文件。比如新增「设备报废」功能,Controller加一个入口方法,Service加一个带事务的报废方法,Mapper加一条更新SQL,三个文件各改各的,互不牵连。

核心类职责最容易越界的地方
表现层EquipController参数绑定、权限判断、JSON封装写业务状态判断
业务层EquipServiceImpl事务控制、状态流转、借用校验拼SQL、直接操作连接
持久层EquipMapper + XMLSQL执行、结果集映射在Mapper里写循环调用

2.2 applicationContext.xml中的组件扫描、数据源与事务配置

SSM毕业设计工程体量不大,用XML配置比纯注解直观,答辩时也好讲。核心配置集中在applicationContext.xml和spring-mvc.xml两个文件里,前者管数据源、SqlSessionFactory、Mapper扫描和事务,后者只管Controller。

<context:component-scan base-package="com.lab"> <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/> </context:component-scan> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/lab_equip?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="root"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.lab.mapper"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>

组件扫描里用exclude-filter把@Controller排除掉,Controller统一交给spring-mvc.xml扫,这是双容器配置的标准写法。最常翻车的三个位置:一是MySQL驱动类,8.x用com.mysql.cj.jdbc.Driver且连接串要带serverTimezone,5.x用com.mysql.jdbc.Driver,版本写错启动直接ClassNotFound;二是mapperLocations路径,写成classpath:mapper/*.xml后XML必须真实存在,否则启动不报错,运行到第一个Mapper方法时报Invalid bound statement (not found),这是SSM项目里伪装得最好的隐形错误;三是MapperScannerConfigurer负责把接口注册成Bean,接口上不用再加@Mapper,事务管理器漏配或没写tx:annotation-driven,是"事务不回滚"的第一原因。

mybatis-config.xml里至少要把mapUnderscoreToCamelCase打开,create_time才能自动映射到createTime字段,否则实体类每个字段都要手动配resultMap。

2.3 spring-mvc.xml与DispatcherServlet的请求映射

<!-- spring-mvc.xml --> <mvc:annotation-driven/> <context:component-scan base-package="com.lab.controller"/> <mvc:default-servlet-handler/>
<!-- web.xml --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>

url-pattern写"/"时所有请求先进DispatcherServlet,静态资源js、css靠mvc:default-servlet-handler放行。毕业设计高频翻车点是把url-pattern写成"/*",JSP渲染完返回时又被DispatcherServlet拦一道,视图解析直接失败,页面永远白板。另一个常见问题是双容器重复扫描:applicationContext.xml和spring-mvc.xml都扫com.lab.controller,Controller会被注册两次,事务注解放在Controller上的项目还会出现事务不生效。约定就一条:Controller只在spring-mvc.xml里扫,其余@Service、@Repository、@Component全留给applicationContext.xml。

提示:启动日志看到"Invalid bound statement",先查mapperLocations路径和XML的namespace,这两处命中率最高,不要先怀疑SQL语法。

3. 实验室设备管理系统的核心表设计与MyBatis映射

3.1 设备表、借用记录表与用户表的字段定义

设备管理的数据模型不复杂,三张核心表就能讲清楚业务:equip_info存设备台账,borrow_record存借用全流程,sys_user存三类使用者。关键设计决策是「状态」用TINYINT存数字而不是直接存"空闲""借用中"这类中文,一是排序过滤快,二是改状态文案不用动数据库。

CREATE TABLE equip_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, equip_no VARCHAR(32) NOT NULL COMMENT '设备编号', name VARCHAR(64) NOT NULL COMMENT '设备名称', category_id INT NOT NULL COMMENT '分类ID', location VARCHAR(64) COMMENT '存放位置', status TINYINT NOT NULL DEFAULT 1 COMMENT '1空闲 2借用中 3维修 4报废', price DECIMAL(10,2), buy_date DATE, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_equip_no (equip_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, equip_id BIGINT NOT NULL COMMENT '设备ID', user_id BIGINT NOT NULL COMMENT '借用人ID', apply_time DATETIME DEFAULT CURRENT_TIMESTAMP, plan_return_time DATETIME NOT NULL COMMENT '计划归还时间', actual_return_time DATETIME COMMENT '实际归还时间', status TINYINT NOT NULL DEFAULT 1 COMMENT '1待审批 2借用中 3已驳回 4已归还', approver_id BIGINT COMMENT '审批人ID', approve_time DATETIME COMMENT '审批时间', remark VARCHAR(255), KEY idx_equip_id (equip_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

equip_no加了唯一索引,它是设备台账的天然业务编号,但VARCHAR做主键会让外键变长,所以表内主键用自增id,equip_no只做唯一约束。borrow_record里的status和equip_info里的status是两套枚举,含义完全不同,联查时最容易看混的就是这个。create_time用DEFAULT CURRENT_TIMESTAMP自动填充,省掉Service里手动new Date()的重复代码。sys_user表相对简单,字段就是id、username、password、real_name、role、dept,role用1管理员、2实验员、3学生区分权限。

两张表的status对应关系:

status值含义维度
equip_info1/2/3/4空闲/借用中/维修/报废设备维度
borrow_record1/2/3/4待审批/借用中/已驳回/已归还单据维度

3.2 设备分页查询的动态SQL与PageHelper接入

设备列表是主视图,查询条件一般三个:设备名称模糊匹配、状态下拉筛选、分类筛选。分页在SSM里最常用的方案是PageHelper,一个拦截器自动拼LIMIT。注意startPage和Mapper调用之间不能插入任何其它数据库操作,PageHelper基于ThreadLocal保存分页参数,中间隔一次查询,分页参数就被那次查询消费掉,症状是列表不分页、一次性返回全部数据。

<select id="selectEquipPage" resultType="com.lab.entity.EquipInfo"> SELECT id, equip_no, name, category_id, location, status, price, buy_date FROM equip_info <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> </where> ORDER BY create_time DESC </select>
PageHelper.startPage(pageNum, pageSize); List<EquipInfo> list = equipMapper.selectEquipPage(name, status, categoryId); PageInfo<EquipInfo> pageInfo = new PageInfo<>(list);

标签自动处理AND前缀:条件都不满足时不生成WHERE,第一个条件成立时自动去掉开头的AND。name用CONCAT拼%而不是写'%#{name}%',是因为#{}在预编译阶段被当作参数占位符,直接写在字符串里会被当成普通字符,匹配结果永远是空集。接口方法带多个参数时,每个参数必须加@Param("name")这类注解,否则XML里的#{name}引用不到参数,运行时报BindingException。PageInfo封装了total、pageNum、pageSize和list,Controller把它塞进Result返回,前端拿pageInfo.list渲染表格,拿total渲染分页条。

3.3 借用记录多表联查的resultMap与列名冲突

借用列表要同时显示设备名称和借用人姓名,必须联查equip_info和sys_user。多表联查最怕两件事:列名重复和字段对不上。两张表都有id和create_time,select列不写别名,结果集映射时后面的值覆盖前面的,取出来的字段全是错的。

<resultMap id="borrowVOMap" type="com.lab.vo.BorrowVO"> <id property="id" column="id"/> <result property="equipId" column="equip_id"/> <result property="equipName" column="equip_name"/> <result property="userName" column="user_name"/> <result property="status" column="status"/> <result property="applyTime" column="apply_time"/> <result property="planReturnTime" column="plan_return_time"/> </resultMap> <select id="selectBorrowPage" resultMap="borrowVOMap"> SELECT r.id, r.equip_id, e.name AS equip_name, u.real_name AS user_name, r.status, r.apply_time, r.plan_return_time FROM borrow_record r LEFT JOIN equip_info e ON r.equip_id = e.id LEFT JOIN sys_user u ON r.user_id = u.id ORDER BY r.apply_time DESC </select>

resultMap比resultType更合适,BorrowVO里的equipName、userName对应别名列,用resultType=Map虽然也能拿数据,但前端解析散列的Map远不如解析有getter的VO方便。LEFT JOIN保证borrow_record有一条就返回一行,设备或用户被删时联查字段为null,单据不会丢。答辩常问「为什么不用JOIN」,答案就一条:借用记录是主单据,查询不能因为关联表数据缺失而消失。

4. 设备借用审批与状态流转的SSM事务实现

4.1 借用状态流转的校验规则与状态机定义

borrow_record的状态流转是个典型小状态机:待审批可以驳回变成已驳回,可以审批通过变成借用中,借用中只能归还变成已归还。毕业设计里最容易出问题的写法是Service里到处写if (status == 1)这类魔法数,状态一多就分不清数字含义。先把状态收敛成常量,代码按语义引用。

public class BorrowStatus { public static final int PENDING = 1; public static final int BORROWED = 2; public static final int REJECTED = 3; public static final int RETURNED = 4; }
当前状态允许动作目标状态
待审批(1)审批通过借用中(2)
待审批(1)审批驳回已驳回(3)
借用中(2)设备归还已归还(4)

审批方法的校验顺序固定三步:先查记录是否存在;再判当前状态是否待审批;最后在事务里同时更新设备状态和单据状态。第一步和第二步防重复处理,第三步防并发冲突。只判断record是否为null、不检查status,同一张单被提交两次审批时,第二次仍会进入更新逻辑,把已驳回的单又改成借用中。

4.2 审批通过时用CAS条件更新防止设备并发借出

同一台设备被两个人同时申请,不能两人都审批通过。常见做法是审批通过时抢设备状态:UPDATE影响行数为1说明抢到,影响行数为0说明设备已被占用或已报废。

@Transactional(rollbackFor = Exception.class) public void approve(Long recordId, Integer approveResult, Long approverId) { BorrowRecord record = borrowMapper.selectById(recordId); if (record == null || record.getStatus() != BorrowStatus.PENDING) { throw new BusinessException("申请记录不存在或已被处理"); } if (approveResult == 1) { int rows = equipMapper.updateStatusIfMatch( record.getEquipId(), EquipStatus.FREE, EquipStatus.BORROWED); if (rows == 0) { throw new BusinessException("设备当前不可借用,审批失败"); } borrowMapper.updateStatus(recordId, BorrowStatus.BORROWED, approverId, new Date()); } else { borrowMapper.updateStatus(recordId, BorrowStatus.REJECTED, approverId, new Date()); } }
UPDATE equip_info SET status = #{newStatus} WHERE id = #{equipId} AND status = #{expectStatus}

先更新设备再更新单据,顺序不能反。设备更新成功而单据更新失败时,事务回滚把设备状态一并还原;反过来先改单据再抢设备,设备抢失败抛异常回滚的是单据,日志里看到单据已审批、设备却是空闲,容易误判。updateStatusIfMatch返回0的分支必须抛异常而不是返回提示,因为@Transactional默认只对RuntimeException回滚,返回0本身不触发回滚。updateStatusIfMatch和updateStatus的Mapper接口方法都要写@Param("equipId")、@Param("newStatus")这类注解,多参数不加@Param是MyBatis BindingException的重灾区。

4.2.1 自调用导致事务失效的排查

同一个类里approve方法调用本类的另一个@Transactional方法,事务不生效。Spring事务基于代理实现,自调用走的是this指向的原对象,代理拦不住。典型场景是把归还逻辑抽成returnEquip方法并在approve内部直接调用。解决方式要么把returnEquip放到另一个Service里注入调用,要么通过AopContext.currentProxy()取代理对象。把带@Transactional的方法调用链过一遍,凡是this.xxx()调用另一个事务方法的,都是隐患。

4.3 Controller层参数校验与统一JSON返回结构

Controller做参数格式校验,业务规则校验放Service,这个分工要立住。审批接口接收recordId、approveResult、approverId,approveResult必须限定0或1,否则非法值落到Service的if判断里,返回语义含糊的错误。

@RestController @RequestMapping("/borrow") public class BorrowController { @Autowired private BorrowService borrowService; @PostMapping("/approve") public Result approve(@RequestParam Long recordId, @RequestParam Integer approveResult, @RequestParam Long approverId) { if (recordId == null || (approveResult != null && approveResult != 0 && approveResult != 1)) { return Result.fail("审批参数不正确"); } try { borrowService.approve(recordId, approveResult, approverId); return Result.ok(); } catch (BusinessException e) { return Result.fail(e.getMessage()); } } }
public class Result { private int code; private String msg; private Object data; // 构造函数与getter/setter省略 public static Result ok() { return new Result(200, "success", null); } public static Result ok(Object data) { return new Result(200, "success", data); } public static Result fail(String msg) { return new Result(500, msg, null); } }

前端只认Result的code和msg,正常返回200,业务异常返回500,data只在查询接口用。Controller用try-catch包住Service调用,把BusinessException转成Result.fail,避免SpringMVC把异常回成500错误页,那样前端拿到的是HTML而不是JSON,联调时最磨人。@RequestParam接收的是表单格式参数,前端用axios的POST加application/json时要用@RequestBody接一个DTO,两种方式混用会出现参数全是null。

5. 用POI导出设备台账并核对SSM工程自检项

设备台账导出是管理员模块标配,答辩时被问的概率也高。SSM里导出一般用Apache POI,链路是Service查出全部设备,Controller设置下载响应头,POI把数据写进工作簿,最后workbook.write到response.getOutputStream()。导出接口不走视图解析器,方法声明返回void。

@RequestMapping("/export") public void export(HttpServletResponse response) throws IOException { List<EquipInfo> list = equipService.listForExport(); response.setContentType("application/vnd.ms-excel"); response.setHeader("Content-Disposition", "attachment;filename=equip.xlsx"); try (XSSFWorkbook workbook = new XSSFWorkbook()) { Sheet sheet = workbook.createSheet("设备台账"); String[] headers = {"设备编号", "名称", "状态", "位置", "价格"}; Row row0 = sheet.createRow(0); for (int i = 0; i < headers.length; i++) { row0.createCell(i).setCellValue(headers[i]); } workbook.write(response.getOutputStream()); } }

try-with-resources处理Workbook关闭,避免导出频繁时文件占用报错。Content-Disposition里的文件名带中文时要用URLEncoder.encode转一遍,否则Chrome下保存文件名乱码。状态列导出去是数字1/2/3/4,答辩常被问,导出前用Map把status翻译成"空闲/借用中/维修/报废"再写单元格,一行代码的功夫。

提示:自检清单——启动报ClassNotFound查MySQL驱动版本;运行报Invalid bound statement查mapper.xml的namespace和路径;页面404查url-pattern和Controller包扫描;事务不回滚查@Transactional位置和自调用;乱码查连接串characterEncoding=utf8及Tomcat的URIEncoding。

导出联调验证不看页面,打开F12的Network面板,点导出后看响应头里有没有Content-Disposition。有,说明POI导出和响应下发都正常;没有,则多半是Controller抛异常被默认异常处理器拦了,去控制台翻堆栈比反复点按钮快。顺手检查equip_info的unique key和borrow_record的idx_equip_id索引有没有建全,数据量过千之后借用列表联查变慢,十有八九是漏了这两个索引。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询