Java停车场系统实战:Spring Boot+MyBatis Plus高并发计费设计 简介本资源是一套面向Java初学者与课程设计学生的Web停车场管理系统完整开发实践包聚焦B/S架构下的企业级应用开发全流程。资源涵盖系统源码、数据库脚本、毕业论文文档、部署与功能模块教学视频及多张界面截图帮助学习者掌握Servlet/JSP或Spring Boot后端开发、MySQL数据库设计、HTML/CSS/JS前端交互及项目工程化交付能力。压缩包共16个文件含7张系统运行截图png、3份Word论文材料开题报告、任务书、正文、2段MP4实操视频含环境搭建与核心模块讲解、1个ZIP源码包、1个SQL建库脚本、1个PPT答辩文件及1个DB数据库文件整体大小40.51MB。已有1632人学习下载内容结构清晰、理论与实操并重提供从需求分析、编码实现、数据库构建到论文撰写与答辩准备的一站式参考特别适合Java Web课程设计、毕业设计选题与智慧园区类项目入门实践。1. 为什么一个“Web停车场管理系统”能成为Java初学者的通关项目它不只管车位更在练闭环能力你可能见过太多“学生管理系统”“图书管理系统”——它们结构清晰但离真实业务太远。而“Web停车场管理系统”不同它天然带时间维度入场/离场计时、状态流转空闲/占用/锁定、并发冲突同一车位被两人同时选中、数据一致性费用计算余额扣减日志留痕——这些不是教科书里的抽象概念是每天在停车场闸机后真实发生的逻辑。用Java实现它你被迫直面Servlet生命周期、JDBC事务边界、MyBatis Plus动态SQL生成、前端表单校验与后端验证的协同、MySQL索引对查询性能的影响……更重要的是它能跑在Tomcat上能连真实数据库能被同事用浏览器访问——这不是玩具是能放进简历里写“独立完成”的最小可行产品MVP。适合刚学完Java基础HTML/CSS/JSMySQL想把碎片知识串成线的人也适合Java面试前两周需要一个能讲清“从请求到响应全流程”的压箱底项目。别小看这个标题——它背后藏着Web开发最硬核的5个落地断点用户会话管理怎么不丢、车辆进出时间怎么精确到秒、费用规则怎么热更新、数据库表结构怎么支撑未来扩展、SQL脚本怎么保证多人协作不冲突。2. 从零搭起骨架用Spring Boot MyBatis Plus快速初始化工程结构2.1 为什么选Spring Boot而不是纯Servlet——省掉80%的XML配置和类路径陷阱纯Servlet项目要手动配web.xml、写ServletContextListener监听上下文、管理数据库连接池、处理字符编码过滤器……这些配置项一旦漏写或顺序错位就会出现“页面乱码但控制台没报错”“Service层注入为空但IDE没提示”这类玄学问题。Spring Boot通过SpringBootApplication自动装配内嵌Tomcat、默认UTF-8编码、自动扫描Mapper接口、整合Druid连接池——它把“让项目跑起来”这件事压缩到3步建Maven工程 → 引入starter依赖 → 写启动类。这不是偷懒而是把精力从环境搭建转移到业务逻辑本身。比如车辆入场时你不用再纠结request.setCharacterEncoding(UTF-8)该写在Filter还是Servlet里Spring Boot的spring.http.encoding.charsetUTF-8一句就全局生效。2.2 MyBatis Plus比原生MyBatis省在哪——实体类改一行表结构自动同步传统MyBatis要写XML映射文件、手写INSERT INTO car_info (plate, type, in_time) VALUES (?, ?, ?)字段一多极易拼错。MyBatis Plus的TableName(parking_car)和TableId(type IdType.AUTO)让实体类直接驱动数据库操作。更关键的是它的代码生成器只要定义好CarInfo.java里的TableField(in_time) private LocalDateTime inTime;就能一键生成建表SQL、Mapper接口、Service实现类。我们实测过——当需求从“只存车牌号”升级为“增加车辆颜色、品牌、车主电话”时只需在实体类加3个字段注解运行CodeGenerator.java新SQL脚本和DAO层代码就自动生成不用手动改XML、不用核对字段名大小写、不用担心insert into语句漏了逗号。这省下的不是时间是避免因手写SQL导致的低级错误——比如把out_time写成outtime上线后查不到离场记录运维半夜打电话。// CodeGenerator.java 核心配置可直接复用 public class CodeGenerator { public static void main(String[] args) { AutoGenerator gen new AutoGenerator(); gen.setGlobalConfig(new GlobalConfig() .setOutputDir(src/main/java) // 生成路径 .setAuthor(yourname) .setOpen(false) // 生成后不自动打开文件夹 .setSwagger2(true)); // 启用Swagger注解 gen.setDataSource(new DataSourceConfig() .setUrl(jdbc:mysql://localhost:3306/parking_db?useSSLfalseserverTimezoneAsia/Shanghai) .setUsername(root) .setPassword(123456) .setDriverName(com.mysql.cj.jdbc.Driver)); gen.setPackageInfo(new PackageConfig() .setParent(com.parking) .setModuleName(system)); // 模块名影响包路径 gen.setStrategy(new StrategyConfig() .setNaming(NamingStrategy.underline_to_camel) // 数据库下划线转驼峰 .setColumnNaming(NamingStrategy.underline_to_camel) .setEntityLombokModel(true) // 用Lombok简化getter/setter .setRestControllerStyle(true) // 生成RestController .setInclude(parking_car, parking_fee_rule, parking_log)); // 只生成指定表 gen.execute(); } }提示setNaming(NamingStrategy.underline_to_camel)必须开启否则生成的实体类字段in_time会变成inTime但数据库列名仍是in_timeMyBatis Plus默认按驼峰匹配会导致查不到数据——这是新手踩坑率最高的配置项之一。2.3 数据库设计的3个反直觉原则别急着建表先想清楚“谁改谁”很多初学者一上来就CREATE TABLE parking_car (...)结果做到一半发现费用规则要支持不同时段不同单价白天5元/小时夜间3元/小时但原表没设计时段字段或者要支持月租车位但car_info表里没预留is_monthly标识。真正的设计起点不是“我要存什么”而是“哪些数据会被谁修改”。我们最终确定的4张核心表表名主要字段修改主体设计要点parking_carplate(主键), type, in_time, out_time, status前端用户后台管理员status用tinyint(1)存0/1/2空闲/占用/锁定不用varchar避免字符串比较慢parking_fee_ruleid, start_time, end_time, fee_per_hour, is_active后台管理员用start_time/end_time区间覆盖全天避免if-else判断时段parking_logid, car_plate, action_type, operator, create_time系统自动写入action_type用枚举值1入场,2离场,3人工释放方便后期统计操作频次parking_userid, username, password, role管理员初始化password字段必须用BCrypt加密存储明文密码是安全红线注意parking_fee_rule表不设外键关联parking_car因为费用规则变更不影响历史记录——这是“历史数据不可变”原则的落地。如果强行加外键修改规则时就得同步更新所有历史订单既慢又危险。3. 让系统真正动起来车辆入场、计费、出场的3个核心流程实现3.1 入场流程为什么INSERT之前必须先SELECT FOR UPDATE表面看车辆入场只是往parking_car表插一条记录。但真实场景是两个用户几乎同时扫同一个二维码进场系统收到两个POST /car/in请求。如果都执行INSERT INTO parking_car (plate, in_time) VALUES (粤B12345, NOW())就会出现两条相同车牌的记录——这违反业务规则同一车牌不能同时占两个车位。解决方案是悲观锁先查出当前空闲车位再锁定它。// CarService.java Transactional public Result? enterCar(String plate) { // 1. 查找第一个空闲车位加行锁 ParkingSpace space parkingSpaceMapper.selectOne( new QueryWrapperParkingSpace() .eq(status, 0) // 0空闲 .last(LIMIT 1 FOR UPDATE) // 关键MySQL的行锁语法 ); if (space null) { return Result.fail(车位已满); } // 2. 更新车位状态为占用 space.setStatus(1); // 1占用 parkingSpaceMapper.updateById(space); // 3. 插入车辆入场记录 CarInfo car new CarInfo(); car.setPlate(plate); car.setInTime(LocalDateTime.now()); car.setSpaceId(space.getId()); car.setStatus(1); // 1在场 carInfoMapper.insert(car); return Result.success(入场成功车位号 space.getSpaceNo()); }注意FOR UPDATE必须在事务内执行且SELECT和UPDATE要在同一个事务里。如果拆成两个方法selectForUpdate()和updateStatus()中间被其他线程插入锁就失效了。这就是为什么Transactional不能只加在Controller层——它必须包裹整个业务逻辑链。3.2 计费逻辑用LocalDateTime算时长为什么比Date少踩3个坑费用计算公式是(out_time - in_time) * fee_per_hour。如果用java.util.Date你会遇到Date.getTime()返回毫秒数除以3600000得小时数但浮点运算有精度丢失如1.9999999999999998小时被截断为1小时时区问题服务器在北京但数据库时区设为UTCnew Date()生成的时间戳存进去后查出来差8小时难以测试new Date()无法mock单元测试只能sleep等待耗时且不稳定。改用LocalDateTime后代码干净且可测// FeeCalculator.java public BigDecimal calculateFee(LocalDateTime inTime, LocalDateTime outTime, BigDecimal hourlyFee) { Duration duration Duration.between(inTime, outTime); long totalMinutes duration.toMinutes(); // 向上取整不足1小时按1小时算 long hours (totalMinutes 59) / 60; return hourlyFee.multiply(BigDecimal.valueOf(hours)); } // 测试用例可直接运行 Test public void testCalculateFee() { LocalDateTime in LocalDateTime.of(2023, 10, 1, 8, 0); // 8:00 LocalDateTime out LocalDateTime.of(2023, 10, 1, 9, 30); // 9:30 → 1.5小时 → 按2小时算 BigDecimal fee calculateFee(in, out, new BigDecimal(5.00)); assertEquals(new BigDecimal(10.00), fee); // 断言结果 }提示MySQL的datetime类型对应Java的LocalDateTime不是Date。如果实体类字段声明为DateMyBatis Plus会自动转换但时区风险仍在。务必在application.yml里加spring.jackson.time-zoneGMT8和spring.jackson.date-formatyyyy-MM-dd HH:mm:ss统一序列化格式。3.3 出场流程为什么UPDATE必须带WHERE status 1离场操作本质是UPDATE parking_car SET out_time ?, status 0 WHERE plate ?。但如果只写WHERE plate 粤B12345会出现致命问题用户A入场后忘记缴费管理员手动把他的status改成0释放车位此时用户A又扫码离场——系统再次执行UPDATE把out_time覆盖成新时间但实际他根本没走。正确做法是限定只更新“状态为占用”的记录// CarService.java public Result? exitCar(String plate) { // 只更新状态为1占用的记录避免重复操作 int updated carInfoMapper.update( new CarInfo().setOutTime(LocalDateTime.now()).setStatus(0), new UpdateWrapperCarInfo() .eq(plate, plate) .eq(status, 1) // 关键条件 ); if (updated 0) { return Result.fail(车辆未在场或已离场); } // 计费并扣款此处省略支付逻辑 CarInfo car carInfoMapper.selectOne( new QueryWrapperCarInfo().eq(plate, plate) ); BigDecimal fee feeCalculator.calculateFee( car.getInTime(), car.getOutTime(), getCurrentHourlyFee() ); // 更新费用字段假设car_info表有fee字段 car.setFee(fee); carInfoMapper.updateById(car); return Result.success(离场成功费用 fee 元); }这个eq(status, 1)看似多此一举却是防止“幽灵离场”的最后一道防线——它让数据库自己校验业务状态比在Java层if (car.getStatus() 1)更可靠因为数据库的UPDATE ... WHERE是原子操作。4. 避坑指南那些让项目卡在上线前夜的5个血泪问题4.1 现象前端提交表单后后端Controller收不到参数RequestBody对象全为null原因前端用FormData提交常见于含文件上传的表单但后端用RequestBody接收。RequestBody只解析JSON格式FormData是multipart/form-data必须用RequestParam或MultipartFile。解决检查前端fetch或axios的Content-Type头。如果是纯表单无文件改用JSON.stringify()headers: {Content-Type: application/json}如果含文件后端方法签名改为public Result? upload(RequestParam String plate, RequestParam MultipartFile image)。4.2 现象MySQL执行INSERT INTO parking_car (...) VALUES (...)报错Data truncation: Incorrect datetime value原因MySQL 5.7默认开启严格模式NOW()返回的datetime精度是秒级但实体类LocalDateTime包含纳秒MyBatis Plus插入时尝试写入2023-10-01 12:00:00.123456789超出MySQLdatetime类型精度最多6位小数。解决在application.yml中添加JDBC连接参数?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingUTF-8zeroDateTimeBehaviorconvertToNulltinyInt1isBitfalse并在实体类字段上加TableField(fill FieldFill.INSERT)配合MetaObjectHandler自动截断纳秒Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, inTime, LocalDateTime.class, LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS)); } }4.3 现象分页查询pageHelper.startPage(1,10)后selectList()返回结果总数永远是10不管数据库有多少条原因PageHelper的分页依赖ThreadLocal如果Service方法被Async异步调用或跨线程传递如用CompletableFutureThreadLocal变量丢失分页失效。解决确认Controller调用Service是同步的若必须异步用PageHelper.startPage(1,10).doSelectPageInfo(() - carInfoMapper.selectList(wrapper))显式传入Lambda避免依赖线程上下文。4.4 现象管理员修改费用规则后已入场车辆离场时仍按旧规则计费原因计费逻辑写在exitCar()方法里每次调用都查最新规则——这没错。但问题出在getCurrentHourlyFee()方法里它用SELECT * FROM parking_fee_rule WHERE is_active 1查规则却没加ORDER BY start_time DESC LIMIT 1导致查出多条活跃规则时返回第一条可能不是当前时段的。解决在FeeRuleMapper.xml中明确排序select idselectCurrentRule resultTypecom.parking.entity.FeeRule SELECT * FROM parking_fee_rule WHERE is_active 1 AND #{now} BETWEEN start_time AND end_time ORDER BY start_time DESC LIMIT 1 /select4.5 现象部署到Linux服务器后图片上传路径/upload/car/报java.io.FileNotFoundException: /upload/car/xxx.jpg (No such file or directory)原因开发时用Windows路径分隔符是\但Linux用/更重要的是/upload目录在Linux上不存在且Tomcat运行用户如tomcat没有/upload的写权限。解决统一用Paths.get(upload, car, filename).toString()生成路径自动适配分隔符在application.yml中配置绝对路径upload.path/var/www/parking/upload部署前执行sudo mkdir -p /var/www/parking/upload/car sudo chown -R tomcat:tomcat /var/www/parking。5. 把SQL脚本变成协作利器用Flyway管理数据库版本告别“我的SQL和你的不一样”5.1 为什么手写SQL脚本在团队里必然失控你写好v1_init.sql建表发给同事A他改了parking_car加了个color字段生成v2_add_color.sql同事B又加了monthly_fee字段生成v3_add_monthly.sql。最后合并时发现A的v2和B的v3都基于v1但v2里ALTER TABLE parking_car ADD COLUMN color VARCHAR(10)v3里ALTER TABLE parking_car ADD COLUMN monthly_fee DECIMAL(10,2)——如果先执行v2再v3没问题但如果顺序颠倒v3执行时color字段还不存在就报错。更糟的是没人记得谁改了哪张表生产环境回滚时不敢删字段。这就是“SQL脚本地狱”。5.2 Flyway如何用“版本号前缀”终结混乱Flyway要求所有SQL文件名带版本号前缀如V1__init.sql、V2__add_color_column.sql、V3__add_monthly_fee.sql。它会在数据库里自动建一张flyway_schema_history表记录每个脚本的执行状态、checksum、执行时间。下次启动时Flyway只执行未执行过的、版本号大于当前最大版本的脚本且按版本号升序执行。这意味着同事A和B各自开发只要命名规范V4__xxx.sql、V5__xxx.sql合并代码后Flyway自动按序执行生产环境升级时只需把新SQL文件放src/main/resources/db/migration/重启应用Flyway自动检测并执行回滚Flyway不支持自动回滚这是故意设计但你可以写R__revert_v3.sql作为修复脚本或直接在数据库里DROP COLUMN——因为flyway_schema_history表里清楚记录着每一步操作你知道删哪个字段不会影响其他功能。5.3 在Spring Boot中集成Flyway的3个关键配置!-- pom.xml -- dependency groupIdorg.flywaydb/groupId artifactIdflyway-core/artifactId /dependency# application.yml spring: flyway: enabled: true locations: classpath:db/migration # SQL文件存放路径 baseline-on-migrate: true # 首次启动时如果数据库非空自动设为baseline避免报错 validate-on-migrate: false # 关闭校验开发期频繁改SQLchecksum总变-- src/main/resources/db/migration/V1__init.sql CREATE TABLE parking_car ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plate VARCHAR(20) NOT NULL, in_time DATETIME NOT NULL, out_time DATETIME NULL, status TINYINT NOT NULL DEFAULT 0, space_id BIGINT NULL, fee DECIMAL(10,2) NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_plate ON parking_car(plate); CREATE INDEX idx_status ON parking_car(status);注意baseline-on-migrate: true仅用于开发和测试环境。生产环境首次部署必须确保数据库为空或手动执行flyway repair修复校验和。validate-on-migrate: false是开发期的妥协——每次改SQL都会变checksum开启校验会导致启动失败等上线前再设为true即可。5.4 如何用Flyway做“热更新”费用规则费用规则表parking_fee_rule需要频繁修改但Flyway的SQL脚本是只读的。解决方案是把规则数据放入CSV文件用Flyway的Java回调Callback在启动时导入。// src/main/resources/db/migration/V2__load_fee_rules.java public class V2__load_fee_rules implements Callback { Override public boolean supports(Flavor flavor, Context context) { return true; } Override public void handle(Event event, Flavor flavor, Context context) { if (event Event.BEFORE_MIGRATE) { JdbcTemplate jdbcTemplate context.getJdbcTemplate(); // 从classpath读取fee_rules.csv逐行INSERT ListString[] rows readCsv(/db/migration/fee_rules.csv); String sql INSERT INTO parking_fee_rule (start_time, end_time, fee_per_hour, is_active) VALUES (?, ?, ?, ?); for (String[] row : rows) { jdbcTemplate.update(sql, row[0], row[1], new BigDecimal(row[2]), Integer.parseInt(row[3])); } } } }这样修改费用规则只需改fee_rules.csv无需写新SQL脚本Flyway启动时自动加载——既保持数据库版本可控又满足业务灵活调整需求。6. 让论文和视频不沦为摆设用PlantUML画出真正能讲清技术决策的架构图6.1 别再用Visio画“三层架构”了面试官一眼看出你在抄模板你的论文里肯定有张图用户→Web层→Service层→DAO层→DB。但这张图没解释任何决策——为什么用MyBatis Plus不用JPA为什么费用计算放在Service层而不是数据库函数为什么日志表单独建而不和车辆表合在一起这些才是评审老师和面试官想听的。PlantUML能让你用代码写图强制思考每个箭头背后的理由。6.2 用PlantUML画“停车场状态机”比文字描述快10倍车辆状态流转是核心逻辑用文字写“入场→占用→离场→空闲”太单薄。PlantUML的状态图能直观展示所有可能路径和触发条件startuml title 停车场车辆状态机 [*] -- InParking InParking -- Occupied : 扫码入场 Occupied -- Released : 管理员释放 Occupied -- OutParking : 扫码离场 OutParking -- Free : 结算完成 Released -- Free : 人工释放 Free -- InParking : 新车入场 关键约束只有Occupied状态才能离场 note right of Occupied 此状态需校验 - 车牌唯一性 - 车位是否空闲 - 时间戳精度为秒 end note enduml这张图直接回答了“为什么入场要加行锁”“为什么离场要校验status1”——它不是装饰是设计过程的快照。答辩时你指着图说“这里Occupied到OutParking的箭头对应代码里exitCar()方法的WHERE status 1条件”评委立刻明白你理解了状态一致性。6.3 用序列图还原“一次入场请求的12个步骤”暴露所有隐藏复杂度你以为入场就是前端发个请求、后端插条记录实际链路远不止于此。PlantUML序列图能逼你写出每一步的参与者和耗时startuml actor 用户 participant 前端Vue as front participant Spring Boot Controller as controller participant CarService as service participant ParkingSpaceMapper as spaceMapper participant CarInfoMapper as carMapper participant MySQL as db 用户 - front: 扫码入场 front - controller: POST /car/in {plate:粤B12345} controller - service: enterCar(粤B12345) service - spaceMapper: SELECT ... FOR UPDATE spaceMapper - db: 查询并锁定空闲车位 db -- spaceMapper: 返回车位ID101 spaceMapper -- service: 锁定成功 service - carMapper: INSERT INTO car_info ... carMapper - db: 插入入场记录 db -- carMapper: 执行成功 carMapper -- service: 插入完成 service -- controller: 返回结果 controller -- front: {code:200,msg:入场成功} front -- 用户: 显示车位号A101 enduml这张图揭示了3个关键点SELECT ... FOR UPDATE必须在事务内否则锁失效INSERT前必须先锁车位顺序不能颠倒整个链路耗时主要在数据库IO所以parking_space表的status字段必须加索引INDEX idx_status ON parking_space(status)。写论文时把这张图放“系统设计”章节旁边配文字“为保障并发入场一致性采用悲观锁机制具体实现见图3-2序列图第5-6步”。答辩时评委问“怎么解决并发问题”你直接指图说步骤比背概念有力得多。6.4 视频录制的3个技术细节让演示不翻车数据库演示必开SQL日志在application.yml加logging.level.com.parking.mapperdebug录屏时窗口分半左边浏览器操作右边IntelliJ IDEA的Console实时打印SQL证明“真正在执行”费用计算演示用固定时间不要用NOW()在测试方法里写死LocalDateTime.of(2023,10,1,8,0)和LocalDateTime.of(2023,10,1,9,30)确保每次演示结果一致异常场景必录专门录一段“同一车牌重复入场”的报错页面然后切到代码指出Transactional和FOR UPDATE的位置——这比只录成功案例更能体现工程能力。我带过的实习生里80%的论文被退回不是因为代码不行而是图和文字脱节图里画了Redis缓存代码里根本没用序列图写了“调用支付接口”实际是模拟扣款。后来我让他们先画PlantUML再写代码最后录视频——图成了代码的契约视频成了图的验证。现在我的习惯是任何新功能先写PlantUML状态图或序列图再动手敲代码。不是为了交差是强迫自己把模糊的想法变成可验证的节点。希望帮到你。本文还有配套的精品资源点击获取