|
|
@@ -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;
|
|
|
+ }
|
|
|
}
|