简介:这是一份基于Java的停车场管理系统设计与实现的毕业设计文档,面向计算机相关专业学生、开发者及需要快速搭建同类管理系统的项目人员,系统性地解决城市停车难、车位管理效率低等问题。文档涵盖课题背景与意义、国内外研究现状、可行性分析、系统流程分析、功能模块设计、数据库ER图及数据表设计、界面设计,并完整描述从开发环境搭建到系统测试的全过程,重点讲解Java、MySQL数据库、B/S架构、SpringBoot后端框架与VUE前端框架的实际应用。资源包为单个docx文档,大小约937KB,便于直接阅读与修改。目前已有53人学习浏览,适合用于毕业设计参考资料、课程设计模板或停车场管理系统开发入门学习。打开文档即可获得完整的设计思路、模块划分与测试方案,可直接借鉴文档结构并扩展实现。
1. 停车场管理系统到底难在哪:从课设题目到真实业务
每年毕业季和课设季,总有一批「基于 Java 的 XX 管理系统」的题目,停车场是里面出镜率最高的一个。这个题目容易给人错觉:不就是车辆进进出出、记个时间、收个钱吗?真动手你会发现,它恰好覆盖了管理系统里最刁钻的那几个点——车位是带状态、会被并发争夺的资源,计费是跨天、有免费时长、有封顶的规则计算,月卡和临时车还是两套逻辑。能把这个系统写顺,等于把 Java 编程里面向对象、事务、集合、日期处理这几块硬骨头都啃了一遍。
这篇笔记要解决的问题很具体:这个系统到底要建哪些表、入场出场流程怎么设计、跨天停车费怎么算不出错、并发抢车位怎么防、答辩前要验证哪些边界。适合刚学完 Java SE 想拿管理系统练手的人,也适合正在做 Java 课程设计、毕业设计选了这道题的人。整套方案不需要车牌识别、不需要物联网设备,一台电脑、一个 MySQL、一个 Spring Boot 工程就能跑起来,但业务闭环是完整的。
2. 技术栈选型:三条路线对比,为什么我推荐 Spring Boot + MyBatis
2.1 三选一:控制台、JSP+Servlet、Spring Boot 各自的适用场景
常见做法有三种路线,先看对比再决定,别一上来就建工程。
| 技术路线 | 实现难度 | 答辩/演示效果 | 适合人群 |
|---|---|---|---|
| Java SE 控制台程序 + JDBC | 低 | 弱,只能看命令行输出 | 只想练 Java 基础语法的人 |
| JSP + Servlet + JDBC | 中 | 中,有网页但代码耦合重 | 学校课程只教了 JSP 的人 |
| Spring Boot + MyBatis + Thymeleaf | 中高 | 强,接近真实项目结构 | 想兼顾课设分数和工程能力的人 |
控制台方案最省事,一个 Main 方法循环打印菜单,入场就是System.out.println,出场就是读键盘输入算钱。但它的天花板很低:答辩时老师看到命令行界面,第一句话大概率是「你这个跟 Excel 有什么区别」。JSP 方案能出网页,可业务逻辑和页面标签混在一个文件里,加一个需求就要在 .jsp 里翻半天,属于典型的「能跑但不想维护」。
我一般给时间紧、Java 基础一般的同学推荐第三条路,Spring Boot 2.7 + MyBatis + Thymeleaf。理由很实在:Thymeleaf 是服务端渲染,在 HTML 模板里写th:each就能把停车记录循环出来,不用单独学 Vue 那套前后端分离,也不用处理跨域问题,Java 里查出来的数据直接塞进 Model 就能渲染。而 Spring Boot 自带 Tomcat,mvn spring-boot:run一条命令起来,配置比 SSM 那套 XML 少了一大截。
2.2 Spring Boot 2.7 + JDK8:为什么这个组合最不容易翻车
版本选择是第一个坑。Spring Boot 3.x 要求 JDK 17 起步,如果你本机装的是 JDK 8,直接编译报错,最典型的就是那句java: 警告: 源发行版 17 需要目标发行版 17,其实就是编译器版本和运行版本对不上。而 JDK 8 是目前课设环境里最普遍的版本,淘宝买的二手课本、学校机房老机器、老师演示用的电脑,几乎都是 JDK 8。
所以选 Spring Boot 2.7.x 是最稳的,它基于 JDK 8 开发,依赖里用的是javax.*命名空间,网上搜得到的教程、报错解决方案也几乎都围绕这个版本。另外要注意,Spring Boot 2.7 的默认打包方式是 jar,内置 Tomcat,不用额外装 Tomcat 到系统里,这对在寝室和机房两个环境切换的人非常友好。
环境配置这块,建议先把三件事做齐再写代码:JDK 装好并配好JAVA_HOME环境变量,命令行里java -version能看到 1.8;Maven 配好镜像源,不然第一次拉依赖能卡半小时;MySQL 装好并用命令行能登录。这三个前置条件不满足就去写代码,后面八成会回来返工——配置环境是最容易因为「看着差不多」就跳过的步骤,结果也是最耽误时间的。
2.3 包结构:controller/service/mapper 三层该放什么
包结构决定后面写代码顺不顺。常见的错误是按「工具类、实体类、连接类」分,业务一复杂就全堆在一起。这里按业务职责拆,直接对着这个结构抄就行:
pms ├── controller │ ├── AdminController.java │ ├── EntryController.java │ └── ExitController.java ├── service │ ├── ParkingService.java │ └── impl │ └── ParkingServiceImpl.java ├── mapper │ ├── ParkingRecordMapper.java │ └── ParkingSpaceMapper.java ├── entity │ ├── ParkingRecord.java │ ├── ParkingSpace.java │ └── FeeRule.java ├── common │ └── Result.java └── config └── WebConfig.javacontroller 层只干两件事:接收 HTTP 参数、调用 service 拿结果返回。所有的业务规则——车位占用判断、费用计算、月卡判断——全部放 service 层。mapper 层是 MyBatis 的接口,只写数据库操作和方法签名,SQL 写在对应的 XML 文件里。entity 就是纯数据类,对应数据库表,字段名和表字段一一映射。
这样分有一个直接好处:写论文画系统模块图的时候,只要把这个包结构翻译成「表示层—业务层—数据访问层」的层次架构图就可以了,不用再费劲重新归纳。Service 接口和 impl 实现类分开,是给后期扩展留的口子,比如你现在只支持临时车,后面要加月卡,接口不用动,在 impl 里加逻辑就行,这也是答辩时老师爱问的点。
3. 数据模型与核心流程:五张表、入场/出场、跨天计费怎么写
3.1 建表:从车位到停车记录最少要五张核心表
先想清楚业务实体再建表。停车场管理离不开这几个东西:车位、车辆、停车记录、收费规则、管理员,对应的就是五张核心表。下面是车位的建表语句,字段注释都写在里面:
CREATE TABLE parking_space ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '车位ID', space_no VARCHAR(20) NOT NULL COMMENT '车位编号,如A-01', status TINYINT NOT NULL DEFAULT 0 COMMENT '车位状态 0空闲 1占用', type TINYINT NOT NULL DEFAULT 0 COMMENT '车位类型 0普通 1新能源', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_space_no (space_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车位表';字段设计有几个点值得说。space_no加唯一索引,这是业务上的自然约束,不允许两个车位编号一样。version字段是给并发控制用的,后面抢车位那节会讲,现在先留好。type字段为了扩展,比如新能源车位优先分配给绿牌车,答辩时可以当亮点提。
停车记录表是核心表,字段得按业务流程一次想全:
CREATE TABLE parking_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '记录ID', plate_no VARCHAR(20) NOT NULL COMMENT '车牌号', space_id BIGINT NOT NULL COMMENT '车位ID', entry_time DATETIME NOT NULL COMMENT '入场时间', exit_time DATETIME NULL COMMENT '出场时间', fee DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '应收金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '记录状态 0在场 1已离场', KEY idx_plate (plate_no), KEY idx_entry (entry_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='停车记录表';plate_no和entry_time都加索引,因为最频繁的查询就是「查这辆车当前停在哪」和「按时间范围统计收入」。fee用DECIMAL(10,2)不用 float/double,这是 Java 开发里被念叨烂了的规矩——浮点数算金额会有精度误差,存数据库更明显,0.1 + 0.2 不等于 0.3 这种问题在金额上绝对不能出现。exit_time允许为 NULL,因为车辆还没出场时这个字段就是空的。
3.2 入场逻辑:一次带事务的「占位 + 建记录」
入场流程拆开就三步:确认这个车位空闲、把车位状态改成占用、插入一条入场记录。第一次写的人最容易做成「先 select 查一下状态,再 update 占用」,但后面会发现并发一高这个写法会翻车。这里用一个带条件的 update 解决问题:
@Service public class ParkingServiceImpl implements ParkingService { @Autowired private ParkingSpaceMapper spaceMapper; @Autowired private ParkingRecordMapper recordMapper; @Transactional(rollbackFor = Exception.class) public Long entry(String plateNo, Long spaceId) { // 乐观锁扣减车位:UPDATE ... WHERE status = 0 只影响空闲中的车位 int affected = spaceMapper.tryOccupy(spaceId); if (affected == 0) { throw new BizException("车位已被占用,请刷新后重试"); } ParkingRecord record = new ParkingRecord(); record.setPlateNo(plateNo); record.setSpaceId(spaceId); record.setEntryTime(LocalDateTime.now()); record.setStatus(0); recordMapper.insert(record); return record.getId(); } }对应的 MyBatis 更新语句长这样:
UPDATE parking_space SET status = 1, version = version + 1 WHERE id = #{spaceId} AND status = 0注意这段代码里的两个关键点。第一,tryOccupy这个 update 把「查状态」和「改状态」合并成了一条 SQL,MySQL 在 execute 这条语句时对同一行记录加锁,两个线程同时执行,只有一个会成功,另一个的affected是 0,直接抛异常。这就是乐观锁的简化实现,比先 select 再 update 安全得多。第二,@Transactional保证车位状态和停车记录要么都成功、要么都失败——如果 insert 停车记录时抛了异常,车位状态会回滚成空闲,不会出现「车没进来,车位却显示占用」的脏数据。
3.3 计费引擎:免费时长、封顶与跨天怎么算才不出错
计费是全场最容易被写崩的地方。常见规则是:入场后 30 分钟内免费,超过后按小时收费,每小时 5 元,不足一小时按一小时算,单日封顶 30 元。看着简单,实际写的时候要处理三个边界:免费时长怎么算、跨天怎么拆、封顶按天还是按总时长。
public BigDecimal calcFee(LocalDateTime entry, LocalDateTime exit, FeeRule rule) { long totalMinutes = Duration.between(entry, exit).toMinutes(); long billable = Math.max(0, totalMinutes - rule.getFreeMinutes()); if (billable == 0) { return BigDecimal.ZERO; } BigDecimal fee = BigDecimal.ZERO; // 从入场时间加上免费时长后开始计费 LocalDateTime cursor = entry.plusMinutes(rule.getFreeMinutes()); while (cursor.isBefore(exit)) { LocalDateTime dayEnd = cursor.toLocalDate().atTime(23, 59, 59); if (exit.isBefore(dayEnd) || exit.equals(dayEnd)) { // 当天结束,按剩余时长计费 fee = fee.add(calcWithinDay(cursor, exit, rule)); break; } else { // 跨天,先收当天封顶,再继续下一天 fee = fee.add(rule.getDailyCap()); cursor = dayEnd.plusSeconds(1); } } return fee.setScale(2, RoundingMode.HALF_UP); } private BigDecimal calcWithinDay(LocalDateTime start, LocalDateTime end, FeeRule rule) { long minutes = Duration.between(start, end).toMinutes(); long units = (minutes + rule.getUnitMinutes() - 1) / rule.getUnitMinutes(); // 向上取整 return rule.getUnitFee().multiply(BigDecimal.valueOf(units)); }这个实现的内存循环逻辑是:从计费起点开始,判断剩余时间是否还在今天;如果跨天了,先收今天一整天的封顶费用,然后把时间指针拨到第二天零点继续循环;如果没跨天,按「不足一个计费单元按一个单元算」的规则收尾。
参数rule.getUnitMinutes()是计费单元,比如 60 分钟为一个单元,calcWithinDay里(minutes + unitMinutes - 1) / unitMinutes是向上取整的经典写法:停了 61 分钟按 2 个单元算,停了刚好 60 分钟按 1 个单元算。RoundingMode.HALF_UP是四舍五入,金额保留两位小数。这套逻辑跑一遍昨天 23:50 入场、今天 00:10 出场的场景:免费时长 30 分钟,计费起点是 00:20,已经过了出场时间,所以费用是 0——这个结果一开始很多人会不信,但按规则推确实如此,规则设计得合理,用户也能接受。
3.4 把收费规则做成数据表:改价格不用改代码
价格规则如果写在代码常量里,改一次价格就要重新编译、重新部署,在答辩现场改需求会非常被动。费率和时长参数抽成一张配置表是更可靠的做法:
CREATE TABLE fee_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, rule_name VARCHAR(50) NOT NULL COMMENT '规则名称,如临时车/月卡车', free_minutes INT NOT NULL DEFAULT 0 COMMENT '免费时长(分钟)', unit_minutes INT NOT NULL DEFAULT 60 COMMENT '计费单元(分钟)', unit_fee DECIMAL(10,2) NOT NULL COMMENT '每个计费单元费用', daily_cap DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '单日封顶金额,0表示不封顶', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收费规则表';initial 数据插入一条临时车规则:免费 30 分钟,每 60 分钟 5 元,日封顶 30 元。系统启动的时候从这张表把启用状态的规则加载到内存缓存里,每次计费从缓存取。答辩演示时改一下数据库里的unit_fee,下一笔订单立刻按新价格算,这种「改配置不重编译」的效果比讲十页 PPT 都有说服力。
4. 工程落地:分层代码、统一返回、分页与环境配置
4.1 Controller 只接参数,业务交给 Service
Controller 层最常见的毛病是「图省事把逻辑全写进来」。一个典型的错误写法是:在 Controller 里先查车位、再判断状态、再插入记录,几十行代码堆在一个方法里,看起来也能跑,但一旦出问题,定位都找不到方向。正确的习惯是 Controller 保持轻薄,只做参数接收和结果封装。
@RestController @RequestMapping("/api/parking") public class EntryController { @Autowired private ParkingService parkingService; @PostMapping("/entry") public Result<Long> entry(@RequestBody EntryReq req) { // 参数校验:车牌号为空直接拒绝 if (req.getPlateNo() == null || req.getPlateNo().trim().isEmpty()) { return Result.error("车牌号不能为空"); } return Result.ok(parkingService.entry(req.getPlateNo(), req.getSpaceId())); } }Result<T>是一个统一返回体,里面至少有三个字段:code(业务状态码)、message(提示信息)、data(真正的数据)。所有接口都返回这个结构,前端或者页面拿到之后先看code再决定展示什么,这是让整个系统看起来「像个正经项目」的最小改造。逻辑说明就一句话:Controller 里只做参数的非空校验和格式校验,涉及业务规则的校验放到 Service 里做,一个方法的长度控制在 20 行以内,超过就拆。
4.2 分页查询:记录多了之后第一步要做什么
停车记录是会不断累积的表,不做分页的话,查一次全表数据量上来以后页面会很卡。MyBatis 的场景下最常见方案是 PageHelper 插件,也可以在 XML 里手写 LIMIT。这里给出 PageHelper 的用法,因为课设和实际项目里它出现频率最高:
public PageInfo<ParkingRecordVO> pageRecords(int pageNum, int pageSize, String plateNo) { // PageHelper 只对紧随其后的第一条查询生效 PageHelper.startPage(pageNum, pageSize); List<ParkingRecord> records = recordMapper.selectByPlateNo(plateNo); return new PageInfo<>(records); }对应的 Mapper XML 里就是一条普通的SELECT * FROM parking_record WHERE plate_no = #{plateNo} ORDER BY entry_time DESC。PageHelper 的原理是在执行这条查询前自动拼接LIMIT offset, size,并且自动执行一条SELECT COUNT(*)来拿总数。使用时的参数说明:pageNum从 1 开始,不是 0,这是新手最容易踩的边界;pageSize一般设 10 或 20,不要超过 50,否则分页就失去意义了。
要特别提醒的是,PageHelper.startPage只对紧接着的第一条 SQL 查询生效,有人会在 startPage 和查询之间夹一行其他数据库操作,结果分页癞到别的查询上去了。写完分页代码一定要真造几十条数据翻两页看看总数对不对,这个步骤不要省。
4.3 运行环境自查:JDK版本、MySQL时区、IDEA编码三连
很多系统不是代码写错,是环境没对齐。跑起来之前,先在命令行里做三连自查:
java -version mvn -v mysql --versionjava -version确认是 1.8 还是 17,如果输出显示是 17,而你的 Spring Boot 是 3.x,那没问题;如果是 1.8 而项目用了 3.x,直接改回 2.7.x 版本。mvn -v看 Maven 版本和它使用的 JDK,两者不一致是最隐蔽的坑。mysql --version确认客户端能连上数据库。
然后看配置文件application.yml,这里有几个写了能少踩很多坑的参数:
spring: datasource: url: jdbc:mysql://localhost:3306/parking_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/ShanghaiserverTimezone=Asia/Shanghai不写,MySQL 8 的驱动会报时区错误;characterEncoding=utf8不写,中文存进去变问号,这条和 IDEA 的 File Encoding 要配合——IDEA 里把项目编码设为 UTF-8,存数据库的字符集也统一成 utf8mb4,两边对不上会出现「代码看着没问题但数据乱了」的玄学问题。driver-class-name在 MySQL 8 里必须是com.mysql.cj.jdbc.Driver,网上老教程写的com.mysql.jdbc.Driver在 MySQL 8 下会直接 ClassNotFoundException。
5. 避坑排查:数据库时区、中文乱码、跨天计费与并发抢车位的翻车记录
5.1 MySQL 时区报错与驱动类名变化
现象:项目启动时控制台报The server time zone value '??? 标准时间' is unrecognized,或者报java.sql.SQLException: The server time zone ...。
原因:MySQL 8 的 JDBC 驱动默认使用 UTC 时区,而本机 MySQL 服务器时区是中国标准时间,两边对不上,驱动拒绝建立连接。另一个常见伴随错误是 ClassNotFoundException,驱动类没找到。
解决:连接 URL 末尾加serverTimezone=Asia/Shanghai,驱动类名统一写com.mysql.cj.jdbc.Driver。如果两个都配了还在报错,就在 MySQL 命令行执行SELECT NOW(),确认数据库本身的系统时间是对的。顺带检查一下 MySQL 服务是不是真的启动了——Windows 上服务没启动时,报错信息往往也是连接失败,但具体错误码不同,注意区分是网络不通(通信链路故障)还是认证失败(Access denied)。
5.2 中文乱码:URL、建表字符集、IDE编码三个地方一起查
现象:页面上显示的车牌号「京A12345」变成「???」,数据库里直接查也是乱码。
原因:这是一条链路的问题,不是单点。最常见的是三层:第一,JDBC URL 没带characterEncoding=utf8;第二,建表时没指定CHARSET=utf8mb4,默认继承了库的 latin1;第三,IDEA 的项目文件编码是系统默认的 GBK,Java 源码里的中文字符串编译时已经坏了,这种乱码在页面和数据库都可能表现为「本来应该是中文的位置出现奇怪字符」。
解决:三层同时改。URL 加useUnicode=true&characterEncoding=utf8;建表语句统一带DEFAULT CHARSET=utf8mb4,如果表已经建了,执行ALTER TABLE 表名 CONVERT TO CHARACTER SET utf8mb4;IDEA 的 Settings 里把 Global Encoding、Project Encoding、Properties Files 三项都改成 UTF-8。排查顺序建议从数据库往上游走:先在命令行里 INSERT 一条中文数据看能不能存,能存说明数据库没问题,问题在应用层。
5.3 跨天停车费算错:计费逻辑没按自然日循环
现象:晚上 22:00 停进去,第二天早上 08:00 开出去,系统算出来 50 元,而按单日封顶 30 元的规则,应该收 30 元。或者反过来,算出来 5 元,明显漏了跨天后的时段。
原因:计费代码只算了「入场当天」的时长,把exit_time和entry_time相减得到总分钟数,再套一个简单的单价公式。总时长虽然是 10 小时,但规则是「单日封顶 30 元」,不是「总时长封顶 30 元」,必须按自然日拆分——第一天 22 点到 24 点收取对应时段费用,第二天 0 点到 8 点重新起算,每天单独判断是否达到封顶。
解决:用前面 3.3 节那个按天循环的算法,先判断是否跨天,跨天就拆成多段逐日计算。写完之后把这两条测试用例跑一遍:22:00 入场、次日 08:00 出场,以及23:50 入场、次日 00:10 出场。后者尤其能检验免费时长跨天时的处理,很多实现会把免费时长算在第一天头上,导致第二天开始计费的时间点算不准。
5.4 端口被占用:先看清楚是谁占的再动手
现象:mvn spring-boot:run启动时报Web server failed to start. Port 8080 was already in use,或者 MySQL 连接报 3306 连接失败。
原因:上一个没关干净的 Java 进程还占着端口,或者是本机装了其他服务,比如某软件的 web 管理台占了 8080、另一个 MySQL 实例占了 3306。很多人这时候直接改端口号,改完发现新的端口也被占了,越改越乱。
解决:先找到占用者再决定。Windows 命令行执行netstat -ano | findstr 8080,Linux 执行lsof -i:8080,拿到 PID 后用taskkill /PID 端口号对应PID /F结束进程,或者kill -9。看清楚这个 PID 对应的进程名再动手,确认是不是自己上次启动的 Java 进程,别把系统服务清了。如果确实是别的软件在用,再改自己项目的端口,Spring Boot 里改server.port就行,但这属于妥协方案,不如把端口腾出来。
5.5 并发抢车位:Select-Then-Update 的经典翻车
现象:用 Postman 或者 Jmeter 模拟两个请求同时入场,都传同一个spaceId,结果两个请求都返回成功,一个车位给了两台车;或者明明车位已被占,第二次请求返回的还是成功。
原因:代码写成「先 SELECT 查 status 是否为 0,再 UPDATE 改成 1」。两个线程同时 SELECT 都看到 status=0,然后都执行 UPDATE,都成功了。这就是并发里的 check-then-act 竞态条件,Java 里锁没加到数据库层面,单靠synchronized在集群环境下也没用。
解决:把检查和更新合并成一条 SQL,也就是 3.2 节的UPDATE ... SET status = 1 WHERE id = ? AND status = 0,以数据库的行锁保证只有一个请求能改成功,影响行数为 0 的那个直接抛业务异常。事务要加上,保证车位更新和记录插入同生共死。压测方式:用 Jmeter 开两个线程同时发请求,断言只有一个返回成功。这种「先查再改」的写法,在库存扣减、优惠券领取、秒杀场景里是同一个坑,搞明白这一个,这一整类问题都通。
6. 再进一步:用边界测试和月卡扩展验证你的系统
6.1 用 JUnit 参数化测试锁死计费边界
手工测试计费规则很容易漏边界。JUnit 5 的参数化测试能把一批边界用例集中维护,每次改完代码一键验证,这是把系统从「能跑」推向「可靠」的关键动作。
@ParameterizedTest @CsvSource({ "2024-01-01 10:00, 2024-01-01 10:30, 0.00", "2024-01-01 10:00, 2024-01-01 10:31, 5.00", "2024-01-01 23:50, 2024-01-02 00:10, 0.00", "2024-01-01 08:00, 2024-01-01 20:00, 30.00" }) void testCalcFee(String entry, String exit, String expected) { LocalDateTime entryTime = LocalDateTime.parse(entry, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); LocalDateTime exitTime = LocalDateTime.parse(exit, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); BigDecimal fee = parkingService.calcFee(entryTime, exitTime, defaultRule); assertEquals(new BigDecimal(expected), fee); }四条用例覆盖了四个典型区间:免费时长内、刚好超过免费时长 1 分钟、跨天但在免费时长内、单日超过封顶。特别注意第二条「10:31 出场」,它验证的是向上取整逻辑——超过免费时长 1 分钟也要按整小时收费。答辩的时候把 IDEA 里测试通过的绿色对勾截图放进论文的测试章节,比空写「经测试系统运行正常」有说服力得多。
6.2 给月卡留扩展位:规则引擎做一处的三个步骤
现在系统只有临时车收费逻辑,实际场景里月卡是标配。扩展的时候不要满世界加 if 判断,正确做法是基于fee_rule表的rule_name做策略分发:第一步,车辆表加一个card_type字段,0 表示临时车,1 表示月卡;第二步,出场计费时先查车辆类型,月卡直接返回 0 元,临时车走原有的calcFee;第三步,把calcFee方法签名改成接收FeeRule参数,月卡对应的FeeRule的unit_fee设为 0 也行,但单独写一个分支语义更清楚。
要点是「规则变化收敛在 service 的一个方法里」,而不是散落在页面模板、Controller、SQL 各处。这时候 2.3 节把 service 拆成接口和实现类的好处就体现出来了:调用方只认接口,你修改实现类不影响 controller 和页面。
6.3 交付前检查清单:从冷启动到功能自测
系统写完后别急着交,按这份清单过一遍,每一行都是一个真实的翻车点:
| 检查项 | 通过标准 |
|---|---|
| 冷启动 | 删除本地数据库,从建表脚本重新执行,应用能一键启动 |
| 入场分配 | 连续两次入场同一车位,第二次返回「已被占用」 |
| 免费时长 | 入场后 29 分钟出场费用为 0,31 分钟出场按一小时计 |
| 跨天计费 | 22:00 入场、次日 08:00 出场,金额等于两天的封顶叠加 |
| 分页总数字段 | 当页数据量小于页大小(如最后一页只有 3 条)时,页码显示正确 |
| 中文显示 | 车牌含中文(如京A)时页面与数据库均无乱码 |
| 记录持久化 | 重启应用后,在场车辆记录仍然存在,车位状态仍然是占用 |
| 异常输入 | 车牌为空、车位号不存在、金额非法字符均返回友好提示 |
冷启动这一项尤其值得做:把数据库整个 drop 掉,从建表脚本一条条执行,再启动项目、跑一遍完整流程。这个动作能暴露一半以上的环境问题——建表脚本缺了哪张表、初始数据没插入导致计费规则查不到、密码写死在配置里换了机器就连接失败,全都会现原形。
我个人的习惯是,做完这些检查之后再故意把数据库停掉启动一次项目,看报错信息是否友好,这个「黑匣子测试」能让你提前想清楚系统在故障时的表现。答辩老师不会真的去你电脑上敲命令,但他会问「如果 MySQL 挂了你的系统会怎样」,能现场演示一个清晰的报错页,比支支吾吾说「应该不会挂」强得多。一次课设做完,代码能力是其次,建立这套「边界意识、持久化验证、冷启动自测」的习惯才是真正的收获,希望帮到你。
本文还有配套的精品资源,点击获取