多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SpringBoot+Vue打造智能停车场管理系统:从数据库设计到部署上线全解析

SpringBoot+Vue打造智能停车场管理系统:从数据库设计到部署上线全解析 接手这个智能停车场管理系统的时候我刚从上一个排期特别紧的项目里抽身出来。客户那边停车场刚完成硬件改造但原有的一套进出场记录系统基本靠人工登记高峰期出入口排长队车主经常因为计费争议闹到物业办公室。需求很明确做一个让车主能自主查询车位、扫码进出场、在线支付费用的平台同时给管理员一个能实时监控全场状态的后台。技术栈定的是SpringBootVue这是当时团队最熟悉、也最能控制交付风险的组合。这个选题其实很典型。SpringBoot负责后端接口、业务逻辑和数据库交互Vue负责前端页面和交互体验两者通过Restful API通信就是标准的“前后端分离”架构。整套系统做下来覆盖了登录鉴权、车位管理、订单计费、统计报表、异常处理这些几乎所有管理系统都要面对的问题所以不管你是拿它做毕业设计、个人实战项目还是给中小型停车场做数字化改造的参考原型这套方案都能直接落地。我写这篇文章会把我在需求分析、数据库设计、后端接口实现、前端页面联调以及部署上线过程中踩过的坑和最终采用的方案完整梳理一遍。内容会偏实战尽量不扯虚的每个环节我都会说清楚为什么这么做以及哪些地方容易出事。1. 系统整体设计与技术选型1.1 为什么选择SpringBootVue前后端分离先聊技术选型。市面上做管理系统能用的框架很多Django、Flask、Go的Gin都能做但SpringBoot在这个场景下确实有它不可替代的优势。第一SpringBoot解决了传统SSH、SSM框架配置繁琐的问题。早期用Spring MVC做项目光是XML配置就要写一大堆数据源、事务管理器、视图解析器、拦截器每个都要单独配置。SpringBoot用自动装配机制把这些常见的配置全部内置了我只需要引入对应的starter依赖框架会自动根据类路径下的jar包来创建对应的Bean。比如我引入spring-boot-starter-data-redisRedisTemplate就会自动注册到容器里不需要手动写一串配置类。第二生态成熟度在这类管理系统里是压倒性的优势。权限控制有Spring Security和Shiro接口文档有Swagger和Knife4j持久层有MyBatis-Plus这些组件的稳定性和资料丰富程度让团队协作时的沟通成本低很多。对于预算有限的停车管理项目来说稳定比炫技重要得多。Vue这一侧的选择也很明确。Vue的双向数据绑定让表单类的页面写起来非常舒服组件化的开发方式可以把车位地图、收费规则配置、报表图表这些部分独立出来复用。Vue Router做页面路由非常顺手特别是动态路由这块可以根据用户的角色权限来动态挂载对应的菜单和页面这个特性我在后面权限管理时会详细说。前后端分离还有个实际好处部署时可以独立扩展。后端接口压力大的时候可以多挂几个实例做负载均衡前端静态文件放在Nginx里就行两边互不干扰。这对停车场这种可能有多个出入口、多个管理员同时操作的系统来说很实用。1.2 系统模块划分与核心流程设计在动手写代码之前我先把整个系统的功能边界划清楚。停车场管理系统如果边做边想很容易做成一个什么都往里塞的大杂烩。我最终定下来的模块划分是这样的车主端微信小程序或H5车位查询、预约车位、扫码进场、扫码出场、在线支付、停车记录查询管理端Vue后台车位管理、车辆管理、订单管理、收费规则配置、月卡管理、报表统计、管理员账号管理设备对接层道闸控制器、摄像头识别车牌识别、地感线圈检测核心的业务流程可以抽象成三条主链路第一条是进场链路。车辆到达入口摄像头识别车牌号系统判断这个车牌是临时车还是月卡车临时车自动抬杆放行并创建一条进行中的停车记录月卡车校验有效期后放行。这个链路最核心的点就是“进场记录”要保证唯一性和原子性不能出现同一辆车重复进场记录的情况。第二条是出场链路。车辆到达出口摄像头再次识别车牌系统查询进行中的停车记录计算停车时长和费用展示给车主缴费。缴费完成后抬杆放行同时更新停车记录状态为已完成。这个链路的核心是计费规则的准确性和并发控制。第三条是管理链路。管理员登录后台可以实时查看车位的占用情况、每辆车的进出记录、当天的收入汇总。这个相对简单主要是统计查询和可视化展示。这样划分之后后端接口的边界就很清楚了。每个模块对应一组Controller业务逻辑在Service层做数据操作通过Mapper层访问MySQL缓存和分布式锁用Redis文件上传用MinIO存储车牌识别照片。整个结构是标准的DDD分层思想不算复杂但胜在清晰。2. 数据库设计与核心表结构2.1 数据建模的关键考量数据库设计是整个系统里最不该省功夫的部分。停车场系统的数据有一个特点写入频繁查询维度多样而且金额相关字段必须绝对准确。基于这些特点我在设计表结构时做了几个关键决策。第一停车记录表单独拆分不跟车位表做物理外键关联。原因很简单一辆车在一个停车场可能会多次进出车位是会复用的如果停车记录直接挂车位ID的外键每次车辆离场释放车位后这个关联关系就要置空操作起来很别扭。我选择让停车记录表只保存车位编号的冗余字段业务上通过逻辑来保证一致性不依赖数据库外键。这样做还有一个好处就是以后如果要支持多停车场扩展只需要在这个字段里带上停车场标识就可以。第二金额字段使用DECIMAL类型千万不要用FLOAT或者DOUBLE。这个是我最早做财务相关功能时踩过的坑浮点数在计算分账的时候会出现0.10.2不等于0.3的情况放到停车收费场景里哪怕一分钱的误差月底跟物业对账的时候都是灾难。DECIMAL(10,2)足够覆盖绝大多数停车场的单笔金额范围。第三所有核心表都加上create_time、update_time、deleted这三个字段。前两个是审计字段排查问题的时候非常有用。deleted是逻辑删除标志这是我特意做的决定因为停车记录涉及财务对账物理删除会破坏数据链万一之后需要追溯数据没了就麻烦了。MyBatis-Plus提供了TableLogic注解逻辑删除用起来成本很低。2.2 车位表与停车记录表设计细节车位表的设计相对简单核心字段是车位编号、区域、类型普通/充电桩/无障碍、状态。重点是状态字段的取值设计。我用的是0-空闲 1-占用 2-预留 3-故障四种状态其中“预留”是给月卡固定车位和预约车辆用的“故障”用于维护中的车位。很多设计会把预留和占用混在一起实际运营时如果某个车位被预约了管理员在后台看到的状态应该是“预留”而不是“占用”这关系到后续统计报表的准确性。停车记录表是系统里最重要的表我列出核心字段字段名类型说明idbigint主键雪花算法生成plate_numbervarchar车牌号car_typetinyint车辆类型0临时车 1月卡车entry_timedatetime进场时间exit_timedatetime离场时间未离场则为nullduration_minutesint停车时长分钟total_amountdecimal应收金额paid_amountdecimal实付金额statustinyint0进行中 1已完成 2已支付待离场 3异常entry_imagevarchar进场抓拍照片URLexit_imagevarchar离场抓拍照片URL这里有个细节要注意exit_time为什么允许为null因为车辆进场后可能长时间未出场如果一开始就设置成NOT NULL进场记录创建时就要填一个默认值这会让“是否已离场”的判断变得麻烦。允许null反而简单查询正在停放的车辆时直接WHERE exit_time IS NULL就行。月卡表单独设计字段包括车牌号、套餐类型月卡/季卡/年卡、生效时间、到期时间、状态。月卡是停车场重要的预付收入来源到期前7天系统需要自动给车主发送续费提醒这个可以在后端写一个定时任务来实现SpringBoot的Scheduled注解就能搞定不用引入额外的任务调度框架。用户表设计分为管理员表和车主表。管理员表主要给后台登录用角色字段区分超级管理员和普通管理员。车主表关联车牌号和手机号主要用于小程序端的注册登录和车辆绑定。这里有个设计建议管理员表和车主表最好分开因为他们的字段差异很大混在一张表里会导致大量字段冗余查询效率也会受影响。3. 后端核心功能实现3.1 登录鉴权与JWT实现登录鉴权我选的是JWT方案没有用Spring Security那套重的OAuth2流程。停车场管理系统的用户角色简单就管理员和车主两种不需要复杂的第三方授权JWT无状态、跨域友好的特点正好合适。JWT的token结构分三段Header、Payload、Signature。Header里记录加密算法Payload里面放用户ID、用户名、角色、过期时间。Signature部分用密钥对Header和Payload做签名防止篡改。后端每个需要登录的接口都经过一个拦截器从请求头的Authorization字段里取出token解析校验签名然后把用户信息放到ThreadLocal的一个上下文对象里这样在Service层就直接可以拿到当前登录用户的信息。我封装了一个简单的JWT工具类核心逻辑如下public String generateToken(String userId, String role) { long expire System.currentTimeMillis() jwtProperties.getExpireTime(); return Jwts.builder() .setSubject(userId) .claim(role, role) .setExpiration(new Date(expire)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }使用HS256对称加密算法密钥配置在application.yml里。有个必须注意的地方生产环境的JWT密钥一定不要写在代码里硬编码我见过很多项目直接把密钥写死在工具类中结果密钥泄露之后整个系统的登录体系就形同虚设。正确的做法是通过环境变量或者配置中心来动态注入部署的时候再设置具体的值。还有一点是关于token过期的处理。车主的操作频次不高过期时间可以设置24小时管理员的token建议2小时就过期因为后台涉及敏感操作过期后要求重新登录是安全底线。为了减少管理员的频繁登录也可以做成双token方案一个accessToken短期有效一个refreshToken长期有效accessToken过期时用refreshToken静默续期。我这个项目里为了控制复杂度没有做但如果是商业化项目这个优化值得考虑。3.2 车位状态实时更新的并发控制停车场的车位状态并发控制是这个系统里最考验细节的地方。想想这个场景同一时刻两个入口的摄像头各识别到一个车牌系统同时处理两笔进场请求都要查询A区03号车位是否空闲结果两个请求都查到“空闲”于是都分配了这个车位同时写入两条进场记录。这就产生了数据竞争。解决这个问题有几种方案我最终采用的是Redis分布式锁加乐观锁的组合。进场处理的流程是这样的public EntryResult handleEntry(String plateNumber) { // 生成唯一业务流水号用于幂等判断 String requestId UUID.randomUUID().toString(); String lockKey parking:entry: plateNumber; boolean locked redisLock.tryLock(lockKey, 3, TimeUnit.SECONDS); if (!locked) { throw new ServiceException(系统正在处理该车辆请勿重复操作); } try { // 先判断是否有进行中的停车记录防止重复进场 ParkingRecord record recordMapper.selectActiveRecord(plateNumber); if (record ! null) { throw new ServiceException(该车辆已在场内无需重复进场); } // 分配车位 // 这里使用乐观锁更新车位状态 int updated parkingSpaceMapper.casOccupy(); if (updated 0) { throw new ServiceException(暂无可分配车位); } // 创建停车记录 // ... } finally { redisLock.unlock(lockKey); } }锁的粒度很重要。我这个方案里锁的是车牌号不是车位。这意味着同一辆车不会并发处理两次进场请求不同车辆进场互不干扰。如果锁的是车位那么两个入口同时来车都得等同一个锁释放性能会明显下降。分布式锁的Redis实现要用setIfAbsent加过期时间保证原子性不能用先get后set的操作。释放锁的时候要校验持有者防止锁过期后其他线程获取了锁前一个线程误删了别人的锁。车位表的casOccupy方法对应SQL是这样的UPDATE parking_space SET status 1, version version 1 WHERE id #{spaceId} AND status 0 AND version #{oldVersion}这里用版本号做乐观锁。如果更新影响的行数为0说明车位状态已经变了就要重新找空闲车位。MyBatis-Plus内置了乐观锁插件只需要给实体加Version注解然后在配置类里注册OptimisticLockerInnerInterceptor即可不复杂但一定要加上。3.3 计费规则的灵活配置计费规则是停车场管理系统里最容易出bug的模块之一。各个停车场的计费差异很大有的按小时计费首小时10元之后每小时5元有的按分钟累计每30分钟3元不足30分钟按30分钟算有的晚上8点到次日8点有夜间封顶价。如果把这些规则硬编码在代码里那每次客户改价格都要发版想想就痛苦。我的方案是做一个计费规则表把规则参数化存储。核心字段包括规则名称如“标准日夜计费”计费类型按小时/按30分钟首时段单价和时长比如首小时10元续时时长和单价单次计费上限封顶价格夜间时段设置开始时间、结束时间、夜间单价免费时长比如前15分钟免费计费引擎放在Service层核心思路是分段计算。举个例子某停车场规则是前15分钟免费首小时10元之后每30分钟3元单日封顶50元。车辆停了5小时20分钟计算过程就是总时长 320分钟 免费时长扣除 320 - 15 305分钟计费时长 首小时 10元 剩余计费 305 - 60 245分钟 按30分钟为单位向上取整 245 / 30 8.17向上取整为9个计费单位 续时费用 9 * 3 27元 总计 10 27 37元未超过单日封顶50元应收37元这里有一个关键点向上取整还是向下取整会直接导致客诉。我最终实现时是把不足一个计费单元统一按一个计费单元处理这个逻辑写清楚注释并且在前端页面展示明细让车主清楚看到每个时间段的金额计算方式。很多计费纠纷都是因为车主觉得金额不明白明细展示出来能减少大量投诉。对于月卡车计费逻辑简单很多只要校验车牌在月卡表里有有效记录就直接放行不生成临时车订单。这里要注意的是月卡车如果超时出场比如包月车辆在非通行时段出场要不要额外收费这个需要跟物业确认我在系统里做成可配置项。免费时长的处理也有细节。如果车辆在免费时长内进出订单状态直接标记为“已完成-免费”金额为0但记录还是要保留这能反映车场真实的进出流量。4. 前端Vue实现与联调实战4.1 Vue环境搭建与路由设计前端用的是Vue3 Vite Element Plus这套组合。Vite构建工具在开发阶段的冷启动速度比Webpack快很多配置也简洁对新手友好。Vue3的Composition API配合setup语法糖写业务逻辑的时候比Options API清晰特别是涉及多个状态联动的时候代码不会像原来那样散落在各种methods里。路由设计上我使用了静态路由加动态路由混合的方案。静态路由只保留登录页和404页其余的业务路由都是登录成功后根据用户的角色权限动态生成的。这部分用到了Vue Router的addRoute方法。动态路由的核心逻辑是这样的// 登录成功后根据角色获取对应的菜单和路由配置 const menuList await getUserMenus(); // 将后端返回的路由配置转换为组件对象 const dynamicRoutes transformRoutes(menuList); // 动态添加路由 dynamicRoutes.forEach(route { router.addRoute(route); });动态路由有一个很常见的坑页面刷新后动态添加的路由会消失因为路由是在内存中维护的。所以必须在全局前置守卫里加一个初始化逻辑判断store中是否有用户信息和路由表如果没有则重新从后端拉取菜单和路由信息然后调用addRoute同时用next({...to, replace: true})重新导航一次确保当前要访问的页面被成功匹配。菜单的权限控制在管理系统里是不能省的。我用Pinia做状态管理把用户信息、菜单列表、按钮权限都放在store里。页面上的操作按钮比如“删除订单”“修改价格”这些敏感操作通过自定义指令v-permission来判断当前用户是否有对应的权限标识没有权限的直接从DOM上移除。这种方式比起单纯在代码里写v-if判断要干净得多权限标识可以统一维护在一张清单里。4.2 车位状态可视化与实时看板管理后台最核心的页面是车位总览。我一开始想用WebSocket做实时推送后来考虑了一下停车场的并发量远没有那么大出入口道闸的识别和抬杆动作是低频事件用轮询就够了没必要为了“实时”引入WebSocket的复杂度。轮询方案里我选的是3秒钟请求一次车位状态接口。为了让前端体验流畅每次请求返回的是全量车位状态数据数量在几十到几百个之间JSON序列化之后不超过几十KB性能上没有压力。车位地图我用CSS Grid实现每个车位是一个小方块状态通过颜色区分绿色为空闲红色为占用黄色为预留灰色为故障。每个方块绑定了一个点击事件点击后弹出该车位的详情包括当前停放的车辆、入场时间、预计费用。这个页面我用了一个小组件来封装template div classparking-grid :stylegridStyle div v-forspace in spaceList :keyspace.id classspace-item :classstatus- space.status clickhandleSpaceClick(space) span{{ space.spaceNo }}/span /div /div /template这里有个细节CSS Grid的gridTemplateColumns是根据车场区域的列数动态计算出来的因为不同区域的车位数和排布方式不一样不能写死。通过computed属性根据areaColumns动态生成样式字符串。统计报表这块我用的ECharts。ECharts是个纯前端的图表库不需要服务端渲染数据源从后端的报表接口拿。主要展示三个维度24小时车流量趋势图折线图、车位利用率热力图堆叠柱状图、近7日收入对比柱状图。ECharts的配置项多但常用的就是data、xAxis、yAxis、series这几个踩坑主要在自适应容器宽度的问题需要在窗口resize时调用chart.resize()方法。报表接口在后端做了聚合查询用MySQL的DATE_FORMAT函数对时间字段分组统计SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, SUM(total_amount) AS revenue FROM parking_record WHERE create_time #{startTime} AND create_time #{endTime} GROUP BY day ORDER BY day这里如果数据量大查询会很慢所以记得在create_time字段上建索引这是个必须注意的点。4.3 前后端联调的常见坑前后端分离的项目联调阶段是最折磨人的。我总结几个这个项目里遇到的典型问题。第一个问题跨域配置。开发环境下前端跑在Vite的5173端口后端接口在8080端口浏览器会阻止跨域请求。解决方案是在后端加一个CORS配置类允许指定来源的跨域请求Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }生产环境下通常会用Nginx做反向代理把前端的/api请求转发到后端的8080端口这样浏览器看到的页面和接口是同源的跨域问题就消失了。所以这个CORS配置主要是给开发环境用的。第二个问题Axios请求拦截器和响应拦截器的配合。我在请求拦截器里统一从Pinia的store里拿token加到请求头的Authorization字段。响应拦截器里统一处理错误码比如token过期时后端返回401前端拦截到这个状态码后清除本地存储跳转到登录页并弹出提示信息。这样每个接口的调用方不需要单独写错误处理逻辑代码会干净很多。第三个问题日期时间的序列化格式不一致。后端返回的时间默认是ISO格式的2024-03-15T14:30:00中间有个T字母前端展示的时候不太好看。我在后端的application.yml里加了一个统一的日期格式配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这样前后端约定俗成所有接口返回的时间都是yyyy-MM-dd HH:mm:ss的格式省去前端到处做转换的麻烦。5. 部署上线与常见问题排查5.1 服务器部署方案部署方案我用的是Docker Compose把整个应用栈编排成一个全家桶。停车场这种规模的系统一台2核4G的云服务器就完全够用。我的docker-compose.yml里包含了以下服务MySQL 8.0容器数据目录挂载到宿主机持久化Redis 6.2容器主要用于分布式锁和缓存MinIO容器用于存储停车抓拍照片后端SpringBoot应用容器打成Jar包后制作成镜像Nginx容器挂载前端Vue构建产物和反向代理配置这里有一个重要的部署细节SpringBoot应用容器要等MySQL和Redis就绪后再启动。我用的方式是docker-compose的depends_on配合健康检查不然第一次启动经常会报数据库连接失败重启几次才能成功。后端应用的Dockerfile大概长这样FROM openjdk:17-jre-slim WORKDIR /app COPY target/parking-system.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar, --spring.profiles.activeprod]生产环境的配置我通过环境变量注入比如数据库密码、Redis密码、MinIO的AccessKey这些都不能写在镜像里。用Docker Compose的environment字段配合宿主机宿主机.env文件管理既不提交到代码仓库也方便在服务器上直接修改。前端构建的时候有个坑要提前说。Vue项目打包默认的资源路径是根目录/如果你用Nginx的某个子路径来访问前端页面一定要在vite.config.js里设置base: ./不然部署后静态资源会全部404。我当时在这个问题上折腾了快一下午最后发现就这么一行配置的事。5.2 高频踩坑清单最后是我在开发这个项目过程中整理出来的高频问题每一条都是实际踩过的坑按出现频率排个序常见问题具体表现解决方案数据库连接池连接耗尽高峰期接口报Connection is not available合理配置HikariCP的maximum-pool-size我设为20同时检查是否有慢查询拖住了连接车牌号大小写不一致同一辆车进场是“京A12345”出场识别成“京a12345”查不到进场记录车牌号统一转大写再存储查询时也先转大写付费后状态未更新车主缴费成功但道闸不抬杆检查支付回调接口的幂等性支付回调因为网络重试可能被调多次用Redis记录处理状态明明有空位但分配失败并发场景下乐观锁更新返回0增加重试机制最多重试3次每次都重新定位空闲车位报表数据与线下记录对不上金额汇总差几分钱确认是否把逻辑删除的数据也统计进去了统一加deleted0条件这里我想重点展开说第一个问题。HikariCP是SpringBoot默认的数据库连接池默认的maximum-pool-size只有10。我遇到的场景是停车场早晚高峰时几十个请求同时进来查询车位、创建记录、更新状态这些操作都要占用数据库连接如果再有个别慢查询占着连接不放很容易就达到连接池上限后续请求全部排队接口响应时间飙升到几十秒表现就是系统看起来像卡死了。解决思路有两步。第一步是把连接池上限调大并配置合理的超时时间但这不是根本办法。第二步是排查是哪些SQL拖慢了查询速度通过MySQL的慢查询日志定位给常用查询字段加索引比如parking_record表的plate_number、create_timeparking_space表的status。索引建好之后高峰期接口响应时间从原来的几百毫秒降到了几十毫秒连接池压力自然就缓解了。关于车牌号统一大写的问题这个坑特别隐蔽。摄像头的车牌识别引擎偶尔会输出小写字母后端如果直接用识别结果去查库匹配不上就会判定为“未找到进场记录”车辆堵在出口体验极差。我在后端加了一个工具方法所有车牌入场和出场处理时都先做处理转成大写再去查询和存储。同时数据库字段的排序规则也改成大小写不敏感的utf8mb4_general_ci这样哪怕有历史脏数据也能匹配上。还有一个值得说的经验是日志规范。我把关键业务操作比如车辆进场、出场、支付成功这些写入了专门的业务日志表配合MDCMapped Diagnostic Context把requestId和车牌号串起来。排查问题的时候用一条查询就能看到某个车牌从进场到支付的全过程日志比翻原始日志文件效率高很多。最后再分享一个个人心得这套停车管理系统从需求梳理到上线跑通前后用了一个多月。我最大的体会是做一个管理系统技术难点其实不在某个单独的知识点上而在于把散落的细节串联起来的能力。数据库字段设计差一个状态可能导致前端要多写几十行判断逻辑并发控制少一把锁高峰时段就可能出现一辆车占用两个车位的诡异数据。这些坑每一个单看都不大但合在一起就是个麻烦。如果你也想自己动手做类似的系统我的建议是先画清楚业务流程图把进出的正常流程和异常分支全部过一遍再动手。代码写错了能改流程如果错了返工的代价要大得多。整套代码的版本管理用Git每天一个小提交随时能回退到能跑的状态这种习惯在项目推进中帮了我大忙。停车场这种项目后续还能扩展的方向也有不少比如对接微信小程序端的预约车位、与第三方支付平台的对账系统、甚至基于停车数据的车场运营分析报告都是在这个骨架上能持续迭代的功能。先把基础打牢后面的事情就顺了。
返回列表