干过“船舶维保管理系统”这个项目之后我才意识到圈子里那些标题带“关注就送源码”的帖子十个里有九个是引流话术但这类系统本身是真有搞头的。航运公司、修船厂、船舶管理公司都在找能落地的东西不是那种套着管理系统的壳、实际只有增删改查的演示项目。我带团队从头做了一版踩了不少坑今天把设计思路、核心模块、代码实现里的关键细节一次性讲透想自己复刻或者二次开发的朋友可以直接参考。1. 项目背景与整体设计思路1.1 为什么船舶维保单独需要一套系统先说业务痛点。船舶设备维护保养跟普通制造业设备管理最大的区别在于设备种类杂、分布分散、作业环境特殊。一条散货船上主机、辅机、锅炉、压载水系统、导航设备、救生消防设备加起来几百项每项都有自己的保养周期和标准。以前很多船管公司是怎么做的轮机长拿Excel排计划大管轮手写工单干没干、什么时候干的全靠码头靠泊时补录台账。结果就是该保养的漏了不该拆的反复拆备件库存心里没数到了海事检查和PSC检查的时候对着记录本翻半天。这套系统解决的四个核心问题一是把维保计划从“人脑记忆”变成“系统自动排期”二是把工单执行从“口头交代”变成“闭环跟踪”三是把备件消耗从“月底盘点”变成“实时扣减、自动预警”四是把管理报表从“人工统计”变成“看板实时刷新”。1.2 技术选型与系统架构技术栈选型这块当时对比过两个方向一套是微服务架构Spring Cloud Alibaba那一套另一套是单体多模块Spring Boot Vue前后端分离。最后选的是后者。原因很实在船舶维保系统的用户量不大一条船最多几十个账号整个公司可能也就几百人同时在线微服务拆出来的好处根本体现不出来反而让部署和运维成本翻倍。单体应用配合好索引、缓存和异步任务性能完全够用。技术栈定为后端Spring Boot 2.7 MyBatis-Plus Spring Security JWT前端Vue 3 Element Plus ECharts数据库MySQL 8.0InnoDBRedis用于缓存和分布式锁文件存储MinIO存放工单附件、设备照片、验收图片定时任务Spring Schedule处理维保计划生成与到期提醒部署Docker Compose一体编排架构上分三层展示层Vue管理端 H5移动端、业务层计划、工单、库存、报表、系统管理五大模块、数据层MySQL Redis MinIO。移动端这块最开始想直接做小程序后来考虑到船员在海上网络不稳定改成了H5嵌入企业微信离线状态下先存本地有网了再同步。实测下来这个决策很重要后面会细说。2. 核心业务模型与数据库设计2.1 从CWBT维保体系到数据模型船舶维保有一个行业标准叫CWBT船舶维修保养工作法核心思想是把全船设备按“系统—设备—部件”三层结构拆成一颗设备树每个节点挂载保养周期和保养内容。这套体系不是某个公司发明的而是船舶行业普遍采用的设备管理方法论很多船级社检查也认可这套逻辑。系统里设备台账和维保计划的设计本质上就是把CWBT体系翻译成数据模型设备树通过parent_id自关联形成树形结构节点类型区分系统、设备、部件。周期类型支持日历周期每30天、每季度、每年和运行周期每运行500小时、每运行1000小时。维保等级日常养护、一级保养、二级保养、三级保养不同等级对应不同的作业内容和工种要求。标准作业包每个保养类型对应一套标准操作步骤和工时预算工单生成时自动带出。设计这块最需要注意的点是不要把所有业务逻辑都塞进代码里CWBT的标准作业内容、保养周期这些都是业务数据应该做成可配置的字典表方便实施人员在不同船型上调整参数。2.2 核心表结构详解数据库设计我直接给关键表的字段和设计理由后面写代码和调优都用得上。第一张是船舶和设备的基础表CREATE TABLE ship_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ship_name VARCHAR(64) NOT NULL COMMENT 船名, ship_type VARCHAR(32) COMMENT 船型散货/集装箱/油轮等, imo_number VARCHAR(16) UNIQUE COMMENT IMO编号, tonnage DECIMAL(10,2) COMMENT 总吨位, engine_type VARCHAR(64) COMMENT 主机型号, build_date DATE COMMENT 建造日期, status TINYINT DEFAULT 1 COMMENT 1在航 0维修 2报废, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT船舶档案表;设备表device_info里除了基础信息外一定要有equip_code设备编码、parent_id所属上级节点、path从根到当前节点的层级路径比如/1/12/128查询子树的时候直接LIKE /1/12/%就能查出来省得递归查数据库。deleted字段做逻辑删除时注意和唯一索引的冲突问题这个坑后面排查章节专门讲。维保计划表和工单表是整个系统的核心我的设计是CREATE TABLE maintenance_plan ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ship_id BIGINT NOT NULL, device_id BIGINT NOT NULL, plan_type VARCHAR(32) COMMENT 保养等级, cycle_type TINYINT COMMENT 1日历周期 2运行周期, cycle_value INT COMMENT 周期数值如30、90、500, plan_start_date DATE COMMENT 计划开始日期, plan_end_date DATE COMMENT 计划截止日期, due_date DATE COMMENT 到期日期, status TINYINT DEFAULT 0 COMMENT 0待执行 1已转工单 2已完成 3已过期, source_type VARCHAR(16) DEFAULT SYSTEM COMMENT 计划来源, last_finish_time DATETIME COMMENT 上次完成时间, next_due_time DATETIME COMMENT 下次到期时间, created_by BIGINT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT维保计划表;工单表设计时有一个关键决定工单和计划分离而不是直接修改计划表的状态。原因是工单会产生执行人、实际工时、耗用备件、验收结果这些过程数据如果全塞在计划表里表字段会臃肿到没法维护。工单表预留了order_no单号格式WO船号日期流水号、assignee_id指派人冗余存这个字段避免每次join、actual_finish_time、quality_check_result等字段。2.3 设计字段时踩过的坑第一个坑是日期类型的处理。船舶维保涉及大量“上次保养时间”、“下次到期日”的计算一开始直接用DATE类型存后来发现运行周期类型的设备需要精确到小时比如主机运行500小时必须用DATETIME。MySQL连接串里一定要加serverTimezoneAsia/Shanghai否则容器化部署时因为服务器时区是UTC定时任务提前8小时触发凌晨三点就开始发待办通知直接把值班轮机长搞蒙了。第二个坑是状态字段的枚举设计。计划状态、工单状态、库存状态如果直接用String类型存中文后续统计和扩展很痛苦。我建议用TINYINT存数字码代码里StatusEnum统一管理前端展示时通过字典翻译。唯一要注意的是枚举值一旦发布就别乱改序号新增可以挂在后面否则旧数据全错位。第三个坑是附件表的关联设计。工单可能要传几十张照片如果直接在工单表里加attachment_ids字段存逗号分隔的ID查询的时候要FIND_IN_SET数据量一大性能就崩。正确的做法是独立的附件表用biz_type和biz_id做多态关联任何业务实体都能挂附件还能按类型单独查。3. 关键功能模块的实操实现3.1 维保计划的自动生成与动态调整计划生成这块我实现了两种触发方式一种是由定时任务在每天凌晨扫描所有设备计算下一次到期时间生成新的待执行计划另一种是工单完成回写时根据该设备的周期参数即时计算下一次计划直接插入计划表。核心计算逻辑要注意日历周期和运行周期必须分别处理。public LocalDateTime calNextDueTime(Device device, MaintenancePlan plan) { LocalDateTime baseTime plan.getLastFinishTime(); if (baseTime null) { baseTime device.getCommissionDate(); // 无历史记录时以投运时间为准 } if (plan.getCycleType() CycleType.CALENDAR.getCode()) { return baseTime.plusDays(plan.getCycleValue()); } else { // 运行周期需要从设备运行小时表中读取累计运行时长 BigDecimal runHours deviceStatsMapper.getTotalRunHours(device.getId()); if (runHours null) { // 没有运行数据时兜底按日历周期估算避免漏保 return baseTime.plusDays(plan.getCycleValue()); } // 计算还差多少小时到期折算成日期每天按平均运行时长估算 BigDecimal remainHours new BigDecimal(plan.getCycleValue()) .subtract(runHours.subtract(plan.getLastRunHoursAtFinish())); if (remainHours.compareTo(BigDecimal.ZERO) 0) { return LocalDateTime.now(); } BigDecimal avgDailyHours device.getAvgDailyRunHours(); // 近30天平均 long days remainHours.divide(avgDailyHours, 0, RoundingMode.CEILING).longValue(); return LocalDateTime.now().plusDays(days); } }运行周期的计算一定是估算值因为船舶的运行时间和航线、装载工况高度相关不可能精确预知。这里我建议加一个“提前预警余量”的参数默认是周期值的10%最少不少于3天这样即使估算有偏差系统也能提前提醒不会直接漏保。计划转工单的操作在界面上就是点一个按钮但这个按钮背后做了一个防重的校验同一设备同一保养等级如果存在状态为待执行/执行中的工单不允许再生成新工单。这个校验只靠数据库查询有并发风险两个轮机员同时操作就可能重复生成最终我用Redis分布式锁数据库唯一索引双保险解决的唯一索引建立在device_id plan_type status三个字段上但注意逻辑删除不能参与唯一索引所以表里还加了一个del_flag字段参与联合唯一索引。3.2 工单流转的状态机实现工单流程是待派工 → 待接单 → 执行中 → 待验收 → 已完成中间还穿插一个已驳回的状态验收不合格退回。最开始我用if/else写状态流转后来新增驳回逻辑时改代码改到想砸键盘干脆重构成了状态机。具体实现方式不复杂不需要引入专门的状态机框架用Map维护“当前状态 事件”到“目标状态”的映射关系就够了Component public class OrderStateMachine { private static final MapOrderStatus, MapOrderEvent, OrderStatus TRANSITIONS new HashMap(); static { // 待派工状态: 可派工、可取消 TRANSITIONS.put(OrderStatus.PENDING_ASSIGN, Map.of( OrderEvent.ASSIGN, OrderStatus.PENDING_ACCEPT, OrderEvent.CANCEL, OrderStatus.CANCELLED )); // 待接单状态: 可接单、可转派 TRANSITIONS.put(OrderStatus.PENDING_ACCEPT, Map.of( OrderEvent.ACCEPT, OrderStatus.EXECUTING, OrderEvent.TRANSFER, OrderStatus.PENDING_ASSIGN )); // 执行中状态: 可申请完工、可退单 TRANSITIONS.put(OrderStatus.EXECUTING, Map.of( OrderEvent.APPLY_FINISH, OrderStatus.PENDING_VERIFY, OrderEvent.RETURN, OrderStatus.PENDING_ASSIGN )); // 待验收状态: 可验收通过、可驳回、可退回修改 TRANSITIONS.put(OrderStatus.PENDING_VERIFY, Map.of( OrderEvent.VERIFY_PASS, OrderStatus.COMPLETED, OrderEvent.VERIFY_REJECT, OrderStatus.REJECTED )); // 已驳回状态: 可重新提交 TRANSITIONS.put(OrderStatus.REJECTED, Map.of( OrderEvent.RESUBMIT, OrderStatus.PENDING_VERIFY )); } public static OrderStatus next(OrderStatus current, OrderEvent event) { MapOrderStatus, OrderStatus map TRANSITIONS.get(current); return map null ? null : map.get(event); } }每次状态变更时先校验next()结果是否为null为null表示当前状态下这个事件不允许触发直接抛业务异常。状态变更记录统一写进order_status_log表谁在什么时间把工单从什么状态改到什么状态原因是什么全部留痕。这个在船管公司做审计追溯时特别好用也是应对PSC检查的重要依据。3.3 备件库存管理与安全库存预警备件管理模块一开始被我们低估了以为就是简单的进销存实际做下来发现逻辑细节多得多。核心流程是备件入库采购到货或退库→ 工单领用关联工单号→ 自动扣减库存 → 库存流水记录。设计上有三个容易漏的关键点。第一库存扣减必须走数据库的乐观锁或悲观锁不能用“先查再改”的方式。我用的方案是UPDATE spare_stock SET quantity quantity - #{reqQty} WHERE id #{stockId} AND quantity #{reqQty}通过受影响行数判断是否扣减成功为0就是库存不足直接返回提示。这个方案性能好并发下也不会超卖。第二库存流水表不能省。每一条出入库记录都要记录备件ID、数量、变动类型、关联单号、操作人、时间。很多管理岗就靠这个流水表做月度成本核算哪个工单用了什么备件、花了多少钱必须从流水反查比在领用表里塞一堆冗余字段更清晰。第三安全库存预警不能只发一次。备件库存低于安全值时系统要生成预警记录状态为待处理库管补库后置为已处理如果库存持续低于阈值每天早上定时任务会再次推送提醒直到处理完。这里要注意防止重复提醒我把预警记录建成了一张独立的stock_warning_log表每次推送前查一下今天有没有推过有就不再推避免短信和站内信轰炸。3.4 移动端离线填报与附件上传船舶在海上航行时网络环境很差经常是卫星网络带宽有限时延还高。移动端如果做成强依赖在线接口的页面在船上基本没法用。最后我采用的方案是前端本地缓存IndexedDB 后端批量同步接口。具体流程是轮机员在H5页面接单后可以离线查看工单详情、作业指导书完工填报时填写的工时、消耗备件、现场照片先存本地等船靠港有网了或者通过卫星通讯的短暂窗口期一键同步到服务端。服务端必须做接口幂等用前端生成的batch_id和order_id联合做去重否则弱网环境下重复提交会造成数据脏掉。附件上传这里最开始直接用MinIO的presigned URL直传后来发现船上网络经常断点续传有问题换成了服务端中转的分片上传前端把照片压缩到200KB以内再切片每片2MB后端合并后用MinIO存储流程绕一点但实际用下来稳定多了。照片压缩这块也踩了坑直接用Java的ImageIO缩放大图容易内存溢出后来换了thumbnailator库一行代码解决。4. 性能优化与安全设计4.1 列表分页慢与索引优化系统上线后最直观的问题是工单列表和计划列表在数据量过万之后翻页明显变慢。排查发现慢在三个地方一是全表扫描。计划表按ship_id和status查询没建联合索引。加索引(ship_id, status, due_date)后常见查询场景直接从全表扫描变成了索引范围扫描速度提升了几十倍。二是COUNT(*)聚合查询太频繁。列表页每次查询都先count再查数据数据量大时count本身就很慢。这个问题的解法是前端分页组件显示总数改为异步加载默认只加载第一页数据滚动到底部再请求总数体感上快很多。三是大字段拖慢查询。工单表的remark备注字段几千字查列表时根本用不到但SELECT *会把所有字段捞出来后来在列表查询Mapper里改成显式字段列表不查大字段单独提供详情接口查完整内容。4.2 Redis缓存与定时任务的设计Redis在系统里承担了两个职责一是缓存设备树和字典数据这类数据变更频率极低但被每个工单表单页面频繁读取。我用的策略是启动时全量加载到缓存管理员修改设备树后主动delete缓存Key下一次查询时回源数据库重建缓存。二是实现分布式锁防止集群部署时定时任务重复执行。Spring Schedule默认是单机执行的如果后端做了多副本部署定时任务会在每个实例上各跑一遍重复生成维保计划这个后果非常严重。我的做法是封装一个ScheduleLock注解在执行方法前尝试通过redis.setIfAbsent(key, token, expireTime)获取锁拿到锁才继续执行。这里有一个细节锁的过期时间必须大于任务的最长执行时间我最初把过期时间设成10秒但生成计划任务在船多的时候要跑20多秒锁提前过期另一个实例又进来执行重复生成了几十条计划。后来改成过期时间30秒并在任务内部心跳续期才算稳定。4.3 登录认证与权限控制安全这块没有多复杂但有几个细节必须说。登录认证用的JWT密钥通过环境变量注入不写死在配置文件里。JWT里我只放userId、userName、roleId三个字段权限判断一律查数据库不把权限列表写进Token避免权限调整后旧Token依然有效。Token过期时间设计成12小时滑动刷新用户每次请求如果离过期时间不足2小时响应头里带上新的Token前端js拦截到新Token后本地替换这样船员在船上长时间使用时不会突然被踢下线。权限控制用了RBAC模型菜单权限和按钮权限分开。菜单权限控制“能不能看到这个页面”按钮权限控制“能不能点这个按钮”比如普通轮机员看不到系统管理菜单即使知道接口地址直接调用后端也会做接口级鉴权防止越权操作。这块必须前后端都做只在前端做纯属自欺欺人。5. 部署运维与常见问题排查实录5.1 Docker Compose一键部署部署方案我给客户交付的是Docker Compose包含了四个容器mysql8、redis7、minio、backend、frontend。这里有个小坑前端容器为了省事直接用了nginx:alpine镜像配置nginx反向代理后端接口。Docker Compose里服务间通信用服务名网上很多教程写localhost导致容器里连不上数据库特别坑。关键配置片段services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD} MYSQL_DATABASE: ship_maintenance TZ: Asia/Shanghai command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci volumes: - ./data/mysql:/var/lib/mysql - ./init:/docker-entrypoint-initdb.d:ro backend: build: ./backend environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql REDIS_HOST: redis MINIO_ENDPOINT: http://minio:9000 depends_on: - mysql - redis - minio frontend: build: ./frontend ports: - 8080:80 depends_on: - backend数据库初始化脚本放在init目录下第一次启动时MySQL容器会自动执行包括建库、建表、初始化字典和管理员账号。这里提醒一下depends_on只是控制启动顺序不保证MySQL初始化完成后端启动如果连不上数据库会不断重试我在后端加了spring.datasource.hikari.connection-timeout60000把连接超时时间拉长避免容器启动竞争导致后端启动失败。5.2 问题排查速查表把项目上线后最常遇到的几个问题整理成了一张速查表都是真实踩过的坑按症状定位原因能少走很多弯路。症状可能原因解决方案定时任务不执行或重复执行服务器时区不对多实例部署无分布式锁统一设置TZAsia/Shanghai加上Redis分布式锁新增设备后列表查不到逻辑删除字段影响唯一索引缓存未刷新删除设备用delete做物理删除修改设备树后主动清缓存工单附件上传失败Nginx上传大小限制MinIO桶权限不对前端压缩图片配置client_max_body_size 20m桶策略改为readwrite库存扣减为负数并发扣减未加锁改用UPDATE ... WHERE quantity #{reqQty}原子操作计划重复生成定时任务与工单完成回写同时触发计划生成统一收敛到定时任务工单完成只更新计划状态报表数据不对跨表查询有脏数据计划状态未同步报表模块统一走统计SQL不直接查业务表移动端离线数据丢失浏览器清缓存IndexedDB空间不足离线数据本地保存7天同步接口幂等设计容器重启后数据丢失未挂载数据卷MySQL、MinIO数据目录必须用volumes持久化登录后接口返回403JWT权限列表过期按钮权限未配置清理浏览器本地Token重新登录检查角色菜单授权中文乱码MySQL连接字符集问题连接串加characterEncodingutf8建库指定utf8mb45.3 项目复盘与一些个人心得做这套系统的过程里最大的体会是船舶维保管理系统的核心不在技术而在对船舶业务的理解深度。技术上的CRUD、状态机、缓存、权限任何一个有经验的Java工程师都能写难的是搞清楚轮机员真正需要什么、海事检查看到什么会挑毛病、备件管理员最烦什么操作。几个非常具体的心得想分享给打算复刻这个项目的朋友第一维保计划是整棵树的根。前期需求不清晰时先把计划生成逻辑做对、做稳后续工单、库存、报表都从计划这条线长出来方向就不会偏。如果计划都生成得乱七八糟后面所有模块都是空中楼阁。第二移动端离线能力一定要做。船上的网络条件跟陆地完全不同很多演示项目在线连得好好的一到实地测试就露馅。哪怕第一版只做一个简单的“离线查看工单本地填报”功能也要优先把同步和幂等机制设计好后面扩展功能快得多。第三验收报告和维保记录的留痕要做得足够细。船管公司拿着这套系统应对海事安检时检查人员看的就是记录的完整性、闭环性——完工时间、验收人、附件照片、设备状态漏一个都算瑕疵。我当时在验收环节强制要求必须上传至少一张完工照片这个细节在真实检查中帮客户加了不少分。第四凡是能配置的千万不要写死在代码里。维保等级、周期参数、字典类型、预警规则都做成数据库配置交付时给客户一套参数维护手册他们自己就能调整也不会天天打电话来让改代码。这套系统从立项到上线大概是三个月左右核心开发两个人加上一个兼职前端。如果需要下篇可以专门拆解报表看板模块的设计包括维保及时率、故障率趋势这些统计指标的计算口径和SQL写法欢迎评论区交流。