Explorar o código

refactor: migrate ability service to ability center

金逸霄 hai 3 semanas
achega
77fd8ffd3c
Modificáronse 100 ficheiros con 2870 adicións e 0 borrados
  1. 1 0
      .gitignore
  2. 63 0
      abilities/exam-sprint/application/pom.xml
  3. 20 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AsyncExamSprintReportGenerationDispatcher.java
  4. 169 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
  5. 17 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java
  6. 6 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationDispatcher.java
  7. 75 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorker.java
  8. 131 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.java
  9. 19 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportRetentionScheduler.java
  10. 385 0
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
  11. 148 0
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java
  12. BIN=BIN
      abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/AsyncExamSprintReportGenerationDispatcher.class
  13. BIN=BIN
      abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.class
  14. BIN=BIN
      abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService$ReportDownloadContent.class
  15. BIN=BIN
      abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.class
  16. BIN=BIN
      abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationDispatcher.class
  17. BIN=BIN
      abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorker.class
  18. BIN=BIN
      abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties$Async.class
  19. BIN=BIN
      abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties$Storage.class
  20. BIN=BIN
      abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.class
  21. BIN=BIN
      abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportRetentionScheduler.class
  22. 10 0
      abilities/exam-sprint/application/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
  23. 7 0
      abilities/exam-sprint/application/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
  24. 8 0
      abilities/exam-sprint/application/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst
  25. 2 0
      abilities/exam-sprint/application/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst
  26. 7 0
      abilities/exam-sprint/application/target/surefire-reports/TEST-cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationServiceTest.xml
  27. 7 0
      abilities/exam-sprint/application/target/surefire-reports/TEST-cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportGenerationWorkerTest.xml
  28. 4 0
      abilities/exam-sprint/application/target/surefire-reports/cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationServiceTest.txt
  29. 4 0
      abilities/exam-sprint/application/target/surefire-reports/cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportGenerationWorkerTest.txt
  30. BIN=BIN
      abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest$TestRepository.class
  31. BIN=BIN
      abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest$TestStorage.class
  32. BIN=BIN
      abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.class
  33. BIN=BIN
      abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest$FailingRenderer.class
  34. BIN=BIN
      abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest$TestRenderer.class
  35. BIN=BIN
      abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest$TestRepository.class
  36. BIN=BIN
      abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest$TestStorage.class
  37. BIN=BIN
      abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.class
  38. 40 0
      abilities/exam-sprint/contracts/pom.xml
  39. 9 0
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportRequest.java
  40. 11 0
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportResponse.java
  41. 14 0
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportDetailResponse.java
  42. 9 0
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportGenerationStatus.java
  43. 6 0
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.java
  44. 78 0
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java
  45. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportRequest.class
  46. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportResponse.class
  47. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportDetailResponse.class
  48. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportGenerationStatus.class
  49. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.class
  50. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$DiagnosticCaseStudy.class
  51. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$DimensionScore.class
  52. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$ReadinessOverview.class
  53. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$ReportMetadata.class
  54. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$SprintPlanOption.class
  55. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$SyllabusMasteryProfile.class
  56. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$VocabularyFrequencyBand.class
  57. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$VocabularyProfile.class
  58. BIN=BIN
      abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.class
  59. 14 0
      abilities/exam-sprint/contracts/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
  60. 6 0
      abilities/exam-sprint/contracts/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
  61. 41 0
      abilities/exam-sprint/domain/pom.xml
  62. 126 0
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java
  63. 6 0
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportPdfGenerator.java
  64. 13 0
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java
  65. 14 0
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRepository.java
  66. 30 0
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage.java
  67. BIN=BIN
      abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.class
  68. BIN=BIN
      abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportPdfGenerator.class
  69. BIN=BIN
      abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.class
  70. BIN=BIN
      abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRepository.class
  71. BIN=BIN
      abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage$StoredExamSprintReportContent.class
  72. BIN=BIN
      abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage$StoredExamSprintReportFile.class
  73. BIN=BIN
      abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage.class
  74. 7 0
      abilities/exam-sprint/domain/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
  75. 5 0
      abilities/exam-sprint/domain/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
  76. 74 0
      abilities/exam-sprint/infrastructure/pom.xml
  77. 53 0
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.java
  78. 178 0
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java
  79. 35 0
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/repository/InMemoryExamSprintReportRepository.java
  80. 123 0
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java
  81. 63 0
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java
  82. 209 0
      abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html
  83. 104 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java
  84. 181 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java
  85. 28 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/OutlookExamSprintReportTemplateCompatibilityTest.java
  86. 58 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorageTest.java
  87. BIN=BIN
      abilities/exam-sprint/infrastructure/target/classes/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.class
  88. BIN=BIN
      abilities/exam-sprint/infrastructure/target/classes/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.class
  89. BIN=BIN
      abilities/exam-sprint/infrastructure/target/classes/cn/yunzhixue/ability/center/examsprint/infrastructure/report/repository/InMemoryExamSprintReportRepository.class
  90. BIN=BIN
      abilities/exam-sprint/infrastructure/target/classes/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.class
  91. BIN=BIN
      abilities/exam-sprint/infrastructure/target/classes/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage$StoredFile.class
  92. BIN=BIN
      abilities/exam-sprint/infrastructure/target/classes/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.class
  93. 209 0
      abilities/exam-sprint/infrastructure/target/classes/templates/outlook-exam-sprint-report-template.html
  94. 6 0
      abilities/exam-sprint/infrastructure/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
  95. 5 0
      abilities/exam-sprint/infrastructure/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
  96. 7 0
      abilities/exam-sprint/infrastructure/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst
  97. 4 0
      abilities/exam-sprint/infrastructure/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst
  98. 7 0
      abilities/exam-sprint/infrastructure/target/surefire-reports/TEST-cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf.OpenHtmlToPdfExamSprintReportPdfGeneratorTest.xml
  99. 7 0
      abilities/exam-sprint/infrastructure/target/surefire-reports/TEST-cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook.ClasspathOutlookExamSprintReportRendererTest.xml
  100. 7 0
      abilities/exam-sprint/infrastructure/target/surefire-reports/TEST-cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook.OutlookExamSprintReportTemplateCompatibilityTest.xml

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+.worktrees/

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

@@ -0,0 +1,63 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.yunzhixue</groupId>
+        <artifactId>ability-center</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>../../../pom.xml</relativePath>
+    </parent>
+    <artifactId>exam-sprint-application</artifactId>
+    <name>exam-sprint-application</name>
+    <dependencies>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>ability-center-kernel</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>exam-sprint-contracts</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>exam-sprint-domain</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-context</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-autoconfigure</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

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

@@ -0,0 +1,20 @@
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+
+@Component
+public class AsyncExamSprintReportGenerationDispatcher implements ExamSprintReportGenerationDispatcher {
+
+    private final ExamSprintReportGenerationWorker worker;
+
+    public AsyncExamSprintReportGenerationDispatcher(ExamSprintReportGenerationWorker worker) {
+        this.worker = worker;
+    }
+
+    @Override
+    @Async("examSprintReportExecutor")
+    public void dispatch(String reportId) {
+        worker.process(reportId);
+    }
+}

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

@@ -0,0 +1,169 @@
+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.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.ExamSprintReportStorage;
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Validator;
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.Set;
+import java.util.UUID;
+
+@Service
+public class DefaultExamSprintReportApplicationService implements ExamSprintReportApplicationService {
+
+    private static final String REPORT_GENERATION_DISPATCH_FAILED = "report_generation_dispatch_failed";
+
+    private final ExamSprintReportRepository repository;
+    private final ExamSprintReportGenerationDispatcher dispatcher;
+    private final ExamSprintReportStorage storage;
+    private final ExamSprintReportProperties properties;
+    private final Clock clock;
+    private final ObjectMapper objectMapper;
+    private final Validator validator;
+
+    public DefaultExamSprintReportApplicationService(
+            ExamSprintReportRepository repository,
+            ExamSprintReportGenerationDispatcher dispatcher,
+            ExamSprintReportStorage storage,
+            ExamSprintReportProperties properties,
+            Clock clock,
+            ObjectMapper objectMapper,
+            Validator validator) {
+        this.repository = repository;
+        this.dispatcher = dispatcher;
+        this.storage = storage;
+        this.properties = properties;
+        this.clock = clock;
+        this.objectMapper = objectMapper;
+        this.validator = validator;
+    }
+
+    @Override
+    public CreateExamSprintReportResponse createReport(CreateExamSprintReportRequest request) {
+        validateCreateRequest(request);
+        Instant now = clock.instant();
+        ExamSprintReport report = ExamSprintReport.pending(
+                UUID.randomUUID().toString(),
+                request.reportType(),
+                request.payload(),
+                now,
+                now.plus(properties.getRetention()));
+        repository.save(report);
+        try {
+            dispatcher.dispatch(report.reportId());
+        } catch (RuntimeException exception) {
+            report = repository.save(report.failed(now, dispatchFailureReason(exception)));
+        }
+        return new CreateExamSprintReportResponse(
+                report.reportId(),
+                report.reportType(),
+                report.generationStatus(),
+                report.createdAt(),
+                report.expiresAt());
+    }
+
+    @Override
+    public ExamSprintReportDetailResponse getReport(String reportId) {
+        Instant now = clock.instant();
+        ExamSprintReport report = requireReport(reportId);
+        if (report.isExpiredAt(now) && report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
+            report = repository.save(report.expired(now));
+        }
+
+        String downloadUrl = null;
+        if (report.generationStatus() == ExamSprintReportGenerationStatus.SUCCESS
+                && !report.isExpiredAt(now)
+                && report.storageObjectKey() != null) {
+            downloadUrl = storage.generateDownloadUrl(report.storageObjectKey(), properties.getDownloadExpiry()).toString();
+        }
+
+        return new ExamSprintReportDetailResponse(
+                report.reportId(),
+                report.reportType(),
+                report.generationStatus(),
+                report.createdAt(),
+                report.updatedAt(),
+                report.expiresAt(),
+                downloadUrl,
+                report.failureReason());
+    }
+
+    @Override
+    public ReportDownloadContent downloadReport(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);
+        }
+        return storage.download(report.storageObjectKey())
+                .map(content -> new ReportDownloadContent(content.fileName(), content.bytes(), content.contentType()))
+                .orElseThrow(() -> new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE));
+    }
+
+    public void cleanupExpiredReports() {
+        Instant now = clock.instant();
+        for (ExamSprintReport report : repository.findExpiredAtOrBefore(now)) {
+            try {
+                if (report.storageObjectKey() != null) {
+                    storage.delete(report.storageObjectKey());
+                    repository.save(report.expiredWithStorageCleared(now));
+                } else if (report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
+                    repository.save(report.expired(now));
+                }
+            } catch (RuntimeException ignored) {
+                // keep this expired report retriable on a later cleanup run
+            }
+        }
+    }
+
+    private ExamSprintReport requireReport(String reportId) {
+        return repository.findById(reportId)
+                .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND));
+    }
+
+    private String dispatchFailureReason(RuntimeException exception) {
+        return REPORT_GENERATION_DISPATCH_FAILED;
+    }
+
+    private void validateCreateRequest(CreateExamSprintReportRequest request) {
+        if (request.reportType() != ExamSprintReportType.OUTLOOK) {
+            throw new BusinessException(ErrorCode.REPORT_TYPE_UNSUPPORTED);
+        }
+        validateOutlookPayload(request.payload());
+    }
+
+    private void validateOutlookPayload(JsonNode payload) {
+        OutlookExamSprintReportPayload reportPayload;
+        try {
+            reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
+        } catch (JsonProcessingException | IllegalArgumentException exception) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+
+        Set<ConstraintViolation<OutlookExamSprintReportPayload>> violations = validator.validate(reportPayload);
+        if (!violations.isEmpty()) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+    }
+}

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

@@ -0,0 +1,17 @@
+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.ExamSprintReportDetailResponse;
+
+public interface ExamSprintReportApplicationService {
+
+    CreateExamSprintReportResponse createReport(CreateExamSprintReportRequest request);
+
+    ExamSprintReportDetailResponse getReport(String reportId);
+
+    ReportDownloadContent downloadReport(String reportId);
+
+    record ReportDownloadContent(String fileName, byte[] bytes, String contentType) {
+    }
+}

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

@@ -0,0 +1,6 @@
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+public interface ExamSprintReportGenerationDispatcher {
+
+    void dispatch(String reportId);
+}

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

@@ -0,0 +1,75 @@
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
+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.ExamSprintReportRepository;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.List;
+
+@Service
+public class ExamSprintReportGenerationWorker {
+
+    private final ExamSprintReportRepository repository;
+    private final List<ExamSprintReportRenderer> renderers;
+    private final ExamSprintReportPdfGenerator pdfGenerator;
+    private final ExamSprintReportStorage storage;
+    private final Clock clock;
+
+    public ExamSprintReportGenerationWorker(
+            ExamSprintReportRepository repository,
+            List<ExamSprintReportRenderer> renderers,
+            ExamSprintReportPdfGenerator pdfGenerator,
+            ExamSprintReportStorage storage,
+            Clock clock) {
+        this.repository = repository;
+        this.renderers = renderers;
+        this.pdfGenerator = pdfGenerator;
+        this.storage = storage;
+        this.clock = clock;
+    }
+
+    public void process(String reportId) {
+        Instant startedAt = clock.instant();
+        ExamSprintReport report = repository.findById(reportId).orElse(null);
+        if (report == null
+                || report.generationStatus() != ExamSprintReportGenerationStatus.PENDING
+                || report.isExpiredAt(startedAt)) {
+            return;
+        }
+
+        repository.save(report.processing(startedAt));
+
+        try {
+            String html = rendererFor(report).render(report.payload(), startedAt);
+            byte[] pdfBytes = pdfGenerator.generate(html);
+            String typeSegment = report.reportType().name().toLowerCase();
+            String fileName = "exam-sprint-" + typeSegment + "-report-" + report.reportId() + ".pdf";
+            ExamSprintReportStorage.StoredExamSprintReportFile storedFile =
+                    storage.upload(report.reportId(), report.reportType(), fileName, pdfBytes, report.expiresAt());
+            repository.save(repository.findById(reportId)
+                    .orElseThrow()
+                    .success(clock.instant(), storedFile.storageObjectKey(), storedFile.fileName()));
+        } catch (Exception exception) {
+            repository.findById(reportId)
+                    .ifPresent(current -> repository.save(current.failed(clock.instant(), failureReasonOf(exception))));
+        }
+    }
+
+    private ExamSprintReportRenderer rendererFor(ExamSprintReport report) {
+        return renderers.stream()
+                .filter(renderer -> renderer.supports(report.reportType()))
+                .findFirst()
+                .orElseThrow(() -> new IllegalStateException("No renderer for report type " + report.reportType()));
+    }
+
+    private String failureReasonOf(Exception exception) {
+        String message = exception.getMessage();
+        return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message;
+    }
+}

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

@@ -0,0 +1,131 @@
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import java.time.Duration;
+
+public class ExamSprintReportProperties {
+
+    private Duration retention = Duration.ofDays(1);
+    private Duration downloadExpiry = Duration.ofMinutes(15);
+    private long cleanupIntervalMs = Duration.ofMinutes(10).toMillis();
+    private final Async async = new Async();
+    private final Storage storage = new Storage();
+
+    public Duration getRetention() {
+        return retention;
+    }
+
+    public void setRetention(Duration retention) {
+        this.retention = retention;
+    }
+
+    public Duration getDownloadExpiry() {
+        return downloadExpiry;
+    }
+
+    public void setDownloadExpiry(Duration downloadExpiry) {
+        this.downloadExpiry = downloadExpiry;
+    }
+
+    public long getCleanupIntervalMs() {
+        return cleanupIntervalMs;
+    }
+
+    public void setCleanupIntervalMs(long cleanupIntervalMs) {
+        this.cleanupIntervalMs = cleanupIntervalMs;
+    }
+
+    public Async getAsync() {
+        return async;
+    }
+
+    public Storage getStorage() {
+        return storage;
+    }
+
+    public static class Async {
+        private int corePoolSize = 2;
+        private int maxPoolSize = 4;
+        private int queueCapacity = 100;
+
+        public int getCorePoolSize() {
+            return corePoolSize;
+        }
+
+        public void setCorePoolSize(int corePoolSize) {
+            this.corePoolSize = corePoolSize;
+        }
+
+        public int getMaxPoolSize() {
+            return maxPoolSize;
+        }
+
+        public void setMaxPoolSize(int maxPoolSize) {
+            this.maxPoolSize = maxPoolSize;
+        }
+
+        public int getQueueCapacity() {
+            return queueCapacity;
+        }
+
+        public void setQueueCapacity(int queueCapacity) {
+            this.queueCapacity = queueCapacity;
+        }
+    }
+
+    public static class Storage {
+        private String type = "memory";
+        private String containerName = "exam-sprint-reports";
+        private String connectionString;
+        private String endpoint;
+        private String accountName;
+        private String accountKey;
+
+        public String getType() {
+            return type;
+        }
+
+        public void setType(String type) {
+            this.type = type;
+        }
+
+        public String getContainerName() {
+            return containerName;
+        }
+
+        public void setContainerName(String containerName) {
+            this.containerName = containerName;
+        }
+
+        public String getConnectionString() {
+            return connectionString;
+        }
+
+        public void setConnectionString(String connectionString) {
+            this.connectionString = connectionString;
+        }
+
+        public String getEndpoint() {
+            return endpoint;
+        }
+
+        public void setEndpoint(String endpoint) {
+            this.endpoint = endpoint;
+        }
+
+        public String getAccountName() {
+            return accountName;
+        }
+
+        public void setAccountName(String accountName) {
+            this.accountName = accountName;
+        }
+
+        public String getAccountKey() {
+            return accountKey;
+        }
+
+        public void setAccountKey(String accountKey) {
+            this.accountKey = accountKey;
+        }
+    }
+}

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

@@ -0,0 +1,19 @@
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Component
+public class ExamSprintReportRetentionScheduler {
+
+    private final DefaultExamSprintReportApplicationService applicationService;
+
+    public ExamSprintReportRetentionScheduler(DefaultExamSprintReportApplicationService applicationService) {
+        this.applicationService = applicationService;
+    }
+
+    @Scheduled(fixedDelayString = "${ability.exam-sprint.report.cleanup-interval-ms:600000}")
+    public void cleanupExpiredReports() {
+        applicationService.cleanupExpiredReports();
+    }
+}

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

@@ -0,0 +1,385 @@
+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.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.ExamSprintReportStorage;
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ExamSprintReportApplicationServiceTest {
+
+    private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-01-02T00:00:00Z"), ZoneOffset.UTC);
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();
+
+    @Test
+    void createReportStoresOutlookTypeAndReturnsReportId() {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
+
+        CreateExamSprintReportRequest request = new CreateExamSprintReportRequest(
+                ExamSprintReportType.OUTLOOK,
+                validOutlookPayload());
+
+        var response = service.createReport(request);
+
+        assertThat(response.reportId()).isNotBlank();
+        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+        assertThat(saved.reportType()).isEqualTo(ExamSprintReportType.OUTLOOK);
+        assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.PENDING);
+    }
+
+    @Test
+    void createReportRejectsUnsupportedReportTypeBeforeSaving() {
+        TestRepository repository = new TestRepository();
+        boolean[] dispatched = {false};
+        DefaultExamSprintReportApplicationService service = service(
+                repository,
+                reportId -> dispatched[0] = true,
+                new TestStorage());
+
+        assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(
+                ExamSprintReportType.ACHIEVEMENT,
+                validOutlookPayload())))
+                .isInstanceOf(BusinessException.class)
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.REPORT_TYPE_UNSUPPORTED);
+
+        assertThat(repository.storage).isEmpty();
+        assertThat(dispatched[0]).isFalse();
+    }
+
+    @Test
+    void createReportRejectsInvalidOutlookPayloadBeforeSaving() {
+        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");
+
+        assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(
+                ExamSprintReportType.OUTLOOK,
+                invalidPayload)))
+                .isInstanceOf(BusinessException.class)
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.VALIDATION_ERROR);
+
+        assertThat(repository.storage).isEmpty();
+        assertThat(dispatched[0]).isFalse();
+    }
+
+    @Test
+    void createReportReturnsFailedStatusWhenDispatchFails() {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(
+                repository,
+                reportId -> {
+                    throw new IllegalStateException("task-executor-7 rejected by dispatcher unavailable");
+                },
+                new TestStorage());
+
+        CreateExamSprintReportRequest request = new CreateExamSprintReportRequest(
+                ExamSprintReportType.OUTLOOK,
+                validOutlookPayload());
+
+        var response = service.createReport(request);
+
+        assertThat(response.reportId()).isNotBlank();
+        assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.FAILED);
+        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+        assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.FAILED);
+        assertThat(saved.failureReason()).isEqualTo("report_generation_dispatch_failed");
+        assertThat(saved.failureReason()).doesNotContain("task-executor-7");
+        assertThat(saved.failureReason()).doesNotContain("dispatcher unavailable");
+        assertThat(repository.countByStatus(ExamSprintReportGenerationStatus.PENDING)).isZero();
+    }
+
+    @Test
+    void createReportCopiesPayloadBeforeSaving() {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+        ObjectNode payload = validOutlookPayload().deepCopy();
+
+        var response = service.createReport(new CreateExamSprintReportRequest(ExamSprintReportType.OUTLOOK, payload));
+
+        payload.withObject("reportMetadata").put("learnerName", "王同学");
+
+        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+        assertThat(saved.payload().path("reportMetadata").path("learnerName").asText()).isEqualTo("李同学");
+    }
+
+    @Test
+    void getReportReturnsApplicationDownloadUrlForSuccessfulReport() {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        ExamSprintReport report = ExamSprintReport.pending(
+                        "report-success",
+                        ExamSprintReportType.OUTLOOK,
+                        OBJECT_MAPPER.createObjectNode(),
+                        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");
+        repository.save(report);
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
+
+        var response = service.getReport("report-success");
+
+        assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
+        assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/report-success/download");
+        assertThat(storage.generatedKeys)
+                .containsExactly("exam-sprint-reports/outlook/report-success/exam-sprint-outlook-report-report-success.pdf");
+    }
+
+    @Test
+    void downloadReportRejectsExpiredReportBeforeCleanupRuns() {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        repository.save(ExamSprintReport.pending(
+                "report-expired",
+                ExamSprintReportType.OUTLOOK,
+                OBJECT_MAPPER.createObjectNode(),
+                FIXED_CLOCK.instant().minusSeconds(600),
+                FIXED_CLOCK.instant().minusSeconds(1)).success(
+                FIXED_CLOCK.instant().minusSeconds(300),
+                "exam-sprint-reports/outlook/report-expired/exam-sprint-outlook-report-report-expired.pdf",
+                "exam-sprint-outlook-report-report-expired.pdf"));
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
+
+        assertThatThrownBy(() -> service.downloadReport("report-expired"))
+                .isInstanceOf(BusinessException.class)
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
+    }
+
+    @Test
+    void cleanupExpiredReportsTreatsExpiresAtEqualsNowAsExpired() {
+        TestRepository repository = new TestRepository();
+        repository.save(ExamSprintReport.pending(
+                "report-expired-at-boundary",
+                ExamSprintReportType.OUTLOOK,
+                OBJECT_MAPPER.createObjectNode(),
+                FIXED_CLOCK.instant().minusSeconds(600),
+                FIXED_CLOCK.instant()));
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+
+        service.cleanupExpiredReports();
+
+        ExamSprintReport saved = repository.findById("report-expired-at-boundary").orElseThrow();
+        assertThat(saved.isExpiredAt(FIXED_CLOCK.instant())).isTrue();
+        assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.EXPIRED);
+    }
+
+    @Test
+    void cleanupExpiredReportsContinuesWhenDeletingOneReportFails() {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        repository.save(ExamSprintReport.pending(
+                "report-delete-fails",
+                ExamSprintReportType.OUTLOOK,
+                OBJECT_MAPPER.createObjectNode(),
+                FIXED_CLOCK.instant().minusSeconds(600),
+                FIXED_CLOCK.instant().minusSeconds(1)).success(
+                FIXED_CLOCK.instant().minusSeconds(300),
+                "exam-sprint-reports/outlook/report-delete-fails/first.pdf",
+                "first.pdf"));
+        repository.save(ExamSprintReport.pending(
+                "report-delete-succeeds",
+                ExamSprintReportType.OUTLOOK,
+                OBJECT_MAPPER.createObjectNode(),
+                FIXED_CLOCK.instant().minusSeconds(600),
+                FIXED_CLOCK.instant().minusSeconds(1)).success(
+                FIXED_CLOCK.instant().minusSeconds(300),
+                "exam-sprint-reports/outlook/report-delete-succeeds/second.pdf",
+                "second.pdf"));
+        storage.failDeleteFor("exam-sprint-reports/outlook/report-delete-fails/first.pdf");
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
+
+        service.cleanupExpiredReports();
+
+        ExamSprintReport failedDeleteReport = repository.findById("report-delete-fails").orElseThrow();
+        ExamSprintReport successfulDeleteReport = repository.findById("report-delete-succeeds").orElseThrow();
+        assertThat(failedDeleteReport.storageObjectKey())
+                .isEqualTo("exam-sprint-reports/outlook/report-delete-fails/first.pdf");
+        assertThat(successfulDeleteReport.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.EXPIRED);
+        assertThat(successfulDeleteReport.storageObjectKey()).isNull();
+        assertThat(storage.deletedKeys)
+                .contains("exam-sprint-reports/outlook/report-delete-fails/first.pdf")
+                .contains("exam-sprint-reports/outlook/report-delete-succeeds/second.pdf");
+    }
+
+    private DefaultExamSprintReportApplicationService service(
+            TestRepository repository,
+            ExamSprintReportGenerationDispatcher dispatcher,
+            TestStorage storage) {
+        return new DefaultExamSprintReportApplicationService(
+                repository,
+                dispatcher,
+                storage,
+                properties(),
+                FIXED_CLOCK,
+                OBJECT_MAPPER,
+                VALIDATOR);
+    }
+
+    private ExamSprintReportProperties properties() {
+        ExamSprintReportProperties properties = new ExamSprintReportProperties();
+        properties.setRetention(Duration.ofDays(1));
+        properties.setDownloadExpiry(Duration.ofMinutes(15));
+        return properties;
+    }
+
+    private ObjectNode validOutlookPayload() {
+        return (ObjectNode) OBJECT_MAPPER.valueToTree(new OutlookExamSprintReportPayload(
+                new OutlookExamSprintReportPayload.ReportMetadata(
+                        "2026 词汇展望报告",
+                        "李同学",
+                        "雅思 6.5",
+                        "2026 春季冲刺",
+                        "Ability Bot"),
+                new OutlookExamSprintReportPayload.ReadinessOverview(
+                        "词汇能力进入提分窗口,适合围绕考纲和高频场景做集中突破。",
+                        "当前阶段:稳态提升",
+                        "核心观察:阅读词汇优于写作输出,仍需补齐同义替换。",
+                        78),
+                new OutlookExamSprintReportPayload.SyllabusMasteryProfile(
+                        76,
+                        "核心考纲理解较稳,长尾主题词还存在断层。",
+                        "先补齐教育、科技和环境主题词,再做套题复盘。",
+                        List.of(
+                                new OutlookExamSprintReportPayload.DimensionScore("核心考纲", 82),
+                                new OutlookExamSprintReportPayload.DimensionScore("场景迁移", 71),
+                                new OutlookExamSprintReportPayload.DimensionScore("同义替换", 68))),
+                new OutlookExamSprintReportPayload.VocabularyProfile(
+                        620,
+                        800,
+                        78,
+                        "Exam 高频词识别准确,但主动输出不够稳定。",
+                        "每次精听后补 5 组同义替换并做口头复述。",
+                        List.of("cohesion", "allocate", "feasible")),
+                new OutlookExamSprintReportPayload.VocabularyProfile(
+                        1400,
+                        1800,
+                        77,
+                        "Common 高频词覆盖较广,但易混词记忆不牢。",
+                        "围绕校园、城市、科技场景做词块复现。",
+                        List.of("sustainable", "motivate", "urban")),
+                List.of(
+                        new OutlookExamSprintReportPayload.VocabularyFrequencyBand("2k 高频", 86, 90),
+                        new OutlookExamSprintReportPayload.VocabularyFrequencyBand("3k 高频", 78, 88),
+                        new OutlookExamSprintReportPayload.VocabularyFrequencyBand("学术词", 62, 80)),
+                List.of(
+                        new OutlookExamSprintReportPayload.SprintPlanOption(
+                                "7 天提分冲刺",
+                                "1 周",
+                                "推荐",
+                                "先保阅读和听力高频正确率",
+                                List.of("晨读 30 分钟", "晚间套题复盘", "错词二次听写"),
+                                "预计把高频词稳定率拉升到 82%"),
+                        new OutlookExamSprintReportPayload.SprintPlanOption(
+                                "21 天系统巩固",
+                                "3 周",
+                                "稳妥",
+                                "补齐学术词和写作替换",
+                                List.of("主题词块复现", "周测 2 次", "口语素材改写"),
+                                "预计把学术词掌握提升到 75%")),
+                new OutlookExamSprintReportPayload.DiagnosticCaseStudy(
+                        "教育类阅读题案例",
+                        "遇到 unfamiliar policy terms 时定位速度明显下降。",
+                        "说明教育政策主题词和近义替换储备不足。",
+                        "建立 policy / curriculum / assessment 词网并结合真题复现。",
+                        "先按主题建网,再回到题目验证。")));
+    }
+
+    private static class TestRepository implements ExamSprintReportRepository {
+        private final ConcurrentMap<String, ExamSprintReport> storage = new ConcurrentHashMap<>();
+
+        @Override
+        public ExamSprintReport save(ExamSprintReport report) {
+            storage.put(report.reportId(), report);
+            return report;
+        }
+
+        @Override
+        public Optional<ExamSprintReport> findById(String reportId) {
+            return Optional.ofNullable(storage.get(reportId));
+        }
+
+        @Override
+        public List<ExamSprintReport> findExpiredAtOrBefore(Instant instant) {
+            return storage.values().stream().filter(report -> report.isExpiredAt(instant)).toList();
+        }
+
+        long countByStatus(ExamSprintReportGenerationStatus status) {
+            return storage.values().stream().filter(report -> report.generationStatus() == status).count();
+        }
+    }
+
+    private static class TestStorage implements ExamSprintReportStorage {
+        private final List<String> generatedKeys = new ArrayList<>();
+        private final List<String> deletedKeys = new ArrayList<>();
+        private final List<String> deleteFailures = new ArrayList<>();
+
+        @Override
+        public StoredExamSprintReportFile upload(
+                String reportId,
+                ExamSprintReportType reportType,
+                String fileName,
+                byte[] pdfBytes,
+                Instant expiresAt) {
+            return new StoredExamSprintReportFile("blob/" + reportId + "/" + fileName, fileName);
+        }
+
+        @Override
+        public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
+            generatedKeys.add(storageObjectKey);
+            return URI.create("/api/exam-sprint/reports/" + storageObjectKey.split("/")[2] + "/download");
+        }
+
+        @Override
+        public Optional<StoredExamSprintReportContent> download(String storageObjectKey) {
+            return Optional.empty();
+        }
+
+        @Override
+        public void delete(String storageObjectKey) {
+            deletedKeys.add(storageObjectKey);
+            if (deleteFailures.contains(storageObjectKey)) {
+                throw new IllegalStateException("blob client delete failed for " + storageObjectKey);
+            }
+        }
+
+        void failDeleteFor(String storageObjectKey) {
+            deleteFailures.add(storageObjectKey);
+        }
+    }
+}

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

@@ -0,0 +1,148 @@
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
+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 com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ExamSprintReportGenerationWorkerTest {
+
+    private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    @Test
+    void processMarksReportSuccessAfterUpload() {
+        TestRepository repository = new TestRepository();
+        repository.save(ExamSprintReport.pending(
+                "report-success",
+                ExamSprintReportType.OUTLOOK,
+                OBJECT_MAPPER.createObjectNode(),
+                FIXED_CLOCK.instant(),
+                FIXED_CLOCK.instant().plusSeconds(86400)));
+        TestStorage storage = new TestStorage();
+        ExamSprintReportGenerationWorker worker = new ExamSprintReportGenerationWorker(
+                repository,
+                List.of(new TestRenderer()),
+                html -> html.getBytes(StandardCharsets.UTF_8),
+                storage,
+                FIXED_CLOCK);
+
+        worker.process("report-success");
+
+        ExamSprintReport report = repository.findById("report-success").orElseThrow();
+        assertThat(report.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
+        assertThat(report.storageObjectKey()).isEqualTo("exam-sprint-reports/outlook/report-success/exam-sprint-outlook-report-report-success.pdf");
+        assertThat(report.fileName()).isEqualTo("exam-sprint-outlook-report-report-success.pdf");
+    }
+
+    @Test
+    void processMarksReportFailedWhenGenerationPipelineThrows() {
+        TestRepository repository = new TestRepository();
+        repository.save(ExamSprintReport.pending(
+                "report-failed",
+                ExamSprintReportType.OUTLOOK,
+                OBJECT_MAPPER.createObjectNode(),
+                FIXED_CLOCK.instant(),
+                FIXED_CLOCK.instant().plusSeconds(86400)));
+
+        ExamSprintReportGenerationWorker worker = new ExamSprintReportGenerationWorker(
+                repository,
+                List.of(new FailingRenderer()),
+                html -> html.getBytes(StandardCharsets.UTF_8),
+                new TestStorage(),
+                FIXED_CLOCK);
+
+        worker.process("report-failed");
+
+        ExamSprintReport report = repository.findById("report-failed").orElseThrow();
+        assertThat(report.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.FAILED);
+        assertThat(report.failureReason()).isEqualTo("renderer exploded");
+    }
+
+    private static class TestRenderer implements ExamSprintReportRenderer {
+        @Override
+        public boolean supports(ExamSprintReportType reportType) {
+            return reportType == ExamSprintReportType.OUTLOOK;
+        }
+
+        @Override
+        public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
+            return "<html><body>ok</body></html>";
+        }
+    }
+
+    private static class FailingRenderer implements ExamSprintReportRenderer {
+        @Override
+        public boolean supports(ExamSprintReportType reportType) {
+            return reportType == ExamSprintReportType.OUTLOOK;
+        }
+
+        @Override
+        public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
+            throw new IllegalStateException("renderer exploded");
+        }
+    }
+
+    private static class TestRepository implements ExamSprintReportRepository {
+        private final ConcurrentMap<String, ExamSprintReport> storage = new ConcurrentHashMap<>();
+
+        @Override
+        public ExamSprintReport save(ExamSprintReport report) {
+            storage.put(report.reportId(), report);
+            return report;
+        }
+
+        @Override
+        public Optional<ExamSprintReport> findById(String reportId) {
+            return Optional.ofNullable(storage.get(reportId));
+        }
+
+        @Override
+        public List<ExamSprintReport> findExpiredAtOrBefore(Instant instant) {
+            return storage.values().stream().filter(report -> report.isExpiredAt(instant)).toList();
+        }
+    }
+
+    private static class TestStorage implements ExamSprintReportStorage {
+        @Override
+        public StoredExamSprintReportFile upload(
+                String reportId,
+                ExamSprintReportType reportType,
+                String fileName,
+                byte[] pdfBytes,
+                Instant expiresAt) {
+            return new StoredExamSprintReportFile("exam-sprint-reports/outlook/" + reportId + "/" + fileName, fileName);
+        }
+
+        @Override
+        public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
+            return URI.create("https://download.example.local/" + storageObjectKey + "?sig=test");
+        }
+
+        @Override
+        public Optional<StoredExamSprintReportContent> download(String storageObjectKey) {
+            return Optional.empty();
+        }
+
+        @Override
+        public void delete(String storageObjectKey) {
+        }
+    }
+}

BIN=BIN
abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/AsyncExamSprintReportGenerationDispatcher.class


BIN=BIN
abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.class


BIN=BIN
abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService$ReportDownloadContent.class


BIN=BIN
abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.class


BIN=BIN
abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationDispatcher.class


BIN=BIN
abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorker.class


BIN=BIN
abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties$Async.class


BIN=BIN
abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties$Storage.class


BIN=BIN
abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.class


BIN=BIN
abilities/exam-sprint/application/target/classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportRetentionScheduler.class


+ 10 - 0
abilities/exam-sprint/application/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst

@@ -0,0 +1,10 @@
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties$Async.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.class
+cn/yunzhixue/ability/center/examsprint/application/report/AsyncExamSprintReportGenerationDispatcher.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorker.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportRetentionScheduler.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService$ReportDownloadContent.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties$Storage.class
+cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationDispatcher.class

+ 7 - 0
abilities/exam-sprint/application/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst

@@ -0,0 +1,7 @@
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AsyncExamSprintReportGenerationDispatcher.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationDispatcher.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorker.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportProperties.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportRetentionScheduler.java

+ 8 - 0
abilities/exam-sprint/application/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst

@@ -0,0 +1,8 @@
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest$TestRepository.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest$TestRenderer.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest$TestStorage.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest$FailingRenderer.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest$TestStorage.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.class
+cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest$TestRepository.class

+ 2 - 0
abilities/exam-sprint/application/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst

@@ -0,0 +1,2 @@
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 7 - 0
abilities/exam-sprint/application/target/surefire-reports/TEST-cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationServiceTest.xml


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 7 - 0
abilities/exam-sprint/application/target/surefire-reports/TEST-cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportGenerationWorkerTest.xml


+ 4 - 0
abilities/exam-sprint/application/target/surefire-reports/cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationServiceTest.txt

@@ -0,0 +1,4 @@
+-------------------------------------------------------------------------------
+Test set: cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationServiceTest
+-------------------------------------------------------------------------------
+Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.399 s -- in cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportApplicationServiceTest

+ 4 - 0
abilities/exam-sprint/application/target/surefire-reports/cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportGenerationWorkerTest.txt

@@ -0,0 +1,4 @@
+-------------------------------------------------------------------------------
+Test set: cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportGenerationWorkerTest
+-------------------------------------------------------------------------------
+Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.007 s -- in cn.yunzhixue.ability.center.examsprint.application.report.ExamSprintReportGenerationWorkerTest

BIN=BIN
abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest$TestRepository.class


BIN=BIN
abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest$TestStorage.class


BIN=BIN
abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.class


BIN=BIN
abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest$FailingRenderer.class


BIN=BIN
abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest$TestRenderer.class


BIN=BIN
abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest$TestRepository.class


BIN=BIN
abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest$TestStorage.class


BIN=BIN
abilities/exam-sprint/application/target/test-classes/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.class


+ 40 - 0
abilities/exam-sprint/contracts/pom.xml

@@ -0,0 +1,40 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.yunzhixue</groupId>
+        <artifactId>ability-center</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>../../../pom.xml</relativePath>
+    </parent>
+    <artifactId>exam-sprint-contracts</artifactId>
+    <name>exam-sprint-contracts</name>
+    <dependencies>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>ability-center-kernel</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>jakarta.validation</groupId>
+            <artifactId>jakarta.validation-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

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

@@ -0,0 +1,9 @@
+package cn.yunzhixue.ability.center.examsprint.contracts.report;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.validation.constraints.NotNull;
+
+public record CreateExamSprintReportRequest(
+        @NotNull ExamSprintReportType reportType,
+        @NotNull JsonNode payload) {
+}

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

@@ -0,0 +1,11 @@
+package cn.yunzhixue.ability.center.examsprint.contracts.report;
+
+import java.time.Instant;
+
+public record CreateExamSprintReportResponse(
+        String reportId,
+        ExamSprintReportType reportType,
+        ExamSprintReportGenerationStatus generationStatus,
+        Instant createdAt,
+        Instant expiresAt) {
+}

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

@@ -0,0 +1,14 @@
+package cn.yunzhixue.ability.center.examsprint.contracts.report;
+
+import java.time.Instant;
+
+public record ExamSprintReportDetailResponse(
+        String reportId,
+        ExamSprintReportType reportType,
+        ExamSprintReportGenerationStatus generationStatus,
+        Instant createdAt,
+        Instant updatedAt,
+        Instant expiresAt,
+        String downloadUrl,
+        String failureReason) {
+}

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

@@ -0,0 +1,9 @@
+package cn.yunzhixue.ability.center.examsprint.contracts.report;
+
+public enum ExamSprintReportGenerationStatus {
+    PENDING,
+    PROCESSING,
+    SUCCESS,
+    FAILED,
+    EXPIRED
+}

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

@@ -0,0 +1,6 @@
+package cn.yunzhixue.ability.center.examsprint.contracts.report;
+
+public enum ExamSprintReportType {
+    OUTLOOK,
+    ACHIEVEMENT
+}

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

@@ -0,0 +1,78 @@
+package cn.yunzhixue.ability.center.examsprint.contracts.report;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.List;
+
+public record OutlookExamSprintReportPayload(
+        @NotNull @Valid ReportMetadata reportMetadata,
+        @NotNull @Valid ReadinessOverview readinessOverview,
+        @NotNull @Valid SyllabusMasteryProfile syllabusMasteryProfile,
+        @NotNull @Valid VocabularyProfile pastPaperVocabularyProfile,
+        @NotNull @Valid VocabularyProfile highFrequencyVocabularyProfile,
+        @NotEmpty List<@Valid VocabularyFrequencyBand> vocabularyFrequencyBands,
+        @NotEmpty List<@Valid SprintPlanOption> sprintPlanOptions,
+        @NotNull @Valid DiagnosticCaseStudy diagnosticCaseStudy) {
+
+    public record ReportMetadata(
+            @NotBlank String reportVersionLabel,
+            @NotBlank String learnerName,
+            @NotBlank String targetExamName,
+            @NotBlank String sprintPeriodLabel,
+            @NotBlank String authorName) {
+    }
+
+    public record ReadinessOverview(
+            @NotBlank String summary,
+            @NotBlank String currentStage,
+            @NotBlank String keyInsight,
+            @Min(0) @Max(100) int readinessScore) {
+    }
+
+    public record SyllabusMasteryProfile(
+            @Min(0) @Max(100) int masteryPercent,
+            @NotBlank String diagnosis,
+            @NotBlank String recommendation,
+            @NotEmpty List<@Valid DimensionScore> dimensionScores) {
+    }
+
+    public record VocabularyProfile(
+            @Min(0) int masteredWordCount,
+            @Min(1) int totalWordCount,
+            @Min(0) @Max(100) int masteryPercent,
+            @NotBlank String diagnosis,
+            @NotBlank String recommendation,
+            List<@NotBlank String> sampleWords) {
+    }
+
+    public record DimensionScore(@NotBlank String label, @Min(0) @Max(100) int score) {
+    }
+
+    public record VocabularyFrequencyBand(
+            @NotBlank String bandLabel,
+            @Min(0) @Max(100) int masteryPercent,
+            @Min(0) @Max(100) int targetPercent) {
+    }
+
+    public record SprintPlanOption(
+            @NotBlank String planName,
+            @NotBlank String cadenceLabel,
+            String tagLabel,
+            @NotBlank String focus,
+            @NotEmpty List<@NotBlank String> actionItems,
+            @NotBlank String expectedOutcome) {
+    }
+
+    public record DiagnosticCaseStudy(
+            @NotBlank String title,
+            @NotBlank String context,
+            @NotBlank String diagnosis,
+            @NotBlank String strategy,
+            @NotBlank String keyTakeaway) {
+    }
+}

BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportRequest.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportResponse.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportDetailResponse.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportGenerationStatus.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$DiagnosticCaseStudy.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$DimensionScore.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$ReadinessOverview.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$ReportMetadata.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$SprintPlanOption.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$SyllabusMasteryProfile.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$VocabularyFrequencyBand.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$VocabularyProfile.class


BIN=BIN
abilities/exam-sprint/contracts/target/classes/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.class


+ 14 - 0
abilities/exam-sprint/contracts/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst

@@ -0,0 +1,14 @@
+cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$DiagnosticCaseStudy.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportGenerationStatus.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$SyllabusMasteryProfile.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$VocabularyProfile.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$ReportMetadata.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$VocabularyFrequencyBand.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportDetailResponse.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$DimensionScore.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$SprintPlanOption.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportRequest.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportResponse.class
+cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload$ReadinessOverview.class

+ 6 - 0
abilities/exam-sprint/contracts/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst

@@ -0,0 +1,6 @@
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportRequest.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/CreateExamSprintReportResponse.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportDetailResponse.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportGenerationStatus.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java

+ 41 - 0
abilities/exam-sprint/domain/pom.xml

@@ -0,0 +1,41 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.yunzhixue</groupId>
+        <artifactId>ability-center</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>../../../pom.xml</relativePath>
+    </parent>
+    <artifactId>exam-sprint-domain</artifactId>
+    <name>exam-sprint-domain</name>
+    <dependencies>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>ability-center-kernel</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>exam-sprint-contracts</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 126 - 0
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java

@@ -0,0 +1,126 @@
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.time.Instant;
+
+public record ExamSprintReport(
+        String reportId,
+        ExamSprintReportType reportType,
+        JsonNode payload,
+        ExamSprintReportGenerationStatus generationStatus,
+        Instant createdAt,
+        Instant updatedAt,
+        Instant expiresAt,
+        String storageObjectKey,
+        String fileName,
+        String failureReason) {
+
+    public ExamSprintReport {
+        payload = copyPayload(payload);
+    }
+
+    public static ExamSprintReport pending(
+            String reportId,
+            ExamSprintReportType reportType,
+            JsonNode payload,
+            Instant createdAt,
+            Instant expiresAt) {
+        return new ExamSprintReport(
+                reportId,
+                reportType,
+                payload,
+                ExamSprintReportGenerationStatus.PENDING,
+                createdAt,
+                createdAt,
+                expiresAt,
+                null,
+                null,
+                null);
+    }
+
+    public ExamSprintReport processing(Instant updatedAt) {
+        return new ExamSprintReport(
+                reportId,
+                reportType,
+                payload,
+                ExamSprintReportGenerationStatus.PROCESSING,
+                createdAt,
+                updatedAt,
+                expiresAt,
+                storageObjectKey,
+                fileName,
+                null);
+    }
+
+    public ExamSprintReport success(Instant updatedAt, String storageObjectKey, String fileName) {
+        return new ExamSprintReport(
+                reportId,
+                reportType,
+                payload,
+                ExamSprintReportGenerationStatus.SUCCESS,
+                createdAt,
+                updatedAt,
+                expiresAt,
+                storageObjectKey,
+                fileName,
+                null);
+    }
+
+    public ExamSprintReport failed(Instant updatedAt, String failureReason) {
+        return new ExamSprintReport(
+                reportId,
+                reportType,
+                payload,
+                ExamSprintReportGenerationStatus.FAILED,
+                createdAt,
+                updatedAt,
+                expiresAt,
+                storageObjectKey,
+                fileName,
+                failureReason);
+    }
+
+    public ExamSprintReport expired(Instant updatedAt) {
+        return new ExamSprintReport(
+                reportId,
+                reportType,
+                payload,
+                ExamSprintReportGenerationStatus.EXPIRED,
+                createdAt,
+                updatedAt,
+                expiresAt,
+                storageObjectKey,
+                fileName,
+                failureReason);
+    }
+
+    public ExamSprintReport expiredWithStorageCleared(Instant updatedAt) {
+        return new ExamSprintReport(
+                reportId,
+                reportType,
+                payload,
+                ExamSprintReportGenerationStatus.EXPIRED,
+                createdAt,
+                updatedAt,
+                expiresAt,
+                null,
+                null,
+                failureReason);
+    }
+
+    public boolean isExpiredAt(Instant instant) {
+        return !expiresAt.isAfter(instant);
+    }
+
+    @Override
+    public JsonNode payload() {
+        return copyPayload(payload);
+    }
+
+    private static JsonNode copyPayload(JsonNode payload) {
+        return payload == null ? null : payload.deepCopy();
+    }
+}

+ 6 - 0
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportPdfGenerator.java

@@ -0,0 +1,6 @@
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+public interface ExamSprintReportPdfGenerator {
+
+    byte[] generate(String htmlContent);
+}

+ 13 - 0
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java

@@ -0,0 +1,13 @@
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.time.Instant;
+
+public interface ExamSprintReportRenderer {
+
+    boolean supports(ExamSprintReportType reportType);
+
+    String render(JsonNode payload, Instant generatedAt);
+}

+ 14 - 0
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRepository.java

@@ -0,0 +1,14 @@
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+public interface ExamSprintReportRepository {
+
+    ExamSprintReport save(ExamSprintReport report);
+
+    Optional<ExamSprintReport> findById(String reportId);
+
+    List<ExamSprintReport> findExpiredAtOrBefore(Instant instant);
+}

+ 30 - 0
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage.java

@@ -0,0 +1,30 @@
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
+
+import java.net.URI;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+
+public interface ExamSprintReportStorage {
+
+    StoredExamSprintReportFile upload(
+            String reportId,
+            ExamSprintReportType reportType,
+            String fileName,
+            byte[] pdfBytes,
+            Instant expiresAt);
+
+    URI generateDownloadUrl(String storageObjectKey, Duration ttl);
+
+    Optional<StoredExamSprintReportContent> download(String storageObjectKey);
+
+    void delete(String storageObjectKey);
+
+    record StoredExamSprintReportFile(String storageObjectKey, String fileName) {
+    }
+
+    record StoredExamSprintReportContent(String fileName, byte[] bytes, String contentType) {
+    }
+}

BIN=BIN
abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.class


BIN=BIN
abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportPdfGenerator.class


BIN=BIN
abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.class


BIN=BIN
abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRepository.class


BIN=BIN
abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage$StoredExamSprintReportContent.class


BIN=BIN
abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage$StoredExamSprintReportFile.class


BIN=BIN
abilities/exam-sprint/domain/target/classes/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage.class


+ 7 - 0
abilities/exam-sprint/domain/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst

@@ -0,0 +1,7 @@
+cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.class
+cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage$StoredExamSprintReportContent.class
+cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage.class
+cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRepository.class
+cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage$StoredExamSprintReportFile.class
+cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportPdfGenerator.class
+cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.class

+ 5 - 0
abilities/exam-sprint/domain/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst

@@ -0,0 +1,5 @@
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportPdfGenerator.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRepository.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportStorage.java

+ 74 - 0
abilities/exam-sprint/infrastructure/pom.xml

@@ -0,0 +1,74 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.yunzhixue</groupId>
+        <artifactId>ability-center</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>../../../pom.xml</relativePath>
+    </parent>
+    <artifactId>exam-sprint-infrastructure</artifactId>
+    <name>exam-sprint-infrastructure</name>
+    <dependencies>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>ability-center-kernel</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>exam-sprint-contracts</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>exam-sprint-domain</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-context</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-autoconfigure</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.openhtmltopdf</groupId>
+            <artifactId>openhtmltopdf-pdfbox</artifactId>
+            <version>1.0.10</version>
+        </dependency>
+        <dependency>
+            <groupId>com.openhtmltopdf</groupId>
+            <artifactId>openhtmltopdf-svg-support</artifactId>
+            <version>1.0.10</version>
+        </dependency>
+        <dependency>
+            <groupId>com.azure</groupId>
+            <artifactId>azure-storage-blob</artifactId>
+            <version>12.28.0</version>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 53 - 0
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.java

@@ -0,0 +1,53 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
+
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
+import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
+import com.openhtmltopdf.svgsupport.BatikSVGDrawer;
+import org.springframework.stereotype.Component;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.List;
+
+@Component
+public class OpenHtmlToPdfExamSprintReportPdfGenerator implements ExamSprintReportPdfGenerator {
+
+    private static final List<String> FONT_CANDIDATES = List.of(
+            "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
+            "/Library/Fonts/Arial Unicode.ttf",
+            "/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf",
+            "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf",
+            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+            "/System/Library/Fonts/Hiragino Sans GB.ttc",
+            "/System/Library/Fonts/Supplemental/Songti.ttc");
+
+    @Override
+    public byte[] generate(String htmlContent) {
+        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+            PdfRendererBuilder builder = new PdfRendererBuilder();
+            builder.useFastMode();
+            builder.withHtmlContent(htmlContent, null);
+            builder.useSVGDrawer(new BatikSVGDrawer());
+            registerAvailableFont(builder);
+            builder.toStream(outputStream);
+            builder.run();
+            return outputStream.toByteArray();
+        } catch (IOException exception) {
+            throw new UncheckedIOException("Failed to generate PDF", exception);
+        } catch (Exception exception) {
+            throw new IllegalStateException("Failed to generate PDF", exception);
+        }
+    }
+
+    private void registerAvailableFont(PdfRendererBuilder builder) {
+        for (String candidate : FONT_CANDIDATES) {
+            File fontFile = new File(candidate);
+            if (fontFile.exists()) {
+                builder.useFont(fontFile, "ReportFont");
+                return;
+            }
+        }
+    }
+}

+ 178 - 0
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java

@@ -0,0 +1,178 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook;
+
+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.ExamSprintReportRenderer;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Objects;
+
+@Component
+public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintReportRenderer {
+
+    private static final DateTimeFormatter DATE_TIME_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC);
+
+    private final ObjectMapper objectMapper;
+
+    public ClasspathOutlookExamSprintReportRenderer(ObjectMapper objectMapper) {
+        this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper");
+    }
+
+    @Override
+    public boolean supports(ExamSprintReportType reportType) {
+        return reportType == ExamSprintReportType.OUTLOOK;
+    }
+
+    @Override
+    public String render(JsonNode payload, Instant generatedAt) {
+        try {
+            OutlookExamSprintReportPayload reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
+            OutlookExamSprintReportPayload.ReportMetadata metadata = reportPayload.reportMetadata();
+            return loadTemplate()
+                    .replace("{{reportVersionLabel}}", escape(metadata.reportVersionLabel()))
+                    .replace("{{learnerName}}", escape(metadata.learnerName()))
+                    .replace("{{targetExamName}}", escape(metadata.targetExamName()))
+                    .replace("{{sprintPeriodLabel}}", escape(metadata.sprintPeriodLabel()))
+                    .replace("{{authorName}}", escape(metadata.authorName()))
+                    .replace("{{generatedAt}}", escape(DATE_TIME_FORMATTER.format(generatedAt)))
+                    .replace("{{summary}}", escape(reportPayload.readinessOverview().summary()))
+                    .replace("{{currentStage}}", escape(reportPayload.readinessOverview().currentStage()))
+                    .replace("{{keyInsight}}", escape(reportPayload.readinessOverview().keyInsight()))
+                    .replace("{{readinessScore}}", String.valueOf(reportPayload.readinessOverview().readinessScore()))
+                    .replace("{{syllabusMasteryProfile}}", renderSyllabusMasteryProfile(reportPayload.syllabusMasteryProfile()))
+                    .replace("{{pastPaperVocabularyProfile}}", renderVocabularyProfile("真题试卷词汇掌握情况", reportPayload.pastPaperVocabularyProfile()))
+                    .replace("{{highFrequencyVocabularyProfile}}", renderVocabularyProfile("常考词汇掌握情况", reportPayload.highFrequencyVocabularyProfile()))
+                    .replace("{{vocabularyFrequencyBands}}", renderVocabularyFrequencyBands(reportPayload.vocabularyFrequencyBands()))
+                    .replace("{{sprintPlanOptions}}", renderSprintPlanOptions(reportPayload.sprintPlanOptions()))
+                    .replace("{{diagnosticCaseStudy}}", renderDiagnosticCaseStudy(reportPayload.diagnosticCaseStudy()));
+        } catch (IOException exception) {
+            throw new UncheckedIOException("Failed to load outlook exam sprint report template", exception);
+        } catch (Exception exception) {
+            throw new IllegalStateException("Failed to render outlook exam sprint report", exception);
+        }
+    }
+
+    private String loadTemplate() throws IOException {
+        try (InputStream inputStream = new ClassPathResource("templates/outlook-exam-sprint-report-template.html").getInputStream()) {
+            return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
+        }
+    }
+
+    private String renderSyllabusMasteryProfile(OutlookExamSprintReportPayload.SyllabusMasteryProfile profile) {
+        StringBuilder builder = new StringBuilder();
+        builder.append("<div class='card-title'>考纲词汇掌握情况</div>")
+                .append("<div class='card-body'>")
+                .append("<p><strong>掌握度:</strong>").append(profile.masteryPercent()).append("%</p>")
+                .append("<p><strong>诊断:</strong>").append(escape(profile.diagnosis())).append("</p>")
+                .append("<p><strong>建议:</strong>").append(escape(profile.recommendation())).append("</p>")
+                .append("<ul>");
+        for (OutlookExamSprintReportPayload.DimensionScore score : profile.dimensionScores()) {
+            builder.append("<li>")
+                    .append(escape(score.label()))
+                    .append(":")
+                    .append(score.score())
+                    .append("%</li>");
+        }
+        builder.append("</ul></div>");
+        return builder.toString();
+    }
+
+    private String renderVocabularyProfile(String title, OutlookExamSprintReportPayload.VocabularyProfile profile) {
+        StringBuilder builder = new StringBuilder();
+        builder.append("<div class='card-title'>").append(title).append("</div>")
+                .append("<div class='card-body'>")
+                .append("<p><strong>已掌握:</strong>").append(profile.masteredWordCount()).append(" / ")
+                .append(profile.totalWordCount()).append(" 词(").append(profile.masteryPercent()).append("%)</p>")
+                .append("<p><strong>诊断:</strong>").append(escape(profile.diagnosis())).append("</p>")
+                .append("<p><strong>建议:</strong>").append(escape(profile.recommendation())).append("</p>")
+                .append(renderSampleWords(profile.sampleWords()))
+                .append("</div>");
+        return builder.toString();
+    }
+
+    private String renderSampleWords(List<String> sampleWords) {
+        if (sampleWords == null || sampleWords.isEmpty()) {
+            return "";
+        }
+
+        StringBuilder builder = new StringBuilder("<div class='chip-group'>");
+        for (String sampleWord : sampleWords) {
+            builder.append("<span class='chip'>").append(escape(sampleWord)).append("</span>");
+        }
+        builder.append("</div>");
+        return builder.toString();
+    }
+
+    private String renderVocabularyFrequencyBands(List<OutlookExamSprintReportPayload.VocabularyFrequencyBand> bands) {
+        StringBuilder builder = new StringBuilder("<div class='frequency-list'>");
+        for (OutlookExamSprintReportPayload.VocabularyFrequencyBand band : bands) {
+            builder.append("<div class='frequency-item'><strong>")
+                    .append(escape(band.bandLabel()))
+                    .append("</strong>:当前 ")
+                    .append(band.masteryPercent())
+                    .append("%,目标 ")
+                    .append(band.targetPercent())
+                    .append("%</div>");
+        }
+        builder.append("</div>");
+        return builder.toString();
+    }
+
+    private String renderSprintPlanOptions(List<OutlookExamSprintReportPayload.SprintPlanOption> options) {
+        StringBuilder builder = new StringBuilder();
+        for (OutlookExamSprintReportPayload.SprintPlanOption option : options) {
+            builder.append("<div class='plan-card'>")
+                    .append("<div class='plan-title'>")
+                    .append(escape(option.planName()));
+            if (option.tagLabel() != null && !option.tagLabel().isBlank()) {
+                builder.append(" <span class='tag'>").append(escape(option.tagLabel())).append("</span>");
+            }
+            builder.append("</div>")
+                    .append("<div class='plan-cadence'>").append(escape(option.cadenceLabel())).append("</div>")
+                    .append("<p><strong>重点:</strong>").append(escape(option.focus())).append("</p>")
+                    .append("<ul>");
+            for (String actionItem : option.actionItems()) {
+                builder.append("<li>").append(escape(actionItem)).append("</li>");
+            }
+            builder.append("</ul>")
+                    .append("<p><strong>预期:</strong>").append(escape(option.expectedOutcome())).append("</p>")
+                    .append("</div>");
+        }
+        return builder.toString();
+    }
+
+    private String renderDiagnosticCaseStudy(OutlookExamSprintReportPayload.DiagnosticCaseStudy caseStudy) {
+        return new StringBuilder()
+                .append("<div class='case-card'>")
+                .append("<h3>").append(escape(caseStudy.title())).append("</h3>")
+                .append("<p><strong>背景:</strong>").append(escape(caseStudy.context())).append("</p>")
+                .append("<p><strong>诊断:</strong>").append(escape(caseStudy.diagnosis())).append("</p>")
+                .append("<p><strong>策略:</strong>").append(escape(caseStudy.strategy())).append("</p>")
+                .append("<p><strong>启示:</strong>").append(escape(caseStudy.keyTakeaway())).append("</p>")
+                .append("</div>")
+                .toString();
+    }
+
+    private String escape(String value) {
+        if (value == null) {
+            return "";
+        }
+        return value.replace("&", "&amp;")
+                .replace("<", "&lt;")
+                .replace(">", "&gt;")
+                .replace("\"", "&quot;")
+                .replace("'", "&#39;");
+    }
+}

+ 35 - 0
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/repository/InMemoryExamSprintReportRepository.java

@@ -0,0 +1,35 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.repository;
+
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
+import org.springframework.stereotype.Repository;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+@Repository
+public class InMemoryExamSprintReportRepository implements ExamSprintReportRepository {
+
+    private final ConcurrentMap<String, ExamSprintReport> storage = new ConcurrentHashMap<>();
+
+    @Override
+    public ExamSprintReport save(ExamSprintReport report) {
+        storage.put(report.reportId(), report);
+        return report;
+    }
+
+    @Override
+    public Optional<ExamSprintReport> findById(String reportId) {
+        return Optional.ofNullable(storage.get(reportId));
+    }
+
+    @Override
+    public List<ExamSprintReport> findExpiredAtOrBefore(Instant instant) {
+        return storage.values().stream()
+                .filter(report -> report.isExpiredAt(instant))
+                .toList();
+    }
+}

+ 123 - 0
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java

@@ -0,0 +1,123 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.storage;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
+import com.azure.storage.blob.BlobClient;
+import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.BlobContainerClientBuilder;
+import com.azure.storage.blob.models.BlobHttpHeaders;
+import com.azure.storage.common.StorageSharedKeyCredential;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.net.URI;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Optional;
+
+@Component
+@ConditionalOnProperty(prefix = "ability.exam-sprint.report.storage", name = "type", havingValue = "azure")
+public class AzureBlobExamSprintReportStorage implements ExamSprintReportStorage {
+
+    private final BlobContainerClient containerClient;
+    private final Clock clock;
+
+    public AzureBlobExamSprintReportStorage(
+            @Value("${ability.exam-sprint.report.storage.container-name:exam-sprint-reports}") String containerName,
+            @Value("${ability.exam-sprint.report.storage.connection-string:}") String connectionString,
+            @Value("${ability.exam-sprint.report.storage.endpoint:}") String endpoint,
+            @Value("${ability.exam-sprint.report.storage.account-name:}") String accountName,
+            @Value("${ability.exam-sprint.report.storage.account-key:}") String accountKey,
+            Clock clock) {
+        this(buildContainerClient(containerName, connectionString, endpoint, accountName, accountKey), clock);
+        this.containerClient.createIfNotExists();
+    }
+
+    AzureBlobExamSprintReportStorage(BlobContainerClient containerClient, Clock clock) {
+        this.containerClient = containerClient;
+        this.clock = clock;
+    }
+
+    @Override
+    public StoredExamSprintReportFile upload(
+            String reportId,
+            ExamSprintReportType reportType,
+            String fileName,
+            byte[] pdfBytes,
+            Instant expiresAt) {
+        String blobName = "exam-sprint-reports/" + reportType.name().toLowerCase() + "/" + reportId + "/" + fileName;
+        BlobClient blobClient = containerClient.getBlobClient(blobName);
+        blobClient.upload(new ByteArrayInputStream(pdfBytes), pdfBytes.length, true);
+        blobClient.setHttpHeaders(new BlobHttpHeaders().setContentType("application/pdf"));
+        blobClient.setMetadata(Map.of(
+                "reportId", reportId,
+                "reportType", reportType.name(),
+                "expiresAt", expiresAt.toString(),
+                "uploadedAt", clock.instant().toString()));
+        return new StoredExamSprintReportFile(blobName, fileName);
+    }
+
+    @Override
+    public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
+        return URI.create("/api/exam-sprint/reports/" + reportId(storageObjectKey) + "/download");
+    }
+
+    @Override
+    public Optional<StoredExamSprintReportContent> download(String storageObjectKey) {
+        BlobClient blobClient = containerClient.getBlobClient(storageObjectKey);
+        if (!blobClient.exists()) {
+            return Optional.empty();
+        }
+
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        blobClient.downloadStream(outputStream);
+        String contentType = blobClient.getProperties().getContentType();
+        return Optional.of(new StoredExamSprintReportContent(
+                fileName(storageObjectKey),
+                outputStream.toByteArray(),
+                contentType == null || contentType.isBlank() ? "application/pdf" : contentType));
+    }
+
+    @Override
+    public void delete(String storageObjectKey) {
+        containerClient.getBlobClient(storageObjectKey).deleteIfExists();
+    }
+
+    private String fileName(String storageObjectKey) {
+        return storageObjectKey.substring(storageObjectKey.lastIndexOf('/') + 1);
+    }
+
+    private String reportId(String storageObjectKey) {
+        String[] segments = storageObjectKey.split("/");
+        return segments[2];
+    }
+
+    private static BlobContainerClient buildContainerClient(
+            String containerName,
+            String connectionString,
+            String endpoint,
+            String accountName,
+            String accountKey) {
+        BlobContainerClientBuilder builder = new BlobContainerClientBuilder().containerName(containerName);
+
+        if (hasText(connectionString)) {
+            builder.connectionString(connectionString);
+        } else if (hasText(endpoint) && hasText(accountName) && hasText(accountKey)) {
+            builder.endpoint(endpoint)
+                    .credential(new StorageSharedKeyCredential(accountName, accountKey));
+        } else {
+            throw new IllegalStateException("Azure storage configuration is incomplete");
+        }
+
+        return builder.buildClient();
+    }
+
+    private static boolean hasText(String value) {
+        return value != null && !value.isBlank();
+    }
+}

+ 63 - 0
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java

@@ -0,0 +1,63 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.storage;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.net.URI;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Component
+@ConditionalOnProperty(prefix = "ability.exam-sprint.report.storage", name = "type", havingValue = "memory", matchIfMissing = true)
+public class InMemoryExamSprintReportStorage implements ExamSprintReportStorage {
+
+    private final Map<String, StoredFile> storage = new ConcurrentHashMap<>();
+
+    @Override
+    public StoredExamSprintReportFile upload(
+            String reportId,
+            ExamSprintReportType reportType,
+            String fileName,
+            byte[] pdfBytes,
+            Instant expiresAt) {
+        String typeSegment = reportType.name().toLowerCase();
+        String storageObjectKey = "exam-sprint-reports/" + typeSegment + "/" + reportId + "/" + fileName;
+        storage.put(storageObjectKey, new StoredFile(fileName, pdfBytes.clone(), "application/pdf"));
+        return new StoredExamSprintReportFile(storageObjectKey, fileName);
+    }
+
+    @Override
+    public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
+        return URI.create("/api/exam-sprint/reports/" + reportId(storageObjectKey) + "/download");
+    }
+
+    @Override
+    public Optional<StoredExamSprintReportContent> download(String storageObjectKey) {
+        StoredFile storedFile = storage.get(storageObjectKey);
+        if (storedFile == null) {
+            return Optional.empty();
+        }
+        return Optional.of(new StoredExamSprintReportContent(
+                storedFile.fileName(),
+                storedFile.bytes().clone(),
+                storedFile.contentType()));
+    }
+
+    @Override
+    public void delete(String storageObjectKey) {
+        storage.remove(storageObjectKey);
+    }
+
+    private String reportId(String storageObjectKey) {
+        String[] segments = storageObjectKey.split("/");
+        return segments[2];
+    }
+
+    private record StoredFile(String fileName, byte[] bytes, String contentType) {
+    }
+}

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

@@ -0,0 +1,209 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8"/>
+    <title>{{reportVersionLabel}}</title>
+    <style>
+        @page {
+            size: A4;
+            margin: 14mm 12mm;
+        }
+
+        body {
+            font-family: ReportFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
+            color: #2f3542;
+            font-size: 12px;
+            line-height: 1.65;
+            margin: 0;
+            background: #f5f7fa;
+        }
+
+        .report-container {
+            background: #ffffff;
+            border: 1px solid #e8eef7;
+            border-radius: 12px;
+            padding: 22px 24px 20px;
+        }
+
+        .report-title {
+            text-align: center;
+            font-size: 28px;
+            color: #2b4c8a;
+            font-weight: 700;
+            margin-bottom: 8px;
+        }
+
+        .report-subtitle {
+            text-align: center;
+            font-size: 14px;
+            color: #5f6b7a;
+            margin-bottom: 20px;
+        }
+
+        .meta-grid,
+        .content-grid,
+        .plan-grid {
+            font-size: 0;
+            margin-bottom: 18px;
+        }
+
+        .meta-item,
+        .summary-card,
+        .content-card,
+        .plan-card,
+        .case-card {
+            background: #fafbfc;
+            border: 1px solid #e4ebf5;
+            border-radius: 14px;
+            padding: 16px;
+        }
+
+        .meta-item,
+        .content-card,
+        .plan-card {
+            display: inline-block;
+            width: 47.2%;
+            vertical-align: top;
+            margin: 0 2.8% 14px 0;
+            box-sizing: border-box;
+            font-size: 12px;
+        }
+
+        .meta-grid .meta-item:nth-child(2n),
+        .content-grid .content-card:nth-child(2n),
+        .plan-grid .plan-card:nth-child(2n) {
+            margin-right: 0;
+        }
+
+        .meta-label {
+            color: #6d7a8a;
+            font-size: 11px;
+            margin-bottom: 4px;
+        }
+
+        .meta-value {
+            color: #2b4c8a;
+            font-weight: 700;
+        }
+
+        .summary-card {
+            background: #edf3fc;
+            border-color: #d7e3f7;
+            margin-bottom: 20px;
+        }
+
+        .section-title {
+            font-size: 20px;
+            color: #2b4c8a;
+            border-left: 6px solid #ff7d00;
+            padding-left: 12px;
+            margin: 24px 0 14px;
+            font-weight: 700;
+        }
+
+        .summary-card,
+        .case-card {
+            font-size: 12px;
+        }
+
+        .card-title,
+        .plan-title {
+            font-size: 16px;
+            color: #2b4c8a;
+            font-weight: 700;
+            margin-bottom: 8px;
+        }
+
+        .chip-group {
+            margin-top: 10px;
+            font-size: 0;
+        }
+
+        .chip,
+        .tag {
+            display: inline-block;
+            background: #fff1e7;
+            color: #ff7d00;
+            border-radius: 999px;
+            padding: 2px 10px;
+            font-size: 11px;
+            font-weight: 700;
+            margin: 0 8px 8px 0;
+        }
+
+        .frequency-list {
+            font-size: 12px;
+        }
+
+        .frequency-item,
+        .plan-cadence {
+            color: #516173;
+            margin-bottom: 8px;
+        }
+
+        ul {
+            margin: 8px 0 0 18px;
+            padding: 0;
+        }
+
+        p {
+            margin: 6px 0;
+        }
+
+        .plan-grid {
+            margin-bottom: 10px;
+        }
+    </style>
+</head>
+<body>
+<div class="report-container">
+    <div class="report-title">{{reportVersionLabel}}</div>
+    <div class="report-subtitle">高考英语临考词汇突击潜力展望报告</div>
+
+    <div class="meta-grid">
+        <div class="meta-item">
+            <div class="meta-label">学生姓名</div>
+            <div class="meta-value">{{learnerName}}</div>
+        </div>
+        <div class="meta-item">
+            <div class="meta-label">目标考试</div>
+            <div class="meta-value">{{targetExamName}}</div>
+        </div>
+        <div class="meta-item">
+            <div class="meta-label">冲刺周期</div>
+            <div class="meta-value">{{sprintPeriodLabel}}</div>
+        </div>
+        <div class="meta-item">
+            <div class="meta-label">生成信息</div>
+            <div class="meta-value">{{authorName}} · {{generatedAt}}</div>
+        </div>
+    </div>
+
+    <div class="summary-card">
+        <p><strong>学情摘要:</strong>{{summary}}</p>
+        <p><strong>当前阶段:</strong>{{currentStage}}</p>
+        <p><strong>关键洞察:</strong>{{keyInsight}}</p>
+        <p><strong>备考就绪度:</strong>{{readinessScore}}%</p>
+    </div>
+
+    <div class="section-title">模块一:个人学情分析</div>
+    <div class="content-grid">
+        <div class="content-card">{{syllabusMasteryProfile}}</div>
+        <div class="content-card">{{pastPaperVocabularyProfile}}</div>
+        <div class="content-card">{{highFrequencyVocabularyProfile}}</div>
+        <div class="content-card">
+            <div class="card-title">词频区间掌握度</div>
+            {{vocabularyFrequencyBands}}
+        </div>
+    </div>
+
+    <div class="section-title">模块二:科学备考建议</div>
+    <div class="plan-grid">
+        {{sprintPlanOptions}}
+    </div>
+
+    <div class="section-title">模块三:诊断案例</div>
+    {{diagnosticCaseStudy}}
+</div>
+</body>
+</html>

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

@@ -0,0 +1,104 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
+
+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.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    @Test
+    void generateCreatesPdfWithExtractableOutlookKeyText() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
+
+        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+        byte[] pdfBytes = pdfGenerator.generate(html);
+
+        assertThat(pdfBytes).isNotEmpty();
+        assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
+
+        try (PDDocument document = PDDocument.load(pdfBytes)) {
+            String normalizedText = new PDFTextStripper().getText(document).replaceAll("\\s+", "");
+            assertThat(normalizedText).contains("2026词汇展望报告");
+            assertThat(normalizedText).contains("常考词汇掌握情况");
+            assertThat(normalizedText).contains("7天提分冲刺");
+        }
+    }
+
+    private JsonNode samplePayload() throws Exception {
+        return OBJECT_MAPPER.readTree("""
+                {
+                  "reportMetadata": {
+                    "reportVersionLabel": "2026 词汇展望报告",
+                    "learnerName": "李同学",
+                    "targetExamName": "春季高考英语",
+                    "sprintPeriodLabel": "30 天考前冲刺",
+                    "authorName": "Ability Bot"
+                  },
+                  "readinessOverview": {
+                    "summary": "基础较稳,具备短期冲刺提分空间。",
+                    "currentStage": "冲刺提升期",
+                    "keyInsight": "高频与常考词群是提分关键。",
+                    "readinessScore": 72
+                  },
+                  "syllabusMasteryProfile": {
+                    "masteryPercent": 78,
+                    "diagnosis": "考纲词覆盖较好。",
+                    "recommendation": "保持滚动复习。",
+                    "dimensionScores": [
+                      {"label": "识记", "score": 82},
+                      {"label": "应用", "score": 74}
+                    ]
+                  },
+                  "pastPaperVocabularyProfile": {
+                    "masteredWordCount": 420,
+                    "totalWordCount": 600,
+                    "masteryPercent": 70,
+                    "diagnosis": "真题词汇还需查漏补缺。",
+                    "recommendation": "优先扫清近三年高频词。",
+                    "sampleWords": ["abandon", "adapt", "assume"]
+                  },
+                  "highFrequencyVocabularyProfile": {
+                    "masteredWordCount": 320,
+                    "totalWordCount": 400,
+                    "masteryPercent": 80,
+                    "diagnosis": "常考词汇掌握情况良好。",
+                    "recommendation": "继续稳固高频词群。",
+                    "sampleWords": ["benefit", "capacity", "decline"]
+                  },
+                  "vocabularyFrequencyBands": [
+                    {"bandLabel": "高频词", "masteryPercent": 80, "targetPercent": 90},
+                    {"bandLabel": "中频词", "masteryPercent": 68, "targetPercent": 80},
+                    {"bandLabel": "低频词", "masteryPercent": 45, "targetPercent": 60}
+                  ],
+                  "sprintPlanOptions": [
+                    {
+                      "planName": "7 天提分冲刺",
+                      "cadenceLabel": "7 天",
+                      "tagLabel": "推荐",
+                      "focus": "高频词与真题词回收",
+                      "actionItems": ["晨读高频词", "午间错词复现", "晚间真题套练"],
+                      "expectedOutcome": "稳定拿下基础词汇题"
+                    }
+                  ],
+                  "diagnosticCaseStudy": {
+                    "title": "上届学员案例",
+                    "context": "基础一般但执行力强。",
+                    "diagnosis": "高频词重复错误较多。",
+                    "strategy": "连续 10 天高频词闭环复习。",
+                    "keyTakeaway": "短周期高频复现可快速提分。"
+                  }
+                }
+                """);
+    }
+}

+ 181 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java

@@ -0,0 +1,181 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ClasspathOutlookExamSprintReportRendererTest {
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final String TEMPLATE_PATH = "templates/outlook-exam-sprint-report-template.html";
+
+    @Test
+    void renderBuildsOutlookHtmlFromClasspathTemplate() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(html).contains("2026 词汇展望报告");
+        assertThat(html).contains("常考词汇掌握情况");
+        assertThat(html).contains("7 天提分冲刺");
+    }
+
+    @Test
+    void supportsOnlyOutlookReportType() {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        assertThat(renderer.supports(ExamSprintReportType.OUTLOOK)).isTrue();
+        assertThat(renderer.supports(ExamSprintReportType.ACHIEVEMENT)).isFalse();
+    }
+
+    @Test
+    void renderUsesInjectedObjectMapperForPayloadDeserialization() throws Exception {
+        TrackingObjectMapper objectMapper = new TrackingObjectMapper();
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(objectMapper);
+
+        renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(objectMapper.treeToValueCalled).isTrue();
+    }
+
+    @Test
+    void renderClosesTemplateInputStreamAfterLoadingClasspathTemplate() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+        ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
+        TrackingTemplateClassLoader trackingClassLoader = new TrackingTemplateClassLoader(originalClassLoader);
+
+        try {
+            Thread.currentThread().setContextClassLoader(trackingClassLoader);
+
+            renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+        } finally {
+            Thread.currentThread().setContextClassLoader(originalClassLoader);
+        }
+
+        assertThat(trackingClassLoader.inputStream.closed).isTrue();
+    }
+
+    private static final class TrackingObjectMapper extends ObjectMapper {
+        private boolean treeToValueCalled;
+
+        @Override
+        public <T> T treeToValue(com.fasterxml.jackson.core.TreeNode node, Class<T> valueType)
+                throws com.fasterxml.jackson.core.JsonProcessingException {
+            treeToValueCalled = true;
+            return super.treeToValue(node, valueType);
+        }
+    }
+
+    private static final class TrackingTemplateClassLoader extends ClassLoader {
+        private final TrackingInputStream inputStream = new TrackingInputStream("template".getBytes(StandardCharsets.UTF_8));
+
+        private TrackingTemplateClassLoader(ClassLoader parent) {
+            super(parent);
+        }
+
+        @Override
+        public java.io.InputStream getResourceAsStream(String name) {
+            if (TEMPLATE_PATH.equals(name)) {
+                return inputStream;
+            }
+            return super.getResourceAsStream(name);
+        }
+    }
+
+    private static final class TrackingInputStream extends ByteArrayInputStream {
+        private boolean closed;
+
+        private TrackingInputStream(byte[] buf) {
+            super(buf);
+        }
+
+        @Override
+        public void close() throws IOException {
+            closed = true;
+            super.close();
+        }
+    }
+
+    private JsonNode samplePayload() throws Exception {
+        return OBJECT_MAPPER.readTree("""
+                {
+                  "reportMetadata": {
+                    "reportVersionLabel": "2026 词汇展望报告",
+                    "learnerName": "李同学",
+                    "targetExamName": "春季高考英语",
+                    "sprintPeriodLabel": "30 天考前冲刺",
+                    "authorName": "Ability Bot"
+                  },
+                  "readinessOverview": {
+                    "summary": "基础较稳,具备短期冲刺提分空间。",
+                    "currentStage": "冲刺提升期",
+                    "keyInsight": "高频与常考词群是提分关键。",
+                    "readinessScore": 72
+                  },
+                  "syllabusMasteryProfile": {
+                    "masteryPercent": 78,
+                    "diagnosis": "考纲词覆盖较好。",
+                    "recommendation": "保持滚动复习。",
+                    "dimensionScores": [
+                      {"label": "识记", "score": 82},
+                      {"label": "应用", "score": 74}
+                    ]
+                  },
+                  "pastPaperVocabularyProfile": {
+                    "masteredWordCount": 420,
+                    "totalWordCount": 600,
+                    "masteryPercent": 70,
+                    "diagnosis": "真题词汇还需查漏补缺。",
+                    "recommendation": "优先扫清近三年高频词。",
+                    "sampleWords": ["abandon", "adapt", "assume"]
+                  },
+                  "highFrequencyVocabularyProfile": {
+                    "masteredWordCount": 320,
+                    "totalWordCount": 400,
+                    "masteryPercent": 80,
+                    "diagnosis": "常考词汇掌握情况良好。",
+                    "recommendation": "继续稳固高频词群。",
+                    "sampleWords": ["benefit", "capacity", "decline"]
+                  },
+                  "vocabularyFrequencyBands": [
+                    {"bandLabel": "高频词", "masteryPercent": 80, "targetPercent": 90},
+                    {"bandLabel": "中频词", "masteryPercent": 68, "targetPercent": 80},
+                    {"bandLabel": "低频词", "masteryPercent": 45, "targetPercent": 60}
+                  ],
+                  "sprintPlanOptions": [
+                    {
+                      "planName": "7 天提分冲刺",
+                      "cadenceLabel": "7 天",
+                      "tagLabel": "推荐",
+                      "focus": "高频词与真题词回收",
+                      "actionItems": ["晨读高频词", "午间错词复现", "晚间真题套练"],
+                      "expectedOutcome": "稳定拿下基础词汇题"
+                    },
+                    {
+                      "planName": "14 天均衡提升",
+                      "cadenceLabel": "14 天",
+                      "tagLabel": "稳妥",
+                      "focus": "词群与题型双线推进",
+                      "actionItems": ["两天一轮主题词", "隔天真题精练"],
+                      "expectedOutcome": "兼顾稳定性与提分空间"
+                    }
+                  ],
+                  "diagnosticCaseStudy": {
+                    "title": "上届学员案例",
+                    "context": "基础一般但执行力强。",
+                    "diagnosis": "高频词重复错误较多。",
+                    "strategy": "连续 10 天高频词闭环复习。",
+                    "keyTakeaway": "短周期高频复现可快速提分。"
+                  }
+                }
+                """);
+    }
+}

+ 28 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/OutlookExamSprintReportTemplateCompatibilityTest.java

@@ -0,0 +1,28 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ClassPathResource;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OutlookExamSprintReportTemplateCompatibilityTest {
+
+    @Test
+    void templateDoesNotUseOpenHtmlToPdfUnsupportedLayoutDeclarations() throws Exception {
+        String template = new String(
+                new ClassPathResource("templates/outlook-exam-sprint-report-template.html")
+                        .getInputStream()
+                        .readAllBytes(),
+                StandardCharsets.UTF_8);
+
+        assertThat(template)
+                .doesNotContain("display: grid")
+                .doesNotContain("grid-template-columns")
+                .doesNotContain("gap:")
+                .doesNotContain("display: flex")
+                .doesNotContain("flex-wrap")
+                .doesNotContain("flex-direction");
+    }
+}

+ 58 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorageTest.java

@@ -0,0 +1,58 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.storage;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
+import com.azure.storage.blob.BlobClient;
+import com.azure.storage.blob.BlobContainerClient;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+class AzureBlobExamSprintReportStorageTest {
+
+    private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-01-03T08:00:00Z"), ZoneOffset.UTC);
+
+    @Test
+    void generateDownloadUrlReturnsApplicationDownloadEndpoint() {
+        BlobContainerClient containerClient = mock(BlobContainerClient.class);
+        AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(containerClient, FIXED_CLOCK);
+
+        URI downloadUrl = storage.generateDownloadUrl(
+                "exam-sprint-reports/outlook/report-123/exam-sprint-outlook-report-report-123.pdf",
+                Duration.ofMinutes(15));
+
+        assertThat(downloadUrl).isEqualTo(URI.create("/api/exam-sprint/reports/report-123/download"));
+        verifyNoInteractions(containerClient);
+    }
+
+    @Test
+    void uploadWritesUploadedAtMetadataFromInjectedClock() {
+        BlobContainerClient containerClient = mock(BlobContainerClient.class);
+        BlobClient blobClient = mock(BlobClient.class);
+        when(containerClient.getBlobClient("exam-sprint-reports/outlook/report-123/file.pdf")).thenReturn(blobClient);
+        AzureBlobExamSprintReportStorage storage = new AzureBlobExamSprintReportStorage(containerClient, FIXED_CLOCK);
+
+        storage.upload(
+                "report-123",
+                ExamSprintReportType.OUTLOOK,
+                "file.pdf",
+                new byte[]{1, 2, 3},
+                Instant.parse("2026-01-10T00:00:00Z"));
+
+        verify(blobClient).setMetadata(argThat(metadata ->
+                "report-123".equals(metadata.get("reportId"))
+                        && "OUTLOOK".equals(metadata.get("reportType"))
+                        && "2026-01-10T00:00:00Z".equals(metadata.get("expiresAt"))
+                        && FIXED_CLOCK.instant().toString().equals(metadata.get("uploadedAt"))));
+    }
+}

BIN=BIN
abilities/exam-sprint/infrastructure/target/classes/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.class


BIN=BIN
abilities/exam-sprint/infrastructure/target/classes/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.class


BIN=BIN
abilities/exam-sprint/infrastructure/target/classes/cn/yunzhixue/ability/center/examsprint/infrastructure/report/repository/InMemoryExamSprintReportRepository.class


BIN=BIN
abilities/exam-sprint/infrastructure/target/classes/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.class


BIN=BIN
abilities/exam-sprint/infrastructure/target/classes/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage$StoredFile.class


BIN=BIN
abilities/exam-sprint/infrastructure/target/classes/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.class


+ 209 - 0
abilities/exam-sprint/infrastructure/target/classes/templates/outlook-exam-sprint-report-template.html

@@ -0,0 +1,209 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8"/>
+    <title>{{reportVersionLabel}}</title>
+    <style>
+        @page {
+            size: A4;
+            margin: 14mm 12mm;
+        }
+
+        body {
+            font-family: ReportFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
+            color: #2f3542;
+            font-size: 12px;
+            line-height: 1.65;
+            margin: 0;
+            background: #f5f7fa;
+        }
+
+        .report-container {
+            background: #ffffff;
+            border: 1px solid #e8eef7;
+            border-radius: 12px;
+            padding: 22px 24px 20px;
+        }
+
+        .report-title {
+            text-align: center;
+            font-size: 28px;
+            color: #2b4c8a;
+            font-weight: 700;
+            margin-bottom: 8px;
+        }
+
+        .report-subtitle {
+            text-align: center;
+            font-size: 14px;
+            color: #5f6b7a;
+            margin-bottom: 20px;
+        }
+
+        .meta-grid,
+        .content-grid,
+        .plan-grid {
+            font-size: 0;
+            margin-bottom: 18px;
+        }
+
+        .meta-item,
+        .summary-card,
+        .content-card,
+        .plan-card,
+        .case-card {
+            background: #fafbfc;
+            border: 1px solid #e4ebf5;
+            border-radius: 14px;
+            padding: 16px;
+        }
+
+        .meta-item,
+        .content-card,
+        .plan-card {
+            display: inline-block;
+            width: 47.2%;
+            vertical-align: top;
+            margin: 0 2.8% 14px 0;
+            box-sizing: border-box;
+            font-size: 12px;
+        }
+
+        .meta-grid .meta-item:nth-child(2n),
+        .content-grid .content-card:nth-child(2n),
+        .plan-grid .plan-card:nth-child(2n) {
+            margin-right: 0;
+        }
+
+        .meta-label {
+            color: #6d7a8a;
+            font-size: 11px;
+            margin-bottom: 4px;
+        }
+
+        .meta-value {
+            color: #2b4c8a;
+            font-weight: 700;
+        }
+
+        .summary-card {
+            background: #edf3fc;
+            border-color: #d7e3f7;
+            margin-bottom: 20px;
+        }
+
+        .section-title {
+            font-size: 20px;
+            color: #2b4c8a;
+            border-left: 6px solid #ff7d00;
+            padding-left: 12px;
+            margin: 24px 0 14px;
+            font-weight: 700;
+        }
+
+        .summary-card,
+        .case-card {
+            font-size: 12px;
+        }
+
+        .card-title,
+        .plan-title {
+            font-size: 16px;
+            color: #2b4c8a;
+            font-weight: 700;
+            margin-bottom: 8px;
+        }
+
+        .chip-group {
+            margin-top: 10px;
+            font-size: 0;
+        }
+
+        .chip,
+        .tag {
+            display: inline-block;
+            background: #fff1e7;
+            color: #ff7d00;
+            border-radius: 999px;
+            padding: 2px 10px;
+            font-size: 11px;
+            font-weight: 700;
+            margin: 0 8px 8px 0;
+        }
+
+        .frequency-list {
+            font-size: 12px;
+        }
+
+        .frequency-item,
+        .plan-cadence {
+            color: #516173;
+            margin-bottom: 8px;
+        }
+
+        ul {
+            margin: 8px 0 0 18px;
+            padding: 0;
+        }
+
+        p {
+            margin: 6px 0;
+        }
+
+        .plan-grid {
+            margin-bottom: 10px;
+        }
+    </style>
+</head>
+<body>
+<div class="report-container">
+    <div class="report-title">{{reportVersionLabel}}</div>
+    <div class="report-subtitle">高考英语临考词汇突击潜力展望报告</div>
+
+    <div class="meta-grid">
+        <div class="meta-item">
+            <div class="meta-label">学生姓名</div>
+            <div class="meta-value">{{learnerName}}</div>
+        </div>
+        <div class="meta-item">
+            <div class="meta-label">目标考试</div>
+            <div class="meta-value">{{targetExamName}}</div>
+        </div>
+        <div class="meta-item">
+            <div class="meta-label">冲刺周期</div>
+            <div class="meta-value">{{sprintPeriodLabel}}</div>
+        </div>
+        <div class="meta-item">
+            <div class="meta-label">生成信息</div>
+            <div class="meta-value">{{authorName}} · {{generatedAt}}</div>
+        </div>
+    </div>
+
+    <div class="summary-card">
+        <p><strong>学情摘要:</strong>{{summary}}</p>
+        <p><strong>当前阶段:</strong>{{currentStage}}</p>
+        <p><strong>关键洞察:</strong>{{keyInsight}}</p>
+        <p><strong>备考就绪度:</strong>{{readinessScore}}%</p>
+    </div>
+
+    <div class="section-title">模块一:个人学情分析</div>
+    <div class="content-grid">
+        <div class="content-card">{{syllabusMasteryProfile}}</div>
+        <div class="content-card">{{pastPaperVocabularyProfile}}</div>
+        <div class="content-card">{{highFrequencyVocabularyProfile}}</div>
+        <div class="content-card">
+            <div class="card-title">词频区间掌握度</div>
+            {{vocabularyFrequencyBands}}
+        </div>
+    </div>
+
+    <div class="section-title">模块二:科学备考建议</div>
+    <div class="plan-grid">
+        {{sprintPlanOptions}}
+    </div>
+
+    <div class="section-title">模块三:诊断案例</div>
+    {{diagnosticCaseStudy}}
+</div>
+</body>
+</html>

+ 6 - 0
abilities/exam-sprint/infrastructure/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst

@@ -0,0 +1,6 @@
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.class
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.class
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/repository/InMemoryExamSprintReportRepository.class
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage$StoredFile.class
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.class
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.class

+ 5 - 0
abilities/exam-sprint/infrastructure/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst

@@ -0,0 +1,5 @@
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/repository/InMemoryExamSprintReportRepository.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java

+ 7 - 0
abilities/exam-sprint/infrastructure/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst

@@ -0,0 +1,7 @@
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorageTest.class
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/OutlookExamSprintReportTemplateCompatibilityTest.class
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.class
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.class
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest$TrackingObjectMapper.class
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest$TrackingTemplateClassLoader.class
+cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest$TrackingInputStream.class

+ 4 - 0
abilities/exam-sprint/infrastructure/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst

@@ -0,0 +1,4 @@
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/OutlookExamSprintReportTemplateCompatibilityTest.java
+/Users/exiao/Codes/ability-service/abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorageTest.java

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 7 - 0
abilities/exam-sprint/infrastructure/target/surefire-reports/TEST-cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf.OpenHtmlToPdfExamSprintReportPdfGeneratorTest.xml


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 7 - 0
abilities/exam-sprint/infrastructure/target/surefire-reports/TEST-cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook.ClasspathOutlookExamSprintReportRendererTest.xml


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 7 - 0
abilities/exam-sprint/infrastructure/target/surefire-reports/TEST-cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook.OutlookExamSprintReportTemplateCompatibilityTest.xml


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio