Pārlūkot izejas kodu

feat(临考突击报告): 增加启动预热

金逸霄 1 nedēļu atpakaļ
vecāks
revīzija
9634b0d589

+ 6 - 0
abilities/exam-sprint/application/pom.xml

@@ -26,6 +26,12 @@
             <artifactId>exam-sprint-domain</artifactId>
             <version>${project.version}</version>
         </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>exam-sprint-infrastructure</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>org.springframework</groupId>
             <artifactId>spring-context</artifactId>

+ 144 - 0
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportWarmupRunner.java

@@ -0,0 +1,144 @@
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.Clock;
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+
+public class ExamSprintReportWarmupRunner {
+
+    static final String OUTLOOK_WARMUP_RESOURCE = "warmup/outlook-exam-sprint-report.json";
+    static final String ACHIEVEMENT_WARMUP_RESOURCE = "warmup/achievement-exam-sprint-report.json";
+
+    private static final Logger log = LoggerFactory.getLogger(ExamSprintReportWarmupRunner.class);
+
+    private final List<ExamSprintReportRenderer> renderers;
+    private final ExamSprintReportPdfGenerator pdfGenerator;
+    private final ObjectMapper objectMapper;
+    private final Clock clock;
+    private final Executor executor;
+
+    public ExamSprintReportWarmupRunner(
+            List<ExamSprintReportRenderer> renderers,
+            ExamSprintReportPdfGenerator pdfGenerator,
+            ObjectMapper objectMapper,
+            Clock clock,
+            @Qualifier("examSprintReportExecutor") Executor executor) {
+        this.renderers = List.copyOf(renderers);
+        this.pdfGenerator = Objects.requireNonNull(pdfGenerator, "pdfGenerator");
+        this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper");
+        this.clock = Objects.requireNonNull(clock, "clock");
+        this.executor = Objects.requireNonNull(executor, "executor");
+    }
+
+    @EventListener(ApplicationReadyEvent.class)
+    public void onApplicationReady() {
+        try {
+            executor.execute(this::warmUpReports);
+        } catch (RuntimeException exception) {
+            log.warn(
+                    "exam_sprint_report_warmup_failed reportType={} stage={} failureReason={} exceptionType={} durationMs={}",
+                    "ALL",
+                    "async_submit",
+                    exception.getClass().getSimpleName(),
+                    exception.getClass().getSimpleName(),
+                    0);
+        }
+    }
+
+    void warmUpReports() {
+        warmUpReport(ReportType.OUTLOOK, OUTLOOK_WARMUP_RESOURCE);
+        warmUpReport(ReportType.ACHIEVEMENT, ACHIEVEMENT_WARMUP_RESOURCE);
+    }
+
+    private void warmUpReport(ReportType reportType, String resource) {
+        long startedNanos = System.nanoTime();
+        String stage = "load_payload";
+        log.info("exam_sprint_report_warmup_started reportType={} resource={}", reportType, resource);
+
+        try {
+            JsonNode payload = loadPayload(resource);
+
+            stage = "map_content";
+            ReportContent content = contentOf(reportType, payload);
+
+            stage = "renderer_selection";
+            ExamSprintReportRenderer renderer = rendererFor(reportType);
+
+            stage = "render_html";
+            long renderStartedNanos = System.nanoTime();
+            Instant generatedAt = clock.instant();
+            String html = renderer.render(content, generatedAt);
+            long renderDurationMs = elapsedMillis(renderStartedNanos);
+
+            stage = "pdf_generation";
+            long pdfStartedNanos = System.nanoTime();
+            byte[] pdfBytes = pdfGenerator.generate(html);
+            long pdfDurationMs = elapsedMillis(pdfStartedNanos);
+
+            log.info(
+                    "exam_sprint_report_warmup_succeeded reportType={} durationMs={} renderDurationMs={} pdfDurationMs={} htmlLength={} pdfByteLength={}",
+                    reportType,
+                    elapsedMillis(startedNanos),
+                    renderDurationMs,
+                    pdfDurationMs,
+                    html.length(),
+                    pdfBytes.length);
+        } catch (Exception exception) {
+            log.warn(
+                    "exam_sprint_report_warmup_failed reportType={} stage={} failureReason={} exceptionType={} durationMs={}",
+                    reportType,
+                    stage,
+                    exception.getClass().getSimpleName(),
+                    exception.getClass().getSimpleName(),
+                    elapsedMillis(startedNanos));
+        }
+    }
+
+    private JsonNode loadPayload(String resource) throws IOException {
+        InputStream inputStream = ExamSprintReportWarmupRunner.class.getClassLoader().getResourceAsStream(resource);
+        if (inputStream == null) {
+            throw new IllegalStateException("Warmup resource not found: " + resource);
+        }
+        try (inputStream) {
+            return objectMapper.readTree(inputStream);
+        }
+    }
+
+    private ReportContent contentOf(ReportType reportType, JsonNode payload) throws IOException {
+        return switch (reportType) {
+            case OUTLOOK -> new UnmodeledReportContent(ReportType.OUTLOOK, payload);
+            case ACHIEVEMENT -> AchievementReportContentMapper.toDomainContent(
+                    objectMapper.treeToValue(payload, AchievementExamSprintReportPayload.class));
+        };
+    }
+
+    private ExamSprintReportRenderer rendererFor(ReportType reportType) {
+        return renderers.stream()
+                .filter(renderer -> renderer.supports(reportType))
+                .findFirst()
+                .orElseThrow(() -> new IllegalStateException("No renderer for report type " + reportType));
+    }
+
+    private long elapsedMillis(long startedNanos) {
+        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
+    }
+}

+ 342 - 0
abilities/exam-sprint/application/src/main/resources/warmup/achievement-exam-sprint-report.json

@@ -0,0 +1,342 @@
+{
+  "StudentName": "20260318测试",
+  "StudentStage": 2,
+  "StageName": "初中",
+  "StageVocabulary": 2400,
+  "StudentVocabulary": 644,
+  "StudentVocabularyBefore": 951,
+  "StudentUnMastedWordCount": 1756,
+  "StudentImproveWordCount": -307,
+  "StudentStrangeWordItems": [
+    {
+      "ExerciseId": 6767952,
+      "WordId": 885286,
+      "MeanId": 2626,
+      "CreateTime": "2026-04-28T10:55:50.0851539+08:00"
+    },
+    {
+      "ExerciseId": 6767952,
+      "WordId": 1016835,
+      "MeanId": 2627,
+      "CreateTime": "2026-04-28T10:55:50.0860988+08:00"
+    }
+  ],
+  "StudentWordsLatest": [
+    {
+      "WordId": 743692,
+      "MeanId": 1,
+      "WordSpell": "a",
+      "WordFrequency": 1,
+      "Mastery": 0.9999,
+      "ReviewTimes": 1,
+      "Reliability": 2,
+      "CreateTime": "2026-03-18T15:28:25.5813874+08:00"
+    },
+    {
+      "WordId": 1127934,
+      "MeanId": 2,
+      "WordSpell": "the",
+      "WordFrequency": 2,
+      "Mastery": 0.9999,
+      "ReviewTimes": 1,
+      "Reliability": 2,
+      "CreateTime": "2026-03-18T15:28:25.5813912+08:00"
+    }
+  ],
+  "StudentWordsFirstPreExamAssaultAfter": [
+    {
+      "WordId": 743692,
+      "MeanId": 1,
+      "WordSpell": "a",
+      "WordFrequency": 1,
+      "Mastery": 0.9999,
+      "ReviewTimes": 1,
+      "Reliability": 2,
+      "CreateTime": "2026-03-18T15:28:25.5813874+08:00"
+    },
+    {
+      "WordId": 1127934,
+      "MeanId": 2,
+      "WordSpell": "the",
+      "WordFrequency": 2,
+      "Mastery": 0.9999,
+      "ReviewTimes": 1,
+      "Reliability": 2,
+      "CreateTime": "2026-03-18T15:28:25.5813912+08:00"
+    }
+  ],
+  "TestPaperTitle": "文章2.jpg",
+  "TestPaperWords": [
+    743692,
+    760054,
+    799918,
+    804625,
+    1176304,
+    884121,
+    919646,
+    955358,
+    969663,
+    999127,
+    1005021,
+    1180913,
+    1133655,
+    1162100,
+    1164567
+  ],
+  "TestPaperWordCount": 13,
+  "TestPaperWordInStudentWords": [
+    [
+      {
+        "WordId": 743692,
+        "MeanId": 1,
+        "WordSpell": "a",
+        "WordFrequency": 1,
+        "Mastery": 0.9999,
+        "ReviewTimes": 1,
+        "Reliability": 2,
+        "CreateTime": "2026-03-18T15:28:25.5813874+08:00"
+      }
+    ],
+    [
+      {
+        "WordId": 1133655,
+        "MeanId": 3,
+        "WordSpell": "to",
+        "WordFrequency": 3,
+        "Mastery": 0.9999,
+        "ReviewTimes": 1,
+        "Reliability": 2,
+        "CreateTime": "2026-03-18T15:28:25.5813939+08:00"
+      }
+    ],
+    [
+      {
+        "WordId": 1005021,
+        "MeanId": 7,
+        "WordSpell": "of",
+        "WordFrequency": 6,
+        "Mastery": 0.9999,
+        "ReviewTimes": 1,
+        "Reliability": 2,
+        "CreateTime": "2026-03-18T15:28:25.5814048+08:00"
+      },
+      {
+        "WordId": 1005021,
+        "MeanId": 8,
+        "WordSpell": "of",
+        "WordFrequency": 6,
+        "Mastery": 0.9999,
+        "ReviewTimes": 1,
+        "Reliability": 2,
+        "CreateTime": "2026-03-18T15:28:25.581407+08:00"
+      }
+    ],
+    [
+      {
+        "WordId": 760054,
+        "MeanId": 9,
+        "WordSpell": "and",
+        "WordFrequency": 7,
+        "Mastery": 0.9999,
+        "ReviewTimes": 1,
+        "Reliability": 2,
+        "CreateTime": "2026-03-18T15:28:25.5814163+08:00"
+      },
+      {
+        "WordId": 760054,
+        "MeanId": 10,
+        "WordSpell": "and",
+        "WordFrequency": 7,
+        "Mastery": 0.9999,
+        "ReviewTimes": 1,
+        "Reliability": 2,
+        "CreateTime": "2026-03-18T15:28:25.5814187+08:00"
+      },
+      {
+        "WordId": 760054,
+        "MeanId": 11,
+        "WordSpell": "and",
+        "WordFrequency": 7,
+        "Mastery": 0.9999,
+        "ReviewTimes": 1,
+        "Reliability": 2,
+        "CreateTime": "2026-03-18T15:28:25.581421+08:00"
+      }
+    ],
+    [
+      {
+        "WordId": 799918,
+        "MeanId": 23,
+        "WordSpell": "can",
+        "WordFrequency": 15,
+        "Mastery": 0.9999,
+        "ReviewTimes": 1,
+        "Reliability": 2,
+        "CreateTime": "2026-03-18T15:28:25.5814496+08:00"
+      },
+      {
+        "WordId": 799918,
+        "MeanId": 24,
+        "WordSpell": "can",
+        "WordFrequency": 15,
+        "Mastery": 0.9999,
+        "ReviewTimes": 1,
+        "Reliability": 2,
+        "CreateTime": "2026-03-18T15:28:25.5814517+08:00"
+      },
+      {
+        "WordId": 799918,
+        "MeanId": 25,
+        "WordSpell": "can",
+        "WordFrequency": 15,
+        "Mastery": 0.9999,
+        "ReviewTimes": 1,
+        "Reliability": 2,
+        "CreateTime": "2026-03-18T15:28:25.5814539+08:00"
+      }
+    ],
+    [
+      {
+        "WordId": 919646,
+        "MeanId": 45,
+        "WordSpell": "he",
+        "WordFrequency": 25,
+        "Mastery": 0.9999,
+        "ReviewTimes": 1,
+        "Reliability": 2,
+        "CreateTime": "2026-03-18T15:28:25.5815034+08:00"
+      }
+    ],
+    [
+      {
+        "WordId": 1162100,
+        "MeanId": 55,
+        "WordSpell": "we",
+        "WordFrequency": 33,
+        "Mastery": 0.9999,
+        "ReviewTimes": 1,
+        "Reliability": 2,
+        "CreateTime": "2026-03-18T15:28:25.5815271+08:00"
+      }
+    ],
+    [
+      {
+        "WordId": 955358,
+        "MeanId": 103,
+        "WordSpell": "know",
+        "WordFrequency": 58,
+        "Mastery": 0,
+        "ReviewTimes": 5,
+        "Reliability": 0,
+        "CreateTime": "2026-03-18T15:28:25.5816517+08:00"
+      }
+    ],
+    [
+      {
+        "WordId": 999127,
+        "MeanId": 108,
+        "WordSpell": "no",
+        "WordFrequency": 62,
+        "Mastery": 0,
+        "ReviewTimes": 5,
+        "Reliability": 0,
+        "CreateTime": "2026-03-18T15:28:25.5816704+08:00"
+      }
+    ],
+    [
+      {
+        "WordId": 1164567,
+        "MeanId": 121,
+        "WordSpell": "who",
+        "WordFrequency": 73,
+        "Mastery": 0,
+        "ReviewTimes": 5,
+        "Reliability": 0,
+        "CreateTime": "2026-03-18T15:28:25.5817027+08:00"
+      }
+    ],
+    [
+      {
+        "WordId": 969663,
+        "MeanId": 229,
+        "WordSpell": "lot",
+        "WordFrequency": 144,
+        "Mastery": 0,
+        "ReviewTimes": 3,
+        "Reliability": 0,
+        "CreateTime": "2026-03-18T15:28:25.5819857+08:00"
+      },
+      {
+        "WordId": 969663,
+        "MeanId": 92178,
+        "WordSpell": "lot",
+        "WordFrequency": 144,
+        "Mastery": 0,
+        "ReviewTimes": 2,
+        "Reliability": 0,
+        "CreateTime": "2026-03-18T15:28:25.5819879+08:00"
+      }
+    ],
+    [
+      {
+        "WordId": 884121,
+        "MeanId": 417,
+        "WordSpell": "father",
+        "WordFrequency": 286,
+        "Mastery": 0,
+        "ReviewTimes": 2,
+        "Reliability": 0,
+        "CreateTime": "2026-03-18T15:28:25.5891094+08:00"
+      },
+      {
+        "WordId": 884121,
+        "MeanId": 418,
+        "WordSpell": "father",
+        "WordFrequency": 286,
+        "Mastery": 0,
+        "ReviewTimes": 2,
+        "Reliability": 0,
+        "CreateTime": "2026-03-18T15:28:25.5891115+08:00"
+      }
+    ],
+    [
+      {
+        "WordId": 804625,
+        "MeanId": 514,
+        "WordSpell": "catch",
+        "WordFrequency": 350,
+        "Mastery": 0,
+        "ReviewTimes": 2,
+        "Reliability": 0,
+        "CreateTime": "2026-03-18T15:28:25.5894013+08:00"
+      },
+      {
+        "WordId": 804625,
+        "MeanId": 515,
+        "WordSpell": "catch",
+        "WordFrequency": 350,
+        "Mastery": 0,
+        "ReviewTimes": 3,
+        "Reliability": 0,
+        "CreateTime": "2026-03-18T15:28:25.5894034+08:00"
+      }
+    ]
+  ],
+  "TestPaperBeforUnMastery": 6,
+  "TestPaperBeforMastery": 7,
+  "TestPaperLatestMastery": 7,
+  "TestPaperAfterUnMastery": 6,
+  "TestPaperImprovedWordIdList": [],
+  "TestPaperImprovedWords": [],
+  "TestPaperImprovedWordCount": 0,
+  "TestPaperImproveRate": 0,
+  "PaperMasteryHitRate": 0,
+  "ImproveStudyEfficiency": 0,
+  "StudentInitialVocabMastery": 39.62,
+  "StudentCurrentVocabMastery": 26.83,
+  "StudentVocabMasteryImprovement": -12.79,
+  "TestPaperBeforMasteryRate": 53.85,
+  "TestPaperLatestMasteryRate": 53.85,
+  "ShouldDisplaySigningGuarantee": false,
+  "SigningGuarantee": ""
+}

+ 62 - 0
abilities/exam-sprint/application/src/main/resources/warmup/outlook-exam-sprint-report.json

@@ -0,0 +1,62 @@
+{
+  "StudentName": "20260318测试",
+  "StudentStage": 2,
+  "StageName": "初中",
+  "StageVocabulary": 2400,
+  "StageExaminName": "中考",
+  "StageImportant": 300,
+  "StudentWordsLatest": [
+    {
+      "WordId": 743692,
+      "MeanId": 1,
+      "WordSpell": "a",
+      "WordFrequency": 1,
+      "Mastery": 0.9999,
+      "ReviewTimes": 1,
+      "Reliability": 2,
+      "CreateTime": "2026-03-18T15:28:25.5813874+08:00"
+    },
+    {
+      "WordId": 1127934,
+      "MeanId": 2,
+      "WordSpell": "the",
+      "WordFrequency": 2,
+      "Mastery": 0.9999,
+      "ReviewTimes": 1,
+      "Reliability": 2,
+      "CreateTime": "2026-03-18T15:28:25.5813912+08:00"
+    }
+  ],
+  "MastedWordCount": 997,
+  "UnMastedWordCount": 1403,
+  "ExamineStrangeWordCount": 209,
+  "TestPaperWordIdArray": [
+    743692,
+    760054,
+    799918,
+    804625,
+    1176304,
+    884121,
+    919646,
+    955358,
+    969663,
+    999127,
+    1005021,
+    1180913,
+    1133655,
+    1162100,
+    1164567
+  ],
+  "TestPaperTitle": "文章2.jpg",
+  "TestPaperUnMasterWords": [
+    "lot",
+    "lot",
+    "father",
+    "father",
+    "catch"
+  ],
+  "TestPaperMastedWords": [],
+  "TestPaperMastedWordCount": 0,
+  "TestPaperWordCount": 15,
+  "Complex": false
+}

+ 74 - 0
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportWarmupResourceIntegrationTest.java

@@ -0,0 +1,74 @@
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
+import cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf.OpenHtmlToPdfExamSprintReportPdfGenerator;
+import cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.achievement.ClasspathAchievementExamSprintReportRenderer;
+import cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook.ClasspathOutlookExamSprintReportRenderer;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ClassPathResource;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ExamSprintReportWarmupResourceIntegrationTest {
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final Instant GENERATED_AT = Instant.parse("2026-05-07T08:00:00Z");
+
+    /** 覆盖运行时 warmup JSON 资源场景,当使用真实渲染器和 PDF 生成器时,两个报告都应产出非空 HTML 与 PDF。 */
+    @Test
+    void warmupResourcesRenderHtmlAndGeneratePdfForBothReports() throws Exception {
+        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
+
+        JsonNode outlookPayload = readResource(ExamSprintReportWarmupRunner.OUTLOOK_WARMUP_RESOURCE);
+        ClasspathOutlookExamSprintReportRenderer outlookRenderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+        String outlookHtml = outlookRenderer.render(
+                new UnmodeledReportContent(ReportType.OUTLOOK, outlookPayload),
+                GENERATED_AT);
+        byte[] outlookPdf = pdfGenerator.generate(outlookHtml);
+
+        assertThat(outlookPayload.path("StudentName").asText()).isEqualTo("20260318测试");
+        assertThat(outlookHtml)
+                .contains("高考英语临考词汇突击潜力展望报告")
+                .contains("模块一:个人学情分析")
+                .contains("模块二:科学备考建议");
+        assertPdfBytes(outlookPdf);
+
+        JsonNode achievementPayload = readResource(ExamSprintReportWarmupRunner.ACHIEVEMENT_WARMUP_RESOURCE);
+        AchievementExamSprintReportPayload reportPayload = OBJECT_MAPPER.treeToValue(
+                achievementPayload,
+                AchievementExamSprintReportPayload.class);
+        AchievementReportContent achievementContent = AchievementReportContentMapper.toDomainContent(reportPayload);
+        ClasspathAchievementExamSprintReportRenderer achievementRenderer = new ClasspathAchievementExamSprintReportRenderer();
+        String achievementHtml = achievementRenderer.render(achievementContent, GENERATED_AT);
+        byte[] achievementPdf = pdfGenerator.generate(achievementHtml);
+
+        assertThat(achievementContent.studentName()).isEqualTo("20260318测试");
+        assertThat(achievementContent.reportTitle()).isEqualTo("初中英语临考突击学习成果报告");
+        assertThat(achievementHtml)
+                .contains("初中英语临考突击学习成果报告")
+                .contains("模块一:词汇量对比")
+                .contains("模块二:试卷熟词量对比")
+                .contains("模块三:实考生词命中状况");
+        assertPdfBytes(achievementPdf);
+    }
+
+    private JsonNode readResource(String resourcePath) throws Exception {
+        try (InputStream inputStream = new ClassPathResource(resourcePath).getInputStream()) {
+            return OBJECT_MAPPER.readTree(inputStream);
+        }
+    }
+
+    private void assertPdfBytes(byte[] pdfBytes) {
+        assertThat(pdfBytes).isNotEmpty();
+        assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
+    }
+}

+ 249 - 0
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportWarmupRunnerTest.java

@@ -0,0 +1,249 @@
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@ExtendWith(OutputCaptureExtension.class)
+class ExamSprintReportWarmupRunnerTest {
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-05-07T08:00:00Z"), ZoneOffset.UTC);
+
+    /** 覆盖启动事件异步提交场景,当应用 ready 后,应只提交后台任务而不在线程内同步执行预热。 */
+    @Test
+    void onApplicationReadySubmitsWarmupJobWithoutRunningInline() {
+        List<String> events = new ArrayList<>();
+        RecordingExecutor executor = new RecordingExecutor();
+        RecordingRenderer outlookRenderer = new RecordingRenderer(ReportType.OUTLOOK, "outlook-html", events);
+        RecordingRenderer achievementRenderer = new RecordingRenderer(ReportType.ACHIEVEMENT, "achievement-html", events);
+        RecordingPdfGenerator pdfGenerator = new RecordingPdfGenerator(events);
+        ExamSprintReportWarmupRunner runner = runner(
+                List.of(outlookRenderer, achievementRenderer),
+                pdfGenerator,
+                executor);
+
+        runner.onApplicationReady();
+
+        assertThat(executor.commands).hasSize(1);
+        assertThat(events).isEmpty();
+
+        executor.runOnlyCommand();
+
+        assertThat(events).containsExactly(
+                "render:OUTLOOK",
+                "pdf:outlook-html",
+                "render:ACHIEVEMENT",
+                "pdf:achievement-html");
+    }
+
+    /** 覆盖 warmup 异步提交失败场景,当 executor 拒绝任务时,应记录安全日志且不向 ApplicationReadyEvent 抛出异常。 */
+    @Test
+    void onApplicationReadyLogsSafeFailureWhenExecutorRejectsTask(CapturedOutput output) {
+        List<String> events = new ArrayList<>();
+        Executor rejectingExecutor = command -> {
+            throw new java.util.concurrent.RejectedExecutionException("SENSITIVE_EXECUTOR_REJECTION_DO_NOT_LOG");
+        };
+        ExamSprintReportWarmupRunner runner = runner(
+                List.of(
+                        new RecordingRenderer(ReportType.OUTLOOK, "outlook-html", events),
+                        new RecordingRenderer(ReportType.ACHIEVEMENT, "achievement-html", events)),
+                new RecordingPdfGenerator(events),
+                rejectingExecutor);
+
+        runner.onApplicationReady();
+
+        assertThat(events).isEmpty();
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_warmup_failed")
+                .contains("reportType=ALL")
+                .contains("stage=async_submit")
+                .contains("exceptionType=RejectedExecutionException")
+                .contains("failureReason=RejectedExecutionException")
+                .doesNotContain("SENSITIVE_EXECUTOR_REJECTION_DO_NOT_LOG")
+                .doesNotContain("StudentWordsLatest")
+                .doesNotContain("TestPaperImprovedWords");
+    }
+
+    /** 覆盖串行预热主流程,当两个报告资源有效时,应按 OUTLOOK 到 ACHIEVEMENT 顺序渲染并生成 PDF。 */
+    @Test
+    void warmUpReportsRendersOutlookBeforeAchievementAndGeneratesPdf(CapturedOutput output) {
+        List<String> events = new ArrayList<>();
+        RecordingRenderer outlookRenderer = new RecordingRenderer(ReportType.OUTLOOK, "outlook-html", events);
+        RecordingRenderer achievementRenderer = new RecordingRenderer(ReportType.ACHIEVEMENT, "achievement-html", events);
+        RecordingPdfGenerator pdfGenerator = new RecordingPdfGenerator(events);
+        ExamSprintReportWarmupRunner runner = runner(
+                List.of(outlookRenderer, achievementRenderer),
+                pdfGenerator,
+                Runnable::run);
+
+        runner.warmUpReports();
+
+        assertThat(events).containsExactly(
+                "render:OUTLOOK",
+                "pdf:outlook-html",
+                "render:ACHIEVEMENT",
+                "pdf:achievement-html");
+        assertThat(outlookRenderer.generatedAt).containsExactly(FIXED_CLOCK.instant());
+        assertThat(achievementRenderer.generatedAt).containsExactly(FIXED_CLOCK.instant());
+
+        assertThat(outlookRenderer.renderedContents).singleElement().isInstanceOf(UnmodeledReportContent.class);
+        UnmodeledReportContent outlookContent = (UnmodeledReportContent) outlookRenderer.renderedContents.get(0);
+        assertThat(outlookContent.reportType()).isEqualTo(ReportType.OUTLOOK);
+        assertThat(((JsonNode) outlookContent.source()).path("StudentName").asText()).isEqualTo("20260318测试");
+
+        assertThat(achievementRenderer.renderedContents).singleElement().isInstanceOf(AchievementReportContent.class);
+        AchievementReportContent achievementContent = (AchievementReportContent) achievementRenderer.renderedContents.get(0);
+        assertThat(achievementContent.studentName()).isEqualTo("20260318测试");
+        assertThat(achievementContent.reportTitle()).isEqualTo("初中英语临考突击学习成果报告");
+
+        assertThat(pdfGenerator.htmlInputs).containsExactly("outlook-html", "achievement-html");
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_warmup_started")
+                .contains("exam_sprint_report_warmup_succeeded")
+                .contains("reportType=OUTLOOK")
+                .contains("reportType=ACHIEVEMENT")
+                .contains("durationMs=")
+                .contains("renderDurationMs=")
+                .contains("pdfDurationMs=")
+                .contains("htmlLength=")
+                .contains("pdfByteLength=");
+    }
+
+    /** 覆盖单报告失败隔离场景,当 OUTLOOK 渲染失败时,应记录安全失败日志且继续预热 ACHIEVEMENT。 */
+    @Test
+    void warmUpReportsLogsFailureWithoutPayloadOrSensitiveMessageAndContinues(CapturedOutput output) {
+        List<String> events = new ArrayList<>();
+        FailingRenderer failingOutlookRenderer = new FailingRenderer(ReportType.OUTLOOK, events);
+        RecordingRenderer achievementRenderer = new RecordingRenderer(ReportType.ACHIEVEMENT, "achievement-html", events);
+        RecordingPdfGenerator pdfGenerator = new RecordingPdfGenerator(events);
+        ExamSprintReportWarmupRunner runner = runner(
+                List.of(failingOutlookRenderer, achievementRenderer),
+                pdfGenerator,
+                Runnable::run);
+
+        runner.warmUpReports();
+
+        assertThat(events).containsExactly(
+                "render:OUTLOOK",
+                "render:ACHIEVEMENT",
+                "pdf:achievement-html");
+        assertThat(achievementRenderer.renderedContents).singleElement().isInstanceOf(AchievementReportContent.class);
+        assertThat(pdfGenerator.htmlInputs).containsExactly("achievement-html");
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_warmup_failed")
+                .contains("reportType=OUTLOOK")
+                .contains("stage=render_html")
+                .contains("exceptionType=IllegalStateException")
+                .contains("failureReason=IllegalStateException")
+                .contains("exam_sprint_report_warmup_succeeded")
+                .contains("reportType=ACHIEVEMENT")
+                .doesNotContain("SENSITIVE_WARMUP_FAILURE_DO_NOT_LOG")
+                .doesNotContain("StudentWordsLatest")
+                .doesNotContain("TestPaperImprovedWords");
+    }
+
+    private ExamSprintReportWarmupRunner runner(
+            List<ExamSprintReportRenderer> renderers,
+            ExamSprintReportPdfGenerator pdfGenerator,
+            Executor executor) {
+        return new ExamSprintReportWarmupRunner(renderers, pdfGenerator, OBJECT_MAPPER, FIXED_CLOCK, executor);
+    }
+
+    private static final class RecordingExecutor implements Executor {
+        private final List<Runnable> commands = new ArrayList<>();
+
+        @Override
+        public void execute(Runnable command) {
+            commands.add(command);
+        }
+
+        private void runOnlyCommand() {
+            assertThat(commands).hasSize(1);
+            commands.get(0).run();
+        }
+    }
+
+    private static final class RecordingRenderer implements ExamSprintReportRenderer {
+        private final ReportType reportType;
+        private final String html;
+        private final List<String> events;
+        private final List<ReportContent> renderedContents = new ArrayList<>();
+        private final List<Instant> generatedAt = new ArrayList<>();
+
+        private RecordingRenderer(ReportType reportType, String html, List<String> events) {
+            this.reportType = reportType;
+            this.html = html;
+            this.events = events;
+        }
+
+        @Override
+        public boolean supports(ReportType reportType) {
+            return this.reportType == reportType;
+        }
+
+        @Override
+        public String render(ReportContent content, Instant generatedAt) {
+            events.add("render:" + reportType);
+            renderedContents.add(content);
+            this.generatedAt.add(generatedAt);
+            return html;
+        }
+    }
+
+    private static final class FailingRenderer implements ExamSprintReportRenderer {
+        private final ReportType reportType;
+        private final List<String> events;
+
+        private FailingRenderer(ReportType reportType, List<String> events) {
+            this.reportType = reportType;
+            this.events = events;
+        }
+
+        @Override
+        public boolean supports(ReportType reportType) {
+            return this.reportType == reportType;
+        }
+
+        @Override
+        public String render(ReportContent content, Instant generatedAt) {
+            events.add("render:" + reportType);
+            throw new IllegalStateException("SENSITIVE_WARMUP_FAILURE_DO_NOT_LOG");
+        }
+    }
+
+    private static final class RecordingPdfGenerator implements ExamSprintReportPdfGenerator {
+        private final List<String> events;
+        private final List<String> htmlInputs = new ArrayList<>();
+
+        private RecordingPdfGenerator(List<String> events) {
+            this.events = events;
+        }
+
+        @Override
+        public byte[] generate(String htmlContent) {
+            events.add("pdf:" + htmlContent);
+            htmlInputs.add(htmlContent);
+            return ("pdf:" + htmlContent).getBytes(StandardCharsets.UTF_8);
+        }
+    }
+}

+ 16 - 0
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfiguration.java

@@ -1,6 +1,11 @@
 package cn.yunzhixue.ability.center.examsprint.configuration;
 
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportProperties;
+import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportWarmupRunner;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
@@ -9,6 +14,7 @@ import org.springframework.context.annotation.Primary;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
 
 import java.time.Clock;
+import java.util.List;
 import java.util.concurrent.Executor;
 
 @Configuration
@@ -51,6 +57,16 @@ public class ExamSprintReportRuntimeConfiguration {
         return executor;
     }
 
+    @Bean
+    public ExamSprintReportWarmupRunner examSprintReportWarmupRunner(
+            List<ExamSprintReportRenderer> renderers,
+            ExamSprintReportPdfGenerator pdfGenerator,
+            ObjectMapper objectMapper,
+            Clock clock,
+            @Qualifier("examSprintReportExecutor") Executor executor) {
+        return new ExamSprintReportWarmupRunner(renderers, pdfGenerator, objectMapper, clock, executor);
+    }
+
     @ConfigurationProperties(prefix = "ability.exam-sprint.report")
     public static class BoundExamSprintReportProperties extends ExamSprintReportProperties {
     }

+ 51 - 0
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfigurationTest.java

@@ -1,7 +1,20 @@
 package cn.yunzhixue.ability.center.examsprint.configuration;
 
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportProperties;
+import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportWarmupRunner;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -19,4 +32,42 @@ class ExamSprintReportRuntimeConfigurationTest {
         assertThat(properties.getStorage().getDownloadUrlPrefix())
                 .isEqualTo("https://dcjxbtest.blob.core.chinacloudapi.cn");
     }
+
+    @Test
+    void examSprintReportWarmupRunnerIsRegisteredAndWiredBySpringContext() {
+        new ApplicationContextRunner()
+                .withUserConfiguration(ExamSprintReportRuntimeConfiguration.class, WarmupRunnerCollaboratorsConfiguration.class)
+                .run(context -> assertThat(context)
+                        .hasSingleBean(ExamSprintReportWarmupRunner.class)
+                        .hasBean("examSprintReportExecutor"));
+    }
+
+    @Configuration
+    static class WarmupRunnerCollaboratorsConfiguration {
+
+        @Bean
+        ObjectMapper objectMapper() {
+            return new ObjectMapper();
+        }
+
+        @Bean
+        ExamSprintReportRenderer examSprintReportRenderer() {
+            return new ExamSprintReportRenderer() {
+                @Override
+                public boolean supports(ReportType reportType) {
+                    return true;
+                }
+
+                @Override
+                public String render(ReportContent content, Instant generatedAt) {
+                    return "html";
+                }
+            };
+        }
+
+        @Bean
+        ExamSprintReportPdfGenerator examSprintReportPdfGenerator() {
+            return htmlContent -> new byte[] {1};
+        }
+    }
 }