简介:面向计算机相关专业学习者与毕业设计开发者,提供一份基于Web的停车场管理系统完整设计与实现文档,围绕城市化背景下的停车难问题,从课题背景、目的意义、国内外现状到开发环境与设计方法均有系统论述。文档明确采用Java、Spring Boot构建后端,前端结合HTML5、CSS3、JavaScript及Bootstrap/Vue.js提升交互,数据库采用MySQL,并涉及RESTful API、AJAX、JWT、GIS等关键技术,可作为理解Web系统开发流程与撰写毕业设计论文的参考范例。内容预览显示正文包含绪论、系统分析、系统设计与实现、数据库设计等章节,功能模块覆盖用户管理、车位管理、计费管理、预约管理等,有助于快速把握整体架构与核心业务逻辑。资源共1个docx文件,压缩包大小约947KB,已有134人学习,适合用于课程设计、毕业设计参考及停车场管理项目的前期方案调研。
1. 为什么先把 web 停车场管理系统拆成「数据、接口、页面」
把停车场从人工岗亭搬到网页上,表面看是加了一块余位大屏和扫码付费页面,实际要解决的是“一辆车从进场到出场这十几分钟内,数据怎么不重、不丢、不错”。web 停车场管理系统听起来是前端工程,但立项后最先暴露问题的地方往往是数据模型和计费规则:入场没登记、出场重复扣费、车位状态和订单对不上,最终都表现为页面上的怪象。
动手做之前,先按“数据闭环”而不是“页面清单”来拆设计:入场流水记录车辆和车位,占用状态决定余位数字,结算订单承载费用,报表再从中聚合。页面只负责把这三段数据读出来并写回去。系统设计阶段的产出不是原型图,而是“谁在什么时间改动哪张表、由哪个接口触发”的状态流转图。
这套拆法适合三类人:做课程设计和毕设的学生,给物业替换 Excel 台账的团队,以及想把旧管理系统迁移到浏览器端的开发者。确定技术栈之前先回答三个问题:入场是否允许重复刷卡、离场费用由谁确认、页面多久要看到余位变化。答案会直接决定建表方式和接口写法。
2. 停车场管理系统的数据库模型:车位、流水与计费规则怎么建表
2.1 一套数据闭环:入场流水、占用状态、结算订单各管一段
许多半成品系统把车位占用状态放在前端内存里,页面一刷新就回到初始值,原因就是没有把状态落到数据库。更稳妥的做法是让四张表各管一段:car_space保存车位的当前占用状态,parking_record保存每一次进出流水,parking_order保存结算结果,fee_rule保存可修改的计费参数。这里的核心原则是「状态由事件推导,不能只有状态本身」:car_space.status只是一个便于查询的冗余字段,真正的在停事实是parking_record中存在exit_time IS NULL的记录。
入场动作把流水写进去并更新车位占用,离场再把同一号牌的在停记录补上离场时间,同时生成订单。三步分别落进两个事务,任何一个环节断掉,都能从表数据反查出来。报表统计也不直接依赖车位状态,而是按月扫描parking_order,这样可以避免“余位数字显示正常,但收费总额对不上账”的尴尬。
2.2 核心字段怎么定:金额用十进制、并发用版本号
字段设计上值得提前避开的坑有三类:金额用浮点数累计产生误差;订单号依赖自增主键导致重复提交无法识别;两台闸机同时抬杆抢同一个车位。下面这张表是落地版本中建议保留的关键字段。
| 表 | 关键字段 | 能解决什么问题 |
|---|---|---|
| car_space | code、status、version、update_time | 用条件更新抢占车位,避免并发分配同一车位 |
| parking_record | plate_no、entry_time、exit_time、space_id、order_id | 用exit_time IS NULL判断在停车辆,链路可回溯 |
| parking_order | order_no、record_id、amount、paid_amount、status | order_no做幂等键,防止重复扣费;record_id关联流水 |
| fee_rule | rule_type、start_time、end_time、priority | 计费规则可配置,改价不用改代码 |
金额用DECIMAL(10,2),所有时间用DATETIME,如果你要跨时区部署,再加一个zone_offset。version是数值型,初始为 0,每次更新加 1。停车场系统不像电商那样高并发,但岗亭设备的重试机制很容易造成同一号牌被同时写入,order_no的唯一索引就是最后一道防线。
2.3 建表 SQL 与在停车辆查询
按 MySQL 8 语法,下面这一个最小集合可以直接跑通,正式环境再加索引和额外字段。
CREATE TABLE car_space ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(20) NOT NULL COMMENT '车位编号', status TINYINT NOT NULL DEFAULT 0 COMMENT '0 空闲/1 占用/2 禁用', version INT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_code (code) ); CREATE TABLE parking_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plate_no VARCHAR(20) NOT NULL COMMENT '车牌号', space_id BIGINT NOT NULL, entry_time DATETIME NOT NULL, exit_time DATETIME NULL, order_id BIGINT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_plate_exit (plate_no, exit_time) ); CREATE TABLE parking_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, record_id BIGINT NOT NULL, plate_no VARCHAR(20) NOT NULL, amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 0 COMMENT '0 待支付/1 已支付/2 已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME NULL, UNIQUE KEY uk_order_no (order_no) ); CREATE TABLE fee_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, rule_type VARCHAR(20) NOT NULL COMMENT 'free/unit/step/night', start_time TIME NULL, end_time TIME NULL, first_minutes INT NOT NULL DEFAULT 0, first_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00, cycle_minutes INT NOT NULL DEFAULT 0, unit_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00, cap_per_day DECIMAL(10,2) NULL, priority INT NOT NULL DEFAULT 0 );parking_record的联合索引(plate_no, exit_time)是为了支撑“查某号牌最近一条在停记录”和“查所有未离场车辆”两个高频动作。日常余位列表直接读car_space,但页面上的在停信息要回到流水表取entry_time,避免把车位状态表当成流水账来用。常用查询如下:
SELECT r.plate_no, s.code AS space_code, r.entry_time, TIMESTAMPDIFF(MINUTE, r.entry_time, NOW()) AS stay_minutes FROM parking_record r JOIN car_space s ON r.space_id = s.id WHERE r.exit_time IS NULL AND r.order_id IS NULL ORDER BY r.entry_time DESC;order_id IS NULL表示这条流水还没有形成结算订单,也就是“待支付”的在停车辆。如果这里过滤条件写错,列表会混入已结算订单,前端支付按钮就会点在已完成的单子上。
提示:入场和离场都依赖
plate_no,要约定统一的大写格式,数据库查询时用UPPER(TRIM(plate_no)),避免“京A12345”和“京a12345”被当成两辆车。
3. web 停车场管理系统的后端计费接口:入场登记与离场结算
3.1 入场接口:先查在停、再分配车位、后写流水
后端接口先定边界,按下面这张表收敛,不要随手加字段。
| 方法 | 路径 | 用途 | 需要参数 | 返回 |
|---|---|---|---|---|
| POST | /api/v1/entry | 入场登记 | plateNo、spaceCode | recordId、entryTime |
| POST | /api/v1/exit | 离场结算 | plateNo、orderNo | orderId、amount |
| GET | /api/v1/spaces | 车位实时状态 | 无 | 车位列表与余位 |
| GET | /api/v1/orders | 订单列表 | status、page、size | 分页订单 |
入场接口的代码量很小,但顺序不能乱:
@PostMapping("/api/v1/entry") public Result<EntryVO> entry(@RequestBody EntryRequest req) { // 1. 幂等:同一号牌不允许同时存在两条在停记录 if (parkingRecordDao.existsRunning(req.getPlateNo())) { return Result.conflict("该车辆已在场内"); } // 2. 用条件更新抢占车位,返回 0 说明车位已变 int updated = spaceDao.occupy(req.getSpaceCode()); if (updated == 0) { return Result.conflict("目标车位不可用,请刷新余位"); } // 3. 写流水,服务端时间统一作为 entryTime ParkingRecord record = new ParkingRecord(); record.setPlateNo(req.getPlateNo().toUpperCase()); record.setSpaceId(spaceDao.findIdByCode(req.getSpaceCode())); record.setEntryTime(LocalDateTime.now()); parkingRecordDao.insert(record); return Result.ok(EntryVO.of(record)); }这里有两个容易漏的细节:spaceDao.occupy执行的是UPDATE car_space SET status=1, version=version+1 WHERE code=? AND status=0,影响行数为 0 就不能继续;existsRunning和occupy之间不是原子操作,真正兜底的是数据库唯一约束或在停记录上的唯一索引。管理端还要加登录校验,建议用成熟的 Spring Security 方案,管理接口全部要求 token,不要为了省事把服务暴露成任何人可以调用。
3.2 计费计算的两种落地:规则表优先与分钟向上取整
计费常见需求是:前 30 分钟免费;超过后首小时 5 元,之后每 30 分钟 2 元;夜间时段封顶 20 元。常见的做法是规则进fee_rule表,让运营自己改,而不是写成一长串 if-else。规则表里的priority字段用来解决时段交叉,比如“日间计时”和“夜间封顶”两条规则都命中的时候,取priority值小的那一条。
public FeeResult calculate(LocalDateTime entry, LocalDateTime exit, boolean member) { if (member) { return FeeResult.free("月租车"); } long minutes = Duration.between(entry, exit).toMinutes(); if (minutes <= 0) minutes = 1; List<FeeRule> rules = feeRuleDao.findEnabledOrderByPriority(); for (FeeRule rule : rules) { if (!rule.cover(entry, exit)) continue; // 时段不覆盖则跳过 if (minutes <= rule.getFirstMinutes()) { return FeeResult.of(rule.getFirstFee(), rule.getName()); } long extraMinutes = minutes - rule.getFirstMinutes(); long cycles = (extraMinutes + rule.getCycleMinutes() - 1) / rule.getCycleMinutes(); BigDecimal fee = rule.getFirstFee() .add(rule.getUnitFee().multiply(BigDecimal.valueOf(cycles))); if (rule.getCapPerDay() != null && fee.compareTo(rule.getCapPerDay()) > 0) { fee = rule.getCapPerDay(); } return FeeResult.of(fee, rule.getName()); } return FeeResult.of(BigDecimal.ZERO, "缺失规则"); }(extraMinutes + cycleMinutes - 1) / cycleMinutes是“向上取整”的标准写法。比如超出 31 分钟、周期 30 分钟,计算结果就是 2 个计费周期。这里的坑在于免费时段:如果入场时间在免费时段末尾,出场跨越免费边界,应该按“首段免费分钟数已消耗完,剩余按正常周期计”处理,而不是直接不收费。Duration.between按绝对分钟数计算,跨天时不受影响;如果用到LocalTime则要小心跨 24 点的减法。
注意:收费结果必须由后端统一计算,前端只能展示。扫码支付的金额回调也要以服务端落库的
amount为准,防止人为篡改请求参数。
3.3 离场结算:幂等键、事务边界与状态流转
离场是入口操作和财务操作的交汇点,要做成“可以安全重试”的接口。客户端每次调用都带上自己生成的orderNo,服务端收到重复orderNo直接返回已有订单,不产生新的扣费记录:
@PostMapping("/api/v1/exit") @Transactional(rollbackFor = Exception.class) public Result<ExitVO> exit(@RequestBody ExitRequest req) { if (orderDao.existsByOrderNo(req.getOrderNo())) { return Result.ok(orderDao.getByOrderNo(req.getOrderNo())); // 幂等返回 } ParkingRecord record = parkingRecordDao.lockRunningByPlateNo(req.getPlateNo()); if (record == null) { return Result.conflict("未找到在停记录"); } FeeResult fee = calculate(record.getEntryTime(), LocalDateTime.now(), req.getMember()); ParkingOrder order = ParkingOrder.create(req.getOrderNo(), record, fee); orderDao.insert(order); parkingRecordDao.finish(record.getId(), order.getId()); spaceDao.release(record.getSpaceId()); return Result.ok(ExitVO.of(order)); }lockRunningByPlateNo使用SELECT ... FOR UPDATE锁住这条在停流水,避免两个客户端同时离场把车位释放两次。spaceDao.release也要写成条件更新:UPDATE car_space SET status=0, version=version+1 WHERE id=? AND status=1,防止把已经禁用的车位重新置为空闲。订单状态建议固定为0 待支付 -> 1 已支付 -> 2 已取消,现金结算可以一步到位更新为已支付,在线支付则等回调再改状态。不要直接删除订单,停车场对账需要保留所有历史状态。
4. 停车场管理系统的前端页面与状态刷新:车位面板如何自动同步
4.1 组件拆分:状态列表、车位地图和计费表单互不阻塞
web 项目不宜做成一整个页面塞全部功能。按数据来源拆分组件,管理端可以拆成四个:车位地图SpaceMap.vue显示二维停车位占位,订单列表OrderTable.vue展示在停和结算,计费规则FeeRuleForm.vue提供表单修改,数据统计StatPanel.vue负责今日收入、车流量。组件之间不要直接修改对方的数据,统一通过一个 store 保存spaces、runningOrders、todayIncome。
import { ref, onMounted, onUnmounted } from 'vue' const spaces = ref([]) const runningOrders = ref([]) let timer = null async function refresh() { const [spaceRes, orderRes] = await Promise.all([ fetch('/api/v1/spaces'), fetch('/api/v1/orders?status=running'), ]) spaces.value = await spaceRes.json() runningOrders.value = await orderRes.json() } function startPolling(intervalMs = 30000) { refresh() timer = setInterval(refresh, intervalMs) } function stopPolling() { if (timer) { clearInterval(timer) } } onMounted(() => startPolling(30000)) onUnmounted(stopPolling)轮询间隔设成 30 秒比较平衡,但要注意refresh里的两个请求是并发发出的,不能保证同时返回。更稳妥的做法是用Promise.all后整体赋值,这样页面不会出现“余位已经更新、订单还是旧数据”的不一致状态;如果请求耗时会超过轮询周期,还要加一个isRefreshing标志,避免上一个请求还没结束又发起下一次。
4.2 三十秒轮询还是 WebSocket:先看数据规模和运维预算
选择哪种方式要看吞吐量和页面呈现。如果只是管理端看余位和订单,30 秒轮询足够;如果要做现场大屏,希望抬杆瞬间余位就变化,则适合加 WebSocket。一个可用的判断基准是:管理后台用轮询,大屏页面用 WebSocket,两端通过同一个状态 store 连接。
| 对比项 | 轮询 | WebSocket |
|---|---|---|
| 服务端改动 | 无 | 需要维护长连接和推送通道 |
| 数据实时性 | 最差延迟一个轮询周期 | 秒级 |
| 断线恢复 | 简单,重连即可 | 需要心跳、重连和补拉差量 |
| 适合页面 | 后台列表、报表 | 余位大屏、车位地图 |
用 WebSocket 时,后端推给前端的消息只放变化,前端收到后刷新对应的space对象,再触发一次订单列表刷新。不要每次推送整个车位数组,停车场几百个车位量不大,但在弱网环境里大消息体很容易造成阻塞。消息格式建议固定为{ type: 'SPACE_OCCUPIED', data: { spaceCode, recordId } },前端按type分发,而不是在回调里写一长串函数。
4.3 Nginx 部署多 web 项目时的代理路径与 Service Worker 坑
本地开发跑npm run dev没问题,部署时如果一台服务器要跑多个 web 项目,就需要在 Nginx 上做路径区分。比如前端打包产物放在/var/www/parking,后端服务在8080,配置可以按下面方式写。
server { listen 80; server_name parking.example.com; location /parking/ { alias /var/www/parking/; try_files $uri $uri/ /parking/index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }alias后面的/不能丢,try_files里的/parking/index.html要和alias路径对应,否则刷新二级页面就直接 404。proxy_pass不带末尾斜杠,请求/api/v1/entry会原样转发到后端,后端接口定义也是以/api开头,两边保持一致最省事。
另一个常被翻出来的问题是浏览器控制台报could not register service worker: InvalidStateError。这个错误多数出现在 PWA 的service-worker.js被放在错误路径,或者页面协议不是 https/localhost。处理办法很简单:给注册逻辑加一个保护,或者干脆不在管理后台启用 PWA。
if ('serviceWorker' in navigator && window.isSecureContext) { navigator.serviceWorker.register('/sw.js').catch(() => { console.warn('service worker 注册失败,不影响页面使用') }) }window.isSecureContext会在非 HTTPS 环境下返回false,这样内网 HTTP 调试时就不会抛出异常,也不会把整个页面的加载流程卡住。
5. 上线前用 curl 脚本把停车场管理系统的入场、计费、离场跑通
5.1 一个订单流脚本,验证幂等和费用计算
部署完成后,先别打开浏览器手工点,用脚本连续跑几遍,验证完整闭环。下面这段 bash 脚本可以直接复制到服务器上:
BASE=http://127.0.0.1:8080 # 第 1 次:正常入场 curl -s -X POST "$BASE/api/v1/entry" \ -H 'Content-Type: application/json' \ -d '{"plateNo":"京A12345","spaceCode":"A01"}' # 第 2 次:重复入场,应返回“该车辆已在场内” curl -s -X POST "$BASE/api/v1/entry" \ -H 'Content-Type: application/json' \ -d '{"plateNo":"京A12345","spaceCode":"A01"}' # 第 3 次:离场,orderNo 固定,便于重试 curl -s -X POST "$BASE/api/v1/exit" \ -H 'Content-Type: application/json' \ -d '{"plateNo":"京A12345","orderNo":"test-order-001"}' # 第 4 次:用同一个 orderNo 再离场一次,应返回同一订单而非新订单 curl -s -X POST "$BASE/api/v1/exit" \ -H 'Content-Type: application/json' \ -d '{"plateNo":"京A12345","orderNo":"test-order-001"}'第 2 次调用如果返回了入场成功,说明幂等没做好;第 4 次调用如果生成了新订单,说明重复扣费的 bug 已经存在。两次调用之间要把数据库的parking_order表查一下,确认只有一条order_no = 'test-order-001'的记录。
5.2 正式数据里找出问题的三个检查点
系统跑起来之后,异常不只在测试时出现,日常运维要会从订单表反查。下面是三组最常见的检查点。
| 现象 | 查哪里 | 可能原因与处理 |
|---|---|---|
| 车位显示被占用,但订单已经支付 | car_space的update_time与parking_record.exit_time | 事务未提交或离场接口中途异常,把spaceDao.release移到@Transactional方法内 |
| 同一辆车出现两条在停记录 | parking_record的plate_no与exit_time | 入口重复抬杆或接口并发,补唯一索引或在业务层二次校验 |
| 结算金额与收费规则差 0.5 元 | fee_rule的cycle_minutes与first_minutes | 向上取整公式在免费时长边界算错,用边界分钟数写单测 |
运维时给parking_record和parking_order各加一个定时任务做一致性检查:扫描exit_time不为空但order_id为空的记录,以及order_id有值但amount为零的订单。这类任务放在凌晨低峰期执行,输出结果到一张check_log表,第二天由管理人员确认。web 停车场管理系统的稳定性,最后靠的不是界面华丽,而是这些能自动对账的边界检查。
本文还有配套的精品资源,点击获取