From 63b01db83d9aad8a15276b4236a9a22e4aeef065 Mon Sep 17 00:00:00 2001
From: Junjie <fallin.jie@qq.com>
Date: 星期二, 05 五月 2026 12:30:59 +0800
Subject: [PATCH] # Agent数据分析V3.0.1.7

---
 src/test/java/com/zy/ai/service/AutoTuneApplyServiceImplTest.java |  812 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 785 insertions(+), 27 deletions(-)

diff --git a/src/test/java/com/zy/ai/service/AutoTuneApplyServiceImplTest.java b/src/test/java/com/zy/ai/service/AutoTuneApplyServiceImplTest.java
index d17da07..4650cc9 100644
--- a/src/test/java/com/zy/ai/service/AutoTuneApplyServiceImplTest.java
+++ b/src/test/java/com/zy/ai/service/AutoTuneApplyServiceImplTest.java
@@ -7,39 +7,55 @@
 import com.zy.ai.entity.AiAutoTuneChange;
 import com.zy.ai.entity.AiAutoTuneJob;
 import com.zy.ai.service.impl.AutoTuneApplyServiceImpl;
+import com.zy.ai.service.impl.AutoTuneControlModeServiceImpl;
 import com.zy.asrs.entity.BasCrnp;
 import com.zy.asrs.entity.BasDualCrnp;
 import com.zy.asrs.entity.BasStation;
-import com.zy.asrs.entity.StationFlowCapacity;
+import com.zy.asrs.entity.WrkMast;
 import com.zy.asrs.service.BasCrnpService;
 import com.zy.asrs.service.BasDualCrnpService;
 import com.zy.asrs.service.BasStationService;
-import com.zy.asrs.service.StationFlowCapacityService;
+import com.zy.asrs.service.WrkMastService;
+import com.zy.common.utils.RedisUtil;
+import com.zy.core.enums.RedisKeyType;
 import com.zy.system.entity.Config;
 import com.zy.system.service.ConfigService;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 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;
 import java.util.Collections;
 import java.util.Date;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicLong;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -62,33 +78,48 @@
     @Mock
     private BasDualCrnpService basDualCrnpService;
     @Mock
-    private StationFlowCapacityService stationFlowCapacityService;
+    private WrkMastService wrkMastService;
+    @Mock
+    private RedisUtil redisUtil;
+    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);
+        ReflectionTestUtils.setField(service, "autoTuneControlModeService",
+                new AutoTuneControlModeServiceImpl(configService));
         ReflectionTestUtils.setField(service, "basStationService", basStationService);
         ReflectionTestUtils.setField(service, "basCrnpService", basCrnpService);
         ReflectionTestUtils.setField(service, "basDualCrnpService", basDualCrnpService);
-        ReflectionTestUtils.setField(service, "stationFlowCapacityService", stationFlowCapacityService);
+        ReflectionTestUtils.setField(service, "wrkMastService", wrkMastService);
+        ReflectionTestUtils.setField(service, "transactionManager", transactionManager);
+        ReflectionTestUtils.setField(service, "redisUtil", redisUtil);
 
         AtomicLong jobId = new AtomicLong(100);
         when(aiAutoTuneJobService.save(any(AiAutoTuneJob.class))).thenAnswer(invocation -> {
+            transactionManager.recordJobSaveCall();
             AiAutoTuneJob job = invocation.getArgument(0);
             job.setId(jobId.incrementAndGet());
             return true;
         });
-        when(aiAutoTuneJobService.updateById(any(AiAutoTuneJob.class))).thenReturn(true);
+        when(aiAutoTuneJobService.updateById(any(AiAutoTuneJob.class))).thenAnswer(invocation -> {
+            transactionManager.recordJobUpdateCall();
+            return true;
+        });
         when(aiAutoTuneChangeService.saveBatch(any(Collection.class))).thenReturn(true);
         when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(wrkMastService.count(any(Wrapper.class))).thenReturn(0L);
         when(configService.getConfigValue(eq("aiAutoTuneIntervalMinutes"), any())).thenReturn("10");
+        when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("N");
         when(configService.saveConfigValue(any(), any())).thenReturn(true);
         when(basStationService.update(any(Wrapper.class))).thenReturn(true);
         when(basCrnpService.update(any(Wrapper.class))).thenReturn(true);
         when(basDualCrnpService.update(any(Wrapper.class))).thenReturn(true);
+        when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(true);
     }
 
     @Test
@@ -115,6 +146,16 @@
     }
 
     @Test
+    void rejectNonnumericNewValue() {
+        AutoTuneApplyResult result = service.apply(request(true, command("sys_config", null, "aiAutoTuneIntervalMinutes", "abc")));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        assertFalse(result.getSuccess());
+        assertEquals("rejected", changes.get(0).getResultStatus());
+        assertTrue(changes.get(0).getRejectReason().contains("蹇呴』涓烘暣鏁�"));
+    }
+
+    @Test
     void rejectOverStepConveyorLimitChange() {
         when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
 
@@ -126,15 +167,137 @@
     }
 
     @Test
-    void rejectStationOutTaskLimitAboveDirectionalBufferCapacity() {
-        when(basStationService.getById(101)).thenReturn(station(101, 1));
-        when(stationFlowCapacityService.getOne(any(Wrapper.class))).thenReturn(capacity(101, "OUT", 2));
+    void crnOutBatchRunningLimitAllowsStepThreeAndRejectsRangeAndStepCases() {
+        when(configService.getOne(any(Wrapper.class))).thenReturn(config("crnOutBatchRunningLimit", "10"));
 
-        service.apply(request(true, command("station", "101", "outTaskLimit", "3")));
+        service.apply(request(true,
+                command("sys_config", null, "crnOutBatchRunningLimit", "13"),
+                command("sys_config", null, "crnOutBatchRunningLimit", "14"),
+                command("sys_config", null, "crnOutBatchRunningLimit", "21")
+        ));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        assertEquals("dry_run", changes.get(0).getResultStatus());
+        assertEquals("13", changes.get(0).getRequestedValue());
+        assertEquals("rejected", changes.get(1).getResultStatus());
+        assertTrue(changes.get(1).getRejectReason().contains("姝ラ暱涓嶈兘瓒呰繃 3"));
+        assertEquals("rejected", changes.get(2).getResultStatus());
+        assertTrue(changes.get(2).getRejectReason().contains("1~20"));
+    }
+
+    @Test
+    void maxInTaskAllowsStepThreeAndRejectsRangeAndStepCases() {
+        when(basCrnpService.getById(1)).thenReturn(crn(1, 1, 5));
+
+        service.apply(request(true,
+                command("crn", "1", "maxInTask", "8"),
+                command("crn", "1", "maxInTask", "9"),
+                command("crn", "1", "maxInTask", "11")
+        ));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        assertEquals("dry_run", changes.get(0).getResultStatus());
+        assertEquals("8", changes.get(0).getRequestedValue());
+        assertEquals("rejected", changes.get(1).getResultStatus());
+        assertTrue(changes.get(1).getRejectReason().contains("姝ラ暱涓嶈兘瓒呰繃 3"));
+        assertEquals("rejected", changes.get(2).getResultStatus());
+        assertTrue(changes.get(2).getRejectReason().contains("0~10"));
+    }
+
+    @Test
+    void maxOutTaskAllowsStepThreeAndRejectsStepFour() {
+        when(basCrnpService.getById(1)).thenReturn(crn(1, 1, 1));
+
+        service.apply(request(true,
+                command("crn", "1", "maxOutTask", "4"),
+                command("crn", "1", "maxOutTask", "5")
+        ));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        assertEquals("dry_run", changes.get(0).getResultStatus());
+        assertEquals("4", changes.get(0).getRequestedValue());
+        assertEquals("rejected", changes.get(1).getResultStatus());
+        assertTrue(changes.get(1).getRejectReason().contains("姝ラ暱涓嶈兘瓒呰繃 3"));
+    }
+
+    @Test
+    void dualCrnMaxOutTaskAllowsStepThreeAndRejectsStepFour() {
+        when(basDualCrnpService.getById(2)).thenReturn(dualCrn(2, 1, 1));
+
+        service.apply(request(true,
+                command("dual_crn", "2", "maxOutTask", "4"),
+                command("dual_crn", "2", "maxOutTask", "5")
+        ));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        assertEquals("dry_run", changes.get(0).getResultStatus());
+        assertEquals("4", changes.get(0).getRequestedValue());
+        assertEquals("rejected", changes.get(1).getResultStatus());
+        assertTrue(changes.get(1).getRejectReason().contains("姝ラ暱涓嶈兘瓒呰繃 3"));
+    }
+
+    @Test
+    void stationOutTaskLimitAllowsAboveBufferCapacityButRejectsOverStep() {
+        when(basStationService.getById(101)).thenReturn(station(101, 5, 3));
+
+        service.apply(request(true,
+                command("station", "101", "outTaskLimit", "6"),
+                command("station", "101", "outTaskLimit", "9")
+        ));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        assertEquals("dry_run", changes.get(0).getResultStatus());
+        assertEquals("5", changes.get(0).getOldValue());
+        assertEquals("6", changes.get(0).getRequestedValue());
+        assertEquals("rejected", changes.get(1).getResultStatus());
+        assertTrue(changes.get(1).getRejectReason().contains("姝ラ暱涓嶈兘瓒呰繃 3"));
+    }
+
+    @Test
+    void rejectStationOutTaskLimitNullUnlimitedCurrentValue() {
+        when(basStationService.getById(101)).thenReturn(station(101, null));
+
+        service.apply(request(true, command("station", "101", "outTaskLimit", "1")));
 
         List<AiAutoTuneChange> changes = savedChanges();
         assertEquals("rejected", changes.get(0).getResultStatus());
-        assertTrue(changes.get(0).getRejectReason().contains("0~2"));
+        assertTrue(changes.get(0).getRejectReason().contains("闇�瑕佷汉宸ュ厛鍒濆鍖栦负鏈夐檺鍊�"));
+    }
+
+    @Test
+    void rejectStationOutTaskLimitNegativeUnlimitedCurrentValue() {
+        when(basStationService.getById(101)).thenReturn(station(101, -1));
+
+        service.apply(request(true, command("station", "101", "outTaskLimit", "1")));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        assertEquals("rejected", changes.get(0).getResultStatus());
+        assertTrue(changes.get(0).getRejectReason().contains("闇�瑕佷汉宸ュ厛鍒濆鍖栦负鏈夐檺鍊�"));
+    }
+
+    @Test
+    void allowStationOutTaskLimitZeroToOneAsFiniteStep() {
+        when(basStationService.getById(101)).thenReturn(station(101, 0, 1));
+
+        AutoTuneApplyResult result = service.apply(request(true, command("station", "101", "outTaskLimit", "1")));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        assertTrue(result.getSuccess());
+        assertEquals("dry_run", changes.get(0).getResultStatus());
+        assertEquals("0", changes.get(0).getOldValue());
+        assertEquals("1", changes.get(0).getRequestedValue());
+    }
+
+    @Test
+    void allowStationOutTaskLimitWithoutOutBufferCapacity() {
+        when(basStationService.getById(101)).thenReturn(station(101, 0));
+
+        AutoTuneApplyResult result = service.apply(request(true, command("station", "101", "outTaskLimit", "1")));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        assertTrue(result.getSuccess());
+        assertEquals("dry_run", changes.get(0).getResultStatus());
+        assertEquals("1", changes.get(0).getRequestedValue());
     }
 
     @Test
@@ -153,10 +316,184 @@
     }
 
     @Test
+    void realMixedValidAndInvalidBatchRejectsAndDoesNotWriteTargets() {
+        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
+        when(basStationService.getById(101)).thenReturn(station(101, 1, 2));
+
+        AutoTuneApplyResult result = service.apply(request(false,
+                command("sys_config", null, "conveyorStationTaskLimit", "15"),
+                command("station", "101", "outTaskLimit", "5")
+        ));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        assertFalse(result.getSuccess());
+        assertEquals(2, changes.size());
+        assertTrue(changes.stream().allMatch(change -> "rejected".equals(change.getResultStatus())));
+        verify(configService, never()).saveConfigValue(any(), any());
+        verify(basStationService, never()).update(any(Wrapper.class));
+    }
+
+    @Test
+    void analysisOnlyRealApplyWritesRejectedAuditAndDoesNotAcquireApplyLock() {
+        when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("Y");
+
+        AutoTuneApplyResult result = service.apply(request(false,
+                command("sys_config", null, "conveyorStationTaskLimit", "15")));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        AiAutoTuneJob job = updatedJob();
+        assertFalse(result.getSuccess());
+        assertTrue(result.getAnalysisOnly());
+        assertTrue(result.getNoApply());
+        assertEquals("rejected", job.getStatus());
+        assertEquals("rejected", changes.get(0).getResultStatus());
+        assertTrue(changes.get(0).getRejectReason().contains("浠呭垎鏋愭ā寮忕姝㈠疄闄呭簲鐢�/鍥炴粴"));
+        verify(redisUtil, never()).trySetStringIfAbsent(anyString(), anyString(), anyLong());
+        verify(configService, never()).saveConfigValue(any(), any());
+    }
+
+    @Test
+    void realApplyResultUsesEntryControlModeSnapshotWhenConfigFlips() {
+        when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("N", "Y");
+        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
+
+        AutoTuneApplyResult result = service.apply(request(false,
+                command("sys_config", null, "conveyorStationTaskLimit", "15")));
+
+        assertTrue(result.getSuccess());
+        assertFalse(result.getAnalysisOnly());
+        verify(configService, times(1)).getConfigValue("aiAutoTuneAnalysisOnly", "Y");
+    }
+
+    @Test
+    void acceptedDryRunDoesNotWriteTargetStores() {
+        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
+
+        AutoTuneApplyResult result = service.apply(request(true, command("sys_config", null, "conveyorStationTaskLimit", "15")));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        assertTrue(result.getSuccess());
+        assertEquals("dry_run", changes.get(0).getResultStatus());
+        verify(configService, never()).saveConfigValue(any(), any());
+        verify(basStationService, never()).update(any(Wrapper.class));
+        verify(basCrnpService, never()).update(any(Wrapper.class));
+        verify(basDualCrnpService, never()).update(any(Wrapper.class));
+    }
+
+    @Test
+    void analysisOnlyRollbackWritesRejectedAuditAndDoesNotAcquireApplyLock() {
+        when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("Y");
+
+        AutoTuneApplyResult result = service.rollbackLastSuccessfulJob("manual rollback");
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        AiAutoTuneJob job = updatedJob();
+        assertFalse(result.getSuccess());
+        assertTrue(result.getAnalysisOnly());
+        assertTrue(result.getNoApply());
+        assertEquals("rejected", job.getStatus());
+        assertEquals("rejected", changes.get(0).getResultStatus());
+        assertEquals("rollback", changes.get(0).getTargetKey());
+        verify(redisUtil, never()).trySetStringIfAbsent(anyString(), anyString(), anyLong());
+    }
+
+    @Test
+    void rollbackResultUsesEntryControlModeSnapshotWhenConfigFlips() {
+        when(configService.getConfigValue("aiAutoTuneAnalysisOnly", "Y")).thenReturn("N", "Y");
+        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"));
+
+        AutoTuneApplyResult result = service.rollbackLastSuccessfulJob("manual rollback");
+
+        assertTrue(result.getSuccess());
+        assertFalse(result.getAnalysisOnly());
+        verify(configService, times(1)).getConfigValue("aiAutoTuneAnalysisOnly", "Y");
+    }
+
+    @Test
+    void applyJobRecordsActiveTasksWhenCountIsPositive() {
+        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
+        when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L);
+
+        service.apply(request(true, command("sys_config", null, "conveyorStationTaskLimit", "15")));
+
+        AiAutoTuneJob updatedJob = updatedJob();
+        assertEquals(1, updatedJob.getHasActiveTasks());
+
+        ArgumentCaptor<Wrapper<WrkMast>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(wrkMastService).count(captor.capture());
+        String sqlSegment = captor.getValue().getSqlSegment();
+        assertTrue(sqlSegment.contains("NOT IN"));
+        assertTrue(sqlSegment.contains("OR"));
+        assertTrue(sqlSegment.contains("wrk_sts IS NULL"));
+    }
+
+    @Test
+    void applyJobRecordsNoActiveTasksWhenCountIsZero() {
+        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
+        when(wrkMastService.count(any(Wrapper.class))).thenReturn(0L);
+
+        service.apply(request(true, command("sys_config", null, "conveyorStationTaskLimit", "15")));
+
+        AiAutoTuneJob updatedJob = updatedJob();
+        assertEquals(0, updatedJob.getHasActiveTasks());
+    }
+
+    @Test
+    void applyJobFallsBackToNoActiveTasksWhenCountFails() {
+        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
+        when(wrkMastService.count(any(Wrapper.class))).thenThrow(new IllegalStateException("count failed"));
+
+        service.apply(request(true, command("sys_config", null, "conveyorStationTaskLimit", "15")));
+
+        AiAutoTuneJob updatedJob = updatedJob();
+        assertEquals(0, updatedJob.getHasActiveTasks());
+    }
+
+    @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));
-        when(stationFlowCapacityService.getOne(any(Wrapper.class))).thenReturn(capacity(101, "OUT", 2));
+        when(basStationService.getById(101)).thenReturn(station(101, 1, 2));
         when(basCrnpService.getById(1)).thenReturn(crn(1, 1, 1));
         when(basDualCrnpService.getById(2)).thenReturn(dualCrn(2, 1, 1));
 
@@ -173,6 +510,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));
@@ -181,14 +519,159 @@
     }
 
     @Test
-    void rollbackLastJobSuccessfully() {
-        AiAutoTuneJob latestJob = new AiAutoTuneJob();
-        latestJob.setId(10L);
-        when(aiAutoTuneJobService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(latestJob));
+    void applyCacheRefreshFailureDoesNotAppendFailedAuditOrJob() {
+        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
+        doThrow(new IllegalStateException("cache refresh failed"))
+                .when(configService).refreshSystemConfigCache();
 
+        AutoTuneApplyResult result = service.apply(request(false,
+                command("sys_config", null, "conveyorStationTaskLimit", "15")
+        ));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        AiAutoTuneJob updatedJob = updatedJob();
+        assertTrue(result.getSuccess());
+        assertEquals("success", updatedJob.getStatus());
+        assertEquals(1, changes.size());
+        assertEquals("success", changes.get(0).getResultStatus());
+        verify(aiAutoTuneChangeService, times(1)).saveBatch(any(Collection.class));
+        verify(aiAutoTuneJobService, times(1)).updateById(any(AiAutoTuneJob.class));
+        verify(configService).refreshSystemConfigCache();
+    }
+
+    @Test
+    void writeFailureReturnsFailedAuditWithoutThrowing() {
+        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
+        when(configService.saveConfigValue("conveyorStationTaskLimit", "15")).thenThrow(new IllegalStateException("db write failed"));
+
+        AutoTuneApplyResult result = service.apply(request(false, command("sys_config", null, "conveyorStationTaskLimit", "15")));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        assertFalse(result.getSuccess());
+        assertEquals("failed", changes.get(0).getResultStatus());
+        assertTrue(changes.get(0).getRejectReason().contains("db write failed"));
+        assertEquals(1, transactionManager.getRollbackCount());
+        assertTrue(transactionManager.getJobSaveInsideTransactionCount() > 0);
+        assertTrue(transactionManager.getJobUpdateInsideTransactionCount() > 0);
+        assertEquals(0, transactionManager.getJobSaveOutsideTransactionCount());
+        assertEquals(0, transactionManager.getJobUpdateOutsideTransactionCount());
+        verify(redisUtil).compareAndDelete(eq(RedisKeyType.AI_AUTO_TUNE_APPLY_LOCK.key), anyString());
+    }
+
+    @Test
+    void auditSaveBatchFailureRollsBackTargetWriteAndReturnsFailedAudit() {
+        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
+        when(aiAutoTuneChangeService.saveBatch(any(Collection.class)))
+                .thenThrow(new IllegalStateException("audit failed"))
+                .thenReturn(true);
+
+        AutoTuneApplyResult result = service.apply(request(false, command("sys_config", null, "conveyorStationTaskLimit", "15")));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        AiAutoTuneJob updatedJob = updatedJob();
+        assertFalse(result.getSuccess());
+        assertEquals("failed", updatedJob.getStatus());
+        assertEquals("failed", changes.get(0).getResultStatus());
+        assertTrue(changes.get(0).getRejectReason().contains("audit failed"));
+        assertEquals(1, transactionManager.getRollbackCount());
+        assertTrue(transactionManager.getJobSaveInsideTransactionCount() > 0);
+        assertTrue(transactionManager.getJobUpdateInsideTransactionCount() > 0);
+        assertEquals(0, transactionManager.getJobSaveOutsideTransactionCount());
+        assertEquals(0, transactionManager.getJobUpdateOutsideTransactionCount());
+        verify(configService).saveConfigValue("conveyorStationTaskLimit", "15");
+        verify(configService, never()).refreshSystemConfigCache();
+        verify(redisUtil).compareAndDelete(eq(RedisKeyType.AI_AUTO_TUNE_APPLY_LOCK.key), anyString());
+    }
+
+    @Test
+    void jobUpdateFailureRollsBackTargetWriteTransaction() {
+        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
+        when(aiAutoTuneJobService.updateById(any(AiAutoTuneJob.class))).thenAnswer(invocation -> {
+            transactionManager.recordJobUpdateCall();
+            return false;
+        });
+
+        IllegalStateException exception = assertThrows(IllegalStateException.class,
+                () -> service.apply(request(false, command("sys_config", null, "conveyorStationTaskLimit", "15"))));
+
+        assertTrue(exception.getMessage().contains("鏇存柊璋冨弬浠诲姟鐘舵�佸け璐�"));
+        assertEquals(2, transactionManager.getRollbackCount());
+        assertEquals(0, transactionManager.getCommitCount());
+        assertTrue(transactionManager.getJobSaveInsideTransactionCount() > 0);
+        assertTrue(transactionManager.getJobUpdateInsideTransactionCount() > 0);
+        assertEquals(0, transactionManager.getJobSaveOutsideTransactionCount());
+        assertEquals(0, transactionManager.getJobUpdateOutsideTransactionCount());
+        verify(configService).saveConfigValue("conveyorStationTaskLimit", "15");
+        verify(configService, never()).refreshSystemConfigCache();
+        verify(redisUtil).compareAndDelete(eq(RedisKeyType.AI_AUTO_TUNE_APPLY_LOCK.key), anyString());
+    }
+
+    @Test
+    void jobUpdateFailureRecordsFailureJobWhenRecoveryUpdateSucceeds() {
+        AtomicInteger updateAttempts = new AtomicInteger();
+        when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10"));
+        when(aiAutoTuneJobService.updateById(any(AiAutoTuneJob.class))).thenAnswer(invocation -> {
+            transactionManager.recordJobUpdateCall();
+            return updateAttempts.incrementAndGet() > 1;
+        });
+
+        AutoTuneApplyResult result = service.apply(request(false, command("sys_config", null, "conveyorStationTaskLimit", "15")));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        AiAutoTuneJob updatedJob = updatedJob();
+        assertFalse(result.getSuccess());
+        assertEquals("failed", updatedJob.getStatus());
+        assertEquals(1, changes.size());
+        assertEquals("failed", changes.get(0).getResultStatus());
+        assertTrue(changes.get(0).getRejectReason().contains("鏇存柊璋冨弬浠诲姟鐘舵�佸け璐�"));
+        assertEquals(1, transactionManager.getRollbackCount());
+        assertTrue(transactionManager.getCommitCount() >= 1);
+        assertTrue(transactionManager.getJobSaveInsideTransactionCount() > 0);
+        assertTrue(transactionManager.getJobUpdateInsideTransactionCount() > 0);
+        assertEquals(0, transactionManager.getJobSaveOutsideTransactionCount());
+        assertEquals(0, transactionManager.getJobUpdateOutsideTransactionCount());
+        verify(configService).saveConfigValue("conveyorStationTaskLimit", "15");
+        verify(configService, never()).refreshSystemConfigCache();
+        verify(aiAutoTuneChangeService, times(2)).saveBatch(any(Collection.class));
+        verify(aiAutoTuneJobService, times(2)).updateById(any(AiAutoTuneJob.class));
+        verify(redisUtil).compareAndDelete(eq(RedisKeyType.AI_AUTO_TUNE_APPLY_LOCK.key), anyString());
+    }
+
+    @Test
+    void realApplyLockNotAcquiredRejectsWithoutTargetWrite() {
+        when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(false);
+
+        AutoTuneApplyResult result = service.apply(request(false, command("sys_config", null, "conveyorStationTaskLimit", "15")));
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        AiAutoTuneJob updatedJob = updatedJob();
+        assertFalse(result.getSuccess());
+        assertEquals("failed", updatedJob.getStatus());
+        assertEquals("failed", changes.get(0).getResultStatus());
+        assertTrue(changes.get(0).getRejectReason().contains("閿佷笉鍙敤"));
+        assertTrue(changes.get(0).getRejectReason().contains("Redis"));
+        assertTrue(transactionManager.getJobSaveInsideTransactionCount() > 0);
+        assertTrue(transactionManager.getJobUpdateInsideTransactionCount() > 0);
+        assertEquals(0, transactionManager.getJobSaveOutsideTransactionCount());
+        assertEquals(0, transactionManager.getJobUpdateOutsideTransactionCount());
+        verify(configService, never()).getOne(any(Wrapper.class));
+        verify(configService, never()).saveConfigValue(any(), any());
+        verify(redisUtil).hasKey(RedisKeyType.AI_AUTO_TUNE_APPLY_LOCK.key);
+        verify(redisUtil, never()).compareAndDelete(anyString(), anyString());
+    }
+
+    @Test
+    void rollbackLastJobSuccessfully() {
+        AiAutoTuneJob rollbackJob = job(20L, "rollback", "success");
+        AiAutoTuneJob latestRealJob = job(10L, "manual", "success");
+        AiAutoTuneChange rollbackChange = successChange(20L, "sys_config", "", "conveyorStationTaskLimit", "15", "18");
         AiAutoTuneChange configChange = successChange(10L, "sys_config", "", "conveyorStationTaskLimit", "10", "15");
         AiAutoTuneChange stationChange = successChange(10L, "station", "101", "outTaskLimit", "1", "2");
-        when(aiAutoTuneChangeService.list(any(Wrapper.class))).thenReturn(List.of(configChange, stationChange));
+        when(aiAutoTuneChangeService.list(any(Wrapper.class)))
+                .thenReturn(List.of(rollbackChange, configChange, stationChange))
+                .thenReturn(List.of(configChange, stationChange));
+        when(aiAutoTuneJobService.getById(10L)).thenReturn(latestRealJob);
+        when(aiAutoTuneJobService.getById(20L)).thenReturn(rollbackJob);
         when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "15"));
         when(basStationService.getById(101)).thenReturn(station(101, 2));
 
@@ -201,6 +684,198 @@
         verify(configService).saveConfigValue("conveyorStationTaskLimit", "10");
         verify(configService).refreshSystemConfigCache();
         verify(basStationService).update(any(Wrapper.class));
+        verify(redisUtil).compareAndDelete(eq(RedisKeyType.AI_AUTO_TUNE_APPLY_LOCK.key), anyString());
+        assertEquals(0, transactionManager.getJobSaveOutsideTransactionCount());
+        assertEquals(0, transactionManager.getJobUpdateOutsideTransactionCount());
+        InOrder rollbackOrder = inOrder(redisUtil, aiAutoTuneChangeService);
+        rollbackOrder.verify(redisUtil).trySetStringIfAbsent(anyString(), anyString(), anyLong());
+        rollbackOrder.verify(aiAutoTuneChangeService, atLeastOnce()).list(any(Wrapper.class));
+    }
+
+    @Test
+    void rollbackJobRecordsActiveTasksWhenCountIsPositive() {
+        AiAutoTuneJob latestRealJob = job(10L, "manual", "success");
+        AiAutoTuneChange configChange = successChange(10L, "sys_config", "", "conveyorStationTaskLimit", "10", "15");
+        when(wrkMastService.count(any(Wrapper.class))).thenReturn(1L);
+        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"));
+
+        service.rollbackLastSuccessfulJob("manual rollback");
+
+        AiAutoTuneJob updatedJob = updatedJob();
+        assertEquals(1, updatedJob.getHasActiveTasks());
+    }
+
+    @Test
+    void rollbackCacheRefreshFailureDoesNotAppendFailedAuditOrJob() {
+        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"));
+        doThrow(new IllegalStateException("cache refresh failed"))
+                .when(configService).refreshSystemConfigCache();
+
+        AutoTuneApplyResult result = service.rollbackLastSuccessfulJob("manual rollback");
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        AiAutoTuneJob updatedJob = updatedJob();
+        assertTrue(result.getSuccess());
+        assertEquals("success", updatedJob.getStatus());
+        assertEquals(1, changes.size());
+        assertEquals("success", changes.get(0).getResultStatus());
+        verify(aiAutoTuneChangeService, times(1)).saveBatch(any(Collection.class));
+        verify(aiAutoTuneJobService, times(1)).updateById(any(AiAutoTuneJob.class));
+        verify(configService).refreshSystemConfigCache();
+    }
+
+    @Test
+    void rollbackAuditSaveBatchFailureRollsBackTargetWriteAndReturnsFailedAudit() {
+        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(aiAutoTuneChangeService.saveBatch(any(Collection.class)))
+                .thenThrow(new IllegalStateException("rollback audit failed"))
+                .thenReturn(true);
+
+        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());
+        assertTrue(changes.get(0).getRejectReason().contains("rollback audit failed"));
+        assertEquals(1, transactionManager.getRollbackCount());
+        assertTrue(transactionManager.getJobSaveInsideTransactionCount() > 0);
+        assertTrue(transactionManager.getJobUpdateInsideTransactionCount() > 0);
+        assertEquals(0, transactionManager.getJobSaveOutsideTransactionCount());
+        assertEquals(0, transactionManager.getJobUpdateOutsideTransactionCount());
+        verify(configService).saveConfigValue("conveyorStationTaskLimit", "10");
+        verify(configService, never()).refreshSystemConfigCache();
+        verify(redisUtil).compareAndDelete(eq(RedisKeyType.AI_AUTO_TUNE_APPLY_LOCK.key), anyString());
+    }
+
+    @Test
+    void rollbackJobUpdateFailureRollsBackAndDoesNotSaveRunningJobOutsideTransaction() {
+        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(aiAutoTuneJobService.updateById(any(AiAutoTuneJob.class))).thenAnswer(invocation -> {
+            transactionManager.recordJobUpdateCall();
+            return false;
+        });
+
+        IllegalStateException exception = assertThrows(IllegalStateException.class,
+                () -> service.rollbackLastSuccessfulJob("manual rollback"));
+
+        assertTrue(exception.getMessage().contains("鏇存柊璋冨弬浠诲姟鐘舵�佸け璐�"));
+        assertEquals(2, transactionManager.getRollbackCount());
+        assertEquals(0, transactionManager.getCommitCount());
+        assertTrue(transactionManager.getJobSaveInsideTransactionCount() > 0);
+        assertTrue(transactionManager.getJobUpdateInsideTransactionCount() > 0);
+        assertEquals(0, transactionManager.getJobSaveOutsideTransactionCount());
+        assertEquals(0, transactionManager.getJobUpdateOutsideTransactionCount());
+        verify(configService).saveConfigValue("conveyorStationTaskLimit", "10");
+        verify(configService, never()).refreshSystemConfigCache();
+        verify(redisUtil).compareAndDelete(eq(RedisKeyType.AI_AUTO_TUNE_APPLY_LOCK.key), anyString());
+    }
+
+    @Test
+    void rollbackJobUpdateFailureRecordsFailureJobWhenRecoveryUpdateSucceeds() {
+        AtomicInteger updateAttempts = new AtomicInteger();
+        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(aiAutoTuneJobService.updateById(any(AiAutoTuneJob.class))).thenAnswer(invocation -> {
+            transactionManager.recordJobUpdateCall();
+            return updateAttempts.incrementAndGet() > 1;
+        });
+
+        AutoTuneApplyResult result = service.rollbackLastSuccessfulJob("manual rollback");
+
+        List<AiAutoTuneChange> changes = savedChanges();
+        AiAutoTuneJob updatedJob = updatedJob();
+        assertFalse(result.getSuccess());
+        assertEquals("failed", updatedJob.getStatus());
+        assertEquals(1, changes.size());
+        assertEquals("failed", changes.get(0).getResultStatus());
+        assertTrue(changes.get(0).getRejectReason().contains("鏇存柊璋冨弬浠诲姟鐘舵�佸け璐�"));
+        assertEquals(1, transactionManager.getRollbackCount());
+        assertTrue(transactionManager.getCommitCount() >= 1);
+        assertTrue(transactionManager.getJobSaveInsideTransactionCount() > 0);
+        assertTrue(transactionManager.getJobUpdateInsideTransactionCount() > 0);
+        assertEquals(0, transactionManager.getJobSaveOutsideTransactionCount());
+        assertEquals(0, transactionManager.getJobUpdateOutsideTransactionCount());
+        verify(configService).saveConfigValue("conveyorStationTaskLimit", "10");
+        verify(configService, never()).refreshSystemConfigCache();
+        verify(aiAutoTuneChangeService, times(2)).saveBatch(any(Collection.class));
+        verify(aiAutoTuneJobService, times(2)).updateById(any(AiAutoTuneJob.class));
+        verify(redisUtil).compareAndDelete(eq(RedisKeyType.AI_AUTO_TUNE_APPLY_LOCK.key), anyString());
+    }
+
+    @Test
+    void rollbackLockNotAcquiredReturnsFailedAuditWithoutTargetWrite() {
+        when(redisUtil.trySetStringIfAbsent(anyString(), anyString(), anyLong())).thenReturn(false);
+
+        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());
+        assertTrue(changes.get(0).getRejectReason().contains("閿佷笉鍙敤"));
+        assertTrue(changes.get(0).getRejectReason().contains("Redis"));
+        assertTrue(transactionManager.getJobSaveInsideTransactionCount() > 0);
+        assertTrue(transactionManager.getJobUpdateInsideTransactionCount() > 0);
+        assertEquals(0, transactionManager.getJobSaveOutsideTransactionCount());
+        assertEquals(0, transactionManager.getJobUpdateOutsideTransactionCount());
+        verify(aiAutoTuneChangeService, never()).list(any(Wrapper.class));
+        verify(aiAutoTuneJobService, never()).getById(any());
+        verify(configService, never()).saveConfigValue(any(), any());
+        verify(configService, never()).refreshSystemConfigCache();
+        verify(redisUtil).hasKey(RedisKeyType.AI_AUTO_TUNE_APPLY_LOCK.key);
+        verify(redisUtil, never()).compareAndDelete(anyString(), anyString());
+    }
+
+    @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) {
@@ -230,9 +905,14 @@
     }
 
     private BasStation station(Integer stationId, Integer outTaskLimit) {
+        return station(stationId, outTaskLimit, null);
+    }
+
+    private BasStation station(Integer stationId, Integer outTaskLimit, Integer outBufferCapacity) {
         BasStation station = new BasStation();
         station.setStationId(stationId);
         station.setOutTaskLimit(outTaskLimit);
+        station.setOutBufferCapacity(outBufferCapacity);
         return station;
     }
 
@@ -252,14 +932,6 @@
         return dualCrnp;
     }
 
-    private StationFlowCapacity capacity(Integer stationId, String directionCode, Integer bufferCapacity) {
-        StationFlowCapacity capacity = new StationFlowCapacity();
-        capacity.setStationId(stationId);
-        capacity.setDirectionCode(directionCode);
-        capacity.setBufferCapacity(bufferCapacity);
-        return capacity;
-    }
-
     private AiAutoTuneChange successChange(Long jobId,
                                            String targetType,
                                            String targetId,
@@ -277,9 +949,95 @@
         return change;
     }
 
+    private AiAutoTuneJob job(Long id, String triggerType, String status) {
+        AiAutoTuneJob job = new AiAutoTuneJob();
+        job.setId(id);
+        job.setTriggerType(triggerType);
+        job.setStatus(status);
+        return job;
+    }
+
     private List<AiAutoTuneChange> savedChanges() {
         ArgumentCaptor<Collection<AiAutoTuneChange>> captor = ArgumentCaptor.forClass(Collection.class);
-        verify(aiAutoTuneChangeService).saveBatch(captor.capture());
-        return new ArrayList<>(captor.getValue());
+        verify(aiAutoTuneChangeService, atLeastOnce()).saveBatch(captor.capture());
+        List<Collection<AiAutoTuneChange>> allValues = captor.getAllValues();
+        return new ArrayList<>(allValues.get(allValues.size() - 1));
+    }
+
+    private AiAutoTuneJob updatedJob() {
+        ArgumentCaptor<AiAutoTuneJob> captor = ArgumentCaptor.forClass(AiAutoTuneJob.class);
+        verify(aiAutoTuneJobService, atLeastOnce()).updateById(captor.capture());
+        List<AiAutoTuneJob> allValues = captor.getAllValues();
+        return allValues.get(allValues.size() - 1);
+    }
+
+    private static class RecordingTransactionManager implements PlatformTransactionManager {
+        private int beginCount;
+        private int commitCount;
+        private int rollbackCount;
+        private boolean transactionActive;
+        private int jobSaveInsideTransactionCount;
+        private int jobSaveOutsideTransactionCount;
+        private int jobUpdateInsideTransactionCount;
+        private int jobUpdateOutsideTransactionCount;
+
+        @Override
+        public TransactionStatus getTransaction(TransactionDefinition definition) {
+            beginCount++;
+            transactionActive = true;
+            return new SimpleTransactionStatus();
+        }
+
+        @Override
+        public void commit(TransactionStatus status) {
+            commitCount++;
+            transactionActive = false;
+        }
+
+        @Override
+        public void rollback(TransactionStatus status) {
+            rollbackCount++;
+            transactionActive = false;
+        }
+
+        public void recordJobSaveCall() {
+            if (transactionActive) {
+                jobSaveInsideTransactionCount++;
+            } else {
+                jobSaveOutsideTransactionCount++;
+            }
+        }
+
+        public void recordJobUpdateCall() {
+            if (transactionActive) {
+                jobUpdateInsideTransactionCount++;
+            } else {
+                jobUpdateOutsideTransactionCount++;
+            }
+        }
+
+        public int getCommitCount() {
+            return commitCount;
+        }
+
+        public int getRollbackCount() {
+            return rollbackCount;
+        }
+
+        public int getJobSaveInsideTransactionCount() {
+            return jobSaveInsideTransactionCount;
+        }
+
+        public int getJobSaveOutsideTransactionCount() {
+            return jobSaveOutsideTransactionCount;
+        }
+
+        public int getJobUpdateInsideTransactionCount() {
+            return jobUpdateInsideTransactionCount;
+        }
+
+        public int getJobUpdateOutsideTransactionCount() {
+            return jobUpdateOutsideTransactionCount;
+        }
     }
 }

--
Gitblit v1.9.1