options = new ArrayList<>();
+ for (QuestionDO question : questions) {
+ question.setCreateTime(now);
+ question.setUpdateTime(now);
+ question.setVersion(0);
+ options.addAll(question.getOptions());
+ }
+ for (QuestionOptionDO option : options) {
+ option.setCreateTime(now);
+ option.setUpdateTime(now);
+ option.setVersion(0);
+ }
+ Db.saveBatch(questions);
+ Db.saveBatch(options);
+
+ // 绑定试卷试题
+ this.bindQuestions(paper.getId(), questions.stream()
+ .map(QuestionDO::getId)
+ .collect(Collectors.toList()));
+
+ return new PaperImportVO(questions.size(), parseResult.getErrors());
+ }
}
diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/service/support/ChoiceQuestionImportRow.java b/safety-cedu-service/src/main/java/org/qinan/cedu/service/support/ChoiceQuestionImportRow.java
new file mode 100644
index 00000000..acc4369d
--- /dev/null
+++ b/safety-cedu-service/src/main/java/org/qinan/cedu/service/support/ChoiceQuestionImportRow.java
@@ -0,0 +1,66 @@
+package org.qinan.cedu.service.support;
+
+import com.alibaba.excel.annotation.ExcelIgnore;
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.DecimalMax;
+import javax.validation.constraints.DecimalMin;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+import java.math.BigDecimal;
+
+/**
+ * 单选题/多选题导入行模型(第一个sheet单选题、第二个sheet多选题,列结构一致)
+ */
+@Data
+public class ChoiceQuestionImportRow {
+
+ @ExcelProperty(value = "题目", index = 0)
+ @NotBlank(message = "题目不能为空")
+ private String title;
+
+ @ExcelProperty(value = "选项A", index = 1)
+ private String optionA;
+
+ @ExcelProperty(value = "选项B", index = 2)
+ private String optionB;
+
+ @ExcelProperty(value = "选项C", index = 3)
+ private String optionC;
+
+ @ExcelProperty(value = "选项D", index = 4)
+ private String optionD;
+
+ @ExcelProperty(value = "答案", index = 5)
+ @NotBlank(message = "答案不能为空")
+ @Size(max = 100, message = "答案长度不能超过100个字符")
+ private String answer;
+
+ @ExcelProperty(value = "分值", index = 6)
+ @NotNull(message = "分值不能为空")
+ @DecimalMin(value = "0.01", message = "分值必须大于0")
+ @DecimalMax(value = "999.99", message = "分值不能超过999.99")
+ private BigDecimal score;
+
+ @ExcelProperty(value = "答案解析", index = 7)
+ @NotBlank(message = "答案解析不能为空")
+ private String analysis;
+
+ @ExcelProperty(value = "标签类型", index = 8)
+ @NotBlank(message = "标签类型不能为空")
+ @Size(max = 100, message = "标签类型长度不能超过100个字符")
+ private String tagType;
+
+ @ExcelProperty(value = "关联课件名称", index = 9)
+ @NotBlank(message = "关联课件名称不能为空")
+ @Size(max = 200, message = "关联课件名称长度不能超过200个字符")
+ private String coursewareName;
+
+ /**
+ * 物理行号(从1开始),由导入器填充
+ */
+ @ExcelIgnore
+ private Integer rowIndex;
+}
diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/service/support/JudgeQuestionImportRow.java b/safety-cedu-service/src/main/java/org/qinan/cedu/service/support/JudgeQuestionImportRow.java
new file mode 100644
index 00000000..afa8a3e6
--- /dev/null
+++ b/safety-cedu-service/src/main/java/org/qinan/cedu/service/support/JudgeQuestionImportRow.java
@@ -0,0 +1,54 @@
+package org.qinan.cedu.service.support;
+
+import com.alibaba.excel.annotation.ExcelIgnore;
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.DecimalMax;
+import javax.validation.constraints.DecimalMin;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+import java.math.BigDecimal;
+
+/**
+ * 判断题导入行模型(第三个sheet)
+ */
+@Data
+public class JudgeQuestionImportRow {
+
+ @ExcelProperty(value = "题目", index = 0)
+ @NotBlank(message = "题目不能为空")
+ private String title;
+
+ @ExcelProperty(value = "判断答案", index = 1)
+ @NotBlank(message = "答案不能为空")
+ @Size(max = 100, message = "答案长度不能超过100个字符")
+ private String answer;
+
+ @ExcelProperty(value = "分值", index = 2)
+ @NotNull(message = "分值不能为空")
+ @DecimalMin(value = "0.01", message = "分值必须大于0")
+ @DecimalMax(value = "999.99", message = "分值不能超过999.99")
+ private BigDecimal score;
+
+ @ExcelProperty(value = "答案解析", index = 3)
+ @NotBlank(message = "答案解析不能为空")
+ private String analysis;
+
+ @ExcelProperty(value = "标签类型", index = 4)
+ @NotBlank(message = "标签类型不能为空")
+ @Size(max = 100, message = "标签类型长度不能超过100个字符")
+ private String tagType;
+
+ @ExcelProperty(value = "关联课件名称", index = 5)
+ @NotBlank(message = "关联课件名称不能为空")
+ @Size(max = 200, message = "关联课件名称长度不能超过200个字符")
+ private String coursewareName;
+
+ /**
+ * 物理行号(从1开始),由导入器填充
+ */
+ @ExcelIgnore
+ private Integer rowIndex;
+}
diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/service/support/QuestionExcelImporter.java b/safety-cedu-service/src/main/java/org/qinan/cedu/service/support/QuestionExcelImporter.java
new file mode 100644
index 00000000..0c4d33b3
--- /dev/null
+++ b/safety-cedu-service/src/main/java/org/qinan/cedu/service/support/QuestionExcelImporter.java
@@ -0,0 +1,385 @@
+package org.qinan.cedu.service.support;
+
+import cn.hutool.core.util.IdUtil;
+import com.alibaba.excel.EasyExcel;
+import com.alibaba.excel.ExcelReader;
+import com.alibaba.excel.read.metadata.ReadSheet;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.qinan.cedu.enums.DeleteEnum;
+import org.qinan.cedu.enums.QuestionTypeEnum;
+import org.qinan.cedu.model.entity.CoursewareManagementDO;
+import org.qinan.cedu.model.entity.QuestionDO;
+import org.qinan.cedu.model.entity.QuestionOptionDO;
+import org.qinan.cedu.model.vo.PaperImportErrorVO;
+import org.qinan.cedu.service.CoursewareManagementService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.validation.ConstraintViolation;
+import javax.validation.Validator;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 试题Excel导入解析器
+ *
+ * 模板说明:前2行为填写说明和表头,数据从第3行开始;
+ * 第一个sheet单选题、第二个sheet多选题、第三个sheet判断题(T/F 转换为 A对/B错 两个选项),
+ * 其余sheet忽略。
+ */
+@Component
+public class QuestionExcelImporter {
+
+ /** 模板前2行为说明和表头,数据从第3行开始 */
+ private static final int HEAD_ROW_NUMBER = 2;
+
+ private static final int SINGLE_SHEET_NO = 0;
+ private static final int MULTIPLE_SHEET_NO = 1;
+ private static final int JUDGE_SHEET_NO = 2;
+
+ private static final String[] CHOICE_OPTION_KEYS = {"A", "B", "C", "D"};
+
+ @Autowired
+ private CoursewareManagementService coursewareManagementService;
+
+ @Autowired
+ private Validator validator;
+
+ /**
+ * 解析上传的试题Excel,返回校验通过的试题(含选项,已预置雪花id)和异常明细
+ */
+ public ParseResult parse(MultipartFile file) {
+ if (file == null || file.isEmpty()) {
+ throw new IllegalArgumentException("请上传试题Excel文件");
+ }
+ byte[] bytes = readBytes(file);
+ Map sheetNames = readSheetNames(bytes);
+
+ List errors = new ArrayList<>();
+ List singleRows;
+ List multipleRows;
+ List judgeRows;
+ try {
+ singleRows = readSheet(bytes, SINGLE_SHEET_NO, ChoiceQuestionImportRow.class);
+ } catch (Exception e) {
+ singleRows = new ArrayList<>();
+ errors.add(new PaperImportErrorVO(null, sheetName(sheetNames, SINGLE_SHEET_NO), "读取单选题sheet失败:" + e.getMessage()));
+ }
+ try {
+ multipleRows = readSheet(bytes, MULTIPLE_SHEET_NO, ChoiceQuestionImportRow.class);
+ } catch (Exception e) {
+ multipleRows = new ArrayList<>();
+ errors.add(new PaperImportErrorVO(null, sheetName(sheetNames, MULTIPLE_SHEET_NO), "读取多选题sheet失败:" + e.getMessage()));
+ }
+ try {
+ judgeRows = readSheet(bytes, JUDGE_SHEET_NO, JudgeQuestionImportRow.class);
+ } catch (Exception e) {
+ judgeRows = new ArrayList<>();
+ errors.add(new PaperImportErrorVO(null, sheetName(sheetNames, JUDGE_SHEET_NO), "读取判断题sheet失败:" + e.getMessage()));
+ }
+
+ Map coursewareIdByName = loadCoursewareIdByName(singleRows, multipleRows, judgeRows);
+
+ List questions = new ArrayList<>();
+ convertChoiceRows(singleRows, sheetName(sheetNames, SINGLE_SHEET_NO), QuestionTypeEnum.SINGLE, coursewareIdByName, questions, errors);
+ convertChoiceRows(multipleRows, sheetName(sheetNames, MULTIPLE_SHEET_NO), QuestionTypeEnum.MULTIPLE, coursewareIdByName, questions, errors);
+ convertJudgeRows(judgeRows, sheetName(sheetNames, JUDGE_SHEET_NO), coursewareIdByName, questions, errors);
+ return new ParseResult(questions, errors);
+ }
+
+ private byte[] readBytes(MultipartFile file) {
+ try {
+ return file.getBytes();
+ } catch (IOException e) {
+ throw new IllegalArgumentException("读取上传文件失败", e);
+ }
+ }
+
+ private Map readSheetNames(byte[] bytes) {
+ ExcelReader excelReader = EasyExcel.read(new ByteArrayInputStream(bytes)).build();
+ try {
+ return excelReader.excelExecutor().sheetList().stream()
+ .collect(Collectors.toMap(ReadSheet::getSheetNo, ReadSheet::getSheetName, (a, b) -> a));
+ } catch (Exception e) {
+ return Collections.emptyMap();
+ } finally {
+ excelReader.finish();
+ }
+ }
+
+ private String sheetName(Map sheetNames, int sheetNo) {
+ return sheetNames.getOrDefault(sheetNo, "sheet[" + (sheetNo + 1) + "]");
+ }
+
+ private List readSheet(byte[] bytes, int sheetNo, Class headClass) {
+ return EasyExcel.read(new ByteArrayInputStream(bytes))
+ .head(headClass)
+ .sheet(sheetNo)
+ .headRowNumber(HEAD_ROW_NUMBER)
+ .doReadSync();
+ }
+
+ /**
+ * 批量查询关联课件名称对应的id(名称 -> id)
+ */
+ private Map loadCoursewareIdByName(List singleRows,
+ List multipleRows,
+ List judgeRows) {
+ Set names = new HashSet<>();
+ singleRows.forEach(row -> addCoursewareName(names, row.getCoursewareName()));
+ multipleRows.forEach(row -> addCoursewareName(names, row.getCoursewareName()));
+ judgeRows.forEach(row -> addCoursewareName(names, row.getCoursewareName()));
+ if (names.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ List coursewares = coursewareManagementService.lambdaQuery()
+ .select(CoursewareManagementDO::getId, CoursewareManagementDO::getCoursewareName)
+ .eq(CoursewareManagementDO::getDeleteEnum, DeleteEnum.FALSE.getCode())
+ .in(CoursewareManagementDO::getCoursewareName, names)
+ .list();
+ Map idByName = new HashMap<>();
+ for (CoursewareManagementDO courseware : coursewares) {
+ idByName.putIfAbsent(courseware.getCoursewareName(), courseware.getId());
+ }
+ return idByName;
+ }
+
+ private void addCoursewareName(Set names, String coursewareName) {
+ if (StringUtils.hasText(coursewareName)) {
+ names.add(coursewareName.trim());
+ }
+ }
+
+ /**
+ * 转换单选/多选sheet数据
+ */
+ private void convertChoiceRows(List rows, String sheetName, QuestionTypeEnum questionType,
+ Map coursewareIdByName, List questions,
+ List errors) {
+ boolean single = questionType == QuestionTypeEnum.SINGLE;
+ for (int i = 0; i < rows.size(); i++) {
+ ChoiceQuestionImportRow row = rows.get(i);
+ int rowIndex = i + HEAD_ROW_NUMBER + 1;
+ if (isEmptyChoiceRow(row)) {
+ continue;
+ }
+ row.setRowIndex(rowIndex);
+
+ // JSR-303 校验(必填、数据库长度限制),不通过则跳过该行
+ Set> violations = validator.validate(row);
+ if (!violations.isEmpty()) {
+ errors.add(new PaperImportErrorVO(rowIndex, sheetName, joinViolationMessages(violations)));
+ continue;
+ }
+
+ // 组装选项(按列固定字母,空白列不生成选项)
+ List optionTexts = Arrays.asList(trimToNull(row.getOptionA()), trimToNull(row.getOptionB()),
+ trimToNull(row.getOptionC()), trimToNull(row.getOptionD()));
+ Map options = new LinkedHashMap<>();
+ for (int j = 0; j < optionTexts.size(); j++) {
+ if (optionTexts.get(j) != null) {
+ options.put(CHOICE_OPTION_KEYS[j], optionTexts.get(j));
+ }
+ }
+ if (options.size() < 2) {
+ errors.add(new PaperImportErrorVO(rowIndex, sheetName, "选项至少需要填写2个"));
+ continue;
+ }
+
+ // 校验答案:必须有答案且答案必须能找到对应的选项
+ String answerRaw = row.getAnswer().trim().toUpperCase();
+ if (!answerRaw.chars().allMatch(c -> c >= 'A' && c <= 'F')) {
+ errors.add(new PaperImportErrorVO(rowIndex, sheetName, "答案只能填写A、B、C、D、E、F中的字母"));
+ continue;
+ }
+ List answerLetters = answerRaw.chars()
+ .mapToObj(c -> String.valueOf((char) c))
+ .distinct()
+ .sorted()
+ .collect(Collectors.toList());
+ if (single && answerLetters.size() != 1) {
+ errors.add(new PaperImportErrorVO(rowIndex, sheetName, "单选题答案只能填写A、B、C、D中的一个"));
+ continue;
+ }
+ List notMatched = answerLetters.stream()
+ .filter(letter -> !options.containsKey(letter))
+ .collect(Collectors.toList());
+ if (!notMatched.isEmpty()) {
+ errors.add(new PaperImportErrorVO(rowIndex, sheetName, "答案[" + String.join("", notMatched) + "]找不到对应的选项"));
+ continue;
+ }
+
+ // 校验关联课件名称,查询到后回填coursewareId
+ String coursewareName = row.getCoursewareName().trim();
+ Long coursewareId = coursewareIdByName.get(coursewareName);
+ if (coursewareId == null) {
+ errors.add(new PaperImportErrorVO(rowIndex, sheetName, "关联课件名称[" + coursewareName + "]不存在"));
+ continue;
+ }
+
+ questions.add(buildChoiceQuestion(row, questionType, options, answerLetters, coursewareId));
+ }
+ }
+
+ private QuestionDO buildChoiceQuestion(ChoiceQuestionImportRow row, QuestionTypeEnum questionType,
+ Map optionTexts, List answerLetters,
+ Long coursewareId) {
+ QuestionDO question = new QuestionDO();
+ question.setId(IdUtil.getSnowflakeNextId());
+ question.setTitle(row.getTitle().trim());
+ question.setQuestionType(questionType.getCode());
+ question.setAnswer(String.join(",", answerLetters));
+ question.setScore(row.getScore());
+ question.setAnalysis(row.getAnalysis().trim());
+ question.setTagType(row.getTagType().trim());
+ question.setCoursewareId(coursewareId);
+ question.setOptions(buildOptions(question.getId(), optionTexts, answerLetters));
+ question.setAnswerQuestionOptionIds(joinCorrectOptionIds(question.getOptions()));
+ return question;
+ }
+
+ /**
+ * 转换判断题sheet数据:T/F 转换为 A对、B错 两个选项
+ */
+ private void convertJudgeRows(List rows, String sheetName,
+ Map coursewareIdByName, List questions,
+ List errors) {
+ for (int i = 0; i < rows.size(); i++) {
+ JudgeQuestionImportRow row = rows.get(i);
+ int rowIndex = i + HEAD_ROW_NUMBER + 1;
+ if (isEmptyJudgeRow(row)) {
+ continue;
+ }
+ row.setRowIndex(rowIndex);
+
+ Set> violations = validator.validate(row);
+ if (!violations.isEmpty()) {
+ errors.add(new PaperImportErrorVO(rowIndex, sheetName, joinViolationMessages(violations)));
+ continue;
+ }
+
+ String answerRaw = row.getAnswer().trim().toUpperCase();
+ if (!"T".equals(answerRaw) && !"F".equals(answerRaw)) {
+ errors.add(new PaperImportErrorVO(rowIndex, sheetName, "判断题答案只能填写T或F"));
+ continue;
+ }
+
+ String coursewareName = row.getCoursewareName().trim();
+ Long coursewareId = coursewareIdByName.get(coursewareName);
+ if (coursewareId == null) {
+ errors.add(new PaperImportErrorVO(rowIndex, sheetName, "关联课件名称[" + coursewareName + "]不存在"));
+ continue;
+ }
+
+ boolean correct = "T".equals(answerRaw);
+ QuestionDO question = new QuestionDO();
+ question.setId(IdUtil.getSnowflakeNextId());
+ question.setTitle(row.getTitle().trim());
+ question.setQuestionType(QuestionTypeEnum.JUDGE.getCode());
+ question.setAnswer(correct ? "A" : "B");
+ question.setScore(row.getScore());
+ question.setAnalysis(row.getAnalysis().trim());
+ question.setTagType(row.getTagType().trim());
+ question.setCoursewareId(coursewareId);
+ Map optionTexts = new LinkedHashMap<>();
+ optionTexts.put("A", "对");
+ optionTexts.put("B", "错");
+ question.setOptions(buildOptions(question.getId(), optionTexts,
+ Collections.singletonList(correct ? "A" : "B")));
+ question.setAnswerQuestionOptionIds(joinCorrectOptionIds(question.getOptions()));
+ questions.add(question);
+ }
+ }
+
+ private List buildOptions(Long questionId, Map optionTexts,
+ List answerLetters) {
+ List options = new ArrayList<>();
+ int sortOrder = 1;
+ for (Map.Entry entry : optionTexts.entrySet()) {
+ QuestionOptionDO option = new QuestionOptionDO();
+ option.setId(IdUtil.getSnowflakeNextId());
+ option.setQuestionId(questionId);
+ option.setOptionKey(entry.getKey());
+ option.setOptionName(entry.getKey());
+ option.setOptionText(entry.getValue());
+ option.setIsCorrect(answerLetters.contains(entry.getKey()));
+ option.setSortOrder(sortOrder++);
+ options.add(option);
+ }
+ return options;
+ }
+
+ private String joinCorrectOptionIds(List options) {
+ return options.stream()
+ .filter(option -> Boolean.TRUE.equals(option.getIsCorrect()))
+ .map(option -> String.valueOf(option.getId()))
+ .collect(Collectors.joining(","));
+ }
+
+ private String joinViolationMessages(Set extends ConstraintViolation>> violations) {
+ return violations.stream()
+ .map(ConstraintViolation::getMessage)
+ .distinct()
+ .collect(Collectors.joining(";"));
+ }
+
+ private boolean isEmptyChoiceRow(ChoiceQuestionImportRow row) {
+ return !StringUtils.hasText(row.getTitle())
+ && !StringUtils.hasText(row.getOptionA())
+ && !StringUtils.hasText(row.getOptionB())
+ && !StringUtils.hasText(row.getOptionC())
+ && !StringUtils.hasText(row.getOptionD())
+ && !StringUtils.hasText(row.getAnswer())
+ && row.getScore() == null
+ && !StringUtils.hasText(row.getAnalysis())
+ && !StringUtils.hasText(row.getTagType())
+ && !StringUtils.hasText(row.getCoursewareName());
+ }
+
+ private boolean isEmptyJudgeRow(JudgeQuestionImportRow row) {
+ return !StringUtils.hasText(row.getTitle())
+ && !StringUtils.hasText(row.getAnswer())
+ && row.getScore() == null
+ && !StringUtils.hasText(row.getAnalysis())
+ && !StringUtils.hasText(row.getTagType())
+ && !StringUtils.hasText(row.getCoursewareName());
+ }
+
+ private String trimToNull(String text) {
+ if (!StringUtils.hasText(text)) {
+ return null;
+ }
+ return text.trim();
+ }
+
+ /**
+ * 解析结果
+ */
+ @Getter
+ @AllArgsConstructor
+ public static class ParseResult {
+
+ /**
+ * 校验通过的试题(含选项)
+ */
+ private final List questions;
+
+ /**
+ * 异常明细
+ */
+ private final List errors;
+ }
+}