基于SpringBoot的停车场管理系统:并发抢车位与计费规则实战解析
2026/9/24 22:11:09 网站建设 项目流程

每年到做课设、毕设的时候,“基于SpringBoot的停车场管理系统”都是热度最高的题目之一。我刚带过的几届毕业生里,至少有十几个选了这个方向。说实话,这个题目看起来就是标准的“增删改查”,但真正动手之后才发现,里面藏着并发抢车位、计费规则、状态流转这种让人头大的细节。这篇文章我按自己实际做过的思路,从需求边界、技术选型、数据库设计到计费逻辑、并发处理、项目交付,一条线完整拆开讲,适合正在做这个题目的人,也适合刚学完SpringBoot想找个真实项目练手的初级开发者。

1. 停车管理系统不是“增删改查”:先搞懂需求边界

很多人拿到这个题目的第一反应是:建一个SpringBoot工程,把车位、车辆、订单几个表拉出来,配上增删改查页面,搞定。但真正做完会发现,答辩老师一个问题就能问穿:“两个管理员同时抢最后一个空闲车位怎么办?”所以在写任何代码之前,先把需求想清楚。

1.1 角色画像:三种人用同一套系统

停车场里实际有几种人?至少三种。

管理员是系统的核心使用者。他们要实时知道场内有多少车、哪些车位空了、每个车位的占用情况、今天收了多少钱、某辆车停了多久、月卡什么时候到期。另一类是月卡车车主,他们对系统的诉求很简单:进出不用停车扫码交钱,能查到自己的月卡有效期和余额。第三类是临时车司机,入场时拿到记录凭证,出场时快速算清费用,能付钱走人。

建议再划分一个超级管理员,负责管理普通管理员账号、查看全局统计数据、配置收费规则。这样一个系统就能拆出四类角色,权限边界也比较清晰。

1.2 功能清单:先列满,再砍

做毕设或者课设,最忌讳一上来就写代码。先把功能列全,再根据工期砍掉不核心的。我整理了一份比较标准的停车场管理系统功能清单:

  • 车位管理:车位的增删改查、状态实时维护(空闲/占用/禁用)、区域划分
  • 车辆入场:车牌录入(支持手动输入)、临时车/月卡车分流、空闲车位分配
  • 车辆出场:费用计算、支付方式选择(现金/扫码)、离场记录留存
  • 月卡管理:月卡办理、续费、退卡、到期提前提醒
  • 收费规则配置:免费时长、首小时费用、后续每小时费用、单日封顶、夜间计费
  • 统计报表:今日营收、车辆入场量、车位周转率、月卡到期名单
  • 系统管理:用户管理、角色权限、操作日志

核心链路是“车位 - 入场 - 计费 - 出场 - 账单”,这条线必须做深做扎实。像操作日志、复杂的可视化大屏,如果时间不够,宁可砍掉,也不要让核心功能半吊子。

1.3 为什么“简单功能”会越做越复杂

停车场管理系统真正的复杂度不在CRUD,而在规则。

举几个最常见的业务规则:入场30分钟内免费,超出后按小时计费;晚上8点到次日早上8点有夜间封顶价;月卡车辆出场时如果超时,需要补缴部分费用;某些车位被管理员设置为禁用,系统不能分配;同一辆车不能同时存在两条“在场”记录。

这些规则如果在需求分析阶段没有逐条固化下来,等到代码写完再改,轻则改Service层,重则要动表结构。我见过有人把计费规则全部用if-else堆在代码里,后来又改了三次收费方案,每次改完都要重新编译部署,维护成本非常高。

所以真正专业的做法是:一开始就把“角色、状态、规则”这三样理清楚,再用白纸画一遍核心业务流转图,再动工写工程。

2. 技术选型:SpringBoot不是越新越好

技术选型决定了你的开发效率和容错率。这里我先说一个很重要的观点:做这类校园项目,稳定性比技术的新鲜度重要得多。

2.1 版本与JDK:2.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系列最后一个维护版本,生态非常成熟,网上搜报错基本都能找到答案。

具体操作步骤:

  1. 在Spring Initializr创建项目时,Spring Boot版本选2.7.18,Java版本选8。
  2. 如果本机只装了JDK 17,别强行用17跑老项目,建议安装一个JDK 8,在IDEA里配置好。
  3. 检查 IDEA 的 Project Structure 里的 Project SDK 和 Modules 是否一致,很多同学出现“IDEA不能创建SpringBoot项目”或者编译报错,基本都是SDK没选对。
  4. 还要检查 Maven 的 JDK 设置。IDEA里路径是 File -> Settings -> Build Tools -> Maven - > Runner,把 JRE 指到 JDK 8 的路径。
  5. pom.xml 里确认<java.version>是 1.8。

如果你遇到过提示“源发行版 17 需要目标发行版 17”,就是因为编译器的 target 和 source 没有对齐到 JDK 8,按上面改完就正常了。

2.2 ORM:为什么推荐 MyBatis-Plus

ORM框架我经历过 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),不要用floatdouble
  • 每张表都加create_timeupdate_timedeleted三个公共字段,并开启 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 是否还有 status=0 的记录,如果有,直接拒绝入场或提示“该车辆已在场内”。

第二步和第三步要放在同一个事务里。先找到空闲车位,但不能只 select 一下,因为并发时两个事务可能同时读到同一个空闲车位。正确做法是使用乐观锁风格的更新:

boolean success = parkingSpaceMapper.update(null, new LambdaUpdateWrapper<ParkingSpace>() .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 出场:先锁记录,再算钱

出场流程的核心是防止重复结算。正常情况下出场要处理这几件事:

  1. 根据车牌号或入场记录ID查询在场记录
  2. 校验记录存在且状态是“在场”
  3. 如果车辆是临时车,计算费用并生成账单
  4. 更新入场记录状态为“已离场”,写入离场时间和金额
  5. 释放车位(把车位状态改为空闲)

重复提交一般来自两种情况:一种是管理员手滑点了两次“结算确认”,另一种是前端没有把按钮置灰,用户连点。解决方式很直接,更新时带状态条件:

boolean updated = entryRecordMapper.update(null, new LambdaUpdateWrapper<EntryRecord>() .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 带上时区参数serverTimezone=Asia/Shanghai,避免日期时间不对
  • 静态资源文件是否正常拷贝进了 jar,如果前端用了外部静态目录,要确认路径配置

另外把server.port固定下来,比如 8080,万一被占用可以在启动时临时指定其他端口。

最后分享一条个人体会:做停车场管理系统,真正拉开差距的不是谁用了更花哨的技术,而是你对自己系统的边界理解得够不够清楚。我第一次做的时候也是一边写代码一边补规则,后来在答辩前重新把计费逻辑完整跑了几遍,才发现免费时长和跨天边界一直有问题。如果你也正在做类似项目,建议拿到题目后不要急着建工程,先拿一张纸,把角色、状态、规则画出来,再把代码写下去。后面几次重构的难度会小很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询