package jnpf.tmsService.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; import jnpf.exception.DataException; import jnpf.tmsEntity.TmsExamEntity; import jnpf.tmsEntity.TmsExamItemEntity; import jnpf.tmsEntity.TmsPaperEntity; import jnpf.tmsEntity.TmsPaperQuestionEntity; import jnpf.tmsEntity.TmsPaperSectionEntity; import jnpf.tmsEntity.TmsPersonTaskEntity; import jnpf.tmsEntity.TmsQuestionEntity; import jnpf.tmsEntity.TmsQuestionOptionEntity; import jnpf.tmsEntity.TmsTaskEntity; import jnpf.tmsEntity.exam.TmsOnlineExamAttemptVO; import jnpf.tmsEntity.exam.TmsOnlineExamDetailVO; import jnpf.tmsEntity.exam.TmsOnlineExamGradeItemVO; import jnpf.tmsEntity.exam.TmsOnlineExamListVO; import jnpf.tmsEntity.exam.TmsOnlineExamOptionVO; import jnpf.tmsEntity.exam.TmsOnlineExamPaperVO; import jnpf.tmsEntity.exam.TmsOnlineExamQuery; import jnpf.tmsEntity.exam.TmsOnlineExamQuestionVO; import jnpf.tmsEntity.exam.TmsOnlineExamSubmitForm; import jnpf.tmsEntity.exam.TmsOnlineExamSubmitResultVO; import jnpf.tmsMapper.TmsExamItemMapper; import jnpf.tmsMapper.TmsExamMapper; import jnpf.tmsMapper.TmsPaperMapper; import jnpf.tmsMapper.TmsPaperQuestionMapper; import jnpf.tmsMapper.TmsPaperSectionMapper; import jnpf.tmsMapper.TmsPersonTaskMapper; import jnpf.tmsMapper.TmsQuestionMapper; import jnpf.tmsMapper.TmsQuestionOptionMapper; import jnpf.tmsMapper.TmsTaskMapper; import jnpf.tmsService.TmsOnlineExamService; import jnpf.util.DateUtil; import jnpf.util.RandomUtil; import jnpf.util.StringUtil; import jnpf.util.UserProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class TmsOnlineExamServiceImpl implements TmsOnlineExamService { private static final String STATUS_OPEN = "open"; private static final String EXAM_DOING = "doing"; private static final String EXAM_SUBMITTED = "submitted"; private static final String NOT_STARTED = "notStarted"; private static final String GRADE_AUTO = "auto"; private static final String GRADE_PENDING = "pending"; private static final String SORT_RANDOM = "random"; private static final String TYPE_ESSAY = "essay"; private static final String TYPE_BLANK = "blank"; private static final String TYPE_MULTI = "multi"; private static final String FMT = "yyyy-MM-dd HH:mm:ss"; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private final TmsPaperMapper paperMapper; private final TmsPaperSectionMapper sectionMapper; private final TmsPaperQuestionMapper paperQuestionMapper; private final TmsQuestionMapper questionMapper; private final TmsQuestionOptionMapper optionMapper; private final TmsExamMapper examMapper; private final TmsExamItemMapper examItemMapper; private final TmsTaskMapper taskMapper; private final TmsPersonTaskMapper personTaskMapper; @Override public List getMyPapers(TmsOnlineExamQuery query) { String userId = UserProvider.getLoginUserId(); LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.eq(TmsPaperEntity::getBizStatus, STATUS_OPEN); if (StringUtil.isNotEmpty(query.getKeyword()) && !"null".equalsIgnoreCase(query.getKeyword())) { qw.like(TmsPaperEntity::getPaperName, query.getKeyword().trim()); } qw.orderByDesc(TmsPaperEntity::getCreatorTime); List papers = this.paperMapper.selectList(qw); Map examMap = this.loadLatestExams(userId, papers); ArrayList all = new ArrayList(); for (TmsPaperEntity paper : papers) { TmsExamEntity exam = examMap.get(paper.getId()); String status = this.statusOf(exam); if (StringUtil.isNotEmpty(query.getStatus()) && !"null".equalsIgnoreCase(query.getStatus()) && !query.getStatus().equals(status)) continue; all.add(this.toListVo(paper, exam, status)); } long current = query.getCurrentPage() > 0L ? query.getCurrentPage() : 1L; long size = query.getPageSize() > 0L ? query.getPageSize() : 20L; int from = (int)((current - 1L) * size); int to = Math.min(from + (int)size, all.size()); ArrayList page = from >= all.size() ? new ArrayList() : new ArrayList(all.subList(from, to)); query.setData(page, (long)all.size()); return page; } @Override public TmsOnlineExamDetailVO getDetail(String paperId) { if (StringUtil.isEmpty(paperId)) { throw new DataException("请选择试卷"); } TmsPaperEntity paper = this.paperMapper.selectById(paperId); if (paper == null) { throw new DataException("试卷不存在"); } TmsExamEntity exam = this.latestExam(UserProvider.getLoginUserId(), paperId); if (exam == null && !STATUS_OPEN.equals(paper.getBizStatus())) { throw new DataException("试卷未开放"); } TmsOnlineExamDetailVO vo = new TmsOnlineExamDetailVO(); vo.setId(paper.getId()); vo.setPaperId(paper.getId()); vo.setPaperName(paper.getPaperName()); vo.setStatus(this.statusOf(exam)); vo.setExamTimeText(this.timeText(paper.getExamStart(), paper.getExamEnd())); vo.setTotalScore(paper.getTotalScore()); vo.setPassScore(paper.getPassScore()); vo.setDurationMin(paper.getDurationMin()); if (exam != null) { vo.setExamId(exam.getId()); vo.setGotScore(exam.getTotalScore()); vo.setStartTime(this.fmt(exam.getStartTime())); vo.setSubmitTime(this.fmt(exam.getSubmitTime())); vo.setPassFlag(exam.getPassFlag()); vo.setGradeStatus(exam.getGradeStatus()); if (EXAM_SUBMITTED.equals(exam.getExamStatus())) { vo.setQuestions(this.toReviewQuestions(this.loadItems(exam.getId()))); if (vo.getTotalScore() == null) { BigDecimal total = BigDecimal.ZERO; for (TmsOnlineExamQuestionVO q : vo.getQuestions()) { total = total.add(this.nvl(q.getScore())); } vo.setTotalScore(total); } } } this.fillRetakeMeta(vo, paper.getId(), exam, UserProvider.getLoginUserId()); vo.setAttempts(this.listAttempts(UserProvider.getLoginUserId(), paper.getId())); return vo; } @Override public TmsOnlineExamDetailVO getExamReview(String examId) { if (StringUtil.isEmpty(examId)) { throw new DataException("请选择答卷"); } TmsExamEntity exam = this.examMapper.selectById(examId); if (exam == null) { throw new DataException("答卷不存在"); } if (!Objects.equals(UserProvider.getLoginUserId(), exam.getUserId())) { throw new DataException("无权查看该答卷"); } if (!EXAM_SUBMITTED.equals(exam.getExamStatus())) { throw new DataException("交卷后才能进行考试回顾"); } TmsPaperEntity paper = this.paperMapper.selectById((exam.getPaperId())); if (paper == null) { throw new DataException("试卷不存在"); } TmsOnlineExamDetailVO vo = new TmsOnlineExamDetailVO(); vo.setId(paper.getId()); vo.setPaperId(paper.getId()); vo.setPaperName(paper.getPaperName()); vo.setStatus(this.statusOf(exam)); vo.setExamTimeText(this.timeText(paper.getExamStart(), paper.getExamEnd())); vo.setTotalScore(paper.getTotalScore()); vo.setPassScore(paper.getPassScore() != null ? paper.getPassScore() : exam.getPassScore()); vo.setDurationMin(paper.getDurationMin()); vo.setExamId(exam.getId()); vo.setGotScore(exam.getTotalScore()); vo.setStartTime(this.fmt(exam.getStartTime())); vo.setSubmitTime(this.fmt(exam.getSubmitTime())); vo.setPassFlag(exam.getPassFlag()); vo.setGradeStatus(exam.getGradeStatus()); vo.setAttemptNo(Integer.valueOf(exam.getAttemptNo() == null ? 1 : exam.getAttemptNo())); vo.setAttemptLabel(this.attemptLabel(vo.getAttemptNo())); vo.setQuestions(this.toReviewQuestions(this.loadItems(exam.getId()))); if (vo.getTotalScore() == null) { BigDecimal total = BigDecimal.ZERO; for (TmsOnlineExamQuestionVO q : vo.getQuestions()) { total = total.add(this.nvl(q.getScore())); } vo.setTotalScore(total); } return vo; } private List listAttempts(String userId, String paperId) { if (StringUtil.isEmpty(userId) || StringUtil.isEmpty(paperId)) { return Collections.emptyList(); } LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.eq(TmsExamEntity::getUserId, userId); qw.eq(TmsExamEntity::getPaperId, paperId); qw.orderByAsc(TmsExamEntity::getAttemptNo); qw.orderByAsc(TmsExamEntity::getCreatorTime); List exams = this.examMapper.selectList(qw); ArrayList list = new ArrayList(exams.size()); for (TmsExamEntity exam : exams) { TmsOnlineExamAttemptVO row = new TmsOnlineExamAttemptVO(); row.setExamId(exam.getId()); int attemptNo = exam.getAttemptNo() == null ? 1 : exam.getAttemptNo(); row.setAttemptNo(Integer.valueOf(attemptNo)); row.setAttemptLabel(this.attemptLabel(attemptNo)); row.setStatus(this.statusOf(exam)); row.setGradeStatus(exam.getGradeStatus()); row.setStartTime(this.fmt(exam.getStartTime())); row.setSubmitTime(this.fmt(exam.getSubmitTime())); row.setGotScore(exam.getTotalScore()); row.setPassFlag(exam.getPassFlag()); list.add(row); } return list; } @Override @Transactional(rollbackFor = Exception.class, noRollbackFor = ExamTimedOutException.class) public TmsOnlineExamPaperVO start(String paperId) { if (StringUtil.isEmpty(paperId)) { throw new DataException("请选择试卷"); } String userId = UserProvider.getLoginUserId(); TmsExamEntity doing = this.findDoing(userId, paperId); if (doing != null) { if (this.isTimedOut(doing)) { // 超时须先交卷再返回;抛异常会导致事务回滚,答卷一直停在考试中 this.submitInternal(doing, null); TmsOnlineExamPaperVO vo = this.toPaperVo(doing, this.loadItems(doing.getId())); vo.setAutoSubmitted(Boolean.TRUE); vo.setRemainSeconds(Integer.valueOf(0)); return vo; } return this.toPaperVo(doing, this.loadItems(doing.getId())); } TmsPaperEntity paper = this.requireOpenPaper(paperId); this.assertExamWindow(paper); RetakeContext retake = this.resolveRetake(userId, paperId); if (!retake.allowStart) { throw new DataException(retake.message); } TmsExamEntity exam = this.createExam(paper, userId, retake); return this.toPaperVo(exam, this.loadItems(exam.getId())); } @Override @Transactional(rollbackFor = Exception.class, noRollbackFor = ExamTimedOutException.class) public void saveAnswers(String examId, TmsOnlineExamSubmitForm form) { TmsExamEntity exam = this.requireDoingExam(examId); if (this.isTimedOut(exam)) { this.submitInternal(exam, form == null ? null : form.getAnswers()); throw new ExamTimedOutException(); } this.writeAnswers(exam.getId(), form == null ? null : form.getAnswers()); } @Override @Transactional(rollbackFor={Exception.class}) public TmsOnlineExamSubmitResultVO submit(String examId, TmsOnlineExamSubmitForm form) { TmsExamEntity exam = this.requireDoingExam(examId); return this.submitInternal(exam, form == null ? null : form.getAnswers()); } private TmsOnlineExamSubmitResultVO submitInternal(TmsExamEntity exam, Map answers) { List items = this.loadItems(exam.getId()); HashMap normalized = new HashMap(); for (TmsExamItemEntity item : items) { if (answers != null && answers.containsKey(item.getId())) { normalized.put(item.getId(), this.normalizeAnswer(answers.get(item.getId()), item.getQuestionType())); continue; } normalized.put(item.getId(), item.getUserAnswer() == null ? "" : item.getUserAnswer()); } BigDecimal objective = BigDecimal.ZERO; boolean pending = false; ArrayList grades = new ArrayList(); Date now = new Date(); for (TmsExamItemEntity item : items) { String userAnswer = normalized.getOrDefault(item.getId(), ""); item.setUserAnswer(userAnswer); TmsOnlineExamGradeItemVO grade = new TmsOnlineExamGradeItemVO(); grade.setId(item.getId()); grade.setCorrectAnswer(item.getCorrectAnswer()); boolean subjective = "1".equals(item.getIsSubjective()); grade.setSubjective(Boolean.valueOf(subjective)); if (subjective) { pending = true; item.setGotScore(null); grade.setGotScore(null); grade.setRight(null); } else { boolean right = this.answerRight(item, userAnswer); BigDecimal got = right ? this.nvl(item.getScore()) : BigDecimal.ZERO; item.setGotScore(got); objective = objective.add(got); grade.setGotScore(got); grade.setRight(Boolean.valueOf(right)); } item.setLastModifyTime(now); this.examItemMapper.updateById(item); grades.add(grade); } objective = objective.setScale(1, RoundingMode.HALF_UP); exam.setObjectiveScore(objective); exam.setSubmitTime(now); exam.setExamStatus(EXAM_SUBMITTED); exam.setLastModifyTime(now); if (pending) { exam.setGradeStatus(GRADE_PENDING); exam.setSubjectiveScore(null); exam.setTotalScore(objective); exam.setPassFlag(null); } else { exam.setGradeStatus(GRADE_AUTO); exam.setSubjectiveScore(BigDecimal.ZERO); exam.setTotalScore(objective); exam.setPassFlag(this.passFlag(objective, exam.getPassScore())); } this.examMapper.updateById(exam); TmsOnlineExamSubmitResultVO result = new TmsOnlineExamSubmitResultVO(); result.setObjectiveScore(exam.getObjectiveScore()); result.setTotalScore(exam.getTotalScore()); result.setPassFlag(exam.getPassFlag()); result.setGradeStatus(exam.getGradeStatus()); result.setPendingGrade(Boolean.valueOf(pending)); result.setItems(grades); return result; } private TmsExamEntity createExam(TmsPaperEntity paper, String userId, RetakeContext retake) { List snaps = this.loadPaperQuestions(paper.getId()); if (snaps.isEmpty()) { throw new DataException("试卷没有试题,无法开考"); } if (SORT_RANDOM.equals(paper.getSortMode())) { Collections.shuffle(snaps); } Date now = new Date(); TmsExamEntity exam = new TmsExamEntity(); exam.setId(RandomUtil.uuId()); exam.setTaskId(retake == null ? null : retake.taskId); exam.setPersonTaskId(retake == null ? null : retake.personTaskId); exam.setPaperId(paper.getId()); exam.setPaperName(paper.getPaperName()); exam.setUserId(userId); exam.setAttemptNo(Integer.valueOf(retake == null || retake.nextAttemptNo == null ? 1 : retake.nextAttemptNo)); exam.setStartTime(now); exam.setDurationMin(paper.getDurationMin()); BigDecimal passScore = paper.getPassScore(); if (retake != null && retake.passScore != null) { passScore = retake.passScore; } exam.setPassScore(passScore); exam.setGradeStatus(GRADE_AUTO); exam.setExamStatus(EXAM_DOING); exam.setCreatorUserId(userId); exam.setCreatorTime(now); this.examMapper.insert(exam); int sort = 1; for (QuestionSnap snap : snaps) { TmsExamItemEntity item = new TmsExamItemEntity(); item.setId(RandomUtil.uuId()); item.setForeignId(exam.getId()); item.setQuestionId(snap.question.getId()); item.setQuestionType(snap.question.getQuestionType()); item.setStem(snap.question.getStem()); item.setOptionsJson(this.toJson(snap.options)); item.setCorrectAnswer(this.correctOf(snap)); item.setScore(snap.score); item.setIsSubjective(TYPE_ESSAY.equals(snap.question.getQuestionType()) ? "1" : "0"); item.setAnalysis(snap.question.getAnalysis()); item.setSortNo(Integer.valueOf(sort++)); item.setCreatorUserId(userId); item.setCreatorTime(now); this.examItemMapper.insert(item); } return exam; } private List loadPaperQuestions(String paperId) { LambdaQueryWrapper secQw = new LambdaQueryWrapper<>(); secQw.eq(TmsPaperSectionEntity::getForeignId, paperId); secQw.orderByAsc(TmsPaperSectionEntity::getSortNo); List sections = this.sectionMapper.selectList(secQw); LambdaQueryWrapper qQw = new LambdaQueryWrapper<>(); qQw.eq(TmsPaperQuestionEntity::getForeignId, paperId); qQw.orderByAsc(TmsPaperQuestionEntity::getSortNo); List links = this.paperQuestionMapper.selectList(qQw); HashMap> bySection = new HashMap<>(); for (TmsPaperQuestionEntity link : links) { bySection.computeIfAbsent(link.getSectionId(), k -> new ArrayList<>()).add(link); } List questionIds = links.stream().map(TmsPaperQuestionEntity::getQuestionId).collect(Collectors.toList()); HashMap questions = new HashMap(); if (!questionIds.isEmpty()) { for (TmsQuestionEntity q : this.questionMapper.selectBatchIds(questionIds)) { questions.put(q.getId(), q); } } Map> optionMap = this.loadOptions(questionIds); ArrayList snaps = new ArrayList(); for (TmsPaperSectionEntity section : sections) { List rows = bySection.getOrDefault(section.getId(), Collections.emptyList()); rows.sort(Comparator.comparing(r -> r.getSortNo() == null ? 0 : r.getSortNo())); for (TmsPaperQuestionEntity row : rows) { TmsQuestionEntity question = questions.get(row.getQuestionId()); if (question == null) { throw new DataException("试卷中的试题已不存在,无法开考"); } QuestionSnap snap = new QuestionSnap(); snap.question = question; snap.score = row.getScore() == null ? BigDecimal.ZERO : row.getScore(); snap.options = this.toStoredOptions(optionMap.getOrDefault(question.getId(), Collections.emptyList())); snaps.add(snap); } } return snaps; } private void writeAnswers(String examId, Map answers) { List items = this.loadItems(examId); Date now = new Date(); for (TmsExamItemEntity item : items) { Object raw = answers == null ? null : answers.get(item.getId()); item.setUserAnswer(this.normalizeAnswer(raw, item.getQuestionType())); item.setLastModifyTime(now); this.examItemMapper.updateById(item); } } private TmsOnlineExamPaperVO toPaperVo(TmsExamEntity exam, List items) { TmsOnlineExamPaperVO vo = new TmsOnlineExamPaperVO(); vo.setExamId(exam.getId()); vo.setPaperId(exam.getPaperId()); vo.setPaperName(exam.getPaperName()); vo.setPassScore(exam.getPassScore()); vo.setDurationMin(exam.getDurationMin()); vo.setStartTime(this.fmt(exam.getStartTime())); vo.setRemainSeconds(this.remainSeconds(exam)); BigDecimal total = BigDecimal.ZERO; ArrayList questions = new ArrayList(); for (TmsExamItemEntity item : items) { total = total.add(this.nvl(item.getScore())); TmsOnlineExamQuestionVO q = new TmsOnlineExamQuestionVO(); q.setId(item.getId()); q.setQuestionId(item.getQuestionId()); q.setQuestionType(item.getQuestionType()); q.setStem(item.getStem()); q.setScore(item.getScore()); q.setUserAnswer(item.getUserAnswer()); q.setOptions(this.toClientOptions(item.getOptionsJson())); questions.add(q); } vo.setTotalScore(total); vo.setQuestions(questions); vo.setAttemptNo(Integer.valueOf(exam.getAttemptNo() == null ? 1 : exam.getAttemptNo())); vo.setAttemptLabel(this.attemptLabel(vo.getAttemptNo())); return vo; } private List toReviewQuestions(List items) { ArrayList questions = new ArrayList(); for (TmsExamItemEntity item : items) { TmsOnlineExamQuestionVO q = new TmsOnlineExamQuestionVO(); q.setId(item.getId()); q.setQuestionId(item.getQuestionId()); q.setQuestionType(item.getQuestionType()); q.setStem(item.getStem()); q.setScore(item.getScore()); q.setGotScore(item.getGotScore()); q.setUserAnswer(item.getUserAnswer()); q.setCorrectAnswer(item.getCorrectAnswer()); boolean subjective = "1".equals(item.getIsSubjective()); q.setSubjective(Boolean.valueOf(subjective)); if (subjective) { q.setRight(null); } else if (item.getScore() != null && item.getScore().compareTo(BigDecimal.ZERO) > 0) { q.setRight(Boolean.valueOf(this.nvl(item.getGotScore()).compareTo(item.getScore()) >= 0)); } else { q.setRight(Boolean.valueOf(Objects.equals(this.normalizeCorrect(item.getCorrectAnswer()), this.normalizeCorrect(item.getUserAnswer())))); } q.setOptions(this.toReviewOptions(item.getOptionsJson())); questions.add(q); } return questions; } private List toReviewOptions(String json) { List stored = this.parseOptions(json); ArrayList list = new ArrayList(stored.size()); for (StoredOption opt : stored) { TmsOnlineExamOptionVO vo = new TmsOnlineExamOptionVO(); vo.setOptionLabel(opt.getOptionLabel()); vo.setOptionContent(opt.getOptionContent()); vo.setIsCorrect(opt.getIsCorrect()); list.add(vo); } return list; } private List toClientOptions(String json) { List stored = this.parseOptions(json); ArrayList list = new ArrayList(stored.size()); for (StoredOption opt : stored) { TmsOnlineExamOptionVO vo = new TmsOnlineExamOptionVO(); vo.setOptionLabel(opt.getOptionLabel()); vo.setOptionContent(opt.getOptionContent()); list.add(vo); } return list; } private TmsOnlineExamListVO toListVo(TmsPaperEntity paper, TmsExamEntity exam, String status) { TmsOnlineExamListVO vo = new TmsOnlineExamListVO(); vo.setId(paper.getId()); vo.setPaperId(paper.getId()); vo.setPaperName(paper.getPaperName()); vo.setStatus(status); vo.setExamStart(this.fmt(paper.getExamStart())); vo.setExamEnd(this.fmt(paper.getExamEnd())); vo.setExamTimeText(this.timeText(paper.getExamStart(), paper.getExamEnd())); vo.setTotalScore(paper.getTotalScore()); vo.setPassScore(paper.getPassScore()); vo.setDurationMin(paper.getDurationMin()); if (exam != null && EXAM_SUBMITTED.equals(exam.getExamStatus())) { vo.setGotScore(exam.getTotalScore()); vo.setPassFlag(exam.getPassFlag()); vo.setExamId(exam.getId()); } else if (exam != null) { vo.setExamId(exam.getId()); } this.fillRetakeMeta(vo, paper.getId(), exam, UserProvider.getLoginUserId()); return vo; } private void fillRetakeMeta(TmsOnlineExamListVO vo, String paperId, TmsExamEntity exam, String userId) { RetakeMeta meta = this.buildRetakeMeta(paperId, exam, userId); vo.setAttemptNo(meta.attemptNo); vo.setAttemptLabel(meta.attemptLabel); vo.setSubmittedCount(meta.submittedCount); vo.setRetakeLimit(meta.retakeLimit); vo.setRemainingRetakes(meta.remainingRetakes); vo.setCanRetake(meta.canRetake); } private void fillRetakeMeta(TmsOnlineExamDetailVO vo, String paperId, TmsExamEntity exam, String userId) { RetakeMeta meta = this.buildRetakeMeta(paperId, exam, userId); vo.setAttemptNo(meta.attemptNo); vo.setAttemptLabel(meta.attemptLabel); vo.setSubmittedCount(meta.submittedCount); vo.setRetakeLimit(meta.retakeLimit); vo.setRemainingRetakes(meta.remainingRetakes); vo.setCanRetake(meta.canRetake); } private RetakeMeta buildRetakeMeta(String paperId, TmsExamEntity exam, String userId) { RetakeMeta meta = new RetakeMeta(); RetakeContext ctx = this.resolveRetake(userId, paperId); meta.retakeLimit = ctx.retakeLimit; meta.submittedCount = ctx.submittedCount == null ? 0 : ctx.submittedCount; meta.attemptNo = exam != null && exam.getAttemptNo() != null ? exam.getAttemptNo() : (ctx.nextAttemptNo != null ? ctx.nextAttemptNo : Integer.valueOf(1)); meta.attemptLabel = this.attemptLabel(meta.attemptNo); if (meta.retakeLimit == null) { meta.remainingRetakes = 0; meta.canRetake = false; } else { int usedRetakes = Math.max(0, meta.submittedCount - 1); meta.remainingRetakes = Math.max(0, meta.retakeLimit - usedRetakes); boolean submittedFail = exam != null && EXAM_SUBMITTED.equals(exam.getExamStatus()) && !"1".equals(exam.getPassFlag()); meta.canRetake = submittedFail && ctx.allowStart; } return meta; } private String attemptLabel(Integer attemptNo) { int n; int n2 = n = attemptNo == null ? 1 : attemptNo; if (n <= 1) { return "首考"; } return "补考(第" + (n - 1) + "次)"; } private Map loadLatestExams(String userId, List papers) { HashMap map = new HashMap(); if (papers.isEmpty() || StringUtil.isEmpty(userId)) { return map; } List ids = papers.stream().map(TmsPaperEntity::getId).collect(Collectors.toList()); LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.eq(TmsExamEntity::getUserId, userId); qw.in(TmsExamEntity::getPaperId, ids); qw.orderByDesc(TmsExamEntity::getCreatorTime); for (TmsExamEntity exam : this.examMapper.selectList(qw)) { TmsExamEntity exists = map.get(exam.getPaperId()); if (exists != null && !EXAM_DOING.equals(exam.getExamStatus())) continue; map.put(exam.getPaperId(), exam); } return map; } private TmsExamEntity latestExam(String userId, String paperId) { LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.eq(TmsExamEntity::getUserId, userId); qw.eq(TmsExamEntity::getPaperId, paperId); qw.orderByDesc(TmsExamEntity::getCreatorTime); List list = this.examMapper.selectList(qw); TmsExamEntity picked = null; for (TmsExamEntity exam : list) { if (EXAM_DOING.equals(exam.getExamStatus())) { return exam; } if (picked != null) continue; picked = exam; } return picked; } private TmsExamEntity findDoing(String userId, String paperId) { LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.eq(TmsExamEntity::getUserId, userId); qw.eq(TmsExamEntity::getPaperId, paperId); qw.eq(TmsExamEntity::getExamStatus, EXAM_DOING); qw.orderByDesc(TmsExamEntity::getCreatorTime); qw.last("limit 1"); return this.examMapper.selectOne(qw); } private TmsExamEntity latestSubmitted(String userId, String paperId) { LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.eq(TmsExamEntity::getUserId, userId); qw.eq(TmsExamEntity::getPaperId, paperId); qw.eq(TmsExamEntity::getExamStatus, EXAM_SUBMITTED); qw.orderByDesc(TmsExamEntity::getCreatorTime); qw.last("limit 1"); return this.examMapper.selectOne(qw); } private RetakeContext resolveRetake(String userId, String paperId) { RetakeContext ctx = new RetakeContext(); ctx.allowStart = true; ctx.nextAttemptNo = 1; TmsExamEntity last = this.latestSubmitted(userId, paperId); TmsPersonTaskEntity personTask = this.findActivePersonTask(userId, paperId); TmsTaskEntity task = null; if (personTask != null) { task = this.taskMapper.selectById((personTask.getTaskId())); } if (task != null) { ctx.taskId = task.getId(); ctx.personTaskId = personTask == null ? null : personTask.getId(); ctx.passScore = task.getPassScore(); int retakeLimit = task.getRetakeLimit() == null ? 1 : Math.max(0, task.getRetakeLimit()); long submitted = this.countSubmitted(userId, paperId, task.getId()); ctx.retakeLimit = retakeLimit; ctx.submittedCount = (int)submitted; ctx.nextAttemptNo = (int)submitted + 1; if (last == null) { return ctx; } if ("1".equals(last.getPassFlag())) { ctx.allowStart = false; ctx.message = "该试卷已合格,无需重考"; return ctx; } if (submitted > (long)retakeLimit) { ctx.allowStart = false; ctx.message = "补考次数已用尽(上限 " + retakeLimit + " 次)"; return ctx; } return ctx; } if (last != null) { ctx.submittedCount = 1; ctx.retakeLimit = 0; ctx.allowStart = false; ctx.message = "该试卷已交卷"; } return ctx; } private long countSubmitted(String userId, String paperId, String taskId) { Long cnt; LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.eq(TmsExamEntity::getUserId, userId); qw.eq(TmsExamEntity::getPaperId, paperId); qw.eq(TmsExamEntity::getExamStatus, EXAM_SUBMITTED); if (StringUtil.isNotEmpty(taskId)) { qw.and(w -> ((w.eq(TmsExamEntity::getTaskId, taskId)).or()).isNull(TmsExamEntity::getTaskId)); } return (cnt = this.examMapper.selectCount(qw)) == null ? 0L : cnt; } private TmsPersonTaskEntity findActivePersonTask(String userId, String paperId) { List tasks = this.taskMapper.selectList( new LambdaQueryWrapper() .eq(TmsTaskEntity::getPaperId, paperId) .eq(TmsTaskEntity::getPublishStatus, "published") .orderByDesc(TmsTaskEntity::getPublishTime)); for (TmsTaskEntity t : tasks) { TmsPersonTaskEntity pt = this.findPersonTask(t.getId(), userId); if (pt == null || "cancelled".equals(pt.getBizStatus())) { continue; } return pt; } return null; } private TmsPersonTaskEntity findPersonTask(String taskId, String userId) { return this.personTaskMapper.selectOne(new LambdaQueryWrapper() .eq(TmsPersonTaskEntity::getTaskId, taskId) .eq(TmsPersonTaskEntity::getUserId, userId) .last("limit 1")); } private TmsExamEntity requireDoingExam(String examId) { TmsExamEntity exam = this.examMapper.selectById(examId); if (exam == null) { throw new DataException("答卷不存在"); } if (!Objects.equals(UserProvider.getLoginUserId(), exam.getUserId())) { throw new DataException("无权操作该答卷"); } if (!EXAM_DOING.equals(exam.getExamStatus())) { throw new DataException("试卷已交卷"); } return exam; } private TmsPaperEntity requireOpenPaper(String paperId) { TmsPaperEntity paper = this.paperMapper.selectById(paperId); if (paper == null) { throw new DataException("试卷不存在"); } if (!STATUS_OPEN.equals(paper.getBizStatus())) { throw new DataException("试卷未开放,不能参加考试"); } return paper; } private void assertExamWindow(TmsPaperEntity paper) { Date now = new Date(); if (paper.getExamStart() != null && now.before(paper.getExamStart())) { throw new DataException("未到考试时间"); } if (paper.getExamEnd() != null && now.after(paper.getExamEnd())) { throw new DataException("考试时间已结束"); } } private List loadItems(String examId) { LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.eq(TmsExamItemEntity::getForeignId, examId); qw.orderByAsc(TmsExamItemEntity::getSortNo); return this.examItemMapper.selectList(qw); } private Map> loadOptions(List questionIds) { if (questionIds == null || questionIds.isEmpty()) { return Collections.emptyMap(); } LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.in(TmsQuestionOptionEntity::getForeignId, questionIds); qw.orderByAsc(TmsQuestionOptionEntity::getSortNo); HashMap> map = new HashMap>(); for (TmsQuestionOptionEntity opt : this.optionMapper.selectList(qw)) { map.computeIfAbsent(opt.getForeignId(), k -> new ArrayList()).add(opt); } return map; } private List toStoredOptions(List opts) { ArrayList list = new ArrayList(); for (TmsQuestionOptionEntity opt : opts) { StoredOption stored = new StoredOption(); stored.setOptionLabel(opt.getOptionLabel()); stored.setOptionContent(opt.getOptionContent()); stored.setIsCorrect(opt.getIsCorrect()); list.add(stored); } return list; } private String correctOf(QuestionSnap snap) { String type = snap.question.getQuestionType(); if (TYPE_ESSAY.equals(type)) { return ""; } if (TYPE_BLANK.equals(type)) { return snap.options.stream().map(StoredOption::getOptionContent).filter(StringUtil::isNotEmpty).collect(Collectors.joining("?")); } return snap.options.stream().filter(o -> "1".equals(o.getIsCorrect())).map(StoredOption::getOptionLabel).filter(StringUtil::isNotEmpty).sorted().collect(Collectors.joining(",")); } private boolean answerRight(TmsExamItemEntity item, String userAnswer) { if (StringUtil.isEmpty(item.getCorrectAnswer())) { return false; } if (TYPE_BLANK.equals(item.getQuestionType())) { return item.getCorrectAnswer().trim().equals(userAnswer == null ? "" : userAnswer.trim()); } return Objects.equals(this.normalizeCorrect(item.getCorrectAnswer()), this.normalizeCorrect(userAnswer)); } private String normalizeAnswer(Object raw, String questionType) { if (raw == null) { return ""; } if (TYPE_MULTI.equals(questionType)) { LinkedHashSet labels = new LinkedHashSet(); if (raw instanceof List) { for (Object o : (List)raw) { if (o == null || !StringUtil.isNotEmpty(String.valueOf(o))) continue; labels.add(String.valueOf(o).trim()); } } else { String text = String.valueOf(raw).trim(); if (StringUtil.isNotEmpty(text)) { for (String part : text.split("[,?]")) { if (!StringUtil.isNotEmpty(part.trim())) continue; labels.add(part.trim()); } } } return labels.stream().sorted().collect(Collectors.joining(",")); } return String.valueOf(raw).trim(); } private String normalizeCorrect(String correct) { if (StringUtil.isEmpty(correct)) { return ""; } return Arrays.stream(correct.split("[,?]")).map(String::trim).filter(StringUtil::isNotEmpty).sorted().collect(Collectors.joining(",")); } private boolean isTimedOut(TmsExamEntity exam) { Integer remain = this.remainSeconds(exam); return remain != null && remain <= 0; } private Integer remainSeconds(TmsExamEntity exam) { if (exam == null || exam.getDurationMin() == null || exam.getDurationMin() <= 0 || exam.getStartTime() == null) { return null; } long endMs = exam.getStartTime().getTime() + (long)exam.getDurationMin().intValue() * 60000L; long remain = (endMs - System.currentTimeMillis()) / 1000L; return (int)Math.max(0L, remain); } private String passFlag(BigDecimal score, BigDecimal passScore) { if (passScore == null) { return null; } return this.nvl(score).compareTo(passScore) >= 0 ? "1" : "0"; } private String statusOf(TmsExamEntity exam) { if (exam == null) { return NOT_STARTED; } if (EXAM_DOING.equals(exam.getExamStatus())) { return EXAM_DOING; } return EXAM_SUBMITTED; } private String timeText(Date start, Date end) { if (start == null && end == null) { return "??"; } String left = start == null ? "??" : this.fmt(start); String right = end == null ? "??" : this.fmt(end); return left + " ~ " + right; } private String fmt(Date date) { return date == null ? null : DateUtil.dateToString((Date)date, (String)FMT); } private BigDecimal nvl(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String toJson(Object obj) { try { return OBJECT_MAPPER.writeValueAsString(obj); } catch (Exception e) { throw new DataException("JSON序列化失败"); } } private List parseOptions(String json) { if (StringUtil.isEmpty(json)) { return Collections.emptyList(); } try { return (List)OBJECT_MAPPER.readValue(json, (TypeReference)new TypeReference>(){}); } catch (Exception e) { return Collections.emptyList(); } } private static class RetakeContext { private boolean allowStart; private String message; private String taskId; private String personTaskId; private Integer nextAttemptNo; private BigDecimal passScore; private Integer retakeLimit; private Integer submittedCount; private RetakeContext() { } } private static class QuestionSnap { private TmsQuestionEntity question; private BigDecimal score; private List options; private QuestionSnap() { } } @JsonIgnoreProperties(ignoreUnknown=true) private static class StoredOption { private String optionLabel; private String optionContent; private String isCorrect; public StoredOption() { } public String getOptionLabel() { return this.optionLabel; } public String getOptionContent() { return this.optionContent; } public String getIsCorrect() { return this.isCorrect; } public void setOptionLabel(String optionLabel) { this.optionLabel = optionLabel; } public void setOptionContent(String optionContent) { this.optionContent = optionContent; } public void setIsCorrect(String isCorrect) { this.isCorrect = isCorrect; } public boolean equals(Object o) { if (o == this) { return true; } if (!(o instanceof StoredOption)) { return false; } StoredOption other = (StoredOption)o; if (!other.canEqual(this)) { return false; } String this$optionLabel = this.getOptionLabel(); String other$optionLabel = other.getOptionLabel(); if (this$optionLabel == null ? other$optionLabel != null : !this$optionLabel.equals(other$optionLabel)) { return false; } String this$optionContent = this.getOptionContent(); String other$optionContent = other.getOptionContent(); if (this$optionContent == null ? other$optionContent != null : !this$optionContent.equals(other$optionContent)) { return false; } String this$isCorrect = this.getIsCorrect(); String other$isCorrect = other.getIsCorrect(); return !(this$isCorrect == null ? other$isCorrect != null : !this$isCorrect.equals(other$isCorrect)); } protected boolean canEqual(Object other) { return other instanceof StoredOption; } public int hashCode() { int PRIME = 59; int result = 1; String $optionLabel = this.getOptionLabel(); result = result * 59 + ($optionLabel == null ? 43 : $optionLabel.hashCode()); String $optionContent = this.getOptionContent(); result = result * 59 + ($optionContent == null ? 43 : $optionContent.hashCode()); String $isCorrect = this.getIsCorrect(); result = result * 59 + ($isCorrect == null ? 43 : $isCorrect.hashCode()); return result; } public String toString() { return "TmsOnlineExamServiceImpl.StoredOption(optionLabel=" + this.getOptionLabel() + ", optionContent=" + this.getOptionContent() + ", isCorrect=" + this.getIsCorrect() + ")"; } } /** 超时已自动交卷:配合 noRollbackFor,保证交卷结果先提交 */ public static class ExamTimedOutException extends DataException { public ExamTimedOutException() { super("考试时间已到,已自动交卷"); } } private static class RetakeMeta { private Integer attemptNo; private String attemptLabel; private Integer submittedCount; private Integer retakeLimit; private Integer remainingRetakes; private Boolean canRetake; private RetakeMeta() { } } }