package tech.easyflow.ai.documentimport.task;

import com.mybatisflex.core.query.QueryWrapper;
import org.apache.ibatis.annotations.Update;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
import tech.easyflow.ai.documentimport.ImportCallerContext;
import tech.easyflow.ai.documentimport.ImportCallerType;
import tech.easyflow.ai.entity.DocumentImportBatch;
import tech.easyflow.ai.entity.DocumentImportBatchItem;
import tech.easyflow.ai.enums.DocumentImportBatchItemStage;
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
import tech.easyflow.ai.enums.DocumentImportMode;
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
import tech.easyflow.ai.service.DocumentImportBatchItemService;
import tech.easyflow.ai.service.DocumentImportBatchService;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.FileStorageWriteResult;
import tech.easyflow.common.web.exceptions.BusinessException;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BooleanSupplier;

/**
 * {@link DocumentImportBatchAppService} 批次启动与重复策略回归测试。
 *
 * @author Codex
 * @since 2026-07-31
 */
public class DocumentImportBatchAppServiceTest {

    /**
     * 验证上传领取通过单条多表更新同步刷新批次进度时间。
     */
    @Test
    public void uploadShouldAtomicallyClaimItemAndTouchBatch() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setStatus(DocumentImportBatchStatus.UPLOADING.name());
        DocumentImportBatchItem pending = pendingUploadItem(BigInteger.valueOf(41));
        DocumentImportBatchItem uploaded = pendingUploadItem(BigInteger.valueOf(41));
        uploaded.setStatus(DocumentImportBatchItemStatus.UPLOADED.name());
        uploaded.setFilePath("/stored/demo.docx");
        FileStorageWriteHandle handle = writeHandle(pending);
        String locator = handle.encodeLocator();
        uploaded.setStorageLocator(locator);
        uploaded.setCleanupPending(false);
        Mockito.when(context.batchTracker.requireItem(pending.getId()))
            .thenReturn(pending, uploaded);
        Mockito.when(context.itemMapper.claimUpload(
            Mockito.eq(batch.getId()),
            Mockito.eq(pending.getId()),
            Mockito.eq(batch.getKnowledgeId()),
            Mockito.any(Date.class)
        )).thenReturn(1);
        Mockito.when(context.itemMapper.registerUploadWriteIntent(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.any(Date.class)
        )).thenReturn(1);
        Mockito.when(context.batchTracker.completeUpload(
            pending.getId(),
            "/stored/demo.docx",
            locator
        )).thenReturn(true);
        FileStorageService storage = Mockito.mock(FileStorageService.class);
        Mockito.when(storage.prepareRecoverableWrite(
            Mockito.anyString(),
            Mockito.anyString()
        )).thenReturn(handle);
        Mockito.when(storage.saveRecoverable(
            Mockito.any(MultipartFile.class),
            Mockito.eq(handle)
        )).thenReturn(new FileStorageWriteResult("/stored/demo.docx", locator));
        setStorageService(context.service, storage);
        MultipartFile file = uploadFile("demo.docx", pending.getFileSize());

        DocumentImportBatchDtos.ItemResponse response = context.service.uploadItem(
            batch.getKnowledgeId(),
            batch.getId(),
            pending.getId(),
            file,
            publicCaller()
        );

        Assert.assertEquals(
            DocumentImportBatchItemStatus.UPLOADED.name(),
            response.getStatus()
        );
        org.mockito.InOrder order = Mockito.inOrder(context.itemMapper, storage);
        order.verify(context.itemMapper).claimUpload(
            Mockito.eq(batch.getId()),
            Mockito.eq(pending.getId()),
            Mockito.eq(batch.getKnowledgeId()),
            Mockito.any(Date.class)
        );
        order.verify(context.itemMapper).registerUploadWriteIntent(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.any(Date.class)
        );
        order.verify(storage).saveRecoverable(file, handle);
    }

    /**
     * 验证对象绑定失败后先清理，再恢复为可重新上传状态。
     */
    @Test
    public void uploadBindFailureShouldCleanupAndAllowSameItemRetry() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setStatus(DocumentImportBatchStatus.UPLOADING.name());
        DocumentImportBatchItem pending = pendingUploadItem(BigInteger.valueOf(42));
        FileStorageWriteHandle handle = writeHandle(pending);
        String locator = handle.encodeLocator();
        DocumentImportBatchItem cleanup = pendingUploadItem(pending.getId());
        cleanup.setStatus(DocumentImportBatchItemStatus.UPLOADING.name());
        cleanup.setStorageLocator(locator);
        cleanup.setFilePath("/stored/demo.docx");
        cleanup.setCleanupPending(true);
        DocumentImportBatchItem retryPending = pendingUploadItem(pending.getId());
        DocumentImportBatchItem uploaded = pendingUploadItem(pending.getId());
        uploaded.setStatus(DocumentImportBatchItemStatus.UPLOADED.name());
        uploaded.setStorageLocator(locator);
        uploaded.setFilePath("/stored/demo.docx");
        Mockito.when(context.batchTracker.requireItem(pending.getId()))
            .thenReturn(pending, pending, cleanup, retryPending, uploaded);
        Mockito.when(context.itemMapper.claimUpload(
            Mockito.eq(batch.getId()),
            Mockito.eq(pending.getId()),
            Mockito.eq(batch.getKnowledgeId()),
            Mockito.any(Date.class)
        )).thenReturn(1, 1);
        Mockito.when(context.itemMapper.registerUploadWriteIntent(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.any(Date.class)
        )).thenReturn(1, 1);
        Mockito.when(context.batchTracker.completeUpload(
            pending.getId(),
            "/stored/demo.docx",
            locator
        )).thenReturn(false, true);
        Mockito.when(context.itemMapper.markUploadCleanupPending(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.any(Date.class)
        )).thenReturn(1);
        Mockito.when(context.itemMapper.completeUploadingStorageCleanup(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.eq("/stored/demo.docx"),
            Mockito.anyString(),
            Mockito.any(Date.class)
        )).thenReturn(1);
        FileStorageService storage = Mockito.mock(FileStorageService.class);
        Mockito.when(storage.prepareRecoverableWrite(
            Mockito.anyString(),
            Mockito.anyString()
        )).thenReturn(handle);
        Mockito.when(storage.saveRecoverable(
            Mockito.any(MultipartFile.class),
            Mockito.eq(handle)
        )).thenReturn(new FileStorageWriteResult("/stored/demo.docx", locator));
        setStorageService(context.service, storage);
        MultipartFile file = uploadFile("demo.docx", pending.getFileSize());

        try {
            context.service.uploadItem(
                batch.getKnowledgeId(),
                batch.getId(),
                pending.getId(),
                file,
                publicCaller()
            );
            Assert.fail("Expected cancelled upload rejection");
        } catch (BusinessException expected) {
            Assert.assertTrue(expected.getMessage().contains("上传批次已取消"));
        }

        DocumentImportBatchDtos.ItemResponse retried = context.service.uploadItem(
            batch.getKnowledgeId(),
            batch.getId(),
            pending.getId(),
            file,
            publicCaller()
        );

        Assert.assertEquals(
            DocumentImportBatchItemStatus.UPLOADED.name(),
            retried.getStatus()
        );
        org.mockito.InOrder order = Mockito.inOrder(context.itemMapper, storage);
        order.verify(context.itemMapper).registerUploadWriteIntent(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.any(Date.class)
        );
        order.verify(storage).saveRecoverable(file, handle);
        order.verify(context.itemMapper).markUploadCleanupPending(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.any(Date.class)
        );
        order.verify(storage).deleteRecoverable(handle);
        order.verify(context.itemMapper).completeUploadingStorageCleanup(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.eq("/stored/demo.docx"),
            Mockito.anyString(),
            Mockito.any(Date.class)
        );
        Mockito.verify(context.itemMapper, Mockito.times(2)).claimUpload(
            Mockito.eq(batch.getId()),
            Mockito.eq(pending.getId()),
            Mockito.eq(batch.getKnowledgeId()),
            Mockito.any(Date.class)
        );
        Mockito.verify(context.batchTracker, Mockito.never()).transitionItem(
            Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(),
            Mockito.anyBoolean(), Mockito.anyInt()
        );
    }

    /**
     * 验证批次并发取消后，对象清理不会把文件项恢复为待上传。
     */
    @Test
    public void uploadCleanupShouldPreserveConcurrentCancellation() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setStatus(DocumentImportBatchStatus.UPLOADING.name());
        DocumentImportBatchItem pending = pendingUploadItem(BigInteger.valueOf(44));
        FileStorageWriteHandle handle = writeHandle(pending);
        String locator = handle.encodeLocator();
        DocumentImportBatchItem cancelled = pendingUploadItem(pending.getId());
        cancelled.setStatus(DocumentImportBatchItemStatus.CANCELLED.name());
        cancelled.setStorageLocator(locator);
        cancelled.setFilePath("/stored/demo.docx");
        cancelled.setCleanupPending(true);
        Mockito.when(context.batchTracker.requireItem(pending.getId()))
            .thenReturn(pending, pending, cancelled);
        Mockito.when(context.itemMapper.claimUpload(
            Mockito.eq(batch.getId()),
            Mockito.eq(pending.getId()),
            Mockito.eq(batch.getKnowledgeId()),
            Mockito.any(Date.class)
        )).thenReturn(1);
        Mockito.when(context.itemMapper.registerUploadWriteIntent(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.any(Date.class)
        )).thenReturn(1);
        Mockito.when(context.batchTracker.completeUpload(
            pending.getId(),
            "/stored/demo.docx",
            locator
        )).thenReturn(false);
        Mockito.when(context.itemMapper.markUploadCleanupPending(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.any(Date.class)
        )).thenReturn(1);
        Mockito.when(context.itemMapper.completeCancelledStorageCleanup(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.eq("/stored/demo.docx"),
            Mockito.any(Date.class)
        )).thenReturn(1);
        FileStorageService storage = Mockito.mock(FileStorageService.class);
        Mockito.when(storage.prepareRecoverableWrite(
            Mockito.anyString(),
            Mockito.anyString()
        )).thenReturn(handle);
        Mockito.when(storage.saveRecoverable(
            Mockito.any(MultipartFile.class),
            Mockito.eq(handle)
        )).thenReturn(new FileStorageWriteResult("/stored/demo.docx", locator));
        setStorageService(context.service, storage);

        try {
            context.service.uploadItem(
                batch.getKnowledgeId(),
                batch.getId(),
                pending.getId(),
                uploadFile("demo.docx", pending.getFileSize()),
                publicCaller()
            );
            Assert.fail("Expected cancelled upload rejection");
        } catch (BusinessException expected) {
            Assert.assertTrue(expected.getMessage().contains("上传批次已取消"));
        }

        Mockito.verify(storage).deleteRecoverable(handle);
        Mockito.verify(context.itemMapper).completeCancelledStorageCleanup(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.eq("/stored/demo.docx"),
            Mockito.any(Date.class)
        );
    }

    /**
     * 验证对象删除后的数据库恢复失败会保留待办，并由下一轮幂等完成。
     */
    @Test
    public void cleanupRecoveryFailureShouldRemainRetryable() {
        TestContext context = createContext();
        DocumentImportBatchItem cleanup = pendingUploadItem(BigInteger.valueOf(45));
        FileStorageWriteHandle handle = writeHandle(cleanup);
        String locator = handle.encodeLocator();
        cleanup.setStatus(DocumentImportBatchItemStatus.UPLOADING.name());
        cleanup.setStorageLocator(locator);
        cleanup.setFilePath("/stored/demo.docx");
        cleanup.setCleanupPending(true);
        Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class)))
            .thenReturn(List.of(cleanup));
        Mockito.when(context.itemMapper.completeUploadingStorageCleanup(
            Mockito.eq(cleanup.getId()),
            Mockito.eq(locator),
            Mockito.eq("/stored/demo.docx"),
            Mockito.anyString(),
            Mockito.any(Date.class)
        )).thenThrow(new RuntimeException("database unavailable"))
            .thenReturn(1);
        FileStorageService storage = Mockito.mock(FileStorageService.class);
        setStorageService(context.service, storage);

        Assert.assertEquals(0, context.service.cleanupCancelledStoredObjects(10));
        Assert.assertEquals(1, context.service.cleanupCancelledStoredObjects(10));

        Mockito.verify(storage, Mockito.times(2)).deleteRecoverable(handle);
        Mockito.verify(context.itemMapper, Mockito.times(2))
            .completeUploadingStorageCleanup(
            Mockito.eq(cleanup.getId()),
            Mockito.eq(locator),
            Mockito.eq("/stored/demo.docx"),
            Mockito.anyString(),
            Mockito.any(Date.class)
        );
    }

    /**
     * 验证写意图登记已提交但调用抛异常时，写入前撤销会清理定位符。
     */
    @Test
    public void uploadIntentUnknownCommitShouldAbortBeforePhysicalWrite() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setStatus(DocumentImportBatchStatus.UPLOADING.name());
        DocumentImportBatchItem pending = pendingUploadItem(BigInteger.valueOf(46));
        FileStorageWriteHandle handle = writeHandle(pending);
        String locator = handle.encodeLocator();
        Mockito.when(context.batchTracker.requireItem(pending.getId()))
            .thenReturn(pending);
        Mockito.when(context.itemMapper.claimUpload(
            Mockito.eq(batch.getId()),
            Mockito.eq(pending.getId()),
            Mockito.eq(batch.getKnowledgeId()),
            Mockito.any(Date.class)
        )).thenReturn(1);
        Mockito.when(context.itemMapper.registerUploadWriteIntent(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.any(Date.class)
        )).thenThrow(new RuntimeException("commit result unknown"));
        Mockito.when(context.itemMapper.abortUploadBeforeWrite(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.anyString(),
            Mockito.any(Date.class)
        )).thenReturn(1);
        FileStorageService storage = Mockito.mock(FileStorageService.class);
        Mockito.when(storage.prepareRecoverableWrite(
            Mockito.anyString(),
            Mockito.anyString()
        )).thenReturn(handle);
        setStorageService(context.service, storage);

        try {
            context.service.uploadItem(
                batch.getKnowledgeId(),
                batch.getId(),
                pending.getId(),
                uploadFile("demo.docx", pending.getFileSize()),
                publicCaller()
            );
            Assert.fail("Expected unknown commit exception");
        } catch (RuntimeException expected) {
            Assert.assertEquals("commit result unknown", expected.getMessage());
        }

        Mockito.verify(context.itemMapper).abortUploadBeforeWrite(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.anyString(),
            Mockito.any(Date.class)
        );
        Mockito.verify(storage, Mockito.never()).saveRecoverable(
            Mockito.any(MultipartFile.class),
            Mockito.any(FileStorageWriteHandle.class)
        );
    }

    /**
     * 验证提交响应异常但数据库已完成上传时不会误删有效对象。
     */
    @Test
    public void uploadCommitUnknownShouldKeepConfirmedUploadedObject() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setStatus(DocumentImportBatchStatus.UPLOADING.name());
        DocumentImportBatchItem pending = pendingUploadItem(BigInteger.valueOf(43));
        FileStorageWriteHandle handle = writeHandle(pending);
        String locator = handle.encodeLocator();
        DocumentImportBatchItem committed = pendingUploadItem(pending.getId());
        committed.setStatus(DocumentImportBatchItemStatus.UPLOADED.name());
        committed.setFilePath("/stored/demo.docx");
        committed.setStorageLocator(locator);
        committed.setCleanupPending(false);
        Mockito.when(context.batchTracker.requireItem(pending.getId()))
            .thenReturn(pending, committed);
        Mockito.when(context.itemMapper.claimUpload(
            Mockito.eq(batch.getId()),
            Mockito.eq(pending.getId()),
            Mockito.eq(batch.getKnowledgeId()),
            Mockito.any(Date.class)
        )).thenReturn(1);
        Mockito.when(context.itemMapper.registerUploadWriteIntent(
            Mockito.eq(pending.getId()),
            Mockito.eq(locator),
            Mockito.any(Date.class)
        )).thenReturn(1);
        Mockito.when(context.batchTracker.completeUpload(
            pending.getId(),
            "/stored/demo.docx",
            locator
        )).thenThrow(new RuntimeException("commit result unknown"));
        FileStorageService storage = Mockito.mock(FileStorageService.class);
        Mockito.when(storage.prepareRecoverableWrite(
            Mockito.anyString(),
            Mockito.anyString()
        )).thenReturn(handle);
        Mockito.when(storage.saveRecoverable(
            Mockito.any(MultipartFile.class),
            Mockito.eq(handle)
        )).thenReturn(new FileStorageWriteResult("/stored/demo.docx", locator));
        setStorageService(context.service, storage);

        DocumentImportBatchDtos.ItemResponse response = context.service.uploadItem(
            batch.getKnowledgeId(),
            batch.getId(),
            pending.getId(),
            uploadFile("demo.docx", pending.getFileSize()),
            publicCaller()
        );

        Assert.assertEquals(
            DocumentImportBatchItemStatus.UPLOADED.name(),
            response.getStatus()
        );
        Mockito.verify(context.itemMapper, Mockito.never())
            .markUploadCleanupPending(
                Mockito.any(),
                Mockito.anyString(),
                Mockito.any(Date.class)
            );
        Mockito.verify(storage, Mockito.never())
            .deleteRecoverable(Mockito.any(FileStorageWriteHandle.class));
    }

    /**
     * 验证同一知识库已有运行中的自动批次时拒绝再次启动。
     */
    @Test
    public void startAutoShouldRejectOtherRunningBatch() {
        TestContext context = createContext();
        Mockito.when(context.batchService.count(Mockito.any(QueryWrapper.class))).thenReturn(1L);
        beginTransactionSynchronization();
        try {
            context.service.startBatch(startRequest("AUTO", "SKIP"));
            Assert.fail("Expected active automatic batch rejection");
        } catch (BusinessException expected) {
            Assert.assertTrue(expected.getMessage().contains("已有自动导入批次"));
        } finally {
            completeTransactionSynchronization(TransactionSynchronization.STATUS_ROLLED_BACK);
        }
        Mockito.verify(context.batchMapper, Mockito.never())
            .updateByQuery(Mockito.any(DocumentImportBatch.class), Mockito.any());
        Mockito.verify(context.lockHandle).release();
    }

    /**
     * 验证覆盖策略会持久化历史文档 ID，并继续创建新文档任务。
     */
    @Test
    public void startOverwriteShouldRecordHistoricalDocument() {
        TestContext context = createContext();
        Mockito.when(context.batchService.count(Mockito.any(QueryWrapper.class))).thenReturn(0L);
        DocumentImportBatchItem uploaded = uploadedItem(BigInteger.valueOf(11), BigInteger.ONE);
        DocumentImportBatchItem historical = uploadedItem(BigInteger.valueOf(21), BigInteger.valueOf(22));
        historical.setBatchId(BigInteger.valueOf(99));
        historical.setDocumentId(BigInteger.valueOf(23));
        historical.setStatus(DocumentImportBatchItemStatus.COMPLETED.name());
        historical.setCreated(new Date());
        Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class)))
            .thenReturn(List.of(uploaded), List.of(historical));
        tech.easyflow.ai.entity.Document historicalDocument = new tech.easyflow.ai.entity.Document();
        historicalDocument.setId(historical.getDocumentId());
        historicalDocument.setCollectionId(BigInteger.TWO);
        Mockito.when(context.documentMapper.selectListByQuery(Mockito.any()))
            .thenReturn(List.of(historicalDocument));
        Mockito.when(context.batchMapper.updateByQuery(
            Mockito.any(DocumentImportBatch.class), Mockito.any()
        )).thenReturn(1);
        Mockito.when(context.batchTracker.refreshBatch(BigInteger.ONE))
            .thenReturn(new DocumentImportBatchDtos.StatusResponse());

        beginTransactionSynchronization();
        try {
            context.service.startBatch(startRequest("AUTO", "OVERWRITE"));
        } finally {
            completeTransactionSynchronization(TransactionSynchronization.STATUS_COMMITTED);
        }

        Mockito.verify(context.batchTracker)
            .markReplacement(uploaded.getId(), historical.getDocumentId());
        Mockito.verify(context.taskAppService)
            .createBatchImportTasks(Mockito.any(DocumentImportBatch.class), Mockito.anyList());
        Mockito.verify(context.lockHandle).release();
    }

    /**
     * 验证重试代次由服务端读取并原子领取。
     */
    @Test
    public void retryShouldClaimCurrentGeneration() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setStatus(DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name());
        batch.setRetryGeneration(0);
        DocumentImportBatchItem failed = uploadedItem(
            BigInteger.valueOf(31),
            batch.getId()
        );
        failed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
        failed.setRetryable(true);
        Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class)))
            .thenReturn(List.of(failed));
        Mockito.when(context.batchMapper.claimRetry(
            Mockito.any(),
            Mockito.anyString(),
            Mockito.any(),
            Mockito.anyInt(),
            Mockito.nullable(String.class),
            Mockito.any(Date.class)
        )).thenReturn(1);

        beginTransactionSynchronization();
        DocumentImportBatchRetryResult first;
        try {
            first = context.service.retryOwnedBatch(
                batch.getId(),
                publicCaller(),
                Set.of("file-key")
            );
        } finally {
            completeTransactionSynchronization(
                TransactionSynchronization.STATUS_ROLLED_BACK
            );
        }

        Assert.assertEquals(Integer.valueOf(1), first.getRetryGeneration());
        Assert.assertEquals(Integer.valueOf(1), first.getRetriedCount());
        Mockito.verify(context.batchMapper).claimRetry(
            Mockito.any(),
            Mockito.anyString(),
            Mockito.any(),
            Mockito.eq(0),
            Mockito.eq("[\"file-key\"]"),
            Mockito.any(Date.class)
        );
        Mockito.verify(context.lockHandle).release();
    }

    /**
     * 验证人工重试直接选择历史失败项，不受旧重试资格字段限制。
     */
    @Test
    public void retryShouldSelectLegacyFailedItemWithoutRetryableGate() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setStatus(DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name());
        batch.setRetryGeneration(0);
        DocumentImportBatchItem failed = uploadedItem(
            BigInteger.valueOf(32),
            batch.getId()
        );
        failed.setStage(DocumentImportBatchItemStage.PARSE.name());
        failed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
        failed.setRetryable(false);
        failed.setFailureCode("parse_failed");
        failed.setErrorSummary("历史代码异常");
        Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class)))
            .thenReturn(List.of(failed));
        Mockito.when(context.batchMapper.claimRetry(
            Mockito.any(),
            Mockito.anyString(),
            Mockito.any(),
            Mockito.anyInt(),
            Mockito.nullable(String.class),
            Mockito.any(Date.class)
        )).thenReturn(1);

        beginTransactionSynchronization();
        DocumentImportBatchRetryResult result;
        try {
            result = context.service.retryOwnedBatch(
                batch.getId(),
                publicCaller(),
                Set.of()
            );
        } finally {
            completeTransactionSynchronization(
                TransactionSynchronization.STATUS_ROLLED_BACK
            );
        }

        Assert.assertEquals(Integer.valueOf(1), result.getRetriedCount());
        Assert.assertFalse(failed.getRetryable());
        Mockito.verify(context.batchTracker, Mockito.never()).transitionItem(
            Mockito.any(),
            Mockito.any(),
            Mockito.any(),
            Mockito.any(),
            Mockito.anyBoolean(),
            Mockito.anyInt(),
            Mockito.any()
        );
        Mockito.verify(context.batchMapper).claimRetry(
            Mockito.eq(batch.getId()),
            Mockito.anyString(),
            Mockito.any(),
            Mockito.eq(0),
            Mockito.isNull(),
            Mockito.any(Date.class)
        );
        Mockito.verify(context.lockHandle).release();
    }

    /**
     * 验证管理端继续按真实失败项恢复，不依赖历史可重试失败计数。
     */
    @Test
    public void continueShouldResumeFailedItemsWhenCompatibilityCounterIsZero() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setImportMode(DocumentImportMode.AUTO.name());
        batch.setStatus(DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name());
        batch.setFailedCount(1);
        batch.setRetryableFailedCount(0);
        DocumentImportBatchItem failed = uploadedItem(
            BigInteger.valueOf(33),
            batch.getId()
        );
        failed.setStage(DocumentImportBatchItemStage.INDEX.name());
        failed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
        failed.setRetryable(false);
        Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class)))
            .thenReturn(List.of(failed));
        Mockito.when(context.batchMapper.claimContinue(
            Mockito.eq(batch.getId()), Mockito.any(Date.class)
        )).thenReturn(1);
        Mockito.when(context.batchTracker.toStatusResponse(batch))
            .thenAnswer(invocation -> {
                DocumentImportBatchDtos.StatusResponse response =
                    new DocumentImportBatchDtos.StatusResponse();
                response.setStatus(batch.getStatus());
                return response;
            });

        DocumentImportBatchDtos.StatusResponse response;
        beginTransactionSynchronization();
        try {
            response = context.service.continueBatch(
                batch.getKnowledgeId(), batch.getId()
            );
        } finally {
            completeTransactionSynchronization(
                TransactionSynchronization.STATUS_COMMITTED
            );
        }

        Assert.assertEquals(DocumentImportBatchStatus.RUNNING.name(),
            response.getStatus());
        Mockito.verify(context.batchMapper).claimContinue(
            Mockito.eq(batch.getId()), Mockito.any(Date.class));
        Mockito.verify(context.batchTracker, Mockito.never())
            .refreshBatch(batch.getId());
        Mockito.verify(context.taskAppService).retryBatchFailures(
            Mockito.eq(batch.getId()),
            Mockito.eq(Set.of()),
            Mockito.any(BooleanSupplier.class)
        );
        Mockito.verify(context.lockHandle).release();
    }

    /**
     * 验证继续后的恢复调度再次异常时通过统一熔断器收口整个批次。
     */
    @Test
    public void continueRecoveryFailureShouldInterruptWholeBatch() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setImportMode(DocumentImportMode.AUTO.name());
        batch.setStatus(DocumentImportBatchStatus.INTERRUPTED.name());
        DocumentImportBatchItem failed = uploadedItem(
            BigInteger.valueOf(34),
            batch.getId()
        );
        failed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
        Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class)))
            .thenReturn(List.of(failed));
        Mockito.when(context.batchMapper.claimContinue(
            Mockito.eq(batch.getId()), Mockito.any(Date.class)
        )).thenReturn(1);
        Mockito.when(context.batchTracker.toStatusResponse(batch))
            .thenReturn(new DocumentImportBatchDtos.StatusResponse());
        IllegalStateException recoveryError =
            new IllegalStateException("Redis unavailable");
        Mockito.doThrow(recoveryError)
            .when(context.taskAppService)
            .retryBatchFailures(
                Mockito.eq(batch.getId()),
                Mockito.eq(Set.of()),
                Mockito.any(BooleanSupplier.class)
            );

        beginTransactionSynchronization();
        try {
            context.service.continueBatch(
                batch.getKnowledgeId(), batch.getId()
            );
        } finally {
            completeTransactionSynchronization(
                TransactionSynchronization.STATUS_COMMITTED
            );
        }

        Mockito.verify(context.circuitBreaker)
            .interruptRecoveryBatch(
                Mockito.eq(batch.getId()),
                Mockito.anyString(),
                Mockito.same(recoveryError)
            );
        Mockito.verify(context.batchMapper, Mockito.never())
            .finalizeRecoveryPending(
                Mockito.any(), Mockito.anyString(), Mockito.any(Date.class));
    }

    /**
     * 验证领取后恢复令牌已经失效时旧持有者直接退出且不触发批次熔断。
     */
    @Test
    public void staleRecoveryOwnerShouldStopWithoutInterruptingBatch() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
        batch.setRecoveryPending(true);
        Mockito.when(context.batchMapper.selectRecoveryPendingBatches(
            Mockito.any(Date.class), Mockito.anyInt()
        )).thenReturn(List.of(batch));
        Mockito.when(context.batchMapper.selectClaimedRecovery(
            Mockito.eq(batch.getId()), Mockito.anyString()
        )).thenReturn(null);

        int recovered = context.service.recoverPendingBatchRetries();

        Assert.assertEquals(1, recovered);
        Mockito.verifyNoInteractions(context.circuitBreaker);
        Mockito.verify(context.taskAppService, Mockito.never())
            .retryBatchFailures(
                Mockito.any(), Mockito.anySet(),
                Mockito.any(BooleanSupplier.class));
    }

    /**
     * 验证进程错过提交后回调时，调度器可从持久恢复待办重放任务创建。
     */
    @Test
    public void pendingRecoveryShouldReplayAndClearDurableMarker() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
        batch.setRecoveryPending(true);
        batch.setRecoveryFileKeysJson("[\"stale-file\"]");
        DocumentImportBatch claimedBatch = new DocumentImportBatch();
        claimedBatch.setId(batch.getId());
        claimedBatch.setRecoveryFileKeysJson("[\"selected-file\"]");
        Mockito.when(context.batchMapper.selectRecoveryPendingBatches(
            Mockito.any(Date.class), Mockito.anyInt()
        )).thenReturn(List.of(batch));
        Mockito.when(context.batchMapper.selectClaimedRecovery(
            Mockito.eq(batch.getId()), Mockito.anyString()
        )).thenReturn(claimedBatch);

        int recovered = context.service.recoverPendingBatchRetries();

        Assert.assertEquals(1, recovered);
        Mockito.verify(context.taskAppService)
            .retryBatchFailures(
                Mockito.eq(batch.getId()),
                Mockito.eq(Set.of("selected-file")),
                Mockito.any(BooleanSupplier.class)
            );
        ArgumentCaptor<String> recoveryToken =
            ArgumentCaptor.forClass(String.class);
        Mockito.verify(context.batchMapper).claimRecoveryPending(
            Mockito.eq(batch.getId()),
            recoveryToken.capture(),
            Mockito.any(Date.class),
            Mockito.any(Date.class)
        );
        Mockito.verify(context.batchMapper).selectClaimedRecovery(
            batch.getId(), recoveryToken.getValue());
        Mockito.verify(context.batchMapper)
            .finalizeRecoveryPending(
                Mockito.eq(batch.getId()),
                Mockito.eq(recoveryToken.getValue()),
                Mockito.any(Date.class));
    }

    /**
     * 验证文件恢复期间租约失效后旧持有者不会清理持久恢复待办。
     */
    @Test
    public void lostRecoveryLeaseShouldKeepDurableMarker() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
        batch.setRecoveryPending(true);
        Mockito.when(context.batchMapper.selectRecoveryPendingBatches(
            Mockito.any(Date.class), Mockito.anyInt()
        )).thenReturn(List.of(batch));
        Mockito.when(context.taskAppService.retryBatchFailures(
            Mockito.eq(batch.getId()), Mockito.anySet(),
            Mockito.any(BooleanSupplier.class)
        )).thenReturn(false);

        Assert.assertEquals(1, context.service.recoverPendingBatchRetries());

        Mockito.verify(context.batchMapper, Mockito.never())
            .finalizeRecoveryPending(
                Mockito.any(), Mockito.anyString(), Mockito.any(Date.class));
        Mockito.verifyNoInteractions(context.circuitBreaker);
    }

    /**
     * 验证长时间恢复会在半租期续租，并在令牌失效后停止旧实例。
     */
    @Test
    public void recoveryLeaseGuardShouldRenewAndStopWhenTokenIsLost() {
        TestContext context = createContext();
        BigInteger batchId = BigInteger.valueOf(84);
        String recoveryToken = "recovery-token";
        AtomicLong now = new AtomicLong(1_000L);
        Mockito.when(context.batchMapper.renewRecoveryPendingLease(
            Mockito.eq(batchId),
            Mockito.eq(recoveryToken),
            Mockito.any(Date.class),
            Mockito.any(Date.class)
        )).thenReturn(1, 0);
        BooleanSupplier guard = context.service.createRecoveryLeaseGuard(
            batchId,
            recoveryToken,
            121_000L,
            now::get
        );

        Assert.assertTrue(guard.getAsBoolean());
        Mockito.verify(context.batchMapper, Mockito.never())
            .renewRecoveryPendingLease(
                Mockito.any(),
                Mockito.anyString(),
                Mockito.any(Date.class),
                Mockito.any(Date.class)
            );

        now.set(61_000L);
        Assert.assertTrue(guard.getAsBoolean());
        now.set(121_000L);
        Assert.assertFalse(guard.getAsBoolean());

        Mockito.verify(context.batchMapper, Mockito.times(2))
            .renewRecoveryPendingLease(
                Mockito.eq(batchId),
                Mockito.eq(recoveryToken),
                Mockito.any(Date.class),
                Mockito.any(Date.class)
            );
    }

    /**
     * 验证恢复租约续期 SQL 同时校验批次状态、令牌和未过期租约。
     *
     * @throws Exception Mapper 方法不存在时抛出
     */
    @Test
    public void recoveryLeaseRenewalSqlShouldFenceCurrentOwner()
        throws Exception {
        Method method = DocumentImportBatchMapper.class.getMethod(
            "renewRecoveryPendingLease",
            BigInteger.class,
            String.class,
            Date.class,
            Date.class
        );
        Update update = method.getAnnotation(Update.class);
        String sql = String.join(" ", update.value());

        Assert.assertTrue(sql.contains("status='RUNNING'"));
        Assert.assertTrue(sql.contains("recovery_pending=1"));
        Assert.assertTrue(sql.contains("recovery_token=#{recoveryToken}"));
        Assert.assertTrue(sql.contains("recovery_lease_until > #{modified}"));

        Method finalizeMethod = DocumentImportBatchMapper.class.getMethod(
            "finalizeRecoveryPending",
            BigInteger.class,
            String.class,
            Date.class
        );
        String finalizeSql = String.join(" ",
            finalizeMethod.getAnnotation(Update.class).value());
        Assert.assertTrue(finalizeSql.contains(
            "status=CASE WHEN"));
        Assert.assertTrue(finalizeSql.contains("recovery_pending=0"));
        Assert.assertTrue(finalizeSql.contains(
            "recovery_token=#{recoveryToken}"));
        Assert.assertTrue(finalizeSql.contains(
            "recovery_lease_until > #{modified}"));
    }

    /**
     * 验证运行中的任务拒绝再次领取，调用方可继续查询原 taskId。
     */
    @Test
    public void retryShouldRejectTaskThatIsAlreadyRunning() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setStatus(DocumentImportBatchStatus.RUNNING.name());

        beginTransactionSynchronization();
        try {
            context.service.retryOwnedBatch(
                BigInteger.ONE,
                publicCaller(),
                Set.of()
            );
            Assert.fail("Expected running task rejection");
        } catch (BusinessException expected) {
            Assert.assertTrue(
                expected.getMessage().contains("当前任务状态不允许重试")
            );
        } finally {
            completeTransactionSynchronization(
                TransactionSynchronization.STATUS_ROLLED_BACK
            );
        }
        Mockito.verify(context.batchMapper, Mockito.never()).claimRetry(
            Mockito.any(),
            Mockito.anyString(),
            Mockito.any(),
            Mockito.anyInt(),
            Mockito.nullable(String.class),
            Mockito.any(Date.class)
        );
        Mockito.verify(context.lockHandle).release();
    }

    /**
     * 验证 Public API 不会在同一知识库已有运行批次时恢复旧批次。
     */
    @Test
    public void retryShouldRejectWhenAnotherAutoBatchIsRunning() {
        TestContext context = createContext();
        DocumentImportBatch batch = context.batchService.getOne(
            QueryWrapper.create()
        );
        batch.setImportMode(DocumentImportMode.AUTO.name());
        batch.setStatus(DocumentImportBatchStatus.INTERRUPTED.name());
        Mockito.when(context.batchService.count(Mockito.any(QueryWrapper.class)))
            .thenReturn(1L);

        beginTransactionSynchronization();
        try {
            context.service.retryOwnedBatch(
                batch.getId(),
                publicCaller(),
                Set.of()
            );
            Assert.fail("Expected active automatic batch rejection");
        } catch (BusinessException expected) {
            Assert.assertTrue(
                expected.getMessage().contains("当前知识库已有自动导入批次")
            );
        } finally {
            completeTransactionSynchronization(
                TransactionSynchronization.STATUS_ROLLED_BACK
            );
        }

        Mockito.verify(context.batchMapper, Mockito.never()).claimRetry(
            Mockito.any(),
            Mockito.anyString(),
            Mockito.any(),
            Mockito.anyInt(),
            Mockito.nullable(String.class),
            Mockito.any(Date.class)
        );
        Mockito.verify(context.lockHandle).release();
    }

    /**
     * 验证候选快照过期后若任务已恢复进展，不会继续取消或清理文件项。
     */
    @Test
    public void staleCancellationShouldStopWhenConditionalClaimMisses() {
        TestContext context = createContext();
        Mockito.when(context.batchMapper.claimStaleCancellation(
            Mockito.any(),
            Mockito.any(),
            Mockito.anyString(),
            Mockito.any(),
            Mockito.any(Date.class),
            Mockito.any(Date.class)
        )).thenReturn(0);

        beginTransactionSynchronization();
        boolean cancelled;
        try {
            cancelled = context.service.cancelStaleBatch(
                BigInteger.TWO,
                BigInteger.ONE,
                publicCaller(),
                new Date(System.currentTimeMillis() - 30L * 60L * 1000L)
            );
        } finally {
            completeTransactionSynchronization(
                TransactionSynchronization.STATUS_ROLLED_BACK
            );
        }

        Assert.assertFalse(cancelled);
        Mockito.verify(context.itemService, Mockito.never())
            .list(Mockito.any(QueryWrapper.class));
        Mockito.verifyNoInteractions(context.itemMapper);
        Mockito.verify(context.lockHandle).release();
    }

    private TestContext createContext() {
        DocumentImportBatchService batchService = Mockito.mock(DocumentImportBatchService.class);
        DocumentImportBatchItemService itemService = Mockito.mock(DocumentImportBatchItemService.class);
        DocumentImportBatchTracker batchTracker = Mockito.mock(DocumentImportBatchTracker.class);
        KnowledgeDocumentImportTaskAppService taskAppService =
            Mockito.mock(KnowledgeDocumentImportTaskAppService.class);
        DocumentImportBatchMapper batchMapper = Mockito.mock(DocumentImportBatchMapper.class);
        DocumentImportBatchItemMapper itemMapper =
            Mockito.mock(DocumentImportBatchItemMapper.class);
        DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
        RedisLockExecutor redisLockExecutor = Mockito.mock(RedisLockExecutor.class);
        DocumentImportBatchCircuitBreaker circuitBreaker =
            Mockito.mock(DocumentImportBatchCircuitBreaker.class);
        RedisLockExecutor.LockHandle lockHandle = Mockito.mock(RedisLockExecutor.LockHandle.class);
        Mockito.when(redisLockExecutor.tryAcquire(
            Mockito.anyString(), Mockito.any(), Mockito.any()
        )).thenReturn(lockHandle);

        DocumentImportBatch batch = new DocumentImportBatch();
        batch.setId(BigInteger.ONE);
        batch.setKnowledgeId(BigInteger.TWO);
        batch.setStatus(DocumentImportBatchStatus.READY.name());
        Mockito.when(batchTracker.requireBatch(BigInteger.ONE)).thenReturn(batch);
        Mockito.when(batchService.getOne(Mockito.any(QueryWrapper.class)))
            .thenReturn(batch);
        Mockito.when(batchMapper.selectOwnedForUpdate(
            Mockito.any(), Mockito.anyString(), Mockito.any()
        )).thenReturn(batch);
        Mockito.when(batchMapper.claimRecoveryPending(
            Mockito.any(),
            Mockito.anyString(),
            Mockito.any(Date.class),
            Mockito.any(Date.class)
        )).thenReturn(1);
        Mockito.when(batchMapper.selectClaimedRecovery(
            Mockito.any(), Mockito.anyString()
        )).thenReturn(batch);
        Mockito.when(taskAppService.retryBatchFailures(
            Mockito.any(), Mockito.anySet(), Mockito.any(BooleanSupplier.class)
        )).thenReturn(true);
        Mockito.when(batchMapper.finalizeRecoveryPending(
            Mockito.any(), Mockito.anyString(), Mockito.any(Date.class)
        )).thenReturn(1);

        DocumentImportBatchAppService service = new DocumentImportBatchAppService(
            batchService,
            itemService,
            batchTracker,
            new DocumentImportBulkProperties(),
            taskAppService,
            batchMapper,
            itemMapper,
            documentMapper,
            redisLockExecutor,
            circuitBreaker
        );
        return new TestContext(
            service, batchService, itemService, batchTracker,
            taskAppService, batchMapper, itemMapper, documentMapper,
            circuitBreaker, lockHandle
        );
    }

    /**
     * 创建 Public API 测试调用者。
     *
     * @return Public API 调用者
     */
    private ImportCallerContext publicCaller() {
        return new ImportCallerContext(
            ImportCallerType.PUBLIC_API,
            BigInteger.valueOf(77)
        );
    }

    private DocumentImportBatchDtos.StartRequest startRequest(String mode, String duplicatePolicy) {
        DocumentImportBatchDtos.StartRequest request = new DocumentImportBatchDtos.StartRequest();
        request.setKnowledgeId(BigInteger.TWO);
        request.setBatchId(BigInteger.ONE);
        request.setImportMode(mode);
        request.setDuplicatePolicy(duplicatePolicy);
        return request;
    }

    private DocumentImportBatchItem uploadedItem(BigInteger itemId, BigInteger batchId) {
        DocumentImportBatchItem item = new DocumentImportBatchItem();
        item.setId(itemId);
        item.setBatchId(batchId);
        item.setKnowledgeId(BigInteger.TWO);
        item.setClientFileKey("file-key");
        item.setFileName("demo.docx");
        item.setFilePath("/demo.docx");
        item.setStatus(DocumentImportBatchItemStatus.UPLOADED.name());
        return item;
    }

    private DocumentImportBatchItem pendingUploadItem(BigInteger itemId) {
        DocumentImportBatchItem item = new DocumentImportBatchItem();
        item.setId(itemId);
        item.setBatchId(BigInteger.ONE);
        item.setKnowledgeId(BigInteger.TWO);
        item.setClientFileKey("file-key-" + itemId);
        item.setFileName("demo.docx");
        item.setRelativePath("demo.docx");
        item.setFileSize(4L);
        item.setStage("UPLOAD");
        item.setStatus(DocumentImportBatchItemStatus.PENDING.name());
        item.setCleanupPending(false);
        return item;
    }

    private FileStorageWriteHandle writeHandle(DocumentImportBatchItem item) {
        return new FileStorageWriteHandle(
            "mock-storage",
            "",
            "/tmp/easyflow-test",
            "knowledge-import/" + item.getBatchId() + "/" + item.getId(),
            item.getId() + ".docx"
        );
    }

    private MultipartFile uploadFile(String fileName, long size) {
        MultipartFile file = Mockito.mock(MultipartFile.class);
        Mockito.when(file.getOriginalFilename()).thenReturn(fileName);
        Mockito.when(file.getSize()).thenReturn(size);
        Mockito.when(file.isEmpty()).thenReturn(false);
        return file;
    }

    private void setStorageService(DocumentImportBatchAppService service,
                                   FileStorageService storageService) {
        try {
            Field field = DocumentImportBatchAppService.class.getDeclaredField(
                "storageService"
            );
            field.setAccessible(true);
            field.set(service, storageService);
        } catch (ReflectiveOperationException error) {
            throw new AssertionError("Failed to inject storage service", error);
        }
    }

    private void beginTransactionSynchronization() {
        TransactionSynchronizationManager.initSynchronization();
    }

    private void completeTransactionSynchronization(int status) {
        List<TransactionSynchronization> synchronizations =
            TransactionSynchronizationManager.getSynchronizations();
        for (TransactionSynchronization synchronization : synchronizations) {
            if (status == TransactionSynchronization.STATUS_COMMITTED) {
                synchronization.afterCommit();
            }
            synchronization.afterCompletion(status);
        }
        TransactionSynchronizationManager.clearSynchronization();
    }

    /**
     * 测试依赖集合。
     */
    private record TestContext(
        DocumentImportBatchAppService service,
        DocumentImportBatchService batchService,
        DocumentImportBatchItemService itemService,
        DocumentImportBatchTracker batchTracker,
        KnowledgeDocumentImportTaskAppService taskAppService,
        DocumentImportBatchMapper batchMapper,
        DocumentImportBatchItemMapper itemMapper,
        DocumentMapper documentMapper,
        DocumentImportBatchCircuitBreaker circuitBreaker,
        RedisLockExecutor.LockHandle lockHandle
    ) {
    }
}
