|
|
@@ -21,6 +21,12 @@ 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;
|
|
|
@@ -28,6 +34,7 @@ import org.springframework.stereotype.Service;
|
|
|
import java.math.BigDecimal;
|
|
|
import java.time.Clock;
|
|
|
import java.time.Instant;
|
|
|
+import java.util.Comparator;
|
|
|
import java.util.List;
|
|
|
import java.util.Optional;
|
|
|
import java.util.Set;
|
|
|
@@ -43,6 +50,8 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
|
|
|
private static final BigDecimal ACHIEVEMENT_MAX_NUMERIC_ABS = new BigDecimal("1000000000");
|
|
|
private static final int ACHIEVEMENT_MAX_NUMERIC_PRECISION = 12;
|
|
|
private static final int ACHIEVEMENT_MAX_NUMERIC_SCALE = 6;
|
|
|
+ private static final String OUTLOOK_REPORT_NAME = "展望报告";
|
|
|
+ private static final String ACHIEVEMENT_REPORT_NAME = "成果报告";
|
|
|
|
|
|
private final ExamSprintReportRepository repository;
|
|
|
private final ExamSprintReportGenerationDispatcher dispatcher;
|
|
|
@@ -390,22 +399,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);
|
|
|
@@ -436,7 +500,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;
|
|
|
}
|
|
|
@@ -448,26 +512,40 @@ 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, "StudentName");
|
|
|
requireTextualField(payload, "StageName");
|
|
|
requireTextualField(payload, "TestPaperTitle");
|
|
|
@@ -498,10 +576,10 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
|
|
|
requireOptionalTextualField(payload, "SigningGuarantee");
|
|
|
}
|
|
|
|
|
|
- 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 + " 必须为字符串");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -509,29 +587,29 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
|
|
|
requireNumericField(objectNode, fieldName, false);
|
|
|
}
|
|
|
|
|
|
- private void requireNumericField(JsonNode objectNode, String fieldName, boolean allowNegative) {
|
|
|
- JsonNode field = objectNode.get(fieldName);
|
|
|
+ private void requireNumericField(JsonNode objectNode, String fieldPath, boolean allowNegative) {
|
|
|
+ JsonNode field = objectNode.get(leafFieldName(fieldPath));
|
|
|
if (field == null || !field.isNumber()) {
|
|
|
- throw new BusinessException(ErrorCode.VALIDATION_ERROR);
|
|
|
+ throw validationException(ACHIEVEMENT_REPORT_NAME, "字段 " + fieldPath + " 必须为数字");
|
|
|
}
|
|
|
BigDecimal value;
|
|
|
try {
|
|
|
value = field.decimalValue();
|
|
|
} catch (RuntimeException exception) {
|
|
|
- throw new BusinessException(ErrorCode.VALIDATION_ERROR);
|
|
|
+ throw validationException(ACHIEVEMENT_REPORT_NAME, "字段 " + fieldPath + " 必须为合法数字");
|
|
|
}
|
|
|
if ((!allowNegative && value.signum() < 0)
|
|
|
|| value.precision() > ACHIEVEMENT_MAX_NUMERIC_PRECISION
|
|
|
|| value.scale() > ACHIEVEMENT_MAX_NUMERIC_SCALE
|
|
|
|| value.abs().compareTo(ACHIEVEMENT_MAX_NUMERIC_ABS) > 0) {
|
|
|
- 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;
|
|
|
}
|
|
|
@@ -539,61 +617,148 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
|
|
|
private void requireOptionalBooleanField(JsonNode objectNode, String fieldName) {
|
|
|
JsonNode field = objectNode.get(fieldName);
|
|
|
if (field != null && !field.isNull() && !field.isBoolean()) {
|
|
|
- throw new BusinessException(ErrorCode.VALIDATION_ERROR);
|
|
|
+ throw validationException(ACHIEVEMENT_REPORT_NAME, "字段 " + fieldName + " 必须为布尔值");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private void requireOptionalTextualField(JsonNode objectNode, String fieldName) {
|
|
|
JsonNode field = objectNode.get(fieldName);
|
|
|
if (field != null && !field.isNull() && !field.isTextual()) {
|
|
|
- throw new BusinessException(ErrorCode.VALIDATION_ERROR);
|
|
|
+ throw validationException(ACHIEVEMENT_REPORT_NAME, "字段 " + fieldName + " 必须为字符串");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private void requireImprovedWordsArrayField(JsonNode objectNode, String fieldName) {
|
|
|
JsonNode field = objectNode.get(fieldName);
|
|
|
if (field == null || !field.isArray()) {
|
|
|
- throw new BusinessException(ErrorCode.VALIDATION_ERROR);
|
|
|
+ throw validationException(ACHIEVEMENT_REPORT_NAME, "字段 " + fieldName + " 必须为数组");
|
|
|
}
|
|
|
+ int index = 0;
|
|
|
for (JsonNode element : field) {
|
|
|
if (element.isTextual()) {
|
|
|
+ index++;
|
|
|
continue;
|
|
|
}
|
|
|
if (!element.isObject() || !element.path("WordSpell").isTextual()) {
|
|
|
- throw new BusinessException(ErrorCode.VALIDATION_ERROR);
|
|
|
+ throw validationException(ACHIEVEMENT_REPORT_NAME, "字段 " + fieldName + "[" + index + "] 必须为字符串或包含 WordSpell 的对象");
|
|
|
}
|
|
|
+ index++;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- 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;
|
|
|
}
|
|
|
}
|