diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ResultFileMissingInspector.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ResultFileMissingInspector.java new file mode 100644 index 00000000..40b71fc4 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ResultFileMissingInspector.java @@ -0,0 +1,80 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +/** + * 结果存在文件缺失巡检(task-158)。 + * + * 只读巡检:file_result 的 resultFileUrl 非空但对应文件在 OSS 不存在的清单输出 + * 报表;空白 URL 行视为"未生成文件"状态、不告警(历史/失败任务无文件属正常); + * 绝不修改任何状态。可重复执行。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ResultFileMissingInspector { + + private final FileResultMapper fileResultMapper; + private final OssStorageService ossStorageService; + + public record MissingFileEntry(Long resultId, Long taskId, String moduleType, String resultFileUrl) { + } + + public record MissingFileReport(List entries) { + + public boolean isEmpty() { + return entries == null || entries.isEmpty(); + } + } + + public MissingFileReport inspectResultsMissingFile(int limit) { + return inspectResultsMissingFile(limit, ossStorageService::objectExists); + } + + MissingFileReport inspectResultsMissingFile(int limit, Predicate urlExists) { + List results = fileResultMapper.selectList(new LambdaQueryWrapper() + .isNotNull(FileResultEntity::getResultFileUrl) + .last("limit " + Math.max(1, Math.min(limit, 500)))); + if (results == null || results.isEmpty()) { + return new MissingFileReport(List.of()); + } + List entries = new ArrayList<>(); + for (FileResultEntity result : results) { + String url = result.getResultFileUrl(); + if (url == null || url.isBlank()) { + continue; + } + boolean exists; + try { + exists = urlExists.test(url); + } catch (Exception ex) { + log.warn("result file existence check failed resultId={} url={} err={}", + result.getId(), url, ex.getMessage()); + continue; + } + if (!exists) { + entries.add(new MissingFileEntry( + result.getId(), result.getTaskId(), result.getModuleType(), url)); + } + } + MissingFileReport report = new MissingFileReport(List.copyOf(entries)); + if (!report.isEmpty()) { + log.info("result-missing-file report: count={}", report.entries().size()); + for (MissingFileEntry entry : report.entries()) { + log.info("result-missing-file: resultId={} taskId={} moduleType={} url={}", + entry.resultId(), entry.taskId(), entry.moduleType(), entry.resultFileUrl()); + } + } + return report; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileMissingInspectorTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileMissingInspectorTest.java new file mode 100644 index 00000000..ae36c694 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileMissingInspectorTest.java @@ -0,0 +1,144 @@ +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.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +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.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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-158:结果存在文件缺失巡检契约(plan 09)。 + * resultFileUrl 对应文件在 OSS 不存在 → 检出;存在不报;空白 URL 不告警; + * 只读;可重复。 + */ +@ExtendWith(MockitoExtension.class) +class ResultFileMissingInspectorTest { + + private static final Long RESULT_ID = 6001L; + private static final Long TASK_ID = 6002L; + + @Mock private FileResultMapper fileResultMapper; + @Mock private OssStorageService ossStorageService; + + private ResultFileMissingInspector inspector; + + @BeforeAll + static void initializeMybatisMetadata() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, FileResultEntity.class); + } + + @BeforeEach + void setUp() { + inspector = new ResultFileMissingInspector(fileResultMapper, ossStorageService); + lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of()); + } + + private FileResultEntity result(Long id, String url) { + FileResultEntity result = new FileResultEntity(); + result.setId(id); + result.setTaskId(TASK_ID); + result.setModuleType("SIMILAR_ASIN"); + result.setResultFileUrl(url); + return result; + } + + @Test + void missingFileDetected() { + when(fileResultMapper.selectList(any())).thenReturn(List.of(result(RESULT_ID, "oss://result/missing.xlsx"))); + + var report = inspector.inspectResultsMissingFile(50, url -> false); + + assertEquals(1, report.entries().size()); + assertEquals(RESULT_ID, report.entries().getFirst().resultId()); + } + + @Test + void existingFileNotReported() { + when(fileResultMapper.selectList(any())).thenReturn(List.of(result(RESULT_ID, "oss://result/ok.xlsx"))); + + var report = inspector.inspectResultsMissingFile(50, url -> true); + + assertTrue(report.isEmpty()); + } + + @Test + void blankUrlHandledAsNormal() { + when(fileResultMapper.selectList(any())).thenReturn(List.of(result(RESULT_ID, " "))); + + var report = inspector.inspectResultsMissingFile(50, url -> false); + + assertTrue(report.isEmpty(), "空白 URL 视为未生成文件状态,不告警"); + } + + @Test + void ossCheckUsedByDefault() { + when(fileResultMapper.selectList(any())).thenReturn(List.of(result(RESULT_ID, "oss://result/x.xlsx"))); + when(ossStorageService.objectExists("oss://result/x.xlsx")).thenReturn(true); + + var report = inspector.inspectResultsMissingFile(50); + + assertTrue(report.isEmpty(), "默认走 OSS 存在性校验"); + } + + @Test + void emptyReportWhenNoResults() { + var report = inspector.inspectResultsMissingFile(50, url -> false); + + assertTrue(report.isEmpty()); + } + + @Test + void readOnlyDoesNotModifyAnything() { + when(fileResultMapper.selectList(any())).thenReturn(List.of(result(RESULT_ID, "oss://result/m.xlsx"))); + + inspector.inspectResultsMissingFile(50, url -> false); + + verify(fileResultMapper, never()).update(any(), any()); + verify(fileResultMapper, never()).delete(any()); + verify(fileResultMapper, never()).updateById(any(FileResultEntity.class)); + } + + @Test + void reportIncludesDetails() { + when(fileResultMapper.selectList(any())).thenReturn(List.of(result(RESULT_ID, "oss://result/m.xlsx"))); + + var report = inspector.inspectResultsMissingFile(50, url -> false); + + ResultFileMissingInspector.MissingFileEntry entry = report.entries().getFirst(); + assertEquals(RESULT_ID, entry.resultId()); + assertEquals(TASK_ID, entry.taskId()); + assertEquals("SIMILAR_ASIN", entry.moduleType()); + assertEquals("oss://result/m.xlsx", entry.resultFileUrl()); + assertNotNull(entry.resultFileUrl()); + } + + @Test + void rerunIsSafeAndStable() { + when(fileResultMapper.selectList(any())).thenReturn(List.of(result(RESULT_ID, "oss://result/m.xlsx"))); + + var first = inspector.inspectResultsMissingFile(50, url -> false); + var second = inspector.inspectResultsMissingFile(50, url -> false); + + assertEquals(first.entries().size(), second.entries().size()); + assertEquals(first.entries().getFirst().resultId(), second.entries().getFirst().resultId()); + } +}