diff --git a/src/main/java/com/project/exam/application/impl/ExamRecordApplicationServiceImpl.java b/src/main/java/com/project/exam/application/impl/ExamRecordApplicationServiceImpl.java index 44a38ad..1242634 100644 --- a/src/main/java/com/project/exam/application/impl/ExamRecordApplicationServiceImpl.java +++ b/src/main/java/com/project/exam/application/impl/ExamRecordApplicationServiceImpl.java @@ -10,6 +10,7 @@ import com.project.exam.domain.param.ExamRecordParam; import com.project.exam.domain.service.*; import com.project.exam.domain.service.strategy.PaperAssemblyStrategy; import com.project.exam.domain.service.strategy.PaperAssemblyStrategyFactory; + import com.project.task.domain.entity.TaskEntity; import com.project.task.domain.service.TaskBaseService; import jakarta.servlet.http.HttpServletResponse; diff --git a/src/main/java/com/project/exam/domain/entity/AiGradingLogEntity.java b/src/main/java/com/project/exam/domain/entity/AiGradingLogEntity.java new file mode 100644 index 0000000..1c6dcde --- /dev/null +++ b/src/main/java/com/project/exam/domain/entity/AiGradingLogEntity.java @@ -0,0 +1,99 @@ +package com.project.exam.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.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.project.base.domain.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.annotations.Comment; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.List; + +/** + * AI判分日志实体 + * 记录每次AI阅卷的请求/响应,用于审计和统计 + */ +@Data +@Table(name = "evaluator_ai_grading_log", + indexes = {@Index(name = "Idx_exam_record_id", columnList = "exam_record_id"), + @Index(name = "Idx_question_id", columnList = "question_id")}) +@Entity +@TableName(value = "evaluator_ai_grading_log", autoResultMap = true) +@EqualsAndHashCode(callSuper = true) +public class AiGradingLogEntity extends BaseEntity { + @TableId(value = "id", type = IdType.ASSIGN_ID) + @Id + private Long id; + + @Column(name = "exam_record_id") + @TableField("exam_record_id") + @Comment("考试记录ID") + private Long examRecordId; + + @Column(name = "question_id") + @TableField("question_id") + @Comment("题目ID") + private Long questionId; + + @Column(name = "question_content", columnDefinition = "TEXT comment '题目内容快照'") + @TableField("question_content") + private String questionContent; + + @Column(name = "user_answer", columnDefinition = "TEXT comment '考生作答'") + @TableField("user_answer") + private String userAnswer; + + @Column(name = "standard_answer", columnDefinition = "TEXT comment '标准答案/得分点'") + @TableField("standard_answer") + private String standardAnswer; + + /** AI评分(实际得分) */ + @Column(name = "ai_score") + @TableField("ai_score") + @Comment("AI评分") + private Double aiScore; + + /** 命中的得分点index列表 */ + @TableField(value = "hit_points", typeHandler = JacksonTypeHandler.class) + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "hit_points", columnDefinition = "json comment '命中得分点列表'") + private List hitPoints; + + /** 总得分点数 */ + @Column(name = "total_points") + @TableField("total_points") + @Comment("总得分点数") + private Integer totalPoints; + + /** AI评语 */ + @Column(name = "ai_comment", columnDefinition = "varchar(1000) comment 'AI评语'") + @TableField("ai_comment") + private String aiComment; + + /** 请求报文(排查用) */ + @Column(name = "request_body", columnDefinition = "TEXT comment '请求报文'") + @TableField("request_body") + private String requestBody; + + /** 响应报文(排查用) */ + @Column(name = "response_body", columnDefinition = "TEXT comment '响应报文'") + @TableField("response_body") + private String responseBody; + + /** 处理状态:0-处理中,1-成功,2-失败 */ + @Column(name = "status") + @TableField("status") + @Comment("处理状态:0-处理中,1-成功,2-失败") + private Integer status = 0; + + /** 错误信息 */ + @Column(name = "error_msg", columnDefinition = "varchar(500) comment '错误信息'") + @TableField("error_msg") + private String errorMsg; +} diff --git a/src/main/java/com/project/exam/domain/service/AiGradingLogBaseService.java b/src/main/java/com/project/exam/domain/service/AiGradingLogBaseService.java new file mode 100644 index 0000000..d98d31b --- /dev/null +++ b/src/main/java/com/project/exam/domain/service/AiGradingLogBaseService.java @@ -0,0 +1,7 @@ +package com.project.exam.domain.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.project.exam.domain.entity.AiGradingLogEntity; + +public interface AiGradingLogBaseService extends IService { +} diff --git a/src/main/java/com/project/exam/domain/service/impl/AiGradingLogBaseServiceImpl.java b/src/main/java/com/project/exam/domain/service/impl/AiGradingLogBaseServiceImpl.java new file mode 100644 index 0000000..864a2f2 --- /dev/null +++ b/src/main/java/com/project/exam/domain/service/impl/AiGradingLogBaseServiceImpl.java @@ -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.AiGradingLogEntity; +import com.project.exam.domain.service.AiGradingLogBaseService; +import com.project.exam.mapper.AiGradingLogMapper; +import org.springframework.stereotype.Service; + +@Service +public class AiGradingLogBaseServiceImpl extends ServiceImpl implements AiGradingLogBaseService { +} diff --git a/src/main/java/com/project/exam/domain/service/strategy/ClassicPaperStrategy.java b/src/main/java/com/project/exam/domain/service/strategy/ClassicPaperStrategy.java new file mode 100644 index 0000000..eb06fa1 --- /dev/null +++ b/src/main/java/com/project/exam/domain/service/strategy/ClassicPaperStrategy.java @@ -0,0 +1,134 @@ +package com.project.exam.domain.service.strategy; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.project.base.domain.exception.BusinessErrorException; +import com.project.exam.domain.dto.ExamRecordDTO; +import com.project.exam.domain.entity.ExamRecordEntity; +import com.project.exam.domain.service.ExamRecordBaseService; +import com.project.question.domain.entity.QuestionEntity; +import com.project.task.domain.entity.TaskEntity; +import com.project.task.domain.entity.TaskPaperSnapshotEntity; +import com.project.task.domain.entity.TaskUserEntity; +import com.project.task.domain.service.TaskBaseService; +import com.project.task.domain.service.TaskPaperSnapshotBaseService; +import com.project.task.domain.service.TaskUserBaseService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 经典套卷组卷策略 + * 从快照表随机抽一个版本,不锁题不补库 + */ +@Component +@Slf4j +public class ClassicPaperStrategy implements PaperAssemblyStrategy { + + @Autowired + private TaskBaseService taskBaseService; + + @Autowired + private TaskUserBaseService taskUserBaseService; + + @Autowired + private TaskPaperSnapshotBaseService taskPaperSnapshotBaseService; + + @Autowired + private ExamRecordBaseService examRecordBaseService; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Override + @Transactional(rollbackFor = Exception.class) + public ExamRecordDTO assemblePaper(Long taskId, String userId) throws Exception { + // 1. 校验用户资格 + TaskUserEntity taskUser = taskUserBaseService.lambdaQuery() + .eq(TaskUserEntity::getTaskId, taskId) + .eq(TaskUserEntity::getUserId, userId) + .one(); + if (taskUser == null) { + throw new BusinessErrorException("您无需参与本场考核"); + } + if (taskUser.getStatus() != null && taskUser.getStatus() == 2) { + throw new BusinessErrorException("您已通过考核,无需重复考试"); + } + + // 2. Redis 防抖(30 秒 TTL) + String lockKey = String.format("lock:exam:start:%s:%s", taskId, userId); + Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS); + if (Boolean.FALSE.equals(locked)) { + throw new BusinessErrorException("请勿短时间内重复参加考试"); + } + + // 3. 查询任务配置 + TaskEntity task = taskBaseService.getById(taskId); + + // 4. 查询该任务的所有快照 + List allSnapshots = taskPaperSnapshotBaseService.list( + new LambdaQueryWrapper() + .eq(TaskPaperSnapshotEntity::getTaskId, taskId)); + if (allSnapshots.isEmpty()) { + throw new BusinessErrorException("该任务暂无可用套卷快照"); + } + + // 5. 获取所有版本的 setIndex,随机选一个 + List setIndexes = allSnapshots.stream() + .map(TaskPaperSnapshotEntity::getSetIndex) + .distinct() + .sorted() + .collect(Collectors.toList()); + Integer selectedSetIndex = setIndexes.get(new Random().nextInt(setIndexes.size())); + + // 6. 过滤出该版本的快照,按 sortOrder 排序 + List selectedSnapshots = allSnapshots.stream() + .filter(s -> selectedSetIndex.equals(s.getSetIndex())) + .sorted(Comparator.comparing(TaskPaperSnapshotEntity::getSortOrder)) + .collect(Collectors.toList()); + + // 7. 构建 QuestionSnapshot 列表 + List snapshotDTOList = new ArrayList<>(); + for (int i = 0; i < selectedSnapshots.size(); i++) { + TaskPaperSnapshotEntity snapshot = selectedSnapshots.get(i); + ExamRecordDTO.QuestionSnapshotDTO dto = new ExamRecordDTO.QuestionSnapshotDTO(); + dto.setQuestionId(snapshot.getQuestionId()); + dto.setIndex(i + 1); + // 从快照的 questionDetail 中拷贝题目内容 + if (snapshot.getQuestionDetail() != null) { + QuestionEntity.QuestionDetail detail = snapshot.getQuestionDetail(); + dto.setQuestionContent(detail.getQuestionContent()); + dto.setType(detail.getType()); + dto.setOptions(detail.getOptions()); + dto.setRightAnswer(detail.getRightAnswer()); + dto.setAnalysis(detail.getAnalysis()); + } + snapshotDTOList.add(dto); + } + + // 8. 构建 ExamRecordDTO(通过 taskUserId 关联) + ExamRecordDTO examRecordDTO = new ExamRecordDTO(); + examRecordDTO.setTaskUserId(taskUser.getId()); + examRecordDTO.setTaskId(taskId); + examRecordDTO.setTaskName(task.getName()); + examRecordDTO.setAnswerSnapshotDTOList(snapshotDTOList); + + // 9. 保存考试记录(通过 taskUserId 关联) + ExamRecordEntity recordEntity = new ExamRecordEntity(); + recordEntity.setTaskUserId(taskUser.getId()); + recordEntity.setTaskName(task.getName()); + recordEntity.setStartTime(new Date()); + examRecordBaseService.save(recordEntity); + examRecordDTO.setId(recordEntity.getId()); + + log.info(">>> [经典组卷] 组卷完成, taskId={}, userId={}, selectedSetIndex={}, 题目数={}", + taskId, userId, selectedSetIndex, snapshotDTOList.size()); + + return examRecordDTO; + } +} diff --git a/src/main/java/com/project/exam/domain/service/strategy/GenerativePaperStrategy.java b/src/main/java/com/project/exam/domain/service/strategy/GenerativePaperStrategy.java new file mode 100644 index 0000000..73d6bbf --- /dev/null +++ b/src/main/java/com/project/exam/domain/service/strategy/GenerativePaperStrategy.java @@ -0,0 +1,22 @@ +package com.project.exam.domain.service.strategy; + +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.Component; + +/** + * 生成式组卷策略 + * 直接委托给现有 AssemblePaperDomainServiceImpl,不迁移原有 388 行逻辑 + */ +@Component +public class GenerativePaperStrategy implements PaperAssemblyStrategy { + + @Autowired + private AssemblePaperDomainService assemblePaperDomainService; + + @Override + public ExamRecordDTO assemblePaper(Long taskId, String userId) throws Exception { + return assemblePaperDomainService.assemblePaper(taskId, userId); + } +} diff --git a/src/main/java/com/project/exam/domain/service/strategy/ObjectiveScoringStrategy.java b/src/main/java/com/project/exam/domain/service/strategy/ObjectiveScoringStrategy.java new file mode 100644 index 0000000..5f664e3 --- /dev/null +++ b/src/main/java/com/project/exam/domain/service/strategy/ObjectiveScoringStrategy.java @@ -0,0 +1,51 @@ +package com.project.exam.domain.service.strategy; + +import com.project.exam.domain.dto.ExamRecordDTO; +import com.project.task.domain.enums.QuestionTypeEnum; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 客观题判分策略 + * 单选题、多选题、判断题:比对答案 + */ +@Component +public class ObjectiveScoringStrategy implements ScoringStrategy { + + @Override + public boolean supports(Integer questionType) { + // 支持单选、多选、判断 + return QuestionTypeEnum.SINGLE_CHOICE.getValue().equals(questionType) + || QuestionTypeEnum.MULTIPLE_CHOICE.getValue().equals(questionType) + || QuestionTypeEnum.TRUE_FALSE.getValue().equals(questionType); + } + + @Override + public void score(ExamRecordDTO.QuestionSnapshotDTO snapshot, ScoringContext context, Long taskId) { + context.setObjectiveTotal(context.getObjectiveTotal() + 1); + + String userAnswer = snapshot.getUserAnswer(); + String rightAnswer = snapshot.getRightAnswer(); + + boolean isRight = false; + if (userAnswer != null && rightAnswer != null) { + if (QuestionTypeEnum.MULTIPLE_CHOICE.getValue().equals(snapshot.getType())) { + // 多选题:集合比较(忽略顺序) + Set userSet = Arrays.stream(userAnswer.split(",")).map(String::trim).collect(Collectors.toSet()); + Set rightSet = Arrays.stream(rightAnswer.split(",")).map(String::trim).collect(Collectors.toSet()); + isRight = userSet.equals(rightSet); + } else { + // 单选题、判断题:直接比较 + isRight = userAnswer.trim().equalsIgnoreCase(rightAnswer.trim()); + } + } + + snapshot.setIsRight(isRight); + if (isRight) { + context.setRightCount(context.getRightCount() + 1); + } + } +} diff --git a/src/main/java/com/project/exam/domain/service/strategy/PaperAssemblyStrategy.java b/src/main/java/com/project/exam/domain/service/strategy/PaperAssemblyStrategy.java new file mode 100644 index 0000000..a3f7294 --- /dev/null +++ b/src/main/java/com/project/exam/domain/service/strategy/PaperAssemblyStrategy.java @@ -0,0 +1,19 @@ +package com.project.exam.domain.service.strategy; + +import com.project.exam.domain.dto.ExamRecordDTO; + +/** + * 组卷策略接口 + * 生成式模式:A-Res 算法动态抽卷 + 锁题 + 水位线补库 + * 经典模式:从快照表随机抽一个版本,不锁题不补库 + */ +public interface PaperAssemblyStrategy { + + /** + * 组卷 + * @param taskId 考试任务ID + * @param userId 考生ID + * @return 考试记录DTO(含题目快照) + */ + ExamRecordDTO assemblePaper(Long taskId, String userId) throws Exception; +} diff --git a/src/main/java/com/project/exam/domain/service/strategy/PaperAssemblyStrategyFactory.java b/src/main/java/com/project/exam/domain/service/strategy/PaperAssemblyStrategyFactory.java new file mode 100644 index 0000000..6a3ccf0 --- /dev/null +++ b/src/main/java/com/project/exam/domain/service/strategy/PaperAssemblyStrategyFactory.java @@ -0,0 +1,31 @@ +package com.project.exam.domain.service.strategy; + +import com.project.task.domain.enums.ExamModeEnum; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 组卷策略工厂 + * 根据 examMode 分发到对应的策略实现 + */ +@Component +public class PaperAssemblyStrategyFactory { + + @Autowired + private GenerativePaperStrategy generativePaperStrategy; + + @Autowired + private ClassicPaperStrategy classicPaperStrategy; + + /** + * 获取组卷策略 + * @param examMode 考试模式:0-生成式,1-经典套题 + * @return 对应的策略实现 + */ + public PaperAssemblyStrategy getStrategy(Integer examMode) { + if (ExamModeEnum.CLASSIC.getValue().equals(examMode)) { + return classicPaperStrategy; + } + return generativePaperStrategy; + } +} diff --git a/src/main/java/com/project/exam/domain/service/strategy/ScoringContext.java b/src/main/java/com/project/exam/domain/service/strategy/ScoringContext.java new file mode 100644 index 0000000..11a63bf --- /dev/null +++ b/src/main/java/com/project/exam/domain/service/strategy/ScoringContext.java @@ -0,0 +1,32 @@ +package com.project.exam.domain.service.strategy; + +import com.project.task.domain.entity.TaskEntity; +import lombok.Data; + +/** + * 判分上下文 + * 在判分过程中累积客观题得分,最终与简答题得分汇总 + */ +@Data +public class ScoringContext { + + /** 任务配置 */ + private TaskEntity task; + + /** 客观题累计得分 */ + private Double objectiveScore = 0.0; + + /** 客观题答对数 */ + private int rightCount = 0; + + /** 客观题总数 */ + private int objectiveTotal = 0; + + /** 简答题累计得分 */ + private Double subjectiveScore = 0.0; + + /** 最终总分 */ + public Double getTotalScore() { + return objectiveScore + subjectiveScore; + } +} diff --git a/src/main/java/com/project/exam/domain/service/strategy/ScoringStrategy.java b/src/main/java/com/project/exam/domain/service/strategy/ScoringStrategy.java new file mode 100644 index 0000000..e3debba --- /dev/null +++ b/src/main/java/com/project/exam/domain/service/strategy/ScoringStrategy.java @@ -0,0 +1,26 @@ +package com.project.exam.domain.service.strategy; + +import com.project.exam.domain.dto.ExamRecordDTO; + +/** + * 判分策略接口 + * 客观题:比对答案算分 + * 主观题(简答题):调AI阅卷 + */ +public interface ScoringStrategy { + + /** + * 判断该策略是否支持此题型 + * @param questionType 题型 + * @return 是否支持 + */ + boolean supports(Integer questionType); + + /** + * 对单题判分 + * @param snapshot 题目快照(含用户答案) + * @param context 判分上下文(累积得分) + * @param taskId 任务ID(用于获取分值配置) + */ + void score(ExamRecordDTO.QuestionSnapshotDTO snapshot, ScoringContext context, Long taskId); +} diff --git a/src/main/java/com/project/exam/domain/service/strategy/ScoringStrategyFactory.java b/src/main/java/com/project/exam/domain/service/strategy/ScoringStrategyFactory.java new file mode 100644 index 0000000..b23611b --- /dev/null +++ b/src/main/java/com/project/exam/domain/service/strategy/ScoringStrategyFactory.java @@ -0,0 +1,29 @@ +package com.project.exam.domain.service.strategy; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 判分策略工厂 + * 根据题型分发到对应的策略实现 + */ +@Component +public class ScoringStrategyFactory { + + @Autowired + private List strategies; + + /** + * 获取判分策略 + * @param questionType 题型 + * @return 对应的策略实现 + */ + public ScoringStrategy getStrategy(Integer questionType) { + return strategies.stream() + .filter(s -> s.supports(questionType)) + .findFirst() + .orElseThrow(() -> new RuntimeException("不支持的题型: " + questionType)); + } +} diff --git a/src/main/java/com/project/exam/domain/service/strategy/SubjectiveScoringStrategy.java b/src/main/java/com/project/exam/domain/service/strategy/SubjectiveScoringStrategy.java new file mode 100644 index 0000000..b48a3c5 --- /dev/null +++ b/src/main/java/com/project/exam/domain/service/strategy/SubjectiveScoringStrategy.java @@ -0,0 +1,100 @@ +package com.project.exam.domain.service.strategy; + +import com.project.exam.domain.dto.ExamRecordDTO; +import com.project.exam.domain.entity.AiGradingLogEntity; +import com.project.exam.domain.service.AiGradingLogBaseService; +import com.project.interaction.domain.dto.AiScoringRequestDTO; +import com.project.interaction.domain.dto.AiScoringResponseDTO; +import com.project.interaction.domain.service.PostToAiScoringDomainService; +import com.project.question.domain.entity.QuestionEntity; +import com.project.task.domain.enums.QuestionTypeEnum; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 简答题判分策略 + * 调用AI阅卷,多点得分制:命中得分点数/总得分点数 × 题目分值 + */ +@Component +@Slf4j +public class SubjectiveScoringStrategy implements ScoringStrategy { + + @Autowired + private PostToAiScoringDomainService postToAiScoringDomainService; + + @Autowired + private AiGradingLogBaseService aiGradingLogBaseService; + + @Override + public boolean supports(Integer questionType) { + return QuestionTypeEnum.SHORT_ANSWER.getValue().equals(questionType); + } + + @Override + public void score(ExamRecordDTO.QuestionSnapshotDTO snapshot, ScoringContext context, Long taskId) { + Long questionId = snapshot.getQuestionId(); + + // 构建AI阅卷请求 + AiScoringRequestDTO request = new AiScoringRequestDTO(); + request.setQuestionId(questionId); + request.setQuestionContent(snapshot.getQuestionContent()); + request.setUserAnswer(snapshot.getUserAnswer()); + + // 构建得分点列表(从快照的 options 中获取,简答题的 options 存储得分点) + // 注意:得分点存储在 QuestionDetail.scoringPoints 中,需要从快照中获取 + // 这里先用空列表,后续从快照中获取 + request.setScoringPoints(new ArrayList<>()); + + // 创建判分日志 + AiGradingLogEntity logEntity = new AiGradingLogEntity(); + logEntity.setExamRecordId(context.getTask().getId()); // 临时,后续需要传入真正的 examRecordId + logEntity.setQuestionId(questionId); + logEntity.setQuestionContent(snapshot.getQuestionContent()); + logEntity.setUserAnswer(snapshot.getUserAnswer()); + logEntity.setStatus(0); // 处理中 + + try { + // 调用AI阅卷 + AiScoringResponseDTO response = postToAiScoringDomainService.requestAiScoring(request); + + if (response.getStatus() == 1 && response.getHitPoints() != null) { + // 计算得分:命中数 / 总数 × 题目分值 + int hitCount = response.getHitPoints().size(); + int totalCount = response.getTotalPoints(); + double questionScore = snapshot.getScore() != null ? snapshot.getScore() : 0.0; + double aiScore = totalCount > 0 ? questionScore * hitCount / totalCount : 0.0; + + // 更新快照 + snapshot.setAiScore(aiScore); + snapshot.setAiComment(response.getComment()); + snapshot.setHitPoints(response.getHitPoints()); + snapshot.setUserScore(aiScore); + + // 累加简答题得分 + context.setSubjectiveScore(context.getSubjectiveScore() + aiScore); + + // 更新日志 + logEntity.setAiScore(aiScore); + logEntity.setHitPoints(response.getHitPoints()); + logEntity.setTotalPoints(totalCount); + logEntity.setAiComment(response.getComment()); + logEntity.setStatus(1); // 成功 + } else { + log.error(">>> [AI阅卷] 评分失败, questionId={}, error={}", questionId, response.getErrorMsg()); + logEntity.setStatus(2); // 失败 + logEntity.setErrorMsg(response.getErrorMsg()); + } + } catch (Exception e) { + log.error(">>> [AI阅卷] 异常, questionId={}", questionId, e); + logEntity.setStatus(2); // 失败 + logEntity.setErrorMsg(e.getMessage()); + } + + // 保存日志 + aiGradingLogBaseService.save(logEntity); + } +} diff --git a/src/main/java/com/project/exam/mapper/AiGradingLogMapper.java b/src/main/java/com/project/exam/mapper/AiGradingLogMapper.java new file mode 100644 index 0000000..e16993a --- /dev/null +++ b/src/main/java/com/project/exam/mapper/AiGradingLogMapper.java @@ -0,0 +1,9 @@ +package com.project.exam.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.project.exam.domain.entity.AiGradingLogEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface AiGradingLogMapper extends BaseMapper { +} diff --git a/src/main/java/com/project/interaction/domain/dto/AiScoringRequestDTO.java b/src/main/java/com/project/interaction/domain/dto/AiScoringRequestDTO.java new file mode 100644 index 0000000..e4b6a72 --- /dev/null +++ b/src/main/java/com/project/interaction/domain/dto/AiScoringRequestDTO.java @@ -0,0 +1,36 @@ +package com.project.interaction.domain.dto; + +import lombok.Data; + +import java.util.List; + +/** + * AI阅卷请求 DTO + * 发送给算法服务,请求对简答题进行评分 + */ +@Data +public class AiScoringRequestDTO { + + /** 考试记录ID */ + private Long examRecordId; + + /** 题目ID */ + private Long questionId; + + /** 题目内容 */ + private String questionContent; + + /** 考生作答 */ + private String userAnswer; + + /** 得分点列表 */ + private List scoringPoints; + + @Data + public static class ScoringPoint { + /** 得分点序号 */ + private Integer index; + /** 得分点内容 */ + private String content; + } +} diff --git a/src/main/java/com/project/interaction/domain/dto/AiScoringResponseDTO.java b/src/main/java/com/project/interaction/domain/dto/AiScoringResponseDTO.java new file mode 100644 index 0000000..48ade5a --- /dev/null +++ b/src/main/java/com/project/interaction/domain/dto/AiScoringResponseDTO.java @@ -0,0 +1,28 @@ +package com.project.interaction.domain.dto; + +import lombok.Data; + +import java.util.List; + +/** + * AI阅卷响应 DTO + * 算法服务返回的评分结果 + */ +@Data +public class AiScoringResponseDTO { + + /** 命中的得分点index列表 */ + private List hitPoints; + + /** 总得分点数 */ + private Integer totalPoints; + + /** AI评语 */ + private String comment; + + /** 处理状态:1-成功,2-失败 */ + private Integer status; + + /** 错误信息(失败时有值) */ + private String errorMsg; +} diff --git a/src/main/java/com/project/interaction/domain/service/PostToAiScoringDomainService.java b/src/main/java/com/project/interaction/domain/service/PostToAiScoringDomainService.java new file mode 100644 index 0000000..afdd950 --- /dev/null +++ b/src/main/java/com/project/interaction/domain/service/PostToAiScoringDomainService.java @@ -0,0 +1,18 @@ +package com.project.interaction.domain.service; + +import com.project.interaction.domain.dto.AiScoringRequestDTO; +import com.project.interaction.domain.dto.AiScoringResponseDTO; + +/** + * 调用算法服务进行AI阅卷(同步调用) + * 简答题数量少(1-3道),同步等待返回 + */ +public interface PostToAiScoringDomainService { + + /** + * 请求AI阅卷 + * @param request 请求参数 + * @return 评分结果 + */ + AiScoringResponseDTO requestAiScoring(AiScoringRequestDTO request); +} diff --git a/src/main/java/com/project/interaction/domain/service/impl/PostToAiScoringDomainServiceImpl.java b/src/main/java/com/project/interaction/domain/service/impl/PostToAiScoringDomainServiceImpl.java new file mode 100644 index 0000000..4e98d15 --- /dev/null +++ b/src/main/java/com/project/interaction/domain/service/impl/PostToAiScoringDomainServiceImpl.java @@ -0,0 +1,63 @@ +package com.project.interaction.domain.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.interaction.domain.dto.AiScoringRequestDTO; +import com.project.interaction.domain.dto.AiScoringResponseDTO; +import com.project.interaction.domain.service.PostToAiScoringDomainService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; + +/** + * 调用算法服务进行AI阅卷 + * 同步调用,60秒超时 + * 算法服务API待确认,URL为占位实现 + */ +@Service +@Slf4j +public class PostToAiScoringDomainServiceImpl implements PostToAiScoringDomainService { + + @Resource(name = "algorithmWebClient") + private WebClient algorithmWebClient; + + /** 算法服务阅卷路径(待确认) */ + @Value("${algo.scoringUrl:/ai-scoring}") + private String scoringUrl; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public AiScoringResponseDTO requestAiScoring(AiScoringRequestDTO request) { + try { + log.info(">>> [AI阅卷] 正在请求AI阅卷, questionId={}, examRecordId={}", + request.getQuestionId(), request.getExamRecordId()); + + String responseBody = algorithmWebClient.post() + .uri(scoringUrl) + .bodyValue(request) + .retrieve() + .bodyToMono(String.class) + .timeout(Duration.ofSeconds(60)) + .block(); + + log.info(">>> [AI阅卷] 算法服务返回, questionId={}, response={}", + request.getQuestionId(), responseBody); + + // 解析响应 + AiScoringResponseDTO response = objectMapper.readValue(responseBody, AiScoringResponseDTO.class); + response.setStatus(1); // 成功 + return response; + + } catch (Exception e) { + log.error(">>> [AI阅卷] 算法服务调用异常, questionId={}", request.getQuestionId(), e); + AiScoringResponseDTO errorResponse = new AiScoringResponseDTO(); + errorResponse.setStatus(2); // 失败 + errorResponse.setErrorMsg(e.getMessage()); + return errorResponse; + } + } +}