ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP双框架实战:打造港口物流船货柜全流程管理系统

PHP双框架实战:打造港口物流船货柜全流程管理系统 我们码头这套系统上线快一年了一直想写篇复盘正好趁着手头项目收尾把当初从零搭建“船只货柜管理”这套东西的思路和踩过的坑都倒出来。标题看着长其实就是个很典型的港口物流场景——“船要进港、柜要落地、车要提箱”三件事串起来就是整个码头的日常。当时定的技术栈是PHP双框架Laravel做核心业务APIThinkPHP管报表和闸口设备对接。很多人问为什么不直接二选一这个后面细讲但先说结论这组合在中小码头的信息化改造里其实挺能打前提是分工够清楚。这套系统解决的痛点如果你管过码头或者堆场一定深有体会船只到港时间、泊位占用、货柜堆在哪个贝位、外集卡提箱排多久队、船公司对账时箱号谁对不上……全是线下excel和口头沟通一忙起来就乱。系统上线后把这些全搬到线上从报港、靠泊、卸船、堆存、提箱到离港一条链路全部可视化每个人的活儿都变得透明了。这篇文章适合两类人看一类是正在做仓储物流、港口码头信息化的PHP开发者另一类是想借鉴双框架混合架构思路做企业级系统的朋友。1. 项目背景与整体设计思路1.1 先理清码头作业的核心业务链做系统之前最忌讳的就是急着建表写接口必须先把业务流捋顺。码头作业看着复杂其实核心就五步船公司或货代报港提交船名、航次、预计到港时间、装卸量调度分配泊位确认靠泊计划通知各作业班组卸船流程岸边吊机抓柜上岸拖车运到指定堆场贝位系统实时记录箱位闸口进出外集卡凭提箱单进闸口系统校验信息后放行堆场指定位置捉箱装船流程按配载图顺序装船最终生成船图离港这个链路里最核心的数据对象就两个船ship和柜container也就是货柜/集装箱其他什么泊位、堆位、任务单、费用结算全是围着这两个对象转的。所以建系统第一步不是急着写代码而是把这两个对象的生命周期状态机画清楚。我当时跟码头的业务经理开了三次需求会磨了差不多一周才把状态定义敲定。船的状态我定了这几个报港、审批通过、已靠泊、卸装中、已离泊、离港货柜的状态稍微复杂点因为要区分“空箱重箱”和“在场不在场”两条线重箱待检、堆存中、待提、已出场空箱可调配、待修、已清洗、已出场这里一定要做状态的版本化管理别只存一个当前状态否则后面追数据的时候就抓瞎了。我们后来加了一张container_status_logs表每次状态变更都留痕谁在什么时间把箱号从“堆存中”改成了“待提”一查就有船公司来扯皮的时候这就是证据。1.2 双框架协作并不是拍脑袋的决定先回答一个很多人都会问的问题为什么一套系统里用两个PHP框架听起来就很别扭。但我可以负责任地讲这在承接老系统改造的项目里非常常见。我们接手时码头闸口的设备端程序是外包公司用ThinkPHP 3.x写的跑了好几年用的MySQL读写逻辑跟主业务流程深度耦合重写成本极高。而新开发的船只调度和货柜管理模块业务逻辑复杂需要队列、事件、任务调度这些成熟能力用Laravel开发效率高得多。所以最终方案是保留ThinkPHP做闸口设备对接和报表导出Laravel做核心业务API两者共用同一个MySQL库和Redis缓存。架构上再补一层Laravel对外暴露JSON API给Web端和小程序ThinkPHP那套代码包在独立路由前缀/legacy下面通过Nginx按路径转发到不同PHP-FPM池。两边都用同一个用户表做认证但Token签发统一放在LaravelThinkPHP那边只需要校验Token合法性就行。这样既没推翻旧代码又让新的业务模块跑在更现代的框架上可以说是性价比最高的迁移路径。分工上具体是这样拆的Laravel侧船只管理、泊位计划、货柜状态流转、装卸任务单、用户权限、API鉴权ThinkPHP侧闸口道闸控制、RFID读卡对接、Excel报表导出、短信通知、历史数据迁移脚本不过要说句实话双框架也有代价最痛的是两边代码风格不统一新来的同事要上手两套框架学习成本直接翻倍。所以后来我们定了个规矩新写的业务代码一律走LaravelThinkPHP那部分只维护不扩张等闸口设备协议升级的时候再逐步替换。1.3 模块划分与系统权限模型系统按业务域拆成了七个模块报港管理、泊位调度、卸船作业、堆场管理、闸口管理、装船配载、系统设置。每个模块在数据库里都是独立的表组在代码里用三层架构Controller、Service、Repository隔离避免业务逻辑乱成一锅粥。权限模型用的是最经典的RBAC五张表用户表、角色表、权限表、用户角色关联表、角色权限关联表。这个没什么新鲜的但有一点提醒大家注意码头这种场景权限粒度一定要落到“操作”而不是“菜单”。比如同样一个“货柜查询”菜单调度员能看到所有箱的实时位置财务能看到费用状态闸口操作员只能看到已放行的箱号。如果只用按钮级权限控制后面需求一变就会捶胸顿足。2. 数据库与核心功能拆解2.1 船只管理模块是怎么建起来的船只信息表我设计的时候没做得太复杂核心字段就这些CREATE TABLE ships ( id bigint unsigned AUTO_INCREMENT, ship_name varchar(100) NOT NULL COMMENT 船名, voyage_no varchar(50) NOT NULL COMMENT 航次号, imo_no varchar(20) DEFAULT NULL COMMENT IMO编号, container_capacity int DEFAULT 0 COMMENT 最大载柜量(TEU), length_m decimal(8,2) DEFAULT NULL COMMENT 船长(米), draft_m decimal(6,2) DEFAULT NULL COMMENT 吃水(米), agent_name varchar(100) DEFAULT NULL COMMENT 船代名称, status tinyint NOT NULL DEFAULT 1 COMMENT 1报港 2审批通过 3已靠泊 4卸装中 5已离泊 6离港, berth_no varchar(20) DEFAULT NULL COMMENT 泊位编号, expected_arrival_at datetime DEFAULT NULL COMMENT 预计到港时间, actual_arrival_at datetime DEFAULT NULL COMMENT 实际到港时间, created_at timestamp NULL DEFAULT NULL, updated_at timestamp NULL DEFAULT NULL, PRIMARY KEY (id), KEY idx_ship_name_voyage (ship_name, voyage_no), KEY idx_status_eta (status, expected_arrival_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT船只信息表;这里有两个设计细节值得说说。一是voyage_no必须跟ship_name一起建联合索引因为日常查询绝大多数是“某条船的某个航次现在在哪一步”两个字段分开建索引效率远不如联合索引。二是状态字段我用的MySQL枚举值tinyint业务状态机放在代码层控制而不是数据库的ENUM类型。这样以后增加状态“待维修”不用改表结构只改代码枚举就行灵活很多。船的状态流转不是随意的必须有线性的状态机控制。我们的做法是在Laravel的ShipService里写了一个状态流转校验方法每个流转动作都检查前置状态// app/Services/ShipService.php 片段 public function changeStatus(Ship $ship, string $newStatus, User $operator): bool { $allowedTransitions [ reported [approved, rejected], approved [berthed, cancelled], berthed [working, departed], working [departed, berthed], departed [finished], ]; if (!in_array($newStatus, $allowedTransitions[$ship-status] ?? [], true)) { throw new InvalidStatusTransitionException( 船只状态不允许由 {$ship-status} 变更为 {$newStatus} ); } // 记录状态变更日志 ShipStatusLog::create([...]); return $ship-update([status $newStatus]); }后来实际使用发现真有不少操作员习惯性点错按钮比如船还在锚地就点了“已靠泊”系统直接拦住并且提示比事后改数据不知道省了多少事。状态机这东西看着是约束其实是保护。2.2 货柜模块全生命周期跟踪怎么做货柜是整个系统里数据量最大、并发最高的部分。一个中等码头一天的进出闸货柜量大概有一两千个月数据量轻松到十万级。货柜表设计时就要为这个量级做准备。货柜核心字段包含箱号、尺寸20尺/40尺/45尺、箱型普通箱、高箱、冷藏箱、开顶箱、框架箱、空重状态、当前堆存位置码头区贝位、承运船公司、目的地、进场时间、离场时间等。其中箱号是全局唯一标识必须加唯一索引这块我还专门写了ISO 6346校验逻辑。基于货柜的全生命周期我设计了这样一条流转链闸口进场重箱/空箱→ 堆场指定位置 → 等待查验/海关放行 → 提箱/装船出闸。每一步都会往container_status_logs写一条记录这样任何一个箱子的来龙去脉都能追溯清楚。查询侧的压力主要在两个场景一是外集卡司机报箱号查位置二是堆场调度查某片区域还有多少空位。后者我们加了物化统计表每个堆场的“区贝位”维护一个剩余容量字段每次入库/出库操作在事务里同步更新这样查询的时候直接读统计表避免对大表做计算型的查询。给货柜拍位置这块很有意思。最初我以为货柜进堆场只要记录“堆场A”就完了结果业务经理说不行要精确到“A区3排6贝位”这种级别。因为龙门吊司机是靠系统指令去挪柜的坐标不对直接出大问题。所以系统里其实有个yard_positions表存储的是三维坐标区、排、贝货柜表里冗余了一个position_code字符串字段形如A-03-06查询方便展示也直观。2.3 装卸作业调度连接船与柜的关键装卸作业是系统里联动最强的部分。船靠泊后系统要生成卸船任务单和装船任务单任务单里包含一批货柜箱号和目标位置。这个环节我先用Laravel的队列来处理因为一个航次动不动就是几百个柜同步生成会堵塞请求。队列设计是这样的// app/Jobs/GenerateDischargeJobs.php public function handle(): void { $containers $this-plan-containers()-where(status, waiting_discharge) -orderBy(bay_order)-get(); foreach ($containers-chunk(50) as $chunk) { // 每个批次生成一个装卸批次记录分配给一台龙门吊 foreach ($chunk as $container) { DischargeTask::create([ plan_id $this-plan-id, container_id $container-id, target_position $this-assignPosition($container), status pending, ]); } } }这里面有个教训千万别在一个队列任务里循环几百次写数据库就算每条SQL很快几百条下来也要卡几秒。我把任务批量组装在内存里先攒好最后用一次insertBatch入库速度提升不是一点半点。调度员端是一个实时看板用WebSocket推送任务进度。龙门吊司机用的是工业平板上操作完成任务时点一下“已完成”系统自动更新柜子状态并释放机械资源。这一步做不好比如柜子还在堆场但系统显示已装船后续船图就乱了。所以我们的装船任务跟实际扫描绑定每个柜在吊上船时必须扫码复核扫码确认这个动作其实就是货柜表的最后一道锁没扫码的柜根本无法在业务上报“已装船”。3. 双框架落地实现的关键细节3.1 Laravel侧Eloquent关联、队列、事件系统Laravel是我们这套系统的新引擎承载了大部分业务逻辑。开发过程中我认为最值得分享的是几个方面的设计。Eloquent ORM用好了确实爽尤其是处理船、货柜、泊位、任务单这种多表关联。举个例子查一条船及其全部货柜任务单代码可以写得像写口语一样$ship Ship::with([ plans fn ($query) $query-where(status, working), plans.tasks fn ($query) $query-with(container)-orderBy(bay_order) ])-find($shipId);这里有个性能陷阱要知道with嵌套太深会导致join非常重尤其是plans.tasks.container这种三层以上的链式加载。实测在200个柜的任务单下用with一次性加载反而比拆成两次查询慢。后来我改成先查计划列表再单独查任务单列表最后在内存里按plan_id做分组关联。数据量上来以后分步查询明显更稳其实这里用的是“宽查询”和“窄查询”的取舍没有绝对的银弹要看数据量和访问模式。事件系统在货柜状态变更这块很出彩。我们定义了一个ContainerStatusUpdated事件状态变化时就广播出去然后一系列监听器各干各的有的发WebSocket通知堆场大屏有的写日志表有的自动创建闸口放行记录。这样做的好处是新增一个业务动作不用改原方法比如后来加了个“发送短信给货主”的需求我只需要新注册一个监听器完全不用动原来的状态流转代码。代码的低耦合在那一次改动上体现得淋漓尽致。队列我前面提到了这里补充一个经验任务队列的失败重试必须设置上限并且失败后要落入一个人工处理表。因为码头作业有时间属性一个几小时前的装船任务自动重试可能已经没意义了所以要人工介入干预。这个设计在最开始被我忽略了结果有一次数据库连接抖动几百条装船任务在队列里反复重试把下游龙门吊系统给顶崩了。后来给每个任务加了attempts上限和失败通知才把这个隐患解决。3.2 ThinkPHP侧设备对接与报表的轻量方案ThinkPHP在老系统那边果然是老兵扛住了所有“脏活累活”。闸口道闸控制器走的是串口HTTP协议RFID读卡器是固定IP的Socket服务这些设备跟外部对接的协议很古老很多包格式是十几年前的十六进制约定用Laravel那套重型生态去搞反而碍手碍脚ThinkPHP的轻量特质和成熟的数据库链式操作在这种场景下特别合适。ThinkPHP这边我最大的改造点是报表导出。以前外包写的是一个查询页面数据超5000行大概率超时或者内存溢出。我接手后改成了异步导出前端把筛选条件提交到/legacy/report/export接口ThinkPHP收到请求后把任务写入export_tasks表状态置为queued一个常驻CLI脚本每分钟扫描一次这张表拿到任务后用PHPExcel分页读取数据库配合ob_clean清缓冲区边读边写CSV生成完毕更新状态为done前端轮询到后弹窗提供下载链接这套异步导出扛过了单次十万行数据的导出需求实测内存占用峰值控制在200MB以内比之前的同步方案稳了一个数量级。关于双框架数据操作的一致性我们是靠“Laravel写核心表ThinkPHP只读少量写设备表”的分层纪律实现的。比如闸口的“道闸放行记录表gate_pass_records”由ThinkPHP写但货柜状态表containers不允许ThinkPHP直接update必须调用我们封装在Laravel侧的一个内部HTTP接口/api/v1/internal/container/update-status。这样做的原因是防止两边都写同一张表、业务规则互相覆盖。如果你们也在做双框架协作这条规矩强烈推荐——边界要清晰宁可多一次HTTP调用也不要让两套代码同时操作一张核心业务表。3.3 双框架鉴权与统一Token方案一张用户表、两套框架怎么让登录状态打通我当时的方案是用户统一在Laravel侧登录Laravel签发一个JWT Token返回给前端。前端访问ThinkPHP那边的接口时把Token放在Authorization头里带过去。ThinkPHP侧写了一个TokenAuthMiddleware收到请求后先解析JWT校验签名和有效期再拿Token里面的user_id去Redis查一下用户状态如果Redis里没有就回源到MySQL查一次整个过程不到十毫秒。踩过的坑是JWT密钥管理。最初两边的密钥直接写死在配置文件里后来发现代码仓库一旦发生泄露整个认证体系就完蛋。后来改成了环境变量注入部署时从密钥管理服务读取配置文件里只放占位符。这是做企业级系统必须养成的习惯——任何密钥、密码都别进代码库。还有个细节ThinkPHP老代码里很多地方直接用$_SESSION[user_id]JWT这套本质上没有会话所以中间件解析完Token之后要用session_id()的方式兼容一把或者在请求生命周期内把user_id绑定到一个全局上下文里。我做的是后者在BaseController的构造函数里存入$this-currentUserId老代码里凡是引用了SESSION的地方统一改成读这个属性。改起来不算多但能保老模块继续正常运转在混合架构迁移期这笔改造投资非常值。4. 部署上线与性能优化实操记录4.1 环境准备与项目目录结构开发环境我们用的是LinuxNginxMySQLRedis的标准组合PHP版本当时是7.4Laravel用的8.xThinkPHP那边因为老代码用的3.x语法PHP 7.4已经是它支持的天花板了再高版本会报一堆废弃警告这算是老系统迁移的第一个坑。目录结构我做了物理拆分两个框架的代码放在同一个代码仓库下的不同子目录Nginx按路径分流server { listen 80; server_name port.example.com; # 新业务API - Laravel location /api/ { root /var/www/port-core/public; try_files $uri /index.php?$query_string; fastcgi_pass unix:/run/php/php7.4-fpm.sock; } # 遗留模块 - ThinkPHP location /legacy/ { root /var/www/port-legacy; index index.php; fastcgi_pass unix:/run/php/php7.4-fpm-legacy.sock; include fastcgi_params; } }两个PHP-FPM池隔离很重要。有一次Laravel侧队列任务占用大量进程如果没有独立的FPM池ThinkPHP的闸口设备接口会被连带拖死闸口那边一死整个码头的进出场全瘫痪。这个教训是用一次真实事故买来的你们在做拆分时务必把不同框架的进程池分开配置。部署流程用的是Ansible脚本实现代码推送到Git后在服务器上拉取composer install 数据库迁移 队列重启一条命令完成。后来业务量大了又上了个简单的灰度发布利用Laravel的config:cache和对称负载均衡器把请求流量逐步切到新版本回滚也方便很多。4.2 高并发场景下的三个优化实战码头系统的并发并非常规意义上的“高并发”没那么夸张但在闸口高峰期确实会出现短时间内的集中请求。比如早上八九点外集卡排队进闸可能1分钟内有几十辆车同时交单。这种场景下系统要是卡一次现场车辆就排长队体验极差。我在这里做了三方面的优化。第一个优化是闸口查询接口的Redis缓存。整个查询链路是外集卡司机报箱号→系统查货柜信息箱重、位置、是否放行→返回结果给闸口屏幕。这个查询频率高、逻辑固定非常适合缓存。我把货柜的“查询结果”序列化后缓存到Rediskey格式是container:query:{container_no}过期时间5分钟。货柜状态变化时主动删除对应缓存。实测下来闸口高峰期接口响应从平均800ms降到了150ms左右效果立竿见影。第二个优化是数据库冷热分离。货柜状态表里查询最多的是“在场柜”和“近期出场柜”。我把出场超过30天且状态已终态的柜子通过定时任务迁移到containers_archive表里只保留必要字段。这样热表的行数从几百万降到十万级索引大小和查询性能都得到了明显改善。操作的时候记得迁移要分批拿用主键ID做游标避免一次性DELETE大表的锁问题。第三个优化是写入侧合并。货车同时进闸时闸口操作员会批量录入多个柜子的进场信息如果这些柜子属于同一艘船的卸船批次我们就在Service层做“攒批”逻辑先暂存在Redis列表里等到攒满50个或者超过2秒后再用一次事务批量写入。这种合并写入方式极大减少了数据库事务次数配合Laravel的upsert方法实测批量导入500个柜的数据用时从原来的3分多钟缩短到了20秒以内。4.3 常见问题速查表这套系统运行以来我记录了不少实际遇到的坑汇总成一张速查表给后来人当个参考。这里挑最有共性的几条展开讲讲。问题现象根因分析解决方案货柜查重偶尔失败出现重复箱号入库代码里做了存在性检查但没加唯一索引高并发下两个请求同时通过检查给containers.container_no加UNIQUE索引代码层捕获唯一冲突异常后重试查询跨框架操作后货柜状态不一致Laravel改状态后没有通知ThinkPHP侧导致闸口端看到的箱状态还是旧的状态变化后通过Redis PUB/SUB发布消息两侧订阅各自的频道刷新报表导出内存溢出一次性把十万行数据加载进PHP数组处理改为流式导出每次读500条写入临时缓冲定期flush定时任务在高峰期抢数据库连接堆场盘点脚本用了多线程并发查询限制定时任务并发数加锁Cache::lock让同一时刻只有一个实例运行时间字段显示差8小时两个框架默认时区配置不一致ThinkPHP是PRCLaravel读取UTC后没转换统一配置date_default_timezone_set(Asia/Shanghai)并检查MySQL连接时区这里最想提醒大家的还是货柜查重那个坑。代码层面的IF判断在高并发下根本不可靠必须靠数据库层的唯一索引做兜底。我们当时上线后第一次闸口批量录入就触发了重复因为没有这个索引数据里悄然混进了两个相同箱号。幸运的是后续复核及时发现了否则这两个相同箱号的柜子流转起来卸到不同贝位后期追查就是一场灾难。后来查业务日志发现是操作员看错了箱号手动改过所以除了数据库兜底前端也加了二次确认弹窗这才算彻底堵住。5. 经验复盘与后续扩展方向5.1 复盘设计阶段最容易犯的三个错这套系统完整做下来回过头看最值得复盘的是设计阶段踩的几个错。一是需求理解不够透彻就画了表。我最初建货柜表时只设计了“所在堆场”一个字段完全不知道码头作业里还有“贝位”这么个精确位置概念导致上线一周后要加位置坐标字段。虽然迁移能做但当时线上已有几万条数据重建索引加上回填数据花了不少工时。这提醒我开始画表之前至少要让业务人员拿着实际单据场景给系统“走查”一遍流程特别是那些特殊场景比如箱体破损查验、冷藏箱插座取电这种默认情况覆盖得再多特殊场景的设计遗漏才是最伤筋动骨的。二是低估了状态机的重要性。早期版本里船只状态和货柜状态都是直接UPDATE谁都能改完全没限制。直到有一次调度岗的实习生不小心把一条已离港的船点回了“报港”状态导致船公司在系统里看到的船舶动态全乱了被商务同事一顿狠批之后我才花时间把所有状态流转全部封装成Service方法加上前置校验。这个改造不难但让我彻底明白了一个道理核心业务状态不能裸奔必须由代码层看护。三是没提前规划审计日志。最初觉得货柜状态记录有个日志表就够了但船公司对账时要翻“某一天某条船实际卸了几个柜、每个柜几点几分进场”这其实是业务过程数据一种要长期留存、不可篡改的业务凭证。刚开始日志表只管了状态变更其他操作信息都不全后来不得不补了一套操作审计中间件在框架层面统一记录所有写操作的请求参数和操作人。如果一开始就规划好后续省掉的返工成本会非常可观。5.2 后续建议这套系统还能往哪里扩展系统运行稳定之后我在想它未来的演化方向这里给几个我觉得可行的扩展点。第一个方向是引入“大数据分析”的初级形态。已经积累了海量的船和柜数据可以做靠泊预测、泊位智能分配、堆场利用率分析甚至预测某条航线未来三天到港量提前安排人手和机械。这个不需要一步到位上大数据平台先从MySQL的联合查询加统计报表做起数据量到了再考虑同步到分析型数据库。第二个方向是给外集卡司机做一个小程序端。司机最关心的就是“我的柜在哪、能不能提、要排队多久”目前这些信息还在闸口屏幕上没办法远程查。做一个轻量的小程序对接Laravel的API司机在家就能看闸口拥堵指数、预约提箱时间既可以错峰也减少现场排队。这个场景可行性很高因为底层数据和鉴权体系都是现成的只差一个对外面向司机的服务入口。第三个方向是物联网设备的深度联动。现在闸口已经有RFID和道闸了后续可以把龙门吊的实时定位、岸桥的作业状态接入系统让调度中心像看地图一样看到每一台机械在哪、在干什么。这样调度员就不用靠对讲机问“那台空吊什么时候能过来”系统里直接可见作业效率的提升空间会非常大。一点实在的收尾话做这种业务系统我最大的体会是三分技术七分业务。PHP框架再多、代码技巧再炫搞不懂码头怎么干活写出来的功能就是空中楼阁。你可能踩过跟我一样的坑——以为自己把需求玩明白了结果上线后业务一句话问住你“我的船图上为什么没有显示贝位”那一刻才意识到最值钱的不是你会在Laravel里写多少队列任务而是你愿不愿意蹲在闸口看半天集卡进出的那种工程视角。最后分享一个我自己一直在用的小技巧每做完一个版本迭代把跟业务经理的沟通纪要里那些“随口一提”的话都翻一遍很多被忽略的细节比如“有时候船没卸完就先提一部分柜”“冷柜插座会不会不够用”都可能成为下个版本的隐藏需求点。好的管理系统从来不是一次设计出来的是跟业务一起“磨”出来的。这套双框架的船柜系统从上线到现在修改版本已经迭代了六七个但它稳稳地托住了码头的日常运转这大概就是我们做软件的人最实在的成就感了。
返回列表