
又是一年毕设季“JavaSpringBoot智能停车场管理系统”这个标题几乎是我被问得最多的题目之一。它的核心是做一个Web版停车场车位预约收费平台用户在线查看车位、预约时段、入场出场、自动计费管理员维护车位、规则、订单和统计报表。用SpringBoot写后端接口页面做前端交互刚好覆盖了Java开发日常工作中最常用的那套技能登录鉴权、数据库设计、状态流转、金额计算、并发控制。不管你是拿它当毕业设计、求职项目还是期末大作业这个题的性价比都很高。我把自己做完整个项目的完整记录整理出来包括表结构怎么设计、预约并发怎么处理、收费时间怎么算、答辩怎么讲还有一些常规文档里不会写的踩坑经验希望能帮你少走弯路。1. 整体设计与技术选型先想清楚再做很多人拿到这类项目第一反应是“赶紧建表写代码”我建议反过来先花半天把业务边界画清楚不然做到一半会发现功能越加越多代码越写越乱。1.1 这个项目本质上在解决什么问题智能停车场管理系统听起来高大上拆开看其实是三个核心场景车位移情车位有多少、分布在哪个区域、当前是空闲/占用/预约状态这套数据要实时可见。车位预约用户选定某个空闲车位和时间段系统把车位锁定避免别人再约同一个车位。收费计费车辆入场到出场这段时间按预设规则算出应收金额并生成一条停车记录。围绕这三个场景还能延伸出注册登录、权限管理、车位CRUD、订单查询、统计图表这些常见模块。整个系统就是一个“带状态管理的业务系统”这在Java面试里也是高频考点尤其是SpringBoot自动装配、事务、并发控制这些问题都能拿这个项目当例子讲。1.2 为什么用JavaSpringBoot这个组合坦率说这个题用Python Flask、PHP也能做但如果你目标明确是Java方向SpringBoot是当前最主流的选择。SpringBoot内置Tomcat不用手动配置一堆XML项目能直接跑起来。起步依赖把常用组件的版本统一管理好了比如web、数据访问、校验这些pom文件干净很多。自动装配机制让项目“约定优于配置”配置文件里写少量必要项即可。生态成熟MyBatis-Plus、Redis、JWT、Vue这些都有大量案例能参考遇到问题搜得到答案。我见过不少同学纠结要不要用“若依”这类脚手架我的建议是如果是为了快速做管理后台若依确实省事但如果是毕设需要讲原理、需要有亮点最好还是自己搭一个结构清晰的单体项目。因为答辩老师大概率会问“登录怎么实现的”“重复预约怎么防止”如果是脚手架自动生成的代码你很难讲清楚也容易露怯。1.3 明确技术栈清单我最终用的版本组合如下都是经过实际测试比较稳的组件选型说明JDK1.8兼容性最好公司生产环境大量使用SpringBoot2.7.18避免直接上3.x3.x要求JDK17且部分旧教程不适用数据库MySQL 5.7免费稳定支持事务ORMMyBatis-Plus单表CRUD不用手写SQL省时间鉴权JWT无状态登录适合前后端分离前端Vue2 Element UI组件成熟中文文档多打包后能放进SpringBoot构建工具Maven不需要额外学Gradle工具库Hutool、Lombok减少重复代码这里有个经验版本号千万别追新。SpringBoot 3.x虽然已经很多年了但很多网上的教程还是基于2.x写的你真遇到问题去查资料会发现一半答案是2.x的写法浪费时间。选2.7.x是稳妥路线。JDK也建议用8或11不要用太高版本因为部分数据库驱动和老依赖在高版本JDK下会有坑。2. 核心功能拆解与数据库设计功能模块想清楚之后数据库设计就是整个项目的地基。很多同学建表凭感觉字段名随意、类型乱用、不加索引导致后面代码越写越别扭。我建议按“主数据—业务单据—规则配置”三层来设计。2.1 预约收费的业务闭环先走一遍完整业务流确认每个节点需要哪些数据用户注册/登录。查看车位列表车位按区域和编号区分状态有三种空闲、已预约、已占用。用户选择一个“空闲”车位填写预约开始时间和结束时间。系统校验时间合法性不能选过去时间、结束时间必须晚于开始时间。系统锁定车位生成预约订单状态为“待入场”。用户到场后管理员/系统确认入场预约状态变更为“已入场”。用户出场时系统根据实际入场时间和出场时间计算费用生成停车记录。预约过期未入场则自动释放车位订单标记为“已过期”。这条链路里有两个关键点一是“锁定车位”必须防止并发冲突二是“计费时间”必须按实际入场出场计算而不是按预约时间这一点很多初学者会忽略。2.2 数据表设计要点我按职责拆成五张核心表sys_user用户表包含账号、密码、姓名、手机号、车牌号、角色管理员/普通用户。car_port车位表包含车位编号、所属区域、状态字段。reservation预约订单表包含用户ID、车牌号、车位ID、预约开始时间、预约结束时间、订单状态、支付状态、金额、创建时间。parking_record停车记录表包含预约订单ID、车牌号、车位ID、入场时间、出场时间、停车时长、实际收费、状态。fee_rule计费规则表包含首小时价格、超出每小时价格、每日封顶金额、免费分钟数、是否启用。一些表字段的命名规则建议统一主键统一叫id使用BIGINT自增。时间字段统一叫create_time、update_time使用datetime。金额字段一律用DECIMAL(10,2)禁止使用float/double。状态字段用tinyint并且要在代码里定义常量或枚举不要散落魔法数字。下面是reservation表的核心建表SQL可以直接参考CREATE TABLE reservation ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, user_id BIGINT NOT NULL COMMENT 用户ID, car_plate VARCHAR(20) NOT NULL COMMENT 车牌号, car_port_id BIGINT NOT NULL COMMENT 车位ID, reserve_start DATETIME NOT NULL COMMENT 预约开始时间, reserve_end DATETIME NOT NULL COMMENT 预约结束时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0待入场 1已入场 2已完成 3已取消 4已过期, pay_status TINYINT NOT NULL DEFAULT 0 COMMENT 支付状态0未支付 1已支付, amount DECIMAL(10,2) DEFAULT 0.00 COMMENT 应收金额, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_car_port_id (car_port_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约订单表;几个需要特别注意的细节pay_status和status要分开一个是业务状态一个是支付状态不要混在一个字段里不然统计很难写。时间字段用datetime不要用varchar存储否则排序、计算时长都会很痛苦。索引不要乱加业务上真正会查询的条件加索引就够了比如user_id、car_port_id、status。如果需要“只查未删除数据”建议加一个deleted字段配合MyBatis-Plus的逻辑删除避免误删数据。3. 关键模块实现与实操步骤这一部分是整个项目的重头我挑四个最容易卡住的地方展开讲项目初始化、登录鉴权、预约并发、收费计算。3.1 快速初始化SpringBoot项目我习惯直接用Spring Initializr生成基础工程也可以自己手写pom。需要引入的关键依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.25/version /dependencyapplication.yml里比较关键的是数据源和MyBatis-Plus配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/parking_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里额外提醒一句serverTimezoneAsia/Shanghai必须加上否则你本地插入的时间和数据库里查出来的时间经常会差8个小时这种问题排查起来非常浪费时间。包结构建议按这个方式组织com.xxx.parking ├── common // 通用返回结果、异常处理、常量 ├── config // 拦截器、跨域等配置 ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // 数据访问层 ├── entity // 数据库实体 ├── dto // 请求/响应对象 └── util // 工具类JWT等这样的分层在答辩时也容易讲老师问“三层架构”你直接照着指就行。3.2 登录鉴权JWT实现无状态登录登录模块是最能体现基本功的部分。我用JWT而不是Session是因为前后端分离场景下无状态更合适而且JWT的原理Header.Payload.Signature很适合在答辩时展开讲。JWT工具类核心方法如下Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; public String generateToken(Long userId, String username, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } public boolean isTokenValid(String token) { try { parseToken(token); return true; } catch (Exception e) { return false; } } }然后写一个拦截器在进入Controller之前校验请求头里的AuthorizationComponent public class JwtInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); if (jwtUtil.isTokenValid(token)) { // 把用户信息放到请求上下文里后面Controller直接取 Claims claims jwtUtil.parseToken(token); request.setAttribute(userId, claims.getSubject()); request.setAttribute(role, claims.get(role)); return true; } } response.setStatus(401); return false; } }在配置类里注册拦截器并放行登录、注册等不需要鉴权的接口Configuration public class WebConfig implements WebMvcConfigurer { Autowired private JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register); } }密码存储不要用明文使用BCryptPasswordEncoderSpring Security里可以直接引入也可以单独用hutool的BCrypt工具。3.3 预约并发处理怎么防止同一个车位被重复约这是整个项目里最有含金量的部分。先说现象如果两个用户同时选中同一个空闲车位提交预约都通过了“检查车位状态0”这个判断就会产生两条预约记录车位超卖。很多人一开始想到用synchronized加锁但这个方案在单机多线程下有效一旦项目部署到多实例就失效了。更靠谱的做法是利用数据库行锁做原子更新。步骤拆解用户提交预约。在事务内先执行原子更新UPDATE car_port SET status 1 WHERE id #{carPortId} AND status 0如果affected rows 1说明当前事务成功抢到了这个车位的“锁”可以继续插入预约订单。如果affected rows 0说明车位已经被别人抢走直接返回错误“该车位已被预约”。Mapper层对应的方法Mapper public interface CarPortMapper extends BaseMapperCarPort { Update(UPDATE car_port SET status 1 WHERE id #{id} AND status 0) int lockPort(Long id); }Service层的核心逻辑Transactional(rollbackFor Exception.class) public Reservation createReservation(ReservationRequest request) { // 1. 校验车位存在 CarPort port carPortMapper.selectById(request.getCarPortId()); if (port null) { throw new BizException(车位不存在); } // 2. 原子抢占车位 int rows carPortMapper.lockPort(request.getCarPortId()); if (rows 0) { throw new BizException(该车位已被预约请选择其他车位); } // 3. 计算金额 BigDecimal amount feeCalculator.calculate(request.getReserveStart(), request.getReserveEnd()); // 4. 创建预约订单 Reservation reservation new Reservation(); reservation.setUserId(request.getUserId()); reservation.setCarPlate(request.getCarPlate()); reservation.setCarPortId(request.getCarPortId()); reservation.setReserveStart(request.getReserveStart()); reservation.setReserveEnd(request.getReserveEnd()); reservation.setStatus(ReservationStatus.WAITING); reservation.setPayStatus(PayStatus.UNPAID); reservation.setAmount(amount); reservationMapper.insert(reservation); return reservation; }其实很多时候技术上没有银弹最朴素的数据库条件更新反而最可靠。再加上Transactional保证事务一致性这个方案在毕设答辩里足够展示你的思考深度。如果想再进一步可以提到“可以用Redis分布式锁或数据库乐观锁”但核心演示用上面的方案就够稳了。补充一点执行lockPort后如果后续插入订单失败事务回滚车位的status也会跟着回滚到0不会出现“车位锁了但订单没建”的脏数据。这就是事务的好处所以Transactional一定要加。3.4 收费规则与时间计算收费计算是评委比较爱问的部分也是新手容易写错的点。常见的规则是免费停车时长15分钟以内免费。首小时价格比如5元。超出部分按小时加收每小时3元不足一小时按一小时算。单日封顶比如30元。金额统一使用BigDecimal。时间差值用Duration.between或ChronoUnit.MINUTES.between不要用(end.getTime() - start.getTime()) / 1000这种老写法。核心计算逻辑public BigDecimal calculate(LocalDateTime start, LocalDateTime end) { if (start null || end null || !end.isAfter(start)) { throw new BizException(时间参数不合法); } long minutes ChronoUnit.MINUTES.between(start, end); // 免费时间内 if (minutes feeRule.getFreeMinutes()) { return BigDecimal.ZERO; } // 超过免费时间后按小时计算 BigDecimal total feeRule.getFirstHourPrice(); long remainMinutes minutes - feeRule.getFreeMinutes(); if (remainMinutes 60) { long extraHours (remainMinutes - 60) / 60; if ((remainMinutes - 60) % 60 0) { extraHours; } total total.add(feeRule.getExtraHourPrice() .multiply(BigDecimal.valueOf(extraHours))); } // 单日封顶这里省略跨天判断简单版本按最大时间24小时封顶 BigDecimal maxDaily feeRule.getMaxDailyPrice(); if (total.compareTo(maxDaily) 0) { total maxDaily; } return total.setScale(2, RoundingMode.HALF_UP); }需要注意“不足一小时按一小时算”的向上取整逻辑用取余判断是否大于0即可别用Math.ceil处理分钟数容易出现精度问题。还有跨天问题如果是多天停车简单处理方式是按天拆分分别计算每天费用后相加或者直接对总时长做封顶判断。毕设场景下建议把跨天计费做成“总天数 * 单日封顶 余小时计费”规则清晰代码也不会太复杂。3.5 前端Web页面与前后端打通前端我建议用Vue2 Element UI做管理后台风格不要花太多时间在花哨特效上页面清爽、功能完整即可。页面清单登录/注册页。车位总览页面一张车位状态地图用Grid布局不同颜色表示空闲/预约/占用。预约页面选择车位、选择时间、确认金额。个人订单页查看预约记录、取消未入场的预约。管理后台车位管理、计费规则配置、订单管理、停车记录。Vue项目通过npm run build后生成dist目录把dist下内容复制到SpringBoot的src/main/resources/static里这样后端jar包直接包含前端页面单端口启动演示和部署都方便。这一步操作在网上经常被搜到其实就是“vue打包放进springboot”的常规做法不需要额外配置Nginx。接口基地址注意处理跨域开发环境可以用vue.config.js配置代理生产环境因为前后端同源了不存在跨域问题。4. 常见问题与排查技巧实录这个项目里的坑不少我把最常见的几个整理成速查表方便你遇到问题时直接对照。4.1 高频问题速查表现象可能原因解决办法查询时间总是差8小时JDBC连接未指定时区数据库连接URL加serverTimezoneAsia/Shanghai前端拿到的金额是科学计数法BigDecimal序列化成Number配置Jackson将BigDecimal序列化为字符串同一个车位被重复预约业务代码先查询再更新中间有并发窗口使用UPDATE ... WHERE status 0原子更新再判断影响行数JWT签名报错密钥长度太短或使用UUID无意义字符串使用至少32字节的密钥打包后前端页面404前端dist未复制到static目录确认dist内容直接放在static根目录下部署到服务器访问不了云服务器安全组/防火墙没放行端口安全组和防火墙同时放行8080端口MyBatis-Plus自增ID不回填实体主键上没有加TableId(type IdType.AUTO)实体主键加TableId(type IdType.AUTO)4.2 一个典型的并发测试排查过程我第一次测试“重复预约”问题时用两个浏览器标签同时点击提交发现两条预约都成功了。排查过程如下先看日志确认两条请求都进入了createReservation。发现Service里第一步是selectById查询车位状态第二步才执行更新。两个请求都查到status 0然后都去更新第二个更新覆盖了第一个。修复方式就是去掉“先查再改”改成直接执行UPDATE car_port SET status 1 WHERE id ? AND status 0。再次测试第二个请求返回“该车位已被预约”符合预期。这个场景可以在答辩时主动演示老师会觉得你考虑到了并发是一个加分项。4.3 金额计算为什么不能用double相信很多人第一次写计费时随手用了float或double然后发现5.0 * 0.1 会得到 0.50000000000000001 这种结果。这就是二进制浮点精度问题。金额相关字段从数据库到Java实体到计算逻辑全程使用BigDecimal和DECIMAL。前端展示时如果是字符串也不会出现精度丢失。5. 论文组织、答辩演示与扩展点5.1 论文目录怎么搭毕设论文建议按这个结构写每一部分都对应项目里的真实内容评委看着也舒服摘要一句话说明系统目标加三个关键词。第一章 绪论背景意义、国内外现状、研究内容。第二章 相关技术介绍SpringBoot、MyBatis-Plus、MySQL、JWT、Vue。第三章 需求分析角色分析、功能需求、数据流图。第四章 系统设计总体架构、功能模块设计、数据库设计、接口设计。第五章 系统实现每个核心模块截图关键代码说明。第六章 系统测试功能测试用例表、并发测试结果、兼容性测试。第七章 总结与展望做成做了什么、不足、未来改进方向。写论文时最好的办法是“先写功能清单再逐个模块截图配文字”不要凭空憋文字。图可以用Visio画用例图和ER图但别用Mermaid生成流程图上交部分学校不识别。5.2 答辩演示脚本参考演示不要从头点到尾要有故事线。我总结的流程是登录系统切换用户角色。管理员新增车位和计费规则。用户查看车位地图选择一个空闲车位发起预约。演示防超卖开两个页面同时预约同一个车位第二个提示失败。入场确认后出场时展示自动计费结果。查看订单列表和统计图表如果做了。演示JWT鉴权去掉请求头直接访问接口返回401。这条线走下来技术点和业务点都覆盖到了时间控制在8分钟左右比较合适。5.3 还能往哪些方向扩展如果时间充足建议挑两个扩展点做答辩会更好看引入Redis车位状态缓存、预约防重、实时计数讲起来有深度。SpringBoot整合ActiveMQ/RabbitMQ预约成功后发送站内通知或短信体现异步处理。文件上传接入MinIO或本地存储支持用户上传车牌照片或停车凭证增加上传模块。小程序端做一个微信小程序预约入口前后端接口复用展示面更完整。对接硬件用ESP32之类的单片机做道闸控制或车牌识别演示适合电子/物联网方向但毕设阶段不是必须。这些方向选一个做深即可不要贪多毕设评审看的是你对核心业务的完整理解。最后说一点个人经验这个项目我最开始犯的错是花了两周把后端接口全写完才开始碰前端结果联调时发现接口返回的字段格式和页面需要的不一致改起来特别痛苦。第二次做的时候我调整了顺序——先把主流程的接口和页面跑通再补边缘功能效率高很多。如果你也要拿它做毕设我的建议是先跑通“登录→预约→入场→出场计费”这条主链路再回头补权限细节、报表、异常处理这些边角。主链路稳了后面加什么都心里有底。