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<String> 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<TmsPaperForm> getList(TmsPaperQuery query) {
|
LambdaQueryWrapper<TmsPaperEntity> 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<TmsPaperEntity> page = paperMapper.selectPage(new Page<>(current, size), qw);
|
|
Map<String, Integer> countMap = loadQuestionCounts(page.getRecords());
|
List<TmsPaperForm> 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<String> 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<TmsPaperQuestionEntity> qQw = new LambdaQueryWrapper<>();
|
qQw.eq(TmsPaperQuestionEntity::getForeignId, paperId);
|
paperQuestionMapper.delete(qQw);
|
|
LambdaQueryWrapper<TmsPaperSectionEntity> sQw = new LambdaQueryWrapper<>();
|
sQw.eq(TmsPaperSectionEntity::getForeignId, paperId);
|
sectionMapper.delete(sQw);
|
}
|
|
private void saveComposition(String paperId, List<TmsPaperSectionForm> 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<TmsPaperQuestionEntity> qw = new LambdaQueryWrapper<>();
|
qw.eq(TmsPaperQuestionEntity::getForeignId, paperId);
|
List<TmsPaperQuestionEntity> 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<TmsPaperSectionForm> loadSections(String paperId) {
|
LambdaQueryWrapper<TmsPaperSectionEntity> sQw = new LambdaQueryWrapper<>();
|
sQw.eq(TmsPaperSectionEntity::getForeignId, paperId);
|
sQw.orderByAsc(TmsPaperSectionEntity::getSortNo);
|
List<TmsPaperSectionEntity> sections = sectionMapper.selectList(sQw);
|
|
LambdaQueryWrapper<TmsPaperQuestionEntity> qQw = new LambdaQueryWrapper<>();
|
qQw.eq(TmsPaperQuestionEntity::getForeignId, paperId);
|
qQw.orderByAsc(TmsPaperQuestionEntity::getSortNo);
|
List<TmsPaperQuestionEntity> questions = paperQuestionMapper.selectList(qQw);
|
|
Map<String, TmsQuestionEntity> questionMap = loadQuestions(questions);
|
Map<String, String> bankNames = loadBankNames(questionMap.values());
|
|
Map<String, List<TmsPaperQuestionForm>> 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<TmsPaperSectionForm> 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<String, TmsQuestionEntity> loadQuestions(List<TmsPaperQuestionEntity> rows) {
|
Set<String> ids = new HashSet<>();
|
for (TmsPaperQuestionEntity row : rows) {
|
if (StringUtil.isNotEmpty(row.getQuestionId())) {
|
ids.add(row.getQuestionId());
|
}
|
}
|
if (ids.isEmpty()) {
|
return Collections.emptyMap();
|
}
|
List<TmsQuestionEntity> list = questionMapper.selectBatchIds(ids);
|
Map<String, TmsQuestionEntity> map = new HashMap<>(list.size());
|
for (TmsQuestionEntity q : list) {
|
map.put(q.getId(), q);
|
}
|
return map;
|
}
|
|
private Map<String, String> loadBankNames(Iterable<TmsQuestionEntity> questions) {
|
Set<String> 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<TmsQuestionBankEntity> banks = bankMapper.selectBatchIds(bankIds);
|
Map<String, String> map = new HashMap<>(banks.size());
|
for (TmsQuestionBankEntity bank : banks) {
|
map.put(bank.getId(), bank.getBankName());
|
}
|
return map;
|
}
|
|
private Map<String, Integer> loadQuestionCounts(List<TmsPaperEntity> papers) {
|
if (papers == null || papers.isEmpty()) {
|
return Collections.emptyMap();
|
}
|
Set<String> ids = new HashSet<>();
|
for (TmsPaperEntity p : papers) {
|
ids.add(p.getId());
|
}
|
LambdaQueryWrapper<TmsPaperQuestionEntity> qw = new LambdaQueryWrapper<>();
|
qw.in(TmsPaperQuestionEntity::getForeignId, ids);
|
List<TmsPaperQuestionEntity> rows = paperQuestionMapper.selectList(qw);
|
Map<String, Integer> 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");
|
}
|
}
|