接手“基于SSM的体育器材管理系统”这类课题时,别被“管理系统”四个字误导,以为就是简单地在网页上做增删改查。这个题目看起来不起眼,但它是很典型的JavaWeb业务系统,麻雀虽小五脏俱全,非常适合用来验证你对SSM框架的掌握程度。我自己在读书期间做过学院体育部器材借用登记的琐碎工作,后来又把把套流程整理成毕业设计,所以对这个题目的痛点和开发中的坑位都比较熟悉。这篇文章就按我当时的完整开发思路来讲,从需求梳理、技术选型、数据库设计到SSM前后端代码实现,再到答辩演示的准备工作,一次性说清楚。
1. 器材管理系统的需求,其实藏在线下登记的细节里
1.1 用Excel做器材登记到底哪里不行
很多同学做毕设时容易犯一个毛病:拿到题目不看业务,直接建表写代码。器材管理系统如果只做成“器材信息录入后能查询”,那价值就很低,答辩时也经不起老师追问。
先回到真实场景。我当时在体育部帮忙管理器材,情况是:篮球、足球、羽毛球拍、排球、瑜伽垫这些器材放在储物间,登记用一本纸质台账,后来好一点用Excel。借用流程是这样的——学生在微信上或者直接跑过来,说“借一个篮球”,我在纸上写“张三借篮球若干”,归还时画勾。听起来很顺利,但实际使用中问题非常多:
- 借出去的球没人追着要,经常一周后才送回来,中间别人想借却没库存。
- 器材损坏、丢失无法追踪,账面上的数量和实物永远对不上。
- 期末盘点统计“哪个器材被借最多”“某学院学生最常借什么”,纯靠人肉翻记录,费时费力。
- 有人想续借,但记录乱,很难确认原始借出时间。
所以,这个系统的核心价值不是“管器材”,而是管器材的借用状态和流转过程。懂得这一点,功能需求才算真正想清楚。
1.2 功能清单:先分角色,再定流程
我按角色来划分功能,这样页面设计、接口设计都会很清晰。系统里就两类角色,不要贪多:
- 普通用户(学生/教师):注册登录、查看器材列表和库存、发起借用申请、归还器材、查看自己的借用记录。
- 管理员:登录、器材信息管理(增加、修改、删除、上下架)、审核借用申请、处理归还、维护用户列表、查看所有借用记录和数据统计。
核心流程其实就三条:
- 借用流程:用户选择器材 → 提交借用申请 → 管理员审批(或者用户直接借出,根据规模)→ 库存扣减。
- 归还流程:用户提交归还 → 管理员确认 → 库存回补 → 生成历史记录。
- 查询统计流程:分角色查看记录、统计借出次数和排行。
业务不复杂,但每个流程的状态变化一定要清晰:器材是“可借/已借出/维修中”,借用记录是“申请中/已借出/已归还/已逾期”。这些状态字段在后面的模块开发和答辩演示中都会被反复问到。
1.3 从需求到表结构之间还差一个原型图
需求想清楚之后,我建议你先用几分钟画个功能模块图,不需要用Axure那么重,直接在白纸上画矩形框分层就行。当时我画了这样一张模块划分,后面开发基本就是照这个图走的:
- 用户管理模块:注册、登录、个人信息、修改密码。
- 器材管理模块:器材分类、器材信息、库存数量、器材状态。
- 借用管理模块:提交借用申请、审核、归还、续借、逾期标记。
- 统计报表模块:器材借出次数、热门器材排行榜、用户借用排行。
- 系统管理模块:管理员维护基础数据。
画出这个图之后,你会发现数据库表结构几乎是顺理成章出来的。需求分析阶段多花二十分钟,数据库设计阶段就能少改三遍表。
2. 为什么选SSM这个组合,以及项目初始化该怎么做
2.1 在三层架构里,SSM各自负责什么
有人问我,为什么都202x年了还选SSM?其实这个问题的答案在毕业设计场景里很简单:课题给定的是SSM,同时SSM也是最多公司面试题里绕不开的经典组合,它体现的“分层的职责边界”比Spring Boot这种全家桶更明显。
SSM就是由三个框架组合而成:
- Spring:核心IoC容器,负责对象的创建和依赖关系的管理。Service、Controller、Mapper等Bean都由Spring统一管理,要用别的类里的方法,注入即可,不需要new对象。
- SpringMVC:负责Web层的请求分发。从前端页面发来的请求到Controller,由HandlerMapping决定交给谁的哪个方法处理,然后返回视图给前端。它本质上就是MVC模式在Web中的实现。
- MyBatis:负责数据持久层。把数据库查询结果自动映射为Java对象,同时把Java对象参数映射到SQL语句中,实现ORM。
我习惯用一个生活例子解释它的架构:Spring像学校后勤处,统一管理所有部门和人员;SpringMVC像学校门口的传达室,所有来访的人先到这里,由接待人员分发给相应办公室;MyBatis像档案室的档案员,你要什么档案,告诉他条件,他去数据库系统里给你查出来打包好。
2.2 项目目录结构和依赖配置
我用Maven管理项目依赖,目录结构是很标准的SSM项目划分:
src/main/java ├── com.example.sports.controller # Controller层 ├── com.example.sports.service # Service接口 ├── com.example.sports.service.impl # Service实现类 ├── com.example.sports.mapper # MyBatis Mapper接口 ├── com.example.sports.pojo # 实体类 ├── com.example.sports.common # 公共配置类、统一结果封装 └── com.example.sports.util # 工具类(JWT、日期处理等)使用Java 8,依赖版本需要匹配好,不然特别容易踩坑,常见的坑后续会提到。
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <spring.version>5.2.9.RELEASE</spring.version> <mybatis.version>3.5.6</mybatis.version> <mybatis-spring.version>2.0.6</mybatis-spring.version> </properties> <dependencies> <!-- Spring核心 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis核心 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>${mybatis-spring.version}</version> </dependency> <!-- 数据库驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> <!-- 数据库连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.1.22</version> </dependency> <!-- JSON处理和分页插件 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.11.3</version> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-3-mybatis${mybatis.version}</artifactId> <version>1.4.1</version> </dependency> </dependencies>2.3 Spring和MyBatis整合的三个配置文件
SSM项目最容易被大家搞混的就是配置文件,我的做法是分成三个xml文件,各自职责单一:
spring-mvc.xml:扫描Controller层,配置视图解析器和注解驱动。spring-mybatis.xml:扫描Service和Mapper接口,配置数据源、SqlSessionFactory和事务管理器。web.xml:配置SpringMVC的DispatcherServlet和Spring的ContextLoaderListener。
一个容易忽略的细节:Mapper接口的包扫描不要连Controller层一起扫,否则Spring容器初始化时会因为找不到Mapper实现类而报错。我当时就在这上面花了一晚上才排查出来。
下面给一个最精简的spring-mybatis.xml配置:
<context:component-scan base-package="com.example.sports.service" /> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/sports_equipment?useSSL=false&characterEncoding=utf-8"/> <property name="username" value="root"/> <property name="password" value="123456"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <!-- 开启驼峰自动映射,例如 user_name 映射到 userName --> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <property name="mapUnderscoreToCamelCase" value="true"/> </bean> </property> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.sports.mapper"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean>3. 数据库设计:器材借还是典型的“状态流转”业务
3.1 五张核心表的设计思路
做完需求分析后,数据库设计就是整个项目的地基。我当时设计了五张核心表,第一版还加了器材分类表,但发现分类数据极少,就先用一个字符串字段存分类名了,避免表关联过多。
| 表名 | 核心字段 | 说明 |
|---|---|---|
user | id, username, password, real_name, role, dept, phone, status | 用户表,role区分管理员和普通用户,dept存学院/部门名 |
equipment | id, name, category, total_quantity, available_quantity, status, location, description | 器材表,总量、可借量、状态是核心字段 |
borrow_record | id, user_id, equipment_id, borrow_time, expected_return_time, actual_return_time, status, remark | 借用记录表,记录每次借还流程 |
admin | id, real_name, username, password, role | 管理员表,也可并入user表,这里单独拆出来方便权限逻辑 |
repair_record | id, equipment_id, description, status, create_time, finish_time | 器材维修记录表,后续扩展很实用 |
注意,我在建表时没有弄太多外键约束。原因是:MyBatis本身就强调SQL可控,数据库层的外键约束会让插入删除顺序变得很死板。借用记录的 user_id 和 equipment_id 只需要建普通索引即可,逻辑关联放在业务代码里维护。这样查询性能更好,删除器材时也不用考虑外键限制。
3.2 用一段SQL把表结构说清楚
下面给出核心的三张表建表SQL,你可以直接照着改:
CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT '密码,建议MD5或BCrypt加密', `real_name` varchar(50) DEFAULT NULL COMMENT '姓名', `role` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0普通用户 1管理员', `dept` varchar(50) DEFAULT '' COMMENT '所属学院或部门', `phone` varchar(20) DEFAULT NULL, `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB AUTO_INCREMENT=1001 DEFAULT CHARSET=utf8mb4; CREATE TABLE `equipment` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '器材名称', `category` varchar(50) DEFAULT '其他' COMMENT '分类', `total_quantity` int(11) NOT NULL DEFAULT '0' COMMENT '总数量', `available_quantity` int(11) NOT NULL DEFAULT '0' COMMENT '当前可借数量', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1可借 0不可借', `location` varchar(100) DEFAULT NULL COMMENT '存放位置', `description` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category`) ) ENGINE=InnoDB AUTO_INCREMENT=2001 DEFAULT CHARSET=utf8mb4; CREATE TABLE `borrow_record` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `equipment_id` int(11) NOT NULL, `borrow_time` datetime NOT NULL COMMENT '借出时间', `expected_return_time` datetime DEFAULT NULL COMMENT '应还时间', `actual_return_time` datetime DEFAULT NULL COMMENT '实际归还时间', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0申请中 1已借出 2已归还 3逾期 4拒绝', `remark` varchar(255) DEFAULT NULL COMMENT '备注,比如损坏情况', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_equipment_id` (`equipment_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB AUTO_INCREMENT=3001 DEFAULT CHARSET=utf8mb4;表建好之后,有一个细节非常关键:available_quantity(可借数量)是冗余字段。它可以通过“总数量 - 已借出总数”算出来,但为了减少联表查询,我直接把它存到了器材表中。这个冗余设计在中小型系统里是实用的,但也要通过事务来保证一致性。
3.3 器材“可借性”的判断规则
器材能不能借,要考虑两个条件:一是设备状态是“可借”,二是可借数量大于0。条件不足时,前端按钮置灰,后端也要做校验,不能只靠前端拦截。这是答辩时容易考察的点。
4. 核心模块从登录到统计报表的实现思路
4.1 登录与会话权限控制
登录模块是SSM项目里最基础但也最能体现细节的部分。我对密码进行了加盐MD5加密存储,数据库中不要存明文密码。
登录成功后,将用户信息放入Session,同时通过SpringMVC的拦截器统一判断登录状态。接口层面,我用角色字段区分管理员和普通用户,拦截器按角色判断是否能访问某些路径。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) { // 未登录,跳转登录页 response.sendRedirect(request.getContextPath() + "/login"); return false; } // 可以进一步判断角色 return true; } }在spring-mvc.xml中注册拦截器:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/register"/> <bean class="com.example.sports.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>4.2 器材管理模块:增删改查和条件分页
器材管理本质上是一个典型的CRUD模块。要注意的一点是,删除器材不要用物理删除,而是用一个status字段做逻辑删除。因为器材一旦被删除,历史借用记录里的外键就会变成悬挂引用,统计时也无法追溯。
Controller层的写法,以分页查询为例:
@Controller @RequestMapping("/equipment") public class EquipmentController { @Autowired private EquipmentService equipmentService; @RequestMapping("/list") public String list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) String name, @RequestParam(required = false) String category, Model model) { PageHelper.startPage(pageNum, pageSize); List<Equipment> list = equipmentService.queryEquipmentList(name, category); PageInfo<Equipment> pageInfo = new PageInfo<>(list); model.addAttribute("pageInfo", pageInfo); model.addAttribute("name", name); model.addAttribute("category", category); return "equipment/list"; } }Service层里要校验参数、做空值处理。MyBatis的Mapper中要写动态SQL,让名称、分类这些条件都是可选参数:
<select id="queryEquipmentList" resultType="com.example.sports.pojo.Equipment"> SELECT * FROM equipment <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="category != null and category != ''"> AND category = #{category} </if> </where> ORDER BY create_time DESC </select>4.3 借出、归还、续借的Service层逻辑
这是整个系统的重头戏。先说借出。用户提交借用申请后,管理员审核,审核通过后才真正扣减库存。这里要注意的是,库存扣减不能用“先查询,后更新”的方式,而应该用一条带条件判断的UPDATE语句,保证并发安全。
@Override @Transactional public boolean borrowEquipment(Integer userId, Integer equipmentId, Integer borrowDays) { if (borrowDays <= 0 || borrowDays > 30) { throw new ServiceException("借用天数必须在1到30天之间"); } // 原子扣减:只有可借数量大于0时才更新成功 int rows = equipmentMapper.decreaseAvailableQuantity(equipmentId); if (rows == 0) { throw new ServiceException("库存不足或器材不可借"); } // 插入借用记录 BorrowRecord record = new BorrowRecord(); record.setUserId(userId); record.setEquipmentId(equipmentId); record.setBorrowTime(new Date()); record.setExpectedReturnTime(DateUtils.addDays(new Date(), borrowDays)); record.setStatus(1); borrowRecordMapper.insert(record); return true; }对应的Mapper SQL是这样:
<update id="decreaseAvailableQuantity"> UPDATE equipment SET available_quantity = available_quantity - 1 WHERE id = #{equipmentId} AND available_quantity > 0 AND status = 1 </update>归还的Service层逻辑正好相反,但还多了一个判断:判定是否逾期。逾期了就更新状态为“逾期”,而不是“已归还”。
@Override @Transactional public boolean returnEquipment(Integer recordId) { BorrowRecord record = borrowRecordMapper.selectById(recordId); if (record == null || record.getStatus() != 1) { throw new ServiceException("借用记录不存在或已归还"); } // 回补库存 equipmentMapper.increaseAvailableQuantity(record.getEquipmentId()); // 更新记录 record.setActualReturnTime(new Date()); if (record.getActualReturnTime().after(record.getExpectedReturnTime())) { record.setStatus(3); // 逾期归还 } else { record.setStatus(2); // 正常归还 } borrowRecordMapper.updateStatus(record); // 检查逾期是否还需要额外罚款处理,可后续扩展 return true; }这一块特别值得在答辩时展开。因为借还逻辑直接涉及事务、并发、状态流转,面的老师也最喜欢从这个模块展开追问。
4.4 统计报表的聚合查询
统计报表模块看起来很“高大上”,但实际操作起来就是几个聚合SQL。热门器材排行可以这样写:
SELECT e.name, COUNT(br.id) AS borrow_count FROM borrow_record br LEFT JOIN equipment e ON br.equipment_id = e.id WHERE br.status IN (1, 2, 3) GROUP BY e.id, e.name ORDER BY borrow_count DESC LIMIT 10;用户借用排行、分类统计等都是类似的思路。把这些报表做到Controller后返回给前端页面,用一个ECharts柱状图展示,效果会很好。
5. 开发中真正让人头疼的四个问题
5.1 并发借出导致器材超借
这是开发中非常实际的问题。最初我写的借出逻辑是“先查库存,再判断,再UPDATE”,但全校体育课集中选课那段时间,多个用户同时借同一批器材,就出现过把库存执行成负数的情况。
问题根源很简单:查询和更新之间不是原子操作。先后有两个请求同时查到available_quantity=1,都认为可以借出,结果一个人借成功后,另一个人又把库存减成了0甚至负数。
解决办法就是在SQL层面做“乐观锁”式的更新:
UPDATE equipment SET available_quantity = available_quantity - 1 WHERE id = #{equipmentId} AND available_quantity > 0如果受影响行数为0,说明库存不足,直接抛异常。这就是我在4.3节里写的那种方式。它比“查了再判断”多不到一行代码,但彻底解决了超借问题。
5.2 日期时间处理上的低级错误
借用时间和归还时间都是核心数据,但日期问题的坑非常多。我当时踩过的一个典型坑是:在Controller中直接用了new Date()传给前端,返回到页面上显示的是“Wed Jun 15 14:22:10 CST 2022”这种格式,前端需要格式化才能友好显示。
还有一个很隐蔽的问题:MySQL时区。连接串里如果没有加serverTimezone=Asia/Shanghai,而数据库服务器的时区是UTC,拿出来的时间会和本地时间差8个小时。特别是借出时间、逾期判断都会受影响。我的方法是:
- 数据库连接串统一设置时区;
- 审核和借入借出时间在Service层统一用
new Date(); - 前端展示时通过JSP标签或Vue过滤器格式化。
5.3 MyBatis里#{}和${}混用引发的SQL问题
MyBatis中,#{}会预编译,生成占位符?,安全的;${}是直接拼接字符串,有SQL注入风险。我见过不少人写排序字段或表名时想用${}实现动态排序,结果被注入了非法条件。
器材查询模块中如果允许用户自定义排序字段,我的做法是白名单校验:
private static final Set<String> SORT_WHITELIST = new HashSet<>(Arrays.asList("name", "create_time", "available_quantity")); public String checkSortField(String sortField) { if (!SORT_WHITELIST.contains(sortField)) { return "create_time"; } return sortField; }5.4 分页插件与SQL性能
SSM项目里我建议直接用PageHelper,它能省掉大量手写分页的逻辑。但要记住一个使用规则:PageHelper.startPage() 后面必须紧跟第一条查询语句,中间不能夹带其他查询。如果你在startPage之后先执行了一次安全检查或日志Mapper查询,分页就会作用在错误的SQL上。
还有一点是,PageHelper生成的count语句,在表数据量大的时候会占用一些时间,但母校体校正门课也就几千条借用记录,完全不用担心这个。
6. 毕业设计和答辩时的演示安排
6.1 演示流程设计:不要让评委看你敲代码
项目做完了,答辩演示同样重要。先准备演示数据,把器材库里的记录补到30条以上,借用记录补到50条以上,有正常归还、有逾期、有维修状态,数据越丰富,统计图表效果越好。千万不要用空数据库演示,也不要在评委面前敲代码。
我的建议是准备两条演示主线:
- 用户流程线:注册/登录 → 浏览器材 → 发起借用 → 管理员审核 → 查看我的借用记录 → 提交归还。
- 管理流程线:登录管理员账号 → 器材管理(增删改查)→ 借用审核 → 归还处理 → 查看统计报表。
演示时要突出借还闭环和库存变化:借用之后可借数量减少了,归还之后可借数量恢复了。这是最能体现你对业务理解的地方。
6.2 答辩常见问题的准备方向
根据我自己答辩和帮同学模拟答辩的经验,把这些问题提前准备好:
- 为什么用SSM而不用Spring Boot?(回答要点:SSM框架边界清晰,适合理解原理;Spring Boot是一种封装和简化,并不是另一个框架)
- 数据库表和表之间关系是什么?为什么不用外键?
- 用户提交借用后库存是怎么扣减的?并发时怎么防止超借?
- 如果器材损坏了,系统怎么处理?(回答要点:维修记录表,状态改为维修中)
- 角色权限是怎么控制的?(回答要点:拦截器+Session角色判断)
6.3 如果想在答辩中让项目更出彩,可以扩展的功能点
如果做完上面这些还有余力,我建议做两个小而精的扩展:一是给借用记录增加“逾期未还自动标记”的定时任务,用Spring的@Scheduled就能实现;二是增加简单的ECharts可视化统计报表,把器材分类、热门排行、每月借出趋势用图表展示出来,视觉上会非常加分。
这两个扩展不会增加太多代码量,但会让项目从“一个作业”变成“一个完整的有业务深度的系统”,而且答辩时非常容易引导老师往你擅长的方向提问。
如果你正准备做这个题目,我不建议去网上随便下载一套看不懂的源码交差。按上面这个思路,自己把表建出来、把借还流程跑通,再认真把并发扣库存和统计这两个点做好,这个项目就已经超过周围大多数人。我自己当时做这套系统最大的体会是:毕设的重点不是代码量,而是对业务闭环的理解和对常见问题的把握,把这些想明白,答辩的时候你会非常从容。