ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ThinkPHP+Vue智能停车场系统设计与实现:车位状态机与并发控制实战

ThinkPHP+Vue智能停车场系统设计与实现:车位状态机与并发控制实战 停车难是不少城市车主的日常焦虑——进场排队、转圈找位、出场掏钱慢每一环都让人头疼。我最初接手这个thinkphpvue智能停车场车辆停车车位系统的设计与实现项目时以为无非就是一张表做增删改查真正动手写代码才发现难点根本不在存数据而在于怎么把车位的生命周期状态管好、怎么在并发时刻别把同一个车位分给两辆车、怎么让计费规则经得起每天几百辆车进出的考验。这篇文章既适合正在做毕业设计的同学参考也适合想快速搭建一套前后端分离业务系统的同行我会把从需求分析、数据库建模、后端接口到前端页面、最后部署上线的完整链路和踩坑心得都梳理出来尽量做到可以直接照着复现。1. 停车场系统真正要解决的问题从车位状态机说起很多人一听到停车场系统就下意识当成普通的增删改查这其实是个误区。一个可用的停车场系统本质上是一套车位资源调度系统核心不是管理记录而是管理状态。1.1 传统停车场的混乱点不是找不到位是不知道哪里有位置传统停车场之所以体验差问题出在信息黑盒入口闸机只知道进了一辆车不知道里面还有没有空位车主转了三圈才在最角落找到位置管理人员想统计某一天的车流量还得翻纸质登记簿。这里藏着一个关键事实——车位是有限的、独占的、非共享资源一辆车占住某个车位的过程中其他人只能用剩下的车位。所以系统设计的第一步不是急着写页面而是把车位的状态拆清楚。我用了一句话来定义系统核心停车场系统是一个实时维护车位占用状态并围绕状态变化触发计费、统计等业务动作的系统。1.2 核心角色与业务闭环根据这个定位我把角色分成三类角色典型操作关心的数据系统管理员管理车位、查看报表、配置费率、管理用户车位利用率、营收、车辆进出明细普通车主用户查询空闲车位、预约车位、查看停车记录、缴费自己车辆的入场时间、费用、车位位置系统本身自动分配车位、计时、计费、释放车位车位状态、停车时长、订单状态业务流程闭环是这样的车辆入场 → 系统分配一个空闲车位 → 生成停车记录车位状态变为占用车辆停在对应车位 → 车位的占用信息同步展示给其他人 → 其他车主避免重复入场车辆准备离场 → 根据停车时长计算费用 → 生成待支付订单 → 用户支付后 → 车位状态变为空闲这个闭环里最容易忽略的是预约场景。如果系统支持车位预约那么车位状态又多了一个已预约的中间态预约后超时未到车位又要自动释放。我在设计表结构时把状态字段预留了足够的灵活性宁可初始值用不上也不要后期拆表。1.3 功能清单与优先级判断完整功能清单按优先级排下来是这样P0必须做管理员登录、车位管理增删改查车位、入场登记、出场结算、订单查询、车位状态可视化P1建议做会员车辆管理、停车记录导出、费率配置、统计报表出入场流量、车位利用率P2锦上添花车位预约、预约超时释放、高峰期预警、大屏可视化我的真实建议是如果时间紧张P0的全部功能做扎实P1做两到三个P2可以先砍掉。因为评审和答辩时系统的稳定性和业务完整度比功能数量更重要一个并发跑不通的大而全远不如一个流程闭环的小而稳。2. 技术选型thinkphpvue的前后端分离组合为什么适合这类项目技术选型这件事脱离项目背景谈最好没有意义。像停车场这种业务它要什么快速开发、部署简单、代码好维护、中小规模并发足够。这套组合正好卡在这个位置上。2.1 后端框架对比thinkphp的优势到底在哪有人在选型时反复纠结为什么不用Spring Boot为什么不用Go抛开语言偏好不谈从项目实际需求看thinkphp有几个很实际的好处上手成本低学习曲线平缓。如果你已经会PHP基础语法thinkphp的MVC结构几乎是零障碍特别是它提供的Db类和模型查询写起来比原生SQL不知道轻松多少。对做毕设或者中小型项目的人来说这能省下大量的摸索时间。部署环境好找。但凡是Linux服务器装NginxPHPMySQL几乎是一行命令的事虚拟主机也大多支持PHP。相比之下Java部署要带JVM和一堆依赖Go虽然部署简单但具体开发起来对新手并不友好。生态够用。数据库迁移、验证码、JWT插件、Excel导出这些常用功能都有现成的组件不会让开发卡在环境上。我并不是说thinkphp比Spring Boot强而是说在这个体量的项目里它能完成任务而且不折腾。选型的第一原则永远是匹配项目规模和团队能力而不是追逐技术热点。2.2 前端vue Element Plus的组合体验前端我用的是Vue 3 Vite Element Plus。为什么不用传统的服务端模板渲染因为停车场系统的交互中有大量局部刷新和状态实时切换的需求比如车位地图上某个车位从绿色变成红色如果用后端模板渲染要么整页刷新要么写一堆jQuery选择器去操作DOM代码会非常散。Vue的响应式系统天然适合这类场景。我把车位状态做成一个数组数据一变模板里的车位格子自动变颜色。加上Element Plus成熟的表格、表单、弹窗组件管理后台开发速度非常快。有几个细节我做了取舍组件库版本Element Plus只支持Vue 3Vue 2项目要用Element UI这两个不要混。按需引入通过unplugin-vue-components自动按需导入组件打包体积能小很多。我项目里Element Plus按需引入后vendor.js少了将近一半体积。组合式API官方推荐使用script setup语法代码读起来像串演讲稿从数据到方法一路顺下来理解成本低。2.3 接口风格、鉴权与跨域约定前后端分离必须先把接口规范定好不然后面联调全是扯皮。我在项目里定的这套规则大家可以参考约定项规则接口前缀/api比如/api/space/list返回格式统一{code: 200, msg: ok, data: {...}}鉴权方式JWT Token请求头Authorization: Bearer token跨域处理开发环境用Vite代理生产环境用Nginx反向代理时间格式统一传时间戳前端格式化显示分页参数统一page和limit接口约定还有一个容易被忽略的点错误码要分层次。我用了三套错误码业务成功200、参数错误400、未授权401、无权限403以及带业务前缀的失败码。前端拦截器见401就跳登录见500就弹提示。如果所有失败都返回200然后msg里面写失败前端就没办法做统一处理。3. 数据库设计让车位的每一次状态变化都有据可查数据库是这套系统的地基设计不好后面写代码全是补丁。我花了整个项目差不多四分之一的时间在这张表结构上事后回头看完全值得。3.1 核心表结构设计思路我把核心表拆成六张每一张都存在的原因都说得清楚admin_user管理员表字段id, username, password, real_name, status, last_login_time作用后台账号体系密码我用password_hash()生成的哈希存入绝不直接存明文。类型说明实际开发用Id字段。注意字段命名不保留MySQL关键字。member车主表字段id, phone, nickname, car_plate, balance, member_type作用普通用户注册后可以绑定自己的车牌号码balance字段用于余额支付member_type区分普通用户和包月用户。设计体会把车牌号单拎出来放车主表还是不合并现实中一个车主可能有多辆车所以车牌和车主我建议拆成两张表car_info如果只想快速跑通也可以先合并用JSON字段存多车牌。parking_space车位表字段id, area_name, space_no, status, type, map_x, map_y作用这是全局最重要的表。status我设置了三个常量0空闲、1占用、2预约type区分普通车位、新能源充电车位和VIP车位map_x和map_y用于前端页面渲染车位地图坐标。为什么留坐标字段如果车位图是固定图片用绝对定位或者用CSS Grid按顺序渲染其实不需要坐标。但一旦页面响应式布局要适配不同屏幕坐标字段就能让前端灵活计算位置图片和数据库解耦。park_record停车记录表字段id, car_plate, space_id, enter_time, exit_time, status, amount作用一次入场的完整生命周期。status1进行中、2已完成、3异常。为什么要有异常状态现实中会遇到车停进去但记录没生成、出场时车牌识别失败等问题异常状态能让管理员介入修正。park_order订单表字段id, record_id, order_no, car_plate, pay_amount, pay_time, pay_type, status作用计费结果落库。把费用从停车记录里单独拆出来是为了将来接入微信支付、支付宝支付时订单数据独立方便做账本和退款。fee_rule计费规则表字段id, name, first_hour_price, extra_hour_price, max_day_price, free_minutes作用不同停车场甚至不同区域可以配置不同费率避免硬编码在代码里。3.2 车位分配的并发控制最关键的三个小细节数据库设计好了真正写车位分配逻辑时最大的挑战是并发。多辆车同时入场都查到了同一批空闲车位怎么保证不会重复分配我用了三个层面来防第一数据库状态条件更新。分配车位时不是先SELECT再UPDATE而是直接执行UPDATE parking_space SET status 1 WHERE id ? AND status 0然后检查affected_rows是否为1只有为1的这次操作才算抢到了车位。这个写法把查询更新合成一个原子操作从根上避免两个请求同时拿到status0的车位。第二事务包裹业务动作。更新车位状态、锁定停车记录、扣减预约信息这些操作必须在同一个数据库事务里执行任何一个失败都需要回滚。thinkphp里可以用Db::transaction()传一个闭包进去异常自动回滚。第三记录唯一约束。在park_record表我建了一个unique(car_plate, status)的约束逻辑——虽然MySQL里不能直接用status做唯一索引因为同一辆车历史记录状态都一样但可以用一个隐藏字段active_flag入场时置为1出场时置为0再对(car_plate, active_flag)建唯一索引。这台车上只有一条进行中的记录重复入场直接插入失败相当于又加了一道保险。3.3 计费规则的建表与计算逻辑计费是停车场项目里逻辑最琐碎的部分。常见规则是这样的首小时内5元之后每小时2元单日封顶20元入场15分钟内免费。听着简单但跨天、跨小时、预约减免一起出现时容易出错。我的计费思路是取出对应规则。计算总停车分钟数$minutes (exit_time - enter_time) / 60。判断是否在免费时长内注意免费时长不是前15分钟不收费而是出场时总时长不足15分钟才免费。计算小时数不愿丢掉零头就统一向上取整但如果首小时和后续小时价格不同要小心处理比如停了65分钟首小时5元 超过1小时的1分钟按1小时算2元应支付7元。跨天判断如果天数 1费用不能简单1天×封顶价后再累计因为首小时价格可能高于后续小时一般按每天封顶价 × 天数 零头处理。我把这套逻辑写成一个FeeCalculator类输入入场时间和出场时间输出金额单元测试能覆盖各种边界情况。计费逻辑千万不要写散在控制器里否则后面调整费率要改一堆文件。class FeeCalculator { public function calculate($enterTime, $exitTime, FeeRule $rule) { $minutes ($exitTime - $enterTime) / 60; if ($minutes $rule-free_minutes) { return 0; } $days (int)($minutes / (24 * 60)); $restMinutes $minutes % (24 * 60); $dayFee $days * $rule-max_day_price; $hourFee ceil($restMinutes / 60) * $rule-extra_hour_price; if ($restMinutes 0 $restMinutes 60) { $hourFee $rule-first_hour_price; } return min($dayFee $hourFee, ($days 1) * $rule-max_day_price); } }有天实测的时候发现停了整整24小时零1分钟费用本应是20元封顶如果最后不加上min限制就会算成22元。这个坑看过一次就忘不掉。4. 后端接口从0到1thinkphp的模块化落地实践数据库设计完接着就是后端接口。thinkphp 6其实自带多应用模式但我觉得把业务接口按模块拆开更清晰。我的目录结构是这样设计的app/ ├── controller/ │ ├── admin/ # 管理员端接口 │ │ ├── Auth.php │ │ ├── Space.php │ │ └── Statistics.php │ └── api/ # 用户端接口 │ ├── User.php │ ├── Park.php │ └── Order.php ├── model/ # 模型层 ├── middleware/ # 中间件 ├── service/ # 业务逻辑层 └── validate/ # 验证器这个结构最关键的一点是把业务逻辑从控制器里抽到service层。很多同学写thinkphp习惯把SQL和if判断全堆在控制器里一旦逻辑复杂控制器几百行自己看都头晕。我坚持的原则是控制器只做参数接收和结果返回具体业务放到service类一个方法干一件事。4.1 路由配置与接口设计thinkphp的路由有两种写法一种是自动路由控制器名/方法名直接就是接口地址另一种是显式路由在route/app.php里手动定义。我用的是显式路由理由很简单——接口文档和代码一一对应谁改了接口路由文件会留下痕迹。Route::group(api, function () { Route::post(login, api.Auth/login); Route::post(register, api.Auth/register); Route::get(space/list, api.Park/spaceList); Route::post(park/enter, api.Park/enter)-middleware(AuthMiddleware::class); Route::post(park/exit, api.Park/exit)-middleware(AuthMiddleware::class); Route::get(record/history, api.Park/history)-middleware(AuthMiddleware::class); Route::post(order/pay, api.Order/pay)-middleware(AuthMiddleware::class); });注意我在需要登录的接口上加了AuthMiddleware在公司做项目时这是个好习惯不会出现某个接口忘了鉴权变成裸奔的情况。4.2 JWT鉴权与登录接口thinkphp里做登录鉴权我推荐直接用JWT。它无状态不用在服务端保存session前后端分离场景下特别顺手。我用的firebase/php-jwt库封装在service/AuthService.php里use Firebase\JWT\JWT; use Firebase\JWT\Key; class AuthService { private static $key your-secret-key; public static function createToken($uid, $role) { $payload [ uid $uid, role $role, iat time(), exp time() 7200, // 2小时过期 ]; return JWT::encode($payload, self::$key, HS256); } public static function parseToken($token) { try { $decoded JWT::decode($token, new Key(self::$key, HS256)); return (array)$decoded; } catch (\Exception $e) { return null; } } }这里要特别提醒JWT的secret一定要放到配置文件中不要硬编码在代码里更不要提交到Git仓库。我见过一个线上项目把secret写死在代码里结果前端打包的JS反编译后secret被扒了出来整个鉴权形同虚设。另外登录成功后我还会把用户的id和角色放进token但不放密码、手机号这些敏感信息避免token泄露后暴露过多数据。4.3 入场流程的完整代码解析入场接口是系统的核心接口我来完整走一遍。控制器的写法是public function enter(Request $request) { $validate new ParkValidate(); if (!$validate-scene(enter)-check($request-param())) { return json([code 400, msg $validate-getError()]); } $service new ParkService(); try { $data $service-enter($request-param(car_plate), $request-param(space_id)); return json([code 200, msg ok, data $data]); } catch (\Exception $e) { return json([code 500, msg $e-getMessage()]); } }业务逻辑的核心在servicepublic function enter($carPlate, $spaceId) { $db Db::name(parking_space); // 原子抢占车位只有status0的车位才允许被更新为1 $affected $db-where(id, $spaceId)-where(status, 0)-update([status 1]); if ($affected ! 1) { throw new \Exception(该车位已被占用请更换车位); } // 生成停车记录 $recordId Db::name(park_record)-insertGetId([ car_plate $carPlate, space_id $spaceId, enter_time time(), status 1, active_flag 1, ]); return [record_id $recordId, enter_time time()]; }这个接口的巧妙之处在于where(status, 0)-update这行数据库在单条UPDATE语句上是原子的两个请求同时过来MySQL会串行执行只有一个能更新成功。这是整个并发控制最关键的一行代码比任何锁都要便宜和可靠。有人可能问入场要不要校验车牌是不是已经停进来了现实中确实会有一些车主——比如司机和车主不是同一个人或车牌录错了——从而导致同一辆车同时停两个位的情况。但我的原则是别把业务规则做太死入场时校验校验规则会在人工操作场景中成为障碍。真要限制可以配合前面说的unique(car_plate, active_flag)唯一索引来做兜底而不是在业务代码里SELECT查一遍。4.4 出场计费与订单生成出场流程要处理的步骤比较多我先列一下再逐段解释查找该车未完成的停车记录更新record.exit_time为当前时间status改为已完成调用FeeCalculator计算费用生成订单状态待支付释放车位status改为0这里有个业务决策值得聊聊是先释放车位还是先收钱我采取的是先算费生成订单再释放车位因为如果车位释放了用户又没付款后台就再也看不到这个待结算车辆了。订单生成后车位可以立即释放因为车位物理上实际已经空出来了系统记录上让它空闲是合理的而欠费则通过订单状态持续追踪。考虑到实际场景中有些闸机系统是离场前必须结清费用的我把生成订单和释放车位放在同一个事务里支付状态通过订单表单独追踪。代码核心部分public function exit($recordId) { $record Db::name(park_record)-find($recordId); if (!$record || $record[status] ! 1) { throw new \Exception(停车记录不存在或已结算); } $exitTime time(); $amount (new FeeCalculator())-calculate( $record[enter_time], $exitTime, $this-getFeeRule() ); $orderNo PK . date(YmdHis) . rand(1000, 9999); Db::transaction(function () use ($record, $exitTime, $amount, $orderNo) { Db::name(park_record)-where(id, $record[id])-update([ exit_time $exitTime, status 2, active_flag 0, ]); Db::name(park_order)-insert([ record_id $record[id], order_no $orderNo, car_plate $record[car_plate], pay_amount $amount, status 0, ]); Db::name(parking_space)-where(id, $record[space_id])-update([status 0]); }); return [order_no $orderNo, amount $amount]; }这里的事务回滚相当关键如果订单插入失败但车位已经被释放那这辆车就凭空消失了后台查不到记录车位也空着系统数据直接错乱。涉及多表写操作的接口一律用事务包起来这是我在这个项目里学得最深的一课。5. 前端开发全流程vue组件如何把车位状态变成可视化界面后端接口就绪后前端才是真正让这个系统看起来智能的部分。车位地图是目前整个项目交互最复杂的地方也是用户看得最直观的功能。5.1 前端工程初始化与目录规划我用的Vite创建项目npm create vitelatest parking-web -- --template vue cd parking-web npm install vue-router4 pinia axios element-plus echarts npm install -D unplugin-auto-import unplugin-vue-components目录结构这样规划src/ ├── api/ # 接口请求封装 ├── assets/ # 静态资源 ├── components/ # 通用组件 ├── router/ # 路由配置 ├── store/ # Pinia状态管理 ├── views/ │ ├── dashboard/ # 统计大屏 │ ├── parking/ # 车位地图与入场 │ ├── order/ # 订单管理 │ └── user/ # 用户个人中心 └── utils/ # 工具函数有必要说明一下为什么用Pinia而不用VuexVue 3官方已经推荐Pinia它更轻量没有mutations的概念直接改state就行心智负担小很多。多个组件共享的当前用户信息当前车位列表放store里比在各页面里重复请求要方便得多。5.2 车位地图组件的动态渲染车位地图是我的核心组件。我把停车场画成一个CSS Grid棋盘每个格子就是一个车位状态不同颜色不同绿色空闲、红色占用、黄色预约。template div classparking-map :stylegridStyle div v-forspace in spaces :keyspace.id classspace-item :classstatusClass(space.status) clickhandleClick(space) {{ space.space_no }} /div /div /template script setup import { computed } from vue const props defineProps({ spaces: { type: Array, required: true } }) const gridStyle computed(() ({ gridTemplateColumns: repeat(${props.columns}, 1fr), gridAutoRows: 80px })) function statusClass(status) { return { space-free: status 0, space-occupied: status 1, space-booked: status 2 } } /script这个组件我踩过两个坑都是新手容易忽视的车位编号要能够区分区域。如果停车场分A区B区C区页面只画一个grid不够直观。我的做法是给车位数据加一个area_name字段按区域分组画多个子grid每个区域一个标题。点击状态处理。空闲车位点击后可以弹出登记入场占用车位点击后弹出查看车辆和时长预约车位点击后提示等待入场。这些交互虽然不复杂但很影响使用体验千万别做成只能看不能点。5.3 axios拦截器与接口调用实践接口请求这块我封了一个统一的request模块核心逻辑是请求拦截加token、响应拦截处理业务码和跳登录// src/utils/request.js import axios from axios import { useUserStore } from /store/user const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const store useUserStore() if (store.token) { config.headers.Authorization Bearer ${store.token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.msg) // token过期或未授权 if (res.code 401) { useUserStore().logout() router.push(/login) } return Promise.reject(new Error(res.msg)) } return res.data }, error { ElMessage.error(error.message) return Promise.reject(error) } )这里要特别提一句不要把token存在localStorage里。localStorage有被XSS脚本读取的风险更稳妥的做法是放在Pinia里一刷新页面就丢了但配合持久化插件或者存内存里安全性和体验之间需要平衡。中小型项目放localStorage其实很常见但一旦做了就要注意不能让第三方脚本注入。另外接口返回的是业务码不是HTTP状态码这个坑在前后端联调时特别常见——后端返回200body里code401如果响应拦截器只盯着HTTP状态码看用户会卡在页面上半天不跳登录还以为只是接口报错。5.4 数据统计与可视化展示停车场系统如果没有统计报表管理员对运营情况就是两眼一抹黑。我选echarts做可视化做了两个有代表性的图表一个是近24小时进出场流量图X轴是小时Y轴是车辆数两条折线分别标入场和出场。数据从后端统计接口拿接口SQL按小时分组SELECT HOUR(FROM_UNIXTIME(enter_time)) AS hour, COUNT(*) AS cnt FROM park_record WHERE enter_time UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 1 DAY)) GROUP BY HOUR(FROM_UNIXTIME(enter_time))一个是车位利用率饼图展示空闲、占用、预约三种状态占所有车位的比例。这个数据其实就是status字段的group by逻辑不复杂但对管理员决策很有用如果占用率长期超过90%说明高峰时段车位紧张可以考虑做预约闸控如果某个区域长期占用率低于30%说明车位分布不均衡可以考虑调整导航引导。6. 联调部署与实战踩坑指南前后端分开开发时一切正常一联调部署就炸出一堆隐藏问题这部分我吃亏最多多花了一整天。希望看完这些坑的你能少走我的弯路。6.1 开发环境的代理与跨域Vite开发服务器默认跑在5173thinkphp接口跑在8000端口不同就涉及跨域。我推荐不要在后端开启跨域头而是用Vite的proxy代理原因有三一是不用改后端减少安全隐患二是生产环境走Nginx反向代理前后端天然同源开发和生产行为能保持一致三是cookie和鉴权头在代理模式下完全透明。// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true, } } } })注意target后面的地址千万不要写错——我一开始写成了http://localhost:8000本地没问题但同事的电脑上localhost解析到他自己机器的服务联调时接口404排查了很久才发现是环境变量问题。改成127.0.0.1后彻底解决。另外changeOrigin: true也别忘了它能让请求头里的Host变成后端地址避免后端某些场景下基于Host做校验时出问题。6.2 nginx部署thinkphp和vue打包产物生产环境我用的单机Nginx部署核心有两块thinkphp的入口在public目录vue打包后的dist目录是纯静态文件。我的Nginx配置大概是这样的server { listen 80; server_name your-domain.com; root /var/www/parking/public; index index.php index.html; # thinkphp 伪静态 location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; } } # 前端静态资源从 /web 目录提供 location /web { alias /var/www/parking/dist; index index.html; } # vue history 路由兜底 location /web { try_files $uri $uri/ /web/index.html; } location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass 127.0.0.1:9000; } }部署过程中最容易翻车的是vue路由history模式刷新404直接访问/web/parking这个地址时Nginx发现没有对应的静态文件就返回404了。解决方式就是try_files $uri $uri/ /web/index.html让所有找不到的文件都落到入口页由vue-router接管路由。6.3 实测中遇到的几个顽固问题与解决方案最后把我项目里真正卡住过我且值得记录的问题列出来PHP时区导致的计时差8小时服务器的默认时区可能不是中国时区停车记录里的时间戳没问题但时间戳直接输出成日期会差8小时。解决方式是在应用入口设置date_default_timezone_set(Asia/Shanghai)或者在thinkphp的config/app.php里把default_timezone改成PRC。这个问题最容易在明明数据库记录的时间是对的前端显示却差了几小时的时候暴露。图片和上传文件访问404管理员给车位上传现场照片或录入车辆图片时文件存到了public/storage但上传成功回显的地址是/storage/xxx.jpg而Nginx的root已经指向了public目录访问的应该是/public/storage/xxx.jpg才对。我最后统一把上传目录指向/storage并且把/storage单独做了一条location别名映射回显地址就和访问地址对齐了。订单金额浮点误差计算费用时如果用浮点数直接做运算比如0.1 0.2在计算机里不等于0.3金额明明两位小数却能算出19.999999。解决方案是金额一律用整数分存储PHP端把元转成分再运算输出时再转回元前端展示时格式化。别嫌麻烦财务数据早晚会坑人。车位地图刷新闪一下前端进入车位页面时最开始spaces是空数组地图渲染为空接口返回后才有数据。这个空状态闪屏体验很差。我的解决方式是先给spaces一个默认loading态用v-loading指令包住地图组件同时给车位总数做骨架屏。顺带一句接口返回的空数组也很重要如果后端返回的是null而不是[]前端v-for会直接报错所以后端接口约定里我强调数组字段必须返回空数组不做摸棱两可的null。结尾前的小建议这套thinkphpvue智能停车场车辆停车车位系统做下来我最深的体会是项目的价值不在技术栈多新多牛而在于业务逻辑是否闭环、数据是否经得起并发和异常的考验。如果你也正在做类似的项目我建议把重点放在三个地方一是车位状态机设计先画清楚状态转换图再写代码二是并发控制入场分配和出场结算都别省事务三是联调规范接口约定越详细后面扯皮越少。至于前端界面会基础的Vue组件化就能做得很不错真正拉开差距的反而是接口返回格式、空数据处理、token过期这些看不见的细节。项目完成后建议把代码跑一遍完整流程——注册、登录、车位列表、入场、查询记录、出场支付、看统计报表——把每一步的异常情况都打出来看一遍这个过程能帮你发现一半以上的潜在问题。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进