feat: add exam assessment backend

bugfix/ai-assessment-evidence-rescore
chenyuan 4 weeks ago
parent 1e8ffce76b
commit 3af6320739

@ -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;

@ -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<List<Map<String, Object>>> classes(HttpServletRequest request) {
return ok(examService.teacherClasses(user(request)));
}
@GetMapping("/teacher/questions")
public ResultEntity<List<Map<String, Object>>> 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<Map<String, Object>> createQuestion(@RequestBody Map<String, Object> payload, HttpServletRequest request) {
return new ResultEntity<>(HttpStatus.OK, "新增成功", examService.createQuestion(user(request), payload));
}
@PutMapping("/teacher/questions/{id}")
public ResultEntity<Map<String, Object>> updateQuestion(@PathVariable long id, @RequestBody Map<String, Object> payload, HttpServletRequest request) {
return ok(examService.updateQuestion(user(request), id, payload));
}
@DeleteMapping("/teacher/questions/{id}")
public ResultEntity<Void> deleteQuestion(@PathVariable long id, HttpServletRequest request) {
examService.deleteQuestion(user(request), id); return new ResultEntity<>(HttpStatus.OK, "删除成功");
}
@PostMapping("/teacher/questions/attachment")
public ResultEntity<Map<String, Object>> questionAttachment(@RequestPart("file") MultipartFile file, HttpServletRequest request) {
return ok(examService.uploadQuestionAttachment(user(request), file));
}
@PostMapping("/teacher/questions/import")
public ResultEntity<List<Map<String, Object>>> 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<List<Map<String, Object>>> exams(@RequestParam(required = false) String keyword, HttpServletRequest request) {
return ok(examService.teacherExams(user(request), keyword));
}
@GetMapping("/teacher/{id}")
public ResultEntity<Map<String, Object>> exam(@PathVariable long id, HttpServletRequest request) {
return ok(examService.teacherExam(user(request), id));
}
@PostMapping("/teacher")
public ResultEntity<Map<String, Object>> createExam(@RequestBody Map<String, Object> payload, HttpServletRequest request) {
return new ResultEntity<>(HttpStatus.OK, "保存成功", examService.saveExam(user(request), null, payload));
}
@PutMapping("/teacher/{id}")
public ResultEntity<Map<String, Object>> updateExam(@PathVariable long id, @RequestBody Map<String, Object> payload, HttpServletRequest request) {
return ok(examService.saveExam(user(request), id, payload));
}
@PostMapping("/teacher/{id}/publish")
public ResultEntity<Void> publish(@PathVariable long id, HttpServletRequest request) {
examService.publish(user(request), id); return new ResultEntity<>(HttpStatus.OK, "发布成功");
}
@PostMapping("/teacher/{id}/withdraw")
public ResultEntity<Void> withdraw(@PathVariable long id, HttpServletRequest request) {
examService.withdraw(user(request), id); return new ResultEntity<>(HttpStatus.OK, "撤回成功");
}
@DeleteMapping("/teacher/{id}")
public ResultEntity<Void> 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<List<Map<String, Object>>> reviewStudents(@PathVariable long id, HttpServletRequest request) {
return ok(examService.reviewStudents(user(request), id));
}
@GetMapping("/teacher/{id}/review/{attemptId}")
public ResultEntity<Map<String, Object>> reviewDetail(@PathVariable long id, @PathVariable long attemptId, HttpServletRequest request) {
return ok(examService.reviewDetail(user(request), id, attemptId));
}
@PutMapping("/teacher/{id}/review/{attemptId}")
public ResultEntity<Map<String, Object>> score(@PathVariable long id, @PathVariable long attemptId,
@RequestBody Map<String, Object> scores, HttpServletRequest request) {
return ok(examService.scoreSubjective(user(request), id, attemptId, scores));
}
@PostMapping("/teacher/{id}/review/{attemptId}/rollback")
public ResultEntity<Void> 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<Map<String, Object>> 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<List<Map<String, Object>>> studentExams(HttpServletRequest request) {
return ok(examService.studentExams(user(request)));
}
@PostMapping("/student/{id}/enter")
public ResultEntity<Map<String, Object>> enter(@PathVariable long id, @RequestBody Map<String, String> payload, HttpServletRequest request) {
return ok(examService.enterExam(user(request), id, payload.get("deviceToken")));
}
@GetMapping("/student/{id}/paper")
public ResultEntity<Map<String, Object>> paper(@PathVariable long id, @RequestParam String deviceToken, HttpServletRequest request) {
return ok(examService.activePaper(user(request), id, deviceToken));
}
@PutMapping("/student/{id}/answers/{questionId}")
public ResultEntity<Void> saveAnswer(@PathVariable long id, @PathVariable long questionId,
@RequestBody Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> submit(@PathVariable long id, @RequestBody Map<String, String> payload, HttpServletRequest request) {
return new ResultEntity<>(HttpStatus.OK, "交卷成功", examService.submit(user(request), id, payload.get("deviceToken")));
}
@GetMapping("/student/{id}/result")
public ResultEntity<Map<String, Object>> result(@PathVariable long id, HttpServletRequest request) {
return ok(examService.studentResult(user(request), id));
}
private JwtUser user(HttpServletRequest request) { return TokenProvider.getJWTUser(request); }
private <T> ResultEntity<T> ok(T data) { return new ResultEntity<>(HttpStatus.OK, "查询成功", data); }
}

@ -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; }
}
}

@ -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<String, Object> 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<String, Object> duplicate = question(ExamService.SINGLE, Arrays.asList(option("A", "甲"), option("a", "乙")), Collections.singletonList("A"));
assertThrows(IllegalArgumentException.class, () -> ReflectionTestUtils.invokeMethod(service, "validateQuestion", duplicate));
Map<String, Object> blank = question(ExamService.SINGLE, Arrays.asList(option("A", "甲"), option("B", " ")), Collections.singletonList("A"));
assertThrows(IllegalArgumentException.class, () -> ReflectionTestUtils.invokeMethod(service, "validateQuestion", blank));
Map<String, Object> 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<Map<String, Object>> 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<String, Object> question(String type, List<Map<String, Object>> options, Object answer) {
return map("type", type, "stem", "题干", "options", options, "answer", answer, "analysis", "");
}
private Map<String, Object> option(String label, String content) {
return map("label", label, "content", content);
}
private Map<String, Object> 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<String, Object> attempt(String token, String status) {
return map("id", 2L, "device_token", token, "attempt_status", status);
}
private Map<String, Object> 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<String, Object> map(Object... values) {
Map<String, Object> result = new LinkedHashMap<>();
for (int i = 0; i < values.length; i += 2) result.put(String.valueOf(values[i]), values[i + 1]);
return result;
}
}
Loading…
Cancel
Save