diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/InspectionSqlReportTask.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/InspectionSqlReportTask.java new file mode 100644 index 00000000..ab6cc974 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/InspectionSqlReportTask.java @@ -0,0 +1,108 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.common.service.DistributedJobLockService; +import com.nanri.aiimage.config.InspectionProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * 巡检只读 SQL 报表任务(task-200)。 + * + * 与 InspectionScheduler 同开关/调度风格:aiimage.inspection.enabled 默认 false;启用后按 + * aiimage.inspection.cron 执行 6 张只读报表 SQL(resources/inspection/*.sql,task-194..199), + * 逐条输出行数与耗时;单条失败记录日志不阻断其余;分布式锁防双实例重复;只 query 不改数据。 + */ +@Slf4j +@Service +public class InspectionSqlReportTask { + + private static final Duration REPORT_LOCK_TTL = Duration.ofMinutes(10); + + /** 与巡检 SQL 文件一一对应(顺序执行)。 */ + static final List REPORT_FILES = List.of( + "01_orphan_job.sql", "02_orphan_result.sql", "03_task_missing_result.sql", + "04_result_missing_file.sql", "05_terminal_task_active_job.sql", + "06_over_retention_active_job.sql"); + + private final InspectionProperties inspectionProperties; + private final DistributedJobLockService distributedJobLockService; + private final ObjectProvider jdbcTemplateProvider; + + /** 测试/无锁构造。 */ + public InspectionSqlReportTask(InspectionProperties inspectionProperties, + ObjectProvider jdbcTemplateProvider) { + this(inspectionProperties, null, jdbcTemplateProvider); + } + + @Autowired + public InspectionSqlReportTask(InspectionProperties inspectionProperties, + DistributedJobLockService distributedJobLockService, + ObjectProvider jdbcTemplateProvider) { + this.inspectionProperties = inspectionProperties; + this.distributedJobLockService = distributedJobLockService; + this.jdbcTemplateProvider = jdbcTemplateProvider; + } + + @Scheduled(cron = "${aiimage.inspection.cron:0 0 3 * * *}") + public void runIfEnabled() { + if (!inspectionProperties.isEnabled()) { + return; + } + if (distributedJobLockService == null) { + return; + } + DistributedJobLockService.LockHandle lockHandle = + distributedJobLockService.tryLock("inspection-sql-report", REPORT_LOCK_TTL); + if (lockHandle == null) { + log.info("[inspection-sql] skip because another instance holds the report lock"); + return; + } + try (lockHandle) { + runAll(); + } + } + + /** 执行全部只读报表 SQL;返回成功执行的报表数(disabled/无 JDBC 返回 0,失败单条不阻断)。 */ + public int runAll() { + if (!inspectionProperties.isEnabled()) { + return 0; + } + JdbcTemplate jdbcTemplate = jdbcTemplateProvider.getIfAvailable(); + if (jdbcTemplate == null) { + log.warn("[inspection-sql] enabled but no JdbcTemplate bean, skip reports"); + return 0; + } + int ok = 0; + for (String file : REPORT_FILES) { + long startedAt = System.nanoTime(); + try { + String sql = loadSql(file); + List> rows = jdbcTemplate.queryForList(sql); + long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000; + log.info("[inspection-sql] report {} rows={} elapsedMs={}", + file, rows.size(), elapsedMs); + ok++; + } catch (Exception ex) { + long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000; + log.error("[inspection-sql] report {} failed elapsedMs={} cause={}", + file, elapsedMs, ex.toString()); + } + } + return ok; + } + + private String loadSql(String file) throws java.io.IOException { + ClassPathResource resource = new ClassPathResource("inspection/" + file); + return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/InspectionSqlReportTaskTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/InspectionSqlReportTaskTest.java new file mode 100644 index 00000000..e0d06dea --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/InspectionSqlReportTaskTest.java @@ -0,0 +1,181 @@ +package com.nanri.aiimage.modules.task.service; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.nanri.aiimage.config.InspectionProperties; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * task-200:巡检报表任务契约。 + * 默认 disabled 不执行;启用后跑 6 张只读报表 SQL 并输出行数;单条失败不阻断;只 query 不改数据; + * 可重复执行;无锁/JDBC 时安全返回。 + */ +class InspectionSqlReportTaskTest { + + private static ObjectProvider provider(JdbcTemplate jdbc) { + return new ObjectProvider() { + @Override + public JdbcTemplate getObject() { + return jdbc; + } + + @Override + public JdbcTemplate getObject(Object... args) { + return jdbc; + } + + @Override + public JdbcTemplate getIfAvailable() { + return jdbc; + } + + @Override + public JdbcTemplate getIfUnique() { + return jdbc; + } + }; + } + + private static ListAppender attach() { + Logger logger = (Logger) LoggerFactory.getLogger(InspectionSqlReportTask.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + return appender; + } + + @Test + void disabledByDefaultRunsNothing() { + InspectionProperties props = new InspectionProperties(); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + assertEquals(false, props.isEnabled(), "巡检默认 disabled"); + InspectionSqlReportTask task = new InspectionSqlReportTask(props, provider(jdbc)); + assertEquals(0, task.runAll(), "disabled 不执行"); + Mockito.verifyNoInteractions(jdbc); + } + + @Test + void enabledRunsAllSixReports() { + InspectionProperties props = new InspectionProperties(); + props.setEnabled(true); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + Mockito.when(jdbc.queryForList(anyString())).thenReturn(List.of()); + InspectionSqlReportTask task = new InspectionSqlReportTask(props, provider(jdbc)); + assertEquals(6, task.runAll()); + verify(jdbc, times(6)).queryForList(anyString()); + } + + @Test + void reportOutputLogsRowCount() { + InspectionProperties props = new InspectionProperties(); + props.setEnabled(true); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + Mockito.when(jdbc.queryForList(anyString())).thenReturn(List.of(Map.of("a", 1), Map.of("a", 2), Map.of("a", 3))); + InspectionSqlReportTask task = new InspectionSqlReportTask(props, provider(jdbc)); + ListAppender captured = attach(); + try { + task.runAll(); + String text = String.join("\n", captured.list.stream().map(ILoggingEvent::getFormattedMessage).toList()); + assertTrue(text.contains("rows=3"), "报表日志应含行数: " + text); + assertTrue(text.contains("[inspection-sql] report"), "应含报表前缀"); + } finally { + ((Logger) LoggerFactory.getLogger(InspectionSqlReportTask.class)).detachAppender(captured); + } + } + + @Test + void singleReportFailureDoesNotBlockOthers() { + InspectionProperties props = new InspectionProperties(); + props.setEnabled(true); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + AtomicInteger calls = new AtomicInteger(); + Mockito.doAnswer(inv -> { + if (calls.incrementAndGet() == 3) { + throw new RuntimeException("模拟第三条 SQL 失败"); + } + return List.of(); + }).when(jdbc).queryForList(anyString()); + InspectionSqlReportTask task = new InspectionSqlReportTask(props, provider(jdbc)); + assertEquals(5, task.runAll(), "单条失败应被捕获,其余继续"); + verify(jdbc, times(6)).queryForList(anyString()); + } + + @Test + void scheduleConfigDefaults() { + InspectionProperties props = new InspectionProperties(); + assertEquals("0 0 3 * * *", props.getCron()); + assertEquals(false, props.isEnabled()); + } + + @Test + void onlyReadQueriesNoWrites() { + InspectionProperties props = new InspectionProperties(); + props.setEnabled(true); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + Mockito.when(jdbc.queryForList(anyString())).thenReturn(List.of()); + InspectionSqlReportTask task = new InspectionSqlReportTask(props, provider(jdbc)); + task.runAll(); + verify(jdbc, times(6)).queryForList(anyString()); + verify(jdbc, never()).execute(anyString()); + } + + @Test + void noLockOrJdbcSafelyNoop() { + InspectionProperties props = new InspectionProperties(); + props.setEnabled(true); + InspectionSqlReportTask noJdbc = new InspectionSqlReportTask(props, new ObjectProvider() { + @Override + public JdbcTemplate getObject() { + return null; + } + + @Override + public JdbcTemplate getObject(Object... args) { + return null; + } + + @Override + public JdbcTemplate getIfAvailable() { + return null; + } + + @Override + public JdbcTemplate getIfUnique() { + return null; + } + }); + assertEquals(0, noJdbc.runAll(), "启用但无 JdbcTemplate 应安全跳过"); + // 无分布式锁实例时 runIfEnabled 不应抛错(测试构造传入 null lock) + InspectionSqlReportTask noLock = new InspectionSqlReportTask(props, null, provider(mock(JdbcTemplate.class))); + noLock.runIfEnabled(); + } + + @Test + void enabledStableAcrossRuns() { + InspectionProperties props = new InspectionProperties(); + props.setEnabled(true); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + Mockito.when(jdbc.queryForList(anyString())).thenReturn(List.of()); + InspectionSqlReportTask task = new InspectionSqlReportTask(props, provider(jdbc)); + assertEquals(6, task.runAll()); + assertEquals(6, task.runAll()); + verify(jdbc, times(12)).queryForList(anyString()); + } +}