diff --git a/src/main/java/com/project/classicpaper/application/ClassicPaperApplicationService.java b/src/main/java/com/project/classicpaper/application/ClassicPaperApplicationService.java index caede53..85d5371 100644 --- a/src/main/java/com/project/classicpaper/application/ClassicPaperApplicationService.java +++ b/src/main/java/com/project/classicpaper/application/ClassicPaperApplicationService.java @@ -4,13 +4,28 @@ import com.project.base.domain.result.Result; import com.project.classicpaper.domain.dto.ClassicPaperDTO; import com.project.classicpaper.domain.dto.ClassicPaperQuestionDTO; +import java.util.Map; + public interface ClassicPaperApplicationService { /** - * 单题重抽(仅 DRAFT 状态的版本可操作) + * 单题重新生题(同步) */ Result regenerateQuestion(Long paperId, Long paperQuestionId) throws Exception; + /** + * 单题重新生题(异步):提交任务后立即返回,前端通过轮询状态接口获取结果 + */ + void regenerateQuestionAsync(Long paperQuestionId) throws Exception; + + /** + * 查询重新生题任务状态 + * + * @param paperQuestionId 套卷题目关联ID + * @return 状态信息:status(0进行中/1成功/2失败), newQuestionId, errorMessage;无任务返回 null + */ + Map getRegenerateStatus(Long paperQuestionId); + /** * 切tab暂存:保存单个版本的编辑(名称、描述、题目排序/替换) */ diff --git a/src/main/java/com/project/classicpaper/application/ClassicPaperSetApplicationService.java b/src/main/java/com/project/classicpaper/application/ClassicPaperSetApplicationService.java index 4742002..8acfad7 100644 --- a/src/main/java/com/project/classicpaper/application/ClassicPaperSetApplicationService.java +++ b/src/main/java/com/project/classicpaper/application/ClassicPaperSetApplicationService.java @@ -30,7 +30,7 @@ public interface ClassicPaperSetApplicationService { Result delete(Long setId) throws Exception; /** - * 保存固化:全量保存套题组所有版本,校验通过后锁定为CONFIRMED + * 更新分值比例,同时计算每道题的分值 */ Result save(ClassicPaperSetDTO request) throws Exception; } diff --git a/src/main/java/com/project/classicpaper/application/impl/ClassicPaperApplicationServiceImpl.java b/src/main/java/com/project/classicpaper/application/impl/ClassicPaperApplicationServiceImpl.java index 9ec6e87..66f138f 100644 --- a/src/main/java/com/project/classicpaper/application/impl/ClassicPaperApplicationServiceImpl.java +++ b/src/main/java/com/project/classicpaper/application/impl/ClassicPaperApplicationServiceImpl.java @@ -9,6 +9,8 @@ import com.project.classicpaper.domain.service.RegenerateQuestionDomainService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import java.util.Map; + @Service public class ClassicPaperApplicationServiceImpl implements ClassicPaperApplicationService { @@ -23,6 +25,16 @@ public class ClassicPaperApplicationServiceImpl implements ClassicPaperApplicati return regenerateQuestionDomainService.regenerate(paperId, paperQuestionId); } + @Override + public void regenerateQuestionAsync(Long paperQuestionId) throws Exception { + regenerateQuestionDomainService.regenerateAsync(paperQuestionId); + } + + @Override + public Map getRegenerateStatus(Long paperQuestionId) { + return regenerateQuestionDomainService.getTaskStatus(paperQuestionId); + } + @Override public Result draft(ClassicPaperDTO request) throws Exception { return draftPaperDomainService.draft(request); diff --git a/src/main/java/com/project/classicpaper/application/impl/ClassicPaperSetApplicationServiceImpl.java b/src/main/java/com/project/classicpaper/application/impl/ClassicPaperSetApplicationServiceImpl.java index 3b87588..6fffc42 100644 --- a/src/main/java/com/project/classicpaper/application/impl/ClassicPaperSetApplicationServiceImpl.java +++ b/src/main/java/com/project/classicpaper/application/impl/ClassicPaperSetApplicationServiceImpl.java @@ -15,6 +15,7 @@ import com.project.classicpaper.domain.dto.ClassicPaperSetDTO; import com.project.classicpaper.domain.entity.ClassicPaperEntity; import com.project.classicpaper.domain.entity.ClassicPaperQuestionEntity; import com.project.classicpaper.domain.entity.ClassicPaperSetEntity; +import com.project.classicpaper.domain.entity.RegenerateQuestionTaskEntity; import com.project.classicpaper.domain.enums.ClassicPaperStatusEnum; import com.project.classicpaper.domain.param.ClassicPaperSetParam; import com.project.classicpaper.domain.service.*; @@ -24,10 +25,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.stream.Collectors; import com.fasterxml.jackson.databind.ObjectMapper; @@ -53,6 +51,9 @@ public class ClassicPaperSetApplicationServiceImpl implements ClassicPaperSetApp @Autowired private SavePaperSetDomainService savePaperSetDomainService; + @Autowired + private RegenerateQuestionTaskBaseService regenerateQuestionTaskBaseService; + @Autowired private com.project.task.config.ExamScoreRatioConfig examScoreRatioConfig; @@ -146,6 +147,20 @@ public class ClassicPaperSetApplicationServiceImpl implements ClassicPaperSetApp .map(ClassicPaperQuestionEntity::getQuestionId).collect(Collectors.toList()); List questions = questionBaseService.listByIds(questionIds); + // 查询正在进行中的重新生题任务 + List paperQuestionIds = paperQuestions.stream() + .map(ClassicPaperQuestionEntity::getId).collect(Collectors.toList()); + Set regeneratingIds = new HashSet<>(); + if (!paperQuestionIds.isEmpty()) { + List inProgressTasks = regenerateQuestionTaskBaseService.list( + new LambdaQueryWrapper() + .in(RegenerateQuestionTaskEntity::getPaperQuestionId, paperQuestionIds) + .eq(RegenerateQuestionTaskEntity::getStatus, 0)); + regeneratingIds = inProgressTasks.stream() + .map(RegenerateQuestionTaskEntity::getPaperQuestionId) + .collect(Collectors.toSet()); + } + List questionDTOs = new ArrayList<>(); for (ClassicPaperQuestionEntity pq : paperQuestions) { ClassicPaperQuestionDTO qDto = new ClassicPaperQuestionDTO(); @@ -154,6 +169,8 @@ public class ClassicPaperSetApplicationServiceImpl implements ClassicPaperSetApp qDto.setQuestionId(pq.getQuestionId()); qDto.setSortOrder(pq.getSortOrder()); qDto.setQuestionType(pq.getQuestionType()); + qDto.setScore(pq.getScore()); + qDto.setRegenerating(regeneratingIds.contains(pq.getId())); questions.stream() .filter(q -> q.getId().equals(pq.getQuestionId())) .findFirst() @@ -173,9 +190,6 @@ public class ClassicPaperSetApplicationServiceImpl implements ClassicPaperSetApp if (paperSet == null) { throw new BusinessErrorException("套题组不存在"); } - if (ClassicPaperStatusEnum.CONFIRMED.getValue().equals(paperSet.getStatus())) { - throw new BusinessErrorException("已固化的套题组不能废弃"); - } paperSet.setStatus(ClassicPaperStatusEnum.DISCARDED.getValue()); classicPaperSetBaseService.updateById(paperSet); @@ -188,6 +202,9 @@ public class ClassicPaperSetApplicationServiceImpl implements ClassicPaperSetApp return Result.success("废弃成功"); } + /** + * 更新分值比例,同时计算每道题的分值 + */ @Override @Transactional(rollbackFor = Exception.class) public Result save(ClassicPaperSetDTO request) throws Exception { diff --git a/src/main/java/com/project/classicpaper/controller/ClassicPaperController.java b/src/main/java/com/project/classicpaper/controller/ClassicPaperController.java index 9991dc8..9529384 100644 --- a/src/main/java/com/project/classicpaper/controller/ClassicPaperController.java +++ b/src/main/java/com/project/classicpaper/controller/ClassicPaperController.java @@ -9,6 +9,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; +import java.util.Map; + @RestController @Slf4j @RequestMapping("/api/admin/classicPaper") @@ -17,12 +19,34 @@ public class ClassicPaperController { @Autowired private ClassicPaperApplicationService classicPaperApplicationService; + /** + * 单题重新生题(同步):等待算法返回结果,适合响应快的场景 + */ @PostMapping("/regenerateQuestion") @OperationLog(module = "经典套题") public Result regenerateQuestion(Long paperId, Long paperQuestionId) throws Exception { return classicPaperApplicationService.regenerateQuestion(paperId, paperQuestionId); } + /** + * 单题重新生题(异步):立即返回,前端通过轮询状态接口获取结果 + */ + @PostMapping("/regenerateQuestion/async") + @OperationLog(module = "经典套题") + public Result regenerateQuestionAsync(Long paperQuestionId) throws Exception { + classicPaperApplicationService.regenerateQuestionAsync(paperQuestionId); + return Result.success("接收成功"); + } + + /** + * 查询重新生题任务状态(前端轮询) + */ + @GetMapping("/regenerateQuestion/status") + public Result> getRegenerateStatus(Long paperQuestionId) { + Map status = classicPaperApplicationService.getRegenerateStatus(paperQuestionId); + return Result.success(status); + } + /** * 切tab暂存:保存当前版本的编辑(名称、描述、题目排序/替换) */ diff --git a/src/main/java/com/project/classicpaper/controller/ClassicPaperSetController.java b/src/main/java/com/project/classicpaper/controller/ClassicPaperSetController.java index 30915c8..0f253c1 100644 --- a/src/main/java/com/project/classicpaper/controller/ClassicPaperSetController.java +++ b/src/main/java/com/project/classicpaper/controller/ClassicPaperSetController.java @@ -41,10 +41,10 @@ public class ClassicPaperSetController { } /** - * 保存固化:全量保存套题组所有版本,校验通过后锁定为CONFIRMED + * 更新分值比例,同时计算每道题的分值 */ @PostMapping("/save") - public Result save(@RequestBody ClassicPaperSetDTO request) throws Exception { + public Result save(ClassicPaperSetDTO request) throws Exception { return classicPaperSetApplicationService.save(request); } } diff --git a/src/main/java/com/project/classicpaper/domain/dto/ClassicPaperQuestionDTO.java b/src/main/java/com/project/classicpaper/domain/dto/ClassicPaperQuestionDTO.java index b2dffef..ec383d0 100644 --- a/src/main/java/com/project/classicpaper/domain/dto/ClassicPaperQuestionDTO.java +++ b/src/main/java/com/project/classicpaper/domain/dto/ClassicPaperQuestionDTO.java @@ -11,7 +11,7 @@ public class ClassicPaperQuestionDTO { /** 套卷ID */ private Long paperId; - /** 题目ID(QuestionEntity 主键),标识具体题目内容,重抽题目时传入此字段 */ + /** 题目ID(QuestionEntity 主键),标识具体题目内容,重新生题时传入此字段 */ private Long questionId; /** 题目在套卷中的排序序号 */ @@ -20,6 +20,12 @@ public class ClassicPaperQuestionDTO { /** 题型:0-单选,1-多选,2-判断,4-简答 */ private Integer questionType; + /** 该题分值(固化时由分值比例计算得出) */ + private Double score; + + /** 是否正在重新生题中:true-显示loading,false-显示题目 */ + private Boolean regenerating; + /** * 题目详情(从 QuestionEntity 关联查询) */ diff --git a/src/main/java/com/project/classicpaper/domain/entity/ClassicPaperQuestionEntity.java b/src/main/java/com/project/classicpaper/domain/entity/ClassicPaperQuestionEntity.java index 17b929e..acf43c8 100644 --- a/src/main/java/com/project/classicpaper/domain/entity/ClassicPaperQuestionEntity.java +++ b/src/main/java/com/project/classicpaper/domain/entity/ClassicPaperQuestionEntity.java @@ -41,4 +41,9 @@ public class ClassicPaperQuestionEntity extends BaseEntity { @TableField("question_type") @Comment("题型") private Integer questionType; + + @Column(name = "score") + @TableField("score") + @Comment("该题分值(固化时由分值比例计算得出)") + private Double score; } diff --git a/src/main/java/com/project/classicpaper/domain/entity/RegenerateQuestionTaskEntity.java b/src/main/java/com/project/classicpaper/domain/entity/RegenerateQuestionTaskEntity.java new file mode 100644 index 0000000..73d28f3 --- /dev/null +++ b/src/main/java/com/project/classicpaper/domain/entity/RegenerateQuestionTaskEntity.java @@ -0,0 +1,54 @@ +package com.project.classicpaper.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.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.annotations.Comment; + +@Data +@Table(name = "evaluator_regenerate_question_task", + indexes = {@Index(name = "Idx_task_id", columnList = "task_id")}) +@Entity +@TableName(value = "evaluator_regenerate_question_task") +@EqualsAndHashCode(callSuper = true) +public class RegenerateQuestionTaskEntity extends BaseEntity { + + @TableId(value = "id", type = IdType.ASSIGN_ID) + @Id + private Long id; + + @Column(name = "task_id", columnDefinition = "varchar(64) comment '任务ID(UUID)'") + @TableField("task_id") + @Comment("任务ID(UUID)") + private String taskId; + + @Column(name = "paper_id") + @TableField("paper_id") + @Comment("套卷ID") + private Long paperId; + + @Column(name = "paper_question_id") + @TableField("paper_question_id") + @Comment("套卷题目关联ID") + private Long paperQuestionId; + + @Column(name = "status") + @TableField("status") + @Comment("状态:0-进行中,1-成功,2-失败") + private Integer status; + + @Column(name = "new_question_id") + @TableField("new_question_id") + @Comment("新题目ID") + private Long newQuestionId; + + @Column(name = "error_message", columnDefinition = "TEXT") + @TableField("error_message") + @Comment("失败原因") + private String errorMessage; +} diff --git a/src/main/java/com/project/classicpaper/domain/service/RegenerateQuestionDomainService.java b/src/main/java/com/project/classicpaper/domain/service/RegenerateQuestionDomainService.java index f6cb812..5e8cfd9 100644 --- a/src/main/java/com/project/classicpaper/domain/service/RegenerateQuestionDomainService.java +++ b/src/main/java/com/project/classicpaper/domain/service/RegenerateQuestionDomainService.java @@ -3,14 +3,31 @@ package com.project.classicpaper.domain.service; import com.project.base.domain.result.Result; import com.project.classicpaper.domain.dto.ClassicPaperQuestionDTO; +import java.util.Map; + public interface RegenerateQuestionDomainService { /** - * 单题重抽:替换套卷中的某道题 + * 单题重新生题(同步):替换套卷中的某道题,等待算法返回结果 * - * @param paperId 套卷ID + * @param paperId 套卷ID * @param paperQuestionId 关联记录ID(ClassicPaperQuestionEntity 主键) * @return 新题目信息 */ Result regenerate(Long paperId, Long paperQuestionId) throws Exception; + + /** + * 单题重新生题(异步):提交任务后立即返回,前端通过轮询状态接口获取结果 + * + * @param paperQuestionId 关联记录ID(ClassicPaperQuestionEntity 主键) + */ + void regenerateAsync(Long paperQuestionId) throws Exception; + + /** + * 查询重新生题任务状态(按 paperQuestionId 查询) + * + * @param paperQuestionId 套卷题目关联ID + * @return 状态信息:status(0进行中/1成功/2失败), newQuestionId, errorMessage;无任务返回 null + */ + Map getTaskStatus(Long paperQuestionId); } diff --git a/src/main/java/com/project/classicpaper/domain/service/RegenerateQuestionTaskBaseService.java b/src/main/java/com/project/classicpaper/domain/service/RegenerateQuestionTaskBaseService.java new file mode 100644 index 0000000..e5d0f73 --- /dev/null +++ b/src/main/java/com/project/classicpaper/domain/service/RegenerateQuestionTaskBaseService.java @@ -0,0 +1,7 @@ +package com.project.classicpaper.domain.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.project.classicpaper.domain.entity.RegenerateQuestionTaskEntity; + +public interface RegenerateQuestionTaskBaseService extends IService { +} diff --git a/src/main/java/com/project/classicpaper/domain/service/SavePaperSetDomainService.java b/src/main/java/com/project/classicpaper/domain/service/SavePaperSetDomainService.java index 36e0612..f82cbf7 100644 --- a/src/main/java/com/project/classicpaper/domain/service/SavePaperSetDomainService.java +++ b/src/main/java/com/project/classicpaper/domain/service/SavePaperSetDomainService.java @@ -6,7 +6,7 @@ import com.project.classicpaper.domain.dto.ClassicPaperSetDTO; public interface SavePaperSetDomainService { /** - * 保存固化:全量保存套题组所有版本,校验通过后锁定为CONFIRMED + * 更新分值比例,同时计算每道题的分值 * 返回 setId,由调用方自行查询详情组装返回 */ Result save(ClassicPaperSetDTO request) throws Exception; diff --git a/src/main/java/com/project/classicpaper/domain/service/impl/DraftPaperDomainServiceImpl.java b/src/main/java/com/project/classicpaper/domain/service/impl/DraftPaperDomainServiceImpl.java index e43b2a4..9319cf5 100644 --- a/src/main/java/com/project/classicpaper/domain/service/impl/DraftPaperDomainServiceImpl.java +++ b/src/main/java/com/project/classicpaper/domain/service/impl/DraftPaperDomainServiceImpl.java @@ -39,14 +39,11 @@ public class DraftPaperDomainServiceImpl implements DraftPaperDomainService { @Override public Result draft(ClassicPaperDTO request) throws Exception { Long paperId = request.getId(); - // 1. 校验套卷存在且为草稿状态 + // 1. 校验套卷存在 ClassicPaperEntity paper = classicPaperBaseService.getById(paperId); if (paper == null) { throw new BusinessErrorException("套卷不存在"); } - if (!ClassicPaperStatusEnum.DRAFT.getValue().equals(paper.getStatus())) { - throw new BusinessErrorException("只有草稿状态的套卷才能编辑"); - } // 2. 更新套卷名称和描述 if (StrUtil.isNotBlank(request.getName())) { @@ -99,6 +96,7 @@ public class DraftPaperDomainServiceImpl implements DraftPaperDomainService { qDto.setQuestionId(rel.getQuestionId()); qDto.setSortOrder(rel.getSortOrder()); qDto.setQuestionType(rel.getQuestionType()); + qDto.setScore(rel.getScore()); questions.stream() .filter(q -> q.getId().equals(rel.getQuestionId())) .findFirst() diff --git a/src/main/java/com/project/classicpaper/domain/service/impl/RegenerateQuestionDomainServiceImpl.java b/src/main/java/com/project/classicpaper/domain/service/impl/RegenerateQuestionDomainServiceImpl.java index e864c70..eb16aa3 100644 --- a/src/main/java/com/project/classicpaper/domain/service/impl/RegenerateQuestionDomainServiceImpl.java +++ b/src/main/java/com/project/classicpaper/domain/service/impl/RegenerateQuestionDomainServiceImpl.java @@ -6,11 +6,9 @@ import com.project.base.domain.result.Result; import com.project.classicpaper.domain.dto.ClassicPaperQuestionDTO; import com.project.classicpaper.domain.entity.ClassicPaperEntity; import com.project.classicpaper.domain.entity.ClassicPaperQuestionEntity; +import com.project.classicpaper.domain.entity.RegenerateQuestionTaskEntity; import com.project.classicpaper.domain.enums.ClassicPaperStatusEnum; -import com.project.classicpaper.domain.service.ClassicPaperBaseService; -import com.project.classicpaper.domain.service.ClassicPaperQuestionBaseService; -import com.project.classicpaper.domain.service.ClassicPaperQuestionGenerator; -import com.project.classicpaper.domain.service.RegenerateQuestionDomainService; +import com.project.classicpaper.domain.service.*; import com.project.information.domain.entity.KnowledgePointEntity; import com.project.information.domain.service.KnowledgePointBaseService; import com.project.question.domain.entity.QuestionEntity; @@ -20,17 +18,28 @@ import com.project.question.domain.service.QuestionKpRelBaseService; import com.project.task.domain.enums.QuestionTypeEnum; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; @Service @Slf4j public class RegenerateQuestionDomainServiceImpl implements RegenerateQuestionDomainService { + @Autowired + @Qualifier("regenerateExecutor") + private Executor regenerateExecutor; + @Autowired private ClassicPaperBaseService classicPaperBaseService; @@ -49,17 +58,23 @@ public class RegenerateQuestionDomainServiceImpl implements RegenerateQuestionDo @Autowired private ClassicPaperQuestionGenerator classicPaperQuestionGenerator; + @Autowired + private RegenerateQuestionTaskBaseService regenerateQuestionTaskBaseService; + + @Value("${classicpaper.mock.regenerate-delay-ms:4000}") + private long mockDelayMin; + + @Value("${classicpaper.mock.regenerate-delay-max-ms:5000}") + private long mockDelayMax; + @Override @Transactional(rollbackFor = Exception.class) public Result regenerate(Long paperId, Long paperQuestionId) throws Exception { - // 1. 校验套卷存在且为草稿状态 + // 1. 校验套卷存在 ClassicPaperEntity paper = classicPaperBaseService.getById(paperId); if (paper == null) { throw new BusinessErrorException("套卷不存在"); } - if (!ClassicPaperStatusEnum.DRAFT.getValue().equals(paper.getStatus())) { - throw new BusinessErrorException("只有草稿状态的套卷才能重抽题目"); - } // 2. 直接用 paperQuestionId 查关联记录 ClassicPaperQuestionEntity paperQuestion = classicPaperQuestionBaseService.getById(paperQuestionId); @@ -84,7 +99,7 @@ public class RegenerateQuestionDomainServiceImpl implements RegenerateQuestionDo .eq(QuestionKpRelEntity::getQuestionId, questionId) ); if (kpRels.isEmpty()) { - throw new BusinessErrorException("原题目没有关联知识点,无法重抽"); + throw new BusinessErrorException("原题目没有关联知识点,无法重新生题"); } List kpIds = kpRels.stream() .map(QuestionKpRelEntity::getKpId).collect(Collectors.toList()); @@ -95,7 +110,10 @@ public class RegenerateQuestionDomainServiceImpl implements RegenerateQuestionDo throw new BusinessErrorException("关联知识点数据不存在"); } - // 6. 调用生成器生成新题 + // 6. Mock 延迟:模拟算法服务返回耗时 + mockSleep(); + + // 7. 调用生成器生成新题 QuestionTypeEnum questionType = QuestionTypeEnum.findByValue(originalQuestion.getQuestionType()); List newQuestions = classicPaperQuestionGenerator.generate( knowledgePoints, questionType, 1); @@ -104,13 +122,13 @@ public class RegenerateQuestionDomainServiceImpl implements RegenerateQuestionDo } QuestionEntity newQuestion = newQuestions.get(0); - // 7. 更新关联关系,指向新题 + // 8. 更新关联关系,指向新题 paperQuestion.setQuestionId(newQuestion.getId()); classicPaperQuestionBaseService.updateById(paperQuestion); - log.info(">>> [经典套题-重抽] 套卷{} 关联记录{} 原题{} -> 新题{}", paperId, paperQuestionId, questionId, newQuestion.getId()); + log.info(">>> [经典套题-重新生题] 套卷{} 关联记录{} 原题{} -> 新题{}", paperId, paperQuestionId, questionId, newQuestion.getId()); - // 8. 构建返回 + // 9. 构建返回 ClassicPaperQuestionDTO dto = new ClassicPaperQuestionDTO(); dto.setId(paperQuestion.getId()); dto.setPaperId(paperId); @@ -122,4 +140,126 @@ public class RegenerateQuestionDomainServiceImpl implements RegenerateQuestionDo } return Result.success(dto); } + + @Override + public void regenerateAsync(Long paperQuestionId) throws Exception { + // 1. 前置校验(快速失败,不占用异步线程) + ClassicPaperQuestionEntity paperQuestion = classicPaperQuestionBaseService.getById(paperQuestionId); + if (paperQuestion == null) { + throw new BusinessErrorException("该题目不存在"); + } + Long paperId = paperQuestion.getPaperId(); + ClassicPaperEntity paper = classicPaperBaseService.getById(paperId); + if (paper == null) { + throw new BusinessErrorException("套卷不存在"); + } + + // 2. 检查是否已有进行中的任务 + RegenerateQuestionTaskEntity existingTask = regenerateQuestionTaskBaseService.getOne( + new LambdaQueryWrapper() + .eq(RegenerateQuestionTaskEntity::getPaperQuestionId, paperQuestionId) + .eq(RegenerateQuestionTaskEntity::getStatus, 0)); + if (existingTask != null) { + throw new BusinessErrorException("该题目正在重新生题中,请稍后再试"); + } + + // 3. 创建任务记录 + String taskId = UUID.randomUUID().toString().replace("-", ""); + RegenerateQuestionTaskEntity taskEntity = new RegenerateQuestionTaskEntity(); + taskEntity.setTaskId(taskId); + taskEntity.setPaperId(paperId); + taskEntity.setPaperQuestionId(paperQuestionId); + taskEntity.setStatus(0); // 进行中 + regenerateQuestionTaskBaseService.save(taskEntity); + + // 4. 异步执行重新生题逻辑 + CompletableFuture.runAsync(() -> doRegenerateAsync(taskId, paperQuestionId), regenerateExecutor); + + log.info(">>> [经典套题-重新生题-异步] 任务已提交, taskId={}, paperId={}, paperQuestionId={}", taskId, paperId, paperQuestionId); + } + + private void doRegenerateAsync(String taskId, Long paperQuestionId) { + try { + // 1. 查询关联记录和原题 + ClassicPaperQuestionEntity paperQuestion = classicPaperQuestionBaseService.getById(paperQuestionId); + Long questionId = paperQuestion.getQuestionId(); + QuestionEntity originalQuestion = questionBaseService.getById(questionId); + + // 2. 查询知识点 + List kpRels = questionKpRelBaseService.list( + new LambdaQueryWrapper() + .eq(QuestionKpRelEntity::getQuestionId, questionId)); + List kpIds = kpRels.stream() + .map(QuestionKpRelEntity::getKpId).collect(Collectors.toList()); + List knowledgePoints = knowledgePointBaseService.listByIds(kpIds); + + // 3. Mock 延迟:模拟算法服务返回耗时 + mockSleep(); + + // 4. 调用生成器生成新题 + QuestionTypeEnum questionType = QuestionTypeEnum.findByValue(originalQuestion.getQuestionType()); + List newQuestions = classicPaperQuestionGenerator.generate( + knowledgePoints, questionType, 1); + if (newQuestions.isEmpty()) { + throw new BusinessErrorException("生成新题失败"); + } + QuestionEntity newQuestion = newQuestions.get(0); + + // 5. 更新关联关系 + paperQuestion.setQuestionId(newQuestion.getId()); + classicPaperQuestionBaseService.updateById(paperQuestion); + + // 6. 更新任务状态为成功 + RegenerateQuestionTaskEntity taskEntity = regenerateQuestionTaskBaseService.getOne( + new LambdaQueryWrapper() + .eq(RegenerateQuestionTaskEntity::getTaskId, taskId)); + taskEntity.setStatus(1); + taskEntity.setNewQuestionId(newQuestion.getId()); + regenerateQuestionTaskBaseService.updateById(taskEntity); + + log.info(">>> [经典套题-重新生题-异步] 任务成功, taskId={}, 原题{} -> 新题{}", taskId, questionId, newQuestion.getId()); + } catch (Exception e) { + log.error(">>> [经典套题-重新生题-异步] 任务失败, taskId={}", taskId, e); + // 更新任务状态为失败 + RegenerateQuestionTaskEntity taskEntity = regenerateQuestionTaskBaseService.getOne( + new LambdaQueryWrapper() + .eq(RegenerateQuestionTaskEntity::getTaskId, taskId)); + if (taskEntity != null) { + taskEntity.setStatus(2); + taskEntity.setErrorMessage(e.getMessage()); + regenerateQuestionTaskBaseService.updateById(taskEntity); + } + } + } + + @Override + public Map getTaskStatus(Long paperQuestionId) { + RegenerateQuestionTaskEntity taskEntity = regenerateQuestionTaskBaseService.getOne( + new LambdaQueryWrapper() + .eq(RegenerateQuestionTaskEntity::getPaperQuestionId, paperQuestionId) + .orderByDesc(RegenerateQuestionTaskEntity::getCreateTime) + .last("LIMIT 1")); + if (taskEntity == null) { + return null; + } + Map result = new java.util.HashMap<>(); + result.put("status", taskEntity.getStatus()); + result.put("newQuestionId", taskEntity.getNewQuestionId()); + result.put("errorMessage", taskEntity.getErrorMessage()); + return result; + } + + /** + * Mock 延迟:模拟算法服务返回耗时(4~5秒随机) + */ + private void mockSleep() { + long delay = ThreadLocalRandom.current().nextLong(mockDelayMin, mockDelayMax + 1); + log.info(">>> [经典套题-重新生题] Mock 延迟 {}ms", delay); + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn(">>> [经典套题-重新生题] Mock 延迟被中断"); + } + } } diff --git a/src/main/java/com/project/classicpaper/domain/service/impl/RegenerateQuestionTaskBaseServiceImpl.java b/src/main/java/com/project/classicpaper/domain/service/impl/RegenerateQuestionTaskBaseServiceImpl.java new file mode 100644 index 0000000..3e81207 --- /dev/null +++ b/src/main/java/com/project/classicpaper/domain/service/impl/RegenerateQuestionTaskBaseServiceImpl.java @@ -0,0 +1,11 @@ +package com.project.classicpaper.domain.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.project.classicpaper.domain.entity.RegenerateQuestionTaskEntity; +import com.project.classicpaper.domain.service.RegenerateQuestionTaskBaseService; +import com.project.classicpaper.mapper.RegenerateQuestionTaskMapper; +import org.springframework.stereotype.Service; + +@Service +public class RegenerateQuestionTaskBaseServiceImpl extends ServiceImpl implements RegenerateQuestionTaskBaseService { +} diff --git a/src/main/java/com/project/classicpaper/domain/service/impl/SavePaperSetDomainServiceImpl.java b/src/main/java/com/project/classicpaper/domain/service/impl/SavePaperSetDomainServiceImpl.java index bf8ce78..370f7bc 100644 --- a/src/main/java/com/project/classicpaper/domain/service/impl/SavePaperSetDomainServiceImpl.java +++ b/src/main/java/com/project/classicpaper/domain/service/impl/SavePaperSetDomainServiceImpl.java @@ -1,27 +1,26 @@ package com.project.classicpaper.domain.service.impl; -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.project.base.domain.exception.BusinessErrorException; import com.project.base.domain.result.Result; -import com.project.classicpaper.domain.dto.ClassicPaperDTO; -import com.project.classicpaper.domain.dto.ClassicPaperQuestionDTO; import com.project.classicpaper.domain.dto.ClassicPaperSetDTO; import com.project.classicpaper.domain.entity.ClassicPaperEntity; +import com.project.classicpaper.domain.entity.ClassicPaperQuestionEntity; import com.project.classicpaper.domain.entity.ClassicPaperSetEntity; import com.project.classicpaper.domain.enums.ClassicPaperStatusEnum; import com.project.classicpaper.domain.service.ClassicPaperBaseService; import com.project.classicpaper.domain.service.ClassicPaperQuestionBaseService; import com.project.classicpaper.domain.service.ClassicPaperSetBaseService; import com.project.classicpaper.domain.service.SavePaperSetDomainService; +import com.project.task.config.ExamScoreRatioConfig; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; @Service @@ -37,94 +36,115 @@ public class SavePaperSetDomainServiceImpl implements SavePaperSetDomainService @Autowired private ClassicPaperQuestionBaseService classicPaperQuestionBaseService; + @Autowired + private ExamScoreRatioConfig examScoreRatioConfig; + + private final ObjectMapper objectMapper = new ObjectMapper(); + @Override @Transactional(rollbackFor = Exception.class) public Result save(ClassicPaperSetDTO request) throws Exception { Long setId = request.getId(); - // 1. 校验套题组存在且为草稿状态 + + // 1. 校验套题组存在 ClassicPaperSetEntity paperSet = classicPaperSetBaseService.getById(setId); if (paperSet == null) { throw new BusinessErrorException("套题组不存在"); } - if (!ClassicPaperStatusEnum.DRAFT.getValue().equals(paperSet.getStatus())) { - throw new BusinessErrorException("只有草稿状态的套题组才能保存固化"); - } - // 2. 更新套题组基本信息 - if (StrUtil.isNotBlank(request.getName())) { - paperSet.setName(request.getName()); - } - if (StrUtil.isNotBlank(request.getDescription())) { - paperSet.setDescription(request.getDescription()); - } - if (request.getClassicCategoryId() != null) { - paperSet.setClassicCategoryId(request.getClassicCategoryId()); - } - // 从四个独立字段重建 scoreRatio JSON + // 2. 更新分值比例 String scoreRatio = buildScoreRatioFromFields(request); if (scoreRatio != null) { paperSet.setScoreRatio(scoreRatio); } classicPaperSetBaseService.updateById(paperSet); - // 3. 全量更新各版本及其题目关联 - if (CollUtil.isNotEmpty(request.getPaperList())) { - for (ClassicPaperDTO paperDto : request.getPaperList()) { - if (paperDto.getId() != null) { - // 更新已有套卷 - ClassicPaperEntity paper = classicPaperBaseService.getById(paperDto.getId()); - if (paper != null) { - if (StrUtil.isNotBlank(paperDto.getName())) { - paper.setName(paperDto.getName()); - } - if (StrUtil.isNotBlank(paperDto.getDescription())) { - paper.setDescription(paperDto.getDescription()); - } - classicPaperBaseService.updateById(paper); - } - - // 更新题目关联(排序、替换的题目ID) - if (CollUtil.isNotEmpty(paperDto.getQuestionList())) { - for (ClassicPaperQuestionDTO qDto : paperDto.getQuestionList()) { - if (qDto.getId() != null) { - com.project.classicpaper.domain.entity.ClassicPaperQuestionEntity relation = - classicPaperQuestionBaseService.getById(qDto.getId()); - if (relation != null) { - if (qDto.getSortOrder() != null) { - relation.setSortOrder(qDto.getSortOrder()); - } - if (qDto.getQuestionId() != null) { - relation.setQuestionId(qDto.getQuestionId()); - } - classicPaperQuestionBaseService.updateById(relation); - } - } - } - } - } - } + // 3. 根据分值比例计算每道题的分值,写入关联表 + calculateAndSaveQuestionScores(paperSet); + + log.info(">>> [经典套题] 分值比例已更新, setId={}", setId); + + return Result.success(setId); + } + + /** + * 根据分值比例和题型数量,计算每道题的分值并写入 ClassicPaperQuestionEntity.score + * 计算公式:每题分值 = (题型权重 / 总权重) × 总分 / 该题型数量 + */ + private void calculateAndSaveQuestionScores(ClassicPaperSetEntity paperSet) throws Exception { + // 解析分值比例 + Map ratioMap = objectMapper.readValue( + paperSet.getScoreRatio(), new TypeReference>() {}); + + // 各题型数量 + int singleCount = paperSet.getSingleChoiceNum() != null ? paperSet.getSingleChoiceNum() : 0; + int multipleCount = paperSet.getMultipleChoiceNum() != null ? paperSet.getMultipleChoiceNum() : 0; + int trueFalseCount = paperSet.getTrueFalseNum() != null ? paperSet.getTrueFalseNum() : 0; + int shortAnswerCount = paperSet.getShortAnswerNum() != null ? paperSet.getShortAnswerNum() : 0; + + // 各题型权重(从 scoreRatio 读取,不存在则用配置默认值) + int singleWeight = ratioMap.getOrDefault("single", examScoreRatioConfig.getSingle()); + int multipleWeight = ratioMap.getOrDefault("multiple", examScoreRatioConfig.getMultiple()); + int trueFalseWeight = ratioMap.getOrDefault("trueFalse", examScoreRatioConfig.getTrueFalse()); + int shortAnswerWeight = ratioMap.getOrDefault("shortAnswer", examScoreRatioConfig.getShortAnswer()); + + // 总权重 = 各题型数量 × 权重 + int totalWeight = singleCount * singleWeight + + multipleCount * multipleWeight + + trueFalseCount * trueFalseWeight + + shortAnswerCount * shortAnswerWeight; + + if (totalWeight == 0) { + return; } - // 4. 全部校验通过,锁定为 CONFIRMED - classicPaperBaseService.lambdaUpdate() - .eq(ClassicPaperEntity::getSetId, setId) - .set(ClassicPaperEntity::getStatus, ClassicPaperStatusEnum.CONFIRMED.getValue()) - .update(); + double totalScore = examScoreRatioConfig.getTotalScore(); - paperSet.setStatus(ClassicPaperStatusEnum.CONFIRMED.getValue()); - classicPaperSetBaseService.updateById(paperSet); + // 计算每题分值(保留2位小数) + double singleScore = singleCount > 0 ? Math.round(totalScore * singleWeight / totalWeight * 100) / 100.0 : 0; + double multipleScore = multipleCount > 0 ? Math.round(totalScore * multipleWeight / totalWeight * 100) / 100.0 : 0; + double trueFalseScore = trueFalseCount > 0 ? Math.round(totalScore * trueFalseWeight / totalWeight * 100) / 100.0 : 0; + double shortAnswerScore = shortAnswerCount > 0 ? Math.round(totalScore * shortAnswerWeight / totalWeight * 100) / 100.0 : 0; + + // 查询该套题组下所有套卷 + List papers = classicPaperBaseService.list( + new LambdaQueryWrapper() + .eq(ClassicPaperEntity::getSetId, paperSet.getId())); + if (papers.isEmpty()) { + return; + } - log.info(">>> [经典套题] 套题组已固化, setId={}", setId); + // 查询所有关联题目 + List paperIds = papers.stream().map(ClassicPaperEntity::getId).toList(); + List allQuestions = classicPaperQuestionBaseService.list( + new LambdaQueryWrapper() + .in(ClassicPaperQuestionEntity::getPaperId, paperIds)); - return Result.success(setId); + // 按题型设置分值 + for (ClassicPaperQuestionEntity q : allQuestions) { + if (q.getQuestionType() == null) continue; + switch (q.getQuestionType()) { + case 1 -> q.setScore(singleScore); // 单选 + case 2 -> q.setScore(multipleScore); // 多选 + case 3 -> q.setScore(trueFalseScore); // 判断 + case 4 -> q.setScore(shortAnswerScore); // 简答 + } + } + + // 批量更新 + if (!allQuestions.isEmpty()) { + classicPaperQuestionBaseService.updateBatchById(allQuestions); + } + + log.info(">>> [经典套题] 分值计算完成, setId={}, 单选{}分, 多选{}分, 判断{}分, 简答{}分", + paperSet.getId(), singleScore, multipleScore, trueFalseScore, shortAnswerScore); } /** * 从 DTO 的四个独立分值比例字段构建 JSON - * 只包含非 null 的字段(对应数量 > 0 的题型) */ private String buildScoreRatioFromFields(ClassicPaperSetDTO request) { - Map ratioMap = new LinkedHashMap<>(); + java.util.Map ratioMap = new java.util.LinkedHashMap<>(); if (request.getSingleChoiceScoreRatio() != null) { ratioMap.put("single", request.getSingleChoiceScoreRatio()); } @@ -141,7 +161,7 @@ public class SavePaperSetDomainServiceImpl implements SavePaperSetDomainService return null; } try { - return new ObjectMapper().writeValueAsString(ratioMap); + return objectMapper.writeValueAsString(ratioMap); } catch (Exception e) { log.warn(">>> [经典套题] scoreRatio 序列化失败", e); return null; diff --git a/src/main/java/com/project/classicpaper/mapper/RegenerateQuestionTaskMapper.java b/src/main/java/com/project/classicpaper/mapper/RegenerateQuestionTaskMapper.java new file mode 100644 index 0000000..83e8128 --- /dev/null +++ b/src/main/java/com/project/classicpaper/mapper/RegenerateQuestionTaskMapper.java @@ -0,0 +1,9 @@ +package com.project.classicpaper.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.project.classicpaper.domain.entity.RegenerateQuestionTaskEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RegenerateQuestionTaskMapper extends BaseMapper { +} diff --git a/src/main/java/com/project/operation/config/AsyncConfig.java b/src/main/java/com/project/operation/config/AsyncConfig.java index c4a6332..a85aa96 100644 --- a/src/main/java/com/project/operation/config/AsyncConfig.java +++ b/src/main/java/com/project/operation/config/AsyncConfig.java @@ -37,4 +37,20 @@ public class AsyncConfig { executor.initialize(); return executor; } + + /** + * 重抽题异步线程池 + */ + @Bean(name = "regenerateExecutor") + public Executor regenerateExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(5); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("regenerate-"); + executor.setKeepAliveSeconds(60); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f6d0395..faa8cd1 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -92,4 +92,12 @@ question: # 重试间隔(秒) retry-interval: 60 scheduled-task: - owner: test \ No newline at end of file + owner: test + +# 经典套题 Mock 配置 +classicpaper: + mock: + # 重抽题最小延迟(毫秒) + regenerate-delay-ms: 4000 + # 重抽题最大延迟(毫秒) + regenerate-delay-max-ms: 5000 \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index aa87666..4be8ce5 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -94,4 +94,10 @@ question: retry-interval: 60 scheduled-task: - owner: test \ No newline at end of file + owner: test + +# 经典套题 Mock 配置(生产环境关闭 mock 延迟) +classicpaper: + mock: + regenerate-delay-ms: 0 + regenerate-delay-max-ms: 0 \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 306add4..6282055 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -94,4 +94,10 @@ question: retry-interval: 60 scheduled-task: - owner: test \ No newline at end of file + owner: test + +# 经典套题 Mock 配置 +classicpaper: + mock: + regenerate-delay-ms: 4000 + regenerate-delay-max-ms: 5000 \ No newline at end of file