fix: 教师可选用本校题库题目组卷;简答题资料支持Excel;未发布考试可删除

- 保存/发布考试时题目校验放宽为:平台题、自己的题、本校教师的题,
  与选题列表(scope=school)口径一致,修复"题目不存在或无权访问"
- 简答题题目资料(教师端/平台管理端)新增 xls、xlsx,学生作答文件规则不变
- 未发布(DRAFT)的考试允许直接删除,其余状态规则不变
fix/exam-question-access-material-excel-delete-draft
chenyuan 5 days ago
parent 960de29bf7
commit 78bb701e53

@ -52,6 +52,7 @@ public class ExamService {
private static final Set<String> MULTIPLE_SCORE_MODES = new HashSet<>(Arrays.asList(ALL_OR_NOTHING, PARTIAL_BY_COUNT, HALF_FOR_PARTIAL));
private static final Set<String> TYPES = new LinkedHashSet<>(Arrays.asList(SINGLE, MULTIPLE, TRUE_FALSE, SHORT_ANSWER));
private static final Set<String> ALLOWED_ATTACHMENTS = new HashSet<>(Arrays.asList("pdf", "doc", "docx", "png", "jpg", "jpeg"));
private static final Set<String> ALLOWED_MATERIALS = new HashSet<>(Arrays.asList("pdf", "doc", "docx", "xls", "xlsx", "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*(.+)$");
@ -133,7 +134,7 @@ public class ExamService {
public Map<String, Object> uploadQuestionAttachment(JwtUser teacher, MultipartFile file) {
requireTeacher(teacher);
validateAttachment(file);
validateMaterial(file);
String path = fileUtil.upload(file);
return map("name", safeFilename(file.getOriginalFilename()), "url", "/" + path.replace('\\', '/'));
}
@ -310,7 +311,7 @@ public class ExamService {
int order = 1;
for (Map<String, Object> item : paper) {
long questionId = number(item.get("questionId")).longValue();
Map<String, Object> question = getOwnedQuestion(teacher, questionId);
Map<String, Object> question = getUsableQuestion(teacher, questionId);
jdbc.update("insert into exam_assessment_question(exam_id,question_id,question_order,question_score,question_type,stem,options_json,answer_json,answer_analysis,attachment_name,attachment_url) values(?,?,?,?,?,?,?,?,?,?,?)",
examId, questionId, order++, decimal(item.get("score")), question.get("type"), question.get("stem"), json(question.get("options")),
json(question.get("answer")), question.get("analysis"), question.get("attachmentName"), question.get("attachmentUrl"));
@ -676,6 +677,13 @@ public class ExamService {
return rows.get(0);
}
private Map<String, Object> getUsableQuestion(JwtUser teacher, long id) {
String schoolId = teacher.getSchoolId();
List<Map<String, Object>> rows = jdbc.query("select q.*,t.project_name,t.task_name from exam_question q left join training_task t on t.task_key=q.task_key where q.id=? and (q.source_type='PLATFORM' or (q.source_type='TEACHER' and (q.teacher_id=? or (? is not null and q.school_id=?))))", (rs, n) -> questionRow(rs, true), id, teacher.getUserId(), schoolId, schoolId);
if (rows.isEmpty()) throw new IllegalArgumentException("题目不存在或无权访问");
return rows.get(0);
}
private Map<String, Object> ownedExam(JwtUser teacher, long id) {
requireTeacher(teacher);
List<Map<String, Object>> rows = jdbc.query("select e.*,(select coalesce(sum(question_score),0) from exam_assessment_question q where q.exam_id=e.id) total_score from exam_assessment e where e.id=? and e.teacher_id=?",
@ -747,7 +755,8 @@ public class ExamService {
}
static void validateDelete(String status, Object exportedAt) {
requireState(status, "ENDED", "只有已结束考试可以删除");
if ("DRAFT".equals(status)) return;
requireState(status, "ENDED", "只有未发布或已结束的考试可以删除");
if (exportedAt == null) throw new IllegalArgumentException("请先导出全部试卷后再删除考试");
}
@ -800,7 +809,7 @@ public class ExamService {
for (Map<String, Object> item : paper) {
long id = number(item.get("questionId")).longValue();
if (!seen.add(id)) throw new IllegalArgumentException("试卷中存在重复题目");
getOwnedQuestion(teacher, id);
getUsableQuestion(teacher, id);
if (decimal(item.get("score")).signum() <= 0) throw new IllegalArgumentException("每题分值必须大于0");
}
}
@ -1188,7 +1197,9 @@ public class ExamService {
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 void validateAttachment(MultipartFile file) { validateAttachment(file, ALLOWED_ATTACHMENTS, "附件仅支持PDF、Word和图片格式"); }
private static void validateMaterial(MultipartFile file) { validateAttachment(file, ALLOWED_MATERIALS, "题目资料仅支持PDF、Word、Excel和图片格式"); }
private static void validateAttachment(MultipartFile file, Set<String> allowed, String unsupportedMessage) { 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.contains(n.substring(i + 1).toLowerCase())) throw new IllegalArgumentException(unsupportedMessage); }
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("仅教师可以操作"); }
private static void requireStudent(JwtUser user) { if (user == null) throw new AccessDeniedException("请先登录"); if (user.getRoleId() != 4) throw new AccessDeniedException("仅学生可以操作"); }

@ -41,7 +41,7 @@ public class PlatformQuestionBankService {
if (file.getSize() > 20L * 1024 * 1024) throw new IllegalArgumentException("资料不能超过20MB");
String name = StringUtils.defaultString(file.getOriginalFilename()).replace("\\", "_").replace("/", "_");
int dot = name.lastIndexOf('.'); String ext = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
if (!Arrays.asList("pdf", "doc", "docx", "png", "jpg", "jpeg").contains(ext)) throw new IllegalArgumentException("资料仅支持PDF、Word和图片格式");
if (!Arrays.asList("pdf", "doc", "docx", "xls", "xlsx", "png", "jpg", "jpeg").contains(ext)) throw new IllegalArgumentException("资料仅支持PDF、Word、Excel和图片格式");
String path = fileUtil.upload(file);
return map("name", name, "url", "/" + path.replace('\\', '/'));
}

@ -41,6 +41,30 @@ class ExamServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final ExamService service = new ExamService(jdbc, new ObjectMapper(), mock(IFileUtil.class));
@Test
void questionMaterialAcceptsWordAndExcelButRejectsOtherTypes() {
IFileUtil fileUtil = mock(IFileUtil.class);
when(fileUtil.upload(any(org.springframework.web.multipart.MultipartFile.class))).thenReturn("exam/material");
ExamService uploadService = new ExamService(jdbc, new ObjectMapper(), fileUtil);
for (String name : Arrays.asList("资料.doc", "资料.docx", "数据.xls", "数据.XLSX", "说明.pdf", "图.png")) {
Map<String, Object> result = uploadService.uploadQuestionAttachment(teacher(), new MockMultipartFile("file", name, "application/octet-stream", new byte[]{1}));
assertEquals(name, result.get("name"));
}
for (String name : Arrays.asList("脚本.exe", "数据.csv", "无扩展名")) {
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> uploadService.uploadQuestionAttachment(teacher(), new MockMultipartFile("file", name, "application/octet-stream", new byte[]{1})));
assertEquals("题目资料仅支持PDF、Word、Excel和图片格式", error.getMessage());
}
}
@Test
void studentAnswerFilesStillRejectExcel() {
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> service.uploadAnswerFile(student(), 1L, 1L, "device", new MockMultipartFile("file", "答案.xlsx", "application/octet-stream", new byte[]{1})));
assertEquals("附件仅支持PDF、Word和图片格式", error.getMessage());
}
@Test
void recognizesOnlyCompletePlainOrBracketedQuestionTypeMarkers() {
assertEquals(ExamService.SINGLE, ExamService.typeMarker("[单选题]"));
@ -202,6 +226,9 @@ class ExamServiceTest {
assertThrows(IllegalArgumentException.class, () -> ExamService.requireState("IN_PROGRESS", "NOT_STARTED", "error"));
assertThrows(IllegalArgumentException.class, () -> ExamService.validateDelete("IN_PROGRESS", new Object()));
assertThrows(IllegalArgumentException.class, () -> ExamService.validateDelete("ENDED", null));
assertThrows(IllegalArgumentException.class, () -> ExamService.validateDelete("NOT_STARTED", new Object()));
ExamService.validateDelete("DRAFT", null);
ExamService.validateDelete("ENDED", new Object());
}
@Test

Loading…
Cancel
Save