diff --git a/docs/sql/2026-09-15-exam.sql b/docs/sql/2026-09-15-exam.sql new file mode 100644 index 0000000..e743297 --- /dev/null +++ b/docs/sql/2026-09-15-exam.sql @@ -0,0 +1,91 @@ +-- Exam module schema. Execute explicitly as a database deployment step. +CREATE TABLE IF NOT EXISTS exam_question ( + id bigint NOT NULL AUTO_INCREMENT, + school_id varchar(64) NOT NULL, + teacher_id varchar(64) NOT NULL, + question_type varchar(24) NOT NULL, + stem longtext NOT NULL, + options_json longtext NULL, + answer_json longtext NOT NULL, + answer_analysis longtext NULL, + attachment_name varchar(255) NULL, + attachment_url varchar(512) NULL, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_exam_question_owner (teacher_id, question_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS exam_assessment ( + id bigint NOT NULL AUTO_INCREMENT, + school_id varchar(64) NOT NULL, + teacher_id varchar(64) NOT NULL, + exam_name varchar(20) NOT NULL, + start_time datetime NOT NULL, + end_time datetime NOT NULL, + publication_status varchar(16) NOT NULL DEFAULT 'DRAFT', + allow_rollback bit NOT NULL DEFAULT 1, + exported_at datetime NULL, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_exam_school_name (school_id, exam_name), + KEY idx_exam_teacher (teacher_id, start_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS exam_assessment_class ( + exam_id bigint NOT NULL, + teaching_class_id varchar(64) NOT NULL, + PRIMARY KEY (exam_id, teaching_class_id), + KEY idx_exam_class (teaching_class_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS exam_assessment_question ( + exam_id bigint NOT NULL, + question_id bigint NOT NULL, + question_order int NOT NULL, + question_score decimal(7,2) NOT NULL, + question_type varchar(24) NOT NULL, + stem longtext NOT NULL, + options_json longtext NULL, + answer_json longtext NOT NULL, + answer_analysis longtext NULL, + attachment_name varchar(255) NULL, + attachment_url varchar(512) NULL, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (exam_id, question_id), + KEY idx_exam_question_order (exam_id, question_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS exam_attempt ( + id bigint NOT NULL AUTO_INCREMENT, + exam_id bigint NOT NULL, + student_id varchar(64) NOT NULL, + teaching_class_id varchar(64) NOT NULL, + attempt_status varchar(16) NOT NULL DEFAULT 'IN_PROGRESS', + device_token varchar(128) NOT NULL, + started_at datetime NOT NULL, + submitted_at datetime NULL, + auto_submitted bit NOT NULL DEFAULT 0, + objective_score decimal(7,2) NOT NULL DEFAULT 0, + subjective_score decimal(7,2) NOT NULL DEFAULT 0, + total_score decimal(7,2) NOT NULL DEFAULT 0, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_exam_student (exam_id, student_id), + KEY idx_attempt_exam_status (exam_id, attempt_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS exam_attempt_answer ( + attempt_id bigint NOT NULL, + question_id bigint NOT NULL, + answer_json longtext NULL, + answer_file_name varchar(255) NULL, + answer_file_url varchar(512) NULL, + is_correct bit NULL, + awarded_score decimal(7,2) NULL, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (attempt_id, question_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/stu/ExamController.java b/src/main/java/com/sztzjy/linkCommerce/controller/stu/ExamController.java new file mode 100644 index 0000000..1f2b234 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/controller/stu/ExamController.java @@ -0,0 +1,187 @@ +package com.sztzjy.linkCommerce.controller.stu; + +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.config.security.TokenProvider; +import com.sztzjy.linkCommerce.service.ExamService; +import com.sztzjy.linkCommerce.util.ResultEntity; +import io.swagger.annotations.Api; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +@Api(tags = "考试考核") +@RestController +@RequestMapping("api/exams") +public class ExamController { + private final ExamService examService; + + public ExamController(ExamService examService) { + this.examService = examService; + } + + @GetMapping("/teacher/classes") + public ResultEntity>> classes(HttpServletRequest request) { + return ok(examService.teacherClasses(user(request))); + } + + @GetMapping("/teacher/questions") + public ResultEntity>> questions(@RequestParam(required = false) String type, + @RequestParam(required = false) String keyword, + HttpServletRequest request) { + return ok(examService.questions(user(request), type, keyword)); + } + + @PostMapping("/teacher/questions") + public ResultEntity> createQuestion(@RequestBody Map payload, HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "新增成功", examService.createQuestion(user(request), payload)); + } + + @PutMapping("/teacher/questions/{id}") + public ResultEntity> updateQuestion(@PathVariable long id, @RequestBody Map payload, HttpServletRequest request) { + return ok(examService.updateQuestion(user(request), id, payload)); + } + + @DeleteMapping("/teacher/questions/{id}") + public ResultEntity deleteQuestion(@PathVariable long id, HttpServletRequest request) { + examService.deleteQuestion(user(request), id); return new ResultEntity<>(HttpStatus.OK, "删除成功"); + } + + @PostMapping("/teacher/questions/attachment") + public ResultEntity> questionAttachment(@RequestPart("file") MultipartFile file, HttpServletRequest request) { + return ok(examService.uploadQuestionAttachment(user(request), file)); + } + + @PostMapping("/teacher/questions/import") + public ResultEntity>> importQuestions(@RequestPart("file") MultipartFile file, HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "导入成功", examService.importQuestions(user(request), file)); + } + + @GetMapping("/teacher/questions/template") + public void questionTemplate(HttpServletRequest request, HttpServletResponse response) throws IOException { + examService.teacherClasses(user(request)); examService.questionTemplate(response); + } + + @GetMapping("/teacher") + public ResultEntity>> exams(@RequestParam(required = false) String keyword, HttpServletRequest request) { + return ok(examService.teacherExams(user(request), keyword)); + } + + @GetMapping("/teacher/{id}") + public ResultEntity> exam(@PathVariable long id, HttpServletRequest request) { + return ok(examService.teacherExam(user(request), id)); + } + + @PostMapping("/teacher") + public ResultEntity> createExam(@RequestBody Map payload, HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "保存成功", examService.saveExam(user(request), null, payload)); + } + + @PutMapping("/teacher/{id}") + public ResultEntity> updateExam(@PathVariable long id, @RequestBody Map payload, HttpServletRequest request) { + return ok(examService.saveExam(user(request), id, payload)); + } + + @PostMapping("/teacher/{id}/publish") + public ResultEntity publish(@PathVariable long id, HttpServletRequest request) { + examService.publish(user(request), id); return new ResultEntity<>(HttpStatus.OK, "发布成功"); + } + + @PostMapping("/teacher/{id}/withdraw") + public ResultEntity withdraw(@PathVariable long id, HttpServletRequest request) { + examService.withdraw(user(request), id); return new ResultEntity<>(HttpStatus.OK, "撤回成功"); + } + + @DeleteMapping("/teacher/{id}") + public ResultEntity deleteExam(@PathVariable long id, HttpServletRequest request) { + examService.deleteExam(user(request), id); return new ResultEntity<>(HttpStatus.OK, "删除成功"); + } + + @GetMapping("/teacher/{id}/paper") + public void exportPaper(@PathVariable long id, HttpServletRequest request, HttpServletResponse response) throws IOException { + examService.exportPaper(user(request), id, response); + } + + @GetMapping("/teacher/{id}/review") + public ResultEntity>> reviewStudents(@PathVariable long id, HttpServletRequest request) { + return ok(examService.reviewStudents(user(request), id)); + } + + @GetMapping("/teacher/{id}/review/{attemptId}") + public ResultEntity> reviewDetail(@PathVariable long id, @PathVariable long attemptId, HttpServletRequest request) { + return ok(examService.reviewDetail(user(request), id, attemptId)); + } + + @PutMapping("/teacher/{id}/review/{attemptId}") + public ResultEntity> score(@PathVariable long id, @PathVariable long attemptId, + @RequestBody Map scores, HttpServletRequest request) { + return ok(examService.scoreSubjective(user(request), id, attemptId, scores)); + } + + @PostMapping("/teacher/{id}/review/{attemptId}/rollback") + public ResultEntity rollback(@PathVariable long id, @PathVariable long attemptId, HttpServletRequest request) { + examService.rollback(user(request), id, attemptId); return new ResultEntity<>(HttpStatus.OK, "回退成功"); + } + + @GetMapping("/teacher/{id}/scores") + public void exportScores(@PathVariable long id, HttpServletRequest request, HttpServletResponse response) throws IOException { + examService.exportScores(user(request), id, response); + } + + @GetMapping("/teacher/{id}/analysis") + public ResultEntity> analysis(@PathVariable long id, @RequestParam(required = false) String classId, HttpServletRequest request) { + return ok(examService.analysis(user(request), id, classId)); + } + + @GetMapping("/teacher/{id}/submissions") + public void exportSubmissions(@PathVariable long id, HttpServletRequest request, HttpServletResponse response) throws IOException { + examService.exportSubmissions(user(request), id, response); + } + + @GetMapping("/student") + public ResultEntity>> studentExams(HttpServletRequest request) { + return ok(examService.studentExams(user(request))); + } + + @PostMapping("/student/{id}/enter") + public ResultEntity> enter(@PathVariable long id, @RequestBody Map payload, HttpServletRequest request) { + return ok(examService.enterExam(user(request), id, payload.get("deviceToken"))); + } + + @GetMapping("/student/{id}/paper") + public ResultEntity> paper(@PathVariable long id, @RequestParam String deviceToken, HttpServletRequest request) { + return ok(examService.activePaper(user(request), id, deviceToken)); + } + + @PutMapping("/student/{id}/answers/{questionId}") + public ResultEntity saveAnswer(@PathVariable long id, @PathVariable long questionId, + @RequestBody Map payload, HttpServletRequest request) { + examService.saveAnswer(user(request), id, questionId, String.valueOf(payload.get("deviceToken")), payload.get("answer")); + return new ResultEntity<>(HttpStatus.OK, "保存成功"); + } + + @PostMapping("/student/{id}/answers/{questionId}/file") + public ResultEntity> answerFile(@PathVariable long id, @PathVariable long questionId, + @RequestParam String deviceToken, @RequestPart("file") MultipartFile file, + HttpServletRequest request) { + return ok(examService.uploadAnswerFile(user(request), id, questionId, deviceToken, file)); + } + + @PostMapping("/student/{id}/submit") + public ResultEntity> submit(@PathVariable long id, @RequestBody Map payload, HttpServletRequest request) { + return new ResultEntity<>(HttpStatus.OK, "交卷成功", examService.submit(user(request), id, payload.get("deviceToken"))); + } + + @GetMapping("/student/{id}/result") + public ResultEntity> result(@PathVariable long id, HttpServletRequest request) { + return ok(examService.studentResult(user(request), id)); + } + + private JwtUser user(HttpServletRequest request) { return TokenProvider.getJWTUser(request); } + private ResultEntity ok(T data) { return new ResultEntity<>(HttpStatus.OK, "查询成功", data); } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/service/ExamService.java b/src/main/java/com/sztzjy/linkCommerce/service/ExamService.java new file mode 100644 index 0000000..e27444e --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/service/ExamService.java @@ -0,0 +1,727 @@ +package com.sztzjy.linkCommerce.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +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.xssf.usermodel.XSSFWorkbook; +import org.apache.poi.xwpf.usermodel.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.io.*; +import java.math.BigDecimal; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.sql.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +@Service +public class ExamService { + public static final String SINGLE = "SINGLE"; + public static final String MULTIPLE = "MULTIPLE"; + public static final String TRUE_FALSE = "TRUE_FALSE"; + public static final String SHORT_ANSWER = "SHORT_ANSWER"; + private static final Set TYPES = new LinkedHashSet<>(Arrays.asList(SINGLE, MULTIPLE, TRUE_FALSE, SHORT_ANSWER)); + 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 final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final IFileUtil fileUtil; + + public ExamService(JdbcTemplate jdbc, ObjectMapper objectMapper, IFileUtil fileUtil) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.fileUtil = fileUtil; + } + + public List> teacherClasses(JwtUser teacher) { + requireTeacher(teacher); + return jdbc.query("select school_class_id as id, class_name as name from school_class " + + "where created_by=? and (status is null or status=1) and (class_type is null or class_type='TEACHING') order by create_time desc", + (rs, n) -> map("id", rs.getString("id"), "name", rs.getString("name")), teacher.getUserId()); + } + + public List> questions(JwtUser teacher, String type, String keyword) { + requireTeacher(teacher); + StringBuilder sql = new StringBuilder("select * from exam_question where teacher_id=?"); + List args = new ArrayList<>(); + args.add(teacher.getUserId()); + if (StringUtils.isNotBlank(type)) { validateType(type); sql.append(" and question_type=?"); args.add(type); } + if (StringUtils.isNotBlank(keyword)) { sql.append(" and stem like ?"); args.add("%" + keyword.trim() + "%"); } + sql.append(" order by update_time desc, id desc"); + return jdbc.query(sql.toString(), (rs, n) -> questionRow(rs, true), args.toArray()); + } + + public Map createQuestion(JwtUser teacher, Map payload) { + requireTeacher(teacher); + QuestionData q = validateQuestion(payload); + jdbc.update("insert into exam_question(school_id,teacher_id,question_type,stem,options_json,answer_json,answer_analysis,attachment_name,attachment_url) values(?,?,?,?,?,?,?,?,?)", + teacher.getSchoolId(), teacher.getUserId(), q.type, q.stem, json(q.options), json(q.answer), q.analysis, + text(payload.get("attachmentName")), text(payload.get("attachmentUrl"))); + Long id = jdbc.queryForObject("select last_insert_id()", Long.class); + return getOwnedQuestion(teacher, id); + } + + public Map updateQuestion(JwtUser teacher, long id, Map payload) { + requireTeacher(teacher); + getOwnedQuestion(teacher, id); + QuestionData q = validateQuestion(payload); + jdbc.update("update exam_question set question_type=?,stem=?,options_json=?,answer_json=?,answer_analysis=?,attachment_name=?,attachment_url=? where id=? and teacher_id=?", + q.type, q.stem, json(q.options), json(q.answer), q.analysis, + text(payload.get("attachmentName")), text(payload.get("attachmentUrl")), id, teacher.getUserId()); + return getOwnedQuestion(teacher, id); + } + + public void deleteQuestion(JwtUser teacher, long id) { + requireTeacher(teacher); + getOwnedQuestion(teacher, id); + jdbc.update("delete from exam_question where id=? and teacher_id=?", id, teacher.getUserId()); + } + + public Map uploadQuestionAttachment(JwtUser teacher, MultipartFile file) { + requireTeacher(teacher); + validateAttachment(file); + String path = fileUtil.upload(file); + return map("name", safeFilename(file.getOriginalFilename()), "url", "/" + path.replace('\\', '/')); + } + + @Transactional + public List> importQuestions(JwtUser teacher, MultipartFile file) { + requireTeacher(teacher); + validateDocx(file); + List> parsed = parseQuestionDocument(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 { + 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"); + } + } + + public List> teacherExams(JwtUser teacher, String keyword) { + requireTeacher(teacher); autoSubmitExpired(); + String sql = "select e.*, (select count(*) from exam_assessment_class c where c.exam_id=e.id) class_count," + + "(select count(*) from exam_assessment_question q where q.exam_id=e.id) question_count," + + "(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.teacher_id=?" + (StringUtils.isBlank(keyword) ? "" : " and e.exam_name like ?") + " order by e.create_time desc"; + Object[] args = StringUtils.isBlank(keyword) ? new Object[]{teacher.getUserId()} : new Object[]{teacher.getUserId(), "%" + keyword.trim() + "%"}; + return jdbc.query(sql, (rs, n) -> examSummary(rs), args); + } + + 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=?", + (rs, n) -> map("id", rs.getString("id"), "name", rs.getString("name")), id)); + result.put("questions", examQuestions(id, true)); + return result; + } + + @Transactional + public Map saveExam(JwtUser teacher, Long id, Map payload) { + requireTeacher(teacher); + if (id != null) { + Map existing = ownedExam(teacher, id); + String status = String.valueOf(existing.get("status")); + if ("IN_PROGRESS".equals(status)) { + Timestamp end = parseTime(payload.get("endTime")); + Timestamp start = (Timestamp) existing.get("startTime"); + if (!end.after(start)) throw new IllegalArgumentException("考试结束时间必须晚于开始时间"); + jdbc.update("update exam_assessment set end_time=? where id=?", end, id); + return teacherExam(teacher, id); + } + if (!"DRAFT".equals(status)) throw new IllegalArgumentException("已发布考试请先撤回后再编辑"); + } + String name = required(payload, "name", "考试名称不能为空"); + if (name.length() > 20 || !name.matches("[\\p{L}\\p{N}]+")) throw new IllegalArgumentException("考试名称仅支持汉字、字母和数字,且不超过20个字符"); + Timestamp start = parseTime(payload.get("startTime")); + Timestamp end = parseTime(payload.get("endTime")); + if (!end.after(start)) throw new IllegalArgumentException("考试结束时间必须晚于开始时间"); + List classIds = stringList(payload.get("classIds")); + List> paper = mapList(payload.get("questions")); + if (classIds.isEmpty()) throw new IllegalArgumentException("至少选择一个考试班级"); + if (paper.isEmpty()) throw new IllegalArgumentException("至少选择一道试题"); + validateOwnedClasses(teacher, classIds); + validatePaper(teacher, paper); + int duplicate = jdbc.queryForObject("select count(*) from exam_assessment where school_id=? and exam_name=?" + (id == null ? "" : " and id<>?"), Integer.class, + id == null ? new Object[]{teacher.getSchoolId(), name} : new Object[]{teacher.getSchoolId(), name, id}); + if (duplicate > 0) throw new IllegalArgumentException("考试名称已存在"); + boolean allowRollback = bool(payload.get("allowRollback"), true); + long examId; + if (id == null) { + jdbc.update("insert into exam_assessment(school_id,teacher_id,exam_name,start_time,end_time,allow_rollback) values(?,?,?,?,?,?)", + teacher.getSchoolId(), teacher.getUserId(), name, start, end, allowRollback); + examId = jdbc.queryForObject("select last_insert_id()", Long.class); + } else { + jdbc.update("update exam_assessment set exam_name=?,start_time=?,end_time=?,allow_rollback=? where id=?", name, start, end, allowRollback, id); + jdbc.update("delete from exam_assessment_class where exam_id=?", id); + jdbc.update("delete from exam_assessment_question where exam_id=?", id); + examId = id; + } + for (String classId : classIds) jdbc.update("insert into exam_assessment_class(exam_id,teaching_class_id) values(?,?)", examId, classId); + int order = 1; + for (Map item : paper) { + long questionId = number(item.get("questionId")).longValue(); + Map question = getOwnedQuestion(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")); + } + return teacherExam(teacher, examId); + } + + public void publish(JwtUser teacher, long id) { + Map exam = ownedExam(teacher, id); + requireState(String.valueOf(exam.get("status")), "DRAFT", "只有未发布考试可以发布"); + jdbc.update("update exam_assessment set publication_status='PUBLISHED' where id=?", id); + } + + public void withdraw(JwtUser teacher, long id) { + Map exam = ownedExam(teacher, id); + requireState(String.valueOf(exam.get("status")), "NOT_STARTED", "只有未开始考试可以撤回"); + jdbc.update("update exam_assessment set publication_status='DRAFT' where id=?", id); + } + + @Transactional + public void deleteExam(JwtUser teacher, long id) { + Map exam = ownedExam(teacher, id); + validateDelete(String.valueOf(exam.get("status")), exam.get("exportedAt")); + List attemptIds = jdbc.queryForList("select id from exam_attempt where exam_id=?", Long.class, id); + for (Long attemptId : attemptIds) jdbc.update("delete from exam_attempt_answer where attempt_id=?", attemptId); + jdbc.update("delete from exam_attempt where exam_id=?", id); + jdbc.update("delete from exam_assessment_question where exam_id=?", id); + jdbc.update("delete from exam_assessment_class where exam_id=?", id); + jdbc.update("delete from exam_assessment where id=?", id); + } + + public void exportPaper(JwtUser teacher, long id, HttpServletResponse response) throws IOException { + Map exam = ownedExam(teacher, id); + try (XWPFDocument doc = buildPaperDocument(exam, examQuestions(id, true), null, false)) { + writeDocx(response, doc, exam.get("name") + "试卷.docx"); + } + } + + public List> reviewStudents(JwtUser teacher, long examId) { + 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 " + + "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"), + "className", rs.getString("class_name"), "name", rs.getString("name"), "studentNo", rs.getString("username"), + "status", rs.getString("attempt_status"), "objectiveScore", rs.getBigDecimal("objective_score"), + "subjectiveScore", rs.getBigDecimal("subjective_score"), "totalScore", rs.getBigDecimal("total_score"), + "submittedAt", rs.getTimestamp("submitted_at")), examId); + } + + public Map reviewDetail(JwtUser teacher, long examId, long attemptId) { + ownedExam(teacher, examId); + Map attempt = attemptRow(examId, attemptId); + if (!"SUBMITTED".equals(attempt.get("status"))) throw new IllegalArgumentException("只能评阅已交卷试卷"); + attempt.put("questions", answeredQuestions(examId, attemptId, true)); + return attempt; + } + + @Transactional + public Map scoreSubjective(JwtUser teacher, long examId, long attemptId, Map scores) { + ownedExam(teacher, examId); + Map attempt = attemptRow(examId, attemptId); + if (!"SUBMITTED".equals(attempt.get("status"))) throw new IllegalArgumentException("只能评阅已交卷试卷"); + Map maxScores = examQuestions(examId, true).stream().filter(q -> SHORT_ANSWER.equals(q.get("type"))) + .collect(Collectors.toMap(q -> ((Number) q.get("id")).longValue(), q -> decimal(q.get("score")))); + for (Map.Entry entry : scores.entrySet()) { + long questionId = Long.parseLong(entry.getKey()); + BigDecimal score = decimal(entry.getValue()); + BigDecimal max = maxScores.get(questionId); + if (max == null || score.signum() < 0 || score.compareTo(max) > 0) throw new IllegalArgumentException("主观题评分必须在0至题目分值之间"); + jdbc.update("insert into exam_attempt_answer(attempt_id,question_id,awarded_score) values(?,?,?) " + + "on duplicate key update awarded_score=values(awarded_score)", attemptId, questionId, score); + } + recalculateAttempt(attemptId); + return reviewDetail(teacher, examId, attemptId); + } + + public void exportScores(JwtUser teacher, long examId, HttpServletResponse response) throws IOException { + Map exam = ownedExam(teacher, examId); + List> rows = reviewStudents(teacher, examId); + try (Workbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("班级成绩"); + String[] headers = {"班级", "姓名", "学号", "客观题得分", "主观题得分", "总分"}; + Row header = sheet.createRow(0); + for (int i = 0; i < headers.length; i++) header.createCell(i).setCellValue(headers[i]); + for (int i = 0; i < rows.size(); i++) { + Map item = rows.get(i); Row row = sheet.createRow(i + 1); + row.createCell(0).setCellValue(text(item.get("className"))); row.createCell(1).setCellValue(text(item.get("name"))); + row.createCell(2).setCellValue(text(item.get("studentNo"))); row.createCell(3).setCellValue(decimal(item.get("objectiveScore")).doubleValue()); + row.createCell(4).setCellValue(decimal(item.get("subjectiveScore")).doubleValue()); row.createCell(5).setCellValue(decimal(item.get("totalScore")).doubleValue()); + } + for (int i = 0; i < headers.length; i++) sheet.autoSizeColumn(i); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + downloadHeader(response, exam.get("name") + "班级成绩.xlsx"); workbook.write(response.getOutputStream()); + } + } + + public Map analysis(JwtUser teacher, long examId, String classId) { + Map exam = ownedExam(teacher, examId); autoSubmitExpired(); + List args = new ArrayList<>(); args.add(examId); + String classFilter = ""; + if (StringUtils.isNotBlank(classId)) { + Integer selected = jdbc.queryForObject("select count(*) from exam_assessment_class where exam_id=? and teaching_class_id=?", Integer.class, examId, classId); + if (selected == null || selected == 0) throw new IllegalArgumentException("所选班级不属于当前考试"); + classFilter = " and c.teaching_class_id=?"; args.add(classId); + } + int expected = jdbc.queryForObject("select count(distinct t.student_user_id) from exam_assessment_class c join teaching_class_student t on t.teaching_class_id=c.teaching_class_id and t.status='IN_PROGRESS' where c.exam_id=?" + classFilter, + Integer.class, args.toArray()); + List scores = jdbc.queryForList("select a.total_score from exam_attempt a where a.exam_id=? and a.attempt_status='SUBMITTED'" + + (StringUtils.isBlank(classId) ? "" : " and a.teaching_class_id=?"), BigDecimal.class, + StringUtils.isBlank(classId) ? new Object[]{examId} : new Object[]{examId, classId}); + BigDecimal total = decimal(exam.get("totalScore")); BigDecimal pass = total.multiply(new BigDecimal("0.60")); + int passed = (int) scores.stream().filter(s -> isPassing(s, total)).count(); + List> distribution = Arrays.asList( + bucket("优秀", scores, total.multiply(new BigDecimal("0.90")), null), + bucket("良好", scores, total.multiply(new BigDecimal("0.80")), total.multiply(new BigDecimal("0.90"))), + bucket("及格", scores, pass, total.multiply(new BigDecimal("0.80"))), + bucket("不及格", scores, BigDecimal.ZERO, pass)); + List> hardest = jdbc.query("select q.id,q.stem,count(*) wrong_count from exam_attempt_answer a " + + "join exam_attempt t on t.id=a.attempt_id join exam_question q on q.id=a.question_id where t.exam_id=? and t.attempt_status='SUBMITTED' and a.is_correct=0 " + + (StringUtils.isBlank(classId) ? "" : "and t.teaching_class_id=? ") + "group by q.id,q.stem order by wrong_count desc limit 3", + (rs, n) -> map("id", rs.getLong("id"), "stem", rs.getString("stem"), "wrongCount", rs.getInt("wrong_count")), + StringUtils.isBlank(classId) ? new Object[]{examId} : new Object[]{examId, classId}); + return map("examName", exam.get("name"), "expected", expected, "attended", scores.size(), "passed", passed, + "failed", scores.size() - passed, "highest", scores.stream().max(BigDecimal::compareTo).orElse(BigDecimal.ZERO), + "lowest", scores.stream().min(BigDecimal::compareTo).orElse(BigDecimal.ZERO), + "average", scores.isEmpty() ? BigDecimal.ZERO : scores.stream().reduce(BigDecimal.ZERO, BigDecimal::add).divide(new BigDecimal(scores.size()), 2, BigDecimal.ROUND_HALF_UP), + "distribution", distribution, "hardestQuestions", hardest); + } + + public void exportSubmissions(JwtUser teacher, long examId, HttpServletResponse response) throws IOException { + Map exam = ownedExam(teacher, examId); + if (!"ENDED".equals(exam.get("status"))) throw new IllegalArgumentException("考试结束后才能导出全部试卷"); + List> students = reviewStudents(teacher, examId); + response.setContentType("application/zip"); downloadHeader(response, exam.get("name") + "全部试卷.zip"); + try (ZipOutputStream zip = new ZipOutputStream(response.getOutputStream())) { + for (Map student : students) { + long attemptId = ((Number) student.get("attemptId")).longValue(); + List> questions = answeredQuestions(examId, attemptId, true); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (XWPFDocument doc = buildPaperDocument(exam, questions, student, true)) { doc.write(buffer); } + String base = cleanEntry(text(student.get("className")) + "_" + text(student.get("studentNo")) + "_" + text(student.get("name"))); + zip.putNextEntry(new ZipEntry(base + ".docx")); zip.write(buffer.toByteArray()); zip.closeEntry(); + for (Map q : questions) { + if (StringUtils.isBlank(text(q.get("answerFileUrl")))) continue; + File f = new File(fileUtil.getFullPath(text(q.get("answerFileUrl")))); + if (!f.exists()) continue; + zip.putNextEntry(new ZipEntry(base + "_附件/" + q.get("order") + "_" + cleanEntry(text(q.get("answerFileName"))))); + try (InputStream in = new FileInputStream(f)) { copy(in, zip); } zip.closeEntry(); + } + } + zip.finish(); + } + jdbc.update("update exam_assessment set exported_at=now() where id=?", examId); + } + + public List> studentExams(JwtUser student) { + requireStudent(student); autoSubmitExpired(); + return jdbc.query("select distinct e.*,a.id attempt_id,a.attempt_status,a.total_score from exam_assessment e " + + "join exam_assessment_class c on c.exam_id=e.id join teaching_class_student t on t.teaching_class_id=c.teaching_class_id and t.status='IN_PROGRESS' " + + "left join exam_attempt a on a.exam_id=e.id and a.student_id=? where t.student_user_id=? and e.publication_status='PUBLISHED' order by e.start_time desc", + (rs, n) -> { Map m = examSummary(rs); m.put("attemptId", rs.getObject("attempt_id")); m.put("attemptStatus", rs.getString("attempt_status")); + m.put("totalScore", rs.getBigDecimal("total_score")); return m; }, student.getUserId(), student.getUserId()); + } + + @Transactional + public Map enterExam(JwtUser student, long examId, String deviceToken) { + requireStudent(student); autoSubmitExpired(); + if (StringUtils.isBlank(deviceToken) || deviceToken.length() > 128) throw new IllegalArgumentException("设备标识无效"); + Map exam = accessibleStudentExam(student, examId); + if (!"IN_PROGRESS".equals(exam.get("status"))) throw new IllegalArgumentException("考试当前不可进入"); + List> attempts = jdbc.queryForList("select * from exam_attempt where exam_id=? and student_id=?", examId, student.getUserId()); + if (attempts.isEmpty()) { + String classId = jdbc.queryForObject("select c.teaching_class_id from exam_assessment_class c join teaching_class_student t on t.teaching_class_id=c.teaching_class_id and t.status='IN_PROGRESS' where c.exam_id=? and t.student_user_id=? limit 1", + String.class, examId, student.getUserId()); + jdbc.update("insert ignore into exam_attempt(exam_id,student_id,teaching_class_id,device_token,started_at) values(?,?,?,?,now())", examId, student.getUserId(), classId, deviceToken); + attempts = jdbc.queryForList("select * from exam_attempt where exam_id=? and student_id=?", examId, student.getUserId()); + } + if (attempts.isEmpty()) throw new IllegalArgumentException("进入考试失败,请重试"); + Map attempt = attempts.get(0); + long attemptId = ((Number) attempt.get("id")).longValue(); + if ("SUBMITTED".equals(attempt.get("attempt_status"))) throw new IllegalArgumentException("试卷已提交"); + if (!deviceToken.equals(attempt.get("device_token"))) throw new IllegalArgumentException("该考试已在其他设备进入,不能重复作答"); + return studentPaper(student, examId, attemptId); + } + + public Map activePaper(JwtUser student, long examId, String deviceToken) { + requireStudent(student); accessibleStudentExam(student, examId); autoSubmitExpired(); + long attemptId = requireAttempt(student, examId, deviceToken, true); + return studentPaper(student, examId, attemptId); + } + + @Transactional + public void saveAnswer(JwtUser student, long examId, long questionId, String deviceToken, Object answer) { + requireStudent(student); Map exam = accessibleStudentExam(student, examId); + if (!"IN_PROGRESS".equals(exam.get("status"))) throw new IllegalArgumentException("考试已结束,不能继续作答"); + long attemptId = requireAttempt(student, examId, deviceToken, true); + Map question = examQuestion(examId, questionId, true); + Boolean correct = SHORT_ANSWER.equals(question.get("type")) ? null : answersEqual(question.get("answer"), answer); + BigDecimal score = correct == null ? null : (correct ? decimal(question.get("score")) : BigDecimal.ZERO); + jdbc.update("insert into exam_attempt_answer(attempt_id,question_id,answer_json,is_correct,awarded_score) values(?,?,?,?,?) " + + "on duplicate key update answer_json=values(answer_json),is_correct=values(is_correct),awarded_score=values(awarded_score)", + attemptId, questionId, json(answer), correct, score); + } + + public Map uploadAnswerFile(JwtUser student, long examId, long questionId, String deviceToken, MultipartFile file) { + requireStudent(student); validateAttachment(file); + Map exam = accessibleStudentExam(student, examId); + if (!"IN_PROGRESS".equals(exam.get("status"))) throw new IllegalArgumentException("考试已结束,不能继续作答"); + long attemptId = requireAttempt(student, examId, deviceToken, true); + Map question = examQuestion(examId, questionId, false); + if (!SHORT_ANSWER.equals(question.get("type"))) throw new IllegalArgumentException("只有简答题支持上传作答文件"); + String path = fileUtil.upload(file); String name = safeFilename(file.getOriginalFilename()); + jdbc.update("insert into exam_attempt_answer(attempt_id,question_id,answer_file_name,answer_file_url) values(?,?,?,?) " + + "on duplicate key update answer_file_name=values(answer_file_name),answer_file_url=values(answer_file_url)", attemptId, questionId, name, "/" + path.replace('\\', '/')); + return map("name", name, "url", "/" + path.replace('\\', '/')); + } + + @Transactional + public Map submit(JwtUser student, long examId, String deviceToken) { + requireStudent(student); + Map exam = accessibleStudentExam(student, examId); + long attemptId = requireAttempt(student, examId, deviceToken, true); + if ("NOT_STARTED".equals(exam.get("status"))) throw new IllegalArgumentException("考试尚未开始"); + submitAttempt(attemptId, "ENDED".equals(exam.get("status"))); + return attemptRow(examId, attemptId); + } + + @Transactional + public void rollback(JwtUser teacher, long examId, long attemptId) { + Map exam = ownedExam(teacher, examId); + Map attempt = attemptRow(examId, attemptId); + validateRollback(String.valueOf(exam.get("status")), bool(exam.get("allowRollback"), false), String.valueOf(attempt.get("status"))); + jdbc.update("update exam_attempt set attempt_status='IN_PROGRESS',submitted_at=null,auto_submitted=0 where id=?", attemptId); + } + + public Map studentResult(JwtUser student, long examId) { + requireStudent(student); Map exam = accessibleStudentExam(student, examId); autoSubmitExpired(); + if (!"ENDED".equals(exam.get("status"))) throw new IllegalArgumentException("考试结束后才能查看成绩"); + List> attempts = jdbc.queryForList("select id from exam_attempt where exam_id=? and student_id=? and attempt_status='SUBMITTED'", examId, student.getUserId()); + if (attempts.isEmpty()) throw new IllegalArgumentException("没有可查看的已提交试卷"); + long attemptId = ((Number) attempts.get(0).get("id")).longValue(); + Map result = attemptRow(examId, attemptId); + List> questions = answeredQuestions(examId, attemptId, true); + long objectiveTotal = questions.stream().filter(q -> !SHORT_ANSWER.equals(q.get("type"))).count(); + long correct = questions.stream().filter(q -> Boolean.TRUE.equals(q.get("correct"))).count(); + result.put("examName", exam.get("name")); result.put("questions", questions); result.put("objectiveTotal", objectiveTotal); + result.put("objectiveCorrect", correct); result.put("objectiveWrong", objectiveTotal - correct); + result.put("objectiveAccuracy", objectiveTotal == 0 ? 0 : Math.round(correct * 10000d / objectiveTotal) / 100d); + return result; + } + + @Scheduled(cron = "0 * * * * ?") + public void autoSubmitExpired() { + List ids = jdbc.queryForList("select a.id from exam_attempt a join exam_assessment e on e.id=a.exam_id where a.attempt_status='IN_PROGRESS' and e.publication_status='PUBLISHED' and e.end_time<=now()", Long.class); + for (Long id : ids) submitAttempt(id, true); + } + + private void submitAttempt(long attemptId, boolean automatic) { + recalculateAttempt(attemptId); + jdbc.update("update exam_attempt set attempt_status='SUBMITTED',submitted_at=now(),auto_submitted=? where id=? and attempt_status='IN_PROGRESS'", automatic, attemptId); + } + + private void recalculateAttempt(long attemptId) { + BigDecimal objective = jdbc.queryForObject("select coalesce(sum(case when q.question_type<>'SHORT_ANSWER' then coalesce(a.awarded_score,0) else 0 end),0) from exam_attempt_answer a join exam_question q on q.id=a.question_id where a.attempt_id=?", BigDecimal.class, attemptId); + BigDecimal subjective = jdbc.queryForObject("select coalesce(sum(case when q.question_type='SHORT_ANSWER' then coalesce(a.awarded_score,0) else 0 end),0) from exam_attempt_answer a join exam_question q on q.id=a.question_id where a.attempt_id=?", BigDecimal.class, attemptId); + jdbc.update("update exam_attempt set objective_score=?,subjective_score=?,total_score=? where id=?", objective, subjective, objective.add(subjective), attemptId); + } + + private Map studentPaper(JwtUser student, long examId, long attemptId) { + Map exam = accessibleStudentExam(student, examId); + exam.remove("teacherId"); exam.remove("exportedAt"); + exam.put("attempt", attemptRow(examId, attemptId)); + exam.put("questions", answeredQuestions(examId, attemptId, false)); + return exam; + } + + private List> answeredQuestions(long examId, long attemptId, boolean revealAnswer) { + List> questions = examQuestions(examId, revealAnswer); + Map> answers = jdbc.query("select * from exam_attempt_answer where attempt_id=?", (rs, n) -> { + Map m = map("questionId", rs.getLong("question_id"), "studentAnswer", parse(rs.getString("answer_json")), + "correct", rs.getObject("is_correct") == null ? null : rs.getBoolean("is_correct"), "awardedScore", rs.getBigDecimal("awarded_score"), + "answerFileName", rs.getString("answer_file_name"), "answerFileUrl", rs.getString("answer_file_url")); return m; + }, attemptId).stream().collect(Collectors.toMap(m -> ((Number) m.get("questionId")).longValue(), Function.identity())); + for (Map question : questions) question.putAll(answers.getOrDefault(((Number) question.get("id")).longValue(), Collections.emptyMap())); + return questions; + } + + private List> examQuestions(long examId, boolean revealAnswer) { + return jdbc.query("select p.question_id id,p.question_type,p.stem,p.options_json,p.answer_json,p.answer_analysis,p.attachment_name,p.attachment_url,p.create_time,p.update_time,p.question_score,p.question_order " + + "from exam_assessment_question p where p.exam_id=? order by p.question_order", + (rs, n) -> { Map q = questionRow(rs, revealAnswer); q.put("score", rs.getBigDecimal("question_score")); q.put("order", rs.getInt("question_order")); return q; }, examId); + } + + private Map examQuestion(long examId, long questionId, boolean revealAnswer) { + List> rows = examQuestions(examId, revealAnswer).stream().filter(q -> ((Number) q.get("id")).longValue() == questionId).collect(Collectors.toList()); + if (rows.isEmpty()) throw new IllegalArgumentException("试题不属于当前考试"); + return rows.get(0); + } + + private Map getOwnedQuestion(JwtUser teacher, long id) { + List> rows = jdbc.query("select * from exam_question where id=? and teacher_id=?", (rs, n) -> questionRow(rs, true), id, teacher.getUserId()); + if (rows.isEmpty()) throw new IllegalArgumentException("题目不存在或无权访问"); + return rows.get(0); + } + + private Map ownedExam(JwtUser teacher, long id) { + requireTeacher(teacher); + List> 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=?", + (rs, n) -> examSummary(rs), id, teacher.getUserId()); + if (rows.isEmpty()) throw new IllegalArgumentException("考试不存在或无权访问"); + return rows.get(0); + } + + private Map accessibleStudentExam(JwtUser student, long id) { + List> rows = jdbc.query("select distinct e.*,(select coalesce(sum(question_score),0) from exam_assessment_question q where q.exam_id=e.id) total_score from exam_assessment e " + + "join exam_assessment_class c on c.exam_id=e.id join teaching_class_student t on t.teaching_class_id=c.teaching_class_id and t.status='IN_PROGRESS' " + + "where e.id=? and t.student_user_id=? and e.publication_status='PUBLISHED'", + (rs, n) -> examSummary(rs), id, student.getUserId()); + if (rows.isEmpty()) throw new AccessDeniedException("考试不存在或无权访问"); + return rows.get(0); + } + + private Map attemptRow(long examId, long attemptId) { + List> rows = jdbc.query("select * from exam_attempt where id=? and exam_id=?", (rs, n) -> map("id", rs.getLong("id"), + "studentId", rs.getString("student_id"), "classId", rs.getString("teaching_class_id"), "status", rs.getString("attempt_status"), + "startedAt", rs.getTimestamp("started_at"), "submittedAt", rs.getTimestamp("submitted_at"), "autoSubmitted", rs.getBoolean("auto_submitted"), + "objectiveScore", rs.getBigDecimal("objective_score"), "subjectiveScore", rs.getBigDecimal("subjective_score"), "totalScore", rs.getBigDecimal("total_score")), attemptId, examId); + if (rows.isEmpty()) throw new IllegalArgumentException("学生试卷不存在"); + return rows.get(0); + } + + private long requireAttempt(JwtUser student, long examId, String deviceToken, boolean inProgress) { + List> rows = jdbc.queryForList("select * from exam_attempt where exam_id=? and student_id=?", examId, student.getUserId()); + if (rows.isEmpty()) throw new IllegalArgumentException("请先进入考试"); + Map row = rows.get(0); + if (!Objects.equals(deviceToken, row.get("device_token"))) throw new IllegalArgumentException("当前设备不是本场考试的作答设备"); + if (inProgress && !"IN_PROGRESS".equals(row.get("attempt_status"))) throw new IllegalArgumentException("试卷已提交"); + return ((Number) row.get("id")).longValue(); + } + + private Map examSummary(ResultSet rs) throws SQLException { + Timestamp start = rs.getTimestamp("start_time"), end = rs.getTimestamp("end_time"); + String publication = rs.getString("publication_status"); + Map m = map("id", rs.getLong("id"), "name", rs.getString("exam_name"), "teacherId", rs.getString("teacher_id"), + "startTime", start, "endTime", end, "durationMinutes", Math.max(0, (end.getTime() - start.getTime()) / 60000), + "publicationStatus", publication, "status", status(publication, start, end), "allowRollback", rs.getBoolean("allow_rollback"), + "exportedAt", getQuiet(rs, "exported_at"), "totalScore", getQuiet(rs, "total_score")); + putQuiet(rs, m, "class_count", "classCount"); putQuiet(rs, m, "question_count", "questionCount"); + return m; + } + + public static String status(String publication, Timestamp start, Timestamp end) { + if (!"PUBLISHED".equals(publication)) return "DRAFT"; + long now = System.currentTimeMillis(); + if (now < start.getTime()) return "NOT_STARTED"; + if (now < end.getTime()) return "IN_PROGRESS"; + return "ENDED"; + } + + static boolean isPassing(BigDecimal score, BigDecimal totalScore) { + return score.compareTo(totalScore.multiply(new BigDecimal("0.60"))) >= 0; + } + + static void requireState(String actual, String expected, String message) { + if (!expected.equals(actual)) throw new IllegalArgumentException(message); + } + + static void validateDelete(String status, Object exportedAt) { + requireState(status, "ENDED", "只有已结束考试可以删除"); + if (exportedAt == null) throw new IllegalArgumentException("请先导出全部试卷后再删除考试"); + } + + static void validateRollback(String examStatus, boolean allowRollback, String attemptStatus) { + requireState(examStatus, "IN_PROGRESS", "只有进行中的考试可以回退"); + if (!allowRollback) throw new IllegalArgumentException("该考试未开启回退功能"); + requireState(attemptStatus, "SUBMITTED", "只有已交卷学生可以回退"); + } + + private Map questionRow(ResultSet rs, boolean revealAnswer) throws SQLException { + Map q = map("id", rs.getLong("id"), "type", rs.getString("question_type"), "stem", rs.getString("stem"), + "options", parse(rs.getString("options_json")), "analysis", revealAnswer ? rs.getString("answer_analysis") : null, + "attachmentName", rs.getString("attachment_name"), "attachmentUrl", rs.getString("attachment_url"), + "createdAt", rs.getTimestamp("create_time"), "updatedAt", rs.getTimestamp("update_time")); + if (revealAnswer) q.put("answer", parse(rs.getString("answer_json"))); + return q; + } + + private QuestionData validateQuestion(Map payload) { + String type = required(payload, "type", "请选择题型"); validateType(type); + String stem = required(payload, "stem", "题干不能为空"); + Object answer = payload.get("answer"); List> options = mapList(payload.get("options")); + if (SINGLE.equals(type) || MULTIPLE.equals(type)) { + if (options.size() < 2) throw new IllegalArgumentException("选择题至少需要两个选项"); + List> normalizedOptions = new ArrayList<>(); + Set labels = new LinkedHashSet<>(); + for (Map option : options) { + String label = text(option.get("label")).trim().toUpperCase(); + String content = text(option.get("content")).trim(); + if (!label.matches("[A-Z]") || StringUtils.isBlank(content)) throw new IllegalArgumentException("选项标识和内容不能为空"); + if (!labels.add(label)) throw new IllegalArgumentException("选项标识不能重复"); + normalizedOptions.add(map("label", label, "content", content)); + } + options = normalizedOptions; + List answers = stringList(answer).stream().map(String::trim).map(String::toUpperCase).distinct().collect(Collectors.toList()); + if (SINGLE.equals(type) && answers.size() != 1) throw new IllegalArgumentException("单选题只能设置一个正确答案"); + if (answers.isEmpty() || !labels.containsAll(answers)) throw new IllegalArgumentException("正确答案必须来自题目选项"); + answer = answers; + } else if (TRUE_FALSE.equals(type)) { + String value = text(answer); if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) throw new IllegalArgumentException("判断题答案必须为正确或错误"); + answer = Boolean.valueOf(value); + } else if (StringUtils.isBlank(text(answer))) throw new IllegalArgumentException("参考答案不能为空"); + return new QuestionData(type, stem, options, answer, text(payload.get("analysis"))); + } + + private void validatePaper(JwtUser teacher, List> paper) { + Set seen = new HashSet<>(); + for (Map item : paper) { + long id = number(item.get("questionId")).longValue(); + if (!seen.add(id)) throw new IllegalArgumentException("试卷中存在重复题目"); + getOwnedQuestion(teacher, id); + if (decimal(item.get("score")).signum() <= 0) throw new IllegalArgumentException("每题分值必须大于0"); + } + } + + private void validateOwnedClasses(JwtUser teacher, List classIds) { + Set owned = teacherClasses(teacher).stream().map(c -> text(c.get("id"))).collect(Collectors.toSet()); + if (!owned.containsAll(classIds)) throw new AccessDeniedException("只能选择自己名下的教学班"); + } + + 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; + 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 (current != null) finishImported(current, options, result); + 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 Object importAnswer(String type, String raw) { + if (TRUE_FALSE.equals(type)) { + if ("正确".equals(raw) || "对".equals(raw) || "true".equalsIgnoreCase(raw)) return true; + if ("错误".equals(raw) || "错".equals(raw) || "false".equalsIgnoreCase(raw)) return false; + return raw; + } + if (SINGLE.equals(type) || MULTIPLE.equals(type)) return Arrays.stream(raw.split("[,,、\\s]+")) + .map(String::trim).filter(StringUtils::isNotBlank).map(String::toUpperCase).collect(Collectors.toList()); + return raw; + } + + 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); + int index = 1; + for (Map q : questions) { + addParagraph(doc, index++ + ". " + text(q.get("stem")) + "(" + decimal(q.get("score")).stripTrailingZeros().toPlainString() + "分)", true); + for (Map option : mapList(q.get("options"))) addParagraph(doc, text(option.get("label")) + ". " + text(option.get("content")), false); + if (answers) { + addParagraph(doc, "学生答案:" + displayAnswer(q.get("studentAnswer")), false); + if (StringUtils.isNotBlank(text(q.get("answerFileName")))) addParagraph(doc, "作答附件:" + text(q.get("answerFileName")), false); + addParagraph(doc, "得分:" + decimal(q.get("awardedScore")).stripTrailingZeros().toPlainString(), false); + } else if (SHORT_ANSWER.equals(q.get("type"))) addParagraph(doc, "\n\n\n", false); + } + return doc; + } + + private Map bucket(String name, List scores, BigDecimal min, BigDecimal maxExclusive) { + long count = scores.stream().filter(s -> s.compareTo(min) >= 0 && (maxExclusive == null || s.compareTo(maxExclusive) < 0)).count(); + return map("name", name, "value", count); + } + + 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); } + private static String cleanEntry(String s) { return s.replaceAll("[\\\\/:*?\"<>|]", "_"); } + 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 ("单选题".equals(marker)) return SINGLE; + if ("多选题".equals(marker)) return MULTIPLE; + if ("判断题".equals(marker)) return TRUE_FALSE; + if ("简答题".equals(marker)) return SHORT_ANSWER; + return null; + } + 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 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("仅教师可以操作"); } + private static void requireStudent(JwtUser user) { if (user == null) throw new AccessDeniedException("请先登录"); if (user.getRoleId() != 4) throw new AccessDeniedException("仅学生可以操作"); } + private static String required(Map p, String key, String message) { String v = text(p.get(key)); if (StringUtils.isBlank(v)) throw new IllegalArgumentException(message); return v.trim(); } + private static String text(Object value) { return value == null ? "" : String.valueOf(value); } + private static Number number(Object value) { if (value instanceof Number) return (Number) value; try { return Long.parseLong(text(value)); } catch (Exception e) { throw new IllegalArgumentException("数字参数格式错误"); } } + private static BigDecimal decimal(Object value) { if (value == null || text(value).isEmpty()) return BigDecimal.ZERO; return value instanceof BigDecimal ? (BigDecimal) value : new BigDecimal(text(value)); } + private static boolean bool(Object value, boolean fallback) { return value == null ? fallback : Boolean.parseBoolean(text(value)); } + private static Timestamp parseTime(Object value) { try { String s = text(value).replace('T', ' '); if (s.length() == 16) s += ":00"; return Timestamp.valueOf(LocalDateTime.parse(s, TIME)); } catch (Exception e) { throw new IllegalArgumentException("考试时间格式不正确"); } } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalArgumentException("数据格式错误"); } } + private Object parse(String value) { if (StringUtils.isBlank(value)) return null; try { return objectMapper.readValue(value, Object.class); } catch (Exception e) { return value; } } + private List> mapList(Object value) { if (value == null) return new ArrayList<>(); return objectMapper.convertValue(value, new TypeReference>>(){}); } + private List stringList(Object value) { if (value == null) return new ArrayList<>(); if (value instanceof Collection) return ((Collection) value).stream().map(String::valueOf).filter(StringUtils::isNotBlank).collect(Collectors.toList()); String s = text(value); return StringUtils.isBlank(s) ? new ArrayList<>() : Collections.singletonList(s); } + private static Object getQuiet(ResultSet rs, String column) { try { return rs.getObject(column); } catch (SQLException e) { return null; } } + private static void putQuiet(ResultSet rs, Map map, String column, String key) { Object value = getQuiet(rs, column); if (value != null) map.put(key, value); } + private static Map map(Object... values) { Map m = new LinkedHashMap<>(); for (int i = 0; i < values.length; i += 2) m.put(String.valueOf(values[i]), values[i + 1]); return m; } + private static boolean answersEqual(Object expected, Object actual) { if (expected instanceof Collection || actual instanceof Collection) { Set a = toSet(expected), b = toSet(actual); return a.equals(b); } return String.valueOf(expected).equalsIgnoreCase(String.valueOf(actual)); } + private static Set toSet(Object value) { if (value instanceof Collection) return ((Collection) value).stream().map(String::valueOf).map(String::trim).map(v -> v.toUpperCase(Locale.ROOT)).collect(Collectors.toCollection(TreeSet::new)); return Collections.singleton(text(value).trim().toUpperCase(Locale.ROOT)); } + private static void downloadHeader(HttpServletResponse response, Object filename) throws UnsupportedEncodingException { response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + URLEncoder.encode(String.valueOf(filename), StandardCharsets.UTF_8.name()).replace("+", "%20")); } + private static void writeDocx(HttpServletResponse response, XWPFDocument doc, String filename) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); downloadHeader(response, filename); doc.write(response.getOutputStream()); } + + private static final class QuestionData { + final String type; final String stem; final List> options; final Object answer; final String analysis; + QuestionData(String type, String stem, List> options, Object answer, String analysis) { this.type = type; this.stem = stem; this.options = options; this.answer = answer; this.analysis = analysis; } + } +} diff --git a/src/test/java/com/sztzjy/linkCommerce/service/ExamServiceTest.java b/src/test/java/com/sztzjy/linkCommerce/service/ExamServiceTest.java new file mode 100644 index 0000000..0a39190 --- /dev/null +++ b/src/test/java/com/sztzjy/linkCommerce/service/ExamServiceTest.java @@ -0,0 +1,193 @@ +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.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.security.access.AccessDeniedException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ExamServiceTest { + + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final ExamService service = new ExamService(jdbc, new ObjectMapper(), mock(IFileUtil.class)); + + @Test + void recognizesOnlyCompletePlainOrBracketedQuestionTypeMarkers() { + assertEquals(ExamService.SINGLE, ExamService.typeMarker("[单选题]")); + assertEquals(ExamService.MULTIPLE, ExamService.typeMarker(" 多选题 ")); + assertEquals(ExamService.TRUE_FALSE, ExamService.typeMarker("[ 判断题 ]")); + assertEquals(ExamService.SHORT_ANSWER, ExamService.typeMarker("简答题")); + assertEquals(null, ExamService.typeMarker("单选")); + assertEquals(null, ExamService.typeMarker("[单选题")); + } + + @Test + void calculatesDraftScheduledRunningAndEndedStatuses() { + long now = System.currentTimeMillis(); + assertEquals("DRAFT", ExamService.status("DRAFT", timestamp(now + 60_000), timestamp(now + 120_000))); + assertEquals("NOT_STARTED", ExamService.status("PUBLISHED", timestamp(now + 60_000), timestamp(now + 120_000))); + assertEquals("IN_PROGRESS", ExamService.status("PUBLISHED", timestamp(now - 60_000), timestamp(now + 60_000))); + assertEquals("ENDED", ExamService.status("PUBLISHED", timestamp(now - 120_000), timestamp(now - 60_000))); + } + + @Test + void validatesChoiceOptionsAndAnswers() { + Map valid = question(ExamService.MULTIPLE, Arrays.asList(option("a", "甲"), option("B", "乙")), Arrays.asList("b", "A")); + Object data = ReflectionTestUtils.invokeMethod(service, "validateQuestion", valid); + assertTrue(data != null); + + Map duplicate = question(ExamService.SINGLE, Arrays.asList(option("A", "甲"), option("a", "乙")), Collections.singletonList("A")); + assertThrows(IllegalArgumentException.class, () -> ReflectionTestUtils.invokeMethod(service, "validateQuestion", duplicate)); + + Map blank = question(ExamService.SINGLE, Arrays.asList(option("A", "甲"), option("B", " ")), Collections.singletonList("A")); + assertThrows(IllegalArgumentException.class, () -> ReflectionTestUtils.invokeMethod(service, "validateQuestion", blank)); + + Map outside = question(ExamService.SINGLE, Arrays.asList(option("A", "甲"), option("B", "乙")), Collections.singletonList("C")); + assertThrows(IllegalArgumentException.class, () -> ReflectionTestUtils.invokeMethod(service, "validateQuestion", outside)); + } + + @Test + void rejectsUnknownJudgementAnswerDuringWordImport() throws Exception { + MockMultipartFile file = docx("[判断题]", "题干:示例", "答案:不确定", "解析:示例"); + assertThrows(IllegalArgumentException.class, () -> ReflectionTestUtils.invokeMethod(service, "parseQuestionDocument", file)); + } + + @Test + void parsesThePublishedWordTemplateShape() throws Exception { + MockMultipartFile file = docx("[单选题]", "题干:示例题", "A. 甲", "B. 乙", "答案:B", "解析:说明"); + List> parsed = ReflectionTestUtils.invokeMethod(service, "parseQuestionDocument", file); + assertEquals(1, parsed.size()); + assertEquals(ExamService.SINGLE, parsed.get(0).get("type")); + assertEquals("示例题", parsed.get(0).get("stem")); + assertEquals(Collections.singletonList("B"), parsed.get(0).get("answer")); + assertEquals(2, ((List) parsed.get(0).get("options")).size()); + } + + @Test + void comparesObjectiveAnswersWithoutDependingOnChoiceOrderOrCase() { + Boolean same = ReflectionTestUtils.invokeMethod(ExamService.class, "answersEqual", Arrays.asList("A", "B"), Arrays.asList("b", "A")); + Boolean different = ReflectionTestUtils.invokeMethod(ExamService.class, "answersEqual", Arrays.asList("A", "B"), Collections.singletonList("A")); + assertTrue(same); + assertFalse(different); + } + + @Test + void rejectsASecondDeviceAndSubmittedAttempt() { + when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(Collections.singletonList(attempt("device-a", "IN_PROGRESS"))); + assertThrows(IllegalArgumentException.class, + () -> ReflectionTestUtils.invokeMethod(service, "requireAttempt", student(), 7L, "device-b", true)); + + when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(Collections.singletonList(attempt("device-a", "SUBMITTED"))); + assertThrows(IllegalArgumentException.class, + () -> ReflectionTestUtils.invokeMethod(service, "requireAttempt", student(), 7L, "device-a", true)); + } + + @Test + void restrictsExamManagementToTeacherRole() { + JwtUser platformAdmin = teacher(); + platformAdmin.setRoleId(1); + assertThrows(AccessDeniedException.class, + () -> ReflectionTestUtils.invokeMethod(ExamService.class, "requireTeacher", platformAdmin)); + } + + @Test + void enforcesPublishWithdrawAndDeleteStateRestrictions() { + assertThrows(IllegalArgumentException.class, () -> ExamService.requireState("NOT_STARTED", "DRAFT", "error")); + 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)); + } + + @Test + void onlyRollsBackSubmittedAttemptsWhileExamIsRunning() { + assertThrows(IllegalArgumentException.class, () -> ExamService.validateRollback("ENDED", true, "SUBMITTED")); + assertThrows(IllegalArgumentException.class, () -> ExamService.validateRollback("IN_PROGRESS", false, "SUBMITTED")); + assertThrows(IllegalArgumentException.class, () -> ExamService.validateRollback("IN_PROGRESS", true, "IN_PROGRESS")); + } + + @Test + void analysisTreatsExactlySixtyPercentAsPassing() { + assertTrue(ExamService.isPassing(new BigDecimal("60.00"), new BigDecimal("100"))); + assertFalse(ExamService.isPassing(new BigDecimal("59.99"), new BigDecimal("100"))); + } + + private Map question(String type, List> options, Object answer) { + return map("type", type, "stem", "题干", "options", options, "answer", answer, "analysis", ""); + } + + private Map option(String label, String content) { + return map("label", label, "content", content); + } + + private Map exam(String status, BigDecimal totalScore) { + return map("id", 1L, "name", "考试", "status", status, "allowRollback", true, + "exportedAt", null, "totalScore", totalScore == null ? BigDecimal.TEN : totalScore, + "startTime", timestamp(System.currentTimeMillis() - 60_000), "endTime", timestamp(System.currentTimeMillis() + 60_000)); + } + + private Map attempt(String token, String status) { + return map("id", 2L, "device_token", token, "attempt_status", status); + } + + private Map attemptRow(String status) { + return map("id", 2L, "status", status); + } + + private JwtUser teacher() { + JwtUser user = new JwtUser(); + user.setUserId("teacher-1"); user.setSchoolId("school-1"); user.setRoleId(3); + return user; + } + + private JwtUser student() { + JwtUser user = new JwtUser(); + user.setUserId("student-1"); user.setSchoolId("school-1"); user.setRoleId(4); + return user; + } + + private MockMultipartFile docx(String... lines) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (XWPFDocument document = new XWPFDocument()) { + for (String line : lines) { + XWPFParagraph paragraph = document.createParagraph(); + paragraph.createRun().setText(line); + } + document.write(output); + } + return new MockMultipartFile("file", "questions.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", output.toByteArray()); + } + + private static Timestamp timestamp(long value) { + return new Timestamp(value); + } + + private static Map map(Object... values) { + Map result = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) result.put(String.valueOf(values[i]), values[i + 1]); + return result; + } +}