
去年年底我接了一个小区停车场的活项目归档编号是154所以大家习惯叫它“154小区停车场管理系统”。这个小区不算大地下两层加地面总共700多个车位但每天进出车辆超过2000次。原来的管理方式是岗亭保安手工记车牌、发纸卡早晚高峰出入口能堵成一片月底对账更是要靠翻登记本。这次的目标很明确把停车场从“人肉登记”改成自动在线管理车牌识别、自动计费、月卡续费、车位统计全部线上化让物业少操心车主少等待财务对账也能清清爽爽。技术栈选的是行业里最常用的组合后端Spring Boot前端VueMySQL存业务数据Redis承担缓存和并发控制车牌识别摄像头负责触发进出场。整套系统从前端界面到后台服务、从道闸控制到收费报表都要自己能说得清、能改得动。适合谁看如果你正在做毕业设计、接私活或者物业公司里想自己搞一套车场管理系统这篇文章对你应该很有用。下面我把从需求梳理到上线跑稳定、中间踩过的坑和设计方案完整拆开讲一遍。1. 项目需求与整体业务流程拆解1.1 核心需求不是“停车”而是“进出场闭环”很多人一听停车场管理系统第一反应是“这不就是记录车辆进出吗”。真正对接物业之后才发现核心不是出个出入记录而是把每一次抬杆、每一笔收费、每一个车位变化变成一条完整可追溯、可对账的数据闭环。物业最怕的不是系统宕机而是“车出去了但系统没记录”“收了现金但报表里没有”“月卡车过期了还能抬杆”这类扯皮问题。154小区的实际需求拆出来大概是这几个维度业主固定车买了车位或租了月卡进出自动抬杆不需要重复缴费。临时车按小时计费出场时先缴费再放行支持微信、支付宝扫码支付。访客车物业登记或访客自行扫预登记码绑定临时车牌在限定时间内免费或按临时车收费。特殊车辆白名单消防、救护、工程抢修等车辆标记后自动放行事后补报备。车位管理总车位、剩余车位实时展示用于入口大屏和物业管理后台。财务对账现金、扫码、月卡充值等分类统计按天/按月导出。这些需求看起来多但落到数据模型上无非是三张主表加若干辅助表车辆档案表、进出场记录表、缴费订单表。后面我在数据库设计部分会详细展开。1.2 角色权限与核心流程系统涉及的角色很明确保安岗亭、物业管理、财务/主管、车主。不同角色看到的内容完全不同如果搞成一个笼统的管理后台后续权限会很难受。我们直接按角色划分菜单和按钮权限保安只管进出场监控、异常车辆处理、手工开闸、无牌车登记。运营人员管理车位、月卡、计费规则、访客预约、日常报表。财务/主管看经营统计、流水明细、导出报表不操作基础资料。超级管理员配置系统参数、账号权限、操作日志。核心业务流程就两条线进场一条、出场一条。进场流程摄像头抓拍→车牌识别→后端查车辆状态→固定车/月卡车直接抬杆→临时车记录入场→剩余车位减一。出场流程摄像头抓拍→识别车牌→查未出场记录→临时车计算费用→车主扫码支付/现金缴费→确认到账后抬杆→剩余车位加一。这条流程看似简单真正难在异常情况车到了道闸前识别错了、月卡刚好当天过期、临时车入场已超24小时、车主扫码付了款但道闸没抬、同一辆车短时间内重复触发……这些都是在设计阶段就要提前埋点处理的不能等到上线再补。1.3 计费规则与业务边界计费规则是停车场系统最容易“打架”的地方。154小区最开始给的规则是“首小时5元之后每小时2元15分钟内免费24小时封顶30元”。听起来简单但物业后来又补了几个条件跨天怎么算一天内多次进出是否累计免费时长是按自然天算还是按单次停车算无牌车入场怎么计费我们最后和物业确认的规则是免费15分钟只针对临时车单次进出超出后按完整停车时长计费。停车时长不足1小时按1小时计费也就是向上取整。跨天停车时按天切分每天独立计算叠加封顶。月卡车辆在有效期内不限次数过期后按临时车规则收费。同一车牌在同一天多次进出每次独立计算不跨次合并。计费规则一定要做配置化不要写死在代码里。因为物业过几个月可能会调整价格如果每次调整都要改代码重新发版运维成本太高。我们把规则表做成可以配置首小时价格、后续每小时价格、封顶金额、免费分钟后端在计算费用时读取规则表这样改价只需要在后台维护配置系统立即生效。2. 技术选型与架构设计2.1 后端为什么选Spring Boot小区停车场系统的并发量不算高高峰期也就是每分钟几十次进出场请求用Spring Boot完全够用而且生态成熟招人好招遇到问题网上一搜一堆方案。相比传统SSH那套Spring Boot把配置简化到了极致自动装配、内嵌Tomcat、统一的依赖管理开发效率高非常多。后端我用的是Java 8 Spring Boot 2.7 MyBatis-Plus MySQL 8 Redis JWT。MyBatis-Plus负责单表CRUD复杂统计和跨表查询就写原生SQL这样既不啰嗦统计报表也能控制住。JWT用来做前后端分离的登录鉴权配合Redis存Token比无状态JWT更可控管理员禁用某个账号时可以让Token立即失效。这也是一个容易踩坑的点如果只依赖JWT本身的无状态特性账号被禁用后旧Token还是能访问接口安全上是漏洞。所以登录成功后在Redis里存token值为用户ID每次请求拦截器校验Redis中是否存在。用户退出、改密码、被禁用直接把Redis里的token删掉就行。2.2 前端为什么用Vue前端选用Vue 3 Element Plus Pinia Vite。Vue的组件化开发很适合管理后台这种页面多、交互重复的场景一个表格组件、一个表单组件可以在多个页面复用。Element Plus的组件库很全表格、弹窗、表单校验、日期选择器都有现成的不用自己造轮子。项目是前后端分离前端通过Axios调用后端接口接口返回统一封装结构。这里要强调的是前端不只是把页面画出来进出场实时数据、大屏剩余车位、支付状态这些需要轮询或者WebSocket的交互一开始就要想清楚。154小区的实际场景是大屏显示剩余车位每5秒向后端拉一次数据支付结果则采用前端轮询订单状态每2秒查一次直到支付成功或超时。2.3 数据库表设计要点停车场系统数据量不算大但表设计一定要经得起推敲。我先后调整了三版最终留下这些核心表表名作用关键字段sys_user系统用户id, username, password, role_id, statusparking_vehicle车辆档案id, plate_no, owner_name, vehicle_type, space_id, statusmonthly_card月卡信息id, plate_no, card_no, start_time, end_time, statuspass_record进出场记录id, plate_no, entry_time, exit_time, entry_image, exit_image, duration, fee, statuscharge_rule计费规则id, first_hour_price, extra_hour_price, daily_cap, free_minutesparking_space车位信息id, space_no, area, type, statuspayment_order缴费订单id, record_id, plate_no, amount, pay_type, pay_time, statusoperation_log操作日志id, user_id, action, detail, create_time几个设计上的细节说一下。车牌号不要直接当主键。同一辆车可能会换车主、换手机号如果车牌当主键改信息会非常麻烦。车辆档案表单独用自增id车牌号加唯一索引。但如果月卡表里也用plate_no关联要注意如果车辆换牌需要同步更新多张表建议所有业务子表都保留vehicle_id而不是plate_no这样换牌只改档案表。金额字段用DECIMAL(10,2)不要用Float或者Double。停车费虽然单价不高但累计一个月下来浮点误差会让财务对不上账这是非常低级但常见的错误。进出场记录表一定要有status字段值域是进场中、已出场、已支付、异常离场。不要只靠exit_time是否为空来判断因为无牌车、手工离场这些情况会让逻辑变得很别扭。所有时间字段统一用DATETIME并明确按服务器时区存。前后端传的时间统一转成时间戳或者指定格式的字符串避免时区偏移导致计费错误。预留parking_lot_id字段也是个好习惯虽然154小区只有一个车场但以后系统要复制到其他小区有这个字段就不用大改表结构。3. 核心功能实现细节3.1 车辆进场流程与数据闭环进场接口是整个系统的入口设计上要求幂等同一辆车同一张抓拍图不能重复抬杆。摄像头厂商的回调机制各不相同有的会重试推送有的前后端会同时收到消息如果不做防重就会出现一辆车刚入场摄像机又补推一次入场消息道闸抬起又落下非常危险。我的做法是用Redis的setNx做一个短时间锁锁的key由“入场通知”和车牌号组成比如entry:{plateNo}:{callbackId}设置5秒过期。如果setNx返回false说明这个消息已经处理过直接返回当前状态不再执行开闸逻辑。进场逻辑的核心代码如下Transactional(rollbackFor Exception.class) public EntryResult handleEntry(PlateCaptureRequest request) { String plateNo request.getPlateNo(); String lockKey entry:lock: plateNo : request.getCallbackId(); boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (!locked) { // 说明是重复推送返回已处理的结果 return EntryResult.repeat(); } // 先查车辆档案判断是月卡车、固定车还是临时车 ParkingVehicle vehicle vehicleMapper.selectByPlateNo(plateNo); String vehicleType vehicle null ? temp : vehicle.getVehicleType(); // 月卡或固定车且在有效期内自动放行 if (vehicle ! null isCardValid(vehicle)) { createPassRecord(plateNo, entry, vehicleType); return EntryResult.openGate(); } // 临时车记录入场不直接收费 createPassRecord(plateNo, entry, temp); return EntryResult.openGate(); }这里要注意一个场景临时车入场后没有出场记录再过几天又进场一次系统不能直接报“已有入场记录”。如果车上次没出场就再次进场实际是上一次异常离场了应该自动关闭上一条未出场记录再创建新的入场记录。这个逻辑在进场接口里要兜底处理否则异常记录会越积越多。3.2 出场计费与并发防重出场接口比进场麻烦因为涉及到费用计算。摄像头识别车牌后先查该车最近一条status为“进场中”的记录然后调费用计算服务。如果费用为0比如在免费时间内直接开闸如果费用大于0生成缴费订单等支付回调成功后再开闸。临时车出场计费的代码逻辑是这样的public BigDecimal calcFee(PassRecord record, ChargeRule rule) { if (record.getExitTime() null) { return BigDecimal.ZERO; } long duration record.getExitTime().getTime() - record.getEntryTime().getTime(); long freeMin rule.getFreeMinutes() * 60 * 1000L; if (duration freeMin) { return BigDecimal.ZERO; } long billable duration - freeMin; long firstHourMs 3600 * 1000L; if (billable firstHourMs) { return rule.getFirstHourPrice(); } long extraMs billable - firstHourMs; long extraHours (extraMs 3600 * 1000L - 1) / (3600 * 1000L); BigDecimal fee rule.getFirstHourPrice() .add(rule.getExtraHourPrice().multiply(BigDecimal.valueOf(extraHours))); // 24小时封顶判断 if (duration 24 * 3600 * 1000L fee.compareTo(rule.getDailyCap()) 0) { return rule.getDailyCap(); } return fee; }这里有个“并发防重”的关键细节车主扫码支付成功支付回调到了同时摄像头又推了一条出场消息两个线程同时处理同一条出场记录如果都判断“可以开闸”就会重复放行两次。解决办法是出场记录加一个状态更新的乐观锁int updated passRecordMapper.updateStatus( record.getId(), paid, processing, record.getVersion()); if (updated 0) { // 说明已经有人处理过直接返回开闸不要再次开闸 return EntryResult.alreadyProcessed(); }updateStatus的SQL是“update pass_record set status ? where id ? and status ?”这一步在整个出手流程中相当于占坑谁先占住谁就负责开闸后面重复的请求直接返回闸机已打开状态。3.3 月卡、固定车和特殊车辆处理月卡设计看似简单但很多项目会在有效期的判断上写错。月卡有效期是到2024-03-15 23:59:59还是到2024-03-15 00:00:00如果不统一车主会在到期当天早上出门时发现抬不了杆。我们的规则是到期日当天23:59:59之前有效到期后零点整开始按临时车计费。用SQL比较时间时一定要用“end_time now()”不要用“end_time now()”因为“到当天有效”意味着end_time晚于当天任意时刻。月卡续费也要做并发控制。续费操作本质是延长end_time但两个并发请求同时续费一个延30天一个延15天后提交的可能会覆盖先提交的把有效期改短。处理办法是把当前有效期作为更新条件在SQL里做条件更新update monthly_card set end_time date_add(end_time, interval #{days} day) where id #{cardId} and end_time #{expectEndTime}如果更新行数为0说明期间有效期有变化提示用户刷新后再操作。这个方案虽然朴素但非常可靠不依赖数据库行锁也能避免覆盖。固定车辆和月卡的区别在于固定车是绑定具体车位的业主车入场后该车位显示占用月卡是不绑定车位的普通车辆进场时占用的是公共剩余车位。两者在进场接口的判断逻辑其实是分开的不要混在一起。特殊车辆白名单单独建一张表status字段设成永久有效或临时有效消防车、救护车这类车辆进场时直接放行并记录抓拍图出场时如果有超时停留提醒保安人工确认。3.4 车位管理与剩余车位统计154小区的地下车位有一部分是业主固定车位一部分是临时公共车位。临时公共车位的剩余数量需要实时展示在入口大屏并且物业后台也要能看到。我们最初的做法是每次进出场后去count数据库剩余车位后来发现高峰期几百个请求挤在一起查count数据库压力很大于是改成用Redis维护剩余车位计数。入场时剩余车位减一出场时加一。但Redis计数最大的问题是和数据库可能不一致比如系统重启、消息重复处理都会导致Redis里的数字漂移。这里有两个措施定时任务每5分钟从数据库重新统计一次剩余车位回写Redis保证计数最终一致。除了简单的计数还要在数据库表里记录当前占用车位明细。如果只靠Redis计数物业问“哪些车位被占了”根本答不出来。固定车位的车主有专属车位号入场时直接标记该车位状态为占用临时车入场不指定具体车位只在公共剩余车位计数上减一。出场时同理固定车位释放车位号临时车只增加公共剩余车位。这类逻辑单独抽一个服务类去管理不要散落在各个接口里后面调整会很痛苦。4. 实测中容易踩的坑与排查技巧4.1 跨天费用计算不能直接累计时长停车计费有个很经典的问题车辆入场23:50出场次日00:30停车时长40分钟按规则15分钟免费应收首小时5元这没问题。但如果这辆车停了20小时从第一天10点到第二天6点按照我们和物业确认的规则跨天每天独立计算那么需要把停车时长切分成两段每段分别判断是否达到封顶最后叠加。如果直接用总时长算费用会出现一种Bug停车26小时理论上应该是第一天封顶30元 第二天2小时5元 35元但如果按总时长算可能被24小时封顶规则拦成30元物业就亏了。所以跨天计费一定要按天切分不能只看总时长。4.2 摄像头回调重复与漏传系统要做兜底我用的是某品牌的车牌识别摄像头这品牌带SDK和HTTP回调但在实际使用中你会发现它的回调不一定可靠。高峰期会重复推送偶发漏传。重复推送可以用幂等锁解决漏传就比较头疼因为没有入场记录车到出口时系统查不到入场信息如果有防逃费逻辑车就被卡死了。我们后来做了一套兜底方案摄像头识别到车牌后不管有没有入场记录先保存一张“车辆识别流水表”这个表记录了相机编号、识别时间、车牌、抓拍图URL。秒级任务每30秒扫描出入口附近的识别流水如果某车出场时没有入场记录系统自动创建一条补录入场记录并标记为“系统补录”这样至少不会让车主卡在出口。反过来也要处理“有入场无出场”的流水每天夜里跑对账任务找出停留超过24小时仍未出场的记录推给安保人员人工确认。是车还停在场内还是上次出场时摄像头没拍到确认后手工闭合记录。4.3 支付结果与开闸的时序问题我遇到最惊险的一次是车主扫码支付成功后支付回调因为网络延迟迟迟没到系统没收到回调就不敢开闸车主已经在出口狂按喇叭了。后来我在缴费订单上增加了“主动查询支付平台订单状态”的功能前端轮询超过5秒没有结果时后端主动调用支付平台接口查询订单状态以此替代被动回调。另外一定不要根据“支付页面展示成功”就直接放行因为支付页面跳到成功页不代表支付平台已经把结果推给业务系统。必须等业务系统收到明确的支付成功通知或者主动查询到平台侧状态为已支付才能发开闸命令。这个顺序颠倒就会出现“车主说付了钱但系统说没收到”的扯皮而且很难查清楚。4.4 无牌车、识别错误车的处理新能源绿牌、旧式黄牌、无牌车、车牌被泥糊住这些情况在真实场景里很常见。无牌车进入停车场时摄像头识别不出来需要保安在岗亭客户端上手工操作选择“无牌车入场”系统生成一个二维码入场凭证司机扫码后绑定临时身份。出场时司机扫同一个二维码系统按入场时间计费保安核对后放行。识别错误的情况更麻烦比如把“京A12345”识别成“京A12346”入场记录就绑错了车。按我们的经验解决方案是“置信度阈值人工复核”识别置信度低于90%的不在后台自动开闸而是推送到保安终端由保安确认后点击开闸。人工确认的几秒钟比识别错误后来回扯皮省事得多。5. 部署上线与运维心法5.1 服务器规划与部署方式这个系统的部署规模不需要太大也没必要一上来就搞K8s。154小区项目最终用了两台云服务器加一台本地工控机节点配置运行内容应用服务器4核8GSpring Boot后端、前端Nginx、图片存储目录数据库服务器4核8GMySQL 8、Redis、定时备份任务本地工控机双核4G车牌识别相机SDK、对接应用服务器的回调服务后端用Maven打jar包配合systemd守护进程启动。前端用Vue打包成静态文件放到Nginx的html目录配置反向代理把/api开头的请求转发到后端8080端口。这样部署最大的优点是升级时前端、后端可以独立发布互不影响。关键配置片段server { listen 80; server_name parking.example.com; root /var/www/parking-front; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 60s; } location /images/ { alias /data/parking-images/; expires 7d; } }5.2 数据备份与对账停车场数据库不能丢一旦丢了当月收费记录、车辆进出记录全部消失财务根本没法做账。MySQL每天凌晨3点自动全量备份备份文件保留30天同时每天把备份文件同步到另一个存储空间。图片抓拍图也重要这些是纠纷时的凭证通过脚本定期压缩打包归档。系统上线后财务每周都要用一份固定的对账SQL核对某天所有支付订单金额总和、某天所有出场记录费用总和、现金缴费金额和手工开闸记录是否匹配。我们开发阶段就准备了几个固定的统计报表页面避免财务每次都要找开发写SQL。对账报表的核心SQL思路是统计某段时间内所有出场记录的费用合计再按支付方式分组和支付平台账面金额对比。如果差异超过一定金额自动发告警给负责人。5.3 稳定性监控一套管理系统最怕的就是半夜宕机没人发现第二天早高峰门口堵死。我们接了一个简单的定时探活任务每5分钟访问后端健康检查接口连续3次失败就发短信通知运维告警内容包括进程状态、最近日志、数据库连接数。这个探活任务本身很小但价值很大比什么监控平台都好使。日志方面后端统一使用logback按天滚动输出保留30天。关键操作如开闸、支付、手动补录都会打一条包含车牌号和操作人的日志。排查问题时先把日志按车牌过滤出来往往问题就一目了然。5.4 上线切换与人员培训系统上线前最复杂的一步不是写代码而是切换方案。154小区原来的岗亭是手工登记第一次上线时我们选择“双轨运行三天”新车入场走系统旧登记本同时记录三天后核对两边数据差异确认系统没问题再彻底停用纸本。这样虽然增加了工作量但给物业留了缓冲时间问题暴露得更早。保安岗亭的培训也很有讲究。不要给保安讲什么是前端、什么是后端就告诉他们几个场景怎么操作正常车进出自动放行、临时车到场扫码付费、识别错了怎么手动改、无牌车怎么录入、闸机卡住了怎么处理。培训材料做成一张A4纸的速查表贴在岗亭里比什么使用手册都管用。最后分享两个实战心得第一个心得把异常流程当成主流程来设计。一个停车场系统正常进出场逻辑只占开发量的三分之一剩下三分之二全在跟异常情况较劲。重复回调、支付超时、跨天计费、无牌车、识别错误、月卡到期、设备断网把所有你能想到的异常都列出来每个都要有兜底方案这个系统才敢上线。第二个心得别把规则写死在代码里。计费价格、免费时长、封顶金额、特殊车辆白名单这些都会随着物业运营策略变化而调整。你这次写死下个月物业调价你就得半夜爬起来改代码发版改多了不出问题才怪。一定要做成后台可配置的参数改规则不重启、不发布这才是真正的省心。154小区这套系统上线到现在跑了快一年高峰时段进出口再没堵过财务月底对账从两三天缩短到半小时物业那边最认可的就是“每一笔抬杆都有记录”。停车管理系统不是什么高深技术但把业务细节抠到位、把异常兜住底就是一个能让用户真正放心的系统。