
简介这份资源是一套面向高校计算机相关专业毕业设计场景的 Java 生产管理系统完整程序适合需要完成选题、搭建业务系统或参考分层架构的学生与初级开发者。系统在需求分析基础上完成总体设计与详细设计内容涵盖功能模块划分、总体结构、数据结构与安全设计并包含数据库访问实现、核心功能模块代码与功能测试分析可据此理解一个管理系统从设计到落地的完整链路。压缩包共 274 个文件、约 1.14MB以 102 个 Java 源文件、68 个 Vue 前端页面、26 个 class 编译文件、22 个 xml 配置与 15 个 js 脚本为主体另含 SQL 建库脚本、properties 与配置文件、样式及图片等静态素材前后端代码与数据库一应俱全配置环境说明随包提供。当前已有 519 人学习关注可作为毕设实现参考、课程设计模板或功能扩展的起点也能帮助读者对照目录结构梳理接口调用、权限拦截与分页查询等常见实现思路。1. 这套 Java 生产管理系统毕设值不值得跑起来先看三条硬指标很多人拿到毕业设计程序包第一反应是导入 IDE 按启动按钮然后在Table pm_work_order doesnt exist的报错页面上卡一下午。这套 java 生产管理系统的思路不太一样它把「工单下达 → 领料出库 → 车间报工 → 库存回写」这条主链做成了可以按顺序启动的最小闭环你不用一次看懂全部代码只要沿着这条链推一遍数据自己就能跑起来。适合谁手里有毕设周期、需要一份能演示能答辩、还能往上加功能的成品也适合已经工作但没碰过制造业业务、想用一个周末把工单模型怎么落到表结构里、状态机怎么落进代码摸清楚的后端。反过来说如果你只想找一套「什么都不用管、一键跑通」的模板那这套东西大概率让你失望——它的价值恰恰在于把业务状态机明明白白写进了代码里你得读懂它才能改得动它。2. 技术栈与模块拆解Spring Boot MyBatis 怎么切工单、报工和库存2.1 先确认技术栈边界别上来就升级拿到包我一般先做一件事不看代码先看pom.xml和package.json。这一步决定了后面几个小时是顺风顺水还是血泪翻车。这套生产管理系统的典型组合是 Spring Boot 2.7 那一代 MyBatis-Plus MySQL 8 Redis可选做登录态缓存 Vue 2 与 Element UI前端打包后丢进resources/static。这几年新做的版本也有 Boot 3 Vue 3 Element Plus 的区别不小见下表维度Boot 2.7 版本Boot 3.x 版本命名空间javax.servlet、javax.validationjakarta.servlet、jakarta.validation权限框架Spring Security 5.x 或 ShiroSpring Security 6.x配置写法改成 Lambda DSLJDK 要求8 / 11 都能跑至少 17前端Vue 2 Element UIVue 3 Element Plus Vite升级代价低适合改中高javax → jakarta会把所有 import 打碎我的建议永远是先按原版本把系统跑起来能登录、能建单、能报工再谈升级。理由很实际——你还没搞清业务边界就去动框架版本一旦报错你根本分不清是版本问题还是自己改错。JDK 用项目声明的那个pom.xml里写了java.version1.8/java.version就别硬上 17编译能过运行时序列化和反射的坑会在你答辩前十分钟集中爆发。依赖这一层还有两个常见分歧点要说清楚。一是 ORMMyBatis-Plus 和纯 MyBatis 的 XML 混用单表 CRUD 走 MP、复杂报表走 XML这是最常见的组合别指望全用 MP 的 Wrapper 拼出多表关联统计。二是权限Shiro 的配置更直观ShiroConfig里一串filterChainDefinitionMap就能看懂Spring Security 的过滤器链对新手不友好但生态更稳网上能查到的解法也多。这套程序用的是哪种直接决定了你后面加一个「车间主任只能看本车间工单」的数据权限时是改一个注解还是改一个 Filter。2.2 模块地图从物料主数据到报工闭环模块拆得清不清楚直接决定你改功能时会不会牵一发动全身。这套系统的模块大致分五块依赖方向是单向的从下往上基础数据物料原材料/半成品/成品、BOM 物料清单、工序、设备、车间、班组。这块是所有业务的根没有物料就没有工单。订单侧生产工单也叫生产订单、派工单。工单从哪个销售订单来、要生产多少、交期是哪天都记在这里。执行侧领料申请与出库、车间报工合格数/不良数、质检记录。这是车间每天真正在点的按钮。库存侧原料入库、成品入库、出库台账、实时库存。所有执行动作最后都要在这里留痕。系统侧用户、角色、菜单权限、数据字典、操作日志。依赖关系得记牢先有物料和 BOM才能下工单工单下达后才允许领料领料出库后库存才减少报工累计到计划数量工单才能置为已完工完工入库后成品库存才增加。这条链上任何一环的数据不完整后面全是空下拉框和 0 条记录。我一般会先把基础数据跑一遍——建 3 个物料、1 个 BOM、2 道工序——再往下走比对着空数据库猜字段含义高效得多。2.3 分层结构Controller / Service / Mapper 的职责线包结构基本长这样认一遍心里就有数了com.example.pm ├── common # 统一返回体、异常、常量、工具 ├── config # MyBatis-Plus、Security、Jackson、跨域配置 ├── controller # 只做参数校验和调用不写业务 ├── service │ ├── impl # 业务逻辑、事务边界都在这一层 ├── mapper # 接口 XML只做数据访问 ├── entity # 与表一一对应 └── dto / vo # 入参与出参不要直接拿 entity 接前端有一个红灯要提前标出来如果看到 Mapper XML 里写了IF(status 2, ...)这种业务判断或者 Service 里直接new了一个HashMap拼返回值说明这份代码的边界是糊的。毕业设计里这很常见不影响跑但你要往上加功能时会很难受。我的做法是新增功能一律按标准分层写老代码能不动就不动别为了「整洁」去重构一个即将要演示的系统——这是血泪经验。3. 数据库落地建表顺序、utf8mb4 与 RBAC 三张表3.1 库和字符集先定死导入 SQL 之前先把库建好字符集直接指定 utf8mb4。MySQL 里那个叫utf8的字符集其实是三字节的存中文没问题但遇到 emoji 或者某些生僻字会直接报错而且报错位置往往在一条你根本没注意的备注字段上。建库和会话字符集一次设好CREATE DATABASE IF NOT EXISTS pm_system DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE pm_system; -- 保证本次会话的客户端、连接、结果集都是 utf8mb4 SET NAMES utf8mb4;utf8mb4_general_ci里的ci是大小写不敏感物料编码这类字段做唯一索引时不会因为大小写不同而放进去两条重复数据对业务系统来说这个行为更符合直觉。排序规则想更严格可以用utf8mb4_0900_ai_ciMySQL 8 默认但老脚本里如果混用了两种 collation联表查询会直接抛Illegal mix of collations所以整个库统一一种最省事。连接串是第二个高频翻车点参数一个都不能少spring.datasource.urljdbc:mysql://127.0.0.1:3306/pm_system?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue spring.datasource.usernameroot spring.datasource.password你的密码 spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver逐项解释一下characterEncodingutf8mb4保证驱动按四字节编码收发serverTimezoneAsia/Shanghai决定 JDBC 把DATETIME当成哪个时区来解释写成UTC就会出现「数据库里存的是 14:00接口返回 06:00」这种差 8 小时的玄学useSSLfalse是本地开发省掉证书握手allowPublicKeyRetrievaltrue是 MySQL 8 默认认证插件下不加会报Public Key Retrieval is not allowed。上线时useSSL该开还是得开本地图省事没关系。3.2 核心表结构七张表撑起主链主链上的表不多但字段设计决定了后面写代码顺不顺。以工单表为例CREATE TABLE pm_work_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, order_no VARCHAR(32) NOT NULL COMMENT 工单号业务唯一, material_id BIGINT NOT NULL COMMENT 产成品物料ID, plan_qty DECIMAL(14,3) NOT NULL COMMENT 计划生产数量, finished_qty DECIMAL(14,3) NOT NULL DEFAULT 0 COMMENT 已完工数量, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1已下达 2生产中 3已完工 4已关闭, plan_end_date DATE DEFAULT NULL COMMENT 计划完工日期, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除标记, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_status_create (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT生产工单;三个细节值得展开。第一数量用DECIMAL(14,3)而不是DOUBLE因为浮点数做累加会出现0.1 0.2 0.30000000000000004这种结果报工累计几十次之后完工数就和计划数对不上判断「是否已完工」永远不成立。第二finished_qty是冗余字段报工明细表里也能SUM出来之所以冗余是为了列表页不用每次聚合代价是必须保证两边一致——后面第 6 章会用一条对账 SQL 专门查它。第三idx_status_create这个联合索引是为「查所有生产中的工单按创建时间倒序」这个最高频的列表查询准备的索引顺序不能反反了就用不上。建表顺序按依赖来物料 → BOM → 工序 → 工单 → 领料单/明细 → 报工记录 → 库存台账 → 库存流水。物理外键我一般不加主键关联靠应用层保证因为加外键之后初始化数据必须先父后子删数据还得反着来毕业设计演示阶段很容易被这个卡住。3.3 RBAC 三张表与初始化数据权限这块跑不出五张表sys_user、sys_role、sys_user_role、sys_menu、sys_role_menu。用户和角色多对多角色和菜单多对多中间表只放两个外键字段加一个联合主键就够了。初始化管理员时最容易踩的坑是密码。明文存进去能登录但一旦代码里配了BCryptPasswordEncoder明文就永远校验不过表现为「账号密码明明是对的就是提示密码错误」。正确做法是先跑一段代码生成哈希再把哈希贴进data.sqlTest void generateAdminPassword() { BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); // 每次运行结果都不同这是正常的BCrypt 自带随机盐 String hash encoder.encode(123456); System.out.println(hash); // 形如 $2a$10$Xk...复制到 data.sql }注意$2a$10$里的10是强度因子数值越大计算越慢10 是默认值本地够用。同一个明文每次生成的哈希都不一样因为盐是随机的所以不要试图去比对两个哈希是否相等要用encoder.matches(raw, encoded)。初始化脚本里还要记得把sys_role_menu的关联数据一起灌进去只灌用户和角色、不灌菜单关联登录进去会看到一个空白侧边栏很多人以为前端坏了其实是权限数据缺了一半。4. 后端核心接口工单状态机、事务边界与库存扣减4.1 工单状态机把 if-else 收敛成一张转移表工单状态是这个系统里最容易写乱的地方。原始代码里常见一大串if (status 1 target 2) ... else if ...分散在七八个方法里加一个状态就要全局搜一遍。合理的做法是把「谁能流转到谁」抽成一张表当前状态允许流转到触发动作0 草稿1 已下达、4 已关闭下达、作废1 已下达2 生产中、4 已关闭首次报工、作废2 生产中3 已完工、4 已关闭报满计划数、强制关闭3 已完工4 已关闭入库完成4 已关闭无终态不可逆落到代码里就是一个枚举加一张EnumMappublic enum WorkOrderStatus { DRAFT(0, 草稿), RELEASED(1, 已下达), IN_PRODUCTION(2, 生产中), FINISHED(3, 已完工), CLOSED(4, 已关闭); private final int code; private final String label; WorkOrderStatus(int code, String label) { this.code code; this.label label; } public int getCode() { return code; } public String getLabel() { return label; } // 状态转移表key 是当前状态value 是允许到达的状态集合 private static final MapWorkOrderStatus, SetWorkOrderStatus FLOW new EnumMap(WorkOrderStatus.class); static { FLOW.put(DRAFT, EnumSet.of(RELEASED, CLOSED)); FLOW.put(RELEASED, EnumSet.of(IN_PRODUCTION, CLOSED)); FLOW.put(IN_PRODUCTION, EnumSet.of(FINISHED, CLOSED)); FLOW.put(FINISHED, EnumSet.of(CLOSED)); FLOW.put(CLOSED, EnumSet.noneOf(WorkOrderStatus.class)); } public boolean canTransitTo(WorkOrderStatus target) { return FLOW.getOrDefault(this, Collections.emptySet()).contains(target); } public static WorkOrderStatus of(int code) { for (WorkOrderStatus s : values()) { if (s.code code) return s; } throw new IllegalArgumentException(未知工单状态: code); } }用EnumMap而不是HashMap是因为它的内部实现是按枚举序号排的数组查表没有哈希计算开销状态就这几个没必要用重的结构。业务层只留一行校验if (!current.canTransitTo(target)) throw new BizException(不允许从 current.getLabel() 流转到 target.getLabel());。以后要加「部分完工」状态只改FLOW这张表业务代码一行不动——这就是把散落的 if 收敛成数据结构的价值。4.2 报工接口Transactional 的三个边界报工是这个系统里并发最高、也最容易出问题的接口。它要同时干四件事写报工记录、累加工单完工数、不良品入废品库、必要时把工单置为已完工。四件事必须同生共死否则就会出现「报工记录有了但工单数没变」这种对不上的状态。Service public class ReportServiceImpl implements ReportService { Resource private WorkOrderMapper workOrderMapper; Resource private ReportRecordMapper reportRecordMapper; Resource private StockService stockService; Override Transactional(rollbackFor Exception.class) public void submit(ReportDTO dto) { // 1. 幂等同一请求号只允许成功一次防前端重复点击 if (reportRecordMapper.countByRequestId(dto.getRequestId()) 0) { return; // 已处理过直接返回不抛异常 } // 2. 行锁读工单并发报工必须串行否则 finished_qty 会互相覆盖 WorkOrder wo workOrderMapper.selectForUpdate(dto.getWorkOrderId()); if (wo null) { throw new BizException(工单不存在); } WorkOrderStatus cur WorkOrderStatus.of(wo.getStatus()); if (!cur.canTransitTo(WorkOrderStatus.FINISHED)) { throw new BizException(当前工单状态不允许报工); } // 3. 超报校验累计完工数不得超过计划数 BigDecimal next wo.getFinishedQty().add(dto.getQualifiedQty()); if (next.compareTo(wo.getPlanQty()) 0) { throw new BizException(报工数量超出计划剩余可报 wo.getPlanQty().subtract(wo.getFinishedQty())); } // 4. 写明细 回写工单顺序不能反 reportRecordMapper.insert(dto.toRecord()); workOrderMapper.updateFinishedQty(wo.getId(), next); // 5. 不良品单独入废品库不计入完工数 if (dto.getDefectQty().compareTo(BigDecimal.ZERO) 0) { stockService.defectIn(wo.getId(), dto.getDefectQty()); } } }配套的 Mapper 要单独写一条加锁查询不能用selectByIdselect idselectForUpdate resultTypecom.example.pm.entity.WorkOrder SELECT * FROM pm_work_order WHERE id #{id} AND deleted 0 FOR UPDATE /select几个参数和写法的含义rollbackFor Exception.class是必须的因为 Spring 默认只回滚RuntimeException而业务代码里如果抛了受检异常事务会悄悄提交钱和货就对不上了。FOR UPDATE是行锁只锁住这一行工单并发报同一张单的请求会排队不同工单互不影响所以粒度够细。requestId由前端生成一般是uuid随请求带上后端靠它做幂等表上要建唯一索引兜底ALTER TABLE pm_report_record ADD UNIQUE KEY uk_request_id (request_id);有唯一索引之后即使两个请求同时穿过了上面的查询判断第二个 insert 也会因为唯一键冲突而抛异常事务回滚结果依然是正确的。这就是「先查后插」必须有数据库约束兜底的原因。4.3 库存扣减乐观锁和行锁怎么选库存扣减和报工不一样它的并发更高、热点更集中。常见两种方案方案实现方式适用场景代价悲观锁SELECT ... FOR UPDATE单行冲突极高、业务必须串行锁等待吞吐低乐观锁UPDATE ... WHERE version ?冲突概率中等读多写少失败要重试可能空转条件扣减UPDATE ... WHERE qty ?只关心不超卖失败无法区分原因库存我一般直接用条件扣减简洁且不会超卖UPDATE pm_stock SET qty qty - #{qty}, version version 1 WHERE material_id #{materialId} AND qty #{qty}执行后判断受影响行数等于 1 说明扣成功等于 0 说明要么库存不够、要么物料不存在直接抛业务异常让事务回滚。这条 SQL 的并发安全由 InnoDB 的行锁保证不需要在 Java 里做任何额外同步。为什么要加qty #{qty}这个条件因为如果写成SET qty qty - ?不加条件库存充足时扣成功、不足时变成负数仓库数量就永久错了而且这种负数在列表页上很不显眼能一直藏着。加上这个条件负数根本写不进去。有个细节容易被忽略报工里的不良品入库、领料出库、完工入库这三处都会调用库存服务事务传播行为默认REQUIRED就够它们会加入外层事务。但如果其中有调用远程接口或者发消息就不能放在同一个事务里否则消息发出去了、事务回滚了下游就拿到了脏数据——这套系统里没有 MQ所以暂时不用考虑但你二次开发接 MQ 时一定要记住这一点。5. 联调与上线排查分页、跨域、打包环节的五个常见问题5.1 分页总数不对、第一页数据重复现象列表第 2 页和第 1 页数据一模一样total显示的也是个明显不对的数字翻到第 3 页反而正常了。原因项目里同时存在 PageHelper 和 MyBatis-Plus 的PaginationInnerInterceptor。两者都在 SQL 层做改写一个把语句改成带LIMIT的另一个再改一遍COUNT语句就彻底错乱了。很多毕设项目是网上的模块拼起来的作者自己都没发现装了两个分页插件。解决二选一先搜pom.xml里的pagehelper依赖和application.yml里的pagehelper:配置块确认用的是哪一套。如果项目主体用的是 MP 的PageT就把 PageHelper 干净移除。分页拦截器这样配Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor page new PaginationInnerInterceptor(DbType.MYSQL); page.setOverflow(false); // 超出最大页返回空集合而不是悄悄回到第一页 page.setMaxLimit(500L); // 单页最多 500 条防止 size999999 拖垮数据库 interceptor.addInnerInterceptor(page); return interceptor; } }setOverflow(true)的话你传page100而实际只有 5 页它会自动回到第 1 页前端表现是「翻着翻着突然跳回开头」很容易被当成 bug 报上来。setMaxLimit是给自己留的后悔药报表页面一旦有人传入超大size没有这个限制数据库会先卡住再超时。5.2 跨域预检 403、Token 一登录就过期现象前端跑 5173 或 8080、后端跑 9090浏览器控制台先报CORS preflight失败或者OPTIONS请求直接 403另一种是登录接口返回成功、Token 也拿到了但一刷新页面就提示未授权。原因第一种是权限框架把OPTIONS预检请求也当成需要鉴权的请求拦了下来预检都没过真正的请求根本发不出去。第二种十有八九是jwt.expire这个配置项——代码里按毫秒取值配置里填了7200本意是两小时实际是 7.2 秒。解决跨域统一交给框架别自己在 Controller 上逐个加CrossOriginBean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg new CorsConfiguration(); cfg.addAllowedOriginPattern(*); // 本地调试放开上线换成固定域名 cfg.addAllowedHeader(*); cfg.addAllowedMethod(*); cfg.setAllowCredentials(true); // 允许携带 Cookie 时必须用 OriginPattern UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, cfg); return source; }同时在过滤器链里放行预检.antMatchers(HttpMethod.OPTIONS, /**).permitAll()登录接口也加入白名单。allowedOriginPattern(*)和allowedOrigins(*)的区别值得记一下后者在开启allowCredentials时会直接抛异常因为通配符加凭证在规范里是不允许的组合前者是 Spring 提供的正则匹配版本可以共存。Token 过期时间统一在配置里写清楚单位比如jwt.expire-ms: 7200000字段名里带上单位比写在注释里可靠。5.3 打包后刷新 404、静态资源加载失败现象前端npm run build之后把dist整个拷进resources/static首页能打开一按 F5 刷新/workOrder/list就 404或者页面白屏控制台一堆 JS 404。原因前端用的是 history 路由服务端没有做 fallback。浏览器直接请求/workOrder/list后端找不到这个路径的处理器自然 404——而在前端里点导航是 JS 拦截的不会真的发请求所以只在刷新时暴露。解决部署层加一条 fallback。用 Nginx 最省事server { listen 80; root /usr/share/nginx/html; # dist 内容放这里 location / { try_files $uri $uri/ /index.html; # 找不到文件就交给前端路由 } location /api/ { proxy_pass http://127.0.0.1:9090/; } }try_files按顺序尝试先找同名文件再找同名目录都没有就返回index.html前端路由自己解析路径。静态资源 404 的另一个常见原因是 Vite 或 Webpack 的base/publicPath没改默认是/部署到子路径/pm/下就全错位改成base: /pm/重新打包即可。5.4 时间差 8 小时、数量小数位丢失现象报工时间在数据库里看是对的接口返回给前端变成 8 小时前或者领料数量 1.5 在页面上显示成 1。原因前者是连接串serverTimezoneUTC和 JVM 时区不一致JDBC 驱动先按 UTC 解释再转来回一次就差了 8 小时。后者是实体字段用了Double或Float或者用了BigDecimal但序列化成了数字JS 的Number精度只有 15~16 位长小数会被截断。解决连接串改成Asia/Shanghai实体字段统一BigDecimal然后加一个全局序列化配置让金额和数量以字符串形式返回给前端Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder - builder // BigDecimal 输出为字符串避免 JS Number 精度丢失 .serializerByType(BigDecimal.class, ToStringSerializer.instance) .timeZone(TimeZone.getTimeZone(Asia/Shanghai)) .simpleDateFormat(yyyy-MM-dd HH:mm:ss); }前端拿到字符串后做展示即可参与计算时用decimal.js这类库别直接parseFloat。5.5 报工重复提交导致完工数翻倍现象车间网络慢操作工连点两次提交工单的finished_qty直接多了一倍库存台账上也多出两条记录。原因按钮没做防抖后端也没有幂等控制两次请求都进了事务都查到了「原来是 0」都写成了「现在是 10」。解决三层一起上。前端提交时立刻disabled按钮请求结束再恢复后端靠requestId做幂等见 4.2 节表上加唯一索引工单行加FOR UPDATE让并发请求排队。三层里最不能省的是数据库唯一索引前两层都是体验优化只有它在极端情况下能真正兜住。排查这类问题时最快的定位方式是把报工记录按work_order_id分组查一遍SELECT request_id, COUNT(*) AS cnt FROM pm_report_record WHERE work_order_id ? GROUP BY request_id HAVING cnt 1;能查出重复的直接说明幂等没生效查不出但完工数还是偏大那问题就在finished_qty的更新语句上去看是不是用了「先查再set绝对值」的写法。6. 二次开发前先做一次对账把完工数和报工数拉平这套系统二次开发时最怕的不是加功能而是改完之后数据悄悄对不上等演示前一天才发现完工数是错的。我的习惯是每次动完报工或库存相关的代码先跑一遍对账再跑一次并发两关都过才继续。6.1 一条对账 SQL 揪出不一致finished_qty是冗余字段理论上永远等于该工单下所有报工记录的合格数之和。理论上成立实践里不成立尤其是改过事务边界之后SELECT wo.order_no, wo.plan_qty, wo.finished_qty AS 工单已完工, IFNULL(SUM(r.qualified_qty), 0) AS 报工合格合计, wo.finished_qty - IFNULL(SUM(r.qualified_qty), 0) AS 差异 FROM pm_work_order wo LEFT JOIN pm_report_record r ON r.work_order_id wo.id AND r.deleted 0 GROUP BY wo.id, wo.order_no, wo.plan_qty, wo.finished_qty HAVING 差异 0;LEFT JOIN保证没有报工记录的工单也会出现在结果里SUM为 null 时用IFNULL兜成 0GROUP BY里必须带上所有非聚合列MySQL 8 默认开了ONLY_FULL_GROUP_BY少写一个就报错HAVING而不是WHERE因为差异是聚合之后才算出来的。这条 SQL 返回空集才算过关。查出来有差异的一般是两种情况事务没覆盖住中间某个更新或者报工记录被逻辑删除了但完工数没回退。前者改代码后者补一条回滚逻辑。6.2 并发压测暴露「看不见」的翻倍单线程点一百次都不会出问题的接口五十个并发跑二十轮就一定出问题。不用搞复杂工具JMeter 命令行就够jmeter -n -t report.jmx -l result.jtl \ -Jthreads50 -Jloops20 \ -Jhost127.0.0.1 -Jport9090 -Jtoken你的token-n是无界面模式服务器上直接跑-Jthreads和-Jloops通过属性传入方便在不同环境改并发数result.jtl里能直接看到失败率和响应时间。压完立刻再跑一次 6.1 的对账 SQL如果差异不为空说明锁和事务还没兜住回去看selectForUpdate有没有被真的调用到。这里有个很隐蔽的坑Transactional加在private方法上或者被同类内部方法直接调用代理不生效事务根本没开——单看代码完全看不出来只能靠压测暴露。对账 SQL 我一般会存成项目里的一个.sql文件改完相关代码就跑一次。从那以后每次动事务边界或者库存扣减我都会强制走一遍「先对账、再压测」的流程宁可多花十分钟也不想在演示前十分钟对着一个差 200 的完工数手足无措。希望帮到你。本文还有配套的精品资源点击获取