浏览代码

fix(exam-sprint): 丰富报告参数校验中文报错

金逸霄 2 周之前
父节点
当前提交
4583b88dc3

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

@@ -21,12 +21,19 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import jakarta.validation.ConstraintViolation;
 import jakarta.validation.Validator;
+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 org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import java.time.Clock;
 import java.time.Instant;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
@@ -39,6 +46,8 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     private static final Logger log = LoggerFactory.getLogger(DefaultExamSprintReportApplicationService.class);
 
     private static final String REPORT_GENERATION_DISPATCH_FAILED = "report_generation_dispatch_failed";
+    private static final String OUTLOOK_REPORT_NAME = "展望报告";
+    private static final String ACHIEVEMENT_REPORT_NAME = "成果报告";
 
     private final ExamSprintReportRepository repository;
     private final ExamSprintReportGenerationDispatcher dispatcher;
@@ -386,22 +395,77 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
 
     private void validateOutlookPayload(JsonNode payload) {
         validateOutlookStudentNameFields(payload);
-        OutlookExamSprintReportPayload reportPayload = readPayload(outlookPayloadForValidation(payload), OutlookExamSprintReportPayload.class);
-        validatePayload(reportPayload);
+        validateOutlookPayloadShape(payload);
+        OutlookExamSprintReportPayload reportPayload = readPayload(
+                outlookPayloadForValidation(payload),
+                OutlookExamSprintReportPayload.class,
+                OUTLOOK_REPORT_NAME);
+        validatePayload(reportPayload, OUTLOOK_REPORT_NAME, this::toOutlookFieldPath);
     }
 
     private void validateOutlookStudentNameFields(JsonNode payload) {
-        requireObjectPayload(payload);
+        requireObjectPayload(payload, OUTLOOK_REPORT_NAME);
         if (resolveOutlookStudentName(payload).isPresent()) {
             return;
         }
-        if (hasNonNullNonTextualField(payload, "StudentName") || hasNonNullNonTextualField(payload, "studentName")) {
-            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        if (hasNonNullNonTextualField(payload, "StudentName")) {
+            throw validationException(OUTLOOK_REPORT_NAME, "字段 StudentName 必须为非空字符串");
+        }
+        if (hasNonNullNonTextualField(payload, "studentName")) {
+            throw validationException(OUTLOOK_REPORT_NAME, "字段 studentName 必须为非空字符串");
+        }
+    }
+
+    private void validateOutlookPayloadShape(JsonNode payload) {
+        requireObjectPayload(payload, OUTLOOK_REPORT_NAME);
+        requireOutlookIntegralField(payload, "StudentStage");
+        requireOutlookTextualField(payload, "StageName");
+        requireOutlookIntegralField(payload, "StageVocabulary");
+        requireOutlookTextualField(payload, "StageExaminName");
+        requireOutlookIntegralField(payload, "StageImportant");
+        requireOutlookArrayField(payload, "StudentWordsLatest");
+        requireOutlookIntegralField(payload, "MastedWordCount");
+        requireOutlookIntegralField(payload, "UnMastedWordCount");
+        requireOutlookIntegralField(payload, "ExamineStrangeWordCount");
+        requireOutlookArrayField(payload, "TestPaperWordIdArray");
+        requireOutlookTextualField(payload, "TestPaperTitle");
+        requireOutlookArrayField(payload, "TestPaperUnMasterWords");
+        requireOutlookArrayField(payload, "TestPaperMastedWords");
+        requireOutlookIntegralField(payload, "TestPaperMastedWordCount");
+        requireOutlookIntegralField(payload, "TestPaperWordCount");
+        requireOutlookBooleanField(payload, "Complex");
+    }
+
+    private void requireOutlookTextualField(JsonNode objectNode, String fieldPath) {
+        JsonNode field = objectNode.get(leafFieldName(fieldPath));
+        if (field == null || !field.isTextual()) {
+            throw validationException(OUTLOOK_REPORT_NAME, "字段 " + fieldPath + " 必须为字符串");
+        }
+    }
+
+    private void requireOutlookIntegralField(JsonNode objectNode, String fieldPath) {
+        JsonNode field = objectNode.get(leafFieldName(fieldPath));
+        if (field == null || !field.isIntegralNumber()) {
+            throw validationException(OUTLOOK_REPORT_NAME, "字段 " + fieldPath + " 必须为数字");
+        }
+    }
+
+    private void requireOutlookArrayField(JsonNode objectNode, String fieldPath) {
+        JsonNode field = objectNode.get(leafFieldName(fieldPath));
+        if (field == null || !field.isArray()) {
+            throw validationException(OUTLOOK_REPORT_NAME, "字段 " + fieldPath + " 必须为数组");
+        }
+    }
+
+    private void requireOutlookBooleanField(JsonNode objectNode, String fieldPath) {
+        JsonNode field = objectNode.get(leafFieldName(fieldPath));
+        if (field == null || !field.isBoolean()) {
+            throw validationException(OUTLOOK_REPORT_NAME, "字段 " + fieldPath + " 必须为布尔值");
         }
     }
 
     private JsonNode normalizeOutlookPayload(JsonNode payload) {
-        requireObjectPayload(payload);
+        requireObjectPayload(payload, OUTLOOK_REPORT_NAME);
         ObjectNode normalizedPayload = ((ObjectNode) payload).deepCopy();
         resolveOutlookStudentName(normalizedPayload).ifPresent(studentName -> {
             normalizedPayload.put("StudentName", studentName);
@@ -432,7 +496,7 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     }
 
     private JsonNode outlookPayloadForValidation(JsonNode payload) {
-        requireObjectPayload(payload);
+        requireObjectPayload(payload, OUTLOOK_REPORT_NAME);
         if (!payload.has("StudentName") || !payload.has("studentName")) {
             return payload;
         }
@@ -444,108 +508,206 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
         return validationPayload;
     }
 
-    private <T> void validatePayload(T reportPayload) {
+    private <T> void validatePayload(T reportPayload, String reportName, java.util.function.Function<String, String> fieldPathMapper) {
         Set<ConstraintViolation<T>> violations = validator.validate(reportPayload);
         if (!violations.isEmpty()) {
-            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+            String detail = violations.stream()
+                    .sorted(Comparator.comparing(violation -> violation.getPropertyPath().toString()))
+                    .map(violation -> "字段 " + fieldPathMapper.apply(violation.getPropertyPath().toString()) + " "
+                            + violationDescription(violation))
+                    .findFirst()
+                    .orElse("请求参数不满足约束");
+            throw validationException(reportName, detail);
         }
     }
 
     private AchievementReportContent validateAchievementPayload(JsonNode payload) {
         validateAchievementPayloadShape(payload);
-        AchievementExamSprintReportPayload reportPayload = readPayload(payload, AchievementExamSprintReportPayload.class);
+        AchievementExamSprintReportPayload reportPayload = readPayload(
+                payload,
+                AchievementExamSprintReportPayload.class,
+                ACHIEVEMENT_REPORT_NAME);
 
         Set<ConstraintViolation<AchievementExamSprintReportPayload>> violations = validator.validate(reportPayload);
         if (!violations.isEmpty()) {
-            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+            String detail = violations.stream()
+                    .sorted(Comparator.comparing(violation -> violation.getPropertyPath().toString()))
+                    .map(violation -> "字段 " + violation.getPropertyPath() + " " + violationDescription(violation))
+                    .findFirst()
+                    .orElse("请求参数不满足约束");
+            throw validationException(ACHIEVEMENT_REPORT_NAME, detail);
         }
         return AchievementReportContentMapper.toDomainContent(reportPayload);
     }
 
     private void validateAchievementPayloadShape(JsonNode payload) {
-        requireObjectPayload(payload);
+        requireObjectPayload(payload, ACHIEVEMENT_REPORT_NAME);
         requireTextualField(payload, "reportTitle");
         requireTextualField(payload, "reportSubtitle");
         requireTextualField(payload, "completionTitle");
         requireTextualField(payload, "completionSubtitle");
 
         JsonNode summaryMetrics = requireObjectField(payload, "summaryMetrics");
-        requireTextualField(summaryMetrics, "vocabularyGrowthText");
-        requireTextualField(summaryMetrics, "paperKnownWordsGrowthText");
-        requireTextualField(summaryMetrics, "unknownWordHitRateText");
-        requireTextualField(summaryMetrics, "learningEfficiencyText");
+        requireTextualField(summaryMetrics, "summaryMetrics.vocabularyGrowthText");
+        requireTextualField(summaryMetrics, "summaryMetrics.paperKnownWordsGrowthText");
+        requireTextualField(summaryMetrics, "summaryMetrics.unknownWordHitRateText");
+        requireTextualField(summaryMetrics, "summaryMetrics.learningEfficiencyText");
 
-        validateComparisonShape(requireObjectField(payload, "vocabularyComparison"));
-        validateComparisonShape(requireObjectField(payload, "paperKnownWordsComparison"));
+        validateComparisonShape(requireObjectField(payload, "vocabularyComparison"), "vocabularyComparison");
+        validateComparisonShape(requireObjectField(payload, "paperKnownWordsComparison"), "paperKnownWordsComparison");
 
         JsonNode examUnknownWordsHitStatus = requireObjectField(payload, "examUnknownWordsHitStatus");
-        requireTextualField(examUnknownWordsHitStatus, "unknownWordHitRateText");
-        requireTextualField(examUnknownWordsHitStatus, "learningEfficiencyText");
-        requireTextualField(examUnknownWordsHitStatus, "unknownWordsBeforeText");
-        requireTextualField(examUnknownWordsHitStatus, "unknownWordsAfterText");
-        requireTextualField(examUnknownWordsHitStatus, "reducedUnknownWordsText");
-        requireTextualArrayField(examUnknownWordsHitStatus, "hitWords");
+        requireTextualField(examUnknownWordsHitStatus, "examUnknownWordsHitStatus.unknownWordHitRateText");
+        requireTextualField(examUnknownWordsHitStatus, "examUnknownWordsHitStatus.learningEfficiencyText");
+        requireTextualField(examUnknownWordsHitStatus, "examUnknownWordsHitStatus.unknownWordsBeforeText");
+        requireTextualField(examUnknownWordsHitStatus, "examUnknownWordsHitStatus.unknownWordsAfterText");
+        requireTextualField(examUnknownWordsHitStatus, "examUnknownWordsHitStatus.reducedUnknownWordsText");
+        requireTextualArrayField(examUnknownWordsHitStatus, "examUnknownWordsHitStatus.hitWords");
     }
 
-    private void validateComparisonShape(JsonNode comparison) {
-        requireNumericField(comparison, "beforeValue");
-        requireNumericField(comparison, "afterValue");
-        requireTextualField(comparison, "beforeText");
-        requireTextualField(comparison, "afterText");
-        requireTextualField(comparison, "growthText");
+    private void validateComparisonShape(JsonNode comparison, String fieldPathPrefix) {
+        requireNumericField(comparison, fieldPathPrefix + ".beforeValue");
+        requireNumericField(comparison, fieldPathPrefix + ".afterValue");
+        requireTextualField(comparison, fieldPathPrefix + ".beforeText");
+        requireTextualField(comparison, fieldPathPrefix + ".afterText");
+        requireTextualField(comparison, fieldPathPrefix + ".growthText");
     }
 
-    private void requireTextualField(JsonNode objectNode, String fieldName) {
-        JsonNode field = objectNode.get(fieldName);
+    private void requireTextualField(JsonNode objectNode, String fieldPath) {
+        JsonNode field = objectNode.get(leafFieldName(fieldPath));
         if (field == null || !field.isTextual()) {
-            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+            throw validationException(ACHIEVEMENT_REPORT_NAME, "字段 " + fieldPath + " 必须为字符串");
         }
     }
 
-    private void requireNumericField(JsonNode objectNode, String fieldName) {
-        JsonNode field = objectNode.get(fieldName);
+    private void requireNumericField(JsonNode objectNode, String fieldPath) {
+        JsonNode field = objectNode.get(leafFieldName(fieldPath));
         if (field == null || !field.isNumber()) {
-            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+            throw validationException(ACHIEVEMENT_REPORT_NAME, "字段 " + fieldPath + " 必须为数字");
         }
     }
 
     private JsonNode requireObjectField(JsonNode objectNode, String fieldName) {
         JsonNode field = objectNode.get(fieldName);
         if (field == null || !field.isObject()) {
-            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+            throw validationException(ACHIEVEMENT_REPORT_NAME, "字段 " + fieldName + " 必须为对象");
         }
         return field;
     }
 
-    private void requireTextualArrayField(JsonNode objectNode, String fieldName) {
-        JsonNode field = objectNode.get(fieldName);
+    private void requireTextualArrayField(JsonNode objectNode, String fieldPath) {
+        JsonNode field = objectNode.get(leafFieldName(fieldPath));
         if (field == null || !field.isArray()) {
-            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+            throw validationException(ACHIEVEMENT_REPORT_NAME, "字段 " + fieldPath + " 必须为字符串数组");
         }
+        int index = 0;
         for (JsonNode element : field) {
             if (!element.isTextual()) {
-                throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+                throw validationException(ACHIEVEMENT_REPORT_NAME, "字段 " + fieldPath + "[" + index + "] 必须为字符串");
             }
+            index++;
         }
     }
 
-    private <T> T readPayload(JsonNode payload, Class<T> payloadType) {
-        requireObjectPayload(payload);
+    private <T> T readPayload(JsonNode payload, Class<T> payloadType, String reportName) {
+        requireObjectPayload(payload, reportName);
 
         try {
             T reportPayload = objectMapper.treeToValue(payload, payloadType);
             if (reportPayload == null) {
-                throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+                throw validationException(reportName, "payload 不能为空");
             }
             return reportPayload;
         } catch (JsonProcessingException | IllegalArgumentException exception) {
-            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+            throw validationException(reportName, "payload 无法解析为报告请求结构,请检查字段类型和结构");
         }
     }
 
-    private void requireObjectPayload(JsonNode payload) {
+    private void requireObjectPayload(JsonNode payload, String reportName) {
         if (payload == null || payload.isNull() || !payload.isObject()) {
-            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+            throw validationException(reportName, "payload 必须为 JSON 对象");
         }
     }
+
+    private String leafFieldName(String fieldPath) {
+        int separatorIndex = fieldPath.lastIndexOf('.');
+        return separatorIndex < 0 ? fieldPath : fieldPath.substring(separatorIndex + 1);
+    }
+
+    private BusinessException validationException(String reportName, String detail) {
+        return new BusinessException(ErrorCode.VALIDATION_ERROR, reportName + "参数校验失败:" + detail);
+    }
+
+    private String violationDescription(ConstraintViolation<?> violation) {
+        Class<?> annotationType = violation.getConstraintDescriptor().getAnnotation().annotationType();
+        if (annotationType == NotBlank.class) {
+            return "必须为非空字符串";
+        }
+        if (annotationType == NotNull.class) {
+            return "不能为空";
+        }
+        if (annotationType == NotEmpty.class) {
+            return "不能为空数组或集合";
+        }
+        if (annotationType == Min.class) {
+            return "必须大于等于 " + violation.getConstraintDescriptor().getAttributes().get("value");
+        }
+        if (annotationType == DecimalMin.class) {
+            return "必须大于等于 " + violation.getConstraintDescriptor().getAttributes().get("value");
+        }
+        if (annotationType == DecimalMax.class) {
+            return "必须小于等于 " + violation.getConstraintDescriptor().getAttributes().get("value");
+        }
+        return "不满足约束";
+    }
+
+    private String toOutlookFieldPath(String propertyPath) {
+        StringBuilder mappedPath = new StringBuilder();
+        for (String segment : propertyPath.split("\\.")) {
+            if (segment.startsWith("<") && segment.endsWith(">")) {
+                continue;
+            }
+            if (!mappedPath.isEmpty()) {
+                mappedPath.append('.');
+            }
+            mappedPath.append(toOutlookFieldPathSegment(segment));
+        }
+        return mappedPath.toString();
+    }
+
+    private String toOutlookFieldPathSegment(String propertyPathSegment) {
+        int indexedPathStart = propertyPathSegment.indexOf('[');
+        String propertyName = indexedPathStart < 0
+                ? propertyPathSegment
+                : propertyPathSegment.substring(0, indexedPathStart);
+        String indexSuffix = indexedPathStart < 0 ? "" : propertyPathSegment.substring(indexedPathStart);
+        return switch (propertyName) {
+            case "studentName" -> "StudentName";
+            case "studentStage" -> "StudentStage";
+            case "stageName" -> "StageName";
+            case "stageVocabulary" -> "StageVocabulary";
+            case "stageExaminName" -> "StageExaminName";
+            case "stageImportant" -> "StageImportant";
+            case "studentWordsLatest" -> "StudentWordsLatest";
+            case "mastedWordCount" -> "MastedWordCount";
+            case "unMastedWordCount" -> "UnMastedWordCount";
+            case "examineStrangeWordCount" -> "ExamineStrangeWordCount";
+            case "testPaperWordIdArray" -> "TestPaperWordIdArray";
+            case "testPaperTitle" -> "TestPaperTitle";
+            case "testPaperUnMasterWords" -> "TestPaperUnMasterWords";
+            case "testPaperMastedWords" -> "TestPaperMastedWords";
+            case "testPaperMastedWordCount" -> "TestPaperMastedWordCount";
+            case "testPaperWordCount" -> "TestPaperWordCount";
+            case "complex" -> "Complex";
+            case "wordId" -> "WordId";
+            case "meanId" -> "MeanId";
+            case "wordSpell" -> "WordSpell";
+            case "wordFrequency" -> "WordFrequency";
+            case "mastery" -> "Mastery";
+            case "reviewTimes" -> "ReviewTimes";
+            case "reliability" -> "Reliability";
+            case "createTime" -> "CreateTime";
+            default -> propertyName;
+        } + indexSuffix;
+    }
 }

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

@@ -178,6 +178,71 @@ class ExamSprintReportApplicationServiceTest {
         assertCreateOutlookReportRejectsInvalidPayload(nonContractOutlookPayload());
     }
 
+    /** 覆盖展望报告参数诊断场景,当 StudentName 缺失时,应抛出包含报告类型和字段名的中文校验消息。 */
+    @Test
+    void createOutlookReportRejectsInvalidPayloadWithChineseFieldMessage() throws Exception {
+        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
+        invalidPayload.remove("StudentName");
+
+        assertThatThrownBy(() -> service(new TestRepository(), reportId -> { }, new TestStorage())
+                        .createOutlookReport(invalidPayload))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("展望报告参数校验失败")
+                .hasMessageContaining("StudentName")
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.VALIDATION_ERROR);
+    }
+
+    /** 覆盖展望报告 JSON shape 校验场景,当 StudentStage 类型错误时,应返回字段级中文类型说明。 */
+    @Test
+    void createOutlookReportRejectsStudentStageTypeMismatchWithChineseFieldMessage() throws Exception {
+        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
+        invalidPayload.put("StudentStage", "2");
+
+        assertThatThrownBy(() -> service(new TestRepository(), reportId -> { }, new TestStorage())
+                        .createOutlookReport(invalidPayload))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("展望报告参数校验失败")
+                .hasMessageContaining("StudentStage")
+                .hasMessageContaining("必须为数字")
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.VALIDATION_ERROR);
+    }
+
+    /** 覆盖展望报告嵌套列表 Bean Validation 路径映射场景,应暴露请求 JSON 字段名并保留索引。 */
+    @Test
+    void createOutlookReportRejectsNestedStudentWordsLatestViolationWithApiFieldPath() throws Exception {
+        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
+        ((ObjectNode) invalidPayload.withArray("StudentWordsLatest").get(0)).put("WordFrequency", 0);
+
+        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);
+    }
+
+    /** 覆盖展望报告直接列表元素约束路径映射场景,应隐藏 Bean Validation 内部 list element 片段。 */
+    @Test
+    void createOutlookReportRejectsDirectListElementViolationWithCleanApiFieldPath() throws Exception {
+        ObjectNode invalidPayload = callerVocabularyPayload().deepCopy();
+        invalidPayload.withArray("TestPaperWordIdArray").removeAll();
+        invalidPayload.withArray("TestPaperWordIdArray").add(-1);
+
+        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);
+    }
+
     /** 覆盖上游词汇报文允许无已掌握真题词的场景,当 TestPaperMastedWords 为空且计数为 0 时,应正常创建并分发。 */
     @Test
     void createOutlookReportAcceptsCallerVocabularyPayloadWithNoMasteredPaperWords() throws Exception {
@@ -413,6 +478,21 @@ class ExamSprintReportApplicationServiceTest {
         assertCreateAchievementReportRejectsInvalidPayload(invalidPayload);
     }
 
+    /** 覆盖成果报告参数诊断场景,当嵌套字段类型错误时,应抛出包含报告类型和字段路径的中文校验消息。 */
+    @Test
+    void createAchievementReportRejectsInvalidPayloadWithChineseFieldMessage() {
+        ObjectNode invalidPayload = validAchievementPayload().deepCopy();
+        invalidPayload.withObject("summaryMetrics").put("vocabularyGrowthText", 19);
+
+        assertThatThrownBy(() -> service(new TestRepository(), reportId -> { }, new TestStorage())
+                        .createAchievementReport(invalidPayload))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("成果报告参数校验失败")
+                .hasMessageContaining("summaryMetrics.vocabularyGrowthText")
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.VALIDATION_ERROR);
+    }
+
     /** 覆盖创建报告 payload 类型校验场景,当 payload 为 null 或非对象时,应在保存前拒绝展望和成果报告。 */
     @Test
     void createReportsRejectNullOrNonObjectPayloadBeforeSaving() {

+ 4 - 0
ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BaseResponse.java

@@ -9,4 +9,8 @@ public record BaseResponse<T>(String code, String message, T data) {
     public static BaseResponse<Void> failure(ErrorCode errorCode) {
         return new BaseResponse<>(errorCode.getCode(), errorCode.getMessage(), null);
     }
+
+    public static BaseResponse<Void> failure(ErrorCode errorCode, String message) {
+        return new BaseResponse<>(errorCode.getCode(), message, null);
+    }
 }

+ 5 - 0
ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/BusinessException.java

@@ -9,6 +9,11 @@ public class BusinessException extends RuntimeException {
         this.errorCode = errorCode;
     }
 
+    public BusinessException(ErrorCode errorCode, String message) {
+        super(message);
+        this.errorCode = errorCode;
+    }
+
     public ErrorCode getErrorCode() {
         return errorCode;
     }

+ 4 - 2
ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/GlobalExceptionHandler.java

@@ -25,7 +25,7 @@ public class GlobalExceptionHandler {
     public ResponseEntity<BaseResponse<Void>> handleBusinessException(BusinessException exception) {
         return ResponseEntity
                 .status(HttpStatusCode.valueOf(exception.getErrorCode().getHttpStatusCode()))
-                .body(BaseResponse.failure(exception.getErrorCode()));
+                .body(BaseResponse.failure(exception.getErrorCode(), exception.getMessage()));
     }
 
     @ExceptionHandler({
@@ -36,7 +36,9 @@ public class GlobalExceptionHandler {
     public ResponseEntity<BaseResponse<Void>> handleValidationException(Exception exception) {
         return ResponseEntity
                 .status(HttpStatusCode.valueOf(ErrorCode.VALIDATION_ERROR.getHttpStatusCode()))
-                .body(BaseResponse.failure(ErrorCode.VALIDATION_ERROR));
+                .body(BaseResponse.failure(
+                        ErrorCode.VALIDATION_ERROR,
+                        "请求体不是合法 JSON,或请求参数格式不正确"));
     }
 
     @ExceptionHandler({NoHandlerFoundException.class, NoResourceFoundException.class})

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

@@ -28,6 +28,7 @@ import java.time.Instant;
 
 import org.mockito.ArgumentCaptor;
 
+import static org.hamcrest.Matchers.containsString;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.ArgumentMatchers.any;
@@ -176,7 +177,21 @@ class ExamSprintReportControllerWebMvcTest {
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("{\"StudentName\":]"))
                 .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
+                .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
+                .andExpect(jsonPath("$.message").value(containsString("请求体不是合法 JSON")));
+
+        verifyNoInteractions(applicationService);
+    }
+
+    // WHY: Achievement malformed JSON is also rejected by Spring before application service validation can add report-specific details.
+    @Test
+    void createAchievementReportReturnsValidationErrorWhenJsonIsMalformed() throws Exception {
+        mockMvc.perform(post("/api/exam-sprint/achievement-reports")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"reportTitle\":]"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
+                .andExpect(jsonPath("$.message").value(containsString("请求体不是合法 JSON")));
 
         verifyNoInteractions(applicationService);
     }
@@ -220,6 +235,23 @@ class ExamSprintReportControllerWebMvcTest {
                 .andExpect(jsonPath("$.code").value("REPORT_NOT_FOUND"));
     }
 
+    // WHY: Controller advice must expose a BusinessException custom Chinese message instead of the ErrorCode default English text.
+    @Test
+    void createOutlookReportUsesBusinessExceptionCustomMessageInResponse() throws Exception {
+        given(applicationService.createOutlookReport(any()))
+                .willThrow(new BusinessException(
+                        ErrorCode.VALIDATION_ERROR,
+                        "展望报告参数校验失败:StudentName 必须为非空字符串"));
+
+        mockMvc.perform(post("/api/exam-sprint/outlook-reports")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"StudentName\":123}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+                .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
+                .andExpect(jsonPath("$.message").value("展望报告参数校验失败:StudentName 必须为非空字符串"));
+    }
+
     private String requestJson(String resourcePath) throws Exception {
         try (InputStream inputStream = ExamSprintReportControllerWebMvcTest.class.getClassLoader().getResourceAsStream(resourcePath)) {
             if (inputStream == null) {