接手这个车间管理系统,其实是被一张Excel表逼出来的。车间主任每天上午要把工单从ERP里导出来,再手动填完成数量、合格率,下午催物料,月底还要对账,光是维护这张表就占掉半天时间。交流了不到半小时,我和同事就决定:别再让人工台账拖后腿了,直接做一个前后端分离的车间管理系统,技术栈选 SpringBoot + Vue + MyBatis + MySQL,先把工单流转、物料领用、质检和设备状态这些核心场景管起来。这篇博文就把项目从需求拆解、数据库设计、后端接口、前端联调到部署上线的完整思路,以及我们踩过的坑,一次性写清楚,给正在做或准备做类似系统的朋友一个可以照着走的参考。
1. 系统整体设计与技术选型
1.1 车间管理到底管什么:从一张Excel表开始拆需求
车间管理系统听起来是个很大的概念,但落到实际生产环境,真正高频的痛点就那么几个。第一是工单进度不透明,车间主任每天要逐单问班长,班长再跑去工位问操作工,信息一层一层传,拿到手往往已经是下午。第二是物料消耗对不上账,领料单靠手写,月底财务和生产对账时经常发现料废和超领没记录。第三是设备状态没人管,机器停了半天才被发现,维修记录散落在纸质本子上。第四是质检合格率统计滞后,质量问题要等到月底汇总才暴露,错过了及时纠偏的窗口。
所以我们的需求拆解没有追求大而全,而是围绕“工单”这条主线展开。一个车间工单从创建开始,要经历排产、领料、加工、质检、完工入库几个环节,每个环节都需要记录时间、人员、数量、状态和异常信息。系统因此被分成五个核心模块:工单管理、物料管理、设备管理、质量管理、系统管理(用户与权限)。工单管理负责整个生产过程的进度跟踪,物料管理管领用和退料,设备管理记录设备运行和维修,质量管理记录检验批次和结果,系统管理则解决谁能看什么、能操作什么的问题。这套结构几乎可以平移到任何机械加工、电子装配、注塑车间的业务场景里,后续扩展也方便。
1.2 前后端分离架构到底怎么分层
选用前后端分离,并不是因为技术流行,而是这个项目实际需要这样切。SpringBoot 后端只需要提供一套 Restful API,不关心页面长什么样;Vue 前端负责页面渲染、表单交互和数据展示,通过 axios 调用后端接口;MyBatis 在中间做 SQL 映射,把数据库表记录转换成 Java 对象;MySQL 负责最终的数据持久化。数据流转大概是:前端用户点击查询 -> 发起 HTTP 请求 -> 后端 Controller 接收 -> Service 处理业务逻辑 -> Mapper 通过 MyBatis 执行 SQL -> MySQL 返回结果 -> 层层封装回传给前端 -> Vue 渲染到页面。
这种分层带来的直接好处是团队可以并行开发。我负责后端接口定义好返回格式,前端同学完全可以拿着接口文档先把页面搭起来,不必等服务端全部写完。第二个好处是部署灵活,前端打包成静态文件放到 Nginx,后端打成一个 jar 包单独运行,任何一方需要升级都不影响另一方,出问题也能快速定位是接口挂了还是页面报错。再往深一层讲,这种架构天然适合未来做移动端看板或小程序端,因为接口可以复用,只需要新写前端壳子就行。
1.3 为什么偏偏选 SpringBoot + Vue + MyBatis + MySQL
选这套组合不是拍脑袋,而是反复对比后的结果。SpringBoot 最核心的价值是简化了Spring应用的配置和部署,内嵌 Tomcat,一个 java -jar 就能跑起来,对一个中小型车间系统来说非常省事。MyBatis 的定位是轻量持久层框架,不像 Hibernate 那样对实体关系做全自动映射,SQL 完全由开发者控制,正好适合车间系统里复杂的多表关联查询、动态条件筛选和报表统计。Vue 则在前端生态里最适合快速开发管理后台,组件化和响应式数据绑定让工单列表、表单弹窗这类页面写起来很顺手。MySQL 自不用说,成熟稳定,社区资料丰富,对工厂这种数据量级(一天新增几千条工单记录)完全够用,还能省下商业数据库的授权成本。
当然,这套组合也有边界。如果你要做的是制造业大型 ERP 系统,要考虑微服务拆分、分布式事务;如果要做的是高并发设备数据采集,可能需要时序数据库和消息队列。但就车间管理这种企业内部系统来说,SpringBoot + Vue + MyBatis + MySQL 是性价比最高的“标准答案”,这也是很多同类项目选它的原因。
2. 数据库设计:车间系统核心表与关系
2.1 从工单流转出发设计数据模型
数据库设计是整个项目的地基,这块没想清楚后面全得返工。我们当时不是一上来就画 ER 图,而是先把工单的完整生命周期跑了一遍,列出每个环节产生的数据和涉及的角色。
新建工单阶段,需要产品信息、计划数量、计划交付时间,对应一张生产工单主表。排产阶段,工单会被拆成多道工序,比如下料、车床加工、铣床加工、表面处理、装配,每道工序有顺序号、计划工时、负责班组,这就有了工单工序表。领料阶段,操作工凭工单去仓库领原材料,记录物料编码、数量、领取人、用途(正常领用、补料、超领),所以有物料领用表。加工阶段,每完成一道工序就上报数量、合格数、废品数,操作工、设备、工时都要记录,于是有生产报工表。质检阶段,一批完工品进入检验,记录抽检数、缺陷数、缺陷类型、检验结果,对应质量检验表。再加上设备本身要建档、维修要记录,以及用户、角色、部门、菜单权限等系统表,整体模型就搭建起来了。
这种“按业务阶段拆分成多张明细表”的设计,比把所有信息堆在工单表里要合理得多。一是数据粒度细,后续想做“每道工序的合格率”、“某台设备产出的废品率”都能直接从表里取数;二是避免单表字段爆炸,工单主表保持简洁,明细数据通过外键关联;三是并发操作更安全,不同环节录入的数据互不阻塞。
2.2 关键表结构与字段说明
工单主表是整个系统的核心,字段设计要从业务查询角度反推。比如车间主任最常看的是当前工单在哪个工序、进度多少,因此状态字段必不可少。我们用 tinyint 存状态码,0表示新建,1表示排产中,2表示生产中,3表示已完工,4表示已质检,5表示已关闭。为什么不用字符串?因为状态码占用空间小,而且前端可以通过字典翻译成任意文案,后续状态改名不用动数据库。数量字段统一用 decimal,避免 float 精度问题。
下面是工单主表的关键字段示例:
CREATE TABLE work_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '工单编号', product_name VARCHAR(128) NOT NULL COMMENT '产品名称', product_code VARCHAR(64) NOT NULL COMMENT '产品编码', plan_qty DECIMAL(12,2) NOT NULL COMMENT '计划数量', completed_qty DECIMAL(12,2) DEFAULT 0 COMMENT '已完成数量', qualified_qty DECIMAL(12,2) DEFAULT 0 COMMENT '合格数量', workshop_id BIGINT NOT NULL COMMENT '所属车间ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '工单状态', priority TINYINT DEFAULT 1 COMMENT '优先级,1普通 2紧急', plan_start_time DATETIME COMMENT '计划开始时间', plan_end_time DATETIME COMMENT '计划结束时间', actual_start_time DATETIME COMMENT '实际开始时间', actual_end_time DATETIME COMMENT '实际完工时间', remark VARCHAR(255) COMMENT '备注', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记', create_time DATETIME NOT NULL COMMENT '创建时间', update_time DATETIME NOT NULL COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='生产工单表';工单工序表相对更简单,核心是记录工序顺序和实际执行情况:
CREATE TABLE work_order_process ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL COMMENT '工单ID', process_name VARCHAR(64) NOT NULL COMMENT '工序名称', sequence_no INT NOT NULL COMMENT '工序顺序号', plan_hours DECIMAL(6,2) DEFAULT 0 COMMENT '计划工时', actual_hours DECIMAL(6,2) DEFAULT 0 COMMENT '实际工时', process_status TINYINT DEFAULT 0 COMMENT '工序状态:0待开始 1进行中 2已完成', operator_name VARCHAR(64) COMMENT '负责人', equipment_id BIGINT COMMENT '使用的设备ID', completed_qty DECIMAL(12,2) DEFAULT 0 COMMENT '该工序完成数量', qualified_qty DECIMAL(12,2) DEFAULT 0 COMMENT '该工序合格数量', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工单工序表';物料领用表要特别设计领料类型,因为在车间实际管理里,正常领料、补料、超领是完全不同的业务动作,月底成本核算全靠这个字段区分。设备表则需要记录设备编码、名称、状态(运行、空闲、维修、停机)、所在车间、下次保养日期,维修记录和工单工序表关联。
2.3 设计时的几个坑:别急着建表,先想这几个问题
第一个坑是状态字段和枚举值没提前约定,后端写死1、2、3,前端又定义另一套数字代表不同含义,联调时进度条怎么都对不上。所以建表之前就要把状态字典、类型字典、优先级字典整理成一份数据字典文档,前后端共用。
第二个坑是时间字段的时区问题。MySQL 的 timestamp 会随数据库时区变化,datetime 不自动转换,而我们的后端部署在云服务器上,时区设置不一致容易导致前端显示的时间比实际差8个小时。统一方案是数据库连接串加上 serverTimezone=Asia/Shanghai,实体类时间字段用 LocalDateTime,接口层统一格式化为 yyyy-MM-dd HH:mm:ss,一次到位。
第三个坑是软删除和唯一约束冲突。工单编号通常要求唯一,但如果用了 deleted 软删除,一条记录被删除后只标记为1,下次新增同样的单号再写入时,唯一索引就冲突了。常用解决方法是单号带上当前日期或时间戳,比如 WO20250626140001,从业务上规避重复。如果没有唯一需求,尽量别乱加索引约束。
3. 后端 SpringBoot + MyBatis 实现要点
3.1 后端工程结构和统一响应体
后端工程我习惯用标准的多层结构,业务量大一点也方便扩展:
com.factory.mes ├── controller // 接收前端请求,做参数校验 ├── service // 业务逻辑层,事务控制 ├── mapper // MyBatis接口 ├── entity // 数据库实体类 ├── dto // 数据传输对象,避免把实体直接暴露给前端 ├── vo // 返回给前端的视图对象 ├── common // 统一返回、异常处理、工具类 ├── config // 配置类,比如MyBatis分页、CORS、JWT拦截器 └── utilsController 只做参数接收和结果包装,不写业务代码;Service 处理核心逻辑,比如创建工单时需要生成单号、初始化工序明细、校验库存;Mapper 只负责 SQL 操作。这里有一个很重要的原则:前端需要什么字段就定义对应的 VO 返回,不要把数据库实体直接返回出去。比如工单列表页要显示“工序名称和进度”,直接返回实体类会导致前端拿到一堆用不上的字段,同时还会把数据库内部信息比如创建者 ID 暴露出去。
统一响应体是前后端联调效率的关键。我封装了一个 Result 类,泛型结构包括 code、message、data,成功状态码约定为 200,业务异常码从 400 开始。所有接口返回 Result.success(data) 或 Result.error(500, "服务端异常"),前端拿到后只需要判断 code 是否等于 200 即可,不用每个接口单独处理 HTTP 状态码。
public class Result<T> { private Integer code; private String message; private T data; // 成功的静态方法 success,失败的静态方法 error }配合全局异常处理器,把业务异常统一转换成 Result 返回,这样即使代码里漏了一层 try-catch,前端也不会收到一堆看不懂的堆栈信息。我在项目里还额外定义了一个 BizException,凡是库存不足、工单状态不允许编辑这类业务性问题,都手动抛出异常,由全局处理器兜底。
3.2 登录与权限:JWT + 拦截器
车间管理系统不需要对接复杂的统一认证,我们用 JWT + 拦截器做了一套轻量权限方案。用户登录成功后,后端校验用户名密码,密码用 BCrypt 加密存储,认证通过后生成 token,把用户 ID、角色编码、过期时间放进 token 里返回给前端。前端每次请求在 axios 拦截器里携带 token,后端自定义一个拦截器,对所有需要认证的路径校验 token 是否有效,并解析出当前用户。
为什么不用 Spring Security?说实话,这个项目涉及的权限模型没那么重,用户表、角色表、菜单表三张表就能搞定,Spring Security 的过滤器链和配置反而会增加理解成本。如果需要类似“车间主管可以审批,操作工只能提交”这种按钮级权限,还可以用自定义注解配合拦截器实现,在需要控制权限的方法上标注一个 @RequirePermission("work_order:approve"),拦截器解析注解,比较用户权限列表,没权限直接返回 403。这种方法比引入一个完整框架要轻得多,也更容易让团队成员上手。
密码加密是很多人容易忽略的细节。数据库里绝不能存明文密码,BCrypt 每次生成的哈希值都不同,验证时用相同算法匹配,安全性比 MD5 高一个量级。用户重置密码时,我会设置一个一次性初始密码,并要求首次登录强制修改,这个逻辑虽然简单,但能避免很多安全隐患。
3.3 MyBatis 动态 SQL:工单分页查询的实战写法
车间系统查询工单时,筛选项非常灵活:按工单编号模糊搜、按状态筛选、按车间筛选、按计划时间范围筛选,而且不同条件可以组合。这种场景 MyBatis 的动态 SQL 非常合适,我在 XML 里写了一个核心查询,配合 if 标签动态拼接条件:
<select id="selectOrderPage" resultMap="OrderResultMap"> SELECT wo.id, wo.order_no, wo.product_name, wo.plan_qty, wo.completed_qty, wo.status, wo.priority, ws.workshop_name, u.real_name AS creator_name, MAX(wop.process_name) AS current_process FROM work_order wo LEFT JOIN workshop ws ON wo.workshop_id = ws.id LEFT JOIN sys_user u ON wo.create_by = u.id LEFT JOIN work_order_process wop ON wop.order_id = wo.id AND wop.process_status = 1 <where> <if test="query.orderNo != null and query.orderNo != ''"> AND wo.order_no LIKE CONCAT('%', #{query.orderNo}, '%') </if> <if test="query.status != null"> AND wo.status = #{query.status} </if> <if test="query.workshopId != null"> AND wo.workshop_id = #{query.workshopId} </if> <if test="query.startTime != null"> AND wo.plan_start_time >= #{query.startTime} </if> <if test="query.endTime != null"> AND wo.plan_end_time <= #{query.endTime} </if> </where> GROUP BY wo.id ORDER BY wo.create_time DESC LIMIT #{query.offset}, #{query.pageSize} </select>这里有三个细节值得说明。第一,模糊查询用 CONCAT('%', #{query.orderNo}, '%'),而不是直接拼字符串,因为 #{} 是预编译参数,能防 SQL 注入,如果用 ${} 拼 '%' 容易被注入风险。第二,SQL 中大于小于号要转义成 > 和 <,否则 XML 解析会报错,这个报错信息很长,不注意看容易让人误判是 SQL 本身的问题。第三,动态查询的 if 判断要注意空字符串和 null 都合法的情况,尤其在 status 为 0 时,不能用 if 判断空字符串导致条件丢失。
MyBatis 的 resultMap 是另一个重点,特别是当查询结果有多个表字段时,建议显式定义列和属性的映射关系。项目里开启了下划线转驼峰配置:map-underscore-to-camel-case=true,但遇到别名或者字段名不规范的情况还是需要写 resultMap。MyBatis 报“Invalid bound statement”是常见问题,一般是 XML 里 namespace 和 Mapper 接口全限定名不一致,或者 mapper XML 的 resource 路径没有配置到 Spring Boot 的 mybatis.mapper-locations 里。
3.4 事务与并发控制:车间数据易错点
车间生产数据最怕出现重复扣料、工单重复完工。以物料领用出库为例,操作工提交领料申请,后端第一步查库存是否足够,第二步扣减库存,第三步写入领料单。如果这些操作不加事务,第二步执行过程中突然抛异常,库存已经扣了但领料单没生成,第二天对账就会发现库存凭空少了。解决方法很简单:在 Service 方法上标注 @Transactional,Spring 会把这个方法里的数据库操作放进同一个事务,任何一步失败就整体回滚。
更隐蔽的坑是并发问题。两个操作工同时领同一批物料,两个请求同时读到库存剩余 10 件,第一个申请领 8 件,第二个申请领 5 件,如果数据库不加锁,两个请求都发现库存够,最后库存变成负数。项目里我给物料库存表加了版本号字段 version,更新库存时用乐观锁:先查版本号为 3,更新时执行 UPDATE material_stock SET qty = qty - 8, version = 4 WHERE id = 1 AND version = 3,如果更新行数为 0 则说明数据已被别人改过,重新读取再提示用户。部分关键操作比如工单完工确认,也可以用悲观锁 SELECT ... FOR UPDATE 来防止同一时刻两个人重复点击完工。
4. 前端 Vue 实现与前后端联调
4.1 Vue3 + Vite 工程初始化:别在环境配置上卡太久
前端我用的是 Vue3 + Vite + Element Plus 这套组合。Vite 启动速度比 Webpack 快很多,本地开发体验明显更顺滑。创建项目可以直接用命令:
npm create vite@latest mes-web -- --template vue cd mes-web npm install安装依赖后需要装两个核心包:路由 vue-router,以及 UI 组件库 element-plus。顺便还要装 axios 用来发请求,以及用于解析 element-plus 图标库和状态管理的 pinia。网上很多 Vue2 老项目还在用 Vue2 + Element UI,如果你是新起项目,我建议直接上 Vue3,因为 Element Plus 组件质量更高,而且生态已经稳定了。
启动项目后第一步是配置环境变量。我会在项目根目录建立 .env.development 和 .env.production 两个文件,开发环境的 VITE_API_BASE_URL 设置成 “/api”,生产环境根据部署情况也设置成 “/api”。后端地址不直接写死到代码里,因为本地联调要用代理,生产环境要按服务器实际 Nginx 配置走,把不同环境的后端地址统一映射到 /api 前缀,前端代码里就只要写相对路径。
4.2 axios 封装与跨域代理配置
axios 如果不做封装,每个页面都要重复写请求路径、token、错误提示,代码很冗余而且容易出错。我在项目里建了一个 request.js 文件,统一处理这几件事:从 localStorage 读取 token 放到请求头;响应拦截器里判断 code 是否为 200,不为 200 就弹 ElMessage 错误提示,并拦截到登录页。
跨域问题也是前后端分离的必修课。本地开发时,前端跑在 5173 端口,后端跑在 8080 端口,浏览器直连后端会报跨域错误。解决方案不是在后端写一堆 CORS 配置,而是用 Vite 的 proxy 代理。在 vite.config.js 里加一段配置:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } }这样前端请求 /api/work-order/list,开发服务器就会把请求转发到后端的 /work-order/list,浏览器端整个请求过程看起来是同源的,不触发跨域。这个配置还有一个好处:生产环境 Nginx 也可以复用同样的 /api 规则,后端接口路径不用改。
4.3 工单列表页与表单页怎么拆组件
工单列表页我拆成了三个主要组件:顶部搜索表单、中间数据表格、底部分页器。搜索表单用 Element Plus 的 el-form 配合 el-input、el-select、el-date-picker 实现,点击查询时把表单数据作为查询条件传给后端。表格用 el-table,渲染工单编号、产品、数量、状态、进度等列,状态列用 el-tag 根据状态码显示不同颜色。分页器绑定当前页和总数,切换页码时重新请求接口。
表单页则单独建一个组件,新建工单和编辑工单共用同一套表单,区别只是处理数据时是否带工单 ID。表单组件用 el-dialog 包起来,打开时回显数据,提交时调用不同的接口。这里有一个很实用的逻辑:提交前做一个统一的字段校验,必填项用 Element Plus 的 rules,比如工单编号、计划数量必填,数量必须大于 0。校验规则除了前端做,后端 Controller 接收 DTO 时也要加 @Validated 注解,前后端双重校验才能减少脏数据。
前端列表页最关键的一个点是后端返回的分页数据结构。我统一用 PageVO 返回数据,包含 records 列表、total 总数、current 当前页码。前端拿到 total 后赋给分页器,拿到 records 交给表格渲染,整个联动就通了。
4.4 前端路由 history 刷新 404 与打包部署
Vue Router 默认有 hash 和 history 两种模式。hash 模式 URL 里带 #,刷新不会出问题,但不好看;history 模式去掉了 #,看起来更规范,但直接刷新子路由页面时,Nginx 找不到对应的物理文件,会返回 404。这个问题在我们系统上线第一天就遇到过,测试同事点进工单详情页按了一下刷新,页面直接白屏。
原因很简单:Nginx 默认配置中 /work-order/detail 这个路径没有真实文件,它不会自动转发到 index.html。解决方法是配置 Nginx 的 try_files,把所有前端路由都回退到 index.html:
location / { root /opt/mes-web/dist; index index.html; try_files $uri $uri/ /index.html; }这样刷新任何一个前端路由时,Nginx 都会先找真实文件,找不到就返回 index.html,由 Vue Router 接管页面渲染。如果你不想用 Nginx,也可以把 dist 文件夹打进 SpringBoot 的 static 目录,但这时必须配置一个转发规则,让所有非接口路径都返回 index.html,否则同样会遇到 404 问题。
5. 部署上线与常见问题排查
5.1 本地从零部署:MySQL 初始化、后端打 jar、前端 build
真正部署时严格按照以下流程走一遍,基本能一次通过。第一步,安装 MySQL 并初始化数据库。登录 MySQL 后执行建库脚本,设置字符集为 utf8mb4:
CREATE DATABASE mes_factory DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;然后导入项目的 init.sql,里面包含建表语句和初始数据。注意 MySQL 8 默认认证插件是 caching_sha2_password,SpringBoot 数据库驱动版本必须用 mysql-connector-j 8.x,否则会提示认证插件不支持。我踩过一次坑,连接字符串写成 5.x 的 com.mysql.jdbc.Driver,直接 ClassNotFoundException,后来改成 com.mysql.cj.jdbc.Driver 才正常。
第二步,后端打 jar 包。在项目根目录执行:
mvn clean package -DskipTests生成的 jar 在 target/ 目录下。启动前需要确认 application.yml 里的数据库配置、端口号、文件路径都符合生产环境。启动命令建议用 nohup 或者 systemd 开机自启,避免终端关闭后进程退出。当时我图省事直接 java -jar 跑,结果 SSH 断了应用就停了,后来改成 systemd 服务才算稳定。
第三步,前端打包:
npm run build生成的 dist 目录是纯静态文件。我一般先放在 Nginx 的 web 目录下,用前面说的 try_files 配置托管,再单独配置接口转发:
server { listen 80; server_name your-domain.com; location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { root /opt/mes-web/dist; index index.html; try_files $uri $uri/ /index.html; } }注意 proxy_pass 末尾的斜杠,如果上游地址是 http://127.0.0.1:8080/,Nginx 会替换掉匹配到的 /api/ 前缀,把 /api/work-order/list 转发成 /work-order/list,这跟本地 Vite 代理的行为保持一致。
5.2 常见问题清单:我的排查经验
这里整理一份我在项目中真实遇到过的问题,按出现频率排序,方便你直接对号入座。
| 问题现象 | 根本原因 | 解决方法 |
|---|---|---|
| 前端请求接口报 404 | Nginx 只托了静态文件,没有转发 /api | 检查 server 块里是否配置 location /api/,并确认转发地址 |
| 登录接口报 401,但密码正确 | JWT token 过期或拦截器没放行登录接口 | 查看拦截器 excludePathPatterns 是否包含 /auth/login |
| 数据库时间比本地时间早 8 小时 | MySQL 连接串没指定时区 | 连接串加 serverTimezone=Asia/Shanghai |
| MyBatis 报 Invalid bound statement | mapper XML 路径或 namespace 不对 | 检查 mybatis.mapper-locations 配置和 XML namespace |
| 页面刷新 404 | history 路由缺少 try_files | 按前面 Nginx 配置加上 try_files |
| 列表接口很慢 | 表没走索引,比如状态字段没建索引 | 给查询频繁的条件字段加普通索引,控制 JOIN 数量 |
| 扣减库存出现负数 | 并发导致乐观锁版本不一致 | 更新时带版本号条件,失败重新读取 |
| 跨域报错但配置了 CORS | 前端用了 Vite 代理但生产又用了 Nginx | 明确开发环境走 Vite proxy,生产走 Nginx /api 转发,二选一 |
排查思路有个顺序:先看浏览器控制台请求返回的 HTTP 状态码,再判断是前端路由问题、接口路径问题还是后端业务问题。如果网络请求没发出去,大概率是前端代码;如果状态码是 401 或者 403,先检查 token 和权限;如果是 500,去后端看日志,SpringBoot 默认控制台会打出异常堆栈,定位很快。
5.3 低配服务器上的优化:让系统跑得更稳
很多工厂内部服务器配置不高,可能就是一台 2核4G 的旧机器,运行 jar 包和 MySQL 再加 Nginx,资源会比较紧张。这时候做几个低成本优化效果很明显。后端启动时指定 JVM 初始和最大堆内存一致,避免动态扩容带来的停顿:java -Xms512m -Xmx512m -jar mes.jar。MySQL 设置 innodb_buffer_pool_size 为物理内存的 50%~70%,比如 2G 内存设置为 256M,能明显提升查询和写入速度。前端打包时开启压缩,Nginx 配置 gzip on,对 js、css、json 等文件压缩传输,体积能减少 60% 以上。
数据库连接池参数也不能忽略。SpringBoot 默认的 HikariCP 已经把最大连接数设为 10,如果并发访问量不大可以保持默认,但必须设置 connection-timeout 和 idle-timeout,避免异常情况下连接池耗尽。另外,项目里我建议把分页查询用到的排序字段、筛选字段都加上索引,尤其工单状态、计划开始时间这两个字段,加索引前后查询速度差距非常明显。
最后再分享两个实操建议
这个系统上线后,最直观的改变是车间主任每天早上的 Excel 流程消失了,工单进度、物料领用、合格率全部可以在看板上实时查到,月底对账也只需要一键导出。个人经验是,做这类工厂内部系统,业务需求优先级排序比技术选型更重要。不要一口吃成一个全流程 ERP,先把工单、物料、质检这三件事做透,车间用起来再逐步加设备对接、扫码报工、移动审批这些扩展功能。
另一个建议是代码规范和数据字典一定要从第一天就建立好。前后端命名不一致、状态码各写各的,越到后面越痛苦。我们后来专门花了一天时间梳理数据字典,把工单状态、设备状态、领料类型、质检结果统一成一套枚举,前后端落地后整个团队沟通成本下降了一大截。如果时间允许,再补一套简单的接口文档或 Postman 集合,后面接手的人不会骂你。