
简介这是一份基于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、entityresources 下放 mapper XML、Spring 配置和数据库配置。依赖也不复杂核心就四组spring-webmvc、mybatis 全家桶、mysql 驱动、jackson。dependencies dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.20/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version5.3.20/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.27/version /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.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 和 mapperSpring MVC 只扫 controller。!-- applicationContext.xml 关键部分 -- context:component-scan base-packagecom.parking.service, com.parking.mapper/ bean iddataSource classorg.apache.commons.dbcp2.BasicDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/parking?useUnicodetrueamp;characterEncodingutf8/ property nameusername valueroot/ property namepassword valuepassword/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.parking.mapper/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/这段配置里有几个值得记住的参数。mapperLocations 指向 classpath:mapper/*.xml如果你的 XML 没被 Maven 打包进去启动时会报 Invalid bound statement这个现象第 5 章会专门讲。useUnicodetrue 和 characterEncodingutf8 是处理中文入库乱码的前置条件缺少任何一个即使过滤器写对了也可能出问题。另外在 XML 里必须写成amp;否则配置解析直接失败。然后 spring-mvc.xml 只需要两件事开启注解驱动配置视图解析器。context:component-scan base-packagecom.parking.controller/ mvc:annotation-driven/ mvc:default-servlet-handler/ bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix 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。它的作用是让事务提交前这一行不能被其他事务修改所以并发场景下第二个请求会等第一个提交后才执行随后读到的新状态就不是空闲了。这个方案适合低并发项目效果直白但要注意锁的释放时间如果事务里还有慢查询很容易把连接池占满。如果不想用行锁也可以靠乐观锁硬扛。写法是更新时带上前一次的 versionUPDATE 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(); ListReservation 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 ListMapString, Object mapData() { ListParkingSpot spotList parkingSpotMapper.selectAll(); ListMapString, Object result new ArrayList(); for (ParkingSpot spot : spotList) { MapString, 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 classcom.parking.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptorsexclude-mapping 里列出的路径注意别漏。静态资源漏在拦截器外会让 CSS 和 JS 都进不来登录页样式全挂反过来如果忘记把登录接口排除用户连登录都进不去这属于配置顺序问题启动时又不会报错只能一点点排查。4.2 违规举报图片上传与后台处理流程举报功能先要解决图片上传。Spring MVC 用 CommonsMultipartResolver 接收文件配置上有几个固定要求。bean idmultipartResolver classorg.springframework.web.multipart.commons.CommonsMultipartResolver property namemaxUploadSize value10485760/ property namedefaultEncoding valueUTF-8/ property namemaxInMemorySize value4096/ /bean注意这个 bean 的 id 必须是 multipartResolver否则 DispatcherServlet 找不到它文件上传接口会一直得到 empty file。maxUploadSize 是单个上传总量限制10MB 对几张举报图够用maxInMemorySize 表示超过 4KB 的文件直接落临时文件不必占内存这个参数一般不需要改。后端接收接口ResponseBody RequestMapping(value /complaint/add, method RequestMethod.POST) public MapString, 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?pathxxx的接口读取好处是删除和迁移都方便。管理员处理举报的接口更简单先改状态如果查实就把车位设为维修状态避免后续用户误订。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 PageInfoParkingSpot pageSpot(Integer pageNum, Integer pageSize, Integer status) { PageHelper.startPage(pageNum, pageSize); ListParkingSpot list parkingSpotMapper.selectByStatus(status); return new PageInfo(list); }PageHelper 第一个坑是 startPage 必须紧跟业务查询语句中间不能有其他查询第二个坑是不建议在大循环里调用分页方法否则每次循环都会产生一个 Page 对象。另外记得在 SqlSessionFactory 的 plugins 里注册 PageInterceptor否则 startPage 不会生效。我这里用 selectByStatus 是一个筛选接口status 为空时查询全部用动态 SQL 处理。select idselectByStatus resultTypeParkingSpot SELECT * FROM parking_spot where if teststatus ! 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 directorysrc/main/java/directory includes include**/*.xml/include /includes /resource resource directorysrc/main/resources/directory /resource /resources /build5.2 中文乱码过滤器写了也不够连接串也要带编码现象注册页面提交中文用户名Controller 拿到的值已经正常但 insert 后数据库里是问号或者反过来数据库能看中文但页面响应是乱码。原因Tomcat 默认编码不是 UTF-8数据库连接串少了 characterEncoding前端页面本身没有声明 charset三个环节任何一个断了都会乱。解决前端 JSP 的 pageEncoding 设为 UTF-8web.xml 加 CharacterEncodingFilter 并让它的 mapping 覆盖 /*JDBC URL 加 useUnicodetruecharacterEncodingutf8。注意 jdbc 配置在 XML 里要转义成amp;否则配置文件解析就报错。filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping5.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 GMT8) private LocalDateTime startTime;必须注意 timezone。用 LocalDateTime 不受时区影响但如果不写 timezone序列化Date类型时可能出现 8 小时偏差。项目里我统一用 LocalDateTime 加 pattern基本不存在时区问题。5.4 车位并发超卖先查后改必须补锁现象测试阶段两个浏览器同时预定同一个车位两边都提示预定成功。原因Service 里的逻辑是先 select 再 update而普通 select 不会锁行两个事务都能读到 status0于是都通过了校验。解决两条路。升级 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 分钟超时释放或者直接入场并租用进入车位导航页确认车位位置结束后结算。走完这条线标题里提到的功能点全部覆盖。这一套初始化脚本还有一个好处每个成员拿到的环境都一致不再出现你本地调得没问题一换机器就缺基础数据的情况。我早先做一个某跨平台系统时就是靠这个把联调期硬生生缩短的。希望这一篇对你正在做的智能停车场管理系统有帮助把状态机、并发锁、配置扫描这几处压住剩下的就是按业务列表一块块补页面。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。