ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue智慧停车全栈解决方案:架构设计与核心实现

SpringBoot+Vue智慧停车全栈解决方案:架构设计与核心实现

停车场这个场景,我前前后后做过好几套方案,从最早的单机版收费系统到后来的云端SaaS,踩的坑不少,沉淀下来的经验也很多。今天聊的这套“智慧停车:SpringBoot+Vue全栈解决方案”,算是把常见需求完整落地的一套组合。它解决的核心问题很简单:车主找车位难、出场缴费排队久,车场管理员统计账目麻烦、车位利用率不清楚。而开发侧要面对的,则是怎么把硬件设备、后台业务、前端界面这三层顺畅地串起来。

这套方案适合谁参考?如果你是刚接触全栈开发的学生,想找一个能写进简历的完整项目;或者你是小团队的技术负责人,需要快速给客户交付一套停车管理系统,这篇文章的内容都值得过一遍。我不打算列一个“完美”的项目清单,而是把我在实际开发中觉得最重要、最容易出问题的环节挑出来讲透:架构怎么拆、数据库怎么设计、计费这种核心逻辑怎么写得稳、硬件对接怎么优雅地解耦、部署时又有哪些坑等着你。

项目虽然叫“智慧停车”,听起来带点物联网的味道,但剥开来看,骨架仍然是典型的SpringBoot后端加Vue前端,外加关系型数据库和缓存中间件。只要你对这三个技术栈有基本了解,整套代码是完全可以看懂并跑起来的。下面直接进入正题。

1. 整体架构设计与技术选型的考量

先说说我为什么最终选了SpringBoot加Vue这套组合,而不是Go写后端或者用Uniapp套壳。一个重要原因在于这套技术栈的生态成熟度,招人好招,出了问题网上方案也多,对于中小型项目来说是性价比最高的选择。

1.1 系统分层与核心模块

整个系统我习惯拆成四层:

  • 设备接入层:负责跟车牌识别相机、道闸控制器、地磁车位检测器打交道。这些设备的通信协议五花八门,有的走HTTP回调,有的走TCP长连接,还有的走MQTT消息。这一层统一做协议适配和数据清洗,不让硬件细节污染上层业务。
  • 业务服务层:后端核心服务,负责用户管理、车辆管理、车位管理、订单计费、月卡续费、数据统计等模块。SpringBoot天然适合做这种模块化开发,按业务边界拆Controller、Service、Mapper,清晰明了。
  • 前端展示层:管理端用Vue3加Element Plus做Web后台,给车场管理员用;车主端可以做成H5或者小程序,我这里做了一套响应式H5,嵌在公众号菜单里就能用。
  • 数据存储层:MySQL存结构化业务数据,Redis承担缓存和车辆出入场状态标记,Elasticsearch如果数据量大可以考虑,但一般项目阶段MySQL加慢查询优化就够了,不必过早引入检索中间件。

1.2 为什么采用前后端分离而不是服务端渲染

我做过的第一个停车管理系统用的是Thymeleaf服务端渲染,后来重构时毫不犹豫切到了前后端分离。原因很简单:车场管理员需要在多块屏幕上同时看实时数据,车主端又要频繁发消息跟后台交互,前后端分离之后接口复用度大幅度提高。数据接口一套,Web端、H5端、甚至未来的小程序端都能共用。而且Vue的响应式生态确实能带来更好的交互体验:车位状态变化、订单金额刷新、入场记录实时滚动,这些如果用页面刷新来做,体验会差很多。

团队成员可以并行开发,后端按接口文档定义好数据结构,前端用Mock数据先做界面,等到联调阶段再对接真实接口。这种协作方式比传统JSP开发效率高出一截。

下表是我在选型时做过的一个简单对比:

方案维护成本并发支撑开发效率适用场景
SpringBoot + Thymeleaf中中低纯内部系统,页面少
SpringBoot + Vue前后端分离中高高多端共用接口,复杂交互
Node.js + React中中中前端团队为主,需要SSR
微服务分布式高高低多停车场跨区域大型平台

对于单体停车场或几个停车场的连锁管理,SpringBoot单体加Vue前端完全够用,刻意上微服务只会让开发和部署的复杂度白白增加。

1.3 硬件对接方案的整体思考

停车系统里最烦的部分其实不是业务代码,而是设备对接。不同厂商的设备SDK不一样,今天接海康的相机,明天客户换了大华的,接口就要重写一遍。我的做法是在设备接入层做一个适配器模式,定义统一的设备事件接口,比如PlateRecognizedEvent、GateStatusChangedEvent,再针对不同厂商写各自的适配实现。

同时,硬件触发业务动作的消息通道我用的是MQTT。车牌识别相机会把识别结果推送到特定Topic,后端监听Topic后解析数据,再调用业务服务完成入场登记、开闸放行等操作。这种做法比直接用HTTP回调更稳定,因为断网重连机制MQTT本身就帮你处理好了。

我最开始做项目时,总想着“自动驾驶”“无感支付”这些听起来高大上的功能,但实际交付时发现,做好车牌识别、稳定计费、快速开闸这三个基础能力,已经能解决车场90%的问题。技术选型一定要围绕真实需求走,不要为了炫技引入复杂度。

2. 核心功能拆解与数据库设计

聊完了整体思路,这一节开始落到细节。数据库设计是整个项目的基石,表结构一旦设计不合理,后面业务代码写得再漂亮也无济于事。我吃过亏,初期为了省事把所有数据塞进一张大表,结果统计报表的时候一塌糊涂,重构成本非常高。

2.1 核心业务表结构

我把常用的表拆出来,按照业务域划分:

用户与车辆域:

  • user:用户表,存储手机号、密码哈希、余额、积分等,手机号是登录凭证,要加唯一索引。
  • car:车辆表,车牌号、颜色、车型,一个用户可绑定多辆车,车牌号加唯一索引。
  • user_car_relation:用户车辆绑定关系表,因为用户和车辆是多对多关系(家人共用车辆等场景)。

车场业务域:

  • parking_lot:停车场表,名称、地址、总车位数、经纬度、收费规则ID。
  • parking_space:车位表,车位编号、所属停车场ID、当前状态(空闲/占用/预定)、设备ID。
  • entrance_record:出入场记录表,最关键的表之一,记录每辆车的入场时间、出场时间、车牌号、抓拍图片URL、入口ID、出口ID、订单ID。

计费与订单域:

  • price_rule:计费规则表,支持按时段配置,比如工作日白天每小时8元,夜间每2小时5元。
  • order:订单表,车辆入场后生成待支付订单,出场时结算,记录应收金额、实收金额、支付方式、优惠减免。
  • monthly_card:月卡/年卡表,记录车主的固定车位租赁或不限次进出权限,包含有效期。

运营分析域:

  • operation_log:操作日志表,记录管理员操作、设备异常、远程开闸等动作,方便事后审计。
  • statistics_daily_report:每日统计汇总表,用定时任务在凌晨汇总昨天的入场流量、总营收、平均停车时长等指标,这是提高报表查询速度的关键做法。

2.2 关键表之间的关系

拿一次完整的停车流程举例:车主A驾驶车牌“苏A12345”进入停车场,入口相机识别车牌后落库一条entrance_record记录,状态设为“在场内”;同时给对应parking_space状态改为“占用”,再生成一条待支付状态的order记录。出场时,出口相机再次识别车牌,系统根据车牌查出对应的入场记录,计算时长,应用计费规则生成应收金额,扣除余额或唤起支付流程,支付成功后抬杆放行。

这个过程中entrance_record表中车牌号要建组合索引,即plate_number + status。因为在场车辆查询是最频繁的操作,每次出口识别后都要根据车牌快速判定这辆车是不是在场内。如果表数据上了百万级,没有这个索引查询延迟会非常明显,现场开闸就会卡顿。

2.3 数据一致性与事务边界

停车计费场景中,Redis和MySQL的同步是个容易出错的地方。我的策略是:MySQL负责最终数据落库,Redis只做短期状态缓存。入场时同时写MySQL和Redis,出场时以MySQL为准,Redis数据不一致时以MySQL为准进行校验修正。

在本地事务中,我会用@Transactional保证单次操作内的数据一致性,比如生成订单时同时扣减用户余额和修改订单状态必须在一个事务里。但对涉及多个聚合根的操作,比如出场结算需要读入场记录、算价、更新车位状态、生成支付流水、记录日志,这些跨模块的操作不适合放在一个事务里长事务处理,我一般会通过领域事件加本地消息表的方案处理,保证最终一致就行,避免锁表时间过长引发超时问题。

设计表结构时,你会发现几乎每张表都需要create_time和update_time字段,这是我在多张表里反复重建的教训,建议初始化表结构时就统一加上,用MyBatisPlus的自动填充功能维护,后期就不用操心这些公共字段了。

3. SpringBoot后端关键实现细节

后端是整套系统的中枢神经,业务逻辑几乎都在这里。这个项目里几个核心实现点——认证授权、计费引擎、实时推送——值得单独拎出来讲,因为这些地方搞砸了,前端做得再好看也没用。

3.1 JWT认证与用户体系实现

我用的认证方案是Spring Security加JWT。为什么不用Session?因为前后端分离之后,接口会被H5、Web管理端甚至小程序同时调用,Session携带Cookie在跨域场景下麻烦不断,Token方案就没有这个困扰,无状态、易扩展。

JWT的结构由三部分组成:Header(加密算法和Token类型)、Payload(用户ID、角色、过期时间等非敏感信息)、Signature(签名,防篡改)。签发Token时把用户ID和角色编码进Payload,之后每次请求在拦截器里校验Token签名并解析出用户身份。

有一点要提醒,Token不要塞太多数据,更不要放敏感信息比如手机号、密码,因为Payload只是Base64编码,不是加密的,破解没有难度。我的做法是只放userId和role,其余信息需要时再查Redis拿。

配套实现一个简单的RBAC权限模型,用@PreAuthorize("hasRole('ADMIN')")等注解控制接口访问。一般角色分三种:超级管理员、车场管理员、普通用户。超级管理员可以管理所有车场,车场管理员只能管理自己车场的设备和订单,普通用户只能操作自己的车辆和订单。

3.2 计费引擎:规则配置驱动的价格计算

计费功能初看很简单:入场时间和出场时间一减,乘以单价就行了。但真实场景远比这复杂:有免费停车时长、单日封顶、跨时段不同费率、会员折扣、节假日优惠等等。如果这些规则写死在代码里,改一条费率就要重新发版,这是客户完全不能接受的。

我设计了一套规则表达式引擎,用Groovy脚本定义计价规则,把规则脚本存在数据库里,后台修改后立即生效。每条price_rule记录包含一段Groovy代码片段,入参是入场时间、出场时间、车辆类型、会员等级,出参是应收金额和明细说明。

一个简单的分时段计费脚本示例:

def calculate(durationMinutes, entryTime, exitTime, carType) { def totalFee = 0 def freeMinutes = 15 def payableMinutes = durationMinutes - freeMinutes if (payableMinutes <= 0) return [fee: 0, detail: "免费时段内"] if (carType == "monthly") return [fee: 0, detail: "月卡用户"] def hourCount = Math.ceil(payableMinutes / 60) def dayCount = Math.ceil(durationMinutes / 1440) def capDaily = 50 totalFee = hourCount * 8 if (totalFee > capDaily) totalFee = capDaily return [fee: totalFee, detail: "每小时8元,封顶50元"] }

用Groovy脚本牺牲了一点性能,但对停车场的并发量来说完全不是问题,换来的灵活配置能力很值。核心计算流程我单独抽了个PricingService,支持不同的实现类做对比测试。

3.3 车辆出入场与订单流转流程

入场流程简要代码如下:

@Transactional public EntryRecord handleEntry(String plateNumber, Long lotId, String imageUrl) { // 1. 查询车位状态 ParkingSpace space = parkingSpaceMapper.findAvailableSpace(lotId); if (space == null) { throw new BusinessException("车位已满"); } // 2. 创建入场记录 EntryRecord record = new EntryRecord(); record.setPlateNumber(plateNumber); record.setLotId(lotId); record.setEntryTime(LocalDateTime.now()); record.setStatus(StatusEnum.INSIDE); // 3. 占用车位 space.setStatus("OCCUPIED"); // 4. 生成待支付订单 Order order = createPendingOrder(record); // 5. 推送前端WebSocket通知 webSocketService.pushCarStatus(record); return record; }

出场流程则需要在前端页面配合,展示应收金额,用户确认支付后调用支付接口完成扣款,再调用开闸接口。这一套流程里容易出问题的点在于重复出场请求。比如出场识别触发两次回调,生成了两笔订单。我的解决方案是在entrance_record上加入场记录唯一流水号,出场时用流水号加乐观锁更新,更新失败说明已被处理过,直接忽略本次请求。

3.4 MQTT对接车牌识别相机与道闸

硬件对接部分,我用Eclipse Paho客户端库订阅MQTT主题,有一个DeviceMessageListener统一监听所有设备消息,按Topic解析数据类型。

@Bean public MqttPahoClientFactory mqttClientFactory() { DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory(); MqttConnectOptions options = new MqttConnectOptions(); options.setServerURIs(new String[]{mqttHost}); options.setAutomaticReconnect(true); options.setCleanSession(false); factory.setConnectionOptions(options); return factory; }

相机的识别结果消息体大概是这种格式:车牌号、识别时间、相机编号、方向(入口/出口)、抓拍图片地址。因为不同厂商的相机JSON字段名不一致,我用了一个DeviceDataNormalizer组件来做字段映射,将厂商格式转换成系统内部统一的DeviceEvent对象,后续业务代码只管处理DeviceEvent,完全不感知具体相机型号。

开闸控制同理,系统内部定义统一的GateControlRequest,适配器负责转换成对应设备厂商的HTTP请求或TCP指令。这套抽象设计在一次客户现场从品牌A相机切换到品牌B相机时得到了验证,业务层代码一行没改,只新增了一个适配器类。

4. 前端Vue实战:管理后台与用户端双端开发

前端部分我同时做了两套:一套是车场管理员的Web管理后台,一套是车主的H5页面。技术栈选的是Vue3加Vite加Pinia加Element Plus,状态管理用Pinia,UI库用Element Plus,地图选车位用Leaflet,实时数据推送用WebSocket。

4.1 管理后台:数据大屏与高密度信息表格

管理后台的核心页面我是这么划分的:实时监控页展示全场车位占用热力图和最近半小时出入场记录滚动列表;订单管理页提供模糊搜索、状态筛选和导出功能;设备管理页显示所有相机、道闸、地磁检测器的在线状态和心跳时间;统计报表页用ECharts渲染日营收趋势、停车时长分布、车位周转率图表。

这种页面需要注意性能,不能让加载数据时界面卡死。我用虚拟滚动组件来渲染上千条订单记录,大类目数据图表组件懒加载,ECharts实例在组件卸载时手动销毁,避免内存泄漏。后台的交互原则是:高频操作尽快,两步以内完成,低频操作清晰,不怕多点一次。

4.2 车主H5:找车位、支付与月卡开通

车主端H5我追求的是轻量和实用的平衡。首页是附近停车场列表,按距离排序,显示剩余车位数和收费标准。点进停车场详情后能看到剩余车位分布图,我是用Canvas绘制的一个简易平面图,真实设备数据映射到位图上的色块,空闲绿色,占用红色,点击色块可以查看具体车位编号。

停车缴费流程要做得顺畅,我的做法是进入H5时静默调用车牌自动识别接口,车主拍一张车头照片,系统识别出车牌号并弹出确认框,确认后直接查询该车牌的在场订单。如果订单存在,直接展示时长和费用,点击支付就完成。整个流程尽量压缩到三步内,减少车主排队操作的时间。

4.3 前后端接口设计与联调避坑指南

前后端接口风格我用的是RESTful,统一返回结构。这个统一结构极其重要,如果每个接口返回格式不一致,前端处理起来痛苦不堪。

返回结构定义:

{ "code": 0, "message": "success", "data": {} }

Axios的响应拦截器统一判断code值,非0统一走错误提示,前端不用在每次请求后都重复做错误处理。所有列表接口的返回结构我用分页对象统一封装,包含records、total、current、size四个字段,避免不同接口分页字段命名不一致。

联调时最常见的问题就是接口返回字段与前端预期不一致,比如后端返回createTime,前端写的是created_at,这类低级问题非常磨人。我建议一朝一夕养成用OpenAPI规范管理接口定义的习惯,后端用SpringDoc自动生成接口文档,前端根据生成的TS类型定义开发,类型对不上编译时就报错,根本拖不到联调阶段。

5. 部署方案与性能优化实践

很多项目在本地跑得好好的,一上服务器就各种问题,原因多半是部署方案不成熟。这个项目我推荐用Docker Compose整合一套部署方案,各组件容器化,方便迁移和扩展。

5.1 Docker Compose编排与环境初始化

部署拓扑大概是这样:Nginx容器承担静态文件托管和反向代理;前端打包后的dist目录挂载进Nginx容器;SpringBoot应用打包成Jar镜像;MySQL用官方镜像做数据持久化;Redis做缓存;如果接了设备消息,还需要部署一个MQTT Broker。

version: '3.8' services: mysql: image: mysql:8.0 container_name: parking-mysql restart: always environment: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: smart_parking ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql redis: image: redis:7.0 container_name: parking-redis restart: always ports: - "6379:6379" backend: build: ./backend container_name: parking-backend restart: always depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod ports: - "8080:8080" nginx: image: nginx:1.24 container_name: parking-nginx restart: always ports: - "80:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf - ./frontend/dist:/usr/share/nginx/html depends_on: - backend volumes: mysql_data:

一个很重要的坑,容器间的网络访问不能用localhost,要用容器名称作为主机名。比如后端要通过jdbc:mysql://mysql:3306/smart_parking连接MySQL,而不是localhost:3306。我第一次部署时就栽在这个上面,后端启动失败排查半天才发现是网络别名问题。

5.2 Nginx反向代理与前端路由

Nginx配置里最关键的两个地方:前端用的是Vue Router的history模式,需要配置try_files让所有路由回退到index.html;API请求要反向代理到后端服务地址,同时配置WebSocket的Upgrade头部,否则实时推送功能会断。

server { listen 80; server_name parking.example.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /ws/ { proxy_pass http://backend:8080/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } }

proxy_pass http://backend:8080/;尾部斜杠加不加效果完全不同,少了斜杠时/api/login会被转发成/api/login,加了斜杠会被转发成/login。这个细节我调试了整整一个下午才搞清楚。

5.3 性能优化三板斧

停车系统并发特点明显:早晚高峰出入场集中,其他时段比较平稳。优化重点放在接口响应速度和数据库压力控制上。

第一板斧,热门数据加缓存。车位余量、计费规则列表、停车场基础信息这些读多写少的数据,放进Redis缓存,设置合理过期时间,数据变更时主动删除缓存。

第二板斧,数据库连接池合理配置。HikariCP默认参数需要调整,我常用的配置是maximum-pool-size设为20,minimum-idle设为5,避免高并发时连接不够、空闲时资源浪费。别忘了给慢查询设置阈值,日志记录超过500ms的SQL,定期review优化。

第三板斧,削峰填谷。入场高峰时大量车辆同时请求,后端压力陡增。我在设备接入层的MQTT消费者里加了阻塞队列做缓冲,消费者线程池大小和队列容量按高峰期预估流量设置,宁可排队稍等一下,也不能让系统因为瞬时流量打崩。

6. 项目实施中的常见问题与排查经验

这部分内容来自一次真实项目的血泪总结。每个问题后面我都附上排查思路和解决方案,希望能帮你少走弯路。

6.1 WebSocket连接频繁断开

现象:管理后台的车位状态图偶尔不刷新,过一会儿又恢复正常,服务端日志出现WebSocket连接超时异常。

原因:Nginx默认对空闲连接有超时时间,WebSocket超过60秒没有数据传输就断了。解决方案是在Nginx配置里增加proxy_read_timeout,我设置成3600秒。后端同时加上心跳检测机制,每隔30秒推送一条心跳消息,双向确认连接保活。

6.2 出口识别成功但查询不到入场记录

现场场景:相机识别了车牌,道闸却没反应,后台日志提示车辆不存在。排查发现是入场时相机识别错误,把“苏A1A111”识别成“苏A1A117”,两个车牌在后天是两条不同的入场记录,出场时按正确车牌查自然查不到。

这类问题的根治方案是做模糊匹配兜底:出场精确匹配失败后,按照品牌、颜色、入场时间段三个维度做候选集匹配,人工确认后合并入场记录。同时入场识别置信度低于90%的抓拍图片,自动推送到管理端提示人工复核。设备识别算法做不到100%正确,业务逻辑就必须设计兜底策略。

6.3 计费金额出现负数

现象:某车主出场时订单金额显示为负数,引发投诉。

排查:发现是一条测试订单的入场时间异常,由于入场时Redis宕机,数据回写到MySQL时入场时间字段插入了一个异常值。金额计算逻辑没做数据合法性校验,导致计费引擎计算出负值。

修复方案:计费前先校验入场时间不能晚于出场时间,时间差值在合理范围内,异常数据直接触发告警人工处理。所有业务数据写入前做基本校验,这条原则应该刻在项目团队每个人脑门上。

6.4 管理后台导出报表内存溢出

现象:点击导出三个月订单报表时后端报OOM,服务重启才恢复。

原因:查询三个月订单数据量有几十万条,一次性把全量数据加载到内存再写入Excel,堆内存直接扛不住。

改进方案:改用游标方式分批查询,每批1000条写入临时文件,最后合并生成Excel,同时限制最长可导出时间范围为一个月,防止用户无限制地拉取数据。这个问题是典型的设计时没考虑数据量增长,项目上线三个月后才暴露出来。

6.5 数据库连接池连接泄漏

现象:系统运行一段时间后响应越来越慢,数据库连接监控显示活动连接数接近上限,重启后又正常。

原因:某段代码里获取数据库连接后,在异常分支没有正确释放连接资源,导致连接池被耗尽。

排查过程:打开HikariCP连接泄漏检测开关leakDetectionThreshold,设置为60000毫秒,日志里定位到了具体业务方法。优化代码后用try-with-resources语法确保连接释放,问题彻底解决。

6.6 前端地图选车位在移动端白屏

现象:H5页面在PC浏览器上一切正常,手机上打开停车场详情页就白屏。

原因:Leaflet默认初始化的地图容器高度为0,PC端浏览器的默认布局碰巧给了高度,移动端浏览器则完全不给。解决方案是在组件挂载后显式重新计算容器高度,并调用map.invalidateSize()方法刷新地图尺寸。

这类问题提醒我,响应式布局不是写几个媒体查询就完事,每个第三方组件库都要真机测试一遍才放心。

项目没有刻意追求最新最炫的技术,用到的都是社区里成熟稳定的东西。团队里新同学照着这套代码能快速接手,换一批人维护也不会出现“除了原作者谁都不敢动”的尴尬局面。我个人体会最深的一点是,全栈项目表面上考验的是技术宽度,实际上更考验数据边界划分能力和对业务细节的理解。停车场的计费规则变化多端,前端页面改起来很容易,但计费引擎的稳定性和扩展性才是真正的护城河。

最后分享一个小经验,项目启动时顺手做一个压测脚本,模拟高峰期同时100辆车入场出场的场景。这个脚本往后帮你排查性能问题的用处太大了,平时看着没有价值的诊断能力,现场事故出现时才发现在救命的全指望它。后续如果要扩展,往无人值守方向加ETC扣费,或者往预约停车方向加车位锁控制,这套架构的扩展口都留好了,直接往里面填代码就是。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进