Просмотр исходного кода

Merge branch 'feature/adapt-achievement-caller-payload' of jyx/dcjxb.microservice into master

金逸霄 2 недель назад
Родитель
Сommit
8af8b72d91
16 измененных файлов с 771 добавлено и 385 удалено
  1. 72 23
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java
  2. 78 30
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
  3. 109 19
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java
  4. 173 42
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
  5. 2 0
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java
  6. 31 35
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java
  7. 22 0
      abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java
  8. 12 0
      abilities/exam-sprint/domain/src/test/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContentTest.java
  9. 26 5
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java
  10. 10 2
      abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html
  11. 30 70
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java
  12. 118 75
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java
  13. 14 17
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java
  14. 12 4
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java
  15. 29 31
      ability-center-runtime/src/test/resources/requests/exam-sprint-achievement-report-invalid-request.json
  16. 33 32
      ability-center-runtime/src/test/resources/requests/exam-sprint-achievement-report-request.json

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

@@ -2,7 +2,10 @@ 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 com.fasterxml.jackson.databind.JsonNode;
 
+import java.math.BigDecimal;
+import java.util.List;
 import java.util.Objects;
 
 final class AchievementReportContentMapper {
@@ -14,32 +17,78 @@ final class AchievementReportContentMapper {
         Objects.requireNonNull(payload, "payload");
         return new AchievementReportContent(
                 payload.studentName(),
-                payload.reportTitle(),
-                payload.reportSubtitle(),
-                payload.completionTitle(),
-                payload.completionSubtitle(),
+                reportTitle(payload.stageName()),
+                payload.testPaperTitle() + " · 临考突击 · 真实提分效果",
+                "恭喜完成临考突击专项训练",
+                "基于" + payload.testPaperTitle() + " · 真实学习效果分析",
                 new AchievementReportContent.SummaryMetrics(
-                        payload.summaryMetrics().vocabularyGrowthText(),
-                        payload.summaryMetrics().paperKnownWordsGrowthText(),
-                        payload.summaryMetrics().unknownWordHitRateText(),
-                        payload.summaryMetrics().learningEfficiencyText()),
-                comparison(payload.vocabularyComparison()),
-                comparison(payload.paperKnownWordsComparison()),
+                        signed(payload.studentImproveWordCount()),
+                        signed(payload.testPaperImprovedWordCount()),
+                        format(payload.paperMasteryHitRate()) + "%",
+                        format(payload.improveStudyEfficiency())),
+                new AchievementReportContent.Comparison(
+                        payload.studentVocabularyBefore().doubleValue(),
+                        payload.studentVocabulary().doubleValue(),
+                        format(payload.studentVocabularyBefore()),
+                        format(payload.studentVocabulary()),
+                        signed(payload.studentImproveWordCount())),
+                new AchievementReportContent.Comparison(
+                        payload.testPaperBeforMastery().doubleValue(),
+                        payload.testPaperLatestMastery().doubleValue(),
+                        format(payload.testPaperBeforMastery()),
+                        format(payload.testPaperLatestMastery()),
+                        signed(payload.testPaperImprovedWordCount())),
+                new AchievementReportContent.StageVocabularySummary(
+                        payload.stageName(),
+                        format(payload.stageVocabulary()),
+                        format(payload.studentInitialVocabMastery()),
+                        format(payload.studentCurrentVocabMastery()),
+                        signed(payload.studentVocabMasteryImprovement())),
+                new AchievementReportContent.TestPaperVocabularySummary(
+                        payload.testPaperTitle(),
+                        format(payload.testPaperWordCount()),
+                        format(payload.testPaperBeforUnMastery()),
+                        format(payload.testPaperAfterUnMastery()),
+                        format(payload.testPaperBeforMasteryRate()),
+                        format(payload.testPaperLatestMasteryRate()),
+                        signed(payload.testPaperImproveRate())),
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
-                        payload.examUnknownWordsHitStatus().unknownWordHitRateText(),
-                        payload.examUnknownWordsHitStatus().learningEfficiencyText(),
-                        payload.examUnknownWordsHitStatus().unknownWordsBeforeText(),
-                        payload.examUnknownWordsHitStatus().unknownWordsAfterText(),
-                        payload.examUnknownWordsHitStatus().reducedUnknownWordsText(),
-                        payload.examUnknownWordsHitStatus().hitWords()));
+                        format(payload.paperMasteryHitRate()) + "%",
+                        format(payload.improveStudyEfficiency()),
+                        format(payload.testPaperBeforUnMastery()),
+                        format(payload.testPaperAfterUnMastery()),
+                        format(payload.testPaperImprovedWordCount()),
+                        hitWords(payload.testPaperImprovedWords())));
     }
 
-    private static AchievementReportContent.Comparison comparison(AchievementExamSprintReportPayload.Comparison comparison) {
-        return new AchievementReportContent.Comparison(
-                comparison.beforeValue(),
-                comparison.afterValue(),
-                comparison.beforeText(),
-                comparison.afterText(),
-                comparison.growthText());
+    private static String reportTitle(String stageName) {
+        String normalizedStageName = stageName == null ? "" : stageName.trim();
+        if (normalizedStageName.contains("英语")) {
+            return normalizedStageName + "临考突击学习成果报告";
+        }
+        return normalizedStageName + "英语临考突击学习成果报告";
+    }
+
+    private static String signed(BigDecimal value) {
+        String formatted = format(value);
+        return value.signum() > 0 ? "+" + formatted : formatted;
+    }
+
+    private static String format(BigDecimal value) {
+        return value.stripTrailingZeros().toPlainString();
+    }
+
+    private static List<String> hitWords(List<JsonNode> improvedWords) {
+        return improvedWords.stream()
+                .map(AchievementReportContentMapper::hitWord)
+                .filter(word -> !word.isBlank())
+                .toList();
+    }
+
+    private static String hitWord(JsonNode improvedWord) {
+        if (improvedWord.isTextual()) {
+            return improvedWord.asText().trim();
+        }
+        return improvedWord.path("WordSpell").asText("").trim();
     }
 }

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

@@ -25,6 +25,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
+import java.math.BigDecimal;
 import java.time.Clock;
 import java.time.Instant;
 import java.util.List;
@@ -39,6 +40,9 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     private static final Logger log = LoggerFactory.getLogger(DefaultExamSprintReportApplicationService.class);
 
     private static final String REPORT_GENERATION_DISPATCH_FAILED = "report_generation_dispatch_failed";
+    private static final BigDecimal ACHIEVEMENT_MAX_NUMERIC_ABS = new BigDecimal("1000000000");
+    private static final int ACHIEVEMENT_MAX_NUMERIC_PRECISION = 12;
+    private static final int ACHIEVEMENT_MAX_NUMERIC_SCALE = 6;
 
     private final ExamSprintReportRepository repository;
     private final ExamSprintReportGenerationDispatcher dispatcher;
@@ -464,35 +468,34 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
 
     private void validateAchievementPayloadShape(JsonNode payload) {
         requireObjectPayload(payload);
-        requireTextualField(payload, "reportTitle");
-        requireTextualField(payload, "reportSubtitle");
-        requireTextualField(payload, "completionTitle");
-        requireTextualField(payload, "completionSubtitle");
-
-        JsonNode summaryMetrics = requireObjectField(payload, "summaryMetrics");
-        requireTextualField(summaryMetrics, "vocabularyGrowthText");
-        requireTextualField(summaryMetrics, "paperKnownWordsGrowthText");
-        requireTextualField(summaryMetrics, "unknownWordHitRateText");
-        requireTextualField(summaryMetrics, "learningEfficiencyText");
-
-        validateComparisonShape(requireObjectField(payload, "vocabularyComparison"));
-        validateComparisonShape(requireObjectField(payload, "paperKnownWordsComparison"));
-
-        JsonNode examUnknownWordsHitStatus = requireObjectField(payload, "examUnknownWordsHitStatus");
-        requireTextualField(examUnknownWordsHitStatus, "unknownWordHitRateText");
-        requireTextualField(examUnknownWordsHitStatus, "learningEfficiencyText");
-        requireTextualField(examUnknownWordsHitStatus, "unknownWordsBeforeText");
-        requireTextualField(examUnknownWordsHitStatus, "unknownWordsAfterText");
-        requireTextualField(examUnknownWordsHitStatus, "reducedUnknownWordsText");
-        requireTextualArrayField(examUnknownWordsHitStatus, "hitWords");
-    }
-
-    private void validateComparisonShape(JsonNode comparison) {
-        requireNumericField(comparison, "beforeValue");
-        requireNumericField(comparison, "afterValue");
-        requireTextualField(comparison, "beforeText");
-        requireTextualField(comparison, "afterText");
-        requireTextualField(comparison, "growthText");
+        requireTextualField(payload, "StudentName");
+        requireTextualField(payload, "StageName");
+        requireTextualField(payload, "TestPaperTitle");
+
+        requireNonNegativeNumericField(payload, "StudentStage");
+        requireNonNegativeNumericField(payload, "StageVocabulary");
+        requireNonNegativeNumericField(payload, "StudentVocabulary");
+        requireNonNegativeNumericField(payload, "StudentVocabularyBefore");
+        requireNonNegativeNumericField(payload, "StudentUnMastedWordCount");
+        requireNumericField(payload, "StudentImproveWordCount", true);
+        requireNonNegativeNumericField(payload, "TestPaperWordCount");
+        requireNonNegativeNumericField(payload, "TestPaperBeforUnMastery");
+        requireNonNegativeNumericField(payload, "TestPaperBeforMastery");
+        requireNonNegativeNumericField(payload, "TestPaperLatestMastery");
+        requireNonNegativeNumericField(payload, "TestPaperAfterUnMastery");
+        requireNumericField(payload, "TestPaperImprovedWordCount", true);
+        requireNumericField(payload, "TestPaperImproveRate", true);
+        requireNonNegativeNumericField(payload, "PaperMasteryHitRate");
+        requireNonNegativeNumericField(payload, "ImproveStudyEfficiency");
+        requireNonNegativeNumericField(payload, "StudentInitialVocabMastery");
+        requireNonNegativeNumericField(payload, "StudentCurrentVocabMastery");
+        requireNumericField(payload, "StudentVocabMasteryImprovement", true);
+        requireNonNegativeNumericField(payload, "TestPaperBeforMasteryRate");
+        requireNonNegativeNumericField(payload, "TestPaperLatestMasteryRate");
+
+        requireImprovedWordsArrayField(payload, "TestPaperImprovedWords");
+        requireOptionalBooleanField(payload, "ShouldDisplaySigningGuarantee");
+        requireOptionalTextualField(payload, "SigningGuarantee");
     }
 
     private void requireTextualField(JsonNode objectNode, String fieldName) {
@@ -502,11 +505,27 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
         }
     }
 
-    private void requireNumericField(JsonNode objectNode, String fieldName) {
+    private void requireNonNegativeNumericField(JsonNode objectNode, String fieldName) {
+        requireNumericField(objectNode, fieldName, false);
+    }
+
+    private void requireNumericField(JsonNode objectNode, String fieldName, boolean allowNegative) {
         JsonNode field = objectNode.get(fieldName);
         if (field == null || !field.isNumber()) {
             throw new BusinessException(ErrorCode.VALIDATION_ERROR);
         }
+        BigDecimal value;
+        try {
+            value = field.decimalValue();
+        } catch (RuntimeException exception) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+        if ((!allowNegative && value.signum() < 0)
+                || value.precision() > ACHIEVEMENT_MAX_NUMERIC_PRECISION
+                || value.scale() > ACHIEVEMENT_MAX_NUMERIC_SCALE
+                || value.abs().compareTo(ACHIEVEMENT_MAX_NUMERIC_ABS) > 0) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
     }
 
     private JsonNode requireObjectField(JsonNode objectNode, String fieldName) {
@@ -517,6 +536,35 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
         return field;
     }
 
+    private void requireOptionalBooleanField(JsonNode objectNode, String fieldName) {
+        JsonNode field = objectNode.get(fieldName);
+        if (field != null && !field.isNull() && !field.isBoolean()) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+    }
+
+    private void requireOptionalTextualField(JsonNode objectNode, String fieldName) {
+        JsonNode field = objectNode.get(fieldName);
+        if (field != null && !field.isNull() && !field.isTextual()) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+    }
+
+    private void requireImprovedWordsArrayField(JsonNode objectNode, String fieldName) {
+        JsonNode field = objectNode.get(fieldName);
+        if (field == null || !field.isArray()) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+        for (JsonNode element : field) {
+            if (element.isTextual()) {
+                continue;
+            }
+            if (!element.isObject() || !element.path("WordSpell").isTextual()) {
+                throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+            }
+        }
+    }
+
     private void requireTextualArrayField(JsonNode objectNode, String fieldName) {
         JsonNode field = objectNode.get(fieldName);
         if (field == null || !field.isArray()) {

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

@@ -2,29 +2,88 @@ 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 com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 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 {
 
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
     @Test
-    void mapsEveryRendererUsedAchievementFieldToDomainContent() {
+    void mapsCallerPascalCasePayloadToExistingAchievementContent() {
         AchievementReportContent content = AchievementReportContentMapper.toDomainContent(payload());
 
         assertThat(content.studentName()).isEqualTo("吴泓妤");
         assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告");
+        assertThat(content.reportSubtitle()).isEqualTo("2024真题 · 临考突击 · 真实提分效果");
+        assertThat(content.completionTitle()).isEqualTo("恭喜完成临考突击专项训练");
+        assertThat(content.completionSubtitle()).isEqualTo("基于2024真题 · 真实学习效果分析");
         assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
+        assertThat(content.summaryMetrics().paperKnownWordsGrowthText()).isEqualTo("+4");
+        assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("1.93%");
+        assertThat(content.summaryMetrics().learningEfficiencyText()).isEqualTo("0.48");
         assertThat(content.vocabularyComparison().beforeValue()).isEqualTo(2328.0);
         assertThat(content.vocabularyComparison().afterText()).isEqualTo("2347");
+        assertThat(content.vocabularyComparison().growthText()).isEqualTo("+19");
+        assertThat(content.stageVocabularySummary().stageName()).isEqualTo("高考");
+        assertThat(content.stageVocabularySummary().stageVocabularyText()).isEqualTo("3500");
+        assertThat(content.stageVocabularySummary().masteryBeforeText()).isEqualTo("66.51");
+        assertThat(content.stageVocabularySummary().masteryAfterText()).isEqualTo("67.06");
+        assertThat(content.stageVocabularySummary().masteryImprovementText()).isEqualTo("+0.55");
+        assertThat(content.paperKnownWordsComparison().beforeValue()).isEqualTo(650.0);
+        assertThat(content.paperKnownWordsComparison().afterText()).isEqualTo("654");
         assertThat(content.paperKnownWordsComparison().growthText()).isEqualTo("+4");
+        assertThat(content.testPaperVocabularySummary().testPaperTitle()).isEqualTo("2024真题");
+        assertThat(content.testPaperVocabularySummary().testPaperWordCountText()).isEqualTo("861");
+        assertThat(content.testPaperVocabularySummary().unknownWordsBeforeText()).isEqualTo("207");
+        assertThat(content.testPaperVocabularySummary().unknownWordsAfterText()).isEqualTo("203");
+        assertThat(content.testPaperVocabularySummary().masteryRateBeforeText()).isEqualTo("75.49");
+        assertThat(content.testPaperVocabularySummary().masteryRateAfterText()).isEqualTo("75.96");
+        assertThat(content.testPaperVocabularySummary().masteryRateImprovementText()).isEqualTo("+0.62");
+        assertThat(content.examUnknownWordsHitStatus().unknownWordHitRateText()).isEqualTo("1.93%");
+        assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("0.48");
+        assertThat(content.examUnknownWordsHitStatus().unknownWordsBeforeText()).isEqualTo("207");
+        assertThat(content.examUnknownWordsHitStatus().unknownWordsAfterText()).isEqualTo("203");
+        assertThat(content.examUnknownWordsHitStatus().reducedUnknownWordsText()).isEqualTo("4");
         assertThat(content.examUnknownWordsHitStatus().hitWords())
                 .containsExactly("number", "bear", "popular", "importance");
     }
 
+    @Test
+    void mapsNegativeSignedGrowthAndDoesNotDuplicateEnglishInReportTitle() {
+        ObjectNode payload = pascalPayload();
+        payload.put("StageName", "高考英语");
+        payload.put("StudentImproveWordCount", -2);
+        payload.put("StudentVocabularyBefore", 2349);
+        payload.put("StudentVocabulary", 2347);
+        payload.put("StudentVocabMasteryImprovement", -0.04);
+
+        AchievementReportContent content = AchievementReportContentMapper.toDomainContent(convert(payload));
+
+        assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告");
+        assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("-2");
+        assertThat(content.vocabularyComparison().growthText()).isEqualTo("-2");
+        assertThat(content.stageVocabularySummary().masteryImprovementText()).isEqualTo("-0.04");
+    }
+
+    @Test
+    void filtersBlankImprovedWordsFromStringAndObjectElements() {
+        ObjectNode payload = pascalPayload();
+        payload.putArray("TestPaperImprovedWords")
+                .add(" number ")
+                .add("   ")
+                .add(OBJECT_MAPPER.createObjectNode().put("WordSpell", " bear "))
+                .add(OBJECT_MAPPER.createObjectNode().put("WordSpell", "\t"));
+
+        AchievementReportContent content = AchievementReportContentMapper.toDomainContent(convert(payload));
+
+        assertThat(content.examUnknownWordsHitStatus().hitWords()).containsExactly("number", "bear");
+    }
+
     @Test
     void rejectsNullPayload() {
         assertThatThrownBy(() -> AchievementReportContentMapper.toDomainContent(null))
@@ -33,21 +92,52 @@ class AchievementReportContentMapperTest {
     }
 
     private AchievementExamSprintReportPayload payload() {
-        return new AchievementExamSprintReportPayload(
-                "吴泓妤",
-                "高考英语临考突击学习成果报告",
-                "2024真题 · 两周专项训练 · 真实提分效果",
-                "恭喜完成两周考前突击专项训练",
-                "基于2024英语真题试卷 · 真实学习效果分析",
-                new AchievementExamSprintReportPayload.SummaryMetrics("+19", "+4", "0.0193", "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(
-                        "0.0193",
-                        "0.48",
-                        "207",
-                        "203",
-                        "4",
-                        List.of("number", "bear", "popular", "importance")));
+        return convert(pascalPayload());
+    }
+
+    private AchievementExamSprintReportPayload convert(ObjectNode payload) {
+        return OBJECT_MAPPER.convertValue(payload, AchievementExamSprintReportPayload.class);
+    }
+
+    private ObjectNode pascalPayloadWithRemainingNumbers(ObjectNode payload) {
+        return payload
+                .put("TestPaperImprovedWordCount", 4)
+                .put("TestPaperImproveRate", 0.62)
+                .put("PaperMasteryHitRate", 1.9300)
+                .put("ImproveStudyEfficiency", 0.48)
+                .put("StudentInitialVocabMastery", 66.51)
+                .put("StudentCurrentVocabMastery", 67.06)
+                .put("StudentVocabMasteryImprovement", 0.55)
+                .put("TestPaperBeforMasteryRate", 75.49)
+                .put("TestPaperLatestMasteryRate", 75.96)
+                .put("ShouldDisplaySigningGuarantee", true)
+                .put("SigningGuarantee", "签约保障");
+    }
+
+    private ObjectNode pascalPayload() {
+        return pascalPayloadWithRemainingNumbers(basePascalPayload());
+    }
+
+    private ObjectNode basePascalPayload() {
+        return (ObjectNode) OBJECT_MAPPER.createObjectNode()
+                .put("StudentName", "吴泓妤")
+                .put("StudentStage", 3)
+                .put("StageName", "高考")
+                .put("StageVocabulary", 3500)
+                .put("StudentVocabulary", 2347)
+                .put("StudentVocabularyBefore", 2328)
+                .put("StudentUnMastedWordCount", 1153)
+                .put("StudentImproveWordCount", 19)
+                .put("TestPaperTitle", "2024真题")
+                .put("TestPaperWordCount", 861)
+                .put("TestPaperBeforUnMastery", 207)
+                .put("TestPaperBeforMastery", 650)
+                .put("TestPaperLatestMastery", 654)
+                .put("TestPaperAfterUnMastery", 203)
+                .set("TestPaperImprovedWords", OBJECT_MAPPER.createArrayNode()
+                        .add(" number ")
+                        .add(OBJECT_MAPPER.createObjectNode().put("WordSpell", "bear"))
+                        .add(OBJECT_MAPPER.createObjectNode().put("WordSpell", " popular "))
+                        .add("importance"));
     }
 }

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

@@ -216,9 +216,9 @@ class ExamSprintReportApplicationServiceTest {
         assertCreateOutlookReportRejectsInvalidPayload(invalidPayload);
     }
 
-    /** 覆盖创建成果报告的有效报文场景,当提交 achievement payload 时,应映射为领域内容并保存 ACHIEVEMENT 报告。 */
+    /** 覆盖创建成果报告的调用方 PascalCase 报文场景,当提交有效 payload 时,应映射为领域内容并保存 ACHIEVEMENT 报告。 */
     @Test
-    void createAchievementReportStoresAchievementTypeAndReturnsReportId() {
+    void createAchievementReportAcceptsCallerPascalCasePayloadAndStoresAchievementContent() {
         TestRepository repository = new TestRepository();
         TestStorage storage = new TestStorage();
         DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
@@ -232,10 +232,105 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(saved.content()).isInstanceOf(AchievementReportContent.class);
         AchievementReportContent content = (AchievementReportContent) saved.content();
         assertThat(content.reportTitle()).isEqualTo("高考英语临考突击学习成果报告");
+        assertThat(content.reportSubtitle()).isEqualTo("2024真题 · 临考突击 · 真实提分效果");
+        assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
+        assertThat(content.summaryMetrics().paperKnownWordsGrowthText()).isEqualTo("+4");
+        assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("1.93%");
+        assertThat(content.stageVocabularySummary().stageName()).isEqualTo("高考");
+        assertThat(content.stageVocabularySummary().stageVocabularyText()).isEqualTo("3500");
+        assertThat(content.stageVocabularySummary().masteryImprovementText()).isEqualTo("+0.55");
+        assertThat(content.testPaperVocabularySummary().testPaperTitle()).isEqualTo("2024真题");
+        assertThat(content.testPaperVocabularySummary().testPaperWordCountText()).isEqualTo("861");
+        assertThat(content.testPaperVocabularySummary().masteryRateImprovementText()).isEqualTo("+0.62");
+        assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("0.48");
         assertThat(content.examUnknownWordsHitStatus().hitWords())
                 .containsExactly("number", "bear", "popular", "importance");
     }
 
+    /** 覆盖成果报告调用方 PascalCase 类型边界场景,当核心数字字段为 string 时,应在保存前校验失败。 */
+    @Test
+    void createAchievementReportRejectsCallerPayloadWithWrongPascalCaseTypesBeforeSaving() {
+        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
+        invalidPayload.put("PaperMasteryHitRate", "1.93");
+
+        assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
+    }
+
+    /** 覆盖成果报告空提分词列表场景,当 TestPaperImprovedWords 为空数组时,应保存空 hitWords。 */
+    @Test
+    void createAchievementReportAcceptsEmptyImprovedWordsForEmptyState() {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+        ObjectNode payload = validAchievementPayload().deepCopy();
+        payload.putArray("TestPaperImprovedWords");
+        payload.put("TestPaperImprovedWordCount", 0);
+
+        var response = service.createAchievementReport(payload);
+
+        AchievementReportContent content = (AchievementReportContent) repository.findById(response.reportId()).orElseThrow().content();
+        assertThat(content.examUnknownWordsHitStatus().hitWords()).isEmpty();
+    }
+
+    /** 覆盖成果报告非 delta 数值边界场景,当非 delta 展示数值为负数时,应在保存前校验失败。 */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("invalidAchievementNonDeltaNegativeNumbers")
+    void createAchievementReportRejectsNegativeNonDeltaAchievementNumbers(
+            String caseName,
+            Consumer<ObjectNode> mutatePayload) {
+        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
+        mutatePayload.accept(invalidPayload);
+
+        assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
+    }
+
+    /** 覆盖成果报告 delta 数值边界场景,当提分/增量字段为负数时,应允许保存并保留负号展示。 */
+    @Test
+    void createAchievementReportAcceptsNegativeDeltaAchievementNumbers() {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+        ObjectNode payload = validAchievementPayload().deepCopy();
+        payload.put("StudentImproveWordCount", -307);
+        payload.put("StudentVocabulary", 2021);
+        payload.put("StudentVocabMasteryImprovement", -12.79);
+        payload.put("TestPaperImproveRate", -0.62);
+
+        var response = service.createAchievementReport(payload);
+
+        AchievementReportContent content = (AchievementReportContent) repository.findById(response.reportId()).orElseThrow().content();
+        assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("-307");
+        assertThat(content.vocabularyComparison().growthText()).isEqualTo("-307");
+        assertThat(content.stageVocabularySummary().masteryImprovementText()).isEqualTo("-12.79");
+        assertThat(content.testPaperVocabularySummary().masteryRateImprovementText()).isEqualTo("-0.62");
+    }
+
+    /** 覆盖成果报告试卷熟词提升量 delta 场景,当 TestPaperImprovedWordCount 为负数时,应允许保存并保留负号展示。 */
+    @Test
+    void createAchievementReportAcceptsNegativeTestPaperImprovedWordCount() {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+        ObjectNode payload = validAchievementPayload().deepCopy();
+        payload.put("TestPaperImprovedWordCount", -4);
+
+        var response = service.createAchievementReport(payload);
+
+        AchievementReportContent content = (AchievementReportContent) repository.findById(response.reportId()).orElseThrow().content();
+        assertThat(content.summaryMetrics().paperKnownWordsGrowthText()).isEqualTo("-4");
+        assertThat(content.paperKnownWordsComparison().growthText()).isEqualTo("-4");
+        assertThat(content.examUnknownWordsHitStatus().reducedUnknownWordsText()).isEqualTo("-4");
+    }
+
+    /** 覆盖成果报告 BigDecimal 防御场景,当展示数值为极端 exponent 或超大数时,应在 mapper 前校验失败。 */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("invalidAchievementExtremeNumbers")
+    void createAchievementReportRejectsExtremeAchievementNumbersBeforeMapping(
+            String caseName,
+            Consumer<ObjectNode> mutatePayload) {
+        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
+        mutatePayload.accept(invalidPayload);
+
+        assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
+    }
+
     /** 覆盖同步创建展望报告场景,当 StudentName 与 studentName 冲突时,应以 StudentName 为准生成文件名并归一化保存。 */
     @Test
     void createOutlookReportSyncGeneratesUploadAndReturnsDownloadUrl() {
@@ -404,11 +499,11 @@ class ExamSprintReportApplicationServiceTest {
                 .isEqualTo(ReportGenerationStatus.FAILED);
     }
 
-    /** 覆盖成果报告缺少必填字段场景,当 reportTitle 缺失时,应在保存前校验失败。 */
+    /** 覆盖成果报告缺少必填字段场景,当 StudentName 缺失时,应在保存前校验失败。 */
     @Test
     void createAchievementReportRejectsInvalidAchievementPayloadBeforeSaving() {
         ObjectNode invalidPayload = validAchievementPayload().deepCopy();
-        invalidPayload.remove("reportTitle");
+        invalidPayload.remove("StudentName");
 
         assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
     }
@@ -422,11 +517,11 @@ class ExamSprintReportApplicationServiceTest {
         assertCreateAchievementReportRejectsInvalidPayload(OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
     }
 
-    /** 覆盖成果报告嵌套数值字段缺失场景,当 beforeValue 缺失时,应在保存前校验失败。 */
+    /** 覆盖成果报告数值字段缺失场景,当 StudentVocabularyBefore 缺失时,应在保存前校验失败。 */
     @Test
     void createAchievementReportRejectsMissingAchievementBeforeValueBeforeSaving() {
         ObjectNode invalidPayload = validAchievementPayload().deepCopy();
-        invalidPayload.withObject("vocabularyComparison").remove("beforeValue");
+        invalidPayload.remove("StudentVocabularyBefore");
 
         assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
     }
@@ -834,36 +929,47 @@ class ExamSprintReportApplicationServiceTest {
     }
 
     private ObjectNode validAchievementPayload() {
-        return (ObjectNode) OBJECT_MAPPER.valueToTree(new AchievementExamSprintReportPayload(
-                "吴泓妤",
-                "高考英语临考突击学习成果报告",
-                "2024真题 · 两周专项训练 · 真实提分效果",
-                "恭喜完成两周考前突击专项训练",
-                "基于2024英语真题试卷 · 真实学习效果分析",
-                new AchievementExamSprintReportPayload.SummaryMetrics(
-                        "+19",
-                        "+4",
-                        "0.0193",
-                        "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(
-                        "0.0193",
-                        "0.48",
-                        "207",
-                        "203",
-                        "4",
-                        List.of("number", "bear", "popular", "importance"))));
+        try {
+            return (ObjectNode) OBJECT_MAPPER.readTree("""
+                    {
+                      "StudentName": "吴泓妤",
+                      "StudentStage": 3,
+                      "StageName": "高考",
+                      "StageVocabulary": 3500,
+                      "StudentVocabulary": 2347,
+                      "StudentVocabularyBefore": 2328,
+                      "StudentUnMastedWordCount": 1153,
+                      "StudentImproveWordCount": 19,
+                      "TestPaperTitle": "2024真题",
+                      "TestPaperWordCount": 861,
+                      "TestPaperBeforUnMastery": 207,
+                      "TestPaperBeforMastery": 650,
+                      "TestPaperLatestMastery": 654,
+                      "TestPaperAfterUnMastery": 203,
+                      "TestPaperImprovedWords": [
+                        " number ",
+                        {"WordSpell": "bear", "Ignored": true},
+                        {"WordSpell": " popular "},
+                        "importance"
+                      ],
+                      "TestPaperImprovedWordCount": 4,
+                      "TestPaperImproveRate": 0.62,
+                      "PaperMasteryHitRate": 1.93,
+                      "ImproveStudyEfficiency": 0.48,
+                      "StudentInitialVocabMastery": 66.51,
+                      "StudentCurrentVocabMastery": 67.06,
+                      "StudentVocabMasteryImprovement": 0.55,
+                      "TestPaperBeforMasteryRate": 75.49,
+                      "TestPaperLatestMasteryRate": 75.96,
+                      "ShouldDisplaySigningGuarantee": true,
+                      "SigningGuarantee": "签约保障",
+                      "StudentWordsLatest": [{"WordSpell": "ignored-large-array"}],
+                      "StudentWordsFirstPreExamAssaultAfter": [{"WordSpell": "ignored-large-array"}]
+                    }
+                    """);
+        } catch (Exception exception) {
+            throw new IllegalStateException("Failed to build valid achievement payload", exception);
+        }
     }
 
     private ReportContent unmodeledOutlookContent() {
@@ -877,12 +983,37 @@ class ExamSprintReportApplicationServiceTest {
 
     private static Stream<Arguments> invalidAchievementPayloadJsonTypes() {
         return Stream.of(
-                Arguments.of("reportTitle boolean is rejected", (Consumer<ObjectNode>) payload -> payload.put("reportTitle", true)),
-                Arguments.of("reportTitle number is rejected", (Consumer<ObjectNode>) payload -> payload.put("reportTitle", 123)),
-                Arguments.of("vocabularyComparison.beforeValue string is rejected",
-                        (Consumer<ObjectNode>) payload -> payload.withObject("vocabularyComparison").put("beforeValue", "2328")),
-                Arguments.of("examUnknownWordsHitStatus.hitWords number element is rejected",
-                        (Consumer<ObjectNode>) payload -> payload.withObject("examUnknownWordsHitStatus").putArray("hitWords").add(123)));
+                Arguments.of("StudentName boolean is rejected", (Consumer<ObjectNode>) payload -> payload.put("StudentName", true)),
+                Arguments.of("StageName number is rejected", (Consumer<ObjectNode>) payload -> payload.put("StageName", 123)),
+                Arguments.of("StudentVocabularyBefore string is rejected",
+                        (Consumer<ObjectNode>) payload -> payload.put("StudentVocabularyBefore", "2328")),
+                Arguments.of("TestPaperImprovedWords number element is rejected",
+                        (Consumer<ObjectNode>) payload -> payload.putArray("TestPaperImprovedWords").add(123)),
+                Arguments.of("TestPaperImprovedWords object without textual WordSpell is rejected",
+                        (Consumer<ObjectNode>) payload -> payload.putArray("TestPaperImprovedWords")
+                                .add(OBJECT_MAPPER.createObjectNode().put("WordSpell", 123))),
+                Arguments.of("ShouldDisplaySigningGuarantee string is rejected",
+                        (Consumer<ObjectNode>) payload -> payload.put("ShouldDisplaySigningGuarantee", "true")),
+                Arguments.of("SigningGuarantee number is rejected",
+                        (Consumer<ObjectNode>) payload -> payload.put("SigningGuarantee", 123)));
+    }
+
+    private static Stream<Arguments> invalidAchievementNonDeltaNegativeNumbers() {
+        return Stream.of(
+                Arguments.of("StageVocabulary negative is rejected", (Consumer<ObjectNode>) payload -> payload.put("StageVocabulary", -1)),
+                Arguments.of("PaperMasteryHitRate negative is rejected", (Consumer<ObjectNode>) payload -> payload.put("PaperMasteryHitRate", -1)));
+    }
+
+    private static Stream<Arguments> invalidAchievementExtremeNumbers() {
+        return Stream.of(
+                Arguments.of("PaperMasteryHitRate extreme exponent is rejected",
+                        (Consumer<ObjectNode>) payload -> payload.set("PaperMasteryHitRate",
+                                com.fasterxml.jackson.databind.node.DecimalNode.valueOf(new java.math.BigDecimal("1e1000")))),
+                Arguments.of("ImproveStudyEfficiency oversized integer is rejected",
+                        (Consumer<ObjectNode>) payload -> payload.put("ImproveStudyEfficiency", 1_000_000_001)),
+                Arguments.of("StudentCurrentVocabMastery excessive scale is rejected",
+                        (Consumer<ObjectNode>) payload -> payload.set("StudentCurrentVocabMastery",
+                                com.fasterxml.jackson.databind.node.DecimalNode.valueOf(new java.math.BigDecimal("39.1234567")))));
     }
 
     private static Stream<Arguments> mixedOutlookPayloadsWithInvalidCanonicalStudentName() {

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

@@ -350,6 +350,8 @@ class ExamSprintReportGenerationWorkerTest {
                 new AchievementReportContent.SummaryMetrics("+19", "+4", "0.0193", "0.48"),
                 new AchievementReportContent.Comparison(2328.0, 2347.0, "2328", "2347", "+19"),
                 new AchievementReportContent.Comparison(650.0, 654.0, "650", "654", "+4"),
+                new AchievementReportContent.StageVocabularySummary("高考", "3500", "66.51", "67.06", "+0.55"),
+                new AchievementReportContent.TestPaperVocabularySummary("2024真题", "861", "207", "203", "75.49", "75.96", "+0.62"),
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
                         "0.0193",
                         "0.48",

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

@@ -1,44 +1,40 @@
 package cn.yunzhixue.ability.center.examsprint.contracts.report;
 
-import jakarta.validation.Valid;
-import jakarta.validation.constraints.DecimalMin;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.JsonNode;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
 
+import java.math.BigDecimal;
 import java.util.List;
 
+@JsonIgnoreProperties(ignoreUnknown = true)
 public record AchievementExamSprintReportPayload(
-        String studentName,
-        @NotBlank String reportTitle,
-        @NotBlank String reportSubtitle,
-        @NotBlank String completionTitle,
-        @NotBlank String completionSubtitle,
-        @NotNull @Valid SummaryMetrics summaryMetrics,
-        @NotNull @Valid Comparison vocabularyComparison,
-        @NotNull @Valid Comparison paperKnownWordsComparison,
-        @NotNull @Valid ExamUnknownWordsHitStatus examUnknownWordsHitStatus) {
-
-    public record SummaryMetrics(
-            @NotBlank String vocabularyGrowthText,
-            @NotBlank String paperKnownWordsGrowthText,
-            @NotBlank String unknownWordHitRateText,
-            @NotBlank String learningEfficiencyText) {
-    }
-
-    public record Comparison(
-            @NotNull @DecimalMin("0.0") Double beforeValue,
-            @NotNull @DecimalMin("0.0") Double afterValue,
-            @NotBlank String beforeText,
-            @NotBlank String afterText,
-            @NotBlank String growthText) {
-    }
-
-    public record ExamUnknownWordsHitStatus(
-            @NotBlank String unknownWordHitRateText,
-            @NotBlank String learningEfficiencyText,
-            @NotBlank String unknownWordsBeforeText,
-            @NotBlank String unknownWordsAfterText,
-            @NotBlank String reducedUnknownWordsText,
-            @NotNull List<@NotBlank String> hitWords) {
-    }
+        @JsonProperty("StudentName") @NotBlank String studentName,
+        @JsonProperty("StudentStage") @NotNull BigDecimal studentStage,
+        @JsonProperty("StageName") @NotBlank String stageName,
+        @JsonProperty("StageVocabulary") @NotNull BigDecimal stageVocabulary,
+        @JsonProperty("StudentVocabulary") @NotNull BigDecimal studentVocabulary,
+        @JsonProperty("StudentVocabularyBefore") @NotNull BigDecimal studentVocabularyBefore,
+        @JsonProperty("StudentUnMastedWordCount") @NotNull BigDecimal studentUnMastedWordCount,
+        @JsonProperty("StudentImproveWordCount") @NotNull BigDecimal studentImproveWordCount,
+        @JsonProperty("TestPaperTitle") @NotBlank String testPaperTitle,
+        @JsonProperty("TestPaperWordCount") @NotNull BigDecimal testPaperWordCount,
+        @JsonProperty("TestPaperBeforUnMastery") @NotNull BigDecimal testPaperBeforUnMastery,
+        @JsonProperty("TestPaperBeforMastery") @NotNull BigDecimal testPaperBeforMastery,
+        @JsonProperty("TestPaperLatestMastery") @NotNull BigDecimal testPaperLatestMastery,
+        @JsonProperty("TestPaperAfterUnMastery") @NotNull BigDecimal testPaperAfterUnMastery,
+        @JsonProperty("TestPaperImprovedWords") @NotNull List<@NotNull JsonNode> testPaperImprovedWords,
+        @JsonProperty("TestPaperImprovedWordCount") @NotNull BigDecimal testPaperImprovedWordCount,
+        @JsonProperty("TestPaperImproveRate") @NotNull BigDecimal testPaperImproveRate,
+        @JsonProperty("PaperMasteryHitRate") @NotNull BigDecimal paperMasteryHitRate,
+        @JsonProperty("ImproveStudyEfficiency") @NotNull BigDecimal improveStudyEfficiency,
+        @JsonProperty("StudentInitialVocabMastery") @NotNull BigDecimal studentInitialVocabMastery,
+        @JsonProperty("StudentCurrentVocabMastery") @NotNull BigDecimal studentCurrentVocabMastery,
+        @JsonProperty("StudentVocabMasteryImprovement") @NotNull BigDecimal studentVocabMasteryImprovement,
+        @JsonProperty("TestPaperBeforMasteryRate") @NotNull BigDecimal testPaperBeforMasteryRate,
+        @JsonProperty("TestPaperLatestMasteryRate") @NotNull BigDecimal testPaperLatestMasteryRate,
+        @JsonProperty("ShouldDisplaySigningGuarantee") Boolean shouldDisplaySigningGuarantee,
+        @JsonProperty("SigningGuarantee") String signingGuarantee) {
 }

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

@@ -12,12 +12,16 @@ public record AchievementReportContent(
         SummaryMetrics summaryMetrics,
         Comparison vocabularyComparison,
         Comparison paperKnownWordsComparison,
+        StageVocabularySummary stageVocabularySummary,
+        TestPaperVocabularySummary testPaperVocabularySummary,
         ExamUnknownWordsHitStatus examUnknownWordsHitStatus) implements ReportContent {
 
     public AchievementReportContent {
         Objects.requireNonNull(summaryMetrics, "summaryMetrics");
         Objects.requireNonNull(vocabularyComparison, "vocabularyComparison");
         Objects.requireNonNull(paperKnownWordsComparison, "paperKnownWordsComparison");
+        Objects.requireNonNull(stageVocabularySummary, "stageVocabularySummary");
+        Objects.requireNonNull(testPaperVocabularySummary, "testPaperVocabularySummary");
         Objects.requireNonNull(examUnknownWordsHitStatus, "examUnknownWordsHitStatus");
     }
 
@@ -41,6 +45,24 @@ public record AchievementReportContent(
             String growthText) {
     }
 
+    public record StageVocabularySummary(
+            String stageName,
+            String stageVocabularyText,
+            String masteryBeforeText,
+            String masteryAfterText,
+            String masteryImprovementText) {
+    }
+
+    public record TestPaperVocabularySummary(
+            String testPaperTitle,
+            String testPaperWordCountText,
+            String unknownWordsBeforeText,
+            String unknownWordsAfterText,
+            String masteryRateBeforeText,
+            String masteryRateAfterText,
+            String masteryRateImprovementText) {
+    }
+
     public record ExamUnknownWordsHitStatus(
             String unknownWordHitRateText,
             String learningEfficiencyText,

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

@@ -32,6 +32,8 @@ class AchievementReportContentTest {
                 null,
                 comparison(),
                 comparison(),
+                stageVocabularySummary(),
+                testPaperVocabularySummary(),
                 hitStatus(List.of("number"))))
                 .isInstanceOf(NullPointerException.class)
                 .hasMessageContaining("summaryMetrics");
@@ -47,6 +49,8 @@ class AchievementReportContentTest {
                 new AchievementReportContent.SummaryMetrics("+19", "+4", "0.0193", "0.48"),
                 comparison(),
                 comparison(),
+                stageVocabularySummary(),
+                testPaperVocabularySummary(),
                 hitStatus(hitWords));
     }
 
@@ -54,6 +58,14 @@ class AchievementReportContentTest {
         return new AchievementReportContent.Comparison(2328.0, 2347.0, "2328", "2347", "+19");
     }
 
+    private AchievementReportContent.StageVocabularySummary stageVocabularySummary() {
+        return new AchievementReportContent.StageVocabularySummary("高考", "3500", "66.51", "67.06", "+0.55");
+    }
+
+    private AchievementReportContent.TestPaperVocabularySummary testPaperVocabularySummary() {
+        return new AchievementReportContent.TestPaperVocabularySummary("2024真题", "861", "207", "203", "75.49", "75.96", "+0.62");
+    }
+
     private AchievementReportContent.ExamUnknownWordsHitStatus hitStatus(List<String> hitWords) {
         return new AchievementReportContent.ExamUnknownWordsHitStatus(
                 "0.0193",

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

@@ -49,9 +49,11 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
             AchievementReportContent.SummaryMetrics summary = reportContent.summaryMetrics();
             AchievementReportContent.Comparison vocabulary = reportContent.vocabularyComparison();
             AchievementReportContent.Comparison paperKnownWords = reportContent.paperKnownWordsComparison();
+            AchievementReportContent.StageVocabularySummary stageVocabulary = reportContent.stageVocabularySummary();
+            AchievementReportContent.TestPaperVocabularySummary testPaperVocabulary = reportContent.testPaperVocabularySummary();
             AchievementReportContent.ExamUnknownWordsHitStatus hitStatus = reportContent.examUnknownWordsHitStatus();
 
-            return renderTemplate(loadTemplate(), placeholders(reportContent, summary, vocabulary, paperKnownWords, hitStatus));
+            return renderTemplate(loadTemplate(), placeholders(reportContent, summary, vocabulary, paperKnownWords, stageVocabulary, testPaperVocabulary, hitStatus));
         } catch (IOException exception) {
             throw new UncheckedIOException("Failed to load achievement exam sprint report template", exception);
         } catch (Exception exception) {
@@ -60,10 +62,12 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
     }
 
     private Map<String, String> placeholders(AchievementReportContent reportContent,
-                                             AchievementReportContent.SummaryMetrics summary,
-                                             AchievementReportContent.Comparison vocabulary,
-                                             AchievementReportContent.Comparison paperKnownWords,
-                                             AchievementReportContent.ExamUnknownWordsHitStatus hitStatus) {
+                                              AchievementReportContent.SummaryMetrics summary,
+                                              AchievementReportContent.Comparison vocabulary,
+                                              AchievementReportContent.Comparison paperKnownWords,
+                                              AchievementReportContent.StageVocabularySummary stageVocabulary,
+                                              AchievementReportContent.TestPaperVocabularySummary testPaperVocabulary,
+                                              AchievementReportContent.ExamUnknownWordsHitStatus hitStatus) {
         Map<String, String> placeholders = new LinkedHashMap<>();
         placeholders.put("reportTitle", escape(reportContent.reportTitle()));
         placeholders.put("reportSubtitle", escape(reportContent.reportSubtitle()));
@@ -78,9 +82,21 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
         placeholders.put("vocabularyBeforeText", escape(withSeparatedUnit(vocabulary.beforeText(), "词")));
         placeholders.put("vocabularyAfterText", escape(withSeparatedUnit(vocabulary.afterText(), "词")));
         placeholders.put("vocabularyGrowthDetailText", escape(withSeparatedUnit(vocabulary.growthText(), "词")));
+        placeholders.put("stageVocabularyLabel", escape(stageVocabularyLabel(stageVocabulary.stageName())));
+        placeholders.put("stageVocabularyText", escape(withSeparatedUnit(stageVocabulary.stageVocabularyText(), "词")));
+        placeholders.put("studentVocabMasteryBeforeText", escape(withUnit(stageVocabulary.masteryBeforeText(), "%")));
+        placeholders.put("studentVocabMasteryAfterText", escape(withUnit(stageVocabulary.masteryAfterText(), "%")));
+        placeholders.put("studentVocabMasteryImprovementText", escape(withUnit(stageVocabulary.masteryImprovementText(), "%")));
         placeholders.put("paperKnownWordsBeforeText", escape(withSeparatedUnit(paperKnownWords.beforeText(), "个")));
         placeholders.put("paperKnownWordsAfterText", escape(withSeparatedUnit(paperKnownWords.afterText(), "个")));
         placeholders.put("paperKnownWordsGrowthDetailText", escape(withSeparatedUnit(paperKnownWords.growthText(), "个")));
+        placeholders.put("testPaperTitle", escape(testPaperVocabulary.testPaperTitle()));
+        placeholders.put("testPaperWordCountText", escape(withSeparatedUnit(testPaperVocabulary.testPaperWordCountText(), "词")));
+        placeholders.put("testPaperUnknownWordsBeforeText", escape(withSeparatedUnit(testPaperVocabulary.unknownWordsBeforeText(), "个")));
+        placeholders.put("testPaperUnknownWordsAfterText", escape(withSeparatedUnit(testPaperVocabulary.unknownWordsAfterText(), "个")));
+        placeholders.put("testPaperMasteryRateBeforeText", escape(withUnit(testPaperVocabulary.masteryRateBeforeText(), "%")));
+        placeholders.put("testPaperMasteryRateAfterText", escape(withUnit(testPaperVocabulary.masteryRateAfterText(), "%")));
+        placeholders.put("testPaperMasteryRateImprovementText", escape(withUnit(testPaperVocabulary.masteryRateImprovementText(), "%")));
         placeholders.put("unknownWordsBeforeText", escape(withSeparatedUnit(hitStatus.unknownWordsBeforeText(), "个")));
         placeholders.put("unknownWordsAfterText", escape(withSeparatedUnit(hitStatus.unknownWordsAfterText(), "个")));
         placeholders.put("reducedUnknownWordsText", escape(withSeparatedUnit(hitStatus.reducedUnknownWordsText(), "个")));
@@ -102,6 +118,11 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
         return rendered.toString();
     }
 
+    private String stageVocabularyLabel(String stageName) {
+        String normalized = normalizeDisplayValue(stageName);
+        return normalized.isEmpty() || isTemplatePlaceholder(normalized) ? normalized : normalized + "词汇量";
+    }
+
     private String loadTemplate() throws IOException {
         try (InputStream inputStream = new ClassPathResource(TEMPLATE_RESOURCE).getInputStream()) {
             return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);

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

@@ -280,7 +280,10 @@
             <div class="chart-box">{{vocabularyComparisonChart}}</div>
             <div class="data-text">训练前词汇量:<span class="highlight">{{vocabularyBeforeText}}</span><br/>
                 训练后词汇量:<span class="highlight">{{vocabularyAfterText}}</span><br/>
-                本次提升:<span class="highlight">{{vocabularyGrowthDetailText}}</span></div>
+                本次提升:<span class="highlight">{{vocabularyGrowthDetailText}}</span><br/>
+                {{stageVocabularyLabel}}:<span class="highlight">{{stageVocabularyText}}</span><br/>
+                掌握率:<span class="highlight">{{studentVocabMasteryBeforeText}} -&gt; {{studentVocabMasteryAfterText}}</span><br/>
+                掌握率提升:<span class="highlight">{{studentVocabMasteryImprovementText}}</span></div>
         </div>
     </div>
 
@@ -290,7 +293,12 @@
             <div class="chart-box">{{paperKnownWordsComparisonChart}}</div>
             <div class="data-text">训练前熟词量:<span class="highlight">{{paperKnownWordsBeforeText}}</span><br/>
                 训练后熟词量:<span class="highlight">{{paperKnownWordsAfterText}}</span><br/>
-                本次提升:<span class="highlight">{{paperKnownWordsGrowthDetailText}}</span></div>
+                本次提升:<span class="highlight">{{paperKnownWordsGrowthDetailText}}</span><br/>
+                试卷标题:<span class="highlight">{{testPaperTitle}}</span><br/>
+                试卷总词量:<span class="highlight">{{testPaperWordCountText}}</span><br/>
+                训练前/后生词:<span class="highlight">{{testPaperUnknownWordsBeforeText}} -&gt; {{testPaperUnknownWordsAfterText}}</span><br/>
+                试卷词率:<span class="highlight">{{testPaperMasteryRateBeforeText}} -&gt; {{testPaperMasteryRateAfterText}}</span><br/>
+                试卷词率提升:<span class="highlight">{{testPaperMasteryRateImprovementText}}</span></div>
         </div>
     </div>
 

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

@@ -1,6 +1,5 @@
 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;
@@ -97,6 +96,16 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                     .contains("真题生词命中率")
                     .contains("1.93%")
                     .contains("0.48倍")
+                    .contains("高考词汇量")
+                    .contains("3500词")
+                    .contains("掌握率")
+                    .contains("66.51%")
+                    .contains("67.06%")
+                    .contains("试卷标题")
+                    .contains("2024真题")
+                    .contains("861词")
+                    .contains("75.49%")
+                    .contains("75.96%")
                     .contains("207个")
                     .contains("203个")
                     .contains("4个")
@@ -227,40 +236,29 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
         return new UnmodeledReportContent(ReportType.OUTLOOK, payload);
     }
 
-    private AchievementReportContent sampleAchievementContent() throws Exception {
-        AchievementExamSprintReportPayload payload = OBJECT_MAPPER.treeToValue(
-                sampleAchievementPayload(),
-                AchievementExamSprintReportPayload.class
-        );
+    private AchievementReportContent sampleAchievementContent() {
         return new AchievementReportContent(
-                payload.studentName(),
-                payload.reportTitle(),
-                payload.reportSubtitle(),
-                payload.completionTitle(),
-                payload.completionSubtitle(),
+                "吴泓妤",
+                "高考英语临考突击学习成果报告",
+                "2024真题 · 两周专项训练 · 真实提分效果",
+                "恭喜完成两周考前突击专项训练",
+                "基于2024英语真题试卷 · 真实学习效果分析",
                 new AchievementReportContent.SummaryMetrics(
-                        payload.summaryMetrics().vocabularyGrowthText(),
-                        payload.summaryMetrics().paperKnownWordsGrowthText(),
-                        payload.summaryMetrics().unknownWordHitRateText(),
-                        payload.summaryMetrics().learningEfficiencyText()),
-                comparison(payload.vocabularyComparison()),
-                comparison(payload.paperKnownWordsComparison()),
+                        "+19",
+                        "+4",
+                        "0.0193",
+                        "0.48"),
+                new AchievementReportContent.Comparison(2328.0, 2347.0, "2328", "2347", "+19"),
+                new AchievementReportContent.Comparison(650.0, 654.0, "650", "654", "+4"),
+                new AchievementReportContent.StageVocabularySummary("高考", "3500", "66.51", "67.06", "+0.55"),
+                new AchievementReportContent.TestPaperVocabularySummary("2024真题", "861", "207", "203", "75.49", "75.96", "+0.62"),
                 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());
+                        "0.0193",
+                        "0.48",
+                        "207",
+                        "203",
+                        "4",
+                        List.of("number", "bear", "popular", "importance")));
     }
 
     private JsonNode samplePayload() throws Exception {
@@ -298,42 +296,4 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                 """);
     }
 
-    private JsonNode sampleAchievementPayload() throws Exception {
-        return OBJECT_MAPPER.readTree("""
-                {
-                  "reportTitle": "高考英语临考突击学习成果报告",
-                  "reportSubtitle": "2024真题 · 两周专项训练 · 真实提分效果",
-                  "completionTitle": "恭喜完成两周考前突击专项训练",
-                  "completionSubtitle": "基于2024英语真题试卷 · 真实学习效果分析",
-                  "summaryMetrics": {
-                    "vocabularyGrowthText": "+19",
-                    "paperKnownWordsGrowthText": "+4",
-                    "unknownWordHitRateText": "0.0193",
-                    "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": "0.0193",
-                    "learningEfficiencyText": "0.48",
-                    "unknownWordsBeforeText": "207",
-                    "unknownWordsAfterText": "203",
-                    "reducedUnknownWordsText": "4",
-                    "hitWords": ["number", "bear", "popular", "importance"]
-                  }
-                }
-                """);
-    }
 }

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

@@ -1,10 +1,7 @@
 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 org.junit.jupiter.api.Test;
 
 import java.time.Instant;
@@ -16,7 +13,6 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 class ClasspathAchievementExamSprintReportRendererTest {
 
-    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
     private static final Pattern SELF_CLOSING_RECT_PATTERN = Pattern.compile("<rect\\b[^>]*/>");
 
     @Test
@@ -35,14 +31,22 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 "vocabulary-growth-chart",
                 "训练前词汇量:<span class=\"highlight\">2328 词</span><br/>",
                 "训练后词汇量:<span class=\"highlight\">2347 词</span><br/>",
-                "本次提升:<span class=\"highlight\">+19 词</span>");
+                "本次提升:<span class=\"highlight\">+19 词</span>",
+                "高考词汇量:<span class=\"highlight\">3500 词</span><br/>",
+                "掌握率:<span class=\"highlight\">66.51% -&gt; 67.06%</span><br/>",
+                "掌握率提升:<span class=\"highlight\">+0.55%</span>");
         assertModuleUsesVerticalCard(
                 html,
                 "<h2 class=\"section-title\">模块二:试卷熟词量对比</h2>",
                 "paper-known-words-chart",
                 "训练前熟词量:<span class=\"highlight\">650 个</span><br/>",
                 "训练后熟词量:<span class=\"highlight\">654 个</span><br/>",
-                "本次提升:<span class=\"highlight\">+4 个</span>");
+                "本次提升:<span class=\"highlight\">+4 个</span>",
+                "试卷标题:<span class=\"highlight\">2024真题</span><br/>",
+                "试卷总词量:<span class=\"highlight\">861 词</span><br/>",
+                "训练前/后生词:<span class=\"highlight\">207 个 -&gt; 203 个</span><br/>",
+                "试卷词率:<span class=\"highlight\">75.49% -&gt; 75.96%</span><br/>",
+                "试卷词率提升:<span class=\"highlight\">+0.62%</span>");
         assertBarFill(extractChartSvg(html, "vocabulary-growth-chart"), "chart-bar chart-bar-before", "#448aff");
         assertBarFill(extractChartSvg(html, "vocabulary-growth-chart"), "chart-bar chart-bar-after", "#448aff");
         assertBarFill(extractChartSvg(html, "paper-known-words-chart"), "chart-bar chart-bar-before", "#34a853");
@@ -91,6 +95,9 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 .contains("成功减少生词:<span class=\"highlight\">4 个</span>")
                 .contains("class=\"word-list\"")
                 .contains("class=\"word-item\">number</div>")
+                .doesNotContain("193%")
+                .doesNotContain("1.93%%")
+                .doesNotContain("0.48倍倍")
                 .doesNotContain("cdn.jsdelivr.net")
                 .doesNotContain("echarts")
                 .doesNotContain("<script")
@@ -132,6 +139,37 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 .doesNotContain("bear<script>");
     }
 
+    @Test
+    void renderEscapesCallerDisplayFieldsAndDoesNotExpandInjectedPlaceholders() throws Exception {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
+        AchievementReportContent content = withStageAndTestPaperSummaries(
+                sampleContent(),
+                new AchievementReportContent.StageVocabularySummary(
+                        "高考<script>alert(1)</script>",
+                        "3500",
+                        "66.51",
+                        "67.06",
+                        "+0.55"),
+                new AchievementReportContent.TestPaperVocabularySummary(
+                        "{{hitWords}}<script>alert(2)</script>",
+                        "861",
+                        "207",
+                        "203",
+                        "75.49",
+                        "75.96",
+                        "+0.62"));
+
+        String html = renderer.render(content, Instant.parse("2026-04-25T08:00:00Z"));
+
+        assertThat(html)
+                .contains("高考&lt;script&gt;alert(1)&lt;/script&gt;词汇量")
+                .contains("试卷标题:<span class=\"highlight\">{{hitWords}}&lt;script&gt;alert(2)&lt;/script&gt;</span><br/>")
+                .contains("class=\"word-item\">number</div>")
+                .doesNotContain("<script>alert(1)</script>")
+                .doesNotContain("<script>alert(2)</script>")
+                .doesNotContain("试卷标题:<span class=\"highlight\"><div class=\"word-item\">number</div>");
+    }
+
     @Test
     void renderShowsEmptyStateWhenHitWordsAreEmpty() throws Exception {
         ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
@@ -229,6 +267,20 @@ class ClasspathAchievementExamSprintReportRendererTest {
                         "650 个",
                         "654个",
                         "+4 个"),
+                new AchievementReportContent.StageVocabularySummary(
+                        content.stageVocabularySummary().stageName(),
+                        "3500 词",
+                        "66.51%",
+                        "67.06%",
+                        "+0.55%"),
+                new AchievementReportContent.TestPaperVocabularySummary(
+                        content.testPaperVocabularySummary().testPaperTitle(),
+                        "861 词",
+                        "207 个",
+                        "203个",
+                        "75.49%",
+                        "75.96%",
+                        "+0.62%"),
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
                         "1.93%",
                         "0.48倍",
@@ -248,6 +300,13 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 .contains("训练前熟词量:<span class=\"highlight\">650 个</span><br/>")
                 .contains("训练后熟词量:<span class=\"highlight\">654个</span><br/>")
                 .contains("本次提升:<span class=\"highlight\">+4 个</span>")
+                .contains("高考词汇量:<span class=\"highlight\">3500 词</span><br/>")
+                .contains("掌握率:<span class=\"highlight\">66.51% -&gt; 67.06%</span><br/>")
+                .contains("掌握率提升:<span class=\"highlight\">+0.55%</span>")
+                .contains("试卷总词量:<span class=\"highlight\">861 词</span><br/>")
+                .contains("训练前/后生词:<span class=\"highlight\">207 个 -&gt; 203个</span><br/>")
+                .contains("试卷词率:<span class=\"highlight\">75.49% -&gt; 75.96%</span><br/>")
+                .contains("试卷词率提升:<span class=\"highlight\">+0.62%</span>")
                 .contains("class=\"hit-stat-value\">1.93%</div>")
                 .contains("class=\"hit-stat-value\">0.48倍</div>")
                 .contains("class=\"hit-stat-value\">207 个</div>")
@@ -259,6 +318,10 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 .doesNotContain("2347词 词")
                 .doesNotContain("650 个 个")
                 .doesNotContain("654个 个")
+                .doesNotContain("3500 词 词")
+                .doesNotContain("861 词 词")
+                .doesNotContain("66.51%%")
+                .doesNotContain("75.49%%")
                 .doesNotContain("207 个 个")
                 .doesNotContain("203个 个");
     }
@@ -280,6 +343,8 @@ class ClasspathAchievementExamSprintReportRendererTest {
                         content.summaryMetrics().learningEfficiencyText()),
                 content.vocabularyComparison(),
                 content.paperKnownWordsComparison(),
+                content.stageVocabularySummary(),
+                content.testPaperVocabularySummary(),
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
                         "Infinity",
                         content.examUnknownWordsHitStatus().learningEfficiencyText(),
@@ -297,40 +362,29 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 .doesNotContain("class=\"hit-stat-value\">0%</div>");
     }
 
-    private AchievementReportContent sampleContent() throws Exception {
-        AchievementExamSprintReportPayload payload = OBJECT_MAPPER.treeToValue(
-                samplePayloadJson(),
-                AchievementExamSprintReportPayload.class
-        );
+    private AchievementReportContent sampleContent() {
         return new AchievementReportContent(
-                payload.studentName(),
-                payload.reportTitle(),
-                payload.reportSubtitle(),
-                payload.completionTitle(),
-                payload.completionSubtitle(),
+                "吴泓妤",
+                "高考英语临考突击学习成果报告",
+                "2024真题 · 两周专项训练 · 真实提分效果",
+                "恭喜完成两周考前突击专项训练",
+                "基于2024英语真题试卷 · 真实学习效果分析",
                 new AchievementReportContent.SummaryMetrics(
-                        payload.summaryMetrics().vocabularyGrowthText(),
-                        payload.summaryMetrics().paperKnownWordsGrowthText(),
-                        payload.summaryMetrics().unknownWordHitRateText(),
-                        payload.summaryMetrics().learningEfficiencyText()),
-                comparison(payload.vocabularyComparison()),
-                comparison(payload.paperKnownWordsComparison()),
+                        "+19",
+                        "+4",
+                        "0.0193",
+                        "0.48"),
+                new AchievementReportContent.Comparison(2328.0, 2347.0, "2328", "2347", "+19"),
+                new AchievementReportContent.Comparison(650.0, 654.0, "650", "654", "+4"),
+                new AchievementReportContent.StageVocabularySummary("高考", "3500", "66.51", "67.06", "+0.55"),
+                new AchievementReportContent.TestPaperVocabularySummary("2024真题", "861", "207", "203", "75.49", "75.96", "+0.62"),
                 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());
+                        "0.0193",
+                        "0.48",
+                        "207",
+                        "203",
+                        "4",
+                        List.of("number", "bear", "popular", "importance")));
     }
 
     private AchievementReportContent withReportTitle(AchievementReportContent content, String reportTitle) {
@@ -343,6 +397,8 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 content.summaryMetrics(),
                 content.vocabularyComparison(),
                 content.paperKnownWordsComparison(),
+                content.stageVocabularySummary(),
+                content.testPaperVocabularySummary(),
                 content.examUnknownWordsHitStatus());
     }
 
@@ -357,6 +413,8 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 content.summaryMetrics(),
                 vocabularyComparison,
                 content.paperKnownWordsComparison(),
+                content.stageVocabularySummary(),
+                content.testPaperVocabularySummary(),
                 content.examUnknownWordsHitStatus());
     }
 
@@ -371,6 +429,8 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 content.summaryMetrics(),
                 content.vocabularyComparison(),
                 content.paperKnownWordsComparison(),
+                content.stageVocabularySummary(),
+                content.testPaperVocabularySummary(),
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
                         hitStatus.unknownWordHitRateText(),
                         hitStatus.learningEfficiencyText(),
@@ -380,43 +440,22 @@ class ClasspathAchievementExamSprintReportRendererTest {
                         hitWords));
     }
 
-    private JsonNode samplePayloadJson() throws Exception {
-        return OBJECT_MAPPER.readTree("""
-                {
-                  "reportTitle": "高考英语临考突击学习成果报告",
-                  "reportSubtitle": "2024真题 · 两周专项训练 · 真实提分效果",
-                  "completionTitle": "恭喜完成两周考前突击专项训练",
-                  "completionSubtitle": "基于2024英语真题试卷 · 真实学习效果分析",
-                  "summaryMetrics": {
-                    "vocabularyGrowthText": "+19",
-                    "paperKnownWordsGrowthText": "+4",
-                    "unknownWordHitRateText": "0.0193",
-                    "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": "0.0193",
-                    "learningEfficiencyText": "0.48",
-                    "unknownWordsBeforeText": "207",
-                    "unknownWordsAfterText": "203",
-                    "reducedUnknownWordsText": "4",
-                    "hitWords": ["number", "bear", "popular", "importance"]
-                  }
-                }
-                """);
+    private AchievementReportContent withStageAndTestPaperSummaries(
+            AchievementReportContent content,
+            AchievementReportContent.StageVocabularySummary stageVocabularySummary,
+            AchievementReportContent.TestPaperVocabularySummary testPaperVocabularySummary) {
+        return new AchievementReportContent(
+                content.studentName(),
+                content.reportTitle(),
+                content.reportSubtitle(),
+                content.completionTitle(),
+                content.completionSubtitle(),
+                content.summaryMetrics(),
+                content.vocabularyComparison(),
+                content.paperKnownWordsComparison(),
+                stageVocabularySummary,
+                testPaperVocabularySummary,
+                content.examUnknownWordsHitStatus());
     }
 
     private void assertModuleUsesVerticalCard(
@@ -425,7 +464,8 @@ class ClasspathAchievementExamSprintReportRendererTest {
             String chartClass,
             String beforeLine,
             String afterLine,
-            String growthLine) {
+            String growthLine,
+            String... additionalLines) {
         int sectionStart = html.indexOf(sectionTitle);
         assertThat(sectionStart)
                 .as("module section should exist: %s", sectionTitle)
@@ -450,6 +490,9 @@ class ClasspathAchievementExamSprintReportRendererTest {
                         beforeLine,
                         afterLine,
                         growthLine);
+        assertThat(sectionHtml)
+                .as("%s should include supplemental caller payload fields", sectionTitle)
+                .contains(additionalLines);
 
         int cardIndex = sectionHtml.indexOf("<div class=\"card\">");
         int chartBoxIndex = sectionHtml.indexOf(chartBox, Math.max(cardIndex, 0));

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

@@ -122,26 +122,23 @@ class ExamSprintReportControllerTest {
                 .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF));
     }
 
-    // WHY: Achievement fixtures intentionally keep unitless text inputs so renderer parsing does not depend on stripping units.
+    // WHY: Achievement fixtures document the upstream caller contract and must not drift back to legacy renderer DTO shape.
     @Test
-    void achievementSyncFixtureUsesUnitlessTextInputs() throws Exception {
+    void achievementSyncFixtureUsesCallerPascalCasePayload() throws Exception {
         JsonNode payload = objectMapper.readTree(validAchievementRequestJson());
 
-        assertThat(payload.at("/summaryMetrics/vocabularyGrowthText").asText()).isEqualTo("+19");
-        assertThat(payload.at("/summaryMetrics/paperKnownWordsGrowthText").asText()).isEqualTo("+4");
-        assertThat(payload.at("/summaryMetrics/unknownWordHitRateText").asText()).isEqualTo("0.0193");
-        assertThat(payload.at("/summaryMetrics/learningEfficiencyText").asText()).isEqualTo("0.48");
-        assertThat(payload.at("/vocabularyComparison/beforeText").asText()).isEqualTo("2328");
-        assertThat(payload.at("/vocabularyComparison/afterText").asText()).isEqualTo("2347");
-        assertThat(payload.at("/vocabularyComparison/growthText").asText()).isEqualTo("+19");
-        assertThat(payload.at("/paperKnownWordsComparison/beforeText").asText()).isEqualTo("650");
-        assertThat(payload.at("/paperKnownWordsComparison/afterText").asText()).isEqualTo("654");
-        assertThat(payload.at("/paperKnownWordsComparison/growthText").asText()).isEqualTo("+4");
-        assertThat(payload.at("/examUnknownWordsHitStatus/unknownWordHitRateText").asText()).isEqualTo("0.0193");
-        assertThat(payload.at("/examUnknownWordsHitStatus/learningEfficiencyText").asText()).isEqualTo("0.48");
-        assertThat(payload.at("/examUnknownWordsHitStatus/unknownWordsBeforeText").asText()).isEqualTo("207");
-        assertThat(payload.at("/examUnknownWordsHitStatus/unknownWordsAfterText").asText()).isEqualTo("203");
-        assertThat(payload.at("/examUnknownWordsHitStatus/reducedUnknownWordsText").asText()).isEqualTo("4");
+        assertThat(payload.path("StudentName").asText()).isEqualTo("吴泓妤");
+        assertThat(payload.path("StageName").asText()).isEqualTo("高考");
+        assertThat(payload.path("TestPaperTitle").asText()).isEqualTo("2024真题");
+        assertThat(payload.path("StudentImproveWordCount").asInt()).isEqualTo(19);
+        assertThat(payload.path("TestPaperImprovedWordCount").asInt()).isEqualTo(4);
+        assertThat(payload.path("PaperMasteryHitRate").asDouble()).isEqualTo(1.93);
+        assertThat(payload.path("ImproveStudyEfficiency").asDouble()).isEqualTo(0.48);
+        assertThat(payload.path("TestPaperImprovedWords").get(0).asText()).isEqualTo(" number ");
+        assertThat(payload.has("summaryMetrics")).isFalse();
+        assertThat(payload.has("vocabularyComparison")).isFalse();
+        assertThat(payload.has("paperKnownWordsComparison")).isFalse();
+        assertThat(payload.has("examUnknownWordsHitStatus")).isFalse();
     }
 
     // WHY: The outlook fixture documents the client contract and must not drift back to legacy wrapper or text-field shapes.

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

@@ -99,10 +99,18 @@ class ExamSprintReportControllerWebMvcTest {
         ArgumentCaptor<JsonNode> payloadCaptor = ArgumentCaptor.forClass(JsonNode.class);
         verify(applicationService).createAchievementReport(payloadCaptor.capture());
         JsonNode payload = payloadCaptor.getValue();
-        Assertions.assertEquals("高考英语临考突击学习成果报告", payload.path("reportTitle").asText());
-        Assertions.assertEquals("+19", payload.path("summaryMetrics").path("vocabularyGrowthText").asText());
-        Assertions.assertEquals(2347, payload.path("vocabularyComparison").path("afterValue").asInt());
-        Assertions.assertEquals("number", payload.path("examUnknownWordsHitStatus").path("hitWords").get(0).asText());
+        Assertions.assertEquals("吴泓妤", payload.path("StudentName").asText());
+        Assertions.assertEquals("高考", payload.path("StageName").asText());
+        Assertions.assertEquals("2024真题", payload.path("TestPaperTitle").asText());
+        Assertions.assertEquals(19, payload.path("StudentImproveWordCount").asInt());
+        Assertions.assertEquals(4, payload.path("TestPaperImprovedWordCount").asInt());
+        Assertions.assertEquals(1.93, payload.path("PaperMasteryHitRate").asDouble());
+        Assertions.assertEquals(0.48, payload.path("ImproveStudyEfficiency").asDouble());
+        Assertions.assertEquals(" number ", payload.path("TestPaperImprovedWords").get(0).asText());
+        Assertions.assertFalse(payload.has("summaryMetrics"));
+        Assertions.assertFalse(payload.has("vocabularyComparison"));
+        Assertions.assertFalse(payload.has("paperKnownWordsComparison"));
+        Assertions.assertFalse(payload.has("examUnknownWordsHitStatus"));
     }
 
     // WHY: Sync outlook requests should use the same root-level upstream payload as async requests.

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

@@ -1,33 +1,31 @@
 {
-  "reportSubtitle": "2024真题 · 两周专项训练 · 真实提分效果",
-  "completionTitle": "恭喜完成两周考前突击专项训练",
-  "completionSubtitle": "基于2024英语真题试卷 · 真实学习效果分析",
-  "summaryMetrics": {
-    "vocabularyGrowthText": "+19",
-    "paperKnownWordsGrowthText": "+4",
-    "unknownWordHitRateText": "0.0193",
-    "learningEfficiencyText": "0.48"
-  },
-  "vocabularyComparison": {
-    "beforeValue": -1,
-    "afterValue": 2347,
-    "beforeText": "2328",
-    "afterText": "2347",
-    "growthText": "+19"
-  },
-  "paperKnownWordsComparison": {
-    "beforeValue": 650,
-    "afterValue": 654,
-    "beforeText": "650",
-    "afterText": "654",
-    "growthText": "+4"
-  },
-  "examUnknownWordsHitStatus": {
-    "unknownWordHitRateText": "0.0193",
-    "learningEfficiencyText": "0.48",
-    "unknownWordsBeforeText": "207",
-    "unknownWordsAfterText": "203",
-    "reducedUnknownWordsText": "4",
-    "hitWords": ["number", "bear", "popular", "importance"]
-  }
+  "StudentName": "吴泓妤",
+  "StudentStage": 3,
+  "StageName": "高考",
+  "StageVocabulary": -1,
+  "StudentVocabulary": 2347,
+  "StudentVocabularyBefore": 2328,
+  "StudentUnMastedWordCount": 1153,
+  "StudentImproveWordCount": 19,
+  "TestPaperTitle": "2024真题",
+  "TestPaperWordCount": 861,
+  "TestPaperBeforUnMastery": 207,
+  "TestPaperBeforMastery": 650,
+  "TestPaperLatestMastery": 654,
+  "TestPaperAfterUnMastery": 203,
+  "TestPaperImprovedWords": [
+    " number ",
+    {"WordSpell": "bear", "Ignored": true},
+    {"WordSpell": " popular "},
+    "importance"
+  ],
+  "TestPaperImprovedWordCount": 4,
+  "TestPaperImproveRate": 0.62,
+  "PaperMasteryHitRate": 1.93,
+  "ImproveStudyEfficiency": 0.48,
+  "StudentInitialVocabMastery": 66.51,
+  "StudentCurrentVocabMastery": 67.06,
+  "StudentVocabMasteryImprovement": 0.55,
+  "TestPaperBeforMasteryRate": 75.49,
+  "TestPaperLatestMasteryRate": 75.96
 }

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

@@ -1,34 +1,35 @@
 {
-  "reportTitle": "高考英语临考突击学习成果报告",
-  "reportSubtitle": "2024真题 · 两周专项训练 · 真实提分效果",
-  "completionTitle": "恭喜完成两周考前突击专项训练",
-  "completionSubtitle": "基于2024英语真题试卷 · 真实学习效果分析",
-  "summaryMetrics": {
-    "vocabularyGrowthText": "+19",
-    "paperKnownWordsGrowthText": "+4",
-    "unknownWordHitRateText": "0.0193",
-    "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": "0.0193",
-    "learningEfficiencyText": "0.48",
-    "unknownWordsBeforeText": "207",
-    "unknownWordsAfterText": "203",
-    "reducedUnknownWordsText": "4",
-    "hitWords": ["number", "bear", "popular", "importance"]
-  }
+  "StudentName": "吴泓妤",
+  "StudentStage": 3,
+  "StageName": "高考",
+  "StageVocabulary": 3500,
+  "StudentVocabulary": 2347,
+  "StudentVocabularyBefore": 2328,
+  "StudentUnMastedWordCount": 1153,
+  "StudentImproveWordCount": 19,
+  "TestPaperTitle": "2024真题",
+  "TestPaperWordCount": 861,
+  "TestPaperBeforUnMastery": 207,
+  "TestPaperBeforMastery": 650,
+  "TestPaperLatestMastery": 654,
+  "TestPaperAfterUnMastery": 203,
+  "TestPaperImprovedWords": [
+    " number ",
+    {"WordSpell": "bear", "Ignored": true},
+    {"WordSpell": " popular "},
+    "importance"
+  ],
+  "TestPaperImprovedWordCount": 4,
+  "TestPaperImproveRate": 0.62,
+  "PaperMasteryHitRate": 1.93,
+  "ImproveStudyEfficiency": 0.48,
+  "StudentInitialVocabMastery": 66.51,
+  "StudentCurrentVocabMastery": 67.06,
+  "StudentVocabMasteryImprovement": 0.55,
+  "TestPaperBeforMasteryRate": 75.49,
+  "TestPaperLatestMasteryRate": 75.96,
+  "ShouldDisplaySigningGuarantee": true,
+  "SigningGuarantee": "签约保障",
+  "StudentWordsLatest": [{"WordSpell": "ignored-large-array"}],
+  "StudentWordsFirstPreExamAssaultAfter": [{"WordSpell": "ignored-large-array"}]
 }