刚做完一个基于SpringBoot+Vue的智慧停车管理系统,从需求分析、数据库设计到前后端联调、打包部署,完整走了一遍。这篇就把整个项目的设计与实现过程拆开来讲,包括表结构怎么设计、车位状态怎么同步、微信支付怎么接入、预约逻辑怎么处理,以及一些实际踩过的坑,给正在做类似系统或者准备拿这个方向做毕设的同学一个参考。
先说一下这套系统最终实现了什么:车主端小程序/H5可以实时查看停车场剩余车位、在线预约车位、停车缴费、查看停车记录;管理端Web可以管理车位、设置收费标准、查看营收统计、处理异常订单。技术栈就是标题里写的SpringBoot+Vue,后端用SpringBoot 2.7 + MyBatis-Plus + MySQL + Redis,前端管理端用Vue 3 + Element Plus,车主端用的是Vue 3 + Vant。整个系统前后端分离,RESTful API通信,JWT做身份认证。
1. 项目整体设计与技术选型
1.1 为什么选SpringBoot+Vue这套组合
这个组合在智慧停车这类管理系统里几乎是标配,原因很实际。后端用SpringBoot,核心价值在于它把Spring家族里那些繁琐的XML配置全部干掉,starter机制让你引入一个依赖就自动配好一大片东西。比如接入Redis,加一个spring-boot-starter-data-redis就完事,连接池、序列化器都给你配好了。这对中小型管理系统来说开发效率提升非常明显,不用花大量时间在环境搭建上。
前端选Vue的原因也很直接。管理端页面大多是表格、表单、弹窗、图表这类交互,Vue的双向数据绑定和组件化开发特别适合这种场景。Element Plus组件库把表格、分页、表单校验、对话框这些都封装好了,写一个车位管理页面,核心代码可能只需要几十行。车主端小程序或H5那边,Vant组件库提供了移动端风格的操作界面,上手也很快。
还有一个关键点是前后端分离架构。在智慧停车这个场景里,意味着停车场的道闸设备、地感线圈、摄像头这些硬件通过HTTP接口跟后端通信,跟车主端、管理端完全解耦。设备上报一个事件,后端更新车位状态,前端轮询或通过WebSocket推送拿到最新数据,整个链路非常清晰。
1.2 系统整体架构
整个系统我按四个层次来设计。表现层是车主H5/小程序和管理端Web;网关层用Nginx做反向代理和静态资源托管;业务层是SpringBoot微服务,包括用户服务、车位服务、订单服务、支付服务、车辆识别服务;数据层是MySQL存业务数据、Redis做缓存和分布式锁、MinIO存车牌识别照片和用户头像。
车主端(H5/小程序) 管理端(Web) | | Nginx(反向代理+静态资源) | | SpringBoot API Server / | | | \ 用户 车位 订单 支付 车辆 \ | | | / MySQL Redis MinIO这个架构的好处是每一层都能独立扩展。比如高峰期车主端请求量大,可以只给API服务加实例,通过Nginx负载均衡,不影响其他部分。数据库层面Redis扛住了绝大部分读请求,MySQL的压力主要在订单写入和账单统计上。
1.3 核心功能模块划分
功能模块我分成三个端来规划。车主端包含注册登录、车位查询、车位预约、扫码支付、停车记录、电子发票。管理端包含车位管理、收费规则配置、车辆管理、订单管理、营收统计、管理员权限。硬件对接端包含车牌识别接口、道闸控制接口、余位屏数据推送。
这里要说一下设计上的取舍。最开始也考虑过做月卡/年卡充值功能,后来觉得在系统第一版里优先级不高,而且涉及复杂的套餐逻辑和到期提醒,就砍掉了。做项目一定要学会做减法,先把核心业务闭环跑通,再考虑增值功能。智慧停车最核心的闭环就是:找车位-预约-入场-计费-出场-支付。
2. 数据库设计与核心表结构
2.1 数据表关系梳理
这是整个项目的地基,我花了两天时间来设计表结构和梳理关系。核心表一共有8张:用户表、车位表、预约表、订单表、收费标准表、车辆表、停车记录表、管理员表。表之间关系不复杂,但有几个细节需要特别注意。
车位表和收费标准表是多对一关系,一个停车场可以有多条收费标准,比如首小时5元、之后每小时2元、24小时封顶20元,这些是同一辆车在不同时长下触发的不同规则。预约表和车位表是多对一关系,一个车位在某个时间段只能被一个预约占用,这里涉及并发控制。订单表和预约表是一对一关系,预约成功后必然会产生一条待支付订单。
2.2 关键表结构详解
用户表(t_user)。字段包括id、openid、phone、nickname、avatar、balance、status。openid是微信小程序端的唯一标识,如果用H5端,可以用手机号+验证码登录,然后生成token。balance字段是预留的,后续如果做余额支付可以复用,当前版本主要还是微信支付。
车位表(t_parking_space)。字段包括id、parking_id、space_no、type、status、curr_vehicle_id。type区分普通车位和充电车位,status有四种:空闲(0)、占用(1)、预约(2)、维护(3)。这里有个设计细节,为什么预约状态要单独一个值而不是直接标记为占用?因为预约状态下的车位在预约时间段到达后会自动转为占用,但在到达之前道闸是不能放行的。如果不区分这两种状态,逻辑会混乱。
预约表(t_reservation)。字段包括id、user_id、space_id、reserve_start、reserve_end、status、order_id。status有:待入场(0)、已入场(1)、已取消(2)、已过期(3)。这里最关键的是预约时间段校验,后面在并发控制部分细说。
订单表(t_order)。字段包括id、order_no、user_id、vehicle_id、space_id、enter_time、leave_time、duration、amount、status、pay_time、pay_type。status有:待支付(0)、已支付(1)、已完结(2)、已退款(3)、已关闭(4)。order_no用时间戳+随机数生成,同时加了唯一索引,防止重复下单。
收费标准表(t_fee_rule)。字段包括id、parking_id、rule_name、free_minutes、base_amount、base_minutes、per_hour_amount、cap_amount。还加了一个effective_time和expire_time来做规则有效期,比如节假日可以启用临时费率。这块设计灵活一些,后面改需求的时候会省很多事。
2.3 索引设计与查询优化
索引这块我踩过坑,刚开始没加索引,车位列表页在数据量到几万条的时候明显变慢。后来给订单表的user_id、enter_time、status分别加了索引,查询速度从800ms降到了20ms左右。这个差距在真实场景下感受非常明显。
核心索引如下:
ALTER TABLE t_order ADD INDEX idx_user_id (user_id); ALTER TABLE t_order ADD INDEX idx_enter_time (enter_time); ALTER TABLE t_order ADD INDEX idx_status (status); ALTER TABLE t_reservation ADD INDEX idx_user_id (user_id); ALTER TABLE t_reservation ADD INDEX idx_space_id (space_id);还有个细节是分页查询一定要用索引覆盖。比如查询某个用户的历史订单,如果用WHERE user_id = ? ORDER BY enter_time DESC LIMIT ?,一定要建(user_id, enter_time)的联合索引,这样排序也能走索引,否则到后面数据量大时会触发filesort,性能下降很严重。
3. 核心功能实现与关键技术点
3.1 用户认证与JWT鉴权
登录流程用的是微信小程序登录+jwt token的方式。前端调用wx.login获取code,传给后端,后端用code换openid,查用户表,如果不存在就自动注册,存在就生成token返回。
@PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { String openid = wxService.code2Session(dto.getCode()); User user = userService.getByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setNickname("微信用户" + RandomUtil.randomNumbers(6)); userService.save(user); } String token = jwtUtil.generateToken(user.getId(), user.getRole()); return Result.success(token); }JWT这块要特别注意token过期时间。我设置了access token有效期2小时,refresh token有效期7天。车主端如果只在停车时打开一下,2小时完全够用,但如果要查历史记录,可能就会过期。我用拦截器统一处理token刷新:如果access token过期但refresh token有效,就返回新token,前端拿到新token后替换本地存储。
拦截器这里还有个细节要处理,就是一个请求里携带的token如果即将过期(比如还剩5分钟),我在响应头里加一个X-Refresh-Token标记,前端检测到就静默刷新。这样用户体验最好,不会出现用着用着突然要重新登录的情况。
3.2 车位状态实时同步
这是智慧停车系统的核心,也是最容易出问题的环节。我设计了三级同步机制,保证车位状态在车主端、管理端、道闸设备之间保持一致。
第一级是数据库状态,作为最终准确来源。所有状态变更都必须落库,比如车辆入场时把车位状态从空闲改为占用,出场时改为空闲。
第二级是Redis缓存,用于高并发读。车位数查询、车位列表这些高频读操作全部走缓存,缓存key设计为parking:space:{id}:status,value是状态码。写入时用先写数据库再删缓存的模式,避免缓存和数据库不一致。
第三级是WebSocket推送,用于车主端实时感知。当车位状态变化时,后端通过WebSocket广播给在线用户。这里用Spring的SimpMessagingTemplate实现,前端订阅/topic/parkingStatus主题。
public void changeStatus(Long spaceId, Integer status) { spaceService.updateStatus(spaceId, status); redisTemplate.opsForValue().set("parking:space:" + spaceId + ":status", String.valueOf(status)); messagingTemplate.convertAndSend("/topic/parkingStatus", new StatusMessage(spaceId, status)); }三级同步顺序为什么是先数据库再Redis再WebSocket?因为数据库是持久化兜底,Redis是缓存加速,WebSocket是通知推送。只要保证最终一致性即可,不需要强事务。
3.3 预约并发控制
预约这块是并发问题最严重的。假设有100个人同时抢一个车位,如果不用并发控制,就会卖超。我用的是Redis分布式锁加数据库唯一约束的双保险方案。
预约流程是这样:
@Transactional public Result reserve(ReserveDTO dto) { // 1. 获取分布式锁 String lockKey = "lock:space:" + dto.getSpaceId(); boolean locked = redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS); if (!locked) { return Result.error("操作太频繁,请稍后重试"); } try { // 2. 校验车位状态 ParkingSpace space = spaceService.getById(dto.getSpaceId()); if (space.getStatus() != SpaceStatus.FREE) { return Result.error("车位已被占用或预约"); } // 3. 校验时间段是否有冲突 int count = reservationService.checkConflict(dto.getSpaceId(), dto.getStartTime(), dto.getEndTime()); if (count > 0) { return Result.error("该时段已被预约"); } // 4. 创建预约和订单 Reservation reservation = new Reservation(); reservation.setUserId(...); reservation.setSpaceId(...); reservation.setStatus(ReservationStatus.PENDING); reservationService.save(reservation); Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setAmount(calcAmount(dto.getStartTime(), dto.getEndTime())); orderService.save(order); // 5. 更新车位状态为预约中 space.setStatus(SpaceStatus.RESERVED); spaceService.updateById(space); return Result.success(order.getId()); } finally { redisLock.unlock(lockKey); } }这里有几个关键点。一是tryLock加超时时间,防止某个请求占用锁后异常退出导致死锁。二是事务和锁的顺序,先加锁再开事务,这样锁的粒度能包住整个业务逻辑。三是预约时间冲突校验,SQL要写start_time < ? AND end_time > ?,取反就是不冲突的判断条件。
3.4 停车计费逻辑
计费是业务规则最复杂的部分,我踩了好几次坑。最初以为就是简单的“首小时5元,之后每小时2元”,实际写代码才发现边缘情况特别多:入场不到15分钟免费、跨天计费、24小时封顶、节假日费率、充电车位额外收费。
计费核心逻辑:
public BigDecimal calcAmount(LocalDateTime enterTime, LocalDateTime leaveTime, FeeRule rule) { long minutes = Duration.between(enterTime, leaveTime).toMinutes(); // 免费时长内 if (minutes <= rule.getFreeMinutes()) { return BigDecimal.ZERO; } BigDecimal amount = BigDecimal.ZERO; // 首小时费用 if (minutes <= rule.getBaseMinutes()) { return rule.getBaseAmount(); } amount = amount.add(rule.getBaseAmount()); // 超出首小时的费用 long extraMinutes = minutes - rule.getBaseMinutes(); amount = amount.add( BigDecimal.valueOf(extraMinutes) .divide(BigDecimal.valueOf(60), 2, RoundingMode.CEILING) .multiply(rule.getPerHourAmount()) ); // 封顶逻辑 if (rule.getCapAmount() != null && amount.compareTo(rule.getCapAmount()) > 0) { return rule.getCapAmount(); } return amount; }最坑的是超时部分向上取整的问题。2小时01分和3小时整,如果按分钟精确算,单价会不一样。实际停车场的规则是超时按小时继续计费,不足一小时按一小时算,所以用了RoundingMode.CEILING。
还有场景是入场时间和出场时间跨天。比如晚上10点进场,凌晨2点出场,数据库存的是时间戳没问题,但计费时Duration.between计算出来的分钟数包含跨天,这个逻辑本身没问题。真正的问题在报表统计,按天聚合营收时要按照场时间来判断归属哪一天,我当时在这里纠结了很久。
3.5 微信支付对接
微信支付这块是很多人的痛点,我讲一下整体流程。用户点击支付,后端调用微信支付统一下单接口,拿到prepay_id返回给前端,前端调起微信支付收银台,用户输入密码支付,微信后台异步通知后端支付结果。
public String createPayOrder(Long orderId) { Order order = orderService.getById(orderId); // 构造微信支付参数 Map<String, String> params = new HashMap<>(); params.put("appid", wxConfig.getAppId()); params.put("mch_id", wxConfig.getMchId()); params.put("out_trade_no", order.getOrderNo()); params.put("total_fee", order.getAmount().multiply(BigDecimal.valueOf(100)).intValue() + ""); params.put("body", "智慧停车-停车费"); params.put("notify_url", wxConfig.getNotifyUrl()); params.put("trade_type", "JSAPI"); params.put("openid", wxConfig.getOpenid(order.getUserId())); String prepayId = wxPayService.unifiedOrder(params); return buildPayParams(prepayId); }一定要注意金额单位。微信支付以分为单位,数据库存的金额以元为单位,转换时用multiply(100).intValue(),千万不能直接强转,否则会有精度问题。我就踩过这个坑,测试时发现支付金额多了1分钱。
支付回调要处理幂等性。微信会多次通知同一个订单,比如网络超时重发,处理时先查订单状态,如果已支付就直接返回成功,不再重复处理。
@PostMapping("/wx/notify") public String wxNotify(@RequestBody String xmlData) { Map<String, String> result = wxPayService.parseNotify(xmlData); String orderNo = result.get("out_trade_no"); String tradeStatus = result.get("result_code"); Order order = orderService.getByOrderNo(orderNo); if (order == null || order.getStatus() == OrderStatus.PAID) { return "<xml><return_code>SUCCESS</return_code><return_msg>OK</return_msg></xml>"; } if ("SUCCESS".equals(tradeStatus)) { orderService.paySuccess(orderNo, result.get("transaction_id")); } return "<xml><return_code>SUCCESS</return_code><return_msg>OK</return_msg></xml>"; }3.6 车牌识别与道闸联动
车牌识别我用的是硬件厂商提供的SDK,停车场入口的摄像头检测到车牌后,把车牌号和抓拍照片推送到后端接口,后端判断车辆是否有预约或是否为月卡用户,决定是否开闸放行。
这里涉及一个异步处理的细节。摄像头推送车牌识别结果后,如果同步去查数据库再控制道闸,整个过程可能需要2-3秒,车辆在闸口的等待体验很差。我用消息队列异步处理:摄像头推送后立即返回响应,后台把车牌识别结果丢进MQ,消费者处理放行逻辑。
@PostMapping("/device/recognize") public Result recognize(@RequestBody RecognizeDTO dto) { // 立即返回,异步处理 mqTemplate.send("parking.recognize.queue", dto); return Result.success("已接收"); } @RabbitListener(queues = "parking.recognize.queue") public void handleRecognize(RecognizeDTO dto) { Vehicle vehicle = vehicleService.getByPlateNo(dto.getPlateNo()); if (vehicle != null) { // 有登记,开闸放行 gateService.openGate(dto.getGateId()); // 更新车位状态 parkingService.vehicleEnter(vehicle.getId(), dto.getSpaceId()); } else { // 无登记车辆,走临时车流程 parkingService.tempVehicleEnter(dto); } }3.7 前端Vue关键实现
管理端用的Vue 3 + Element Plus + ECharts。布局是经典的后台管理布局:左侧菜单栏,右侧内容区。路由用vue-router,鉴权用路由守卫,请求用axios封装。
登录路由守卫这里分享一个我处理过的坑。用户刷新页面后,Vue实例重新加载,store里的用户信息会丢失。我的处理是路由守卫里每次检查localStorage里有没有token,如果有但store里没有用户信息,就调用/user/info接口重新获取。
router.beforeEach(async (to, from, next) => { const token = localStorage.getItem('token'); if (token) { if (!store.state.userInfo) { try { const res = await userApi.getInfo(); store.commit('setUserInfo', res.data); } catch (e) { localStorage.removeItem('token'); next('/login'); return; } } } if (to.meta.requiresAuth && !token) { next('/login'); } else { next(); } })车位管理页面是典型的CRUD界面,用Element Plus的el-table绑数据,el-dialog做新增编辑,el-form做表单校验,分页用el-pagination。模板代码看起来很长,但核心逻辑很简单,难点在状态切换时的即时刷新。比如管理员把一个车位改成维护状态,已经订阅了WebSocket的页面会自动收到更新,这里有个重复渲染的问题,需要判断消息是否来自当前管理员操作。
车主端用Vue 3 + Vant,核心页面是首页车位列表、预约页面、订单页面。首页车位列表用轮询加WebSocket双通道,WebSocket断线时自动切换到轮询模式,每10秒请求一次车位数。这个降级方案很实用,WebSocket在弱网环境下非常不稳定,必须有备用方案。
4. 系统部署与性能优化
4.1 前后端部署方案
部署我用了Docker Compose,把SpringBoot应用、Nginx、MySQL、Redis全部容器化。后端打的jar包做一个镜像,前端构建出来的dist目录挂载到Nginx容器里,Nginx里配置了/api前缀的反向代理。这套方案在单台2核4G的云服务器上跑得很稳。
version: '3' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 volumes: - ./data/mysql:/var/lib/mysql redis: image: redis:7.0 backend: build: ./backend depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod frontend: image: nginx:alpine volumes: - ./frontend/dist:/usr/share/nginx/html - ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf ports: - "80:80"4.2 性能优化实践
系统上线前做了压测,用的是JMeter,模拟200个并发用户同时查车位列表和提交预约。压测结果暴露了三个问题:一是MySQL连接池默认只有10个,并发一高就报连接不够;二是车位列表查询每次都查数据库,没有走缓存;三是预约事务时间过长,导致锁等待严重。
优化方案分别是:连接池调到50,最大等待时间5000ms;车位列表接口加Redis缓存,缓存5秒;预约流程去掉不必要的事务嵌套,减少锁持有时间。优化后压测结果,TPS从原来的200涨到800,接口响应时间从600ms降到100ms左右。
还有一个优化点是静态资源,前端打包后的JS/CSS通过Nginx开启gzip压缩,带宽占用下降60%,首屏加载时间从3秒降到1秒以内。
4.3 数据库读写分离与高可用
如果数据量上来,单库单表会撑不住。我的规划是先用读写分离解决读压力:主库写订单、车位状态,从库读历史订单、统计报表。MySQL主从复制配起来不难,但应用层要区分数据源,这里用ShardingSphere或MyBatis-Plus的多数据源插件都能实现。
不过我要提醒一句,做毕业设计或者演示项目,完全没有必要上微服务、读写分离、消息队列这些重量级的东西,单机单库足够了。把Redis缓存、索引优化、连接池调优这些做好,10万级的数据量完全没问题。
4.4 常见部署问题速查
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 前端页面打开白屏 | 后端API无法访问,或静态资源路径错误 | 检查Nginx代理配置和dist路径 |
| 接口报401 | token过期,或JWT密钥不匹配 | 检查JWT配置,确认前后端密钥一致 |
| 二维码支付报错 | 回调地址是内网地址,微信无法访问 | 配置公网IP或内网穿透地址 |
| 登录后刷新页面跳回登录页 | store状态丢失 | 路由守卫里增加用户信息重新获取逻辑 |
| 微信群发通知无法发送 | 小程序订阅消息模板ID配置错误 | 检查模板ID和用户订阅状态 |
5. 项目测试与常见问题排查实录
5.1 功能测试清单梳理
测试阶段我列了一个清单,把系统所有功能点过了一遍。登录注册、车位列表、车位预约、订单支付、扫码出场、停车记录、后台管理、收费规则,一共8大模块37个测试用例。重点测的是预约流程完整链路:用户预约车位-收到预约成功通知-到场扫码入场-停车-计费-支付-出场-车位状态恢复。
测出来几个问题,除了前面说过的微信支付精度问题,还有一个是预约过期状态没有定时任务去清理。预设了用户预约了车位但没到场,到了预约结束时间,车位还显示预约中,别人看不了也约不了。后来加了定时任务,每5分钟扫一次预约表,把过期未入场的预约改成过期状态,同时释放车位。
5.2 并发场景测试实录
我用JMeter模拟了50个用户同时预约同一个车位,测试结果只有1个用户成功,其他49个都返回“车位已被预约”。这个结果说明分布式锁和状态校验是生效的。但是也暴露了一个问题:很多用户挤在提交预约接口,响应时间从正常时的150ms涨到800ms,因为锁等待排队了。
后来做了优化,在进入预约接口之前,先用Redis的计数器做了一层限流,比如每秒最多处理500个预约请求,超过的返回“系统繁忙”。这个限流方案效果很好,接口响应时间稳定在300ms以内。
还有一个并发问题是出库时的重复开闸。摄像头识别到同一个车牌,可能因为光线变化等原因触发两次识别事件,导致两次调开闸接口。处理方式是在设备服务层做了幂等:同一车牌5秒内的重复开闸请求直接忽略。
5.3 日常运维排查技巧
上线之后最常遇到的问题不是代码bug,而是环境问题。我总结了几个排查技巧:接口突然变慢,先看Redis连接数,连接数过高可能是有慢查询阻塞了业务;再比如某个时间段车位状态不更新,先看RabbitMQ消费者是否正常,消费者线程挂掉会导致消息堆积。日志排查用关键字搜索,比如搜“Exception”或“ERROR”,再看对应的请求ID关联上下游日志。我在日志里统一加了traceId,前端传的请求ID会贯穿整个链路,排查问题非常方便。
这套系统从设计到落地,前前后后花了一个多月时间。回过头来看,最有价值的收获不是代码量,而是对整个业务闭环的理解:车位状态怎么在各种操作下保持一致、计费规则怎么设计才能既灵活又可控、并发场景下怎么保证数据不错不乱。最后再分享一个通用技巧:做这类管理系统,先把所有涉及状态变更的操作列表理出来,逐条核对状态流向,就能避免大部分逻辑混乱的问题。项目文档和关键代码片段我整理在本地了,有需要的可以留言交流。