Junjie
2026-04-27 1080e8e862fcfb9492fd3eed78c34b7ba3abfe32
fix: strengthen auto tune apply transaction safety
2个文件已修改
279 ■■■■ 已修改文件
src/main/java/com/zy/ai/service/impl/AutoTuneApplyServiceImpl.java 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/zy/ai/service/AutoTuneApplyServiceImplTest.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/AutoTuneApplyServiceImpl.java
@@ -56,7 +56,7 @@
    private BasDualCrnpService basDualCrnpService;
    @Autowired
    private StationFlowCapacityService stationFlowCapacityService;
    @Autowired(required = false)
    @Autowired
    private PlatformTransactionManager transactionManager;
    @Override
@@ -132,31 +132,37 @@
    private ValidatedChange validateChange(AutoTuneChangeCommand command, boolean dryRun, Date now) {
        ValidatedChange validatedChange = new ValidatedChange(command);
        AutoTuneRuleDefinition.Rule rule = AutoTuneRuleDefinition.findRule(command.getTargetType(), command.getTargetKey());
        AutoTuneRuleDefinition.Rule rule = AutoTuneRuleDefinition.findRule(
                validatedChange.getTargetType(),
                validatedChange.getTargetKey()
        );
        if (rule == null) {
            return validatedChange.reject("不支持的调参目标: " + command.getTargetType() + "/" + command.getTargetKey());
            return validatedChange.reject("不支持的调参目标: "
                    + validatedChange.getTargetType() + "/" + validatedChange.getTargetKey());
        }
        validatedChange.setRule(rule);
        Integer requestedValue = parseRequestedInt(command.getNewValue());
        Integer requestedValue = parseRequestedInt(validatedChange.getRawRequestedValue());
        if (requestedValue == null) {
            return validatedChange.reject(command.getTargetKey() + " 必须为整数");
            return validatedChange.reject(validatedChange.getTargetKey() + " 必须为整数");
        }
        validatedChange.setRequestedIntValue(requestedValue);
        validatedChange.setRequestedValue(String.valueOf(requestedValue));
        CurrentValue currentValue = readCurrentValueForValidation(command, rule);
        CurrentValue currentValue = readCurrentValueForValidation(validatedChange, rule);
        if (currentValue.getRejectReason() != null) {
            return validatedChange.reject(currentValue.getRejectReason());
        }
        validatedChange.setOldValue(currentValue.getOldValue());
        Integer maxValue = resolveMaxValue(command, rule, requestedValue);
        Integer maxValue = resolveMaxValue(validatedChange, rule, requestedValue);
        if (maxValue == null) {
            return validatedChange.reject("站点 " + command.getTargetId() + " 缺少 OUT 方向 bufferCapacity,无法证明 outTaskLimit 上限");
            return validatedChange.reject("站点 " + validatedChange.getTargetId()
                    + " 缺少 OUT 方向 bufferCapacity,无法证明 outTaskLimit 上限");
        }
        if (requestedValue < rule.getMinValue() || requestedValue > maxValue) {
            return validatedChange.reject(command.getTargetKey() + " 必须在 " + rule.getMinValue() + "~" + maxValue + " 范围内");
            return validatedChange.reject(validatedChange.getTargetKey() + " 必须在 "
                    + rule.getMinValue() + "~" + maxValue + " 范围内");
        }
        if (Objects.equals(currentValue.getNumericValue(), requestedValue)) {
@@ -164,10 +170,10 @@
        }
        int step = Math.abs(requestedValue - currentValue.getNumericValue());
        if (step > rule.getMaxStep()) {
            return validatedChange.reject(command.getTargetKey() + " 单次调整步长不能超过 " + rule.getMaxStep());
            return validatedChange.reject(validatedChange.getTargetKey() + " 单次调整步长不能超过 " + rule.getMaxStep());
        }
        Date cooldownExpireTime = findCooldownExpireTime(command, now);
        Date cooldownExpireTime = findCooldownExpireTime(validatedChange, now);
        if (cooldownExpireTime != null) {
            validatedChange.setCooldownExpireTime(cooldownExpireTime);
            return validatedChange.reject("目标仍在冷却期,冷却截止时间: " + cooldownExpireTime);
@@ -178,40 +184,40 @@
        return validatedChange.accept(dryRun ? ChangeStatus.DRY_RUN : ChangeStatus.PENDING, null);
    }
    private CurrentValue readCurrentValueForValidation(AutoTuneChangeCommand command, AutoTuneRuleDefinition.Rule rule) {
    private CurrentValue readCurrentValueForValidation(ValidatedChange validatedChange, AutoTuneRuleDefinition.Rule rule) {
        AutoTuneTargetType targetType = rule.getTargetType();
        Integer targetId = parseTargetId(command.getTargetId(), targetType);
        Integer targetId = parseTargetId(validatedChange.getTargetId(), targetType);
        if (!AutoTuneTargetType.SYS_CONFIG.equals(targetType) && targetId == null) {
            return CurrentValue.rejected("targetId 必须为整数");
        }
        if (AutoTuneTargetType.SYS_CONFIG.equals(targetType)) {
            Config config = configService.getOne(new QueryWrapper<Config>().eq("code", command.getTargetKey()).last("limit 1"));
            Config config = configService.getOne(new QueryWrapper<Config>().eq("code", validatedChange.getTargetKey()).last("limit 1"));
            if (config == null) {
                return CurrentValue.rejected("运行参数不存在: " + command.getTargetKey());
                return CurrentValue.rejected("运行参数不存在: " + validatedChange.getTargetKey());
            }
            return numericCurrentValue(config.getValue(), false, command.getTargetKey());
            return numericCurrentValue(config.getValue(), false, validatedChange.getTargetKey());
        }
        if (AutoTuneTargetType.STATION.equals(targetType)) {
            BasStation station = basStationService.getById(targetId);
            if (station == null) {
                return CurrentValue.rejected("站点不存在: " + command.getTargetId());
                return CurrentValue.rejected("站点不存在: " + validatedChange.getTargetId());
            }
            return numericCurrentValue(toText(station.getOutTaskLimit()), true, command.getTargetKey());
            return numericCurrentValue(toText(station.getOutTaskLimit()), true, validatedChange.getTargetKey());
        }
        if (AutoTuneTargetType.CRN.equals(targetType)) {
            BasCrnp crnp = basCrnpService.getById(targetId);
            if (crnp == null) {
                return CurrentValue.rejected("堆垛机不存在: " + command.getTargetId());
                return CurrentValue.rejected("堆垛机不存在: " + validatedChange.getTargetId());
            }
            Integer value = "maxOutTask".equals(command.getTargetKey()) ? crnp.getMaxOutTask() : crnp.getMaxInTask();
            return numericCurrentValue(toText(value), false, command.getTargetKey());
            Integer value = "maxOutTask".equals(validatedChange.getTargetKey()) ? crnp.getMaxOutTask() : crnp.getMaxInTask();
            return numericCurrentValue(toText(value), false, validatedChange.getTargetKey());
        }
        BasDualCrnp dualCrnp = basDualCrnpService.getById(targetId);
        if (dualCrnp == null) {
            return CurrentValue.rejected("双工位堆垛机不存在: " + command.getTargetId());
            return CurrentValue.rejected("双工位堆垛机不存在: " + validatedChange.getTargetId());
        }
        Integer value = "maxOutTask".equals(command.getTargetKey()) ? dualCrnp.getMaxOutTask() : dualCrnp.getMaxInTask();
        return numericCurrentValue(toText(value), false, command.getTargetKey());
        Integer value = "maxOutTask".equals(validatedChange.getTargetKey()) ? dualCrnp.getMaxOutTask() : dualCrnp.getMaxInTask();
        return numericCurrentValue(toText(value), false, validatedChange.getTargetKey());
    }
    private CurrentValue numericCurrentValue(String oldValue, boolean nullOrNegativeAsZero, String targetKey) {
@@ -232,13 +238,13 @@
        }
    }
    private Integer resolveMaxValue(AutoTuneChangeCommand command,
    private Integer resolveMaxValue(ValidatedChange validatedChange,
                                    AutoTuneRuleDefinition.Rule rule,
                                    Integer requestedValue) {
        if (!rule.isDynamicMaxValue()) {
            return rule.getMaxValue();
        }
        Integer targetId = parseTargetId(command.getTargetId(), rule.getTargetType());
        Integer targetId = parseTargetId(validatedChange.getTargetId(), rule.getTargetType());
        StationFlowCapacity capacity = stationFlowCapacityService.getOne(
                new QueryWrapper<StationFlowCapacity>()
                        .eq("station_id", targetId)
@@ -251,13 +257,12 @@
        return Math.max(0, capacity.getBufferCapacity());
    }
    private Date findCooldownExpireTime(AutoTuneChangeCommand command, Date now) {
        String targetId = normalizeTargetId(command.getTargetType(), command.getTargetId());
    private Date findCooldownExpireTime(ValidatedChange validatedChange, Date now) {
        List<AiAutoTuneChange> recentChanges = aiAutoTuneChangeService.list(
                new QueryWrapper<AiAutoTuneChange>()
                        .eq("target_type", command.getTargetType())
                        .eq("target_id", targetId)
                        .eq("target_key", command.getTargetKey())
                        .eq("target_type", validatedChange.getTargetType())
                        .eq("target_id", validatedChange.getTargetId())
                        .eq("target_key", validatedChange.getTargetKey())
                        .eq("result_status", ChangeStatus.SUCCESS.getCode())
                        .gt("cooldown_expire_time", now)
                        .orderByDesc("create_time")
@@ -275,10 +280,6 @@
    }
    private void applyValidatedChangesInTransaction(List<ValidatedChange> validatedChanges) {
        if (transactionManager == null) {
            applyValidatedChanges(validatedChanges);
            return;
        }
        TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
        transactionTemplate.executeWithoutResult(status -> applyValidatedChanges(validatedChanges));
    }
@@ -289,11 +290,15 @@
            if (ChangeStatus.NO_CHANGE.equals(validatedChange.getStatus())) {
                continue;
            }
            AutoTuneChangeCommand command = validatedChange.getCommand();
            writeValue(command.getTargetType(), command.getTargetId(), command.getTargetKey(), validatedChange.getRequestedValue());
            writeValue(
                    validatedChange.getTargetType(),
                    validatedChange.getTargetId(),
                    validatedChange.getTargetKey(),
                    validatedChange.getRequestedValue()
            );
            validatedChange.accept(ChangeStatus.SUCCESS, null);
            validatedChange.setAppliedValue(validatedChange.getRequestedValue());
            if (AutoTuneTargetType.SYS_CONFIG.getCode().equals(command.getTargetType())) {
            if (AutoTuneTargetType.SYS_CONFIG.getCode().equals(validatedChange.getTargetType())) {
                refreshConfigCache = true;
            }
        }
@@ -305,9 +310,6 @@
    private List<AiAutoTuneChange> rollbackChangesInTransaction(Long rollbackJobId,
                                                                List<AiAutoTuneChange> sourceChanges,
                                                                Date now) {
        if (transactionManager == null) {
            return rollbackChanges(rollbackJobId, sourceChanges, now);
        }
        TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
        return transactionTemplate.execute(status -> rollbackChanges(rollbackJobId, sourceChanges, now));
    }
@@ -338,48 +340,60 @@
    }
    private void writeValue(String targetType, String targetId, String targetKey, String value) {
        AutoTuneTargetType parsedTargetType = AutoTuneTargetType.fromCode(targetType);
        String normalizedTargetType = normalizeText(targetType);
        String normalizedTargetKey = normalizeText(targetKey);
        String normalizedTargetId = normalizeTargetId(normalizedTargetType, targetId);
        AutoTuneTargetType parsedTargetType = AutoTuneTargetType.fromCode(normalizedTargetType);
        if (parsedTargetType == null) {
            throw new IllegalArgumentException("不支持的调参目标: " + normalizedTargetType + "/" + normalizedTargetKey);
        }
        if (AutoTuneTargetType.SYS_CONFIG.equals(parsedTargetType)) {
            if (!configService.saveConfigValue(targetKey, value)) {
                throw new IllegalStateException("保存运行参数失败: " + targetKey);
            if (!configService.saveConfigValue(normalizedTargetKey, value)) {
                throw new IllegalStateException("保存运行参数失败: " + normalizedTargetKey);
            }
            return;
        }
        Integer parsedTargetId = parseTargetId(targetId, parsedTargetType);
        Integer parsedTargetId = parseTargetId(normalizedTargetId, parsedTargetType);
        Integer intValue = value == null ? null : Integer.valueOf(value);
        if (AutoTuneTargetType.STATION.equals(parsedTargetType)) {
            boolean updated = basStationService.update(new UpdateWrapper<BasStation>()
                    .eq("station_id", parsedTargetId)
                    .set("out_task_limit", intValue));
            if (!updated) {
                throw new IllegalStateException("保存站点参数失败: " + targetId + "/" + targetKey);
                throw new IllegalStateException("保存站点参数失败: " + normalizedTargetId + "/" + normalizedTargetKey);
            }
            return;
        }
        if (AutoTuneTargetType.CRN.equals(parsedTargetType)) {
            String column = "maxOutTask".equals(targetKey) ? "max_out_task" : "max_in_task";
            String column = "maxOutTask".equals(normalizedTargetKey) ? "max_out_task" : "max_in_task";
            boolean updated = basCrnpService.update(new UpdateWrapper<BasCrnp>()
                    .eq("crn_no", parsedTargetId)
                    .set(column, intValue));
            if (!updated) {
                throw new IllegalStateException("保存堆垛机参数失败: " + targetId + "/" + targetKey);
                throw new IllegalStateException("保存堆垛机参数失败: " + normalizedTargetId + "/" + normalizedTargetKey);
            }
            return;
        }
        String column = "maxOutTask".equals(targetKey) ? "max_out_task" : "max_in_task";
        String column = "maxOutTask".equals(normalizedTargetKey) ? "max_out_task" : "max_in_task";
        boolean updated = basDualCrnpService.update(new UpdateWrapper<BasDualCrnp>()
                .eq("crn_no", parsedTargetId)
                .set(column, intValue));
        if (!updated) {
            throw new IllegalStateException("保存双工位堆垛机参数失败: " + targetId + "/" + targetKey);
            throw new IllegalStateException("保存双工位堆垛机参数失败: " + normalizedTargetId + "/" + normalizedTargetKey);
        }
    }
    private String readCurrentValue(String targetType, String targetId, String targetKey) {
        AutoTuneTargetType parsedTargetType = AutoTuneTargetType.fromCode(targetType);
        Integer parsedTargetId = parseTargetId(targetId, parsedTargetType);
        String normalizedTargetType = normalizeText(targetType);
        String normalizedTargetKey = normalizeText(targetKey);
        String normalizedTargetId = normalizeTargetId(normalizedTargetType, targetId);
        AutoTuneTargetType parsedTargetType = AutoTuneTargetType.fromCode(normalizedTargetType);
        if (parsedTargetType == null) {
            throw new IllegalArgumentException("不支持的调参目标: " + normalizedTargetType + "/" + normalizedTargetKey);
        }
        Integer parsedTargetId = parseTargetId(normalizedTargetId, parsedTargetType);
        if (AutoTuneTargetType.SYS_CONFIG.equals(parsedTargetType)) {
            Config config = configService.getOne(new QueryWrapper<Config>().eq("code", targetKey).last("limit 1"));
            Config config = configService.getOne(new QueryWrapper<Config>().eq("code", normalizedTargetKey).last("limit 1"));
            return config == null ? null : config.getValue();
        }
        if (AutoTuneTargetType.STATION.equals(parsedTargetType)) {
@@ -391,13 +405,13 @@
            if (crnp == null) {
                return null;
            }
            return toText("maxOutTask".equals(targetKey) ? crnp.getMaxOutTask() : crnp.getMaxInTask());
            return toText("maxOutTask".equals(normalizedTargetKey) ? crnp.getMaxOutTask() : crnp.getMaxInTask());
        }
        BasDualCrnp dualCrnp = basDualCrnpService.getById(parsedTargetId);
        if (dualCrnp == null) {
            return null;
        }
        return toText("maxOutTask".equals(targetKey) ? dualCrnp.getMaxOutTask() : dualCrnp.getMaxInTask());
        return toText("maxOutTask".equals(normalizedTargetKey) ? dualCrnp.getMaxOutTask() : dualCrnp.getMaxInTask());
    }
    private AiAutoTuneJob createJob(AutoTuneApplyRequest request, boolean dryRun, Date now) {
@@ -463,7 +477,15 @@
        job.setSuccessCount(successCount);
        job.setRejectCount(rejectCount);
        job.setIntervalAfter(readIntervalMinutes());
        job.setStatus(rejectCount == 0 ? AutoTuneJobStatus.SUCCESS.getCode() : AutoTuneJobStatus.PARTIAL_SUCCESS.getCode());
        if (rejectCount == 0) {
            job.setStatus(AutoTuneJobStatus.SUCCESS.getCode());
        } else if (successCount == 0 && hasFailedChange(changes)) {
            job.setStatus(AutoTuneJobStatus.FAILED.getCode());
        } else if (successCount == 0) {
            job.setStatus(AutoTuneJobStatus.REJECTED.getCode());
        } else {
            job.setStatus(AutoTuneJobStatus.PARTIAL_SUCCESS.getCode());
        }
        job.setSummary("回滚最近一次成功调参,成功 " + successCount + " 项,失败 " + rejectCount + " 项");
        if (rejectCount > 0) {
            job.setErrorMessage(firstRejectReason(changes));
@@ -502,13 +524,14 @@
        List<AiAutoTuneChange> auditChanges = new ArrayList<>();
        for (ValidatedChange validatedChange : validatedChanges) {
            AiAutoTuneChange change = new AiAutoTuneChange();
            AutoTuneChangeCommand command = validatedChange.getCommand();
            change.setJobId(jobId);
            change.setTargetType(command.getTargetType());
            change.setTargetId(normalizeTargetId(command.getTargetType(), command.getTargetId()));
            change.setTargetKey(command.getTargetKey());
            change.setTargetType(validatedChange.getTargetType());
            change.setTargetId(validatedChange.getTargetId());
            change.setTargetKey(validatedChange.getTargetKey());
            change.setOldValue(validatedChange.getOldValue());
            change.setRequestedValue(validatedChange.getRequestedValue() == null ? command.getNewValue() : validatedChange.getRequestedValue());
            change.setRequestedValue(validatedChange.getRequestedValue() == null
                    ? validatedChange.getRawRequestedValue()
                    : validatedChange.getRequestedValue());
            change.setAppliedValue(validatedChange.getAppliedValue());
            change.setResultStatus(validatedChange.getStatus().getCode());
            change.setRejectReason(validatedChange.getRejectReason());
@@ -658,12 +681,16 @@
        }
    }
    private String normalizeTargetId(String targetType, String targetId) {
    private static String normalizeTargetId(String targetType, String targetId) {
        AutoTuneTargetType parsedTargetType = AutoTuneTargetType.fromCode(targetType);
        if (AutoTuneTargetType.SYS_CONFIG.equals(parsedTargetType)) {
            return "";
        }
        return targetId == null ? "" : targetId.trim();
        return normalizeText(targetId);
    }
    private static String normalizeText(String value) {
        return value == null ? "" : value.trim();
    }
    private int readIntervalMinutes() {
@@ -734,6 +761,10 @@
    private static class ValidatedChange {
        private final AutoTuneChangeCommand command;
        private final String targetType;
        private final String targetId;
        private final String targetKey;
        private final String rawRequestedValue;
        private AutoTuneRuleDefinition.Rule rule;
        private String oldValue;
        private String requestedValue;
@@ -745,6 +776,10 @@
        private ValidatedChange(AutoTuneChangeCommand command) {
            this.command = command == null ? new AutoTuneChangeCommand() : command;
            this.targetType = normalizeText(this.command.getTargetType());
            this.targetId = normalizeTargetId(this.targetType, this.command.getTargetId());
            this.targetKey = normalizeText(this.command.getTargetKey());
            this.rawRequestedValue = this.command.getNewValue();
        }
        private ValidatedChange reject(String reason) {
@@ -773,6 +808,22 @@
            return command;
        }
        public String getTargetType() {
            return targetType;
        }
        public String getTargetId() {
            return targetId;
        }
        public String getTargetKey() {
            return targetKey;
        }
        public String getRawRequestedValue() {
            return rawRequestedValue;
        }
        public AutoTuneRuleDefinition.Rule getRule() {
            return rule;
        }
src/test/java/com/zy/ai/service/AutoTuneApplyServiceImplTest.java
@@ -26,6 +26,10 @@
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.SimpleTransactionStatus;
import java.util.ArrayList;
import java.util.Collection;
@@ -63,10 +67,12 @@
    private BasDualCrnpService basDualCrnpService;
    @Mock
    private StationFlowCapacityService stationFlowCapacityService;
    private RecordingTransactionManager transactionManager;
    @BeforeEach
    void setUp() {
        service = new AutoTuneApplyServiceImpl();
        transactionManager = new RecordingTransactionManager();
        ReflectionTestUtils.setField(service, "aiAutoTuneJobService", aiAutoTuneJobService);
        ReflectionTestUtils.setField(service, "aiAutoTuneChangeService", aiAutoTuneChangeService);
        ReflectionTestUtils.setField(service, "configService", configService);
@@ -74,6 +80,7 @@
        ReflectionTestUtils.setField(service, "basCrnpService", basCrnpService);
        ReflectionTestUtils.setField(service, "basDualCrnpService", basDualCrnpService);
        ReflectionTestUtils.setField(service, "stationFlowCapacityService", stationFlowCapacityService);
        ReflectionTestUtils.setField(service, "transactionManager", transactionManager);
        AtomicLong jobId = new AtomicLong(100);
        when(aiAutoTuneJobService.save(any(AiAutoTuneJob.class))).thenAnswer(invocation -> {
@@ -229,6 +236,41 @@
    }
    @Test
    void whitespaceTargetFieldsAreNormalizedBeforeValidationWriteAndAudit() {
        when(basCrnpService.getById(1)).thenReturn(crn(1, 1, 9));
        AutoTuneApplyResult result = service.apply(request(false, command(" crn ", " 1 ", " maxOutTask ", "2")));
        List<AiAutoTuneChange> changes = savedChanges();
        assertTrue(result.getSuccess());
        assertEquals("crn", changes.get(0).getTargetType());
        assertEquals("1", changes.get(0).getTargetId());
        assertEquals("maxOutTask", changes.get(0).getTargetKey());
        assertEquals("success", changes.get(0).getResultStatus());
        assertEquals(1, transactionManager.getCommitCount());
        verify(basCrnpService).update(any(Wrapper.class));
    }
    @Test
    void whitespaceTargetFieldsUseNormalizedCooldownScope() {
        when(basCrnpService.getById(1)).thenReturn(crn(1, 1, 9));
        AiAutoTuneChange cooldownChange = new AiAutoTuneChange();
        cooldownChange.setResultStatus("success");
        cooldownChange.setCooldownExpireTime(new Date(System.currentTimeMillis() + 60_000L));
        when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(cooldownChange));
        AutoTuneApplyResult result = service.apply(request(true, command(" crn ", " 1 ", " maxOutTask ", "2")));
        List<AiAutoTuneChange> changes = savedChanges();
        assertFalse(result.getSuccess());
        assertEquals("crn", changes.get(0).getTargetType());
        assertEquals("1", changes.get(0).getTargetId());
        assertEquals("maxOutTask", changes.get(0).getTargetKey());
        assertEquals("rejected", changes.get(0).getResultStatus());
        assertTrue(changes.get(0).getRejectReason().contains("冷却期"));
    }
    @Test
    void applyMixedBatchSuccessfully() {
        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
        when(basStationService.getById(101)).thenReturn(station(101, 1));
@@ -249,6 +291,7 @@
        assertEquals(4, result.getSuccessCount());
        assertEquals(0, result.getRejectCount());
        assertTrue(changes.stream().allMatch(change -> "success".equals(change.getResultStatus())));
        assertEquals(1, transactionManager.getCommitCount());
        verify(configService).saveConfigValue("conveyorStationTaskLimit", "15");
        verify(configService).refreshSystemConfigCache();
        verify(basStationService).update(any(Wrapper.class));
@@ -267,6 +310,7 @@
        assertFalse(result.getSuccess());
        assertEquals("failed", changes.get(0).getResultStatus());
        assertTrue(changes.get(0).getRejectReason().contains("db write failed"));
        assertEquals(1, transactionManager.getRollbackCount());
    }
    @Test
@@ -293,6 +337,28 @@
        verify(configService).saveConfigValue("conveyorStationTaskLimit", "10");
        verify(configService).refreshSystemConfigCache();
        verify(basStationService).update(any(Wrapper.class));
    }
    @Test
    void fullyFailedRollbackReturnsFailedStatus() {
        AiAutoTuneJob latestRealJob = job(10L, "manual", "success");
        AiAutoTuneChange configChange = successChange(10L, "sys_config", "", "conveyorStationTaskLimit", "10", "15");
        when(aiAutoTuneChangeService.list(any(Wrapper.class)))
                .thenReturn(List.of(configChange))
                .thenReturn(List.of(configChange));
        when(aiAutoTuneJobService.getById(10L)).thenReturn(latestRealJob);
        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "15"));
        when(configService.saveConfigValue("conveyorStationTaskLimit", "10"))
                .thenThrow(new IllegalStateException("rollback write failed"));
        AutoTuneApplyResult result = service.rollbackLastSuccessfulJob("manual rollback");
        List<AiAutoTuneChange> changes = savedChanges();
        AiAutoTuneJob updatedJob = updatedJob();
        assertFalse(result.getSuccess());
        assertEquals("failed", updatedJob.getStatus());
        assertEquals("failed", changes.get(0).getResultStatus());
        assertEquals(1, transactionManager.getRollbackCount());
    }
    private AutoTuneApplyRequest request(boolean dryRun, AutoTuneChangeCommand... commands) {
@@ -382,4 +448,40 @@
        verify(aiAutoTuneChangeService).saveBatch(captor.capture());
        return new ArrayList<>(captor.getValue());
    }
    private AiAutoTuneJob updatedJob() {
        ArgumentCaptor<AiAutoTuneJob> captor = ArgumentCaptor.forClass(AiAutoTuneJob.class);
        verify(aiAutoTuneJobService).updateById(captor.capture());
        return captor.getValue();
    }
    private static class RecordingTransactionManager implements PlatformTransactionManager {
        private int beginCount;
        private int commitCount;
        private int rollbackCount;
        @Override
        public TransactionStatus getTransaction(TransactionDefinition definition) {
            beginCount++;
            return new SimpleTransactionStatus();
        }
        @Override
        public void commit(TransactionStatus status) {
            commitCount++;
        }
        @Override
        public void rollback(TransactionStatus status) {
            rollbackCount++;
        }
        public int getCommitCount() {
            return commitCount;
        }
        public int getRollbackCount() {
            return rollbackCount;
        }
    }
}