☰
PHP停车场管理系统源码实战:数据模型、车牌识别与部署避坑
2026/10/2 18:38:35 网站建设 项目流程

简介:这份资源是一套基于PHP的停车场管理系统完整源码,面向Web开发初学者、PHP进阶学习者以及需要课程设计或毕业设计参考的开发者,帮助理解车辆入场、车位分配、计费计算与权限控制等业务逻辑的落地实现。压缩包共1857个文件,约20.44MB,以952个PHP文件为核心,辅以164个JavaScript、53个CSS、41个Vue及30个HTML文件构成前后端交互,另有81个xlsx、47个jpg、63个png等数据与静态素材,以及sql、config、bat等配置与部署脚本,目录结构完整。目前已有386人学习下载。源码同时涉及原生PHP与ThinkPHP5框架、Apache服务器、MySQL 5.7数据库及Navicat管理工具,读者可借此梳理MVC分层、数据库表设计与URL重写等关键环节,对照实际项目代码加深对Web应用架构的理解,适合作为动手实践与二次开发的参考底本。

1. 停车场管理系统为什么还在用 PHP 写:一套能跑通的源码该长什么样

地下车库入口那台道闸,车主按下取卡键到抬杆,中间只有 1.5 秒。这 1.5 秒里,系统要完成车牌识别、余位判断、费率匹配、开闸指令下发、入场流水落库五件事。很多中小停车场的老板不会为这套逻辑去买一套年费六位数的 SaaS,他们更愿意花几千块买一套 PHP 源码,扔在 2 核 4G 的云服务器上自己跑。这就是「基于 PHP 之停车场管理系统(源码)」这个标题背后真实的生存空间:它不是技术选型的胜利,而是成本、部署门槛和二次开发自由度三者妥协的结果。

PHP 在这个场景里有一个被低估的优势——从入口道闸的 HTTP 回调,到岗亭收费端的页面,再到后台的月卡管理,一套 LAMP 就能全包,不需要为物联网设备单独起一套 Java 微服务。源码交付意味着你可以直接改费率表、改月卡续费逻辑、改 LED 屏显示文案,而不用等厂商排期。这篇文章面向三类人:拿到一套 PHP 停车场源码但不知道怎么落地的人、想自己从零搭一套的人、以及接手了别人半成品需要排错的人。下面按「数据模型怎么设计 → 核心业务怎么实现 → 部署怎么跑起来 → 坑在哪」的顺序讲透。

2. 停车场管理系统的数据模型与核心表设计

一套停车场系统能不能扛住,八成看表设计。我见过太多源码把「车位」和「车辆」混在一张表里,结果月卡续费时把临时车的入场记录一起改了。下面这套表结构是我在几个实际项目里收敛出来的最小可用集合,字段名可以直接抄。

2.1 六张核心表与字段取舍

表名作用关键字段取舍说明
parking_lot停车场/区域id, name, total_spots, free_spotsfree_spots 冗余存储,避免每次 count
parking_spot具体车位id, lot_id, spot_no, statusstatus: 0空闲 1占用 2锁定
vehicle车辆档案id, plate_no, type, owner_phonetype: 1临时 2月卡 3免费
month_card月卡记录id, vehicle_id, start_at, end_at, status独立表,一车可有多张历史卡
entry_record入场流水id, plate_no, spot_id, entry_time, statusstatus: 0在场 1已出场
billing_rule费率规则id, lot_id, free_minutes, hourly_fee, daily_cap按 lot 维度配置

free_spots这个冗余字段是血泪经验。早期我用SELECT COUNT(*) FROM parking_spot WHERE status=0实时算余位,车流高峰期每秒几十次查询直接把 MySQL 打满。改成入场减一、出场加一,配合事务保证一致性,QPS 立刻降下来。代价是要处理「减到负数」的并发问题,后面避坑章节会讲。

month_card单独建表而不是在vehicle上加is_month_card字段,是因为月卡有生命周期。一辆车这个月是月卡、下个月不续了就是临时车,历史记录必须留痕,否则财务对账时说不清。

2.2 建表 SQL 与索引设计

CREATE TABLE `entry_record` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `plate_no` VARCHAR(16) NOT NULL COMMENT '车牌号', `spot_id` INT UNSIGNED DEFAULT NULL COMMENT '分配车位', `entry_time` DATETIME NOT NULL, `exit_time` DATETIME DEFAULT NULL, `fee` DECIMAL(10,2) DEFAULT 0.00 COMMENT '实收金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0在场 1已出场', PRIMARY KEY (`id`), KEY `idx_plate_status` (`plate_no`, `status`), KEY `idx_entry_time` (`entry_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

idx_plate_status这个联合索引是给「查这辆车当前是否在场」用的,出场结算时必查。idx_entry_time给后台按时间段导出流水用。注意plate_no用VARCHAR(16)而不是CHAR(7),因为新能源车牌 8 位,加上有些系统要存临时牌号,留余量。

fee用DECIMAL(10,2)而不是FLOAT,这是财务数据的铁律。浮点数算钱迟早出现0.1+0.2=0.30000000000000004的玄学问题,对账时能让你加班到凌晨。

2.3 余位并发更新的两种写法

余位更新是这套系统最容易翻车的地方。常见做法有两种:

// 写法一:悲观锁,适合车流不大的场景 $pdo->beginTransaction(); $row = $pdo->query("SELECT free_spots FROM parking_lot WHERE id=1 FOR UPDATE")->fetch(); if ($row['free_spots'] <= 0) { $pdo->rollBack(); throw new Exception('车位已满'); } $pdo->exec("UPDATE parking_lot SET free_spots = free_spots - 1 WHERE id=1"); $pdo->commit();
// 写法二:条件更新,靠 affected_rows 判断,适合高并发 $stmt = $pdo->prepare("UPDATE parking_lot SET free_spots = free_spots - 1 WHERE id=? AND free_spots > 0"); $stmt->execute([$lotId]); if ($stmt->rowCount() === 0) { throw new Exception('车位已满'); }

写法二把「判断」和「扣减」合并成一条原子 SQL,靠rowCount()是否为 0 来判断是否扣减成功,避免了锁等待。我一般用写法二,只有在需要同时更新多张表时才上事务加悲观锁。参数上要注意:free_spots字段设成UNSIGNED,即使逻辑出错也不会变成负数,数据库层面兜底。

3. 车牌识别对接与出入场业务逻辑实现

数据模型立住之后,真正决定用户体验的是出入场这条主链路。车牌识别相机(常见的是臻识、华夏智信这类)一般通过 HTTP POST 把识别结果推给你的接口,你的系统要在几百毫秒内返回开闸指令。

3.1 相机回调接口的接收与校验

// entry_callback.php 相机入场回调 $raw = file_get_contents('php://input'); $data = json_decode($raw, true); // 相机一般会带一个签名或固定 token,务必校验,否则任何人都能伪造开闸 $sign = $_SERVER['HTTP_X_SIGN'] ?? ''; if ($sign !== md5($raw . CAMERA_SECRET)) { http_response_code(403); exit(json_encode(['code' => 403, 'msg' => 'sign error'])); } $plate = strtoupper(trim($data['plate_no'] ?? '')); if (!preg_match('/^[\x{4e00}-\x{9fa5}][A-Z][A-Z0-9]{5,6}$/u', $plate)) { exit(json_encode(['code' => 400, 'msg' => 'invalid plate'])); }

这里有两个参数必须调:一是签名校验,很多源码为了省事直接不校验,等于把道闸的控制权公开在公网上,这是最严重的安全漏洞;二是车牌正则,[\x{4e00}-\x{9fa5}]匹配省份汉字,后面跟字母和数字,长度 5 到 6 位兼容新能源。正则写错会把「京A12345」这种正常车牌拦掉,车主在入口干等。

3.2 入场逻辑:判断月卡、分配车位、写流水

function handleEntry($pdo, $plate, $lotId) { // 1. 查是否在场,防止重复入场 $stmt = $pdo->prepare("SELECT id FROM entry_record WHERE plate_no=? AND status=0"); $stmt->execute([$plate]); if ($stmt->fetch()) { return ['code' => 200, 'open' => true, 'msg' => 'already inside']; } // 2. 判断月卡是否有效 $stmt = $pdo->prepare( "SELECT mc.id FROM month_card mc JOIN vehicle v ON v.id = mc.vehicle_id WHERE v.plate_no=? AND mc.status=1 AND mc.end_at > NOW()" ); $stmt->execute([$plate]); $isMonthCard = (bool)$stmt->fetch(); // 3. 扣减余位(条件更新) $stmt = $pdo->prepare("UPDATE parking_lot SET free_spots=free_spots-1 WHERE id=? AND free_spots>0"); $stmt->execute([$lotId]); if ($stmt->rowCount() === 0) { return ['code' => 200, 'open' => false, 'msg' => 'lot full']; } // 4. 写入场流水 $pdo->prepare("INSERT INTO entry_record(plate_no, entry_time, status) VALUES(?, NOW(), 0)") ->execute([$plate]); return ['code' => 200, 'open' => true, 'is_month_card' => $isMonthCard]; }

逻辑顺序不能乱:先查重复入场,再判月卡,再扣余位,最后写流水。如果先扣余位再查重复,一辆车被相机重复推送两次就会扣掉两个车位。is_month_card这个返回值要透传给岗亭端,月卡车出场不收费,直接放行。

3.3 出场计费:费率匹配与免费时长处理

function calcFee($pdo, $plate, $lotId) { $stmt = $pdo->prepare("SELECT * FROM entry_record WHERE plate_no=? AND status=0 ORDER BY id DESC LIMIT 1"); $stmt->execute([$plate]); $rec = $stmt->fetch(); if (!$rec) return null; $rule = $pdo->query("SELECT * FROM billing_rule WHERE lot_id=" . (int)$lotId)->fetch(); $minutes = floor((time() - strtotime($rec['entry_time'])) / 60); if ($minutes <= $rule['free_minutes']) { return ['fee' => 0, 'minutes' => $minutes, 'reason' => 'free']; } $billable = $minutes - $rule['free_minutes']; $fee = ceil($billable / 60) * $rule['hourly_fee']; // 封顶价 if ($rule['daily_cap'] > 0 && $fee > $rule['daily_cap']) { $fee = $rule['daily_cap']; } return ['fee' => $fee, 'minutes' => $minutes]; }

ceil($billable / 60)是「不足一小时按一小时」的常见计费方式,如果业务是「按半小时计费」,改成ceil($billable / 30) * ($hourly_fee / 2)。daily_cap是 24 小时封顶价,很多源码漏了这个字段,导致停三天的车算出天价,车主直接投诉。免费时长free_minutes一般设 15 或 30 分钟,这个值要能后台改,不能写死在代码里。

4. 从源码到上线:LAMP 环境部署与接口联调

拿到源码后,最常见的翻车不是代码逻辑,而是环境跑不起来。PHP 版本、扩展、时区、伪静态,任何一项不对都是白屏或 500。

4.1 环境版本与扩展清单

组件推荐版本必须扩展说明
PHP7.4 或 8.0pdo_mysql, mbstring, json, openssl8.1+ 部分老源码有兼容问题
MySQL5.7 或 8.0—8.0 注意默认字符集和认证插件
Nginx1.20+—或 Apache + mod_rewrite
Redis5.0+phpredis缓存余位和会话,可选但强烈建议

PHP 8.1 之后strtotime对某些格式更严格,老源码里strtotime($date)传空值会直接抛错。如果源码是几年前写的,先用 PHP 7.4 跑通,再逐步升级。

4.2 部署命令与伪静态配置

# 1. 拉代码、装依赖(如果有 composer) cd /var/www git clone <your-repo> parking cd parking && composer install --no-dev # 2. 导入数据库 mysql -uroot -p parking < install/parking.sql # 3. 改配置 cp config/database.example.php config/database.php # 编辑 host/user/pass/dbname # 4. 目录权限 chown -R www-data:www-data runtime uploads chmod -R 755 runtime uploads
# Nginx 伪静态,ThinkPHP/Laravel 风格 location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/run/php/php7.4-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; }

try_files这行是伪静态的核心,缺了它所有非文件请求都会 404。runtime和uploads目录必须可写,否则日志写不进去、车牌图片存不下来,表现为「能登录但一操作就报错」。

4.3 相机联调:用 curl 模拟回调

上线前不要等真车来测,直接用 curl 模拟相机推送:

curl -X POST http://your-domain/entry_callback.php \ -H "Content-Type: application/json" \ -H "X-Sign: $(echo -n '{"plate_no":"京A12345"}' | md5sum | cut -d' ' -f1)" \ -d '{"plate_no":"京A12345"}'

返回{"code":200,"open":true}说明链路通了。如果返回 403,检查签名算法是否和相机端一致;如果返回 500,看runtime/log里的错误堆栈,八成是数据库连不上或表不存在。联调阶段把display_errors打开,上线前务必关掉,否则报错信息会暴露数据库账号。

5. 停车场管理系统源码落地避坑清单

这一章是我踩过的坑,按「现象 → 原因 → 解决」写,每条都对应真实故障。

5.1 余位扣成负数,后台显示 -3

现象:高峰期后台余位显示负数,明明只有 100 个车位却显示 -3。 原因:入场扣减和出场回加没有放在同一套并发控制里,或者出场时重复回加。相机重复推送出场事件时,free_spots被加了两次。 解决:出场逻辑先查entry_record的status,只有从 0 改成 1 成功(rowCount()===1)才回加余位。同时把free_spots字段设成UNSIGNED,数据库层面拒绝负数写入,出错时至少能立刻发现。

5.2 月卡到期当天被收费,车主堵门

现象:月卡end_at是今天 23:59:59,但车主今天下午出场被收了费。 原因:判断月卡有效的 SQL 用了mc.end_at > NOW(),而end_at存的是当天 00:00:00,导致当天全天都算过期。 解决:月卡end_at统一存到期日的 23:59:59,或者判断条件改成mc.end_at >= CURDATE()。我一般用后者,存日期不存时间,逻辑更清晰。这个坑的教训是:所有涉及「当天」的时间比较,都要明确边界是 00:00 还是 23:59。

5.3 车牌识别回调超时,道闸不抬杆

现象:车压到地感,相机识别了,但道闸半天不抬,车主按喇叭。 原因:回调接口里做了耗时操作,比如同步调用第三方支付、同步写大文件日志、或者数据库慢查询。 解决:回调接口只做「校验 → 写流水 → 返回开闸」,其他操作全部异步。用 Redis 队列把「发短信通知」「更新统计」这类事丢出去,接口本身控制在 200ms 内返回。相机端一般有 3 秒超时,超过就不开闸。

5.4 后台导出流水时服务器卡死

现象:点「导出本月流水」,页面转圈,然后整个系统都变慢。 原因:一次性SELECT * FROM entry_record把几十万行读进内存,PHP 内存溢出或 MySQL 长时间占用连接。 解决:分批查,用LIMIT加游标,或者直接SELECT ... INTO OUTFILE让 MySQL 写文件。导出这种操作必须限流,同一时间只允许一个导出任务,用文件锁或 Redis 锁控制。

5.5 源码里的后门:未授权的开闸接口

现象:安全扫描发现一个open_gate.php可以直接 GET 请求开闸,不需要任何认证。 原因:很多免费或低价源码作者留了调试接口没删,或者故意留后门。 解决:上线前全局搜索open_gate、debug、test这类关键词,把所有非必要接口删掉或加认证。所有涉及硬件控制的接口,必须校验签名或登录态。这是最不能妥协的一条,道闸被随意控制不只是丢钱的问题。

6. 让这套源码多活三年的两个改造技巧

源码能跑起来只是起点,真正决定它能不能长期用的是可维护性。我接手过一套跑了五年的 PHP 停车场系统,作者早就不维护了,但它还在稳定出账,靠的就是两个改造。

第一个是把费率计算抽成独立函数并加单元测试。计费逻辑是这套系统里最容易被业务方要求改的部分——今天加个「夜间半价」,明天加个「会员日免费」。如果计费散落在出场、后台补录、月卡续费三个地方,改一处漏两处是必然的。我一般把它收敛成一个BillingService::calc($plate, $lotId, $exitTime),所有入口都调它,然后用 PHPUnit 写十几个边界用例:刚好免费时长、跨天、封顶、月卡过期当天。改费率前先跑测试,绿了再上线。

// tests/BillingServiceTest.php public function testFreeMinutesBoundary() { // 入场 10:00,出场 10:15,免费 15 分钟,应收费 0 $fee = BillingService::calc('京A12345', 1, strtotime('2024-01-01 10:15:00')); $this->assertEquals(0, $fee['fee']); } public function testCrossDayCap() { // 停 30 小时,封顶价 50,应收费 50 而不是 30*小时费 $fee = BillingService::calc('京A12345', 1, strtotime('2024-01-02 16:00:00')); $this->assertEquals(50, $fee['fee']); }

第二个是给关键操作加操作日志表。停车场系统涉及钱,出了纠纷要能查「谁在什么时候手动改了哪条记录」。我加一张op_log表,记录operator_id、action、target_table、target_id、before_json、after_json、created_at。后台的「手动放行」「修改月卡到期日」「调整费率」全部写日志。这张表平时没人看,一旦车主投诉「我明明是月卡为什么收费」,翻日志五分钟就能定位是月卡没续上还是系统判断错了。

这两个改造加起来不到两天工作量,但能让一套没人维护的源码多撑三年。我自己的习惯是:拿到任何一套二手源码,先不急着改业务,先把计费抽出来加测试、把操作日志加上,再动其他。这样后面无论怎么改,都有后悔药可吃。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询