拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于SpringBoot的停车场管理系统:并发抢车位与计费规则实战解析

每年到做课设、毕设的时候“基于SpringBoot的停车场管理系统”都是热度最高的题目之一。我刚带过的几届毕业生里至少有十几个选了这个方向。说实话这个题目看起来就是标准的“增删改查”但真正动手之后才发现里面藏着并发抢车位、计费规则、状态流转这种让人头大的细节。这篇文章我按自己实际做过的思路从需求边界、技术选型、数据库设计到计费逻辑、并发处理、项目交付一条线完整拆开讲适合正在做这个题目的人也适合刚学完SpringBoot想找个真实项目练手的初级开发者。1. 停车管理系统不是“增删改查”先搞懂需求边界很多人拿到这个题目的第一反应是建一个SpringBoot工程把车位、车辆、订单几个表拉出来配上增删改查页面搞定。但真正做完会发现答辩老师一个问题就能问穿“两个管理员同时抢最后一个空闲车位怎么办”所以在写任何代码之前先把需求想清楚。1.1 角色画像三种人用同一套系统停车场里实际有几种人至少三种。管理员是系统的核心使用者。他们要实时知道场内有多少车、哪些车位空了、每个车位的占用情况、今天收了多少钱、某辆车停了多久、月卡什么时候到期。另一类是月卡车车主他们对系统的诉求很简单进出不用停车扫码交钱能查到自己的月卡有效期和余额。第三类是临时车司机入场时拿到记录凭证出场时快速算清费用能付钱走人。建议再划分一个超级管理员负责管理普通管理员账号、查看全局统计数据、配置收费规则。这样一个系统就能拆出四类角色权限边界也比较清晰。1.2 功能清单先列满再砍做毕设或者课设最忌讳一上来就写代码。先把功能列全再根据工期砍掉不核心的。我整理了一份比较标准的停车场管理系统功能清单车位管理车位的增删改查、状态实时维护空闲/占用/禁用、区域划分车辆入场车牌录入支持手动输入、临时车/月卡车分流、空闲车位分配车辆出场费用计算、支付方式选择现金/扫码、离场记录留存月卡管理月卡办理、续费、退卡、到期提前提醒收费规则配置免费时长、首小时费用、后续每小时费用、单日封顶、夜间计费统计报表今日营收、车辆入场量、车位周转率、月卡到期名单系统管理用户管理、角色权限、操作日志核心链路是“车位 - 入场 - 计费 - 出场 - 账单”这条线必须做深做扎实。像操作日志、复杂的可视化大屏如果时间不够宁可砍掉也不要让核心功能半吊子。1.3 为什么“简单功能”会越做越复杂停车场管理系统真正的复杂度不在CRUD而在规则。举几个最常见的业务规则入场30分钟内免费超出后按小时计费晚上8点到次日早上8点有夜间封顶价月卡车辆出场时如果超时需要补缴部分费用某些车位被管理员设置为禁用系统不能分配同一辆车不能同时存在两条“在场”记录。这些规则如果在需求分析阶段没有逐条固化下来等到代码写完再改轻则改Service层重则要动表结构。我见过有人把计费规则全部用if-else堆在代码里后来又改了三次收费方案每次改完都要重新编译部署维护成本非常高。所以真正专业的做法是一开始就把“角色、状态、规则”这三样理清楚再用白纸画一遍核心业务流转图再动工写工程。2. 技术选型SpringBoot不是越新越好技术选型决定了你的开发效率和容错率。这里我先说一个很重要的观点做这类校园项目稳定性比技术的新鲜度重要得多。2.1 版本与JDK2.7.x JDK 8/11 的组合最稳这几年很多同学启动项目就报错绝大多数原因不是代码问题而是SpringBoot版本和JDK版本不匹配。SpringBoot 3.x 要求 JDK 17并且把 javax.* 换成了 jakarta.*很多老版本第三方库还没适配。如果你只是做停车场管理系统完全没必要给自己加这些负担。我的建议是使用 SpringBoot 2.7.18 配 JDK 8 或 11。SpringBoot 2.7.x 是2.x系列最后一个维护版本生态非常成熟网上搜报错基本都能找到答案。具体操作步骤在Spring Initializr创建项目时Spring Boot版本选2.7.18Java版本选8。如果本机只装了JDK 17别强行用17跑老项目建议安装一个JDK 8在IDEA里配置好。检查 IDEA 的 Project Structure 里的 Project SDK 和 Modules 是否一致很多同学出现“IDEA不能创建SpringBoot项目”或者编译报错基本都是SDK没选对。还要检查 Maven 的 JDK 设置。IDEA里路径是 File - Settings - Build Tools - Maven - Runner把 JRE 指到 JDK 8 的路径。pom.xml 里确认java.version是 1.8。如果你遇到过提示“源发行版 17 需要目标发行版 17”就是因为编译器的 target 和 source 没有对齐到 JDK 8按上面改完就正常了。2.2 ORM为什么推荐 MyBatis-PlusORM框架我经历过 JPA、MyBatis、MyBatis-Plus 三种都用过的阶段给这个项目做技术选型的话我推荐直接上 MyBatis-Plus。对比维度JPA原生 MyBatisMyBatis-Plus基础CRUD不需要写SQL需要写大量XML自带BaseMapper零SQL复杂查询需要学QBC/JPQLSQL灵活支持LambdaQueryWrapper中文资料一般很多很多分页Pageable手写分页或插件内置分页插件学习成本偏高一般低MyBatis-Plus 最舒服的地方是单表操作几乎不用写SQL。查询车辆、车位状态、插入入场记录一行 BaseMapper 的方法就搞定。分页也简单但要注意分页插件需要先配置拦截器很多人漏了这步导致分页查询根本不生效。配置代码如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setOverflow(false); paginationInterceptor.setMaxLimit(500L); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }2.3 前端方案不折腾才是王道前端有两条路线可选。一条是 Thymeleaf Bootstrap和SpringBoot同属一个生态服务端渲染不用考虑跨域问题搭后台管理页面非常快。另一条是 Vue Element UI 做前后端分离视觉上更现代但需要处理跨域配置、独立部署或者把dist目录打包进SpringBoot的static目录。我的建议很务实如果是课程设计或者时间紧张的毕设直接选 Thymeleaf Bootstrap如果做完后端还有充足时间再考虑Vue。核心是后端 API 要设计清晰前端只是展示层。答辩老师更在意的还是你后端逻辑的完整性和自己对系统的理解。3. 数据库设计从车位到账单的业务闭环数据库设计是这个项目的地基。表设计得好后面写业务代码会非常顺畅反过来表设计混乱业务层再努力都很难做到严谨。3.1 六张核心表一张表打通整个业务停车场管理系统最核心的表至少有六张它们之间的关系是一条完整的业务闭环。表名关键字段作用parking_spaceid, space_no, area, type, status维护车位基础数据与状态vehicleid, plate_no, owner_name, phone, type登记车辆信息区分临时车/月卡车entry_recordid, plate_no, space_id, entry_time, exit_time, status, amount每一次入场的完整记录month_cardid, plate_no, start_date, end_date, balance, status月卡办理与有效期管理charge_ruleid, rule_name, free_minutes, first_hour_fee, per_hour_fee, max_fee_per_day收费规则配置billid, record_id, amount, pay_method, pay_time出场后的结算账单额外补充几个设计细节金额字段一律用decimal(10,2)不要用float和double。每张表都加create_time、update_time、deleted三个公共字段并开启 MyBatis-Plus 的逻辑删除后续做数据恢复和排查会更方便。车牌号、手机号这类高频检索字段要建索引避免数据量大了以后查询变慢。状态字段建议用 TINYINT 存数字并在代码中定义枚举常量不要直接散落写死数字。比如车位的状态我习惯用 0 表示空闲、1 表示占用、2 表示禁用。车辆类型用 0 表示临时车、1 表示月卡车。入场记录状态用 0 表示在场、1 表示已离场。这样做的好处是在代码里判断逻辑可读性非常高。3.2 状态流转关键业务状态不能随便改系统里最容易出 bug 的地方就是状态没有约束。车位、入场记录、月卡都有状态字段但很多学生的代码里这些状态经常被随意 update导致数据不一致。车位状态流转必须是这样空闲0到占用1是入场时触发占用1回空闲0是出场结算完成后触发禁用2只能由管理员操作禁用状态下不能分配给任何车辆。入场记录的状态流转也必须严格遵守入场时生成记录状态为“在场0”出场结算成功后才变成“已离场1”。一旦变为已离场就不允许再对这个记录做任何金额修改更不允许重复结算。这需要在Service层做好前置校验。月卡的状态则更偏时间驱动有效期内是正常状态到期后自动视为过期续费后重新开始计时。不建议在数据库里跑定时任务去改状态而是在查询时实时判断有效期这样更简单也更可靠。3.3 计费规则把规则放进数据库而不是代码计费规则是这个系统的灵魂也是交付时老师最容易追问的地方。我的建议是设计一张 charge_rule 表把计费参数全部配置化。比如这张表至少包含rule_name规则名称比如“标准临停计费”free_minutes免费分钟数first_hour_fee首小时费用per_hour_fee超出一小时后每小时费用max_fee_per_day单日封顶金额night_start、night_end、night_fee夜间计费时段和费用为什么要放数据库而不是Java代码里因为停车场运营方调整收费策略是常见事。一旦规则变了运营人员只需要在管理后台改一条记录不需要重新编译发版。代码里要做的是把规则表读取出来用策略模式计算。4. 核心业务模块入场、计费、出场全链路数据库设计好了业务代码就是围绕这几张表的流转。这一章我把入场、计费、出场三个月关键模块的完整思路和核心代码写出来。4.1 入场先找车位再锁车位入场的正常流程是校验车牌是否已有在场记录 - 查询空闲车位 - 抢占车位 - 插入入场记录。第一步的校验非常关键。如果一辆车已经停在停车场里又因为其他入口重复入场会造成后续出场计费的混乱。所以入场时要先查 entry_record 是否还有 status0 的记录如果有直接拒绝入场或提示“该车辆已在场内”。第二步和第三步要放在同一个事务里。先找到空闲车位但不能只 select 一下因为并发时两个事务可能同时读到同一个空闲车位。正确做法是使用乐观锁风格的更新boolean success parkingSpaceMapper.update(null, new LambdaUpdateWrapperParkingSpace() .eq(ParkingSpace::getId, spaceId) .eq(ParkingSpace::getStatus, 0) .set(ParkingSpace::getStatus, 1)) 0;这条 update 语句的意思是只有当这条车位的 status 还是 0 的时候才把它改成 1。如果更新的影响行数返回 0说明车位已经被别人抢走需要换一个车位。这比先查询再更新的做法安全得多。确认车位锁定成功后再插入入场记录把车牌号、入场时间、分配的车位ID写清楚。4.2 计费把规则变成一段可测试的代码计费是坑最多的模块。我建议把计费逻辑单独抽出一个策略类不要在 Controller 或者 Service 里写一大串 if-else。下面是一个简化的临时车计费实现你可以直接参考改造public class TemporaryChargeCalculator { private final ChargeRuleDO rule; public BigDecimal calculate(LocalDateTime entryTime, LocalDateTime exitTime) { long minutes Duration.between(entryTime, exitTime).toMinutes(); if (minutes rule.getFreeMinutes()) { return BigDecimal.ZERO; } long payableMinutes minutes - rule.getFreeMinutes(); BigDecimal totalAmount BigDecimal.ZERO; if (payableMinutes 60) { totalAmount rule.getFirstHourFee(); } else { totalAmount rule.getFirstHourFee(); long extraHours (payableMinutes - 60 59) / 60; BigDecimal extraAmount rule.getPerHourFee().multiply(BigDecimal.valueOf(extraHours)); totalAmount totalAmount.add(extraAmount); } if (rule.getMaxFeePerDay() ! null totalAmount.compareTo(rule.getMaxFeePerDay()) 0) { totalAmount rule.getMaxFeePerDay(); } return totalAmount; } }这里有几个比较容易出错的细节。一个是(payableMinutes - 60 59) / 60是为了做向上取整停车1小时1分钟也要按2小时算钱。另一个是大金额计算每小时费用乘以小时数如果规则是首小时10元后面每小时5元停一天的结果不会因为整数乘法而丢精度。如果后面还要扩展夜间计费、跨天规则就把这段逻辑再拆成“白天计费器”和“夜间计费器”用策略模式组合。4.3 出场先锁记录再算钱出场流程的核心是防止重复结算。正常情况下出场要处理这几件事根据车牌号或入场记录ID查询在场记录校验记录存在且状态是“在场”如果车辆是临时车计算费用并生成账单更新入场记录状态为“已离场”写入离场时间和金额释放车位把车位状态改为空闲重复提交一般来自两种情况一种是管理员手滑点了两次“结算确认”另一种是前端没有把按钮置灰用户连点。解决方式很直接更新时带状态条件boolean updated entryRecordMapper.update(null, new LambdaUpdateWrapperEntryRecord() .eq(EntryRecord::getId, recordId) .eq(EntryRecord::getStatus, 0) .set(EntryRecord::getStatus, 1) .set(EntryRecord::getExitTime, now) .set(EntryRecord::getAmount, amount)) 0; if (!updated) { throw new ServiceException(该入场记录已结算请勿重复操作); }这样即使两个请求同时进来也只有第一个能把状态从0改成1第二个请求因为条件不满足更新失败。避免了自己先查再判的“检查然后执行”竞态条件。4.4 月卡与临时车互斥别让一辆车有双重身份月卡车辆在有效期内出场不需要再单独计费但系统必须保证同一辆车不会同时以“月卡”和“临时车”两种身份在库里留下混乱数据。比较简单可靠的处理方式是入场时先去查 month_card 表判断当前车牌是否存在有效月卡。如果存在入场记录里的车辆类型标记为月卡车出场时不走计费逻辑直接置为离场并释放车位。如果月卡已过期则按临时车流程处理入场前提示管理员或车主“月卡已过期本次按临时车计费”。另一个要注意的是月卡和临时车的入场记录都应该在同一个 entry_record 表里只是用一个 type 字段区分。不要把月卡车记录单独建表否则统计报表时还得做表关联平白增加查询复杂度。5. 答辩和面试的高频提问提前想好答案这个项目最常见的高频追问集中在这几个方向并发、精度、事务、索引。提前想好答案答辩和面试都会从容很多。5.1 并发抢车位还剩最后一个车位怎么办这是停车场系统里最有技术含量的问题。场景是系统里只剩最后一个空闲车位A入口和B入口的两位管理员几乎同时操作怎么保证不会把同一个车位分配给两辆车核心思路是让“占车位”这个动作原子化。不要“先查询空闲车位再在内存里判断最后再去更新”而是直接把更新条件写在SQL里UPDATE parking_space SET status 1, update_time NOW() WHERE id #{spaceId} AND status 0;更新的影响行数如果大于0说明当前时刻只有这个事务抢到了车位如果返回0说明车位刚刚被别人占用需要重新查询下一个空闲车位。MySQL 的行锁在 update 时生效多个事务同时执行也不会出现同时成功的情况。如果想做得更稳可以配合select ... for update在事务里锁行但这会增加锁粒度查询时也要注意顺序。对毕设而言最理想方案是“条件更新 影响行数判断”。5.2 金额精度为什么float和double会翻车这个问题很基础但很多人真的会踩。浮点数在计算机里是二进制表示的像 0.1 这种十进制小数二进制根本表示不精确。用 float 或 double 计算金额长期累计下来会出大问题。可能有人会拿“金额差几分钱无所谓”来反驳但在计费系统里出现对不上账是致命伤。而且答辩老师绝对会追问到底。正确的做法是数据库字段用decimal(10,2)Java 里用 BigDecimal运算时全部走 BigDecimal 的 add/multiply/compareTo注意一个很隐蔽的坑BigDecimal bad new BigDecimal(10.5); // 错误写法 BigDecimal good new BigDecimal(10.5); // 正确写法直接传 double 的构造方式会把二进制浮点数的误差带进来结果可能是 10.499999999... 这种诡异的值。正确方式永远是传字符串或者使用BigDecimal.valueOf(10.5)。5.3 事务边界一次出场操作哪些必须一起成功一次出场操作涉及多张表的更新入场记录状态、车位状态、账单生成。这些要么全部成功要么全部失败否则系统会处于中间态。简单做法是在Service方法上加Transactional注解由Spring管理事务边界Transactional(rollbackFor Exception.class) public void settleParking(String recordId) { // 1. 查询记录 // 2. 校验状态 // 3. 计算金额 // 4. 更新入场记录 // 5. 更新车位状态 // 6. 生成账单 }加事务时要注意一个反模式不要在事务里做远程HTTP调用、等待用户输入、sleep这样耗时很长的操作。因为事务会一直持握数据库连接并发一高很容易把连接池打满。5.4 索引设计写SQL之前先想清楚查询场景索引不难但很多人都没认真设计。停车场的查询场景比较固定按查询角度建索引即可。查询场景推荐索引查询某车牌当前是否在场entry_record(plate_no, status)查询一段时间的出入场记录entry_record(entry_time)按入场记录查账单bill(record_id) 唯一索引查月卡是否有效month_card(plate_no) 唯一索引按状态分配车位parking_space(status, type)MyBatis-Plus 的字段注解里可以用TableField或建表SQL直接创建索引具体看你用哪种方式管理表结构。重要的是设计阶段就要想到而不是等到数据量大跑慢了再来补。6. 项目交付让演示不翻车的小细节项目功能做完了不代表就万事大吉。我见过很多人源码写得不错结果演示的时候因为环境问题当场翻车。这一章聊聊如何让项目交付更体面。6.1 初始化数据脚本启动就有内容可看给项目准备一份data.sql或者init.sql里面预置好停车位、管理员账号、收费规则。这样老师或者同学拿到项目后导入数据库启动项目就能直接看到数据不会面对一片空白。INSERT INTO parking_space (space_no, area, type, status) VALUES (A001, A区, 0, 0), (A002, A区, 0, 0), (A003, A区, 0, 0); INSERT INTO charge_rule (rule_name, free_minutes, first_hour_fee, per_hour_fee, max_fee_per_day) VALUES (标准临停计费, 30, 5.00, 3.00, 30.00); INSERT INTO sys_user (username, password, role) VALUES (admin, 加密后的密码, ADMIN);密码字段记得要用加密后的值不要明文存。如果用了MD5加盐或BCrypt脚本里也要保持和代码逻辑一致。6.2 README和演示流程别人能跑起来比什么都重要README 的质量往往被低估。一份好的 README 至少包含项目运行环境要求JDK版本、Maven版本、MySQL版本数据库初始化步骤配置文件修改说明主要是数据库连接、端口启动命令和访问地址自带测试账号和密码演示前自己练一遍完整流程从启动项目到登录、录入车辆、入场、出场、查看账单这几步走顺了答辩效果会好很多。6.3 打包部署从IDEA到jar的一步之遥如果用 IDEA 开发本地跑没问题但演示现场有时会遇到环境不一致的问题。提前打成可执行 jar 会更稳妥。mvn clean package -DskipTests java -jar target/parking-system-0.0.1-SNAPSHOT.jar打包前检查三点application.yml 里的数据库连接和生产环境是否匹配MySQL 连接 URL 带上时区参数serverTimezoneAsia/Shanghai避免日期时间不对静态资源文件是否正常拷贝进了 jar如果前端用了外部静态目录要确认路径配置另外把server.port固定下来比如 8080万一被占用可以在启动时临时指定其他端口。最后分享一条个人体会做停车场管理系统真正拉开差距的不是谁用了更花哨的技术而是你对自己系统的边界理解得够不够清楚。我第一次做的时候也是一边写代码一边补规则后来在答辩前重新把计费逻辑完整跑了几遍才发现免费时长和跨天边界一直有问题。如果你也正在做类似项目建议拿到题目后不要急着建工程先拿一张纸把角色、状态、规则画出来再把代码写下去。后面几次重构的难度会小很多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门