船舶监造系统开发实战:SpringBoot+Vue3+MyBatis实现报检流程管理
我在接触船舶监造系统之前一直以为这类项目的难点在“大屏好不好看”。真正做完某船舶监造系统后我发现这个判断完全反了。业务方天天盯着的不是船体3D模型而是一张报检单哪道工序要报检谁去现场见证有没有逾期缺陷是否关闭交船文件是否齐全。这套用 Java SpringBoot Vue3 MyBatis 搭建的前后端分离船舶监造系统就是把船厂、监造方、第三方检验这三方的协作痕迹搬到线上。它适合两类人看一是准备做船舶或重型装备制造数字化项目的人二是想从单体项目转前后端分离架构、但需要一个真实业务场景练手的开发者。整套系统以 MySQL 为底层存储后端提供 RESTful 接口前端用 Vue3 Vite 构建。本文不打算只讲增删改查重点说清楚几个事监造业务到底在管什么、表结构为什么这样拆、报检单的状态流转里藏了多少并发坑、前端动态表单怎么做以及把源码跑起来之前必须解决的版本问题。1. 船舶监造系统的业务复杂度远不止“进度跟踪”1.1 监造流程里的关键角色与动作船东或监造方跟船厂合作时最核心的动作是“见证”。船厂完成某个建造节点后不能自己说“好了”就算数必须发起报检请监造人员或第三方检验人员到现场看。看得过检验在单据上签字这个节点才算关闭看不过就要写缺陷、定整改期限整改后再复检。这个流程拆到系统里至少出现五类角色船厂项目管理员、船厂报检员、监造工程师、第三方检验人员、系统管理员。每个角色对系统的操作视角完全不同。报检员要的是“快速发起报检、上传照片和检测报告”监造工程师要的是“今天有哪些待见证项、哪些已经逾期”管理层要的是“项目整体进度百分比、缺陷关闭率”。一套没有角色区分的系统在这个行业里根本用不起来。1.2 必须要覆盖的五类核心业务我做需求梳理的时候把业务主线收敛成五条后面所有表结构和接口都是围着这五条转的。第一是项目与船舶基础信息比如船舶编号、项目阶段、计划开工日期、计划交付日期。第二是报检管理这是整个系统的心脏所有工序节点都通过报检单驱动。第三是见证点管理一个报检单可能对应多个见证点包括停止点、检验点、文件点不同类型走不同检验流程。第四是缺陷管理见证不通过时生成缺陷记录缺陷要能关联回原报检单并跟踪整改状态。第五是文档管理包括图纸、检测报告、合格证、往来函件文档必须关联到具体船舶和报检单不然交船时整理资料就是一场灾难。1.3 为什么现货管理系统大多不适用当时也考虑过用成品项目管理系统改造后来放弃了。原因是船舶监造有个非常特殊的点报检单的状态流转是强校验的而且会牵扯第三方检验。通用项目管理系统擅长管任务、工时、里程碑但很难表达“某报检单由某检验人员签字后才算关闭”这种带有见证性质的业务。更麻烦的是缺陷和报检单的闭环关系很多成品系统要么只记录缺陷要么只能发工单做不到从一个报检单出发串联检验记录、缺陷整改、复检结果、最终关闭的完整证据链。所以最终决定自己开发用 SpringBoot 做后端Vue3 做前端MyBatis 做数据访问层这也就有了后面整个项目的技术底子。2. 技术栈选择SpringBoot Vue3 MyBatis MySQL 的适配逻辑2.1 前后端分离解决的是多方协作问题这类系统在船厂上线时使用环境往往不只一个。船厂办公区、建造现场、监造办公室可能都在不同网络位置现场人员更多用平板或手机访问。前后端分离之后后端只出一套 API前端可以同时面对 PC 浏览器、平板和后续可能的小程序端不需要每个端都做一套服务端页面。更重要的是开发分工。我这边写接口的时候前端同学可以并行开发页面只要提前把接口文档定好。Vue3 的 Composition API 也比 Vue2 更适合这种表单和看板混合的中后台项目逻辑复用起来干净很多。2.2 SpringBoot 的接口组织方式后端我习惯按业务模块划分包结构而不是按技术类型划分。也就是controller、service、mapper这些文件夹不是顶层结构它们放在每个业务模块内部。比如报检模块就是inspection.controller、inspection.service、inspection.mapper这样改一个报检单状态的时候需要动的代码都在一个包里面不会满项目跳。SpringBoot 提供的能力在这个项目里用得最多的是参数校验和统一异常处理。所有接口入参都用 JSR 303 注解校验配合一个全局异常处理器把参数错误、业务错误、数据库异常分别映射成不同的响应码。前端拿到统一的{ code, message, data }结构才能把错误信息直接展示到页面上。2.3 MyBatis 比 JPA 更适合报表类查询船舶监造系统看起来很复杂但核心页面无非三类表单页、列表页、统计看板。这三类对 SQL 的要求完全不同。表单页是单条数据读写列表页是多表关联查询统计看板则是分组聚合查询。MyBatis 的优势是 SQL 完全自己控制尤其是统计看板那类 SQL可以写成数据库能直接优化的原生语句而不必受 ORM 自动生成的 SQL 限制。举一个实际例子。统计“各项目缺陷关闭率”的时候需要关联报检单表、缺陷表、项目表按项目分组计算关闭数量和未关闭数量。这类 SQL 用 JPA 规范写起来比较绕而 MyBatis 的 XML 里可以直接写SELECT p.project_name AS projectName, COUNT(d.id) AS totalDefectCount, SUM(CASE WHEN d.status CLOSED THEN 1 ELSE 0 END) AS closedCount, ROUND(SUM(CASE WHEN d.status CLOSED THEN 1 ELSE 0 END) / COUNT(d.id) * 100, 2) AS closeRate FROM project_info p LEFT JOIN inspection_order io ON p.id io.project_id LEFT JOIN defect_record d ON io.id d.inspection_id GROUP BY p.id, p.project_name ORDER BY closeRate DESC这种 SQL 在 XML 里写出来谁都能看懂后期要加过滤条件也只改一个文件。2.4 MySQL 在船舶监造数据量下的定位单船厂的监造项目可能同时开工三五条船一条船从开工到交付产生几千条报检记录、上万条文档记录。这个数据量远没有到需要分布式数据库的程度MySQL 配合合理的索引和分表策略完全扛得住。选 MySQL 还有一个实际原因团队里后续接手的人基本都会部署成本低出问题也好找资料。技术选型不需要追求最强追求“团队能长期维护住”反而更实在。3. 数据库设计从报检单到见证点表结构这样拆3.1 核心表结构与字段逻辑整个数据库我最后收敛到九张核心表。项目信息表存船舶项目报检单表存一次报检请求见证点表存报检单下面的检验项目缺陷表存见证未通过的整改事项文档表存图纸、报告等附件用户表、角色表、用户角色关联表管权限操作日志表存关键动作。其中最关键的是报检单和见证点的拆分。我在设计初期最大的纠结是“一次报检对应一个检验结果”还是一对多。后来确定一对多一个报检单可能包含多个工序见证点每个见证点独立记录检验意见。这样处理有个实际好处现场经常出现“大部分节点过了其中一个需要整改”如果报检单和见证点不拆开要么整个报检单卡住要么只能推翻重新录入。表名和核心字段大致如下表名核心字段说明project_infoid, project_no, project_name, plan_start_date, plan_end_date, status船舶项目主表inspection_orderid, order_no, project_id, report_unit, report_date, expect_witness_date, status, version报检单主表witness_pointid, inspection_id, point_name, point_type, witness_result, witness_user, witness_time, remark见证点明细defect_recordid, inspection_id, witness_point_id, defect_desc, rectify_user, rectify_deadline, status缺陷整改记录document_infoid, project_id, inspection_id, file_name, file_url, file_type, upload_user, upload_time文档附件表sys_userid, username, password, real_name, mobile, enabled用户表sys_roleid, role_code, role_name角色表user_roleid, user_id, role_id用户角色关联表operation_logid, user_id, operation_type, module, business_id, content, create_time操作日志表3.2 状态字段用字典表而不用枚举很多开发者在设计表的时候喜欢用 enum 类型存状态比如status ENUM(DRAFT,REPORTED,WITNESSED,CLOSED)。我刚开始也是这样做的后来改成了字典表加普通字符串字段。原因很现实业务状态不是一开始就能定死的。比如最初只设计了四五个状态上线后业务方提出“见证不通过后还要分‘待整改’和‘整改完成待复检’”如果这个状态是枚举类型就得改表结构影响面非常大。改成字典表之后状态字段只存字符串编码展示名称用前端字典翻译。新增一个状态只需要在字典表里加一条记录最多再补充一下状态机的流转规则不用动表结构也不用重新发布后端。这一点在船舶监造这种业务规则还在不停细化的行业里是非常实用的设计。3.3 索引设计让报检列表和统计报表不卡列表页最常见的查询条件是项目编号、报检单号、状态、报检日期范围。组合索引我建议这样建ALTER TABLE inspection_order ADD INDEX idx_project_status_date (project_id, status, report_date);这个索引为什么把project_id放最前面因为列表页第一筛选条件通常是“当前项目下”先通过项目把数据范围缩小再用状态过滤最后按日期排序刚好匹配组合索引的最左前缀原则。单独给status字段建索引意义不大因为状态值就那么几个区分度低。document_info 表要特别注意inspection_id的索引。交船资料整理时会频繁按报检单拉取该单下所有文档没有这个索引文档一多就开始拖慢页面。3.4 初始化数据脚本的准备这个项目源码里有配套的 SQL 初始化脚本直接执行就能建库建表、写入基础字典和管理员账号。但我要提醒一点演示数据不要给太多尤其是报检单数据最好只造几十条否则第一次启动系统时列表页加载速度和看板统计会显得很慢给客户演示的第一印象就差了。4. 后端实现状态机、幂等与操作日志三个最有嚼头的点4.1 用数据库条件更新实现状态机报检单的状态流转看起来简单草稿 → 已报检 → 见证中 → 见证通过 → 关闭见证不通过则转缺陷整改。但真正写代码的时候最大的问题是并发。举个例子同一个报检单监造人员正在操作“见证通过”另一个工程师可能同时点了“退回修改”。如果后端先查出状态再在 Java 代码里判断状态然后更新这个流程在并发下一定会出问题。两个请求都查到了同一个旧状态都认为自己的操作合法后更新的把前面人的结果覆盖掉。我的做法是把状态判断放到更新 SQL 里利用数据库的行锁和影响行数来判断是否更新成功update idcompareAndUpdateStatus UPDATE inspection_order SET status #{targetStatus}, version version 1, update_time NOW() WHERE id #{id} AND status #{expectedStatus} AND version #{version} /updateService 层拿到返回的影响行数如果为 0说明状态已经被其他人改掉直接抛出业务异常提示“当前报检单状态已变化请刷新后重试”。这样既不需要引入分布式锁也能把并发冲突挡在数据库层。version 字段加进去之后每次更新都要求版本号匹配实际上就是乐观锁。4.2 并发重复提交的幂等控制报检员经常连点两次“提交”或者网络差的时候重复刷新后端会收到两条一样的请求。如果不处理数据库里可能会出现两条报检单后面所有见证和缺陷记录都会串掉。这个项目的幂等方案没有引入额外中间件直接用数据库唯一索引解决。在报检单表上建立一个“报检单业务幂等键”项目编号、报检类型、报检日期、报检单位四个字段拼接后生成一个幂等号。只要同一个人在同一项目同一天对同一类型发起报检后续重复请求会被唯一索引挡住。ALTER TABLE inspection_order ADD COLUMN idempotent_no VARCHAR(64) NOT NULL; ALTER TABLE inspection_order ADD UNIQUE INDEX uk_idempotent_no (idempotent_no);后端在创建报检单之前先计算idempotent_no插入时捕获唯一键冲突如果冲突直接返回已经存在的报检单号而不是报“系统错误”。这种做法的好处是逻辑简单不需要维护 Redis 里的过期 key特别适合这种低频、高可靠要求的业务操作。4.3 基于注解和 AOP 的操作日志监造系统上线后业务方最在意“这个东西谁改的、什么时候改的、改之前是什么样”。所以操作日志不能只在登录时记一次而是要记录到具体业务单据的变化。我实现的方式是自定义一个OperationLog注解支持传入模块名、操作类型并在注解里写一个 SpEL 表达式来定位业务单号。然后用 AOP 拦截在方法执行完成后异步记录日志。这样做比在每个 Service 方法里手动写日志代码干净得多后续新增业务操作时只要加一个注解。OperationLog(module inspection, operation UPDATE_STATUS, businessId #orderId, content 报检单状态更新) public void updateStatus(Long orderId, String targetStatus) { // 业务逻辑 }日志内容一定要记录操作前的关键字段和操作后的关键字段否则出问题回溯的时候看不到改动细节。我在切面里通过反射读取实体旧值和新值只比较非空字段把有变化的字段拼成“字段名: 旧值 → 新值”的形式存到 content 字段里。4.4 文件上传与图纸版本管理船舶监造涉及的图纸和检测文件比较大PDF 图纸经常几十兆直接存数据库不现实。这个系统把文件存在服务器本地磁盘目录数据库只存文件路径和文件元数据。上传接口用 MultipartFile 接收文件名重命名为 UUID 加原扩展名避免中文名乱码和重名覆盖。图纸版本管理是我后来加的一块。船厂会反复升版图纸如果只存最新版后面发现现场施工用的不是最新版会出大事故。所以 docum ent_info 表里加了version_no和parent_doc_id新版本上传时指定“升版自哪个文档”系统保留历史版本页面默认展示最新版同时可以展开历史版本列表。5. Vue3 前端动态表单、看板和文件预览的实现思路5.1 动态表单的字段配置船舶监造的报检单很讨厌的一点是不同船型、不同建造阶段的报检表单字段不一样。比如涂装报检要填油漆品牌、膜厚测试数据机械设备报检要填设备型号、安装精度。如果每个类型做一个表单页面前端工作量会爆炸。最终的方案是做一个“字段配置驱动的动态表单”。数据库里有一张表单字段配置表存储每个报检类型对应的字段列表、字段类型、是否必填、排序。前端拿到配置后动态渲染template el-form :modelformData :rulesrules label-width120px el-form-item v-forfield in fields :keyfield.fieldKey :labelfield.fieldLabel :propfield.fieldKey el-input v-iffield.fieldType input v-modelformData[field.fieldKey] :placeholderfield.placeholder / el-date-picker v-else-iffield.fieldType date v-modelformData[field.fieldKey] typedate / el-select v-else-iffield.fieldType select v-modelformData[field.fieldKey] el-option v-foropt in field.options :keyopt.value :labelopt.label :valueopt.value / /el-select /el-form-item /el-form /template这个方案的核心是把“表单结构”从代码里抽出来变成数据。后续增加一个新报检类型前端不用发版只需要后端在字段配置表里配置好字段前端刷新页面表单就出来了。5.2 进度看板与实时刷新看板页主要是项目整体进度、各船舶节点完成率、缺陷趋势图。图表我用的是 EChartsVue3 封装成组件后按需引入避免打包体积过大。进度数据从后端聚合接口拉取接口返回按时间维度的统计数据前端直接渲染折线图和柱状图。实时刷新这里没有用 WebSocket 全量推送而是采用了轻量轮询。报检单状态变更并不像聊天消息那么高频每隔 30 秒拉一次最新统计数据足够满足业务方需求。轮询实现简单维护成本低也不会占用太多服务器连接资源。如果后面要做“全厂驾驶舱大屏”再单独加 WebSocket 接口也不迟。5.3 文件预览的坑文件预览方面PDF 是当前最省事的格式。浏览器直接打开 PDF 的 URL 就能预览但这个项目里很多文件在服务器本地磁盘不能直接暴露公共 URL。所以后端加了一个预览接口先做权限校验再通过ResponseEntity流式返回文件内容。GetMapping(/preview) public ResponseEntityResource preview(RequestParam Long fileId) { checkPermission(fileId); File file getFileFromDisk(fileId); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_PDF) .header(HttpHeaders.CONTENT_DISPOSITION, inline; filename URLEncoder.encode(file.getName(), UTF-8)) .body(new FileSystemResource(file)); }这里最容易踩的坑是Content-Disposition写成attachment后浏览器会直接下载而不是预览。一定要用inline。另外文件名必须做 URL 编码否则有空格或中文的文件名会被浏览器截断。6. 源码本地复现环境版本和部署注意事项6.1 本地启动的版本匹配这个项目源码要跑起来第一步不是改代码而是检查本机环境版本。SpringBoot 后端、Vue3 前端、MySQL 数据库三者之间版本不对会出现一堆莫名其妙的报错。我建议按“后端版本定前向兼容”的原则来配环境组件推荐版本说明JDK8 或 11如果后端是 SpringBoot 2.7.xJDK 8 可以用Maven3.6别用太老的版本依赖解析会出问题Node.js16Vite 5 以上对 Node 版本有硬性要求MySQL5.7 或 8.0字符集务必设成 utf8mb4Nginx1.20仅部署环境需要启动前端前先执行npm install如果 node_modules 安装失败绝大多数原因是 Node 版本太低或者 npm 镜像源问题。后端启动前检查数据库连接串application.yml里的数据库地址、用户名、密码必须改成自己本机的配置。6.2 数据库初始化和演示数据数据库初始化脚本我用的是init.sql里面包含建库、建表、基础字典、管理员账号。执行的时候建议用命令行或数据库客户端按顺序执行整个脚本不要只执行其中一部分否则外键关系会乱。MySQL 8.0 默认使用utf8mb4字符集建表语句最好也显式声明CREATE TABLE inspection_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, project_id BIGINT NOT NULL, status VARCHAR(32) NOT NULL, version INT DEFAULT 0, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, INDEX idx_project_status_date (project_id, status, report_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT报检单表;初始化数据里管理员账号默认密码是加密后的密文不要试图手动改数据库里的密码字段。登录进去之后在用户管理页面重置密码否则很容易踩“密码明文写入但登录失败”的坑。6.3 常见问题排查清单本地跑这套系统我见过最多的五类问题按排查顺序整理一下。第一类是接口能访问但前端白屏多半是 Vue3 项目的环境变量没配好VITE_API_BASE_URL指向了错误的地址。第二类是登录接口报 401先看数据库用户表里有没有初始化数据再看密码加密方式是否一致。第三类是报表页面加载慢优先检查 SQL 是否走了索引用EXPLAIN看执行计划不要先想着加缓存。第四类是上传文件后预览 404检查文件存储路径是否存在以及 Tomcat 或外部容器对临时目录的权限。第五类是状态更新莫名其妙失效回去看乐观锁的 version 字段是否被前端正常回传如果前端没带 version更新条件永远不成立。部署到服务器时SpringBoot 后端可以直接用mvn package打 jar 包前端npm run build后把 dist 目录交给 Nginx 托管。后端接口地址通过 Nginx 反向代理转发避免跨域问题。Nginx 配置里要特别注意前端 history 路由的 fallback否则刷新页面会出现 404location / { root /opt/ship-monitor/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }最后说一个我自己的体会船舶监造系统这类业务型项目真正的技术难点从来不在某个框架的高端用法而在于对业务状态的把握和对数据一致性的敬畏。报检单的状态一旦被并发搞乱现场检验记录就会对不上后面整理交船资料时所有问题一起爆出来那时候再补救就非常被动了。所以我在这个项目里最满意的一部分不是某个页面做得多漂亮而是状态更新、幂等控制、操作日志这三层防护做得足够扎实。把这几个点吃透再去复现这套源码你会明显感觉到比单纯写增删改查有意思得多。