package com.zy.ai.service; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.zy.ai.domain.autotune.AutoTuneApplyRequest; import com.zy.ai.domain.autotune.AutoTuneApplyResult; import com.zy.ai.domain.autotune.AutoTuneChangeCommand; 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.WrkMast; import com.zy.asrs.service.BasCrnpService; import com.zy.asrs.service.BasDualCrnpService; import com.zy.asrs.service.BasStationService; 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; @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) class AutoTuneApplyServiceImplTest { private AutoTuneApplyServiceImpl service; @Mock private AiAutoTuneJobService aiAutoTuneJobService; @Mock private AiAutoTuneChangeService aiAutoTuneChangeService; @Mock private ConfigService configService; @Mock private BasStationService basStationService; @Mock private BasCrnpService basCrnpService; @Mock private BasDualCrnpService basDualCrnpService; @Mock 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, "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))).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 void rejectNonWhitelistedKey() { AutoTuneApplyResult result = service.apply(request(true, command("sys_config", null, "badKey", "10"))); List changes = savedChanges(); assertFalse(result.getSuccess()); assertEquals("rejected", changes.get(0).getResultStatus()); assertTrue(changes.get(0).getRejectReason().contains("不支持的调参目标")); verify(configService, never()).saveConfigValue(any(), any()); } @Test void rejectOutOfRangeInterval() { when(configService.getOne(any(Wrapper.class))).thenReturn(config("aiAutoTuneIntervalMinutes", "10")); AutoTuneApplyResult result = service.apply(request(true, command("sys_config", null, "aiAutoTuneIntervalMinutes", "100"))); List changes = savedChanges(); assertFalse(result.getSuccess()); assertEquals("rejected", changes.get(0).getResultStatus()); assertTrue(changes.get(0).getRejectReason().contains("5~60")); } @Test void rejectNonnumericNewValue() { AutoTuneApplyResult result = service.apply(request(true, command("sys_config", null, "aiAutoTuneIntervalMinutes", "abc"))); List 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")); service.apply(request(true, command("sys_config", null, "conveyorStationTaskLimit", "16"))); List changes = savedChanges(); assertEquals("rejected", changes.get(0).getResultStatus()); assertTrue(changes.get(0).getRejectReason().contains("步长不能超过 5")); } @Test void crnOutBatchRunningLimitAllowsStepThreeAndRejectsRangeAndStepCases() { when(configService.getOne(any(Wrapper.class))).thenReturn(config("crnOutBatchRunningLimit", "10")); service.apply(request(true, command("sys_config", null, "crnOutBatchRunningLimit", "13"), command("sys_config", null, "crnOutBatchRunningLimit", "14"), command("sys_config", null, "crnOutBatchRunningLimit", "21") )); List 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 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 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 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 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 changes = savedChanges(); assertEquals("rejected", changes.get(0).getResultStatus()); 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 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 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 changes = savedChanges(); assertTrue(result.getSuccess()); assertEquals("dry_run", changes.get(0).getResultStatus()); assertEquals("1", changes.get(0).getRequestedValue()); } @Test void rejectCooldownHit() { when(configService.getOne(any(Wrapper.class))).thenReturn(config("conveyorStationTaskLimit", "10")); 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)); service.apply(request(true, command("sys_config", null, "conveyorStationTaskLimit", "15"))); List changes = savedChanges(); assertEquals("rejected", changes.get(0).getResultStatus()); assertTrue(changes.get(0).getRejectReason().contains("冷却期")); } @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 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 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 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 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> 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 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 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, 2)); when(basCrnpService.getById(1)).thenReturn(crn(1, 1, 1)); when(basDualCrnpService.getById(2)).thenReturn(dualCrn(2, 1, 1)); AutoTuneApplyResult result = service.apply(request(false, command("sys_config", null, "conveyorStationTaskLimit", "15"), command("station", "101", "outTaskLimit", "2"), command("crn", "1", "maxOutTask", "2"), command("dual_crn", "2", "maxInTask", "2") )); List changes = savedChanges(); assertTrue(result.getSuccess()); assertEquals(4, changes.size()); 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)); verify(basCrnpService).update(any(Wrapper.class)); verify(basDualCrnpService).update(any(Wrapper.class)); } @Test 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 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 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 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 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 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(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)); AutoTuneApplyResult result = service.rollbackLastSuccessfulJob("manual rollback"); List changes = savedChanges(); assertTrue(result.getSuccess()); assertEquals(2, changes.size()); assertTrue(changes.stream().allMatch(change -> "success".equals(change.getResultStatus()))); 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 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 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 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 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 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) { AutoTuneApplyRequest request = new AutoTuneApplyRequest(); request.setDryRun(dryRun); request.setReason("test"); request.setTriggerType("manual"); request.setChanges(List.of(commands)); return request; } private AutoTuneChangeCommand command(String targetType, String targetId, String targetKey, String newValue) { AutoTuneChangeCommand command = new AutoTuneChangeCommand(); command.setTargetType(targetType); command.setTargetId(targetId); command.setTargetKey(targetKey); command.setNewValue(newValue); return command; } private Config config(String code, String value) { Config config = new Config(); config.setCode(code); config.setValue(value); config.setStatus((short) 1); return config; } 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; } private BasCrnp crn(Integer crnNo, Integer maxOutTask, Integer maxInTask) { BasCrnp crnp = new BasCrnp(); crnp.setCrnNo(crnNo); crnp.setMaxOutTask(maxOutTask); crnp.setMaxInTask(maxInTask); return crnp; } private BasDualCrnp dualCrn(Integer crnNo, Integer maxOutTask, Integer maxInTask) { BasDualCrnp dualCrnp = new BasDualCrnp(); dualCrnp.setCrnNo(crnNo); dualCrnp.setMaxOutTask(maxOutTask); dualCrnp.setMaxInTask(maxInTask); return dualCrnp; } private AiAutoTuneChange successChange(Long jobId, String targetType, String targetId, String targetKey, String oldValue, String appliedValue) { AiAutoTuneChange change = new AiAutoTuneChange(); change.setJobId(jobId); change.setTargetType(targetType); change.setTargetId(targetId); change.setTargetKey(targetKey); change.setOldValue(oldValue); change.setAppliedValue(appliedValue); change.setResultStatus("success"); 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 savedChanges() { ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); verify(aiAutoTuneChangeService, atLeastOnce()).saveBatch(captor.capture()); List> allValues = captor.getAllValues(); return new ArrayList<>(allValues.get(allValues.size() - 1)); } private AiAutoTuneJob updatedJob() { ArgumentCaptor captor = ArgumentCaptor.forClass(AiAutoTuneJob.class); verify(aiAutoTuneJobService, atLeastOnce()).updateById(captor.capture()); List 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; } } }