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<TmsOnlineExamListVO> getMyPapers(TmsOnlineExamQuery query) {
|
String userId = UserProvider.getLoginUserId();
|
LambdaQueryWrapper<TmsPaperEntity> 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<TmsPaperEntity> papers = this.paperMapper.selectList(qw);
|
Map<String, TmsExamEntity> examMap = this.loadLatestExams(userId, papers);
|
ArrayList<TmsOnlineExamListVO> all = new ArrayList<TmsOnlineExamListVO>();
|
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<TmsOnlineExamListVO> page = from >= all.size() ? new ArrayList<TmsOnlineExamListVO>() : 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<TmsOnlineExamAttemptVO> listAttempts(String userId, String paperId) {
|
if (StringUtil.isEmpty(userId) || StringUtil.isEmpty(paperId)) {
|
return Collections.emptyList();
|
}
|
LambdaQueryWrapper<TmsExamEntity> qw = new LambdaQueryWrapper<>();
|
qw.eq(TmsExamEntity::getUserId, userId);
|
qw.eq(TmsExamEntity::getPaperId, paperId);
|
qw.orderByAsc(TmsExamEntity::getAttemptNo);
|
qw.orderByAsc(TmsExamEntity::getCreatorTime);
|
List<TmsExamEntity> exams = this.examMapper.selectList(qw);
|
ArrayList<TmsOnlineExamAttemptVO> list = new ArrayList<TmsOnlineExamAttemptVO>(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<String, Object> answers) {
|
List<TmsExamItemEntity> items = this.loadItems(exam.getId());
|
HashMap<String, String> normalized = new HashMap<String, String>();
|
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<TmsOnlineExamGradeItemVO> grades = new ArrayList<TmsOnlineExamGradeItemVO>();
|
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<QuestionSnap> 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<QuestionSnap> loadPaperQuestions(String paperId) {
|
LambdaQueryWrapper<TmsPaperSectionEntity> secQw = new LambdaQueryWrapper<>();
|
secQw.eq(TmsPaperSectionEntity::getForeignId, paperId);
|
secQw.orderByAsc(TmsPaperSectionEntity::getSortNo);
|
List<TmsPaperSectionEntity> sections = this.sectionMapper.selectList(secQw);
|
LambdaQueryWrapper<TmsPaperQuestionEntity> qQw = new LambdaQueryWrapper<>();
|
qQw.eq(TmsPaperQuestionEntity::getForeignId, paperId);
|
qQw.orderByAsc(TmsPaperQuestionEntity::getSortNo);
|
List<TmsPaperQuestionEntity> links = this.paperQuestionMapper.selectList(qQw);
|
HashMap<String, List<TmsPaperQuestionEntity>> bySection = new HashMap<>();
|
for (TmsPaperQuestionEntity link : links) {
|
bySection.computeIfAbsent(link.getSectionId(), k -> new ArrayList<>()).add(link);
|
}
|
List<String> questionIds = links.stream().map(TmsPaperQuestionEntity::getQuestionId).collect(Collectors.toList());
|
HashMap<String, TmsQuestionEntity> questions = new HashMap<String, TmsQuestionEntity>();
|
if (!questionIds.isEmpty()) {
|
for (TmsQuestionEntity q : this.questionMapper.selectBatchIds(questionIds)) {
|
questions.put(q.getId(), q);
|
}
|
}
|
Map<String, List<TmsQuestionOptionEntity>> optionMap = this.loadOptions(questionIds);
|
ArrayList<QuestionSnap> snaps = new ArrayList<QuestionSnap>();
|
for (TmsPaperSectionEntity section : sections) {
|
List<TmsPaperQuestionEntity> 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<String, Object> answers) {
|
List<TmsExamItemEntity> 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<TmsExamItemEntity> 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<TmsOnlineExamQuestionVO> questions = new ArrayList<TmsOnlineExamQuestionVO>();
|
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<TmsOnlineExamQuestionVO> toReviewQuestions(List<TmsExamItemEntity> items) {
|
ArrayList<TmsOnlineExamQuestionVO> questions = new ArrayList<TmsOnlineExamQuestionVO>();
|
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<TmsOnlineExamOptionVO> toReviewOptions(String json) {
|
List<StoredOption> stored = this.parseOptions(json);
|
ArrayList<TmsOnlineExamOptionVO> list = new ArrayList<TmsOnlineExamOptionVO>(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<TmsOnlineExamOptionVO> toClientOptions(String json) {
|
List<StoredOption> stored = this.parseOptions(json);
|
ArrayList<TmsOnlineExamOptionVO> list = new ArrayList<TmsOnlineExamOptionVO>(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<String, TmsExamEntity> loadLatestExams(String userId, List<TmsPaperEntity> papers) {
|
HashMap<String, TmsExamEntity> map = new HashMap<String, TmsExamEntity>();
|
if (papers.isEmpty() || StringUtil.isEmpty(userId)) {
|
return map;
|
}
|
List<String> ids = papers.stream().map(TmsPaperEntity::getId).collect(Collectors.toList());
|
LambdaQueryWrapper<TmsExamEntity> 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<TmsExamEntity> qw = new LambdaQueryWrapper<>();
|
qw.eq(TmsExamEntity::getUserId, userId);
|
qw.eq(TmsExamEntity::getPaperId, paperId);
|
qw.orderByDesc(TmsExamEntity::getCreatorTime);
|
List<TmsExamEntity> 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<TmsExamEntity> 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<TmsExamEntity> 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<TmsExamEntity> 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<TmsTaskEntity> tasks = this.taskMapper.selectList(
|
new LambdaQueryWrapper<TmsTaskEntity>()
|
.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<TmsPersonTaskEntity>()
|
.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<TmsExamItemEntity> loadItems(String examId) {
|
LambdaQueryWrapper<TmsExamItemEntity> qw = new LambdaQueryWrapper<>();
|
qw.eq(TmsExamItemEntity::getForeignId, examId);
|
qw.orderByAsc(TmsExamItemEntity::getSortNo);
|
return this.examItemMapper.selectList(qw);
|
}
|
|
private Map<String, List<TmsQuestionOptionEntity>> loadOptions(List<String> questionIds) {
|
if (questionIds == null || questionIds.isEmpty()) {
|
return Collections.emptyMap();
|
}
|
LambdaQueryWrapper<TmsQuestionOptionEntity> qw = new LambdaQueryWrapper<>();
|
qw.in(TmsQuestionOptionEntity::getForeignId, questionIds);
|
qw.orderByAsc(TmsQuestionOptionEntity::getSortNo);
|
HashMap<String, List<TmsQuestionOptionEntity>> map = new HashMap<String, List<TmsQuestionOptionEntity>>();
|
for (TmsQuestionOptionEntity opt : this.optionMapper.selectList(qw)) {
|
map.computeIfAbsent(opt.getForeignId(), k -> new ArrayList()).add(opt);
|
}
|
return map;
|
}
|
|
private List<StoredOption> toStoredOptions(List<TmsQuestionOptionEntity> opts) {
|
ArrayList<StoredOption> list = new ArrayList<StoredOption>();
|
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<String> labels = new LinkedHashSet<String>();
|
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<StoredOption> parseOptions(String json) {
|
if (StringUtil.isEmpty(json)) {
|
return Collections.emptyList();
|
}
|
try {
|
return (List)OBJECT_MAPPER.readValue(json, (TypeReference)new TypeReference<List<StoredOption>>(){});
|
}
|
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<StoredOption> 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() {
|
}
|
}
|
}
|