☰
SpringBoot+Vue停车场管理系统:入场计费出场闭环
2026/10/31 7:09:09 网站建设 项目流程

简介:面向计算机相关专业课程设计与毕业设计场景的停车场管理系统项目源码包,围绕车位管理、车辆进出、费用结算与后台数据处理等典型业务流程展开,适合需要完整项目参考、二次开发或答辩准备的学生与开发者。压缩包共592个文件、约142.93MB,包含89个Java源文件、184个class编译文件、135个xml配置、66个jar依赖,以及sql脚本、html/js/css前端页面、jsp与properties配置等,基本覆盖Java Web项目的源码、依赖、数据库与运行配置。已有2529人学习下载。读者可从中梳理停车场系统的模块划分、数据库表设计与接口组织方式,参考车牌识别、计费逻辑、异常报警等功能的实现思路,并借助现成工程结构进行本地调试、功能扩展或课程报告撰写,减少从零搭建的时间。

1. 地下车库排长队的那个下午

地下车库入口排着七八辆车,保安拿着本子手抄车牌,出口收费员按小时心算——这套流程在很多老旧停车场仍然活着。停车场管理系统要解决的其实就是三件事:车进来有位置、停着的时间算得清、出去时钱和闸机对得上。这份课程设计资源围绕这三件事搭了一套前后端分离的系统,SpringBoot 做后端、Vue + Element UI 做管理端,覆盖入场登记、车位状态维护、分段计费、出场结算、月卡管理和统计报表。适合正在做课程设计或毕业设计的同学直接拿来跑通业务闭环,也适合已经写熟 CRUD、想练一次「业务规则真正落到代码里」的开发者。

2. 技术选型与目录结构:一套课设该有的分层

2.1 技术栈组合与选型理由

这类系统的技术选型其实没有太多悬念,关键是想清楚每一层为什么用这个,而不是答辩时被问一句就卡住。后端用 SpringBoot 是因为 starter 把 Web、事务、JSON 序列化都打包好了,配置文件写十几行就能起来,省下的时间可以放到计费和状态流转上,那才是这套系统真正有含量的部分。持久层选 MyBatis-Plus,单表 CRUD 直接由 BaseMapper 提供,复杂一点的统计报表仍然能写原生 SQL,比纯 JPA 灵活。数据库用 MySQL 8.0,窗口函数和 CTE 在报表里偶尔能用上,字符集统一 utf8mb4。

层次技术用途
后端框架SpringBoot 2.7容器、事务、参数校验
持久层MyBatis-Plus 3.5单表 CRUD + 自定义 SQL
数据库MySQL 8.0车辆、车位、记录、订单、月卡
认证JWT + 拦截器管理端登录态
前端Vue 2 + Element UI后台管理界面
图表ECharts收入趋势、车位周转率
缓存(可选)Redis车位余量计数,不做也不影响跑通

前端为什么是 Vue2 而不是 Vue3,理由很朴素:Element UI 的表格、分页、日期选择器组件在这个版本上文档最全,后台管理页面九成时间都在跟表格打交道,能少查文档就少查。车位余量这类高频读取的数据,常见做法是在 Redis 里维护一个lot:free:{lotId}的计数,入场decr、出场incr,但课设阶段直接用count(status=0)查一次也没问题,别为了炫技把链路搞复杂。

2.2 目录结构与分层职责

工程按 Maven 多模块切分,虽然课设规模用一个模块也能写完,但拆开之后 Controller 里不会堆业务代码,答辩讲分层也讲得清楚。

parking-system/ ├── parking-common/ # 统一返回体 Result、业务异常、日期工具 ├── parking-dao/ # 实体 Entity、Mapper 接口、XML ├── parking-service/ # 入场、计费、出场、月卡等业务逻辑 ├── parking-api/ # Controller、JWT 拦截器、全局异常处理 ├── parking-web/ # Vue2 前端工程 │ ├── src/api/ # axios 封装,按模块拆文件 │ ├── src/views/ # 车位、记录、月卡、报表页面 │ └── vue.config.js # 开发代理到 8080 └── sql/ ├── schema.sql # 建表语句 └── data.sql # 初始化车位、计费规则、管理员账号

分层的关键约束只有一条:parking-api不允许直接依赖parking-dao,Controller 只能调 Service。这样计费逻辑改动时不会牵一发动全身,也避免有人图省事在 Controller 里直接写 update。全局异常处理器统一把BizException转成{code, msg, data}的返回体,前端 axios 拦截器里判code !== 200就弹 ElMessage,页面代码能干净很多。

2.3 数据库表设计:五张核心表撑起业务

表设计里最容易翻车的是停车记录表:它同时承担「在场凭证」和「历史订单」两个角色,所以出场时间和金额必须是可空字段,靠out_time is null判断车辆是否还在场内。这个设计比单独加一个in_parking标志位可靠,不会出现两个字段互相矛盾的情况。

CREATE TABLE parking_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plate_no VARCHAR(16) NOT NULL COMMENT '车牌号', lot_id BIGINT NOT NULL COMMENT '停车场ID', space_id BIGINT NOT NULL COMMENT '车位ID', in_time DATETIME NOT NULL COMMENT '入场时间', out_time DATETIME NULL COMMENT '出场时间,NULL 表示仍在场内', amount DECIMAL(10,2) NULL COMMENT '应收金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0在场 1待支付 2已支付 3已放行', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', KEY idx_plate (plate_no, out_time), KEY idx_lot_in (lot_id, in_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

idx_plate (plate_no, out_time)这个联合索引是给「查该车是否在场」准备的,out_time is null走索引前缀,入场接口在高峰期不会被全表扫拖死。车位表parking_space用status表示状态(0空闲、1占用、2预约、3维修),并且把plate_no冗余进去,前端车位平面图一次查询就能渲染,不用再关联记录表。金额字段一律DECIMAL(10,2),别用DOUBLE——浮点数算停车费,最后对账差几分钱的事我见过不止一次。

提示:data.sql里至少要预置一个停车场、20 个车位、一条默认计费规则和一个管理员账号,否则前端首屏全是空表格,演示时很难看。

3. 核心链路拆解:入场、计费、出场三段代码

3.1 车辆入场:先占位,再落记录

入场接口要同时处理三件事:拦重复入场、抢一个空闲车位、写停车记录。三件事必须在一个事务里,否则会出现「车位占了但记录没写」的脏状态,这是这类系统最典型的坑。

@Transactional(rollbackFor = Exception.class) public EntryVO entry(String plateNo, Integer vehicleType, Long lotId) { // 1. 同一车牌在场内只允许一条未结算记录 Long inCount = recordMapper.selectCount(new LambdaQueryWrapper<ParkingRecord>() .eq(ParkingRecord::getPlateNo, plateNo) .isNull(ParkingRecord::getOutTime)); if (inCount > 0) { throw new BizException("该车辆已在场内,请勿重复入场"); } // 2. 取一个空闲车位,并带条件更新占位(防并发抢同一车位) ParkingSpace space = spaceMapper.selectFreeOne(lotId); if (space == null) { throw new BizException("车位已满,请稍后"); } int updated = spaceMapper.occupy(space.getId(), plateNo); if (updated == 0) { throw new BizException("车位被抢占,请重试"); } // 3. 写停车记录,out_time 留空表示在场 ParkingRecord record = new ParkingRecord(); record.setPlateNo(plateNo); record.setLotId(lotId); record.setSpaceId(space.getId()); record.setInTime(LocalDateTime.now()); record.setStatus(0); recordMapper.insert(record); return new EntryVO(record.getId(), space.getSpaceNo(), record.getInTime()); }

第二个参数里的vehicleType用来区分小车、货车,后续计费规则可以按车型取不同单价,课设阶段固定为 1 也能跑通。真正保证并发安全的是occupy这条带条件的 update:

<update id="occupy"> UPDATE parking_space SET status = 1, plate_no = #{plateNo}, occupy_time = NOW() WHERE id = #{id} AND status = 0 </update>

WHERE id = ? AND status = 0是整段代码的核心,两个请求同时拿到同一个空闲车位时,只有一个能更新成功,另一个返回 0 行受影响,业务层直接抛异常让用户重试。用select ... for update也行,但锁行时间长、容易把连接池占满,条件更新这种方式在这个场景更轻。返回的EntryVO里带上车位编号,前端入口大屏可以直接显示「请前往 B 区 12 号车位」。

3.2 计费规则:分段、取整和封顶

停车费计算是整个项目里最容易被老师追问的部分,也是最容易写错的部分。常见的规则是:免费 15 分钟,首小时 5 元,之后每 30 分钟 2 元,24 小时封顶 30 元。注意「每 30 分钟」是向上取整的,停了 31 分钟要按 60 分钟算,这个细节写错,金额就对不上。

public BigDecimal calcFee(LocalDateTime inTime, LocalDateTime outTime, BillingRule rule) { long minutes = Duration.between(inTime, outTime).toMinutes(); if (minutes <= rule.getFreeMinutes()) { return BigDecimal.ZERO; // 免费时段内直接返回 0 } long billable = minutes - rule.getFreeMinutes(); // 不足一个计费单元按一个算:向上取整 long units = (billable + rule.getUnitMinutes() - 1) / rule.getUnitMinutes(); BigDecimal fee = rule.getFirstUnitPrice() .add(rule.getUnitPrice().multiply(BigDecimal.valueOf(Math.max(units - 1, 0)))); // 按自然天封顶,跨天时封顶金额乘以天数 long days = minutes / (24 * 60) + 1; BigDecimal cap = rule.getDayCap().multiply(BigDecimal.valueOf(days)); return fee.min(cap).setScale(2, RoundingMode.HALF_UP); }

参数含义需要对着表说清楚:freeMinutes免费时长,unitMinutes计费单元(30 表示半小时一计),firstUnitPrice首单元价格,unitPrice后续每单元价格,dayCap单日封顶。这几个值都存在billing_rule表里,按lot_id区分,管理端改价后对新入场的车生效,已经在场内的车建议在入场时把规则快照到记录表,避免停车中途老板改价导致金额跳动。我一般会在parking_record加一个rule_json字段存当时的规则,出场时优先读快照,读不到再用当前规则兜底。

3.3 出场结算:金额、状态、车位要一起落

出场接口是整条链路的收口,涉及金额计算、记录更新、车位释放三个动作。这里必须用@Transactional,并且更新记录时带上version做乐观锁,防止同一个订单被两次出场请求重复结算。

@Transactional(rollbackFor = Exception.class) public PayResult exit(String plateNo) { ParkingRecord record = recordMapper.selectOne(new LambdaQueryWrapper<ParkingRecord>() .eq(ParkingRecord::getPlateNo, plateNo) .isNull(ParkingRecord::getOutTime)); if (record == null) { throw new BizException("未找到在场记录"); } // 月卡车辆直接免费放行,不进入计费流程 boolean monthly = cardService.hasValidCard(plateNo, LocalDateTime.now()); BigDecimal fee = monthly ? BigDecimal.ZERO : feeCalculator.calcFee(record.getInTime(), LocalDateTime.now(), ruleService.current(record.getLotId())); record.setOutTime(LocalDateTime.now()); record.setAmount(fee); record.setStatus(fee.signum() == 0 ? 3 : 1); // 0 元直接放行,否则待支付 int rows = recordMapper.updateById(record); // version 变化才算成功 if (rows == 0) { throw new BizException("订单已被处理,请勿重复操作"); } spaceMapper.release(record.getSpaceId()); // 车位置回空闲 return new PayResult(record.getId(), fee, record.getOutTime()); }

updateById能带乐观锁是因为实体上加了@Version注解,MyBatis-Plus 会自动把 version 拼进 where 条件。这里有个业务顺序要想清楚:先算钱还是先放行。真实场景是「扫码支付成功 → 回调更新状态为已支付 → 闸机放行」,课设里可以简化成出场即结算,但支付回调接口要留出来,并且回调必须做幂等——用订单 id 查一次状态,已支付就直接返回成功,不要重复加钱。免费车辆(月卡、免费时长内)直接把状态置成 3,前端展示「已放行」,少一次支付交互。

4. 后台管理模块:车位、月卡、订单怎么改

4.1 车位管理与状态流转

车位状态直接决定入场能不能成功,所以状态定义要收敛在一个枚举里,别在代码各处散落魔法数字。车位一共有四种状态,流转关系是固定的:空闲可被入场占用、可被预约锁定,占用只能回到空闲,维修状态只能人工开启和关闭。

public enum SpaceStatus { FREE(0, "空闲"), OCCUPIED(1, "占用"), RESERVED(2, "预约"), REPAIR(3, "维修"); private final int code; private final String desc; public static boolean canOccupy(int code) { return code == FREE.code; // 只有空闲才能被入场占用 } // 省略 getter 与构造 }

前端车位平面图用 Element UI 的栅格加卡片渲染,颜色按status区分,点击卡片弹出该车位的当前车牌和入场时间。管理端要提供「强制释放」按钮,处理那些因为调试中断、记录没写完导致的占用残留:后端做的是把车位 status 置回 0,同时把对应未结算记录的out_time补上并标记异常备注,而不是简单删记录。这个按钮很有必要,演示前一天数据弄脏了,靠它能在两分钟内恢复现场。

4.2 月卡与免费时长:出场前的第一道判断

月卡逻辑本身简单,容易错在时间边界。判断一张卡是否有效要同时满足三个条件:车牌匹配、当前时间落在起止时间之间、卡片状态为启用。用 MyBatis-Plus 的le和ge拼条件,注意start_time <= now <= end_time两个边界都要包含,否则到期当天会出现「上午能进下午不能出」的怪现象。

public boolean hasValidCard(String plateNo, LocalDateTime now) { return cardMapper.selectCount(new LambdaQueryWrapper<MonthlyCard>() .eq(MonthlyCard::getPlateNo, plateNo) .eq(MonthlyCard::getStatus, 1) .le(MonthlyCard::getStartTime, now) .ge(MonthlyCard::getEndTime, now)) > 0; }

这里查的是count > 0,因为同一车牌理论上可能有多张历史卡,只要有一张在有效期内就放行。续费的处理方式是在原记录上延长end_time还是新插一条,取决于业务约定,课设建议新插一条并保留历史,报表里算月卡收入时按创建时间统计,账目更清楚。免费时长的判断放在计费方法内部,不要和月卡判断混在一起,两者是「或」的关系,先判月卡能省掉一次计费计算。

4.3 订单查询与收入统计

管理端最常被点开的是记录列表和收入报表。列表页做分页加多条件查询,车牌模糊匹配、时间范围筛选,注意时间范围要用in_time而不是out_time,因为要能看到还在场内的车。报表接口按天聚合,SQL 直接写,不要在 Java 里循环累加。

SELECT DATE_FORMAT(out_time, '%Y-%m-%d') AS day, COUNT(*) AS order_cnt, SUM(amount) AS total_amount, ROUND(AVG(TIMESTAMPDIFF(MINUTE, in_time, out_time)), 1) AS avg_minutes FROM parking_record WHERE out_time IS NOT NULL AND status IN (2, 3) AND lot_id = #{lotId} AND out_time BETWEEN #{start} AND #{end} GROUP BY DATE_FORMAT(out_time, '%Y-%m-%d') ORDER BY day;

status IN (2, 3)只统计已支付和已放行的记录,待支付的挂单不能计入收入,这个口径要跟前端说明,否则对账时数字对不上。avg_minutes是平均停车时长,配一张 ECharts 折线图,答辩时比单纯的金额柱状图更有说服力。如果数据量不大,日历热力图也能做,但别在课设阶段上复杂的实时大屏,投入产出比太低。

5. 排查手册:跑不起来时先看这几条

5.1 启动与环境类问题

现象:数据库里时间比实际早 8 小时,费用算出来少了一截。原因:JDBC 连接串没指定时区,MySQL 驱动按 UTC 解析DATETIME,写入和读取都偏了。解决:连接串加上serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8,同时确认 MySQL 服务端时区是+08:00,两边统一之后再清一次测试数据,别在脏时间上继续调。

现象:前端页面能打开,但所有接口 401。原因:JWT 拦截器把登录接口也拦了,或者前端 axios 没带Authorization头。解决:拦截器里用excludePathPatterns放行/auth/login、/error,前端请求拦截器统一加config.headers.Authorization = 'Bearer ' + token,token 存 localStorage 时注意页面刷新后要重新读取。

5.2 业务数据类问题

现象:车位显示占用,但记录表里查不到对应的在场车辆。原因:调试时手动改过parking_space,或者入场事务提交到一半被中断。解决:用管理端的强制释放功能修复,同时在occupy的 update 里保留occupy_time,写一个定时任务扫描「占用超过 7 天且无在场记录」的车位自动释放。

现象:同一辆车能重复入场。原因:判断在场用的是status = 0,但出场后记录状态变成了 1/2,历史数据里如果有out_time为空但状态非 0 的脏数据就漏判了。解决:统一用out_time IS NULL作为在场判据,这个字段是唯一真相,其他状态位只做展示用。

现象:停车费算出来带很多小数位。原因:用了double做乘法,或者数据库字段是FLOAT。解决:全链路改BigDecimal,参与运算时用BigDecimal.valueOf()而不是new BigDecimal(double),最后setScale(2, RoundingMode.HALF_UP),数据库字段同步改成DECIMAL(10,2)。

5.3 部署与演示类问题

现象:前端npm run build之后放到 nginx,刷新页面 404。原因:Vue Router 用了 history 模式,nginx 没配 fallback。解决:加try_files $uri $uri/ /index.html;,或者在课设里直接用 hash 模式,省掉这段配置。

现象:图表接口返回数据但图上空白。原因:ECharts 容器在数据回来时还是display:none,尺寸是 0。解决:在请求完成后的$nextTick里调chart.resize(),或者给容器写死高度。

注意:演示前一定手动跑一遍「入场 → 改计费规则 → 出场」的完整流程,规则改动的生效范围是这类系统最容易被追问的点,自己先跑通比临场解释强。

6. 二次开发:把课设改成能讲出花的版本

拿到这套代码之后,最省力的加分方向不是加页面,而是把计费规则从「一张表几个字段」升级成策略模式。现在calcFee里写死了首小时和后续单元的算法,只要把这段抽成接口,白天夜间、工作日节假日、不同车型就能并行扩展,改动只集中在一个包内。

public interface FeeStrategy { boolean supports(BillingRule rule); BigDecimal calc(long minutes, BillingRule rule); } @Component public class DayNightFeeStrategy implements FeeStrategy { @Override public boolean supports(BillingRule rule) { return rule.getStrategyType() == 1; // 1 表示分时段计费 } @Override public BigDecimal calc(long minutes, BillingRule rule) { // 这里按入场时间段切分,白天夜间分别累加,最后封顶 return doCalc(minutes, rule); } }

调用侧改成一个FeeStrategyFactory,根据规则里的strategyType挑实现,原来那段 if-else 全部收进默认策略类,行为不变但结构清晰了。第二个改造点是接车牌识别:不要动入场业务代码,单独写一个/device/callback接口接识别结果,内部转换成entry调用,设备型项目最常见的错误就是把硬件协议逻辑塞进业务层,最后换个设备全盘重写。第三个点是支付回调的幂等,留一张pay_log表记录第三方流水号,重复回调直接返回成功。

改造点主要改动文件工作量风险
计费策略化FeeStrategy 包、BillingRule半天低,行为可回归对比
车牌识别接入新增 DeviceController一天中,依赖设备协议
支付回调幂等OrderService、pay_log 表半天中,要造重复回调测试
报表加同比统计 SQL、ECharts 配置两小时低

验证方式很简单:改造前录一批固定的入场出场时间,跑出金额存成 CSV,改造后用同样的数据再跑一遍,逐行 diff,只要一模一样就说明策略化没改坏原有逻辑。这个习惯救过我很多次,尤其是这种「看起来只是重构」的改动,出问题往往最隐蔽。

从那以后我每次接这类带计费和状态流转的项目,都先把「入场 → 计费 → 出场 → 放行」这条链路单独写一版脚本跑通,再回头动前端和报表,页面永远放在最后。这份资源里的建表脚本和初始化数据都在sql目录下,照着第 5 章的启动顺序先跑一遍,再按第 6 章的三个方向动刀,能省下不少返工。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询