feat: support exam question template formats and grading

fix/exam-question-access-material-excel-delete-draft
chenyuan 3 weeks ago
parent 3af6320739
commit c74c803618

@ -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")

@ -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<String> 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<Map<String, Object>> importQuestions(JwtUser teacher, MultipartFile file) {
requireTeacher(teacher);
validateDocx(file);
List<Map<String, Object>> parsed = parseQuestionDocument(file);
String format = validateQuestionImportFile(file);
List<Map<String, Object>> parsed = "docx".equals(format)
? parseQuestionDocument(file) : parseQuestionSpreadsheet(file);
if (parsed.isEmpty()) throw new IllegalArgumentException("未读取到符合模板的题目");
List<Map<String, Object>> created = new ArrayList<>();
for (Map<String, Object> 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<String, Object> teacherExam(JwtUser teacher, long id) {
requireTeacher(teacher); autoSubmitExpired();
Map<String, Object> 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<String, Object> 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<Map<String, Object>> parseQuestionDocument(MultipartFile file) {
try (XWPFDocument doc = new XWPFDocument(file.getInputStream())) {
List<String> lines = doc.getParagraphs().stream().map(XWPFParagraph::getText).map(String::trim).collect(Collectors.toList());
List<Map<String, Object>> result = new ArrayList<>(); Map<String, Object> current = null; List<Map<String, Object>> options = null;
List<String> 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<Map<String, Object>> result = new ArrayList<>();
String currentType = null; String activeField = null;
Map<String, Object> current = null; List<Map<String, Object>> options = null; Map<String, Object> 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 (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));
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) finishImported(current, options, result);
if (current == null || line.isEmpty()) continue;
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, "第 " + (result.size() + 1) + " 题");
return result;
} catch (IOException e) { throw new IllegalArgumentException("Word文件读取失败"); }
}
private void finishImported(Map<String, Object> current, List<Map<String, Object>> options, List<Map<String, Object>> result) {
current.put("options", options); validateQuestion(current); result.add(current);
private List<Map<String, Object>> 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<String, Integer> headers = questionHeaders(sheet.getRow(headerRowIndex), formatter);
List<Map<String, Object>> 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<String, Object> question = new LinkedHashMap<>();
question.put("type", type);
question.put("stem", stripQuestionNumber(questionCell(row, headers.get("题干"), formatter)));
List<Map<String, Object>> 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<String, Object> current, List<Map<String, Object>> options,
List<Map<String, Object>> 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<String, Object> 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<String, Integer> headers = questionHeaders(sheet.getRow(i), formatter);
if (headers.containsKey("题型") && headers.containsKey("题干") && headers.containsKey("正确答案")) return i;
}
return -1;
}
private Map<String, Integer> questionHeaders(Row row, DataFormatter formatter) {
Map<String, Integer> 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<String, Integer> 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<String, Object> exam, List<Map<String, Object>> questions, Map<String, Object> 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("仅教师可以操作"); }

@ -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<Map<String, Object>> parsed = ReflectionTestUtils.invokeMethod(service, "parseQuestionDocument", file);
assertEquals(2, parsed.size());
assertEquals("第一题第一行\n题干续行", parsed.get(0).get("stem"));
assertEquals("甲\n选项甲续行", ((List<Map<String, Object>>) 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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);
}

Loading…
Cancel
Save