相信每个大学里待过一段时间的人,都有过这种经历:上午连着上四节课,手机电量掉到20%以下,下一节还要换教学楼,一路上却找不到一个能救急的插座。共享充电宝确实解决了一部分需求,但放到校园这个特定场景里,商业品牌的铺设密度、计费规则、押金策略,跟学生的实际使用习惯之间是有明显错位的。我当时决定把“基于SSM的校园充电宝租借管理系统的设计与实现”作为毕设选题,出发点其实很简单:找一个需求足够清晰、业务闭环足够完整的场景,把一套Web系统从需求分析、数据库建模、框架搭建到编码调试的整个链路跑通。这个题目看起来是典型的“管理系统”,但它里面涉及到的设备状态管理、并发租借、计费结算、统计报表,都是真实业务里绕不开的点,做成一个完整项目后,不管是毕业答辩还是写进简历,都有东西可讲。
这篇文章我会把完整的项目拆解过程、技术选型思路、数据库设计、核心模块实现,以及我在实际开发中踩过的坑都整理出来。适合正在准备毕设或课设、想系统了解SSM项目从零到一的同学,也适合想用“一个完整业务场景”来巩固Spring、SpringMVC、MyBatis三件套的初级开发者。内容不绕弯子,直接按做项目的顺序讲。
1. 项目定位:先搞清楚这个系统到底要解决什么问题
1.1 校园充电宝租借和商业场景有什么不同
很多人在选题时会直接照搬商业共享充电宝的模式,但校园场景有它自己的特殊性,这些特殊性决定了系统设计上的很多细节。
第一是用户群体相对固定。校园里的使用者基本是在校学生和教职工,账号体系可以跟学号、校园卡绑定,不需要像商业App那样做复杂的实名认证和多端注册流程。这意味着系统可以把“用户注册”做得更轻,而把“信用体系”做得更重——比如多次逾期未归还的学生,可以限制其租借权限,这套机制在校园场景里操作起来比商业场景容易得多。
第二是高峰时段非常集中。上课前后、图书馆开闭馆时间,会出现大量学生同时扫码借还的情况,这时候一批充电桩和充电宝的状态并发更新是系统压力最大的时刻。做压力测试时往往不是均匀负载,而是典型的“脉冲式”流量,这直接影响了你对数据库锁、接口超时时间的取舍。
第三是归还策略更复杂。校园里的充电桩会分布在图书馆、食堂、教学楼、宿舍楼等多个位置,学生借走充电宝后,可能回到另一栋楼归还。系统必须支持跨点位归还,并且在订单结算时,根据借出点和归还点生成记录,方便管理员后续调整各点位的投放数量。
第四是计费规则要兼顾公益和运营。校园充电宝不适合照搬商业场景“1小时3-5元”的定价,普遍的做法是“前30分钟免费、超出后按小时计费、每日封顶”的组合规则。这种多段阶梯计费对订单结算模块提出了额外要求,后面我会详细讲实现方式。
1.2 角色划分与核心业务闭环
这个系统围绕两类角色构建,我把用例直接画在纸上,然后逐步细化成功能清单。
学生(前端用户):注册登录、余额充值、扫码借充电宝、查看附近充电桩与空闲情况、发起归还、查看历史租借订单、处理逾期欠费、向管理员报备设备故障。
管理员(后台用户):维护充电桩和充电宝的基础信息、管理设备状态(空闲、租借中、维修中、电量过低)、查看实时租借记录、处理用户的异常订单和报备、配置计费规则、查看各点位使用数据和营收统计。
核心业务闭环可以串成一条线:学生扫码 → 创建租借订单 → 充电宝状态由“空闲”变为“租借中”→ 使用中 → 归还扫码 → 充电宝入桩并更新状态 → 系统按规则计算费用 → 余额扣款或生成欠费记录 → 订单关闭。
这条链路由始至终只有一个核心原则:订单状态和设备状态必须严格保持一致。我这个项目里遇到的不少Bug,追溯到最后都是这两个状态没同步导致的,所以设计的时候就要把它当成头等大事来处理。
1.3 为什么到了2026年还选SSM
这个题目经常被问到“为什么不用Spring Boot”。我的回答有两层。第一层是针对毕设/课设本身的:Spring Boot虽然启动快、配置少,但很多约定大于配置的东西被封装得太深了,答辩时老师深问几个问题,比如自动配置原理、内嵌Tomcat机制、Starter的加载过程,如果平时只是跟着脚手架写CRUD,很容易答不上来。SSM是另一条路径,Spring的IoC和AOP、SpringMVC的请求流转、MyBatis的SQL映射关系都是显式可见的,每一行配置你都知道它做了什么。项目做完之后,你对这套体系的理解深度是完全不同的。
第二层是从实用角度说的。SSM和Spring Boot之间的迁移成本没有想象中高,SSM项目里写的Service层代码、Mapper接口、业务逻辑,拿到Spring Boot项目里基本可以平迁,差的只是自动配置和依赖管理方式。把SSM吃透,再去看Spring Boot会轻松很多。反过来如果一上来就用Spring Boot,再回头补SSM反而会觉得别扭。这个坑我在帮学弟看代码时见过太多次了。
所以这个选题不是技术上的倒退,而是有意识地在“框架原理”和“工程效率”之间选择了前者,从这个项目出发布置成Spring Boot版或微服务版,只是后面几步的事情。
2. 技术选型与总体架构:SSM的取舍和系统骨架
2.1 技术栈的完整清单与选择理由
后端主框架是经典的SSM组合,但具体到每个技术点,我根据校园场景做了进一步取舍。
Spring负责Bean管理、依赖注入和声明式事务。充电宝租借系统涉及用户余额、订单、设备状态多个实体的联动,没有事务管理的话,订单创建成功但充电宝状态没改过来这类问题会频繁发生。Spring的@Transactional在这种场景下非常直观。
SpringMVC负责控制层的请求路由与参数绑定。项目里既有前端页面又有后端管理接口,我用REST风格区分了两类请求路径,同时通过拦截器统一做登录态校验,这一点对于控制层集中管理非常有用。
MyBatis负责持久层的数据访问。租借订单的查询条件非常多(按用户、按设备、按时间段、按状态任意组合),MyBatis的动态SQL让这种“你不知道用户会按哪几个条件查”的场景处理起来很舒服。和Hibernate相比,MyBatis在复杂统计SQL上也更直接,写报表查询时你控制的就是SQL本身。
数据库使用MySQL 5.7,原因无他,校园项目里最普及、资料最多、出问题好排查。开发工具和辅助库方面,我用Maven做依赖管理,用Lombok简化实体类的getter/setter,用ZXing生成设备二维码,用ECharts做管理后台的统计图表。前端没有上重框架,JSP+Bootstrap+Ajax足够应付这个体量的系统,也节省了前后端分离带来的跨域、鉴权沟通成本。
业务规模上,按5000人同时在线、日均租借订单3000-5000单来估算,单台服务器加单库MySQL完全可以抗住。所以从一开始就没考虑Redis、消息队列这些组件,它们不是不能用,而是对这个项目来说是过度设计。
2.2 三层架构与项目目录组织
SSM项目最常见的就是按controller/service/mapper/entity分包的经典三层架构。我实际的目录设计如下:
src/main/java/com/campus/powerbank/ ├── controller/ # 控制层,学生端和管理端接口分开 │ ├── user/ # 用户端:登录、租借、订单 │ └── admin/ # 管理端:设备管理、统计 ├── service/ # 业务接口与实现 │ ├── RentService.java │ ├── RechargeService.java │ └── StatistiscService.java ├── mapper/ # MyBatis Mapper接口 ├── entity/ # 与数据库表对应的实体类 ├── common/ # 通用类:Result封装、异常、常量 └── interceptor/ # 登录拦截器这个结构的核心价值在于“依赖方向严格单向”:controller调用service,service调用mapper,实体类和common包被所有层共用。我见过一些同学的代码,Service里直接写SqlSession操作数据库,或者controller里写一长段业务逻辑,短期看着方便,但项目一旦进入联调阶段就会发现没法维护。分层不是用来显摆的,它保证的是“业务逻辑改动时不需要碰SQL”“数据库改动时不需要动接口”,这个项目真正做完后你会对这句话有切身体会。
2.3 核心配置要点和事务控制
SSM最劝退的就是那堆XML配置。这里我把最关键的几个配置列出来,并解释每段配置在做什么。
Spring核心配置文件applicationContext.xml:
<context:component-scan base-package="com.campus.powerbank"/> <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/campus_powerbank?useSSL=false&characterEncoding=utf8"/> <property name="username" value="root"/> <property name="password" value="123456"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.campus.powerbank.mapper"/> </bean>这里注意几个细节。第一,数据源选了Druid而不是默认的JDBC数据源,主要看中它的连接池监控功能,调优时可以查到慢SQL和活跃连接数。第二,mapperLocations指向的是“classpath:mapper/*.xml”,这意味着你的XML映射文件必须放在resources/mapper目录下,路径配对错了会报BindingException,这个问题我后面专门讲。第三,MapperScannerConfigurer会自动扫描basePackage下的所有Mapper接口,生成代理对象交给Spring管理,这也是为什么你的Mapper接口不用写实现类也能直接注入。
SpringMVC配置文件spring-mvc.xml中主要是注解驱动、静态资源和视图解析器:
<mvc:annotation-driven/> <mvc:default-servlet-handler/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/"/> <property name="suffix" value=".jsp"/> </bean>事务管理放在Spring配置里统一做:
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:advice id="txAdvice" transaction-manager="transactionManager"> <tx:attributes> <tx:method name="add*" propagation="REQUIRED"/> <tx:method name="borrow*" propagation="REQUIRED"/> <tx:method name="return*" propagation="REQUIRED"/> <tx:method name="update*" propagation="REQUIRED"/> <tx:method name="get*" read-only="true"/> <tx:method name="select*" read-only="true"/> </tx:attributes> </tx:advice>事务方法命名规范很重要,我在createOrder、borrowPowerBank这类写操作前面统一用add/borrow/return/update开头,配和txAdvice的切面表达式,让它自动匹配到对应事务规则。一开始我用的是在Service方法上加@Transactional注解,后来发现XML统一声明式事务比注解更清晰,尤其当方法多了以后,不用每个方法前都去加一遍注解。
3. 数据库设计:用表结构把业务流程钉死
3.1 核心表拆解
这个项目一共设计了8张表,我不打算全部罗列,重点说几个业务主链路相关表的字段设计和理由。
用户表 user:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键,自增 |
| username | varchar(32) | 登录账号,唯一 |
| password | varchar(64) | 密码,MD5加盐存储 |
| student_no | varchar(20) | 学号 |
| real_name | varchar(32) | 真实姓名 |
| balance | decimal(10,2) | 账户余额 |
| status | tinyint | 0正常 1禁用 |
| create_time | datetime | 注册时间 |
学生账号是和学号打通的,所以student_no加了唯一索引。balance字段用decimal而不是double,原因很简单:金额计算必须精确,double的浮点误差在累计扣费时会被慢慢放大。密码不存明文,虽然MD5不算安全,但配合盐值在毕设等级已经属于合格做法,如果你有时间,可以升级成BCrypt加盐散列,差别不大但答辩时能多讲两句。
充电宝表 power_bank:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| device_code | varchar(32) | 充电宝编号,对应二维码内容 |
| station_id | int | 当前所在充电桩ID |
| status | tinyint | 0空闲 1租借中 2维修中 3电量过低 |
| battery_level | int | 电量百分比 |
| version | int | 乐观锁版本号 |
| update_time | datetime | 最后状态变更时间 |
这里最需要说的是status和version这两个字段。status用来标记充电宝当前状态,所有业务判断都基于它;version是后来加上的,为了解决并发场景下多个用户同时抢占同一充电宝的问题,后面代码实现我会展开。充电宝必须有station_id,表示它“现在在哪个桩上”,归还时这个值要更新为目标桩的ID,这样才能实现跨点位的归还。
充电桩表 station:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| location_name | varchar(64) | 位置名称,如“图书馆一楼大厅” |
| longitude / latitude | decimal(10,6) | 经纬度,用于地图展示 |
| capacity | int | 可容纳充电宝数量 |
| status | tinyint | 0正常 1维护中 |
充电桩和充电宝是一对多的关系,但充电宝不一定总在充电桩里,所以这种关联是通过power_bank.station_id实现的动态关联,而不是在station表里存一个充电宝列表。
租借订单表 rent_order 是整个系统最核心的表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| order_no | varchar(32) | 订单编号,业务唯一 |
| user_id | int | 租借用户 |
| power_bank_id | int | 租借的充电宝 |
| borrow_station_id | int | 借出充电桩 |
| return_station_id | int | 归还充电桩,可空 |
| borrow_time | datetime | 借出时间 |
| return_time | datetime | 归还时间,可空 |
| status | tinyint | 0借用中 1已归还 2已逾期 3已报修 |
| rental_fee | decimal(10,2) | 租金 |
| penalty_fee | decimal(10,2) | 逾期费用 |
| total_fee | decimal(10,2) | 应付总额 |
| create_time | datetime | 创建时间 |
status这个状态值是所有业务的焦点,它会跟着整个租借生命周期流动。borrow_station_id和return_station_id分别记录借出和归还点位,这样才能算出各充电桩的流转情况和利用率。订单编号order_no是我用“yyyyMMddHHmmss + 随机四位数”拼出来的,没有直接用自增主键,因为要提供给用户查看、客服核对、日志追踪,业务编号和物理主键要拆开。
支付流水表 payment_record:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| user_id | int | 用户ID |
| order_no | varchar(32) | 关联订单号(充值时可为空) |
| type | tinyint | 1充值 2租借扣费 3退款 |
| amount | decimal(10,2) | 金额 |
| create_time | datetime | 流水时间 |
支付流水主要是为了对账。用户充了多少钱、每次租借扣了多少钱、退了多少款,所有钱的变化都应该在这个表里留底。做管理端的时候,我加了一个简单的账目汇总页面,每天的总充值、总扣费、总退款都从这个表里聚合出来。
3.2 索引与查询设计
数据库设计里索引这块经常被忽略,但在订单量上来以后影响非常大。我在rent_order表上建了联合索引(borrow_time, status),这样管理端查询“某天所有借用中的订单”可以走索引覆盖。user表、power_bank表的唯一键也是隐式索引,这里不多说。
统计类查询几乎都是围绕时间维度做GROUP BY。比如“最近7天每日租借量”:
SELECT DATE(borrow_time) AS d, COUNT(*) AS cnt FROM rent_order WHERE borrow_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE(borrow_time)“各充电桩今日借用次数”:
SELECT borrow_station_id, COUNT(*) FROM rent_order WHERE DATE(borrow_time) = CURDATE() GROUP BY borrow_station_id这些都是报表页面的核心SQL,MyBatis里直接写XML映射就可以,不需要额外做ORM封装。
4. 核心功能实现:租借流程、并发控制与计费
4.1 租借流程的状态机设计
租借不是一个单步操作,我在设计时把它的完整状态流转画成了一条链:
空闲 → 借用中 → 已归还 → 已结算 → 关闭(正常流程) 借用中 → 已逾期 → 处理后归还(异常流程) 借用中 → 用户报修 → 管理员介入(异常流程)
在代码层面,这个流转要拆成两个核心步骤:创建订单和归还结算。
创建订单的接口逻辑伪代码如下:
@Transactional public Result borrowPowerBank(Integer userId, Integer powerBankId, Integer stationId) { // 1. 校验用户状态和余额 User user = userMapper.selectById(userId); if (user.getStatus() == 1) throw new BusinessException("该账号已被禁用"); // 2. 校验充电宝状态(核心并发控制点,见4.2) int updated = powerBankMapper.compareAndSetStatus(powerBankId, 0, 1); if (updated == 0) throw new BusinessException("充电宝已被借出,请选择其他设备"); // 3. 创建订单 RentOrder order = new RentOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setPowerBankId(powerBankId); order.setBorrowStationId(stationId); order.setBorrowTime(new Date()); order.setStatus(0); rentOrderMapper.insert(order); // 4. 返回订单信息,含前端展示用的初始计费时间 return Result.success(order); }注意这里必须在同一个事务里完成“更新充电宝状态”和“创建订单”,否则可能出现订单建好了但充电宝状态没改,或者反过来充电宝被占了但没有订单记录的情况。
4.2 并发控制:同一个充电宝被两个人同时扫了怎么办
这是我在开发中真实遇到的最棘手的问题。测试时用两个浏览器同时对一个二维码发起租借,结果两个人同时成功了,查了下数据,充电宝被借出两次,订单也建了两条,彻底乱套。
原因很简单:我的第一版更新语句是
PowerBank bank = powerBankMapper.selectById(powerBankId); if (bank.getStatus() == 0) { powerBankMapper.updateStatus(powerBankId, 1); }这段代码的问题在于“查询”和“更新”是两步操作,两个请求可能都查到了status为0的充电宝,然后都执行了更新。要解决这个问题,必须把“判断状态并更新”这两步合并成一个原子操作。我用的是乐观锁方案,在power_bank表加了version字段,然后写了一条带条件更新的SQL:
<update id="compareAndSetStatus"> UPDATE power_bank SET status = #{targetStatus}, version = version + 1 WHERE id = #{id} AND status = #{expectStatus} AND version = #{expectVersion} </update>更新时检查当前状态是否等于期望状态,同时检查version是否等于读取时的版本号,只有两个条件都满足时才更新成功,否则更新行数为0,业务层拿到0就知道抢失败了。
如果是在SQL层面再保险一点,也可以用悲观锁,查询时加上FOR UPDATE:
SELECT * FROM power_bank WHERE id = #{id} FOR UPDATE把待操作的记录锁住,直到事务提交。但悲观锁在高并发下容易造成锁等待,而且校园系统本身并发量没有那么大,用乐观锁不仅实现简单,也让答辩时有“并发控制方案对比”这个可以聊的亮点。
4.3 计费规则与订单结算的实现
计费规则我定的是最常见的校园方案:前30分钟免费,超出后按每30分钟0.5元累加,单日封顶10元,超过24小时视为逾期,逾期每天额外加收2元。
结算发生在用户归还充电宝的时候,不是定时任务去算。归还接口接收充电宝ID,先做状态更新(空闲),再查出对应订单,计算费用并完成扣款:
@Transactional public Result returnPowerBank(Integer powerBankId, Integer returnStationId) { // 1. 找到借用中的订单 RentOrder order = rentOrderMapper.selectByPowerBankIdAndStatus(powerBankId, 1); if (order == null) throw new BusinessException("未找到借用中的订单"); // 2. 更新充电宝状态和所在充电桩 powerBankMapper.compareAndSetStation(powerBankId, returnStationId); // 3. 计算费用 long useMinutes = (System.currentTimeMillis() - order.getBorrowTime().getTime()) / 60000; double fee = calculateFee(useMinutes); // 4. 扣费、更新订单 userMapper.updateBalance(order.getUserId(), fee); order.setReturnTime(new Date()); order.setReturnStationId(returnStationId); order.setRentalFee(fee); order.setStatus(1); rentOrderMapper.updateById(order); return Result.success(fee); }calculateFee方法就是简单分段计算,把规则写清楚就行。有一个细节值得提醒:结算时没有直接把订单状态置为“已归还”然后不管了,因为用户余额可能不够扣。我处理的方式是,如果余额不足以支付,订单状态置为“已逾期”,同时给用户生成一条待补缴记录,下次用户充值或再次租借前必须补齐欠费。这套逻辑保证了系统里不存在“欠着钱还能继续租”的漏洞。
4.4 二维码与扫码链路
充电宝上的二维码是静态的,我用ZXing库生成,内容是一个包含设备编号的参数链接,形如:
http://localhost:8080/powerbank/rent?deviceCode=PB0001前端扫码后跳到租借页面,页面先展示充电宝编号、当前所在位置和计费规则,用户点击“确认租借”才真正调用后端接口。这样做的目的是防止“误扫直接扣款”的纠纷。页面展示这块我用的是移动端适配的Bootstrap模板,数据通过Ajax从后端拉取,用户不需要下载App,浏览器里就能完成全部操作。
二维码生成功能放在管理端“设备管理”页面,管理员录入新充电宝后,系统自动生成对应二维码图片,可以直接下载打印贴到设备上。ZXing的生成代码很简单:
String content = "http://localhost:8080/powerbank/rent?deviceCode=" + deviceCode; int width = 300, height = 300; BitMatrix bitMatrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height); MatrixToImageWriter.writeToStream(bitMatrix, "png", outputStream);这个功能做起来快,但实际使用中非常有用,也方便在答辩时直接演示。
5. 管理后台与数据统计:让数据说话
5.1 管理后台的功能组织
管理端我单独做了一套页面,通过拦截器实现路径权限控制。URL以/admin/开头的请求都会经过AdminInterceptor,校验当前Session里是否有管理员登录标记,没有就重定向到登录页。
管理后台功能主要分成四块:
设备管理:充电桩和充电宝的增删改查。充电宝列表里可以看到每个设备的当前状态、当前所在桩位、电量,管理员可以手动把“电量过低”的设备标记为“维修中”,维修完成后再改回“空闲”。这里“手动维护状态”这个功能很关键,因为系统只能记录租借状态,设备真的坏了、没电了,还得靠人工确认。
订单管理:所有租借订单的列表查询,支持按订单号、用户学号、设备编号、订单状态、时间范围组合查询。这个列表数据量大了以后,MyBatis动态SQL就特别有用,各种查询条件的组合都能在一个XML里表达清楚。
用户管理:查看注册用户列表、余额、租借记录,支持禁用账号。被禁用的用户无法再租借设备,但已产生的欠费仍然保留在余额里。
数据统计:基于rent_order和payment_record表做聚合分析。我做了四个核心指标:每日租借量、日流水、充电桩使用率、周转率。直接用ECharts在前端画折线图和柱状图,数据接口返回JSON数组就行。
5.2 几个关键统计指标的SQL实现
充电桩使用率计算的是“一个充电桩在一天内平均有多少个充电宝处于租借状态”:
SELECT borrow_station_id, COUNT(*) / COUNT(DISTINCT DATE(borrow_time)) AS avg_daily_orders FROM rent_order WHERE borrow_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY borrow_station_id充电宝周转率是“单个充电宝一天内平均被租借的次数”:
SELECT power_bank_id, COUNT(*) / COUNT(DISTINCT DATE(borrow_time)) AS turnover_rate FROM rent_order WHERE borrow_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY power_bank_id这两个指标做出来后,管理员可以很直观地看出哪个点位的充电宝不够用、哪些点位设备长期闲置,从而调整充电桩投放策略。这部分内容在答辩时几乎每次都会被问到“你的统计功能有什么实际价值”,我的回答就是围绕这两个指标讲调度故事,比空谈“可视化管理”有说服力得多。
6. 踩坑实录:从配置到联调的高频问题
6.1 MyBatis的Mapper绑定失败
项目跑起来第一次报错就是Invalid bound statement (not found),原因是接口和XML映射对不上。排查步骤我给一个标准流程:先确认XML文件在target/classes下面存在,注意resources/mapper目录是否被Maven排除;再确认XML里namespace严格等于接口的全限定名;最后确认XML里的id与接口方法名一致,返回值resultType或resultMap配置正确。
最常见的是第二种。如果用的是IDEA,可以安装MyBatisX插件,接口和方法有对应关系时会有小鸟图标提示,没有就说明映射没生效。这个排查过程我至少帮别人处理了七八次,每次原因基本都集中在这三点上。
6.2 #{}和${}的区别,以及一个差点翻车的案例
MyBatis里#{}是预编译占位符,${}是字符串拼接。查找用户时我一开始图省事直接在SQL里用了${keyword}做模糊查询:
SELECT * FROM user WHERE real_name LIKE '%${keyword}%'当时自己测试没问题,后来想清楚了吓出一身汗。如果用户在输入框里填了' OR '1'='1,拼接出来的SQL就变成
SELECT * FROM user WHERE real_name LIKE '%%' OR '1'='1'直接把整表数据查出来了。这不是危言耸听,SQL注入攻击就是这么干的。正确写法是:
SELECT * FROM user WHERE real_name LIKE CONCAT('%', #{keyword}, '%')用#{}传参,MySQL用CONCAT函数拼接模糊查询条件,这样keyword只会被当作一个字符串参数处理,不会被拼进SQL语句结构里。这个我在答辩时也主动讲了,评委老师对安全细节的印象分是实打实的。
6.3 事务没生效:方法自调用和try-catch吞异常
Service层里如果A方法调用了同类中的B方法,而且A方法没有事务注解,B方法加了事务注解,B的事务实际上不会生效。因为A调用B走的是this.B(),没有经过Spring的代理对象,事务切面根本拦截不到。我当时就犯过这个错,解决方法是把需要事务控制的逻辑拆到不同的Service类中,或者通过注入自身的代理对象来调用。
另一个问题是方法内部用try-catch把异常捕获了没有重新抛出,事务感知不到异常,自然就会提交。比如归还充电宝时,我先更新了充电宝状态,后面计算费用抛了个空指针异常,但异常被catch掉,结果状态改了钱没扣。正确做法是事务方法里不吞异常,让异常被事务管理器感知,或者手动标记rollback-only:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();一般情况下,别在事务方法里写try-catch才是根治思路。
6.4 图片上传和静态资源路径
管理员上传充电桩的图片,存到项目根目录下的upload文件夹。开发环境跑得好好的,部署到服务器后图片经常加载不出来。后来定位到原因:上传用的是绝对物理磁盘路径,而页面访问走的是Tomcat的ContextPath,两者没对应上。我在SpringMVC里加了一个自定义资源映射把两者打通:
<mvc:resources mapping="/upload/**" location="/upload/"/>同时把上传路径配置写到properties文件里,不写死在代码中,部署到不同环境时改配置就行。如果使用内嵌Tomcat,注意重启后临时目录会被清空,上传目录最好指定到服务器固定路径,而不是项目临时目录。
6.5 前后端联调时的时间格式问题
前端页面显示订单时间出现“2026-04-05 10:30:00.0”,很难看。原因是后端返回的Date类型,Jackson默认序列化成带毫秒的时间戳。解决方式是在SpringMVC配置里统一设置日期格式:
<mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"> <property name="objectMapper"> <bean class="com.fasterxml.jackson.databind.ObjectMapper"> <property name="dateFormat" value="yyyy-MM-dd HH:mm:ss"/> </bean> </property> </bean> </mvc:message-converters> </mvc:annotation-driven>另外接收前端传来的日期参数时,也要在Controller参数上加@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss"),不然日期字符串转Date会报400错。这种时间处理问题几乎每个SSM项目都会遇到一次,早点统一配置能省很多事。
6.6 用JMeter做了个简单压测
系统联调完成后,我用JMeter对“用户扫码租借”接口做了一次简单的并发测试:模拟100个用户同时发起租借请求。结果发现部分请求返回“充电宝已被借出”,这正是乐观锁生效的正常表现——当多个请求竞争同一个充电宝时,只有一个能成功,其余的被安全拦截。通过这次压测,我确认了核心并发控制没有明显瓶颈,也积累了“用工具验证稳定性”的实践经验。
7. 复盘与可扩展方向
这个项目从选题到完成,前后花了大概两个月。最深的体会有三点:一是业务流程想清楚再动手,状态机没设计好就写代码,后面必然要大改;二是并发控制这类问题越早暴露越好,不要等部署了才去处理;三是项目里每一块东西都要能说出“为什么这么做”,这也是把SSM项目做出差异化价值的关键。
如果说给这个项目一个扩展方向,我会优先做两件事。一件是把后端平滑迁移到Spring Boot加MyBatis Plus,把SSM阶段写的Service逻辑搬过去,顺便补上Redis缓存热点充电桩状态,缓解数据库压力;另一件是把前端从JSP升级为Vue单页应用,管理后台和用户端彻底前后端分离,接口层不变,后端结构可以沿用。再远一点,如果想把并发和分布式搞明白,可以把订单拆成独立服务、接入消息队列做异步结算——这些场景都是在当前充电宝租借业务基础上自然生长出来的,不是硬凑的微服务。
最后再分享一个小技巧:写毕设前,把你理解中的完整业务闭环画在一张A4纸上,从用户注册到订单完成,每个步骤涉及哪些表、哪些状态变更,全部标出来。这张纸就是你的系统设计文档、答辩PPT素材和排错指南。我的所有核心代码和图,几乎都能回溯到那张纸。这个习惯从校园项目到工作后的业务系统,一直都很好用。