资讯动态

基于Spring Boot的码头船只货柜管理系统设计与实践

发布时间:2026/9/15 9:10:08 来源:尧图企业网站定制
简介这是一套基于SpringBoot的码头船只货柜管理系统设计与实现资料面向Java方向课程作业、毕业设计也适合需要了解港口物流业务系统开发的读者。系统功能覆盖多角色用户权限、船只信息登记与靠港出港安排、货柜编号及位置追踪、货物装卸计划制定与进度监控、库存查询统计、调度与运输协调等模块能从数据库表设计、后端接口到前端页面完整呈现项目实现思路。压缩包共731个文件大小约17.65MB包含93个Java后端源码、46个Vue前端组件、49个CSS样式、156个JS交互脚本以及SQL数据库脚本、一键安装/启动批处理、项目说明文档和多格式图片图标方便直接还原整套工程。另附毕业论文、开题报告和任务书可辅助理解课题背景、研究内容与技术路线在撰写报告或进行系统演示时对照参考。目前已有41人浏览学习适合作为SpringBoot毕业设计或课程项目开发时的参考实例。1. 码头货柜调度不能只靠ExcelSpring Boot才是那个把流程钉死的底盘做过港口物流信息化的都懂码头业务表面上是船来了、柜子卸下来、堆场放一放、再装上另一条船实际跑起来全是状态变更靠港时间要记、货柜从船上挪到堆场要记、空柜满柜要切、装卸进度要实时可见。用Excel管车队长问一句那个柜子在哪你得翻三张表。这套基于Spring Boot的码头船只货柜管理系统本质上就是把船只—货柜—堆场—装卸计划这条链路上的每个状态位都做成数据库里可查询、可追溯的记录。对做毕设或者中小码头内部工具来说Spring Boot的价值在于内置的starter体系能快速把Web层、持久层、权限框架接起来你只需要专注写业务规则不需要从零搭骨架。适合两类人一是拿它当Java Web毕业设计的蓝本二是中小型物流公司想用一个能改的内网管理系统。2. 领域建模与工程结构先把船、柜、堆场的关系设计成表2.1 核心实体识别从业务流程倒推数据模型码头作业最忌讳的是把船只货柜装卸揉成一张大表。这个系统的功能清单里藏着六个核心域用户、船只、货柜、装卸、库存、调度。我的建议是第一版不要急着写代码先拿一张纸把主流程画出来船只到港 → 登记靠港 → 生成装卸计划 → 货柜从船上卸到堆场或从堆场装上船→ 库存变动 → 出港确认。每一个箭头都对应至少一张表。以这个思路拆解第一版表结构大概是这样的表名关键字段说明ship_infoship_name, ship_type, tonnage, voyage_no, status船只基本信息status标记在港/离港berth_recordship_id, berth_no, arrival_time, departure_time靠港记录一条船可以有多条历史container_infocontainer_no, size_type, max_weight, status, location_type, location_id货柜当前状态和位置冗余存储便于查询load_unload_planship_id, plan_type, plan_time, status, operator_id装卸计划主表load_unload_itemplan_id, container_id, operation_type, sequence计划明细一个计划对应多个货柜inventory_recordcontainer_id, warehouse_no, position_no, operation_type, change_time库存变更流水s hip_info和berth_record分开是刻意的。船只的静态属性和靠港动态记录如果混在一张表里每次查询在港状态都要过滤历史靠港记录索引压力大。拆开后查当前在港船舶就是WHERE status IN_PORT简单直接。container_info里的location_type和location_id是典型的可扩展点位设计。location_type 取枚举值DOCK码头作业区、YARD堆场、SHIP船上location_id 对应具体位置编号。这样当货柜从船上卸到堆场时只需要更新这一个实体的两个字段追踪逻辑就不会散落在各种业务代码里。2.2 Spring Boot工程骨架与Maven依赖工程结构推荐按模块分包而不是按层分包。也就是说不要建controllerservicemapper三个大包然后往里面塞几百个类而是按业务域拆user、ship、container、loadplan、inventory、dispatch。每个域内部再分层。这样改动船只模块不会误碰货柜模块多人协作时冲突也少。Maven依赖里这几个是必须的dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId version2.7.18/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId version2.7.18/version /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency选 MyBatis-Plus 而不是 Spring Data JPA是因为码头货柜查询场景里多表关联和条件拼接太频繁了比如查所有堆场里承重10吨以上的40尺满柜MyBatis-Plus 的LambdaQueryWrapper能把这种动态条件写得直接可读。Spring Boot 版本用 2.7.x 而不是 3.x因为 3.x 基于 Spring Framework 6javax.*改成了jakarta.*很多教材和老项目里的代码直接迁移会报包名错误。如果是毕设或者改造老系统2.7.18 是目前兼容性最稳的版本。代码里分页插件需要单独配置Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页插件不注册的话Page对象查出来 total 永远是0这是新手最容易踩的坑。注册之后PageContainerInfo page new Page(current, size);就能正常工作。PaginationInnerInterceptor构造参数里的DbType.MYSQL指定方言让它生成LIMIT语句而不是其他分页写法。2.3 用户权限RBAC模型在码头场景的最小落地功能清单里的管理员、操作员、调度员三种角色对应的是 RBAC基于角色的访问控制模型。不需要引入太重的工作流引擎Spring Security 加四张表就能解决user、role、user_role、role_permission。权限粒度控制在菜单级别足够——操作员能进货柜管理但不能删除记录调度员能创建装卸计划管理员拥有全部端点。实际配置里核心是让未认证请求返回 JSON 而不是重定向到登录页Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeRequests() .antMatchers(/api/auth/login, /api/public/**).permitAll() .antMatchers(/api/ship/**, /api/container/**).hasAnyRole(ADMIN, OPERATOR) .antMatchers(/api/loadplan/**).hasAnyRole(ADMIN, DISPATCHER) .anyRequest().authenticated() .and() .exceptionHandling().authenticationEntryPoint((req, resp, e) - { resp.setContentType(application/json;charsetUTF-8); resp.setStatus(401); resp.getWriter().write({\code\:401,\msg\:\未登录或会话已过期\}); }); }csrf().disable()是因为前后端分离项目通过 Token 做认证没有 Session Cookie 的 CSRF 风险面。antMatchers的规则顺序是重要的请求地址如果同时匹配了所有人可见和指定角色可见两条规则Spring Security 按声明顺序取第一条匹配项所以更开放的规则必须写在前面。如果把这个顺序写反登录接口也会被拦截排错时看日志只会看到异常没有 401容易绕弯子。3. 靠港与装卸把状态流做成代码里看得见的规则3.1 状态机设计船和柜的每一次移动都要有迹可循船只有三个状态航行中SAILING、靠港中BERTHED、离港DEPARTED。货柜状态在这个系统里不能只用空/满区分必须结合位置船上的满柜、堆场的空柜、堆场的满柜、装卸中的柜子这四种状态对应完全不同的业务操作。如果只存一个status字段查哪些柜子在堆场且已满载等待装船就得靠多条件组合而且没法预防漏更状态。我的做法是引入一个container_status枚举类public enum ContainerStatus { ON_SHIP(船上, 1), IN_YARD_EMPTY(堆场空柜, 2), IN_YARD_FULL(堆场满柜, 3), LOADING(装卸中, 4); private final String desc; private final int code; ContainerStatus(String desc, int code) { this.desc desc; this.code code; } }状态迁移规则写在 service 层不散落在 controller 里。比如从船上卸到堆场这个动作合法路径是ON_SHIP → IN_YARD_FULL如果柜子是满的或ON_SHIP → IN_YARD_EMPTY如果是空柜但绝不能IN_YARD_EMPTY → ON_SHIP然后还带着空柜状态——空柜装上船没有任何业务意义应该报错。响应中直接返回不能从XX状态到XX状态的提示比笼统的操作失败对用户要明确得多。这是代码里最值得仔细打磨的部分所有装卸异常、对不上的货柜编号、状态跳跃基本都能在迁移规则处拦住。3.2 装卸计划落库主从表结构与事务边界装卸计划是码头业务的核心单据。一个计划对应多条货柜明细必须用主从表结构主表和明细表分别落库并且用同一个事务包住。如果先插入主表再逐条插入明细中间任何一条失败主表已经提交了后续就查不到完整数据。Transactional(rollbackFor Exception.class) public Long createLoadUnloadPlan(PlanCreateRequest request) { LoadUnloadPlan plan new LoadUnloadPlan(); plan.setShipId(request.getShipId()); plan.setPlanType(request.getPlanType()); // LOAD / UNLOAD plan.setPlanTime(request.getPlanTime()); plan.setStatus(DRAFT); plan.setOperatorId(SecurityUtils.getCurrentUserId()); loadUnloadPlanMapper.insert(plan); ListLoadUnloadItem items request.getContainers().stream() .map(c - { LoadUnloadItem item new LoadUnloadItem(); item.setPlanId(plan.getId()); item.setContainerId(c.getContainerId()); item.setOperationType(c.getOperationType()); item.setSequence(c.getSequence()); return item; }) .collect(Collectors.toList()); for (LoadUnloadItem item : items) { loadUnloadItemMapper.insert(item); updateContainerStatus(item.getContainerId(), item.getOperationType()); } return plan.getId(); }Transactional(rollbackFor Exception.class)这个写法是必须的。Spring 默认只回滚 RuntimeException如果你在业务代码里 catch 了异常后重新抛出new Exception()受检异常事务不会回滚数据就停留在半成品状态。rollbackFor显式指定后任何异常都会触发回滚。明细插入和状态变更放在同一个循环里顺序上先插入明细再更新状态这样即使中途失败回滚后不会出现明细不存在但货柜状态已变更的数据错乱。装船和卸船的方向判断在updateContainerStatus内部做private void updateContainerStatus(Long containerId, String operationType) { ContainerInfo container containerMapper.selectById(containerId); if (container null) { throw new BizException(货柜不存在: containerId); } switch (operationType) { case UNLOAD_FROM_SHIP: container.setLocationType(YARD); container.setStatus(IN_YARD_FULL); break; case LOAD_TO_SHIP: container.setLocationType(SHIP); container.setStatus(ON_SHIP); break; default: throw new BizException(不支持的作业类型: operationType); } containerMapper.updateById(container); }这里的BizException是自定义运行时异常由全局异常处理器统一捕获。这个设计的意义在于service 层只负责抛出业务语义明确的异常货柜不存在状态不支持controller 层不写 try-catch最终由RestControllerAdvice捕获并转成统一 JSON 结构。代码会干净很多也不会在每层重复处理错误。查询装卸进度时常见的做法是执行一条关联统计 SQLSELECT p.id AS plan_id, p.plan_type, p.status, p.plan_time, COUNT(i.id) AS total_items, SUM(CASE WHEN i.finish_flag 1 THEN 1 ELSE 0 END) AS finished_items FROM load_unload_plan p LEFT JOIN load_unload_item i ON p.id i.plan_id WHERE p.ship_id #{shipId} GROUP BY p.id, p.plan_type, p.status, p.plan_time ORDER BY p.plan_time DESC这条 SQL 查的是这条船最近装卸计划各完成了多少柜LEFT JOIN保证计划还没有录入明细时也能返回主表记录CASE WHEN用于按完成标记统计数量返回结果里 total_items 为 0 说明明细还没绑定。进度监控页面每 30 秒轮询一次对应接口显示5/12 已完成这种直观的进度条操作员就能在不停确认的日常状态里快速判断计划是否需要干预。4. 库存台账与调度联动让堆场里的柜子不再靠人肉记忆4.1 库存流水表查询快照永远赶不上追加流水可靠库存管理这块最容易犯的错是只保存最新状态。开业第一天堆场 500 个柜子记当前库存是 500第二天出了 30 个还剩 470第三天你又想知道这个月总共周转了多少个柜子——如果只有快照这个数据就永远拿不出来了。这个系统的库存设计思路是inventory_record流水表 冗余的当前状态字段并用。每次货柜移动到新的位置从堆场A到堆场B、从船上到堆场、从堆场到船上都追加一条流水记录同时更新container_info里的当前位置和状态。INSERT INTO inventory_record ( container_id, warehouse_no, position_no, operation_type, change_time, operator_id ) VALUES ( #{containerId}, #{warehouseNo}, #{positionNo}, #{operationType}, NOW(), #{operatorId} );流水记录是只增不改的。operator_id存下操作人是日后追溯这个柜子是谁移动的、什么时候移动的的关键节点。有些团队图省事不记操作人真出了问题排查成本极高为了少填一个字段把责任链断了不值得。为了简化操作移动货柜这个动作建议抽成一个通用的moveContainer(containerId, targetWarehouse, targetPosition)方法内部完成三件事更新container_info的位置状态、插入inventory_record流水、更新所属装卸计划明细的完成标记。三个动作共用一个事务不会出现位置变了但库存流水没记的情况。4.2 库存报表多个维度的统计码头管理者每天要看的几个数字无非是当前堆场总柜数、空柜占比、最近7天周转量、各堆场利用率。这些报表如果用 Java 代码在内存里循环统计数据量大了以后效率极低。正确做法是交给 MySQL 的 GROUP BY 和日期函数。SELECT warehouse_no, COUNT(*) AS total_containers, SUM(CASE WHEN status IN_YARD_EMPTY THEN 1 ELSE 0 END) AS empty_count, SUM(CASE WHEN status IN_YARD_FULL THEN 1 ELSE 0 END) AS full_count FROM container_info WHERE location_type YARD GROUP BY warehouse_no;这条语句能算出每个堆场的在库柜数、空柜数、满柜数。一个堆场空柜过多说明出口货源不足满柜积压则可能意味着进口接卸能力跟不上这些数字既是业务指标也是堆场容量规划的输入。周周转量的统计用DATE_SUB从流水中取数据SELECT DATE(change_time) AS day, COUNT(*) AS turnover_count FROM inventory_record WHERE change_time DATE_SUB(CURDATE(), INTERVAL 7 DAY) AND operation_type IN (MOVE_IN, MOVE_OUT) GROUP BY DATE(change_time) ORDER BY day DESC;写条件时change_time 比DATE(change_time) 好因为后者会让 MySQL 在 change_time 上执行全表扫描、放弃索引。INTERVAL 7 DAY的语义是从今天零点往前的7天日常周报足够用。接口层把查询结果封装成MapString, Object返回前端用 ECharts 渲染成折线图管理者一眼看出吞吐趋势的涨落。代码里要显式设置GROUP BY的字段和SELECT的非聚合字段一致MySQL 的ONLY_FULL_GROUP_BY模式默认开启不一致直接报错。4.3 调度协调货柜任务分配与事件通知调度这块系统不直接对接司机 App 的话就先做成任务池。调度员创建运输任务分配状态从PENDING待分配到ASSIGNED已指派再到COMPLETED完成。分配逻辑按工作量均衡优先把任务分给当前待处理任务数最少的司机或运输队。MySQL 里用一条带计数子查询的更新语句就能实现UPDATE transport_task t SET t.driver_id ( SELECT driver_id FROM driver_load WHERE load_count ( SELECT MIN(load_count) FROM driver_load WHERE status ACTIVE ) LIMIT 1 ), t.status ASSIGNED, t.assign_time NOW() WHERE t.id #{taskId};这种每次都实时查最少负载司机的方式在任务量几百条时有可用性而且逻辑直观先取最小负载值再取对应司机 ID最后写入任务。不过要注意如果司机量上千且任务并发高这条 SQL 在并发时容易重复分配给同一个人届时可以加FOR UPDATE行锁来补救需要的时候再改就行。通知靠 Spring Boot 的事件机制任务创建后发布TaskAssignedEvent监听器里发站内信或 WebSocket 消息推送给对应司机。WebSocket 要记住配心跳30 秒没消息浏览器会断开长连接用户下次就收不到通知了。5. 权限边界与部署脚本交付前必须确认的几个细节5.1 角色权限的二次校验前端隐藏不够后端拦截才算数页面上的按钮权限只是用户体验层面的事真正防越权的是后端接口校验。Spring Security 的hasRole解决了这个操作员能不能访问装卸接口的问题但解决不了这个操作员能不能访问他负责范围之外的船只数据。数据级权限需要在 service 层加一道隐性判断public ListLoadUnloadPlan queryPlanByShip(Long shipId) { // 普通操作员只能看自己创建的或参与过的计划 if (SecurityUtils.hasRole(OPERATOR)) { Long userId SecurityUtils.getCurrentUserId(); LambdaQueryWrapperLoadUnloadPlan wrapper new LambdaQueryWrapper(); wrapper.eq(LoadUnloadPlan::getShipId, shipId) .and(w - w.eq(LoadUnloadPlan::getOperatorId, userId) .or().apply(EXISTS (SELECT 1 FROM load_unload_item i JOIN transport_task t ON i.id t.item_id WHERE t.driver_id {0}), userId)); return loadUnloadPlanMapper.selectList(wrapper); } return loadUnloadPlanMapper.selectList( new LambdaQueryWrapperLoadUnloadPlan().eq(LoadUnloadPlan::getShipId, shipId) ); }这个例子说明的是代码里先用角色判断给数据查询加了边界管理员能看全量操作员只能看自己的计划。MyBatis-Plus 的.apply(EXISTS ...)片段是拼接原生 SQL 的常用方式注意{0}占位符传参不要直接拼字符串防止 SQL 注入。这里暴露了一个常见误用如果单靠前端路由隐藏来限制权限直接用浏览器开发者工具改响应就能绕过。后端每个涉及数据的接口都值得自问一句这个角色该不该看到这些数据。5.2 三件套批处理脚本从裸机到启动的自动化拿到项目压缩包解压后第一眼看到的1-install.bat、2-run.bat、3-build.bat三件套这个设计其实就是把部署流程标准化了避免每次部署都要手动敲 Maven 命令。打开内容看是这类逻辑echo off REM 1-install.bat 初始化数据库脚本 mysql -uroot -p schema.sql echo 数据库初始化完成 pause1-install.bat执行的是建库建表和初始数据导入2-run.bat是开发环境直接调用mvn spring-boot:run启动项目3-build.bat打包生产 jar。三件套在团队交接时价值明显任何一个人拿到代码按号码顺序点三次就能跑起来不用去翻 README 里写了又没更新过的启动说明。有个 Windows 下容易踩的细节bat 脚本里的中文注释在 GBK 编码下正常但如果开发机用的是 UTF-8 编码保存执行时会显示乱码甚至某些命令会因编码错乱而执行失败。把脚本文件另存为 ANSI 编码再分发问题就解决了。2-run.bat里如果加了title命令标识窗口多个微服务实例调试时一眼能分辨哪个窗口是哪个服务。5.3 安全加固Spring Boot Actuator 与堆转储文件泄露这是 5 年以上工程师接手后会重点检查的点。Spring Boot 的 Actuator 默认暴露了/actuator/heapdump端点如果你在依赖里引入了spring-boot-starter-actuator但是没有做安全配置任何人访问http://ip:8080/actuator/heapdump就能下载整个堆转储文件——里面装着内存里所有字符串常量包括数据库账号、Redis 密码、业务私钥。这个泄露途径在最近几年不断被安全研究者证实可被利用。management: endpoints: web: exposure: include: health,info endpoint: health: show-details: never把暴露范围收敛为只留health和infohealth的详情也不要对外展示show-details: never因为你不想让外部探测到数据库连接池的剩余状态。如果你的端口是公网可达的强烈建议再检查一下spring-boot-starter-web是否开启了 Swagger 或 Knife4j 的接口文档自动扫描——生产环境里接口文档直接裸奔等于给攻击者送地图。用springdoc.api-docs.enabledfalse和springdoc.swagger-ui.enabledfalse关掉。5.4 生产验证清单与常见踩坑表格列几个上线前必查项检查项验证方法预期结果数据库连接池压测createLoadUnloadPlan接口连接数不持续飙升超时时间设置有兜底缓存与静态资源反复访问前端页面第二次起走浏览器缓存响应头带Cache-Control日志级别观察启动日志com.xxx.mapper的 SQL 打印默认关掉需要时开 DEBUG定时任务检查自动生成报表任务无重复执行、无漏执行确认单机部署JVM 参数jinfo -flag MaxHeapSize堆大小符合当前机器内存 1/4 的常规配置MyBatis-Plus 的 SQL 日志默认是关闭的排错时在application.yml加一行mybatis-plus.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl执行完查询之后控制台会打印完整 SQL。记得排查完删掉这条配置生产环境打印 SQL 日志量极大而且会把查询参数里的敏感信息写到日志文件里。分页查询还有一个常见认知错误MyBatis-Plus 的Page对象使用后要调用getRecords()取数据而不是getList()——用错方法在编译期不会报错运行期返回空列表但可能误导排查方向。最后提一个优化技巧码头现场的网络环境往往不如办公网稳定所有写操作接口建议在 Controller 入口做一个简单的幂等键校验。前端调用创建装卸计划接口时生成一个requestId后端在 Redis 里用setNx判断这个值是否已经处理过。如果处理过直接返回上次的结果。这样即使操作员手抖点了两次提交调度室也不会凭空多出一条重复的作业计划。这不是什么高深架构但在码头的现场网络条件下能实打实避免不少因为重试导致的数据重复问题。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价