diff --git a/safety-cedu-service/pom.xml b/safety-cedu-service/pom.xml index f91c0568..a15b4abc 100644 --- a/safety-cedu-service/pom.xml +++ b/safety-cedu-service/pom.xml @@ -37,6 +37,25 @@ swagger-annotations + + + com.alibaba + easyexcel + + + + + cn.hutool + hutool-core + 5.7.19 + + + + + org.springframework.boot + spring-boot-starter-validation + + org.projectlombok diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/controller/PaperController.java b/safety-cedu-service/src/main/java/org/qinan/cedu/controller/PaperController.java index 7029b683..5c7d9f2a 100644 --- a/safety-cedu-service/src/main/java/org/qinan/cedu/controller/PaperController.java +++ b/safety-cedu-service/src/main/java/org/qinan/cedu/controller/PaperController.java @@ -3,9 +3,12 @@ package org.qinan.cedu.controller; import com.baomidou.mybatisplus.core.metadata.IPage; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; +import org.qinan.cedu.model.dto.PaperImportDTO; import org.qinan.cedu.model.entity.PaperDO; +import org.qinan.cedu.model.vo.PaperImportVO; import org.qinan.cedu.service.PaperService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -74,4 +77,10 @@ public class PaperController { public List questionIds(@PathVariable("id") Long paperId) { return paperService.getQuestionIds(paperId); } + + @ApiOperation("新建试卷-导入试题") + @PostMapping("/import") + public PaperImportVO importQuestions(@Validated PaperImportDTO paperImportDTO) { + return paperService.importQuestions(paperImportDTO); + } } diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/model/dto/PaperImportDTO.java b/safety-cedu-service/src/main/java/org/qinan/cedu/model/dto/PaperImportDTO.java new file mode 100644 index 00000000..3a0b868c --- /dev/null +++ b/safety-cedu-service/src/main/java/org/qinan/cedu/model/dto/PaperImportDTO.java @@ -0,0 +1,42 @@ +package org.qinan.cedu.model.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import org.springframework.web.multipart.MultipartFile; + +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; + +/** + * 新建试卷-导入试题 请求对象 + */ +@Data +@ApiModel(value = "PaperImportDTO", description = "新建试卷-导入试题请求") +public class PaperImportDTO { + + @ApiModelProperty(value = "试卷名称", required = true) + @NotBlank(message = "试卷名称不能为空") + @Size(max = 128, message = "试卷名称长度不能超过128个字符") + private String paperName; + + @ApiModelProperty(value = "试卷总分", required = true) + @NotNull(message = "试卷总分不能为空") + @DecimalMin(value = "0.01", message = "试卷总分必须大于0") + @DecimalMax(value = "9999.99", message = "试卷总分不能超过9999.99") + private BigDecimal paperTotalScore; + + @ApiModelProperty(value = "合格分数", required = true) + @NotNull(message = "合格分数不能为空") + @DecimalMin(value = "0", message = "合格分数不能小于0") + @DecimalMax(value = "9999.99", message = "合格分数不能超过9999.99") + private BigDecimal paperPassScore; + + @ApiModelProperty(value = "试题Excel文件(第一个sheet单选题,第二个sheet多选题,第三个sheet判断题)", required = true) + @NotNull(message = "请上传试题Excel文件") + private MultipartFile file; +} diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/model/entity/QuestionDO.java b/safety-cedu-service/src/main/java/org/qinan/cedu/model/entity/QuestionDO.java index 35942598..a99efc9e 100644 --- a/safety-cedu-service/src/main/java/org/qinan/cedu/model/entity/QuestionDO.java +++ b/safety-cedu-service/src/main/java/org/qinan/cedu/model/entity/QuestionDO.java @@ -23,7 +23,7 @@ public class QuestionDO extends BaseEntity { private static final long serialVersionUID = 1L; - @TableId(type = IdType.AUTO) + @TableId(type = IdType.ASSIGN_ID) @ApiModelProperty("试题ID") private Long id; diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/model/entity/QuestionOptionDO.java b/safety-cedu-service/src/main/java/org/qinan/cedu/model/entity/QuestionOptionDO.java index dba5fd48..f1e5d08b 100644 --- a/safety-cedu-service/src/main/java/org/qinan/cedu/model/entity/QuestionOptionDO.java +++ b/safety-cedu-service/src/main/java/org/qinan/cedu/model/entity/QuestionOptionDO.java @@ -19,7 +19,7 @@ public class QuestionOptionDO extends BaseEntity { private static final long serialVersionUID = 1L; - @TableId(type = IdType.AUTO) + @TableId(type = IdType.ASSIGN_ID) @ApiModelProperty("选项ID") private Long id; diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/model/vo/PaperImportErrorVO.java b/safety-cedu-service/src/main/java/org/qinan/cedu/model/vo/PaperImportErrorVO.java new file mode 100644 index 00000000..af1681f3 --- /dev/null +++ b/safety-cedu-service/src/main/java/org/qinan/cedu/model/vo/PaperImportErrorVO.java @@ -0,0 +1,30 @@ +package org.qinan.cedu.model.vo; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 试题导入异常消息 + * + * @author safety-eval + */ +@Data +@NoArgsConstructor +public class PaperImportErrorVO { + + @ApiModelProperty(value = "Excel行号(从1开始的物理行号)") + private Integer rowIndex; + + @ApiModelProperty(value = "sheet页名称") + private String sheetName; + + @ApiModelProperty(value = "异常原因") + private String errorMessage; + + public PaperImportErrorVO(Integer rowIndex, String sheetName, String errorMessage) { + this.rowIndex = rowIndex; + this.sheetName = sheetName; + this.errorMessage = errorMessage; + } +} diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/model/vo/PaperImportVO.java b/safety-cedu-service/src/main/java/org/qinan/cedu/model/vo/PaperImportVO.java new file mode 100644 index 00000000..fe5c2ca7 --- /dev/null +++ b/safety-cedu-service/src/main/java/org/qinan/cedu/model/vo/PaperImportVO.java @@ -0,0 +1,29 @@ +package org.qinan.cedu.model.vo; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +/** + * 试题导入结果 + * + * @author safety-eval + */ +@Data +@NoArgsConstructor +public class PaperImportVO { + + @ApiModelProperty(value = "成功导入条数") + private Integer successCount; + + @ApiModelProperty(value = "异常明细") + private List errors = new ArrayList<>(); + + public PaperImportVO(Integer successCount, List errors) { + this.successCount = successCount; + this.errors = errors; + } +} diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/service/PaperService.java b/safety-cedu-service/src/main/java/org/qinan/cedu/service/PaperService.java index 2b0a2502..66812590 100644 --- a/safety-cedu-service/src/main/java/org/qinan/cedu/service/PaperService.java +++ b/safety-cedu-service/src/main/java/org/qinan/cedu/service/PaperService.java @@ -2,7 +2,9 @@ package org.qinan.cedu.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.service.IService; +import org.qinan.cedu.model.dto.PaperImportDTO; import org.qinan.cedu.model.entity.PaperDO; +import org.qinan.cedu.model.vo.PaperImportVO; import java.util.List; @@ -62,4 +64,12 @@ public interface PaperService extends IService { * @return 试题id列表 */ List getQuestionIds(Long paperId); + + /** + * 新建试卷-导入试题方式:解析试题Excel(单选/多选/判断),校验通过后创建试卷并绑定试题 + * + * @param importDTO 试卷信息+试题Excel文件 + * @return 导入结果(成功条数+异常明细) + */ + PaperImportVO importQuestions(PaperImportDTO importDTO); } diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/service/impl/PaperServiceImpl.java b/safety-cedu-service/src/main/java/org/qinan/cedu/service/impl/PaperServiceImpl.java index 8c74f0f1..dc4fb3f2 100644 --- a/safety-cedu-service/src/main/java/org/qinan/cedu/service/impl/PaperServiceImpl.java +++ b/safety-cedu-service/src/main/java/org/qinan/cedu/service/impl/PaperServiceImpl.java @@ -4,13 +4,22 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.baomidou.mybatisplus.extension.toolkit.Db; +import org.qinan.cedu.mapper.QuestionMapper; +import org.qinan.cedu.model.dto.PaperImportDTO; import org.qinan.cedu.model.entity.PaperDO; import org.qinan.cedu.model.entity.PaperQuestionRelDO; +import org.qinan.cedu.model.entity.QuestionDO; +import org.qinan.cedu.model.entity.QuestionOptionDO; +import org.qinan.cedu.model.vo.PaperImportVO; import org.qinan.cedu.enums.DeleteEnum; import org.qinan.cedu.enums.PaperStatusEnum; import org.qinan.cedu.mapper.PaperMapper; import org.qinan.cedu.service.PaperQuestionRelService; import org.qinan.cedu.service.PaperService; +import org.qinan.cedu.service.QuestionOptionService; +import org.qinan.cedu.service.QuestionService; +import org.qinan.cedu.service.support.QuestionExcelImporter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +27,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -30,6 +40,14 @@ public class PaperServiceImpl extends ServiceImpl implemen @Autowired private PaperQuestionRelService paperQuestionRelService; + @Autowired + private QuestionService questionService; + @Autowired + private QuestionMapper questionMapper; + + @Autowired + private QuestionExcelImporter questionExcelImporter; + @Override public IPage pageQuery(long current, long size, String paperName, String status) { return this.lambdaQuery() @@ -107,4 +125,46 @@ public class PaperServiceImpl extends ServiceImpl implemen .map(PaperQuestionRelDO::getQuestionId) .collect(Collectors.toList()); } + + @Override + @Transactional(rollbackFor = Exception.class) + public PaperImportVO importQuestions(PaperImportDTO importDTO) { + QuestionExcelImporter.ParseResult parseResult = questionExcelImporter.parse(importDTO.getFile()); + List questions = parseResult.getQuestions(); + if (CollectionUtils.isEmpty(questions)) { + // 没有任何校验通过的试题,不创建试卷,直接返回异常明细 + return new PaperImportVO(0, parseResult.getErrors()); + } + + // 创建试卷 + PaperDO paper = new PaperDO(); + paper.setPaperName(importDTO.getPaperName().trim()); + paper.setPaperTotalScore(importDTO.getPaperTotalScore()); + paper.setPaperPassScore(importDTO.getPaperPassScore()); + this.savePaper(paper); + + // 保存试题和选项(id已预置雪花id) + LocalDateTime now = LocalDateTime.now(); + List 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> 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; + } +}