简介:这是一份基于SSM框架(Spring、SpringMVC、MyBatis)的智能停车场管理系统完整项目资源,面向需要完成毕业设计、课程设计或学习Java Web开发的读者。系统覆盖车位租用、退租、违规举报、车位查询与预定、车位导航、停车缴费、车位管理、用户管理及后台管理等核心模块,贯通从用户在线操作到管理员监控调度的完整业务流程。资源包共162个文件,压缩包仅1.5MB,以119个Java源文件为主,另含27张PNG界面图、MyBatis相关XML配置、Eclipse工程文件及项目说明文档,导入开发工具即可查看模块结构与运行调试。内容预览中的YonghuController、CheweixinxiController等控制器类,有助于从控制层理解各业务模块的请求处理逻辑。目前已有106人学习下载,适合具备一定Java基础、希望掌握SSM整合方式与智能停车业务场景的读者参考学习。
1. 智能停车场管理系统:从“能用”到“真能用”的关键一步
做过几个 SSM 课程项目之后,你会发现最容易被问住的不是框架配置,而是业务状态怎么组织。基于 SSM 框架的智能停车场管理系统刚好是一个能把这套问题一次问全的场景:车位租用、退租、违规举报、车位查询、车位预定、车位导航、停车缴费,再加上用户管理和后台管理。用户侧的每个操作最终都会落到一张车位状态表上,这一层数据模型想清楚,功能页面反而全是体力活。这篇文章按我平时做项目的顺序讲:先选型定数据库,再写核心业务,最后带你过一遍 SSM 集成最常见的几个坑。
2. SSM 技术选型与数据库设计:先定状态,再造接口
2.1 为什么这个项目仍然选 SSM:一个停车管理项目的真实约束
SSM 不是新东西,却是每年都会出现的课设和内部管理系统主力。一个智能停车场管理系统没有高并发、没有复杂分布式事务,最麻烦的部分是业务状态流转。用 Spring 管对象、Spring MVC 管 HTTP 请求、MyBatis 管 SQL,刚好把三层结构拆得很清楚。相比 Spring Boot 的自动配置,SSM 手工配置会让你被迫了解每个组件是怎么接进来的,这个理解过程在排错时非常值钱。
我也遇到过只会在 Boot 里写 CRUD 的开发者,遇到配置问题完全不知道从哪里查。而 SSM 项目里,数据源错了看 jdbc.properties,事务没生效看 applicationContext.xml,请求进不来查 spring-mvc.xml,每一条线索都很明确。对一个以学习和演示为主要目标的系统,这种“不隐藏细节”反而是优点。
如果你纯粹为了快速上线,用 Spring Boot 会更快;但如果你要理解整个请求链路,或者要维护老旧项目,SSM 的 XML 配置就是最好的教材。下面是我常用的对比角度。
| 对比维度 | SSM 手工配置 | Spring Boot |
|---|---|---|
| 入门门槛 | 偏高,需要理解容器 | 偏低,默认配置多 |
| 问题排查 | 链路清楚 | 自动配置容易黑盒 |
| 事务配置 | XML + 注解 | 自动注入 |
| 适合场景 | 课设、毕设、老系统维护 | 新服务快速迭代 |
结论其实很直接:这套停车场管理系统的核心价值在业务逻辑和表结构,不在框架。SSM 带来的额外成本只有一个配置时间,换来的是每行配置都能解释清楚。
2.2 数据库设计:六张表把业务闭环撑起来
数据模型是整个项目最不能省的部分。我的建议是至少建六张表:用户表、车位表、预定表、租用订单表、举报表、缴费记录表。先给出一套建表 SQL,字段上我已经预留了后面会用的坐标和乐观锁。
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, phone VARCHAR(20), role TINYINT DEFAULT 0 COMMENT '0 普通用户, 1 管理员', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE parking_spot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, spot_no VARCHAR(20) NOT NULL UNIQUE, x DOUBLE NOT NULL COMMENT '地图横坐标', y DOUBLE NOT NULL COMMENT '地图纵坐标', status TINYINT DEFAULT 0 COMMENT '0 空闲, 1 占用, 2 预定, 3 维修', price_per_hour DECIMAL(10,2) DEFAULT 5.00, area_name VARCHAR(50) DEFAULT 'A区', version INT DEFAULT 0 COMMENT '乐观锁版本号' ); CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, spot_id BIGINT NOT NULL, reserve_time DATETIME NOT NULL, expire_time DATETIME NOT NULL, status TINYINT DEFAULT 0 COMMENT '0 等待入场, 1 已取消, 2 已入场, 3 已过期', INDEX idx_spot_expire (spot_id, expire_time) ); CREATE TABLE lease_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL UNIQUE, user_id BIGINT NOT NULL, spot_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME COMMENT '退租或结算时间', amount DECIMAL(10,2) DEFAULT 0.00, status TINYINT DEFAULT 0 COMMENT '0 进行中, 1 已结算, 2 已退租, 3 异常', version INT DEFAULT 0 COMMENT '乐观锁版本号' ); CREATE TABLE complaint ( id BIGINT PRIMARY KEY AUTO_INCREMENT, reporter_id BIGINT NOT NULL, spot_id BIGINT NOT NULL, reason VARCHAR(500) NOT NULL, images VARCHAR(1000) COMMENT '举报图片相对路径', status TINYINT DEFAULT 0 COMMENT '0 待处理, 1 已处理, 2 已驳回', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, handle_time DATETIME, handle_result VARCHAR(500) ); CREATE TABLE payment_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL UNIQUE, amount DECIMAL(10,2) NOT NULL, pay_type VARCHAR(20) DEFAULT 'wxpay', pay_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 0 COMMENT '0 未支付, 1 已支付, 2 已退款' );这套表的设计逻辑有几点值得记下来。第一,车位表里直接放 x 和 y 坐标,是为了后面车位导航页面能直接用 Canvas 渲染,不用再单独维护一张地图表。第二,预定和租用是两种不同的生命周期,预定是临时占位,过期要释放,租用是真正计费,所以拆成两张表。第三,lease_order 里冗余了 amount 和订单状态,但没有冗余车位单价,我只存最终金额,因为订单结算后价格再改不影响历史记录。第四,version 字段是为并发控制准备的,后面 3.1 节会用到。
字段类型上有两个容易踩的细节。一个是金额用 DECIMAL(10,2) 而不是 DOUBLE,避免浮点数累加出0.10000000000001这种结果。另一个是状态字段用 TINYINT 存枚举值,不要直接在数据库里存“空闲”“占用”这种中文字段,否则后面做统计、做筛选都要写一堆等值匹配。
2.3 项目骨架与 Maven 依赖:先让工程能跑起来
我习惯用 Maven 的 war 工程结构,这样后续直接丢进 Tomcat。项目大体分四层:controller、service、mapper、entity,resources 下放 mapper XML、Spring 配置和数据库配置。依赖也不复杂,核心就四组:spring-webmvc、mybatis 全家桶、mysql 驱动、jackson。
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.20</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.3.20</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.27</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.13.3</version> </dependency> </dependencies>版本号不需要追新,锁在 2021 年后比较稳的版本就好,Spring 5.3.x 和 MyBatis 2.x 的组合在 Tomcat 8/9 上都能正常跑。如果还打算做分页,可以提前引入 PageHelper 的 mybatis 适配包,后面 4.3 节管理列表直接用。
配置文件里最容易出错的是两个容器的扫描范围。我见过很多项目把 service 和 controller 都放在 spring-mvc.xml 的 component-scan 里,导致事务管理器没有生效。正确做法是根容器 applicationContext.xml 扫描 service 和 mapper,Spring MVC 只扫 controller。
<!-- applicationContext.xml 关键部分 --> <context:component-scan base-package="com.parking.service, com.parking.mapper"/> <bean id="dataSource" class="org.apache.commons.dbcp2.BasicDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/parking?useUnicode=true&characterEncoding=utf8"/> <property name="username" value="root"/> <property name="password" value="password"/> </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.parking.mapper"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>这段配置里有几个值得记住的参数。mapperLocations 指向 classpath:mapper/*.xml,如果你的 XML 没被 Maven 打包进去,启动时会报 Invalid bound statement,这个现象第 5 章会专门讲。useUnicode=true 和 characterEncoding=utf8 是处理中文入库乱码的前置条件,缺少任何一个,即使过滤器写对了也可能出问题。另外&在 XML 里必须写成&,否则配置解析直接失败。
然后 spring-mvc.xml 只需要两件事:开启注解驱动,配置视图解析器。
<context:component-scan base-package="com.parking.controller"/> <mvc:annotation-driven/> <mvc:default-servlet-handler/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>到这里工程已经能启动,只是还没有任何业务代码。接下来的重头戏是把车位从预定到缴费的完整链路写出来。
3. 核心业务:车位租用、预定、退租、缴费、导航
3.1 车位租用与退租:用状态机和行锁避免抢座
车位是共享资源,最容易出的问题是两个用户同时看到空闲车位,结果都下单成功。我一般会在两个层面控制:数据库层面用行锁或乐观锁,业务层面用明确的状态机。所谓状态机,就是约定车位只能按“空闲 -> 预定 -> 占用 -> 空闲”或者“占用 -> 维修”的顺序流转,任何不符状态的更新都直接抛异常。
先看租用接口的核心逻辑:
@Service public class SpotServiceImpl implements SpotService { @Resource private ParkingSpotMapper parkingSpotMapper; @Resource private LeaseOrderMapper leaseOrderMapper; @Override @Transactional public LeaseOrder createLease(Long userId, Long spotId) { // 用 FOR UPDATE 锁住这一行,防止两个请求同时进入 ParkingSpot spot = parkingSpotMapper.selectByIdForUpdate(spotId); if (spot == null || spot.getStatus() != 0) { throw new BizException("车位当前不可租用"); } LeaseOrder order = new LeaseOrder(); order.setOrderNo("LS" + System.currentTimeMillis()); order.setUserId(userId); order.setSpotId(spotId); order.setStartTime(new Date()); order.setStatus(0); leaseOrderMapper.insert(order); spot.setStatus(1); parkingSpotMapper.updateById(spot); return order; } }selectByIdForUpdate 是 Mapper 里自定义的查询,对应的 SQL 是SELECT * FROM parking_spot WHERE id = #{id} FOR UPDATE。它的作用是让事务提交前这一行不能被其他事务修改,所以并发场景下第二个请求会等第一个提交后才执行,随后读到的新状态就不是空闲了。这个方案适合低并发项目,效果直白,但要注意锁的释放时间,如果事务里还有慢查询,很容易把连接池占满。
如果不想用行锁,也可以靠乐观锁硬扛。写法是更新时带上前一次的 version:
UPDATE parking_spot SET status = 2, version = version + 1 WHERE id = #{spotId} AND status = 0 AND version = #{version}执行后返回影响行数,如果为 0 就说明车位已经被别人抢走,前端提示重新选位即可。两种方案选一种就行,我推荐项目里先做乐观锁,逻辑简单,不用考虑锁的释放时机。
退租接口刚好是反向操作,把占用中的车位释放回空闲,并记录结束时间。
@Override @Transactional public void cancelLease(Long orderId) { LeaseOrder order = leaseOrderMapper.selectById(orderId); if (order == null || order.getStatus() != 0) { throw new BizException("订单不存在或已结束"); } order.setEndTime(new Date()); order.setStatus(2); leaseOrderMapper.updateById(order); parkingSpotMapper.updateStatus(order.getSpotId(), 0); }这里有个容易漏的点:更新订单状态和释放车位必须是同一个事务,否则会出现订单已经退租但车位还显示占用的情况。我就是在 Service 方法上加了 @Transactional,把两个操作绑在一起。
3.2 车位预定与超时释放:定时任务兜底
预定和租用不同。预定只是临时占坑,用户可能不去,所以必须有超时机制。我的做法是独立 reservation 表,用户点预定后写入一条记录,状态为等待入场,同时把车位改为预定,15 分钟内未入场就自动释放。
@Scheduled(fixedDelay = 60000) public void releaseExpiredReservations() { Date now = new Date(); List<Reservation> expiredList = reservationMapper.selectExpired(now); for (Reservation reservation : expiredList) { if (reservation.getStatus() != 0) { continue; } reservation.setStatus(3); reservationMapper.updateById(reservation); parkingSpotMapper.updateStatus(reservation.getSpotId(), 0); } }selectExpired 对应的 SQL 是WHERE status = 0 AND expire_time < NOW(),只查过期且还处于等待状态的记录。固定延迟 60 秒扫一次,对课设和中小型停车场足够了。使用 @Scheduled 需要提前在配置文件里加一句<task:annotation-driven/>,不开启的话注解不生效,这个问题很难一眼看出来。
如果不想依赖定时任务,也可以在用户查询车位时做懒释放:查列表前先把当前登录用户的过期预定清掉。但懒释放只对发起请求的用户生效,后台管理页看数据时还是会看到脏状态。所以定时任务才是兜底方案,懒释放只能算优化。
3.3 停车缴费:计费规则的小数点和结算一致性
缴费环节的计算不复杂,复杂的是“计费规则怎么解释”。我先写一个通用的结算逻辑:
@Transactional public PaymentRecord settleOrder(Long orderId) { LeaseOrder order = leaseOrderMapper.selectById(orderId); if (order == null || order.getStatus() != 0) { throw new BizException("订单状态异常,无法结算"); } if (order.getEndTime() == null) { order.setEndTime(new Date()); } long durationMs = order.getEndTime().getTime() - order.getStartTime().getTime(); long minutes = durationMs / 60000; if (minutes < 15) { minutes = 0; // 前 15 分钟免费 } else { minutes -= 15; } long payHours = minutes / 60; if (minutes % 60 != 0) { payHours += 1; // 不足 1 小时按 1 小时计 } ParkingSpot spot = parkingSpotMapper.selectById(order.getSpotId()); BigDecimal amount = spot.getPricePerHour() .multiply(new BigDecimal(payHours)) .setScale(2, RoundingMode.HALF_UP); order.setAmount(amount); order.setStatus(1); leaseOrderMapper.updateById(order); PaymentRecord payment = new PaymentRecord(); payment.setOrderId(order.getId()); payment.setAmount(amount); payment.setPayType("demo"); payment.setStatus(1); paymentRecordMapper.insert(payment); return payment; }代码里三个规则值得说明。第一个是 payHours 向上取整,“59 分钟也按 1 小时”这个规则要在代码里写清楚,不能只靠前端约定;第二个是免费时段,免费 15 分钟是停车场常见策略,如果需求没有,直接把 if 块删掉;第三个是支付状态,演示代码直接改为已支付,真实环境一定不能这么做,要以支付平台回调或主动查单为准,否则会出现用户没付钱但系统认为已支付的记录,这是生产事故级别的隐患。
我见过有的项目把金额计算放在前端,后端只接收结果,这是最危险的做法。金额必须由后端根据开始和结束时间重新计算,前端展示只能作为参考。
3.4 车位导航:坐标存库,前端 Canvas 渲染
车位导航这个功能听起来高级,实际落地不需要接入外部地图服务。管理员在后台维护停车场的平面图,车位表里已经有 x、y 坐标,前端用 Canvas 把车位画在背景图上。这也是我在 2.2 节坚持加 x、y 字段的原因。
后端只需要给前端返回坐标列表:
@ResponseBody @RequestMapping("/spot/mapData") public List<Map<String, Object>> mapData() { List<ParkingSpot> spotList = parkingSpotMapper.selectAll(); List<Map<String, Object>> result = new ArrayList<>(); for (ParkingSpot spot : spotList) { Map<String, Object> item = new HashMap<>(); item.put("id", spot.getId()); item.put("spotNo", spot.getSpotNo()); item.put("x", spot.getX()); item.put("y", spot.getY()); item.put("status", spot.getStatus()); result.add(item); } return result; }前端核心是一个 drawMap 函数:
fetch('/spot/mapData') .then(res => res.json()) .then(points => { const ctx = canvas.getContext('2d'); points.forEach(p => { ctx.fillStyle = p.status === 0 ? '#2e7d32' : (p.status === 2 ? '#f9a825' : '#c62828'); ctx.beginPath(); ctx.arc(p.x, p.y, 8, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#333'; ctx.font = '12px sans-serif'; ctx.fillText(p.spotNo, p.x - 14, p.y - 12); }); });导航的含义是“让用户知道车位在哪里”,不是真的打开地图路线。如果评审要求看到从入口到车位的路径,我再画一条折线,入口坐标写死,车位坐标从数据库读。这里要提醒一句:前端 Canvas 的缩放会导致点位偏移,我习惯让管理员录坐标时以底图为参照,比例统一成 1:1,避免前端再做换算。
4. 用户端与后台管理:登录、举报、分页一个都不能少
4.1 登录拦截与角色权限:用拦截器代替每个方法里的判断
用户和管理员共用一个系统,后端必须处理两个问题:未登录不能访问业务页面;管理员接口不能让普通用户访问。Spring MVC 的拦截器是最直接的办法,不用在每个 Controller 里重复判断 session。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object loginUser = request.getSession().getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } if (request.getRequestURI().startsWith("/admin")) { SysUser user = (SysUser) loginUser; if (user.getRole() != 1) { response.sendError(403, "无管理员权限"); return false; } } return true; } }在 spring-mvc.xml 里注册拦截器:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/register"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.parking.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>exclude-mapping 里列出的路径注意别漏。静态资源漏在拦截器外会让 CSS 和 JS 都进不来,登录页样式全挂;反过来如果忘记把登录接口排除,用户连登录都进不去,这属于配置顺序问题,启动时又不会报错,只能一点点排查。
4.2 违规举报:图片上传与后台处理流程
举报功能先要解决图片上传。Spring MVC 用 CommonsMultipartResolver 接收文件,配置上有几个固定要求。
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <property name="maxUploadSize" value="10485760"/> <property name="defaultEncoding" value="UTF-8"/> <property name="maxInMemorySize" value="4096"/> </bean>注意这个 bean 的 id 必须是 multipartResolver,否则 DispatcherServlet 找不到它,文件上传接口会一直得到 empty file。maxUploadSize 是单个上传总量限制,10MB 对几张举报图够用;maxInMemorySize 表示超过 4KB 的文件直接落临时文件,不必占内存,这个参数一般不需要改。
后端接收接口:
@ResponseBody @RequestMapping(value = "/complaint/add", method = RequestMethod.POST) public Map<String, Object> addComplaint(@RequestParam Long spotId, @RequestParam String reason, @RequestParam("file") MultipartFile file, HttpSession session) { String path = fileUtil.save(file, "complaint"); Complaint complaint = new Complaint(); SysUser user = (SysUser) session.getAttribute("loginUser"); complaint.setReporterId(user.getId()); complaint.setSpotId(spotId); complaint.setReason(reason); complaint.setImages(path); complaint.setStatus(0); complaintMapper.insert(complaint); return result.success(); }save 方法只是把文件写成complaint/20250101_1203.jpg这种带时间戳的路径。这里我建议不要把图片存在数据库里,只存相对路径,然后通过一个file/show?path=xxx的接口读取,好处是删除和迁移都方便。
管理员处理举报的接口更简单,先改状态,如果查实就把车位设为维修状态,避免后续用户误订。
@Transactional public void handleComplaint(Long complaintId, Integer status, String result) { Complaint complaint = complaintMapper.selectById(complaintId); if (complaint == null) { throw new BizException("举报记录不存在"); } complaint.setStatus(status); complaint.setHandleResult(result); complaint.setHandleTime(new Date()); complaintMapper.updateById(complaint); if (status == 1) { parkingSpotMapper.updateStatus(complaint.getSpotId(), 3); } }这里的处置逻辑是我比较推荐的:举报查实后“车位进入维修状态”,比直接置为空闲安全得多。管理员修完车位再手动改回空闲,流程上更可控。
4.3 后台管理:分页查询与列表筛选
后台管理页面要处理车位列表、用户列表、订单列表。我习惯用 PageHelper,主要是因为它不侵入业务代码,在查询前一行 startPage,后面跟着的查询自动分页。
public PageInfo<ParkingSpot> pageSpot(Integer pageNum, Integer pageSize, Integer status) { PageHelper.startPage(pageNum, pageSize); List<ParkingSpot> list = parkingSpotMapper.selectByStatus(status); return new PageInfo<>(list); }PageHelper 第一个坑是 startPage 必须紧跟业务查询语句,中间不能有其他查询;第二个坑是不建议在大循环里调用分页方法,否则每次循环都会产生一个 Page 对象。另外记得在 SqlSessionFactory 的 plugins 里注册 PageInterceptor,否则 startPage 不会生效。
我这里用 selectByStatus 是一个筛选接口,status 为空时查询全部,用动态 SQL 处理。
<select id="selectByStatus" resultType="ParkingSpot"> SELECT * FROM parking_spot <where> <if test="status != null"> status = #{status} </if> </where> ORDER BY id </select>后台管理还有一个细节是数据导出。如果评审要求导出 Excel,不要一次性把 10 万行全查进内存,我一般先查 id 列表,再分页查询拼接,这样内存占用可控。不过对一个课设项目来说,列表分页加上关键字查询已经够用。
5. SSM 集成避坑手册:Mapper、乱码、并发和事务四类高危问题排查
SSM 项目写业务代码其实快,真正的耗时全在配置和集成。我把实际开发中翻车次数最多的问题整理成五条,每条按“现象 -> 原因 -> 解决”来讲,遇到时照这个顺序查基本能定位。
5.1 Invalid bound statement (not found):Mapper XML 没有生效是常态
现象:项目启动不报错,但一调用 Mapper 方法就抛 Invalid bound statement (not found)。
原因:三种情况最常见。XML 的 namespace 跟接口全限定名不一致;XML 和接口不在同一个 classpath 路径;Maven 打包时只打包了 class,没有打包 mapper 目录下的 XML。
解决:先检查 namespace 是否等于接口完整类名,再检查 applicationContext.xml 里 MapperScannerConfigurer 的 basePackage 是否正确。如果项目结构是 XML 放 src/main/java 下,需要在 pom.xml 的 build 里加一段资源配置,确保 XML 被打进 classpath。
<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> <resource> <directory>src/main/resources</directory> </resource> </resources> </build>5.2 中文乱码:过滤器写了也不够,连接串也要带编码
现象:注册页面提交中文用户名,Controller 拿到的值已经正常,但 insert 后数据库里是问号;或者反过来,数据库能看中文,但页面响应是乱码。
原因:Tomcat 默认编码不是 UTF-8,数据库连接串少了 characterEncoding,前端页面本身没有声明 charset,三个环节任何一个断了都会乱。
解决:前端 JSP 的 pageEncoding 设为 UTF-8;web.xml 加 CharacterEncodingFilter 并让它的 mapping 覆盖 /*;JDBC URL 加 useUnicode=true&characterEncoding=utf8。注意 jdbc 配置在 XML 里&要转义成&,否则配置文件解析就报错。
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>5.3 JSON 时间格式变成时间戳或带 T:序列化策略没告诉 Jackson
现象:前端拿到的时间是2025-01-01T10:30:00或者一长串数字,日期显示完全没法看。
原因:MyBatis 查询出来的 LocalDateTime 被 Jackson 默认序列化,默认写法就是 ISO 模式,不是业务想要的2025-01-01 10:30:00。
解决:在 spring-mvc.xml 的 mvc:annotation-driven 里注册自定义 ObjectMapper,或者直接给实体类时间字段加 @JsonFormat 注解。我这种老派做法是加注解,改动最小。
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime startTime;必须注意 timezone。用 LocalDateTime 不受时区影响,但如果不写 timezone,序列化Date类型时可能出现 8 小时偏差。项目里我统一用 LocalDateTime 加 pattern,基本不存在时区问题。
5.4 车位并发超卖:先查后改必须补锁
现象:测试阶段两个浏览器同时预定同一个车位,两边都提示预定成功。
原因:Service 里的逻辑是先 select 再 update,而普通 select 不会锁行,两个事务都能读到 status=0,于是都通过了校验。
解决:两条路。升级 SQL 为 select for update,事务结束前锁住该行;或者用乐观锁在 update 时带 status 条件,影响行数为 0 就说明被别人抢了。我用的是乐观锁:
UPDATE parking_spot SET status = #{targetStatus}, version = version + 1 WHERE id = #{spotId} AND status = #{expectStatus}业务侧只需判断 mapper.update 的返回值是否大于 0,如果不大于 0 就回滚并提示换一个车位。这个写法比行锁更轻量,也更适合演示环境。
5.5 事务不生效:同一个 Service 里的 this 调用
现象:方法标记了 @Transactional,方法内部调用另一个也有事务的私有方法,抛异常后数据库里数据没回滚。
原因:Spring 事务基于 AOP 切面实现,但 this 调用发生在对象内部,走的是原始对象的直接调用,不经过切面,所以第二个方法的事务根本不存在。
解决:把需要独立事务的逻辑放到另一个 Service 里,注入后调用;或者把公共代码抽到一个独立类。最忌讳的是在同一个类里写一个私有方法用于“事务”,那只是自我安慰。
以上每一条都对应我在实际项目里踩过的坑。能提前规避的话,整个联调时间能少砍一天。
6. 交付前的一步:用初始化数据跑通全流程
6.1 让演示不再手忙脚乱的初始化脚本
演示最尴尬的瞬间不是代码报错,而是页面打开一片空白,临时造数据又嫌麻烦。我后来养成的习惯是给项目配一个 init.sql,每次部署完先执行一次,把所有页面可能用到的数据一次性埋好。一个停车系统的初始化脚本不需要复杂,核心就是这几类。
用户至少两个,一个是普通用户 test,一个是管理员 admin,密码用 BCrypt 加密后的字符串存进去,避免明文。车位放 10 到 15 个,分布在 A、B 两个区域,坐标按停车场平面图预先量好。订单最好造一笔“进行中”和一笔“已结算”,这样车位列表明细、订单查询、退租流程都能直接演示。
INSERT INTO parking_spot (spot_no, x, y, status, price_per_hour, area_name) VALUES ('A01', 50, 40, 0, 5.00, 'A区'), ('A02', 90, 40, 0, 5.00, 'A区'), ('B01', 50, 120, 0, 8.00, 'B区');注意车位状态不要全设成空闲,至少要留一个占用、一个维修状态,这样管理员后台能看到不同状态的展示差异。我在一次演示里把所有车位都置为空闲,结果评审问“占用状态什么颜色”时,我只能临时改数据库,体验非常糟糕。
演示路径我建议固定成一条主线:管理员登录后台,添加一个新用户;用户登录,查询 A 区空闲车位,预定一个车位;等 15 分钟超时释放,或者直接入场并租用;进入车位导航页,确认车位位置;结束后结算。走完这条线,标题里提到的功能点全部覆盖。
这一套初始化脚本还有一个好处:每个成员拿到的环境都一致,不再出现你本地调得没问题,一换机器就缺基础数据的情况。我早先做一个某跨平台系统时就是靠这个把联调期硬生生缩短的。希望这一篇对你正在做的智能停车场管理系统有帮助,把状态机、并发锁、配置扫描这几处压住,剩下的就是按业务列表一块块补页面。
本文还有配套的精品资源,点击获取