瀏覽代碼

refactor(exam-sprint): 推进DDD命名治理三轮内容建模

金逸霄 2 周之前
父節點
當前提交
1dcc78b190
共有 22 個文件被更改,包括 1811 次插入163 次删除
  1. 44 0
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java
  2. 15 11
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
  3. 1 1
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java
  4. 51 0
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java
  5. 34 13
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
  6. 21 11
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java
  7. 3 2
      abilities/exam-sprint/domain/pom.xml
  8. 55 0
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java
  9. 11 20
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java
  10. 1 3
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java
  11. 6 0
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ReportContent.java
  12. 11 0
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/UnmodeledReportContent.java
  13. 64 0
      abilities/exam-sprint/domain/src/test/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContentTest.java
  14. 27 34
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java
  15. 8 1
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java
  16. 46 3
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java
  17. 121 30
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java
  18. 14 9
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java
  19. 1 19
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java
  20. 1 1
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java
  21. 1266 0
      docs/superpowers/plans/2026-04-28-ddd-naming-governance-jsonnode-payload-loop.md
  22. 10 5
      docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md

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

@@ -0,0 +1,44 @@
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
+
+import java.util.Objects;
+
+final class AchievementReportContentMapper {
+
+    private AchievementReportContentMapper() {
+    }
+
+    static AchievementReportContent toDomainContent(AchievementExamSprintReportPayload payload) {
+        Objects.requireNonNull(payload, "payload");
+        return new AchievementReportContent(
+                payload.reportTitle(),
+                payload.reportSubtitle(),
+                payload.completionTitle(),
+                payload.completionSubtitle(),
+                new AchievementReportContent.SummaryMetrics(
+                        payload.summaryMetrics().vocabularyGrowthText(),
+                        payload.summaryMetrics().paperKnownWordsGrowthText(),
+                        payload.summaryMetrics().unknownWordHitRateText(),
+                        payload.summaryMetrics().learningEfficiencyText()),
+                comparison(payload.vocabularyComparison()),
+                comparison(payload.paperKnownWordsComparison()),
+                new AchievementReportContent.ExamUnknownWordsHitStatus(
+                        payload.examUnknownWordsHitStatus().unknownWordHitRateText(),
+                        payload.examUnknownWordsHitStatus().learningEfficiencyText(),
+                        payload.examUnknownWordsHitStatus().unknownWordsBeforeText(),
+                        payload.examUnknownWordsHitStatus().unknownWordsAfterText(),
+                        payload.examUnknownWordsHitStatus().reducedUnknownWordsText(),
+                        payload.examUnknownWordsHitStatus().hitWords()));
+    }
+
+    private static AchievementReportContent.Comparison comparison(AchievementExamSprintReportPayload.Comparison comparison) {
+        return new AchievementReportContent.Comparison(
+                comparison.beforeValue(),
+                comparison.afterValue(),
+                comparison.beforeText(),
+                comparison.afterText(),
+                comparison.growthText());
+    }
+}

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

@@ -5,11 +5,14 @@ import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintR
 import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportWithUrlResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
 import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
 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.examsprint.domain.report.ReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportGenerationStatus;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
 import cn.yunzhixue.ability.center.kernel.BusinessException;
 import cn.yunzhixue.ability.center.kernel.ErrorCode;
 import com.fasterxml.jackson.core.JsonProcessingException;
@@ -67,33 +70,33 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     @Override
     public CreateExamSprintReportResponse createOutlookReport(JsonNode payload) {
         validateOutlookPayload(payload);
-        return submitReportGeneration(ReportType.OUTLOOK, payload);
+        return submitReportGeneration(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, payload.deepCopy()));
     }
 
     @Override
     public CreateExamSprintReportResponse createAchievementReport(JsonNode payload) {
-        validateAchievementPayload(payload);
-        return submitReportGeneration(ReportType.ACHIEVEMENT, payload);
+        AchievementReportContent content = validateAchievementPayload(payload);
+        return submitReportGeneration(ReportType.ACHIEVEMENT, content);
     }
 
     @Override
     public CreateExamSprintReportWithUrlResponse createOutlookReportSync(JsonNode payload) {
         validateOutlookPayload(payload);
-        return submitReportGenerationSync(ReportType.OUTLOOK, payload);
+        return submitReportGenerationSync(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, payload.deepCopy()));
     }
 
     @Override
     public CreateExamSprintReportWithUrlResponse createAchievementReportSync(JsonNode payload) {
-        validateAchievementPayload(payload);
-        return submitReportGenerationSync(ReportType.ACHIEVEMENT, payload);
+        AchievementReportContent content = validateAchievementPayload(payload);
+        return submitReportGenerationSync(ReportType.ACHIEVEMENT, content);
     }
 
-    private CreateExamSprintReportResponse submitReportGeneration(ReportType reportType, JsonNode payload) {
+    private CreateExamSprintReportResponse submitReportGeneration(ReportType reportType, ReportContent content) {
         Instant now = clock.instant();
         ExamSprintReport report = ExamSprintReport.pending(
                 UUID.randomUUID().toString(),
                 reportType,
-                payload,
+                content,
                 now,
                 now.plus(properties.getRetention()));
         repository.save(report);
@@ -128,13 +131,13 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
 
     private CreateExamSprintReportWithUrlResponse submitReportGenerationSync(
             ReportType reportType,
-            JsonNode payload) {
+            ReportContent content) {
         long startedNanos = System.nanoTime();
         Instant now = clock.instant();
         ExamSprintReport report = ExamSprintReport.pending(
                 UUID.randomUUID().toString(),
                 reportType,
-                payload,
+                content,
                 now,
                 now.plus(properties.getRetention()));
         repository.save(report);
@@ -387,7 +390,7 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
         }
     }
 
-    private void validateAchievementPayload(JsonNode payload) {
+    private AchievementReportContent validateAchievementPayload(JsonNode payload) {
         validateAchievementPayloadShape(payload);
         AchievementExamSprintReportPayload reportPayload = readPayload(payload, AchievementExamSprintReportPayload.class);
 
@@ -395,6 +398,7 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
         if (!violations.isEmpty()) {
             throw new BusinessException(ErrorCode.VALIDATION_ERROR);
         }
+        return AchievementReportContentMapper.toDomainContent(reportPayload);
     }
 
     private void validateAchievementPayloadShape(JsonNode payload) {

+ 1 - 1
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java

@@ -88,7 +88,7 @@ public class ExamSprintReportGenerationPipeline {
 
             stage = "render_html";
             long renderStartedNanos = System.nanoTime();
-            String html = renderer.render(processingReport.payload(), startedAt);
+            String html = renderer.render(processingReport.content(), startedAt);
             log.info(
                     "exam_sprint_report_generation_stage_completed reportId={} reportType={} stage=render_html durationMs={} htmlLength={}",
                     processingReport.reportId(),

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

@@ -0,0 +1,51 @@
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class AchievementReportContentMapperTest {
+
+    @Test
+    void mapsEveryRendererUsedAchievementFieldToDomainContent() {
+        AchievementReportContent content = AchievementReportContentMapper.toDomainContent(payload());
+
+        assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告");
+        assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
+        assertThat(content.vocabularyComparison().beforeValue()).isEqualTo(2328.0);
+        assertThat(content.vocabularyComparison().afterText()).isEqualTo("2347 词");
+        assertThat(content.paperKnownWordsComparison().growthText()).isEqualTo("+4 个");
+        assertThat(content.examUnknownWordsHitStatus().hitWords())
+                .containsExactly("number", "bear", "popular", "importance");
+    }
+
+    @Test
+    void rejectsNullPayload() {
+        assertThatThrownBy(() -> AchievementReportContentMapper.toDomainContent(null))
+                .isInstanceOf(NullPointerException.class)
+                .hasMessageContaining("payload");
+    }
+
+    private AchievementExamSprintReportPayload payload() {
+        return new AchievementExamSprintReportPayload(
+                "高考英语临考突击学习成果报告",
+                "2024真题 · 两周专项训练 · 真实提分效果",
+                "恭喜完成两周考前突击专项训练",
+                "基于2024英语真题试卷 · 真实学习效果分析",
+                new AchievementExamSprintReportPayload.SummaryMetrics("+19", "+4", "1.93%", "0.48倍"),
+                new AchievementExamSprintReportPayload.Comparison(2328.0, 2347.0, "2328 词", "2347 词", "+19 词"),
+                new AchievementExamSprintReportPayload.Comparison(650.0, 654.0, "650 个", "654 个", "+4 个"),
+                new AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus(
+                        "1.93%",
+                        "0.48倍",
+                        "207 个",
+                        "203 个",
+                        "4 个",
+                        List.of("number", "bear", "popular", "importance")));
+    }
+}

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

@@ -5,13 +5,16 @@ import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintR
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportDetailResponse;
 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.AchievementReportContent;
 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 cn.yunzhixue.ability.center.examsprint.domain.report.ReportGenerationStatus;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
 import cn.yunzhixue.ability.center.kernel.BusinessException;
 import cn.yunzhixue.ability.center.kernel.ErrorCode;
 import com.fasterxml.jackson.databind.JsonNode;
@@ -93,7 +96,11 @@ class ExamSprintReportApplicationServiceTest {
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
         assertThat(saved.reportType()).isEqualTo(ReportType.ACHIEVEMENT);
         assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.PENDING);
-        assertThat(saved.payload().path("reportTitle").asText()).isEqualTo("高考英语临考突击学习成果报告");
+        assertThat(saved.content()).isInstanceOf(AchievementReportContent.class);
+        AchievementReportContent content = (AchievementReportContent) saved.content();
+        assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告");
+        assertThat(content.examUnknownWordsHitStatus().hitWords())
+                .containsExactly("number", "bear", "popular", "importance");
     }
 
     @Test
@@ -271,7 +278,9 @@ class ExamSprintReportApplicationServiceTest {
         payload.withObject("reportMetadata").put("learnerName", "王同学");
 
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
-        assertThat(saved.payload().path("reportMetadata").path("learnerName").asText()).isEqualTo("李同学");
+        UnmodeledReportContent content = (UnmodeledReportContent) saved.content();
+        JsonNode savedPayload = (JsonNode) content.source();
+        assertThat(savedPayload.path("reportMetadata").path("learnerName").asText()).isEqualTo("李同学");
     }
 
     @Test
@@ -281,7 +290,7 @@ class ExamSprintReportApplicationServiceTest {
         ExamSprintReport report = ExamSprintReport.pending(
                         "report-success",
                         ReportType.ACHIEVEMENT,
-                        validAchievementPayload(),
+                        validAchievementContent(),
                         FIXED_CLOCK.instant().minusSeconds(120),
                         FIXED_CLOCK.instant().plusSeconds(3600))
                 .success(
@@ -313,7 +322,7 @@ class ExamSprintReportApplicationServiceTest {
         ExamSprintReport report = ExamSprintReport.pending(
                         "report-query-url-failure",
                         ReportType.ACHIEVEMENT,
-                        validAchievementPayload(),
+                        validAchievementContent(),
                         FIXED_CLOCK.instant().minusSeconds(120),
                         FIXED_CLOCK.instant().plusSeconds(3600))
                 .success(
@@ -343,7 +352,7 @@ class ExamSprintReportApplicationServiceTest {
         repository.save(ExamSprintReport.pending(
                 "report-expired",
                 ReportType.OUTLOOK,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledOutlookContent(),
                 FIXED_CLOCK.instant().minusSeconds(600),
                 FIXED_CLOCK.instant().minusSeconds(1)).success(
                 FIXED_CLOCK.instant().minusSeconds(300),
@@ -370,7 +379,7 @@ class ExamSprintReportApplicationServiceTest {
         repository.save(ExamSprintReport.pending(
                 "report-missing-content",
                 ReportType.OUTLOOK,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledOutlookContent(),
                 FIXED_CLOCK.instant().minusSeconds(600),
                 FIXED_CLOCK.instant().plusSeconds(3600)).success(
                 FIXED_CLOCK.instant().minusSeconds(300),
@@ -398,7 +407,7 @@ class ExamSprintReportApplicationServiceTest {
         repository.save(ExamSprintReport.pending(
                 "report-storage-download-failure",
                 ReportType.OUTLOOK,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledOutlookContent(),
                 FIXED_CLOCK.instant().minusSeconds(600),
                 FIXED_CLOCK.instant().plusSeconds(3600)).success(
                 FIXED_CLOCK.instant().minusSeconds(300),
@@ -425,7 +434,7 @@ class ExamSprintReportApplicationServiceTest {
         repository.save(ExamSprintReport.pending(
                 "report-expired-at-boundary",
                 ReportType.OUTLOOK,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledOutlookContent(),
                 FIXED_CLOCK.instant().minusSeconds(600),
                 FIXED_CLOCK.instant()));
         DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
@@ -444,7 +453,7 @@ class ExamSprintReportApplicationServiceTest {
         repository.save(ExamSprintReport.pending(
                 "report-delete-fails",
                 ReportType.OUTLOOK,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledOutlookContent(),
                 FIXED_CLOCK.instant().minusSeconds(600),
                 FIXED_CLOCK.instant().minusSeconds(1)).success(
                 FIXED_CLOCK.instant().minusSeconds(300),
@@ -453,7 +462,7 @@ class ExamSprintReportApplicationServiceTest {
         repository.save(ExamSprintReport.pending(
                 "report-delete-succeeds",
                 ReportType.OUTLOOK,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledOutlookContent(),
                 FIXED_CLOCK.instant().minusSeconds(600),
                 FIXED_CLOCK.instant().minusSeconds(1)).success(
                 FIXED_CLOCK.instant().minusSeconds(300),
@@ -634,6 +643,15 @@ class ExamSprintReportApplicationServiceTest {
                         List.of("number", "bear", "popular", "importance"))));
     }
 
+    private ReportContent unmodeledOutlookContent() {
+        return new UnmodeledReportContent(ReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode());
+    }
+
+    private AchievementReportContent validAchievementContent() {
+        return AchievementReportContentMapper.toDomainContent(
+                OBJECT_MAPPER.convertValue(validAchievementPayload(), AchievementExamSprintReportPayload.class));
+    }
+
     private static Stream<Arguments> invalidAchievementPayloadJsonTypes() {
         return Stream.of(
                 Arguments.of("reportTitle boolean is rejected", (Consumer<ObjectNode>) payload -> payload.put("reportTitle", true)),
@@ -795,8 +813,11 @@ class ExamSprintReportApplicationServiceTest {
         }
 
         @Override
-        public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
-            return "<html><body>preview:" + payload.path("reportTitle").asText() + ":" + generatedAt + "</body></html>";
+        public String render(ReportContent content, Instant generatedAt) {
+            String title = content instanceof AchievementReportContent achievementContent
+                    ? achievementContent.reportTitle()
+                    : "";
+            return "<html><body>preview:" + title + ":" + generatedAt + "</body></html>";
         }
     }
 
@@ -808,7 +829,7 @@ class ExamSprintReportApplicationServiceTest {
         }
 
         @Override
-        public String render(JsonNode payload, Instant generatedAt) {
+        public String render(ReportContent content, Instant generatedAt) {
             throw new IllegalStateException("renderer exploded");
         }
     }

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

@@ -5,7 +5,9 @@ import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRend
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportGenerationStatus;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -37,7 +39,7 @@ class ExamSprintReportGenerationWorkerTest {
         repository.save(ExamSprintReport.pending(
                 "report-success",
                 ReportType.OUTLOOK,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledOutlookContent(),
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
         TestStorage storage = new TestStorage();
@@ -57,7 +59,7 @@ class ExamSprintReportGenerationWorkerTest {
         repository.save(ExamSprintReport.pending(
                 "report-log-success",
                 ReportType.OUTLOOK,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledOutlookContent(),
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
         TestStorage storage = new TestStorage();
@@ -85,7 +87,7 @@ class ExamSprintReportGenerationWorkerTest {
         repository.save(ExamSprintReport.pending(
                 "report-achievement",
                 ReportType.ACHIEVEMENT,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledAchievementContent(),
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
         TestStorage storage = new TestStorage();
@@ -108,7 +110,7 @@ class ExamSprintReportGenerationWorkerTest {
         repository.save(ExamSprintReport.pending(
                 "report-failed",
                 ReportType.OUTLOOK,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledOutlookContent(),
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
 
@@ -130,7 +132,7 @@ class ExamSprintReportGenerationWorkerTest {
         repository.save(ExamSprintReport.pending(
                 "report-log-failed",
                 ReportType.OUTLOOK,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledOutlookContent(),
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
 
@@ -157,7 +159,7 @@ class ExamSprintReportGenerationWorkerTest {
         repository.save(ExamSprintReport.pending(
                 "report-sensitive-failed",
                 ReportType.OUTLOOK,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledOutlookContent(),
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
 
@@ -184,7 +186,7 @@ class ExamSprintReportGenerationWorkerTest {
         repository.save(ExamSprintReport.pending(
                 "report-deleted",
                 ReportType.OUTLOOK,
-                OBJECT_MAPPER.createObjectNode(),
+                unmodeledOutlookContent(),
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
         TestStorage storage = new TestStorage();
@@ -212,6 +214,14 @@ class ExamSprintReportGenerationWorkerTest {
                         FIXED_CLOCK));
     }
 
+    private ReportContent unmodeledOutlookContent() {
+        return new UnmodeledReportContent(ReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode());
+    }
+
+    private ReportContent unmodeledAchievementContent() {
+        return new UnmodeledReportContent(ReportType.ACHIEVEMENT, OBJECT_MAPPER.createObjectNode());
+    }
+
     private static class TestRenderer implements ExamSprintReportRenderer {
         private final ReportType supportedReportType;
 
@@ -229,7 +239,7 @@ class ExamSprintReportGenerationWorkerTest {
         }
 
         @Override
-        public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
+        public String render(ReportContent content, Instant generatedAt) {
             return "<html><body>ok</body></html>";
         }
     }
@@ -241,7 +251,7 @@ class ExamSprintReportGenerationWorkerTest {
         }
 
         @Override
-        public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
+        public String render(ReportContent content, Instant generatedAt) {
             throw new IllegalStateException("renderer exploded");
         }
     }
@@ -253,7 +263,7 @@ class ExamSprintReportGenerationWorkerTest {
         }
 
         @Override
-        public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
+        public String render(ReportContent content, Instant generatedAt) {
             return "<html><body>SENSITIVE_HTML_DO_NOT_LOG</body></html>";
         }
     }
@@ -265,7 +275,7 @@ class ExamSprintReportGenerationWorkerTest {
         }
 
         @Override
-        public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
+        public String render(ReportContent content, Instant generatedAt) {
             throw new IllegalStateException("<html>SENSITIVE_FAILURE_DO_NOT_LOG</html>");
         }
     }

+ 3 - 2
abilities/exam-sprint/domain/pom.xml

@@ -17,8 +17,9 @@
             <version>${project.version}</version>
         </dependency>
         <dependency>
-            <groupId>com.fasterxml.jackson.core</groupId>
-            <artifactId>jackson-databind</artifactId>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
         </dependency>
     </dependencies>
 

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

@@ -0,0 +1,55 @@
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+import java.util.List;
+import java.util.Objects;
+
+public record AchievementReportContent(
+        String reportTitle,
+        String reportSubtitle,
+        String completionTitle,
+        String completionSubtitle,
+        SummaryMetrics summaryMetrics,
+        Comparison vocabularyComparison,
+        Comparison paperKnownWordsComparison,
+        ExamUnknownWordsHitStatus examUnknownWordsHitStatus) implements ReportContent {
+
+    public AchievementReportContent {
+        Objects.requireNonNull(summaryMetrics, "summaryMetrics");
+        Objects.requireNonNull(vocabularyComparison, "vocabularyComparison");
+        Objects.requireNonNull(paperKnownWordsComparison, "paperKnownWordsComparison");
+        Objects.requireNonNull(examUnknownWordsHitStatus, "examUnknownWordsHitStatus");
+    }
+
+    @Override
+    public ReportType reportType() {
+        return ReportType.ACHIEVEMENT;
+    }
+
+    public record SummaryMetrics(
+            String vocabularyGrowthText,
+            String paperKnownWordsGrowthText,
+            String unknownWordHitRateText,
+            String learningEfficiencyText) {
+    }
+
+    public record Comparison(
+            Double beforeValue,
+            Double afterValue,
+            String beforeText,
+            String afterText,
+            String growthText) {
+    }
+
+    public record ExamUnknownWordsHitStatus(
+            String unknownWordHitRateText,
+            String learningEfficiencyText,
+            String unknownWordsBeforeText,
+            String unknownWordsAfterText,
+            String reducedUnknownWordsText,
+            List<String> hitWords) {
+
+        public ExamUnknownWordsHitStatus {
+            hitWords = hitWords == null ? null : List.copyOf(hitWords);
+        }
+    }
+}

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

@@ -1,13 +1,11 @@
 package cn.yunzhixue.ability.center.examsprint.domain.report;
 
-import com.fasterxml.jackson.databind.JsonNode;
-
 import java.time.Instant;
 
 public record ExamSprintReport(
         String reportId,
         ReportType reportType,
-        JsonNode payload,
+        ReportContent content,
         ReportGenerationStatus generationStatus,
         Instant createdAt,
         Instant updatedAt,
@@ -17,19 +15,21 @@ public record ExamSprintReport(
         String failureReason) {
 
     public ExamSprintReport {
-        payload = copyPayload(payload);
+        if (content != null && reportType != content.reportType()) {
+            throw new IllegalArgumentException("reportType must match content.reportType");
+        }
     }
 
     public static ExamSprintReport pending(
             String reportId,
             ReportType reportType,
-            JsonNode payload,
+            ReportContent content,
             Instant createdAt,
             Instant expiresAt) {
         return new ExamSprintReport(
                 reportId,
                 reportType,
-                payload,
+                content,
                 ReportGenerationStatus.PENDING,
                 createdAt,
                 createdAt,
@@ -43,7 +43,7 @@ public record ExamSprintReport(
         return new ExamSprintReport(
                 reportId,
                 reportType,
-                payload,
+                content,
                 ReportGenerationStatus.PROCESSING,
                 createdAt,
                 updatedAt,
@@ -57,7 +57,7 @@ public record ExamSprintReport(
         return new ExamSprintReport(
                 reportId,
                 reportType,
-                payload,
+                content,
                 ReportGenerationStatus.SUCCESS,
                 createdAt,
                 updatedAt,
@@ -71,7 +71,7 @@ public record ExamSprintReport(
         return new ExamSprintReport(
                 reportId,
                 reportType,
-                payload,
+                content,
                 ReportGenerationStatus.FAILED,
                 createdAt,
                 updatedAt,
@@ -85,7 +85,7 @@ public record ExamSprintReport(
         return new ExamSprintReport(
                 reportId,
                 reportType,
-                payload,
+                content,
                 ReportGenerationStatus.EXPIRED,
                 createdAt,
                 updatedAt,
@@ -99,7 +99,7 @@ public record ExamSprintReport(
         return new ExamSprintReport(
                 reportId,
                 reportType,
-                payload,
+                content,
                 ReportGenerationStatus.EXPIRED,
                 createdAt,
                 updatedAt,
@@ -112,13 +112,4 @@ public record ExamSprintReport(
     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();
-    }
 }

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

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

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

@@ -0,0 +1,6 @@
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+public sealed interface ReportContent permits AchievementReportContent, UnmodeledReportContent {
+
+    ReportType reportType();
+}

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

@@ -0,0 +1,11 @@
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+import java.util.Objects;
+
+public record UnmodeledReportContent(ReportType reportType, Object source) implements ReportContent {
+
+    public UnmodeledReportContent {
+        Objects.requireNonNull(reportType, "reportType");
+        Objects.requireNonNull(source, "source");
+    }
+}

+ 64 - 0
abilities/exam-sprint/domain/src/test/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContentTest.java

@@ -0,0 +1,64 @@
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class AchievementReportContentTest {
+
+    @Test
+    void reportsAchievementTypeAndCopiesHitWords() {
+        List<String> hitWords = new java.util.ArrayList<>(List.of("number", "bear"));
+        AchievementReportContent content = sampleContent(hitWords);
+
+        hitWords.add("mutated");
+
+        assertThat(content.reportType()).isEqualTo(ReportType.ACHIEVEMENT);
+        assertThat(content.examUnknownWordsHitStatus().hitWords())
+                .containsExactly("number", "bear");
+    }
+
+    @Test
+    void rejectsNullRequiredGroups() {
+        assertThatThrownBy(() -> new AchievementReportContent(
+                "title",
+                "subtitle",
+                "completion title",
+                "completion subtitle",
+                null,
+                comparison(),
+                comparison(),
+                hitStatus(List.of("number"))))
+                .isInstanceOf(NullPointerException.class)
+                .hasMessageContaining("summaryMetrics");
+    }
+
+    private AchievementReportContent sampleContent(List<String> hitWords) {
+        return new AchievementReportContent(
+                "高考英语临考突击学习成果报告",
+                "2024真题 · 两周专项训练 · 真实提分效果",
+                "恭喜完成两周考前突击专项训练",
+                "基于2024英语真题试卷 · 真实学习效果分析",
+                new AchievementReportContent.SummaryMetrics("+19", "+4", "1.93%", "0.48倍"),
+                comparison(),
+                comparison(),
+                hitStatus(hitWords));
+    }
+
+    private AchievementReportContent.Comparison comparison() {
+        return new AchievementReportContent.Comparison(2328.0, 2347.0, "2328 词", "2347 词", "+19 词");
+    }
+
+    private AchievementReportContent.ExamUnknownWordsHitStatus hitStatus(List<String> hitWords) {
+        return new AchievementReportContent.ExamUnknownWordsHitStatus(
+                "1.93%",
+                "0.48倍",
+                "207 个",
+                "203 个",
+                "4 个",
+                hitWords);
+    }
+}

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

@@ -1,11 +1,9 @@
 package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.achievement;
 
-import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.core.io.ClassPathResource;
 import org.springframework.stereotype.Component;
 
@@ -26,17 +24,13 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
     private static final String TEMPLATE_RESOURCE = "templates/achievement-exam-sprint-report-template.html";
     private static final Pattern TEMPLATE_PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{([A-Za-z0-9]+)}}");
 
-    private final ObjectMapper objectMapper;
     private final AchievementExamSprintReportSvgChartBuilder chartBuilder;
 
-    @Autowired
-    public ClasspathAchievementExamSprintReportRenderer(ObjectMapper objectMapper) {
-        this(objectMapper, new AchievementExamSprintReportSvgChartBuilder());
+    public ClasspathAchievementExamSprintReportRenderer() {
+        this(new AchievementExamSprintReportSvgChartBuilder());
     }
 
-    ClasspathAchievementExamSprintReportRenderer(ObjectMapper objectMapper,
-                                                 AchievementExamSprintReportSvgChartBuilder chartBuilder) {
-        this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper");
+    ClasspathAchievementExamSprintReportRenderer(AchievementExamSprintReportSvgChartBuilder chartBuilder) {
         this.chartBuilder = Objects.requireNonNull(chartBuilder, "chartBuilder");
     }
 
@@ -46,18 +40,17 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
     }
 
     @Override
-    public String render(JsonNode payload, Instant generatedAt) {
+    public String render(ReportContent content, Instant generatedAt) {
+        if (!(content instanceof AchievementReportContent reportContent)) {
+            throw new IllegalArgumentException("Achievement renderer requires AchievementReportContent");
+        }
         try {
-            AchievementExamSprintReportPayload reportPayload = objectMapper.treeToValue(
-                    payload,
-                    AchievementExamSprintReportPayload.class
-            );
-            AchievementExamSprintReportPayload.SummaryMetrics summary = reportPayload.summaryMetrics();
-            AchievementExamSprintReportPayload.Comparison vocabulary = reportPayload.vocabularyComparison();
-            AchievementExamSprintReportPayload.Comparison paperKnownWords = reportPayload.paperKnownWordsComparison();
-            AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus hitStatus = reportPayload.examUnknownWordsHitStatus();
-
-            return renderTemplate(loadTemplate(), placeholders(reportPayload, summary, vocabulary, paperKnownWords, hitStatus));
+            AchievementReportContent.SummaryMetrics summary = reportContent.summaryMetrics();
+            AchievementReportContent.Comparison vocabulary = reportContent.vocabularyComparison();
+            AchievementReportContent.Comparison paperKnownWords = reportContent.paperKnownWordsComparison();
+            AchievementReportContent.ExamUnknownWordsHitStatus hitStatus = reportContent.examUnknownWordsHitStatus();
+
+            return renderTemplate(loadTemplate(), placeholders(reportContent, summary, vocabulary, paperKnownWords, hitStatus));
         } catch (IOException exception) {
             throw new UncheckedIOException("Failed to load achievement exam sprint report template", exception);
         } catch (Exception exception) {
@@ -65,16 +58,16 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
         }
     }
 
-    private Map<String, String> placeholders(AchievementExamSprintReportPayload reportPayload,
-                                             AchievementExamSprintReportPayload.SummaryMetrics summary,
-                                             AchievementExamSprintReportPayload.Comparison vocabulary,
-                                             AchievementExamSprintReportPayload.Comparison paperKnownWords,
-                                             AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus hitStatus) {
+    private Map<String, String> placeholders(AchievementReportContent reportContent,
+                                             AchievementReportContent.SummaryMetrics summary,
+                                             AchievementReportContent.Comparison vocabulary,
+                                             AchievementReportContent.Comparison paperKnownWords,
+                                             AchievementReportContent.ExamUnknownWordsHitStatus hitStatus) {
         Map<String, String> placeholders = new LinkedHashMap<>();
-        placeholders.put("reportTitle", escape(reportPayload.reportTitle()));
-        placeholders.put("reportSubtitle", escape(reportPayload.reportSubtitle()));
-        placeholders.put("completionTitle", escape(reportPayload.completionTitle()));
-        placeholders.put("completionSubtitle", escape(reportPayload.completionSubtitle()));
+        placeholders.put("reportTitle", escape(reportContent.reportTitle()));
+        placeholders.put("reportSubtitle", escape(reportContent.reportSubtitle()));
+        placeholders.put("completionTitle", escape(reportContent.completionTitle()));
+        placeholders.put("completionSubtitle", escape(reportContent.completionSubtitle()));
         placeholders.put("vocabularyGrowthText", escape(summary.vocabularyGrowthText()));
         placeholders.put("paperKnownWordsGrowthText", escape(summary.paperKnownWordsGrowthText()));
         placeholders.put("unknownWordHitRateText", escape(summary.unknownWordHitRateText()));
@@ -114,7 +107,7 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
         }
     }
 
-    private String renderVocabularyComparisonChart(AchievementExamSprintReportPayload.Comparison comparison) {
+    private String renderVocabularyComparisonChart(AchievementReportContent.Comparison comparison) {
         return chartBuilder.comparisonBarChart(
                 "vocabulary-growth-chart",
                 "词汇量对比",
@@ -128,7 +121,7 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
         );
     }
 
-    private String renderPaperKnownWordsComparisonChart(AchievementExamSprintReportPayload.Comparison comparison) {
+    private String renderPaperKnownWordsComparisonChart(AchievementReportContent.Comparison comparison) {
         return chartBuilder.comparisonBarChart(
                 "paper-known-words-chart",
                 "试卷熟词量对比",
@@ -142,7 +135,7 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
         );
     }
 
-    private String renderHitWords(AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus hitStatus) {
+    private String renderHitWords(AchievementReportContent.ExamUnknownWordsHitStatus hitStatus) {
         if (hitStatus == null || hitStatus.hitWords() == null || hitStatus.hitWords().isEmpty()) {
             return "<div class=\"word-empty\">暂无命中单词</div>";
         }

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

@@ -2,7 +2,9 @@ package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.o
 
 import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookExamSprintReportPayload;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.springframework.core.io.ClassPathResource;
@@ -42,7 +44,12 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
     }
 
     @Override
-    public String render(JsonNode payload, Instant generatedAt) {
+    public String render(ReportContent content, Instant generatedAt) {
+        if (!(content instanceof UnmodeledReportContent unmodeledContent)
+                || unmodeledContent.reportType() != ReportType.OUTLOOK
+                || !(unmodeledContent.source() instanceof JsonNode payload)) {
+            throw new IllegalArgumentException("Outlook renderer requires unmodeled OUTLOOK JsonNode content");
+        }
         try {
             OutlookExamSprintReportPayload reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
             return loadTemplate()

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

@@ -1,5 +1,9 @@
 package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
 
+import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
 import cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.achievement.ClasspathAchievementExamSprintReportRenderer;
 import cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook.ClasspathOutlookExamSprintReportRenderer;
 import com.fasterxml.jackson.databind.JsonNode;
@@ -28,7 +32,7 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
         OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
 
-        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+        String html = renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
         byte[] pdfBytes = pdfGenerator.generate(html);
 
         assertThat(pdfBytes).isNotEmpty();
@@ -61,10 +65,10 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
 
     @Test
     void generateCreatesPdfSmokeWithExtractableAchievementKeyText() throws Exception {
-        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
         OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
 
-        String html = renderer.render(sampleAchievementPayload(), Instant.parse("2026-04-25T08:00:00Z"));
+        String html = renderer.render(sampleAchievementContent(), Instant.parse("2026-04-25T08:00:00Z"));
         byte[] pdfBytes = pdfGenerator.generate(html);
 
         assertThat(pdfBytes).isNotEmpty();
@@ -193,6 +197,45 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
         return Normalizer.normalize(text, Normalizer.Form.NFKC).replaceAll("\\s+", "");
     }
 
+    private UnmodeledReportContent unmodeledOutlookContent(JsonNode payload) {
+        return new UnmodeledReportContent(ReportType.OUTLOOK, payload);
+    }
+
+    private AchievementReportContent sampleAchievementContent() throws Exception {
+        AchievementExamSprintReportPayload payload = OBJECT_MAPPER.treeToValue(
+                sampleAchievementPayload(),
+                AchievementExamSprintReportPayload.class
+        );
+        return new AchievementReportContent(
+                payload.reportTitle(),
+                payload.reportSubtitle(),
+                payload.completionTitle(),
+                payload.completionSubtitle(),
+                new AchievementReportContent.SummaryMetrics(
+                        payload.summaryMetrics().vocabularyGrowthText(),
+                        payload.summaryMetrics().paperKnownWordsGrowthText(),
+                        payload.summaryMetrics().unknownWordHitRateText(),
+                        payload.summaryMetrics().learningEfficiencyText()),
+                comparison(payload.vocabularyComparison()),
+                comparison(payload.paperKnownWordsComparison()),
+                new AchievementReportContent.ExamUnknownWordsHitStatus(
+                        payload.examUnknownWordsHitStatus().unknownWordHitRateText(),
+                        payload.examUnknownWordsHitStatus().learningEfficiencyText(),
+                        payload.examUnknownWordsHitStatus().unknownWordsBeforeText(),
+                        payload.examUnknownWordsHitStatus().unknownWordsAfterText(),
+                        payload.examUnknownWordsHitStatus().reducedUnknownWordsText(),
+                        payload.examUnknownWordsHitStatus().hitWords()));
+    }
+
+    private AchievementReportContent.Comparison comparison(AchievementExamSprintReportPayload.Comparison comparison) {
+        return new AchievementReportContent.Comparison(
+                comparison.beforeValue(),
+                comparison.afterValue(),
+                comparison.beforeText(),
+                comparison.afterText(),
+                comparison.growthText());
+    }
+
     private JsonNode samplePayload() throws Exception {
         return OBJECT_MAPPER.readTree("""
                 {

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

@@ -1,12 +1,14 @@
 package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.achievement;
 
+import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ObjectNode;
 import org.junit.jupiter.api.Test;
 
 import java.time.Instant;
+import java.util.List;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -19,9 +21,9 @@ class ClasspathAchievementExamSprintReportRendererTest {
 
     @Test
     void renderBuildsAchievementHtmlAlignedWithDesignDraft() throws Exception {
-        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
 
-        String html = renderer.render(samplePayload(), Instant.parse("2026-04-25T08:00:00Z"));
+        String html = renderer.render(sampleContent(), Instant.parse("2026-04-25T08:00:00Z"));
 
         assertSectionClassForTitle(html, "模块一:词汇量对比", "section comparison-section");
         assertSectionClassForTitle(html, "模块二:试卷熟词量对比", "section comparison-section");
@@ -98,7 +100,7 @@ class ClasspathAchievementExamSprintReportRendererTest {
 
     @Test
     void supportsOnlyAchievementReportType() {
-        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
 
         assertThat(renderer.supports(ReportType.ACHIEVEMENT)).isTrue();
         assertThat(renderer.supports(ReportType.OUTLOOK)).isFalse();
@@ -106,12 +108,12 @@ class ClasspathAchievementExamSprintReportRendererTest {
 
     @Test
     void renderEscapesPayloadTextAndHitWords() throws Exception {
-        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
-        ObjectNode payload = samplePayload().deepCopy();
-        payload.put("reportTitle", "成果<script>alert(1)</script>");
-        payload.with("examUnknownWordsHitStatus").withArray("hitWords").add("bear<script>");
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
+        AchievementReportContent content = withHitWords(
+                withReportTitle(sampleContent(), "成果<script>alert(1)</script>"),
+                List.of("number", "bear", "popular", "importance", "bear<script>"));
 
-        String html = renderer.render(payload, Instant.parse("2026-04-25T08:00:00Z"));
+        String html = renderer.render(content, Instant.parse("2026-04-25T08:00:00Z"));
 
         assertThat(html)
                 .contains("成果&lt;script&gt;alert(1)&lt;/script&gt;")
@@ -122,11 +124,10 @@ class ClasspathAchievementExamSprintReportRendererTest {
 
     @Test
     void renderShowsEmptyStateWhenHitWordsAreEmpty() throws Exception {
-        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
-        ObjectNode payload = samplePayload().deepCopy();
-        payload.with("examUnknownWordsHitStatus").putArray("hitWords");
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
+        AchievementReportContent content = withHitWords(sampleContent(), List.of());
 
-        String html = renderer.render(payload, Instant.parse("2026-04-25T08:00:00Z"));
+        String html = renderer.render(content, Instant.parse("2026-04-25T08:00:00Z"));
 
         assertThat(html)
                 .contains("class=\"word-empty\"")
@@ -136,11 +137,10 @@ class ClasspathAchievementExamSprintReportRendererTest {
 
     @Test
     void renderShowsEmptyStateWhenHitWordsAreNull() throws Exception {
-        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
-        ObjectNode payload = samplePayload().deepCopy();
-        payload.with("examUnknownWordsHitStatus").putNull("hitWords");
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
+        AchievementReportContent content = withHitWords(sampleContent(), null);
 
-        String html = renderer.render(payload, Instant.parse("2026-04-25T08:00:00Z"));
+        String html = renderer.render(content, Instant.parse("2026-04-25T08:00:00Z"));
 
         assertThat(html)
                 .contains("class=\"word-empty\"")
@@ -150,12 +150,18 @@ class ClasspathAchievementExamSprintReportRendererTest {
 
     @Test
     void renderUsesSafeChartValuesWhenComparisonNumbersAreMissingOrNonFinite() throws Exception {
-        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
-        ObjectNode payload = samplePayload().deepCopy();
-        payload.with("vocabularyComparison").putNull("beforeValue");
-        payload.with("vocabularyComparison").put("afterValue", Double.NaN);
-
-        String html = renderer.render(payload, Instant.parse("2026-04-25T08:00:00Z"));
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
+        AchievementReportContent content = sampleContent();
+        AchievementReportContent mutated = withVocabularyComparison(
+                content,
+                new AchievementReportContent.Comparison(
+                        null,
+                        Double.NaN,
+                        content.vocabularyComparison().beforeText(),
+                        content.vocabularyComparison().afterText(),
+                        content.vocabularyComparison().growthText()));
+
+        String html = renderer.render(mutated, Instant.parse("2026-04-25T08:00:00Z"));
 
         assertThat(html)
                 .contains("class='achievement-bar-chart vocabulary-growth-chart'")
@@ -165,12 +171,18 @@ class ClasspathAchievementExamSprintReportRendererTest {
 
     @Test
     void renderDoesNotExpandPlaceholdersInjectedByPayloadText() throws Exception {
-        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer(OBJECT_MAPPER);
-        ObjectNode payload = samplePayload().deepCopy();
-        payload.put("reportTitle", "{{hitWords}}");
-        payload.with("vocabularyComparison").put("beforeText", "{{hitWords}}");
-
-        String html = renderer.render(payload, Instant.parse("2026-04-25T08:00:00Z"));
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
+        AchievementReportContent content = sampleContent();
+        AchievementReportContent mutated = withVocabularyComparison(
+                withReportTitle(content, "{{hitWords}}"),
+                new AchievementReportContent.Comparison(
+                        content.vocabularyComparison().beforeValue(),
+                        content.vocabularyComparison().afterValue(),
+                        "{{hitWords}}",
+                        content.vocabularyComparison().afterText(),
+                        content.vocabularyComparison().growthText()));
+
+        String html = renderer.render(mutated, Instant.parse("2026-04-25T08:00:00Z"));
 
         assertThat(html)
                 .contains("<h1 class=\"report-title\">{{hitWords}}</h1>")
@@ -178,7 +190,86 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 .contains("class=\"word-item\">number</div>");
     }
 
-    private JsonNode samplePayload() throws Exception {
+    private AchievementReportContent sampleContent() throws Exception {
+        AchievementExamSprintReportPayload payload = OBJECT_MAPPER.treeToValue(
+                samplePayloadJson(),
+                AchievementExamSprintReportPayload.class
+        );
+        return new AchievementReportContent(
+                payload.reportTitle(),
+                payload.reportSubtitle(),
+                payload.completionTitle(),
+                payload.completionSubtitle(),
+                new AchievementReportContent.SummaryMetrics(
+                        payload.summaryMetrics().vocabularyGrowthText(),
+                        payload.summaryMetrics().paperKnownWordsGrowthText(),
+                        payload.summaryMetrics().unknownWordHitRateText(),
+                        payload.summaryMetrics().learningEfficiencyText()),
+                comparison(payload.vocabularyComparison()),
+                comparison(payload.paperKnownWordsComparison()),
+                new AchievementReportContent.ExamUnknownWordsHitStatus(
+                        payload.examUnknownWordsHitStatus().unknownWordHitRateText(),
+                        payload.examUnknownWordsHitStatus().learningEfficiencyText(),
+                        payload.examUnknownWordsHitStatus().unknownWordsBeforeText(),
+                        payload.examUnknownWordsHitStatus().unknownWordsAfterText(),
+                        payload.examUnknownWordsHitStatus().reducedUnknownWordsText(),
+                        payload.examUnknownWordsHitStatus().hitWords()));
+    }
+
+    private AchievementReportContent.Comparison comparison(AchievementExamSprintReportPayload.Comparison comparison) {
+        return new AchievementReportContent.Comparison(
+                comparison.beforeValue(),
+                comparison.afterValue(),
+                comparison.beforeText(),
+                comparison.afterText(),
+                comparison.growthText());
+    }
+
+    private AchievementReportContent withReportTitle(AchievementReportContent content, String reportTitle) {
+        return new AchievementReportContent(
+                reportTitle,
+                content.reportSubtitle(),
+                content.completionTitle(),
+                content.completionSubtitle(),
+                content.summaryMetrics(),
+                content.vocabularyComparison(),
+                content.paperKnownWordsComparison(),
+                content.examUnknownWordsHitStatus());
+    }
+
+    private AchievementReportContent withVocabularyComparison(AchievementReportContent content,
+                                                              AchievementReportContent.Comparison vocabularyComparison) {
+        return new AchievementReportContent(
+                content.reportTitle(),
+                content.reportSubtitle(),
+                content.completionTitle(),
+                content.completionSubtitle(),
+                content.summaryMetrics(),
+                vocabularyComparison,
+                content.paperKnownWordsComparison(),
+                content.examUnknownWordsHitStatus());
+    }
+
+    private AchievementReportContent withHitWords(AchievementReportContent content, List<String> hitWords) {
+        AchievementReportContent.ExamUnknownWordsHitStatus hitStatus = content.examUnknownWordsHitStatus();
+        return new AchievementReportContent(
+                content.reportTitle(),
+                content.reportSubtitle(),
+                content.completionTitle(),
+                content.completionSubtitle(),
+                content.summaryMetrics(),
+                content.vocabularyComparison(),
+                content.paperKnownWordsComparison(),
+                new AchievementReportContent.ExamUnknownWordsHitStatus(
+                        hitStatus.unknownWordHitRateText(),
+                        hitStatus.learningEfficiencyText(),
+                        hitStatus.unknownWordsBeforeText(),
+                        hitStatus.unknownWordsAfterText(),
+                        hitStatus.reducedUnknownWordsText(),
+                        hitWords));
+    }
+
+    private JsonNode samplePayloadJson() throws Exception {
         return OBJECT_MAPPER.readTree("""
                 {
                   "reportTitle": "高考英语临考突击学习成果报告",

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

@@ -1,6 +1,7 @@
 package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook;
 
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -28,7 +29,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
     void renderBuildsOutlookHtmlAlignedWithDesignDraftDynamicStructure() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
-        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+        String html = renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
                 .contains("class=\"analysis-table\"")
@@ -83,7 +84,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
     void renderAddsAxisTicksToThreeBarCharts() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
-        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+        String html = renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
                 .contains("class='chart-axis-tick chart-axis-tick-y'")
@@ -97,7 +98,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
     void renderDeclaresBatikCjkFontFamilyOnEveryInlineSvg() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
-        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+        String html = renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
                 .contains("<svg class='syllabus-donut-chart' font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"")
@@ -121,7 +122,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
         TrackingObjectMapper objectMapper = new TrackingObjectMapper();
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(objectMapper);
 
-        renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+        renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(objectMapper.treeToValueCalled).isTrue();
     }
@@ -135,7 +136,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
         try {
             Thread.currentThread().setContextClassLoader(trackingClassLoader);
 
-            renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+            renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
         } finally {
             Thread.currentThread().setContextClassLoader(originalClassLoader);
         }
@@ -148,7 +149,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
         JsonNode payload = payloadWithEscapingSamples();
-        String html = renderer.render(payload, Instant.parse("2026-01-03T08:00:00Z"));
+        String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
                 .contains("李&lt;同学&gt;")
@@ -165,7 +166,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
         JsonNode payload = payloadWithInvalidThemeColors();
-        String html = renderer.render(payload, Instant.parse("2026-01-03T08:00:00Z"));
+        String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
                 .containsPattern("class='chart-column high-band-column'[^>]*fill='#448aff'")
@@ -180,7 +181,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
         JsonNode payload = payloadWithProgressPercent(100, 100.0);
-        String html = renderer.render(payload, Instant.parse("2026-01-03T08:00:00Z"));
+        String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
                 .contains("class='donut-mastered-full-circle'")
@@ -194,7 +195,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
         JsonNode payload = payloadWithProgressPercent(0, 0.0);
-        String html = renderer.render(payload, Instant.parse("2026-01-03T08:00:00Z"));
+        String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
                 .doesNotContain("class='donut-mastered-arc'")
@@ -343,6 +344,10 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 """);
     }
 
+    private UnmodeledReportContent unmodeledOutlookContent(JsonNode payload) {
+        return new UnmodeledReportContent(ReportType.OUTLOOK, payload);
+    }
+
     private List<String> svgStartTags(String html) {
         Matcher matcher = SVG_START_TAG_PATTERN.matcher(html);
         List<String> svgStartTags = new ArrayList<>();

+ 1 - 19
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java

@@ -1,13 +1,9 @@
 package cn.yunzhixue.ability.center.architecture;
 
-import com.tngtech.archunit.core.domain.JavaClass;
 import com.tngtech.archunit.core.importer.ImportOption;
 import com.tngtech.archunit.junit.AnalyzeClasses;
 import com.tngtech.archunit.junit.ArchTest;
 import com.tngtech.archunit.lang.ArchRule;
-import com.tngtech.archunit.base.DescribedPredicate;
-
-import java.util.Set;
 
 import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
 
@@ -16,10 +12,6 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
         importOptions = ImportOption.DoNotIncludeTests.class)
 class ExamSprintArchitectureTest {
 
-    private static final Set<String> CURRENT_DOMAIN_JACKSON_DEBT = Set.of(
-            "ExamSprintReport",
-            "ExamSprintReportRenderer");
-
     @ArchTest
     static final ArchRule contracts_should_not_depend_on_inner_layers = noClasses()
             .that().resideInAPackage("..contracts..")
@@ -44,19 +36,9 @@ class ExamSprintArchitectureTest {
             .should().dependOnClassesThat().resideInAPackage("..contracts..");
 
     @ArchTest
-    static final ArchRule new_domain_classes_should_not_depend_on_jackson = noClasses()
+    static final ArchRule domain_should_not_depend_on_jackson = noClasses()
             .that().resideInAPackage("..domain..")
-            .and(areNotNamed(CURRENT_DOMAIN_JACKSON_DEBT))
             .should().dependOnClassesThat().resideInAnyPackage(
                     "com.fasterxml.jackson.."
             );
-
-    private static DescribedPredicate<JavaClass> areNotNamed(Set<String> simpleNames) {
-        return new DescribedPredicate<>("are not named " + simpleNames) {
-            @Override
-            public boolean test(JavaClass input) {
-                return !simpleNames.contains(input.getSimpleName());
-            }
-        };
-    }
 }

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

@@ -158,7 +158,7 @@ class ExamSprintReportControllerTest {
         reportRepository.save(new ExamSprintReport(
                 report.reportId(),
                 report.reportType(),
-                report.payload(),
+                report.content(),
                 report.generationStatus(),
                 report.createdAt(),
                 report.updatedAt(),

+ 1266 - 0
docs/superpowers/plans/2026-04-28-ddd-naming-governance-jsonnode-payload-loop.md

@@ -0,0 +1,1266 @@
+# DDD Naming Governance JsonNode Payload Loop Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Complete a small third DDD naming governance loop by introducing `AchievementReportContent` as a domain-owned content concept for the `ACHIEVEMENT` report path and removing direct Jackson / `JsonNode` usage from `exam-sprint-domain` main source without changing external HTTP contracts.
+
+**Architecture:** Keep public request/response DTOs, HTTP JSON fields, paths, status codes, and public enum literals unchanged. The application boundary still accepts external `JsonNode`, validates contract payload shape, and converts the selected `ACHIEVEMENT` payload into domain `AchievementReportContent`; the legacy `OUTLOOK` path remains explicitly wrapped as transitional unmodeled content with an exit condition. Domain owns the `ReportContent` abstraction and `AchievementReportContent`, while infrastructure renderers adapt domain content into HTML without moving `Storage` / `Renderer` / `PdfGenerator` or splitting `DefaultExamSprintReportApplicationService`.
+
+**Tech Stack:** Java 17 source level, Maven multi-module build, Spring Boot 3.3.5, JUnit 5, AssertJ, ArchUnit, Jackson at application/infrastructure/runtime boundaries only.
+
+---
+
+> **Execution note:** Do not create git commits unless the user explicitly asks for commits. Treat each “commit checkpoint” below as a local verification checkpoint until commit permission is given.
+
+> **Scope guard:** This loop only models the `ACHIEVEMENT` report content. Do not migrate `OUTLOOK` payload records, do not rename `ExamSprintReport`, do not move `Storage` / `Renderer` / `PdfGenerator`, do not split `DefaultExamSprintReportApplicationService`, do not change `ReportGenerationStatus` / `ReportType`, and do not edit public contract enum names or DTO field names.
+
+> **Initial worktree baseline:** The isolated worktree is `/Users/exiao/Codes/dcjxb.microservice/.worktrees/refactor-ddd-jsonnode-payload-loop` on branch `refactor/ddd命名治理三轮-jsonnode-payload`. Before writing this plan, `git status --short` was empty in the source worktree, a new worktree was created from `master`, and `mvn -q test` was run in the new worktree without a tool-reported failure; Maven output was truncated because of normal test logs.
+
+## Current Research Findings
+
+### Current domain Jackson / `JsonNode` usage
+
+Command:
+
+```bash
+rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
+```
+
+Current matches:
+
+```text
+abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java:import com.fasterxml.jackson.databind.JsonNode;
+abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java:    String render(JsonNode payload, Instant generatedAt);
+abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java:import com.fasterxml.jackson.databind.JsonNode;
+abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java:        JsonNode payload,
+abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java:            JsonNode payload,
+abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java:    public JsonNode payload() {
+abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java:    private static JsonNode copyPayload(JsonNode payload) {
+```
+
+Active domain Jackson debt is exactly two domain classes:
+
+- `ExamSprintReport`
+- `ExamSprintReportRenderer`
+
+### Current payload / render conversion chain
+
+Command:
+
+```bash
+rg "payload\(|render\(|treeToValue|valueToTree|JsonNode" "abilities/exam-sprint"
+```
+
+Important chain:
+
+```text
+runtime HTTP controller -> ExamSprintReportApplicationService accepts JsonNode
+DefaultExamSprintReportApplicationService.validateAchievementPayload(JsonNode)
+DefaultExamSprintReportApplicationService.readPayload(JsonNode, AchievementExamSprintReportPayload.class)
+ExamSprintReport.pending(..., JsonNode payload, ...)
+ExamSprintReportGenerationPipeline.generate(...)
+ExamSprintReportGenerationPipeline calls renderer.render(processingReport.payload(), startedAt)
+ClasspathAchievementExamSprintReportRenderer.render(JsonNode, Instant)
+ClasspathAchievementExamSprintReportRenderer objectMapper.treeToValue(payload, AchievementExamSprintReportPayload.class)
+```
+
+The same pattern exists for `OUTLOOK`, but `OUTLOOK` has a larger payload and renderer surface.
+
+### Current ArchUnit Jackson allowlist
+
+Command:
+
+```bash
+rg "CURRENT_DOMAIN_JACKSON_DEBT|domain_should_not_depend_on_jackson|new_domain_classes_should_not_depend_on_jackson" "ability-center-runtime/src/test/java"
+```
+
+Current allowlist:
+
+```java
+private static final Set<String> CURRENT_DOMAIN_JACKSON_DEBT = Set.of(
+        "ExamSprintReport",
+        "ExamSprintReportRenderer");
+
+@ArchTest
+static final ArchRule new_domain_classes_should_not_depend_on_jackson = noClasses()
+        .that().resideInAPackage("..domain..")
+        .and(areNotNamed(CURRENT_DOMAIN_JACKSON_DEBT))
+        .should().dependOnClassesThat().resideInAnyPackage(
+                "com.fasterxml.jackson.."
+        );
+```
+
+### Trial report type selection
+
+Use `ACHIEVEMENT` as the trial report type.
+
+Reasons:
+
+1. `AchievementExamSprintReportPayload` is smaller than `OutlookExamSprintReportPayload`: 4 top-level text fields plus 4 nested content groups, compared with the much larger `OUTLOOK` payload with charts, frequency plans, case studies, color handling, and more renderer-specific edge cases.
+2. `ClasspathAchievementExamSprintReportRenderer` already uses a simple placeholder map and a separate SVG chart builder, so replacing contract payload DTO usage with domain content has a smaller blast radius.
+3. Existing application and runtime tests already exercise `ACHIEVEMENT` sync generation and public JSON compatibility.
+4. This slice can remove domain compile-time Jackson debt while leaving `OUTLOOK` JSON conversion as an explicitly named transitional compatibility path.
+
+## Target File Structure
+
+### Create
+
+- `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ReportContent.java`
+  - Domain-owned report content marker with `ReportType reportType()`; no Jackson imports.
+- `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java`
+  - Strongly named domain content for the `ACHIEVEMENT` report; fields mirror only what the current achievement renderer needs.
+- `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/UnmodeledReportContent.java`
+  - Transitional wrapper for report types not modeled in this loop; stores an opaque boundary-prepared object and has an explicit exit condition in this plan.
+- `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java`
+  - Package-private application boundary mapper from contract `AchievementExamSprintReportPayload` to domain `AchievementReportContent`.
+- `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java`
+  - TDD test for mapping every renderer-used field and null handling.
+
+### Modify
+
+- `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java`
+  - Replace `JsonNode payload` with `ReportContent content`; keep `reportType` and lifecycle methods unchanged.
+- `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java`
+  - Change `render(JsonNode payload, Instant generatedAt)` to `render(ReportContent content, Instant generatedAt)`.
+- `abilities/exam-sprint/domain/pom.xml`
+  - Remove `jackson-databind` after domain source no longer imports Jackson.
+- `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java`
+  - Convert `ACHIEVEMENT` JSON/contracts payload to `AchievementReportContent`; wrap `OUTLOOK` JSON as `UnmodeledReportContent` for this loop.
+- `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java`
+  - Pass `processingReport.content()` into the renderer.
+- `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
+  - Assert domain `AchievementReportContent` for achievement saves; keep public response assertions on contracts enums/DTOs.
+- `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java`
+  - Update test renderers and report construction to `ReportContent`.
+- `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java`
+  - Render from `AchievementReportContent`; remove contract payload DTO and `ObjectMapper.treeToValue(...)` from the selected path.
+- `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java`
+  - Continue using `OutlookExamSprintReportPayload` and Jackson inside infrastructure by unwrapping `UnmodeledReportContent` for `OUTLOOK` only.
+- `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java`
+  - Convert sample JSON to domain content in the test boundary and call `render(AchievementReportContent, Instant)`.
+- `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java`
+  - Wrap sample `JsonNode` as `UnmodeledReportContent` before rendering.
+- `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java`
+  - Wrap sample content when rendering before PDF generation.
+- `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java`
+  - Remove the Jackson allowlist and make domain -> Jackson a hard rule.
+- `docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md`
+  - Record the third-loop result and remaining content modeling debt.
+
+### Keep unchanged
+
+- `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java`
+  - Public payload record name and JSON field names remain unchanged.
+- `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java`
+  - Not migrated in this loop.
+- `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/ExamSprintReportType.java`
+  - Public enum remains `OUTLOOK`, `ACHIEVEMENT`.
+- `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationService.java`
+  - Public application boundary still accepts `JsonNode` because runtime HTTP JSON remains unchanged.
+- `abilities/exam-sprint/infrastructure/pom.xml`
+  - Keep `exam-sprint-contracts` and Jackson dependencies because infrastructure still renders legacy `OUTLOOK` from contract DTOs.
+- `DefaultExamSprintReportApplicationService`, `Storage`, `Renderer`, and `PdfGenerator` locations and names.
+
+## Task 1: Establish current baseline and active debt
+
+**Files:**
+- Verify: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java`
+- Verify: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java`
+- Verify: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java`
+
+- [ ] **Step 1: Confirm branch and clean worktree**
+
+Run:
+
+```bash
+git status --short
+git status -sb
+```
+
+Expected: branch is `refactor/ddd命名治理三轮-jsonnode-payload`; before implementation edits, `git status --short` shows only this plan document as untracked or modified.
+
+- [ ] **Step 2: Search current domain Jackson usage**
+
+Run:
+
+```bash
+rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
+```
+
+Expected current matches are only `ExamSprintReport` and `ExamSprintReportRenderer`.
+
+- [ ] **Step 3: Search payload/render conversion chain**
+
+Run:
+
+```bash
+rg "payload\(|render\(|treeToValue|valueToTree|JsonNode" "abilities/exam-sprint"
+```
+
+Expected: application accepts `JsonNode`; application validates using contract payload classes; pipeline currently renders `processingReport.payload()`; infrastructure renderers currently deserialize `JsonNode` with `treeToValue(...)`.
+
+- [ ] **Step 4: Check current ArchUnit Jackson allowlist**
+
+Run:
+
+```bash
+rg "CURRENT_DOMAIN_JACKSON_DEBT|domain_should_not_depend_on_jackson|new_domain_classes_should_not_depend_on_jackson" "ability-center-runtime/src/test/java"
+```
+
+Expected: allowlist contains `ExamSprintReport` and `ExamSprintReportRenderer`.
+
+- [ ] **Step 5: Run full baseline tests**
+
+Run:
+
+```bash
+mvn -q test
+```
+
+Expected: PASS in this worktree. If this fails before implementation, stop and record the exact failing module/test before continuing.
+
+## Task 2: Add domain report content concepts with tests first
+
+**Files:**
+- Create: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ReportContent.java`
+- Create: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java`
+- Create: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/UnmodeledReportContent.java`
+- Create: `abilities/exam-sprint/domain/src/test/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContentTest.java`
+
+- [ ] **Step 1: Write the failing domain content test**
+
+Create `AchievementReportContentTest.java`:
+
+```java
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class AchievementReportContentTest {
+
+    @Test
+    void reportsAchievementTypeAndCopiesHitWords() {
+        List<String> hitWords = new java.util.ArrayList<>(List.of("number", "bear"));
+        AchievementReportContent content = sampleContent(hitWords);
+
+        hitWords.add("mutated");
+
+        assertThat(content.reportType()).isEqualTo(ReportType.ACHIEVEMENT);
+        assertThat(content.examUnknownWordsHitStatus().hitWords())
+                .containsExactly("number", "bear");
+    }
+
+    @Test
+    void rejectsNullRequiredGroups() {
+        assertThatThrownBy(() -> new AchievementReportContent(
+                "title",
+                "subtitle",
+                "completion title",
+                "completion subtitle",
+                null,
+                comparison(),
+                comparison(),
+                hitStatus(List.of("number"))))
+                .isInstanceOf(NullPointerException.class)
+                .hasMessageContaining("summaryMetrics");
+    }
+
+    private AchievementReportContent sampleContent(List<String> hitWords) {
+        return new AchievementReportContent(
+                "高考英语临考突击学习成果报告",
+                "2024真题 · 两周专项训练 · 真实提分效果",
+                "恭喜完成两周考前突击专项训练",
+                "基于2024英语真题试卷 · 真实学习效果分析",
+                new AchievementReportContent.SummaryMetrics("+19", "+4", "1.93%", "0.48倍"),
+                comparison(),
+                comparison(),
+                hitStatus(hitWords));
+    }
+
+    private AchievementReportContent.Comparison comparison() {
+        return new AchievementReportContent.Comparison(2328.0, 2347.0, "2328 词", "2347 词", "+19 词");
+    }
+
+    private AchievementReportContent.ExamUnknownWordsHitStatus hitStatus(List<String> hitWords) {
+        return new AchievementReportContent.ExamUnknownWordsHitStatus(
+                "1.93%",
+                "0.48倍",
+                "207 个",
+                "203 个",
+                "4 个",
+                hitWords);
+    }
+}
+```
+
+- [ ] **Step 2: Run the domain test and verify RED**
+
+Run:
+
+```bash
+mvn -q -pl abilities/exam-sprint/domain -am -Dtest=AchievementReportContentTest test
+```
+
+Expected: FAIL because `AchievementReportContent` does not exist.
+
+- [ ] **Step 3: Add `ReportContent`**
+
+Create `ReportContent.java`:
+
+```java
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+public sealed interface ReportContent permits AchievementReportContent, UnmodeledReportContent {
+
+    ReportType reportType();
+}
+```
+
+- [ ] **Step 4: Add `AchievementReportContent`**
+
+Create `AchievementReportContent.java`:
+
+```java
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+import java.util.List;
+import java.util.Objects;
+
+public record AchievementReportContent(
+        String reportTitle,
+        String reportSubtitle,
+        String completionTitle,
+        String completionSubtitle,
+        SummaryMetrics summaryMetrics,
+        Comparison vocabularyComparison,
+        Comparison paperKnownWordsComparison,
+        ExamUnknownWordsHitStatus examUnknownWordsHitStatus) implements ReportContent {
+
+    public AchievementReportContent {
+        Objects.requireNonNull(summaryMetrics, "summaryMetrics");
+        Objects.requireNonNull(vocabularyComparison, "vocabularyComparison");
+        Objects.requireNonNull(paperKnownWordsComparison, "paperKnownWordsComparison");
+        Objects.requireNonNull(examUnknownWordsHitStatus, "examUnknownWordsHitStatus");
+    }
+
+    @Override
+    public ReportType reportType() {
+        return ReportType.ACHIEVEMENT;
+    }
+
+    public record SummaryMetrics(
+            String vocabularyGrowthText,
+            String paperKnownWordsGrowthText,
+            String unknownWordHitRateText,
+            String learningEfficiencyText) {
+    }
+
+    public record Comparison(
+            Double beforeValue,
+            Double afterValue,
+            String beforeText,
+            String afterText,
+            String growthText) {
+    }
+
+    public record ExamUnknownWordsHitStatus(
+            String unknownWordHitRateText,
+            String learningEfficiencyText,
+            String unknownWordsBeforeText,
+            String unknownWordsAfterText,
+            String reducedUnknownWordsText,
+            List<String> hitWords) {
+
+        public ExamUnknownWordsHitStatus {
+            hitWords = hitWords == null ? null : List.copyOf(hitWords);
+        }
+    }
+}
+```
+
+- [ ] **Step 5: Add transitional `UnmodeledReportContent`**
+
+Create `UnmodeledReportContent.java`:
+
+```java
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+import java.util.Objects;
+
+public record UnmodeledReportContent(ReportType reportType, Object source) implements ReportContent {
+
+    public UnmodeledReportContent {
+        Objects.requireNonNull(reportType, "reportType");
+        Objects.requireNonNull(source, "source");
+    }
+}
+```
+
+Exit condition: remove `UnmodeledReportContent` after a later loop introduces `OutlookReportContent` and the `OUTLOOK` renderer no longer needs JSON-backed content.
+
+- [ ] **Step 6: Run domain content tests**
+
+Run:
+
+```bash
+mvn -q -pl abilities/exam-sprint/domain -am -Dtest=AchievementReportContentTest test
+```
+
+Expected: PASS.
+
+## Task 3: Convert application boundary from contract payload to domain content
+
+**Files:**
+- Create: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java`
+- Create: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java`
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java`
+
+- [ ] **Step 1: Write mapper test first**
+
+Create `AchievementReportContentMapperTest.java`:
+
+```java
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class AchievementReportContentMapperTest {
+
+    @Test
+    void mapsEveryRendererUsedAchievementFieldToDomainContent() {
+        AchievementReportContent content = AchievementReportContentMapper.toDomainContent(payload());
+
+        assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告");
+        assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
+        assertThat(content.vocabularyComparison().beforeValue()).isEqualTo(2328.0);
+        assertThat(content.vocabularyComparison().afterText()).isEqualTo("2347 词");
+        assertThat(content.paperKnownWordsComparison().growthText()).isEqualTo("+4 个");
+        assertThat(content.examUnknownWordsHitStatus().hitWords())
+                .containsExactly("number", "bear", "popular", "importance");
+    }
+
+    @Test
+    void rejectsNullPayload() {
+        assertThatThrownBy(() -> AchievementReportContentMapper.toDomainContent(null))
+                .isInstanceOf(NullPointerException.class)
+                .hasMessageContaining("payload");
+    }
+
+    private AchievementExamSprintReportPayload payload() {
+        return new AchievementExamSprintReportPayload(
+                "高考英语临考突击学习成果报告",
+                "2024真题 · 两周专项训练 · 真实提分效果",
+                "恭喜完成两周考前突击专项训练",
+                "基于2024英语真题试卷 · 真实学习效果分析",
+                new AchievementExamSprintReportPayload.SummaryMetrics("+19", "+4", "1.93%", "0.48倍"),
+                new AchievementExamSprintReportPayload.Comparison(2328.0, 2347.0, "2328 词", "2347 词", "+19 词"),
+                new AchievementExamSprintReportPayload.Comparison(650.0, 654.0, "650 个", "654 个", "+4 个"),
+                new AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus(
+                        "1.93%",
+                        "0.48倍",
+                        "207 个",
+                        "203 个",
+                        "4 个",
+                        List.of("number", "bear", "popular", "importance")));
+    }
+}
+```
+
+- [ ] **Step 2: Run mapper test and verify RED**
+
+Run:
+
+```bash
+mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest test
+```
+
+Expected: FAIL because `AchievementReportContentMapper` does not exist.
+
+- [ ] **Step 3: Implement mapper**
+
+Create `AchievementReportContentMapper.java`:
+
+```java
+package cn.yunzhixue.ability.center.examsprint.application.report;
+
+import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
+
+import java.util.Objects;
+
+final class AchievementReportContentMapper {
+
+    private AchievementReportContentMapper() {
+    }
+
+    static AchievementReportContent toDomainContent(AchievementExamSprintReportPayload payload) {
+        Objects.requireNonNull(payload, "payload");
+        return new AchievementReportContent(
+                payload.reportTitle(),
+                payload.reportSubtitle(),
+                payload.completionTitle(),
+                payload.completionSubtitle(),
+                new AchievementReportContent.SummaryMetrics(
+                        payload.summaryMetrics().vocabularyGrowthText(),
+                        payload.summaryMetrics().paperKnownWordsGrowthText(),
+                        payload.summaryMetrics().unknownWordHitRateText(),
+                        payload.summaryMetrics().learningEfficiencyText()),
+                comparison(payload.vocabularyComparison()),
+                comparison(payload.paperKnownWordsComparison()),
+                new AchievementReportContent.ExamUnknownWordsHitStatus(
+                        payload.examUnknownWordsHitStatus().unknownWordHitRateText(),
+                        payload.examUnknownWordsHitStatus().learningEfficiencyText(),
+                        payload.examUnknownWordsHitStatus().unknownWordsBeforeText(),
+                        payload.examUnknownWordsHitStatus().unknownWordsAfterText(),
+                        payload.examUnknownWordsHitStatus().reducedUnknownWordsText(),
+                        payload.examUnknownWordsHitStatus().hitWords()));
+    }
+
+    private static AchievementReportContent.Comparison comparison(AchievementExamSprintReportPayload.Comparison comparison) {
+        return new AchievementReportContent.Comparison(
+                comparison.beforeValue(),
+                comparison.afterValue(),
+                comparison.beforeText(),
+                comparison.afterText(),
+                comparison.growthText());
+    }
+}
+```
+
+- [ ] **Step 4: Run mapper test GREEN**
+
+Run:
+
+```bash
+mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest test
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Change achievement validation to return domain content**
+
+In `DefaultExamSprintReportApplicationService`, change achievement entry methods to preserve validation and conversion at the application boundary:
+
+```java
+public CreateExamSprintReportResponse createAchievementReport(JsonNode payload) {
+    AchievementReportContent content = validateAchievementPayload(payload);
+    return submitReportGeneration(ReportType.ACHIEVEMENT, content);
+}
+
+public CreateExamSprintReportWithUrlResponse createAchievementReportSync(JsonNode payload) {
+    AchievementReportContent content = validateAchievementPayload(payload);
+    return submitReportGenerationSync(ReportType.ACHIEVEMENT, content);
+}
+```
+
+Change outlook entry methods to wrap boundary-prepared JSON without modeling it in this loop:
+
+```java
+public CreateExamSprintReportResponse createOutlookReport(JsonNode payload) {
+    validateOutlookPayload(payload);
+    return submitReportGeneration(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, payload.deepCopy()));
+}
+
+public CreateExamSprintReportWithUrlResponse createOutlookReportSync(JsonNode payload) {
+    validateOutlookPayload(payload);
+    return submitReportGenerationSync(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, payload.deepCopy()));
+}
+```
+
+Change private submit methods from `JsonNode payload` to `ReportContent content`, and pass `content` to `ExamSprintReport.pending(...)`.
+
+Change `validateAchievementPayload` to:
+
+```java
+private AchievementReportContent validateAchievementPayload(JsonNode payload) {
+    validateAchievementPayloadShape(payload);
+    AchievementExamSprintReportPayload reportPayload = readPayload(payload, AchievementExamSprintReportPayload.class);
+
+    Set<ConstraintViolation<AchievementExamSprintReportPayload>> violations = validator.validate(reportPayload);
+    if (!violations.isEmpty()) {
+        throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+    }
+    return AchievementReportContentMapper.toDomainContent(reportPayload);
+}
+```
+
+- [ ] **Step 6: Run application tests to expose compile failures before domain model migration is complete**
+
+Run:
+
+```bash
+mvn -q -pl abilities/exam-sprint/application -am test
+```
+
+Expected: FAIL until `ExamSprintReport` accepts `ReportContent` and tests are updated. Record the first compile failure as the expected red state for the next task.
+
+## Task 4: Replace domain `JsonNode` with domain `ReportContent`
+
+**Files:**
+- Modify: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReport.java`
+- Modify: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/ExamSprintReportRenderer.java`
+- Modify: `abilities/exam-sprint/domain/pom.xml`
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java`
+
+- [ ] **Step 1: Update `ExamSprintReport`**
+
+Remove:
+
+```java
+import com.fasterxml.jackson.databind.JsonNode;
+```
+
+Change the record field and factory parameter to:
+
+```java
+public record ExamSprintReport(
+        String reportId,
+        ReportType reportType,
+        ReportContent content,
+        ReportGenerationStatus generationStatus,
+        Instant createdAt,
+        Instant updatedAt,
+        Instant expiresAt,
+        String storageObjectKey,
+        String fileName,
+        String failureReason) {
+
+    public ExamSprintReport {
+        if (content != null && reportType != content.reportType()) {
+            throw new IllegalArgumentException("reportType must match content.reportType");
+        }
+    }
+
+    public static ExamSprintReport pending(
+            String reportId,
+            ReportType reportType,
+            ReportContent content,
+            Instant createdAt,
+            Instant expiresAt) {
+```
+
+Replace all constructor arguments currently passing `payload` with `content`. Delete the overridden `payload()` accessor and `copyPayload(...)` helper.
+
+- [ ] **Step 2: Update renderer port**
+
+Change `ExamSprintReportRenderer.java` to:
+
+```java
+package cn.yunzhixue.ability.center.examsprint.domain.report;
+
+import java.time.Instant;
+
+public interface ExamSprintReportRenderer {
+
+    boolean supports(ReportType reportType);
+
+    String render(ReportContent content, Instant generatedAt);
+}
+```
+
+- [ ] **Step 3: Update pipeline render call**
+
+In `ExamSprintReportGenerationPipeline`, replace:
+
+```java
+String html = renderer.render(processingReport.payload(), startedAt);
+```
+
+with:
+
+```java
+String html = renderer.render(processingReport.content(), startedAt);
+```
+
+- [ ] **Step 4: Remove domain Jackson dependency**
+
+In `abilities/exam-sprint/domain/pom.xml`, remove only this dependency block:
+
+```xml
+<dependency>
+    <groupId>com.fasterxml.jackson.core</groupId>
+    <artifactId>jackson-databind</artifactId>
+</dependency>
+```
+
+- [ ] **Step 5: Verify domain source no longer references Jackson**
+
+Run:
+
+```bash
+mvn -q -pl abilities/exam-sprint/domain -am test
+rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
+```
+
+Expected: domain tests PASS; `rg` returns no matches in domain main source.
+
+## Task 5: Adapt application tests and selected application assertions
+
+**Files:**
+- Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
+- Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java`
+
+- [ ] **Step 1: Update application service content assertions**
+
+In `ExamSprintReportApplicationServiceTest`, import:
+
+```java
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
+```
+
+Replace the current achievement payload assertion:
+
+```java
+assertThat(saved.payload().path("reportTitle").asText()).isEqualTo("高考英语临考突击学习成果报告");
+```
+
+with:
+
+```java
+assertThat(saved.content()).isInstanceOf(AchievementReportContent.class);
+AchievementReportContent content = (AchievementReportContent) saved.content();
+assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告");
+assertThat(content.examUnknownWordsHitStatus().hitWords())
+        .containsExactly("number", "bear", "popular", "importance");
+```
+
+- [ ] **Step 2: Replace raw `ObjectNode` report construction with transitional content helpers**
+
+Add helper methods near existing payload helpers:
+
+```java
+private ReportContent unmodeledOutlookContent() {
+    return new UnmodeledReportContent(ReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode());
+}
+
+private AchievementReportContent validAchievementContent() {
+    return AchievementReportContentMapper.toDomainContent(
+            OBJECT_MAPPER.convertValue(validAchievementPayload(), AchievementExamSprintReportPayload.class));
+}
+```
+
+Replace `ExamSprintReport.pending(..., OBJECT_MAPPER.createObjectNode(), ...)` for `OUTLOOK` tests with `unmodeledOutlookContent()`.
+
+Replace `ExamSprintReport.pending(..., validAchievementPayload(), ...)` for `ACHIEVEMENT` tests with `validAchievementContent()`.
+
+- [ ] **Step 3: Preserve copy behavior at application boundary**
+
+Replace the old `createOutlookReportCopiesPayloadBeforeSaving` assertion with:
+
+```java
+ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+UnmodeledReportContent content = (UnmodeledReportContent) saved.content();
+JsonNode savedPayload = (JsonNode) content.source();
+assertThat(savedPayload.path("reportMetadata").path("learnerName").asText()).isEqualTo("李同学");
+```
+
+This verifies the copy now happens in the application boundary for the legacy `OUTLOOK` path.
+
+- [ ] **Step 4: Update test renderers**
+
+Change all `ExamSprintReportRenderer` test doubles from:
+
+```java
+public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
+```
+
+to:
+
+```java
+public String render(ReportContent content, Instant generatedAt) {
+```
+
+For `PreviewTestRenderer`, if the title is needed, use:
+
+```java
+String title = content instanceof AchievementReportContent achievementContent
+        ? achievementContent.reportTitle()
+        : "";
+return "<html><body>preview:" + title + ":" + generatedAt + "</body></html>";
+```
+
+- [ ] **Step 5: Update worker tests**
+
+In `ExamSprintReportGenerationWorkerTest`, import `ReportContent` and `UnmodeledReportContent`, add:
+
+```java
+private ReportContent unmodeledOutlookContent() {
+    return new UnmodeledReportContent(ReportType.OUTLOOK, OBJECT_MAPPER.createObjectNode());
+}
+
+private ReportContent unmodeledAchievementContent() {
+    return new UnmodeledReportContent(ReportType.ACHIEVEMENT, OBJECT_MAPPER.createObjectNode());
+}
+```
+
+Use `unmodeledOutlookContent()` for existing `OUTLOOK` worker tests and `unmodeledAchievementContent()` for the file-name-only `ACHIEVEMENT` worker test. Change test renderer signatures to `render(ReportContent content, Instant generatedAt)`.
+
+- [ ] **Step 6: Run application verification**
+
+Run:
+
+```bash
+mvn -q -pl abilities/exam-sprint/application -am test
+```
+
+Expected: PASS. Tests should now distinguish domain content assertions from public API/contract assertions.
+
+## Task 6: Adapt infrastructure renderers and renderer tests
+
+**Files:**
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java`
+
+- [ ] **Step 1: Run infrastructure tests to expose port signature failures**
+
+Run:
+
+```bash
+mvn -q -pl abilities/exam-sprint/infrastructure -am test
+```
+
+Expected: FAIL until infrastructure renderers implement `render(ReportContent, Instant)`.
+
+- [ ] **Step 2: Change achievement renderer to domain content**
+
+In `ClasspathAchievementExamSprintReportRenderer`, remove imports for `AchievementExamSprintReportPayload`, `JsonNode`, `ObjectMapper`, and `Autowired`. Add imports:
+
+```java
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
+```
+
+Change constructors to:
+
+```java
+public ClasspathAchievementExamSprintReportRenderer() {
+    this(new AchievementExamSprintReportSvgChartBuilder());
+}
+
+ClasspathAchievementExamSprintReportRenderer(AchievementExamSprintReportSvgChartBuilder chartBuilder) {
+    this.chartBuilder = Objects.requireNonNull(chartBuilder, "chartBuilder");
+}
+```
+
+Change render entry point to:
+
+```java
+public String render(ReportContent content, Instant generatedAt) {
+    if (!(content instanceof AchievementReportContent reportContent)) {
+        throw new IllegalArgumentException("Achievement renderer requires AchievementReportContent");
+    }
+    try {
+        AchievementReportContent.SummaryMetrics summary = reportContent.summaryMetrics();
+        AchievementReportContent.Comparison vocabulary = reportContent.vocabularyComparison();
+        AchievementReportContent.Comparison paperKnownWords = reportContent.paperKnownWordsComparison();
+        AchievementReportContent.ExamUnknownWordsHitStatus hitStatus = reportContent.examUnknownWordsHitStatus();
+
+        return renderTemplate(loadTemplate(), placeholders(reportContent, summary, vocabulary, paperKnownWords, hitStatus));
+    } catch (IOException exception) {
+        throw new UncheckedIOException("Failed to load achievement exam sprint report template", exception);
+    } catch (Exception exception) {
+        throw new IllegalStateException("Failed to render achievement exam sprint report", exception);
+    }
+}
+```
+
+Change helper signatures from `AchievementExamSprintReportPayload.*` to `AchievementReportContent.*`.
+
+- [ ] **Step 3: Change outlook renderer to unwrap transitional content in infrastructure**
+
+In `ClasspathOutlookExamSprintReportRenderer`, keep `ObjectMapper`, `JsonNode`, and `OutlookExamSprintReportPayload` because `OUTLOOK` remains unmodeled. Add:
+
+```java
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
+```
+
+Change render entry point to:
+
+```java
+public String render(ReportContent content, Instant generatedAt) {
+    if (!(content instanceof UnmodeledReportContent unmodeledContent)
+            || unmodeledContent.reportType() != ReportType.OUTLOOK
+            || !(unmodeledContent.source() instanceof JsonNode payload)) {
+        throw new IllegalArgumentException("Outlook renderer requires unmodeled OUTLOOK JsonNode content");
+    }
+    try {
+        OutlookExamSprintReportPayload reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
+        return loadTemplate()
+                .replace("{{syllabusMasteryChart}}", renderSyllabusMasteryChart(reportPayload.syllabusMasteryChart()))
+                .replace("{{pastPaperVocabularyChart}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularyChart()))
+                .replace("{{highFrequencyVocabularyChart}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularyChart()))
+                .replace("{{vocabularyFrequencyBandChart}}", renderVocabularyFrequencyBandChart(reportPayload.vocabularyFrequencyBandChart()))
+                .replace("{{studySuggestionSection}}", renderStudySuggestionSection(reportPayload.frequencyPlan()))
+                .replace("{{scoreImprovementCaseStudy}}", renderScoreImprovementCaseStudy(reportPayload.scoreImprovementCaseStudy()));
+    } 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);
+    }
+}
+```
+
+- [ ] **Step 4: Update achievement renderer tests to pass domain content**
+
+In `ClasspathAchievementExamSprintReportRendererTest`, replace `JsonNode samplePayload()` with:
+
+```java
+private AchievementReportContent sampleContent() throws Exception {
+    AchievementExamSprintReportPayload payload = OBJECT_MAPPER.treeToValue(samplePayloadJson(), AchievementExamSprintReportPayload.class);
+    return new AchievementReportContent(
+            payload.reportTitle(),
+            payload.reportSubtitle(),
+            payload.completionTitle(),
+            payload.completionSubtitle(),
+            new AchievementReportContent.SummaryMetrics(
+                    payload.summaryMetrics().vocabularyGrowthText(),
+                    payload.summaryMetrics().paperKnownWordsGrowthText(),
+                    payload.summaryMetrics().unknownWordHitRateText(),
+                    payload.summaryMetrics().learningEfficiencyText()),
+            new AchievementReportContent.Comparison(
+                    payload.vocabularyComparison().beforeValue(),
+                    payload.vocabularyComparison().afterValue(),
+                    payload.vocabularyComparison().beforeText(),
+                    payload.vocabularyComparison().afterText(),
+                    payload.vocabularyComparison().growthText()),
+            new AchievementReportContent.Comparison(
+                    payload.paperKnownWordsComparison().beforeValue(),
+                    payload.paperKnownWordsComparison().afterValue(),
+                    payload.paperKnownWordsComparison().beforeText(),
+                    payload.paperKnownWordsComparison().afterText(),
+                    payload.paperKnownWordsComparison().growthText()),
+            new AchievementReportContent.ExamUnknownWordsHitStatus(
+                    payload.examUnknownWordsHitStatus().unknownWordHitRateText(),
+                    payload.examUnknownWordsHitStatus().learningEfficiencyText(),
+                    payload.examUnknownWordsHitStatus().unknownWordsBeforeText(),
+                    payload.examUnknownWordsHitStatus().unknownWordsAfterText(),
+                    payload.examUnknownWordsHitStatus().reducedUnknownWordsText(),
+                    payload.examUnknownWordsHitStatus().hitWords()));
+}
+
+private JsonNode samplePayloadJson() throws Exception {
+    return OBJECT_MAPPER.readTree("""
+            {
+              "reportTitle": "高考英语临考突击学习成果报告",
+              "reportSubtitle": "2024真题 · 两周专项训练 · 真实提分效果",
+              "completionTitle": "恭喜完成两周考前突击专项训练",
+              "completionSubtitle": "基于2024英语真题试卷 · 真实学习效果分析",
+              "summaryMetrics": {
+                "vocabularyGrowthText": "+19",
+                "paperKnownWordsGrowthText": "+4",
+                "unknownWordHitRateText": "1.93%",
+                "learningEfficiencyText": "0.48倍"
+              },
+              "vocabularyComparison": {
+                "beforeValue": 2328,
+                "afterValue": 2347,
+                "beforeText": "2328 词",
+                "afterText": "2347 词",
+                "growthText": "+19 词"
+              },
+              "paperKnownWordsComparison": {
+                "beforeValue": 650,
+                "afterValue": 654,
+                "beforeText": "650 个",
+                "afterText": "654 个",
+                "growthText": "+4 个"
+              },
+              "examUnknownWordsHitStatus": {
+                "unknownWordHitRateText": "1.93%",
+                "learningEfficiencyText": "0.48倍",
+                "unknownWordsBeforeText": "207 个",
+                "unknownWordsAfterText": "203 个",
+                "reducedUnknownWordsText": "4 个",
+                "hitWords": ["number", "bear", "popular", "importance"]
+              }
+            }
+            """);
+}
+```
+
+When mutating test content, create a small helper that rebuilds `AchievementReportContent` with the changed field. Example for hit words:
+
+```java
+AchievementReportContent content = sampleContent();
+AchievementReportContent mutated = new AchievementReportContent(
+        "成果<script>alert(1)</script>",
+        content.reportSubtitle(),
+        content.completionTitle(),
+        content.completionSubtitle(),
+        content.summaryMetrics(),
+        content.vocabularyComparison(),
+        content.paperKnownWordsComparison(),
+        new AchievementReportContent.ExamUnknownWordsHitStatus(
+                content.examUnknownWordsHitStatus().unknownWordHitRateText(),
+                content.examUnknownWordsHitStatus().learningEfficiencyText(),
+                content.examUnknownWordsHitStatus().unknownWordsBeforeText(),
+                content.examUnknownWordsHitStatus().unknownWordsAfterText(),
+                content.examUnknownWordsHitStatus().reducedUnknownWordsText(),
+                java.util.List.of("number", "bear<script>")));
+String html = renderer.render(mutated, Instant.parse("2026-04-25T08:00:00Z"));
+```
+
+- [ ] **Step 5: Update outlook renderer and PDF tests to wrap JSON content**
+
+Add helper in each affected test class:
+
+```java
+private UnmodeledReportContent unmodeledOutlookContent(JsonNode payload) {
+    return new UnmodeledReportContent(ReportType.OUTLOOK, payload);
+}
+```
+
+Change calls from:
+
+```java
+renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+```
+
+to:
+
+```java
+renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
+```
+
+For achievement PDF rendering tests, use `AchievementReportContent` instead of JSON.
+
+- [ ] **Step 6: Run infrastructure verification**
+
+Run:
+
+```bash
+mvn -q -pl abilities/exam-sprint/infrastructure -am test
+```
+
+Expected: PASS. Achievement renderer no longer depends on contract payload DTOs or Jackson for its render input; outlook renderer still does by explicit transitional design.
+
+## Task 7: Tighten ArchUnit Jackson rule and run runtime/API compatibility checks
+
+**Files:**
+- Modify: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java`
+
+- [ ] **Step 1: Verify no domain Jackson matches before tightening**
+
+Run:
+
+```bash
+rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
+```
+
+Expected: no matches.
+
+- [ ] **Step 2: Remove Jackson allowlist and make hard rule**
+
+In `ExamSprintArchitectureTest`, remove:
+
+```java
+import com.tngtech.archunit.core.domain.JavaClass;
+import com.tngtech.archunit.base.DescribedPredicate;
+import java.util.Set;
+
+private static final Set<String> CURRENT_DOMAIN_JACKSON_DEBT = Set.of(
+        "ExamSprintReport",
+        "ExamSprintReportRenderer");
+```
+
+Replace:
+
+```java
+@ArchTest
+static final ArchRule new_domain_classes_should_not_depend_on_jackson = noClasses()
+        .that().resideInAPackage("..domain..")
+        .and(areNotNamed(CURRENT_DOMAIN_JACKSON_DEBT))
+        .should().dependOnClassesThat().resideInAnyPackage(
+                "com.fasterxml.jackson.."
+        );
+```
+
+with:
+
+```java
+@ArchTest
+static final ArchRule domain_should_not_depend_on_jackson = noClasses()
+        .that().resideInAPackage("..domain..")
+        .should().dependOnClassesThat().resideInAnyPackage(
+                "com.fasterxml.jackson.."
+        );
+```
+
+Delete the `areNotNamed(...)` helper if it has no remaining caller.
+
+- [ ] **Step 3: Run architecture/runtime verification**
+
+Run:
+
+```bash
+mvn -q -pl ability-center-runtime -Dtest=ExamSprintArchitectureTest test
+mvn -q -pl ability-center-runtime -am test
+```
+
+Expected: PASS. Runtime HTTP tests continue to verify public JSON fields and enum literals such as `OUTLOOK`, `ACHIEVEMENT`, `PENDING`, `PROCESSING`, `SUCCESS`, `FAILED`, and `EXPIRED`.
+
+## Task 8: Update governance documentation
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md`
+- Verify: `docs/superpowers/plans/2026-04-28-ddd-naming-governance-jsonnode-payload-loop.md`
+
+- [ ] **Step 1: Update design document status**
+
+Change the status line near the top to:
+
+```markdown
+> Status: Third governance loop planned for Jackson / `JsonNode` payload governance. The intended slice introduces `AchievementReportContent` as a domain concept while keeping external contracts stable.
+```
+
+After implementation, change it to:
+
+```markdown
+> Status: Third governance loop implemented for `AchievementReportContent`; `exam-sprint-domain` no longer depends on Jackson / `JsonNode`. Remaining content debt is modeling `OUTLOOK` and retiring transitional unmodeled content.
+```
+
+- [ ] **Step 2: Update technical debt register rows**
+
+Replace the `domain -> jackson` row with:
+
+```markdown
+| `domain -> jackson` | Cleared at compile-time in the `AchievementReportContent` loop by replacing domain `JsonNode` with `ReportContent`; `OUTLOOK` still uses a transitional unmodeled content wrapper prepared at application boundary | introduce `OutlookReportContent`, remove `UnmodeledReportContent`, and keep ArchUnit hard rule |
+```
+
+Update the payload records row to:
+
+```markdown
+| Payload records contain business behavior | `AchievementReportContent` now owns the selected achievement content shape; `OutlookExamSprintReportPayload` and richer invariants remain in contracts/infrastructure | migrate `OutlookReportContent` and move stable invariants into domain value objects incrementally |
+```
+
+- [ ] **Step 3: Add third-loop result and next-loop recommendation**
+
+Near the first/second loop summary, add:
+
+```markdown
+Third loop result: `AchievementReportContent` is introduced as strongly named domain report content; the application boundary converts public achievement payload JSON/contracts DTOs into domain content; domain main source no longer imports Jackson / `JsonNode`; and the domain-to-Jackson architecture rule is tightened to a hard guardrail.
+
+Remaining payload debt: `OUTLOOK` content remains unmodeled through a transitional `UnmodeledReportContent` wrapper so this loop does not migrate all payload records or rewrite the larger outlook renderer.
+
+Next recommended loop: introduce `OutlookReportContent` and retire `UnmodeledReportContent`, then review whether rendering/file/PDF ports should move to application in a separate dedicated loop.
+```
+
+- [ ] **Step 4: Run documentation grep checks**
+
+Run:
+
+```bash
+rg "AchievementReportContent|domain -> jackson|JsonNode|UnmodeledReportContent|OutlookReportContent" "docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md" "docs/superpowers/plans/2026-04-28-ddd-naming-governance-jsonnode-payload-loop.md"
+```
+
+Expected: matches describe this loop, remaining debt, and next recommendation clearly.
+
+## Task 9: Final verification and review preparation
+
+**Files:**
+- Verify: all modified files
+
+- [ ] **Step 1: Run targeted module suites with `-am`**
+
+Run:
+
+```bash
+mvn -q -pl abilities/exam-sprint/application -am test
+mvn -q -pl abilities/exam-sprint/infrastructure -am test
+mvn -q -pl ability-center-runtime -am test
+```
+
+Expected: all PASS.
+
+- [ ] **Step 2: Run full reactor tests**
+
+Run:
+
+```bash
+mvn -q test
+```
+
+Expected: PASS. If unrelated failures occur, record exact module/test names and do not widen the governance scope without review.
+
+- [ ] **Step 3: Run required final searches**
+
+Run:
+
+```bash
+git status --short
+rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
+rg "contracts\.report" "abilities/exam-sprint/domain/src/main/java"
+rg "exam-sprint-contracts" "abilities/exam-sprint/domain/pom.xml"
+```
+
+Expected:
+
+```text
+git status --short
+# only files intentionally touched by this plan
+
+rg "JsonNode|com\.fasterxml\.jackson" "abilities/exam-sprint/domain/src/main/java"
+# no matches
+
+rg "contracts\.report" "abilities/exam-sprint/domain/src/main/java"
+# no matches
+
+rg "exam-sprint-contracts" "abilities/exam-sprint/domain/pom.xml"
+# no matches
+```
+
+- [ ] **Step 4: Prepare code review checklist**
+
+Review these exact points before claiming completion:
+
+```text
+- domain main source has no Jackson / JsonNode imports or types.
+- domain main source still has no contracts.report imports.
+- abilities/exam-sprint/domain/pom.xml has no exam-sprint-contracts and no jackson-databind dependency.
+- ACHIEVEMENT path stores AchievementReportContent in ExamSprintReport.
+- application boundary converts external JsonNode/contracts payload to AchievementReportContent.
+- public contracts DTO fields and enum literals remain unchanged.
+- runtime HTTP JSON fields, paths, status codes, and enum strings remain unchanged.
+- OUTLOOK remains transitional and documented through UnmodeledReportContent; no full OUTLOOK payload migration happened in this loop.
+- ArchUnit Jackson allowlist is removed only after the source grep confirms no domain Jackson usage.
+- Storage / Renderer / PdfGenerator were not moved.
+- DefaultExamSprintReportApplicationService was not split.
+- ExamSprintReport was not renamed.
+```
+
+- [ ] **Commit checkpoint, only if explicitly requested**
+
+Suggested message:
+
+```text
+refactor(exam-sprint): 推进DDD命名治理三轮内容建模
+```
+
+## Implementation Handoff
+
+Recommended execution mode:
+
+1. Use subagent-driven development, with one task per implementation subagent after this plan is reviewed.
+2. Review after each task for dependency direction, public API compatibility, and scope control.
+3. Use TDD for each production-code change: write or update the failing test first, run it to observe the expected failure, implement the smallest code change, and rerun targeted verification.
+4. Use `systematic-debugging` before fixing any unexpected test/build failure.
+5. Use `verification-before-completion` before claiming the implementation is complete.
+6. Use `requesting-code-review` after implementation and verification.

+ 10 - 5
docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md

@@ -1,6 +1,6 @@
 # DDD Naming and Architecture Governance Design
 
-> Status: Second governance loop implemented for `ReportType`; `exam-sprint-domain` no longer depends on `exam-sprint-contracts`. Remaining near-term debt is Jackson / `JsonNode` payload governance.
+> Status: Third governance loop implemented for `AchievementReportContent`; `exam-sprint-domain` no longer depends on Jackson / `JsonNode`. Remaining content debt is modeling `OUTLOOK` and retiring transitional unmodeled content.
 
 ## Goal
 
@@ -30,7 +30,7 @@ The project already has a useful DDD/Clean Architecture foundation:
 The main governance items are:
 
 - historical `exam-sprint-domain -> exam-sprint-contracts` coupling has been cleared and should remain guarded by architecture tests;
-- domain classes use `JsonNode` as report payload representation;
+- `OUTLOOK` report content remains transitional through unmodeled boundary-prepared content;
 - `contracts` payload classes contain many business concepts and value-object candidates;
 - `ExamSprintReport` mixes report, generation task, generated file, and status-record semantics;
 - `Pipeline`, `Worker`, `Dispatcher`, and `Scheduler` naming emphasizes execution mechanics over use cases;
@@ -323,8 +323,8 @@ Acceptance criteria:
 | Debt | Status / current reason | Exit condition or guardrail |
 | --- | --- | --- |
 | `domain -> contracts` | Cleared in the `ReportType` loop after `ReportGenerationStatus` and `ReportType` became domain-owned | keep ArchUnit hard rule; map future contract/domain duplicates in application |
-| `domain -> jackson` | `ExamSprintReport` and renderer use `JsonNode` payload | domain uses strongly named content/value objects |
-| Payload records contain business behavior | request payloads currently host consistency methods | invariants move to domain value objects |
+| `domain -> jackson` | Cleared at compile-time in the `AchievementReportContent` loop by replacing domain `JsonNode` with `ReportContent`; `OUTLOOK` still uses a transitional unmodeled content wrapper prepared at application boundary | introduce `OutlookReportContent`, remove `UnmodeledReportContent`, and keep ArchUnit hard rule |
+| Payload records contain business behavior | `AchievementReportContent` now owns the selected achievement content shape; `OutlookExamSprintReportPayload` and richer invariants remain in contracts/infrastructure | migrate `OutlookReportContent` and move stable invariants into domain value objects incrementally |
 | `ExamSprintReport` has mixed semantics | current model stores generation state and file references | model is renamed or split around generation semantics |
 | domain hosts `Storage`/`Renderer`/`PdfGenerator` | ports were initially placed near report aggregate | ports move to application or are explicitly documented as domain capabilities |
 | application contains technical-flow names | existing pipeline/worker/dispatcher naming | new use-case names introduced and old names retired gradually |
@@ -399,11 +399,16 @@ First loop result: `ReportGenerationStatus` is now domain-owned; the application
 
 Second loop result: `ReportType` is now domain-owned; the application boundary maps it to and from public `ExamSprintReportType`; `exam-sprint-domain` no longer depends on `exam-sprint-contracts`; and the domain-to-contract architecture rule is a hard guardrail.
 
-Next recommended loop: govern Jackson / `JsonNode` payload leakage by introducing strongly named domain report-content concepts for one report type while keeping external payload DTOs stable.
+Third loop result: `AchievementReportContent` is introduced as strongly named domain report content; the application boundary converts public achievement payload JSON/contracts DTOs into domain content; domain main source no longer imports Jackson / `JsonNode`; and the domain-to-Jackson architecture rule is tightened to a hard guardrail.
+
+Remaining payload debt: `OUTLOOK` content remains unmodeled through a transitional `UnmodeledReportContent` wrapper so this loop does not migrate all payload records or rewrite the larger outlook renderer.
+
+Next recommended loop: introduce `OutlookReportContent` and retire `UnmodeledReportContent`, then review whether rendering/file/PDF ports should move to application in a separate dedicated loop.
 
 The detailed implementation plan is in:
 
 ```text
 docs/superpowers/plans/2026-04-27-ddd-naming-governance-first-loop.md
 docs/superpowers/plans/2026-04-28-ddd-naming-governance-report-type-loop.md
+docs/superpowers/plans/2026-04-28-ddd-naming-governance-jsonnode-payload-loop.md
 ```