feat(education): add question import job application slice
This commit is contained in:
@@ -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<QuestionImportJobRespVO> 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<QuestionImportJobRespVO> 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<QuestionImportJobRespVO> get(@PathVariable("id") Long id) {
|
||||
return success(QuestionImportJobRespVO.from(service.get(id)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<QuestionImportJobDO> {
|
||||
|
||||
default QuestionImportJobDO selectByPreviewCommand(Long tenantId, String commandKey) {
|
||||
return selectOne(new LambdaQueryWrapperX<QuestionImportJobDO>()
|
||||
.eq(QuestionImportJobDO::getTenantId, tenantId)
|
||||
.eq(QuestionImportJobDO::getPreviewCommandKey, commandKey));
|
||||
}
|
||||
|
||||
default QuestionImportJobDO selectByExecuteCommand(Long tenantId, String commandKey) {
|
||||
return selectOne(new LambdaQueryWrapperX<QuestionImportJobDO>()
|
||||
.eq(QuestionImportJobDO::getTenantId, tenantId)
|
||||
.eq(QuestionImportJobDO::getExecuteCommandKey, commandKey));
|
||||
}
|
||||
|
||||
default QuestionImportJobDO selectTenantJob(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<QuestionImportJobDO>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
"当前题库数据源模式不支持内容节点创作:{}");
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package cn.iocoder.yudao.module.education.service.importjob;
|
||||
|
||||
public record ExecuteQuestionImportCommand(Long jobId, String commandKey) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package cn.iocoder.yudao.module.education.service.importjob;
|
||||
|
||||
public interface ImportObjectScanGateway {
|
||||
enum ScanResult { CLEAN, INFECTED, ERROR }
|
||||
ScanResult scan(String objectKey);
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<ImportObjectScanGateway> scanGateway;
|
||||
private final Optional<QuestionImportParser> parser;
|
||||
private final TenantQuestionLifecycleService lifecycleService;
|
||||
|
||||
public QuestionImportJobServiceImpl(EducationProperties properties, QuestionImportJobMapper jobMapper,
|
||||
Optional<ImportObjectScanGateway> scanGateway,
|
||||
Optional<QuestionImportParser> 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<QuestionDraftCommand> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<QuestionDraftCommand> restore(String payload);
|
||||
record ParsedImport(String payload, List<QuestionDraftCommand> questions) {}
|
||||
}
|
||||
@@ -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<String> 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; }
|
||||
}
|
||||
}
|
||||
@@ -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<QuestionImportJobDO> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user