Răsfoiți Sursa

Merge branch 'feature/outlook-vocabulary-bands' of jyx/dcjxb.microservice into master

金逸霄 2 săptămâni în urmă
părinte
comite
8e95734a12

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

@@ -5,6 +5,7 @@ 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.contracts.report.OutlookStudentVocabularyReportPayload;
 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;
@@ -382,9 +383,29 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     }
 
     private void validateOutlookPayload(JsonNode payload) {
+        if (isStudentVocabularyOutlookPayload(payload)) {
+            OutlookStudentVocabularyReportPayload reportPayload = readPayload(
+                    payload,
+                    OutlookStudentVocabularyReportPayload.class);
+            validatePayload(reportPayload);
+            return;
+        }
+
         OutlookExamSprintReportPayload reportPayload = readPayload(payload, OutlookExamSprintReportPayload.class);
+        validatePayload(reportPayload);
+    }
+
+    private boolean isStudentVocabularyOutlookPayload(JsonNode payload) {
+        return payload != null
+                && payload.isObject()
+                && payload.hasNonNull("StudentWordsLatest")
+                && payload.hasNonNull("StudentName")
+                && payload.hasNonNull("StageVocabulary")
+                && payload.hasNonNull("StageExaminName");
+    }
 
-        Set<ConstraintViolation<OutlookExamSprintReportPayload>> violations = validator.validate(reportPayload);
+    private <T> void validatePayload(T reportPayload) {
+        Set<ConstraintViolation<T>> violations = validator.validate(reportPayload);
         if (!violations.isEmpty()) {
             throw new BusinessException(ErrorCode.VALIDATION_ERROR);
         }

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

@@ -84,6 +84,75 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.PENDING);
     }
 
+    @Test
+    void createOutlookReportAcceptsCallerVocabularyPayloadAndDispatches() throws Exception {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        List<String> dispatchedReportIds = new ArrayList<>();
+        DefaultExamSprintReportApplicationService service = service(repository, dispatchedReportIds::add, storage);
+
+        var response = service.createOutlookReport(callerVocabularyPayload());
+
+        assertThat(response.reportId()).isNotBlank();
+        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+        assertThat(saved.reportType()).isEqualTo(ReportType.OUTLOOK);
+        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.PENDING);
+        assertThat(dispatchedReportIds).containsExactly(response.reportId());
+    }
+
+    @Test
+    void createOutlookReportKeepsStructuredPayloadPathWhenOnlyStageVocabularyFieldIsAdded() {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        List<String> dispatchedReportIds = new ArrayList<>();
+        DefaultExamSprintReportApplicationService service = service(repository, dispatchedReportIds::add, storage);
+        ObjectNode payload = validOutlookPayload().deepCopy();
+        payload.put("StageVocabulary", 4200);
+
+        var response = service.createOutlookReport(payload);
+
+        assertThat(response.reportId()).isNotBlank();
+        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+        assertThat(saved.reportType()).isEqualTo(ReportType.OUTLOOK);
+        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.PENDING);
+        assertThat(dispatchedReportIds).containsExactly(response.reportId());
+    }
+
+    @Test
+    void createOutlookReportAcceptsCallerVocabularyPayloadWithNoMasteredPaperWords() throws Exception {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        List<String> dispatchedReportIds = new ArrayList<>();
+        DefaultExamSprintReportApplicationService service = service(repository, dispatchedReportIds::add, storage);
+        ObjectNode payload = callerVocabularyPayload().deepCopy();
+        payload.putArray("TestPaperMastedWords");
+        payload.put("TestPaperMastedWordCount", 0);
+
+        var response = service.createOutlookReport(payload);
+
+        assertThat(response.reportId()).isNotBlank();
+        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+        assertThat(saved.reportType()).isEqualTo(ReportType.OUTLOOK);
+        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.PENDING);
+        assertThat(dispatchedReportIds).containsExactly(response.reportId());
+    }
+
+    @Test
+    void createOutlookReportRejectsCallerVocabularyPayloadWithoutStudentWordsLatest() throws Exception {
+        ObjectNode invalidPayload = callerVocabularyPayload();
+        invalidPayload.remove("StudentWordsLatest");
+
+        assertCreateOutlookReportRejectsInvalidPayload(invalidPayload);
+    }
+
+    @Test
+    void createOutlookReportRejectsCallerVocabularyPayloadWithNullStudentWordLatest() throws Exception {
+        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
+        ((com.fasterxml.jackson.databind.node.ArrayNode) invalidPayload.get("StudentWordsLatest")).addNull();
+
+        assertCreateOutlookReportRejectsInvalidPayload(invalidPayload);
+    }
+
     @Test
     void createAchievementReportStoresAchievementTypeAndReturnsReportId() {
         TestRepository repository = new TestRepository();
@@ -127,6 +196,32 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(storage.generatedKeys).containsExactly(saved.storageObjectKey());
     }
 
+    @Test
+    void createOutlookReportSyncAcceptsCallerVocabularyPayloadAndReturnsDownloadUrl() throws Exception {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        DefaultExamSprintReportApplicationService service = service(
+                repository,
+                reportId -> {
+                    throw new IllegalStateException("sync create must not dispatch async generation");
+                },
+                storage);
+
+        var response = service.createOutlookReportSync(callerVocabularyPayload());
+
+        assertThat(response.reportId()).isNotBlank();
+        assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/download");
+        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+        assertThat(saved.reportType()).isEqualTo(ReportType.OUTLOOK);
+        assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
+        assertThat(saved.storageObjectKey()).isEqualTo("exam-sprint-outlook-report-" + response.reportId() + ".pdf");
+        assertThat(storage.generatedKeys).containsExactly(saved.storageObjectKey());
+        assertThat(storage.uploadedContents).containsOnlyKeys(saved.storageObjectKey());
+        assertThat(new String(storage.uploadedContents.get(saved.storageObjectKey()).bytes(), StandardCharsets.UTF_8))
+                .contains("preview:20260318测试:")
+                .contains(FIXED_CLOCK.instant().toString());
+    }
+
     @Test
     void createAchievementReportSyncGeneratesUploadAndReturnsDownloadUrl() {
         TestRepository repository = new TestRepository();
@@ -583,6 +678,7 @@ class ExamSprintReportApplicationServiceTest {
                         "每次精听后补 5 组同义替换并做口头复述。"),
                 new OutlookExamSprintReportPayload.HighFrequencyVocabularyChart(
                         77,
+                        70,
                         62,
                         "Common 高频词覆盖较广,但易混词记忆不牢。"),
                 new OutlookExamSprintReportPayload.VocabularyFrequencyBandChart(
@@ -611,6 +707,41 @@ class ExamSprintReportApplicationServiceTest {
                         19)));
     }
 
+    private ObjectNode callerVocabularyPayload() throws Exception {
+        return (ObjectNode) OBJECT_MAPPER.readTree("""
+                {
+                  "StudentName": "20260318测试",
+                  "StudentStage": 2,
+                  "StageName": "初中",
+                  "StageVocabulary": 10,
+                  "StageExaminName": "中考",
+                  "StageImportant": 3,
+                  "StudentWordsLatest": [
+                    {"WordId": 1, "MeanId": 1, "WordSpell": "w1", "WordFrequency": 1, "Mastery": 1.0, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 2, "MeanId": 1, "WordSpell": "w2", "WordFrequency": 2, "Mastery": 0.5, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 3, "MeanId": 1, "WordSpell": "w3", "WordFrequency": 3, "Mastery": 0.2, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 4, "MeanId": 1, "WordSpell": "w4", "WordFrequency": 4, "Mastery": 0.4, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 5, "MeanId": 1, "WordSpell": "w5", "WordFrequency": 5, "Mastery": 0.6, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 6, "MeanId": 1, "WordSpell": "w6", "WordFrequency": 6, "Mastery": 0.8, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 7, "MeanId": 1, "WordSpell": "w7", "WordFrequency": 7, "Mastery": 0.1, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 8, "MeanId": 1, "WordSpell": "w8", "WordFrequency": 8, "Mastery": 0.2, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 9, "MeanId": 1, "WordSpell": "w9", "WordFrequency": 9, "Mastery": 0.3, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 10, "MeanId": 1, "WordSpell": "w10", "WordFrequency": 10, "Mastery": 0.4, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"}
+                  ],
+                  "MastedWordCount": 4,
+                  "UnMastedWordCount": 6,
+                  "ExamineStrangeWordCount": 3,
+                  "TestPaperWordIdArray": [1, 2, 3, 4, 5],
+                  "TestPaperTitle": "文章2.jpg",
+                  "TestPaperUnMasterWords": ["lot", "father", "catch"],
+                  "TestPaperMastedWords": ["a", "the"],
+                  "TestPaperMastedWordCount": 2,
+                  "TestPaperWordCount": 5,
+                  "Complex": false
+                }
+                """);
+    }
+
     private ObjectNode validAchievementPayload() {
         return (ObjectNode) OBJECT_MAPPER.valueToTree(new AchievementExamSprintReportPayload(
                 "高考英语临考突击学习成果报告",
@@ -741,6 +872,7 @@ class ExamSprintReportApplicationServiceTest {
         private final List<String> generatedKeys = new ArrayList<>();
         private final List<String> deletedKeys = new ArrayList<>();
         private final List<String> deleteFailures = new ArrayList<>();
+        private final ConcurrentMap<String, StoredExamSprintReportContent> uploadedContents = new ConcurrentHashMap<>();
         private RuntimeException generateDownloadUrlFailure;
         private RuntimeException downloadFailure;
 
@@ -751,6 +883,7 @@ class ExamSprintReportApplicationServiceTest {
                 String fileName,
                 byte[] pdfBytes,
                 Instant expiresAt) {
+            uploadedContents.put(fileName, new StoredExamSprintReportContent(fileName, pdfBytes, "application/pdf"));
             return new StoredExamSprintReportFile(fileName, fileName);
         }
 
@@ -768,7 +901,7 @@ class ExamSprintReportApplicationServiceTest {
             if (downloadFailure != null) {
                 throw downloadFailure;
             }
-            return Optional.empty();
+            return Optional.ofNullable(uploadedContents.get(storageObjectKey));
         }
 
         @Override
@@ -814,9 +947,13 @@ class ExamSprintReportApplicationServiceTest {
 
         @Override
         public String render(ReportContent content, Instant generatedAt) {
-            String title = content instanceof AchievementReportContent achievementContent
-                    ? achievementContent.reportTitle()
-                    : "";
+            String title = "";
+            if (content instanceof AchievementReportContent achievementContent) {
+                title = achievementContent.reportTitle();
+            } else if (content instanceof UnmodeledReportContent unmodeledContent
+                    && unmodeledContent.source() instanceof JsonNode payload) {
+                title = payload.path("reportMetadata").path("learnerName").asText(payload.path("StudentName").asText(""));
+            }
             return "<html><body>preview:" + title + ":" + generatedAt + "</body></html>";
         }
     }

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

@@ -1,5 +1,8 @@
 package cn.yunzhixue.ability.center.examsprint.contracts.report;
 
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
 import jakarta.validation.Valid;
 import jakarta.validation.constraints.Max;
 import jakarta.validation.constraints.Min;
@@ -9,6 +12,7 @@ import jakarta.validation.constraints.NotNull;
 
 import java.util.List;
 
+@JsonIgnoreProperties(ignoreUnknown = true)
 public record OutlookExamSprintReportPayload(
         @NotNull @Valid ReportMetadata reportMetadata,
         @NotNull @Valid ReadinessOverview readinessOverview,
@@ -65,8 +69,24 @@ public record OutlookExamSprintReportPayload(
 
     public record HighFrequencyVocabularyChart(
             @Min(0) @Max(100) int basicCorePercent,
-            @Min(0) @Max(100) int highScorePercent,
+            @Min(0) @Max(100) int frequentCorePercent,
+            @Min(0) @Max(100) int advancedScorePercent,
             @NotBlank String highlightLabel) {
+
+        @JsonCreator
+        public static HighFrequencyVocabularyChart create(
+                @JsonProperty("basicCorePercent") int basicCorePercent,
+                @JsonProperty("frequentCorePercent") Integer frequentCorePercent,
+                @JsonProperty("advancedScorePercent") Integer advancedScorePercent,
+                @JsonProperty("highScorePercent") Integer highScorePercent,
+                @JsonProperty("highlightLabel") String highlightLabel) {
+            int legacyPercent = highScorePercent == null ? 0 : highScorePercent;
+            return new HighFrequencyVocabularyChart(
+                    basicCorePercent,
+                    frequentCorePercent != null ? frequentCorePercent : legacyPercent,
+                    advancedScorePercent != null ? advancedScorePercent : legacyPercent,
+                    highlightLabel);
+        }
     }
 
     public record VocabularyFrequencyBandChart(

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

@@ -0,0 +1,43 @@
+package cn.yunzhixue.ability.center.examsprint.contracts.report;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.List;
+
+public record OutlookStudentVocabularyReportPayload(
+        @JsonProperty("StudentName") @NotBlank String studentName,
+        @JsonProperty("StudentStage") @NotNull @Min(0) Integer studentStage,
+        @JsonProperty("StageName") @NotBlank String stageName,
+        @JsonProperty("StageVocabulary") @NotNull @Min(0) Integer stageVocabulary,
+        @JsonProperty("StageExaminName") @NotBlank String stageExaminName,
+        @JsonProperty("StageImportant") @NotNull @Min(0) Integer stageImportant,
+        @JsonProperty("StudentWordsLatest") @NotEmpty List<@NotNull @Valid StudentWordLatest> studentWordsLatest,
+        @JsonProperty("MastedWordCount") @NotNull @Min(0) Integer mastedWordCount,
+        @JsonProperty("UnMastedWordCount") @NotNull @Min(0) Integer unMastedWordCount,
+        @JsonProperty("ExamineStrangeWordCount") @NotNull @Min(0) Integer examineStrangeWordCount,
+        @JsonProperty("TestPaperWordIdArray") @NotNull List<@NotNull @Min(0) Integer> testPaperWordIdArray,
+        @JsonProperty("TestPaperTitle") @NotBlank String testPaperTitle,
+        @JsonProperty("TestPaperUnMasterWords") @NotNull List<@NotBlank String> testPaperUnMasterWords,
+        @JsonProperty("TestPaperMastedWords") @NotNull List<@NotBlank String> testPaperMastedWords,
+        @JsonProperty("TestPaperMastedWordCount") @NotNull @Min(0) Integer testPaperMastedWordCount,
+        @JsonProperty("TestPaperWordCount") @NotNull @Min(0) Integer testPaperWordCount,
+        @JsonProperty("Complex") @NotNull Boolean complex) {
+
+    public record StudentWordLatest(
+            @JsonProperty("WordId") @NotNull @Min(0) Integer wordId,
+            @JsonProperty("MeanId") @NotNull @Min(0) Integer meanId,
+            @JsonProperty("WordSpell") @NotBlank String wordSpell,
+            @JsonProperty("WordFrequency") @NotNull @Min(1) Integer wordFrequency,
+            @JsonProperty("Mastery") @NotNull @DecimalMin("0.0") @DecimalMax("1.0") Double mastery,
+            @JsonProperty("ReviewTimes") @NotNull @Min(0) Integer reviewTimes,
+            @JsonProperty("Reliability") @NotNull @Min(0) Integer reliability,
+            @JsonProperty("CreateTime") @NotBlank String createTime) {
+    }
+}

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

@@ -1,6 +1,7 @@
 package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook;
 
 import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookExamSprintReportPayload;
+import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookStudentVocabularyReportPayload;
 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;
@@ -51,7 +52,9 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
             throw new IllegalArgumentException("Outlook renderer requires unmodeled OUTLOOK JsonNode content");
         }
         try {
-            OutlookExamSprintReportPayload reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
+            OutlookExamSprintReportPayload reportPayload = isStudentVocabularyOutlookPayload(payload)
+                    ? adaptStudentVocabularyPayload(objectMapper.treeToValue(payload, OutlookStudentVocabularyReportPayload.class))
+                    : objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
             return loadTemplate()
                     .replace("{{syllabusMasteryChart}}", renderSyllabusMasteryChart(reportPayload.syllabusMasteryChart()))
                     .replace("{{pastPaperVocabularyChart}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularyChart()))
@@ -66,6 +69,124 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         }
     }
 
+    private boolean isStudentVocabularyOutlookPayload(JsonNode payload) {
+        return payload != null
+                && payload.isObject()
+                && payload.hasNonNull("StudentWordsLatest")
+                && payload.hasNonNull("StudentName")
+                && payload.hasNonNull("StageVocabulary")
+                && payload.hasNonNull("StageExaminName");
+    }
+
+    private OutlookExamSprintReportPayload adaptStudentVocabularyPayload(OutlookStudentVocabularyReportPayload payload) {
+        int stageVocabulary = payload.stageVocabulary();
+        int masteredWordCount = payload.mastedWordCount();
+        int unmasteredWordCount = payload.unMastedWordCount();
+        int masteryPercent = (int) Math.round(percentage(masteredWordCount, stageVocabulary));
+        List<OutlookStudentVocabularyReportPayload.StudentWordLatest> words = participatingWords(payload);
+
+        int basicUpper = (int) Math.ceil(stageVocabulary * 0.2d);
+        int frequentUpper = (int) Math.ceil(stageVocabulary * 0.6d);
+        int highUpper = (int) Math.ceil(stageVocabulary * 0.3d);
+        int midUpper = (int) Math.ceil(stageVocabulary * 0.7d);
+
+        return new OutlookExamSprintReportPayload(
+                new OutlookExamSprintReportPayload.ReportMetadata(
+                        "2026 词汇展望报告",
+                        payload.studentName(),
+                        payload.stageExaminName(),
+                        payload.stageName() + "词汇冲刺",
+                        "Ability Bot"),
+                new OutlookExamSprintReportPayload.ReadinessOverview(
+                        "词汇能力进入提分窗口,适合围绕考纲和高频场景做集中突破。",
+                        payload.stageName(),
+                        "高频与常考词群是提分关键。",
+                        masteryPercent),
+                new OutlookExamSprintReportPayload.SyllabusMasteryChart(
+                        stageVocabulary,
+                        masteredWordCount,
+                        unmasteredWordCount,
+                        masteryPercent,
+                        "考纲词汇掌握概览",
+                        "优先补齐高频和核心常考词。"),
+                new OutlookExamSprintReportPayload.PastPaperVocabularyChart(
+                        payload.testPaperWordCount(),
+                        payload.testPaperUnMasterWords().size(),
+                        payload.testPaperUnMasterWords().size(),
+                        "预计提分5-15分",
+                        "先压降真题生词占比。"),
+                new OutlookExamSprintReportPayload.HighFrequencyVocabularyChart(
+                        roundedMasteryPercent(words, 0, basicUpper),
+                        roundedMasteryPercent(words, basicUpper, frequentUpper),
+                        roundedMasteryPercent(words, frequentUpper, stageVocabulary),
+                        "拉分词是提分核心突破项"),
+                new OutlookExamSprintReportPayload.VocabularyFrequencyBandChart(
+                        List.of(
+                                new OutlookExamSprintReportPayload.VocabularyFrequencyBar(
+                                        "高频词", masteryPercent(words, 0, highUpper), "优先学习", "#448aff"),
+                                new OutlookExamSprintReportPayload.VocabularyFrequencyBar(
+                                        "中频词", masteryPercent(words, highUpper, midUpper), "重点突破", "#4caf50"),
+                                new OutlookExamSprintReportPayload.VocabularyFrequencyBar(
+                                        "低频词", masteryPercent(words, midUpper, stageVocabulary), "酌情学习", "#ff9800"))),
+                staticFrequencyPlan(),
+                staticScoreImprovementCaseStudy());
+    }
+
+    private List<OutlookStudentVocabularyReportPayload.StudentWordLatest> participatingWords(OutlookStudentVocabularyReportPayload payload) {
+        int stageVocabulary = payload.stageVocabulary();
+        return payload.studentWordsLatest().stream()
+                .filter(word -> word.wordFrequency() >= 1 && word.wordFrequency() <= stageVocabulary)
+                .toList();
+    }
+
+    private int roundedMasteryPercent(List<OutlookStudentVocabularyReportPayload.StudentWordLatest> words,
+                                      int lowerExclusive,
+                                      int upperInclusive) {
+        return (int) Math.round(masteryPercent(words, lowerExclusive, upperInclusive));
+    }
+
+    private double masteryPercent(List<OutlookStudentVocabularyReportPayload.StudentWordLatest> words,
+                                  int lowerExclusive,
+                                  int upperInclusive) {
+        List<OutlookStudentVocabularyReportPayload.StudentWordLatest> matchedWords = words.stream()
+                .filter(word -> word.wordFrequency() > lowerExclusive && word.wordFrequency() <= upperInclusive)
+                .toList();
+        if (matchedWords.isEmpty()) {
+            return 0d;
+        }
+        return matchedWords.stream()
+                .mapToDouble(OutlookStudentVocabularyReportPayload.StudentWordLatest::mastery)
+                .average()
+                .orElse(0d) * 100d;
+    }
+
+    private OutlookExamSprintReportPayload.FrequencyPlan staticFrequencyPlan() {
+        return new OutlookExamSprintReportPayload.FrequencyPlan(
+                List.of(
+                        new OutlookExamSprintReportPayload.FrequencyPlanCard(1, "+5分", 38, false, "稳健", "①"),
+                        new OutlookExamSprintReportPayload.FrequencyPlanCard(2, "+10分", 55, false, "均衡", "②"),
+                        new OutlookExamSprintReportPayload.FrequencyPlanCard(3, "+15分", 72, true, "推荐", "③"),
+                        new OutlookExamSprintReportPayload.FrequencyPlanCard(5, "+20分", 88, false, "冲刺", "④")),
+                "💡建议策略",
+                "7 天提分冲刺是首选节奏,按词频优先级记忆,不浪费时间;只攻克高频/中频核心词,2周15小时速记500-800必考词,快速缩小生词缺口。",
+                List.of(
+                        new OutlookExamSprintReportPayload.PhaseSuggestion("考前半个月·核心突击期", "围绕高频词建立记忆闭环。"),
+                        new OutlookExamSprintReportPayload.PhaseSuggestion("考前半小时·临阵巩固期", "结合真题词做循环巩固。")));
+    }
+
+    private OutlookExamSprintReportPayload.ScoreImprovementCaseStudy staticScoreImprovementCaseStudy() {
+        return new OutlookExamSprintReportPayload.ScoreImprovementCaseStudy(
+                "真实提分 · 效果可复制",
+                "王雷宇",
+                "考前半个月·核心突击期",
+                705,
+                237,
+                33.8,
+                "70分以下",
+                89,
+                19);
+    }
+
     private String loadTemplate() throws IOException {
         try (InputStream inputStream = new ClassPathResource(TEMPLATE_RESOURCE).getInputStream()) {
             return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
@@ -144,27 +265,32 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
     private String renderHighFrequencyVocabularyChart(OutlookExamSprintReportPayload.HighFrequencyVocabularyChart chart) {
         int axisMax = 100;
         int basicHeight = barHeight(chart.basicCorePercent(), axisMax);
-        int highScoreHeight = barHeight(chart.highScorePercent(), axisMax);
+        int frequentHeight = barHeight(chart.frequentCorePercent(), axisMax);
+        int advancedHeight = barHeight(chart.advancedScorePercent(), axisMax);
 
         return new StringBuilder()
                 .append("<div class='card'>")
                 .append("<h3 class='card-title'>常考词汇掌握情况</h3>")
                 .append("<div class='chart-box'>")
                 .append("<svg class='high-frequency-column-chart'").append(SVG_CJK_FONT_FAMILY)
-                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='常考词汇掌握情况'>")
-                .append(renderChartAxes(320))
+                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='常考词汇掌握情况'>")
+                .append(renderChartAxes(360))
                 .append(renderYAxisTicks(axisMax, 20))
-                .append(renderXAxisTickMarks(112, 208))
-                .append("<rect class='chart-column basic-core-column' x='84' y='").append(CHART_AXIS_BOTTOM - basicHeight)
-                .append("' width='56' height='").append(basicHeight).append("' rx='8' ry='8' fill='#42a5f5'/>")
-                .append("<rect class='chart-column high-score-column' x='180' y='").append(CHART_AXIS_BOTTOM - highScoreHeight)
-                .append("' width='56' height='").append(highScoreHeight).append("' rx='8' ry='8' fill='#66bb6a'/>")
-                .append("<text class='chart-caption' x='112' y='198' text-anchor='middle'>基础必会词</text>")
-                .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>高分拉分词</text>")
+                .append(renderXAxisTickMarks(97, 187, 277))
+                .append("<rect class='chart-column basic-core-column' x='70' y='").append(CHART_AXIS_BOTTOM - basicHeight)
+                .append("' width='54' height='").append(basicHeight).append("' rx='8' ry='8' fill='#42a5f5'/>")
+                .append("<rect class='chart-column frequent-core-column' x='160' y='").append(CHART_AXIS_BOTTOM - frequentHeight)
+                .append("' width='54' height='").append(frequentHeight).append("' rx='8' ry='8' fill='#66bb6a'/>")
+                .append("<rect class='chart-column advanced-score-column' x='250' y='").append(CHART_AXIS_BOTTOM - advancedHeight)
+                .append("' width='54' height='").append(advancedHeight).append("' rx='8' ry='8' fill='#ff9800'/>")
+                .append("<text class='chart-caption' x='97' y='198' text-anchor='middle'>基础必会词</text>")
+                .append("<text class='chart-caption' x='187' y='198' text-anchor='middle'>核心常考词</text>")
+                .append("<text class='chart-caption' x='277' y='198' text-anchor='middle'>拔高拉分词</text>")
                 .append("</svg>")
                 .append("</div>")
-                .append("<div class='data-text'>基础必会词:掌握率").append(chart.basicCorePercent())
-                .append("%<br/>高分拉分词:掌握率").append(chart.highScorePercent()).append("%</div>")
+                .append("<div class='data-text'>基础必会词:综合掌握率").append(chart.basicCorePercent())
+                .append("%<br/>核心常考词:综合掌握率").append(chart.frequentCorePercent())
+                .append("%<br/>拔高拉分词:综合掌握率").append(chart.advancedScorePercent()).append("%</div>")
                 .append("<p class='data-text'><span class='highlight'>")
                 .append(escape(chart.highlightLabel()))
                 .append("</span></p>")
@@ -218,6 +344,7 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
             }
             builder.append(escape(bar.bandLabel())).append(":")
                     .append(formatOneDecimal(bar.currentValue()))
+                    .append("%")
                     .append("(")
                     .append(escape(bar.priorityLabel()))
                     .append(")");
@@ -377,7 +504,7 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
     }
 
     private int barHeight(int value, int maxValue) {
-        if (maxValue <= 0) {
+        if (maxValue <= 0 || value <= 0) {
             return 0;
         }
         double ratio = Math.max(0d, Math.min(1d, value / (double) maxValue));

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

@@ -57,9 +57,9 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                     .contains("705词")
                     .contains("237词")
                     .contains("+19分")
-                    .containsAnyOf("高频词:188.6(优先学习)", "高频词188.6优先学习")
-                    .containsAnyOf("中频词:154.5(重点突破)", "中频词154.5重点突破")
-                    .containsAnyOf("低频词:70.4(酌情学习)", "低频词70.4酌情学习");
+                    .containsAnyOf("高频词:86.0%(优先学习)", "高频词86.0%优先学习")
+                    .containsAnyOf("中频词:78.0%(重点突破)", "中频词78.0%重点突破")
+                    .containsAnyOf("低频词:62.0%(酌情学习)", "低频词62.0%酌情学习");
         }
     }
 
@@ -274,14 +274,15 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                   },
                   "highFrequencyVocabularyChart": {
                     "basicCorePercent": 62,
-                    "highScorePercent": 41,
+                    "frequentCorePercent": 54,
+                    "advancedScorePercent": 41,
                     "highlightLabel": "拉分词是提分核心突破项"
                   },
                   "vocabularyFrequencyBandChart": {
                     "bars": [
-                      {"bandLabel": "高频词", "currentValue": 188.6, "priorityLabel": "优先学习", "themeColor": "#448aff"},
-                      {"bandLabel": "中频词", "currentValue": 154.5, "priorityLabel": "重点突破", "themeColor": "#4caf50"},
-                      {"bandLabel": "低频词", "currentValue": 70.4, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
+                      {"bandLabel": "高频词", "currentValue": 86.0, "priorityLabel": "优先学习", "themeColor": "#448aff"},
+                      {"bandLabel": "中频词", "currentValue": 78.0, "priorityLabel": "重点突破", "themeColor": "#4caf50"},
+                      {"bandLabel": "低频词", "currentValue": 62.0, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
                     ]
                   },
                   "frequencyPlan": {

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

@@ -73,11 +73,75 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .contains("未掌握:<span class='highlight'>1499词(35.69%)</span>")
                 .contains("真题总词:961词 | 生词量:847词(88.14%)")
                 .contains("生词占比降至74.51%")
-                .contains("基础必会词:掌握率62%")
-                .contains("高分拉分词:掌握率41%")
-                .contains("高频词:188.6(优先学习)")
-                .contains("中频词:154.5(重点突破)")
-                .contains("低频词:70.4(酌情学习)");
+                .contains("基础必会词:综合掌握率62%")
+                .contains("核心常考词:综合掌握率54%")
+                .contains("拔高拉分词:综合掌握率41%")
+                .contains("高频词:86.0%(优先学习)")
+                .contains("中频词:78.0%(重点突破)")
+                .contains("低频词:62.0%(酌情学习)");
+    }
+
+    @Test
+    void renderAdaptsCallerVocabularyPayloadIntoOutlookReportBands() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(html)
+                .contains("考纲总量:<span class='highlight'>10词</span>")
+                .contains("已掌握:<span class='highlight'>4词(40.00%)</span>")
+                .contains("未掌握:<span class='highlight'>6词(60.00%)</span>")
+                .contains("真题总词:5词 | 生词量:3词(60.00%)")
+                .contains("基础必会词:综合掌握率75%")
+                .contains("核心常考词:综合掌握率50%")
+                .contains("拔高拉分词:综合掌握率25%")
+                .contains("高频词:56.7%(优先学习)")
+                .contains("中频词:47.5%(重点突破)")
+                .contains("低频词:30.0%(酌情学习)")
+                .contains("7 天提分冲刺是首选节奏")
+                .contains("王雷宇")
+                .contains("+19分");
+    }
+
+    @Test
+    void renderKeepsStructuredPayloadPathWhenOnlyStageVocabularyFieldIsAdded() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+        ObjectNode payload = samplePayload().deepCopy();
+        payload.put("StageVocabulary", 4200);
+        ((ObjectNode) payload.get("highFrequencyVocabularyChart")).put("basicCorePercent", 77);
+
+        String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(html)
+                .contains("基础必会词:综合掌握率77%")
+                .contains("高频词:86.0%(优先学习)");
+    }
+
+    @Test
+    void renderSupportsLegacyHighScorePercentForStructuredOutlookPayload() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        String html = renderer.render(unmodeledOutlookContent(legacyStructuredPayloadWithHighScorePercent()),
+                Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(html)
+                .contains("基础必会词:综合掌握率77%")
+                .contains("核心常考词:综合掌握率62%")
+                .contains("拔高拉分词:综合掌握率62%");
+    }
+
+    @Test
+    void renderUsesZeroHeightForZeroValueBarsWithoutForcingMinimumHeight() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayloadWithZeroValueBars()),
+                Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(html)
+                .contains("class='chart-column advanced-score-column' x='250' y='180' width='54' height='0'")
+                .contains("拔高拉分词:综合掌握率0%")
+                .contains("class='chart-column low-band-column' x='250' y='180' width='54' height='0'")
+                .contains("低频词:0.0%(酌情学习)");
     }
 
     @Test
@@ -91,7 +155,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .contains("class='chart-axis-tick chart-axis-tick-x'")
                 .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>1000</text>")
                 .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>100</text>")
-                .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>200</text>");
+                .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>100</text>");
     }
 
     @Test
@@ -277,14 +341,15 @@ class ClasspathOutlookExamSprintReportRendererTest {
                   },
                   "highFrequencyVocabularyChart": {
                     "basicCorePercent": 62,
-                    "highScorePercent": 41,
+                    "frequentCorePercent": 54,
+                    "advancedScorePercent": 41,
                     "highlightLabel": "拉分词是提分核心突破项"
                   },
                   "vocabularyFrequencyBandChart": {
                     "bars": [
-                      {"bandLabel": "高频词", "currentValue": 188.6, "priorityLabel": "优先学习", "themeColor": "#448aff"},
-                      {"bandLabel": "中频词", "currentValue": 154.5, "priorityLabel": "重点突破", "themeColor": "#4caf50"},
-                      {"bandLabel": "低频词", "currentValue": 70.4, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
+                      {"bandLabel": "高频词", "currentValue": 86.0, "priorityLabel": "优先学习", "themeColor": "#448aff"},
+                      {"bandLabel": "中频词", "currentValue": 78.0, "priorityLabel": "重点突破", "themeColor": "#4caf50"},
+                      {"bandLabel": "低频词", "currentValue": 62.0, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
                     ]
                   },
                   "frequencyPlan": {
@@ -344,6 +409,62 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 """);
     }
 
+    private JsonNode callerVocabularyPayload() throws Exception {
+        return OBJECT_MAPPER.readTree("""
+                {
+                  "StudentName": "20260318测试",
+                  "StudentStage": 2,
+                  "StageName": "初中",
+                  "StageVocabulary": 10,
+                  "StageExaminName": "中考",
+                  "StageImportant": 3,
+                  "StudentWordsLatest": [
+                    {"WordId": 1, "MeanId": 1, "WordSpell": "w1", "WordFrequency": 1, "Mastery": 1.0, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 2, "MeanId": 1, "WordSpell": "w2", "WordFrequency": 2, "Mastery": 0.5, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 3, "MeanId": 1, "WordSpell": "w3", "WordFrequency": 3, "Mastery": 0.2, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 4, "MeanId": 1, "WordSpell": "w4", "WordFrequency": 4, "Mastery": 0.4, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 5, "MeanId": 1, "WordSpell": "w5", "WordFrequency": 5, "Mastery": 0.6, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 6, "MeanId": 1, "WordSpell": "w6", "WordFrequency": 6, "Mastery": 0.8, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 7, "MeanId": 1, "WordSpell": "w7", "WordFrequency": 7, "Mastery": 0.1, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 8, "MeanId": 1, "WordSpell": "w8", "WordFrequency": 8, "Mastery": 0.2, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 9, "MeanId": 1, "WordSpell": "w9", "WordFrequency": 9, "Mastery": 0.3, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
+                    {"WordId": 10, "MeanId": 1, "WordSpell": "w10", "WordFrequency": 10, "Mastery": 0.4, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"}
+                  ],
+                  "MastedWordCount": 4,
+                  "UnMastedWordCount": 6,
+                  "ExamineStrangeWordCount": 3,
+                  "TestPaperWordIdArray": [1, 2, 3, 4, 5],
+                  "TestPaperTitle": "文章2.jpg",
+                  "TestPaperUnMasterWords": ["lot", "father", "catch"],
+                  "TestPaperMastedWords": ["a", "the"],
+                  "TestPaperMastedWordCount": 2,
+                  "TestPaperWordCount": 5,
+                  "Complex": false
+                }
+                """);
+    }
+
+    private JsonNode legacyStructuredPayloadWithHighScorePercent() throws Exception {
+        ObjectNode payload = samplePayload().deepCopy();
+        ObjectNode highFrequencyVocabularyChart = (ObjectNode) payload.get("highFrequencyVocabularyChart");
+        highFrequencyVocabularyChart.put("basicCorePercent", 77);
+        highFrequencyVocabularyChart.remove("frequentCorePercent");
+        highFrequencyVocabularyChart.remove("advancedScorePercent");
+        highFrequencyVocabularyChart.put("highScorePercent", 62);
+        return payload;
+    }
+
+    private JsonNode callerVocabularyPayloadWithZeroValueBars() throws Exception {
+        ObjectNode payload = (ObjectNode) callerVocabularyPayload();
+        payload.withArray("StudentWordsLatest").forEach(node -> {
+            ObjectNode word = (ObjectNode) node;
+            if (word.path("WordFrequency").asInt() >= 7) {
+                word.put("Mastery", 0.0);
+            }
+        });
+        return payload;
+    }
+
     private UnmodeledReportContent unmodeledOutlookContent(JsonNode payload) {
         return new UnmodeledReportContent(ReportType.OUTLOOK, payload);
     }

+ 2 - 1
ability-center-runtime/scripts/outlook-report-demo.sh

@@ -51,7 +51,8 @@ http_code="$({
   },
   "highFrequencyVocabularyChart": {
     "basicCorePercent": 77,
-    "highScorePercent": 62,
+    "frequentCorePercent": 70,
+    "advancedScorePercent": 62,
     "highlightLabel": "Common 高频词覆盖较广,但易混词记忆不牢。"
   },
   "vocabularyFrequencyBandChart": {

+ 2 - 1
ability-center-runtime/src/test/resources/requests/exam-sprint-outlook-report-request.json

@@ -31,7 +31,8 @@
     },
     "highFrequencyVocabularyChart": {
       "basicCorePercent": 77,
-      "highScorePercent": 62,
+      "frequentCorePercent": 70,
+      "advancedScorePercent": 62,
       "highlightLabel": "Common 高频词覆盖较广,但易混词记忆不牢。"
     },
     "vocabularyFrequencyBandChart": {