我们码头这套系统上线快一年了,一直想写篇复盘,正好趁着手头项目收尾,把当初从零搭建“船只+货柜管理”这套东西的思路和踩过的坑都倒出来。标题看着长,其实就是个很典型的港口物流场景——“船要进港、柜要落地、车要提箱”,三件事串起来就是整个码头的日常。当时定的技术栈是PHP双框架:Laravel做核心业务API,ThinkPHP管报表和闸口设备对接。很多人问为什么不直接二选一,这个后面细讲,但先说结论:这组合在中小码头的信息化改造里其实挺能打,前提是分工够清楚。
这套系统解决的痛点,如果你管过码头或者堆场一定深有体会:船只到港时间、泊位占用、货柜堆在哪个贝位、外集卡提箱排多久队、船公司对账时箱号谁对不上……全是线下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签发统一放在Laravel,ThinkPHP那边只需要校验Token合法性就行。这样既没推翻旧代码,又让新的业务模块跑在更现代的框架上,可以说是性价比最高的迁移路径。
分工上具体是这样拆的:
- Laravel侧:船只管理、泊位计划、货柜状态流转、装卸任务单、用户权限、API鉴权
- ThinkPHP侧:闸口道闸控制、RFID读卡对接、Excel报表导出、短信通知、历史数据迁移脚本
不过要说句实话,双框架也有代价,最痛的是两边代码风格不统一,新来的同事要上手两套框架,学习成本直接翻倍。所以后来我们定了个规矩:新写的业务代码一律走Laravel,ThinkPHP那部分只维护不扩张,等闸口设备协议升级的时候再逐步替换。
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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 环境准备与项目目录结构
开发环境我们用的是Linux+Nginx+MySQL+Redis的标准组合,PHP版本当时是7.4,Laravel用的8.x,ThinkPHP那边因为老代码用的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缓存。整个查询链路是:外集卡司机报箱号→系统查货柜信息(箱重、位置、是否放行)→返回结果给闸口屏幕。这个查询频率高、逻辑固定,非常适合缓存。我把货柜的“查询结果”序列化后缓存到Redis,key格式是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是PRC,Laravel读取UTC后没转换 | 统一配置date_default_timezone_set('Asia/Shanghai')并检查MySQL连接时区 |
这里最想提醒大家的还是货柜查重那个坑。代码层面的IF判断在高并发下根本不可靠,必须靠数据库层的唯一索引做兜底。我们当时上线后第一次闸口批量录入就触发了重复,因为没有这个索引,数据里悄然混进了两个相同箱号。幸运的是后续复核及时发现了,否则这两个相同箱号的柜子流转起来,卸到不同贝位,后期追查就是一场灾难。后来查业务日志发现是操作员看错了箱号手动改过,所以除了数据库兜底,前端也加了二次确认弹窗,这才算彻底堵住。
5. 经验复盘与后续扩展方向
5.1 复盘:设计阶段最容易犯的三个错
这套系统完整做下来,回过头看,最值得复盘的是设计阶段踩的几个错。
一是需求理解不够透彻就画了表。我最初建货柜表时只设计了“所在堆场”一个字段,完全不知道码头作业里还有“贝位”这么个精确位置概念,导致上线一周后要加位置坐标字段。虽然迁移能做,但当时线上已有几万条数据,重建索引加上回填数据花了不少工时。这提醒我,开始画表之前,至少要让业务人员拿着实际单据场景给系统“走查”一遍流程,特别是那些特殊场景,比如箱体破损查验、冷藏箱插座取电这种,默认情况覆盖得再多,特殊场景的设计遗漏才是最伤筋动骨的。
二是低估了状态机的重要性。早期版本里船只状态和货柜状态都是直接UPDATE,谁都能改,完全没限制。直到有一次调度岗的实习生不小心把一条已离港的船点回了“报港”状态,导致船公司在系统里看到的船舶动态全乱了,被商务同事一顿狠批之后,我才花时间把所有状态流转全部封装成Service方法,加上前置校验。这个改造不难,但让我彻底明白了一个道理:核心业务状态不能裸奔,必须由代码层看护。
三是没提前规划审计日志。最初觉得货柜状态记录有个日志表就够了,但船公司对账时要翻“某一天某条船实际卸了几个柜、每个柜几点几分进场”,这其实是业务过程数据,一种要长期留存、不可篡改的业务凭证。刚开始日志表只管了状态变更,其他操作信息都不全,后来不得不补了一套操作审计中间件,在框架层面统一记录所有写操作的请求参数和操作人。如果一开始就规划好,后续省掉的返工成本会非常可观。
5.2 后续建议:这套系统还能往哪里扩展
系统运行稳定之后,我在想它未来的演化方向,这里给几个我觉得可行的扩展点。
第一个方向是引入“大数据分析”的初级形态。已经积累了海量的船和柜数据,可以做靠泊预测、泊位智能分配、堆场利用率分析,甚至预测某条航线未来三天到港量,提前安排人手和机械。这个不需要一步到位上大数据平台,先从MySQL的联合查询加统计报表做起,数据量到了再考虑同步到分析型数据库。
第二个方向是给外集卡司机做一个小程序端。司机最关心的就是“我的柜在哪、能不能提、要排队多久”,目前这些信息还在闸口屏幕上,没办法远程查。做一个轻量的小程序,对接Laravel的API,司机在家就能看闸口拥堵指数、预约提箱时间,既可以错峰,也减少现场排队。这个场景可行性很高,因为底层数据和鉴权体系都是现成的,只差一个对外面向司机的服务入口。
第三个方向是物联网设备的深度联动。现在闸口已经有RFID和道闸了,后续可以把龙门吊的实时定位、岸桥的作业状态接入系统,让调度中心像看地图一样看到每一台机械在哪、在干什么。这样调度员就不用靠对讲机问“那台空吊什么时候能过来”,系统里直接可见,作业效率的提升空间会非常大。
一点实在的收尾话
做这种业务系统,我最大的体会是:三分技术,七分业务。PHP框架再多、代码技巧再炫,搞不懂码头怎么干活,写出来的功能就是空中楼阁。你可能踩过跟我一样的坑——以为自己把需求玩明白了,结果上线后业务一句话问住你:“我的船图上为什么没有显示贝位?”那一刻才意识到,最值钱的不是你会在Laravel里写多少队列任务,而是你愿不愿意蹲在闸口看半天集卡进出的那种工程视角。
最后分享一个我自己一直在用的小技巧:每做完一个版本迭代,把跟业务经理的沟通纪要里那些“随口一提”的话都翻一遍,很多被忽略的细节,比如“有时候船没卸完就先提一部分柜”“冷柜插座会不会不够用”,都可能成为下个版本的隐藏需求点。好的管理系统,从来不是一次设计出来的,是跟业务一起“磨”出来的。这套双框架的船柜系统从上线到现在,修改版本已经迭代了六七个,但它稳稳地托住了码头的日常运转,这大概就是我们做软件的人最实在的成就感了。