Преглед изворни кода

refactor(exam-sprint): 转正展望报告词汇入参契约

金逸霄 пре 2 недеља
родитељ
комит
649ece72de
16 измењених фајлова са 814 додато и 901 уклоњено
  1. 0 18
      abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
  2. 57 73
      abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
  3. 29 129
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java
  4. 0 43
      abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookStudentVocabularyReportPayload.java
  5. 123 71
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java
  6. 5 5
      abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html
  7. 52 97
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java
  8. 76 224
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java
  9. 9 9
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/OutlookExamSprintReportTemplateCompatibilityTest.java
  10. 58 64
      ability-center-runtime/scripts/outlook-report-demo.sh
  11. 25 21
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java
  12. 17 17
      ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java
  13. 30 33
      ability-center-runtime/src/test/resources/requests/exam-sprint-achievement-report-invalid-request.json
  14. 31 34
      ability-center-runtime/src/test/resources/requests/exam-sprint-achievement-report-request.json
  15. 54 63
      ability-center-runtime/src/test/resources/requests/exam-sprint-outlook-report-request.json
  16. 248 0
      docs/plans/2026-04-29-outlook-payload-contract.md

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

@@ -5,7 +5,6 @@ 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;
@@ -383,27 +382,10 @@ 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");
-    }
-
     private <T> void validatePayload(T reportPayload) {
         Set<ConstraintViolation<T>> violations = validator.validate(reportPayload);
         if (!violations.isEmpty()) {

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

@@ -4,7 +4,6 @@ import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSp
 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.ExamSprintReportType;
-import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookExamSprintReportPayload;
 import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
@@ -56,12 +55,14 @@ class ExamSprintReportApplicationServiceTest {
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
     private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();
 
+    /** 覆盖公开响应合约字段收敛场景,当检查下载响应 record 时,应只暴露 downloadUrl 而不再暴露预览地址。 */
     @Test
     void publicReportContractsExposeDownloadUrlOnly() {
         assertReportRecordUsesDownloadUrlOnly(CreateExamSprintReportWithUrlResponse.class);
         assertReportRecordUsesDownloadUrlOnly(ExamSprintReportDetailResponse.class);
     }
 
+    /** 覆盖应用服务接口方法收敛场景,当反射检查接口声明时,应不存在已移除的 HTML 预览方法。 */
     @Test
     void applicationServiceDoesNotDeclareHtmlPreviewMethod() {
         assertThat(Arrays.stream(ExamSprintReportApplicationService.class.getDeclaredMethods())
@@ -70,6 +71,7 @@ class ExamSprintReportApplicationServiceTest {
                 .doesNotContain(removedHtmlPreviewMethodName());
     }
 
+    /** 覆盖创建展望报告的上游词汇报文场景,当提交有效 payload 时,应保存 OUTLOOK 报告并保留 StudentName 和 StageVocabulary。 */
     @Test
     void createOutlookReportStoresOutlookTypeAndReturnsReportId() {
         TestRepository repository = new TestRepository();
@@ -82,8 +84,13 @@ class ExamSprintReportApplicationServiceTest {
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
         assertThat(saved.reportType()).isEqualTo(ReportType.OUTLOOK);
         assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.PENDING);
+        UnmodeledReportContent content = (UnmodeledReportContent) saved.content();
+        JsonNode savedPayload = (JsonNode) content.source();
+        assertThat(savedPayload.path("StudentName").asText()).isEqualTo("20260318测试");
+        assertThat(savedPayload.path("StageVocabulary").asInt()).isEqualTo(10);
     }
 
+    /** 覆盖调用方直接提交上游词汇报文场景,当 payload 有完整 StudentWordsLatest 时,应创建并分发 OUTLOOK 报告。 */
     @Test
     void createOutlookReportAcceptsCallerVocabularyPayloadAndDispatches() throws Exception {
         TestRepository repository = new TestRepository();
@@ -100,24 +107,13 @@ class ExamSprintReportApplicationServiceTest {
         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());
+    void createOutlookReportRejectsNonContractPayloadBeforeSaving() throws Exception {
+        assertCreateOutlookReportRejectsInvalidPayload(nonContractOutlookPayload());
     }
 
+    /** 覆盖上游词汇报文允许无已掌握真题词的场景,当 TestPaperMastedWords 为空且计数为 0 时,应正常创建并分发。 */
     @Test
     void createOutlookReportAcceptsCallerVocabularyPayloadWithNoMasteredPaperWords() throws Exception {
         TestRepository repository = new TestRepository();
@@ -137,6 +133,7 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(dispatchedReportIds).containsExactly(response.reportId());
     }
 
+    /** 覆盖上游词汇报文缺少词汇明细的场景,当 StudentWordsLatest 缺失时,应在保存前校验失败。 */
     @Test
     void createOutlookReportRejectsCallerVocabularyPayloadWithoutStudentWordsLatest() throws Exception {
         ObjectNode invalidPayload = callerVocabularyPayload();
@@ -145,6 +142,7 @@ class ExamSprintReportApplicationServiceTest {
         assertCreateOutlookReportRejectsInvalidPayload(invalidPayload);
     }
 
+    /** 覆盖上游词汇报文明细含 null 元素的场景,当 StudentWordsLatest 中存在 null 时,应在保存前校验失败。 */
     @Test
     void createOutlookReportRejectsCallerVocabularyPayloadWithNullStudentWordLatest() throws Exception {
         ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
@@ -153,6 +151,7 @@ class ExamSprintReportApplicationServiceTest {
         assertCreateOutlookReportRejectsInvalidPayload(invalidPayload);
     }
 
+    /** 覆盖创建成果报告的有效报文场景,当提交 achievement payload 时,应映射为领域内容并保存 ACHIEVEMENT 报告。 */
     @Test
     void createAchievementReportStoresAchievementTypeAndReturnsReportId() {
         TestRepository repository = new TestRepository();
@@ -172,6 +171,7 @@ class ExamSprintReportApplicationServiceTest {
                 .containsExactly("number", "bear", "popular", "importance");
     }
 
+    /** 覆盖同步创建展望报告场景,当有效上游词汇报文生成成功时,应上传 PDF 并返回下载地址。 */
     @Test
     void createOutlookReportSyncGeneratesUploadAndReturnsDownloadUrl() {
         TestRepository repository = new TestRepository();
@@ -196,6 +196,7 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(storage.generatedKeys).containsExactly(saved.storageObjectKey());
     }
 
+    /** 覆盖同步创建展望报告的调用方词汇报文场景,当生成成功时,应在预览内容中使用 StudentName。 */
     @Test
     void createOutlookReportSyncAcceptsCallerVocabularyPayloadAndReturnsDownloadUrl() throws Exception {
         TestRepository repository = new TestRepository();
@@ -222,6 +223,7 @@ class ExamSprintReportApplicationServiceTest {
                 .contains(FIXED_CLOCK.instant().toString());
     }
 
+    /** 覆盖同步创建成果报告场景,当有效 achievement payload 生成成功时,应上传 PDF 并返回下载地址。 */
     @Test
     void createAchievementReportSyncGeneratesUploadAndReturnsDownloadUrl() {
         TestRepository repository = new TestRepository();
@@ -246,6 +248,7 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(storage.generatedKeys).containsExactly(saved.storageObjectKey());
     }
 
+    /** 覆盖同步展望报告下载地址生成失败场景,当存储层抛错时,应转换为下载不可用且日志不泄露异常消息。 */
     @Test
     void createOutlookReportSyncConvertsDownloadUrlGenerationFailureWithoutSensitiveLog(CapturedOutput output) {
         TestRepository repository = new TestRepository();
@@ -269,11 +272,13 @@ class ExamSprintReportApplicationServiceTest {
                 .doesNotContain("SENSITIVE_DOWNLOAD_URL_DO_NOT_LOG");
     }
 
+    /** 覆盖同步展望报告非法 payload 场景,当 payload 非对象或为空时,应在保存前校验失败。 */
     @Test
     void createOutlookReportSyncRejectsInvalidPayloadBeforeSaving() {
         assertCreateOutlookReportSyncRejectsInvalidPayload(OBJECT_MAPPER.nullNode());
     }
 
+    /** 覆盖同步展望报告渲染失败场景,当 pipeline 生成失败时,应抛出下载不可用并保留失败报告。 */
     @Test
     void createOutlookReportSyncThrowsDownloadUnavailableAndKeepsFailedReportWhenGenerationFails() {
         TestRepository repository = new TestRepository();
@@ -295,6 +300,7 @@ class ExamSprintReportApplicationServiceTest {
                 .isEqualTo(ReportGenerationStatus.FAILED);
     }
 
+    /** 覆盖成果报告缺少必填字段场景,当 reportTitle 缺失时,应在保存前校验失败。 */
     @Test
     void createAchievementReportRejectsInvalidAchievementPayloadBeforeSaving() {
         ObjectNode invalidPayload = validAchievementPayload().deepCopy();
@@ -303,6 +309,7 @@ class ExamSprintReportApplicationServiceTest {
         assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
     }
 
+    /** 覆盖创建报告 payload 类型校验场景,当 payload 为 null 或非对象时,应在保存前拒绝展望和成果报告。 */
     @Test
     void createReportsRejectNullOrNonObjectPayloadBeforeSaving() {
         assertCreateOutlookReportRejectsInvalidPayload(OBJECT_MAPPER.nullNode());
@@ -311,6 +318,7 @@ class ExamSprintReportApplicationServiceTest {
         assertCreateAchievementReportRejectsInvalidPayload(OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
     }
 
+    /** 覆盖成果报告嵌套数值字段缺失场景,当 beforeValue 缺失时,应在保存前校验失败。 */
     @Test
     void createAchievementReportRejectsMissingAchievementBeforeValueBeforeSaving() {
         ObjectNode invalidPayload = validAchievementPayload().deepCopy();
@@ -319,6 +327,7 @@ class ExamSprintReportApplicationServiceTest {
         assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
     }
 
+    /** 覆盖成果报告 JSON 类型错误场景,当关键字段类型不符时,应在保存前校验失败。 */
     @ParameterizedTest(name = "{0}")
     @MethodSource("invalidAchievementPayloadJsonTypes")
     void createAchievementReportRejectsAchievementPayloadWithInvalidJsonTypes(
@@ -330,6 +339,7 @@ class ExamSprintReportApplicationServiceTest {
         assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
     }
 
+    /** 覆盖异步分发失败场景,当 dispatcher 抛错时,应保存失败状态且不泄露底层异常消息。 */
     @Test
     void createOutlookReportReturnsFailedStatusWhenDispatchFails(CapturedOutput output) {
         TestRepository repository = new TestRepository();
@@ -362,6 +372,7 @@ class ExamSprintReportApplicationServiceTest {
                 .doesNotContain("dispatcher unavailable");
     }
 
+    /** 覆盖展望报告 payload 防御性复制场景,当调用方提交后继续修改 StudentName 时,已保存内容应保持原值。 */
     @Test
     void createOutlookReportCopiesPayloadBeforeSaving() {
         TestRepository repository = new TestRepository();
@@ -370,14 +381,15 @@ class ExamSprintReportApplicationServiceTest {
 
         var response = service.createOutlookReport(payload);
 
-        payload.withObject("reportMetadata").put("learnerName", "王同学");
+        payload.put("StudentName", "王同学");
 
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
         UnmodeledReportContent content = (UnmodeledReportContent) saved.content();
         JsonNode savedPayload = (JsonNode) content.source();
-        assertThat(savedPayload.path("reportMetadata").path("learnerName").asText()).isEqualTo("李同学");
+        assertThat(savedPayload.path("StudentName").asText()).isEqualTo("20260318测试");
     }
 
+    /** 覆盖查询成功报告场景,当报告已生成且未过期时,应返回下载地址并记录查询完成日志。 */
     @Test
     void getReportReturnsDownloadUrlForSuccessfulReport(CapturedOutput output) {
         TestRepository repository = new TestRepository();
@@ -410,6 +422,7 @@ class ExamSprintReportApplicationServiceTest {
                 .contains("downloadUrlIncluded=true");
     }
 
+    /** 覆盖查询下载地址生成失败场景,当存储层抛错时,应保留响应且日志不泄露异常消息。 */
     @Test
     void getReportKeepsResponseWhenDownloadUrlGenerationFailsWithoutSensitiveLog(CapturedOutput output) {
         TestRepository repository = new TestRepository();
@@ -440,6 +453,7 @@ class ExamSprintReportApplicationServiceTest {
                 .doesNotContain("SENSITIVE_QUERY_URL_DO_NOT_LOG");
     }
 
+    /** 覆盖下载已过期报告场景,当报告过期但清理尚未运行时,应拒绝下载并记录 expired 原因。 */
     @Test
     void downloadReportRejectsExpiredReportBeforeCleanupRuns(CapturedOutput output) {
         TestRepository repository = new TestRepository();
@@ -467,6 +481,7 @@ class ExamSprintReportApplicationServiceTest {
                 .contains("reason=expired");
     }
 
+    /** 覆盖下载缺少存储内容场景,当 storageObjectKey 找不到内容时,应拒绝下载并记录缺失对象键。 */
     @Test
     void downloadReportLogsMissingStorageContent(CapturedOutput output) {
         TestRepository repository = new TestRepository();
@@ -495,6 +510,7 @@ class ExamSprintReportApplicationServiceTest {
                 .contains("storageObjectKey=exam-sprint-outlook-report-report-missing-content.pdf");
     }
 
+    /** 覆盖存储下载失败场景,当 storage.download 抛错时,应转换为下载不可用且日志不泄露异常消息。 */
     @Test
     void downloadReportConvertsStorageDownloadFailureWithoutSensitiveLog(CapturedOutput output) {
         TestRepository repository = new TestRepository();
@@ -523,6 +539,7 @@ class ExamSprintReportApplicationServiceTest {
                 .doesNotContain("SENSITIVE_STORAGE_DOWNLOAD_DO_NOT_LOG");
     }
 
+    /** 覆盖过期边界场景,当 expiresAt 等于当前时间时,清理任务应将报告标记为 EXPIRED。 */
     @Test
     void cleanupExpiredReportsTreatsExpiresAtEqualsNowAsExpired() {
         TestRepository repository = new TestRepository();
@@ -541,6 +558,7 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.EXPIRED);
     }
 
+    /** 覆盖清理任务局部失败场景,当删除一个对象失败时,应继续清理其他过期报告并汇总失败数。 */
     @Test
     void cleanupExpiredReportsContinuesWhenDeletingOneReportFails(CapturedOutput output) {
         TestRepository repository = new TestRepository();
@@ -651,60 +669,26 @@ class ExamSprintReportApplicationServiceTest {
     }
 
     private ObjectNode validOutlookPayload() {
-        return (ObjectNode) OBJECT_MAPPER.valueToTree(new OutlookExamSprintReportPayload(
-                new OutlookExamSprintReportPayload.ReportMetadata(
-                        "2026 词汇展望报告",
-                        "李同学",
-                        "雅思 6.5",
-                        "2026 春季冲刺",
-                        "Ability Bot"),
-                new OutlookExamSprintReportPayload.ReadinessOverview(
-                        "词汇能力进入提分窗口,适合围绕考纲和高频场景做集中突破。",
-                        "当前阶段:稳态提升",
-                        "核心观察:阅读词汇优于写作输出,仍需补齐同义替换。",
-                        78),
-                new OutlookExamSprintReportPayload.SyllabusMasteryChart(
-                        4200,
-                        3192,
-                        1008,
-                        76,
-                        "考纲词汇掌握概览",
-                        "先补齐教育、科技和环境主题词,再做套题复盘。"),
-                new OutlookExamSprintReportPayload.PastPaperVocabularyChart(
-                        800,
-                        180,
-                        120,
-                        "预计提分 5-10 分",
-                        "每次精听后补 5 组同义替换并做口头复述。"),
-                new OutlookExamSprintReportPayload.HighFrequencyVocabularyChart(
-                        77,
-                        70,
-                        62,
-                        "Common 高频词覆盖较广,但易混词记忆不牢。"),
-                new OutlookExamSprintReportPayload.VocabularyFrequencyBandChart(
-                        List.of(
-                                new OutlookExamSprintReportPayload.VocabularyFrequencyBar("2k 高频", 86, "优先学习", "#448aff"),
-                                new OutlookExamSprintReportPayload.VocabularyFrequencyBar("3k 高频", 78, "重点突破", "#4caf50"),
-                                new OutlookExamSprintReportPayload.VocabularyFrequencyBar("学术词", 62, "酌情学习", "#ff9800"))),
-                new OutlookExamSprintReportPayload.FrequencyPlan(
-                        List.of(
-                                new OutlookExamSprintReportPayload.FrequencyPlanCard(3, "+12 分", 78, true, "推荐", "③"),
-                                new OutlookExamSprintReportPayload.FrequencyPlanCard(2, "+8 分", 61, false, "稳妥", "②")),
-                        "💡建议策略",
-                        "7 天提分冲刺优先保证高频词正确率,再逐步覆盖中频词。",
-                        List.of(
-                                new OutlookExamSprintReportPayload.PhaseSuggestion("考前半个月·核心突击期", "围绕高频词建立记忆闭环。"),
-                                new OutlookExamSprintReportPayload.PhaseSuggestion("考前一周·强化巩固期", "结合真题错词做循环复盘。"))),
-                new OutlookExamSprintReportPayload.ScoreImprovementCaseStudy(
-                        "教育类阅读题案例",
-                        "李同学",
-                        "考前半个月·核心突击期",
-                        705,
-                        237,
-                        33.6,
-                        "70 分以下",
-                        89,
-                        19)));
+        try {
+            return callerVocabularyPayload();
+        } catch (Exception exception) {
+            throw new IllegalStateException("Failed to build valid outlook payload", exception);
+        }
+    }
+
+    private ObjectNode nonContractOutlookPayload() throws Exception {
+        return (ObjectNode) OBJECT_MAPPER.readTree("""
+                {
+                  "learnerProfile": {
+                    "learnerName": "李同学",
+                    "examName": "雅思 6.5"
+                  },
+                  "summaryCards": [
+                    {"label": "词汇掌握", "value": 76},
+                    {"label": "提分预测", "value": "+12 分"}
+                  ]
+                }
+                """);
     }
 
     private ObjectNode callerVocabularyPayload() throws Exception {
@@ -952,7 +936,7 @@ class ExamSprintReportApplicationServiceTest {
                 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(""));
+                title = payload.path("StudentName").asText("");
             }
             return "<html><body>preview:" + title + ":" + generatedAt + "</body></html>";
         }

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

@@ -1,10 +1,9 @@
 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.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
 import jakarta.validation.constraints.Min;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotEmpty;
@@ -12,132 +11,33 @@ import jakarta.validation.constraints.NotNull;
 
 import java.util.List;
 
-@JsonIgnoreProperties(ignoreUnknown = true)
 public record OutlookExamSprintReportPayload(
-        @NotNull @Valid ReportMetadata reportMetadata,
-        @NotNull @Valid ReadinessOverview readinessOverview,
-        @NotNull @Valid SyllabusMasteryChart syllabusMasteryChart,
-        @NotNull @Valid PastPaperVocabularyChart pastPaperVocabularyChart,
-        @NotNull @Valid HighFrequencyVocabularyChart highFrequencyVocabularyChart,
-        @NotNull @Valid VocabularyFrequencyBandChart vocabularyFrequencyBandChart,
-        @NotNull @Valid FrequencyPlan frequencyPlan,
-        @NotNull @Valid ScoreImprovementCaseStudy scoreImprovementCaseStudy) {
-
-    public record ReportMetadata(
-            @NotBlank String reportVersionLabel,
-            @NotBlank String learnerName,
-            @NotBlank String targetExamName,
-            @NotBlank String sprintPeriodLabel,
-            @NotBlank String authorName) {
-    }
-
-    public record ReadinessOverview(
-            @NotBlank String summary,
-            @NotBlank String currentStage,
-            @NotBlank String keyInsight,
-            @Min(0) @Max(100) int readinessScore) {
-    }
-
-    public record SyllabusMasteryChart(
-            @Min(0) int totalWordCount,
-            @Min(0) int masteredWordCount,
-            @Min(0) int unmasteredWordCount,
-            @Min(0) @Max(100) int masteryPercent,
-            @NotBlank String summaryLabel,
-            @NotBlank String recommendation) {
-
-        public boolean hasConsistentWordCounts() {
-            return masteredWordCount + unmasteredWordCount == totalWordCount;
-        }
-
-        public boolean hasConsistentMasteryPercent() {
-            return totalWordCount == 0 || masteryPercent == Math.round((masteredWordCount * 100.0f) / totalWordCount);
-        }
-    }
-
-    public record PastPaperVocabularyChart(
-            @Min(0) int totalWordCount,
-            @Min(0) int unknownWordCountBeforeSprint,
-            @Min(0) int unknownWordCountAfterSprint,
-            @NotBlank String projectedScoreGainLabel,
-            @NotBlank String recommendation) {
-
-        public boolean hasValidUnknownWordCounts() {
-            return unknownWordCountBeforeSprint >= unknownWordCountAfterSprint;
-        }
-    }
-
-    public record HighFrequencyVocabularyChart(
-            @Min(0) @Max(100) int basicCorePercent,
-            @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(
-            @NotEmpty List<@Valid VocabularyFrequencyBar> bars) {
-
-        public boolean hasExpectedBarCount() {
-            return bars != null && bars.size() == 3;
-        }
-    }
-
-    public record VocabularyFrequencyBar(
-            @NotBlank String bandLabel,
-            double currentValue,
-            @NotBlank String priorityLabel,
-            @NotBlank String themeColor) {
-    }
-
-    public record FrequencyPlan(
-            @NotEmpty List<@Valid FrequencyPlanCard> cards,
-            @NotBlank String recommendationTitle,
-            @NotBlank String recommendationSummary,
-            @NotEmpty List<@Valid PhaseSuggestion> phaseSuggestions) {
-
-        public boolean hasExpectedCadences() {
-            return cards != null && !cards.isEmpty();
-        }
-    }
-
-    public record FrequencyPlanCard(
-            @Min(0) int cadencePerWeek,
-            @NotBlank String scoreGainLabel,
-            @Min(0) @Max(100) int winRatePercent,
-            boolean recommended,
-            String badgeLabel,
-            String emphasisIcon) {
-    }
-
-    public record PhaseSuggestion(
-            @NotBlank String title,
-            @NotBlank String description) {
-    }
-
-    public record ScoreImprovementCaseStudy(
-            @NotBlank String headline,
-            @NotBlank String learnerName,
-            @NotBlank String studyPeriodLabel,
-            @Min(0) int memorizedWordCount,
-            @Min(0) int examHitWordCount,
-            double hitRatePercent,
-            @NotBlank String baselineScoreLabel,
-            @Min(0) int finalScore,
-            @Min(0) int scoreGain) {
+        @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) {
     }
 }

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

@@ -1,43 +0,0 @@
-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) {
-    }
-}

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

@@ -1,7 +1,6 @@
 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;
@@ -52,16 +51,15 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
             throw new IllegalArgumentException("Outlook renderer requires unmodeled OUTLOOK JsonNode content");
         }
         try {
-            OutlookExamSprintReportPayload reportPayload = isStudentVocabularyOutlookPayload(payload)
-                    ? adaptStudentVocabularyPayload(objectMapper.treeToValue(payload, OutlookStudentVocabularyReportPayload.class))
-                    : objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
+            OutlookExamSprintReportPayload payloadContract = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
+            OutlookReportViewModel reportPayload = adaptPayload(payloadContract);
             return loadTemplate()
-                    .replace("{{syllabusMasteryChart}}", renderSyllabusMasteryChart(reportPayload.syllabusMasteryChart()))
-                    .replace("{{pastPaperVocabularyChart}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularyChart()))
-                    .replace("{{highFrequencyVocabularyChart}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularyChart()))
-                    .replace("{{vocabularyFrequencyBandChart}}", renderVocabularyFrequencyBandChart(reportPayload.vocabularyFrequencyBandChart()))
-                    .replace("{{studySuggestionSection}}", renderStudySuggestionSection(reportPayload.frequencyPlan()))
-                    .replace("{{scoreImprovementCaseStudy}}", renderScoreImprovementCaseStudy(reportPayload.scoreImprovementCaseStudy()));
+                    .replace("{{syllabusMasterySection}}", renderSyllabusMasteryChart(reportPayload.syllabusMasterySection()))
+                    .replace("{{pastPaperVocabularySection}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularySection()))
+                    .replace("{{highFrequencyVocabularySection}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularySection()))
+                    .replace("{{frequencyBandSection}}", renderVocabularyFrequencyBandChart(reportPayload.frequencyBandSection()))
+                    .replace("{{studySuggestionSection}}", renderStudySuggestionSection(reportPayload.studyPlan()))
+                    .replace("{{caseStudySection}}", renderScoreImprovementCaseStudy(reportPayload.caseStudy()));
         } catch (IOException exception) {
             throw new UncheckedIOException("Failed to load outlook exam sprint report template", exception);
         } catch (Exception exception) {
@@ -69,113 +67,93 @@ 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) {
+    private OutlookReportViewModel adaptPayload(OutlookExamSprintReportPayload 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);
+        List<OutlookExamSprintReportPayload.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(
+        return new OutlookReportViewModel(
+                new SyllabusMasteryChart(
                         stageVocabulary,
                         masteredWordCount,
                         unmasteredWordCount,
                         masteryPercent,
                         "考纲词汇掌握概览",
                         "优先补齐高频和核心常考词。"),
-                new OutlookExamSprintReportPayload.PastPaperVocabularyChart(
+                new PastPaperVocabularyChart(
                         payload.testPaperWordCount(),
                         payload.testPaperUnMasterWords().size(),
                         payload.testPaperUnMasterWords().size(),
                         "预计提分5-15分",
                         "先压降真题生词占比。"),
-                new OutlookExamSprintReportPayload.HighFrequencyVocabularyChart(
+                new HighFrequencyVocabularyChart(
                         roundedMasteryPercent(words, 0, basicUpper),
                         roundedMasteryPercent(words, basicUpper, frequentUpper),
                         roundedMasteryPercent(words, frequentUpper, stageVocabulary),
                         "拉分词是提分核心突破项"),
-                new OutlookExamSprintReportPayload.VocabularyFrequencyBandChart(
+                new VocabularyFrequencyBandChart(
                         List.of(
-                                new OutlookExamSprintReportPayload.VocabularyFrequencyBar(
+                                new VocabularyFrequencyBar(
                                         "高频词", masteryPercent(words, 0, highUpper), "优先学习", "#448aff"),
-                                new OutlookExamSprintReportPayload.VocabularyFrequencyBar(
+                                new VocabularyFrequencyBar(
                                         "中频词", masteryPercent(words, highUpper, midUpper), "重点突破", "#4caf50"),
-                                new OutlookExamSprintReportPayload.VocabularyFrequencyBar(
+                                new VocabularyFrequencyBar(
                                         "低频词", masteryPercent(words, midUpper, stageVocabulary), "酌情学习", "#ff9800"))),
                 staticFrequencyPlan(),
                 staticScoreImprovementCaseStudy());
     }
 
-    private List<OutlookStudentVocabularyReportPayload.StudentWordLatest> participatingWords(OutlookStudentVocabularyReportPayload payload) {
+    private List<OutlookExamSprintReportPayload.StudentWordLatest> participatingWords(OutlookExamSprintReportPayload 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,
+    private int roundedMasteryPercent(List<OutlookExamSprintReportPayload.StudentWordLatest> words,
                                       int lowerExclusive,
                                       int upperInclusive) {
         return (int) Math.round(masteryPercent(words, lowerExclusive, upperInclusive));
     }
 
-    private double masteryPercent(List<OutlookStudentVocabularyReportPayload.StudentWordLatest> words,
+    private double masteryPercent(List<OutlookExamSprintReportPayload.StudentWordLatest> words,
                                   int lowerExclusive,
                                   int upperInclusive) {
-        List<OutlookStudentVocabularyReportPayload.StudentWordLatest> matchedWords = words.stream()
+        List<OutlookExamSprintReportPayload.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)
+                .mapToDouble(OutlookExamSprintReportPayload.StudentWordLatest::mastery)
                 .average()
                 .orElse(0d) * 100d;
     }
 
-    private OutlookExamSprintReportPayload.FrequencyPlan staticFrequencyPlan() {
-        return new OutlookExamSprintReportPayload.FrequencyPlan(
+    private FrequencyPlan staticFrequencyPlan() {
+        return new 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, "冲刺", "④")),
+                        new FrequencyPlanCard(1, "+5分", 38, false, "稳健", "①"),
+                        new FrequencyPlanCard(2, "+10分", 55, false, "均衡", "②"),
+                        new FrequencyPlanCard(3, "+15分", 72, true, "推荐", "③"),
+                        new FrequencyPlanCard(5, "+20分", 88, false, "冲刺", "④")),
                 "💡建议策略",
                 "7 天提分冲刺是首选节奏,按词频优先级记忆,不浪费时间;只攻克高频/中频核心词,2周15小时速记500-800必考词,快速缩小生词缺口。",
                 List.of(
-                        new OutlookExamSprintReportPayload.PhaseSuggestion("考前半个月·核心突击期", "围绕高频词建立记忆闭环。"),
-                        new OutlookExamSprintReportPayload.PhaseSuggestion("考前半小时·临阵巩固期", "结合真题词做循环巩固。")));
+                        new PhaseSuggestion("考前半个月·核心突击期", "围绕高频词建立记忆闭环。"),
+                        new PhaseSuggestion("考前半小时·临阵巩固期", "结合真题词做循环巩固。")));
     }
 
-    private OutlookExamSprintReportPayload.ScoreImprovementCaseStudy staticScoreImprovementCaseStudy() {
-        return new OutlookExamSprintReportPayload.ScoreImprovementCaseStudy(
+    private ScoreImprovementCaseStudy staticScoreImprovementCaseStudy() {
+        return new ScoreImprovementCaseStudy(
                 "真实提分 · 效果可复制",
                 "王雷宇",
                 "考前半个月·核心突击期",
@@ -187,13 +165,87 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 19);
     }
 
+    private record OutlookReportViewModel(
+            SyllabusMasteryChart syllabusMasterySection,
+            PastPaperVocabularyChart pastPaperVocabularySection,
+            HighFrequencyVocabularyChart highFrequencyVocabularySection,
+            VocabularyFrequencyBandChart frequencyBandSection,
+            FrequencyPlan studyPlan,
+            ScoreImprovementCaseStudy caseStudy) {
+    }
+
+    private record SyllabusMasteryChart(
+            int totalWordCount,
+            int masteredWordCount,
+            int unmasteredWordCount,
+            int masteryPercent,
+            String summaryLabel,
+            String recommendation) {
+    }
+
+    private record PastPaperVocabularyChart(
+            int totalWordCount,
+            int unknownWordCountBeforeSprint,
+            int unknownWordCountAfterSprint,
+            String projectedScoreGainLabel,
+            String recommendation) {
+    }
+
+    private record HighFrequencyVocabularyChart(
+            int basicCorePercent,
+            int frequentCorePercent,
+            int advancedScorePercent,
+            String highlightLabel) {
+    }
+
+    private record VocabularyFrequencyBandChart(List<VocabularyFrequencyBar> bars) {
+    }
+
+    private record VocabularyFrequencyBar(
+            String bandLabel,
+            double currentValue,
+            String priorityLabel,
+            String themeColor) {
+    }
+
+    private record FrequencyPlan(
+            List<FrequencyPlanCard> cards,
+            String recommendationTitle,
+            String recommendationSummary,
+            List<PhaseSuggestion> phaseSuggestions) {
+    }
+
+    private record FrequencyPlanCard(
+            int cadencePerWeek,
+            String scoreGainLabel,
+            int winRatePercent,
+            boolean recommended,
+            String badgeLabel,
+            String emphasisIcon) {
+    }
+
+    private record PhaseSuggestion(String title, String description) {
+    }
+
+    private record ScoreImprovementCaseStudy(
+            String headline,
+            String learnerName,
+            String studyPeriodLabel,
+            int memorizedWordCount,
+            int examHitWordCount,
+            double hitRatePercent,
+            String baselineScoreLabel,
+            int finalScore,
+            int scoreGain) {
+    }
+
     private String loadTemplate() throws IOException {
         try (InputStream inputStream = new ClassPathResource(TEMPLATE_RESOURCE).getInputStream()) {
             return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
         }
     }
 
-    private String renderSyllabusMasteryChart(OutlookExamSprintReportPayload.SyllabusMasteryChart chart) {
+    private String renderSyllabusMasteryChart(SyllabusMasteryChart chart) {
         double masteredPercent = percentage(chart.masteredWordCount(), chart.totalWordCount());
         double unmasteredPercent = percentage(chart.unmasteredWordCount(), chart.totalWordCount());
         double endAngle = -90 + (Math.max(0d, Math.min(100d, masteredPercent)) * 3.6);
@@ -225,7 +277,7 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .toString();
     }
 
-    private String renderPastPaperVocabularyChart(OutlookExamSprintReportPayload.PastPaperVocabularyChart chart) {
+    private String renderPastPaperVocabularyChart(PastPaperVocabularyChart chart) {
         int axisMax = roundUpToStep(Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint()), 250);
         int totalHeight = barHeight(chart.totalWordCount(), axisMax);
         int unknownHeight = barHeight(chart.unknownWordCountBeforeSprint(), axisMax);
@@ -262,7 +314,7 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .toString();
     }
 
-    private String renderHighFrequencyVocabularyChart(OutlookExamSprintReportPayload.HighFrequencyVocabularyChart chart) {
+    private String renderHighFrequencyVocabularyChart(HighFrequencyVocabularyChart chart) {
         int axisMax = 100;
         int basicHeight = barHeight(chart.basicCorePercent(), axisMax);
         int frequentHeight = barHeight(chart.frequentCorePercent(), axisMax);
@@ -298,10 +350,10 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .toString();
     }
 
-    private String renderVocabularyFrequencyBandChart(OutlookExamSprintReportPayload.VocabularyFrequencyBandChart chart) {
-        List<OutlookExamSprintReportPayload.VocabularyFrequencyBar> bars = chart.bars();
+    private String renderVocabularyFrequencyBandChart(VocabularyFrequencyBandChart chart) {
+        List<VocabularyFrequencyBar> bars = chart.bars();
         double highest = bars.stream()
-                .mapToDouble(OutlookExamSprintReportPayload.VocabularyFrequencyBar::currentValue)
+                .mapToDouble(VocabularyFrequencyBar::currentValue)
                 .max()
                 .orElse(1d);
         int axisMax = roundUpToStep((int) Math.ceil(highest), 50);
@@ -320,7 +372,7 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         int[] xPositions = {70, 160, 250};
         String[] columnClasses = {"high-band-column", "mid-band-column", "low-band-column"};
         for (int index = 0; index < Math.min(3, bars.size()); index++) {
-            OutlookExamSprintReportPayload.VocabularyFrequencyBar bar = bars.get(index);
+            VocabularyFrequencyBar bar = bars.get(index);
             int height = barHeight((int) Math.round(bar.currentValue() * 10d), maxScaled);
             int x = xPositions[index];
 
@@ -338,7 +390,7 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .append("<div class='data-text'>");
 
         for (int index = 0; index < Math.min(3, bars.size()); index++) {
-            OutlookExamSprintReportPayload.VocabularyFrequencyBar bar = bars.get(index);
+            VocabularyFrequencyBar bar = bars.get(index);
             if (index > 0) {
                 builder.append("<br/>");
             }
@@ -355,18 +407,18 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         return builder.toString();
     }
 
-    private String renderStudySuggestionSection(OutlookExamSprintReportPayload.FrequencyPlan frequencyPlan) {
+    private String renderStudySuggestionSection(FrequencyPlan studyPlan) {
         StringBuilder builder = new StringBuilder();
         builder.append("<div class='suggest-item'>")
                 .append("<h4>🎯 练习学案频率与提分规划</h4>")
                 .append("<p class='text-desc'>")
-                .append(escape(frequencyPlan.recommendationSummary()))
+                .append(escape(studyPlan.recommendationSummary()))
                 .append("</p>")
                 .append("</div>")
                 .append("<table class='frequency-table' role='presentation'><tr class='frequency-row'>");
 
-        for (int index = 0; index < frequencyPlan.cards().size(); index++) {
-            OutlookExamSprintReportPayload.FrequencyPlanCard card = frequencyPlan.cards().get(index);
+        for (int index = 0; index < studyPlan.cards().size(); index++) {
+            FrequencyPlanCard card = studyPlan.cards().get(index);
             int columnNumber = index + 1;
             builder.append("<td class='frequency-cell frequency-cell-").append(columnNumber).append("'>")
                     .append("<div class='freq-card card-").append(columnNumber);
@@ -398,13 +450,13 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
 
         builder.append("</tr></table>")
                 .append("<div class='suggest-note'><strong>")
-                .append(escape(frequencyPlan.recommendationTitle()))
+                .append(escape(studyPlan.recommendationTitle()))
                 .append(":</strong>")
-                .append(escape(frequencyPlan.recommendationSummary()))
+                .append(escape(studyPlan.recommendationSummary()))
                 .append("</div>")
                 .append("<div class='suggest-box'>");
 
-        for (OutlookExamSprintReportPayload.PhaseSuggestion suggestion : frequencyPlan.phaseSuggestions()) {
+        for (PhaseSuggestion suggestion : studyPlan.phaseSuggestions()) {
             builder.append("<div class='suggest-item'>")
                     .append("<h4>").append(escape(suggestion.title())).append("</h4>")
                     .append("<p>").append(escape(suggestion.description())).append("</p>")
@@ -415,7 +467,7 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         return builder.toString();
     }
 
-    private String renderScoreImprovementCaseStudy(OutlookExamSprintReportPayload.ScoreImprovementCaseStudy caseStudy) {
+    private String renderScoreImprovementCaseStudy(ScoreImprovementCaseStudy caseStudy) {
         double progressPercent = Math.max(0d, Math.min(100d, caseStudy.hitRatePercent()));
         double endAngle = -90 + (progressPercent * 3.6);
 

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

@@ -336,12 +336,12 @@
         <h2 class="section-title">模块一:个人学情分析</h2>
         <table class="analysis-table" role="presentation">
             <tr class="analysis-row">
-                <td class="analysis-cell analysis-cell-left">{{syllabusMasteryChart}}</td>
-                <td class="analysis-cell analysis-cell-right">{{pastPaperVocabularyChart}}</td>
+                <td class="analysis-cell analysis-cell-left">{{syllabusMasterySection}}</td>
+                <td class="analysis-cell analysis-cell-right">{{pastPaperVocabularySection}}</td>
             </tr>
             <tr class="analysis-row">
-                <td class="analysis-cell analysis-cell-left">{{highFrequencyVocabularyChart}}</td>
-                <td class="analysis-cell analysis-cell-right">{{vocabularyFrequencyBandChart}}</td>
+                <td class="analysis-cell analysis-cell-left">{{highFrequencyVocabularySection}}</td>
+                <td class="analysis-cell analysis-cell-right">{{frequencyBandSection}}</td>
             </tr>
         </table>
     </div>
@@ -355,7 +355,7 @@
 
     <div class="section">
         <h2 class="section-title">模块三:上届学员提分案例</h2>
-        {{scoreImprovementCaseStudy}}
+        {{caseStudySection}}
     </div>
 </div>
 </body>

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

@@ -27,6 +27,9 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
 
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
+    /**
+     * 覆盖官方上游词汇 payload 渲染后的 Outlook HTML 生成 PDF 时,应产出可解析 PDF 并包含关键静态与计算文本。
+     */
     @Test
     void generateCreatesPdfSmokeWithExtractableOutlookKeyText() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
@@ -57,12 +60,15 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                     .contains("705词")
                     .contains("237词")
                     .contains("+19分")
-                    .containsAnyOf("高频词:86.0%(优先学习)", "高频词86.0%优先学习")
-                    .containsAnyOf("中频词:78.0%(重点突破)", "中频词78.0%重点突破")
-                    .containsAnyOf("低频词:62.0%(酌情学习)", "低频词62.0%酌情学习");
+                    .containsAnyOf("高频词:56.7%(优先学习)", "高频词56.7%优先学习")
+                    .containsAnyOf("中频词:47.5%(重点突破)", "中频词47.5%重点突破")
+                    .containsAnyOf("低频词:30.0%(酌情学习)", "低频词30.0%酌情学习");
         }
     }
 
+    /**
+     * 覆盖学习成果报告 HTML 生成 PDF 时,应产出可解析 PDF 并包含成果报告关键文本。
+     */
     @Test
     void generateCreatesPdfSmokeWithExtractableAchievementKeyText() throws Exception {
         ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
@@ -98,6 +104,9 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
         }
     }
 
+    /**
+     * 覆盖系统字体候选为空时,PDF 生成器应使用随包 MiSans 字体输出可抽取中文文本。
+     */
     @Test
     void generateUsesBundledMiSansWhenSystemFontCandidatesAreEmpty() throws Exception {
         OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
@@ -128,6 +137,9 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
         }
     }
 
+    /**
+     * 覆盖系统字体候选为空且 HTML 包含内联 SVG 中文时,AWT 注册的随包 MiSans 应支持 SVG 文本渲染。
+     */
     @Test
     void generateRendersInlineSvgChineseTextWithAwtRegisteredMiSansWhenSystemFontCandidatesAreEmpty() throws Exception {
         OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
@@ -160,6 +172,9 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
         }
     }
 
+    /**
+     * 覆盖随包字体 supplier 抛出非预期异常时,PDF 生成器应保留根因并向调用方报告生成失败。
+     */
     @Test
     void generateDoesNotHideUnexpectedBundledFontSupplierFailures() {
         OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
@@ -173,6 +188,9 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                 .hasRootCauseMessage("broken supplier contract");
     }
 
+    /**
+     * 覆盖随包字体资源缺失时,PDF 生成器应回退系统字体并仍能生成 ASCII PDF。
+     */
     @Test
     void generateFallsBackToSystemFontsWhenBundledFontResourceIsMissing() {
         OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
@@ -185,6 +203,9 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
         assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
     }
 
+    /**
+     * 覆盖随包字体注册失败时,PDF 生成器不应静默回退而应报告生成失败。
+     */
     @Test
     void generateDoesNotFallbackWhenBundledFontRegistrationFails() {
         OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
@@ -244,100 +265,34 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
     private JsonNode samplePayload() throws Exception {
         return OBJECT_MAPPER.readTree("""
                 {
-                  "reportMetadata": {
-                    "reportVersionLabel": "2026 词汇展望报告",
-                    "learnerName": "李同学",
-                    "targetExamName": "春季高考英语",
-                    "sprintPeriodLabel": "30 天考前冲刺",
-                    "authorName": "Ability Bot"
-                  },
-                  "readinessOverview": {
-                    "summary": "基础较稳,具备短期冲刺提分空间。",
-                    "currentStage": "冲刺提升期",
-                    "keyInsight": "核心观察:高频与常考词群是提分关键。",
-                    "readinessScore": 72
-                  },
-                  "syllabusMasteryChart": {
-                    "totalWordCount": 4200,
-                    "masteredWordCount": 2701,
-                    "unmasteredWordCount": 1499,
-                    "masteryPercent": 64,
-                    "summaryLabel": "考纲词汇掌握概览",
-                    "recommendation": "优先补齐高考核心场景词。"
-                  },
-                  "pastPaperVocabularyChart": {
-                    "totalWordCount": 961,
-                    "unknownWordCountBeforeSprint": 847,
-                    "unknownWordCountAfterSprint": 716,
-                    "projectedScoreGainLabel": "预计提分5-15分",
-                    "recommendation": "先压降真题生词占比。"
-                  },
-                  "highFrequencyVocabularyChart": {
-                    "basicCorePercent": 62,
-                    "frequentCorePercent": 54,
-                    "advancedScorePercent": 41,
-                    "highlightLabel": "拉分词是提分核心突破项"
-                  },
-                  "vocabularyFrequencyBandChart": {
-                    "bars": [
-                      {"bandLabel": "高频词", "currentValue": 86.0, "priorityLabel": "优先学习", "themeColor": "#448aff"},
-                      {"bandLabel": "中频词", "currentValue": 78.0, "priorityLabel": "重点突破", "themeColor": "#4caf50"},
-                      {"bandLabel": "低频词", "currentValue": 62.0, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
-                    ]
-                  },
-                  "frequencyPlan": {
-                    "cards": [
-                      {
-                        "cadencePerWeek": 1,
-                        "scoreGainLabel": "+5分",
-                        "winRatePercent": 38,
-                        "recommended": false,
-                        "badgeLabel": "稳健",
-                        "emphasisIcon": "①"
-                      },
-                      {
-                        "cadencePerWeek": 2,
-                        "scoreGainLabel": "+10分",
-                        "winRatePercent": 55,
-                        "recommended": false,
-                        "badgeLabel": "均衡",
-                        "emphasisIcon": "②"
-                      },
-                      {
-                        "cadencePerWeek": 3,
-                        "scoreGainLabel": "+15分",
-                        "winRatePercent": 72,
-                        "recommended": true,
-                        "badgeLabel": "推荐",
-                        "emphasisIcon": "③"
-                      },
-                      {
-                        "cadencePerWeek": 5,
-                        "scoreGainLabel": "+20分",
-                        "winRatePercent": 88,
-                        "recommended": false,
-                        "badgeLabel": "冲刺",
-                        "emphasisIcon": "④"
-                      }
-                    ],
-                    "recommendationTitle": "💡建议策略",
-                    "recommendationSummary": "7 天提分冲刺是首选节奏,按词频优先级记忆,不浪费时间;只攻克高频/中频核心词,2周15小时速记500-800必考词,快速缩小生词缺口。",
-                    "phaseSuggestions": [
-                      {"title": "考前半个月·核心突击期", "description": "围绕高频词建立记忆闭环。"},
-                      {"title": "考前半小时·临阵巩固期", "description": "结合真题词做循环巩固。"}
-                    ]
-                  },
-                  "scoreImprovementCaseStudy": {
-                    "headline": "真实提分 · 效果可复制",
-                    "learnerName": "王雷宇",
-                    "studyPeriodLabel": "考前半个月·核心突击期",
-                    "memorizedWordCount": 705,
-                    "examHitWordCount": 237,
-                    "hitRatePercent": 33.8,
-                    "baselineScoreLabel": "70分以下",
-                    "finalScore": 89,
-                    "scoreGain": 19
-                  }
+                  "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
                 }
                 """);
     }

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

@@ -25,11 +25,14 @@ class ClasspathOutlookExamSprintReportRendererTest {
     private static final String SVG_CJK_FONT_FAMILY = "font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"";
     private static final Pattern SVG_START_TAG_PATTERN = Pattern.compile("<svg\\b[^>]*>");
 
+    /**
+     * 覆盖官方上游词汇 payload 触发完整展望报告渲染时,应保留设计稿动态结构并输出由词汇数据计算出的核心数值。
+     */
     @Test
     void renderBuildsOutlookHtmlAlignedWithDesignDraftDynamicStructure() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
-        String html = renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
+        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
                 .contains("class=\"analysis-table\"")
@@ -68,19 +71,22 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .contains("王雷宇")
                 .contains("705词 | 高考命中:237词 | 命中率:33.8%")
                 .contains("+19分")
-                .contains("考纲总量:<span class='highlight'>4200词</span>")
-                .contains("已掌握:<span class='highlight'>2701词(64.31%)</span>")
-                .contains("未掌握:<span class='highlight'>1499词(35.69%)</span>")
-                .contains("真题总词:961词 | 生词量:847词(88.14%)")
-                .contains("生词占比降至74.51%")
-                .contains("基础必会词:综合掌握率62%")
-                .contains("核心常考词:综合掌握率54%")
-                .contains("拔高拉分词:综合掌握率41%")
-                .contains("高频词:86.0%(优先学习)")
-                .contains("中频词:78.0%(重点突破)")
-                .contains("低频词:62.0%(酌情学习)");
+                .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("生词占比降至60.00%")
+                .contains("基础必会词:综合掌握率75%")
+                .contains("核心常考词:综合掌握率50%")
+                .contains("拔高拉分词:综合掌握率25%")
+                .contains("高频词:56.7%(优先学习)")
+                .contains("中频词:47.5%(重点突破)")
+                .contains("低频词:30.0%(酌情学习)");
     }
 
+    /**
+     * 覆盖官方上游词汇 payload 中词频与掌握度组合变化时,应复用词频区间计算逻辑生成所有图表数值。
+     */
     @Test
     void renderAdaptsCallerVocabularyPayloadIntoOutlookReportBands() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
@@ -92,6 +98,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .contains("已掌握:<span class='highlight'>4词(40.00%)</span>")
                 .contains("未掌握:<span class='highlight'>6词(60.00%)</span>")
                 .contains("真题总词:5词 | 生词量:3词(60.00%)")
+                .contains("冲刺后生词:3词,生词占比降至60.00%")
                 .contains("基础必会词:综合掌握率75%")
                 .contains("核心常考词:综合掌握率50%")
                 .contains("拔高拉分词:综合掌握率25%")
@@ -103,33 +110,9 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .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%");
-    }
-
+    /**
+     * 覆盖官方词汇 payload 计算出 0 值区间时,对应柱状图高度应保持为 0 且不被最小高度兜底抬高。
+     */
     @Test
     void renderUsesZeroHeightForZeroValueBarsWithoutForcingMinimumHeight() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
@@ -144,25 +127,30 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .contains("低频词:0.0%(酌情学习)");
     }
 
+    /**
+     * 覆盖官方上游词汇 payload 渲染三组柱状图时,应为图表补充可见坐标轴刻度。
+     */
     @Test
     void renderAddsAxisTicksToThreeBarCharts() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
-        String html = renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
+        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
                 .contains("class='chart-axis-tick chart-axis-tick-y'")
                 .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'>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'>250</text>");
     }
 
+    /**
+     * 覆盖官方上游词汇 payload 渲染所有内联 SVG 时,每个 SVG 起始标签都应声明 Batik 可识别的 CJK 字体族。
+     */
     @Test
     void renderDeclaresBatikCjkFontFamilyOnEveryInlineSvg() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
-        String html = renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
+        String html = renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
                 .contains("<svg class='syllabus-donut-chart' font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"")
@@ -173,6 +161,9 @@ class ClasspathOutlookExamSprintReportRendererTest {
         assertThat(svgStartTags(html)).hasSize(5).allSatisfy(svg -> assertThat(svg).contains(SVG_CJK_FONT_FAMILY));
     }
 
+    /**
+     * 覆盖 renderer 类型判断时,仅 OUTLOOK 报告类型应被该 renderer 支持。
+     */
     @Test
     void supportsOnlyOutlookReportType() {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
@@ -181,16 +172,22 @@ class ClasspathOutlookExamSprintReportRendererTest {
         assertThat(renderer.supports(ReportType.ACHIEVEMENT)).isFalse();
     }
 
+    /**
+     * 覆盖渲染官方上游词汇 payload 时,应使用构造器注入的 ObjectMapper 完成唯一 public contract 反序列化。
+     */
     @Test
     void renderUsesInjectedObjectMapperForPayloadDeserialization() throws Exception {
         TrackingObjectMapper objectMapper = new TrackingObjectMapper();
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(objectMapper);
 
-        renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
+        renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(objectMapper.treeToValueCalled).isTrue();
     }
 
+    /**
+     * 覆盖渲染官方上游词汇 payload 并加载 classpath 模板时,模板输入流应在渲染结束后关闭。
+     */
     @Test
     void renderClosesTemplateInputStreamAfterLoadingClasspathTemplate() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
@@ -200,7 +197,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
         try {
             Thread.currentThread().setContextClassLoader(trackingClassLoader);
 
-            renderer.render(unmodeledOutlookContent(samplePayload()), Instant.parse("2026-01-03T08:00:00Z"));
+            renderer.render(unmodeledOutlookContent(callerVocabularyPayload()), Instant.parse("2026-01-03T08:00:00Z"));
         } finally {
             Thread.currentThread().setContextClassLoader(originalClassLoader);
         }
@@ -208,62 +205,53 @@ class ClasspathOutlookExamSprintReportRendererTest {
         assertThat(trackingClassLoader.inputStream.closed).isTrue();
     }
 
+    /**
+     * 覆盖官方 payload 中当前不展示的调用方文本字段包含脚本时,渲染结果不应暴露这些外部注入文本且静态阶段文案保持不变。
+     */
     @Test
-    void renderEscapesHtmlSensitiveCharactersAndScriptPayloads() throws Exception {
+    void renderDoesNotExposeCallerControlledTextFieldsThatAreNotPartOfHtmlView() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
-        JsonNode payload = payloadWithEscapingSamples();
+        JsonNode payload = payloadWithCallerControlledTextSamples();
         String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
-                .contains("李&lt;同学&gt;")
-                .contains("春季&amp;高考英语")
-                .contains("Ability &quot;Bot&quot;")
-                .contains("O&#39;Brien")
-                .contains("&lt;script&gt;alert(1)&lt;/script&gt;")
+                .contains("考前半个月·核心突击期")
+                .contains("考前半小时·临阵巩固期")
+                .doesNotContain("注入学生")
+                .doesNotContain("evil-word")
+                .doesNotContain("注入文章")
                 .doesNotContain("<script>alert(1)</script>")
                 .doesNotContain("<script>");
     }
 
+    /**
+     * 覆盖官方 payload 计算出的考纲掌握率达到 100% 时,考纲进度图应使用完整圆而非弧线。
+     */
     @Test
-    void renderFallsBackToDefaultThemeColorWhenPayloadColorIsInvalid() throws Exception {
+    void renderUsesCircleForSyllabusProgressWhenPercentReachesHundred() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
-        JsonNode payload = payloadWithInvalidThemeColors();
-        String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));
-
-        assertThat(html)
-                .containsPattern("class='chart-column high-band-column'[^>]*fill='#448aff'")
-                .containsPattern("class='chart-column mid-band-column'[^>]*fill='#448aff'")
-                .containsPattern("class='chart-column low-band-column'[^>]*fill='#448aff'")
-                .doesNotContain("onload=")
-                .doesNotContain("javascript:alert(1)");
-    }
-
-    @Test
-    void renderUsesCircleForSyllabusAndCaseProgressWhenPercentReachesHundred() throws Exception {
-        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
-
-        JsonNode payload = payloadWithProgressPercent(100, 100.0);
+        JsonNode payload = payloadWithSyllabusProgressPercent(100);
         String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
                 .contains("class='donut-mastered-full-circle'")
-                .contains("class='case-progress-full-circle'")
-                .doesNotContain("class='donut-mastered-arc'")
-                .doesNotContain("class='case-progress-arc'");
+                .doesNotContain("class='donut-mastered-arc'");
     }
 
+    /**
+     * 覆盖官方 payload 计算出的考纲掌握率为 0% 时,考纲已掌握弧线应被省略且未掌握弧线仍存在。
+     */
     @Test
-    void renderSkipsProgressArcWhenSyllabusAndCasePercentAreZeroOrBelow() throws Exception {
+    void renderSkipsProgressArcWhenSyllabusPercentIsZeroOrBelow() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
-        JsonNode payload = payloadWithProgressPercent(0, 0.0);
+        JsonNode payload = payloadWithSyllabusProgressPercent(0);
         String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));
 
         assertThat(html)
                 .doesNotContain("class='donut-mastered-arc'")
-                .doesNotContain("class='case-progress-arc'")
                 .contains("class='donut-unmastered-arc'");
     }
 
@@ -308,107 +296,6 @@ class ClasspathOutlookExamSprintReportRendererTest {
         }
     }
 
-    private JsonNode samplePayload() throws Exception {
-        return OBJECT_MAPPER.readTree("""
-                {
-                  "reportMetadata": {
-                    "reportVersionLabel": "2026 词汇展望报告",
-                    "learnerName": "李同学",
-                    "targetExamName": "春季高考英语",
-                    "sprintPeriodLabel": "30 天考前冲刺",
-                    "authorName": "Ability Bot"
-                  },
-                  "readinessOverview": {
-                    "summary": "基础较稳,具备短期冲刺提分空间。",
-                    "currentStage": "冲刺提升期",
-                    "keyInsight": "高频与常考词群是提分关键。",
-                    "readinessScore": 72
-                  },
-                  "syllabusMasteryChart": {
-                    "totalWordCount": 4200,
-                    "masteredWordCount": 2701,
-                    "unmasteredWordCount": 1499,
-                    "masteryPercent": 64,
-                    "summaryLabel": "考纲词汇掌握概览",
-                    "recommendation": "优先补齐高考核心场景词。"
-                  },
-                  "pastPaperVocabularyChart": {
-                    "totalWordCount": 961,
-                    "unknownWordCountBeforeSprint": 847,
-                    "unknownWordCountAfterSprint": 716,
-                    "projectedScoreGainLabel": "预计提分5-15分",
-                    "recommendation": "先压降真题生词占比。"
-                  },
-                  "highFrequencyVocabularyChart": {
-                    "basicCorePercent": 62,
-                    "frequentCorePercent": 54,
-                    "advancedScorePercent": 41,
-                    "highlightLabel": "拉分词是提分核心突破项"
-                  },
-                  "vocabularyFrequencyBandChart": {
-                    "bars": [
-                      {"bandLabel": "高频词", "currentValue": 86.0, "priorityLabel": "优先学习", "themeColor": "#448aff"},
-                      {"bandLabel": "中频词", "currentValue": 78.0, "priorityLabel": "重点突破", "themeColor": "#4caf50"},
-                      {"bandLabel": "低频词", "currentValue": 62.0, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
-                    ]
-                  },
-                  "frequencyPlan": {
-                    "cards": [
-                      {
-                        "cadencePerWeek": 1,
-                        "scoreGainLabel": "+5分",
-                        "winRatePercent": 38,
-                        "recommended": false,
-                        "badgeLabel": "稳健",
-                        "emphasisIcon": "①"
-                      },
-                      {
-                        "cadencePerWeek": 2,
-                        "scoreGainLabel": "+10分",
-                        "winRatePercent": 55,
-                        "recommended": false,
-                        "badgeLabel": "均衡",
-                        "emphasisIcon": "②"
-                      },
-                      {
-                        "cadencePerWeek": 3,
-                        "scoreGainLabel": "+15分",
-                        "winRatePercent": 72,
-                        "recommended": true,
-                        "badgeLabel": "推荐",
-                        "emphasisIcon": "③"
-                      },
-                      {
-                        "cadencePerWeek": 5,
-                        "scoreGainLabel": "+20分",
-                        "winRatePercent": 88,
-                        "recommended": false,
-                        "badgeLabel": "冲刺",
-                        "emphasisIcon": "④"
-                      }
-                    ],
-                    "recommendationTitle": "💡建议策略",
-                    "recommendationSummary": "7 天提分冲刺是首选节奏,按词频优先级记忆,不浪费时间;只攻克高频/中频核心词,2周15小时速记500-800必考词,快速缩小生词缺口。",
-                    "phaseSuggestions": [
-                      {"title": "考前半个月·核心突击期", "description": "围绕高频词建立记忆闭环。"},
-                      {"title": "考前半小时·临阵巩固期", "description": "结合真题词做循环巩固。"}
-                    ]
-                  },
-                  "scoreImprovementCaseStudy": {
-                    "headline": "真实提分 · 效果可复制",
-                    "learnerName": "王雷宇",
-                    "studyPeriodLabel": "考前半个月·核心突击期",
-                    "memorizedWordCount": 705,
-                    "examHitWordCount": 237,
-                    "hitRatePercent": 33.8,
-                    "baselineScoreLabel": "70分以下",
-                    "finalScore": 89,
-                    "scoreGain": 19
-                  }
-                }
-                """);
-    }
-
     private JsonNode callerVocabularyPayload() throws Exception {
         return OBJECT_MAPPER.readTree("""
                 {
@@ -444,16 +331,6 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 """);
     }
 
-    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 -> {
@@ -478,47 +355,22 @@ class ClasspathOutlookExamSprintReportRendererTest {
         return svgStartTags;
     }
 
-    private JsonNode payloadWithEscapingSamples() throws Exception {
-        ObjectNode payload = samplePayload().deepCopy();
-
-        ObjectNode frequencyPlan = (ObjectNode) payload.get("frequencyPlan");
-        ObjectNode firstPhaseSuggestion = (ObjectNode) frequencyPlan.withArray("phaseSuggestions").get(0);
-        firstPhaseSuggestion.put("title", "春季&高考英语");
-        frequencyPlan.put("recommendationSummary", "<script>alert(1)</script>");
-
-        ObjectNode scoreImprovementCaseStudy = (ObjectNode) payload.get("scoreImprovementCaseStudy");
-        scoreImprovementCaseStudy.put("learnerName", "李<同学>");
-        scoreImprovementCaseStudy.put("headline", "Ability \"Bot\"");
-        scoreImprovementCaseStudy.put("baselineScoreLabel", "O'Brien");
-
-        return payload;
-    }
-
-    private JsonNode payloadWithInvalidThemeColors() throws Exception {
-        ObjectNode payload = samplePayload().deepCopy();
-        payload.with("vocabularyFrequencyBandChart").withArray("bars").forEach(node -> {
-            ObjectNode bar = (ObjectNode) node;
-            if ("高频词".equals(bar.path("bandLabel").asText())) {
-                bar.put("themeColor", "\" onload=\"alert(1)");
-                return;
-            }
-            if ("中频词".equals(bar.path("bandLabel").asText())) {
-                bar.put("themeColor", "url(javascript:alert(1))");
-                return;
-            }
-            bar.put("themeColor", "#12");
-        });
+    private JsonNode payloadWithCallerControlledTextSamples() throws Exception {
+        ObjectNode payload = (ObjectNode) callerVocabularyPayload();
+        payload.put("StudentName", "注入学生<script>alert(1)</script>");
+        payload.put("StageName", "注入阶段<script>alert(1)</script>");
+        payload.put("StageExaminName", "注入考试<script>alert(1)</script>");
+        payload.put("TestPaperTitle", "注入文章<script>alert(1)</script>");
+        ((ObjectNode) payload.withArray("StudentWordsLatest").get(0)).put("WordSpell", "evil-word<script>alert(1)</script>");
         return payload;
     }
 
-    private JsonNode payloadWithProgressPercent(int masteryPercent, double hitRatePercent) throws Exception {
-        ObjectNode payload = samplePayload().deepCopy();
-        ObjectNode syllabusMasteryChart = (ObjectNode) payload.get("syllabusMasteryChart");
-        syllabusMasteryChart.put("totalWordCount", 100);
-        syllabusMasteryChart.put("masteredWordCount", Math.max(0, Math.min(100, masteryPercent)));
-        syllabusMasteryChart.put("unmasteredWordCount", 100 - Math.max(0, Math.min(100, masteryPercent)));
-        syllabusMasteryChart.put("masteryPercent", masteryPercent);
-        ((ObjectNode) payload.get("scoreImprovementCaseStudy")).put("hitRatePercent", hitRatePercent);
+    private JsonNode payloadWithSyllabusProgressPercent(int masteryPercent) throws Exception {
+        ObjectNode payload = (ObjectNode) callerVocabularyPayload();
+        int boundedMasteryPercent = Math.max(0, Math.min(100, masteryPercent));
+        payload.put("StageVocabulary", 100);
+        payload.put("MastedWordCount", boundedMasteryPercent);
+        payload.put("UnMastedWordCount", 100 - boundedMasteryPercent);
         return payload;
     }
 }

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

@@ -88,18 +88,18 @@ class OutlookExamSprintReportTemplateCompatibilityTest {
         String normalizedTemplate = normalizeWhitespace(loadTemplate());
 
         assertThat(normalizedTemplate)
-                .contains("{{syllabusMasteryChart}}")
-                .contains("{{pastPaperVocabularyChart}}")
-                .contains("{{highFrequencyVocabularyChart}}")
-                .contains("{{vocabularyFrequencyBandChart}}")
-                .contains("{{scoreImprovementCaseStudy}}")
+                .contains("{{syllabusMasterySection}}")
+                .contains("{{pastPaperVocabularySection}}")
+                .contains("{{highFrequencyVocabularySection}}")
+                .contains("{{frequencyBandSection}}")
+                .contains("{{caseStudySection}}")
                 .contains("{{studySuggestionSection}}")
                 .contains("<table class=\"analysis-table\" role=\"presentation\">")
                 .contains("<tr class=\"analysis-row\">")
-                .contains("<td class=\"analysis-cell analysis-cell-left\">{{syllabusMasteryChart}}</td>")
-                .contains("<td class=\"analysis-cell analysis-cell-right\">{{pastPaperVocabularyChart}}</td>")
-                .contains("<td class=\"analysis-cell analysis-cell-left\">{{highFrequencyVocabularyChart}}</td>")
-                .contains("<td class=\"analysis-cell analysis-cell-right\">{{vocabularyFrequencyBandChart}}</td>")
+                .contains("<td class=\"analysis-cell analysis-cell-left\">{{syllabusMasterySection}}</td>")
+                .contains("<td class=\"analysis-cell analysis-cell-right\">{{pastPaperVocabularySection}}</td>")
+                .contains("<td class=\"analysis-cell analysis-cell-left\">{{highFrequencyVocabularySection}}</td>")
+                .contains("<td class=\"analysis-cell analysis-cell-right\">{{frequencyBandSection}}</td>")
                 .doesNotContain("{{reportIntroShell}}");
     }
 

+ 58 - 64
ability-center-runtime/scripts/outlook-report-demo.sh

@@ -21,70 +21,64 @@ http_code="$({
     --data-binary @- \
     "$ENDPOINT" <<'JSON'
 {
-  "reportMetadata": {
-    "reportVersionLabel": "2026 词汇展望报告",
-    "learnerName": "李同学",
-    "targetExamName": "雅思 6.5",
-    "sprintPeriodLabel": "2026 春季冲刺",
-    "authorName": "Ability Bot"
-  },
-  "readinessOverview": {
-    "summary": "词汇能力进入提分窗口,适合围绕考纲和高频场景做集中突破。",
-    "currentStage": "当前阶段:稳态提升",
-    "keyInsight": "核心观察:阅读词汇优于写作输出,仍需补齐同义替换。",
-    "readinessScore": 78
-  },
-  "syllabusMasteryChart": {
-    "totalWordCount": 4200,
-    "masteredWordCount": 3192,
-    "unmasteredWordCount": 1008,
-    "masteryPercent": 76,
-    "summaryLabel": "考纲词汇掌握概览",
-    "recommendation": "先补齐教育、科技和环境主题词,再做套题复盘。"
-  },
-  "pastPaperVocabularyChart": {
-    "totalWordCount": 800,
-    "unknownWordCountBeforeSprint": 180,
-    "unknownWordCountAfterSprint": 120,
-    "projectedScoreGainLabel": "预计提分 5-10 分",
-    "recommendation": "每次精听后补 5 组同义替换并做口头复述。"
-  },
-  "highFrequencyVocabularyChart": {
-    "basicCorePercent": 77,
-    "frequentCorePercent": 70,
-    "advancedScorePercent": 62,
-    "highlightLabel": "Common 高频词覆盖较广,但易混词记忆不牢。"
-  },
-  "vocabularyFrequencyBandChart": {
-    "bars": [
-      {"bandLabel": "2k 高频", "currentValue": 86, "priorityLabel": "优先学习", "themeColor": "#448aff"},
-      {"bandLabel": "3k 高频", "currentValue": 78, "priorityLabel": "重点突破", "themeColor": "#4caf50"},
-      {"bandLabel": "学术词", "currentValue": 62, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
-    ]
-  },
-  "frequencyPlan": {
-    "cards": [
-      {"cadencePerWeek": 3, "scoreGainLabel": "+12 分", "winRatePercent": 78, "recommended": true, "badgeLabel": "推荐", "emphasisIcon": "③"},
-      {"cadencePerWeek": 2, "scoreGainLabel": "+8 分", "winRatePercent": 61, "recommended": false, "badgeLabel": "稳妥", "emphasisIcon": "②"}
-    ],
-    "recommendationTitle": "💡建议策略",
-    "recommendationSummary": "7 天提分冲刺优先保证高频词正确率,再逐步覆盖中频词。",
-    "phaseSuggestions": [
-      {"title": "考前半个月·核心突击期", "description": "围绕高频词建立记忆闭环。"},
-      {"title": "考前一周·强化巩固期", "description": "结合真题错词做循环复盘。"}
-    ]
-  },
-  "scoreImprovementCaseStudy": {
-    "headline": "教育类阅读题案例",
-    "learnerName": "李同学",
-    "studyPeriodLabel": "考前半个月·核心突击期",
-    "memorizedWordCount": 705,
-    "examHitWordCount": 237,
-    "hitRatePercent": 33.6,
-    "baselineScoreLabel": "70 分以下",
-    "finalScore": 89,
-    "scoreGain": 19
-  }
+  "StudentName": "20260318测试",
+  "StudentStage": 2,
+  "StageName": "初中",
+  "StageVocabulary": 10,
+  "StageExaminName": "中考",
+  "StageImportant": 3,
+  "StudentWordsLatest": [
+    {
+      "WordId": 1,
+      "MeanId": 101,
+      "WordSpell": "ability",
+      "WordFrequency": 1,
+      "Mastery": 0.9,
+      "ReviewTimes": 4,
+      "Reliability": 95,
+      "CreateTime": "2026-03-18T08:00:00Z"
+    },
+    {
+      "WordId": 2,
+      "MeanId": 102,
+      "WordSpell": "center",
+      "WordFrequency": 3,
+      "Mastery": 0.75,
+      "ReviewTimes": 3,
+      "Reliability": 88,
+      "CreateTime": "2026-03-18T08:05:00Z"
+    },
+    {
+      "WordId": 3,
+      "MeanId": 103,
+      "WordSpell": "father",
+      "WordFrequency": 6,
+      "Mastery": 0.4,
+      "ReviewTimes": 1,
+      "Reliability": 70,
+      "CreateTime": "2026-03-18T08:10:00Z"
+    },
+    {
+      "WordId": 4,
+      "MeanId": 104,
+      "WordSpell": "catch",
+      "WordFrequency": 9,
+      "Mastery": 0.2,
+      "ReviewTimes": 0,
+      "Reliability": 60,
+      "CreateTime": "2026-03-18T08:15:00Z"
+    }
+  ],
+  "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
 }
 JSON
 })"

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

@@ -40,22 +40,24 @@ class ExamSprintReportControllerTest {
     @Autowired
     private ExamSprintReportRepository reportRepository;
 
+    // WHY: The runtime fixture is the curl-ready upstream payload contract, so async creation must accept it without a wrapper.
     @Test
     void createReportReturnsAcceptedResponse() throws Exception {
         mockMvc.perform(post("/api/exam-sprint/outlook-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(payloadJson(validRequestJson())))
+                        .content(validRequestJson()))
                 .andExpect(status().isAccepted())
                 .andExpect(jsonPath("$.data.reportId").isNotEmpty())
                 .andExpect(jsonPath("$.data.reportType").value("OUTLOOK"))
                 .andExpect(jsonPath("$.data.generationStatus").value("PENDING"));
     }
 
+    // WHY: The synchronous path must render the same root-level upstream payload clients submit in production.
     @Test
     void createOutlookReportSyncReturnsDownloadUrlAndPdfIsDownloadable() throws Exception {
         MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/outlook-reports/sync")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(payloadJson(validRequestJson())))
+                        .content(validRequestJson()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.reportId").isNotEmpty())
                 .andExpect(jsonPath("$.data.reportType").value("OUTLOOK"))
@@ -70,20 +72,22 @@ class ExamSprintReportControllerTest {
                 .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF));
     }
 
+    // WHY: Missing required upstream vocabulary fields should fail validation before report generation starts.
     @Test
     void createReportWithInvalidPayloadReturnsValidationError() throws Exception {
         mockMvc.perform(post("/api/exam-sprint/outlook-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(payloadJson(invalidRequestJson())))
+                        .content(invalidRequestJson()))
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
     }
 
+    // WHY: Achievement async creation should use the same curl-ready fixture style as independent report endpoints.
     @Test
     void createAchievementReportDownloadUrlReturnsPdfContent() throws Exception {
         MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/achievement-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(payloadJson(validAchievementRequestJson())))
+                        .content(validAchievementRequestJson()))
                 .andExpect(status().isAccepted())
                 .andExpect(jsonPath("$.data.reportId").isNotEmpty())
                 .andExpect(jsonPath("$.data.reportType").value("ACHIEVEMENT"))
@@ -99,11 +103,12 @@ class ExamSprintReportControllerTest {
                 .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF));
     }
 
+    // WHY: The achievement sync route shares runtime wiring with outlook and guards against breaking cross-report PDF downloads.
     @Test
     void createAchievementReportSyncReturnsDownloadUrlAndPdfIsDownloadable() throws Exception {
         MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/achievement-reports/sync")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(payloadJson(validAchievementRequestJson())))
+                        .content(validAchievementRequestJson()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.reportType").value("ACHIEVEMENT"))
                 .andExpect(jsonPath("$.data.generationStatus").value("SUCCESS"))
@@ -117,9 +122,10 @@ 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.
     @Test
     void achievementSyncFixtureUsesUnitlessTextInputs() throws Exception {
-        JsonNode payload = objectMapper.readTree(validAchievementRequestJson()).path("payload");
+        JsonNode payload = objectMapper.readTree(validAchievementRequestJson());
 
         assertThat(payload.at("/summaryMetrics/vocabularyGrowthText").asText()).isEqualTo("+19");
         assertThat(payload.at("/summaryMetrics/paperKnownWordsGrowthText").asText()).isEqualTo("+4");
@@ -138,10 +144,14 @@ class ExamSprintReportControllerTest {
         assertThat(payload.at("/examUnknownWordsHitStatus/reducedUnknownWordsText").asText()).isEqualTo("4");
     }
 
+    // WHY: The outlook fixture documents the client contract and must not drift back to legacy wrapper or text-field shapes.
     @Test
-    void outlookSyncFixtureDoesNotDefineTextFieldsRequiringUnitStripping() throws Exception {
-        JsonNode payload = objectMapper.readTree(validRequestJson()).path("payload");
+    void outlookSyncFixtureUsesUpstreamVocabularyPayloadWithoutUnitStrippingTextFields() throws Exception {
+        JsonNode payload = objectMapper.readTree(validRequestJson());
 
+        assertThat(payload.path("StudentName").asText()).isEqualTo("20260318测试");
+        assertThat(payload.path("StageExaminName").asText()).isEqualTo("中考");
+        assertThat(payload.has("payload")).isFalse();
         assertThat(payload.findValuesAsText("learningEfficiencyText")).isEmpty();
         assertThat(payload.findValuesAsText("unknownWordHitRateText")).isEmpty();
         assertThat(payload.findValuesAsText("beforeText")).isEmpty();
@@ -149,20 +159,22 @@ class ExamSprintReportControllerTest {
         assertThat(payload.findValuesAsText("growthText")).isEmpty();
     }
 
+    // WHY: Achievement validation failures must keep the same public error envelope as outlook validation failures.
     @Test
     void createAchievementReportWithInvalidPayloadReturnsValidationError() throws Exception {
         mockMvc.perform(post("/api/exam-sprint/achievement-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(payloadJson(invalidAchievementRequestJson())))
+                        .content(invalidAchievementRequestJson()))
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
     }
 
+    // WHY: Async outlook reports should progress from the root-level upstream payload to downloadable PDF content.
     @Test
     void getCreatedReportDownloadUrlReturnsPdfContent() throws Exception {
         MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/outlook-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(payloadJson(validRequestJson())))
+                        .content(validRequestJson()))
                 .andExpect(status().isAccepted())
                 .andReturn();
 
@@ -175,11 +187,12 @@ class ExamSprintReportControllerTest {
                 .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF));
     }
 
+    // WHY: Expiry handling must reject stale download URLs even when reports are created from the upstream payload contract.
     @Test
     void expiredReportDownloadIsRejected() throws Exception {
         MvcResult createResult = mockMvc.perform(post("/api/exam-sprint/outlook-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content(payloadJson(validRequestJson())))
+                        .content(validRequestJson()))
                 .andExpect(status().isAccepted())
                 .andReturn();
 
@@ -215,8 +228,7 @@ class ExamSprintReportControllerTest {
         try {
             com.fasterxml.jackson.databind.node.ObjectNode request =
                     (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.readTree(validRequestJson());
-            ((com.fasterxml.jackson.databind.node.ObjectNode) request.path("payload").path("reportMetadata"))
-                    .remove("learnerName");
+            request.remove("StudentName");
             return objectMapper.writeValueAsString(request);
         } catch (Exception exception) {
             throw new IllegalStateException("Failed to build invalid request json", exception);
@@ -243,14 +255,6 @@ class ExamSprintReportControllerTest {
         return objectMapper.readTree(result.getResponse().getContentAsString());
     }
 
-    private String payloadJson(String wrappedRequestJson) {
-        try {
-            return objectMapper.writeValueAsString(objectMapper.readTree(wrappedRequestJson).path("payload"));
-        } catch (Exception exception) {
-            throw new IllegalStateException("Failed to extract payload json", exception);
-        }
-    }
-
     private JsonNode waitForSuccessfulReport(String reportId) throws Exception {
         JsonNode queryBody = null;
         for (int attempt = 0; attempt < 30; attempt++) {

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

@@ -10,7 +10,6 @@ import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportT
 import cn.yunzhixue.ability.center.kernel.BusinessException;
 import cn.yunzhixue.ability.center.kernel.ErrorCode;
 import cn.yunzhixue.ability.center.GlobalExceptionHandler;
-import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.JsonNode;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -47,12 +46,10 @@ class ExamSprintReportControllerWebMvcTest {
     @Autowired
     private MockMvc mockMvc;
 
-    @Autowired
-    private ObjectMapper objectMapper;
-
     @MockBean
     private ExamSprintReportApplicationService applicationService;
 
+    // WHY: Independent outlook endpoints must forward the root-level upstream payload unchanged to the application layer.
     @Test
     void createOutlookReportUsesIndependentEndpoint() throws Exception {
         given(applicationService.createOutlookReport(any())).willReturn(new CreateExamSprintReportResponse(
@@ -62,7 +59,7 @@ class ExamSprintReportControllerWebMvcTest {
                 Instant.parse("2026-01-01T00:00:00Z"),
                 Instant.parse("2026-01-02T00:00:00Z")));
 
-        String requestJson = requestPayloadJson("requests/exam-sprint-outlook-report-request.json");
+        String requestJson = requestJson("requests/exam-sprint-outlook-report-request.json");
 
         mockMvc.perform(post("/api/exam-sprint/outlook-reports")
                         .contentType(MediaType.APPLICATION_JSON)
@@ -75,10 +72,11 @@ class ExamSprintReportControllerWebMvcTest {
         ArgumentCaptor<JsonNode> payloadCaptor = ArgumentCaptor.forClass(JsonNode.class);
         verify(applicationService).createOutlookReport(payloadCaptor.capture());
         JsonNode payload = payloadCaptor.getValue();
-        Assertions.assertEquals("李同学", payload.path("reportMetadata").path("learnerName").asText());
-        Assertions.assertEquals("雅思 6.5", payload.path("reportMetadata").path("targetExamName").asText());
+        Assertions.assertEquals("20260318测试", payload.path("StudentName").asText());
+        Assertions.assertEquals("中考", payload.path("StageExaminName").asText());
     }
 
+    // WHY: Achievement endpoint tests protect the route split using the same curl-ready fixture style as outlook.
     @Test
     void createAchievementReportUsesIndependentEndpoint() throws Exception {
         given(applicationService.createAchievementReport(any())).willReturn(new CreateExamSprintReportResponse(
@@ -88,7 +86,7 @@ class ExamSprintReportControllerWebMvcTest {
                 Instant.parse("2026-01-01T00:00:00Z"),
                 Instant.parse("2026-01-02T00:00:00Z")));
 
-        String requestJson = requestPayloadJson("requests/exam-sprint-achievement-report-request.json");
+        String requestJson = requestJson("requests/exam-sprint-achievement-report-request.json");
 
         mockMvc.perform(post("/api/exam-sprint/achievement-reports")
                         .contentType(MediaType.APPLICATION_JSON)
@@ -107,6 +105,7 @@ class ExamSprintReportControllerWebMvcTest {
         Assertions.assertEquals("number", payload.path("examUnknownWordsHitStatus").path("hitWords").get(0).asText());
     }
 
+    // WHY: Sync outlook requests should use the same root-level upstream payload as async requests.
     @Test
     void createOutlookReportSyncReturnsDownloadUrl() throws Exception {
         given(applicationService.createOutlookReportSync(any())).willReturn(new CreateExamSprintReportWithUrlResponse(
@@ -118,7 +117,7 @@ class ExamSprintReportControllerWebMvcTest {
                 Instant.parse("2026-01-02T00:00:00Z"),
                 "https://dcjxbtest.blob.core.chinacloudapi.cn/report/exam-sprint-outlook-report-report-sync-001.pdf"));
 
-        String requestJson = requestPayloadJson("requests/exam-sprint-outlook-report-request.json");
+        String requestJson = requestJson("requests/exam-sprint-outlook-report-request.json");
 
         mockMvc.perform(post("/api/exam-sprint/outlook-reports/sync")
                         .contentType(MediaType.APPLICATION_JSON)
@@ -131,6 +130,7 @@ class ExamSprintReportControllerWebMvcTest {
         verify(applicationService).createOutlookReportSync(any());
     }
 
+    // WHY: Achievement sync routing must stay independent from outlook while returning the same response envelope shape.
     @Test
     void createAchievementReportSyncReturnsDownloadUrl() throws Exception {
         given(applicationService.createAchievementReportSync(any())).willReturn(new CreateExamSprintReportWithUrlResponse(
@@ -142,7 +142,7 @@ class ExamSprintReportControllerWebMvcTest {
                 Instant.parse("2026-01-02T00:00:00Z"),
                 "https://dcjxbtest.blob.core.chinacloudapi.cn/report/exam-sprint-achievement-report-report-sync-002.pdf"));
 
-        String requestJson = requestPayloadJson("requests/exam-sprint-achievement-report-request.json");
+        String requestJson = requestJson("requests/exam-sprint-achievement-report-request.json");
 
         mockMvc.perform(post("/api/exam-sprint/achievement-reports/sync")
                         .contentType(MediaType.APPLICATION_JSON)
@@ -155,6 +155,7 @@ class ExamSprintReportControllerWebMvcTest {
         verify(applicationService).createAchievementReportSync(any());
     }
 
+    // WHY: Removed generic report endpoints must not reappear while independent report endpoints are being maintained.
     @Test
     void removedReportEndpointsAreNotExposed() throws Exception {
         mockMvc.perform(post("/api/exam-sprint/reports")
@@ -168,17 +169,19 @@ class ExamSprintReportControllerWebMvcTest {
         verifyNoInteractions(applicationService);
     }
 
+    // WHY: Malformed JSON should be rejected at the web boundary without invoking application services.
     @Test
     void createOutlookReportReturnsValidationErrorWhenJsonIsMalformed() throws Exception {
         mockMvc.perform(post("/api/exam-sprint/outlook-reports")
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"reportMetadata\":{]"))
+                        .content("{\"StudentName\":]"))
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
 
         verifyNoInteractions(applicationService);
     }
 
+    // WHY: Download routes expose report ids publicly and must not require callers to know storage object keys.
     @Test
     void downloadReportUsesReportIdInsteadOfStorageKey() throws Exception {
         given(applicationService.getReport(eq("report-001"))).willReturn(new ExamSprintReportDetailResponse(
@@ -205,6 +208,7 @@ class ExamSprintReportControllerWebMvcTest {
                 .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PDF));
     }
 
+    // WHY: Kernel business errors must keep stable HTTP status and code mapping at the controller boundary.
     @Test
     void getReportMapsNewKernelBusinessExceptionToBusinessStatusCode() throws Exception {
         given(applicationService.getReport(eq("missing-report")))
@@ -216,16 +220,12 @@ class ExamSprintReportControllerWebMvcTest {
                 .andExpect(jsonPath("$.code").value("REPORT_NOT_FOUND"));
     }
 
-    private String requestPayloadJson(String resourcePath) throws Exception {
-        return objectMapper.writeValueAsString(requestPayload(resourcePath));
-    }
-
-    private JsonNode requestPayload(String resourcePath) throws Exception {
+    private String requestJson(String resourcePath) throws Exception {
         try (InputStream inputStream = ExamSprintReportControllerWebMvcTest.class.getClassLoader().getResourceAsStream(resourcePath)) {
             if (inputStream == null) {
                 throw new IllegalArgumentException("Missing test resource: " + resourcePath);
             }
-            return objectMapper.readTree(inputStream).path("payload");
+            return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
         }
     }
 

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

@@ -1,36 +1,33 @@
 {
-  "reportType": "ACHIEVEMENT",
-  "payload": {
-    "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"]
-    }
+  "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"]
   }
 }

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

@@ -1,37 +1,34 @@
 {
-  "reportType": "ACHIEVEMENT",
-  "payload": {
-    "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"]
-    }
+  "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"]
   }
 }

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

@@ -1,69 +1,60 @@
 {
-  "reportType": "OUTLOOK",
-  "payload": {
-    "reportMetadata": {
-      "reportVersionLabel": "2026 词汇展望报告",
-      "learnerName": "李同学",
-      "targetExamName": "雅思 6.5",
-      "sprintPeriodLabel": "2026 春季冲刺",
-      "authorName": "Ability Bot"
+  "StudentName": "20260318测试",
+  "StudentStage": 2,
+  "StageName": "初中",
+  "StageVocabulary": 10,
+  "StageExaminName": "中考",
+  "StageImportant": 3,
+  "StudentWordsLatest": [
+    {
+      "WordId": 1,
+      "MeanId": 101,
+      "WordSpell": "ability",
+      "WordFrequency": 1,
+      "Mastery": 0.9,
+      "ReviewTimes": 4,
+      "Reliability": 95,
+      "CreateTime": "2026-03-18T08:00:00Z"
     },
-    "readinessOverview": {
-      "summary": "词汇能力进入提分窗口,适合围绕考纲和高频场景做集中突破。",
-      "currentStage": "当前阶段:稳态提升",
-      "keyInsight": "核心观察:阅读词汇优于写作输出,仍需补齐同义替换。",
-      "readinessScore": 78
+    {
+      "WordId": 2,
+      "MeanId": 102,
+      "WordSpell": "center",
+      "WordFrequency": 3,
+      "Mastery": 0.75,
+      "ReviewTimes": 3,
+      "Reliability": 88,
+      "CreateTime": "2026-03-18T08:05:00Z"
     },
-    "syllabusMasteryChart": {
-      "totalWordCount": 4200,
-      "masteredWordCount": 3192,
-      "unmasteredWordCount": 1008,
-      "masteryPercent": 76,
-      "summaryLabel": "考纲词汇掌握概览",
-      "recommendation": "先补齐教育、科技和环境主题词,再做套题复盘。"
+    {
+      "WordId": 3,
+      "MeanId": 103,
+      "WordSpell": "father",
+      "WordFrequency": 6,
+      "Mastery": 0.4,
+      "ReviewTimes": 1,
+      "Reliability": 70,
+      "CreateTime": "2026-03-18T08:10:00Z"
     },
-    "pastPaperVocabularyChart": {
-      "totalWordCount": 800,
-      "unknownWordCountBeforeSprint": 180,
-      "unknownWordCountAfterSprint": 120,
-      "projectedScoreGainLabel": "预计提分 5-10 分",
-      "recommendation": "每次精听后补 5 组同义替换并做口头复述。"
-    },
-    "highFrequencyVocabularyChart": {
-      "basicCorePercent": 77,
-      "frequentCorePercent": 70,
-      "advancedScorePercent": 62,
-      "highlightLabel": "Common 高频词覆盖较广,但易混词记忆不牢。"
-    },
-    "vocabularyFrequencyBandChart": {
-      "bars": [
-        {"bandLabel": "2k 高频", "currentValue": 86, "priorityLabel": "优先学习", "themeColor": "#448aff"},
-        {"bandLabel": "3k 高频", "currentValue": 78, "priorityLabel": "重点突破", "themeColor": "#4caf50"},
-        {"bandLabel": "学术词", "currentValue": 62, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
-      ]
-    },
-    "frequencyPlan": {
-      "cards": [
-        {"cadencePerWeek": 3, "scoreGainLabel": "+12 分", "winRatePercent": 78, "recommended": true, "badgeLabel": "推荐", "emphasisIcon": "③"},
-        {"cadencePerWeek": 2, "scoreGainLabel": "+8 分", "winRatePercent": 61, "recommended": false, "badgeLabel": "稳妥", "emphasisIcon": "②"}
-      ],
-      "recommendationTitle": "💡建议策略",
-      "recommendationSummary": "7 天提分冲刺优先保证高频词正确率,再逐步覆盖中频词。",
-      "phaseSuggestions": [
-        {"title": "考前半个月·核心突击期", "description": "围绕高频词建立记忆闭环。"},
-        {"title": "考前一周·强化巩固期", "description": "结合真题错词做循环复盘。"}
-      ]
-    },
-    "scoreImprovementCaseStudy": {
-      "headline": "教育类阅读题案例",
-      "learnerName": "李同学",
-      "studyPeriodLabel": "考前半个月·核心突击期",
-      "memorizedWordCount": 705,
-      "examHitWordCount": 237,
-      "hitRatePercent": 33.6,
-      "baselineScoreLabel": "70 分以下",
-      "finalScore": 89,
-      "scoreGain": 19
+    {
+      "WordId": 4,
+      "MeanId": 104,
+      "WordSpell": "catch",
+      "WordFrequency": 9,
+      "Mastery": 0.2,
+      "ReviewTimes": 0,
+      "Reliability": 60,
+      "CreateTime": "2026-03-18T08:15:00Z"
     }
-  }
+  ],
+  "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
 }

+ 248 - 0
docs/plans/2026-04-29-outlook-payload-contract.md

@@ -0,0 +1,248 @@
+# Outlook Payload Contract Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Make the upstream vocabulary payload the only official outlook report request contract by renaming `OutlookStudentVocabularyReportPayload` to `OutlookExamSprintReportPayload` and removing legacy structured payload compatibility.
+
+**Architecture:** The public contract module exposes one outlook payload record matching the upstream `StudentName`/`StudentWordsLatest` JSON shape. Application validation deserializes only that contract. The infrastructure renderer adapts that public contract into an internal view model used by existing rendering methods, preserving report output while deleting the old dual-payload branch.
+
+**Tech Stack:** Java 17 records, Jackson `@JsonProperty`, Jakarta Bean Validation, JUnit 5, AssertJ, Maven multi-module build.
+
+---
+
+### Task 1: Replace the outlook contract type
+
+**Files:**
+- Modify: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java`
+- Delete: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookStudentVocabularyReportPayload.java`
+
+**Step 1: Write the failing test/check**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/contracts -DskipTests compile
+```
+
+Expected before implementation: compile still succeeds, but old contract remains. The implementation will make downstream references fail until updated.
+
+**Step 2: Implement the contract replacement**
+
+Replace `OutlookExamSprintReportPayload` contents with the fields currently in `OutlookStudentVocabularyReportPayload`, including nested `StudentWordLatest` and all `@JsonProperty` annotations:
+
+```java
+public record OutlookExamSprintReportPayload(
+        @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) {
+}
+```
+
+Delete `OutlookStudentVocabularyReportPayload.java`.
+
+**Step 3: Run compile to expose downstream references**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/contracts,abilities/exam-sprint/application,abilities/exam-sprint/infrastructure,ability-center-runtime -am -DskipTests compile
+```
+
+Expected: FAIL on references to removed nested legacy view-model records and `OutlookStudentVocabularyReportPayload`.
+
+---
+
+### Task 2: Simplify application-layer validation
+
+**Files:**
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java`
+- Modify tests: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
+
+**Step 1: Update tests to use the new official payload**
+
+Replace helper construction of old structured `OutlookExamSprintReportPayload` with JSON/ObjectNode matching upstream fields. Ensure tests still assert `createOutlookReport` stores `UnmodeledReportContent` with `StudentName` and `StageVocabulary` fields.
+
+**Step 2: Implement validation simplification**
+
+Remove import and usage of `OutlookStudentVocabularyReportPayload`. Replace `validateOutlookPayload` with a single read/validate path:
+
+```java
+private void validateOutlookPayload(JsonNode payload) {
+    OutlookExamSprintReportPayload reportPayload = readPayload(payload, OutlookExamSprintReportPayload.class);
+    validatePayload(reportPayload);
+}
+```
+
+Delete `isStudentVocabularyOutlookPayload` from the application service.
+
+**Step 3: Run application tests**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application -am test
+```
+
+Expected: PASS after test/helper updates.
+
+---
+
+### Task 3: Convert renderer to one public payload plus internal view model
+
+**Files:**
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java`
+- Modify tests: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java`
+- Modify tests: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java`
+
+**Step 1: Update renderer tests first**
+
+Delete/replace legacy structured-path tests:
+
+- Remove `renderKeepsStructuredPayloadPathWhenOnlyStageVocabularyFieldIsAdded`.
+- Remove `renderSupportsLegacyHighScorePercentForStructuredOutlookPayload`.
+- Keep and strengthen the upstream vocabulary payload assertions.
+- Update escaping tests to use fields now reachable from the official payload, e.g. `StudentName`, `StageName`, `StageExaminName`, `TestPaperUnMasterWords` if rendered, or remove assertions for old-only fields that are no longer rendered.
+- Update invalid theme color tests if theme color is no longer externally supplied; delete if not applicable.
+
+**Step 2: Implement internal view model**
+
+Inside `ClasspathOutlookExamSprintReportRenderer`, introduce private nested records such as:
+
+```java
+private record OutlookReportViewModel(
+        SyllabusMasteryChart syllabusMasteryChart,
+        PastPaperVocabularyChart pastPaperVocabularyChart,
+        HighFrequencyVocabularyChart highFrequencyVocabularyChart,
+        VocabularyFrequencyBandChart vocabularyFrequencyBandChart,
+        FrequencyPlan frequencyPlan,
+        ScoreImprovementCaseStudy scoreImprovementCaseStudy) {}
+```
+
+Move the old renderer-only records into private nested records. Keep existing rendering methods, but change their parameter types from `OutlookExamSprintReportPayload.*` to the private view-model record types.
+
+**Step 3: Replace dual-payload render branch**
+
+Deserialize only:
+
+```java
+OutlookExamSprintReportPayload payload = objectMapper.treeToValue(json, OutlookExamSprintReportPayload.class);
+OutlookReportViewModel reportPayload = adaptPayload(payload);
+```
+
+Rename `adaptStudentVocabularyPayload` to `adaptPayload` and make it return `OutlookReportViewModel`.
+
+**Step 4: Run renderer/PDF tests**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/infrastructure -am test
+```
+
+Expected: PASS.
+
+---
+
+### Task 4: Update runtime fixtures and demo script
+
+**Files:**
+- Modify: `ability-center-runtime/src/test/resources/requests/exam-sprint-outlook-report-request.json`
+- Modify: `ability-center-runtime/scripts/outlook-report-demo.sh`
+- Modify tests if needed: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerWebMvcTest.java`
+- Modify tests if needed: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/adapter/http/ExamSprintReportControllerTest.java`
+
+**Step 1: Update request fixture**
+
+Change `payload` to the upstream vocabulary shape with fields:
+
+```json
+{
+  "StudentName": "20260318测试",
+  "StudentStage": 2,
+  "StageName": "初中",
+  "StageVocabulary": 10,
+  "StageExaminName": "中考",
+  "StageImportant": 3,
+  "StudentWordsLatest": [...],
+  "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
+}
+```
+
+**Step 2: Update controller assertions**
+
+Change assertions from `reportMetadata.learnerName` and `targetExamName` to `StudentName` and `StageExaminName`.
+
+**Step 3: Update demo script**
+
+Make `outlook-report-demo.sh` send the same official payload shape directly to `/api/exam-sprint/outlook-reports`.
+
+**Step 4: Run runtime tests**
+
+Run:
+
+```bash
+mvn -pl ability-center-runtime -am test
+```
+
+Expected: PASS.
+
+---
+
+### Task 5: Remove stale references and run full verification
+
+**Files:**
+- Search all Java/resources/docs touched by references.
+
+**Step 1: Check stale references**
+
+Run:
+
+```bash
+rg "OutlookStudentVocabularyReportPayload|reportMetadata|highScorePercent|vocabularyFrequencyBandChart" abilities ability-center-runtime
+```
+
+Expected: no `OutlookStudentVocabularyReportPayload` references. Remaining old structured payload field names should only exist in unrelated docs or intentionally retained generated output tests; remove/update runtime fixtures and active tests.
+
+**Step 2: Run relevant full test suite**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/contracts,abilities/exam-sprint/application,abilities/exam-sprint/infrastructure,ability-center-runtime -am test
+```
+
+Expected: BUILD SUCCESS.
+
+**Step 3: Inspect git diff**
+
+Run:
+
+```bash
+git diff --stat
+git diff -- abilities/exam-sprint/contracts abilities/exam-sprint/application abilities/exam-sprint/infrastructure ability-center-runtime
+```
+
+Expected: contract rename, app validation simplification, renderer single-payload adaptation, updated fixtures/tests/scripts only.