Browse Source

Merge branch 'feature/exam-sprint-achievement-report' of jyx/dcjxb.microservice into master

金逸霄 2 weeks ago
parent
commit
19d1a15fa1
17 changed files with 1655 additions and 36 deletions
  1. 135 8
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
  2. 5 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java
  3. 173 14
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
  4. 39 2
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java
  5. 43 0
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java
  6. 1 0
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportDetailResponse.java
  7. 88 0
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/AchievementExamSprintReportSvgChartBuilder.java
  8. 176 0
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java
  9. 327 0
      abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html
  10. 70 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java
  11. 172 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java
  12. 287 0
      ability-center-runtime/scripts/achievement-report-demo.sh
  13. 9 0
      ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportController.java
  14. 43 12
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java
  15. 14 0
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java
  16. 36 0
      ability-center-runtime/src/test/resources/requests/exam-sprint-achievement-report-invalid-request.json
  17. 37 0
      ability-center-runtime/src/test/resources/requests/exam-sprint-achievement-report-request.json

+ 135 - 8
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java

@@ -2,12 +2,14 @@ package cn.yunzhixue.ability.center.examsprint.application.report;
 
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportRequest;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportResponse;
+import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookExamSprintReportPayload;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
 import cn.yunzhixue.ability.center.kernel.BusinessException;
 import cn.yunzhixue.ability.center.kernel.ErrorCode;
@@ -20,6 +22,7 @@ import org.springframework.stereotype.Service;
 
 import java.time.Clock;
 import java.time.Instant;
+import java.util.List;
 import java.util.Set;
 import java.util.UUID;
 
@@ -31,6 +34,7 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     private final ExamSprintReportRepository repository;
     private final ExamSprintReportGenerationDispatcher dispatcher;
     private final ExamSprintReportStorage storage;
+    private final List<ExamSprintReportRenderer> renderers;
     private final ExamSprintReportProperties properties;
     private final Clock clock;
     private final ObjectMapper objectMapper;
@@ -43,10 +47,12 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
             ExamSprintReportProperties properties,
             Clock clock,
             ObjectMapper objectMapper,
-            Validator validator) {
+            Validator validator,
+            List<ExamSprintReportRenderer> renderers) {
         this.repository = repository;
         this.dispatcher = dispatcher;
         this.storage = storage;
+        this.renderers = List.copyOf(renderers);
         this.properties = properties;
         this.clock = clock;
         this.objectMapper = objectMapper;
@@ -86,10 +92,12 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
         }
 
         String downloadUrl = null;
+        String previewHtmlUrl = null;
         if (report.generationStatus() == ExamSprintReportGenerationStatus.SUCCESS
                 && !report.isExpiredAt(now)
                 && report.storageObjectKey() != null) {
             downloadUrl = storage.generateDownloadUrl(report.storageObjectKey(), properties.getDownloadExpiry()).toString();
+            previewHtmlUrl = "/api/exam-sprint/reports/" + report.reportId() + "/preview/html";
         }
 
         return new ExamSprintReportDetailResponse(
@@ -100,6 +108,7 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
                 report.updatedAt(),
                 report.expiresAt(),
                 downloadUrl,
+                previewHtmlUrl,
                 report.failureReason());
     }
 
@@ -121,6 +130,27 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
                 .orElseThrow(() -> new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE));
     }
 
+    @Override
+    public ReportHtmlPreviewContent previewReportHtml(String reportId) {
+        Instant now = clock.instant();
+        ExamSprintReport report = requireReport(reportId);
+        if (report.isExpiredAt(now)) {
+            if (report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
+                repository.save(report.expired(now));
+            }
+            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
+        }
+        if (report.generationStatus() != ExamSprintReportGenerationStatus.SUCCESS || report.storageObjectKey() == null) {
+            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
+        }
+        String html = renderers.stream()
+                .filter(renderer -> renderer.supports(report.reportType()))
+                .findFirst()
+                .map(renderer -> renderer.render(report.payload(), report.updatedAt()))
+                .orElseThrow(() -> new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE));
+        return new ReportHtmlPreviewContent(html, "text/html;charset=UTF-8");
+    }
+
     public void cleanupExpiredReports() {
         Instant now = clock.instant();
         for (ExamSprintReport report : repository.findExpiredAtOrBefore(now)) {
@@ -147,22 +177,119 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     }
 
     private void validateCreateRequest(CreateExamSprintReportRequest request) {
-        if (request.reportType() != ExamSprintReportType.OUTLOOK) {
-            throw new BusinessException(ErrorCode.REPORT_TYPE_UNSUPPORTED);
+        if (request.reportType() == ExamSprintReportType.OUTLOOK) {
+            validateOutlookPayload(request.payload());
+            return;
         }
-        validateOutlookPayload(request.payload());
+        if (request.reportType() == ExamSprintReportType.ACHIEVEMENT) {
+            validateAchievementPayload(request.payload());
+            return;
+        }
+        throw new BusinessException(ErrorCode.REPORT_TYPE_UNSUPPORTED);
     }
 
     private void validateOutlookPayload(JsonNode payload) {
-        OutlookExamSprintReportPayload reportPayload;
+        OutlookExamSprintReportPayload reportPayload = readPayload(payload, OutlookExamSprintReportPayload.class);
+
+        Set<ConstraintViolation<OutlookExamSprintReportPayload>> violations = validator.validate(reportPayload);
+        if (!violations.isEmpty()) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+    }
+
+    private void validateAchievementPayload(JsonNode payload) {
+        validateAchievementPayloadShape(payload);
+        AchievementExamSprintReportPayload reportPayload = readPayload(payload, AchievementExamSprintReportPayload.class);
+
+        Set<ConstraintViolation<AchievementExamSprintReportPayload>> violations = validator.validate(reportPayload);
+        if (!violations.isEmpty()) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+    }
+
+    private void validateAchievementPayloadShape(JsonNode payload) {
+        requireObjectPayload(payload);
+        requireTextualField(payload, "reportTitle");
+        requireTextualField(payload, "reportSubtitle");
+        requireTextualField(payload, "completionTitle");
+        requireTextualField(payload, "completionSubtitle");
+
+        JsonNode summaryMetrics = requireObjectField(payload, "summaryMetrics");
+        requireTextualField(summaryMetrics, "vocabularyGrowthText");
+        requireTextualField(summaryMetrics, "paperKnownWordsGrowthText");
+        requireTextualField(summaryMetrics, "unknownWordHitRateText");
+        requireTextualField(summaryMetrics, "learningEfficiencyText");
+
+        validateComparisonShape(requireObjectField(payload, "vocabularyComparison"));
+        validateComparisonShape(requireObjectField(payload, "paperKnownWordsComparison"));
+
+        JsonNode examUnknownWordsHitStatus = requireObjectField(payload, "examUnknownWordsHitStatus");
+        requireTextualField(examUnknownWordsHitStatus, "unknownWordHitRateText");
+        requireTextualField(examUnknownWordsHitStatus, "learningEfficiencyText");
+        requireTextualField(examUnknownWordsHitStatus, "unknownWordsBeforeText");
+        requireTextualField(examUnknownWordsHitStatus, "unknownWordsAfterText");
+        requireTextualField(examUnknownWordsHitStatus, "reducedUnknownWordsText");
+        requireTextualArrayField(examUnknownWordsHitStatus, "hitWords");
+    }
+
+    private void validateComparisonShape(JsonNode comparison) {
+        requireNumericField(comparison, "beforeValue");
+        requireNumericField(comparison, "afterValue");
+        requireTextualField(comparison, "beforeText");
+        requireTextualField(comparison, "afterText");
+        requireTextualField(comparison, "growthText");
+    }
+
+    private void requireTextualField(JsonNode objectNode, String fieldName) {
+        JsonNode field = objectNode.get(fieldName);
+        if (field == null || !field.isTextual()) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+    }
+
+    private void requireNumericField(JsonNode objectNode, String fieldName) {
+        JsonNode field = objectNode.get(fieldName);
+        if (field == null || !field.isNumber()) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+    }
+
+    private JsonNode requireObjectField(JsonNode objectNode, String fieldName) {
+        JsonNode field = objectNode.get(fieldName);
+        if (field == null || !field.isObject()) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+        return field;
+    }
+
+    private void requireTextualArrayField(JsonNode objectNode, String fieldName) {
+        JsonNode field = objectNode.get(fieldName);
+        if (field == null || !field.isArray()) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+        for (JsonNode element : field) {
+            if (!element.isTextual()) {
+                throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+            }
+        }
+    }
+
+    private <T> T readPayload(JsonNode payload, Class<T> payloadType) {
+        requireObjectPayload(payload);
+
         try {
-            reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
+            T reportPayload = objectMapper.treeToValue(payload, payloadType);
+            if (reportPayload == null) {
+                throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+            }
+            return reportPayload;
         } catch (JsonProcessingException | IllegalArgumentException exception) {
             throw new BusinessException(ErrorCode.VALIDATION_ERROR);
         }
+    }
 
-        Set<ConstraintViolation<OutlookExamSprintReportPayload>> violations = validator.validate(reportPayload);
-        if (!violations.isEmpty()) {
+    private void requireObjectPayload(JsonNode payload) {
+        if (payload == null || payload.isNull() || !payload.isObject()) {
             throw new BusinessException(ErrorCode.VALIDATION_ERROR);
         }
     }

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

@@ -12,6 +12,11 @@ public interface ExamSprintReportApplicationService {
 
     ReportDownloadContent downloadReport(String reportId);
 
+    ReportHtmlPreviewContent previewReportHtml(String reportId);
+
     record ReportDownloadContent(String fileName, byte[] bytes, String contentType) {
     }
+
+    record ReportHtmlPreviewContent(String html, String contentType) {
+    }
 }

+ 173 - 14
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java

@@ -1,10 +1,12 @@
 package cn.yunzhixue.ability.center.examsprint.application.report;
 
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportRequest;
+import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookExamSprintReportPayload;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
 import cn.yunzhixue.ability.center.kernel.BusinessException;
@@ -14,6 +16,9 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
 import jakarta.validation.Validation;
 import jakarta.validation.Validator;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import java.net.URI;
 import java.time.Clock;
@@ -25,6 +30,8 @@ import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -54,38 +61,67 @@ class ExamSprintReportApplicationServiceTest {
     }
 
     @Test
-    void createReportRejectsUnsupportedReportTypeBeforeSaving() {
+    void createReportStoresAchievementTypeAndReturnsReportId() {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
+
+        CreateExamSprintReportRequest request = new CreateExamSprintReportRequest(
+                ExamSprintReportType.ACHIEVEMENT,
+                validAchievementPayload());
+
+        var response = service.createReport(request);
+
+        assertThat(response.reportId()).isNotBlank();
+        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+        assertThat(saved.reportType()).isEqualTo(ExamSprintReportType.ACHIEVEMENT);
+        assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.PENDING);
+        assertThat(saved.payload().path("reportTitle").asText()).isEqualTo("高考英语临考突击学习成果报告");
+    }
+
+    @Test
+    void createReportRejectsInvalidAchievementPayloadBeforeSaving() {
         TestRepository repository = new TestRepository();
         boolean[] dispatched = {false};
         DefaultExamSprintReportApplicationService service = service(
                 repository,
                 reportId -> dispatched[0] = true,
                 new TestStorage());
+        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
+        invalidPayload.remove("reportTitle");
 
         assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(
                 ExamSprintReportType.ACHIEVEMENT,
-                validOutlookPayload())))
+                invalidPayload)))
                 .isInstanceOf(BusinessException.class)
                 .extracting(exception -> ((BusinessException) exception).getErrorCode())
-                .isEqualTo(ErrorCode.REPORT_TYPE_UNSUPPORTED);
+                .isEqualTo(ErrorCode.VALIDATION_ERROR);
 
         assertThat(repository.storage).isEmpty();
         assertThat(dispatched[0]).isFalse();
     }
 
     @Test
-    void createReportRejectsInvalidOutlookPayloadBeforeSaving() {
+    void createReportRejectsNullOrNonObjectPayloadBeforeSaving() {
+        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.OUTLOOK, OBJECT_MAPPER.nullNode());
+        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.ACHIEVEMENT, OBJECT_MAPPER.nullNode());
+        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.OUTLOOK, OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
+        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.ACHIEVEMENT, OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
+    }
+
+    @Test
+    void createReportRejectsMissingAchievementBeforeValueBeforeSaving() {
         TestRepository repository = new TestRepository();
         boolean[] dispatched = {false};
         DefaultExamSprintReportApplicationService service = service(
                 repository,
                 reportId -> dispatched[0] = true,
                 new TestStorage());
-        ObjectNode invalidPayload = validOutlookPayload().deepCopy();
-        ((ObjectNode) invalidPayload.path("reportMetadata")).remove("learnerName");
+        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
+        invalidPayload.withObject("vocabularyComparison").remove("beforeValue");
 
         assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(
-                ExamSprintReportType.OUTLOOK,
+                ExamSprintReportType.ACHIEVEMENT,
                 invalidPayload)))
                 .isInstanceOf(BusinessException.class)
                 .extracting(exception -> ((BusinessException) exception).getErrorCode())
@@ -95,6 +131,17 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(dispatched[0]).isFalse();
     }
 
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("invalidAchievementPayloadJsonTypes")
+    void createReportRejectsAchievementPayloadWithInvalidJsonTypes(
+            String caseName,
+            Consumer<ObjectNode> mutatePayload) {
+        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
+        mutatePayload.accept(invalidPayload);
+
+        assertCreateReportRejectsInvalidPayload(ExamSprintReportType.ACHIEVEMENT, invalidPayload);
+    }
+
     @Test
     void createReportReturnsFailedStatusWhenDispatchFails() {
         TestRepository repository = new TestRepository();
@@ -136,19 +183,19 @@ class ExamSprintReportApplicationServiceTest {
     }
 
     @Test
-    void getReportReturnsApplicationDownloadUrlForSuccessfulReport() {
+    void getReportReturnsDownloadAndPreviewUrlsForSuccessfulReport() {
         TestRepository repository = new TestRepository();
         TestStorage storage = new TestStorage();
         ExamSprintReport report = ExamSprintReport.pending(
                         "report-success",
-                        ExamSprintReportType.OUTLOOK,
-                        OBJECT_MAPPER.createObjectNode(),
+                        ExamSprintReportType.ACHIEVEMENT,
+                        validAchievementPayload(),
                         FIXED_CLOCK.instant().minusSeconds(120),
                         FIXED_CLOCK.instant().plusSeconds(3600))
                 .success(
                         FIXED_CLOCK.instant().minusSeconds(30),
-                        "exam-sprint-reports/outlook/report-success/exam-sprint-outlook-report-report-success.pdf",
-                        "exam-sprint-outlook-report-report-success.pdf");
+                        "exam-sprint-reports/achievement/report-success/exam-sprint-achievement-report-report-success.pdf",
+                        "exam-sprint-achievement-report-report-success.pdf");
         repository.save(report);
         DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
 
@@ -156,8 +203,47 @@ class ExamSprintReportApplicationServiceTest {
 
         assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
         assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/report-success/download");
+        assertThat(response.previewHtmlUrl()).isEqualTo("/api/exam-sprint/reports/report-success/preview/html");
         assertThat(storage.generatedKeys)
-                .containsExactly("exam-sprint-reports/outlook/report-success/exam-sprint-outlook-report-report-success.pdf");
+                .containsExactly("exam-sprint-reports/achievement/report-success/exam-sprint-achievement-report-report-success.pdf");
+    }
+
+    @Test
+    void previewReportHtmlRendersSavedPayloadForSuccessfulReport() {
+        TestRepository repository = new TestRepository();
+        repository.save(ExamSprintReport.pending(
+                        "report-preview",
+                        ExamSprintReportType.ACHIEVEMENT,
+                        validAchievementPayload(),
+                        FIXED_CLOCK.instant().minusSeconds(120),
+                        FIXED_CLOCK.instant().plusSeconds(3600))
+                .success(
+                        FIXED_CLOCK.instant().minusSeconds(30),
+                        "exam-sprint-reports/achievement/report-preview/exam-sprint-achievement-report-report-preview.pdf",
+                        "exam-sprint-achievement-report-report-preview.pdf"));
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+
+        var content = service.previewReportHtml("report-preview");
+
+        assertThat(content.contentType()).isEqualTo("text/html;charset=UTF-8");
+        assertThat(content.html()).contains("preview:高考英语临考突击学习成果报告:2026-01-01T23:59:30Z");
+    }
+
+    @Test
+    void previewReportHtmlRejectsPendingReport() {
+        TestRepository repository = new TestRepository();
+        repository.save(ExamSprintReport.pending(
+                "report-pending",
+                ExamSprintReportType.ACHIEVEMENT,
+                validAchievementPayload(),
+                FIXED_CLOCK.instant().minusSeconds(120),
+                FIXED_CLOCK.instant().plusSeconds(3600)));
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+
+        assertThatThrownBy(() -> service.previewReportHtml("report-pending"))
+                .isInstanceOf(BusinessException.class)
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
     }
 
     @Test
@@ -248,7 +334,8 @@ class ExamSprintReportApplicationServiceTest {
                 properties(),
                 FIXED_CLOCK,
                 OBJECT_MAPPER,
-                VALIDATOR);
+                VALIDATOR,
+                List.of(new PreviewTestRenderer()));
     }
 
     private ExamSprintReportProperties properties() {
@@ -314,6 +401,65 @@ class ExamSprintReportApplicationServiceTest {
                         19)));
     }
 
+    private ObjectNode validAchievementPayload() {
+        return (ObjectNode) OBJECT_MAPPER.valueToTree(new AchievementExamSprintReportPayload(
+                "高考英语临考突击学习成果报告",
+                "2024真题 · 两周专项训练 · 真实提分效果",
+                "恭喜完成两周考前突击专项训练",
+                "基于2024英语真题试卷 · 真实学习效果分析",
+                new AchievementExamSprintReportPayload.SummaryMetrics(
+                        "+19",
+                        "+4",
+                        "1.93%",
+                        "0.48倍"),
+                new AchievementExamSprintReportPayload.Comparison(
+                        2328.0,
+                        2347.0,
+                        "2328 词",
+                        "2347 词",
+                        "+19 词"),
+                new AchievementExamSprintReportPayload.Comparison(
+                        650.0,
+                        654.0,
+                        "650 个",
+                        "654 个",
+                        "+4 个"),
+                new AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus(
+                        "1.93%",
+                        "0.48倍",
+                        "207 个",
+                        "203 个",
+                        "4 个",
+                        List.of("number", "bear", "popular", "importance"))));
+    }
+
+    private static Stream<Arguments> invalidAchievementPayloadJsonTypes() {
+        return Stream.of(
+                Arguments.of("reportTitle boolean is rejected", (Consumer<ObjectNode>) payload -> payload.put("reportTitle", true)),
+                Arguments.of("reportTitle number is rejected", (Consumer<ObjectNode>) payload -> payload.put("reportTitle", 123)),
+                Arguments.of("vocabularyComparison.beforeValue string is rejected",
+                        (Consumer<ObjectNode>) payload -> payload.withObject("vocabularyComparison").put("beforeValue", "2328")),
+                Arguments.of("examUnknownWordsHitStatus.hitWords number element is rejected",
+                        (Consumer<ObjectNode>) payload -> payload.withObject("examUnknownWordsHitStatus").putArray("hitWords").add(123)));
+    }
+
+    private void assertCreateReportRejectsInvalidPayload(ExamSprintReportType reportType, com.fasterxml.jackson.databind.JsonNode payload) {
+        TestRepository repository = new TestRepository();
+        boolean[] dispatched = {false};
+        DefaultExamSprintReportApplicationService service = service(
+                repository,
+                reportId -> dispatched[0] = true,
+                new TestStorage());
+
+        assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(reportType, payload)))
+                .isInstanceOf(BusinessException.class)
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.VALIDATION_ERROR);
+
+        assertThat(repository.storage).isEmpty();
+        assertThat(dispatched[0]).isFalse();
+    }
+
     private static class TestRepository implements ExamSprintReportRepository {
         private final ConcurrentMap<String, ExamSprintReport> storage = new ConcurrentHashMap<>();
 
@@ -376,4 +522,17 @@ class ExamSprintReportApplicationServiceTest {
             deleteFailures.add(storageObjectKey);
         }
     }
+
+    private static class PreviewTestRenderer implements ExamSprintReportRenderer {
+
+        @Override
+        public boolean supports(ExamSprintReportType reportType) {
+            return reportType == ExamSprintReportType.OUTLOOK || reportType == ExamSprintReportType.ACHIEVEMENT;
+        }
+
+        @Override
+        public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
+            return "<html><body>preview:" + payload.path("reportTitle").asText() + ":" + generatedAt + "</body></html>";
+        }
+    }
 }

+ 39 - 2
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java

@@ -52,6 +52,32 @@ class ExamSprintReportGenerationWorkerTest {
         assertThat(report.fileName()).isEqualTo("exam-sprint-outlook-report-report-success.pdf");
     }
 
+    @Test
+    void processCreatesAchievementFileNameAndStorageKeyAfterUpload() {
+        TestRepository repository = new TestRepository();
+        repository.save(ExamSprintReport.pending(
+                "report-achievement",
+                ExamSprintReportType.ACHIEVEMENT,
+                OBJECT_MAPPER.createObjectNode(),
+                FIXED_CLOCK.instant(),
+                FIXED_CLOCK.instant().plusSeconds(86400)));
+        TestStorage storage = new TestStorage();
+        ExamSprintReportGenerationWorker worker = new ExamSprintReportGenerationWorker(
+                repository,
+                List.of(new TestRenderer(ExamSprintReportType.ACHIEVEMENT)),
+                html -> html.getBytes(StandardCharsets.UTF_8),
+                storage,
+                FIXED_CLOCK);
+
+        worker.process("report-achievement");
+
+        ExamSprintReport report = repository.findById("report-achievement").orElseThrow();
+        assertThat(report.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
+        assertThat(report.fileName()).isEqualTo("exam-sprint-achievement-report-report-achievement.pdf");
+        assertThat(report.storageObjectKey()).isEqualTo(
+                "exam-sprint-reports/achievement/report-achievement/exam-sprint-achievement-report-report-achievement.pdf");
+    }
+
     @Test
     void processMarksReportFailedWhenGenerationPipelineThrows() {
         TestRepository repository = new TestRepository();
@@ -77,9 +103,19 @@ class ExamSprintReportGenerationWorkerTest {
     }
 
     private static class TestRenderer implements ExamSprintReportRenderer {
+        private final ExamSprintReportType supportedReportType;
+
+        TestRenderer() {
+            this(ExamSprintReportType.OUTLOOK);
+        }
+
+        TestRenderer(ExamSprintReportType supportedReportType) {
+            this.supportedReportType = supportedReportType;
+        }
+
         @Override
         public boolean supports(ExamSprintReportType reportType) {
-            return reportType == ExamSprintReportType.OUTLOOK;
+            return reportType == supportedReportType;
         }
 
         @Override
@@ -128,7 +164,8 @@ class ExamSprintReportGenerationWorkerTest {
                 String fileName,
                 byte[] pdfBytes,
                 Instant expiresAt) {
-            return new StoredExamSprintReportFile("exam-sprint-reports/outlook/" + reportId + "/" + fileName, fileName);
+            String typeSegment = reportType.name().toLowerCase();
+            return new StoredExamSprintReportFile("exam-sprint-reports/" + typeSegment + "/" + reportId + "/" + fileName, fileName);
         }
 
         @Override

+ 43 - 0
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java

@@ -0,0 +1,43 @@
+package cn.yunzhixue.ability.center.examsprint.contracts.report;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.List;
+
+public record AchievementExamSprintReportPayload(
+        @NotBlank String reportTitle,
+        @NotBlank String reportSubtitle,
+        @NotBlank String completionTitle,
+        @NotBlank String completionSubtitle,
+        @NotNull @Valid SummaryMetrics summaryMetrics,
+        @NotNull @Valid Comparison vocabularyComparison,
+        @NotNull @Valid Comparison paperKnownWordsComparison,
+        @NotNull @Valid ExamUnknownWordsHitStatus examUnknownWordsHitStatus) {
+
+    public record SummaryMetrics(
+            @NotBlank String vocabularyGrowthText,
+            @NotBlank String paperKnownWordsGrowthText,
+            @NotBlank String unknownWordHitRateText,
+            @NotBlank String learningEfficiencyText) {
+    }
+
+    public record Comparison(
+            @NotNull @DecimalMin("0.0") Double beforeValue,
+            @NotNull @DecimalMin("0.0") Double afterValue,
+            @NotBlank String beforeText,
+            @NotBlank String afterText,
+            @NotBlank String growthText) {
+    }
+
+    public record ExamUnknownWordsHitStatus(
+            @NotBlank String unknownWordHitRateText,
+            @NotBlank String learningEfficiencyText,
+            @NotBlank String unknownWordsBeforeText,
+            @NotBlank String unknownWordsAfterText,
+            @NotBlank String reducedUnknownWordsText,
+            @NotNull List<@NotBlank String> hitWords) {
+    }
+}

+ 1 - 0
abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportDetailResponse.java

@@ -10,5 +10,6 @@ public record ExamSprintReportDetailResponse(
         Instant updatedAt,
         Instant expiresAt,
         String downloadUrl,
+        String previewHtmlUrl,
         String failureReason) {
 }

+ 88 - 0
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/AchievementExamSprintReportSvgChartBuilder.java

@@ -0,0 +1,88 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.achievement;
+
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+public class AchievementExamSprintReportSvgChartBuilder {
+
+    private static final Pattern CSS_CLASS_PATTERN = Pattern.compile("^[A-Za-z0-9_ -]+$");
+    private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$");
+    private static final String DEFAULT_FILL_COLOR = "#ff7d00";
+
+    public String comparisonBarChart(String cssClass,
+                                     String ariaLabel,
+                                     String beforeLabel,
+                                     double beforeValue,
+                                     String beforeText,
+                                     String afterLabel,
+                                     double afterValue,
+                                     String afterText,
+                                     String fillColor) {
+        double maxValue = Math.max(Math.max(beforeValue, afterValue), 1d);
+        int beforeHeight = barHeight(beforeValue, maxValue);
+        int afterHeight = barHeight(afterValue, maxValue);
+
+        return new StringBuilder()
+                .append("<svg class='achievement-bar-chart ").append(safeCssClass(cssClass))
+                .append("' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='")
+                .append(escape(ariaLabel)).append("'>")
+                .append("<line class='chart-axis' x1='44' y1='180' x2='318' y2='180' stroke='#d5dde8' stroke-width='1'/>")
+                .append("<rect class='chart-bar chart-bar-before' x='92' y='").append(180 - beforeHeight)
+                .append("' width='64' height='").append(beforeHeight)
+                .append("' rx='10' ry='10' fill='#9fb3c8'/>")
+                .append("<rect class='chart-bar chart-bar-after' x='204' y='").append(180 - afterHeight)
+                .append("' width='64' height='").append(afterHeight)
+                .append("' rx='10' ry='10' fill='").append(safeColor(fillColor)).append("'/>")
+                .append("<text class='chart-value' x='124' y='").append(Math.max(18, 170 - beforeHeight))
+                .append("' text-anchor='middle'>").append(escape(beforeText)).append("</text>")
+                .append("<text class='chart-value' x='236' y='").append(Math.max(18, 170 - afterHeight))
+                .append("' text-anchor='middle'>").append(escape(afterText)).append("</text>")
+                .append("<text class='chart-label' x='124' y='202' text-anchor='middle'>")
+                .append(escape(beforeLabel)).append("</text>")
+                .append("<text class='chart-label' x='236' y='202' text-anchor='middle'>")
+                .append(escape(afterLabel)).append("</text>")
+                .append("</svg>")
+                .toString();
+    }
+
+    private int barHeight(double value, double maxValue) {
+        if (value <= 0d || maxValue <= 0d) {
+            return 0;
+        }
+        double ratio = Math.max(0d, Math.min(1d, value / maxValue));
+        return Math.max(18, (int) Math.round(ratio * 130d));
+    }
+
+    private String safeCssClass(String value) {
+        if (value == null) {
+            return "";
+        }
+        String trimmed = value.trim();
+        if (CSS_CLASS_PATTERN.matcher(trimmed).matches()) {
+            return trimmed;
+        }
+        return "";
+    }
+
+    private String safeColor(String value) {
+        if (value == null) {
+            return DEFAULT_FILL_COLOR;
+        }
+        String trimmed = value.trim().toLowerCase(Locale.ROOT);
+        if (HEX_COLOR_PATTERN.matcher(trimmed).matches()) {
+            return trimmed;
+        }
+        return DEFAULT_FILL_COLOR;
+    }
+
+    private String escape(String value) {
+        if (value == null) {
+            return "";
+        }
+        return value.replace("&", "&amp;")
+                .replace("<", "&lt;")
+                .replace(">", "&gt;")
+                .replace("\"", "&quot;")
+                .replace("'", "&#39;");
+    }
+}

+ 176 - 0
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java

@@ -0,0 +1,176 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.achievement;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Component
+public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintReportRenderer {
+
+    private static final String TEMPLATE_RESOURCE = "templates/achievement-exam-sprint-report-template.html";
+    private static final Pattern TEMPLATE_PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{([A-Za-z0-9]+)}}");
+
+    private final ObjectMapper objectMapper;
+    private final AchievementExamSprintReportSvgChartBuilder chartBuilder;
+
+    @Autowired
+    public ClasspathAchievementExamSprintReportRenderer(ObjectMapper objectMapper) {
+        this(objectMapper, new AchievementExamSprintReportSvgChartBuilder());
+    }
+
+    ClasspathAchievementExamSprintReportRenderer(ObjectMapper objectMapper,
+                                                 AchievementExamSprintReportSvgChartBuilder chartBuilder) {
+        this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper");
+        this.chartBuilder = Objects.requireNonNull(chartBuilder, "chartBuilder");
+    }
+
+    @Override
+    public boolean supports(ExamSprintReportType reportType) {
+        return reportType == ExamSprintReportType.ACHIEVEMENT;
+    }
+
+    @Override
+    public String render(JsonNode payload, Instant generatedAt) {
+        try {
+            AchievementExamSprintReportPayload reportPayload = objectMapper.treeToValue(
+                    payload,
+                    AchievementExamSprintReportPayload.class
+            );
+            AchievementExamSprintReportPayload.SummaryMetrics summary = reportPayload.summaryMetrics();
+            AchievementExamSprintReportPayload.Comparison vocabulary = reportPayload.vocabularyComparison();
+            AchievementExamSprintReportPayload.Comparison paperKnownWords = reportPayload.paperKnownWordsComparison();
+            AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus hitStatus = reportPayload.examUnknownWordsHitStatus();
+
+            return renderTemplate(loadTemplate(), placeholders(reportPayload, summary, vocabulary, paperKnownWords, hitStatus));
+        } catch (IOException exception) {
+            throw new UncheckedIOException("Failed to load achievement exam sprint report template", exception);
+        } catch (Exception exception) {
+            throw new IllegalStateException("Failed to render achievement exam sprint report", exception);
+        }
+    }
+
+    private Map<String, String> placeholders(AchievementExamSprintReportPayload reportPayload,
+                                             AchievementExamSprintReportPayload.SummaryMetrics summary,
+                                             AchievementExamSprintReportPayload.Comparison vocabulary,
+                                             AchievementExamSprintReportPayload.Comparison paperKnownWords,
+                                             AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus hitStatus) {
+        Map<String, String> placeholders = new LinkedHashMap<>();
+        placeholders.put("reportTitle", escape(reportPayload.reportTitle()));
+        placeholders.put("reportSubtitle", escape(reportPayload.reportSubtitle()));
+        placeholders.put("completionTitle", escape(reportPayload.completionTitle()));
+        placeholders.put("completionSubtitle", escape(reportPayload.completionSubtitle()));
+        placeholders.put("vocabularyGrowthText", escape(summary.vocabularyGrowthText()));
+        placeholders.put("paperKnownWordsGrowthText", escape(summary.paperKnownWordsGrowthText()));
+        placeholders.put("unknownWordHitRateText", escape(summary.unknownWordHitRateText()));
+        placeholders.put("learningEfficiencyText", escape(summary.learningEfficiencyText()));
+        placeholders.put("vocabularyComparisonChart", renderVocabularyComparisonChart(vocabulary));
+        placeholders.put("paperKnownWordsComparisonChart", renderPaperKnownWordsComparisonChart(paperKnownWords));
+        placeholders.put("vocabularyBeforeText", escape(vocabulary.beforeText()));
+        placeholders.put("vocabularyAfterText", escape(vocabulary.afterText()));
+        placeholders.put("vocabularyGrowthDetailText", escape(vocabulary.growthText()));
+        placeholders.put("paperKnownWordsBeforeText", escape(paperKnownWords.beforeText()));
+        placeholders.put("paperKnownWordsAfterText", escape(paperKnownWords.afterText()));
+        placeholders.put("paperKnownWordsGrowthDetailText", escape(paperKnownWords.growthText()));
+        placeholders.put("unknownWordsBeforeText", escape(hitStatus.unknownWordsBeforeText()));
+        placeholders.put("unknownWordsAfterText", escape(hitStatus.unknownWordsAfterText()));
+        placeholders.put("reducedUnknownWordsText", escape(hitStatus.reducedUnknownWordsText()));
+        placeholders.put("hitStatusUnknownWordHitRateText", escape(hitStatus.unknownWordHitRateText()));
+        placeholders.put("hitStatusLearningEfficiencyText", escape(hitStatus.learningEfficiencyText()));
+        placeholders.put("hitWords", renderHitWords(hitStatus));
+        return placeholders;
+    }
+
+    private String renderTemplate(String template, Map<String, String> placeholders) {
+        Matcher matcher = TEMPLATE_PLACEHOLDER_PATTERN.matcher(template);
+        StringBuffer rendered = new StringBuffer();
+        while (matcher.find()) {
+            String placeholderName = matcher.group(1);
+            String replacement = placeholders.getOrDefault(placeholderName, matcher.group(0));
+            matcher.appendReplacement(rendered, Matcher.quoteReplacement(replacement));
+        }
+        matcher.appendTail(rendered);
+        return rendered.toString();
+    }
+
+    private String loadTemplate() throws IOException {
+        try (InputStream inputStream = new ClassPathResource(TEMPLATE_RESOURCE).getInputStream()) {
+            return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
+        }
+    }
+
+    private String renderVocabularyComparisonChart(AchievementExamSprintReportPayload.Comparison comparison) {
+        return chartBuilder.comparisonBarChart(
+                "vocabulary-growth-chart",
+                "词汇量对比",
+                "训练前",
+                safeNonNegativeFinite(comparison.beforeValue()),
+                comparison.beforeText(),
+                "训练后",
+                safeNonNegativeFinite(comparison.afterValue()),
+                comparison.afterText(),
+                "#ff7d00"
+        );
+    }
+
+    private String renderPaperKnownWordsComparisonChart(AchievementExamSprintReportPayload.Comparison comparison) {
+        return chartBuilder.comparisonBarChart(
+                "paper-known-words-chart",
+                "试卷熟词量对比",
+                "训练前",
+                safeNonNegativeFinite(comparison.beforeValue()),
+                comparison.beforeText(),
+                "训练后",
+                safeNonNegativeFinite(comparison.afterValue()),
+                comparison.afterText(),
+                "#3f8cff"
+        );
+    }
+
+    private String renderHitWords(AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus hitStatus) {
+        if (hitStatus == null || hitStatus.hitWords() == null || hitStatus.hitWords().isEmpty()) {
+            return "<div class=\"word-empty\">暂无命中单词</div>";
+        }
+
+        StringBuilder builder = new StringBuilder();
+        for (String hitWord : hitStatus.hitWords()) {
+            builder.append("<div class=\"word-item\">")
+                    .append(escape(hitWord))
+                    .append("</div>");
+        }
+        return builder.toString();
+    }
+
+    private double safeNonNegativeFinite(Double value) {
+        if (value == null || !Double.isFinite(value) || value < 0d) {
+            return 0d;
+        }
+        return value;
+    }
+
+    private String escape(String value) {
+        if (value == null) {
+            return "";
+        }
+        return value.replace("&", "&amp;")
+                .replace("<", "&lt;")
+                .replace(">", "&gt;")
+                .replace("\"", "&quot;")
+                .replace("'", "&#39;");
+    }
+}

+ 327 - 0
abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html

@@ -0,0 +1,327 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8"/>
+    <title>{{reportTitle}}</title>
+    <style>
+        @page {
+            size: A4;
+            margin: 0;
+        }
+
+        body {
+            margin: 0;
+            padding: 0;
+            background: #f5f7fa;
+            color: #263241;
+            font-family: MiSans, ReportFont, sans-serif;
+            font-size: 14px;
+            line-height: 1.72;
+        }
+
+        .report-container {
+            max-width: 1200px;
+            margin: 0 auto;
+            background: #fff;
+            border: 1px solid #e7edf5;
+            border-radius: 14px;
+            padding: 32px;
+        }
+
+        .report-title {
+            margin: 0 0 8px;
+            color: #233f76;
+            font-family: MiSans, ReportFont, sans-serif;
+            font-size: 28px;
+            font-weight: 700;
+            text-align: center;
+        }
+
+        .report-subtitle {
+            margin: 0 0 24px;
+            color: #68768a;
+            font-family: MiSans, ReportFont, sans-serif;
+            text-align: center;
+        }
+
+        .result-header {
+            margin: 0 0 22px;
+            padding: 22px 24px;
+            background: #fff7ed;
+            border: 1px solid #ffe1c2;
+            border-radius: 14px;
+            text-align: center;
+            page-break-inside: avoid;
+        }
+
+        .completion-title {
+            margin: 0 0 6px;
+            color: #9a4f00;
+            font-family: MiSans, ReportFont, sans-serif;
+            font-size: 22px;
+            font-weight: 700;
+        }
+
+        .completion-subtitle {
+            margin: 0;
+            color: #7b5a2f;
+        }
+
+        .result-grid {
+            width: 100%;
+            table-layout: fixed;
+            border-collapse: separate;
+            border-spacing: 14px 0;
+            margin-bottom: 26px;
+        }
+
+        .result-card {
+            width: 25%;
+            vertical-align: top;
+            background: #f8fbff;
+            border: 1px solid #e2eaf5;
+            border-radius: 12px;
+            padding: 14px;
+            text-align: center;
+            page-break-inside: avoid;
+        }
+
+        .result-value {
+            color: #ff7d00;
+            font-family: MiSans, ReportFont, sans-serif;
+            font-size: 24px;
+            font-weight: 700;
+            line-height: 1.2;
+        }
+
+        .result-label {
+            margin-top: 6px;
+            color: #56657a;
+            font-size: 13px;
+        }
+
+        .section {
+            margin-top: 24px;
+            page-break-inside: avoid;
+        }
+
+        .section-title {
+            margin: 0 0 14px;
+            border-left: 6px solid #ff7d00;
+            padding-left: 12px;
+            color: #233f76;
+            font-family: MiSans, ReportFont, sans-serif;
+            font-size: 20px;
+            font-weight: 700;
+        }
+
+        .comparison-table {
+            width: 100%;
+            table-layout: fixed;
+            border-collapse: collapse;
+        }
+
+        .chart-cell,
+        .detail-cell {
+            width: 50%;
+            vertical-align: middle;
+            background: #fff;
+            border: 1px solid #e7edf5;
+        }
+
+        .chart-cell {
+            border-right: 0;
+            border-radius: 12px 0 0 12px;
+            padding: 16px;
+        }
+
+        .detail-cell {
+            border-radius: 0 12px 12px 0;
+            padding: 18px;
+        }
+
+        .chart-box {
+            height: 210px;
+            background: #f8fbff;
+            border-radius: 10px;
+        }
+
+        .chart-box svg {
+            display: block;
+            width: 100%;
+            height: 100%;
+        }
+
+        .chart-value {
+            fill: #233f76;
+            font-family: MiSans, ReportFont, sans-serif;
+            font-size: 13px;
+            font-weight: 700;
+        }
+
+        .chart-label {
+            fill: #56657a;
+            font-family: MiSans, ReportFont, sans-serif;
+            font-size: 12px;
+        }
+
+        .detail-text {
+            margin: 8px 0;
+            color: #3d4a5d;
+            font-family: MiSans, ReportFont, sans-serif;
+        }
+
+        .highlight {
+            color: #ff7d00;
+            font-weight: 700;
+        }
+
+        .hit-panel {
+            background: #f8fbff;
+            border: 1px solid #e2eaf5;
+            border-radius: 12px;
+            padding: 18px;
+            page-break-inside: avoid;
+        }
+
+        .hit-stats {
+            width: 100%;
+            table-layout: fixed;
+            border-collapse: separate;
+            border-spacing: 12px 0;
+            margin-bottom: 14px;
+        }
+
+        .hit-stat {
+            width: 25%;
+            background: #fff;
+            border: 1px solid #e7edf5;
+            border-radius: 10px;
+            padding: 12px;
+            text-align: center;
+        }
+
+        .hit-stat-label {
+            color: #68768a;
+            font-size: 12px;
+        }
+
+        .hit-stat-value {
+            color: #233f76;
+            font-size: 18px;
+            font-weight: 700;
+        }
+
+        .word-list {
+            margin-top: 10px;
+        }
+
+        .word-item {
+            display: inline-block;
+            margin: 0 8px 8px 0;
+            padding: 5px 12px;
+            border-radius: 999px;
+            background: #fff1e7;
+            color: #9a4f00;
+            font-family: MiSans, ReportFont, sans-serif;
+            font-weight: 600;
+        }
+
+        .word-empty {
+            padding: 14px;
+            border: 1px dashed #c6d2e3;
+            border-radius: 10px;
+            color: #68768a;
+            text-align: center;
+        }
+    </style>
+</head>
+<body>
+<div class="report-container">
+    <h1 class="report-title">{{reportTitle}}</h1>
+    <p class="report-subtitle">{{reportSubtitle}}</p>
+
+    <div class="result-header">
+        <h2 class="completion-title">{{completionTitle}}</h2>
+        <p class="completion-subtitle">{{completionSubtitle}}</p>
+    </div>
+
+    <table class="result-grid" role="presentation">
+        <tr>
+            <td class="result-card">
+                <div class="result-value">{{vocabularyGrowthText}}</div>
+                <div class="result-label">词汇量提升(个)</div>
+            </td>
+            <td class="result-card">
+                <div class="result-value">{{paperKnownWordsGrowthText}}</div>
+                <div class="result-label">试卷熟词提升(个)</div>
+            </td>
+            <td class="result-card">
+                <div class="result-value">{{unknownWordHitRateText}}</div>
+                <div class="result-label">真题生词命中率</div>
+            </td>
+            <td class="result-card">
+                <div class="result-value">{{learningEfficiencyText}}</div>
+                <div class="result-label">学习效率提升</div>
+            </td>
+        </tr>
+    </table>
+
+    <div class="section">
+        <h2 class="section-title">模块一:词汇量对比</h2>
+        <table class="comparison-table" role="presentation">
+            <tr>
+                <td class="chart-cell"><div class="chart-box">{{vocabularyComparisonChart}}</div></td>
+                <td class="detail-cell">
+                    <p class="detail-text">训练前词汇量:<span class="highlight">{{vocabularyBeforeText}}</span></p>
+                    <p class="detail-text">训练后词汇量:<span class="highlight">{{vocabularyAfterText}}</span></p>
+                    <p class="detail-text">本次提升:<span class="highlight">{{vocabularyGrowthDetailText}}</span></p>
+                </td>
+            </tr>
+        </table>
+    </div>
+
+    <div class="section">
+        <h2 class="section-title">模块二:试卷熟词量对比</h2>
+        <table class="comparison-table" role="presentation">
+            <tr>
+                <td class="chart-cell"><div class="chart-box">{{paperKnownWordsComparisonChart}}</div></td>
+                <td class="detail-cell">
+                    <p class="detail-text">训练前熟词量:<span class="highlight">{{paperKnownWordsBeforeText}}</span></p>
+                    <p class="detail-text">训练后熟词量:<span class="highlight">{{paperKnownWordsAfterText}}</span></p>
+                    <p class="detail-text">本次提升:<span class="highlight">{{paperKnownWordsGrowthDetailText}}</span></p>
+                </td>
+            </tr>
+        </table>
+    </div>
+
+    <div class="section">
+        <h2 class="section-title">模块三:实考生词命中状况</h2>
+        <div class="hit-panel">
+            <table class="hit-stats" role="presentation">
+                <tr>
+                    <td class="hit-stat">
+                        <div class="hit-stat-value">{{hitStatusUnknownWordHitRateText}}</div>
+                        <div class="hit-stat-label">真题生词命中率</div>
+                    </td>
+                    <td class="hit-stat">
+                        <div class="hit-stat-value">{{hitStatusLearningEfficiencyText}}</div>
+                        <div class="hit-stat-label">学习效率提升</div>
+                    </td>
+                    <td class="hit-stat">
+                        <div class="hit-stat-value">{{unknownWordsBeforeText}}</div>
+                        <div class="hit-stat-label">训练前生词</div>
+                    </td>
+                    <td class="hit-stat">
+                        <div class="hit-stat-value">{{unknownWordsAfterText}}</div>
+                        <div class="hit-stat-label">训练后生词</div>
+                    </td>
+                </tr>
+            </table>
+            <p class="detail-text">成功减少生词:<span class="highlight">{{reducedUnknownWordsText}}</span></p>
+            <div class="word-list">{{hitWords}}</div>
+        </div>
+    </div>
+</div>
+</body>
+</html>

+ 70 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java

@@ -1,5 +1,6 @@
 package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
 
+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;
@@ -57,6 +58,36 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
         }
     }
 
+    @Test
+    void generateCreatesPdfSmokeWithExtractableAchievementKeyText() throws Exception {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
+        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
+
+        String html = renderer.render(sampleAchievementPayload(), Instant.parse("2026-04-25T08:00:00Z"));
+        byte[] pdfBytes = pdfGenerator.generate(html);
+
+        assertThat(pdfBytes).isNotEmpty();
+        assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
+
+        Path previewPdfPath = Path.of(System.getProperty("user.dir"), "target", "achievement-report-demo.pdf");
+        Files.createDirectories(previewPdfPath.getParent());
+        Files.write(previewPdfPath, pdfBytes);
+        assertThat(previewPdfPath).exists().isRegularFile();
+
+        try (PDDocument document = PDDocument.load(pdfBytes)) {
+            assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
+            String normalizedText = normalizePdfText(new PDFTextStripper().getText(document));
+            assertThat(normalizedText)
+                    .contains("高考英语临考突击学习成果报告")
+                    .contains("模块一:词汇量对比")
+                    .contains("模块二:试卷熟词量对比")
+                    .contains("模块三:实考生词命中状况")
+                    .contains("词汇量提升(个)")
+                    .contains("真题生词命中率")
+                    .containsAnyOf("number", "bear", "popular", "importance");
+        }
+    }
+
     @Test
     void generateUsesBundledMiSansWhenSystemFontCandidatesAreEmpty() throws Exception {
         OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
@@ -203,4 +234,43 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                 }
                 """);
     }
+
+    private JsonNode sampleAchievementPayload() throws Exception {
+        return OBJECT_MAPPER.readTree("""
+                {
+                  "reportTitle": "高考英语临考突击学习成果报告",
+                  "reportSubtitle": "2024真题 · 两周专项训练 · 真实提分效果",
+                  "completionTitle": "恭喜完成两周考前突击专项训练",
+                  "completionSubtitle": "基于2024英语真题试卷 · 真实学习效果分析",
+                  "summaryMetrics": {
+                    "vocabularyGrowthText": "+19",
+                    "paperKnownWordsGrowthText": "+4",
+                    "unknownWordHitRateText": "1.93%",
+                    "learningEfficiencyText": "0.48倍"
+                  },
+                  "vocabularyComparison": {
+                    "beforeValue": 2328,
+                    "afterValue": 2347,
+                    "beforeText": "2328 词",
+                    "afterText": "2347 词",
+                    "growthText": "+19 词"
+                  },
+                  "paperKnownWordsComparison": {
+                    "beforeValue": 650,
+                    "afterValue": 654,
+                    "beforeText": "650 个",
+                    "afterText": "654 个",
+                    "growthText": "+4 个"
+                  },
+                  "examUnknownWordsHitStatus": {
+                    "unknownWordHitRateText": "1.93%",
+                    "learningEfficiencyText": "0.48倍",
+                    "unknownWordsBeforeText": "207 个",
+                    "unknownWordsAfterText": "203 个",
+                    "reducedUnknownWordsText": "4 个",
+                    "hitWords": ["number", "bear", "popular", "importance"]
+                  }
+                }
+                """);
+    }
 }

+ 172 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java

@@ -0,0 +1,172 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.achievement;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ClasspathAchievementExamSprintReportRendererTest {
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    @Test
+    void renderBuildsAchievementHtmlAlignedWithDesignDraft() throws Exception {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
+
+        String html = renderer.render(samplePayload(), Instant.parse("2026-04-25T08:00:00Z"));
+
+        assertThat(html)
+                .contains("高考英语临考突击学习成果报告")
+                .contains("2024真题 · 两周专项训练 · 真实提分效果")
+                .contains("恭喜完成两周考前突击专项训练")
+                .contains("class=\"report-container\"")
+                .contains("class=\"result-header\"")
+                .contains("class=\"result-grid\"")
+                .contains("词汇量提升(个)")
+                .contains("试卷熟词提升(个)")
+                .contains("真题生词命中率")
+                .contains("学习效率提升")
+                .contains("模块一:词汇量对比")
+                .contains("模块二:试卷熟词量对比")
+                .contains("模块三:实考生词命中状况")
+                .contains("class='achievement-bar-chart vocabulary-growth-chart'")
+                .contains("class='achievement-bar-chart paper-known-words-chart'")
+                .contains("训练前词汇量:<span class=\"highlight\">2328 词</span>")
+                .contains("训练后词汇量:<span class=\"highlight\">2347 词</span>")
+                .contains("本次提升:<span class=\"highlight\">+19 词</span>")
+                .contains("训练前熟词量:<span class=\"highlight\">650 个</span>")
+                .contains("训练后熟词量:<span class=\"highlight\">654 个</span>")
+                .contains("成功减少生词:<span class=\"highlight\">4 个</span>")
+                .contains("class=\"word-list\"")
+                .contains("class=\"word-item\">number</div>")
+                .doesNotContain("cdn.jsdelivr.net")
+                .doesNotContain("echarts")
+                .doesNotContain("<script");
+    }
+
+    @Test
+    void supportsOnlyAchievementReportType() {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
+
+        assertThat(renderer.supports(ExamSprintReportType.ACHIEVEMENT)).isTrue();
+        assertThat(renderer.supports(ExamSprintReportType.OUTLOOK)).isFalse();
+    }
+
+    @Test
+    void renderEscapesPayloadTextAndHitWords() throws Exception {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
+        ObjectNode payload = samplePayload().deepCopy();
+        payload.put("reportTitle", "成果<script>alert(1)</script>");
+        payload.with("examUnknownWordsHitStatus").withArray("hitWords").add("bear<script>");
+
+        String html = renderer.render(payload, Instant.parse("2026-04-25T08:00:00Z"));
+
+        assertThat(html)
+                .contains("成果&lt;script&gt;alert(1)&lt;/script&gt;")
+                .contains("bear&lt;script&gt;")
+                .doesNotContain("<script>alert(1)</script>")
+                .doesNotContain("bear<script>");
+    }
+
+    @Test
+    void renderShowsEmptyStateWhenHitWordsAreEmpty() throws Exception {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
+        ObjectNode payload = samplePayload().deepCopy();
+        payload.with("examUnknownWordsHitStatus").putArray("hitWords");
+
+        String html = renderer.render(payload, Instant.parse("2026-04-25T08:00:00Z"));
+
+        assertThat(html)
+                .contains("class=\"word-empty\"")
+                .contains("暂无命中单词")
+                .doesNotContain("class=\"word-item\"");
+    }
+
+    @Test
+    void renderShowsEmptyStateWhenHitWordsAreNull() throws Exception {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
+        ObjectNode payload = samplePayload().deepCopy();
+        payload.with("examUnknownWordsHitStatus").putNull("hitWords");
+
+        String html = renderer.render(payload, Instant.parse("2026-04-25T08:00:00Z"));
+
+        assertThat(html)
+                .contains("class=\"word-empty\"")
+                .contains("暂无命中单词")
+                .doesNotContain("class=\"word-item\"");
+    }
+
+    @Test
+    void renderUsesSafeChartValuesWhenComparisonNumbersAreMissingOrNonFinite() throws Exception {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
+        ObjectNode payload = samplePayload().deepCopy();
+        payload.with("vocabularyComparison").putNull("beforeValue");
+        payload.with("vocabularyComparison").put("afterValue", Double.NaN);
+
+        String html = renderer.render(payload, Instant.parse("2026-04-25T08:00:00Z"));
+
+        assertThat(html)
+                .contains("class='achievement-bar-chart vocabulary-growth-chart'")
+                .doesNotContain("NaN")
+                .doesNotContain("Infinity");
+    }
+
+    @Test
+    void renderDoesNotExpandPlaceholdersInjectedByPayloadText() throws Exception {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
+        ObjectNode payload = samplePayload().deepCopy();
+        payload.put("reportTitle", "{{hitWords}}");
+        payload.with("vocabularyComparison").put("beforeText", "{{hitWords}}");
+
+        String html = renderer.render(payload, Instant.parse("2026-04-25T08:00:00Z"));
+
+        assertThat(html)
+                .contains("<h1 class=\"report-title\">{{hitWords}}</h1>")
+                .contains(">{{hitWords}}</text>")
+                .contains("class=\"word-item\">number</div>");
+    }
+
+    private JsonNode samplePayload() throws Exception {
+        return OBJECT_MAPPER.readTree("""
+                {
+                  "reportTitle": "高考英语临考突击学习成果报告",
+                  "reportSubtitle": "2024真题 · 两周专项训练 · 真实提分效果",
+                  "completionTitle": "恭喜完成两周考前突击专项训练",
+                  "completionSubtitle": "基于2024英语真题试卷 · 真实学习效果分析",
+                  "summaryMetrics": {
+                    "vocabularyGrowthText": "+19",
+                    "paperKnownWordsGrowthText": "+4",
+                    "unknownWordHitRateText": "1.93%",
+                    "learningEfficiencyText": "0.48倍"
+                  },
+                  "vocabularyComparison": {
+                    "beforeValue": 2328,
+                    "afterValue": 2347,
+                    "beforeText": "2328 词",
+                    "afterText": "2347 词",
+                    "growthText": "+19 词"
+                  },
+                  "paperKnownWordsComparison": {
+                    "beforeValue": 650,
+                    "afterValue": 654,
+                    "beforeText": "650 个",
+                    "afterText": "654 个",
+                    "growthText": "+4 个"
+                  },
+                  "examUnknownWordsHitStatus": {
+                    "unknownWordHitRateText": "1.93%",
+                    "learningEfficiencyText": "0.48倍",
+                    "unknownWordsBeforeText": "207 个",
+                    "unknownWordsAfterText": "203 个",
+                    "reducedUnknownWordsText": "4 个",
+                    "hitWords": ["number", "bear", "popular", "importance"]
+                  }
+                }
+                """);
+    }
+}

+ 287 - 0
ability-center-runtime/scripts/achievement-report-demo.sh

@@ -0,0 +1,287 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REQUEST_DIR="${SCRIPT_DIR}/../src/test/resources/requests"
+VALID_REQUEST="${REQUEST_DIR}/exam-sprint-achievement-report-request.json"
+INVALID_REQUEST="${REQUEST_DIR}/exam-sprint-achievement-report-invalid-request.json"
+BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
+OUTPUT_PATH="${OUTPUT_PATH:-${PWD}/achievement-report-demo.pdf}"
+HTML_OUTPUT_PATH="${HTML_OUTPUT_PATH:-${PWD}/achievement-report-preview.html}"
+MAX_ATTEMPTS="${MAX_ATTEMPTS:-30}"
+POLL_INTERVAL_SECONDS="${POLL_INTERVAL_SECONDS:-1}"
+JSON_PARSER=""
+TMP_FILES=()
+
+usage() {
+  cat <<EOF
+Usage:
+  $(basename "$0") success
+  $(basename "$0") invalid
+  $(basename "$0") --help
+
+Modes:
+  success   Submit the valid sample request, poll report status, then download the PDF and preview HTML.
+  invalid   Submit the invalid sample request and print the validation error response.
+
+Environment variables:
+  BASE_URL               API base URL. Default: http://127.0.0.1:8080
+  OUTPUT_PATH            PDF output path for success mode.
+                         Default: current working directory/achievement-report-demo.pdf
+  HTML_OUTPUT_PATH       HTML preview output path for success mode.
+                         Default: current working directory/achievement-report-preview.html
+  MAX_ATTEMPTS           Polling attempts for success mode. Default: 30
+  POLL_INTERVAL_SECONDS  Seconds between polling attempts. Default: 1
+
+Examples:
+  $(basename "$0") success
+  BASE_URL=http://127.0.0.1:8081 $(basename "$0") invalid
+EOF
+}
+
+cleanup() {
+  if [ "${#TMP_FILES[@]}" -gt 0 ]; then
+    rm -f "${TMP_FILES[@]}"
+  fi
+}
+
+trap cleanup EXIT
+
+die() {
+  printf 'Error: %s\n' "$1" >&2
+  exit 1
+}
+
+make_temp_file() {
+  local file
+  file="$(mktemp)"
+  TMP_FILES+=("$file")
+  printf '%s\n' "$file"
+}
+
+require_command() {
+  command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
+}
+
+select_json_parser() {
+  if command -v jq >/dev/null 2>&1; then
+    JSON_PARSER="jq"
+  elif command -v python3 >/dev/null 2>&1; then
+    JSON_PARSER="python3"
+  else
+    die "JSON parsing requires jq or python3, but neither was found in PATH."
+  fi
+}
+
+json_get() {
+  local file="$1"
+  local path="$2"
+
+  if [ "$JSON_PARSER" = "jq" ]; then
+    jq -er ".$path" "$file"
+  else
+    python3 - "$file" "$path" <<'PY'
+import json
+import sys
+from pathlib import Path
+
+file_path = Path(sys.argv[1])
+path = [part for part in sys.argv[2].split('.') if part]
+value = json.loads(file_path.read_text(encoding='utf-8'))
+
+for part in path:
+    if isinstance(value, dict) and part in value:
+        value = value[part]
+    else:
+        sys.exit(1)
+
+if value is None:
+    sys.exit(1)
+
+if isinstance(value, (dict, list)):
+    print(json.dumps(value, ensure_ascii=False))
+else:
+    print(value)
+PY
+  fi
+}
+
+pretty_print_json() {
+  local file="$1"
+
+  if [ "$JSON_PARSER" = "jq" ]; then
+    jq . "$file"
+  else
+    python3 - "$file" <<'PY'
+import json
+import sys
+from pathlib import Path
+
+file_path = Path(sys.argv[1])
+data = json.loads(file_path.read_text(encoding='utf-8'))
+print(json.dumps(data, ensure_ascii=False, indent=2))
+PY
+  fi
+}
+
+normalize_url() {
+  local url="$1"
+
+  if [[ "$url" =~ ^https?:// ]]; then
+    printf '%s\n' "$url"
+  elif [[ "$url" == /* ]]; then
+    printf '%s%s\n' "${BASE_URL%/}" "$url"
+  else
+    printf '%s/%s\n' "${BASE_URL%/}" "$url"
+  fi
+}
+
+post_json() {
+  local url="$1"
+  local payload="$2"
+  local output="$3"
+
+  curl -sS -o "$output" -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data "@$payload" "$url"
+}
+
+get_json() {
+  local url="$1"
+  local output="$2"
+
+  curl -sS -o "$output" -w '%{http_code}' "$url"
+}
+
+download_file() {
+  local url="$1"
+  local output="$2"
+
+  curl -sS -L -o "$output" -w '%{http_code}' "$url"
+}
+
+ensure_request_files() {
+  [ -f "$VALID_REQUEST" ] || die "Missing request fixture: $VALID_REQUEST"
+  [ -f "$INVALID_REQUEST" ] || die "Missing request fixture: $INVALID_REQUEST"
+}
+
+run_success_mode() {
+  local submit_url submit_body submit_status report_id query_url query_body query_status current_status
+  local download_url download_target download_status preview_html_url preview_html_target preview_html_status attempt
+
+  submit_url="${BASE_URL%/}/api/exam-sprint/reports"
+  submit_body="$(make_temp_file)"
+  submit_status="$(post_json "$submit_url" "$VALID_REQUEST" "$submit_body")" || die "Failed to submit request to ${submit_url}"
+
+  if [ "$submit_status" != "202" ]; then
+    printf 'Submit request failed with HTTP %s\n' "$submit_status" >&2
+    pretty_print_json "$submit_body" >&2
+    exit 1
+  fi
+
+  report_id="$(json_get "$submit_body" 'data.reportId')" || die "Accepted response did not contain data.reportId"
+  printf 'Report created: %s\n' "$report_id"
+
+  query_url="${BASE_URL%/}/api/exam-sprint/reports/${report_id}"
+  query_body="$(make_temp_file)"
+
+  for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
+    query_status="$(get_json "$query_url" "$query_body")" || die "Failed to query report status from ${query_url}"
+    if [ "$query_status" != "200" ]; then
+      printf 'Query report failed with HTTP %s\n' "$query_status" >&2
+      pretty_print_json "$query_body" >&2
+      exit 1
+    fi
+
+    current_status="$(json_get "$query_body" 'data.generationStatus')" || die "Report response did not contain data.generationStatus"
+    printf 'Poll %d/%s: %s\n' "$attempt" "$MAX_ATTEMPTS" "$current_status"
+
+    case "$current_status" in
+      SUCCESS)
+        download_url="$(json_get "$query_body" 'data.downloadUrl')" || die "Successful report did not contain data.downloadUrl"
+        download_url="$(normalize_url "$download_url")"
+        download_target="$(make_temp_file)"
+        download_status="$(download_file "$download_url" "$download_target")" || die "Failed to download PDF from ${download_url}"
+        if [ "$download_status" != "200" ]; then
+          printf 'Download failed with HTTP %s\n' "$download_status" >&2
+          pretty_print_json "$download_target" >&2
+          exit 1
+        fi
+
+        preview_html_url="$(json_get "$query_body" 'data.previewHtmlUrl')" || die "Successful report did not contain data.previewHtmlUrl"
+        preview_html_url="$(normalize_url "$preview_html_url")"
+        preview_html_target="$(make_temp_file)"
+        preview_html_status="$(download_file "$preview_html_url" "$preview_html_target")" || die "Failed to download preview HTML from ${preview_html_url}"
+        if [ "$preview_html_status" != "200" ]; then
+          printf 'Preview HTML download failed with HTTP %s\n' "$preview_html_status" >&2
+          pretty_print_json "$preview_html_target" >&2
+          exit 1
+        fi
+
+        mv "$download_target" "$OUTPUT_PATH"
+        mv "$preview_html_target" "$HTML_OUTPUT_PATH"
+        printf 'PDF downloaded to: %s\n' "$OUTPUT_PATH"
+        printf 'Preview HTML downloaded to: %s\n' "$HTML_OUTPUT_PATH"
+        return 0
+        ;;
+      FAILED|EXPIRED)
+        printf 'Report ended with status %s\n' "$current_status" >&2
+        pretty_print_json "$query_body" >&2
+        exit 1
+        ;;
+    esac
+
+    sleep "$POLL_INTERVAL_SECONDS"
+  done
+
+  printf 'Report did not reach SUCCESS within %s attempts\n' "$MAX_ATTEMPTS" >&2
+  pretty_print_json "$query_body" >&2
+  exit 1
+}
+
+run_invalid_mode() {
+  local submit_url response_body response_status
+
+  submit_url="${BASE_URL%/}/api/exam-sprint/reports"
+  response_body="$(make_temp_file)"
+  response_status="$(post_json "$submit_url" "$INVALID_REQUEST" "$response_body")" || die "Failed to submit invalid request to ${submit_url}"
+
+  printf 'HTTP %s\n' "$response_status"
+  pretty_print_json "$response_body"
+
+  if [ "$response_status" != "400" ]; then
+    die "Expected HTTP 400 for invalid request, got ${response_status}"
+  fi
+}
+
+main() {
+  local mode="${1:-}"
+
+  if [ "$#" -gt 1 ]; then
+    usage
+    exit 1
+  fi
+
+  case "$mode" in
+    ""|--help|-h|help)
+      usage
+      ;;
+    success)
+      require_command curl
+      select_json_parser
+      ensure_request_files
+      run_success_mode
+      ;;
+    invalid)
+      require_command curl
+      select_json_parser
+      ensure_request_files
+      run_invalid_mode
+      ;;
+    *)
+      usage
+      exit 1
+      ;;
+  esac
+}
+
+main "$@"

+ 9 - 0
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportController.java

@@ -2,6 +2,7 @@ package cn.yunzhixue.ability.center.examsprint.adapter.http;
 
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationService;
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationService.ReportDownloadContent;
+import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationService.ReportHtmlPreviewContent;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportRequest;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
@@ -53,4 +54,12 @@ public class ExamSprintReportController {
                                 .toString())
                 .body(content.bytes());
     }
+
+    @GetMapping(value = "/{reportId}/preview/html", produces = MediaType.TEXT_HTML_VALUE)
+    public ResponseEntity<String> previewReportHtml(@PathVariable String reportId) {
+        ReportHtmlPreviewContent content = applicationService.previewReportHtml(reportId);
+        return ResponseEntity.ok()
+                .contentType(MediaType.parseMediaType(content.contentType()))
+                .body(content.html());
+    }
 }

+ 43 - 12
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java

@@ -61,12 +61,46 @@ class ExamSprintReportControllerTest {
     }
 
     @Test
-    void createAchievementReportReturnsUnsupportedTypeError() throws Exception {
+    void createAchievementReportDownloadAndPreviewHtml() throws Exception {
+        MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/reports")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(validAchievementRequestJson()))
+                .andExpect(status().isAccepted())
+                .andExpect(jsonPath("$.data.reportId").isNotEmpty())
+                .andExpect(jsonPath("$.data.reportType").value("ACHIEVEMENT"))
+                .andExpect(jsonPath("$.data.generationStatus").value("PENDING"))
+                .andReturn();
+
+        String reportId = readJson(createResult).at("/data/reportId").asText();
+        JsonNode queryBody = waitForSuccessfulReport(reportId);
+        URI downloadUri = URI.create(queryBody.at("/data/downloadUrl").asText());
+        URI previewHtmlUri = URI.create(queryBody.at("/data/previewHtmlUrl").asText());
+
+        mockMvc.perform(get(downloadUri))
+                .andExpect(status().isOk())
+                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF));
+
+        MvcResult previewResult = mockMvc.perform(get(previewHtmlUri))
+                .andExpect(status().isOk())
+                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
+                .andReturn();
+
+        assertThat(previewResult.getResponse().getContentAsString(StandardCharsets.UTF_8))
+                .contains("高考英语临考突击学习成果报告")
+                .contains("模块一:词汇量对比")
+                .contains("模块二:试卷熟词量对比")
+                .contains("模块三:实考生词命中状况")
+                .contains("achievement-bar-chart")
+                .doesNotContain("echarts");
+    }
+
+    @Test
+    void createAchievementReportWithInvalidPayloadReturnsValidationError() throws Exception {
         mockMvc.perform(post("/api/exam-sprint/reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(unsupportedReportTypeRequestJson()))
+                        .content(invalidAchievementRequestJson()))
                 .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value("REPORT_TYPE_UNSUPPORTED"));
+                .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
     }
 
     @Test
@@ -134,15 +168,12 @@ class ExamSprintReportControllerTest {
         }
     }
 
-    private String unsupportedReportTypeRequestJson() {
-        try {
-            com.fasterxml.jackson.databind.node.ObjectNode request =
-                    (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.readTree(validRequestJson());
-            request.put("reportType", "ACHIEVEMENT");
-            return objectMapper.writeValueAsString(request);
-        } catch (Exception exception) {
-            throw new IllegalStateException("Failed to build unsupported report type request json", exception);
-        }
+    private String validAchievementRequestJson() {
+        return readRequestFromClasspath("requests/exam-sprint-achievement-report-request.json");
+    }
+
+    private String invalidAchievementRequestJson() {
+        return readRequestFromClasspath("requests/exam-sprint-achievement-report-invalid-request.json");
     }
 
     private String readRequestFromClasspath(String resourcePath) {

+ 14 - 0
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java

@@ -2,6 +2,7 @@ package cn.yunzhixue.ability.center.examsprint.adapter.http;
 
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationService;
 import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationService.ReportDownloadContent;
+import cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationService.ReportHtmlPreviewContent;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
@@ -126,6 +127,7 @@ class ExamSprintReportControllerWebMvcTest {
                 Instant.parse("2026-01-01T00:05:00Z"),
                 Instant.parse("2026-01-02T00:00:00Z"),
                 "/api/exam-sprint/reports/report-001/download",
+                "/api/exam-sprint/reports/report-001/preview/html",
                 null));
         given(applicationService.downloadReport("report-001")).willReturn(new ReportDownloadContent(
                 "exam-sprint-outlook-report-report-001.pdf",
@@ -142,6 +144,18 @@ class ExamSprintReportControllerWebMvcTest {
                 .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF));
     }
 
+    @Test
+    void previewHtmlReturnsTextHtmlForSucceededReport() throws Exception {
+        given(applicationService.previewReportHtml("report-001")).willReturn(new ReportHtmlPreviewContent(
+                "<html><body>高考英语临考突击学习成果报告</body></html>",
+                "text/html;charset=UTF-8"));
+
+        mockMvc.perform(get("/api/exam-sprint/reports/{reportId}/preview/html", "report-001"))
+                .andExpect(status().isOk())
+                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
+                .andExpect(content().string(org.hamcrest.Matchers.containsString("高考英语临考突击学习成果报告")));
+    }
+
     @Test
     void getReportMapsNewKernelBusinessExceptionToBusinessStatusCode() throws Exception {
         given(applicationService.getReport(eq("missing-report")))

+ 36 - 0
ability-center-runtime/src/test/resources/requests/exam-sprint-achievement-report-invalid-request.json

@@ -0,0 +1,36 @@
+{
+  "reportType": "ACHIEVEMENT",
+  "payload": {
+    "reportSubtitle": "2024真题 · 两周专项训练 · 真实提分效果",
+    "completionTitle": "恭喜完成两周考前突击专项训练",
+    "completionSubtitle": "基于2024英语真题试卷 · 真实学习效果分析",
+    "summaryMetrics": {
+      "vocabularyGrowthText": "+19",
+      "paperKnownWordsGrowthText": "+4",
+      "unknownWordHitRateText": "1.93%",
+      "learningEfficiencyText": "0.48倍"
+    },
+    "vocabularyComparison": {
+      "beforeValue": -1,
+      "afterValue": 2347,
+      "beforeText": "2328 词",
+      "afterText": "2347 词",
+      "growthText": "+19 词"
+    },
+    "paperKnownWordsComparison": {
+      "beforeValue": 650,
+      "afterValue": 654,
+      "beforeText": "650 个",
+      "afterText": "654 个",
+      "growthText": "+4 个"
+    },
+    "examUnknownWordsHitStatus": {
+      "unknownWordHitRateText": "1.93%",
+      "learningEfficiencyText": "0.48倍",
+      "unknownWordsBeforeText": "207 个",
+      "unknownWordsAfterText": "203 个",
+      "reducedUnknownWordsText": "4 个",
+      "hitWords": ["number", "bear", "popular", "importance"]
+    }
+  }
+}

+ 37 - 0
ability-center-runtime/src/test/resources/requests/exam-sprint-achievement-report-request.json

@@ -0,0 +1,37 @@
+{
+  "reportType": "ACHIEVEMENT",
+  "payload": {
+    "reportTitle": "高考英语临考突击学习成果报告",
+    "reportSubtitle": "2024真题 · 两周专项训练 · 真实提分效果",
+    "completionTitle": "恭喜完成两周考前突击专项训练",
+    "completionSubtitle": "基于2024英语真题试卷 · 真实学习效果分析",
+    "summaryMetrics": {
+      "vocabularyGrowthText": "+19",
+      "paperKnownWordsGrowthText": "+4",
+      "unknownWordHitRateText": "1.93%",
+      "learningEfficiencyText": "0.48倍"
+    },
+    "vocabularyComparison": {
+      "beforeValue": 2328,
+      "afterValue": 2347,
+      "beforeText": "2328 词",
+      "afterText": "2347 词",
+      "growthText": "+19 词"
+    },
+    "paperKnownWordsComparison": {
+      "beforeValue": 650,
+      "afterValue": 654,
+      "beforeText": "650 个",
+      "afterText": "654 个",
+      "growthText": "+4 个"
+    },
+    "examUnknownWordsHitStatus": {
+      "unknownWordHitRateText": "1.93%",
+      "learningEfficiencyText": "0.48倍",
+      "unknownWordsBeforeText": "207 个",
+      "unknownWordsAfterText": "203 个",
+      "reducedUnknownWordsText": "4 个",
+      "hitWords": ["number", "bear", "popular", "importance"]
+    }
+  }
+}