diff --git a/src/main/java/com/project/classicpaper/application/ClassicCategoryApplicationService.java b/src/main/java/com/project/classicpaper/application/ClassicCategoryApplicationService.java new file mode 100644 index 0000000..96d1dad --- /dev/null +++ b/src/main/java/com/project/classicpaper/application/ClassicCategoryApplicationService.java @@ -0,0 +1,15 @@ +package com.project.classicpaper.application; + +import com.project.base.domain.result.Result; +import com.project.classicpaper.domain.dto.ClassicCategoryDTO; + +import java.util.List; + +public interface ClassicCategoryApplicationService { + + Result> treeList(); + + Result save(ClassicCategoryDTO dto) throws Exception; + + Result delete(Long id) throws Exception; +} diff --git a/src/main/java/com/project/classicpaper/application/impl/ClassicCategoryApplicationServiceImpl.java b/src/main/java/com/project/classicpaper/application/impl/ClassicCategoryApplicationServiceImpl.java new file mode 100644 index 0000000..303550c --- /dev/null +++ b/src/main/java/com/project/classicpaper/application/impl/ClassicCategoryApplicationServiceImpl.java @@ -0,0 +1,95 @@ +package com.project.classicpaper.application.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.project.base.domain.exception.BusinessErrorException; +import com.project.base.domain.result.Result; +import com.project.base.domain.utils.TreeUtils; +import com.project.classicpaper.application.ClassicCategoryApplicationService; +import com.project.classicpaper.domain.dto.ClassicCategoryDTO; +import com.project.classicpaper.domain.entity.ClassicCategoryEntity; +import com.project.classicpaper.domain.service.ClassicCategoryBaseService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +public class ClassicCategoryApplicationServiceImpl implements ClassicCategoryApplicationService { + + @Autowired + private ClassicCategoryBaseService classicCategoryBaseService; + + @Override + public Result> treeList() { + List list = classicCategoryBaseService.list( + new LambdaQueryWrapper().orderByAsc(ClassicCategoryEntity::getSort) + ).stream().map(entity -> entity.toDTO(ClassicCategoryDTO::new)) + .collect(Collectors.toList()); + return Result.success(TreeUtils.buildLongTree(list, + ClassicCategoryDTO::getId, + ClassicCategoryDTO::getParentId, + ClassicCategoryDTO::setChildrenList)); + } + + @Override + public Result save(ClassicCategoryDTO dto) throws Exception { + if (StrUtil.isBlank(dto.getName())) { + throw new BusinessErrorException("分类名称不能为空"); + } + if (StrUtil.length(dto.getName()) > 50) { + throw new BusinessErrorException("分类名称过长"); + } + // 新增时校验层级 + if (dto.getId() == null) { + if (dto.getLevel() == null || (dto.getLevel() != 1 && dto.getLevel() != 2)) { + throw new BusinessErrorException("层级参数错误"); + } + if (dto.getLevel() == 2) { + if (dto.getParentId() == null || dto.getParentId() == 0) { + throw new BusinessErrorException("二级分类必须选择父级"); + } + ClassicCategoryEntity parent = classicCategoryBaseService.getById(dto.getParentId()); + if (parent == null || !Objects.equals(parent.getLevel(), 1)) { + throw new BusinessErrorException("父级分类不存在或不是一级分类"); + } + } else { + dto.setParentId(0L); + } + } + // 名称去重(同父级下不可重名) + LambdaQueryWrapper duplicateQuery = new LambdaQueryWrapper() + .eq(ClassicCategoryEntity::getName, dto.getName()) + .eq(ClassicCategoryEntity::getParentId, dto.getParentId()); + if (dto.getId() != null) { + duplicateQuery.ne(ClassicCategoryEntity::getId, dto.getId()); + } + if (classicCategoryBaseService.count(duplicateQuery) > 0) { + throw new BusinessErrorException("同级下已存在同名分类"); + } + + ClassicCategoryEntity entity = dto.toEntity(ClassicCategoryEntity::new); + classicCategoryBaseService.saveOrUpdate(entity); + return Result.success(entity.toDTO(ClassicCategoryDTO::new)); + } + + @Override + public Result delete(Long id) throws Exception { + ClassicCategoryEntity category = classicCategoryBaseService.getById(id); + if (category == null) { + throw new BusinessErrorException("分类不存在"); + } + // 如果是一级分类,校验是否有子分类 + if (Objects.equals(category.getLevel(), 1)) { + long childCount = classicCategoryBaseService.count( + new LambdaQueryWrapper().eq(ClassicCategoryEntity::getParentId, id)); + if (childCount > 0) { + throw new BusinessErrorException("该分类下存在子分类,无法删除"); + } + } + classicCategoryBaseService.removeById(id); + return Result.success("删除成功"); + } +} diff --git a/src/main/java/com/project/classicpaper/controller/ClassicCategoryController.java b/src/main/java/com/project/classicpaper/controller/ClassicCategoryController.java new file mode 100644 index 0000000..db27fb5 --- /dev/null +++ b/src/main/java/com/project/classicpaper/controller/ClassicCategoryController.java @@ -0,0 +1,40 @@ +package com.project.classicpaper.controller; + +import com.project.base.domain.result.Result; +import com.project.classicpaper.application.ClassicCategoryApplicationService; +import com.project.classicpaper.domain.dto.ClassicCategoryDTO; +import com.project.operation.annotation.OperationLog; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@Slf4j +@RequestMapping("/api/admin/classicCategory") +public class ClassicCategoryController { + + @Autowired + private ClassicCategoryApplicationService classicCategoryApplicationService; + + @GetMapping("/treeList") + public Result> treeList() { + return classicCategoryApplicationService.treeList(); + } + + @PostMapping("/save") + @OperationLog(module = "经典分类") + public Result save(ClassicCategoryDTO dto) throws Exception { + return classicCategoryApplicationService.save(dto); + } + + @PostMapping("/delete") + @OperationLog(module = "经典分类") + public Result delete(Long id) throws Exception { + return classicCategoryApplicationService.delete(id); + } +} diff --git a/src/main/java/com/project/classicpaper/domain/dto/ClassicCategoryDTO.java b/src/main/java/com/project/classicpaper/domain/dto/ClassicCategoryDTO.java new file mode 100644 index 0000000..24b1385 --- /dev/null +++ b/src/main/java/com/project/classicpaper/domain/dto/ClassicCategoryDTO.java @@ -0,0 +1,22 @@ +package com.project.classicpaper.domain.dto; + +import com.project.base.domain.dto.BaseDTO; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class ClassicCategoryDTO extends BaseDTO { + private Long id; + + private String name; + + private Long parentId; + + private Integer level; + + private Integer sort = 0; + + private List childrenList = new ArrayList<>(); +} diff --git a/src/main/java/com/project/classicpaper/domain/entity/ClassicCategoryEntity.java b/src/main/java/com/project/classicpaper/domain/entity/ClassicCategoryEntity.java new file mode 100644 index 0000000..97200e2 --- /dev/null +++ b/src/main/java/com/project/classicpaper/domain/entity/ClassicCategoryEntity.java @@ -0,0 +1,43 @@ +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_classic_category", + indexes = {@Index(name = "Idx_parent_id", columnList = "parent_id")}) +@Entity +@TableName(value = "evaluator_classic_category") +@EqualsAndHashCode(callSuper = true) +public class ClassicCategoryEntity extends BaseEntity { + @TableId(value = "id", type = IdType.ASSIGN_ID) + @Id + private Long id; + + @Column(name = "name", columnDefinition = "varchar(200) comment '分类名称'") + @Comment("分类名称") + private String name; + + @Column(name = "parent_id") + @TableField("parent_id") + @Comment("父级ID,一级分类为0") + private Long parentId; + + @Column(name = "level") + @TableField("level") + @Comment("层级:1-一级分类,2-二级分类") + private Integer level; + + @Column(name = "sort") + @TableField("sort") + @Comment("排序权重") + private Integer sort = 0; +} diff --git a/src/main/java/com/project/classicpaper/domain/service/ClassicCategoryBaseService.java b/src/main/java/com/project/classicpaper/domain/service/ClassicCategoryBaseService.java new file mode 100644 index 0000000..54801ad --- /dev/null +++ b/src/main/java/com/project/classicpaper/domain/service/ClassicCategoryBaseService.java @@ -0,0 +1,7 @@ +package com.project.classicpaper.domain.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.project.classicpaper.domain.entity.ClassicCategoryEntity; + +public interface ClassicCategoryBaseService extends IService { +} diff --git a/src/main/java/com/project/classicpaper/domain/service/impl/ClassicCategoryBaseServiceImpl.java b/src/main/java/com/project/classicpaper/domain/service/impl/ClassicCategoryBaseServiceImpl.java new file mode 100644 index 0000000..77cfaa8 --- /dev/null +++ b/src/main/java/com/project/classicpaper/domain/service/impl/ClassicCategoryBaseServiceImpl.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.ClassicCategoryEntity; +import com.project.classicpaper.domain.service.ClassicCategoryBaseService; +import com.project.classicpaper.mapper.ClassicCategoryMapper; +import org.springframework.stereotype.Service; + +@Service +public class ClassicCategoryBaseServiceImpl extends ServiceImpl implements ClassicCategoryBaseService { +} diff --git a/src/main/java/com/project/classicpaper/mapper/ClassicCategoryMapper.java b/src/main/java/com/project/classicpaper/mapper/ClassicCategoryMapper.java new file mode 100644 index 0000000..579e02c --- /dev/null +++ b/src/main/java/com/project/classicpaper/mapper/ClassicCategoryMapper.java @@ -0,0 +1,9 @@ +package com.project.classicpaper.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.project.classicpaper.domain.entity.ClassicCategoryEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ClassicCategoryMapper extends BaseMapper { +} diff --git a/src/main/java/com/project/task/config/ExamScoreRatioConfig.java b/src/main/java/com/project/task/config/ExamScoreRatioConfig.java index de75848..54a816d 100644 --- a/src/main/java/com/project/task/config/ExamScoreRatioConfig.java +++ b/src/main/java/com/project/task/config/ExamScoreRatioConfig.java @@ -23,6 +23,11 @@ public class ExamScoreRatioConfig { */ private Integer trueFalse = 1; + /** + * 简答题权重,默认 4 + */ + private Integer shortAnswer = 4; + /** * 总分固定 100 */ diff --git a/src/main/java/com/project/task/domain/dto/TaskDTO.java b/src/main/java/com/project/task/domain/dto/TaskDTO.java index 01b66cb..7eb0772 100644 --- a/src/main/java/com/project/task/domain/dto/TaskDTO.java +++ b/src/main/java/com/project/task/domain/dto/TaskDTO.java @@ -80,6 +80,14 @@ public class TaskDTO extends BaseDTO { private Double shortAnswerScore; + private Long classicCategoryId; + + private String classicCategoryName; + + private Long classicCategoryParentId; + + private String classicCategoryParentName; + // =============== V1.1 新增字段 结束 ============ } diff --git a/src/main/java/com/project/task/domain/entity/TaskEntity.java b/src/main/java/com/project/task/domain/entity/TaskEntity.java index 6254e5f..0454cbc 100644 --- a/src/main/java/com/project/task/domain/entity/TaskEntity.java +++ b/src/main/java/com/project/task/domain/entity/TaskEntity.java @@ -168,6 +168,26 @@ public class TaskEntity extends BaseEntity { @Comment("简答题单题分值") private Double shortAnswerScore = 0D; + @Column(name = "classic_category_id") + @TableField("classic_category_id") + @Comment("经典套卷分类ID") + private Long classicCategoryId; + + @Column(name = "classic_category_name", columnDefinition = "varchar(200) comment '经典套卷分类名称'") + @TableField("classic_category_name") + @Comment("经典套卷分类名称") + private String classicCategoryName; + + @Column(name = "classic_category_parent_id") + @TableField("classic_category_parent_id") + @Comment("经典套卷父级分类ID") + private Long classicCategoryParentId; + + @Column(name = "classic_category_parent_name", columnDefinition = "varchar(200) comment '经典套卷父级分类名称'") + @TableField("classic_category_parent_name") + @Comment("经典套卷父级分类名称") + private String classicCategoryParentName; + // =============== V1.1 新增字段 结束 ============ } diff --git a/src/main/java/com/project/task/domain/service/impl/SaveOrUpdateTaskDomainServiceImpl.java b/src/main/java/com/project/task/domain/service/impl/SaveOrUpdateTaskDomainServiceImpl.java index 19c2d67..34256a0 100644 --- a/src/main/java/com/project/task/domain/service/impl/SaveOrUpdateTaskDomainServiceImpl.java +++ b/src/main/java/com/project/task/domain/service/impl/SaveOrUpdateTaskDomainServiceImpl.java @@ -1,10 +1,5 @@ package com.project.task.domain.service.impl; -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.date.DateUnit; -import cn.hutool.core.date.DateUtil; -import cn.hutool.core.lang.Validator; -import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.project.base.domain.exception.BusinessErrorException; import com.project.base.domain.result.Result; @@ -12,15 +7,16 @@ import com.project.ding.domain.dto.UserDTO; import com.project.ding.domain.entity.UserEntity; import com.project.ding.domain.service.UserBaseService; import com.project.ding.utils.DingUtil; -import com.project.information.domain.dto.KnowledgePointStatisticsDTO; +import com.project.classicpaper.domain.entity.ClassicCategoryEntity; +import com.project.classicpaper.domain.service.ClassicCategoryBaseService; import com.project.information.domain.entity.ProductLineEntity; -import com.project.information.domain.service.GetStatisticsKnowledgePointDomainService; import com.project.information.domain.service.ProductLineBaseService; -import com.project.task.config.ExamScoreRatioConfig; import com.project.task.domain.dto.TaskDTO; import com.project.task.domain.entity.TaskEntity; import com.project.task.domain.entity.TaskUserEntity; +import com.project.task.domain.enums.ExamModeEnum; import com.project.task.domain.service.InitTaskDomainService; +import com.project.task.domain.service.strategy.TaskConfigStrategyFactory; import com.project.task.domain.service.SaveOrUpdateTaskDomainService; import com.project.task.domain.service.TaskBaseService; import com.project.task.domain.service.TaskUserBaseService; @@ -39,9 +35,6 @@ import java.util.stream.Collectors; @Service public class SaveOrUpdateTaskDomainServiceImpl implements SaveOrUpdateTaskDomainService { - @Autowired - private ExamScoreRatioConfig examScoreRatioConfig; - @Autowired private ProductLineBaseService productLineBaseService; @@ -61,45 +54,41 @@ public class SaveOrUpdateTaskDomainServiceImpl implements SaveOrUpdateTaskDomain private InitTaskDomainService initTaskDomainService; @Autowired - private GetStatisticsKnowledgePointDomainService getStatisticsKnowledgePointDomainService; + private TaskConfigStrategyFactory taskConfigStrategyFactory; + + @Autowired + private ClassicCategoryBaseService classicCategoryBaseService; @Override @Transactional(rollbackFor = Exception.class) public Result saveOrUpdate(TaskDTO dto) throws Exception { - checkDTO(dto); - // 计算各题型赋分 - // 1. 计算总权重份额 - int totalUnit = dto.getSingleChoiceNum() * examScoreRatioConfig.getSingle() - + dto.getMultipleChoiceNum() * examScoreRatioConfig.getMultiple() - + dto.getTrueFalseNum() * examScoreRatioConfig.getTrueFalse(); - double base = examScoreRatioConfig.getTotalScore() * 1.0 / totalUnit; - // 2. 基础赋值:按比例初步分配并保留2位小数 - dto.setSingleChoiceScore(dto.getSingleChoiceNum() > 0 ? Math.round(base * examScoreRatioConfig.getSingle() * 100) / 100.0 : 0.0); - dto.setMultipleChoiceScore(dto.getMultipleChoiceNum() > 0 ? Math.round(base * examScoreRatioConfig.getMultiple() * 100) / 100.0 : 0.0); - dto.setTrueFalseScore(dto.getTrueFalseNum() > 0 ? Math.round(base * examScoreRatioConfig.getTrueFalse() * 100) / 100.0 : 0.0); - - // 3. 余数平差:从配置中读取总分进行逆向补齐,确保总分 100% 对应配置 - double configTotal = examScoreRatioConfig.getTotalScore(); - - if (dto.getTrueFalseNum() > 0) { - dto.setTrueFalseScore(Math.round((configTotal - dto.getSingleChoiceNum() * dto.getSingleChoiceScore() - dto.getMultipleChoiceNum() * dto.getMultipleChoiceScore()) / dto.getTrueFalseNum() * 100) / 100.0); - } else if (dto.getMultipleChoiceNum() > 0) { - dto.setMultipleChoiceScore(Math.round((configTotal - dto.getSingleChoiceNum() * dto.getSingleChoiceScore()) / dto.getMultipleChoiceNum() * 100) / 100.0); - } else if (dto.getSingleChoiceNum() > 0) { - dto.setSingleChoiceScore(Math.round(configTotal / dto.getSingleChoiceNum() * 100) / 100.0); + // 按考试模式走不同策略:生成式(校验+赋分) / 经典套题(校验+默认值) + taskConfigStrategyFactory.getStrategy(dto.getExamMode()).validateAndConfigure(dto); + // 补全冗余字段(经典模式不关联产品线,subLineId 为 null 时跳过) + if (dto.getSubLineId() != null) { + ProductLineEntity subProductLine = productLineBaseService.getById(dto.getSubLineId()); + dto.setSubLineName(subProductLine.getName()); + dto.setLineId(subProductLine.getParentId()); + dto.setLineName(subProductLine.getParentName()); + dto.setBusinessId(subProductLine.getBusinessId()); + } + // 经典模式:补全分类冗余字段 + if (ExamModeEnum.CLASSIC.getValue().equals(dto.getExamMode()) && dto.getClassicCategoryId() != null) { + ClassicCategoryEntity category = classicCategoryBaseService.getById(dto.getClassicCategoryId()); + if (category != null) { + dto.setClassicCategoryName(category.getName()); + // 如果是二级分类,补全父级信息 + if (category.getParentId() != null && category.getParentId() != 0) { + dto.setClassicCategoryParentId(category.getParentId()); + ClassicCategoryEntity parentCategory = classicCategoryBaseService.getById(category.getParentId()); + if (parentCategory != null) { + dto.setClassicCategoryParentName(parentCategory.getName()); + } + } + } } - - - - dto.setTotalScore((int) configTotal); - // 补全冗余字段 - ProductLineEntity subProductLine = productLineBaseService.getById(dto.getSubLineId()); - dto.setSubLineName(subProductLine.getName()); - dto.setLineId(subProductLine.getParentId()); - dto.setLineName(subProductLine.getParentName()); - dto.setBusinessId(subProductLine.getBusinessId()); // 保存 TaskEntity saveEntity = dto.toEntity(TaskEntity::new); saveEntity.setParticipantNum(dto.getParticipantUserIdList().size()); @@ -137,8 +126,8 @@ public class SaveOrUpdateTaskDomainServiceImpl implements SaveOrUpdateTaskDomain } taskUserBaseService.saveBatch(taskUserEntityList); - // todo 知识点分簇,预出题 - if (Objects.isNull(dto.getId())) { + // 仅生成式模式触发异步初始化(知识点分簇、预出题) + if (Objects.isNull(dto.getId()) && ExamModeEnum.GENERATIVE.getValue().equals(dto.getExamMode())) { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { @@ -163,93 +152,4 @@ public class SaveOrUpdateTaskDomainServiceImpl implements SaveOrUpdateTaskDomain } } - private void checkDTO(TaskDTO dto) throws Exception { - if (StrUtil.isBlank(dto.getName())) { - throw new BusinessErrorException("考试任务名称不能为空"); - } - //校验考试名称是否存在 - if (dto.getId() == null){ - long count = taskBaseService.count(new LambdaQueryWrapper().eq(TaskEntity::getName, dto.getName())); - if (count > 0){ - throw new BusinessErrorException("考试任务名称已存在"); - } - } - - if (StrUtil.length(dto.getName()) > 10) { - throw new BusinessErrorException("考试任务名称过长"); - } - if (Objects.isNull(dto.getStartTime())) { - throw new BusinessErrorException("开始时间不能为空"); - } - if (Objects.isNull(dto.getEndTime())) { - throw new BusinessErrorException("截止时间不能为空"); - } - if (DateUtil.between(dto.getStartTime() , dto.getEndTime() , DateUnit.DAY) < 0) { - throw new BusinessErrorException("截止时间不能早于开始时间"); - } - if (Objects.isNull(dto.getSubLineId())) { - throw new BusinessErrorException("关联产品线不能为空"); - } - if (Objects.isNull(dto.getDuration())) { - throw new BusinessErrorException("考试时长不能为空"); - } - if (Objects.isNull(dto.getRemindTimePoint())) { - throw new BusinessErrorException("剩余时间提示点不能为空"); - } - if (dto.getRemindTimePoint() > dto.getDuration()) { - throw new BusinessErrorException("剩余时间提示点不能大于考试时长"); - } - if (Objects.isNull(dto.getPassScore())) { - throw new BusinessErrorException("通过考试分数线不能为空"); - } - if (!Validator.isBetween(dto.getPassScore(), 1, 100)) { - throw new BusinessErrorException("通过考试分数线设置错误"); - } - if (StrUtil.isBlank(dto.getNote())) { - throw new BusinessErrorException("注意事项不能为空"); - } - if (Objects.isNull(dto.getSingleChoiceNum())) { - throw new BusinessErrorException("单选题数量不能为空"); - } - if (Objects.isNull(dto.getMultipleChoiceNum())) { - throw new BusinessErrorException("多选题数量不能为空"); - } - if (Objects.isNull(dto.getTrueFalseNum())) { - throw new BusinessErrorException("判断题数量不能为空"); - } - if (CollUtil.isEmpty(dto.getParticipantUserIdList())) { - throw new BusinessErrorException("参与用户不能为空"); - } - if (CollUtil.isEmpty(dto.getRelatedDocumentList())) { - throw new BusinessErrorException("关联文档列表不能为空"); - } - if (Objects.isNull(dto.getAccurateGraspNum())) { - throw new BusinessErrorException("精准掌握知识点数量不能为空"); - } - if (Objects.isNull(dto.getVagueGraspNum())) { - throw new BusinessErrorException("模糊掌握知识点数量不能为空"); - } - //校验知识点数是否有变化 - Result statistics = getStatisticsKnowledgePointDomainService.getStatistics(dto.getSubLineId()); - if (!Objects.equals(statistics.getData().getAccurateGraspNum() , dto.getAccurateGraspNum()) || - !Objects.equals(statistics.getData().getVagueGraspNum() , dto.getVagueGraspNum())) { - throw new BusinessErrorException("知识点数有变化,请重新选择关联产品线"); - } - - // 题目总数 - int totalQuestions = dto.getSingleChoiceNum() + dto.getMultipleChoiceNum() + dto.getTrueFalseNum(); - // 获取知识点总数用于校验 - int totalKnowledgePoints = dto.getAccurateGraspNum() + dto.getVagueGraspNum(); - if (totalQuestions > totalKnowledgePoints) { - throw new BusinessErrorException("题目总数" + totalQuestions + ",不能超过知识点总数" + totalKnowledgePoints); - } - if (totalQuestions > 100) { - throw new BusinessErrorException("题目总数不能超过 100 道,当前为:" + totalQuestions); - } - if (totalQuestions < 1) { - throw new BusinessErrorException("题目总数不能少于 1 道"); - } - } - - } diff --git a/src/main/java/com/project/task/domain/service/strategy/ClassicTaskConfigStrategy.java b/src/main/java/com/project/task/domain/service/strategy/ClassicTaskConfigStrategy.java new file mode 100644 index 0000000..235f0e0 --- /dev/null +++ b/src/main/java/com/project/task/domain/service/strategy/ClassicTaskConfigStrategy.java @@ -0,0 +1,103 @@ +package com.project.task.domain.service.strategy; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.DateUnit; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.lang.Validator; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.project.base.domain.exception.BusinessErrorException; +import com.project.task.domain.dto.TaskDTO; +import com.project.task.domain.entity.TaskEntity; +import com.project.task.domain.service.TaskBaseService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +/** + * 经典套题模式:校验 + 字段默认值 + * 赋分来自套卷本身,此处不做赋分计算 + */ +@Component +public class ClassicTaskConfigStrategy implements TaskConfigStrategy { + + @Autowired + private TaskBaseService taskBaseService; + + @Override + public void validateAndConfigure(TaskDTO dto) throws Exception { + validate(dto); + fillDefaults(dto); + } + + /** + * 经典模式校验:通用参数 + classicPaperId 必填 + */ + private void validate(TaskDTO dto) throws Exception { + if (StrUtil.isBlank(dto.getName())) { + throw new BusinessErrorException("考试任务名称不能为空"); + } + if (dto.getId() == null) { + long count = taskBaseService.count(new LambdaQueryWrapper().eq(TaskEntity::getName, dto.getName())); + if (count > 0) { + throw new BusinessErrorException("考试任务名称已存在"); + } + } + if (StrUtil.length(dto.getName()) > 10) { + throw new BusinessErrorException("考试任务名称过长"); + } + if (Objects.isNull(dto.getStartTime())) { + throw new BusinessErrorException("开始时间不能为空"); + } + if (Objects.isNull(dto.getEndTime())) { + throw new BusinessErrorException("截止时间不能为空"); + } + if (DateUtil.between(dto.getStartTime(), dto.getEndTime(), DateUnit.DAY) < 0) { + throw new BusinessErrorException("截止时间不能早于开始时间"); + } + // 经典模式不关联产品线,subLineId 非必填 + if (Objects.isNull(dto.getDuration())) { + throw new BusinessErrorException("考试时长不能为空"); + } + if (Objects.isNull(dto.getRemindTimePoint())) { + throw new BusinessErrorException("剩余时间提示点不能为空"); + } + if (dto.getRemindTimePoint() > dto.getDuration()) { + throw new BusinessErrorException("剩余时间提示点不能大于考试时长"); + } + if (Objects.isNull(dto.getPassScore())) { + throw new BusinessErrorException("通过考试分数线不能为空"); + } + if (!Validator.isBetween(dto.getPassScore(), 1, 100)) { + throw new BusinessErrorException("通过考试分数线设置错误"); + } + if (StrUtil.isBlank(dto.getNote())) { + throw new BusinessErrorException("注意事项不能为空"); + } + if (CollUtil.isEmpty(dto.getParticipantUserIdList())) { + throw new BusinessErrorException("参与用户不能为空"); + } + // 经典模式特有校验 + if (Objects.isNull(dto.getClassicPaperId())) { + throw new BusinessErrorException("经典套卷不能为空"); + } + } + + /** + * 经典模式下题目数量和分数来自套卷,DTO 中可能为 null,赋默认值 0 避免 NPE + */ + private void fillDefaults(TaskDTO dto) { + if (dto.getSingleChoiceNum() == null) dto.setSingleChoiceNum(0); + if (dto.getMultipleChoiceNum() == null) dto.setMultipleChoiceNum(0); + if (dto.getTrueFalseNum() == null) dto.setTrueFalseNum(0); + if (dto.getShortAnswerNum() == null) dto.setShortAnswerNum(0); + if (dto.getSingleChoiceScore() == null) dto.setSingleChoiceScore(0.0); + if (dto.getMultipleChoiceScore() == null) dto.setMultipleChoiceScore(0.0); + if (dto.getTrueFalseScore() == null) dto.setTrueFalseScore(0.0); + if (dto.getShortAnswerScore() == null) dto.setShortAnswerScore(0.0); + if (dto.getAccurateGraspNum() == null) dto.setAccurateGraspNum(0); + if (dto.getVagueGraspNum() == null) dto.setVagueGraspNum(0); + if (dto.getTotalScore() == null) dto.setTotalScore(0); + } +} diff --git a/src/main/java/com/project/task/domain/service/strategy/GenerativeTaskConfigStrategy.java b/src/main/java/com/project/task/domain/service/strategy/GenerativeTaskConfigStrategy.java new file mode 100644 index 0000000..f1b7e44 --- /dev/null +++ b/src/main/java/com/project/task/domain/service/strategy/GenerativeTaskConfigStrategy.java @@ -0,0 +1,168 @@ +package com.project.task.domain.service.strategy; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.DateUnit; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.lang.Validator; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.project.base.domain.exception.BusinessErrorException; +import com.project.base.domain.result.Result; +import com.project.information.domain.dto.KnowledgePointStatisticsDTO; +import com.project.information.domain.service.GetStatisticsKnowledgePointDomainService; +import com.project.task.config.ExamScoreRatioConfig; +import com.project.task.domain.dto.TaskDTO; +import com.project.task.domain.entity.TaskEntity; +import com.project.task.domain.service.TaskBaseService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +/** + * 生成式套题模式:校验 + 赋分 + * 校验逻辑从 SaveOrUpdateTaskDomainServiceImpl.checkDTO() 原样搬出,新增简答题赋分 + */ +@Component +public class GenerativeTaskConfigStrategy implements TaskConfigStrategy { + + @Autowired + private ExamScoreRatioConfig examScoreRatioConfig; + + @Autowired + private TaskBaseService taskBaseService; + + @Autowired + private GetStatisticsKnowledgePointDomainService getStatisticsKnowledgePointDomainService; + + @Override + public void validateAndConfigure(TaskDTO dto) throws Exception { + validate(dto); + configureScore(dto); + } + + /** + * 校验任务参数(原 SaveOrUpdateTaskDomainServiceImpl.checkDTO 逻辑) + */ + private void validate(TaskDTO dto) throws Exception { + if (StrUtil.isBlank(dto.getName())) { + throw new BusinessErrorException("考试任务名称不能为空"); + } + if (dto.getId() == null) { + long count = taskBaseService.count(new LambdaQueryWrapper().eq(TaskEntity::getName, dto.getName())); + if (count > 0) { + throw new BusinessErrorException("考试任务名称已存在"); + } + } + if (StrUtil.length(dto.getName()) > 10) { + throw new BusinessErrorException("考试任务名称过长"); + } + if (Objects.isNull(dto.getStartTime())) { + throw new BusinessErrorException("开始时间不能为空"); + } + if (Objects.isNull(dto.getEndTime())) { + throw new BusinessErrorException("截止时间不能为空"); + } + if (DateUtil.between(dto.getStartTime(), dto.getEndTime(), DateUnit.DAY) < 0) { + throw new BusinessErrorException("截止时间不能早于开始时间"); + } + if (Objects.isNull(dto.getSubLineId())) { + throw new BusinessErrorException("关联产品线不能为空"); + } + if (Objects.isNull(dto.getDuration())) { + throw new BusinessErrorException("考试时长不能为空"); + } + if (Objects.isNull(dto.getRemindTimePoint())) { + throw new BusinessErrorException("剩余时间提示点不能为空"); + } + if (dto.getRemindTimePoint() > dto.getDuration()) { + throw new BusinessErrorException("剩余时间提示点不能大于考试时长"); + } + if (Objects.isNull(dto.getPassScore())) { + throw new BusinessErrorException("通过考试分数线不能为空"); + } + if (!Validator.isBetween(dto.getPassScore(), 1, 100)) { + throw new BusinessErrorException("通过考试分数线设置错误"); + } + if (StrUtil.isBlank(dto.getNote())) { + throw new BusinessErrorException("注意事项不能为空"); + } + if (Objects.isNull(dto.getSingleChoiceNum())) { + throw new BusinessErrorException("单选题数量不能为空"); + } + if (Objects.isNull(dto.getMultipleChoiceNum())) { + throw new BusinessErrorException("多选题数量不能为空"); + } + if (Objects.isNull(dto.getTrueFalseNum())) { + throw new BusinessErrorException("判断题数量不能为空"); + } + if (CollUtil.isEmpty(dto.getParticipantUserIdList())) { + throw new BusinessErrorException("参与用户不能为空"); + } + if (CollUtil.isEmpty(dto.getRelatedDocumentList())) { + throw new BusinessErrorException("关联文档列表不能为空"); + } + if (Objects.isNull(dto.getAccurateGraspNum())) { + throw new BusinessErrorException("精准掌握知识点数量不能为空"); + } + if (Objects.isNull(dto.getVagueGraspNum())) { + throw new BusinessErrorException("模糊掌握知识点数量不能为空"); + } + Result statistics = getStatisticsKnowledgePointDomainService.getStatistics(dto.getSubLineId()); + if (!Objects.equals(statistics.getData().getAccurateGraspNum(), dto.getAccurateGraspNum()) || + !Objects.equals(statistics.getData().getVagueGraspNum(), dto.getVagueGraspNum())) { + throw new BusinessErrorException("知识点数有变化,请重新选择关联产品线"); + } + + int totalQuestions = dto.getSingleChoiceNum() + dto.getMultipleChoiceNum() + dto.getTrueFalseNum() + + (dto.getShortAnswerNum() != null ? dto.getShortAnswerNum() : 0); + int totalKnowledgePoints = dto.getAccurateGraspNum() + dto.getVagueGraspNum(); + if (totalQuestions > totalKnowledgePoints) { + throw new BusinessErrorException("题目总数" + totalQuestions + ",不能超过知识点总数" + totalKnowledgePoints); + } + if (totalQuestions > 100) { + throw new BusinessErrorException("题目总数不能超过 100 道,当前为:" + totalQuestions); + } + if (totalQuestions < 1) { + throw new BusinessErrorException("题目总数不能少于 1 道"); + } + } + + /** + * 各题型赋分计算(原逻辑 + 简答题权重) + */ + private void configureScore(TaskDTO dto) { + int shortAnswerNum = dto.getShortAnswerNum() != null ? dto.getShortAnswerNum() : 0; + + // 1. 计算总权重份额 + int totalUnit = dto.getSingleChoiceNum() * examScoreRatioConfig.getSingle() + + dto.getMultipleChoiceNum() * examScoreRatioConfig.getMultiple() + + dto.getTrueFalseNum() * examScoreRatioConfig.getTrueFalse() + + shortAnswerNum * examScoreRatioConfig.getShortAnswer(); + double base = examScoreRatioConfig.getTotalScore() * 1.0 / totalUnit; + + // 2. 基础赋值:按比例初步分配并保留2位小数 + dto.setSingleChoiceScore(dto.getSingleChoiceNum() > 0 ? Math.round(base * examScoreRatioConfig.getSingle() * 100) / 100.0 : 0.0); + dto.setMultipleChoiceScore(dto.getMultipleChoiceNum() > 0 ? Math.round(base * examScoreRatioConfig.getMultiple() * 100) / 100.0 : 0.0); + dto.setTrueFalseScore(dto.getTrueFalseNum() > 0 ? Math.round(base * examScoreRatioConfig.getTrueFalse() * 100) / 100.0 : 0.0); + dto.setShortAnswerScore(shortAnswerNum > 0 ? Math.round(base * examScoreRatioConfig.getShortAnswer() * 100) / 100.0 : 0.0); + + // 3. 余数平差:从配置中读取总分进行逆向补齐,确保总分 100% 对应配置 + double configTotal = examScoreRatioConfig.getTotalScore(); + + if (dto.getTrueFalseNum() > 0) { + dto.setTrueFalseScore(Math.round((configTotal - dto.getSingleChoiceNum() * dto.getSingleChoiceScore() + - dto.getMultipleChoiceNum() * dto.getMultipleChoiceScore() + - shortAnswerNum * dto.getShortAnswerScore()) / dto.getTrueFalseNum() * 100) / 100.0); + } else if (shortAnswerNum > 0) { + dto.setShortAnswerScore(Math.round((configTotal - dto.getSingleChoiceNum() * dto.getSingleChoiceScore() + - dto.getMultipleChoiceNum() * dto.getMultipleChoiceScore()) / shortAnswerNum * 100) / 100.0); + } else if (dto.getMultipleChoiceNum() > 0) { + dto.setMultipleChoiceScore(Math.round((configTotal - dto.getSingleChoiceNum() * dto.getSingleChoiceScore()) / dto.getMultipleChoiceNum() * 100) / 100.0); + } else if (dto.getSingleChoiceNum() > 0) { + dto.setSingleChoiceScore(Math.round(configTotal / dto.getSingleChoiceNum() * 100) / 100.0); + } + + dto.setTotalScore((int) configTotal); + } +} diff --git a/src/main/java/com/project/task/domain/service/strategy/TaskConfigStrategy.java b/src/main/java/com/project/task/domain/service/strategy/TaskConfigStrategy.java new file mode 100644 index 0000000..7a96764 --- /dev/null +++ b/src/main/java/com/project/task/domain/service/strategy/TaskConfigStrategy.java @@ -0,0 +1,17 @@ +package com.project.task.domain.service.strategy; + +import com.project.task.domain.dto.TaskDTO; + +/** + * 任务配置策略接口 + * 不同考试模式(生成式/经典套题)的校验与赋分逻辑分离 + */ +public interface TaskConfigStrategy { + + /** + * 校验任务参数并完成赋分配置(校验不通过直接抛异常) + * + * @param dto 任务DTO + */ + void validateAndConfigure(TaskDTO dto) throws Exception; +} diff --git a/src/main/java/com/project/task/domain/service/strategy/TaskConfigStrategyFactory.java b/src/main/java/com/project/task/domain/service/strategy/TaskConfigStrategyFactory.java new file mode 100644 index 0000000..0f77bc1 --- /dev/null +++ b/src/main/java/com/project/task/domain/service/strategy/TaskConfigStrategyFactory.java @@ -0,0 +1,25 @@ +package com.project.task.domain.service.strategy; + +import com.project.task.domain.enums.ExamModeEnum; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 任务配置策略工厂,按考试模式返回对应策略 + */ +@Component +public class TaskConfigStrategyFactory { + + @Autowired + private GenerativeTaskConfigStrategy generativeTaskConfigStrategy; + + @Autowired + private ClassicTaskConfigStrategy classicTaskConfigStrategy; + + public TaskConfigStrategy getStrategy(Integer examMode) { + if (ExamModeEnum.CLASSIC.getValue().equals(examMode)) { + return classicTaskConfigStrategy; + } + return generativeTaskConfigStrategy; + } +}