diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultMissingInspector.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultMissingInspector.java new file mode 100644 index 00000000..498e329e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultMissingInspector.java @@ -0,0 +1,81 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 任务存在结果缺失巡检(task-157)。 + * + * 只读巡检:终态(SUCCESS/FAILED)任务无对应 file_result 行的清单输出报表; + * 运行中/待处理任务忽略;绝不修改任何状态。可重复执行。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskResultMissingInspector { + + private final FileTaskMapper fileTaskMapper; + private final FileResultMapper fileResultMapper; + + public record MissingResultEntry(Long taskId, String moduleType, String status, String updatedAt) { + } + + public record MissingResultReport(List entries) { + + public boolean isEmpty() { + return entries == null || entries.isEmpty(); + } + } + + public MissingResultReport inspectTasksMissingResult(int limit) { + List tasks = fileTaskMapper.selectList(new LambdaQueryWrapper() + .in(FileTaskEntity::getStatus, List.of("SUCCESS", "FAILED")) + .orderByAsc(FileTaskEntity::getUpdatedAt) + .last("limit " + Math.max(1, Math.min(limit, 500)))); + if (tasks == null || tasks.isEmpty()) { + return new MissingResultReport(List.of()); + } + Set taskIds = tasks.stream() + .map(FileTaskEntity::getId) + .filter(id -> id != null && id > 0) + .collect(Collectors.toSet()); + Map> resultsByTask = taskIds.isEmpty() ? Map.of() + : fileResultMapper.selectList(new LambdaQueryWrapper() + .in(FileResultEntity::getTaskId, taskIds)).stream() + .collect(Collectors.groupingBy(FileResultEntity::getTaskId)); + + List entries = new ArrayList<>(); + for (FileTaskEntity task : tasks) { + if (task.getId() == null || task.getId() <= 0) { + continue; + } + List results = resultsByTask.get(task.getId()); + if (results == null || results.isEmpty()) { + entries.add(new MissingResultEntry( + task.getId(), task.getModuleType(), task.getStatus(), + task.getUpdatedAt() == null ? null : task.getUpdatedAt().toString())); + } + } + MissingResultReport report = new MissingResultReport(List.copyOf(entries)); + if (!report.isEmpty()) { + log.info("task-missing-result report: count={}", report.entries().size()); + for (MissingResultEntry entry : report.entries()) { + log.info("task-missing-result: taskId={} moduleType={} status={} updatedAt={}", + entry.taskId(), entry.moduleType(), entry.status(), entry.updatedAt()); + } + } + return report; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultMissingInspectorTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultMissingInspectorTest.java new file mode 100644 index 00000000..6a5b64c0 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultMissingInspectorTest.java @@ -0,0 +1,154 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * task-157:任务存在结果缺失巡检契约(plan 09)。 + * 终态(SUCCESS/FAILED)任务无 file_result 行 → 检出;运行中忽略;只读;可重复。 + */ +@ExtendWith(MockitoExtension.class) +class TaskResultMissingInspectorTest { + + private static final Long MISSING_TASK_ID = 7001L; + private static final Long WITH_RESULT_TASK_ID = 7101L; + private static final Long RUNNING_TASK_ID = 7201L; + + @Mock private FileTaskMapper fileTaskMapper; + @Mock private FileResultMapper fileResultMapper; + + private TaskResultMissingInspector inspector; + + @BeforeAll + static void initializeMybatisMetadata() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class); + TableInfoHelper.initTableInfo(assistant, FileResultEntity.class); + } + + @BeforeEach + void setUp() { + inspector = new TaskResultMissingInspector(fileTaskMapper, fileResultMapper); + lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of()); + } + + private FileTaskEntity task(Long id, String status) { + FileTaskEntity task = new FileTaskEntity(); + task.setId(id); + task.setModuleType("SIMILAR_ASIN"); + task.setStatus(status); + task.setUpdatedAt(LocalDateTime.now()); + return task; + } + + @Test + void missingResultDetected() { + when(fileTaskMapper.selectList(any())).thenReturn(List.of(task(MISSING_TASK_ID, "SUCCESS"))); + + var report = inspector.inspectTasksMissingResult(50); + + assertEquals(1, report.entries().size()); + assertEquals(MISSING_TASK_ID, report.entries().getFirst().taskId()); + } + + @Test + void taskWithResultNotReported() { + when(fileTaskMapper.selectList(any())).thenReturn(List.of(task(WITH_RESULT_TASK_ID, "SUCCESS"))); + FileResultEntity result = new FileResultEntity(); + result.setTaskId(WITH_RESULT_TASK_ID); + when(fileResultMapper.selectList(any())).thenReturn(List.of(result)); + + var report = inspector.inspectTasksMissingResult(50); + + assertTrue(report.isEmpty()); + } + + @Test + void successWithoutResultIsReported() { + when(fileTaskMapper.selectList(any())).thenReturn(List.of(task(MISSING_TASK_ID, "SUCCESS"))); + + var report = inspector.inspectTasksMissingResult(50); + + assertEquals("SUCCESS", report.entries().getFirst().status()); + } + + @Test + void runningTasksAreIgnored() { + // 巡检只查终态任务:RUNNING 不在查询范围内(mapper 断言终态 IN 条件) + when(fileTaskMapper.selectList(any())).thenReturn(List.of()); + inspector.inspectTasksMissingResult(50); + + var wrapper = org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class); + verify(fileTaskMapper).selectList(wrapper.capture()); + assertTrue(wrapper.getValue().getSqlSegment().contains("IN"), "只查终态任务: " + wrapper.getValue().getSqlSegment()); + assertFalse(wrapper.getValue().getSqlSegment().contains("RUNNING"), "运行中任务不参与巡检"); + } + + @Test + void emptyReportWhenNoTasks() { + when(fileTaskMapper.selectList(any())).thenReturn(List.of()); + + var report = inspector.inspectTasksMissingResult(50); + + assertTrue(report.isEmpty()); + } + + @Test + void readOnlyDoesNotModifyAnything() { + when(fileTaskMapper.selectList(any())).thenReturn(List.of(task(MISSING_TASK_ID, "FAILED"))); + + inspector.inspectTasksMissingResult(50); + + verify(fileTaskMapper, never()).update(any(), any()); + verify(fileResultMapper, never()).update(any(), any()); + verify(fileTaskMapper, never()).delete(any()); + } + + @Test + void reportIncludesDetails() { + when(fileTaskMapper.selectList(any())).thenReturn(List.of(task(MISSING_TASK_ID, "FAILED"))); + + var report = inspector.inspectTasksMissingResult(50); + + TaskResultMissingInspector.MissingResultEntry entry = report.entries().getFirst(); + assertEquals(MISSING_TASK_ID, entry.taskId()); + assertEquals("SIMILAR_ASIN", entry.moduleType()); + assertEquals("FAILED", entry.status()); + assertNotNull(entry.updatedAt()); + } + + @Test + void rerunIsSafeAndStable() { + when(fileTaskMapper.selectList(any())).thenReturn(List.of(task(MISSING_TASK_ID, "SUCCESS"))); + + var first = inspector.inspectTasksMissingResult(50); + var second = inspector.inspectTasksMissingResult(50); + + assertEquals(first.entries().size(), second.entries().size()); + assertEquals(first.entries().getFirst().taskId(), second.entries().getFirst().taskId()); + } +}