1.报工审批模块1.我当时负责了报工后的审批模块。如果工人报工后需要主管进行审批审批通过后要同时触发对应的物料消耗扣减、产品和产品和成品入库的数据更新以及工单和任务的进度更新这点当时的难度对我来说还是挺有挑战的因为审批这个单次操作的操作就涉及了物料、仓库、和生产进度这3个领域且还涉及到了6张表的更新当时的主要的业务逻辑是根据对应的报工单去查询对应的工艺路线得到两个keyFlag和checkFlag接着就要去仓库中扣料然后接着要根据keyFlag的值判断是否是关键工序如果不是关键工序的话因为非关键工序没有设计到物料的消耗也没有产生和产出此时直接不入库了直接结束即可。如果是关键工序的话还要考虑判断是否需要质检如果不需要质检直接根据工单中填的合格品数量和不合格品数量分别生成产出行合格品入成品库不合格品入废品库两条行都生成对应的库存的入库流水如果需要质检的话这是最复杂的此时系统先完成了物料消耗生成一条“待检”状态的产出行此时报工单的状态就变为了“带检验”此时直接返回这时候物料已经扣了但是产品没有入库需要等待QC任务质检完成之后才能入库等QC人员完成检验后QC就会表面合格和不合格的数量此时就可以将原来的待检行拆成两行合格品行入库到成品区不合格品走废品流程同时会写报工单的合格/不合格数量接着去更新工单和任务进度因为这个业务的涉及的状态转换和表有点多此时的状态转换我是通过一个状态机来严格控制状态的扭转顺序保证状态的正确扭转且你要保证如果这其中的某一步出错的话对应的数据库表的数据也要回滚这里我是通过一个整体事务用Transactional注解将所有涉及到的操作都包成一个整体这样如果里面的任何一部出现异常此时自动回滚对应的数据库表数据第一层是状态扭转控制。我没有用第三方的状态机框架就是用枚举定义好工单的几种状态然后在每个操作方法里先查一次数据库拿到当前状态if 判断是否允许转换。比如工单完成操作只允许 CONFIRMED 状态才能执行如果不是直接抛异常。这样虽然代码看起来是 if-else但逻辑上是严格的状态校验而且不用引入额外的框架依赖。第一步定义状态和事件枚举public enum FeedbackState { PREPARE, // 草稿 APPROVING, // 审批中 UNCHECK, // 待检验 FINISHED // 已完成 } public enum FeedbackEvent { SUBMIT, // 提交审批 REJECT, // 驳回 APPROVE, // 审批通过非质检工序→直接完成 APPROVE_QC, // 审批通过质检工序→待检验 QC_PASS // 质检完成回写 }第二步配置状态转换规则Action 挂载到转换上Configuration EnableStateMachine(name feedbackStateMachine) public class FeedbackStateMachineConfig extends StateMachineConfigurerAdapterFeedbackState, FeedbackEvent { Resource private SubmitAction submitAction; Resource private RejectAction rejectAction; Resource private ApproveFinishAction approveFinishAction; // 非质检工序直接完成 Resource private ApproveToQcAction approveToQcAction; // 质检工序进入待检验 Resource private QcPassAction qcPassAction; // 质检完成拆行入库进度 Override public void configure(StateMachineStateConfigurerFeedbackState, FeedbackEvent states) { states.withStates() .initial(FeedbackState.PREPARE) .states(EnumSet.allOf(FeedbackState.class)); } Override public void configure(StateMachineTransitionConfigurerFeedbackState, FeedbackEvent transitions) { transitions // 1. 提交审批草稿 → 审批中 .withExternal() .source(FeedbackState.PREPARE) .target(FeedbackState.APPROVING) .event(FeedbackEvent.SUBMIT) .action(submitAction) .and() // 2. 驳回审批中 → 草稿 .withExternal() .source(FeedbackState.APPROVING) .target(FeedbackState.PREPARE) .event(FeedbackEvent.REJECT) .action(rejectAction) .and() // 3. 审批通过非关键工序/非质检工序审批中 → 已完成 .withExternal() .source(FeedbackState.APPROVING) .target(FeedbackState.FINISHED) .event(FeedbackEvent.APPROVE) .action(approveFinishAction) .and() // 4. 审批通过关键质检工序审批中 → 待检验 .withExternal() .source(FeedbackState.APPROVING) .target(FeedbackState.UNCHECK) .event(FeedbackEvent.APPROVE_QC) .action(approveToQcAction) .and() // 5. 质检完成回写待检验 → 已完成 .withExternal() .source(FeedbackState.UNCHECK) .target(FeedbackState.FINISHED) .event(FeedbackEvent.QC_PASS) .action(qcPassAction); } }第三步Action 里写真正的业务逻辑现在每个 Action 只关心状态变完之后要做什么不关心当前状态允不允许——那是框架干的。//SubmitAction 提交审批只改状态无级联操作 Component public class SubmitAction implements StateMachineActionFeedbackState, FeedbackEvent { Resource private MesProFeedbackMapper feedbackMapper; Override public void execute(StateContextFeedbackState, FeedbackEvent context) { Long feedbackId context.getMessage().getHeaders().get(feedbackId, Long.class); feedbackMapper.updateById(new MesProFeedbackDO() .setId(feedbackId) .setStatus(FeedbackState.APPROVING.name())); } }//ApproveFinishAction 审批通过非质检工序→ 物料消耗 入库 进度 Component public class ApproveFinishAction implements StateMachineActionFeedbackState, FeedbackEvent { Resource private MesWmItemConsumeService itemConsumeService; Resource private MesWmProductProduceService productProduceService; Resource private MesProFeedbackMapper feedbackMapper; Resource private MesProTaskService taskService; Resource private MesProWorkOrderService workOrderService; Override public void execute(StateContextFeedbackState, FeedbackEvent context) { Long feedbackId context.getMessage().getHeaders().get(feedbackId, Long.class); // 1. 物料消耗 MesWmItemConsumeDO consume itemConsumeService.generateItemConsume(feedbackId); if (consume ! null) { itemConsumeService.finishItemConsume(consume.getId()); } // 2. 产成品入库 MesWmProductProduceDO produce productProduceService.generateProductProduce(feedbackId, false); productProduceService.finishProductProduce(produce.getId()); // 3. 更新任务/工单进度 MesProFeedbackDO feedback feedbackMapper.selectById(feedbackId); taskService.updateProducedQuantity(feedback.getTaskId(), ...); workOrderService.updateProducedQuantity(feedback.getWorkOrderId(), ...); // 4. 更新状态为 FINISHED feedbackMapper.updateById(new MesProFeedbackDO() .setId(feedbackId).setStatus(FeedbackState.FINISHED.name())); } }//ApproveToQcAction 审批通过质检工序→ 只生成待检产出行不入库 Component public class ApproveToQcAction implements StateMachineActionFeedbackState, FeedbackEvent { Resource private MesWmProductProduceService productProduceService; Resource private MesProFeedbackMapper feedbackMapper; Override public void execute(StateContextFeedbackState, FeedbackEvent context) { Long feedbackId context.getMessage().getHeaders().get(feedbackId, Long.class); // 1. 生成待检产出行qualityStatus PENDING productProduceService.generateProductProduce(feedbackId, true); // 2. 状态改为 UNCHECK feedbackMapper.updateById(new MesProFeedbackDO() .setId(feedbackId).setStatus(FeedbackState.UNCHECK.name())); } }//QcPassAction 质检完成 → 拆合格/不合格行 入库 进度 Component public class QcPassAction implements StateMachineActionFeedbackState, FeedbackEvent { Resource private MesWmProductProduceService productProduceService; Resource private MesProFeedbackMapper feedbackMapper; Resource private MesProTaskService taskService; Override public void execute(StateContextFeedbackState, FeedbackEvent context) { Long feedbackId context.getMessage().getHeaders().get(feedbackId, Long.class); Long sourceLineId context.getMessage().getHeaders().get(sourceLineId, Long.class); BigDecimal qualifiedQty context.getMessage().getHeaders().get(qualifiedQty, BigDecimal.class); BigDecimal unqualifiedQty context.getMessage().getHeaders().get(unqualifiedQty, BigDecimal.class); // 1. 拆分待检产出行 productProduceService.splitPendingAndFinishProduce(feedbackId, sourceLineId, qualifiedQty, unqualifiedQty); // 2. 更新报工单状态为 FINISHED 回填合格/不合格数量 feedbackMapper.updateById(new MesProFeedbackDO() .setId(feedbackId) .setQualifiedQuantity(qualifiedQty) .setUnqualifiedQuantity(unqualifiedQty) .setStatus(FeedbackState.FINISHED.name())); // 3. 更新任务/工单进度 MesProFeedbackDO feedback feedbackMapper.selectById(feedbackId); taskService.updateProducedQuantity(feedback.getTaskId(), ...); workOrderService.updateProducedQuantity(feedback.getWorkOrderId(), ...); } }第四步持久化——状态机重启后从数据库恢复Spring State Machine 默认所有状态存在内存里服务器重启就丢了。报工单的状态必须持久化到数据库所以需要一个 Persist 层Component public class FeedbackStatePersist implements StateMachinePersistFeedbackState, FeedbackEvent, Long { Resource private MesProFeedbackMapper feedbackMapper; Override public void write(StateMachineContextFeedbackState, FeedbackEvent context, Long feedbackId) { // 状态变更时把当前状态写入数据库 feedbackMapper.updateById(new MesProFeedbackDO() .setId(feedbackId) .setStatus(context.getState().name())); } Override public StateMachineContextFeedbackState, FeedbackEvent read(Long feedbackId) { // 启动时从数据库恢复状态 MesProFeedbackDO feedback feedbackMapper.selectById(feedbackId); if (feedback null) return null; return new DefaultStateMachineContext( FeedbackState.valueOf(feedback.getStatus()), null, null, null, null); } }然后通过拦截器让每次状态变更自动持久化public class PersistingStateChangeInterceptor extends StateMachineInterceptorAdapterFeedbackState, FeedbackEvent { Resource private FeedbackStatePersist persist; Override public void preStateChange(StateContextFeedbackState, FeedbackEvent context) { Long feedbackId context.getMessage().getHeaders().get(feedbackId, Long.class); persist.write(context, feedbackId); } Override public void postStateChange(StateContextFeedbackState, FeedbackEvent context) { // post状态已持久化完毕 } }持久层不是用来写的是用于服务重启后从数据库恢复状态机的内存状态让状态机知道你当前在哪个状态别从头开始。写库的事 Action 已经做了。服务器重启后发生的事服务器启动 ↓ 一个请求过来要审批通过 feedbackId 42 的报工单 ↓ Service 层调用 stateMachineFactory.getStateMachine(42) ↓ Spring State Machine 创建了一个全新的内存状态机实例 ↓ 这个状态机不知道 feedback 42 当前是 PREPARE 还是 APPROVING 还是 FINISHED ↓ 默认从 initial 状态PREPARE开始 ↓ 如果此时发一个 APPROVE 事件 → 状态机从 PREPARE 成功走到 APPROVING ---但数据库里这个报工单实际已经是 FINISHED 了--- ---状态机认为它在 APPROVING数据库认为它是 FINISHED--- ---后面再发一个 QC_PASS 事件状态机还能继续走实际上已经乱套了---第五步 改造后的 Service不再手动写状态校验全部交给状态机框架Service public class MesProFeedbackServiceImpl { Resource private StateMachineFactoryFeedbackState, FeedbackEvent stateMachineFactory; Resource private FeedbackStatePersist statePersist; Override Transactional(rollbackFor Exception.class) public boolean approveFeedback(Long id) { // 1. 查询报工单的业务属性决定用哪个 Event MesProFeedbackDO feedback feedbackMapper.selectById(id); MesProRouteProcessDO routeProcess routeProcessService.getRouteProcessByRouteIdAndProcessId( feedback.getRouteId(), feedback.getProcessId()); boolean keyFlag Boolean.TRUE.equals(routeProcess.getKeyFlag()); boolean checkFlag Boolean.TRUE.equals(routeProcess.getCheckFlag()); // 2. 根据业务属性选择事件 FeedbackEvent event; if (keyFlag checkFlag) { event FeedbackEvent.APPROVE_QC; // 关键质检工序 → 待检验 } else { event FeedbackEvent.APPROVE; // 其他 → 直接完成 } // 3. 构建消息 MessageFeedbackEvent message MessageBuilder .withPayload(event) .setHeader(feedbackId, id) .build(); // 4. 获取状态机恢复状态发送事件 StateMachineFeedbackState, FeedbackEvent sm stateMachineFactory.getStateMachine(id.toString()); sm.stop(); sm.getStateMachineAccessor() .withRegion() .addStateMachineInterceptor(new PersistingStateChangeInterceptor()); statePersist.read(sm, id); sm.start(); boolean success sm.sendEvent(message); if (!success) { throw exception(PRO_FEEDBACK_STATUS_ERROR); } return FeedbackState.FINISHED.equals(sm.getState().getId()); } }使用状态机的好处好处一状态流转规则集中在一个地方不会散落如果以后有些单据的状态流转的规则发生了改变此时就很容器去修改了只需要去调整状态转换配置即可好处二非法流转被框架拦截而不是靠人记得写 if。第二层是事务一致性。我把所有级联操作放到同一个方法里在方法上加了Transactional(rollbackFor Exception.class)。比如 finishWorkOrder 方法里先是遍历完成该工单下的所有子任务再更新工单状态为已完成。任何一个子任务抛异常比如任务状态不对整个事务回滚工单状态不会变成已完成保证数据一致。级联操作就是一个入口方法内部按顺序自动调用多个关联表的操作方法2.策略模式实现编码2.第二个的话就是当时mes系统中有很多单据比如工单、质检单、入库单、批次号等不同的单据编码规则还不一样有些事固定前缀日期流水号有的还要带上物料分类我一开始做的时候就是给直接给不同的单据模块写一个生成单号的方法逻辑都是拼接字符串数据库去max流水号我写着写着就发现有点奇怪这些编码的逻辑差不多是一样了写了很多重复的代码但是代码洁癖发作了而且当时还考虑到如果以后有增加了很多单据那么岂不是又要写很多重复的代码当时就去优化一下后面的话就想到了那个策略模式我把编码拆分成四种基本片段固定字符串、日期、用户输入的字符、依旧流水号。每种片段都独立设计成一个策略类都实现同一个策略接口此时生成编码的时候此时只要去数据库读取配置号的规则和字符按顺序遍历每段调用对应的策略生成对应的内容然后再拼接起来即可我这样改完之后就省去了很多重复的代码我看起来也舒服多了而且用例策略模式如果后续新增一种单据编码此时只需要在数据库中配置表中添加对应的编码信息接口后端哪里此时也只用在去增加一个新的策略类去实现策略接口此时就不用对整体的业务逻辑进行很大的调整这样代码的可扩展性也很高了第一步抽象策略接口// 4 种片段类型 enum MesMdAutoCodePartTypeEnum { FIXED_CHAR(3, 固定字符), // 比如 WO- DATE(2, 当前日期), // 比如 20260705 INPUT_CHAR(1, 输入字符), // 比如物料编码 MAT001 SERIAL_NUMBER(4, 流水号) // 比如 0001 } // 策略接口 public interface MesMdAutoCodePartStrategy { Integer getType(); // 返回类型用于匹配 String generate(MesMdAutoCodePartDO part, MesMdAutoCodeContext context); }第二步四种策略各自实现固定字符策略最简单返回配好的值Component public class MesMdAutoCodeFixedCharPartStrategy implements MesMdAutoCodePartStrategy { Override public Integer getType() { return MesMdAutoCodePartTypeEnum.FIXED_CHAR.getType(); // 3 } Override public String generate(MesMdAutoCodePartDO part, MesMdAutoCodeContext context) { // 直接从配置取固定字符比如 WO- return StrUtil.emptyToDefault(part.getFixCharacter(), ); } }日期策略按配置的格式返回当前时间Component public class MesMdAutoCodeDatePartStrategy implements MesMdAutoCodePartStrategy { Override public Integer getType() { return MesMdAutoCodePartTypeEnum.DATE.getType(); // 2 } Override public String generate(MesMdAutoCodePartDO part, MesMdAutoCodeContext context) { String dateFormat StrUtil.emptyToDefault(part.getDateFormat(), yyyyMMdd); return DateUtil.format(LocalDateTime.now(), dateFormat); } }输入字符策略返回调用方传入的参数Component public class MesMdAutoCodeInputCharPartStrategy implements MesMdAutoCodePartStrategy { Override public Integer getType() { return MesMdAutoCodePartTypeEnum.INPUT_CHAR.getType(); // 1 } Override public String generate(MesMdAutoCodePartDO part, MesMdAutoCodeContext context) { // 调用方传进来的参数比如物料编码 return StrUtil.emptyToDefault(context.getInputChar(), ); } }流水号策略最核心的Redis INCR 自增 按周期重置Component public class MesMdAutoCodeSerialNumberPartStrategy implements MesMdAutoCodePartStrategy { Resource private MesMdAutoCodeRedisDAO redisDAO; Override public Integer getType() { return MesMdAutoCodePartTypeEnum.SERIAL_NUMBER.getType(); // 4 } Override public String generate(MesMdAutoCodePartDO part, MesMdAutoCodeContext context) { // key 后缀支持按天/月/年归零 String keySuffix buildRedisKeySuffix(part, context); // 比如 key auto_code:3:20260705 Integer step part.getSerialStep() ! null ? part.getSerialStep() : 1; Integer startNo part.getSerialStartNo() ! null ? part.getSerialStartNo() : 1; Long serialNo redisDAO.increment(keySuffix, duration, startNo, step); // 补零长度不够左边补 0 String serialStr String.valueOf(serialNo); if (serialStr.length() part.getLength()) { serialStr StrUtil.padPre(serialStr, part.getLength(), 0); } context.setSerialNo(serialNo); return serialStr; } }第三步PostConstruct 自动注入所有策略Service public class MesMdAutoCodeRecordServiceImpl implements MesMdAutoCodeRecordService { Resource private ListMesMdAutoCodePartStrategy strategyList; // Spring 自动注入所有策略实现 private MapInteger, MesMdAutoCodePartStrategy strategyMap; PostConstruct public void init() { // type → 策略 的映射 // FIXED_CHAR(3) → FixedCharPartStrategy // DATE(2) → DatePartStrategy // INPUT_CHAR(1) → InputCharPartStrategy // SERIAL_NUMBER(4) → SerialNumberPartStrategy strategyMap CollectionUtils.convertMap(strategyList, MesMdAutoCodePartStrategy::getType); } }这里用到了PostConstruct List 注入——Spring 会自动收集所有MesMdAutoCodePartStrategy的实现类4 个 Component然后 init 时按 type 放到 Map 里。如果以后加第 5 种策略只需要新加一个 Component 类一行现有代码都不用改。第四步编排引擎按配置顺序拼接Override Transactional public String generateAutoCode(String ruleCode, String inputChar) { // 1. 从数据库查出编码规则和它的各个片段列表按 sort 排序 MesMdAutoCodeRuleDO rule ruleService.getAutoCodeRuleByCode(ruleCode); ListMesMdAutoCodePartDO parts partService.getAutoCodePartListByRuleId(rule.getId()); // 2. 遍历每个片段按 type 找到对应的策略执行 MesMdAutoCodeContext context new MesMdAutoCodeContext() .setRule(rule).setParts(parts).setInputChar(inputChar); StringBuilder codeBuilder new StringBuilder(); for (MesMdAutoCodePartDO part : parts) { // 从 Map 中取出对应策略 MesMdAutoCodePartStrategy strategy strategyMap.get(part.getType()); // 策略生成内容 String partCode strategy.generate(part, context); // 截断到指定长度 partCode StrUtil.sub(partCode, 0, part.getLength()); codeBuilder.append(partCode); } // 3. 补全到最大长度可选 String result codeBuilder.toString(); if (Boolean.TRUE.equals(rule.getPadded())) { result padCode(result, rule); } // 4. 二次校验 保存记录 // ... return result; }3Redis INCR指令生成流水号3.还有那个用INCR原子性解决流水号生成重复的问题我一开始写的当时生成流水号的逻辑是从数据库中查最大的流水号然后加1然后插入对应的流水号表。后来上线的时候报了个错误前端提示“编码重复保存失败”我第一反应是去查数据库找到那两条重复的工单记录发现它们的编码一模一样都是WO-20260304-0015但创建时间差了不到 50 毫秒然后我去翻那个时间点的应用日志看到第一条 insert 成功第二条 insert 被 MySQL 的唯一索引挡住了抛了个DuplicateEntryException。说明两个请求几乎是同时进来的因为我写的流水号生成逻辑是“从数据库中查最大的流水号然后加1”这个 查询最大流水号的select和后面的 insert 之间没有加任何锁。两个线程同时查到同一个最大值各自加 1 算出同样的流水号此时就撞上了当时为了证明这个观点我还用JMeter模拟了20个线程并发的创建工单然后就发现这个流水号重复生成的问题复现了定位清楚之后我当时不想加锁我觉得这种流水号生成是一种非常频繁的操作加锁的效率会很低此时我就想到用Redis的INCR指令这种就是天然的编码流水号重复生成的方案嘛能够保证每次自增都是原子的且后面我发现流水号的归零重置也很容易了此时只需要在key的设计上按照规则ID加日期后缀这样不同天生成的流水号都可以是从0开始的4.Reddision分布式锁防止库存扣减Service public class MesWmTransactionServiceImpl implements MesWmTransactionService { Resource private RedissonClient redissonClient; Resource private MesWmMaterialStockMapper materialStockMapper; Resource private MesWmTransactionMapper transactionMapper; Override Transactional(rollbackFor Exception.class) public Long createTransaction(MesWmTransactionSaveReqDTO reqDTO) { // 1. 锁外前置校验物料存在性、仓库冻结等 MesWmTransactionTypeEnum typeEnum MesWmTransactionTypeEnum.valueOf(reqDTO.getType()); if (typeEnum null) { throw exception(WM_TRANSACTION_TYPE_NOT_EXISTS); } // 2. 获取或创建库存记录这一步可以在锁外做因为 getOrCreate 不涉及数量变更 MesWmMaterialStockDO materialStock materialStockService.getOrCreateMaterialStock( reqDTO.getItemId(), reqDTO.getWarehouseId(), reqDTO.getLocationId(), reqDTO.getAreaId(), reqDTO.getBatchId(), reqDTO.getBatchCode(), reqDTO.getVendorId(), reqDTO.getReceiptTime()); // 3. 加分布式锁key 按物料 批次粒度 String lockKey stock: reqDTO.getItemId() : reqDTO.getBatchId(); RLock lock redissonClient.getLock(lockKey); // 尝试获取锁等待 3 秒持有锁 10 秒看门狗自动续期 boolean acquired lock.tryLock(3, 10, TimeUnit.SECONDS); if (!acquired) { throw exception(WM_TRANSACTION_LOCK_ACQUIRE_FAILED); } try { // 4. 锁内重新查询库存拿到最新值 MesWmMaterialStockDO latestStock materialStockMapper.selectById(materialStock.getId()); // 5. 扣减校验如果是出库操作判断库存是否够 if (!typeEnum.isInbound()) { // delta 是负数直接用当前库存 delta 判断 if (latestStock.getQuantity().add(reqDTO.getQuantity()).compareTo(BigDecimal.ZERO) 0) { throw exception(WM_MATERIAL_STOCK_INSUFFICIENT); } } // 6. 执行库存更新这里不用 CAS 了因为分布式锁已经串行化了 materialStockMapper.updateQuantityDirect(latestStock.getId(), reqDTO.getQuantity()); // 7. 插入事务流水 MesWmTransactionDO transaction MesWmTransactionDO.builder() .type(reqDTO.getType()) .quantity(reqDTO.getQuantity()) .itemId(reqDTO.getItemId()) .batchId(reqDTO.getBatchId()) .materialStockId(materialStock.getId()) .bizType(reqDTO.getBizType()) .bizId(reqDTO.getBizId()) .bizCode(reqDTO.getBizCode()) .build(); transactionMapper.insert(transaction); } finally { // 8. 释放锁 lock.unlock(); } return transaction.getId(); } }