Преглед на файлове

Merge branch 'fix/展望报告轻量校验' of jyx/dcjxb.microservice into master

金逸霄 преди 6 дни
родител
ревизия
cdd37a2904

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

@@ -285,38 +285,40 @@ class ExamSprintReportApplicationServiceTest {
                 .isEqualTo(ErrorCode.VALIDATION_ERROR);
                 .isEqualTo(ErrorCode.VALIDATION_ERROR);
     }
     }
 
 
-    /** 覆盖展望报告嵌套列表 Bean Validation 路径映射场景,应暴露请求 JSON 字段名并保留索引。 */
+    /** 覆盖展望报告大数组轻量校验场景,当 StudentWordsLatest 内部元素字段不满足约束时,入口只校验数组非空并继续创建。 */
     @Test
     @Test
-    void createOutlookReportRejectsNestedStudentWordsLatestViolationWithApiFieldPath() throws Exception {
-        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
-        ((ObjectNode) invalidPayload.withArray("StudentWordsLatest").get(0)).put("WordFrequency", 0);
+    void createOutlookReportAcceptsInvalidStudentWordsLatestElementsBecauseLargeArrayValidatesSizeOnly() throws Exception {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        List<String> dispatchedReportIds = new ArrayList<>();
+        ObjectNode payload = callerVocabularyPayload().deepCopy();
+        ((ObjectNode) payload.withArray("StudentWordsLatest").get(0)).put("WordFrequency", 0);
+        ((ObjectNode) payload.withArray("StudentWordsLatest").get(1)).remove("Mastery");
+        payload.withArray("StudentWordsLatest").addNull();
 
 
-        assertThatThrownBy(() -> service(new TestRepository(), reportId -> { }, new TestStorage())
-                        .createOutlookReport(invalidPayload))
-                .isInstanceOf(BusinessException.class)
-                .hasMessageContaining("展望报告参数校验失败")
-                .hasMessageContaining("StudentWordsLatest[0].WordFrequency")
-                .hasMessageContaining("必须大于等于 1")
-                .extracting(exception -> ((BusinessException) exception).getErrorCode())
-                .isEqualTo(ErrorCode.VALIDATION_ERROR);
+        var response = service(repository, dispatchedReportIds::add, storage).createOutlookReport(payload);
+
+        assertThat(response.reportId()).isNotBlank();
+        assertThat(repository.findById(response.reportId())).isPresent();
+        assertThat(dispatchedReportIds).containsExactly(response.reportId());
     }
     }
 
 
-    /** 覆盖展望报告直接列表元素约束路径映射场景,应隐藏 Bean Validation 内部 list element 片段。 */
+    /** 覆盖展望报告大数组轻量校验场景,当 TestPaperWordIdArray 元素不满足约束时,入口只校验数组非空并继续创建。 */
     @Test
     @Test
-    void createOutlookReportRejectsDirectListElementViolationWithCleanApiFieldPath() throws Exception {
-        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
-        invalidPayload.withArray("TestPaperWordIdArray").removeAll();
-        invalidPayload.withArray("TestPaperWordIdArray").add(-1);
+    void createOutlookReportAcceptsInvalidTestPaperWordIdArrayElementsBecauseLargeArrayValidatesSizeOnly() throws Exception {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        List<String> dispatchedReportIds = new ArrayList<>();
+        ObjectNode payload = callerVocabularyPayload().deepCopy();
+        payload.withArray("TestPaperWordIdArray").removeAll();
+        payload.withArray("TestPaperWordIdArray").add(-1);
+        payload.withArray("TestPaperWordIdArray").addNull();
 
 
-        assertThatThrownBy(() -> service(new TestRepository(), reportId -> { }, new TestStorage())
-                        .createOutlookReport(invalidPayload))
-                .isInstanceOf(BusinessException.class)
-                .hasMessageContaining("展望报告参数校验失败")
-                .hasMessageContaining("TestPaperWordIdArray[0]")
-                .hasMessageNotContaining("<list element>")
-                .hasMessageContaining("必须大于等于 0")
-                .extracting(exception -> ((BusinessException) exception).getErrorCode())
-                .isEqualTo(ErrorCode.VALIDATION_ERROR);
+        var response = service(repository, dispatchedReportIds::add, storage).createOutlookReport(payload);
+
+        assertThat(response.reportId()).isNotBlank();
+        assertThat(repository.findById(response.reportId())).isPresent();
+        assertThat(dispatchedReportIds).containsExactly(response.reportId());
     }
     }
 
 
     /** 覆盖展望报告新词汇契约场景,当调用方不再传真题词数组但传 TestPaperUnMasterWordCount 时,应正常创建并分发。 */
     /** 覆盖展望报告新词汇契约场景,当调用方不再传真题词数组但传 TestPaperUnMasterWordCount 时,应正常创建并分发。 */
@@ -351,13 +353,19 @@ class ExamSprintReportApplicationServiceTest {
         assertCreateOutlookReportRejectsInvalidPayload(invalidPayload);
         assertCreateOutlookReportRejectsInvalidPayload(invalidPayload);
     }
     }
 
 
-    /** 覆盖上游词汇报文明细含 null 元素的场景,当 StudentWordsLatest 中存在 null 时,应在保存前校验失败。 */
+    /** 覆盖上游词汇报文明细含 null 元素的场景,当 StudentWordsLatest 非空但包含 null 时,入口不递归校验元素。 */
     @Test
     @Test
-    void createOutlookReportRejectsCallerVocabularyPayloadWithNullStudentWordLatest() throws Exception {
-        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
-        ((com.fasterxml.jackson.databind.node.ArrayNode) invalidPayload.get("StudentWordsLatest")).addNull();
+    void createOutlookReportAcceptsCallerVocabularyPayloadWithNullStudentWordLatest() throws Exception {
+        TestRepository repository = new TestRepository();
+        List<String> dispatchedReportIds = new ArrayList<>();
+        ObjectNode payload = callerVocabularyPayload().deepCopy();
+        ((com.fasterxml.jackson.databind.node.ArrayNode) payload.get("StudentWordsLatest")).addNull();
 
 
-        assertCreateOutlookReportRejectsInvalidPayload(invalidPayload);
+        var response = service(repository, dispatchedReportIds::add, new TestStorage()).createOutlookReport(payload);
+
+        assertThat(response.reportId()).isNotBlank();
+        assertThat(repository.findById(response.reportId())).isPresent();
+        assertThat(dispatchedReportIds).containsExactly(response.reportId());
     }
     }
 
 
     /** 覆盖创建成果报告的调用方 PascalCase 报文场景,当提交有效 payload 时,应映射为领域内容并保存 ACHIEVEMENT 报告。 */
     /** 覆盖创建成果报告的调用方 PascalCase 报文场景,当提交有效 payload 时,应映射为领域内容并保存 ACHIEVEMENT 报告。 */

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

@@ -2,7 +2,6 @@ package cn.yunzhixue.ability.center.examsprint.contracts.report;
 
 
 import com.fasterxml.jackson.annotation.JsonAlias;
 import com.fasterxml.jackson.annotation.JsonAlias;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.annotation.JsonProperty;
-import jakarta.validation.Valid;
 import jakarta.validation.constraints.DecimalMax;
 import jakarta.validation.constraints.DecimalMax;
 import jakarta.validation.constraints.DecimalMin;
 import jakarta.validation.constraints.DecimalMin;
 import jakarta.validation.constraints.Min;
 import jakarta.validation.constraints.Min;
@@ -20,11 +19,11 @@ public record OutlookExamSprintReportPayload(
         @JsonProperty("StudentVocabulary") @NotNull @Min(0) Integer studentVocabulary,
         @JsonProperty("StudentVocabulary") @NotNull @Min(0) Integer studentVocabulary,
         @JsonProperty("StageExaminName") @NotBlank String stageExaminName,
         @JsonProperty("StageExaminName") @NotBlank String stageExaminName,
         @JsonProperty("StageImportant") @NotNull @Min(0) Integer stageImportant,
         @JsonProperty("StageImportant") @NotNull @Min(0) Integer stageImportant,
-        @JsonProperty("StudentWordsLatest") @NotEmpty List<@NotNull @Valid StudentWordLatest> studentWordsLatest,
+        @JsonProperty("StudentWordsLatest") @NotEmpty List<StudentWordLatest> studentWordsLatest,
         @JsonProperty("MastedWordCount") @NotNull @Min(0) Integer mastedWordCount,
         @JsonProperty("MastedWordCount") @NotNull @Min(0) Integer mastedWordCount,
         @JsonProperty("UnMastedWordCount") @NotNull @Min(0) Integer unMastedWordCount,
         @JsonProperty("UnMastedWordCount") @NotNull @Min(0) Integer unMastedWordCount,
         @JsonProperty("ExamineStrangeWordCount") @NotNull @Min(0) Integer examineStrangeWordCount,
         @JsonProperty("ExamineStrangeWordCount") @NotNull @Min(0) Integer examineStrangeWordCount,
-        @JsonProperty("TestPaperWordIdArray") @NotNull List<@NotNull @Min(0) Integer> testPaperWordIdArray,
+        @JsonProperty("TestPaperWordIdArray") @NotEmpty List<Integer> testPaperWordIdArray,
         @JsonProperty("TestPaperTitle") @NotBlank String testPaperTitle,
         @JsonProperty("TestPaperTitle") @NotBlank String testPaperTitle,
         @JsonProperty("TestPaperUnMasterWordCount") @NotNull @Min(0) Integer testPaperUnMasterWordCount,
         @JsonProperty("TestPaperUnMasterWordCount") @NotNull @Min(0) Integer testPaperUnMasterWordCount,
         @JsonProperty("TestPaperMastedWordCount") @NotNull @Min(0) Integer testPaperMastedWordCount,
         @JsonProperty("TestPaperMastedWordCount") @NotNull @Min(0) Integer testPaperMastedWordCount,

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

@@ -128,7 +128,11 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
     private List<OutlookExamSprintReportPayload.StudentWordLatest> participatingWords(OutlookExamSprintReportPayload payload) {
     private List<OutlookExamSprintReportPayload.StudentWordLatest> participatingWords(OutlookExamSprintReportPayload payload) {
         int stageVocabulary = payload.stageVocabulary();
         int stageVocabulary = payload.stageVocabulary();
         return payload.studentWordsLatest().stream()
         return payload.studentWordsLatest().stream()
+                .filter(Objects::nonNull)
+                .filter(word -> word.wordFrequency() != null)
+                .filter(word -> word.mastery() != null)
                 .filter(word -> word.wordFrequency() >= 1 && word.wordFrequency() <= stageVocabulary)
                 .filter(word -> word.wordFrequency() >= 1 && word.wordFrequency() <= stageVocabulary)
+                .filter(word -> word.mastery() >= 0d && word.mastery() <= 1d)
                 .toList();
                 .toList();
     }
     }
 
 

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

@@ -230,6 +230,51 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .contains("低频词:0.0%(酌情学习)");
                 .contains("低频词:0.0%(酌情学习)");
     }
     }
 
 
+    /**
+     * 覆盖大数组入口不递归校验后的渲染容错场景,应跳过无法参与词频统计的无效词元素而不让报告生成失败。
+     */
+    @Test
+    void renderSkipsInvalidStudentWordsLatestElementsAfterLargeArrayElementValidationIsRelaxed() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+        ObjectNode payload = (ObjectNode) callerVocabularyPayload();
+        payload.withArray("StudentWordsLatest").removeAll();
+        payload.withArray("StudentWordsLatest").addNull();
+        payload.withArray("StudentWordsLatest").add(OBJECT_MAPPER.createObjectNode()
+                .put("WordId", 1)
+                .put("MeanId", 1)
+                .put("WordSpell", "missing-mastery")
+                .put("WordFrequency", 1)
+                .put("ReviewTimes", 1)
+                .put("Reliability", 2)
+                .put("CreateTime", "2026-03-18T15:28:25.5813874+08:00"));
+        payload.withArray("StudentWordsLatest").add(OBJECT_MAPPER.createObjectNode()
+                .put("WordId", 2)
+                .put("MeanId", 1)
+                .put("WordSpell", "zero-frequency")
+                .put("WordFrequency", 0)
+                .put("Mastery", 1.0)
+                .put("ReviewTimes", 1)
+                .put("Reliability", 2)
+                .put("CreateTime", "2026-03-18T15:28:25.5813874+08:00"));
+        payload.withArray("StudentWordsLatest").add(OBJECT_MAPPER.createObjectNode()
+                .put("WordId", 3)
+                .put("MeanId", 1)
+                .put("WordSpell", "valid-word")
+                .put("WordFrequency", 1)
+                .put("Mastery", 0.5)
+                .put("ReviewTimes", 1)
+                .put("Reliability", 2)
+                .put("CreateTime", "2026-03-18T15:28:25.5813874+08:00"));
+
+        String html = renderer.render(unmodeledOutlookContent(payload), Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(html)
+                .contains("基础必会词:综合掌握率50%")
+                .contains("高频词:50.0%(优先学习)")
+                .doesNotContain("{{highFrequencyVocabularySection}}")
+                .doesNotContain("{{frequencyBandSection}}");
+    }
+
     /**
     /**
      * 覆盖官方上游词汇 payload 渲染三组柱状图时,应为图表补充可见坐标轴刻度。
      * 覆盖官方上游词汇 payload 渲染三组柱状图时,应为图表补充可见坐标轴刻度。
      */
      */