From c74c803618eaa44d69265edc6f4c80470a1d8ae4 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Mon, 21 Sep 2026 11:31:28 +0800 Subject: [PATCH] feat: support exam question template formats and grading --- .../controller/stu/ExamController.java | 5 +- .../linkCommerce/service/ExamService.java | 452 ++++++++++++++++-- .../linkCommerce/service/ExamServiceTest.java | 101 ++++ 3 files changed, 526 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/stu/ExamController.java b/src/main/java/com/sztzjy/linkCommerce/controller/stu/ExamController.java index 1f2b234..929f342 100644 --- a/src/main/java/com/sztzjy/linkCommerce/controller/stu/ExamController.java +++ b/src/main/java/com/sztzjy/linkCommerce/controller/stu/ExamController.java @@ -63,8 +63,9 @@ public class ExamController { } @GetMapping("/teacher/questions/template") - public void questionTemplate(HttpServletRequest request, HttpServletResponse response) throws IOException { - examService.teacherClasses(user(request)); examService.questionTemplate(response); + public void questionTemplate(@RequestParam(required = false, defaultValue = "word") String format, + HttpServletRequest request, HttpServletResponse response) throws IOException { + examService.teacherClasses(user(request)); examService.questionTemplate(format, response); } @GetMapping("/teacher") diff --git a/src/main/java/com/sztzjy/linkCommerce/service/ExamService.java b/src/main/java/com/sztzjy/linkCommerce/service/ExamService.java index e27444e..b33a1fa 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/ExamService.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/ExamService.java @@ -6,8 +6,14 @@ import com.sztzjy.linkCommerce.config.security.JwtUser; import com.sztzjy.linkCommerce.util.file.IFileUtil; import org.apache.commons.lang3.StringUtils; import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.ss.util.CellRangeAddressList; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.xwpf.usermodel.*; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.security.access.AccessDeniedException; @@ -25,6 +31,8 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -39,6 +47,8 @@ public class ExamService { private static final Set ALLOWED_ATTACHMENTS = new HashSet<>(Arrays.asList("pdf", "doc", "docx", "png", "jpg", "jpeg")); private static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024; private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final Pattern OPTION_LINE = Pattern.compile("^([A-Ha-h])\\s*[..、::]\\s*(.+)$"); + private static final Pattern QUESTION_NUMBER = Pattern.compile("^\\s*(?:\\d+[.、.)]|[((]\\d+[))])\\s*"); private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; @@ -104,29 +114,102 @@ public class ExamService { @Transactional public List> importQuestions(JwtUser teacher, MultipartFile file) { requireTeacher(teacher); - validateDocx(file); - List> parsed = parseQuestionDocument(file); + String format = validateQuestionImportFile(file); + List> parsed = "docx".equals(format) + ? parseQuestionDocument(file) : parseQuestionSpreadsheet(file); if (parsed.isEmpty()) throw new IllegalArgumentException("未读取到符合模板的题目"); List> created = new ArrayList<>(); for (Map question : parsed) created.add(createQuestion(teacher, question)); return created; } - public void questionTemplate(HttpServletResponse response) throws IOException { + public void questionTemplate(String format, HttpServletResponse response) throws IOException { + String normalized = StringUtils.defaultIfBlank(format, "word").trim().toLowerCase(Locale.ROOT); + if ("word".equals(normalized) || "docx".equals(normalized)) { + writeQuestionWordTemplate(response); + } else if ("excel".equals(normalized) || "xlsx".equals(normalized)) { + writeQuestionExcelTemplate(response); + } else { + throw new IllegalArgumentException("模板格式仅支持word或excel"); + } + } + + private void writeQuestionWordTemplate(HttpServletResponse response) throws IOException { try (XWPFDocument doc = new XWPFDocument()) { - addTitle(doc, "考试题库导入模板"); - addParagraph(doc, "请保留题型标记、题干、答案和解析字段。不同题目之间空一行;选项使用 A.、B. 格式。", false); - addParagraph(doc, "[单选题]", true); addParagraph(doc, "题干:示例单选题", false); - addParagraph(doc, "A. 选项一", false); addParagraph(doc, "B. 选项二", false); - addParagraph(doc, "答案:A", false); addParagraph(doc, "解析:答案解析,可留空", false); - addParagraph(doc, "", false); addParagraph(doc, "[多选题]", true); addParagraph(doc, "题干:示例多选题", false); - addParagraph(doc, "A. 选项一", false); addParagraph(doc, "B. 选项二", false); addParagraph(doc, "C. 选项三", false); - addParagraph(doc, "答案:A,C", false); addParagraph(doc, "解析:答案解析,可留空", false); - addParagraph(doc, "", false); addParagraph(doc, "[判断题]", true); addParagraph(doc, "题干:示例判断题", false); - addParagraph(doc, "答案:正确", false); addParagraph(doc, "解析:答案解析,可留空", false); - addParagraph(doc, "", false); addParagraph(doc, "[简答题]", true); addParagraph(doc, "题干:示例简答题", false); - addParagraph(doc, "答案:参考答案", false); addParagraph(doc, "解析:评分说明,可留空", false); - writeDocx(response, doc, "考试题库导入模板.docx"); + configureA4Page(doc); + addTemplateTitle(doc, "考试题库 Word 导入模板"); + addTemplateNote(doc, "使用说明", "请删除示例内容后填写。系统按“题干:”拆分题目;同一题型连续出题时,无需重复题型标记。"); + addTemplateNote(doc, "格式要求", "题型使用 [单选题]、[多选题]、[判断题]、[简答题];选项使用 A.、B. 格式;答案字段必须保留,解析可为空。支持中英文冒号和字段内容换行。"); + + addTemplateSection(doc, "[单选题]"); + addTemplateQuestion(doc, "题干:1. 在电商产品开发中,需供比的计算公式是?", + "A. 搜索次数 ÷ 在线商品数", "B. 成交金额 ÷ 在线商家数", "C. 在线商品数 ÷ 搜索次数", "D. 转化率 ÷ 跳失率", + "答案:A", "解析:需供比等于搜索次数除以在线商品数。"); + addTemplateQuestion(doc, "题干:2. Min-Max 归一化的核心作用是什么?", + "A. 删除异常值", "B. 将数据映射到统一范围", "C. 增加样本数量", "D. 改变指标方向", + "答案:B", "解析:归一化用于消除不同指标量纲带来的影响。"); + + addTemplateSection(doc, "[多选题]"); + addTemplateQuestion(doc, "题干:电商商品标题优化时,通常需要关注哪些因素?", + "A. 核心关键词", "B. 用户搜索意图", "C. 平台规则", "D. 无关热词", + "答案:A,B,C", "解析:标题应兼顾相关性、搜索意图和平台规范。"); + + addTemplateSection(doc, "[判断题]"); + addTemplateQuestion(doc, "题干:商品转化率越高,通常说明访问流量的成交效率越高。", + "答案:正确", "解析:转化率反映访问用户完成目标行为的比例。"); + + addTemplateSection(doc, "[简答题]"); + addTemplateQuestion(doc, "题干:请简述新品上架前进行竞品分析的主要目的。", + "答案:识别市场需求、竞争格局、价格区间和差异化机会。", + "解析:评分时应覆盖需求、竞争、定价和差异化等关键点。"); + writeDocx(response, doc, "考试题库Word导入模板.docx"); + } + } + + private void writeQuestionExcelTemplate(HttpServletResponse response) throws IOException { + try (Workbook workbook = new XSSFWorkbook()) { + String[] headers = {"题型", "题干", "选项A", "选项B", "选项C", "选项D", "选项E", "选项F", "选项G", "选项H", "正确答案", "答案解析"}; + Sheet input = workbook.createSheet("题目导入"); + input.addMergedRegion(new CellRangeAddress(0, 0, 0, headers.length - 1)); + Row titleRow = input.createRow(0); titleRow.setHeightInPoints(30); + Cell title = titleRow.createCell(0); + title.setCellValue("考试题库 Excel 导入模板"); + title.setCellStyle(excelTitleStyle(workbook)); + input.addMergedRegion(new CellRangeAddress(1, 1, 0, headers.length - 1)); + Row noteRow = input.createRow(1); noteRow.setHeightInPoints(28); + Cell note = noteRow.createCell(0); + note.setCellValue("一题一行;请从第 4 行开始填写。题型、题干、正确答案为必填项,详细规则及示例见“填写说明”。"); + note.setCellStyle(excelNoteStyle(workbook)); + Row header = input.createRow(2); + header.setHeightInPoints(24); + CellStyle headerStyle = excelHeaderStyle(workbook); + for (int i = 0; i < headers.length; i++) { + Cell cell = header.createCell(i); + cell.setCellValue(headers[i]); + cell.setCellStyle(headerStyle); + } + CellStyle entryStyle = excelEntryStyle(workbook); + for (int rowIndex = 3; rowIndex <= 202; rowIndex++) { + Row row = input.createRow(rowIndex); + row.setHeightInPoints(34); + for (int column = 0; column < headers.length; column++) row.createCell(column).setCellStyle(entryStyle); + } + int[] widths = {14, 52, 24, 24, 24, 24, 24, 24, 24, 24, 18, 42}; + for (int i = 0; i < widths.length; i++) input.setColumnWidth(i, widths[i] * 256); + input.createFreezePane(0, 3); + input.setAutoFilter(new CellRangeAddress(2, 202, 0, headers.length - 1)); + DataValidationHelper validationHelper = input.getDataValidationHelper(); + DataValidationConstraint typeConstraint = validationHelper.createExplicitListConstraint(new String[]{"单选题", "多选题", "判断题", "简答题"}); + DataValidation typeValidation = validationHelper.createValidation(typeConstraint, new CellRangeAddressList(3, 202, 0, 0)); + typeValidation.setShowErrorBox(true); + typeValidation.createErrorBox("题型不正确", "请从下拉列表中选择题型"); + input.addValidationData(typeValidation); + + Sheet guide = workbook.createSheet("填写说明"); + writeExcelGuide(workbook, guide, headers); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + downloadHeader(response, "考试题库Excel导入模板.xlsx"); + workbook.write(response.getOutputStream()); } } @@ -143,7 +226,8 @@ public class ExamService { public Map teacherExam(JwtUser teacher, long id) { requireTeacher(teacher); autoSubmitExpired(); Map result = ownedExam(teacher, id); - result.put("classes", jdbc.query("select c.teaching_class_id id,s.class_name name from exam_assessment_class c join school_class s on s.school_class_id=c.teaching_class_id where c.exam_id=?", + // exam_* 表为 utf8mb4_unicode_ci,遗留表 school_class/userinfo 沿用各自建表时的排序规则,列对列比较必须显式 COLLATE,否则报 1267。 + result.put("classes", jdbc.query("select c.teaching_class_id id,s.class_name name from exam_assessment_class c join school_class s on s.school_class_id=c.teaching_class_id collate utf8mb4_0900_ai_ci where c.exam_id=?", (rs, n) -> map("id", rs.getString("id"), "name", rs.getString("name")), id)); result.put("questions", examQuestions(id, true)); return result; @@ -237,7 +321,8 @@ public class ExamService { Map exam = ownedExam(teacher, examId); autoSubmitExpired(); boolean running = "IN_PROGRESS".equals(exam.get("status")); return jdbc.query("select a.id attempt_id,a.student_id,a.teaching_class_id,c.class_name,u.name,u.username,a.attempt_status,a.objective_score,a.subjective_score,a.total_score,a.submitted_at " + - "from exam_attempt a join userinfo u on u.user_id=a.student_id join school_class c on c.school_class_id=a.teaching_class_id " + + "from exam_attempt a join userinfo u on u.user_id=a.student_id collate utf8mb4_general_ci " + + "join school_class c on c.school_class_id=a.teaching_class_id collate utf8mb4_0900_ai_ci " + "where a.exam_id=?" + (running ? "" : " and a.attempt_status='SUBMITTED'") + " order by case when a.attempt_status='SUBMITTED' then 0 else 1 end,a.submitted_at,u.username", (rs, n) -> map("attemptId", rs.getLong("attempt_id"), "studentId", rs.getString("student_id"), "classId", rs.getString("teaching_class_id"), @@ -629,24 +714,103 @@ public class ExamService { private List> parseQuestionDocument(MultipartFile file) { try (XWPFDocument doc = new XWPFDocument(file.getInputStream())) { - List lines = doc.getParagraphs().stream().map(XWPFParagraph::getText).map(String::trim).collect(Collectors.toList()); - List> result = new ArrayList<>(); Map current = null; List> options = null; + List lines = new ArrayList<>(); + for (XWPFParagraph paragraph : doc.getParagraphs()) { + String paragraphText = StringUtils.defaultString(paragraph.getText()); + String[] paragraphLines = paragraphText.split("\\R", -1); + for (String paragraphLine : paragraphLines) lines.add(paragraphLine.trim()); + } + List> result = new ArrayList<>(); + String currentType = null; String activeField = null; + Map current = null; List> options = null; Map lastOption = null; for (String line : lines) { String type = typeMarker(line); - if (type != null) { if (current != null) finishImported(current, options, result); current = new LinkedHashMap<>(); current.put("type", type); options = new ArrayList<>(); continue; } + if (type != null) { + if (current != null) finishImported(current, options, result, "第 " + (result.size() + 1) + " 题"); + currentType = type; current = null; options = null; lastOption = null; activeField = null; + continue; + } + if (isImportField(line, "题干")) { + if (current != null) finishImported(current, options, result, "第 " + (result.size() + 1) + " 题"); + if (currentType == null) throw new IllegalArgumentException("第 " + (result.size() + 1) + " 题:题干前缺少题型标记"); + current = new LinkedHashMap<>(); current.put("type", currentType); + current.put("stem", stripQuestionNumber(afterColon(line))); + options = new ArrayList<>(); lastOption = null; activeField = "stem"; + continue; + } if (current == null || line.isEmpty()) continue; - if (line.startsWith("题干:") || line.startsWith("题干:")) current.put("stem", afterColon(line)); - else if (line.matches("^[A-Za-z][\\.、].+")) options.add(map("label", line.substring(0, 1).toUpperCase(), "content", line.substring(2).trim())); - else if (line.startsWith("答案:") || line.startsWith("答案:")) current.put("answer", importAnswer(text(current.get("type")), afterColon(line))); - else if (line.startsWith("解析:") || line.startsWith("解析:")) current.put("analysis", afterColon(line)); + Matcher optionMatcher = OPTION_LINE.matcher(line); + if (optionMatcher.matches()) { + lastOption = map("label", optionMatcher.group(1).toUpperCase(Locale.ROOT), "content", optionMatcher.group(2).trim()); + options.add(lastOption); activeField = "option"; + } else if (isImportField(line, "答案")) { + current.put("answer", afterColon(line)); activeField = "answer"; lastOption = null; + } else if (isImportField(line, "解析")) { + current.put("analysis", afterColon(line)); activeField = "analysis"; lastOption = null; + } else if ("option".equals(activeField) && lastOption != null) { + lastOption.put("content", appendImportText(lastOption.get("content"), line)); + } else if (activeField != null) { + current.put(activeField, appendImportText(current.get(activeField), line)); + } } - if (current != null) finishImported(current, options, result); + if (current != null) finishImported(current, options, result, "第 " + (result.size() + 1) + " 题"); return result; } catch (IOException e) { throw new IllegalArgumentException("Word文件读取失败"); } } - private void finishImported(Map current, List> options, List> result) { - current.put("options", options); validateQuestion(current); result.add(current); + private List> parseQuestionSpreadsheet(MultipartFile file) { + try (Workbook workbook = WorkbookFactory.create(file.getInputStream())) { + if (workbook.getNumberOfSheets() == 0) throw new IllegalArgumentException("Excel文件中没有工作表"); + Sheet sheet = workbook.getSheetAt(0); + DataFormatter formatter = new DataFormatter(Locale.CHINA); + int headerRowIndex = findQuestionHeaderRow(sheet, formatter); + if (headerRowIndex < 0) throw new IllegalArgumentException("Excel前20行中未找到完整表头:题型、题干、正确答案"); + Map headers = questionHeaders(sheet.getRow(headerRowIndex), formatter); + List> result = new ArrayList<>(); + for (int rowIndex = headerRowIndex + 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) { + Row row = sheet.getRow(rowIndex); + if (row == null || questionRowIsBlank(row, headers, formatter)) continue; + int excelRow = rowIndex + 1; + String rawType = questionCell(row, headers.get("题型"), formatter); + String type = normalizeImportedType(rawType); + if (type == null) throw new IllegalArgumentException("Excel 第 " + excelRow + " 行:不支持的题型“" + rawType + "”"); + Map question = new LinkedHashMap<>(); + question.put("type", type); + question.put("stem", stripQuestionNumber(questionCell(row, headers.get("题干"), formatter))); + List> options = new ArrayList<>(); + for (char label = 'A'; label <= 'H'; label++) { + Integer column = headers.get("选项" + label); + String content = questionCell(row, column, formatter); + if (StringUtils.isNotBlank(content)) options.add(map("label", String.valueOf(label), "content", content)); + } + question.put("options", options); + question.put("answer", importAnswer(type, questionCell(row, headers.get("正确答案"), formatter))); + question.put("analysis", questionCell(row, headers.get("答案解析"), formatter)); + validateImportedQuestion(question, "Excel 第 " + excelRow + " 行"); + result.add(question); + } + return result; + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException("Excel文件读取失败"); + } + } + + private void finishImported(Map current, List> options, + List> result, String context) { + current.put("options", options == null ? new ArrayList<>() : options); + current.put("answer", importAnswer(text(current.get("type")), text(current.get("answer")))); + validateImportedQuestion(current, context); + result.add(current); + } + + private void validateImportedQuestion(Map question, String context) { + try { + validateQuestion(question); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(context + ":" + e.getMessage()); + } } private Object importAnswer(String type, String raw) { @@ -660,6 +824,64 @@ public class ExamService { return raw; } + private int findQuestionHeaderRow(Sheet sheet, DataFormatter formatter) { + int last = Math.min(sheet.getLastRowNum(), 19); + for (int i = 0; i <= last; i++) { + Map headers = questionHeaders(sheet.getRow(i), formatter); + if (headers.containsKey("题型") && headers.containsKey("题干") && headers.containsKey("正确答案")) return i; + } + return -1; + } + + private Map questionHeaders(Row row, DataFormatter formatter) { + Map headers = new LinkedHashMap<>(); + if (row == null) return headers; + for (Cell cell : row) { + String value = normalizeQuestionHeader(formatter.formatCellValue(cell)); + if ("答案".equals(value)) value = "正确答案"; + if ("解析".equals(value)) value = "答案解析"; + if ("题型".equals(value) || "题干".equals(value) || "正确答案".equals(value) || "答案解析".equals(value) + || value.matches("选项[A-H]")) headers.putIfAbsent(value, cell.getColumnIndex()); + } + return headers; + } + + private boolean questionRowIsBlank(Row row, Map headers, DataFormatter formatter) { + for (Integer column : headers.values()) if (StringUtils.isNotBlank(questionCell(row, column, formatter))) return false; + return true; + } + + private static String questionCell(Row row, Integer column, DataFormatter formatter) { + if (row == null || column == null) return ""; + Cell cell = row.getCell(column, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL); + return cell == null ? "" : formatter.formatCellValue(cell).trim(); + } + + private static String normalizeQuestionHeader(String value) { + return StringUtils.trimToEmpty(value).replace("\uFEFF", "").replaceAll("\\s+", ""); + } + + private static String normalizeImportedType(String value) { + String normalized = StringUtils.trimToEmpty(value); + String marker = typeMarker(normalized); + if (marker != null) return marker; + String internal = normalized.toUpperCase(Locale.ROOT); + return TYPES.contains(internal) ? internal : null; + } + + private static boolean isImportField(String line, String field) { + return StringUtils.defaultString(line).matches("^" + field + "\\s*[::].*"); + } + + private static String appendImportText(Object current, String addition) { + String existing = text(current); + return StringUtils.isBlank(existing) ? addition.trim() : existing + "\n" + addition.trim(); + } + + private static String stripQuestionNumber(String value) { + return QUESTION_NUMBER.matcher(StringUtils.trimToEmpty(value)).replaceFirst("").trim(); + } + private XWPFDocument buildPaperDocument(Map exam, List> questions, Map student, boolean answers) { XWPFDocument doc = new XWPFDocument(); addTitle(doc, text(exam.get("name"))); if (student != null) addParagraph(doc, "班级:" + text(student.get("className")) + " 姓名:" + text(student.get("name")) + " 学号:" + text(student.get("studentNo")), false); @@ -681,6 +903,168 @@ public class ExamService { return map("name", name, "value", count); } + private static void addTemplateTitle(XWPFDocument doc, String value) { + XWPFParagraph paragraph = doc.createParagraph(); + paragraph.setAlignment(ParagraphAlignment.CENTER); + paragraph.setSpacingAfter(180); + XWPFRun run = paragraph.createRun(); + run.setBold(true); run.setFontFamily("Microsoft YaHei"); run.setFontSize(20); run.setColor("123047"); run.setText(value); + } + + private static void configureA4Page(XWPFDocument doc) { + String namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + CTSectPr section = doc.getDocument().getBody().addNewSectPr(); + Node sectionNode = section.getDomNode(); + Document document = sectionNode.getOwnerDocument(); + Element pageSize = document.createElementNS(namespace, "w:pgSz"); + pageSize.setAttributeNS(namespace, "w:w", "11906"); pageSize.setAttributeNS(namespace, "w:h", "16838"); + sectionNode.appendChild(pageSize); + Element margins = document.createElementNS(namespace, "w:pgMar"); + margins.setAttributeNS(namespace, "w:top", "1134"); margins.setAttributeNS(namespace, "w:right", "1276"); + margins.setAttributeNS(namespace, "w:bottom", "1134"); margins.setAttributeNS(namespace, "w:left", "1276"); + sectionNode.appendChild(margins); + } + + private static void addTemplateNote(XWPFDocument doc, String label, String value) { + XWPFParagraph paragraph = doc.createParagraph(); + paragraph.setIndentationLeft(240); paragraph.setIndentationRight(240); paragraph.setSpacingAfter(70); + XWPFRun labelRun = paragraph.createRun(); + labelRun.setBold(true); labelRun.setFontFamily("Microsoft YaHei"); labelRun.setFontSize(10); labelRun.setColor("167C80"); labelRun.setText(label + ":"); + XWPFRun valueRun = paragraph.createRun(); + valueRun.setFontFamily("Microsoft YaHei"); valueRun.setFontSize(10); valueRun.setColor("34495E"); valueRun.setText(value); + } + + private static void addTemplateSection(XWPFDocument doc, String value) { + XWPFParagraph paragraph = doc.createParagraph(); + paragraph.setSpacingBefore(180); paragraph.setSpacingAfter(80); + XWPFRun run = paragraph.createRun(); + run.setBold(true); run.setFontFamily("Microsoft YaHei"); run.setFontSize(13); run.setColor("0F6670"); run.setText(value); + } + + private static void addTemplateQuestion(XWPFDocument doc, String... lines) { + for (String line : lines) { + XWPFParagraph paragraph = doc.createParagraph(); + paragraph.setIndentationLeft(180); paragraph.setSpacingAfter(45); paragraph.setSpacingBetween(1.15); + int colon = Math.max(line.indexOf(':'), line.indexOf(':')); + if (colon > 0 && (line.startsWith("题干") || line.startsWith("答案") || line.startsWith("解析"))) { + XWPFRun label = paragraph.createRun(); + label.setBold(true); label.setFontFamily("Microsoft YaHei"); label.setFontSize(10); label.setColor("23384A"); label.setText(line.substring(0, colon + 1)); + XWPFRun content = paragraph.createRun(); + content.setFontFamily("Microsoft YaHei"); content.setFontSize(10); content.setColor("23384A"); content.setText(line.substring(colon + 1)); + } else { + XWPFRun run = paragraph.createRun(); + run.setFontFamily("Microsoft YaHei"); run.setFontSize(10); run.setColor("34495E"); run.setText(line); + } + } + XWPFParagraph spacer = doc.createParagraph(); + spacer.setSpacingAfter(40); + } + + private static void writeExcelGuide(Workbook workbook, Sheet guide, String[] headers) { + guide.addMergedRegion(new CellRangeAddress(0, 0, 0, headers.length - 1)); + Row titleRow = guide.createRow(0); titleRow.setHeightInPoints(30); + Cell title = titleRow.createCell(0); + title.setCellValue("考试题库 Excel 填写说明"); title.setCellStyle(excelTitleStyle(workbook)); + String[][] rules = { + {"必填字段", "题型、题干、正确答案。答案解析可以为空。"}, + {"题型写法", "单选题、多选题、判断题、简答题,也支持 SINGLE、MULTIPLE、TRUE_FALSE、SHORT_ANSWER。"}, + {"选择题", "至少填写两个选项;单选答案写一个字母,多选答案使用英文逗号分隔,如 A,C。"}, + {"判断题", "正确答案填写“正确”或“错误”,也支持“对/错”和 true/false。"}, + {"简答题", "正确答案填写参考答案;答案解析可用于填写评分说明。"}, + {"导入范围", "系统只读取第一张工作表“题目导入”,本页示例不会被导入。"} + }; + CellStyle ruleLabel = excelRuleLabelStyle(workbook); + CellStyle ruleText = excelRuleTextStyle(workbook); + for (int i = 0; i < rules.length; i++) { + Row row = guide.createRow(i + 2); row.setHeightInPoints(28); + Cell label = row.createCell(0); label.setCellValue(rules[i][0]); label.setCellStyle(ruleLabel); + Cell value = row.createCell(1); value.setCellValue(rules[i][1]); value.setCellStyle(ruleText); + guide.addMergedRegion(new CellRangeAddress(i + 2, i + 2, 1, headers.length - 1)); + for (int column = 2; column < headers.length; column++) row.createCell(column).setCellStyle(ruleText); + } + int headerRowIndex = rules.length + 3; + Row exampleTitle = guide.createRow(headerRowIndex - 1); + exampleTitle.createCell(0).setCellValue("四种题型填写示例(仅供参考,不会被导入)"); + exampleTitle.getCell(0).setCellStyle(excelSectionStyle(workbook)); + guide.addMergedRegion(new CellRangeAddress(headerRowIndex - 1, headerRowIndex - 1, 0, headers.length - 1)); + Row header = guide.createRow(headerRowIndex); + header.setHeightInPoints(24); + CellStyle headerStyle = excelHeaderStyle(workbook); + for (int i = 0; i < headers.length; i++) { + Cell cell = header.createCell(i); cell.setCellValue(headers[i]); cell.setCellStyle(headerStyle); + } + String[][] examples = { + {"单选题", "需供比的计算公式是?", "搜索次数 ÷ 在线商品数", "成交金额 ÷ 在线商家数", "在线商品数 ÷ 搜索次数", "转化率 ÷ 跳失率", "", "", "", "", "A", "需供比反映市场供需关系。"}, + {"多选题", "商品标题优化应关注哪些因素?", "核心关键词", "搜索意图", "平台规则", "无关热词", "", "", "", "", "A,B,C", "选择与商品相关且符合规范的要素。"}, + {"判断题", "转化率越高通常说明成交效率越高。", "", "", "", "", "", "", "", "", "正确", "判断题无需填写选项。"}, + {"简答题", "请简述竞品分析的主要目的。", "", "", "", "", "", "", "", "", "识别需求、竞争、定价和差异化机会。", "简答题答案填写参考答案。"} + }; + CellStyle exampleStyle = excelEntryStyle(workbook); + for (int i = 0; i < examples.length; i++) { + Row row = guide.createRow(headerRowIndex + i + 1); row.setHeightInPoints(42); + for (int j = 0; j < headers.length; j++) { + Cell cell = row.createCell(j); + if (StringUtils.isNotBlank(examples[i][j])) cell.setCellValue(examples[i][j]); + cell.setCellStyle(exampleStyle); + } + } + int[] widths = {14, 44, 22, 22, 22, 22, 18, 18, 18, 18, 28, 38}; + for (int i = 0; i < widths.length; i++) guide.setColumnWidth(i, widths[i] * 256); + guide.createFreezePane(0, headerRowIndex + 1); + } + + private static CellStyle excelTitleStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setFillForegroundColor(IndexedColors.DARK_TEAL.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER); + Font font = workbook.createFont(); font.setBold(true); font.setFontHeightInPoints((short) 16); font.setColor(IndexedColors.WHITE.getIndex()); + style.setFont(font); return style; + } + + private static CellStyle excelNoteStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setFillForegroundColor(IndexedColors.LIGHT_TURQUOISE.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setWrapText(true); style.setVerticalAlignment(VerticalAlignment.CENTER); + Font font = workbook.createFont(); font.setColor(IndexedColors.DARK_TEAL.getIndex()); font.setFontHeightInPoints((short) 10); + style.setFont(font); return style; + } + + private static CellStyle excelHeaderStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setFillForegroundColor(IndexedColors.DARK_TEAL.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setBorderTop(BorderStyle.THIN); style.setBorderBottom(BorderStyle.THIN); style.setBorderLeft(BorderStyle.THIN); style.setBorderRight(BorderStyle.THIN); + Font font = workbook.createFont(); font.setBold(true); font.setColor(IndexedColors.WHITE.getIndex()); + style.setFont(font); return style; + } + + private static CellStyle excelEntryStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setWrapText(true); style.setVerticalAlignment(VerticalAlignment.TOP); + style.setBorderTop(BorderStyle.HAIR); style.setBorderBottom(BorderStyle.HAIR); style.setBorderLeft(BorderStyle.HAIR); style.setBorderRight(BorderStyle.HAIR); + style.setFillForegroundColor(IndexedColors.WHITE.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + return style; + } + + private static CellStyle excelRuleLabelStyle(Workbook workbook) { + CellStyle style = excelEntryStyle(workbook); + style.setFillForegroundColor(IndexedColors.LIGHT_TURQUOISE.getIndex()); + Font font = workbook.createFont(); font.setBold(true); font.setColor(IndexedColors.DARK_TEAL.getIndex()); + style.setFont(font); return style; + } + + private static CellStyle excelRuleTextStyle(Workbook workbook) { + CellStyle style = excelEntryStyle(workbook); style.setVerticalAlignment(VerticalAlignment.CENTER); return style; + } + + private static CellStyle excelSectionStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setFillForegroundColor(IndexedColors.LIGHT_ORANGE.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setVerticalAlignment(VerticalAlignment.CENTER); + Font font = workbook.createFont(); font.setBold(true); font.setColor(IndexedColors.BROWN.getIndex()); + style.setFont(font); return style; + } + private static void addTitle(XWPFDocument doc, String value) { XWPFParagraph p = doc.createParagraph(); p.setAlignment(ParagraphAlignment.CENTER); XWPFRun r = p.createRun(); r.setBold(true); r.setFontSize(18); r.setText(value); } private static void addParagraph(XWPFDocument doc, String value, boolean bold) { XWPFRun r = doc.createParagraph().createRun(); r.setBold(bold); r.setFontSize(11); r.setText(value); } private static void copy(InputStream in, OutputStream out) throws IOException { byte[] b = new byte[8192]; int n; while ((n = in.read(b)) >= 0) out.write(b, 0, n); } @@ -688,7 +1072,8 @@ public class ExamService { private static String displayAnswer(Object answer) { if (answer == null) return "未作答"; if (answer instanceof Collection) return ((Collection) answer).stream().map(String::valueOf).collect(Collectors.joining("、")); return String.valueOf(answer); } static String typeMarker(String line) { String marker = StringUtils.trimToEmpty(line); - if (marker.startsWith("[") && marker.endsWith("]") && marker.length() > 2) marker = marker.substring(1, marker.length() - 1).trim(); + if (((marker.startsWith("[") && marker.endsWith("]")) || (marker.startsWith("【") && marker.endsWith("】"))) && marker.length() > 2) + marker = marker.substring(1, marker.length() - 1).trim(); if ("单选题".equals(marker)) return SINGLE; if ("多选题".equals(marker)) return MULTIPLE; if ("判断题".equals(marker)) return TRUE_FALSE; @@ -697,7 +1082,14 @@ public class ExamService { } private static String afterColon(String line) { int i = Math.max(line.indexOf(':'), line.indexOf(':')); return i < 0 ? "" : line.substring(i + 1).trim(); } private static void validateType(String type) { if (!TYPES.contains(type)) throw new IllegalArgumentException("不支持的题型"); } - private static void validateDocx(MultipartFile file) { if (file == null || file.isEmpty() || !safeFilename(file.getOriginalFilename()).toLowerCase().endsWith(".docx")) throw new IllegalArgumentException("请选择docx格式的Word文件"); } + private static String validateQuestionImportFile(MultipartFile file) { + if (file == null || file.isEmpty()) throw new IllegalArgumentException("请选择要导入的Word或Excel文件"); + String filename = safeFilename(file.getOriginalFilename()).toLowerCase(Locale.ROOT); + int dot = filename.lastIndexOf('.'); + String extension = dot < 0 ? "" : filename.substring(dot + 1); + if (!Arrays.asList("docx", "xls", "xlsx").contains(extension)) throw new IllegalArgumentException("仅支持docx、xls或xlsx格式"); + return extension; + } private static void validateAttachment(MultipartFile file) { if (file == null || file.isEmpty()) throw new IllegalArgumentException("请选择附件"); if (file.getSize() > MAX_ATTACHMENT_SIZE) throw new IllegalArgumentException("附件不能超过20MB"); String n = safeFilename(file.getOriginalFilename()); int i = n.lastIndexOf('.'); if (i < 0 || !ALLOWED_ATTACHMENTS.contains(n.substring(i + 1).toLowerCase())) throw new IllegalArgumentException("附件仅支持PDF、Word和图片格式"); } private static String safeFilename(String name) { return StringUtils.defaultString(name).replace("\\", "_").replace("/", "_"); } private static void requireTeacher(JwtUser user) { if (user == null) throw new AccessDeniedException("请先登录"); if (user.getRoleId() != 3) throw new AccessDeniedException("仅教师可以操作"); } diff --git a/src/test/java/com/sztzjy/linkCommerce/service/ExamServiceTest.java b/src/test/java/com/sztzjy/linkCommerce/service/ExamServiceTest.java index 0a39190..d567c36 100644 --- a/src/test/java/com/sztzjy/linkCommerce/service/ExamServiceTest.java +++ b/src/test/java/com/sztzjy/linkCommerce/service/ExamServiceTest.java @@ -3,14 +3,21 @@ package com.sztzjy.linkCommerce.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.sztzjy.linkCommerce.config.security.JwtUser; import com.sztzjy.linkCommerce.util.file.IFileUtil; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.access.AccessDeniedException; import org.springframework.test.util.ReflectionTestUtils; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.math.BigDecimal; import java.sql.Timestamp; @@ -37,6 +44,7 @@ class ExamServiceTest { @Test void recognizesOnlyCompletePlainOrBracketedQuestionTypeMarkers() { assertEquals(ExamService.SINGLE, ExamService.typeMarker("[单选题]")); + assertEquals(ExamService.SINGLE, ExamService.typeMarker("【单选题】")); assertEquals(ExamService.MULTIPLE, ExamService.typeMarker(" 多选题 ")); assertEquals(ExamService.TRUE_FALSE, ExamService.typeMarker("[ 判断题 ]")); assertEquals(ExamService.SHORT_ANSWER, ExamService.typeMarker("简答题")); @@ -86,6 +94,81 @@ class ExamServiceTest { assertEquals(2, ((List) parsed.get(0).get("options")).size()); } + @Test + void parsesConsecutiveWordQuestionsAndContinuationLines() throws Exception { + MockMultipartFile file = docx("【单选题】", + "题干:1. 第一题第一行", "题干续行", "A:甲", "选项甲续行", "B.乙", "答案:A", "解析:第一行", "解析续行", + "题干:2、第二题", "A. 丙", "B、丁", "答案:B", "解析:第二题解析"); + List> parsed = ReflectionTestUtils.invokeMethod(service, "parseQuestionDocument", file); + assertEquals(2, parsed.size()); + assertEquals("第一题第一行\n题干续行", parsed.get(0).get("stem")); + assertEquals("甲\n选项甲续行", ((List>) parsed.get(0).get("options")).get(0).get("content")); + assertEquals("第一行\n解析续行", parsed.get(0).get("analysis")); + assertEquals("第二题", parsed.get(1).get("stem")); + assertEquals(Collections.singletonList("B"), parsed.get(1).get("answer")); + } + + @Test + void parsesLineBreaksInsideWordAnswerAndAnalysisParagraphs() throws Exception { + MockMultipartFile file = docx("[简答题]", "题干:请说明计算方法", "答案:第一步计算需求\n第二步计算供给", "解析:分步说明\n补充评分要点"); + List> parsed = ReflectionTestUtils.invokeMethod(service, "parseQuestionDocument", file); + assertEquals(1, parsed.size()); + assertEquals("第一步计算需求\n第二步计算供给", parsed.get(0).get("answer")); + assertEquals("分步说明\n补充评分要点", parsed.get(0).get("analysis")); + } + + @Test + void parsesAllQuestionTypesFromExcel() throws Exception { + MockMultipartFile file = xlsx( + new String[]{"单选题", "单选", "甲", "乙", "", "", "B", "单选解析"}, + new String[]{"多选题", "多选", "甲", "乙", "丙", "", "A,C", "多选解析"}, + new String[]{"判断题", "判断", "", "", "", "", "正确", "判断解析"}, + new String[]{"简答题", "简答", "", "", "", "", "参考答案", "评分说明"}); + List> parsed = ReflectionTestUtils.invokeMethod(service, "parseQuestionSpreadsheet", file); + assertEquals(4, parsed.size()); + assertEquals(Collections.singletonList("B"), parsed.get(0).get("answer")); + assertEquals(Arrays.asList("A", "C"), parsed.get(1).get("answer")); + assertEquals(true, parsed.get(2).get("answer")); + assertEquals("参考答案", parsed.get(3).get("answer")); + } + + @Test + void reportsExcelRowForInvalidAnswer() throws Exception { + MockMultipartFile file = xlsx(new String[]{"单选题", "错误答案", "甲", "乙", "", "", "C", ""}); + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> ReflectionTestUtils.invokeMethod(service, "parseQuestionSpreadsheet", file)); + assertTrue(error.getMessage().contains("Excel 第 3 行")); + } + + @Test + void generatesReadableWordAndExcelTemplates() throws Exception { + MockHttpServletResponse wordResponse = new MockHttpServletResponse(); + service.questionTemplate("word", wordResponse); + byte[] wordTemplate = wordResponse.getContentAsByteArray(); + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(wordTemplate))) { + String text = document.getParagraphs().stream().map(XWPFParagraph::getText).collect(java.util.stream.Collectors.joining("\n")); + assertTrue(text.contains("同一题型连续出题")); + assertTrue(text.contains("题干:2. Min-Max")); + assertTrue(text.contains("答案:A,B,C")); + } + MockMultipartFile generatedWord = new MockMultipartFile("file", "template.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", wordTemplate); + List> generatedQuestions = ReflectionTestUtils.invokeMethod(service, "parseQuestionDocument", generatedWord); + assertEquals(5, generatedQuestions.size()); + assertEquals(ExamService.SINGLE, generatedQuestions.get(1).get("type")); + + MockHttpServletResponse excelResponse = new MockHttpServletResponse(); + service.questionTemplate("excel", excelResponse); + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(excelResponse.getContentAsByteArray()))) { + assertEquals(2, workbook.getNumberOfSheets()); + assertEquals("题目导入", workbook.getSheetAt(0).getSheetName()); + assertEquals("填写说明", workbook.getSheetAt(1).getSheetName()); + assertEquals("题型", workbook.getSheetAt(0).getRow(2).getCell(0).getStringCellValue()); + assertEquals("", workbook.getSheetAt(0).getRow(3).getCell(1).getStringCellValue()); + assertEquals("单选题", workbook.getSheetAt(1).getRow(10).getCell(0).getStringCellValue()); + } + } + @Test void comparesObjectiveAnswersWithoutDependingOnChoiceOrderOrCase() { Boolean same = ReflectionTestUtils.invokeMethod(ExamService.class, "answersEqual", Arrays.asList("A", "B"), Arrays.asList("b", "A")); @@ -181,6 +264,24 @@ class ExamServiceTest { "application/vnd.openxmlformats-officedocument.wordprocessingml.document", output.toByteArray()); } + private MockMultipartFile xlsx(String[]... questions) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (Workbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("题目导入"); + sheet.createRow(0).createCell(0).setCellValue("说明行"); + Row header = sheet.createRow(1); + String[] headers = {"题型", "题干", "选项A", "选项B", "选项C", "选项D", "正确答案", "答案解析"}; + for (int i = 0; i < headers.length; i++) header.createCell(i).setCellValue(headers[i]); + for (int i = 0; i < questions.length; i++) { + Row row = sheet.createRow(i + 2); + for (int j = 0; j < questions[i].length; j++) row.createCell(j).setCellValue(questions[i][j]); + } + workbook.write(output); + } + return new MockMultipartFile("file", "questions.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", output.toByteArray()); + } + private static Timestamp timestamp(long value) { return new Timestamp(value); }