做停车场系统,听起来不算什么大项目,但真正动手之后你会发现,这里面的门槛全在细节上。车位状态的实时更新、计费规则的边界条件、支付回调的幂等处理、高峰期的并发扣费,每一处都能让一个“看起来能用”的Demo直接翻车。我这次用ThinkPHP 6 + Vue 3从零搭了一套智能停车场停车缴费管理系统,前端负责交互展示,后端只输出JSON接口,整体走的是前后端分离的架构。整套系统已经在一家小型商业停车场跑了几个月,支持车牌识别录入、手动入场、自动计费、扫码支付、出场校验、订单管理和计费规则配置。这篇文章会把我在设计数据库、写计费算法、对接支付和部署上线过程中踩过的坑和沉淀下来的方案完整拆给你,适合正在做毕业设计、接私活或者公司内部需要一套轻量停车管理系统的朋友参考。
1. 项目整体设计与技术选型思路
1.1 为什么选ThinkPHP 6 + Vue 3这对组合
先聊技术选型。市面上停车管理系统的方案很多,有纯Java + Spring Boot的,有Go写的,也有PHP做后端加小程序端的。我这次选ThinkPHP 6 + Vue 3,核心原因是这个组合最适合中小型停车场的实际场景。
ThinkPHP 6是目前ThinkPHP的LTS版本,相比ThinkPHP 5在底层架构上做了不少调整,核心改成了依赖注入容器,路由、中间件、事件系统全都是独立组件,用起来比5.x顺手得多。关键是它上手门槛低,一个懂PHP的开发者一周内就能进入业务开发状态。对于停车场这种业务逻辑不算极其复杂、但CRUD操作量很大的系统,ThinkPHP的模型层和查询构造器写起来效率很高。
Vue 3这边,组合式API(Composition API)让状态管理和逻辑复用比Vue 2的选项式API清爽太多了。停车场的监控大屏、收费端和管理后台,本质上是大量数据展示加高频交互的页面,Vue 3的响应式系统和组件化开发正好贴合这个需求。加上Element Plus提供现成的表格、表单、弹窗组件,后台管理页面的开发速度能比传统jQuery方案快两倍以上。
这套组合还有一个隐藏优势:部署成本极低。ThinkPHP跑在PHP-FPM上,一个2核4G的云服务器就能扛住一家中型停车场的日均流量,Vue打包后的静态文件交给Nginx托管,前后端都放在同一台机器上,运维压力很小。
1.2 系统整体架构:前后端分离怎么分层
以前用ThinkPHP做项目,基本都是服务端渲染页面,控制器里既要查数据库又要拼HTML,前端逻辑和后端逻辑糊在一起。这次我直接走前后端分离,ThinkPHP只负责输出JSON,Vue负责渲染页面,通过Axios发HTTP请求拿数据。
整个系统的架构分成了三层:
- 展示层:Vue 3 + Vite + Element Plus构建的SPA单页应用,包含监控大屏、收费端、管理后台三个子模块。
- 服务层:ThinkPHP 6提供RESTful API接口,负责鉴权、业务逻辑处理、计费计算、支付对接和数据持久化。
- 数据层:MySQL 8.0存储业务数据,Redis缓存车位状态和热点数据,使用定时任务处理超时订单和异常记录。
前端和后端通过JSON格式的数据交互,接口风格按照RESTful规范来,比如POST /api/entry表示入场,POST /api/calcFee表示计算费用,POST /api/notify表示支付回调。
这么分的第一个好处是开发可以并行,前端不用等后端写完接口,可以先Mock数据开发页面;第二个好处是将来如果要扩展小程序端或者IOS/Android App,后端接口可以直接复用,不需要再写一套服务端渲染页面。
1.3 环境版本与服务规划
开发环境我列一下,照着装就能跑起来:
- 后端:PHP 8.0 + ThinkPHP 6.0.12LTS + MySQL 8.0 + Redis 6.0
- 前端:Node.js 16.20 + Vue 3.4 + Vite 5.0 + Element Plus 2.4
- 服务器:Nginx 1.24 + PHP-FPM,CentOS 7.9
- 代码仓库:Gitee私有仓库,方便团队协作
PHP版本我特意选了8.0以上,因为ThinkPHP 6对PHP 8的兼容性已经非常成熟,而且PHP 8的性能相比7.x有明显的提升。命名参数、构造器属性提升、match表达式这些新语法写起来也舒服。
前端包管理器用的npm,没有上pnpm。原因很简单,项目体量不大,pnpm的优势体现不出来,npm在团队协作时大家更熟,减少沟通成本。
2. 数据库设计与计费规则拆解
2.1 核心数据表结构说明
停车场系统虽然看着功能不复杂,但数据库设计如果不提前规划好,后面改起来能让人崩溃。我这边总共设计了六张核心表:停车场表、车辆表、停车记录表、订单表、计费规则表、操作日志表。
停车场表保存停车场基本信息,包括名称、总车位数、地址、收费开关状态。车辆表区分临时车和月租车,月租车有过期时间字段。停车记录表是整个系统最核心的表,每一次车辆入场出场都对应一条记录,里面存车牌号、入场时间、出场时间、停车时长、应收金额、实收金额、状态等字段。
订单表关联停车记录,保存支付流水信息,包括订单号、支付渠道(支付宝/微信)、支付单号、支付状态、回调时间等。计费规则表比较灵活,支持配置免费时长、首小时价格、续费单价、单日封顶金额、不同车型的不同费率。操作日志表记录关键操作,比如强制抬杆、手动出场、费率修改这类操作,方便出问题后追溯。
主要表结构如下:
CREATE TABLE `tp_parking_record` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `plate_no` varchar(20) NOT NULL COMMENT '车牌号', `car_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1-临时车 2-月租车', `entry_time` datetime DEFAULT NULL COMMENT '入场时间', `exit_time` datetime DEFAULT NULL COMMENT '出场时间', `duration_seconds` int(11) DEFAULT '0' COMMENT '停车时长(秒)', `fee` decimal(10,2) DEFAULT '0.00' COMMENT '应收金额', `paid_fee` decimal(10,2) DEFAULT '0.00' COMMENT '实收金额', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1-在场 2-已出场 3-异常', `lot_id` int(11) NOT NULL DEFAULT '1' COMMENT '停车场ID', `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_plate_status` (`plate_no`, `status`), KEY `idx_entry_time` (`entry_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;停车记录表这里有两个容易踩的坑。第一个是车牌号一定要加联合索引(plate_no, status),因为查询某个车当前是否在场是最频繁的操作,不加索引数据量大了之后会慢到难以接受。第二个是金额字段必须用decimal(10,2),不能图省事用float,浮点数计算精度问题在涉及钱的场景里是绝对不能妥协的。
2.2 计费规则设计的完整思路
计费规则是整个系统的灵魂。停车场收费看着简单,就一句话“按停车时长收费”,但真到实现层面,各种边界条件能把人绕晕。我这边把计费规则拆成了四个维度:车型(临时车/月租车)、时段(日间/夜间)、阶梯(首小时/续费小时/封顶价)、特殊规则(免费时长、跨天处理)。
以我实际跑的这套商业停车场为例,计费规则是这样的:
- 临时车:首小时5元,超过1小时后每30分钟加收2元,不足30分钟按30分钟计算;单日24小时封顶30元。
- 免费时长:入场后15分钟内出场不收费。这个逻辑要特别注意,如果停车时长小于等于900秒,费用直接置为0。
- 跨天处理:停车时长跨过凌晨零点,重新计算封顶金额。比如第一天停了8小时收费17元,第二天又停了5小时收费9元,总计26元,而不是简单按总时长13小时算首小时加续费。
用PHP实现这个计费算法的时候,我先把计算逻辑封装成了一个独立的计费服务类,不跟控制器粘在一起。这样测试方便,将来要调费率也不用来回改控制器。
<?php namespace app\service; class ParkingFeeService { protected $rule; public function __construct(array $rule) { $this->rule = $rule; } public function calcFee(int $durationSeconds, string $entryTime, string $exitTime): float { // 免费时长判断 if ($durationSeconds <= $this->rule['free_minutes'] * 60) { return 0.00; } $entry = strtotime($entryTime); $exit = strtotime($exitTime); // 跨天处理:按自然日拆分 $days = $this->splitByDay($entry, $exit); $totalFee = 0.00; foreach ($days as $day) { $dayDuration = $day['end'] - $day['start']; $dayFee = $this->calcDayFee($dayDuration); $totalFee += $dayFee; } // 封顶判断(单日封顶在calcDayFee里已处理) return round($totalFee, 2); } protected function splitByDay(int $start, int $end): array { $days = []; $current = $start; while ($current < $end) { $dayEnd = strtotime(date('Y-m-d 23:59:59', $current)); if ($dayEnd >= $end) { $days[] = ['start' => $current, 'end' => $end]; } else { $days[] = ['start' => $current, 'end' => $dayEnd]; } $current = $dayEnd + 1; } return $days; } protected function calcDayFee(int $dayDurationSeconds): float { // 首小时费用 $fee = $this->rule['first_hour_price']; if ($dayDurationSeconds > 3600) { $extraSeconds = $dayDurationSeconds - 3600; $unitSeconds = $this->rule['unit_minutes'] * 60; // 按30分钟一个计费单位 $units = (int) ceil($extraSeconds / $unitSeconds); $fee += $units * $this->rule['unit_price']; } // 单日封顶 if ($fee > $this->rule['max_price_per_day']) { $fee = $this->rule['max_price_per_day']; } return $fee; } }这段代码里有两个细节值得注意。第一是ceil取整函数在这里用得恰到好处,不足30分钟按30分钟计算,比如超了1小时零1分钟,就按2个计费单位算;第二是单日封顶在calcDayFee内部处理,这样跨天的时候每天都能独立封顶,符合实际停车场的收费习惯。
2.3 数据库索引和事务设计经验
停车场的数据库操作有几个高频场景:查询车辆是否在场、查询停车记录列表、统计车位占用情况。这些查询条件组合起来,索引设计就很有讲究。
我自己实践后的索引设计是:停车记录表建(plate_no, status)联合索引处理“查某辆车在场记录”的请求,(entry_time)单列索引处理按时间范围查记录的请求,订单表(order_no)加唯一索引保证订单号不重复。
事务设计上,入场和出场操作都需要保证数据一致性。入场时先查车辆是否已在场内,有则在场记录就返回错误,没有才插入新记录,这里用事务包住,防止两个人同时扫同一个车牌导致重复入场。出场时先锁住停车记录行,计算费用和更新状态必须在一个事务里,不然可能出现扣了钱但记录没更新状态这种惨案。
ThinkPHP 6里使用事务的方式很简单:
Db::transaction(function () use ($recordId, $fee) { // 更新停车记录 // 写入订单 // 更新车位状态 });3. 后端接口实现与关键业务逻辑
3.1 入场、出场、缴费三大核心接口
入场接口是整个系统数据流动的起点。当车牌识别摄像机识别到一个车牌号,或者收费员手动输入车牌点击入场后,前端就会调用POST /api/entry。后端要做的事情包括:判断该车是否已经在场内,如果是月租车检查是否过期,创建停车记录,更新车位占用数。
出场接口稍微复杂一些。前端会先调用POST /api/calcFee查询费用展示给车主,车主扫码支付后,微信或支付宝会异步回调POST /api/notify通知支付结果,后端确认金额无误后更新停车记录状态为已出场,然后向道闸发送抬杆指令。
这里有个业务顺序问题值得思考:是先抬杆再结算,还是先结算再抬杆?我最终的做法是出场时先锁定停车记录,生成支付订单,车主完成支付后回调更新状态,之后前端轮询到已支付状态再呼叫抬杆。这样能避免那种“杆抬了车走了但钱没付”的情况。
入场接口的核心代码如下:
public function entry(Request $request) { $plateNo = strtoupper(trim($request->post('plate_no'))); $carType = $request->post('car_type', 1); if (empty($plateNo)) { return json(['code' => 400, 'msg' => '车牌号不能为空']); } // 检查是否已在场内 $exists = ParkingRecord::where('plate_no', $plateNo) ->where('status', 1) ->find(); if ($exists) { return json(['code' => 400, 'msg' => '该车已在停车场内,请勿重复入场']); } Db::startTrans(); try { $record = ParkingRecord::create([ 'plate_no' => $plateNo, 'car_type' => $carType, 'entry_time' => date('Y-m-d H:i:s'), 'status' => 1 ]); // 更新车位占用数 ParkingLot::where('id', 1)->dec('available_count')->update(); Db::commit(); return json(['code' => 0, 'msg' => '入场成功', 'data' => ['record_id' => $record->id]]); } catch (\Exception $e) { Db::rollback(); return json(['code' => 500, 'msg' => '入场失败:' . $e->getMessage()]); } }3.2 支付对接流程与回调幂等处理
支付我同时接了微信支付和支付宝,这两种方式流程上大同小异:后端生成预支付订单,返回支付二维码链接或支付串给前端,前端展示二维码,用户扫码完成支付,支付平台异步通知后端接口,后端更新订单状态。
对接支付时最怕的是回调重复通知。微信和支付宝为了保证支付结果一定送达,会多次重复发送回调通知,如果后端不在处理时做幂等校验,就会出现订单状态被覆盖、停车记录重复更新这类问题。
幂等处理的思路很直接:在回调处理方法里先查订单表,判断当前订单是否已经是已支付状态,如果是直接返回成功,不再重复处理业务逻辑。同时回调处理要放在事务里,保证订单状态和停车记录状态更新的一致性。
public function notify(Request $request) { $orderNo = $request->post('out_trade_no'); $order = Order::where('order_no', $orderNo)->find(); if (!$order) { return '订单不存在'; } // 幂等判断:已支付直接返回成功 if ($order->status == 2) { return 'success'; } Db::startTrans(); try { $order->status = 2; $order->paid_time = date('Y-m-d H:i:s'); $order->trade_no = $request->post('trade_no'); $order->save(); // 更新停车记录状态 $record = ParkingRecord::find($order->record_id); $record->status = 2; $record->exit_time = date('Y-m-d H:i:s'); $record->paid_fee = $order->amount; $record->save(); Db::commit(); return 'success'; } catch (\Exception $e) { Db::rollback(); return 'fail'; } }一个容易忽略的细节是,回调接口返回的内容必须严格符合支付平台的要求。微信支付回调要求返回<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>这种格式,支付宝返回success字符串,如果格式不对,支付平台就会一直重试,造成大量无效请求。
3.3 ThinkPHP中间件实现接口鉴权和跨域处理
前后端分离的项目里,接口鉴权和跨域是两个必须处理的问题。我这里用ThinkPHP 6的中间件机制统一处理。
跨域问题在开发环境特别烦人。前端跑在Vite的5173端口,后端跑在Nginx的8080端口,端口不同就会触发浏览器的同源策略限制。解决办法是在ThinkPHP里自定义一个跨域中间件,设置Access-Control-Allow-Origin响应头。
<?php namespace app\middleware; class CrossDomain { public function handle($request, \Closure $next) { header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With'); if ($request->isOptions()) { return response('', 204); } return $next($request); } }接口鉴权我实现了两层。第一层是登录态校验,后台管理员登录成功后,后端返回一个JWT Token,前端存在localStorage里,后续请求通过Authorization请求头携带,中间件里校验Token有效性和过期时间。第二层是操作权限校验,比如只有管理员角色的用户才能修改计费规则、查看财务报表这类敏感接口。
ThinkPHP 6的路由分组加上中间件配置非常简单:
Route::group('api', function () { Route::post('entry', 'Parking/entry'); Route::post('exit', 'Parking/exit'); Route::post('calcFee', 'Parking/calcFee'); })->middleware([\app\middleware\CrossDomain::class, \app\middleware\AuthCheck::class]);3.4 Redis缓存优化车位状态查询
停车场系统有一个很常见的性能瓶颈:车位剩余数量的实时统计。如果每次车主扫码打开小程序查车位,后端都去MySQL里数一遍有哪几条停车记录状态是在场,再来个总数减去在场数,高峰期能把数据库打崩。
我的方案是在Redis里维护一个实时的车位占用数字。入场时在Redis里减一,出场时在Redis里加一,前端查车位状态直接读Redis,不查数据库。这样不仅性能好,还能减轻数据库压力。
用ThinkPHP操作Redis很简单,框架已经内置了缓存处理类。我把Redis缓存设计成一个服务类,统一封装车位数量的增减和读取,避免在控制器里散落一堆缓存操作代码。
public static function changeAvailableCount($lotId, $delta) { $key = 'parking:lot:available:' . $lotId; $current = Cache::get($key); if ($current === false) { // Redis中没有缓存时,从数据库初始化 $lot = ParkingLot::find($lotId); $current = $lot->available_count; Cache::set($key, $current, 3600); } $newCount = $current + $delta; Cache::set($key, $newCount, 3600); return $newCount; }使用Redis之后有一个需要注意的地方:缓存和数据库的一致性问题。我采用的做法是每天凌晨统一次从数据库重建缓存,运行期间Redis为主数据源,操作日志里记录所有变更,万一出现异常可以追溯修复。
4. Vue 3前端实现与页面拆解
4.1 项目初始化和环境配置
前端这部分我用的Vite作为构建工具,Vite冷启动速度和热更新速度比Webpack快一个数量级,开发体验非常爽。创建项目的命令很简单:
npm create vite@latest parking-web -- --template vue项目创建之后,需要安装Vue Router做页面路由、Pinia做状态管理、Axios发HTTP请求、Element Plus做UI组件库。安装依赖的时候建议用npm install,不要用npm install -g全局装,每个项目保持独立的依赖环境,避免版本冲突。
Vite的配置文件vite.config.js里做了两件事:一是配置了开发服务器端口和代理,二是设置路径别名,让@符号可以快速访问src目录。
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这里配置代理特别重要。开发环境下,前端页面请求/api/entry这个路径时,没有代理的话浏览器会直接请求http://localhost:5173/api/entry,结果肯定是404。配置代理后Vite会把这个请求转发到http://localhost:8080/api/entry,完美绕过跨域问题。
4.2 监控大屏、收费端、管理后台三个子模块
我把前端拆成了三个相对独立的子模块,通过路由和侧边栏菜单切换。
监控大屏是给停车场管理层看的,页面上半部分是车位总览,用环形进度条展示总车位数、已占用数、剩余数;下半部分是实时进出记录表格,最新一条入场/出场的记录置顶高亮。这里的轮询请求用setInterval定时器每10秒拉取一次最新数据,实际使用下来压力不大。
收费端是给岗亭收费员用的,这是整个系统交互最核心的页面。收费员输入车牌号后,前端调/api/calcFee接口拿停车信息,页面上展示入场时间、停车时长、应收金额,然后点击收款按钮生成支付二维码。这里的交互要求快、准、稳,一个操作最多三步完成,不能让车主在旁边等太久。
管理后台功能最杂,包括停车记录查询、订单管理、计费规则配置、月租车管理、财务报表等。管理后台我大量用到Element Plus的el-table、el-form、el-dialog组件,配合Vue 3的reactive和ref做状态管理。
4.3 用Vue 3组合式API封装接口请求
我前端封装了一个统一的Axios实例,请求拦截器里自动加上JWT Token,响应拦截器处理业务错误码和HTTP异常,这样各个页面里写接口调用时不用重复处理这些逻辑。
// src/api/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default request在组合式API里调用接口,我习惯把每个业务模块的接口封装成一个独立的Hook函数,比如useParking管理入场出场操作,useOrder管理订单查询和支付操作,这样组件里只需要调用这些函数,逻辑非常清晰。
4.4 前端支付交互与二维码展示
扫码支付是前端交互最复杂的环节。用户点击“确认缴费”后,前端调用后端接口拿到支付二维码的链接,然后用qrcode这个npm包把链接转成二维码图片展示在弹窗中。同时启动一个定时器每2秒轮询订单状态,一旦发现订单已支付,立刻关闭弹窗并提示成功。
这个轮询操作要特别注意清理定时器,不然用户关闭弹窗后定时器还在跑,浪费请求资源。正确做法是在关闭弹窗时clearInterval,或者使用Vue 3的onBeforeUnmount钩子清理。
<script setup> import { ref, onBeforeUnmount } from 'vue' import QRCode from 'qrcode' const dialogVisible = ref(false) const qrCodeUrl = ref('') let pollTimer = null async function openPayDialog(orderNo, qrCodeContent) { qrCodeUrl.value = await QRCode.toDataURL(qrCodeContent) dialogVisible.value = true startPoll(orderNo) } function startPoll(orderNo) { pollTimer = setInterval(async () => { const res = await checkOrderStatus(orderNo) if (res.data.status === 2) { clearInterval(pollTimer) dialogVisible.value = false ElMessage.success('支付成功') } }, 2000) } onBeforeUnmount(() => { if (pollTimer) clearInterval(pollTimer) }) </script>这里有个体验细节:二维码内容如果是支付宝或微信的支付链接,直接用qrcode转图片就行;如果是微信Native支付,后端返回的是一个weixin://wxpay/...开头的特殊链接,这种链接扫码后会自动唤起微信支付。一定不能把这种链接原样展示成文本让用户复制,体验太差了。
5. 项目部署实战与常见问题排查
5.1 Nginx配置和前端打包上线
项目开发完成后的部署流程,前端和后端是分开的。前端执行npm run build会在dist目录下生成静态文件,把这些文件上传到服务器的/var/www/parking-web目录即可。
Nginx的配置我直接给出实际在用的版本。需要注意两个关键点:一是PHP请求要转发给PHP-FPM处理,二是前端是SPA单页应用,所有路由都要try_files重写到index.html。
server { listen 80; server_name parking.example.com; root /var/www/parking-web; index index.html; # 前端静态文件 location / { try_files $uri $uri/ /index.html; } # 后端API接口 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; } }后端代码上传到服务器后,需要安装PHP依赖。ThinkPHP 6使用Composer管理依赖,在项目根目录执行composer install --no-dev即可。然后要修改.env环境配置文件,把数据库连接信息、Redis配置、支付密钥等敏感信息都放在这里,不要把密钥写死在代码里。
5.2 上线后遇到的5个真实问题与解决方法
系统上线至今我遇到了一系列问题,挑了五个最有代表性的写在这里。
第一个问题是金额误差。有用户停车2小时05分钟,计算出的费用跟人工计算不一致。排查后发现是PHP的浮点数运算精度问题。0.1 + 0.2在PHP里得到0.30000000000000004,直接用浮点数做累加就会出现传说中的精度误差。解决办法是把所有金额运算统一用整型分来算,最后展示时才转换成元。
第二个问题高一峰期接口超时。停车场出口在晚上六点到八点的高峰期,出口扫码缴费的请求量突然增大,数据库连接数被打满。优化方案是加Redis缓存热点数据(计费规则、车位数量),同时用数据库连接池,让连接复用而不是每次请求都重新建立。
第三个问题是支付回调重复处理导致停车记录被重复更新。前面提过,微信支付回调在网络抖动时会重复推送通知,如果后端不校验订单状态,就会出现费用已缴但系统再次显示未缴费的情况。我的解决方案在订单表加了一个status字段的查询校验,已支付订单直接返回成功。
第四个问题是Vue页面在手机端出现白屏。排查发现是兼容性问题,Element Plus 2.x默认不兼容部分老旧浏览器,解决方法是根据实际使用场景在main.js里引入浏览器前缀补全。如果车载终端或广告屏用的安卓7以下系统,还需要额外处理WebView的兼容问题。
第五个问题是ThinkPHP的SQL查询日志怎么开启。线上排查慢查询和怪问题时,没有SQL日志简直寸步难行。TP6里在config/log.php中配置不同的日志级别,或者用框架的Db::listen方法监听SQL执行:
// 开启SQL日志 Db::listen(function($sql, $time, $explain) { Log::write('[SQL] ' . $sql . ' [' . $time . 'ms]', 'sql'); });这段代码一般放在app/common.php或者自定义的服务提供者里,这样每次执行SQL都会记录到日志文件,线上排查问题时能直接看到每一条SQL的耗时和执行结果。
5.3 监控告警与日常巡检建议
系统稳定运行不能只靠开发时写代码,运维侧的监控告警也必不可少。我目前实现了三个维度的线上监控。
一是接口健康检查。写了一个定时脚本,每5分钟请求一次入场和查费接口,如果接口返回非预期数据或响应时间超过3秒,就通过企业微信机器人推告警给开发人员。
二是数据库慢查询监控。开启MySQL的slow_query_log,超过1秒的SQL自动记录,每周汇总分析一次,针对频率最高的慢查询做索引优化或缓存改造。
三是磁盘和内存监控。利用系统的定时任务检查挂载磁盘空间和PHP-FPM进程内存占用,低于阈值时自动清理日志文件。
日常巡检方面,我每周会做一次订单状态一致性检查,写SQL对比订单表和停车记录表,找出已支付但记录未更新这类异常数据。每月会核对一次财务报表和实际收款记录,确保支付渠道的账单跟系统数据对得上。
6. 支付对接的坑与安全合规注意事项
6.1 微信支付、支付宝申请接入的必备条件
支付接入对整个项目来说既关键又繁琐。我调试支付功能的时候花了两天时间,大部分时间都消耗在申请商户号和配置各种密钥证书上。
微信支付需要申请微信商户号,个人开发者可以选择个体工商户或企业资质,个人主体无法申请。申请时需要提供营业执照、法人身份证、银行账户等资料,审核通过后会在商户平台拿到商户号(mch_id)和AppSecret。支付宝方面,需要到开放平台注册开发者账号,创建应用后获得AppID和应用私钥,然后签约对应的产品(支付宝电脑网站支付、手机网站支付或当面付)。
有一个经验想说:开发调试时可以使用支付平台提供的沙箱环境。微信支付沙箱环境是独立于正式环境的,接口域名是https://api.mch.weixin.qq.com/sandboxnew,需要用正式商户号去申请沙箱密钥。支付宝沙箱环境更简单,开放平台可以直接用沙箱应用测试,不用真实商户号。
6.2 金额精度与支付安全的关键设计
支付系统涉及资金安全,代码规范上必须严格遵守几条原则。
第一,金额一律存分为单位。数据库里用int类型存储金额对应的分数,比如5元存500,30元存3000。虽然前端展示时需要转成带两位小数的元,但这个转换放在展示层做,后端逻辑全用整数运算,彻底规避浮点数误差问题。
第二,支付回调验签不能省。微信支付回调会带上签名信息,后端收到通知后需要按支付平台的规则重新计算签名并对比,防止伪造回调。TP6里配合官方SDK处理即可,不要图省事跳过这一步。
第三,订单号必须全局唯一。我用日期 + 随机数的方式生成订单号,比如20240315103000123456,再加数据库唯一索引兜底。曾经遇到过一次并发场景下同时生成了两个一样的订单号,唯一索引直接拦住,避免了脏数据。
6.3 月租车和特殊场景的计费扩展
临时车的计费逻辑相对简单,但实际的停车场业务里还有月租车、固定车位用户、VIP客户等角色。我做系统时在这些方面做了一定的抽取设计,方便后续扩展。
月租车统一通过车辆表里的car_type字段区分,月租车入场时不计算费用,出场直接抬杆放行。月租车是否过期,在入场时校验,如果已过期会提示收费员转临时车流程收费。这里还有一个细节:月租车转临时车后,如果当天再次入场,就按临时车重新计费,不能沿用月租规则。
免费时长、夜间优惠、会员打折这类特殊规则,我都在计费规则表里预留了配置位。虽然当前商业停车场只用到了首小时加续费的简单模型,但表结构上已经能支持更复杂的规则组合,后续有需求加配置就行,不用改代码。
7. 项目复盘与实用经验总结
7.1 开发时间线和团队协作经验
这次项目从需求确认到部署上线,前后一共花了25天。第一天到第三天是做需求沟通和数据库设计,第四天到第十五天是后端接口开发,第八天开始前端并行开发,第十六天到第二十天联调支付和场内测试,最后五天处理部署上线和小范围试运行期间的问题反馈。
团队协作上最值得说的经验是接口文档先行。我们在后端写接口之前,先用Apifox把所有的接口URL、请求参数、响应格式定义好。前后端并行开发时,前端看着接口文档Mock数据写页面,后端照着接口文档实现功能,联调阶段几乎没有出现“你接口字段名字不对”这种问题。
一个小建议是接口返回格式统一约定成这样的结构:
{ "code": 0, "msg": "success", "data": {} }code为0表示成功,非0表示业务错误,msg是提示信息,data放业务数据。这个约定能省掉很多前后端沟通成本。
7.2 从这套系统复盘得到的技术收获
写完这套系统,我对“技术选型如何服务业务场景”有了更深的理解。ThinkPHP这类PHP框架虽然被很多人吐槽不够“高大上”,但它的开发效率和部署便捷性在中小型项目中是实打实的优势。Vue 3的工程化能力配合Element Plus组件库,让后台类项目的前端开发速度提升了不是一星半点。
停车场管理系统虽然业务逻辑不算极其复杂,但“计费精度、并发扣费、支付回调幂等”这些通用问题覆盖了大部分管理系统的核心痛点。做完这套项目后,再去做其他管理系统类的项目,很多代码和设计思想是可以平移复用的。
我个人在实际操作中的体会是,这种偏业务型的系统,真正难的不是写代码,而是把一个看似简单的问题想周全。你永远不知道现场会发生什么情况——比如司机在出口处扫了码但是手机没网,比如两个车同时入场被系统识别成同一个车牌,比如凌晨断电重启后计费规则失效。只有在设计阶段就想清楚这些边界情况,系统才有可能扛住真实的业务考验。最后再分享一个小技巧:给停车场系统写代码时,一定要把“强制抬杆”这个功能做出来,虽然平时用不上,但碰到设备异常的时候,它就是救命的工具。