fix(education): consolidate question import persistence
This commit is contained in:
@@ -28,8 +28,7 @@ public class QuestionImportJobController {
|
||||
@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())));
|
||||
request.getCommandKey(), request.getAssetId()), getLoginUserId())));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/execute")
|
||||
|
||||
@@ -8,8 +8,5 @@ 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;
|
||||
@NotNull @Positive private Long assetId;
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
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.ContentImportAssetDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ContentImportAssetMapper extends BaseMapperX<ContentImportAssetDO> {
|
||||
|
||||
default ContentImportAssetDO selectTenantAsset(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<ContentImportAssetDO>()
|
||||
.eq(ContentImportAssetDO::getTenantId, tenantId)
|
||||
.eq(ContentImportAssetDO::getId, id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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.ContentImportJobDO;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
@@ -9,6 +10,24 @@ import java.util.List;
|
||||
@Mapper
|
||||
public interface ContentImportJobMapper extends BaseMapperX<ContentImportJobDO> {
|
||||
|
||||
default ContentImportJobDO selectByPreviewKey(Long tenantId, String previewKey) {
|
||||
return selectOne(new LambdaQueryWrapperX<ContentImportJobDO>()
|
||||
.eq(ContentImportJobDO::getTenantId, tenantId)
|
||||
.eq(ContentImportJobDO::getPreviewKey, previewKey));
|
||||
}
|
||||
|
||||
default ContentImportJobDO selectByExecuteKey(Long tenantId, String executeKey) {
|
||||
return selectOne(new LambdaQueryWrapperX<ContentImportJobDO>()
|
||||
.eq(ContentImportJobDO::getTenantId, tenantId)
|
||||
.eq(ContentImportJobDO::getExecuteKey, executeKey));
|
||||
}
|
||||
|
||||
default ContentImportJobDO selectTenantJob(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<ContentImportJobDO>()
|
||||
.eq(ContentImportJobDO::getTenantId, tenantId)
|
||||
.eq(ContentImportJobDO::getId, id));
|
||||
}
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO education_content_import_job
|
||||
(tenant_id, asset_id, actor_id, preview_key, preview_request_hash, import_type, status,
|
||||
@@ -33,6 +52,21 @@ public interface ContentImportJobMapper extends BaseMapperX<ContentImportJobDO>
|
||||
int requestExecute(@Param("tenantId") Long tenantId, @Param("id") Long id,
|
||||
@Param("executeKey") String executeKey, @Param("executeRequestHash") String executeRequestHash);
|
||||
|
||||
@Select("""
|
||||
UPDATE education_content_import_job
|
||||
SET locked_by=#{workerId}, lease_token=#{leaseToken},
|
||||
lease_expires_at=CURRENT_TIMESTAMP + CAST(#{leaseSeconds} || ' seconds' AS INTERVAL),
|
||||
last_heartbeat_at=CURRENT_TIMESTAMP, attempt_count=attempt_count + 1,
|
||||
next_attempt_at=NULL, started_at=COALESCE(started_at,CURRENT_TIMESTAMP), update_time=CURRENT_TIMESTAMP
|
||||
WHERE id=#{id} AND deleted=false AND status=#{expectedStatus} AND attempt_count < max_attempts
|
||||
AND (next_attempt_at IS NULL OR next_attempt_at <= CURRENT_TIMESTAMP)
|
||||
AND (lease_expires_at IS NULL OR lease_expires_at <= CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
""")
|
||||
ContentImportJobDO claimById(@Param("id") Long id, @Param("expectedStatus") String expectedStatus,
|
||||
@Param("workerId") String workerId, @Param("leaseToken") String leaseToken,
|
||||
@Param("leaseSeconds") long leaseSeconds);
|
||||
|
||||
@Select("""
|
||||
WITH candidate AS (
|
||||
SELECT id FROM education_content_import_job
|
||||
@@ -86,6 +120,18 @@ public interface ContentImportJobMapper extends BaseMapperX<ContentImportJobDO>
|
||||
@Param("resultSummary") String resultSummary, @Param("importedQuestionCount") Integer importedQuestionCount,
|
||||
@Param("failureCode") String failureCode, @Param("failureMessage") String failureMessage);
|
||||
|
||||
@Update("""
|
||||
UPDATE education_content_import_job
|
||||
SET status='FAILED', failure_code='ATTEMPTS_EXHAUSTED',
|
||||
failure_message='Maximum import attempts exhausted', finished_at=CURRENT_TIMESTAMP,
|
||||
locked_by=NULL, lease_token=NULL, lease_expires_at=NULL, last_heartbeat_at=NULL,
|
||||
update_time=CURRENT_TIMESTAMP
|
||||
WHERE deleted=false AND status IN ('PREVIEW_PENDING','EXECUTE_PENDING')
|
||||
AND attempt_count >= max_attempts
|
||||
AND (lease_expires_at IS NULL OR lease_expires_at <= CURRENT_TIMESTAMP)
|
||||
""")
|
||||
int failExhausted();
|
||||
|
||||
@Update("UPDATE education_content_import_job SET lease_expires_at=CURRENT_TIMESTAMP-INTERVAL '1 second' WHERE id=#{id}")
|
||||
int expireLeaseForTest(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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("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("FAILED"); job.setFailureCode(failureCode);
|
||||
return updateById(job);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
package cn.iocoder.yudao.module.education.service.importjob;
|
||||
|
||||
public record PreviewQuestionImportCommand(String commandKey, String objectKey, String fileName,
|
||||
String mediaType, Long fileSize) {
|
||||
public record PreviewQuestionImportCommand(String commandKey, Long assetId) {
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ 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.dal.dataobject.importjob.ContentImportAssetDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportJobDO;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.importjob.ContentImportAssetMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.importjob.ContentImportJobMapper;
|
||||
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;
|
||||
@@ -17,6 +19,7 @@ import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
@@ -24,17 +27,23 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
@Service
|
||||
public class QuestionImportJobServiceImpl implements QuestionImportJobService {
|
||||
|
||||
private static final int MAX_ATTEMPTS = 5;
|
||||
private static final long LEASE_SECONDS = 60;
|
||||
|
||||
private final EducationProperties properties;
|
||||
private final QuestionImportJobMapper jobMapper;
|
||||
private final ContentImportAssetMapper assetMapper;
|
||||
private final ContentImportJobMapper jobMapper;
|
||||
private final Optional<ImportObjectScanGateway> scanGateway;
|
||||
private final Optional<QuestionImportParser> parser;
|
||||
private final TenantQuestionLifecycleService lifecycleService;
|
||||
|
||||
public QuestionImportJobServiceImpl(EducationProperties properties, QuestionImportJobMapper jobMapper,
|
||||
public QuestionImportJobServiceImpl(EducationProperties properties, ContentImportAssetMapper assetMapper,
|
||||
ContentImportJobMapper jobMapper,
|
||||
Optional<ImportObjectScanGateway> scanGateway,
|
||||
Optional<QuestionImportParser> parser,
|
||||
TenantQuestionLifecycleService lifecycleService) {
|
||||
this.properties = properties;
|
||||
this.assetMapper = assetMapper;
|
||||
this.jobMapper = jobMapper;
|
||||
this.scanGateway = scanGateway;
|
||||
this.parser = parser;
|
||||
@@ -45,26 +54,27 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public QuestionImportJobProjection requestPreview(PreviewQuestionImportCommand command, Long actorId) {
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
ContentImportAssetDO asset = assetMapper.selectTenantAsset(tenantId, command.assetId());
|
||||
if (asset == null) throw exception(QUESTION_IMPORT_NOT_FOUND);
|
||||
String requestHash = hash(command);
|
||||
QuestionImportJobDO existing = jobMapper.selectByPreviewCommand(tenantId, command.commandKey());
|
||||
ContentImportJobDO existing = jobMapper.selectByPreviewKey(tenantId, command.commandKey());
|
||||
if (existing != null) {
|
||||
requireHash(existing.getPreviewRequestHash(), requestHash);
|
||||
return project(existing);
|
||||
return project(existing, asset);
|
||||
}
|
||||
QuestionImportJobDO job = new QuestionImportJobDO();
|
||||
ContentImportJobDO job = new ContentImportJobDO();
|
||||
job.setTenantId(tenantId);
|
||||
job.setAssetId(asset.getId());
|
||||
job.setActorId(actorId);
|
||||
job.setObjectKey(command.objectKey());
|
||||
job.setFileName(command.fileName());
|
||||
job.setMediaType(command.mediaType());
|
||||
job.setFileSize(command.fileSize());
|
||||
job.setPreviewCommandKey(command.commandKey());
|
||||
job.setPreviewKey(command.commandKey());
|
||||
job.setPreviewRequestHash(requestHash);
|
||||
job.setStatus("PREVIEW_PENDING");
|
||||
job.setScanStatus("PENDING");
|
||||
job.setParserStatus("PENDING");
|
||||
jobMapper.insert(job);
|
||||
return project(job);
|
||||
job.setImportType("QUESTION_DRAFT");
|
||||
job.setMaxAttempts(MAX_ATTEMPTS);
|
||||
job.setPreviewPayload("{}");
|
||||
jobMapper.insertPreviewIgnore(job);
|
||||
ContentImportJobDO stored = jobMapper.selectByPreviewKey(tenantId, command.commandKey());
|
||||
requireHash(stored.getPreviewRequestHash(), requestHash);
|
||||
return project(stored, asset);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -72,87 +82,108 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
|
||||
public QuestionImportJobProjection requestExecute(ExecuteQuestionImportCommand command, Long actorId) {
|
||||
assertJavaRead();
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
QuestionImportJobDO replay = jobMapper.selectByExecuteCommand(tenantId, command.commandKey());
|
||||
String requestHash = hash(command);
|
||||
ContentImportJobDO replay = jobMapper.selectByExecuteKey(tenantId, command.commandKey());
|
||||
if (replay != null) {
|
||||
requireHash(replay.getExecuteRequestHash(), requestHash);
|
||||
return project(replay);
|
||||
return project(replay, requireAsset(tenantId, replay.getAssetId()));
|
||||
}
|
||||
QuestionImportJobDO job = requireJob(tenantId, command.jobId());
|
||||
if (!"PREVIEW_READY".equals(job.getStatus()) || !"CLEAN".equals(job.getScanStatus())
|
||||
|| !"PARSED".equals(job.getParserStatus()) || job.getParsedPayload() == null) {
|
||||
ContentImportJobDO job = requireJob(tenantId, command.jobId());
|
||||
if (jobMapper.requestExecute(tenantId, job.getId(), command.commandKey(), requestHash) != 1) {
|
||||
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);
|
||||
job = requireJob(tenantId, job.getId());
|
||||
return project(job, requireAsset(tenantId, job.getAssetId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QuestionImportJobProjection get(Long jobId) {
|
||||
return project(requireJob(TenantContextHolder.getRequiredTenantId(), jobId));
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
ContentImportJobDO job = requireJob(tenantId, jobId);
|
||||
return project(job, requireAsset(tenantId, job.getAssetId()));
|
||||
}
|
||||
|
||||
@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);
|
||||
processClaimed(jobId, "PREVIEW_PENDING");
|
||||
}
|
||||
|
||||
@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));
|
||||
processClaimed(jobId, "EXECUTE_PENDING");
|
||||
}
|
||||
|
||||
private void processExecute(QuestionImportJobDO job) {
|
||||
private void processClaimed(Long jobId, String expectedStatus) {
|
||||
jobMapper.failExhausted();
|
||||
String token = UUID.randomUUID().toString();
|
||||
ContentImportJobDO job = jobMapper.claimById(jobId, expectedStatus, "education-import", token, LEASE_SECONDS);
|
||||
if (job != null) TenantUtils.execute(job.getTenantId(), () -> process(job, token));
|
||||
}
|
||||
|
||||
private void process(ContentImportJobDO job, String token) {
|
||||
if ("PREVIEW_PENDING".equals(job.getStatus())) processPreview(job, token);
|
||||
else processExecute(job, token);
|
||||
}
|
||||
|
||||
private void processPreview(ContentImportJobDO job, String token) {
|
||||
ContentImportAssetDO asset = requireAsset(job.getTenantId(), job.getAssetId());
|
||||
ImportObjectScanGateway gateway = scanGateway.orElse(null);
|
||||
if (gateway == null) {
|
||||
finishFailed(job, token, "SCAN_UNAVAILABLE");
|
||||
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
|
||||
}
|
||||
ImportObjectScanGateway.ScanResult scanResult = gateway.scan(asset.getObjectKey());
|
||||
if (scanResult != ImportObjectScanGateway.ScanResult.CLEAN) {
|
||||
finishFailed(job, token, "SCAN_" + scanResult.name());
|
||||
throw exception(QUESTION_IMPORT_SCAN_NOT_CLEAN);
|
||||
}
|
||||
QuestionImportParser availableParser = parser.orElse(null);
|
||||
String parserStatus = "UNAVAILABLE";
|
||||
String parsedPayload = null;
|
||||
Integer questionCount = null;
|
||||
String previewPayload = "{\"mode\":\"METADATA_ONLY\",\"executable\":false}";
|
||||
if (availableParser != null) {
|
||||
QuestionImportParser.ParsedImport parsed = availableParser.parse(asset.getObjectKey());
|
||||
parserStatus = "PARSED";
|
||||
parsedPayload = parsed.payload();
|
||||
questionCount = parsed.questions().size();
|
||||
previewPayload = "{\"mode\":\"PARSED\",\"executable\":true}";
|
||||
}
|
||||
int updated = jobMapper.finishClaim(job.getTenantId(), job.getId(), "PREVIEW_PENDING", "PREVIEW_READY",
|
||||
token, "CLEAN", parserStatus, previewPayload, parsedPayload, questionCount, null, null, null, null);
|
||||
if (updated != 1) throw exception(CONTENT_IMPORT_LEASE_LOST);
|
||||
}
|
||||
|
||||
private void processExecute(ContentImportJobDO job, String token) {
|
||||
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());
|
||||
int updated = jobMapper.finishClaim(job.getTenantId(), job.getId(), "EXECUTE_PENDING", "COMPLETED",
|
||||
token, null, null, null, job.getParsedPayload(), job.getPreviewQuestionCount(),
|
||||
"{\"result\":\"COMPLETED\"}", questions.size(), null, null);
|
||||
if (updated != 1) throw exception(CONTENT_IMPORT_LEASE_LOST);
|
||||
}
|
||||
|
||||
private QuestionImportJobDO requireJob(Long tenantId, Long id) {
|
||||
QuestionImportJobDO job = jobMapper.selectTenantJob(tenantId, id);
|
||||
private void finishFailed(ContentImportJobDO job, String token, String failureCode) {
|
||||
jobMapper.finishClaim(job.getTenantId(), job.getId(), job.getStatus(), "FAILED", token,
|
||||
null, null, null, job.getParsedPayload(), job.getPreviewQuestionCount(), null,
|
||||
job.getImportedQuestionCount(), failureCode, failureCode);
|
||||
}
|
||||
|
||||
private ContentImportJobDO requireJob(Long tenantId, Long id) {
|
||||
ContentImportJobDO job = jobMapper.selectTenantJob(tenantId, id);
|
||||
if (job == null) throw exception(QUESTION_IMPORT_NOT_FOUND);
|
||||
return job;
|
||||
}
|
||||
|
||||
private ContentImportAssetDO requireAsset(Long tenantId, Long id) {
|
||||
ContentImportAssetDO asset = assetMapper.selectTenantAsset(tenantId, id);
|
||||
if (asset == null) throw exception(QUESTION_IMPORT_NOT_FOUND);
|
||||
return asset;
|
||||
}
|
||||
|
||||
private void assertJavaRead() {
|
||||
if (properties.getCatalogMode() != CatalogProviderMode.JAVA_READ) {
|
||||
throw exception(QUESTION_IMPORT_PROVIDER_UNSUPPORTED, properties.getCatalogMode());
|
||||
@@ -173,9 +204,9 @@ public class QuestionImportJobServiceImpl implements QuestionImportJobService {
|
||||
}
|
||||
}
|
||||
|
||||
private QuestionImportJobProjection project(QuestionImportJobDO job) {
|
||||
private QuestionImportJobProjection project(ContentImportJobDO job, ContentImportAssetDO asset) {
|
||||
return new QuestionImportJobProjection(job.getId(), job.getStatus(), job.getScanStatus(),
|
||||
job.getParserStatus(), job.getFileName(), job.getFileSize(), job.getPreviewQuestionCount(),
|
||||
job.getParserStatus(), asset.getFileName(), asset.getFileSizeBytes(), job.getPreviewQuestionCount(),
|
||||
job.getImportedQuestionCount(), job.getFailureCode());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@ class QuestionImportJobControllerTest {
|
||||
@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);
|
||||
request.setCommandKey("preview-1"); request.setAssetId(88L);
|
||||
|
||||
assertEquals(101L, controller.preview(request).getData().getId());
|
||||
assertEquals("preview-1", service.preview.commandKey());
|
||||
assertEquals(88L, service.preview.assetId());
|
||||
assertEquals(7L, service.actorId);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ 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.dal.dataobject.importjob.ContentImportAssetDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.importjob.ContentImportJobDO;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.importjob.ContentImportAssetMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.importjob.ContentImportJobMapper;
|
||||
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;
|
||||
@@ -12,7 +14,6 @@ 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;
|
||||
|
||||
@@ -21,13 +22,14 @@ 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.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class QuestionImportJobServiceImplTest {
|
||||
|
||||
@Mock private QuestionImportJobMapper jobMapper;
|
||||
@Mock private ContentImportAssetMapper assetMapper;
|
||||
@Mock private ContentImportJobMapper jobMapper;
|
||||
@Mock private ImportObjectScanGateway scanGateway;
|
||||
@Mock private QuestionImportParser parser;
|
||||
@Mock private TenantQuestionLifecycleService lifecycleService;
|
||||
@@ -39,108 +41,103 @@ class QuestionImportJobServiceImplTest {
|
||||
void setUp() {
|
||||
properties = new EducationProperties();
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
service = new QuestionImportJobServiceImpl(properties, jobMapper, Optional.of(scanGateway),
|
||||
service = new QuestionImportJobServiceImpl(properties, assetMapper, jobMapper, Optional.of(scanGateway),
|
||||
Optional.of(parser), lifecycleService);
|
||||
TenantContextHolder.setTenantId(10L);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
@AfterEach void tearDown() { TenantContextHolder.clear(); }
|
||||
|
||||
@Test
|
||||
void duplicatePreviewCommandReturnsExistingJobWithoutRescanning() {
|
||||
QuestionImportJobDO existing = job(101L, "PREVIEW_READY");
|
||||
void duplicatePreviewUsesTenantBoundAdmittedAsset() {
|
||||
ContentImportAssetDO asset = asset(88L);
|
||||
ContentImportJobDO existing = job(101L, "PREVIEW_READY");
|
||||
existing.setPreviewRequestHash(null);
|
||||
when(jobMapper.selectByPreviewCommand(10L, "preview-1")).thenReturn(existing);
|
||||
when(assetMapper.selectTenantAsset(10L, 88L)).thenReturn(asset);
|
||||
when(jobMapper.selectByPreviewKey(10L, "preview-1")).thenReturn(existing);
|
||||
|
||||
QuestionImportJobProjection result = service.requestPreview(new PreviewQuestionImportCommand(
|
||||
"preview-1", "object/10/questions.csv", "questions.csv", "text/csv", 128L), 7L);
|
||||
QuestionImportJobProjection result = service.requestPreview(new PreviewQuestionImportCommand("preview-1", 88L), 7L);
|
||||
|
||||
assertEquals(101L, result.id());
|
||||
verify(jobMapper, never()).insert((QuestionImportJobDO) any(QuestionImportJobDO.class));
|
||||
verifyNoInteractions(scanGateway, parser, lifecycleService);
|
||||
assertEquals("questions.csv", result.fileName());
|
||||
verify(jobMapper, never()).insertPreviewIgnore(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanScanWithoutParserProducesMetadataOnlyPreviewReady() {
|
||||
service = new QuestionImportJobServiceImpl(properties, jobMapper, Optional.of(scanGateway),
|
||||
void crossTenantOrMissingAssetFailsBeforeJobInsert() {
|
||||
when(assetMapper.selectTenantAsset(10L, 88L)).thenReturn(null);
|
||||
assertThrows(ServiceException.class,
|
||||
() -> service.requestPreview(new PreviewQuestionImportCommand("preview-1", 88L), 7L));
|
||||
verifyNoInteractions(jobMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanScanWithoutParserProducesMetadataOnlyPreview() {
|
||||
service = new QuestionImportJobServiceImpl(properties, assetMapper, jobMapper, Optional.of(scanGateway),
|
||||
Optional.empty(), lifecycleService);
|
||||
QuestionImportJobDO claimed = job(101L, "PREVIEW_PENDING");
|
||||
claimed.setTenantId(10L);
|
||||
when(jobMapper.claimPreview(101L)).thenReturn(claimed);
|
||||
ContentImportJobDO claimed = job(101L, "PREVIEW_PENDING");
|
||||
when(jobMapper.claimById(eq(101L), eq("PREVIEW_PENDING"), anyString(), anyString(), anyLong()))
|
||||
.thenReturn(claimed);
|
||||
when(assetMapper.selectTenantAsset(10L, 88L)).thenReturn(asset(88L));
|
||||
when(scanGateway.scan("object/10/questions.csv")).thenReturn(ImportObjectScanGateway.ScanResult.CLEAN);
|
||||
when(jobMapper.finishClaim(eq(10L), eq(101L), eq("PREVIEW_PENDING"), eq("PREVIEW_READY"),
|
||||
anyString(), eq("CLEAN"), eq("UNAVAILABLE"), contains("METADATA_ONLY"), isNull(), isNull(),
|
||||
isNull(), isNull(), isNull(), isNull())).thenReturn(1);
|
||||
|
||||
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);
|
||||
verifyNoInteractions(parser, lifecycleService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableScannerFailsClosedAndNeverParses() {
|
||||
service = new QuestionImportJobServiceImpl(properties, jobMapper, Optional.empty(),
|
||||
void unavailableScannerFailsClosedAndFencesTerminalWrite() {
|
||||
service = new QuestionImportJobServiceImpl(properties, assetMapper, jobMapper, Optional.empty(),
|
||||
Optional.of(parser), lifecycleService);
|
||||
when(jobMapper.claimPreview(101L)).thenReturn(job(101L, "PREVIEW_PENDING"));
|
||||
when(jobMapper.claimById(eq(101L), eq("PREVIEW_PENDING"), anyString(), anyString(), anyLong()))
|
||||
.thenReturn(job(101L, "PREVIEW_PENDING"));
|
||||
when(assetMapper.selectTenantAsset(10L, 88L)).thenReturn(asset(88L));
|
||||
|
||||
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"));
|
||||
verify(jobMapper).finishClaim(eq(10L), eq(101L), eq("PREVIEW_PENDING"), eq("FAILED"),
|
||||
anyString(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(),
|
||||
eq("SCAN_UNAVAILABLE"), eq("SCAN_UNAVAILABLE"));
|
||||
verifyNoInteractions(parser, lifecycleService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void executePropagatesClaimedTenantAndCreatesEveryQuestionAsDraft() {
|
||||
void executePropagatesClaimedTenantAndCreatesDrafts() {
|
||||
TenantContextHolder.clear();
|
||||
QuestionImportJobDO claimed = job(101L, "EXECUTE_PENDING");
|
||||
claimed.setTenantId(42L);
|
||||
claimed.setScanStatus("CLEAN");
|
||||
claimed.setParserStatus("PARSED");
|
||||
ContentImportJobDO 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(jobMapper.claimById(eq(101L), eq("EXECUTE_PENDING"), anyString(), anyString(), anyLong()))
|
||||
.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;
|
||||
assertEquals(42L, TenantContextHolder.getRequiredTenantId()); return 100L;
|
||||
});
|
||||
when(jobMapper.finishClaim(eq(42L), eq(101L), eq("EXECUTE_PENDING"), eq("COMPLETED"),
|
||||
anyString(), isNull(), isNull(), isNull(), eq("stored-preview"), isNull(), anyString(), eq(2),
|
||||
isNull(), isNull())).thenReturn(1);
|
||||
|
||||
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 ContentImportAssetDO asset(Long id) {
|
||||
ContentImportAssetDO asset = new ContentImportAssetDO(); asset.setId(id); asset.setTenantId(10L);
|
||||
asset.setObjectKey("object/10/questions.csv"); asset.setFileName("questions.csv");
|
||||
asset.setMimeType("text/csv"); asset.setFileSizeBytes(128L); return asset;
|
||||
}
|
||||
|
||||
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 ContentImportJobDO job(Long id, String status) {
|
||||
ContentImportJobDO job = new ContentImportJobDO(); job.setId(id); job.setTenantId(10L); job.setAssetId(88L);
|
||||
job.setActorId(7L); job.setPreviewKey("preview-1"); job.setPreviewRequestHash("hash");
|
||||
job.setStatus(status); return job;
|
||||
}
|
||||
|
||||
private QuestionDraftCommand question(String stem) {
|
||||
|
||||
Reference in New Issue
Block a user