基于ThinkPHP与Laravel的小区停车场收费计费系统开发实践
2026/9/9 2:36:56 网站建设 项目流程

这套系统不是拍脑袋想出来的。当时接了一个小区停车场的收费改造需求,小区1800多户,地下车位600多个,地面还有不少临时停车位,平时全靠门岗拿本子记时间、手算费用,月底对账的时候经常对不上,业主因为多收几块钱的事没少扯皮。后来物业下定决心要上一套收费管理系统,我负责主导开发。整个项目从需求梳理、技术选型到最终落地,前后差不多用了三个月,生产环境跑的是ThinkPHP 6.0.12 LTS版本,同时用Laravel做了一套功能对齐的对照实现,用于评估两套框架在真实业务场景下的差异。这套小区停车场收费车辆计费管理系统,核心解决的就是车辆进出登记、分段计费、月租车管理、线上支付和财务对账这几件事。如果你正打算用PHP做类似的垂直业务系统,或者在做ThinkPHP和Laravel的选型对比,这篇文章应该能给你省不少时间。

我把整个项目从需求到实现拆开来讲,包括数据模型怎么设计、计费算法怎么写、并发问题怎么解、支付回调怎么保证不丢单,还有一些常规文档里不会写的踩坑记录,全部摊开说清楚。

1. 项目整体设计与需求拆解

1.1 项目背景与核心痛点

停车收费系统表面看是个小业务,真做起来才发现水很深。最初的需求描述很简单:车辆进场记个时间,出场的时候算一下收了多少钱,月租车自动识别放行。但一落到现场调研,细节问题全冒出来了。

比如计费规则,物业的收费标准不是简单的一小时五块钱,而是"前30分钟免费,超过30分钟按小时计费,每小时5元,24小时内封顶30元,白天和夜间还可以有不同标准"。月租车又分地下固定车位和地面普通月租,收费标准不一样。还有内部员工车辆、军警车辆、残疾人车辆免费通行的情况。这些规则混在一起,如果没有一个灵活的规则引擎,后期每调一次价都要改代码,那就麻烦了。

业务角色这边也比我预想的多。物业经理要能看经营日报,财务要能核账单、导出报表,门岗值班员只需要简单的进出场操作和现金收费,业主希望能在线缴月租费和临时停车费。不同角色对系统的权限需求完全不同,所以系统从一开始就按RBAC权限模型设计,没有用简单的单用户后台。

1.2 系统功能模块与业务边界

整个小区停车场收费车辆计费管理系统最终划分成六大模块:车辆档案管理、计费规则管理、出入场管理、收费结算、财务对账和系统管理。

  • 车辆档案管理:登记业主车辆、车牌号、车型、月租有效期、联系电话,支持车辆与车位绑定。
  • 计费规则管理:维护临停车、月租车、免费车等不同车辆类型的计费策略,支持按小时段、跨天、封顶金额设置。
  • 出入场管理:对接车牌识别道闸,记录每次进出场流水,无牌车走人工扫码录入。
  • 收费结算:支持出场时扫码支付、现金支付,也支持提前预缴后限时离场。
  • 财务对账:每天自动汇总应收、实收、优惠金额,与支付渠道账单核对。
  • 系统管理:管理员、操作员账号管理,角色权限分配,操作日志。

边界方面,硬件识别这块我们只做对接不做开发。道闸控制器和车牌识别摄像头由硬件供应商配套出货,他们提供HTTP接口和开关闸信号,我们的系统负责接收识别结果、判断是否放行、记录计费数据。这套边界非常重要,一开始就划清楚,后面和硬件厂商联调时才没扯皮。

2. 框架选型:ThinkPHP与Laravel双轨实现

2.1 为什么生产环境选择ThinkPHP 6.0.12 LTS

项目标题里同时出现了ThinkPHP和Laravel,这其实不是一个二选一的问题,而是我刻意采用的双轨策略。生产环境最终跑的是ThinkPHP 6.0.12 LTS版本,这不是拍脑袋定的,而是对比了项目实际约束后的结果。

物业方的服务器配置很一般,一台4核8G的云服务器,还要同时跑MySQL、Redis和Nginx,系统预算也不希望为了一个停车系统上太高的运维成本。ThinkPHP 6的部署简单到近乎朴素,PHP 8.1 + Nginx + PHP-FPM跑起来就行了,不用额外学习太多容器编排、任务调度的东西,出个小问题我远程检查也很快。另一个因素是ThinkPHP 6.0.12是LTS版本,意味着长期维护和安全补丁有保障,对这类"上线后不折腾"的项目来说很重要。它的MVC结构清晰,文档全中文,后面物业方如果自己维护或者找本地外包公司接手,学习成本会低很多。

当然Laravel也不是不行,我在对照项目里用的是Laravel 10,它的生态和工程化水平确实强不少,尤其是Queue队列、事件系统、Eloquent的模型事件、以及GraphQL扩展支持。但同样的功能,Laravel的服务容器、中间件、配置层级这些概念,对不熟悉现代PHP的维护者来说有理解门槛。加上生产服务器资源紧张,Laravel框架本身加载的组件更多,初期优化不到位的话,响应速度反而可能比ThinkPHP更慢。

2.2 双框架切换的关键工程策略

既然两套都要写,最怕的就是业务逻辑写两遍还写岔了。我在设计公共业务层时定了一条铁律:所有计费、价格计算、状态流转这类核心业务逻辑全部写在服务层,绝对不写在控制器、模型或者模板里。这样同一个计费引擎,在ThinkPHP里调,和Laravel里调,得到的计算结果完全一致,只不过面向的接口框架不同。

工程结构上,ThinkPHP端的应用目录大概是这样的:

app/ ├── controller/ │ ├── admin/ // 后台管理控制器 │ └── api/ // 小程序/道闸接口控制器 ├── model/ // 数据模型层 ├── service/ // 业务服务层(计费、订单、支付等) ├── validate/ // 参数校验 └── common.php

Laravel端保持同样的业务划分,把服务类放到app/Services,模型放app/Models,控制器只做参数接收、校验和调用服务,不写任何业务SQL。这样切换框架时,核心服务的代码可以直接搬运,最多改一下命名空间和数据库连接配置。实际开发过程中,我大概在ThinkPHP上把核心逻辑全部跑通后,再在Laravel项目里复用服务层代码,调试工作量小了很多。

如果你也在做类似的双框架评估,我的建议是别一开始就把两边摊子铺太大。先在一边把核心业务跑通,另一边只做功能对标的接口层和少量页面,重点对比开发效率和运行表现就够了。

3. 核心数据模型与数据库设计

3.1 车辆档案与业主关系建模

停车系统的基础是车辆档案,这决定了后续所有计费和通行逻辑。我的车辆档案表设计思路是:一辆车对应一个车位,但车位与业主房号可以解耦,因为有些家庭有两辆车,有些车位是租赁的。

CREATE TABLE `car_members` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', `community_id` int(11) NOT NULL DEFAULT 0 COMMENT '小区ID', `owner_name` varchar(50) NOT NULL DEFAULT '' COMMENT '业主姓名', `phone` varchar(20) NOT NULL DEFAULT '' COMMENT '联系电话', `plate_no` varchar(20) NOT NULL DEFAULT '' COMMENT '车牌号', `plate_color` tinyint(4) NOT NULL DEFAULT 0 COMMENT '车牌颜色 0蓝 1绿 2黄', `car_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '车辆类型 1临时 2月租 3免费 4内部员工', `parking_no` varchar(20) NOT NULL DEFAULT '' COMMENT '绑定车位号', `valid_start` datetime DEFAULT NULL COMMENT '月租生效时间', `valid_end` datetime DEFAULT NULL COMMENT '月租到期时间', `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态 1启用 0禁用', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_plate_no` (`plate_no`, `community_id`), KEY `idx_status_cartype` (`status`, `car_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆档案表';

车牌号这里有个细节必须注意:要统一大写存储,新能源车牌是8位,传统蓝牌是7位,校验表达式需要区别对待。比如绿牌最后一位是字符,蓝牌最后一位有可能是数字也有可能是字符,不能简单用纯数字校验。我用的正则大致是^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z][A-HJ-NP-Z0-9]{5,6}$,这里最后一位字符长度不强制固定,兼容新能源车牌。

还有一个容易忽略的点:plate_no不能单独做唯一索引,必须和community_id组合起来唯一。因为我们现在做的是一个小区,但系统架构上要支持后续扩展到物业集团下多个小区,同样一个车牌号在不同小区可能登记为不同车辆类型。

3.2 计费规则引擎表结构设计

计费规则是整个系统最灵活、也最容易翻车的部分。物业的收费规则不是一成不变的,今天搞个活动免一小时,明天涨个价,如果规则写死在代码里,那以后每次改价都需要重新发版。所以我把计费规则抽成了一张独立的规则表,配合JSON字段存扩展规则。

CREATE TABLE `charging_rules` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `rule_name` varchar(100) NOT NULL DEFAULT '' COMMENT '规则名称', `car_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '适用车辆类型', `free_minutes` int(11) NOT NULL DEFAULT 0 COMMENT '免费分钟数', `first_hours` int(11) NOT NULL DEFAULT 0 COMMENT '首个小时数/计费单位', `first_fee` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '首个计费单位费用', `step_hours` int(11) NOT NULL DEFAULT 0 COMMENT '后续每步小时数', `step_fee` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '后续每步费用', `daily_cap` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '24小时内封顶金额', `max_fee` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '单次停车封顶金额', `time_period` json DEFAULT NULL COMMENT '时段规则: [{"start":"08:00","end":"22:00","fee":"2"},{"start":"22:00","end":"08:00","fee":"1"}]', `is_enable` tinyint(1) NOT NULL DEFAULT 1, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计费规则表';

我在这里做的关键决策是:把"基础规则"用固定字段存,把"复杂时段规则"用JSON存。为什么这么设计?因为时间段规则这种灵活配置,如果也用字段来搞,设计成时间段子表,每次计费都要join子表,性能差而且代码啰嗦。JSON虽然不能直接索引,但计费过程是在代码里算的,查询次数并不多,反范式带来的性能收益是实打实的。

计费算法读取规则的顺序是:确定车辆类型,查该类型当前启用的规则;规则里有time_period就走时段精细化计费,没有就走基础规则统一计费。这样平时用基础规则就能覆盖大部分场景,遇到医院、商场那种白天贵夜间便宜的停车场,也能通过时段规则快速适配。

3.3 停车流水与收费记录设计

停车流水是整个计费系统的核心事实数据。一次停车从入场到出场,包含多条状态变化,我用两张表来存:car_records存进出场记录,charge_records存费用结算记录。

CREATE TABLE `car_records` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `record_no` varchar(32) NOT NULL COMMENT '停车流水号 唯一', `plate_no` varchar(20) NOT NULL DEFAULT '' COMMENT '车牌号', `car_type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '车辆类型快照', `gate_in` varchar(30) NOT NULL DEFAULT '' COMMENT '入场道闸编号', `gate_out` varchar(30) NOT NULL DEFAULT '' COMMENT '出场道闸编号', `in_time` datetime NOT NULL COMMENT '入场时间', `out_time` datetime DEFAULT NULL COMMENT '出场时间', `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1在场 2已离场 3异常离场', `image_in` varchar(255) NOT NULL DEFAULT '' COMMENT '入场抓拍图片', `image_out` varchar(255) NOT NULL DEFAULT '' COMMENT '出场抓拍图片', `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', PRIMARY KEY (`id`), UNIQUE KEY `uk_record_no` (`record_no`), KEY `idx_plate_in_time` (`plate_no`, `in_time`), KEY `idx_status_in_time` (`status`, `in_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='停车记录表';

record_no我直接用业务生成的唯一字符串,格式类似2024010110305912345,由日期时间加随机数组成,生成后全局唯一。为什么要单独设计这个字段而不是依赖自增ID?因为出场结算、支付回调、人工查询这些场景都需要一个业务上可辨识的编号,自增ID不具备业务含义,出现问题时和业主沟通也说不清。而status字段要加索引,因为后台列表默认查"在场"车辆,这个查询非常高频。

收费记录表单独拆出来而不是合并到停车记录里,是因为一个停车记录可能存在"先预缴一部分,出场时再补差额"或者"多次补缴"的场景,一单对多笔流水才符合财务模型。charge_records表里存支付渠道、支付单号、应收金额、优惠金额、实收金额、操作人等信息,每笔都有唯一流水号,方便对接微信支付宝流水。

3.4 索引设计与历史数据归档策略

停车记录表是典型的高增长表,小区规模中等的话,一天进出场记录大概在1500条左右,一年50多万条,三年后就是150万条,如果不做归档,查询会越来越慢。我在设计阶段就留了两个策略:按月分表和定时归档。

分表这里没有用应用层的复杂分表中间件,而是直接按月份手工建表car_records_202401这种形式,一年12张表,跨月查询用视图层统一。实际做下来,按月的表数量可控,运维也简单,如果物业要求查半年内某辆车的记录,直接定位到对应月份的表查询即可。超出一年以上的历史数据,通过计划任务自动导出备份到冷存储,同时把超过两年的流水删除,因为这类垂直管理系统,业主和物业很少会去翻两年前的停车记录,真正的财务审计需求基本在一年以内。

索引方面,我只保留了最必要的那几个:uk_plate_noidx_plate_in_timeidx_status_in_time。不要贪多,每多一个索引就拖慢一次写入,停车流水的写入频率非常高,尤其在早晚高峰期,索引过多会明显影响入库速度。

4. 计费核心逻辑与高并发方案落地

4.1 入出场流程与道闸联动设计

整个进出场流程,我从实际业务角度整理成下面的状态流转逻辑,虽然文字描述不如流程图直观,但每一步对应的代码逻辑是这样的:

  • 车辆入场:车牌识别摄像头识别到车牌,向系统发送HTTP请求,携带车牌号、道闸编号、抓拍图片。系统先查询车辆档案,判断车辆类型和状态,再看是否在黑白名单中,然后返回"放行"或"不放行"以及对应的放行原因,道闸收到放行指令后抬杆。
  • 无牌车入场:摄像头识别不到车牌,识别结果为空,道闸不自动抬杆,门岗在系统后台手动录入一个临时编号或者拍下车前脸照片后手动开闸,系统生成一条以"临时编号"为车牌号的停车记录。
  • 车辆出场:车牌识别后,系统查询在场记录,如果该车是月租车且月租在有效期内,直接放行;如果是临停车,查询计费规则算出费用,弹窗显示在门岗收费终端或者自助缴费机上,支付成功后自动抬杆。
  • 缴费后离场:出场时如果系统检测到该车有支付成功且未离场的记录,在免费离场时间内直接放行,不再重复收费。

这里有一个细节我反复调过:道闸的抬杆、落杆时序和系统记录的出场时间必须解耦。道闸厂商的接口一般是同步返回抬杆结果,但系统这边的出场时间应该以道闸识别到车牌、上报出场请求的时间为准,而不是等抬杆完成才写库,否则高峰期多辆车排队出场,每辆都等抬杆再记录,数据库的写入就堵住了。

另外,车牌识别不可能100%准确,经常出现O和0混淆、数字1被识别成字母I的情况。我的处理方案是出场时如果识别到的车牌没有在场记录,不直接拒绝放行,而是把相似车牌候选集推送给门岗,让门岗人工确认,避免因为识别错误导致车堵在出口。这个功能上线第一周就发挥了作用,至少处理了不下十次识别异常。

4.2 分段计费算法实现剖析

计费算法是整个系统的技术核心。我直接贴出实际可用的核心函数,这段代码在ThinkPHP和Laravel两端是完全复用的,只依赖PHP基础语法。

/** * 计算停车费用 * @param string $entryTime 入场时间 Y-m-d H:i:s * @param string $exitTime 出场时间 Y-m-d H:i:s * @param array $rule 计费规则 * @return array [fee, detail] */ function calcParkingFee($entryTime, $exitTime, $rule) { $entry = new DateTime($entryTime); $exit = new DateTime($exitTime); // 1. 如果出场时间小于入场时间,直接视为异常 if ($exit <= $entry) { throw new InvalidArgumentException('出场时间不能早于入场时间'); } // 2. 免费时长内直接免费 $totalMinutes = $entry->diff($exit)->days * 1440 + $entry->diff($exit)->h * 60 + $entry->diff($exit)->i; if ($totalMinutes <= intval($rule['free_minutes'])) { return [ 'fee' => 0, 'surplus_minutes' => $totalMinutes, 'detail' => '免费时段' ]; } // 3. 跨天处理:按天拆分,再按天计算费用 if ($entry->format('Ymd') !== $exit->format('Ymd')) { $totalFee = 0; $current = clone $entry; while ($current->format('Ymd') !== $exit->format('Ymd')) { $dayEnd = clone $current; $dayEnd->setTime(23, 59, 59); $dayFee = calcDailyFee($current, $dayEnd, $rule); $totalFee += $dayFee; $current = clone $dayEnd; $current->modify('+1 second'); } $dayFee = calcDailyFee($current, $exit, $rule); $totalFee += $dayFee; // 跨天停车每天单独计费,不累计跨天封顶 if (isset($rule['daily_cap']) && $totalFee > $rule['daily_cap']) { $totalFee = $rule['daily_cap']; } return [ 'fee' => round($totalFee, 2), 'detail' => '跨天分段计费' ]; } // 4. 同一天计费 $fee = calcDailyFee($entry, $exit, $rule); return [ 'fee' => round($fee, 2), 'detail' => '单日计费' ]; } /** * 计算单日内的停车费用 */ function calcDailyFee($entry, $exit, $rule) { // 如果规则配置了时段费用,则按时段明细计算 if (!empty($rule['time_period'])) { return calcTimePeriodFee($entry, $exit, $rule); } // 基础分段计费 $minutes = $entry->diff($exit)->days * 1440 + $entry->diff($exit)->h * 60 + $entry->diff($exit)->i; // 向上取整到计费单位。比如超过1分钟按1小时计费 $units = intdiv($minutes + intval($rule['step_hours']) * 60 - 1, intval($rule['step_hours']) * 60); $fee = $units * $rule['step_fee']; return $fee; }

这段代码我特别说明了为什么一定要做跨天拆分。很多新手直接拿时间差总分钟数去乘以单价,这在单日停车场景下勉

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

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

立即咨询