SpringBoot2+Vue3+MySQL8停车场管理系统实战与踩坑记录
做一个停车场管理系统很多人第一反应是“不就是CRUD嘛”。但真把这套SpringBoot2 Vue3 MyBatis-Plus MySQL8.0的组合落地从表结构设计到计费状态机从前端权限路由到数据库连接串的时区坑每一步都有值得记录的细节。这篇文章把我从零搭建这套系统的完整思路、核心实现、以及线上部署时踩过的坑整理出来希望能给正在做同类管理系统的朋友一些参考。1. 技术选型背后的取舍为什么是这套组合而不是别的先说说选型逻辑。SpringBoot2到现在依然是国内中小型项目的主力版本稳定、资料多、生态成熟团队招人也容易上手。Vue3配合Element Plus做后台管理界面组合性足够好相比Vue2Vue3的Composition API在复用登录态校验、权限控制这些逻辑时确实干净很多。MyBatis-Plus解决的是单表CRUD的重复劳动配合代码生成器能在几分钟内把基础Mapper、Service、Controller全部铺好但复杂统计查询我还是写了原生SQL这个后面细说。MySQL8.0选它主要是因为窗口函数和更好的JSON支持在报表统计场景里比5.7顺手不少。这套组合的另一个优势是上手曲线平滑。我见过太多项目一上来就微服务、分布式、消息队列结果业务还没跑通光运维就把团队拖垮了。停车场管理系统典型的场景就是车位管理、车辆进出、计费、订单、用户权限数据量级在中小型车场也就是百万级单应用加主从库完全够用。项目带上文档也说明这更适合作为教学案例或二次开发基座不是那种追求极致性能的架构。提示如果你的读者是刚开始做毕业设计或者公司内部小项目这个选型可以直接照搬。如果目标是高并发互联网产品那这套结构需要再加缓存和消息队列但那是另一个话题了。技术栈确定后先理清楚系统的核心业务闭环。停车场的核心不是“管理”而是“计费”。所有功能都是围绕车辆从进场到出场的生命周期展开的这个理解直接决定了数据库表怎么设计、接口怎么划分。2. 停车场核心业务模型与表结构设计停车场的业务实体其实不多但实体之间的关系容易搞乱。我按“一次停车行为”为主线来建模车辆进场生成一条在场记录出场时关联计费规则计算金额生成订单订单支付后归档。这条主线上挂载的是车位、会员、用户这些辅助实体。2.1 关键表结构设计业务主表我设计了六张字段上刻意做了一些冗余设计这在实际查询时能省掉大量JOIN表名核心字段设计说明parking_lotid, name, total_spaces, available_spaces, address车位余量冗余在停车场表进出场时通过事务更新避免每次统计vehicle_recordid, plate_number, entry_time, exit_time, parking_lot_id, space_id, status, fee_amount车辆在场记录表status区分IN_PARKING/OUT_PARKING/PENDING_PAYMENT这是计费状态机的核心space_infoid, lot_id, space_code, status, vehicle_record_id每个物理车位一条记录绑定当前占用它的在场记录memberid, car_plate, member_type, start_time, end_time会员表月租车、年租车场景userid, username, password_hash, role, status后台用户RBAC权限模型基础fee_ruleid, rule_name, unit_price, free_minutes, max_daily_charge, rule_type计费规则不同类型车位、不同时段可用不同规则这里最想强调的是vehicle_record 表的 status 字段。很多初学SpringBoot的人会把“车是否在场”隐式地理解为“exit_time 是否为空”但实际上计费系统里存在“已出场但未支付”的状态出场时先冻结金额、用户支付后才算完成。如果只用 exit_time 判断支付状态就没法表达了。我在实际开发中把这个字段做成枚举并且所有计费逻辑都基于status流转避免状态判断散落在各个Service里。2.2 车位余量的一致性问题available_spaces 这个冗余字段是典型的空间换一致性。两种实现方式第一种是每次实时统计SELECT COUNT(*) FROM space_info WHERE status FREE。数据量大了以后这个查询会变慢而且在频繁进出场的车场统计延迟会导致界面车位余量显示不准确。第二种是维护一个冗余计数字段进场时减一出场时加一在同一个事务里完成。这套方案的优点是查询快、显示实时缺点是一旦数据不一致比如中途手动改了数据库余量就永远错了。我的妥协方案是高峰期用冗余字段展示每5分钟做一次定时任务用真实统计修正冗余值。代码里用Scheduled实现修正脚本就是个简单的UPDATE语句。3. 后端骨架统一返回体、异常处理与JWT认证后端工程结构我是按 DDD 的轻量思想分的没有严格分层controller、service、mapper、entity、common、config、security、aspect。对于中小项目过度抽象反而是负担但要保证层次清晰否则后期加需求会非常痛苦。3.1 统一返回体的设计前端不管是成功还是失败都期望收到同样结构的JSON否则Vue3里封装的请求拦截器就得处理各种乱七八糟的返回格式。Data public class RT { private Integer code; private String message; private T data; public static T RT ok(T data) { RT r new R(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T RT error(Integer code, String message) { RT r new R(); r.setCode(code); r.setMessage(message); return r; } }配合全局异常处理器业务上抛出ServiceException前端就能收到统一的错误结构。这里有个经验不要把后端异常栈直接抛给前端而是记录到日志文件里。我见过很多项目把SQL异常直接返回给浏览器这在生产环境等于把数据库结构脱裤展示非常危险。3.2 JWT认证与拦截器系统采用JWT做无状态认证用户登录成功后签发token前端存在localStorage里每次请求通过Axios拦截器放入请求头。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Claims claims Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); Long userId Long.valueOf(claims.get(userId).toString()); request.setAttribute(userId, userId); return true; } catch (Exception e) { // token非法或过期 } } response.setStatus(401); response.setContentType(application/json); response.getWriter().write(JSON.toJSONString(R.error(401, 未登录或登录已过期))); return false; } }JWT方案在前后端分离场景下的好处是服务端无状态水平扩展时不需要考虑Session同步。但有一个坑必须提醒JWT一旦签发在过期之前是没法主动失效的。用户改密码、管理员封禁账号原来的token还是有效。停车场系统的后台用户数量不大我用了简单的解决方案在用户表里加一个token_version字段JWT的claims中带上这个版本号校验时对比版本号不一致则拒绝。代价是多一次数据库查询但对这个量级的系统完全可以接受。3.3 MyBatis-Plus的正确打开方式MyBatis-Plus的BaseMapper提供了insert、deleteById、selectById、updateById等基础方法配合Wrapper可以完成大多数单表操作。但有些场景必须绕开它第一个是批量插入。BaseMapper的insert一次只能插一条循环调用会有严重的性能问题。我写了一个自定义SQL用foreach标签实现批量插入车辆进出流水单次插入万条级别的数据毫无压力。第二个是复杂统计。比如查询“每小时进出场车辆趋势”用MP的Wrapper怎么也写不清楚直接在Mapper接口方法上加Select注解写原生SQL配合MySQL8.0的窗口函数一个查询就能出结果。记住MyBatis-Plus解决80%的简单问题剩下20%需要你保留原生SQL的能力。第三个是分页。MP的分页插件用起来确实方便但必须正确配置PaginationInnerInterceptor。这里有个细节分页插件会对Page对象进行拦截处理如果你在Mapper方法里同时使用了自定义JOIN和Page参数要确保JOIN条件的WHERE没有歧义列名否则会报unknown column错误。我在车位管理列表页就是这么踩到的。4. 计费模块规则引擎、并发防重与对账补偿计费是整个业务最核心的部分也是最容易出bug的地方。我花了两周时间打磨这个模块总结下来就三个词规则灵活、并发安全、数据可对账。4.1 计费规则的状态机先把状态机图用文字描述清楚车辆进场创建记录PENDING_PAYMENT → IN_PARKING出场时先结算费用IN_PARKING → OUT_PARKING用户支付后订单关闭OUT_PARKING → COMPLETED。如果用户迟迟不支付系统定时任务将订单标记为超时OUT_PARKING → TIMEOUT并释放车位。状态流转全部封装在ParkingOrderService里对外暴露的方法只有public class ParkingOrderService { Transactional public Long entryVehicle(EntryRequest request) { } Transactional public ExitResponse exitVehicle(ExitRequest request) { } Transactional public PayResponse payOrder(PayRequest request) { } }三个方法对应三个状态迁移所有状态迁移必须通过这三个入口杜绝其他Service直接改status字段。这是防止状态机混乱最有效的设计约束。4.2 计费规则引擎计费规则的复杂性在于组合方式按时计费、按次计费、免费时段、封顶价格、会员折扣。我把规则拆成两个维度基础规则和叠加规则。基础规则决定单价和计费粒度叠加规则处理特殊情况。实际实现不算花哨但很有效public class FeeCalculator { public BigDecimal calculate(FeeRule rule, LocalDateTime entryTime, LocalDateTime exitTime, Member member) { long minutes Duration.between(entryTime, exitTime).toMinutes(); // 免费时长处理 minutes Math.max(0, minutes - rule.getFreeMinutes()); if (minutes 0) { return BigDecimal.ZERO; } // 按次计费 if (ONCE.equals(rule.getRuleType())) { return rule.getUnitPrice(); } // 按时计费不足一小时按一小时算 BigDecimal hours BigDecimal.valueOf((minutes 59) / 60); BigDecimal amount rule.getUnitPrice().multiply(hours); // 每日封顶 if (rule.getMaxDailyCharge() ! null amount.compareTo(rule.getMaxDailyCharge()) 0) { amount rule.getMaxDailyCharge(); } // 会员折扣 if (member ! null member.getDiscountRate() ! null) { amount amount.multiply(member.getDiscountRate()); } return amount.setScale(2, RoundingMode.HALF_UP); } }这里的边界条件很多跨天如何计算封顶车辆停留超过24小时怎么算我的做法是如果停车时长超过24小时按天为单位计算每天单独应用封顶规则不满一天的部分按小时计费。这样逻辑清晰用户也容易理解。测试用例要覆盖这些边界我用JUnit写了三十多个用例专门测计费确保改动计费规则时不会破坏已有逻辑。4.3 并发进场防重与数据库锁真正上线后第一个遇到的问题就是并发同一个车牌的车在道闸还没完全抬起时就重复进场请求。如果两个请求同时通过Service校验车位余量就会超卖车位。我在进场接口上加了分布式锁。考虑到项目没有引入Redis就用MySQL的悲观锁实现选取vehicle_record表上车牌和状态的唯一索引进行防重在事务内部用SELECT ... FOR UPDATE锁定记录再进行后续操作。这种方式在单库场景下比Redis分布式锁简单可靠两个请求同时进来时后一个会被锁阻塞等前一个事务提交后再判断车牌已经存在在场记录直接返回“车辆已在场内”。出场逻辑的并发问题相对少一些但支付回调时可能重复通知我在订单表上加了唯一订单号约束重复的支付请求会因为主键冲突而失败不会导致重复退款。4.4 对账补偿机制任何系统都不能保证100%不丢数据所以必须考虑对账。我设计了一个夜间定时任务每天凌晨3点扫描所有车辆在场记录与道闸硬件系统的进出日志做比对发现只有系统记录了进场但道闸没有抬杆记录的标记为异常记录通知管理员人工确认。同时统计当天所有订单的总金额与支付平台的对账单核对不一致的生成差异报表。这套对账机制上线后确实发现过几次数据异常而且基本都是道闸设备通信失败导致的。这也是单体架构的一个优势数据和逻辑都在一起排查相对容易。5. 前端Vue3工程化登录态管理、动态路由与实时刷新前端我用的Vue3 TypeScript Vite Element Plus Pinia。很多人问为什么不选WebpackVite在开发体验上确实好很多冷启动秒开改完代码热更新几乎没有等待。但要注意Vite默认只能兼容较新版本的浏览器如果有老旧浏览器访问需求构建配置需要额外处理。5.1 登录态持久化与会话恢复Pinia配合localStorage做token持久化页面刷新后从localStorage恢复登录态// stores/user.ts export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo) || {}), roles: JSON.parse(localStorage.getItem(roles) || []), }), getters: { isLoggedIn: (state) !!state.token, }, actions: { setLoginInfo(token: string, userInfo: any, roles: string[]) { this.token token this.userInfo userInfo this.roles roles localStorage.setItem(token, token) localStorage.setItem(userInfo, JSON.stringify(userInfo)) localStorage.setItem(roles, JSON.stringify(roles)) }, logout() { this.token this.userInfo {} this.roles [] localStorage.removeItem(token) localStorage.removeItem(userInfo) localStorage.removeItem(roles) router.push(/login) }, }, })Axios请求拦截器统一注入token响应拦截器处理错误码和401跳转。这里有个经验401跳转要放在响应拦截器里统一处理不要在具体的业务组件里重复写跳转逻辑不然以后token过期策略改变时你得改几十个文件。5.2 动态路由与菜单权限后台系统的菜单是根据用户角色动态生成的。我在Router里只配置基础路由login、404、layout菜单权限通过后端的/user/menus接口获取前端根据返回的菜单数据动态添加路由。// permission.ts const registerDynamicRoutes async () { const userStore useUserStore() const menuData await getMenus() // 后端返回菜单和按钮权限 const dynamicRoutes generateRoutes(menuData) dynamicRoutes.forEach(route { router.addRoute(route) }) return dynamicRoutes }菜单数据后端存储的字段包括path、component、meta前端拿到后用import.meta.glob动态加载组件这样每个路由组件打包时自动按需加载首屏体积能减少30%以上。5.3 实时刷新车位状态停车场管理页面需要一个实时更新的车位状态图。最简单的轮询方案是setInterval每5秒请求一次车位数据接口。但轮询有个问题车场上百个车位每次全量刷新数据量和渲染都是一个压力。我的优化方案是后端提供增量接口前端通过WebSocket订阅车位变化事件只有变化时推送差异数据。如果WebSocket连接失败降级为30秒一次的轮询。这种渐进增强的思路在实战中很实用。前端SSEServer-Sent Events也可以实现服务端推送和WebSocket相比SSE走HTTP协议自动重连机制更成熟实现更简单。我用SpringBoot的SseEmitter实现了车位余量推送代码比WebSocket简洁得多而且前端用EventSource就能接收到。6. MySQL8.0专项驱动版本、时区陷阱、Docker部署与分页调优MySQL8.0比5.7多了不少好东西但踩坑也更经典。我把整个过程中印象最深的几个点单独拿出来说。6.1 驱动和时区问题第一个坑就是驱动类名。MySQL8.0的驱动类从com.mysql.jdbc.Driver改成了com.mysql.cj.jdbc.Driver同样URL里的参数也变了spring.datasource.urljdbc:mysql://localhost:3306/parking_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue spring.datasource.usernameroot spring.datasource.passwordyourpassword spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver其中serverTimezoneAsia/Shanghai这行必须加。MySQL8.0默认时区是UTC不加的话Java的LocalDateTime存进去再查出来会差8个小时。我见过很多新手的停车记录入场时间莫名多了8小时都是这个时区设置造成的。allowPublicKeyRetrievaltrue是MySQL8.0另一个特性引发的坑8.0默认使用caching_sha2_password认证插件某些MySQL连接工具和Java驱动首次连接时需要通过RSA密钥交换获取公钥如果不开这个参数连接会报Public Key Retrieval is not allowed。虽然准确说这个参数是在驱动侧开的但实际排查时很多人找半天都定位不到。6.2 Docker快速部署MySQL8.0本地开发环境我推荐用Docker跑MySQL干净利落不污染本机。docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDroot123456 \ -e MYSQL_DATABASEparking_system \ -v /data/mysql8/conf:/etc/mysql/conf.d \ -v /data/mysql8/data:/var/lib/mysql \ -v /data/mysql8/logs:/logs \ mysql:8.0注意几个细节。-v /data/mysql8/conf:/etc/mysql/conf.d把配置目录挂出来方便改my.cnf。数据目录一定要挂载不然容器删了数据就没了。字符集最好在配置里强制指定utf8mb4[mysqld] character-set-serverutf8mb4 collation-serverutf8mb4_unicode_ci default-time-zone08:00MySQL8.0默认字符集已经是utf8mb4但显式配置更保险尤其是做中文项目。default-time-zone08:00也很关键确保数据库服务端的时间是中国时区这样SQL里的NOW()函数返回的就是北京时间。Docker方式的另一个优势是版本切换方便。想从8.0降到5.7测试兼容性一条命令的事情不用折腾卸载重装。6.3 分页查询与IN查询优化MyBatis-Plus的分页插件底层是通过拦截器改写SQL实现的它会在原SQL前面拼接SELECT COUNT(*)做总数统计但数据量大时这条count语句可能非常慢。一个常用的优化是给分页查询配上optimizeCountSql和专门的count查询。更大头的是IN查询性能。在停车记录查询页面用户经常按车牌筛选而车牌可能对应多条记录。如果IN后面的列表太长MySQL可能放弃索引走全表扫描。我在实际优化中把超过500个元素的IN查询改为JOIN临时表性能提升明显。SQL写成SELECT v.* FROM vehicle_record v INNER JOIN tmp_plates t ON v.plate_number t.plate_number WHERE v.status IN_PARKING ORDER BY v.entry_time DESC这种写法对MySQL8.0优化器来说更容易生成高效的执行计划。但注意临时表要加上索引否则造出一个笛卡尔积更慢。7. 从开发到上线编译打包、部署架构、环境变量与经典踩坑整理打包部署这块很多人写前台项目时不重视结果上线时各种问题。我按前后端分离的方案走用Nginx托管前端静态文件并反向代理后端API。7.1 前端构建与Nginx配置Vue3项目构建前先确认环境变量。我在项目根目录建了.env.productionVITE_API_BASE_URL/api这样打包后前端所有请求都走相对路径/api由Nginx统一转发到后端服务。好处是以后后端换端口甚至换IP前端代码不需要重新打包。构建命令npm run build产物在dist目录。Nginx配置server { listen 80; server_name parking.example.com; root /data/www/parking/dist; index 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # SSE 长连接配置 proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; } location / { try_files $uri $uri/ /index.html; } }两个关键点。第一try_files $uri $uri/ /index.html;是Vue Router的history模式必须配置的否则刷新页面时Nginx会去找物理路径然后直接404。第二proxy_buffering off;是SSE推送必须的如果不关缓冲后端推送的事件会攒一批才发到浏览器实时性变成虚假的。7.2 后端打包与启动脚本后端打包用Mavenmvn clean package -DskipTests生成jar包后我用一个启动脚本管理进程#!/bin/bash APP_NAMEparking-system JAR_NAMEparking-system.jar LOG_DIR/data/logs nohup java -jar \ -Xms512m -Xmx1024m \ -Dspring.profiles.activeprod \ -Dserver.port8080 \ /data/app/$JAR_NAME \ $LOG_DIR/$APP_NAME.log 21 生产环境我建议用-Dspring.profiles.activeprod区分环境配置数据库密码等敏感信息不要写在application.yml里用环境变量注入。我在CI/CD流程里设置了SPRING_DATASOURCE_PASSWORD环境变量配置文件中用占位符读取。7.3 配置加密和敏感信息保护数据库密码明文写在jar包的配置文件里等于把钥匙放在保险箱旁边。我用了Jasypt对密码进行加密配置里只存密文启动时通过密钥解密。虽然也不是绝对安全但至少比明文好一个档次。全文搜索“password”看不到真实密码了。注意Jasypt和SpringBoot2整合时需要手动引入jasypt-spring-boot-starter并在配置里指定加密算法和解密密钥。密钥通过启动参数-Djasypt.encryptor.passwordxxx传入不要写进配置文件。7.4 经典踩坑清单汇总最后把整个开发过程中踩过的坑整理成一个清单方便读者对照排查问题现象根因解决方案MySQL连接报Public Key Retrieval is not allowedMySQL8.0默认caching_sha2_password插件JDBC URL加allowPublicKeyRetrievaltrue数据库时间差8小时服务端时区默认UTCJDBC URL加serverTimezoneAsia/ShanghaiDocker启动加default-time-zone08:00Vue3刷新页面404history模式路由直接请求物理路径Nginx配置try_filesSSE数据堆积不推送Nginx默认开启缓冲配置proxy_buffering off并设置read_timeoutMyBatis-Plus分页count慢大表COUNT全表扫描优化count SQL及分页插件配置重复进场导致超卖并发请求未加锁SELECT FOR UPDATE悲观锁日期序列化格式不对没有配置Jackson全局格式配置spring.jackson.date-format和time-zoneElement Plus按需引入样式丢失自动导入插件配置不完整检查unplugin-vue-components及unplugin-auto-import插件最后一条是最让我印象深刻的。Element Plus按需引入时message和messageBox这类命令式组件的样式不会自动引入必须手动导入import element-plus/theme-chalk/el-message.css。有人嫌麻烦干脆全量引入了也无伤大雅。这套系统从开发到上线历时大概六周代码量不多但每个模块都经过实际业务的考验。如果你也在搭建类似的后台管理系统我建议先把计费规则和状态流梳理清楚再动手写代码业务模型比技术方案更容易让项目烂尾。我在设计计费模块时反复推翻重来了三轮就是因为一开始没想清楚跨天、超时、会员折扣这些边界条件。后续如果有机会我可能在这套系统上再加一个车位预约功能核心是锁位超时释放和信用分机制比现在的会员月租逻辑又要复杂度上一个台阶。到时候再记录一篇踩坑笔记分享出来。