|
|
|
|
@ -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<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 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<Map<String, Object>> 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<Map<String, Object>> questions(JwtUser teacher, String type, String keyword) {
|
|
|
|
|
requireTeacher(teacher);
|
|
|
|
|
StringBuilder sql = new StringBuilder("select * from exam_question where teacher_id=?");
|
|
|
|
|
List<Object> 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<String, Object> createQuestion(JwtUser teacher, Map<String, Object> 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<String, Object> updateQuestion(JwtUser teacher, long id, Map<String, Object> 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<String, Object> 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<Map<String, Object>> importQuestions(JwtUser teacher, MultipartFile file) {
|
|
|
|
|
requireTeacher(teacher);
|
|
|
|
|
validateDocx(file);
|
|
|
|
|
List<Map<String, Object>> parsed = parseQuestionDocument(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 {
|
|
|
|
|
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<Map<String, Object>> 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<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=?",
|
|
|
|
|
(rs, n) -> map("id", rs.getString("id"), "name", rs.getString("name")), id));
|
|
|
|
|
result.put("questions", examQuestions(id, true));
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
public Map<String, Object> saveExam(JwtUser teacher, Long id, Map<String, Object> payload) {
|
|
|
|
|
requireTeacher(teacher);
|
|
|
|
|
if (id != null) {
|
|
|
|
|
Map<String, Object> 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<String> classIds = stringList(payload.get("classIds"));
|
|
|
|
|
List<Map<String, Object>> 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<String, Object> item : paper) {
|
|
|
|
|
long questionId = number(item.get("questionId")).longValue();
|
|
|
|
|
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> exam = ownedExam(teacher, id);
|
|
|
|
|
validateDelete(String.valueOf(exam.get("status")), exam.get("exportedAt"));
|
|
|
|
|
List<Long> 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<String, Object> exam = ownedExam(teacher, id);
|
|
|
|
|
try (XWPFDocument doc = buildPaperDocument(exam, examQuestions(id, true), null, false)) {
|
|
|
|
|
writeDocx(response, doc, exam.get("name") + "试卷.docx");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public List<Map<String, Object>> reviewStudents(JwtUser teacher, long examId) {
|
|
|
|
|
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 " +
|
|
|
|
|
"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<String, Object> reviewDetail(JwtUser teacher, long examId, long attemptId) {
|
|
|
|
|
ownedExam(teacher, examId);
|
|
|
|
|
Map<String, Object> 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<String, Object> scoreSubjective(JwtUser teacher, long examId, long attemptId, Map<String, Object> scores) {
|
|
|
|
|
ownedExam(teacher, examId);
|
|
|
|
|
Map<String, Object> attempt = attemptRow(examId, attemptId);
|
|
|
|
|
if (!"SUBMITTED".equals(attempt.get("status"))) throw new IllegalArgumentException("只能评阅已交卷试卷");
|
|
|
|
|
Map<Long, BigDecimal> 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<String, Object> 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<String, Object> exam = ownedExam(teacher, examId);
|
|
|
|
|
List<Map<String, Object>> 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<String, Object> 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<String, Object> analysis(JwtUser teacher, long examId, String classId) {
|
|
|
|
|
Map<String, Object> exam = ownedExam(teacher, examId); autoSubmitExpired();
|
|
|
|
|
List<Object> 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<BigDecimal> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> exam = ownedExam(teacher, examId);
|
|
|
|
|
if (!"ENDED".equals(exam.get("status"))) throw new IllegalArgumentException("考试结束后才能导出全部试卷");
|
|
|
|
|
List<Map<String, Object>> students = reviewStudents(teacher, examId);
|
|
|
|
|
response.setContentType("application/zip"); downloadHeader(response, exam.get("name") + "全部试卷.zip");
|
|
|
|
|
try (ZipOutputStream zip = new ZipOutputStream(response.getOutputStream())) {
|
|
|
|
|
for (Map<String, Object> student : students) {
|
|
|
|
|
long attemptId = ((Number) student.get("attemptId")).longValue();
|
|
|
|
|
List<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> enterExam(JwtUser student, long examId, String deviceToken) {
|
|
|
|
|
requireStudent(student); autoSubmitExpired();
|
|
|
|
|
if (StringUtils.isBlank(deviceToken) || deviceToken.length() > 128) throw new IllegalArgumentException("设备标识无效");
|
|
|
|
|
Map<String, Object> exam = accessibleStudentExam(student, examId);
|
|
|
|
|
if (!"IN_PROGRESS".equals(exam.get("status"))) throw new IllegalArgumentException("考试当前不可进入");
|
|
|
|
|
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> exam = accessibleStudentExam(student, examId);
|
|
|
|
|
if (!"IN_PROGRESS".equals(exam.get("status"))) throw new IllegalArgumentException("考试已结束,不能继续作答");
|
|
|
|
|
long attemptId = requireAttempt(student, examId, deviceToken, true);
|
|
|
|
|
Map<String, Object> 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<String, Object> uploadAnswerFile(JwtUser student, long examId, long questionId, String deviceToken, MultipartFile file) {
|
|
|
|
|
requireStudent(student); validateAttachment(file);
|
|
|
|
|
Map<String, Object> exam = accessibleStudentExam(student, examId);
|
|
|
|
|
if (!"IN_PROGRESS".equals(exam.get("status"))) throw new IllegalArgumentException("考试已结束,不能继续作答");
|
|
|
|
|
long attemptId = requireAttempt(student, examId, deviceToken, true);
|
|
|
|
|
Map<String, Object> 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<String, Object> submit(JwtUser student, long examId, String deviceToken) {
|
|
|
|
|
requireStudent(student);
|
|
|
|
|
Map<String, Object> 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<String, Object> exam = ownedExam(teacher, examId);
|
|
|
|
|
Map<String, Object> 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<String, Object> studentResult(JwtUser student, long examId) {
|
|
|
|
|
requireStudent(student); Map<String, Object> exam = accessibleStudentExam(student, examId); autoSubmitExpired();
|
|
|
|
|
if (!"ENDED".equals(exam.get("status"))) throw new IllegalArgumentException("考试结束后才能查看成绩");
|
|
|
|
|
List<Map<String, Object>> 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<String, Object> result = attemptRow(examId, attemptId);
|
|
|
|
|
List<Map<String, Object>> 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<Long> 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<String, Object> studentPaper(JwtUser student, long examId, long attemptId) {
|
|
|
|
|
Map<String, Object> 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<Map<String, Object>> answeredQuestions(long examId, long attemptId, boolean revealAnswer) {
|
|
|
|
|
List<Map<String, Object>> questions = examQuestions(examId, revealAnswer);
|
|
|
|
|
Map<Long, Map<String, Object>> answers = jdbc.query("select * from exam_attempt_answer where attempt_id=?", (rs, n) -> {
|
|
|
|
|
Map<String, Object> 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<String, Object> question : questions) question.putAll(answers.getOrDefault(((Number) question.get("id")).longValue(), Collections.emptyMap()));
|
|
|
|
|
return questions;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private List<Map<String, Object>> 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<String, Object> q = questionRow(rs, revealAnswer); q.put("score", rs.getBigDecimal("question_score")); q.put("order", rs.getInt("question_order")); return q; }, examId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Map<String, Object> examQuestion(long examId, long questionId, boolean revealAnswer) {
|
|
|
|
|
List<Map<String, Object>> 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<String, Object> getOwnedQuestion(JwtUser teacher, long id) {
|
|
|
|
|
List<Map<String, Object>> 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<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=?",
|
|
|
|
|
(rs, n) -> examSummary(rs), id, teacher.getUserId());
|
|
|
|
|
if (rows.isEmpty()) throw new IllegalArgumentException("考试不存在或无权访问");
|
|
|
|
|
return rows.get(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Map<String, Object> accessibleStudentExam(JwtUser student, long id) {
|
|
|
|
|
List<Map<String, Object>> 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<String, Object> attemptRow(long examId, long attemptId) {
|
|
|
|
|
List<Map<String, Object>> 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<Map<String, Object>> rows = jdbc.queryForList("select * from exam_attempt where exam_id=? and student_id=?", examId, student.getUserId());
|
|
|
|
|
if (rows.isEmpty()) throw new IllegalArgumentException("请先进入考试");
|
|
|
|
|
Map<String, Object> 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<String, Object> examSummary(ResultSet rs) throws SQLException {
|
|
|
|
|
Timestamp start = rs.getTimestamp("start_time"), end = rs.getTimestamp("end_time");
|
|
|
|
|
String publication = rs.getString("publication_status");
|
|
|
|
|
Map<String, Object> 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<String, Object> questionRow(ResultSet rs, boolean revealAnswer) throws SQLException {
|
|
|
|
|
Map<String, Object> 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<String, Object> payload) {
|
|
|
|
|
String type = required(payload, "type", "请选择题型"); validateType(type);
|
|
|
|
|
String stem = required(payload, "stem", "题干不能为空");
|
|
|
|
|
Object answer = payload.get("answer"); List<Map<String, Object>> options = mapList(payload.get("options"));
|
|
|
|
|
if (SINGLE.equals(type) || MULTIPLE.equals(type)) {
|
|
|
|
|
if (options.size() < 2) throw new IllegalArgumentException("选择题至少需要两个选项");
|
|
|
|
|
List<Map<String, Object>> normalizedOptions = new ArrayList<>();
|
|
|
|
|
Set<String> labels = new LinkedHashSet<>();
|
|
|
|
|
for (Map<String, Object> 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<String> 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<Map<String, Object>> paper) {
|
|
|
|
|
Set<Long> seen = new HashSet<>();
|
|
|
|
|
for (Map<String, Object> 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<String> classIds) {
|
|
|
|
|
Set<String> owned = teacherClasses(teacher).stream().map(c -> text(c.get("id"))).collect(Collectors.toSet());
|
|
|
|
|
if (!owned.containsAll(classIds)) throw new AccessDeniedException("只能选择自己名下的教学班");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
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<String, Object> current, List<Map<String, Object>> options, List<Map<String, Object>> 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<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);
|
|
|
|
|
int index = 1;
|
|
|
|
|
for (Map<String, Object> q : questions) {
|
|
|
|
|
addParagraph(doc, index++ + ". " + text(q.get("stem")) + "(" + decimal(q.get("score")).stripTrailingZeros().toPlainString() + "分)", true);
|
|
|
|
|
for (Map<String, Object> 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<String, Object> bucket(String name, List<BigDecimal> 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<String, Object> 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<Map<String, Object>> mapList(Object value) { if (value == null) return new ArrayList<>(); return objectMapper.convertValue(value, new TypeReference<List<Map<String, Object>>>(){}); }
|
|
|
|
|
private List<String> 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<String, Object> map, String column, String key) { Object value = getQuiet(rs, column); if (value != null) map.put(key, value); }
|
|
|
|
|
private static Map<String, Object> map(Object... values) { Map<String, Object> 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<String> a = toSet(expected), b = toSet(actual); return a.equals(b); } return String.valueOf(expected).equalsIgnoreCase(String.valueOf(actual)); }
|
|
|
|
|
private static Set<String> 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<Map<String, Object>> options; final Object answer; final String analysis;
|
|
|
|
|
QuestionData(String type, String stem, List<Map<String, Object>> options, Object answer, String analysis) { this.type = type; this.stem = stem; this.options = options; this.answer = answer; this.analysis = analysis; }
|
|
|
|
|
}
|
|
|
|
|
}
|