package jnpf.tmsService.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import jnpf.exception.DataException; import jnpf.tmsEntity.TmsPaperEntity; import jnpf.tmsEntity.TmsPaperQuestionEntity; import jnpf.tmsEntity.TmsPaperSectionEntity; import jnpf.tmsEntity.TmsQuestionBankEntity; import jnpf.tmsEntity.TmsQuestionEntity; import jnpf.tmsEntity.paper.TmsPaperForm; import jnpf.tmsEntity.paper.TmsPaperQuestionForm; import jnpf.tmsEntity.paper.TmsPaperQuery; import jnpf.tmsEntity.paper.TmsPaperSectionForm; import jnpf.tmsMapper.TmsPaperMapper; import jnpf.tmsMapper.TmsPaperQuestionMapper; import jnpf.tmsMapper.TmsPaperSectionMapper; import jnpf.tmsMapper.TmsQuestionBankMapper; import jnpf.tmsMapper.TmsQuestionMapper; import jnpf.tmsService.TmsPaperService; 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; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @Service @RequiredArgsConstructor public class TmsPaperServiceImpl implements TmsPaperService { private static final String STATUS_OPEN = "open"; private static final String STATUS_INVALID = "invalid"; private static final String SORT_PAPER = "paper"; private static final Set SUBJECTIVE_TYPES = Collections.singleton("essay"); private final TmsPaperMapper paperMapper; private final TmsPaperSectionMapper sectionMapper; private final TmsPaperQuestionMapper paperQuestionMapper; private final TmsQuestionMapper questionMapper; private final TmsQuestionBankMapper bankMapper; @Override public List getList(TmsPaperQuery query) { LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); if (StringUtil.isNotEmpty(query.getBizStatus())) { qw.eq(TmsPaperEntity::getBizStatus, query.getBizStatus()); } else { qw.ne(TmsPaperEntity::getBizStatus, STATUS_INVALID); } if (StringUtil.isNotEmpty(query.getKeyword()) && !"null".equalsIgnoreCase(query.getKeyword())) { String kw = query.getKeyword().trim(); qw.and(w -> w.like(TmsPaperEntity::getPaperName, kw).or().like(TmsPaperEntity::getPaperNo, kw)); } qw.orderByDesc(TmsPaperEntity::getCreatorTime); long current = query.getCurrentPage() > 0 ? query.getCurrentPage() : 1; long size = query.getPageSize() > 0 ? query.getPageSize() : 20; Page page = paperMapper.selectPage(new Page<>(current, size), qw); Map countMap = loadQuestionCounts(page.getRecords()); List list = new ArrayList<>(page.getRecords().size()); for (TmsPaperEntity row : page.getRecords()) { TmsPaperForm form = toHeaderForm(row); form.setQuestionCount(countMap.getOrDefault(row.getId(), 0)); list.add(form); } query.setData(list, page.getTotal()); return list; } @Override public TmsPaperForm getInfo(String id) { TmsPaperEntity entity = requireOne(id); TmsPaperForm form = toHeaderForm(entity); form.setSections(loadSections(id)); int count = 0; if (form.getSections() != null) { for (TmsPaperSectionForm sec : form.getSections()) { if (sec.getQuestions() != null) { count += sec.getQuestions().size(); } } } form.setQuestionCount(count); return form; } @Override @Transactional(rollbackFor = Exception.class) public String create(TmsPaperForm form) { validateForm(form); String id = RandomUtil.uuId(); Date now = new Date(); String userId = UserProvider.getLoginUserId(); TmsPaperEntity entity = new TmsPaperEntity(); entity.setId(id); entity.setPaperNo(nextPaperNo()); applyHeader(entity, form); entity.setCreatorUserId(userId); entity.setCreatorTime(now); paperMapper.insert(entity); saveComposition(id, form.getSections(), userId, now); refreshComputed(id); return id; } @Override @Transactional(rollbackFor = Exception.class) public void update(String id, TmsPaperForm form) { TmsPaperEntity entity = requireOne(id); if (STATUS_INVALID.equals(entity.getBizStatus())) { throw new DataException("已废弃试卷不可编辑"); } if (STATUS_OPEN.equals(entity.getBizStatus())) { throw new DataException("请先停用后再编辑"); } validateForm(form); Date now = new Date(); String userId = UserProvider.getLoginUserId(); applyHeader(entity, form); entity.setLastModifyUserId(userId); entity.setLastModifyTime(now); paperMapper.updateById(entity); // 章节/试题全量替换(已考快照在 tms_exam_item,改卷不影响历史) clearComposition(id); saveComposition(id, form.getSections(), userId, now); refreshComputed(id); } @Override @Transactional(rollbackFor = Exception.class) public void invalidate(String id) { TmsPaperEntity entity = requireOne(id); if (STATUS_INVALID.equals(entity.getBizStatus())) { return; } if (STATUS_OPEN.equals(entity.getBizStatus())) { throw new DataException("请先停用后再废弃"); } entity.setBizStatus(STATUS_INVALID); entity.setLastModifyUserId(UserProvider.getLoginUserId()); entity.setLastModifyTime(new Date()); paperMapper.updateById(entity); } @Override @Transactional(rollbackFor = Exception.class) public void setBizStatus(String id, String bizStatus) { if (!STATUS_OPEN.equals(bizStatus) && !"closed".equals(bizStatus)) { throw new DataException("状态无效,仅支持开放/不开放"); } TmsPaperEntity entity = requireOne(id); if (STATUS_INVALID.equals(entity.getBizStatus())) { throw new DataException("已废弃试卷不可启用或停用"); } entity.setBizStatus(bizStatus); entity.setLastModifyUserId(UserProvider.getLoginUserId()); entity.setLastModifyTime(new Date()); paperMapper.updateById(entity); } private void validateForm(TmsPaperForm form) { if (form == null || StringUtil.isEmpty(form.getPaperName())) { throw new DataException("请填写试卷名称"); } if (form.getDurationMin() != null && form.getDurationMin() < 0) { throw new DataException("考试时长不能为负数"); } Date start = parseDate(form.getExamStart()); Date end = parseDate(form.getExamEnd()); Date publish = parseDate(form.getScorePublishTime()); if ((start == null) != (end == null)) { throw new DataException("开考时间与结束时间需同时填写或同时留空"); } if (start != null && end != null) { if (!end.after(start)) { throw new DataException("结束时间必须晚于开考时间"); } if (form.getDurationMin() != null && form.getDurationMin() > 0) { long spanMin = (end.getTime() - start.getTime()) / 60_000L; if (form.getDurationMin() > spanMin) { throw new DataException("考试时长不能超过开考至结束的间隔(" + spanMin + " 分钟)"); } } } if (publish != null) { if (end != null && publish.before(end)) { throw new DataException("成绩公布时间不能早于结束时间"); } if (end == null && start != null && publish.before(start)) { throw new DataException("成绩公布时间不能早于开考时间"); } } BigDecimal totalFromForm = sumSectionScores(form); if (form.getPassScore() != null && totalFromForm != null && form.getPassScore().compareTo(totalFromForm) > 0) { throw new DataException("合格分数不能大于试卷总分(当前总分 " + totalFromForm.stripTrailingZeros().toPlainString() + ")"); } if (form.getSections() != null) { Set usedQuestionIds = new HashSet<>(); for (TmsPaperSectionForm sec : form.getSections()) { if (sec.getQuestions() == null) { continue; } for (TmsPaperQuestionForm q : sec.getQuestions()) { if (StringUtil.isEmpty(q.getQuestionId())) { throw new DataException("试卷试题缺少试题ID"); } if (q.getScore() == null || q.getScore().compareTo(BigDecimal.ZERO) <= 0) { throw new DataException("组卷试题分值必须大于 0"); } if (!usedQuestionIds.add(q.getQuestionId())) { throw new DataException("同一试卷不能重复添加同一试题"); } TmsQuestionEntity question = questionMapper.selectById(q.getQuestionId()); if (question == null) { throw new DataException("试题不存在或已废弃,无法组卷"); } if (STATUS_INVALID.equals(question.getBizStatus())) { throw new DataException("废弃试题不可用于组卷:" + question.getQuestionNo()); } if (StringUtil.isNotEmpty(sec.getQuestionType()) && StringUtil.isNotEmpty(question.getQuestionType()) && !sec.getQuestionType().equals(question.getQuestionType())) { throw new DataException("试题「" + question.getQuestionNo() + "」与章节题型不一致"); } } } } } private static BigDecimal sumSectionScores(TmsPaperForm form) { if (form == null || form.getSections() == null) { return BigDecimal.ZERO; } BigDecimal total = BigDecimal.ZERO; for (TmsPaperSectionForm sec : form.getSections()) { if (sec.getQuestions() == null) { continue; } for (TmsPaperQuestionForm q : sec.getQuestions()) { if (q.getScore() != null) { total = total.add(q.getScore()); } } } return total; } private void applyHeader(TmsPaperEntity entity, TmsPaperForm form) { entity.setPaperName(form.getPaperName().trim()); // 保存仅允许开放/不开放;废弃走 invalidate String status = StringUtil.isNotEmpty(form.getBizStatus()) ? form.getBizStatus() : STATUS_OPEN; if (!STATUS_OPEN.equals(status) && !"closed".equals(status)) { status = STATUS_OPEN; } entity.setBizStatus(status); entity.setDurationMin(form.getDurationMin() == null ? 60 : form.getDurationMin()); entity.setExamStart(parseDate(form.getExamStart())); entity.setExamEnd(parseDate(form.getExamEnd())); entity.setScorePublishTime(parseDate(form.getScorePublishTime())); entity.setPassScore(form.getPassScore()); entity.setTotalScore(form.getTotalScore()); entity.setSortMode(StringUtil.isNotEmpty(form.getSortMode()) ? form.getSortMode() : SORT_PAPER); entity.setRemark(form.getRemark()); } private void clearComposition(String paperId) { LambdaQueryWrapper qQw = new LambdaQueryWrapper<>(); qQw.eq(TmsPaperQuestionEntity::getForeignId, paperId); paperQuestionMapper.delete(qQw); LambdaQueryWrapper sQw = new LambdaQueryWrapper<>(); sQw.eq(TmsPaperSectionEntity::getForeignId, paperId); sectionMapper.delete(sQw); } private void saveComposition(String paperId, List sections, String userId, Date now) { if (sections == null || sections.isEmpty()) { return; } int secIdx = 0; for (TmsPaperSectionForm secForm : sections) { String sectionId = RandomUtil.uuId(); TmsPaperSectionEntity sec = new TmsPaperSectionEntity(); sec.setId(sectionId); sec.setForeignId(paperId); sec.setSectionName(StringUtil.isEmpty(secForm.getSectionName()) ? defaultSectionName(secForm.getQuestionType(), ++secIdx) : secForm.getSectionName().trim()); sec.setQuestionType(secForm.getQuestionType()); sec.setSortNo(secForm.getSortNo() != null ? secForm.getSortNo() : secIdx); sec.setCreatorUserId(userId); sec.setCreatorTime(now); sectionMapper.insert(sec); if (secForm.getQuestions() == null || secForm.getQuestions().isEmpty()) { continue; } int qIdx = 0; for (TmsPaperQuestionForm qForm : secForm.getQuestions()) { TmsPaperQuestionEntity pq = new TmsPaperQuestionEntity(); pq.setId(RandomUtil.uuId()); pq.setForeignId(paperId); pq.setSectionId(sectionId); pq.setQuestionId(qForm.getQuestionId()); pq.setScore(qForm.getScore() == null ? BigDecimal.ZERO : qForm.getScore()); pq.setSortNo(qForm.getSortNo() != null ? qForm.getSortNo() : ++qIdx); pq.setCreatorUserId(userId); pq.setCreatorTime(now); paperQuestionMapper.insert(pq); } } } /** 按卷内试题重算总分、是否含主观题 */ private void refreshComputed(String paperId) { TmsPaperEntity entity = paperMapper.selectById(paperId); if (entity == null) { return; } LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.eq(TmsPaperQuestionEntity::getForeignId, paperId); List rows = paperQuestionMapper.selectList(qw); BigDecimal total = BigDecimal.ZERO; boolean subjective = false; for (TmsPaperQuestionEntity row : rows) { if (row.getScore() != null) { total = total.add(row.getScore()); } if (!subjective) { TmsQuestionEntity q = questionMapper.selectById(row.getQuestionId()); if (q != null && SUBJECTIVE_TYPES.contains(q.getQuestionType())) { subjective = true; } } } entity.setTotalScore(total); entity.setHasSubjective(subjective ? "1" : "0"); entity.setLastModifyUserId(UserProvider.getLoginUserId()); entity.setLastModifyTime(new Date()); paperMapper.updateById(entity); } private List loadSections(String paperId) { LambdaQueryWrapper sQw = new LambdaQueryWrapper<>(); sQw.eq(TmsPaperSectionEntity::getForeignId, paperId); sQw.orderByAsc(TmsPaperSectionEntity::getSortNo); List sections = sectionMapper.selectList(sQw); LambdaQueryWrapper qQw = new LambdaQueryWrapper<>(); qQw.eq(TmsPaperQuestionEntity::getForeignId, paperId); qQw.orderByAsc(TmsPaperQuestionEntity::getSortNo); List questions = paperQuestionMapper.selectList(qQw); Map questionMap = loadQuestions(questions); Map bankNames = loadBankNames(questionMap.values()); Map> bySection = new HashMap<>(); for (TmsPaperQuestionEntity pq : questions) { TmsPaperQuestionForm qf = new TmsPaperQuestionForm(); qf.setId(pq.getId()); qf.setSectionId(pq.getSectionId()); qf.setQuestionId(pq.getQuestionId()); qf.setScore(pq.getScore()); qf.setSortNo(pq.getSortNo()); TmsQuestionEntity q = questionMap.get(pq.getQuestionId()); if (q != null) { qf.setQuestionNo(q.getQuestionNo()); qf.setStem(q.getStem()); qf.setQuestionType(q.getQuestionType()); qf.setBankName(bankNames.get(q.getBankId())); } bySection.computeIfAbsent(pq.getSectionId(), k -> new ArrayList<>()).add(qf); } List result = new ArrayList<>(sections.size()); for (TmsPaperSectionEntity sec : sections) { TmsPaperSectionForm sf = new TmsPaperSectionForm(); sf.setId(sec.getId()); sf.setSectionName(sec.getSectionName()); sf.setQuestionType(sec.getQuestionType()); sf.setSortNo(sec.getSortNo()); sf.setQuestions(bySection.getOrDefault(sec.getId(), Collections.emptyList())); result.add(sf); } return result; } private Map loadQuestions(List rows) { Set ids = new HashSet<>(); for (TmsPaperQuestionEntity row : rows) { if (StringUtil.isNotEmpty(row.getQuestionId())) { ids.add(row.getQuestionId()); } } if (ids.isEmpty()) { return Collections.emptyMap(); } List list = questionMapper.selectBatchIds(ids); Map map = new HashMap<>(list.size()); for (TmsQuestionEntity q : list) { map.put(q.getId(), q); } return map; } private Map loadBankNames(Iterable questions) { Set bankIds = new HashSet<>(); for (TmsQuestionEntity q : questions) { if (q != null && StringUtil.isNotEmpty(q.getBankId())) { bankIds.add(q.getBankId()); } } if (bankIds.isEmpty()) { return Collections.emptyMap(); } List banks = bankMapper.selectBatchIds(bankIds); Map map = new HashMap<>(banks.size()); for (TmsQuestionBankEntity bank : banks) { map.put(bank.getId(), bank.getBankName()); } return map; } private Map loadQuestionCounts(List papers) { if (papers == null || papers.isEmpty()) { return Collections.emptyMap(); } Set ids = new HashSet<>(); for (TmsPaperEntity p : papers) { ids.add(p.getId()); } LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); qw.in(TmsPaperQuestionEntity::getForeignId, ids); List rows = paperQuestionMapper.selectList(qw); Map map = new HashMap<>(); for (TmsPaperQuestionEntity row : rows) { map.merge(row.getForeignId(), 1, Integer::sum); } return map; } private TmsPaperEntity requireOne(String id) { if (StringUtil.isEmpty(id)) { throw new DataException("试卷不存在"); } TmsPaperEntity entity = paperMapper.selectById(id); if (entity == null) { throw new DataException("试卷不存在"); } return entity; } private TmsPaperForm toHeaderForm(TmsPaperEntity entity) { TmsPaperForm form = new TmsPaperForm(); form.setId(entity.getId()); form.setPaperNo(entity.getPaperNo()); form.setPaperName(entity.getPaperName()); form.setBizStatus(entity.getBizStatus()); form.setDurationMin(entity.getDurationMin()); form.setExamStart(formatDate(entity.getExamStart())); form.setExamEnd(formatDate(entity.getExamEnd())); form.setScorePublishTime(formatDate(entity.getScorePublishTime())); form.setPassScore(entity.getPassScore()); form.setTotalScore(entity.getTotalScore()); form.setSortMode(entity.getSortMode()); form.setHasSubjective(entity.getHasSubjective()); form.setRemark(entity.getRemark()); form.setCreatorTime(formatDate(entity.getCreatorTime())); return form; } /** * 试卷编号:年份后两位 + 4 位流水,如 260001。 * 按当年最大号 +1,含已删记录,避免流水回退。 */ private synchronized String nextPaperNo() { String prefix = String.format("%02d", LocalDate.now().getYear() % 100); String maxNo = paperMapper.selectMaxPaperNo(prefix); int next = 1; if (StringUtil.isNotEmpty(maxNo) && maxNo.length() >= 6 && maxNo.startsWith(prefix)) { try { next = Integer.parseInt(maxNo.substring(prefix.length())) + 1; } catch (NumberFormatException ignored) { next = 1; } } if (next > 9999) { throw new DataException("当年试卷编号已用尽(" + prefix + "9999)"); } return prefix + String.format("%04d", next); } private static String defaultSectionName(String questionType, int idx) { if (StringUtil.isEmpty(questionType)) { return "第" + idx + "章"; } switch (questionType) { case "single": return "单选题"; case "multi": return "多选题"; case "judge": return "判断题"; case "blank": return "填空题"; case "essay": return "问答题"; default: return "第" + idx + "章"; } } private static Date parseDate(String text) { if (StringUtil.isEmpty(text)) { return null; } String raw = text.trim(); try { if (raw.matches("^\\d{10,13}$")) { long ts = Long.parseLong(raw); if (raw.length() == 10) { ts = ts * 1000L; } return new Date(ts); } return DateUtil.stringToDate(raw); } catch (Exception ex) { throw new DataException("时间格式无效:" + text); } } private static String formatDate(Date date) { if (date == null) { return null; } return DateUtil.dateToString(date, "yyyy-MM-dd HH:mm:ss"); } }