32 changed files with 697 additions and 80 deletions
@ -0,0 +1,40 @@ |
|||||
|
package com.project.base.domain.service; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.extension.service.IService; |
||||
|
import com.project.base.domain.dto.BaseDTO; |
||||
|
import com.project.base.domain.entity.BaseEntity; |
||||
|
import java.util.function.Supplier; |
||||
|
|
||||
|
/** |
||||
|
* 增强版通用 Service 接口 |
||||
|
*/ |
||||
|
public interface IBaseService<T extends BaseEntity> extends IService<T> { |
||||
|
|
||||
|
/** |
||||
|
* 通用保存 DTO 的方法:转换 -> 保存 -> 回传带ID的DTO |
||||
|
*/ |
||||
|
default <D extends BaseDTO> D saveDTO(D dto, Supplier<T> entitySupplier, Supplier<D> dtoSupplier) { |
||||
|
if (dto == null) return null; |
||||
|
|
||||
|
// 1. DTO 转 Entity
|
||||
|
T entity = dto.toEntity(entitySupplier); |
||||
|
|
||||
|
// 2. 调用 MyBatis-Plus 的 save 方法
|
||||
|
// 由于 IBaseService 继承了 IService,所以这里可以直接调 this.save
|
||||
|
this.save(entity); |
||||
|
|
||||
|
// 3. 将持久化后(带ID和自动填充字段)的 Entity 转回 DTO 并返回
|
||||
|
return entity.toDTO(dtoSupplier); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 【扩展】通用更新 DTO 的方法 |
||||
|
*/ |
||||
|
default <D extends BaseDTO> boolean updateDTO(D dto, Supplier<T> entitySupplier) { |
||||
|
if (dto == null) { |
||||
|
return false; |
||||
|
} |
||||
|
T entity = dto.toEntity(entitySupplier); |
||||
|
return this.updateById(entity); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
package com.project.ding.utils; |
||||
|
|
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.context.SecurityContextHolder; |
||||
|
|
||||
|
import java.util.Optional; |
||||
|
|
||||
|
public class SecurityUtils { |
||||
|
/** |
||||
|
* 获取当前登录用户的 ID |
||||
|
*/ |
||||
|
public static String getUserId() { |
||||
|
return getAuthentication() |
||||
|
.map(auth -> (String) auth.getPrincipal()) |
||||
|
.orElse(null); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取当前认证信息 |
||||
|
*/ |
||||
|
private static Optional<Authentication> getAuthentication() { |
||||
|
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package com.project.exam.application; |
||||
|
|
||||
|
import com.project.base.domain.result.Result; |
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
|
||||
|
public interface ExamRecordApplicationService { |
||||
|
|
||||
|
Result<ExamRecordDTO> assemblePaper(Long taskId) throws Exception; |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
package com.project.exam.application.impl; |
||||
|
|
||||
|
import com.project.base.domain.result.Result; |
||||
|
import com.project.ding.utils.SecurityUtils; |
||||
|
import com.project.exam.application.ExamRecordApplicationService; |
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
import com.project.exam.domain.service.AssemblePaperDomainService; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
@Service |
||||
|
public class ExamRecordApplicationServiceImpl implements ExamRecordApplicationService { |
||||
|
@Autowired |
||||
|
private AssemblePaperDomainService assemblePaperDomainService; |
||||
|
@Override |
||||
|
public Result<ExamRecordDTO> assemblePaper(Long taskId) throws Exception { |
||||
|
return Result.success(assemblePaperDomainService.assemblePaper(taskId , SecurityUtils.getUserId())); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
package com.project.exam.controller; |
||||
|
|
||||
|
|
||||
|
import com.project.base.domain.result.Result; |
||||
|
import com.project.exam.application.ExamRecordApplicationService; |
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.web.bind.annotation.PostMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
@RestController |
||||
|
@RequestMapping("/api/examRecord") |
||||
|
public class ExamRecordController { |
||||
|
@Autowired |
||||
|
private ExamRecordApplicationService examRecordApplicationService; |
||||
|
@PostMapping("/assemblePaper") |
||||
|
public Result<ExamRecordDTO> assemblePaper(Long taskId) throws Exception{ |
||||
|
return examRecordApplicationService.assemblePaper(taskId); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,66 @@ |
|||||
|
package com.project.exam.domain.dto; |
||||
|
|
||||
|
import cn.hutool.core.collection.CollUtil; |
||||
|
import com.project.base.domain.dto.BaseDTO; |
||||
|
import com.project.exam.domain.entity.ExamRecordEntity; |
||||
|
import com.project.task.domain.dto.TaskDTO; |
||||
|
import lombok.Data; |
||||
|
import org.springframework.beans.BeanUtils; |
||||
|
|
||||
|
import java.util.Date; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.function.Supplier; |
||||
|
|
||||
|
@Data |
||||
|
public class ExamRecordDTO extends BaseDTO { |
||||
|
private Long id; |
||||
|
private Long taskUserId; |
||||
|
private Double score; |
||||
|
private Boolean pass; |
||||
|
private Date startTime; |
||||
|
private Date submitTime; |
||||
|
private List<QuestionSnapshotDTO> answerSnapshotDTOList; |
||||
|
|
||||
|
private Long taskId; |
||||
|
private TaskDTO taskDTO; |
||||
|
@Data |
||||
|
public static class QuestionSnapshotDTO { |
||||
|
private Long questionId; |
||||
|
// 题干原文
|
||||
|
private String questionContent; |
||||
|
// 题型
|
||||
|
private Integer type; |
||||
|
// 用户看的选项:{"A":"xxx", "B":"yyy"}
|
||||
|
private Map<String, String> options; |
||||
|
// 正确项
|
||||
|
private String rightAnswer; |
||||
|
// 用户选的
|
||||
|
private String userAnswer; |
||||
|
// 判定正确
|
||||
|
private Boolean isRight; |
||||
|
// AI解析
|
||||
|
private String analysis; |
||||
|
// 该题是否已发起申诉
|
||||
|
private Boolean hasAppealed; |
||||
|
private Double score; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public <T> T toEntity(Supplier<T> supplier) { |
||||
|
T result = super.toEntity(supplier); |
||||
|
if (result instanceof ExamRecordEntity entity) { |
||||
|
// 处理内部列表快照的转换
|
||||
|
if (CollUtil.isNotEmpty(this.answerSnapshotDTOList)) { |
||||
|
List<ExamRecordEntity.QuestionSnapshot> snapshotList = this.answerSnapshotDTOList.stream().map(dto -> { |
||||
|
ExamRecordEntity.QuestionSnapshot snapshot = new ExamRecordEntity.QuestionSnapshot(); |
||||
|
BeanUtils.copyProperties(dto, snapshot); |
||||
|
return snapshot; |
||||
|
}).toList(); |
||||
|
entity.setAnswerSnapshot(snapshotList); |
||||
|
} |
||||
|
entity.setId(this.getId()); |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
package com.project.exam.domain.service; |
||||
|
|
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
|
||||
|
public interface AssemblePaperDomainService { |
||||
|
|
||||
|
|
||||
|
ExamRecordDTO assemblePaper(Long taskId , String userId) throws Exception; |
||||
|
|
||||
|
|
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
package com.project.exam.domain.service; |
||||
|
|
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
|
||||
|
public interface BuildExamRecordDomainService { |
||||
|
ExamRecordDTO buildDTO(ExamRecordDTO dto) throws Exception; |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
package com.project.exam.domain.service; |
||||
|
|
||||
|
import com.project.base.domain.service.IBaseService; |
||||
|
import com.project.exam.domain.entity.ExamRecordEntity; |
||||
|
|
||||
|
public interface ExamRecordBaseService extends IBaseService<ExamRecordEntity> { |
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
package com.project.exam.domain.service; |
||||
|
|
||||
|
public interface StepSaveExamRecordDomainService { |
||||
|
|
||||
|
void stepSave(Long recordId , int index , String answer) throws Exception; |
||||
|
} |
||||
@ -0,0 +1,4 @@ |
|||||
|
package com.project.exam.domain.service; |
||||
|
|
||||
|
public interface SummitPaperDomainService { |
||||
|
} |
||||
@ -0,0 +1,245 @@ |
|||||
|
package com.project.exam.domain.service.impl; |
||||
|
|
||||
|
import cn.hutool.core.collection.CollUtil; |
||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||
|
import com.project.base.domain.exception.BusinessErrorException; |
||||
|
import com.project.exam.domain.service.AssemblePaperDomainService; |
||||
|
import com.project.exam.domain.service.BuildExamRecordDomainService; |
||||
|
import com.project.question.domain.dto.QuestionDTO; |
||||
|
import com.project.question.domain.dto.TaskKnowledgePointDTO; |
||||
|
import com.project.question.domain.entity.QuestionEntity; |
||||
|
import com.project.question.domain.entity.TaskKnowledgePointEntity; |
||||
|
import com.project.question.domain.enums.QuestionUseStatusEnum; |
||||
|
import com.project.question.domain.service.TaskKnowledgePointBaseService; |
||||
|
import com.project.question.mapper.QuestionKpRelMapper; |
||||
|
import com.project.question.mapper.QuestionMapper; |
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
import com.project.task.domain.dto.TaskDTO; |
||||
|
import com.project.task.domain.dto.TaskUserDTO; |
||||
|
import com.project.exam.domain.entity.ExamRecordEntity; |
||||
|
import com.project.task.domain.entity.TaskUserEntity; |
||||
|
import com.project.task.domain.enums.QuestionTypeEnum; |
||||
|
import com.project.task.domain.enums.TaskUserStatusEnum; |
||||
|
import com.project.exam.domain.service.ExamRecordBaseService; |
||||
|
import com.project.task.domain.service.TaskBaseService; |
||||
|
import com.project.task.domain.service.TaskUserBaseService; |
||||
|
import io.vavr.control.Try; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.BeanUtils; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.data.redis.core.StringRedisTemplate; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
import java.util.*; |
||||
|
import java.util.concurrent.ConcurrentHashMap; |
||||
|
import java.util.concurrent.ThreadLocalRandom; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.concurrent.locks.ReentrantLock; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
@Service |
||||
|
@Slf4j |
||||
|
public class AssemblePaperDomainServiceImpl implements AssemblePaperDomainService { |
||||
|
// 簇锁(针对多选题)
|
||||
|
private final ConcurrentHashMap<Long, ReentrantLock> clusterLocks = new ConcurrentHashMap<>(); |
||||
|
// 知识点锁(针对单选和判断)
|
||||
|
private final ConcurrentHashMap<Long, ReentrantLock> kpLocks = new ConcurrentHashMap<>(); |
||||
|
|
||||
|
@Autowired |
||||
|
private TaskKnowledgePointBaseService taskKnowledgePointBaseService; |
||||
|
|
||||
|
@Autowired |
||||
|
private TaskBaseService taskBaseService; |
||||
|
|
||||
|
@Autowired |
||||
|
private TaskUserBaseService taskUserBaseService; |
||||
|
|
||||
|
@Autowired |
||||
|
private ExamRecordBaseService examRecordBaseService; |
||||
|
|
||||
|
@Autowired |
||||
|
private StringRedisTemplate redisTemplate; |
||||
|
|
||||
|
@Autowired |
||||
|
private BuildExamRecordDomainService buildExamRecordDomainService; |
||||
|
|
||||
|
@Autowired |
||||
|
private QuestionMapper questionMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private QuestionKpRelMapper questionKpRelMapper; |
||||
|
|
||||
|
private static final String EXAM_START_LOCK = "lock:exam:start:%s:%s"; |
||||
|
|
||||
|
|
||||
|
|
||||
|
@Override |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public ExamRecordDTO assemblePaper(Long taskId, String userId) throws Exception { |
||||
|
// 校验是否已通过考核
|
||||
|
TaskUserDTO taskUserDTO = Try.of(() -> taskUserBaseService.getOne(new LambdaQueryWrapper<TaskUserEntity>() |
||||
|
.eq(TaskUserEntity::getTaskId, taskId).eq(TaskUserEntity::getUserId, userId)) |
||||
|
.toDTO(TaskUserDTO::new)).getOrNull(); |
||||
|
if (Objects.nonNull(taskUserDTO)) { |
||||
|
throw new BusinessErrorException("您无需参与本场考核"); |
||||
|
} |
||||
|
if (TaskUserStatusEnum.Pass.getValue().equals(taskUserDTO.getStatus())) { |
||||
|
throw new BusinessErrorException("您已通过考核,无需重复考试"); |
||||
|
} |
||||
|
// 防抖
|
||||
|
String lockKey = String.format(EXAM_START_LOCK , taskId , userId); |
||||
|
Boolean isLock = redisTemplate.opsForValue().setIfAbsent(lockKey, "processing", 30, TimeUnit.SECONDS); |
||||
|
if (Boolean.FALSE.equals(isLock)) { |
||||
|
log.warn(">>> [考试限制] 用户{} 尝试短时间内重复参加任务{}", userId, taskId); |
||||
|
throw new BusinessErrorException("请勿短时间内重复参加考试"); |
||||
|
} |
||||
|
|
||||
|
// 获取任务配置
|
||||
|
TaskDTO taskDTO = taskBaseService.getById(taskId).toDTO(TaskDTO::new); |
||||
|
int totalQuestionNum = taskDTO.getSingleChoiceNum() + taskDTO.getMultipleChoiceNum() + taskDTO.getTrueFalseNum(); |
||||
|
// 权重乱序种子选取
|
||||
|
List<TaskKnowledgePointDTO> seedKpList = selectWeightedSeedKpList(taskDTO, totalQuestionNum); |
||||
|
// 本张试卷已覆盖的知识点ID集合
|
||||
|
Set<Long> coveredKpPool = new HashSet<>(); |
||||
|
// 最终选定题目
|
||||
|
List<QuestionDTO> selectedQuestionList = new ArrayList<>(); |
||||
|
// 分配种子:前N3个多选,中间N1个单选,最后N2个判断
|
||||
|
List<TaskKnowledgePointDTO> mcSeeds = seedKpList.subList(0, taskDTO.getMultipleChoiceNum()); |
||||
|
List<TaskKnowledgePointDTO> scSeeds = seedKpList.subList(taskDTO.getMultipleChoiceNum() , taskDTO.getMultipleChoiceNum() + taskDTO.getSingleChoiceNum()); |
||||
|
List<TaskKnowledgePointDTO> tfSeeds = seedKpList.subList(taskDTO.getMultipleChoiceNum() + taskDTO.getSingleChoiceNum() , totalQuestionNum); |
||||
|
// 选择单选题
|
||||
|
pickWithLock(scSeeds, QuestionTypeEnum.SINGLE_CHOICE , selectedQuestionList, coveredKpPool); |
||||
|
// 选择判断题
|
||||
|
pickWithLock(tfSeeds, QuestionTypeEnum.TRUE_FALSE , selectedQuestionList, coveredKpPool); |
||||
|
|
||||
|
pickMultipleQuestionsWithGreedy(mcSeeds , selectedQuestionList, coveredKpPool); |
||||
|
selectedQuestionList.sort(Comparator.comparing(QuestionDTO::getQuestionType)); |
||||
|
// 校验题数
|
||||
|
if (selectedQuestionList.size() < totalQuestionNum) { |
||||
|
throw new BusinessErrorException("当前题库可用题目不足,请联系管理员补货"); |
||||
|
} |
||||
|
// todo 异步起一个检测库存
|
||||
|
|
||||
|
// 持久化记录
|
||||
|
ExamRecordDTO recordDTO = new ExamRecordDTO(); |
||||
|
recordDTO.setTaskDTO(taskDTO); |
||||
|
recordDTO.setTaskUserId(taskUserDTO.getId()); |
||||
|
recordDTO.setStartTime(new Date()); |
||||
|
recordDTO.setAnswerSnapshotDTOList(buildPaperSnapshot(selectedQuestionList)); |
||||
|
ExamRecordDTO examRecordDTO = examRecordBaseService.saveDTO(recordDTO, ExamRecordEntity::new, ExamRecordDTO::new); |
||||
|
return buildExamRecordDomainService.buildDTO(examRecordDTO); |
||||
|
} |
||||
|
|
||||
|
private List<ExamRecordDTO.QuestionSnapshotDTO> buildPaperSnapshot(List<QuestionDTO> questionDTOList) { |
||||
|
return questionDTOList.stream().map(questionDTO -> { |
||||
|
QuestionDTO.QuestionDetailDTO detail = questionDTO.getQuestionDetailDTO(); |
||||
|
ExamRecordDTO.QuestionSnapshotDTO snapshot = new ExamRecordDTO.QuestionSnapshotDTO(); |
||||
|
BeanUtils.copyProperties(detail , snapshot); |
||||
|
return snapshot; |
||||
|
}).toList(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 算权重,带权乱序抽取种子知识点 |
||||
|
* @param dto |
||||
|
* @param totalNum |
||||
|
* @return |
||||
|
*/ |
||||
|
private List<TaskKnowledgePointDTO> selectWeightedSeedKpList(TaskDTO dto , int totalNum) { |
||||
|
List<TaskKnowledgePointDTO> kpList = taskKnowledgePointBaseService.lambdaQuery() |
||||
|
.eq(TaskKnowledgePointEntity::getTaskId, dto.getId()).list().stream() |
||||
|
.map(entity -> entity.toDTO(TaskKnowledgePointDTO::new)).toList(); |
||||
|
// 按簇分组
|
||||
|
Map<Long, List<TaskKnowledgePointDTO>> clusterGroup = kpList.stream() |
||||
|
.collect(Collectors.groupingBy(TaskKnowledgePointDTO::getClusterId)); |
||||
|
// 计算分层常量 C = 当前任务中最大簇的规模 + 1
|
||||
|
int maxClusterSize = clusterGroup.values().stream() |
||||
|
.mapToInt(List::size) |
||||
|
.max() |
||||
|
.orElse(0); |
||||
|
int c = maxClusterSize + 1; |
||||
|
// 簇内洗牌并计算 S
|
||||
|
clusterGroup.forEach((clusterId, kpsInCluster) -> { |
||||
|
// 每次组卷都随机洗牌,保证 IDx 动态变化
|
||||
|
Collections.shuffle(kpsInCluster); |
||||
|
int w = kpsInCluster.size(); // 固定簇大小
|
||||
|
for (int i = 0; i < kpsInCluster.size(); i++) { |
||||
|
TaskKnowledgePointDTO kp = kpsInCluster.get(i); |
||||
|
// 序号从 1 开始
|
||||
|
int idx = i + 1; |
||||
|
// 计算动态分值:S = W + (IDx * C)
|
||||
|
Integer dynamicS = w + (idx * c); |
||||
|
kp.setWeightScore(dynamicS); |
||||
|
} |
||||
|
}); |
||||
|
// 带权乱序(A-Res算法)
|
||||
|
return kpList.stream() |
||||
|
.sorted(Comparator.comparingDouble(kp -> -Math.pow(ThreadLocalRandom.current().nextDouble(), 1.0 / kp.getWeightScore()))) |
||||
|
.limit(totalNum) |
||||
|
.toList(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 通用的普通题抽取逻辑(锁 KP 级别) |
||||
|
*/ |
||||
|
private void pickWithLock(List<TaskKnowledgePointDTO> seeds, QuestionTypeEnum type, |
||||
|
List<QuestionDTO> result, Set<Long> coveredPool) { |
||||
|
for (TaskKnowledgePointDTO seed : seeds) { |
||||
|
// 锁住当前知识点,不阻塞其他知识点的考生
|
||||
|
ReentrantLock lock = kpLocks.computeIfAbsent(seed.getId(), k -> new ReentrantLock()); |
||||
|
lock.lock(); |
||||
|
try { |
||||
|
// 查可用的题
|
||||
|
List<Long> qIds = questionKpRelMapper.findAvailableIdsByKpAndType(seed.getId(), type.getValue()); |
||||
|
if (CollUtil.isNotEmpty(qIds)) { |
||||
|
// 更新数据库状态
|
||||
|
QuestionEntity questionEntity = questionMapper.selectById(qIds.stream().findAny().get()); |
||||
|
questionEntity.setUseStatus(QuestionUseStatusEnum.Used.getValue()); |
||||
|
questionMapper.updateById(questionEntity); |
||||
|
QuestionDTO questionDTO = questionEntity.toDTO(QuestionDTO::new); |
||||
|
result.add(questionDTO); |
||||
|
coveredPool.addAll(questionDTO.getKpIdList()); |
||||
|
} |
||||
|
} finally { |
||||
|
lock.unlock(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 抽取多选题:执行贪婪算法 |
||||
|
*/ |
||||
|
private void pickMultipleQuestionsWithGreedy(List<TaskKnowledgePointDTO> seedKpList, |
||||
|
List<QuestionDTO> result, Set<Long> coveredKpPool) { |
||||
|
for (TaskKnowledgePointDTO seedKp : seedKpList) { |
||||
|
// 锁定当前种子所属的簇,保证抽题互斥
|
||||
|
ReentrantLock lock = clusterLocks.computeIfAbsent(seedKp.getClusterId(), k -> new ReentrantLock()); |
||||
|
lock.lock(); |
||||
|
try { |
||||
|
// 候选题目id
|
||||
|
List<Long> candidateIds = questionKpRelMapper.findAvailableIdsByKpAndType(seedKp.getId(), QuestionTypeEnum.MULTIPLE_CHOICE.getValue()); |
||||
|
if (CollUtil.isEmpty(candidateIds)){ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
QuestionDTO bestQuestionDTO = questionMapper.selectBatchIds(candidateIds).stream() |
||||
|
.max(Comparator.comparingInt(q -> { |
||||
|
int kp1 = CollUtil.count(q.getKpIdList(), k -> !coveredKpPool.contains(k)); |
||||
|
int kp2 = q.getKpIdList().size() - kp1; |
||||
|
return kp1 - kp2; |
||||
|
})).map(entity -> entity.toDTO(QuestionDTO::new)) |
||||
|
.orElse(null); |
||||
|
// 选题成功
|
||||
|
if (Objects.nonNull(bestQuestionDTO)) { |
||||
|
// c. 占位:立即更新数据库 is_used 状态,实现全系统排他
|
||||
|
bestQuestionDTO.setUseStatus(QuestionUseStatusEnum.Used.getValue()); |
||||
|
questionMapper.updateById(bestQuestionDTO.toEntity(QuestionEntity::new)); |
||||
|
result.add(bestQuestionDTO); |
||||
|
coveredKpPool.addAll(bestQuestionDTO.getKpIdList()); // 更新已覆盖池
|
||||
|
} |
||||
|
} finally { |
||||
|
lock.unlock(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
package com.project.exam.domain.service.impl; |
||||
|
|
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
import com.project.exam.domain.service.BuildExamRecordDomainService; |
||||
|
import com.project.task.domain.enums.QuestionTypeEnum; |
||||
|
import com.project.task.domain.service.TaskBaseService; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
import java.util.Objects; |
||||
|
|
||||
|
|
||||
|
@Service |
||||
|
public class BuildExamRecordDomainServiceImpl implements BuildExamRecordDomainService { |
||||
|
@Autowired |
||||
|
private TaskBaseService taskBaseService; |
||||
|
@Override |
||||
|
public ExamRecordDTO buildDTO(ExamRecordDTO dto) throws Exception { |
||||
|
// 补全题目分数
|
||||
|
if (Objects.nonNull(dto.getTaskDTO())) { |
||||
|
dto.getAnswerSnapshotDTOList().stream().map(answerSnapshotDTO -> { |
||||
|
if (QuestionTypeEnum.SINGLE_CHOICE.getValue().equals(answerSnapshotDTO.getType())) { |
||||
|
answerSnapshotDTO.setScore(dto.getTaskDTO().getSingleChoiceScore()); |
||||
|
} else if (QuestionTypeEnum.TRUE_FALSE.getValue().equals(answerSnapshotDTO.getType())) { |
||||
|
answerSnapshotDTO.setScore(dto.getTaskDTO().getTrueFalseScore()); |
||||
|
} else { |
||||
|
answerSnapshotDTO.setScore(dto.getTaskDTO().getMultipleChoiceScore()); |
||||
|
} |
||||
|
return answerSnapshotDTO; |
||||
|
}); |
||||
|
} |
||||
|
return dto; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
package com.project.exam.domain.service.impl; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
||||
|
import com.project.exam.domain.entity.ExamRecordEntity; |
||||
|
import com.project.exam.domain.service.ExamRecordBaseService; |
||||
|
import com.project.exam.mapper.ExamRecordMapper; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
@Service |
||||
|
public class ExamRecordBaseServiceImpl extends ServiceImpl<ExamRecordMapper, ExamRecordEntity> implements ExamRecordBaseService { |
||||
|
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
package com.project.exam.domain.service.impl; |
||||
|
|
||||
|
import cn.hutool.core.util.StrUtil; |
||||
|
import com.project.base.domain.exception.BusinessErrorException; |
||||
|
import com.project.exam.domain.service.StepSaveExamRecordDomainService; |
||||
|
import com.project.exam.mapper.ExamRecordMapper; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
@Service |
||||
|
public class StepSaveExamRecordDomainServiceImpl implements StepSaveExamRecordDomainService { |
||||
|
@Autowired |
||||
|
private ExamRecordMapper examRecordMapper; |
||||
|
|
||||
|
@Override |
||||
|
public void stepSave(Long recordId, int index, String answer) throws Exception { |
||||
|
if (index < 1) { |
||||
|
throw new BusinessErrorException("非法题号"); |
||||
|
} |
||||
|
if (StrUtil.isNotBlank(answer)) { |
||||
|
throw new BusinessErrorException("答案不能为空"); |
||||
|
} |
||||
|
int rows = examRecordMapper.updateAnswerIncremental(recordId, index - 1, answer); |
||||
|
|
||||
|
if (rows == 0) { |
||||
|
throw new BusinessErrorException("保存进度失败,请确认考试是否有效"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
package com.project.exam.mapper; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import com.project.exam.domain.entity.ExamRecordEntity; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
import org.apache.ibatis.annotations.Update; |
||||
|
import org.springframework.data.repository.query.Param; |
||||
|
|
||||
|
@Mapper |
||||
|
public interface ExamRecordMapper extends BaseMapper<ExamRecordEntity> { |
||||
|
/** |
||||
|
* 原子更新 JSON 数组中指定索引的用户答案 |
||||
|
* SQL 解释:$[index].userAnswer 对应 JSON 数组中第 index 个对象的字段 |
||||
|
*/ |
||||
|
@Update("UPDATE evaluator_exam_record SET " + |
||||
|
"answer_snapshot = JSON_SET(paper_snapshot, '$[${idx}].userAnswer', #{answer}), " + |
||||
|
"update_time = NOW() " + |
||||
|
"WHERE id = #{recordId}") |
||||
|
int updateAnswerIncremental(@Param("recordId") Long recordId, |
||||
|
@Param("idx") int idx, |
||||
|
@Param("answer") String answer); |
||||
|
} |
||||
@ -1,54 +0,0 @@ |
|||||
package com.project.question.domain.entity; |
|
||||
|
|
||||
import com.baomidou.mybatisplus.annotation.IdType; |
|
||||
import com.baomidou.mybatisplus.annotation.TableField; |
|
||||
import com.baomidou.mybatisplus.annotation.TableId; |
|
||||
import com.baomidou.mybatisplus.annotation.TableName; |
|
||||
import com.project.base.domain.entity.BaseEntity; |
|
||||
import jakarta.persistence.Column; |
|
||||
import jakarta.persistence.Entity; |
|
||||
import jakarta.persistence.Id; |
|
||||
import jakarta.persistence.Table; |
|
||||
import lombok.Data; |
|
||||
import lombok.EqualsAndHashCode; |
|
||||
import org.hibernate.annotations.Comment; |
|
||||
|
|
||||
@Data |
|
||||
@Table(name = "evaluator_question_answer" ) |
|
||||
@Entity |
|
||||
@TableName(value = "evaluator_question_answer", autoResultMap = true) |
|
||||
@EqualsAndHashCode(callSuper = true) |
|
||||
public class QuestionAnswerEntity extends BaseEntity { |
|
||||
@TableId(type = IdType.ASSIGN_ID) |
|
||||
@Id |
|
||||
private Long id; |
|
||||
|
|
||||
@Column(name = "exam_record_id") |
|
||||
@TableField("exam_record_id") |
|
||||
@Comment("所属考试记录ID") |
|
||||
private Long examRecordId; |
|
||||
|
|
||||
@Column(name = "user_id") |
|
||||
@TableField("user_id") |
|
||||
@Comment("所属用户ID") |
|
||||
private Long userId; |
|
||||
|
|
||||
@Column(name = "question_id") |
|
||||
@TableField("question_id") |
|
||||
@Comment("问题ID") |
|
||||
private Long questionId; |
|
||||
|
|
||||
@Column(name = "user_answer" , columnDefinition="varchar(255) comment '用户回答答案'") |
|
||||
@TableField("user_answer") |
|
||||
private String userAnswer; |
|
||||
|
|
||||
@Column(name = "is_right") |
|
||||
@TableField("is_right") |
|
||||
@Comment("是否正确") |
|
||||
private Boolean isRight; |
|
||||
|
|
||||
@Column(name = "score") |
|
||||
@TableField("score") |
|
||||
@Comment("得分") |
|
||||
private Double score; |
|
||||
} |
|
||||
@ -0,0 +1,17 @@ |
|||||
|
package com.project.task.domain.dto; |
||||
|
|
||||
|
import com.project.base.domain.dto.BaseDTO; |
||||
|
import com.project.task.domain.enums.TaskUserStatusEnum; |
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class TaskUserDTO extends BaseDTO { |
||||
|
private Long id; |
||||
|
private Long taskId; |
||||
|
private String userId; |
||||
|
private Integer status; |
||||
|
private Long lastRecordId; |
||||
|
private Integer attemptNum = 0; |
||||
|
|
||||
|
|
||||
|
} |
||||
Loading…
Reference in new issue