
做停车场管理系统这个项目前后实际编码加调试花了大概三周。技术栈是SpringBootVueMyBatisMySQL典型的Java全栈前后端分离方案。这套组合用在“智能停车场车辆管理系统”这个场景上不算花哨但非常稳而且很容易落地。项目做完以后我觉得这类业务系统特别适合作为SpringBoot和Vue的练手项目因为它牵扯到状态流转、并发扣费、数据统计这些很实在的问题不是那种纯CRUD的“玩具项目”。这篇博文就把我当时的设计思路、核心功能怎么做、数据库怎么设计、踩过哪些坑都写出来给正在做类似系统或者准备用这套技术栈做毕业设计/项目实战的朋友一个参考。1. 项目整体设计与架构选型1.1 为什么选SpringBootVueMyBatisMySQL这套组合现在做业务管理系统技术选型其实没什么悬念。SpringBoot负责后端接口Vue负责前端页面MyBatis管数据库操作MySQL存数据这是一个非常成熟的前后端分离开发模式。SpringBoot用的人多是有原因的。它对Spring生态做了大量自动化配置内嵌Tomcat打出一个jar包就能直接跑部署非常方便。停车管理系统不需要多复杂的分布式架构单体应用完全够用SpringBoot这种“开箱即用”的轻量特性正好匹配。Vue这边用Vue2还是Vue3看个人习惯我当时用的是Vue2 Element UI。二者核心原理一样数据驱动视图组件化开发。管理后台类项目用Vue特别顺手页面交互复杂但是Vue的双向绑定可以省掉大量操作DOM的代码。MyBatis的作用主要是把SQL和Java代码解耦。停车场的业务SQL不是那种非常标准化的增删改查比如查询“当前场内车辆数”“某时间段收费总额”SQL写起来有明显业务语义。MyBatis允许你手写SQL把它放在Mapper.xml里既灵活又清晰比JPA那种全自动ORM在复杂查询场景下更可控。MySQL不解释了开源、稳定、资料多中小型系统的首选。这套组合还有一个实际好处网上资料极其丰富。只要遇到问题搜索一下基本都能找到解决方案这在你独立开发一个完整项目的时候非常重要。1.2 系统模块划分与核心业务流程一个智能停车场管理系统表面上看是管理车辆进出实际上拆开来看就是这几个核心模块车辆入场、车位管理、车辆出场、收费管理、月卡管理、统计报表、系统管理。车辆入场是整个系统最核心的流程起点。车辆开到入口车牌识别设备识别出车牌号系统判断这个车牌是否在月卡车牌名单里如果是直接抬杆放行如果不是临时车记录入场时间分配车位抬杆入场。整个过程的关键是生成一条入场记录同时更新车位占用状态。车辆出场是另一个核心流程。识别车牌查询该车牌的入场记录计算停车时长根据计费规则计算应收费用。月卡车辆直接验证有效期后放行临时车辆缴费后放行。关键是更新车位状态确保车位数量一致。收费管理里面我做了按小时计费规则配置可以设置首小时费用、超出部分每小时费用、单日封顶费用。这样收费规则可以灵活配置修改而不是硬编码在代码里。计费规则大致是这样临时车首小时5元之后每小时2元单日封顶20元月卡车按月收费入场时只校验有效期不重复计费免费时长入场后15分钟内出场免费避免短时间进出重复收费统计报表模块提供当日车流量、当日营收、车位利用率这些统计数据用图表展示方便管理者做决策。系统管理就是常规的用户管理、角色管理、操作日志这个不多说所有管理后台都有。1.3 数据库表结构设计思路数据库设计是整个项目的基石。表结构设计不合理后面业务代码怎么写都别扭。我设计了10张左右的表最核心的是这几张车辆入场记录表entry_record记录每一次入场事件。字段包括主键ID、车牌号、入场时间、入场通道、车辆类型临时/月卡、关联车位ID、状态在场/已出场。这张表是所有计费、统计的数据来源。车位信息表parking_space记录车位编号、所属区域、车位类型普通/新能源、状态空闲/占用。新能源车位要带充电桩这个字段是给后续扩展留的。收费规则表fee_rule记录计费参数。字段包括规则名称、首小时费用、续时费用、日封顶金额、免费时长、生效状态。这样收费策略调整不用改代码直接改数据库里的一条记录。车辆表vehicle比较特殊它存的是注册车辆的档案信息。月卡车辆提前录入车牌、车主姓名、手机号、月卡有效期。临时车辆不提前建档入场时直接写入车牌号出场时通过车牌号反查入场记录即可。这些表之间的核心关系就是入场记录关联车辆信息与车位信息计费时通过入场记录查询时长再根据收费规则计算价格。数据结构没有复杂的多对多关系但是状态字段比如入场记录的状态、车位的状态在并发场景下要小心处理后面专门说。2. 后端核心实现与业务逻辑2.1 SpringBoot工程结构设计与接口规范SpringBoot后端工程我采用了常见的分层结构controller、service、mapper、entity、common。Controller层只做参数接收和结果封装不写业务逻辑Service层处理实际业务Mapper层通过MyBatis与数据库交互。接口设计上沿用RESTful风格核心接口大致是这样的POST /api/entry车辆入场POST /api/exit车辆出场GET /api/parking-space/status查询所有车位状态GET /api/records查询出入场记录分页、条件筛选GET /api/revenue/daily查询每日营收统计POST /api/vehicle添加月卡车辆PUT /api/fee-rule修改收费规则统一返回结果我封装了一个Result类包含code、message、data三个字段。code为200表示成功其他为失败或异常。这样前端处理结果时逻辑统一不用每个接口都单独处理。Controller层代码尽量精简。举个例子车辆入场接口的Controller层是这样的RestController RequestMapping(/api) public class EntryController { Autowired private EntryService entryService; PostMapping(/entry) public Result? vehicleEntry(RequestBody EntryRequest request) { EntryVO vo entryService.handleEntry(request.getPlateNumber(), request.getGateId()); return Result.success(vo); } }业务逻辑都在Service里Controller只负责参数传递和结果返回这样接口层非常薄也方便做单元测试。2.2 车辆入场与出场核心逻辑实现车辆入场逻辑是我花时间最多的地方因为要考虑的状态比较多。入场流程大致是这样的先校验车牌号格式然后查这个车牌是不是月卡车。如果是月卡车还要校验月卡有效期。如果过期了按临时车处理但要给出明确提示。接着查当前是否有空闲车位如果没有空闲车位直接返回“车位已满”。如果有车位分配一个空闲车位生成入场记录把车位置为占用。这里有两个关键点。第一个是车牌号校验真实场景中车牌识别可能有误我加了一个简单的校验规则7位长度新能源车牌8位第一位必须是汉字省份简称其他位包含字母和数字。这个校验规则不能太严格否则识别有一点偏差就会误判但也不能没有校验空车牌和乱格式车牌必须挡掉。第二个是入场记录和车位更新的一致性。生成入场记录需要insert车位改为占用需要update这两个操作要么同时成功要么同时失败所以必须放在同一个事务里。Transactional(rollbackFor Exception.class) public EntryVO handleEntry(String plateNumber, String gateId) { // 1.校验车牌格式 if (!PlateNumberValidator.isValid(plateNumber)) { throw new BusinessException(车牌号格式不正确); } // 2.查询车辆信息判断是否月卡 Vehicle vehicle vehicleMapper.selectByPlateNumber(plateNumber); boolean isMonthly false; if (vehicle ! null vehicle.getCardExpireTime().after(new Date())) { isMonthly true; } // 3.查询空闲车位 ParkingSpace space parkingSpaceMapper.selectOneAvailableSpace(); if (space null) { throw new BusinessException(当前无空闲车位); } // 4.生成入场记录 EntryRecord record new EntryRecord(); record.setPlateNumber(plateNumber); record.setEntryTime(new Date()); record.setGateId(gateId); record.setStatus(1); // 1在场 0已出场 record.setVehicleType(isMonthly ? 1 : 0); record.setSpaceId(space.getId()); entryRecordMapper.insert(record); // 5.更新车位状态 parkingSpaceMapper.updateStatus(space.getId(), 1); return buildEntryVO(record, space, isMonthly); }出场逻辑比入场复杂因为涉及计费。流程是根据车牌号查询在场记录 → 计算停车时长分钟 → 按收费规则计算费用 → 如果是月卡车直接放行如果是临时车生成账单 → 更新入场记录状态 → 释放车位。收费计算的细节集中在时长处理和阶梯计费上。我的处理方式是把停车分钟数转成小时数不足1小时按实际小时数计算。比如停了1小时40分钟就按1.67小时算首小时5元超出部分0.67小时×每小时2元向下取整到分。当然这个规则可以根据实际需求调整关键是收费规则和计费引擎要独立方便以后改规则。2.3 MyBatis动态SQL与自定义映射处理MyBatis在项目里主要用在Mapper层。选择用XML方式而不是注解方式是因为停车场的业务SQL有好几条比较复杂XML方式写动态SQL更顺。典型的动态SQL是查询出入场记录支持按车牌号、时间范围、车辆类型组合筛选select idselectRecords resultTypecom.example.entity.EntryRecord SELECT * FROM entry_record where if testplateNumber ! null and plateNumber ! AND plate_number LIKE CONCAT(%, #{plateNumber}, %) /if if teststartTime ! null AND entry_time gt; #{startTime} /if if testendTime ! null AND entry_time lt; #{endTime} /if if testvehicleType ! null AND vehicle_type #{vehicleType} /if /where ORDER BY entry_time DESC LIMIT #{offset}, #{pageSize} /select用过MyBatis的人都知道XML方式最坑的不是写SQL而是字段映射。MyBatis默认开启驼峰映射后数据库字段entry_time能自动映射到Java属性entryTime但是像status这种数据库关键词同名的字段SQL里必须加反引号。我当时因为status字段踩过一个坑后来统一规范化了SQL。另一个实用技能是自定义TypeHandler。停车时长计算里我需要把数据库存储的分钟数int类型映射成Java里的Duration类型。默认TypeHandler处理不了我写了一个自定义TypeHandler来实现Java类型和JDBC类型之间的转换。这个功能平时用得少但在类型不匹配时非常救命。2.4 月卡车辆管理与到期校验月卡管理是停车场系统里比较关键的功能编辑车辆信息、充值续费、到期提醒等都是常见的操作。月卡到期校验我写了一个定时任务每天凌晨扫描一遍所有月卡车辆把当天到期的车辆标记出来并且提前3天给车主发送续费提醒短信或公众号通知。月卡续费的核心是更新有效期字段。这里有个细节月卡续费时新有效期是从当前有效期到期日往后累加而不是从当前时间往后累加。比如有效期到2025年3月15日3月10日续费一个月新有效期应该是2025年4月14日。如果按“当前时间1个月”算车主就白赚了5天所以这段逻辑写的时候要注意。public void renewMonthlyCard(Long vehicleId, int months) { Vehicle vehicle vehicleMapper.selectById(vehicleId); Calendar calendar Calendar.getInstance(); Date expireTime vehicle.getCardExpireTime(); if (expireTime.after(new Date())) { calendar.setTime(expireTime); } else { calendar.setTime(new Date()); } calendar.add(Calendar.MONTH, months); vehicle.setCardExpireTime(calendar.getTime()); vehicleMapper.updateById(vehicle); }这个逻辑不算复杂但面试或答辩时经常被问到建议理清楚。3. 前端页面与交互实现3.1 Vue工程搭建与路由设计前端我用Vue2配合Element UI组件库搭建。工程化用Vue CLI创建装好vue-router和axios整体结构是标准的src/views、src/components、src/api、src/router目录。路由设计上采用动态路由方式。登录成功后根据用户角色动态生成可访问的路由表不同角色看到的菜单不同例如管理员能看到系统管理普通操作员看不到。动态路由的实现思路是后端接口返回当前用户可访问的菜单权限列表前端用router.addRoutes动态挂载路由。这个方案比起静态路由优势在于权限控制更直观不用把隐藏路由写死在前端。页面组件拆分为布局组件和业务组件。布局组件就是常见的管理后台布局顶部导航、左侧菜单、右侧内容区。业务组件按模块拆分比如车位状态页面里每个车位格子是一个子组件接收车位对象作为props根据状态字段渲染不同颜色。这种组件化思路让代码结构非常清晰。3.2 核心页面功能拆解车位状态监控页面是停车场系统的“门面”它要实时展示整个停车场的车位占用情况。我采用网格布局每个格子代表一个车位绿色代表空闲红色代表占用新能源车位额外显示充电桩图标。数据来源是定时轮询后端接口每10秒拉取一次车位列表数据。这个轮询频率在真实场景下够用也不会给服务器造成压力。实时更新还有一个方案是WebSocket但我没采用因为停车场管理后台对车位的实时性要求并不高10秒轮询的延迟完全能接受而且轮询实现更简单不会引入额外依赖。如果要做大屏展示或者车主端App实时推送再上WebSocket也不迟。出入场记录页面主要是一张可筛选的分页表格关键功能是支持按车牌号模糊查询、按时间范围筛选、按车辆类型筛选。表格数据量大时后端做了分页前端每次只加载当前页数据。这里有个实践优化车牌号查询使用Input输入框结合回车触发搜索而不是每输入一个字就查一次避免频繁请求。收费统计页面用折线图和柱状图展示每日营收数据、车流量数据。图表的实现用的是ECharts因为Vue和ECharts的结合非常成熟。3.3 与后端交互的细节处理axios请求封装是前端必须做好的部分。我在api目录下封装了一个request.js统一配置baseURL、超时时间、请求拦截器携带token和响应拦截器统一处理错误码。跨域问题在这里要提前处理。开发环境通过Vue CLI的devServer配置代理转发生产环境用Nginx反向代理。proxy配置如下// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }时间格式化也是一个常见的坑。Java后端返回的时间是LocalDateTime序列化后的格式形如“2025-02-25T14:30:00”前端需要处理成“2025-02-25 14:30:00”这种常见格式。我的做法是在后端加一个Jackson配置全局统一格式化LocalDateTime这样前端拿到的就是格式化好的字符串不需要特别处理。Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder - builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); } }前后端协作场景下这类统一格式约定的问题越早规范化越好避免每个接口单独处理浪费时间和精力。4. 数据库设计与性能优化实践4.1 核心表结构索引设计表结构设计阶段就要把索引考虑进去不要等数据量大了再补。设计表的时候我给各个表的关键查询字段设计了索引。入场记录表entry_record我给plate_number加了索引。为什么因为出场时要通过车牌号反查入场记录这是高频操作没有索引表的查询会很慢。入场时间entry_time是统计查询常用的时间范围筛选字段也加了索引。这里有一个实际体会索引不是越多越好关键是匹配查询场景不要无脑给所有字段加索引。比如status字段状态值很单一0或1区分度低加索引意义不大。车位表parking_space的status字段也面临同样的情况。车位就几百个状态是占用或空闲这个表的数据量很小全表扫描也很快完全不需要索引。数据量小的表索引反而是浪费空间。索引设计有一个基本原则区分度高的字段加索引小表别加大表按查询场景加复合索引。比如查询“某时间段内所有月卡车辆的车流量”这个查询条件涉及vehicle_type和entry_time可以建复合索引(vehicle_type, entry_time)。但前提是SQL真正用到了否则都是纸面优化。4.2 并发场景下的事务与锁处理停车场的并发主要出现在早晚高峰期多个车辆同时入库、同时出库。核心风险在于“车位超卖”也就是两个请求同时查询到有空闲车位然后同时分配了同一个车位。要解决这个问题需要利用数据库层面的行锁。实现方式就是在查询空闲车位时加上FOR UPDATE悲观锁Select(SELECT * FROM parking_space WHERE status 0 LIMIT 1 FOR UPDATE) ParkingSpace selectOneAvailableSpaceForUpdate();FOR UPDATE是行级锁锁住的记录在事务提交或回滚前其他事务无法修改。在高并发下两个同时进来的入场请求一个拿到了锁另一个只能等待。这样就不会出现两个车分配到同一个车位的情况。这里有个需要注意的点FOR UPDATE锁是在事务内才生效。如果查询方法没有被事务包裹即没有Transactional锁会在查询后立即释放等于没锁。所以使用FOR UPDATE的查询所在方法必须有明确的事务边界。出场扣费的一致性同样需要事务。具体来说查询入场记录SELECT、计算费用纯Java逻辑、更新出场记录UPDATE、释放车位UPDATE这4步要么全部成功要么全部失败。如果不加事务中间某一步失败就会出现车已经出场了但车位还占着或者出场记录算出的费用与实际不符这类脏数据问题。我建议出场逻辑加Transactional并把业务校验放在事务方法的最前面这种编程习惯能省掉很多后续排查的麻烦。4.3 慢SQL排查与优化项目上线运行一段时间后可能会出现统计报表页面加载越来越慢的情况。大部分原因就是SQL执行慢需要排查。慢SQL排查的基本路径是先看MySQL的慢查询日志找到耗时超过阈值的SQL语句然后用EXPLAIN分析执行计划看有没有走索引、扫描了多少行。基本思路是查看慢查询日志 → 拿到慢SQL → 用EXPLAIN分析执行计划 → 针对全表扫描的查询加索引或改写SQL。实际工作中常见的SQL性能陷阱有三个在查询条件中对索引字段使用了函数导致索引失效。比如WHERE DATE(entry_time) 2025-02-25会全表扫描应该写成entry_time 2025-02-25 00:00:00 AND entry_time 2025-02-26 00:00:00。LIKE查询以%开头同样会导致索引失效。分页时深翻页比如LIMIT 100000, 20数据库要扫描前100020条数据再丢弃前10万条这种场景用游标分页或延迟关联改写。这几个坑都是实际项目中很容易碰到的建议提前记住。5. 常见问题与排查技巧实录5.1 SpringBoot启动与依赖问题开发中比较常见的一个问题是项目启动报错控制台提示“Consider defining a bean of type com.example.mapper.xxxMapper in your configuration”。大概率原因是没有在启动类上添加MapperScan注解或者添加了但扫描路径不对。解决方案是在启动类上加上MapperScan(com.example.mapper)指定正确的Mapper接口包路径。还有个很经典的坑是版本不匹配。SpringBoot 2.x和SpringBoot 3.x的依赖版本差异很大比如SpringBoot 3.x要求JDK17及以后而很多人本地是JDK8。如果使用了SpringBoot 3.x又没装JDK17直接起不来。我的建议是做这种单体项目用SpringBoot 2.7.x搭配JDK8资料多、兼容性好没必要强行追新版本。等熟悉了基础原理JDK17SpringBoot3.x再自己升级也不迟。5.2 跨域与请求接口404开发环境下前端页面可以正常打开但请求后端接口时控制台报CORS错误这是因为浏览器同源策略拦截。解决方式有两种前端开发服务器配置proxy代理或者后端配置全局CORS。两种方式我建议开发时用proxy代理与生产环境Nginx代理保持一致的路径风格省事一点可以在后端加CrossOrigin或者全局CorsFilter。接口404最常见的原因是前后端路径对不上。比如前端请求的是/api/entry/123后端Controller写的是PostMapping(/entry)漏了路径层级或者请求方法不对GET/POST用错就会报404。这类问题排查时先看浏览器Network面板实际发出的请求URL再对比后端Controller写的路径比对后立即能定位。5.3 时间差8小时问题项目本地运行正常部署到服务器后发现出入场记录的时间比实际时间少了8小时。这是经典的时区问题原因在于MySQL连接配置时区没指定Java和数据库时区不一致。解决方案是在JDBC连接URL里加上serverTimezoneAsia/Shanghai并且统一使用MySQL DATETIME类型存储时间。顺手提一句前端拿到的时间显示不对也可能是Jackson格式化导致的但这个是显示问题一般不是数据问题。排查顺序建议是先查数据库里的值再查后端接口返回的值最后查前端渲染的值这样定位快得多。5.4 MyBatis查询结果字段映射不全问题查询出来某个字段返回null但数据库里这个字段明明有值。大概率原因是数据库字段名与Java实体属性名不一致且驼峰映射没有开启。解决办法是在application.yml里配置mybatis.configuration.map-underscore-to-camel-casetrue同时保证数据库字段用下划线命名entry_timeJava属性用驼峰命名entryTime。如果使用了resultMap手写映射则要检查resultMap里是否漏了字段、字段是否正确。5.5 导出报表数据量过大导致内存溢出统计报表导出Excel如果一次性查询全部数据并写入内存数据量大时会导致内存溢出OutOfMemoryError。我的做法是用MyBatis的流式游标查询Cursor逐行读取数据并写入Excel文件而不是一次性加载全部。思路大概是这样CursorEntryRecord cursor entryRecordMapper.scanAll(); while (cursor.hasNext()) { EntryRecord record cursor.next(); // 逐行写入Excel }这个方案在数据量达到几万条甚至几十万条时效果非常明显内存占用始终是平稳的。如果后端这么处理完还有问题就上EasyExcel的异步导出把导出任务丢到线程池前端先提示“正在导出”完成后下载。6. 写在最后的实战心得这类管理系统项目做完一遍后最大的体会是业务逻辑的复杂度远高于技术本身。SpringBoot、Vue、MyBatis、MySQL这几个框架的基础用法其实一两天就能上手但把停车场的进出场流程、计费规则、车位状态一致性这些业务细节理清楚让系统在真实场景下稳定运行才是真正需要投入精力的地方。有几个具体经验可以分享给正准备做类似项目的朋友。第一个是数据库设计阶段一定要想清楚状态字段的含义和流转方向我一开始设计entry_record表时没有区分“入场未缴费”和“已缴费未出场”的状态导致出场流程代码中间返工了一次。第二个是接口返回的时间格式和金额精度要提前和前端约定好项目中途来回改格式成本很高。第三个是并发场景一定要提前考虑哪怕是小项目“两面针”式的车位分配Bug也很容易在演示的时候暴露出来。如果你正在规划同类系统建议先把核心业务流程用文字画一遍理清状态流转再动手写代码。这个习惯帮你省掉的时间远比多写几行代码多得多。