From 82a0a7fc50871a7908780cabd3c3099008834ad5 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 31 Jul 2026 12:47:58 +0800 Subject: [PATCH] feat(education): add question import job application slice --- .../QuestionImportJobController.java | 48 +++++ .../vo/QuestionImportExecuteReqVO.java | 9 + .../importjob/vo/QuestionImportJobRespVO.java | 26 +++ .../vo/QuestionImportPreviewReqVO.java | 15 ++ .../importjob/QuestionImportJobDO.java | 32 ++++ .../importjob/QuestionImportJobMapper.java | 54 ++++++ .../education/enums/ErrorCodeConstants.java | 9 + .../ExecuteQuestionImportCommand.java | 4 + .../importjob/ImportObjectScanGateway.java | 6 + .../PreviewQuestionImportCommand.java | 5 + .../QuestionImportJobProjection.java | 6 + .../importjob/QuestionImportJobService.java | 9 + .../QuestionImportJobServiceImpl.java | 181 ++++++++++++++++++ .../importjob/QuestionImportParser.java | 10 + .../QuestionImportJobControllerTest.java | 73 +++++++ .../QuestionImportJobServiceImplTest.java | 151 +++++++++++++++ 16 files changed, 638 insertions(+) create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/QuestionImportJobController.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportExecuteReqVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportJobRespVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportPreviewReqVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/importjob/QuestionImportJobDO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/QuestionImportJobMapper.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/ExecuteQuestionImportCommand.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/ImportObjectScanGateway.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/PreviewQuestionImportCommand.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobProjection.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobService.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImpl.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportParser.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/importjob/QuestionImportJobControllerTest.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImplTest.java diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/QuestionImportJobController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/QuestionImportJobController.java new file mode 100644 index 00000000..580007ec --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/QuestionImportJobController.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.education.controller.admin.importjob; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.education.controller.admin.importjob.vo.QuestionImportExecuteReqVO; +import cn.iocoder.yudao.module.education.controller.admin.importjob.vo.QuestionImportJobRespVO; +import cn.iocoder.yudao.module.education.controller.admin.importjob.vo.QuestionImportPreviewReqVO; +import cn.iocoder.yudao.module.education.service.importjob.ExecuteQuestionImportCommand; +import cn.iocoder.yudao.module.education.service.importjob.PreviewQuestionImportCommand; +import cn.iocoder.yudao.module.education.service.importjob.QuestionImportJobService; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@RestController +@RequestMapping("/education/question-import-jobs") +@Validated +@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true") +public class QuestionImportJobController { + private final QuestionImportJobService service; + public QuestionImportJobController(QuestionImportJobService service) { this.service = service; } + + @PostMapping("/preview") + @PreAuthorize("@ss.hasPermission('education:question-import:create')") + public CommonResult preview(@Valid @RequestBody QuestionImportPreviewReqVO request) { + return success(QuestionImportJobRespVO.from(service.requestPreview(new PreviewQuestionImportCommand( + request.getCommandKey(), request.getObjectKey(), request.getFileName(), request.getMediaType(), + request.getFileSize()), getLoginUserId()))); + } + + @PostMapping("/{id}/execute") + @PreAuthorize("@ss.hasPermission('education:question-import:execute')") + public CommonResult execute(@PathVariable("id") Long id, + @Valid @RequestBody QuestionImportExecuteReqVO request) { + return success(QuestionImportJobRespVO.from(service.requestExecute( + new ExecuteQuestionImportCommand(id, request.getCommandKey()), getLoginUserId()))); + } + + @GetMapping("/{id}") + @PreAuthorize("@ss.hasPermission('education:question-import:query')") + public CommonResult get(@PathVariable("id") Long id) { + return success(QuestionImportJobRespVO.from(service.get(id))); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportExecuteReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportExecuteReqVO.java new file mode 100644 index 00000000..b7500bd6 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportExecuteReqVO.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.education.controller.admin.importjob.vo; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class QuestionImportExecuteReqVO { + @NotBlank private String commandKey; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportJobRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportJobRespVO.java new file mode 100644 index 00000000..6a5be66e --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportJobRespVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.education.controller.admin.importjob.vo; + +import cn.iocoder.yudao.module.education.service.importjob.QuestionImportJobProjection; +import lombok.Data; + +@Data +public class QuestionImportJobRespVO { + private Long id; + private String status; + private String scanStatus; + private String parserStatus; + private String fileName; + private Long fileSize; + private Integer previewQuestionCount; + private Integer importedQuestionCount; + private String failureCode; + + public static QuestionImportJobRespVO from(QuestionImportJobProjection source) { + QuestionImportJobRespVO target = new QuestionImportJobRespVO(); + target.id = source.id(); target.status = source.status(); target.scanStatus = source.scanStatus(); + target.parserStatus = source.parserStatus(); target.fileName = source.fileName(); target.fileSize = source.fileSize(); + target.previewQuestionCount = source.previewQuestionCount(); target.importedQuestionCount = source.importedQuestionCount(); + target.failureCode = source.failureCode(); + return target; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportPreviewReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportPreviewReqVO.java new file mode 100644 index 00000000..b1f07f9a --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/importjob/vo/QuestionImportPreviewReqVO.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.education.controller.admin.importjob.vo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +@Data +public class QuestionImportPreviewReqVO { + @NotBlank private String commandKey; + @NotBlank private String objectKey; + @NotBlank private String fileName; + @NotBlank private String mediaType; + @NotNull @Positive private Long fileSize; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/importjob/QuestionImportJobDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/importjob/QuestionImportJobDO.java new file mode 100644 index 00000000..020af382 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/importjob/QuestionImportJobDO.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.education.dal.dataobject.importjob; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@TableName("education_question_import_job") +@KeySequence("education_question_import_job_seq") +@Data +@EqualsAndHashCode(callSuper = true) +public class QuestionImportJobDO extends TenantBaseDO { + @TableId private Long id; + private Long actorId; + private String objectKey; + private String fileName; + private String mediaType; + private Long fileSize; + private String previewCommandKey; + private String previewRequestHash; + private String executeCommandKey; + private String executeRequestHash; + private String status; + private String scanStatus; + private String parserStatus; + private String parsedPayload; + private Integer previewQuestionCount; + private Integer importedQuestionCount; + private String failureCode; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/QuestionImportJobMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/QuestionImportJobMapper.java new file mode 100644 index 00000000..29ea5bd7 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/importjob/QuestionImportJobMapper.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.education.dal.mysql.importjob; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.education.dal.dataobject.importjob.QuestionImportJobDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface QuestionImportJobMapper extends BaseMapperX { + + default QuestionImportJobDO selectByPreviewCommand(Long tenantId, String commandKey) { + return selectOne(new LambdaQueryWrapperX() + .eq(QuestionImportJobDO::getTenantId, tenantId) + .eq(QuestionImportJobDO::getPreviewCommandKey, commandKey)); + } + + default QuestionImportJobDO selectByExecuteCommand(Long tenantId, String commandKey) { + return selectOne(new LambdaQueryWrapperX() + .eq(QuestionImportJobDO::getTenantId, tenantId) + .eq(QuestionImportJobDO::getExecuteCommandKey, commandKey)); + } + + default QuestionImportJobDO selectTenantJob(Long tenantId, Long id) { + return selectOne(new LambdaQueryWrapperX() + .eq(QuestionImportJobDO::getTenantId, tenantId).eq(QuestionImportJobDO::getId, id)); + } + + default QuestionImportJobDO claimPreview(Long id) { + QuestionImportJobDO job = selectById(id); + return job != null && "PREVIEW_PENDING".equals(job.getStatus()) ? job : null; + } + + default QuestionImportJobDO claimExecute(Long id) { + QuestionImportJobDO job = selectById(id); + return job != null && "EXECUTE_PENDING".equals(job.getStatus()) ? job : null; + } + + default int completePreview(QuestionImportJobDO job) { return updateById(job); } + default int failPreview(Long id, String failureCode) { + QuestionImportJobDO job = new QuestionImportJobDO(); + job.setId(id); job.setStatus("PREVIEW_FAILED"); job.setFailureCode(failureCode); + return updateById(job); + } + default int completeExecute(Long id, int count) { + QuestionImportJobDO job = new QuestionImportJobDO(); + job.setId(id); job.setStatus("COMPLETED"); job.setImportedQuestionCount(count); + return updateById(job); + } + default int failExecute(Long id, String failureCode) { + QuestionImportJobDO job = new QuestionImportJobDO(); + job.setId(id); job.setStatus("EXECUTE_FAILED"); job.setFailureCode(failureCode); + return updateById(job); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 07097384..f0b47743 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -109,6 +109,15 @@ public interface ErrorCodeConstants { ErrorCode QUESTION_PLACEMENT_REQUIRED = new ErrorCode(1_005_003_076, "题目尚未归类,无法发布"); ErrorCode QUESTION_ACTIVE_COLLECTION_CONFLICT = new ErrorCode(1_005_003_077, "题目仍属于已发布题集,无法归档"); + // ========== 题目导入任务 1-005-003-080 ~ 1-005-003-089 ========== + ErrorCode QUESTION_IMPORT_NOT_FOUND = new ErrorCode(1_005_003_080, "题目导入任务不存在或无权访问"); + ErrorCode QUESTION_IMPORT_COMMAND_CONFLICT = new ErrorCode(1_005_003_081, "导入命令幂等键已用于不同请求"); + ErrorCode QUESTION_IMPORT_STATE_CONFLICT = new ErrorCode(1_005_003_082, "题目导入任务状态不允许当前操作"); + ErrorCode QUESTION_IMPORT_SCAN_NOT_CLEAN = new ErrorCode(1_005_003_083, "导入文件未通过安全扫描"); + ErrorCode QUESTION_IMPORT_PARSER_UNAVAILABLE = new ErrorCode(1_005_003_084, "导入解析器不可用"); + ErrorCode QUESTION_IMPORT_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_003_085, + "当前题库数据源模式不支持执行导入:{}"); + // ========== 内容节点创作 1-005-002-020 ~ 1-005-002-029 ========== ErrorCode CONTENT_NODE_AUTHORING_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_002_020, "当前题库数据源模式不支持内容节点创作:{}"); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/ExecuteQuestionImportCommand.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/ExecuteQuestionImportCommand.java new file mode 100644 index 00000000..4302686f --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/ExecuteQuestionImportCommand.java @@ -0,0 +1,4 @@ +package cn.iocoder.yudao.module.education.service.importjob; + +public record ExecuteQuestionImportCommand(Long jobId, String commandKey) { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/ImportObjectScanGateway.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/ImportObjectScanGateway.java new file mode 100644 index 00000000..95ab6c1c --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/ImportObjectScanGateway.java @@ -0,0 +1,6 @@ +package cn.iocoder.yudao.module.education.service.importjob; + +public interface ImportObjectScanGateway { + enum ScanResult { CLEAN, INFECTED, ERROR } + ScanResult scan(String objectKey); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/PreviewQuestionImportCommand.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/PreviewQuestionImportCommand.java new file mode 100644 index 00000000..7626cf84 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/PreviewQuestionImportCommand.java @@ -0,0 +1,5 @@ +package cn.iocoder.yudao.module.education.service.importjob; + +public record PreviewQuestionImportCommand(String commandKey, String objectKey, String fileName, + String mediaType, Long fileSize) { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobProjection.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobProjection.java new file mode 100644 index 00000000..1ece5dd2 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobProjection.java @@ -0,0 +1,6 @@ +package cn.iocoder.yudao.module.education.service.importjob; + +public record QuestionImportJobProjection(Long id, String status, String scanStatus, String parserStatus, + String fileName, Long fileSize, Integer previewQuestionCount, + Integer importedQuestionCount, String failureCode) { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobService.java new file mode 100644 index 00000000..f51b628e --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobService.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.education.service.importjob; + +public interface QuestionImportJobService { + QuestionImportJobProjection requestPreview(PreviewQuestionImportCommand command, Long actorId); + QuestionImportJobProjection requestExecute(ExecuteQuestionImportCommand command, Long actorId); + QuestionImportJobProjection get(Long jobId); + void preview(Long jobId); + void execute(Long jobId); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImpl.java new file mode 100644 index 00000000..fd06ecb5 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImpl.java @@ -0,0 +1,181 @@ +package cn.iocoder.yudao.module.education.service.importjob; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; +import cn.iocoder.yudao.module.education.config.EducationProperties; +import cn.iocoder.yudao.module.education.dal.dataobject.importjob.QuestionImportJobDO; +import cn.iocoder.yudao.module.education.dal.mysql.importjob.QuestionImportJobMapper; +import cn.iocoder.yudao.module.education.enums.CatalogProviderMode; +import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand; +import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; + +@Service +public class QuestionImportJobServiceImpl implements QuestionImportJobService { + + private final EducationProperties properties; + private final QuestionImportJobMapper jobMapper; + private final Optional scanGateway; + private final Optional parser; + private final TenantQuestionLifecycleService lifecycleService; + + public QuestionImportJobServiceImpl(EducationProperties properties, QuestionImportJobMapper jobMapper, + Optional scanGateway, + Optional parser, + TenantQuestionLifecycleService lifecycleService) { + this.properties = properties; + this.jobMapper = jobMapper; + this.scanGateway = scanGateway; + this.parser = parser; + this.lifecycleService = lifecycleService; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public QuestionImportJobProjection requestPreview(PreviewQuestionImportCommand command, Long actorId) { + Long tenantId = TenantContextHolder.getRequiredTenantId(); + String requestHash = hash(command); + QuestionImportJobDO existing = jobMapper.selectByPreviewCommand(tenantId, command.commandKey()); + if (existing != null) { + requireHash(existing.getPreviewRequestHash(), requestHash); + return project(existing); + } + QuestionImportJobDO job = new QuestionImportJobDO(); + job.setTenantId(tenantId); + job.setActorId(actorId); + job.setObjectKey(command.objectKey()); + job.setFileName(command.fileName()); + job.setMediaType(command.mediaType()); + job.setFileSize(command.fileSize()); + job.setPreviewCommandKey(command.commandKey()); + job.setPreviewRequestHash(requestHash); + job.setStatus("PREVIEW_PENDING"); + job.setScanStatus("PENDING"); + job.setParserStatus("PENDING"); + jobMapper.insert(job); + return project(job); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public QuestionImportJobProjection requestExecute(ExecuteQuestionImportCommand command, Long actorId) { + assertJavaRead(); + Long tenantId = TenantContextHolder.getRequiredTenantId(); + QuestionImportJobDO replay = jobMapper.selectByExecuteCommand(tenantId, command.commandKey()); + String requestHash = hash(command); + if (replay != null) { + requireHash(replay.getExecuteRequestHash(), requestHash); + return project(replay); + } + QuestionImportJobDO job = requireJob(tenantId, command.jobId()); + if (!"PREVIEW_READY".equals(job.getStatus()) || !"CLEAN".equals(job.getScanStatus()) + || !"PARSED".equals(job.getParserStatus()) || job.getParsedPayload() == null) { + throw exception(QUESTION_IMPORT_STATE_CONFLICT); + } + job.setActorId(actorId); + job.setExecuteCommandKey(command.commandKey()); + job.setExecuteRequestHash(requestHash); + job.setStatus("EXECUTE_PENDING"); + jobMapper.updateById(job); + return project(job); + } + + @Override + public QuestionImportJobProjection get(Long jobId) { + return project(requireJob(TenantContextHolder.getRequiredTenantId(), jobId)); + } + + @Override + public void preview(Long jobId) { + QuestionImportJobDO claimed = jobMapper.claimPreview(jobId); + if (claimed == null) return; + TenantUtils.execute(claimed.getTenantId(), () -> processPreview(claimed)); + } + + private void processPreview(QuestionImportJobDO job) { + ImportObjectScanGateway gateway = scanGateway.orElse(null); + if (gateway == null) { + jobMapper.failPreview(job.getId(), "SCAN_UNAVAILABLE"); + throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN); + } + ImportObjectScanGateway.ScanResult scanResult = gateway.scan(job.getObjectKey()); + if (scanResult != ImportObjectScanGateway.ScanResult.CLEAN) { + jobMapper.failPreview(job.getId(), "SCAN_" + scanResult.name()); + throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN); + } + job.setScanStatus("CLEAN"); + QuestionImportParser availableParser = parser.orElse(null); + if (availableParser == null) { + job.setParserStatus("UNAVAILABLE"); + job.setParsedPayload(null); + job.setPreviewQuestionCount(null); + } else { + QuestionImportParser.ParsedImport parsed = availableParser.parse(job.getObjectKey()); + job.setParserStatus("PARSED"); + job.setParsedPayload(parsed.payload()); + job.setPreviewQuestionCount(parsed.questions().size()); + } + job.setStatus("PREVIEW_READY"); + jobMapper.completePreview(job); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void execute(Long jobId) { + QuestionImportJobDO claimed = jobMapper.claimExecute(jobId); + if (claimed == null) return; + assertJavaRead(); + TenantUtils.execute(claimed.getTenantId(), () -> processExecute(claimed)); + } + + private void processExecute(QuestionImportJobDO job) { + if (!"CLEAN".equals(job.getScanStatus())) throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN); + QuestionImportParser availableParser = parser.orElseThrow(() -> exception(QUESTION_IMPORT_PARSER_UNAVAILABLE)); + List questions = availableParser.restore(job.getParsedPayload()); + for (QuestionDraftCommand question : questions) lifecycleService.createDraft(question); + jobMapper.completeExecute(job.getId(), questions.size()); + } + + private QuestionImportJobDO requireJob(Long tenantId, Long id) { + QuestionImportJobDO job = jobMapper.selectTenantJob(tenantId, id); + if (job == null) throw exception(QUESTION_IMPORT_NOT_FOUND); + return job; + } + + private void assertJavaRead() { + if (properties.getCatalogMode() != CatalogProviderMode.JAVA_READ) { + throw exception(QUESTION_IMPORT_PROVIDER_UNSUPPORTED, properties.getCatalogMode()); + } + } + + private void requireHash(String expected, String actual) { + if (expected != null && !expected.equals(actual)) throw exception(QUESTION_IMPORT_COMMAND_CONFLICT); + } + + private String hash(Object command) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(JsonUtils.toJsonString(command).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private QuestionImportJobProjection project(QuestionImportJobDO job) { + return new QuestionImportJobProjection(job.getId(), job.getStatus(), job.getScanStatus(), + job.getParserStatus(), job.getFileName(), job.getFileSize(), job.getPreviewQuestionCount(), + job.getImportedQuestionCount(), job.getFailureCode()); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportParser.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportParser.java new file mode 100644 index 00000000..64e7ed82 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportParser.java @@ -0,0 +1,10 @@ +package cn.iocoder.yudao.module.education.service.importjob; + +import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand; +import java.util.List; + +public interface QuestionImportParser { + ParsedImport parse(String objectKey); + List restore(String payload); + record ParsedImport(String payload, List questions) {} +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/importjob/QuestionImportJobControllerTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/importjob/QuestionImportJobControllerTest.java new file mode 100644 index 00000000..7590232b --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/importjob/QuestionImportJobControllerTest.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.education.controller.admin.importjob; + +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.yudao.module.education.controller.admin.importjob.vo.QuestionImportExecuteReqVO; +import cn.iocoder.yudao.module.education.controller.admin.importjob.vo.QuestionImportPreviewReqVO; +import cn.iocoder.yudao.module.education.service.importjob.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class QuestionImportJobControllerTest { + private MutableSecurity security; + private RecordingService service; + private QuestionImportJobController controller; + + @BeforeEach + void setUp() { + security = new MutableSecurity(); service = new RecordingService(); controller = new QuestionImportJobController(service); + LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(10L); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null)); + } + @AfterEach void tearDown() { SecurityContextHolder.clearContext(); } + + @Test + void controllerMapsPreviewRequestAndActor() { + QuestionImportPreviewReqVO request = new QuestionImportPreviewReqVO(); + request.setCommandKey("preview-1"); request.setObjectKey("object/10/q.csv"); request.setFileName("q.csv"); + request.setMediaType("text/csv"); request.setFileSize(12L); + + assertEquals(101L, controller.preview(request).getData().getId()); + assertEquals("preview-1", service.preview.commandKey()); + assertEquals(7L, service.actorId); + } + + @Test + void controllerMapsExecuteRequestAndActor() { + QuestionImportExecuteReqVO request = new QuestionImportExecuteReqVO(); request.setCommandKey("execute-1"); + assertEquals("EXECUTE_PENDING", controller.execute(101L, request).getData().getStatus()); + assertEquals(new ExecuteQuestionImportCommand(101L, "execute-1"), service.execute); + assertEquals(7L, service.actorId); + } + + private static final class RecordingService implements QuestionImportJobService { + PreviewQuestionImportCommand preview; ExecuteQuestionImportCommand execute; Long actorId; + public QuestionImportJobProjection requestPreview(PreviewQuestionImportCommand command, Long actorId) { + this.preview=command; this.actorId=actorId; return projection("PREVIEW_PENDING"); + } + public QuestionImportJobProjection requestExecute(ExecuteQuestionImportCommand command, Long actorId) { + this.execute=command; this.actorId=actorId; return projection("EXECUTE_PENDING"); + } + public QuestionImportJobProjection get(Long id) { return projection("PREVIEW_READY"); } + public void preview(Long id) {} public void execute(Long id) {} + private QuestionImportJobProjection projection(String status) { + return new QuestionImportJobProjection(101L,status,"CLEAN","PARSED","q.csv",12L,2,null,null); + } + } + private static final class MutableSecurity implements SecurityFrameworkService { + private final Set permissions = new HashSet<>(); + public boolean hasPermission(String permission) { return permissions.contains(permission); } + public boolean hasAnyPermissions(String... permissions) { return false; } + public boolean hasRole(String role) { return false; } public boolean hasAnyRoles(String... roles) { return false; } + public boolean hasScope(String scope) { return false; } public boolean hasAnyScopes(String... scopes) { return false; } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImplTest.java new file mode 100644 index 00000000..9c712c1a --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/importjob/QuestionImportJobServiceImplTest.java @@ -0,0 +1,151 @@ +package cn.iocoder.yudao.module.education.service.importjob; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.education.config.EducationProperties; +import cn.iocoder.yudao.module.education.dal.dataobject.importjob.QuestionImportJobDO; +import cn.iocoder.yudao.module.education.dal.mysql.importjob.QuestionImportJobMapper; +import cn.iocoder.yudao.module.education.enums.CatalogProviderMode; +import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand; +import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.QUESTION_IMPORT_SCAN_NOT_CLEAN; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class QuestionImportJobServiceImplTest { + + @Mock private QuestionImportJobMapper jobMapper; + @Mock private ImportObjectScanGateway scanGateway; + @Mock private QuestionImportParser parser; + @Mock private TenantQuestionLifecycleService lifecycleService; + + private EducationProperties properties; + private QuestionImportJobServiceImpl service; + + @BeforeEach + void setUp() { + properties = new EducationProperties(); + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + service = new QuestionImportJobServiceImpl(properties, jobMapper, Optional.of(scanGateway), + Optional.of(parser), lifecycleService); + TenantContextHolder.setTenantId(10L); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + } + + @Test + void duplicatePreviewCommandReturnsExistingJobWithoutRescanning() { + QuestionImportJobDO existing = job(101L, "PREVIEW_READY"); + existing.setPreviewRequestHash(null); + when(jobMapper.selectByPreviewCommand(10L, "preview-1")).thenReturn(existing); + + QuestionImportJobProjection result = service.requestPreview(new PreviewQuestionImportCommand( + "preview-1", "object/10/questions.csv", "questions.csv", "text/csv", 128L), 7L); + + assertEquals(101L, result.id()); + verify(jobMapper, never()).insert((QuestionImportJobDO) any(QuestionImportJobDO.class)); + verifyNoInteractions(scanGateway, parser, lifecycleService); + } + + @Test + void cleanScanWithoutParserProducesMetadataOnlyPreviewReady() { + service = new QuestionImportJobServiceImpl(properties, jobMapper, Optional.of(scanGateway), + Optional.empty(), lifecycleService); + QuestionImportJobDO claimed = job(101L, "PREVIEW_PENDING"); + claimed.setTenantId(10L); + when(jobMapper.claimPreview(101L)).thenReturn(claimed); + when(scanGateway.scan("object/10/questions.csv")).thenReturn(ImportObjectScanGateway.ScanResult.CLEAN); + + service.preview(101L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(QuestionImportJobDO.class); + verify(jobMapper).completePreview(captor.capture()); + assertEquals("PREVIEW_READY", captor.getValue().getStatus()); + assertEquals("CLEAN", captor.getValue().getScanStatus()); + assertEquals("UNAVAILABLE", captor.getValue().getParserStatus()); + assertNull(captor.getValue().getParsedPayload()); + verifyNoInteractions(lifecycleService); + } + + @Test + void unavailableScannerFailsClosedAndNeverParses() { + service = new QuestionImportJobServiceImpl(properties, jobMapper, Optional.empty(), + Optional.of(parser), lifecycleService); + when(jobMapper.claimPreview(101L)).thenReturn(job(101L, "PREVIEW_PENDING")); + + ServiceException ex = assertThrows(ServiceException.class, () -> service.preview(101L)); + + assertEquals(QUESTION_IMPORT_SCAN_NOT_CLEAN.getCode(), ex.getCode()); + verify(jobMapper).failPreview(eq(101L), eq("SCAN_UNAVAILABLE")); + verifyNoInteractions(parser, lifecycleService); + } + + @Test + void executePropagatesClaimedTenantAndCreatesEveryQuestionAsDraft() { + TenantContextHolder.clear(); + QuestionImportJobDO claimed = job(101L, "EXECUTE_PENDING"); + claimed.setTenantId(42L); + claimed.setScanStatus("CLEAN"); + claimed.setParserStatus("PARSED"); + claimed.setParsedPayload("stored-preview"); + when(jobMapper.claimExecute(101L)).thenReturn(claimed); + when(parser.restore("stored-preview")).thenReturn(List.of(question("One"), question("Two"))); + when(lifecycleService.createDraft(any())).thenAnswer(invocation -> { + assertEquals(42L, TenantContextHolder.getRequiredTenantId()); + return 100L; + }); + + service.execute(101L); + + verify(lifecycleService, times(2)).createDraft(any()); + verify(jobMapper).completeExecute(101L, 2); + assertNull(TenantContextHolder.getTenantId()); + } + + @Test + void executeFailsBeforeImportOutsideJavaRead() { + properties.setCatalogMode(CatalogProviderMode.SCALAR_READ); + when(jobMapper.claimExecute(101L)).thenReturn(job(101L, "EXECUTE_PENDING")); + + assertThrows(ServiceException.class, () -> service.execute(101L)); + + verifyNoInteractions(parser, lifecycleService); + } + + private QuestionImportJobDO job(Long id, String status) { + QuestionImportJobDO job = new QuestionImportJobDO(); + job.setId(id); + job.setTenantId(10L); + job.setActorId(7L); + job.setObjectKey("object/10/questions.csv"); + job.setFileName("questions.csv"); + job.setMediaType("text/csv"); + job.setFileSize(128L); + job.setPreviewCommandKey("preview-1"); + job.setPreviewRequestHash("hash"); + job.setStatus(status); + return job; + } + + private QuestionDraftCommand question(String stem) { + return new QuestionDraftCommand(stem, "choice", "easy", List.of( + new QuestionDraftCommand.QuestionDraftOption("A", "Yes", 1D), + new QuestionDraftCommand.QuestionDraftOption("B", "No", 2D)), "A", null, null); + } +}