package tech.easyflow.ai.documentimport.task;

import cn.dev33.satoken.stp.StpUtil;
import com.alibaba.fastjson2.JSON;
import com.mybatisflex.core.query.QueryWrapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
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.enums.DocumentProcessStatus;
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.cache.RedisLockExecutor.LockHandle;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.FileStorageWriteResult;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException;

import javax.annotation.Resource;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BooleanSupplier;
import java.util.function.LongSupplier;

/**
 * 知识库文档批量导入应用服务。
 *
 * @author Codex
 * @since 2026-07-31
 */
@Service
public class DocumentImportBatchAppService {

    private static final Logger LOG = LoggerFactory.getLogger(DocumentImportBatchAppService.class);
    private static final Set<String> SUPPORTED_EXTENSIONS =
        DocumentImportFormatPolicy.supportedExtensions();
    private static final Duration BATCH_MUTATION_LOCK_LEASE = Duration.ofMinutes(30);
    private static final Duration RECOVERY_DISPATCH_LEASE = Duration.ofMinutes(2);
    private static final Duration RECOVERY_LEASE_RENEW_INTERVAL =
        RECOVERY_DISPATCH_LEASE.dividedBy(2);

    private final DocumentImportBatchService batchService;
    private final DocumentImportBatchItemService itemService;
    private final DocumentImportBatchTracker batchTracker;
    private final DocumentImportBulkProperties properties;
    private final KnowledgeDocumentImportTaskAppService taskAppService;
    private final DocumentImportBatchMapper batchMapper;
    private final DocumentImportBatchItemMapper itemMapper;
    private final DocumentMapper documentMapper;
    private final RedisLockExecutor redisLockExecutor;
    private final DocumentImportBatchCircuitBreaker circuitBreaker;

    @Resource(name = "default")
    private FileStorageService storageService;

    /**
     * 创建批量导入应用服务。
     *
     * @param batchService 批次服务
     * @param itemService 批次项服务
     * @param batchTracker 批次状态跟踪器
     * @param properties 容量与并发配置
     * @param taskAppService 文档任务服务
     * @param batchMapper 批次 Mapper
     * @param itemMapper 批次项 Mapper
     * @param documentMapper 文档 Mapper
     * @param redisLockExecutor 分布式锁执行器
     * @param circuitBreaker 自动导入批次熔断器
     */
    public DocumentImportBatchAppService(DocumentImportBatchService batchService,
                                         DocumentImportBatchItemService itemService,
                                         DocumentImportBatchTracker batchTracker,
                                         DocumentImportBulkProperties properties,
                                         KnowledgeDocumentImportTaskAppService taskAppService,
                                         DocumentImportBatchMapper batchMapper,
                                         DocumentImportBatchItemMapper itemMapper,
                                         DocumentMapper documentMapper,
                                         RedisLockExecutor redisLockExecutor,
                                         DocumentImportBatchCircuitBreaker circuitBreaker) {
        this.batchService = batchService;
        this.itemService = itemService;
        this.batchTracker = batchTracker;
        this.properties = properties;
        this.taskAppService = taskAppService;
        this.batchMapper = batchMapper;
        this.itemMapper = itemMapper;
        this.documentMapper = documentMapper;
        this.redisLockExecutor = redisLockExecutor;
        this.circuitBreaker = circuitBreaker;
    }

    /**
     * 根据客户端文件清单创建上传批次。
     *
     * @param request 创建请求
     * @return 批次及文件项映射
     */
    @Transactional
    public DocumentImportBatchDtos.CreateResponse createBatch(DocumentImportBatchDtos.CreateRequest request) {
        BigInteger operatorId = resolveOperatorId();
        return createBatch(
            request,
            new DocumentImportBatchCreateContext(
                new ImportCallerContext(ImportCallerType.ADMIN, operatorId),
                null,
                null,
                "SKIP",
                null
            )
        );
    }

    /**
     * 根据客户端文件清单和调用者上下文创建上传批次。
     *
     * @param request 创建请求
     * @param createContext 建单上下文
     * @return 批次及文件项映射
     */
    @Transactional
    public DocumentImportBatchDtos.CreateResponse createBatch(
        DocumentImportBatchDtos.CreateRequest request,
        DocumentImportBatchCreateContext createContext) {
        if (request == null || request.getKnowledgeId() == null) {
            throw new BusinessException("知识库id不能为空");
        }
        if (createContext == null || createContext.getCaller() == null) {
            throw new BusinessException("导入调用者信息不完整");
        }
        List<DocumentImportBatchDtos.ManifestItem> files = request.getFiles();
        if (files == null || files.isEmpty()) {
            throw new BusinessException("请选择需要上传的文件");
        }
        if (files.size() > properties.getMaxFileCount()) {
            throw new BusinessException("单批次文件数不能超过" + properties.getMaxFileCount());
        }
        long totalBytes = validateManifest(files);
        Date now = new Date();
        BigInteger operatorId = resolveOperatorId();
        ImportCallerContext caller = createContext.getCaller();

        DocumentImportBatch batch = new DocumentImportBatch();
        batch.setKnowledgeId(request.getKnowledgeId());
        batch.setCallerType(caller.getCallerType().name());
        batch.setCallerId(caller.getCallerId());
        batch.setIdempotencyKeyHash(createContext.getIdempotencyKeyHash());
        batch.setRequestDigest(createContext.getRequestDigest());
        batch.setDuplicatePolicy(createContext.getDuplicatePolicy());
        batch.setRequestedStrategyJson(createContext.getRequestedStrategyJson());
        batch.setRetryGeneration(0);
        batch.setVersion(0);
        batch.setStatus(DocumentImportBatchStatus.UPLOADING.name());
        batch.setTotalCount(files.size());
        batch.setTotalBytes(totalBytes);
        batch.setCompletedCount(0);
        batch.setProcessingCount(0);
        batch.setFailedCount(0);
        batch.setPendingCount(files.size());
        batch.setUploadedCount(0);
        batch.setSkippedCount(0);
        batch.setCancelledCount(0);
        batch.setRetryableFailedCount(0);
        batch.setRecoveryPending(false);
        batch.setRecoveryFileKeysJson(null);
        batch.setRecoveryToken(null);
        batch.setRecoveryLeaseUntil(null);
        batch.setCreated(now);
        batch.setModified(now);
        batch.setCreatedBy(operatorId);
        batch.setModifiedBy(operatorId);
        batchService.save(batch);

        List<DocumentImportBatchItem> entities = new ArrayList<DocumentImportBatchItem>(files.size());
        for (DocumentImportBatchDtos.ManifestItem file : files) {
            DocumentImportBatchItem item = new DocumentImportBatchItem();
            item.setBatchId(batch.getId());
            item.setKnowledgeId(batch.getKnowledgeId());
            item.setClientFileKey(file.getClientFileKey());
            item.setFileName(file.getFileName());
            item.setRelativePath(normalizeRelativePath(file.getRelativePath(), file.getFileName()));
            item.setFileSize(file.getFileSize());
            item.setStage(DocumentImportBatchItemStage.UPLOAD.name());
            item.setStatus(DocumentImportBatchItemStatus.PENDING.name());
            item.setCleanupPending(false);
            item.setRetryable(false);
            item.setAttemptCount(0);
            item.setCreated(now);
            item.setModified(now);
            item.setCreatedBy(operatorId);
            item.setModifiedBy(operatorId);
            entities.add(item);
        }
        itemService.saveBatch(entities);

        DocumentImportBatchDtos.CreateResponse response = new DocumentImportBatchDtos.CreateResponse();
        response.setBatchId(batch.getId());
        response.setUploadConcurrency(Math.max(1, properties.getUploadConcurrency()));
        response.setItems(entities.stream().map(this::toItemResponse).toList());
        return response;
    }

    /**
     * 上传单个批次文件。
     *
     * @param knowledgeId 知识库 ID
     * @param batchId 批次 ID
     * @param itemId 文件项 ID
     * @param file 上传文件
     * @return 文件项状态
     */
    public DocumentImportBatchDtos.ItemResponse uploadItem(BigInteger knowledgeId,
                                                           BigInteger batchId,
                                                           BigInteger itemId,
                                                           MultipartFile file) {
        return uploadItem(knowledgeId, batchId, itemId, file, null);
    }

    /**
     * 上传单个批次文件并校验调用者归属。
     *
     * @param knowledgeId 知识库 ID
     * @param batchId 批次 ID
     * @param itemId 文件项 ID
     * @param file 上传文件
     * @param caller 调用者上下文；为空时沿用管理端兼容校验
     * @return 文件项状态
     */
    public DocumentImportBatchDtos.ItemResponse uploadItem(BigInteger knowledgeId,
                                                           BigInteger batchId,
                                                           BigInteger itemId,
                                                           MultipartFile file,
                                                           ImportCallerContext caller) {
        DocumentImportBatch batch = requireOwnedBatch(knowledgeId, batchId, caller);
        if (!DocumentImportBatchStatus.UPLOADING.name().equals(batch.getStatus())
            && !DocumentImportBatchStatus.READY.name().equals(batch.getStatus())) {
            throw new BusinessException("当前批次不允许继续上传");
        }
        DocumentImportBatchItem item = batchTracker.requireItem(itemId);
        if (!batchId.equals(item.getBatchId())) {
            throw new BusinessException("导入文件不属于当前批次");
        }
        validateUploadedFile(item, file);
        if (DocumentImportBatchItemStatus.UPLOADED.name().equals(item.getStatus())) {
            return toItemResponse(item);
        }
        if (itemMapper.claimUpload(batchId, itemId, knowledgeId, new Date()) <= 0) {
            throw new BusinessException("文件正在上传或状态已变化，请稍后重试");
        }

        FileStorageWriteHandle writeHandle;
        try {
            writeHandle = storageService.prepareRecoverableWrite(
                "knowledge-import/" + batchId + "/" + itemId,
                buildRecoverableFileName(item)
            );
        } catch (RuntimeException error) {
            abortUploadBeforeWrite(itemId, null, error);
            throw error;
        }
        String storageLocator = writeHandle.encodeLocator();
        try {
            if (itemMapper.registerUploadWriteIntent(
                itemId,
                storageLocator,
                new Date()
            ) <= 0) {
                throw new BusinessException("文件上传状态已变化，请重新提交");
            }
        } catch (RuntimeException error) {
            abortUploadBeforeWrite(itemId, storageLocator, error);
            throw error;
        }

        FileStorageWriteResult writeResult;
        try {
            writeResult = storageService.saveRecoverable(file, writeHandle);
            if (!storageLocator.equals(writeResult.getLocator())) {
                throw new IllegalStateException("文件存储恢复定位符不一致");
            }
        } catch (RuntimeException error) {
            markStoredObjectForCleanup(itemId, storageLocator, null, error);
            throw error;
        }
        try {
            if (!batchTracker.completeUpload(
                itemId,
                writeResult.getUrl(),
                storageLocator
            )) {
                throw new BusinessException("上传批次已取消，请重新选择文件");
            }
        } catch (RuntimeException error) {
            DocumentImportBatchItem committed = resolveCommittedUpload(
                itemId,
                writeResult.getUrl(),
                storageLocator
            );
            if (committed != null) {
                return toItemResponse(committed);
            }
            markStoredObjectForCleanup(
                itemId,
                storageLocator,
                writeResult.getUrl(),
                error
            );
            throw error;
        }
        return toItemResponse(batchTracker.requireItem(itemId));
    }

    /**
     * 启动手动或自动批量导入。
     *
     * @param request 启动请求
     * @return 启动后的批次状态
     */
    @Transactional
    public DocumentImportBatchDtos.StatusResponse startBatch(DocumentImportBatchDtos.StartRequest request) {
        return startBatch(request, null);
    }

    /**
     * 启动批次并校验调用者归属。
     *
     * @param request 启动请求
     * @param caller 调用者上下文；为空时沿用管理端兼容校验
     * @return 启动后的批次状态
     */
    @Transactional
    public DocumentImportBatchDtos.StatusResponse startBatch(
        DocumentImportBatchDtos.StartRequest request,
        ImportCallerContext caller) {
        if (request == null || request.getKnowledgeId() == null || request.getBatchId() == null) {
            throw new BusinessException("批次信息不完整");
        }
        DocumentImportBatch batch =
            requireOwnedBatch(request.getKnowledgeId(), request.getBatchId(), caller);
        DocumentImportMode mode = parseMode(request.getImportMode());
        DuplicatePolicy duplicatePolicy = parseDuplicatePolicy(
            StringUtil.hasText(request.getDuplicatePolicy())
                ? request.getDuplicatePolicy()
                : batch.getDuplicatePolicy()
        );
        acquireBatchMutationLock(batch.getKnowledgeId());
        if (!DocumentImportBatchStatus.READY.name().equals(batch.getStatus())) {
            throw new BusinessException("文件尚未全部上传完成");
        }
        if (mode == DocumentImportMode.AUTO) {
            assertNoOtherActiveAutoBatch(batch);
        }
        List<DocumentImportBatchItem> items = itemService.list(
            QueryWrapper.create().eq(DocumentImportBatchItem::getBatchId, batch.getId())
        );
        if (items.stream().anyMatch(item -> !DocumentImportBatchItemStatus.UPLOADED.name().equals(item.getStatus()))) {
            throw new BusinessException("文件尚未全部上传完成");
        }

        markHistoricalDuplicates(items, batch.getId(), duplicatePolicy);
        Date now = new Date();
        DocumentImportBatch update = new DocumentImportBatch();
        update.setImportMode(mode.name());
        update.setStatus(DocumentImportBatchStatus.RUNNING.name());
        update.setStartedAt(now);
        update.setFinishedAt(null);
        update.setModified(now);
        int claimed = batchMapper.updateByQuery(update,
            QueryWrapper.create()
                .eq(DocumentImportBatch::getId, batch.getId())
                .eq(DocumentImportBatch::getStatus, DocumentImportBatchStatus.READY.name())
                .isNull(DocumentImportBatch::getImportMode));
        if (claimed <= 0) {
            throw new BusinessException("导入批次已启动，请勿重复提交");
        }
        batch.setImportMode(mode.name());
        batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
        batch.setStartedAt(now);
        batch.setFinishedAt(null);
        taskAppService.createBatchImportTasks(batch, items);
        return batchTracker.refreshBatch(batch.getId());
    }

    /**
     * 查询指定批次状态。
     *
     * @param knowledgeId 知识库 ID
     * @param batchId 批次 ID
     * @return 批次状态
     */
    public DocumentImportBatchDtos.StatusResponse getBatchStatus(BigInteger knowledgeId, BigInteger batchId) {
        DocumentImportBatch batch = requireOwnedBatch(knowledgeId, batchId);
        return batchTracker.toStatusResponse(batch);
    }

    /**
     * 查询知识库最近一个自动导入批次。
     *
     * @param knowledgeId 知识库 ID
     * @return 最近批次；不存在时返回 null
     */
    public DocumentImportBatchDtos.StatusResponse getLatestAutoBatch(BigInteger knowledgeId) {
        DocumentImportBatch batch = batchService.getOne(
            QueryWrapper.create()
                .eq(DocumentImportBatch::getKnowledgeId, knowledgeId)
                .eq(DocumentImportBatch::getImportMode, DocumentImportMode.AUTO.name())
                .orderBy(DocumentImportBatch::getCreated, false)
                .limit(1)
        );
        if (batch == null) {
            return null;
        }
        DocumentImportBatchDtos.StatusResponse response =
            batchTracker.toStatusResponse(batch);
        response.setActualCompleted(isAutoBatchActuallyCompleted(batch));
        return response;
    }

    /**
     * 根据批次项及其关联文档的真实状态判断失败批次是否已经完成。
     *
     * <p>正常完成批次直接返回成功；仅对部分失败或中断批次执行补充查询，
     * 避免运行中轮询产生额外数据库压力。批次项数量不完整、文档缺失、
     * 跨知识库或文档仍未完成时均保持失败提示。</p>
     *
     * @param batch 自动导入批次
     * @return 批次关联任务是否已经全部完成
     */
    private boolean isAutoBatchActuallyCompleted(DocumentImportBatch batch) {
        if (DocumentImportBatchStatus.COMPLETED.name().equals(batch.getStatus())) {
            return true;
        }
        if (!DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name().equals(batch.getStatus())
            && !DocumentImportBatchStatus.INTERRUPTED.name().equals(batch.getStatus())) {
            return false;
        }
        List<DocumentImportBatchItem> items = itemService.list(
            QueryWrapper.create()
                .eq(DocumentImportBatchItem::getBatchId, batch.getId())
                .orderBy(DocumentImportBatchItem::getId, true)
        );
        int totalCount = valueOrZero(batch.getTotalCount());
        if (items == null || totalCount <= 0 || items.size() != totalCount) {
            return false;
        }

        Set<BigInteger> unresolvedDocumentIds = new LinkedHashSet<BigInteger>();
        for (DocumentImportBatchItem item : items) {
            String itemStatus = item.getStatus();
            if (DocumentImportBatchItemStatus.COMPLETED.name().equals(itemStatus)
                || DocumentImportBatchItemStatus.SKIPPED.name().equals(itemStatus)
                || DocumentImportBatchItemStatus.CANCELLED.name().equals(itemStatus)) {
                continue;
            }
            if (item.getDocumentId() == null
                || !batch.getKnowledgeId().equals(item.getKnowledgeId())) {
                return false;
            }
            unresolvedDocumentIds.add(item.getDocumentId());
        }
        if (unresolvedDocumentIds.isEmpty()) {
            return true;
        }

        List<tech.easyflow.ai.entity.Document> completedDocuments =
            documentMapper.selectListByQuery(
                QueryWrapper.create()
                    .select(tech.easyflow.ai.entity.Document::getId)
                    .eq(tech.easyflow.ai.entity.Document::getCollectionId,
                        batch.getKnowledgeId())
                    .eq(tech.easyflow.ai.entity.Document::getProcessStatus,
                        DocumentProcessStatus.COMPLETED.name())
                    .in(tech.easyflow.ai.entity.Document::getId,
                        unresolvedDocumentIds)
            );
        if (completedDocuments == null) {
            return false;
        }
        Set<BigInteger> completedDocumentIds = completedDocuments.stream()
            .map(tech.easyflow.ai.entity.Document::getId)
            .collect(java.util.stream.Collectors.toSet());
        return completedDocumentIds.containsAll(unresolvedDocumentIds);
    }

    /**
     * 继续一个存在失败项或中断项的自动导入批次。
     *
     * @param knowledgeId 知识库 ID
     * @param batchId 批次 ID
     * @return 继续后的批次状态
     */
    @Transactional
    public DocumentImportBatchDtos.StatusResponse continueBatch(BigInteger knowledgeId, BigInteger batchId) {
        DocumentImportBatch batch = requireOwnedBatch(knowledgeId, batchId);
        acquireBatchMutationLock(knowledgeId);
        if (!DocumentImportMode.AUTO.name().equals(batch.getImportMode())) {
            throw new BusinessException("手动导入批次无需批量继续");
        }
        if (!DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name().equals(batch.getStatus())
            && !DocumentImportBatchStatus.INTERRUPTED.name().equals(batch.getStatus())) {
            throw new BusinessException("当前批次无需继续");
        }
        assertNoOtherActiveAutoBatch(batch);
        List<DocumentImportBatchItem> failedItems =
            listFailedItems(batchId, Set.of());
        if (failedItems.isEmpty()) {
            throw new BusinessException("当前批次没有失败项");
        }
        Date now = new Date();
        int claimed = batchMapper.claimContinue(batchId, now);
        if (claimed <= 0) {
            throw new BusinessException("批次状态已变化，请刷新后重试");
        }
        batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
        batch.setFinishedAt(null);
        batch.setInterruptCode(null);
        batch.setInterruptMessage(null);
        batch.setInterruptedAt(null);
        batch.setRecoveryPending(true);
        batch.setRecoveryFileKeysJson(null);
        batch.setRecoveryToken(null);
        batch.setRecoveryLeaseUntil(null);
        batch.setModified(now);
        runAfterCommit(() -> resumeBatchFailures(batchId));
        return batchTracker.toStatusResponse(batch);
    }

    /**
     * 取消尚未启动的上传批次，并清理未被文档引用的上传对象。
     *
     * @param knowledgeId 知识库 ID
     * @param batchId 批次 ID
     */
    @Transactional
    public void cancelBatch(BigInteger knowledgeId, BigInteger batchId) {
        cancelBatch(knowledgeId, batchId, null);
    }

    /**
     * 取消尚未启动的上传批次并校验调用者归属。
     *
     * @param knowledgeId 知识库 ID
     * @param batchId 批次 ID
     * @param caller 调用者上下文；为空时沿用管理端兼容校验
     */
    @Transactional
    public void cancelBatch(BigInteger knowledgeId,
                            BigInteger batchId,
                            ImportCallerContext caller) {
        DocumentImportBatch batch = requireOwnedBatch(knowledgeId, batchId, caller);
        acquireBatchMutationLock(knowledgeId);
        if (DocumentImportBatchStatus.CANCELLED.name().equals(batch.getStatus())) {
            return;
        }
        if (!DocumentImportBatchStatus.UPLOADING.name().equals(batch.getStatus())
            && !DocumentImportBatchStatus.READY.name().equals(batch.getStatus())) {
            throw new BusinessException("已启动的导入批次不能取消");
        }
        Date now = new Date();
        DocumentImportBatch update = new DocumentImportBatch();
        update.setStatus(DocumentImportBatchStatus.CANCELLED.name());
        update.setFinishedAt(now);
        update.setModified(now);
        int cancelled = batchMapper.updateByQuery(update,
            QueryWrapper.create()
                .eq(DocumentImportBatch::getId, batchId)
                .in(DocumentImportBatch::getStatus, List.of(
                    DocumentImportBatchStatus.UPLOADING.name(),
                    DocumentImportBatchStatus.READY.name()
                )));
        if (cancelled <= 0) {
            throw new BusinessException("批次状态已变化，请刷新后重试");
        }
        cancelPendingItemsAndScheduleCleanup(batchId);
    }

    /**
     * 原子取消超过截止时间且仍无进展的未完成批次。
     *
     * @param knowledgeId 知识库 ID
     * @param batchId 批次 ID
     * @param caller 调用者上下文
     * @param incompleteCutoff 最后进展截止时间
     * @return 是否成功领取取消权
     */
    @Transactional
    public boolean cancelStaleBatch(BigInteger knowledgeId,
                                    BigInteger batchId,
                                    ImportCallerContext caller,
                                    Date incompleteCutoff) {
        if (knowledgeId == null || batchId == null || caller == null
            || caller.getCallerType() == null || caller.getCallerId() == null
            || incompleteCutoff == null) {
            throw new BusinessException("超时批次取消信息不完整");
        }
        acquireBatchMutationLock(knowledgeId);
        Date now = new Date();
        int cancelled = batchMapper.claimStaleCancellation(
            batchId,
            knowledgeId,
            caller.getCallerType().name(),
            caller.getCallerId(),
            incompleteCutoff,
            now
        );
        if (cancelled <= 0) {
            return false;
        }
        cancelPendingItemsAndScheduleCleanup(batchId);
        return true;
    }

    /**
     * 重试删除未完成绑定的上传对象。
     *
     * <p>{@code cleanupPending} 是可索引的持久化清理状态；删除成功后通过
     * 定位符与路径条件更新清除，失败项由下一轮调度继续处理。</p>
     *
     * @param limit 单次最大处理数量
     * @return 清理成功数量
     */
    public int cleanupCancelledStoredObjects(int limit) {
        int boundedLimit = Math.max(1, Math.min(limit, 500));
        List<DocumentImportBatchItem> items = itemService.list(
            QueryWrapper.create()
                .eq(
                    DocumentImportBatchItem::getCleanupPending,
                    true
                )
                .orderBy(DocumentImportBatchItem::getModified, true)
                .orderBy(DocumentImportBatchItem::getId, true)
                .limit(boundedLimit)
        );
        int cleaned = 0;
        for (DocumentImportBatchItem item : items) {
            if (cleanupStoredObject(item)) {
                cleaned++;
            }
        }
        return cleaned;
    }

    /**
     * 识别事务提交结果未知但数据库实际已完成的上传。
     *
     * @param itemId 文件项 ID
     * @param storedPath 已写入的对象路径
     * @param storageLocator 可恢复存储定位符
     * @return 已完成上传的最新文件项；未确认完成时返回 null
     */
    private DocumentImportBatchItem resolveCommittedUpload(BigInteger itemId,
                                                           String storedPath,
                                                           String storageLocator) {
        try {
            DocumentImportBatchItem current = batchTracker.requireItem(itemId);
            if (DocumentImportBatchItemStatus.UPLOADED.name().equals(current.getStatus())
                && storedPath.equals(current.getFilePath())
                && storageLocator.equals(current.getStorageLocator())
                && !Boolean.TRUE.equals(current.getCleanupPending())) {
                return current;
            }
        } catch (RuntimeException lookupError) {
            LOG.warn(
                "上传提交结果未知且暂时无法读取文件项，保留恢复定位符等待回收: itemId={}",
                itemId,
                lookupError
            );
        }
        return null;
    }

    /**
     * 在物理写入前原子撤销上传领取，并处理写意图登记提交结果未知。
     *
     * @param itemId 文件项 ID
     * @param storageLocator 本次预期定位符，可为空
     * @param originalError 上传准备或登记异常
     */
    private void abortUploadBeforeWrite(BigInteger itemId,
                                        String storageLocator,
                                        RuntimeException originalError) {
        try {
            if (itemMapper.abortUploadBeforeWrite(
                itemId,
                storageLocator,
                truncateUploadError(originalError.getMessage()),
                new Date()
            ) > 0) {
                return;
            }
            DocumentImportBatchItem current = batchTracker.requireItem(itemId);
            boolean recovered = DocumentImportBatchItemStatus.PENDING.name()
                .equals(current.getStatus())
                && !StringUtil.hasText(current.getStorageLocator())
                && !Boolean.TRUE.equals(current.getCleanupPending());
            boolean cancelled = DocumentImportBatchItemStatus.CANCELLED.name()
                .equals(current.getStatus());
            if (!recovered && !cancelled) {
                LOG.error(
                    "物理写入前撤销上传状态未完成: itemId={}, status={}, cleanupPending={}",
                    itemId,
                    current.getStatus(),
                    current.getCleanupPending()
                );
            }
        } catch (RuntimeException recoveryError) {
            LOG.error(
                "物理写入前撤销上传状态失败: itemId={}",
                itemId,
                recoveryError
            );
        }
    }

    /**
     * 将预先登记的写意图标记为清理待办并立即尝试回收。
     *
     * <p>清理期间保留 {@code UPLOADING} 状态；对象回收成功后恢复为
     * {@code PENDING}，使瞬时存储或绑定异常可以重新上传。若批次并发
     * 取消，取消事务会将文件项迁移为 {@code CANCELLED}，清理逻辑会
     * 根据最新状态保留取消结果。</p>
     *
     * @param itemId 文件项 ID
     * @param storageLocator 可恢复存储定位符
     * @param storedPath 已写入的兼容对象路径，可为空
     * @param originalError 导致对象未绑定的原始异常
     */
    private void markStoredObjectForCleanup(BigInteger itemId,
                                            String storageLocator,
                                            String storedPath,
                                            RuntimeException originalError) {
        int marked;
        try {
            marked = itemMapper.markUploadCleanupPending(
                itemId,
                storageLocator,
                new Date()
            );
        } catch (RuntimeException markerError) {
            LOG.error(
                "标记上传对象等待清理失败，保留预写入定位符供超时回收: itemId={}",
                itemId,
                markerError
            );
            return;
        }
        if (marked <= 0) {
            LOG.warn(
                "上传对象未领取清理权，保留预写入定位符供状态恢复: itemId={}, path={}",
                itemId,
                storedPath,
                originalError
            );
            return;
        }
        cleanupStoredObject(itemId);
    }

    /**
     * 将未启动文件项迁移为取消，并在事务提交后清理上传对象。
     *
     * @param batchId 批次 ID
     */
    private void cancelPendingItemsAndScheduleCleanup(BigInteger batchId) {
        List<DocumentImportBatchItem> items = itemService.list(
            QueryWrapper.create()
                .eq(DocumentImportBatchItem::getBatchId, batchId)
                .in(DocumentImportBatchItem::getStatus, List.of(
                    DocumentImportBatchItemStatus.PENDING.name(),
                    DocumentImportBatchItemStatus.UPLOADING.name(),
                    DocumentImportBatchItemStatus.UPLOADED.name()
                ))
        );
        List<BigInteger> cleanupItemIds = new ArrayList<>();
        for (DocumentImportBatchItem item : items) {
            batchTracker.transitionItem(item.getId(),
                DocumentImportBatchItemStage.UPLOAD,
                DocumentImportBatchItemStatus.CANCELLED,
                null,
                false,
                0);
            if (itemMapper.markCancelledCleanupPending(
                item.getId(),
                new Date()
            ) > 0) {
                cleanupItemIds.add(item.getId());
            }
        }
        runAfterCommit(() -> cleanupItemIds.forEach(this::cleanupStoredObject));
    }

    /**
     * 原子领取并调度 Public API 批次重试。
     *
     * @param taskId 批次任务 ID
     * @param caller 调用者上下文
     * @param fileKeys 指定文件键；为空时重试全部失败项
     * @return 稳定的重试响应快照
     */
    @Transactional
    public DocumentImportBatchRetryResult retryOwnedBatch(
        BigInteger taskId,
        ImportCallerContext caller,
        Set<String> fileKeys) {
        if (taskId == null || caller == null) {
            throw new BusinessException("重试任务信息不完整");
        }
        DocumentImportBatch initial = requireBatchForCaller(taskId, caller);
        acquireBatchMutationLock(initial.getKnowledgeId());
        DocumentImportBatch batch = batchMapper.selectOwnedForUpdate(
            taskId,
            caller.getCallerType().name(),
            caller.getCallerId()
        );
        if (batch == null) {
            throw new BusinessException(404, 404, "导入任务不存在");
        }
        if (!DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name().equals(batch.getStatus())
            && !DocumentImportBatchStatus.INTERRUPTED.name().equals(batch.getStatus())) {
            throw new BusinessException(409, 40904, "当前任务状态不允许重试");
        }
        assertNoOtherActiveAutoBatch(batch);
        Set<String> requestedFileKeys = fileKeys == null ? Set.of() : fileKeys;
        List<DocumentImportBatchItem> retryItems =
            listFailedItems(batch.getId(), requestedFileKeys);
        if (retryItems.isEmpty()) {
            throw new BusinessException(409, 40905, "当前任务没有失败文件");
        }
        Date now = new Date();
        int expectedGeneration = valueOrZero(batch.getRetryGeneration());
        int claimedGeneration = expectedGeneration + 1;
        Set<String> selectedKeys = retryItems.stream()
            .map(DocumentImportBatchItem::getClientFileKey)
            .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
        String recoveryFileKeysJson = requestedFileKeys.isEmpty()
            ? null
            : JSON.toJSONString(selectedKeys);
        int claimed = batchMapper.claimRetry(
            batch.getId(),
            caller.getCallerType().name(),
            caller.getCallerId(),
            expectedGeneration,
            recoveryFileKeysJson,
            now
        );
        if (claimed <= 0) {
            throw new BusinessException(409, 40903, "任务已被其他请求重试，请刷新任务状态");
        }
        runAfterCommit(() -> resumeBatchFailures(batch.getId()));
        return new DocumentImportBatchRetryResult(
            batch.getId(),
            DocumentImportBatchStatus.RUNNING.name(),
            claimedGeneration,
            retryItems.size()
        );
    }

    /**
     * 查询 Public API 调用者拥有的批次。
     *
     * @param taskId 批次任务 ID
     * @param caller 调用者上下文
     * @return 导入批次
     */
    public DocumentImportBatch requireBatchForCaller(BigInteger taskId,
                                                     ImportCallerContext caller) {
        if (taskId == null || caller == null) {
            throw new BusinessException(404, 404, "导入任务不存在");
        }
        DocumentImportBatch batch = batchService.getOne(
            QueryWrapper.create()
                .eq(DocumentImportBatch::getId, taskId)
                .eq(DocumentImportBatch::getCallerType, caller.getCallerType().name())
                .eq(DocumentImportBatch::getCallerId, caller.getCallerId())
        );
        if (batch == null) {
            throw new BusinessException(404, 404, "导入任务不存在");
        }
        return batch;
    }

    private long validateManifest(List<DocumentImportBatchDtos.ManifestItem> files) {
        long totalBytes = 0L;
        Set<String> keys = new HashSet<String>();
        for (DocumentImportBatchDtos.ManifestItem file : files) {
            if (file == null || !StringUtil.hasText(file.getClientFileKey())
                || !StringUtil.hasText(file.getFileName()) || file.getFileSize() == null) {
                throw new BusinessException("文件清单不完整");
            }
            if (file.getClientFileKey().length() > 64 || !keys.add(file.getClientFileKey())) {
                throw new BusinessException("文件清单包含重复或非法文件");
            }
            if (file.getFileName().length() > 512) {
                throw new BusinessException("文件名过长");
            }
            if (file.getFileSize() <= 0 || file.getFileSize() > properties.getMaxFileSize().toBytes()) {
                throw new BusinessException("单个文件不能超过100MB");
            }
            assertSupportedExtension(file.getFileName());
            normalizeRelativePath(file.getRelativePath(), file.getFileName());
            totalBytes = Math.addExact(totalBytes, file.getFileSize());
            if (totalBytes > properties.getMaxTotalSize().toBytes()) {
                throw new BusinessException("文件夹总大小不能超过1GB");
            }
        }
        return totalBytes;
    }

    private void validateUploadedFile(DocumentImportBatchItem item, MultipartFile file) {
        if (file == null || file.isEmpty()) {
            throw new BusinessException("上传文件不能为空");
        }
        if (!item.getFileName().equals(file.getOriginalFilename()) || item.getFileSize() != file.getSize()) {
            throw new BusinessException("上传文件与文件清单不一致");
        }
        if (file.getSize() > properties.getMaxFileSize().toBytes()) {
            throw new BusinessException("单个文件不能超过100MB");
        }
    }

    private void markHistoricalDuplicates(List<DocumentImportBatchItem> items,
                                          BigInteger currentBatchId,
                                          DuplicatePolicy duplicatePolicy) {
        if (duplicatePolicy == DuplicatePolicy.REIMPORT) {
            return;
        }
        List<String> keys = items.stream().map(DocumentImportBatchItem::getClientFileKey).toList();
        if (keys.isEmpty()) {
            return;
        }
        List<DocumentImportBatchItem> historical = itemService.list(
            QueryWrapper.create()
                .eq(DocumentImportBatchItem::getKnowledgeId, items.get(0).getKnowledgeId())
                .ne(DocumentImportBatchItem::getBatchId, currentBatchId)
                .in(DocumentImportBatchItem::getClientFileKey, keys)
                .eq(DocumentImportBatchItem::getStatus, DocumentImportBatchItemStatus.COMPLETED.name())
                .isNotNull(DocumentImportBatchItem::getDocumentId)
                .orderBy(DocumentImportBatchItem::getCreated, false)
        );
        Set<BigInteger> historicalDocumentIds = historical.stream()
            .map(DocumentImportBatchItem::getDocumentId)
            .collect(java.util.stream.Collectors.toSet());
        Set<BigInteger> existingDocumentIds = historicalDocumentIds.isEmpty()
            ? Set.of()
            : documentMapper.selectListByQuery(
                QueryWrapper.create()
                    .select(tech.easyflow.ai.entity.Document::getId)
                    .eq(tech.easyflow.ai.entity.Document::getCollectionId,
                        items.get(0).getKnowledgeId())
                    .in(tech.easyflow.ai.entity.Document::getId, historicalDocumentIds)
            ).stream()
                .map(tech.easyflow.ai.entity.Document::getId)
                .collect(java.util.stream.Collectors.toSet());
        Map<String, BigInteger> existingDocuments = new LinkedHashMap<String, BigInteger>();
        for (DocumentImportBatchItem existing : historical) {
            if (existingDocumentIds.contains(existing.getDocumentId())) {
                existingDocuments.putIfAbsent(existing.getClientFileKey(), existing.getDocumentId());
            }
        }
        for (DocumentImportBatchItem item : items) {
            BigInteger existingDocumentId = existingDocuments.get(item.getClientFileKey());
            if (existingDocumentId == null) {
                continue;
            }
            if (duplicatePolicy == DuplicatePolicy.SKIP) {
                item.setStage(DocumentImportBatchItemStage.DONE.name());
                item.setStatus(DocumentImportBatchItemStatus.SKIPPED.name());
                item.setErrorSummary("重复文件已跳过");
                item.setModified(new Date());
                batchTracker.transitionItem(item.getId(),
                    DocumentImportBatchItemStage.DONE,
                    DocumentImportBatchItemStatus.SKIPPED,
                    "重复文件已跳过",
                    false,
                    0);
            } else {
                batchTracker.markReplacement(item.getId(), existingDocumentId);
                item.setReplacedDocumentId(existingDocumentId);
            }
        }
    }

    private void assertNoOtherActiveAutoBatch(DocumentImportBatch current) {
        long activeCount = batchService.count(
            QueryWrapper.create()
                .eq(DocumentImportBatch::getKnowledgeId, current.getKnowledgeId())
                .eq(DocumentImportBatch::getImportMode, DocumentImportMode.AUTO.name())
                .ne(DocumentImportBatch::getId, current.getId())
                .in(DocumentImportBatch::getStatus, List.of(
                    DocumentImportBatchStatus.RUNNING.name()
                ))
        );
        if (activeCount > 0) {
            throw new BusinessException("当前知识库已有自动导入批次，请完成后再试");
        }
    }

    private DocumentImportBatch requireOwnedBatch(BigInteger knowledgeId, BigInteger batchId) {
        return requireOwnedBatch(knowledgeId, batchId, null);
    }

    /**
     * 查询批次并校验知识库与调用者归属。
     *
     * <p>Public API 归属不匹配统一返回任务不存在，避免泄露其他令牌的任务信息。</p>
     *
     * @param knowledgeId 知识库 ID
     * @param batchId 批次 ID
     * @param caller 调用者上下文；为空时仅校验知识库
     * @return 导入批次
     */
    public DocumentImportBatch requireOwnedBatch(BigInteger knowledgeId,
                                                 BigInteger batchId,
                                                 ImportCallerContext caller) {
        if (knowledgeId == null || batchId == null) {
            throw new BusinessException("批次信息不完整");
        }
        QueryWrapper wrapper = QueryWrapper.create()
            .eq(DocumentImportBatch::getId, batchId)
            .eq(DocumentImportBatch::getKnowledgeId, knowledgeId);
        if (caller != null) {
            wrapper.eq(DocumentImportBatch::getCallerType, caller.getCallerType().name())
                .eq(DocumentImportBatch::getCallerId, caller.getCallerId());
        }
        DocumentImportBatch batch = batchService.getOne(wrapper);
        if (batch == null && caller != null) {
            throw new BusinessException(404, 404, "导入任务不存在");
        }
        if (batch == null) {
            throw new BusinessException("导入批次不属于当前知识库");
        }
        return batch;
    }

    private DocumentImportMode parseMode(String value) {
        try {
            return DocumentImportMode.valueOf(String.valueOf(value).toUpperCase(Locale.ROOT));
        } catch (Exception error) {
            throw new BusinessException("导入模式无效");
        }
    }

    /**
     * 解析重复文件处理策略，缺省跳过历史重复项。
     *
     * @param value 请求值
     * @return 重复文件策略
     */
    private DuplicatePolicy parseDuplicatePolicy(String value) {
        if (!StringUtil.hasText(value)) {
            return DuplicatePolicy.SKIP;
        }
        try {
            return DuplicatePolicy.valueOf(value.toUpperCase(Locale.ROOT));
        } catch (Exception error) {
            throw new BusinessException("重复文件处理策略无效");
        }
    }

    /**
     * 获取知识库批次变更锁，并在当前事务完成后释放。
     *
     * @param knowledgeId 知识库 ID
     */
    private void acquireBatchMutationLock(BigInteger knowledgeId) {
        LockHandle handle = redisLockExecutor.tryAcquire(
            "easyflow:lock:document-import:batch:" + knowledgeId,
            Duration.ZERO,
            BATCH_MUTATION_LOCK_LEASE
        );
        if (handle == null) {
            throw new BusinessException("导入批次正在变更，请稍后重试");
        }
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            handle.release();
            throw new IllegalStateException("批次变更必须在事务中执行");
        }
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCompletion(int status) {
                handle.release();
            }
        });
    }

    /**
     * 事务提交后执行文件清理。
     *
     * @param action 清理动作
     */
    private void runAfterCommit(Runnable action) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            action.run();
            return;
        }
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                action.run();
            }
        });
    }

    /**
     * 读取最新文件项并清理其存储待办。
     *
     * @param itemId 批次项 ID
     * @return 是否删除成功并完成标记清理
     */
    private boolean cleanupStoredObject(BigInteger itemId) {
        try {
            return cleanupStoredObject(batchTracker.requireItem(itemId));
        } catch (RuntimeException lookupError) {
            LOG.error(
                "读取上传对象清理待办失败: itemId={}",
                itemId,
                lookupError
            );
            return false;
        }
    }

    /**
     * 按恢复定位符优先、兼容路径兜底的顺序幂等删除上传对象。
     *
     * @param item 存储清理待办
     * @return 是否删除成功并完成标记清理
     */
    private boolean cleanupStoredObject(DocumentImportBatchItem item) {
        if (item == null || item.getId() == null
            || !Boolean.TRUE.equals(item.getCleanupPending())) {
            return false;
        }
        try {
            if (StringUtil.hasText(item.getStorageLocator())) {
                storageService.deleteRecoverable(
                    FileStorageWriteHandle.decodeLocator(item.getStorageLocator())
                );
            } else if (StringUtil.hasText(item.getFilePath())) {
                storageService.delete(item.getFilePath());
            }
        } catch (RuntimeException error) {
            LOG.error(
                "清理上传对象失败: itemId={}, path={}",
                item.getId(),
                item.getFilePath(),
                error
            );
            return false;
        }
        int completed;
        try {
            if (DocumentImportBatchItemStatus.UPLOADING.name()
                .equals(item.getStatus())) {
                completed = itemMapper.completeUploadingStorageCleanup(
                    item.getId(),
                    item.getStorageLocator(),
                    item.getFilePath(),
                    "上次上传对象已回收，请重新上传",
                    new Date()
                );
            } else if (DocumentImportBatchItemStatus.CANCELLED.name()
                .equals(item.getStatus())) {
                completed = itemMapper.completeCancelledStorageCleanup(
                    item.getId(),
                    item.getStorageLocator(),
                    item.getFilePath(),
                    new Date()
                );
            } else {
                return false;
            }
        } catch (RuntimeException error) {
            LOG.error(
                "上传对象已删除但原子恢复状态失败，保留清理待办等待重试: itemId={}, path={}",
                item.getId(),
                item.getFilePath(),
                error
            );
            return false;
        }
        if (completed > 0) {
            return true;
        }
        try {
            DocumentImportBatchItem current = batchTracker.requireItem(item.getId());
            if (!Boolean.TRUE.equals(current.getCleanupPending())
                && !StringUtil.hasText(current.getStorageLocator())
                && !StringUtil.hasText(current.getFilePath())
                && (DocumentImportBatchItemStatus.PENDING.name().equals(current.getStatus())
                || DocumentImportBatchItemStatus.CANCELLED.name().equals(current.getStatus()))) {
                return true;
            }
        } catch (RuntimeException lookupError) {
            LOG.warn(
                "上传对象清理 CAS 未命中且最新状态读取失败: itemId={}",
                item.getId(),
                lookupError
            );
        }
        LOG.warn(
            "上传对象已删除但清理状态未恢复，等待下一轮重试: itemId={}, path={}",
            item.getId(),
            item.getFilePath()
        );
        return false;
    }

    /**
     * 在批次状态提交后启动选定失败项重试。
     *
     * @param batchId 批次 ID
     */
    private void resumeBatchFailures(BigInteger batchId) {
        String recoveryToken = UUID.randomUUID().toString();
        Date claimedAt = new Date();
        Date leaseUntil = new Date(
            claimedAt.getTime() + RECOVERY_DISPATCH_LEASE.toMillis());
        if (batchMapper.claimRecoveryPending(
            batchId, recoveryToken, leaseUntil, claimedAt) <= 0) {
            return;
        }
        try {
            DocumentImportBatch claimedBatch =
                batchMapper.selectClaimedRecovery(batchId, recoveryToken);
            if (claimedBatch == null) {
                LOG.info(
                    "批次恢复调度令牌已失效，旧持有者停止恢复: "
                        + "batchId={}, recoveryToken={}",
                    batchId,
                    recoveryToken
                );
                return;
            }
            Set<String> fileKeys =
                parseRecoveryFileKeys(claimedBatch.getRecoveryFileKeysJson());
            BooleanSupplier recoveryLeaseGuard = createRecoveryLeaseGuard(
                batchId,
                recoveryToken,
                leaseUntil.getTime(),
                System::currentTimeMillis
            );
            boolean recoveryCompleted = taskAppService.retryBatchFailures(
                batchId,
                fileKeys,
                recoveryLeaseGuard
            );
            if (!recoveryCompleted) {
                LOG.info(
                    "批次恢复租约已失效，旧持有者跳过批次收尾: "
                        + "batchId={}, recoveryToken={}",
                    batchId,
                    recoveryToken
                );
                return;
            }
            int finalized = batchMapper.finalizeRecoveryPending(
                batchId, recoveryToken, new Date());
            if (finalized <= 0) {
                LOG.info(
                    "批次恢复待办已变更，当前实例跳过收尾: "
                        + "batchId={}, recoveryToken={}",
                    batchId,
                    recoveryToken
                );
            }
        } catch (RuntimeException error) {
            try {
                if (!circuitBreaker.interruptRecoveryBatch(
                    batchId, recoveryToken, error)) {
                    LOG.info(
                        "批次恢复异常发生时令牌已失效，跳过旧持有者熔断: "
                            + "batchId={}, recoveryToken={}",
                        batchId,
                        recoveryToken
                    );
                }
            } catch (RuntimeException interruptError) {
                interruptError.addSuppressed(error);
                LOG.error(
                    "批次失败项恢复调度异常且强制中断失败: batchId={}",
                    batchId,
                    interruptError
                );
                throw interruptError;
            }
        }
    }

    /**
     * 创建按时间续期的恢复租约检查器。
     *
     * <p>检查器在租约剩余一半时执行带令牌 CAS 的续租。续租未命中
     * 表示当前实例已失去恢复权，调用方应停止后续文件重试。</p>
     *
     * @param batchId 批次 ID
     * @param recoveryToken 恢复调度令牌
     * @param leaseUntilMillis 当前租约到期毫秒时间
     * @param clock 毫秒时钟
     * @return 恢复租约检查器
     */
    BooleanSupplier createRecoveryLeaseGuard(BigInteger batchId,
                                             String recoveryToken,
                                             long leaseUntilMillis,
                                             LongSupplier clock) {
        long renewIntervalMillis =
            RECOVERY_LEASE_RENEW_INTERVAL.toMillis();
        AtomicLong renewAfter = new AtomicLong(
            Math.max(
                clock.getAsLong(),
                leaseUntilMillis - renewIntervalMillis
            )
        );
        return () -> {
            long nowMillis = clock.getAsLong();
            if (nowMillis < renewAfter.get()) {
                return true;
            }
            Date now = new Date(nowMillis);
            Date renewedLeaseUntil = new Date(
                nowMillis + RECOVERY_DISPATCH_LEASE.toMillis()
            );
            int renewed = batchMapper.renewRecoveryPendingLease(
                batchId,
                recoveryToken,
                renewedLeaseUntil,
                now
            );
            if (renewed <= 0) {
                return false;
            }
            renewAfter.set(nowMillis + renewIntervalMillis);
            return true;
        };
    }

    /**
     * 重放事务已经提交但进程尚未完成的批次恢复调度。
     *
     * <p>恢复待办持久化在批次表中；多实例重复扫描由文件项与批次
     * 的行锁门禁保证幂等。</p>
     *
     * @return 本轮扫描批次数
     */
    public int recoverPendingBatchRetries() {
        int limit = Math.max(1, properties.getPendingDispatchBatchSize());
        List<DocumentImportBatch> batches =
            batchMapper.selectRecoveryPendingBatches(new Date(), limit);
        for (DocumentImportBatch batch : batches) {
            // 中断状态写入失败属于系统性故障，交由外层调度冷却后重试。
            resumeBatchFailures(batch.getId());
        }
        return batches.size();
    }

    /**
     * 解析持久化的恢复文件选择。
     *
     * @param recoveryFileKeysJson 文件键 JSON；为空表示恢复全部失败项
     * @return 本轮恢复文件键
     */
    private Set<String> parseRecoveryFileKeys(String recoveryFileKeysJson) {
        if (!StringUtil.hasText(recoveryFileKeysJson)) {
            return Set.of();
        }
        List<String> fileKeys =
            JSON.parseArray(recoveryFileKeysJson, String.class);
        return fileKeys == null
            ? Set.of()
            : new LinkedHashSet<String>(fileKeys);
    }

    /**
     * 查询并校验本次人工重试选中的失败项。
     *
     * @param batchId 批次 ID
     * @param fileKeys 指定文件键
     * @return 失败项
     */
    private List<DocumentImportBatchItem> listFailedItems(BigInteger batchId,
                                                          Set<String> fileKeys) {
        QueryWrapper wrapper = QueryWrapper.create()
            .eq(DocumentImportBatchItem::getBatchId, batchId)
            .eq(DocumentImportBatchItem::getStatus, DocumentImportBatchItemStatus.FAILED.name());
        if (fileKeys != null && !fileKeys.isEmpty()) {
            Set<String> normalized = fileKeys.stream()
                .filter(StringUtil::hasText)
                .map(String::trim)
                .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
            if (normalized.size() != fileKeys.size()) {
                throw new BusinessException("fileKeys 包含空值或重复值");
            }
            wrapper.in(DocumentImportBatchItem::getClientFileKey, normalized);
            List<DocumentImportBatchItem> selected = itemService.list(wrapper);
            if (selected.size() != normalized.size()) {
                throw new BusinessException(409, 40906, "部分文件当前不是失败状态");
            }
            return selected;
        }
        return itemService.list(wrapper);
    }

    /**
     * 限制上传错误写入批次项的长度。
     *
     * @param message 原始异常信息
     * @return 安全长度的错误摘要
     */
    private String truncateUploadError(String message) {
        if (!StringUtil.hasText(message)) {
            return "文件上传失败，请重试";
        }
        return message.length() > 500 ? message.substring(0, 500) : message;
    }

    /**
     * 将可空计数转换为零。
     *
     * @param value 可空计数
     * @return 非空计数
     */
    private int valueOrZero(Integer value) {
        return value == null ? 0 : value;
    }

    private void assertSupportedExtension(String fileName) {
        int dotIndex = fileName == null ? -1 : fileName.lastIndexOf('.');
        String extension = dotIndex < 0 ? "" : fileName.substring(dotIndex + 1).toLowerCase(Locale.ROOT);
        if (!SUPPORTED_EXTENSIONS.contains(extension)) {
            throw new BusinessException("暂不支持该文件格式");
        }
    }

    /**
     * 为可恢复写入生成长度稳定且不会跨文件项冲突的物理文件名。
     *
     * @param item 批次文件项
     * @return 由文件项 ID 与原扩展名组成的文件名
     */
    private String buildRecoverableFileName(DocumentImportBatchItem item) {
        String fileName = item.getFileName();
        int dotIndex = fileName == null ? -1 : fileName.lastIndexOf('.');
        String extension = dotIndex < 0
            ? "bin"
            : fileName.substring(dotIndex + 1).toLowerCase(Locale.ROOT);
        return item.getId() + "." + extension;
    }

    private String normalizeRelativePath(String relativePath, String fileName) {
        String normalized = StringUtil.hasText(relativePath) ? relativePath.replace('\\', '/') : fileName;
        if (normalized.length() > 1024
            || normalized.startsWith("/")
            || normalized.split("/").length > 64) {
            throw new BusinessException("文件相对路径无效");
        }
        for (String segment : normalized.split("/")) {
            if ("..".equals(segment)) {
                throw new BusinessException("文件相对路径无效");
            }
        }
        return normalized;
    }

    private DocumentImportBatchDtos.ItemResponse toItemResponse(DocumentImportBatchItem item) {
        DocumentImportBatchDtos.ItemResponse response = new DocumentImportBatchDtos.ItemResponse();
        response.setItemId(item.getId());
        response.setDocumentId(item.getDocumentId());
        response.setClientFileKey(item.getClientFileKey());
        response.setFileName(item.getFileName());
        response.setRelativePath(item.getRelativePath());
        response.setFileSize(item.getFileSize());
        response.setStage(item.getStage());
        response.setStatus(item.getStatus());
        response.setErrorSummary(item.getErrorSummary());
        return response;
    }

    private BigInteger resolveOperatorId() {
        try {
            return BigInteger.valueOf(StpUtil.getLoginIdAsLong());
        } catch (Exception ignored) {
            return BigInteger.ZERO;
        }
    }

    /**
     * 历史重复文件处理策略。
     */
    private enum DuplicatePolicy {
        /** 跳过历史重复项。 */
        SKIP,
        /** 新文档成功入库后覆盖历史文档。 */
        OVERWRITE,
        /** 保留历史文档并重新导入一份。 */
        REIMPORT
    }
}
