package tech.easyflow.ai.documentimport.task;

import cn.dev33.satoken.stp.StpUtil;
import com.alibaba.fastjson2.JSON;
import com.easyagents.core.document.Document;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.easyagents.core.model.embedding.EmbeddingOptions;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import com.easyagents.rag.core.BgeM3ChunkSafety;
import com.easyagents.rag.core.RagChunk;
import com.easyagents.rag.core.RagDefaults;
import com.easyagents.rag.core.RagStrategyCodes;
import com.easyagents.rag.core.RagChunkTypes;
import com.easyagents.rag.ingestion.RagIngestionService;
import com.easyagents.rag.ingestion.model.AnalysisResult;
import com.easyagents.rag.ingestion.model.StrategyConfig;
import com.easyagents.search.engine.service.DocumentSearcher;
import com.easyagents.search.engine.service.KeywordSearchMetadataKeys;
import com.easyagents.document.core.entity.DocumentBlock;
import com.easyagents.document.core.entity.DocumentImage;
import com.easyagents.document.core.entity.DocumentTable;
import com.mybatisflex.core.keygen.impl.FlexIDKeyGenerator;
import com.mybatisflex.core.query.QueryWrapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.config.SearcherFactory;
import tech.easyflow.ai.document.model.DocumentParseArtifacts;
import tech.easyflow.ai.document.model.DocumentParseScenario;
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
import tech.easyflow.ai.document.model.DocumentParsedResult;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.DocumentImportKeys;
import tech.easyflow.ai.documentimport.DocumentImportPreviewService;
import tech.easyflow.ai.easyagents.CustomMultipartFile;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.DocumentImportBatch;
import tech.easyflow.ai.entity.DocumentImportBatchItem;
import tech.easyflow.ai.entity.DocumentImportTask;
import tech.easyflow.ai.enums.DocumentImportBatchItemStage;
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
import tech.easyflow.ai.enums.DocumentImportMode;
import tech.easyflow.ai.enums.DocumentImportTaskPhase;
import tech.easyflow.ai.enums.DocumentImportTaskStatus;
import tech.easyflow.ai.enums.DocumentProcessStatus;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.mapper.DocumentChunkMapper;
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
import tech.easyflow.ai.mapper.DocumentImportTaskMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.DocumentImportTaskService;
import tech.easyflow.ai.service.DocumentImportBatchItemService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.DocumentService;
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.cache.RedisLockExecutor.LockHandle;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.util.FileUtil;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException;

import javax.annotation.Resource;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.net.URLConnection;
import java.time.Duration;
import java.util.Base64;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
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.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 知识库文档任务化导入应用服务。
 *
 * @author Codex
 * @since 2026-04-14
 */
@Service
public class KnowledgeDocumentImportTaskAppService {

    private static final Logger LOG = LoggerFactory.getLogger(KnowledgeDocumentImportTaskAppService.class);
    private static final int INDEX_BATCH_SIZE = 20;
    private static final String SOURCE_RANGES_KEY = "sourceRanges";
    private static final String KNOWLEDGE_PARSE_IMAGE_CATEGORY = "knowledge-parse";
    private static final String OFFICE_PPTX_PAGE_STRATEGY = "OFFICE_PPTX_PAGE";
    private static final String OFFICE_XLSX_ROW_WINDOW_STRATEGY = "OFFICE_XLSX_ROW_WINDOW";
    private static final String TABLE_ROW_STRATEGY = "TABLE_ROW";
    private static final String SEARCH_RENDER_MARKDOWN_METADATA_KEY = "renderMarkdown";
    private static final String TASK_ERROR_PARSE_SERVICE_UNAVAILABLE = "parse_service_unavailable";
    private static final String TASK_ERROR_PARSE_SERVICE_TIMEOUT = "parse_service_timeout";
    private static final String TASK_ERROR_DOCUMENT_SOURCE_UNAVAILABLE = "document_source_unavailable";
    private static final String TASK_ERROR_UNSUPPORTED_DOCUMENT_SOURCE = "unsupported_document_source";
    private static final String TASK_ERROR_INVALID_PARSE_REQUEST = "invalid_parse_request";
    private static final String TASK_ERROR_PARSE_FAILED = "parse_failed";
    private static final String TASK_ERROR_PENDING_TIMEOUT = "pending_timeout";
    private static final String TASK_ERROR_EXECUTION_INTERRUPTED = "execution_interrupted";
    private static final String TASK_ERROR_SPLIT_FAILED = "split_failed";
    private static final String TASK_ERROR_INDEX_FAILED = "index_failed";
    private static final String DEFAULT_INDEX_FAILURE_MESSAGE = "分块或向量化失败，请重试";
    private static final String ROLLBACK_FAILURE_SUFFIX = "；外部索引回滚未完成，请联系管理员处理";
    private static final Pattern HTTP_SERVER_ERROR_PATTERN = Pattern.compile("\\bstatus=5\\d{2}\\b");
    private static final Pattern MARKDOWN_IMAGE_PATTERN = Pattern.compile("!\\[(?:[^\\]]*)\\]\\(([^)]+)\\)");
    private final FlexIDKeyGenerator flexIdKeyGenerator = new FlexIDKeyGenerator();
    private final TabularRowWindowChunkBuilder tabularChunkBuilder =
        new TabularRowWindowChunkBuilder();

    @Resource
    private DocumentMapper documentMapper;

    @Resource
    private DocumentChunkMapper documentChunkMapper;

    @Resource
    private DocumentImportTaskMapper documentImportTaskMapper;

    @Resource
    private DocumentCollectionService knowledgeService;

    @Resource
    private ModelService modelService;

    @Resource
    private DocumentChunkService documentChunkService;

    @Resource
    private DocumentImportTaskService documentImportTaskService;

    @Resource
    private DocumentImportBatchItemService documentImportBatchItemService;

    @Resource
    private DocumentImportBatchItemMapper documentImportBatchItemMapper;

    @Autowired
    @Lazy
    private DocumentService documentService;

    @Resource
    private DocumentImportBatchTracker documentImportBatchTracker;

    @Resource
    private DocumentImportBulkProperties bulkProperties;

    @Resource
    private RedisLockExecutor redisLockExecutor;

    @Resource
    private DocumentImportParseMonitorProperties parseMonitorProperties;

    @Resource
    private DocumentImportPreviewService documentImportPreviewService;

    @Resource
    private DocumentImportChunkSnapshotService documentImportChunkSnapshotService;

    @Resource
    private DocumentImportBatchCircuitBreaker documentImportBatchCircuitBreaker;

    @Resource
    private CsvTableSnapshotService csvTableSnapshotService;

    @Resource
    private DocumentImportSnapshotCleanupService snapshotCleanupService;

    @Resource
    private RagIngestionService ragIngestionService;

    @Resource
    private DocumentParseBridgeService documentParseBridgeService;

    @Resource
    private DocumentImportParseTaskProducer parseTaskProducer;

    @Resource
    private DocumentImportSplitTaskProducer splitTaskProducer;

    @Resource
    private DocumentImportIndexTaskProducer indexTaskProducer;

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

    @Resource
    private SearcherFactory searcherFactory;

    @Resource(name = "documentImportTaskExecutor")
    private ThreadPoolTaskExecutor documentImportTaskExecutor;

    @Resource
    private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService;

    @Autowired
    @Lazy
    private KnowledgeDocumentImportTaskAppService selfProxy;

    /**
     * 上传后创建解析任务。
     *
     * @param request 创建请求
     * @return 任务结果
     */
    @Transactional
    public Result<DocumentImportDtos.TaskCreateResponse> createImportTask(DocumentImportDtos.TaskCreateRequest request) {
        DocumentCollection knowledge = assertDocumentCollection(request.getKnowledgeId());
        if (!StringUtil.hasText(request.getFilePath()) || !StringUtil.hasText(request.getFileName())) {
            throw new BusinessException("文件信息不完整");
        }
        String fileExt = normalizeFileExtension(request.getFileName(), request.getFilePath());
        assertSupportedImportFile(fileExt);

        Date now = new Date();
        tech.easyflow.ai.entity.Document document = new tech.easyflow.ai.entity.Document();
        document.setId(generateId(document));
        document.setCollectionId(knowledge.getId());
        document.setDocumentPath(request.getFilePath());
        document.setTitle(request.getFileName());
        document.setDocumentType(fileExt);
        document.setCreated(now);
        document.setModified(now);
        document.setCreatedBy(resolveOperatorId());
        document.setModifiedBy(resolveOperatorId());
        document.setProcessStatus(DocumentProcessStatus.PARSING.name());
        document.setTotalChunks(0);
        document.setCompletedChunks(0);
        document.setFailedChunks(0);
        document.setProgressPercent(0);
        document.setTaskModifiedAt(now);
        document.setOptions(buildInitialOptions(fileExt));
        documentMapper.insert(document);

        DocumentImportTask task = createTask(document, DocumentImportTaskPhase.PARSE, buildFilePayload(request));
        LOG.info("文档导入任务已创建: knowledgeId={}, documentId={}, taskId={}, fileName={}, fileExt={}, parseMode={}",
            knowledge.getId(),
            document.getId(),
            task.getId(),
            document.getTitle(),
            fileExt,
            shouldUseDocumentParseBridge(fileExt) ? "async-bridge" : "sync-default");
        documentImportTaskStatusStreamService.publishAfterCommit(document.getId());
        LOG.info("文档解析任务准备异步投递: knowledgeId={}, documentId={}, taskId={}, fileExt={}",
            knowledge.getId(), document.getId(), task.getId(), fileExt);
        dispatchParseTaskAfterCommit(task.getId());
        scheduleParseTaskFallback(task.getId());

        DocumentImportDtos.TaskCreateResponse response = new DocumentImportDtos.TaskCreateResponse();
        response.setDocumentId(document.getId());
        response.setTaskId(task.getId());
        response.setProcessStatus(document.getProcessStatus());
        return Result.ok(response);
    }

    /**
     * 为已上传完成的批次文件创建文档与解析任务。
     *
     * @param batch 导入批次
     * @param items 批次文件项
     */
    @Transactional
    public void createBatchImportTasks(DocumentImportBatch batch,
                                       List<DocumentImportBatchItem> items) {
        DocumentCollection knowledge = assertDocumentCollection(batch.getKnowledgeId());
        List<BigInteger> taskIds = new ArrayList<BigInteger>();
        for (DocumentImportBatchItem item : items) {
            if (DocumentImportBatchItemStatus.SKIPPED.name().equals(item.getStatus())) {
                continue;
            }
            if (!DocumentImportBatchItemStatus.UPLOADED.name().equals(item.getStatus())
                || !StringUtil.hasText(item.getFilePath())) {
                throw new BusinessException("批次包含未上传完成的文件");
            }
            DocumentImportTask task = createBatchImportTask(batch, item, knowledge);
            taskIds.add(task.getId());
        }
        // 启动接口返回后列表会统一刷新；批量建档阶段不逐文件推送，避免提交后形成 SSE 风暴。
        int initialDispatchCount = Math.min(
            taskIds.size(),
            Math.max(1, bulkProperties.getPerBatchParseMaxRunning())
        );
        runAfterCommit(() -> taskIds.subList(0, initialDispatchCount)
            .forEach(parseTaskProducer::send));
    }

    /**
     * 为一个已上传文件或缺少文档的失败项创建文档与解析任务。
     *
     * @param batch 导入批次
     * @param item 批次文件项
     * @param knowledge 目标知识库
     * @return 新创建的解析任务
     * @throws BusinessException 文件状态、路径或格式不允许建档时抛出
     */
    private DocumentImportTask createBatchImportTask(
        DocumentImportBatch batch,
        DocumentImportBatchItem item,
        DocumentCollection knowledge) {
        boolean uploaded = DocumentImportBatchItemStatus.UPLOADED.name()
            .equals(item.getStatus());
        boolean failedWithoutDocument =
            DocumentImportBatchItemStatus.FAILED.name().equals(item.getStatus())
                && item.getDocumentId() == null;
        if ((!uploaded && !failedWithoutDocument)
            || !StringUtil.hasText(item.getFilePath())) {
            throw new BusinessException("批次包含无法恢复的文件");
        }
        String fileExt = normalizeFileExtension(
            item.getFileName(), item.getFilePath());
        assertSupportedImportFile(fileExt);
        Date now = new Date();
        tech.easyflow.ai.entity.Document document =
            new tech.easyflow.ai.entity.Document();
        document.setId(generateId(document));
        document.setCollectionId(knowledge.getId());
        document.setDocumentPath(item.getFilePath());
        document.setTitle(item.getFileName());
        document.setDocumentType(fileExt);
        document.setCreated(now);
        document.setModified(now);
        document.setCreatedBy(resolveOperatorId());
        document.setModifiedBy(resolveOperatorId());
        document.setProcessStatus(DocumentProcessStatus.PARSING.name());
        document.setTotalChunks(0);
        document.setCompletedChunks(0);
        document.setFailedChunks(0);
        document.setProgressPercent(0);
        document.setTaskModifiedAt(now);
        Map<String, Object> options = buildInitialOptions(fileExt);
        options.put(DocumentImportKeys.KEY_DOCUMENT_IMPORT_MODE,
            batch.getImportMode());
        options.put(DocumentImportKeys.KEY_DOCUMENT_IMPORT_BATCH_ID,
            batch.getId().toString());
        options.put(DocumentImportKeys.KEY_DOCUMENT_IMPORT_BATCH_ITEM_ID,
            item.getId().toString());
        options.put(DocumentImportKeys.KEY_DOCUMENT_IMPORT_RELATIVE_PATH,
            item.getRelativePath());
        document.setOptions(options);
        documentMapper.insert(document);

        DocumentImportTask task = createTask(
            document,
            DocumentImportTaskPhase.PARSE,
            buildDocumentPayload(document)
        );
        documentImportBatchTracker.bindDocument(item.getId(), document.getId());
        return task;
    }

    /**
     * 重试批次中所有失败文件，单文件业务失败不会中止其他文件。
     *
     * @param batchId 批次 ID
     * @throws RuntimeException 基础设施异常需要交由恢复令牌持有者熔断时抛出
     */
    public void retryBatchFailures(BigInteger batchId) {
        retryBatchFailures(batchId, Set.of());
    }

    /**
     * 重试批次中选定的失败文件，单文件业务失败不会中止其他文件。
     *
     * @param batchId 批次 ID
     * @param fileKeys 指定文件键；为空时重试全部失败项
     * @throws RuntimeException 基础设施异常需要交由恢复令牌持有者熔断时抛出
     */
    public void retryBatchFailures(BigInteger batchId, Set<String> fileKeys) {
        retryBatchFailures(batchId, fileKeys, () -> true);
    }

    /**
     * 在恢复租约仍有效时重试批次中的选定失败文件。
     *
     * <p>租约检查发生在每个文件重试前，旧持有者失去令牌后立即停止，
     * 避免多实例继续重复扫描和竞争文件项行锁。</p>
     *
     * @param batchId 批次 ID
     * @param fileKeys 指定文件键；为空时重试全部失败项
     * @param recoveryLeaseGuard 恢复租约检查器
     * @return 全部文件处理完成且最终仍持有恢复租约时返回 {@code true}
     * @throws RuntimeException 基础设施异常需要交由恢复令牌持有者熔断时抛出
     */
    boolean retryBatchFailures(BigInteger batchId,
                               Set<String> fileKeys,
                               BooleanSupplier recoveryLeaseGuard) {
        QueryWrapper query = QueryWrapper.create()
            .eq(DocumentImportBatchItem::getBatchId, batchId)
            .eq(DocumentImportBatchItem::getStatus, DocumentImportBatchItemStatus.FAILED.name());
        if (fileKeys != null && !fileKeys.isEmpty()) {
            query.in(DocumentImportBatchItem::getClientFileKey, fileKeys);
        }
        List<DocumentImportBatchItem> failedItems = documentImportBatchItemService.list(
            query
        );
        for (DocumentImportBatchItem item : failedItems) {
            if (recoveryLeaseGuard != null
                && !recoveryLeaseGuard.getAsBoolean()) {
                LOG.info(
                    "批次失败项恢复租约已失效，停止当前实例重试: "
                        + "batchId={}, itemId={}",
                    batchId,
                    item.getId()
                );
                return false;
            }
            try {
                selfProxy.retryBatchItemInNewTransaction(item.getId());
            } catch (BusinessException error) {
                LOG.error("批次失败项重试启动失败: batchId={}, itemId={}, documentId={}",
                    batchId, item.getId(), item.getDocumentId(), error);
                documentImportBatchItemMapper.updateFailedRetryError(
                    item.getId(), batchId,
                    truncateError(error.getMessage()), new Date());
            }
        }
        // 最后一次校验覆盖“末项处理完成到批次收尾”之间的租约失效窗口。
        if (recoveryLeaseGuard != null
            && !recoveryLeaseGuard.getAsBoolean()) {
            LOG.info(
                "批次失败项处理完成时恢复租约已失效，跳过旧持有者收尾: "
                    + "batchId={}",
                batchId
            );
            return false;
        }
        return true;
    }

    /**
     * 在独立事务中重试一个批次失败项，避免单文件异常影响同批次其他文件。
     *
     * @param itemId 批次文件项 ID
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void retryBatchItemInNewTransaction(BigInteger itemId) {
        DocumentImportBatchItem item =
            documentImportBatchItemMapper.selectFailedForRetry(itemId);
        if (item == null) {
            return;
        }
        retryBatchItem(item);
    }

    /**
     * 定时重新投递待处理任务，承接容量不足和消息投递异常后的恢复。
     */
    public void dispatchPendingTasks() {
        int limit = Math.max(1, bulkProperties.getPendingDispatchBatchSize());
        Date redispatchBefore = new Date(System.currentTimeMillis()
            - Math.max(1L, bulkProperties.getPendingRedispatchDelay().toMillis()));
        List<DocumentImportTask> tasks =
            documentImportTaskMapper.selectPendingFairly(redispatchBefore, limit);
        for (DocumentImportTask task : tasks) {
            int touched = documentImportTaskMapper.touchPendingForDispatch(
                task.getId(), redispatchBefore, new Date(), resolveOperatorId());
            if (touched != 1) {
                continue;
            }
            try {
                if (DocumentImportTaskPhase.PARSE.name().equals(task.getPhase())) {
                    parseTaskProducer.send(task.getId());
                } else if (DocumentImportTaskPhase.SPLIT.name().equals(task.getPhase())) {
                    splitTaskProducer.send(task.getId());
                } else if (DocumentImportTaskPhase.INDEX.name().equals(task.getPhase())) {
                    indexTaskProducer.send(task.getId());
                }
            } catch (Exception error) {
                handlePendingDispatchFailure(task, error);
            }
        }
    }

    /**
     * 处理待执行任务重新投递异常。
     *
     * <p>自动导入任务会触发批次熔断；未能完成熔断或无批次任务
     * 保留 PENDING，并向调度器传播异常以触发有界退避。</p>
     *
     * @param task 投递失败任务
     * @param error 原始异常
     */
    private void handlePendingDispatchFailure(DocumentImportTask task,
                                              Exception error) {
        try {
            if (documentImportBatchCircuitBreaker != null
                && documentImportBatchCircuitBreaker.interruptTaskBatch(
                    task.getId(), error)) {
                return;
            }
        } catch (Exception interruptError) {
            IllegalStateException dispatchError = new IllegalStateException(
                "文档导入任务投递失败且批次中断状态写入失败: "
                    + "taskId=" + task.getId()
                    + ", batchId=" + task.getBatchId()
                    + ", phase=" + task.getPhase(),
                interruptError
            );
            dispatchError.addSuppressed(error);
            throw dispatchError;
        }
        throw new IllegalStateException(
            "文档导入任务投递失败，等待调度退避后重试: "
                + "taskId=" + task.getId()
                + ", batchId=" + task.getBatchId()
                + ", phase=" + task.getPhase(),
            error
        );
    }

    /**
     * 检测超过最长排队时间的待处理任务。
     */
    public void expireTimedOutPendingTasks() {
        Date cutoff = new Date(System.currentTimeMillis()
            - Math.max(1L, bulkProperties.getPendingTimeout().toMillis()));
        List<DocumentImportTask> staleTasks = documentImportTaskService.list(
            QueryWrapper.create()
                .eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.PENDING.name())
                .le(DocumentImportTask::getCreated, cutoff)
                .orderBy(DocumentImportTask::getCreated, true)
                .limit(Math.max(1, bulkProperties.getPendingDispatchBatchSize()))
        );
        for (DocumentImportTask task : staleTasks) {
            selfProxy.expireTimedOutPendingTask(task.getId(), cutoff);
        }
    }

    /**
     * 检测已进入解析阶段但未取得服务任务 ID 的超时提交。
     */
    public void expireTimedOutParseSubmissions() {
        Date cutoff = new Date(System.currentTimeMillis()
            - Math.max(1L, bulkProperties.getParseSubmitTimeout().toMillis()));
        List<DocumentImportTask> staleTasks = documentImportTaskService.list(
            QueryWrapper.create()
                .eq(DocumentImportTask::getPhase, DocumentImportTaskPhase.PARSE.name())
                .eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.RUNNING.name())
                .isNull(DocumentImportTask::getProviderTaskId)
                .le(DocumentImportTask::getStartedAt, cutoff)
                .orderBy(DocumentImportTask::getStartedAt, true)
                .limit(Math.max(1, bulkProperties.getPendingDispatchBatchSize()))
        );
        for (DocumentImportTask task : staleTasks) {
            selfProxy.expireTimedOutParseSubmission(task.getId(), cutoff);
        }
    }

    /**
     * 将一个未取得服务任务 ID 的超时提交标记为失败并释放并发名额。
     *
     * @param taskId 任务 ID
     * @param cutoff 提交超时时间边界
     */
    @Transactional
    public void expireTimedOutParseSubmission(BigInteger taskId, Date cutoff) {
        DocumentImportTask current = requireTask(taskId);
        if (!DocumentImportTaskPhase.PARSE.name().equals(current.getPhase())
            || !DocumentImportTaskStatus.RUNNING.name().equals(current.getStatus())
            || StringUtil.hasText(current.getProviderTaskId())
            || current.getStartedAt() == null
            || current.getStartedAt().after(cutoff)) {
            return;
        }
        Date now = new Date();
        String errorMessage = "文档解析服务响应超时，请重试";
        DocumentImportTask update = new DocumentImportTask();
        update.setStatus(DocumentImportTaskStatus.FAILED.name());
        update.setErrorSummary(errorMessage);
        update.setFailureCode(TASK_ERROR_PARSE_SERVICE_TIMEOUT);
        update.setLeaseUntil(null);
        update.setFinishedAt(now);
        update.setModified(now);
        update.setModifiedBy(resolveOperatorId());
        int updated = documentImportTaskMapper.updateByQuery(update,
            QueryWrapper.create()
                .eq(DocumentImportTask::getId, taskId)
                .eq(DocumentImportTask::getPhase, DocumentImportTaskPhase.PARSE.name())
                .eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.RUNNING.name())
                .eq(DocumentImportTask::getExecutionToken, current.getExecutionToken())
                .isNull(DocumentImportTask::getProviderTaskId)
                .le(DocumentImportTask::getStartedAt, cutoff));
        if (updated <= 0) {
            return;
        }
        finishRecoveredTask(current, now, errorMessage, TASK_ERROR_PARSE_SERVICE_TIMEOUT);
    }

    /**
     * 将超过最长排队时间的任务标记为失败。
     *
     * @param taskId 任务 ID
     * @param cutoff 排队超时时间边界
     */
    @Transactional
    public void expireTimedOutPendingTask(BigInteger taskId, Date cutoff) {
        DocumentImportTask current = requireTask(taskId);
        if (!DocumentImportTaskStatus.PENDING.name().equals(current.getStatus())
            || current.getCreated() == null
            || current.getCreated().after(cutoff)) {
            return;
        }
        Date now = new Date();
        String errorMessage = "任务排队超时，请重试";
        DocumentImportTask update = new DocumentImportTask();
        update.setStatus(DocumentImportTaskStatus.FAILED.name());
        update.setErrorSummary(errorMessage);
        update.setFailureCode(TASK_ERROR_PENDING_TIMEOUT);
        update.setFinishedAt(now);
        update.setModified(now);
        update.setModifiedBy(resolveOperatorId());
        int updated = documentImportTaskMapper.updateByQuery(update,
            QueryWrapper.create()
                .eq(DocumentImportTask::getId, taskId)
                .eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.PENDING.name())
                .le(DocumentImportTask::getCreated, cutoff));
        if (updated <= 0) {
            return;
        }
        finishRecoveredTask(current, now, errorMessage, TASK_ERROR_PENDING_TIMEOUT);
    }

    /**
     * 检测所有长时间无心跳的运行任务，避免孤儿任务持续占用并发名额。
     */
    public void recoverInterruptedTasks() {
        Date cutoff = new Date(System.currentTimeMillis()
            - Math.max(1L, bulkProperties.getInterruptionTimeout().toMillis()));
        Date now = new Date();
        List<DocumentImportTask> staleTasks =
            documentImportTaskMapper.selectExpiredRunningTasks(
                now,
                cutoff,
                Math.max(1, bulkProperties.getPendingDispatchBatchSize())
        );
        for (DocumentImportTask task : staleTasks) {
            selfProxy.recoverInterruptedTask(task.getId(), cutoff);
        }
    }

    /**
     * 重试已完成导入项遗留的历史文档覆盖清理。
     */
    public void cleanupCompletedReplacements() {
        List<DocumentImportBatchItem> items = documentImportBatchItemService.list(
            QueryWrapper.create()
                .eq(DocumentImportBatchItem::getStatus, DocumentImportBatchItemStatus.COMPLETED.name())
                .isNotNull(DocumentImportBatchItem::getReplacedDocumentId)
                .orderBy(DocumentImportBatchItem::getModified, true)
                .limit(Math.max(1, bulkProperties.getPendingDispatchBatchSize()))
        );
        for (DocumentImportBatchItem item : items) {
            cleanupCompletedReplacement(item.getId());
        }
    }

    /**
     * 清理一个已被成功导入文档替代的历史文档。
     *
     * @param itemId 新导入批次项 ID
     */
    public void cleanupCompletedReplacement(BigInteger itemId) {
        DocumentImportBatchItem item = documentImportBatchTracker.requireItem(itemId);
        BigInteger replacedDocumentId = item.getReplacedDocumentId();
        if (replacedDocumentId == null) {
            return;
        }
        LockHandle lockHandle = redisLockExecutor.tryAcquire(
            "easyflow:lock:document-import:replacement:" + replacedDocumentId,
            Duration.ZERO,
            Duration.ofSeconds(30)
        );
        if (lockHandle == null) {
            return;
        }
        try {
            item = documentImportBatchTracker.requireItem(itemId);
            replacedDocumentId = item.getReplacedDocumentId();
            if (replacedDocumentId == null) {
                return;
            }
            tech.easyflow.ai.entity.Document replacement =
                documentMapper.selectOneById(item.getDocumentId());
            tech.easyflow.ai.entity.Document historical =
                documentMapper.selectOneById(replacedDocumentId);
            if (replacement == null || historical == null
                || replacedDocumentId.equals(item.getDocumentId())) {
                documentImportBatchTracker.clearReplacement(itemId, replacedDocumentId);
                return;
            }
            if (!DocumentProcessStatus.COMPLETED.name().equals(replacement.getProcessStatus())) {
                return;
            }
            if (!Objects.equals(replacement.getCollectionId(), historical.getCollectionId())) {
                LOG.error("拒绝跨知识库清理被覆盖文档: itemId={}, documentId={}, replacedDocumentId={}",
                    itemId, item.getDocumentId(), replacedDocumentId);
                documentImportBatchTracker.clearReplacement(itemId, replacedDocumentId);
                return;
            }
            if (!documentService.removeDoc(replacedDocumentId.toString())) {
                LOG.warn("被覆盖文档暂未清理，等待下次重试: itemId={}, replacedDocumentId={}",
                    itemId, replacedDocumentId);
                return;
            }
            documentMapper.deleteById(replacedDocumentId);
            documentImportBatchTracker.clearReplacement(itemId, replacedDocumentId);
            LOG.info("被覆盖的历史文档已清理: itemId={}, documentId={}, replacedDocumentId={}",
                itemId, item.getDocumentId(), replacedDocumentId);
        } catch (Exception error) {
            LOG.error("清理被覆盖的历史文档失败，等待下次重试: itemId={}, replacedDocumentId={}",
                itemId, replacedDocumentId, error);
        } finally {
            lockHandle.release();
        }
    }

    /**
     * 将一个失去心跳的运行任务标记为失败并释放并发名额。
     *
     * @param taskId 任务 ID
     * @param cutoff 失联时间边界
     */
    @Transactional
    public void recoverInterruptedTask(BigInteger taskId, Date cutoff) {
        DocumentImportTask current = requireTask(taskId);
        Date now = new Date();
        boolean leaseExpired = current.getLeaseUntil() != null
            ? !current.getLeaseUntil().after(now)
            : current.getModified() != null
                && !current.getModified().after(cutoff);
        if (!DocumentImportTaskStatus.RUNNING.name().equals(current.getStatus())
            || !leaseExpired) {
            return;
        }
        String errorMessage = current.getBatchId() == null
            ? "任务执行中断，请重试"
            : "任务执行中断，请继续批次";
        int updated = documentImportTaskMapper.failExpiredOwned(
            taskId,
            current.getExecutionToken(),
            errorMessage,
            TASK_ERROR_EXECUTION_INTERRUPTED,
            now,
            cutoff,
            resolveOperatorId()
        );
        if (updated <= 0) {
            return;
        }
        finishRecoveredTask(current, now, errorMessage, TASK_ERROR_EXECUTION_INTERRUPTED);
    }

    /**
     * 收口调度器已经原子置为失败的任务，并同步文档与批次状态。
     *
     * @param task 任务实体
     * @param now 收口时间
     * @param errorMessage 用户可见错误信息
     * @param errorCode 稳定错误码
     */
    private void finishRecoveredTask(DocumentImportTask task,
                                     Date now,
                                     String errorMessage,
                                     String errorCode) {
        DocumentImportBatchItemStage stage = toBatchItemStage(task.getPhase());
        tech.easyflow.ai.entity.Document document = documentMapper.selectOneById(task.getDocumentId());
        if (document == null) {
            LOG.warn("自动清理缺少关联文档的导入任务: taskId={}, documentId={}, phase={}, batchId={}",
                task.getId(), task.getDocumentId(), task.getPhase(), task.getBatchId());
        } else {
            document.setProcessStatus(toFailureProcessStatus(task.getPhase()).name());
            document.setProgressPercent(0);
            setDocumentTaskError(document, errorMessage, errorCode);
            persistDocumentTaskState(document, now);
        }
        finishRecoveredBatchState(task, stage, errorMessage, errorCode);
    }

    /**
     * 查询任务详情。
     *
     * @param taskId 任务 ID
     * @return 任务详情
     */
    public Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(BigInteger taskId) {
        DocumentImportTask task = requireTask(taskId);
        tech.easyflow.ai.entity.Document document = requireDocument(task.getDocumentId());
        DocumentImportDtos.TaskDetailResponse response = new DocumentImportDtos.TaskDetailResponse();
        response.setTaskId(task.getId());
        response.setDocumentId(task.getDocumentId());
        response.setKnowledgeId(task.getKnowledgeId());
        response.setPhase(task.getPhase());
        response.setStatus(task.getStatus());
        response.setProcessStatus(document.getProcessStatus());
        response.setProgressPercent(defaultInt(document.getProgressPercent()));
        response.setTotalChunks(defaultInt(document.getTotalChunks()));
        response.setCompletedChunks(defaultInt(document.getCompletedChunks()));
        response.setFailedChunks(defaultInt(document.getFailedChunks()));
        response.setProviderTaskId(task.getProviderTaskId());
        response.setErrorSummary(StringUtil.hasText(document.getLastTaskError()) ? document.getLastTaskError() : task.getErrorSummary());
        response.setStartedAt(task.getStartedAt());
        response.setFinishedAt(task.getFinishedAt());
        return Result.ok(response);
    }

    /**
     * 生成单文档分块预览。
     *
     * @param request 预览请求
     * @return 预览结果
     */
    @Transactional
    public Result<DocumentImportDtos.PreviewResponse> previewImportTask(DocumentImportDtos.PreviewRequest request) {
        if (request.getDocumentId() == null) {
            throw new BusinessException("文档ID不能为空");
        }
        DocumentCollection knowledge = assertDocumentCollection(request.getKnowledgeId());
        tech.easyflow.ai.entity.Document document = requireDocumentForKnowledge(request.getDocumentId(), knowledge.getId());
        ensurePreviewable(document);

        int pageNo = normalizePreviewPageNo(request.getPageNo());
        int pageSize = normalizePreviewPageSize(request.getPageSize());
        DocumentImportDtos.PreviewSession session;
        String sessionId;
        if (StringUtil.hasText(request.getPreviewSessionId())) {
            sessionId = request.getPreviewSessionId();
            session = documentImportPreviewService.getRequired(sessionId);
            assertPreviewSessionOwner(knowledge, document, session);
        } else {
            StrategyConfig requestedStrategy = null;
            if (request.getFiles() != null && !request.getFiles().isEmpty()) {
                requestedStrategy = request.getFiles().get(0).getStrategyConfig();
            }
            session = buildPreviewSessionForDocument(
                knowledge, document, requestedStrategy);
            DocumentImportDtos.PreviewSession previousSession =
                documentImportPreviewService.replaceForDocument(session);
            sessionId = session.getSessionId();
            if (previousSession != null
                && StringUtil.hasText(previousSession.getChunkSnapshotPath())
                && !Objects.equals(
                    previousSession.getChunkSnapshotPath(),
                    session.getChunkSnapshotPath())) {
                deleteChunkSnapshotAfterCompletion(
                    previousSession.getChunkSnapshotPath());
            }
        }

        List<DocumentChunk> pageChunks =
            loadPreviewPage(session, pageNo, pageSize);
        DocumentImportDtos.PreviewFileResult item = buildPreviewFileResult(
            document, session, sessionId, pageNo, pageSize, pageChunks);

        DocumentImportDtos.PreviewResponse response = new DocumentImportDtos.PreviewResponse();
        response.setItems(List.of(item));
        response.setTotalFiles(1);
        response.setTotalChunks(resolveSessionTotalChunks(session));
        return Result.ok(response);
    }

    /**
     * 启动向量化任务。
     *
     * @param request 启动请求
     * @return 启动结果
     */
    @Transactional
    public Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(DocumentImportDtos.TaskStartIndexRequest request) {
        if (request.getDocumentId() == null) {
            throw new BusinessException("文档ID不能为空");
        }
        DocumentCollection knowledge = assertDocumentCollection(request.getKnowledgeId());
        tech.easyflow.ai.entity.Document document = requireDocumentForKnowledge(request.getDocumentId(), knowledge.getId());
        if (!allowIndexStart(document)) {
            throw new BusinessException("当前文档状态不允许开始向量化");
        }

        String existingSnapshotPath = optionAsString(
            document.getOptions(), DocumentImportKeys.KEY_DOCUMENT_CHUNK_SNAPSHOT_PATH);
        DocumentImportDtos.PreviewSession session = resolveIndexPreviewSession(
            knowledge,
            document,
            request.getPreviewSessionId(),
            StringUtil.hasText(request.getPreviewSessionId()) ? null : existingSnapshotPath
        );
        int totalChunks = resolveSessionTotalChunks(session);
        if (totalChunks <= 0) {
            throw new BusinessException("未生成有效分块，无法开始向量化");
        }

        String preparedSnapshotPath = session.getChunkSnapshotPath();
        boolean createdSnapshot = !StringUtil.hasText(existingSnapshotPath)
            && !StringUtil.hasText(preparedSnapshotPath);
        String snapshotPath;
        if (StringUtil.hasText(preparedSnapshotPath)) {
            snapshotPath = preparedSnapshotPath;
        } else if (createdSnapshot) {
            snapshotPath = documentImportChunkSnapshotService.save(session);
        } else {
            snapshotPath = existingSnapshotPath;
        }
        mergeDocumentPreviewOptions(document, session, snapshotPath);
        if (!claimDocumentIndexing(document, totalChunks)) {
            if (createdSnapshot) {
                deleteChunkSnapshotAfterCompletion(snapshotPath);
            }
            throw new BusinessException("文档状态已变化，请刷新后重试");
        }
        Map<String, Object> payload = new LinkedHashMap<String, Object>();
        if (StringUtil.hasText(session.getSessionId())) {
            payload.put("previewSessionId", session.getSessionId());
        }
        payload.put("chunkSnapshotPath", snapshotPath);
        payload.put("totalChunks", totalChunks);
        DocumentImportTask task = createTask(document, DocumentImportTaskPhase.INDEX, payload);
        updateBatchItem(task.getBatchItemId(),
            DocumentImportBatchItemStage.INDEX,
            DocumentImportBatchItemStatus.PENDING,
            null);
        LOG.info("文档向量化任务已创建: knowledgeId={}, documentId={}, taskId={}, previewSessionId={}, totalChunks={}",
            knowledge.getId(), document.getId(), task.getId(), session.getSessionId(), totalChunks);
        dispatchIndexTaskAfterCommit(task.getId());
        if (task.getBatchId() == null) {
            scheduleIndexTaskFallback(task.getId());
        }

        DocumentImportDtos.TaskStartIndexResponse response = new DocumentImportDtos.TaskStartIndexResponse();
        response.setTaskId(task.getId());
        response.setProcessStatus(DocumentProcessStatus.INDEXING.name());
        return Result.ok(response);
    }

    /**
     * 重试解析任务。
     *
     * @param request 重试请求
     * @return 任务结果
     */
    @Transactional
    public Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(DocumentImportDtos.TaskRetryRequest request) {
        DocumentCollection knowledge = assertDocumentCollection(request.getKnowledgeId());
        tech.easyflow.ai.entity.Document document = requireDocumentForKnowledge(request.getDocumentId(), knowledge.getId());
        if (!DocumentProcessStatus.PARSE_FAILED.name().equals(document.getProcessStatus())) {
            throw new BusinessException("当前文档不支持重试解析");
        }
        if (!claimDocumentParseRetry(document)) {
            throw new BusinessException("文档状态已变化，请刷新后重试");
        }
        resetDocumentForParseRetry(document);
        DocumentImportTask task = createTask(document, DocumentImportTaskPhase.PARSE, buildDocumentPayload(document));
        updateBatchItem(task.getBatchItemId(),
            DocumentImportBatchItemStage.PARSE,
            DocumentImportBatchItemStatus.PENDING,
            null);
        dispatchParseTaskAfterCommit(task.getId());
        if (task.getBatchId() == null) {
            scheduleParseTaskFallback(task.getId());
        }
        return Result.ok(buildTaskStartResponse(task, DocumentProcessStatus.PARSING));
    }

    /**
     * 重试向量化任务。
     *
     * @param request 重试请求
     * @return 任务结果
     */
    @Transactional
    public Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(DocumentImportDtos.TaskRetryRequest request) {
        DocumentCollection knowledge = assertDocumentCollection(request.getKnowledgeId());
        tech.easyflow.ai.entity.Document document =
            requireDocumentForKnowledge(request.getDocumentId(), knowledge.getId());
        BigInteger batchId = optionAsBigInteger(
            document.getOptions(), DocumentImportKeys.KEY_DOCUMENT_IMPORT_BATCH_ID);
        if (DocumentProcessStatus.INDEX_FAILED.name().equals(document.getProcessStatus())
            && isAutomaticBatch(batchId)) {
            return retryFailedTask(request);
        }
        DocumentImportDtos.TaskStartIndexRequest startRequest = new DocumentImportDtos.TaskStartIndexRequest();
        startRequest.setKnowledgeId(request.getKnowledgeId());
        startRequest.setDocumentId(request.getDocumentId());
        return startIndexTask(startRequest);
    }

    /**
     * 根据文档失败阶段执行统一重试。
     *
     * @param request 重试请求
     * @return 重试后的任务状态
     */
    @Transactional
    public Result<DocumentImportDtos.TaskStartIndexResponse> retryFailedTask(
        DocumentImportDtos.TaskRetryRequest request) {
        DocumentCollection knowledge = assertDocumentCollection(request.getKnowledgeId());
        tech.easyflow.ai.entity.Document document =
            requireDocumentForKnowledge(request.getDocumentId(), knowledge.getId());
        String status = document.getProcessStatus();
        if (DocumentProcessStatus.PARSE_FAILED.name().equals(status)) {
            return retryParseTask(request);
        }
        BigInteger batchId = optionAsBigInteger(
            document.getOptions(), DocumentImportKeys.KEY_DOCUMENT_IMPORT_BATCH_ID);
        BigInteger batchItemId = optionAsBigInteger(
            document.getOptions(), DocumentImportKeys.KEY_DOCUMENT_IMPORT_BATCH_ITEM_ID);
        if (DocumentProcessStatus.INDEX_FAILED.name().equals(status)
            && !isAutomaticBatch(batchId)) {
            return retryIndexTask(request);
        }
        if (!DocumentProcessStatus.SPLIT_FAILED.name().equals(status)
            && !DocumentProcessStatus.INDEX_FAILED.name().equals(status)) {
            throw new BusinessException("当前文档无需重试");
        }
        if (!claimDocumentSplitRetry(document, status)) {
            throw new BusinessException("文档状态已变化，请刷新后重试");
        }
        String staleSnapshotPath = invalidateChunkSnapshot(document);
        DocumentImportTask splitTask =
            enqueueAutomaticSplit(batchId, batchItemId, document);
        deleteChunkSnapshotAfterCommit(staleSnapshotPath);
        DocumentImportDtos.TaskStartIndexResponse response = new DocumentImportDtos.TaskStartIndexResponse();
        response.setTaskId(splitTask.getId());
        response.setProcessStatus(DocumentProcessStatus.SPLITTING.name());
        return Result.ok(response);
    }

    /**
     * 恢复一个失败批次项；缺少文档时先原子重新建档。
     *
     * @param item 已锁定的失败批次项
     * @throws BusinessException 文件或文档状态不允许恢复时抛出
     */
    private void retryBatchItem(DocumentImportBatchItem item) {
        if (item.getDocumentId() == null) {
            DocumentImportBatch batch =
                documentImportBatchTracker.requireBatch(item.getBatchId());
            if (!Objects.equals(batch.getKnowledgeId(), item.getKnowledgeId())) {
                throw new IllegalStateException("批次文件知识库归属不一致");
            }
            DocumentCollection knowledge =
                assertDocumentCollection(item.getKnowledgeId());
            DocumentImportTask task =
                createBatchImportTask(batch, item, knowledge);
            dispatchParseTaskAfterCommit(task.getId());
            return;
        }
        tech.easyflow.ai.entity.Document document = requireDocument(item.getDocumentId());
        DocumentImportDtos.TaskRetryRequest request = new DocumentImportDtos.TaskRetryRequest();
        request.setKnowledgeId(item.getKnowledgeId());
        request.setDocumentId(item.getDocumentId());
        String status = document.getProcessStatus();
        if (!DocumentProcessStatus.PARSE_FAILED.name().equals(status)
            && !DocumentProcessStatus.SPLIT_FAILED.name().equals(status)
            && !DocumentProcessStatus.INDEX_FAILED.name().equals(status)) {
            throw new BusinessException("当前失败项状态不支持重试");
        }
        retryFailedTask(request);
    }

    /**
     * 持久化一个自动分块任务，并在当前事务提交后投递。
     *
     * @param batchId 批次 ID
     * @param batchItemId 文件项 ID
     * @param document 文档实体
     * @return 已创建的分块任务
     */
    private DocumentImportTask enqueueAutomaticSplit(
        BigInteger batchId,
        BigInteger batchItemId,
        tech.easyflow.ai.entity.Document document) {
        if (batchId == null || batchItemId == null) {
            throw new BusinessException("自动分块任务缺少批次归属");
        }
        DocumentImportBatch batch = documentImportBatchTracker.requireBatch(batchId);
        Map<String, Object> payload = new LinkedHashMap<String, Object>();
        payload.put("strategyConfigJson", batch.getRequestedStrategyJson());
        Date now = new Date();
        document.setProcessStatus(DocumentProcessStatus.SPLITTING.name());
        document.setProgressPercent(0);
        clearDocumentTaskError(document);
        persistDocumentTaskState(document, now);
        updateBatchItem(batchItemId,
            DocumentImportBatchItemStage.SPLIT,
            DocumentImportBatchItemStatus.PENDING,
            null);
        DocumentImportTask task =
            createTask(document, DocumentImportTaskPhase.SPLIT, payload);
        dispatchSplitTaskAfterCommit(task.getId());
        return task;
    }

    /**
     * 处理持久化分块任务消息。
     *
     * @param taskId 任务 ID
     */
    public void handleSplitTask(BigInteger taskId) {
        DocumentImportTask task = requireTask(taskId);
        if (!DocumentImportTaskPhase.SPLIT.name().equals(task.getPhase())) {
            LOG.warn("忽略非分块阶段任务: taskId={}, phase={}", taskId, task.getPhase());
            return;
        }
        if (DocumentImportTaskStatus.COMPLETED.name().equals(task.getStatus())
            || DocumentImportTaskStatus.FAILED.name().equals(task.getStatus())) {
            LOG.info("分块任务已结束，跳过重复处理: taskId={}, status={}",
                taskId, task.getStatus());
            return;
        }
        LockHandle executionLock = redisLockExecutor.tryAcquire(
            "easyflow:lock:document-import:document:" + task.getDocumentId(),
            Duration.ZERO,
            Duration.ofMinutes(30)
        );
        if (executionLock == null) {
            LOG.info("文档已有导入任务执行中，延后分块任务: taskId={}, documentId={}",
                taskId, task.getDocumentId());
            return;
        }
        String snapshotPath = null;
        String previewSessionId = null;
        try {
            if (!selfProxy.tryMarkTaskRunning(taskId)) {
                LOG.info("分块任务未抢占成功，跳过本次执行: taskId={}", taskId);
                return;
            }
            task = requireTask(taskId);
            tech.easyflow.ai.entity.Document document =
                documentMapper.selectOneById(task.getDocumentId());
            if (document == null) {
                finishMissingDocumentTask(task);
                return;
            }
            updateBatchItem(task.getBatchItemId(),
                DocumentImportBatchItemStage.SPLIT,
                DocumentImportBatchItemStatus.RUNNING,
                null);
            if (!touchRunningTask(task) || !executionLock.renew()) {
                throw new TaskOwnershipLostException(task.getId());
            }

            StrategyConfig requestedStrategy = resolveSplitStrategy(task);
            DocumentCollection knowledge =
                assertDocumentCollection(task.getKnowledgeId());
            DocumentImportDtos.PreviewSession session =
                buildPreviewSessionForDocument(
                    knowledge, document, requestedStrategy);
            previewSessionId = documentImportPreviewService.put(session);
            session.setSessionId(previewSessionId);
            int totalChunks = resolveSessionTotalChunks(session);
            if (totalChunks <= 0) {
                throw new BusinessException("未生成有效分块，无法开始向量化");
            }
            snapshotPath = StringUtil.hasText(session.getChunkSnapshotPath())
                ? session.getChunkSnapshotPath()
                : documentImportChunkSnapshotService.save(session);
            if (!touchRunningTask(task) || !executionLock.renew()) {
                throw new TaskOwnershipLostException(task.getId());
            }
            if (!selfProxy.completeSplitTask(
                task, document, session, snapshotPath, totalChunks)) {
                deleteOwnedSplitArtifacts(previewSessionId, snapshotPath);
                LOG.warn("分块任务所有权已失效，忽略迟到结果: taskId={}, documentId={}",
                    task.getId(), document.getId());
            }
        } catch (TaskOwnershipLostException ownershipLost) {
            deleteOwnedSplitArtifacts(previewSessionId, snapshotPath);
            LOG.warn("分块任务执行令牌已失效: taskId={}", taskId);
        } catch (Exception error) {
            deleteOwnedSplitArtifacts(previewSessionId, snapshotPath);
            String errorMessage = resolveSplitFailureMessage(error);
            LOG.error("文档分块任务失败: taskId={}, documentId={}",
                taskId, task.getDocumentId(), error);
            String failureCode = error instanceof CsvImportException csvError
                ? csvError.getFailureCode()
                : TASK_ERROR_SPLIT_FAILED;
            if (!selfProxy.failSplitTask(
                task, task.getDocumentId(), errorMessage, failureCode, error)) {
                LOG.warn("分块任务所有权已失效，忽略迟到失败: taskId={}", taskId);
            }
        } finally {
            executionLock.release();
        }
    }

    /**
     * 处理解析任务消息。
     *
     * @param taskId 任务 ID
     */
    public void handleParseTask(BigInteger taskId) {
        DocumentImportTask task = requireTask(taskId);
        if (!DocumentImportTaskPhase.PARSE.name().equals(task.getPhase())) {
            LOG.warn("忽略非解析阶段任务: taskId={}, phase={}", taskId, task.getPhase());
            return;
        }
        if (DocumentImportTaskStatus.COMPLETED.name().equals(task.getStatus())
            || DocumentImportTaskStatus.FAILED.name().equals(task.getStatus())) {
            LOG.info("解析任务已结束，跳过重复处理: taskId={}, status={}", taskId, task.getStatus());
            return;
        }
        if (!selfProxy.tryMarkTaskRunning(taskId)) {
            LOG.info("解析任务未抢占成功，跳过本次执行: taskId={}", taskId);
            return;
        }
        task = requireTask(taskId);
        tech.easyflow.ai.entity.Document document = documentMapper.selectOneById(task.getDocumentId());
        if (document == null) {
            finishMissingDocumentTask(task);
            return;
        }
        updateBatchItem(task.getBatchItemId(),
            DocumentImportBatchItemStage.PARSE,
            DocumentImportBatchItemStatus.RUNNING,
            null);

        LOG.info("开始执行文档解析任务: taskId={}, documentId={}, knowledgeId={}, currentStatus={}",
            taskId, document.getId(), task.getKnowledgeId(), document.getProcessStatus());

        try {
            String fileExt = normalizeFileExtension(document.getTitle(), document.getDocumentPath());
            if (shouldUseDocumentParseBridge(fileExt)) {
                handleBridgeParse(task, document, fileExt);
            } else {
                handleDefaultParse(task, document, fileExt);
            }
        } catch (Exception e) {
            LOG.error("文档解析任务失败: taskId={}, documentId={}", taskId, document.getId(), e);
            String errorCode = resolveParseFailureCode(e);
            markParseFailed(
                task,
                document,
                resolveParseFailureMessage(e, errorCode),
                errorCode,
                e
            );
        }
    }

    /**
     * 收敛运行中的桥接解析任务。
     */
    public void monitorRunningParseTasks() {
        QueryWrapper queryWrapper = QueryWrapper.create()
            .eq(DocumentImportTask::getPhase, DocumentImportTaskPhase.PARSE.name())
            .eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.RUNNING.name())
            .isNotNull(DocumentImportTask::getProviderTaskId)
            .orderBy(DocumentImportTask::getModified, true)
            .limit(parseMonitorProperties.getBatchSize());
        List<DocumentImportTask> runningTasks = documentImportTaskService.list(queryWrapper);
        if (runningTasks == null || runningTasks.isEmpty()) {
            return;
        }
        for (DocumentImportTask task : runningTasks) {
            if (task == null || task.getId() == null || !StringUtil.hasText(task.getProviderTaskId())) {
                continue;
            }
            try {
                selfProxy.handleRunningParseTask(task.getId());
            } catch (Exception e) {
                LOG.error("收敛运行中的文档解析任务失败: taskId={}", task.getId(), e);
            }
        }
    }

    /**
     * 单次收敛指定解析任务的桥接状态。
     *
     * @param taskId 任务 ID
     */
    @Transactional
    public void handleRunningParseTask(BigInteger taskId) {
        DocumentImportTask task = requireTask(taskId);
        if (!DocumentImportTaskPhase.PARSE.name().equals(task.getPhase())) {
            return;
        }
        if (!DocumentImportTaskStatus.RUNNING.name().equals(task.getStatus())
            || !StringUtil.hasText(task.getProviderTaskId())) {
            return;
        }
        tech.easyflow.ai.entity.Document document = documentMapper.selectOneById(task.getDocumentId());
        if (document == null) {
            finishMissingDocumentTask(task);
            return;
        }
        if (!DocumentProcessStatus.PARSING.name().equals(document.getProcessStatus())) {
            return;
        }
        String fileExt = normalizeFileExtension(document.getTitle(), document.getDocumentPath());
        if (!shouldUseDocumentParseBridge(fileExt)) {
            return;
        }
        try {
            syncBridgeParseTask(task, document, fileExt);
        } catch (Exception e) {
            LOG.error("文档解析任务收敛失败: taskId={}, documentId={}", taskId, document.getId(), e);
            String errorCode = resolveParseFailureCode(e);
            markParseFailed(
                task,
                document,
                resolveParseFailureMessage(e, errorCode),
                errorCode,
                e
            );
        }
    }

    /**
     * 处理向量化任务消息。
     *
     * @param taskId 任务 ID
     */
    public void handleIndexTask(BigInteger taskId) {
        DocumentImportTask task = requireTask(taskId);
        if (!DocumentImportTaskPhase.INDEX.name().equals(task.getPhase())) {
            LOG.warn("忽略非向量化阶段任务: taskId={}, phase={}", taskId, task.getPhase());
            return;
        }
        if (DocumentImportTaskStatus.COMPLETED.name().equals(task.getStatus())
            || DocumentImportTaskStatus.FAILED.name().equals(task.getStatus())) {
            LOG.info("向量化任务已结束，跳过重复处理: taskId={}, status={}", taskId, task.getStatus());
            return;
        }
        LockHandle executionLock = redisLockExecutor.tryAcquire(
            "easyflow:lock:document-import:document:" + task.getDocumentId(),
            Duration.ZERO,
            Duration.ofMinutes(30)
        );
        if (executionLock == null) {
            LOG.info("文档已有导入任务执行中，延后向量化任务: taskId={}, documentId={}",
                taskId, task.getDocumentId());
            return;
        }
        try {
            if (!selfProxy.tryMarkTaskRunning(taskId)) {
                LOG.info("向量化任务未抢占成功，跳过本次执行: taskId={}", taskId);
                return;
            }
            task = requireTask(taskId);
            tech.easyflow.ai.entity.Document document = documentMapper.selectOneById(task.getDocumentId());
            if (document == null) {
                finishMissingDocumentTask(task);
                return;
            }
            updateBatchItem(task.getBatchItemId(),
                DocumentImportBatchItemStage.INDEX,
                DocumentImportBatchItemStatus.RUNNING,
                null);

            LOG.info("开始执行文档向量化任务: taskId={}, documentId={}, knowledgeId={}, currentStatus={}",
                taskId, document.getId(), task.getKnowledgeId(), document.getProcessStatus());
            StoreExecutionContext storeContext = null;
            List<DocumentChunk> storedChunks = new ArrayList<DocumentChunk>();
            try {
                DocumentCollection knowledge = assertDocumentCollection(task.getKnowledgeId());
                if (!touchRunningTask(task) || !executionLock.renew()) {
                    throw new TaskOwnershipLostException(task.getId());
                }
                clearPersistedChunks(document.getId());
                storeContext = prepareStoreContext(document);
                String chunkSnapshotPath =
                    asString(task.getPayloadJson().get("chunkSnapshotPath"));
                DocumentImportDtos.PreviewSession session =
                    resolveIndexPreviewSession(
                        knowledge,
                        document,
                        asString(task.getPayloadJson().get("previewSessionId")),
                        chunkSnapshotPath
                    );
                int totalChunks = resolveSessionTotalChunks(session);
                if (totalChunks <= 0) {
                    throw new BusinessException("预览会话无有效分块");
                }
                StoreExecutionContext activeStoreContext = storeContext;
                DocumentImportTask activeTask = task;
                Set<BigInteger> uniqueChunkIds =
                    new HashSet<BigInteger>(Math.min(totalChunks, 65_536));
                int[] completedChunks = new int[]{0};
                java.util.function.Consumer<List<DocumentChunk>> batchConsumer = batch -> {
                    assertUniqueChunkIds(batch, uniqueChunkIds);
                    LOG.info("文档向量化任务开始处理批次: taskId={}, documentId={}, batchStart={}, batchEnd={}, batchSize={}, totalChunks={}",
                        taskId, document.getId(), completedChunks[0],
                        completedChunks[0] + batch.size(), batch.size(), totalChunks);
                    // 提前登记当前批次，确保外部存储部分成功后仍能覆盖回滚范围。
                    storedChunks.addAll(toChunkIdMarkers(batch));
                    storeDocumentChunks(activeStoreContext, batch);
                    persistChunkBatch(document, batch);
                    completedChunks[0] += batch.size();
                    updateDocumentIndexProgress(
                        document.getId(), totalChunks, completedChunks[0]);
                    if (!touchRunningTask(activeTask) || !executionLock.renew()) {
                        throw new TaskOwnershipLostException(activeTask.getId());
                    }
                };
                if (StringUtil.hasText(chunkSnapshotPath)) {
                    documentImportChunkSnapshotService.forEachBatch(
                        chunkSnapshotPath, INDEX_BATCH_SIZE, batchConsumer);
                } else {
                    List<DocumentChunk> chunks = session.getDocumentChunks();
                    if (chunks == null || chunks.isEmpty()) {
                        throw new BusinessException("预览会话无有效分块");
                    }
                    for (int start = 0; start < chunks.size(); start += INDEX_BATCH_SIZE) {
                        int end = Math.min(start + INDEX_BATCH_SIZE, chunks.size());
                        batchConsumer.accept(new ArrayList<DocumentChunk>(
                            chunks.subList(start, end)));
                    }
                }
                updateKnowledgeAfterStore(storeContext);
                selfProxy.completeIndexTask(task, document, totalChunks);
                if (StringUtil.hasText(session.getSessionId())) {
                    documentImportPreviewService.remove(session.getSessionId());
                }
                deleteChunkSnapshotAfterCompletion(
                    asString(task.getPayloadJson().get("chunkSnapshotPath")));
                cleanupCsvTableSnapshotAfterCompletion(document);
            } catch (TaskOwnershipLostException ownershipLost) {
                // 旧执行失去 token 后不能清理可能已由新执行写入的索引或分块。
                LOG.warn("向量化任务执行令牌已失效，停止迟到回滚: taskId={}, documentId={}",
                    taskId, document.getId());
            } catch (Exception e) {
                LOG.error("文档向量化任务失败: taskId={}, documentId={}", taskId, document.getId(), e);
                if (!ownsRunningTask(task)) {
                    LOG.warn("向量化任务已失去执行权，跳过迟到失败和清理: taskId={}, documentId={}",
                        taskId, document.getId());
                    return;
                }
                clearPersistedChunks(document.getId());
                boolean rollbackSucceeded = true;
                if (storeContext != null && !storedChunks.isEmpty()) {
                    rollbackSucceeded = rollbackStoredChunks(
                        taskId, document.getId(), storeContext, storedChunks);
                }
                String failureCode = e instanceof CsvImportException csvError
                    ? csvError.getFailureCode()
                    : TASK_ERROR_INDEX_FAILED;
                markIndexFailed(
                    task, document,
                    resolveIndexFailureMessage(e, rollbackSucceeded),
                    failureCode,
                    e);
            } finally {
                closeStoreContext(storeContext);
            }
        } finally {
            executionLock.release();
        }
    }

    /**
     * 通过统一文档解析桥接处理异步解析任务。
     *
     * @param task 任务实体
     * @param document 文档实体
     * @param fileExt 文件后缀
     */
    private void handleBridgeParse(DocumentImportTask task,
                                   tech.easyflow.ai.entity.Document document,
                                   String fileExt) {
        if (!StringUtil.hasText(task.getProviderTaskId())) {
            submitBridgeParseTask(task, document, fileExt);
            return;
        }
        syncBridgeParseTask(task, document, fileExt);
    }

    private void handleDefaultParse(DocumentImportTask task,
                                    tech.easyflow.ai.entity.Document document,
                                    String fileExt) {
        LOG.info("开始同步解析文档: taskId={}, documentId={}, fileName={}, fileExt={}",
            task.getId(), document.getId(), document.getTitle(), fileExt);
        if ("csv".equals(fileExt)) {
            CsvTableSnapshotService.CsvParseResult csvResult =
                csvTableSnapshotService.parse(
                    document.getDocumentPath(),
                    document.getCollectionId(),
                    document.getId(),
                    task.getExecutionToken());
            DocumentParsedResult parsedResult = new DocumentParsedResult();
            parsedResult.setFileName(document.getTitle());
            parsedResult.setPreferredText(csvResult.buildSummary());
            parsedResult.setPlainText(csvResult.buildSummary());
            parsedResult.setMetadata(csvResult.toMetadata());
            LOG.info("CSV 流式解析完成: taskId={}, documentId={}, rows={}, columns={}, encoding={}",
                task.getId(), document.getId(), csvResult.getRowCount(),
                csvResult.getColumnCount(), csvResult.getEncoding());
            boolean published = false;
            try {
                published = markParseSuccess(
                    task, document, parsedResult, fileExt, null);
            } finally {
                if (!published) {
                    snapshotCleanupService.scheduleCsvTableSnapshot(
                        document.getCollectionId(),
                        document.getId(),
                        csvResult.getManifestPath());
                }
            }
            return;
        }
        String normalizedContent = readFileContent(document.getDocumentPath(), document.getTitle());
        DocumentParsedResult parsedResult = new DocumentParsedResult();
        parsedResult.setFileName(document.getTitle());
        parsedResult.setPreferredText(normalizedContent);
        parsedResult.setPlainText(normalizedContent);
        parsedResult.setMetadata(Map.of("sourceFormat", fileExt));
        LOG.info("同步解析文档完成: taskId={}, documentId={}, fileName={}, fileExt={}, contentLength={}",
            task.getId(), document.getId(), document.getTitle(), fileExt, normalizedContent == null ? 0 : normalizedContent.length());
        markParseSuccess(task, document, parsedResult, fileExt, null);
    }

    /**
     * 发布解析结果并原子推进文档和批次状态。
     *
     * @param task 解析任务
     * @param document 文档实体
     * @param parsedResult 解析结果
     * @param sourceFormat 来源格式
     * @param providerTaskId 外部解析任务 ID
     * @return 结果是否由当前任务代次成功发布
     */
    private boolean markParseSuccess(
        DocumentImportTask task,
        tech.easyflow.ai.entity.Document document,
        DocumentParsedResult parsedResult,
        String sourceFormat,
        String providerTaskId) {
        parsedResult = normalizeParsedImagesForKnowledgeImport(document, parsedResult);
        ParsedKnowledgeContent parsedKnowledgeContent = buildParsedKnowledgeContent(document, parsedResult, sourceFormat);
        if (!StringUtil.hasText(parsedKnowledgeContent.documentLlmContent)) {
            throw new BusinessException("文档解析结果为空");
        }

        Map<String, Object> options = copyOptions(document.getOptions());
        options.put(DocumentImportKeys.KEY_DOCUMENT_SOURCE_FILE_EXT, sourceFormat);
        if (StringUtil.hasText(providerTaskId)) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PROVIDER_TASK_ID, providerTaskId);
        }
        if (parsedResult.getMetadata() != null && !parsedResult.getMetadata().isEmpty()) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_METADATA, new LinkedHashMap<String, Object>(parsedResult.getMetadata()));
            copyCsvParseMetadata(options, parsedResult.getMetadata());
        }
        if (parsedResult.getWarnings() != null && !parsedResult.getWarnings().isEmpty()) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_WARNINGS, new ArrayList<String>(parsedResult.getWarnings()));
        }
        if (parsedResult.getMetadata() != null) {
            Object imageUrls = parsedResult.getMetadata().get(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_URLS);
            if (imageUrls instanceof List<?>) {
                options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_URLS, new ArrayList<Object>((List<?>) imageUrls));
            }
            Object imageCount = parsedResult.getMetadata().get(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_COUNT);
            if (imageCount != null) {
                options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_COUNT, imageCount);
            }
            Object imageStoragePrefix = parsedResult.getMetadata().get(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_STORAGE_PREFIX);
            if (StringUtil.hasText(asString(imageStoragePrefix))) {
                options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_STORAGE_PREFIX, imageStoragePrefix);
            }
        }
        options.put(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN, parsedKnowledgeContent.documentRenderMarkdown);
        options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_ARTIFACT_SUMMARY, parsedKnowledgeContent.parseArtifactSummary);
        options.remove(DocumentImportKeys.KEY_DOCUMENT_TASK_ERROR_CODE);
        clearDocumentParseProgress(options);

        boolean automaticBatch = isAutomaticBatch(task.getBatchId());
        document.setContent(parsedKnowledgeContent.documentLlmContent);
        document.setDocumentType(sourceFormat);
        document.setOptions(options);
        document.setProcessStatus(DocumentProcessStatus.READY_FOR_SEGMENT.name());
        document.setProgressPercent(0);
        document.setTotalChunks(0);
        document.setCompletedChunks(0);
        document.setFailedChunks(0);
        document.setLastTaskError(null);
        if (!selfProxy.completeParseTask(task, document, automaticBatch)) {
            LOG.warn("解析任务所有权已失效，忽略迟到结果: taskId={}, documentId={}",
                task.getId(), document.getId());
            return false;
        }
        LOG.info("文档解析任务完成: taskId={}, documentId={}, processStatus={}, providerTaskId={}, contentLength={}",
            task.getId(), document.getId(), document.getProcessStatus(), providerTaskId,
            parsedKnowledgeContent.documentLlmContent.length());
        return true;
    }

    /**
     * 原子完成解析任务、文档状态与批次项状态。
     *
     * @param task 解析任务
     * @param document 已填充解析结果的文档
     * @param automaticBatch 是否为自动导入批次
     * @return 是否仍持有任务终态写入权
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean completeParseTask(DocumentImportTask task,
                                     tech.easyflow.ai.entity.Document document,
                                     boolean automaticBatch) {
        Date now = new Date();
        if (!finishTask(task, now, DocumentImportTaskStatus.COMPLETED, null)) {
            return false;
        }
        persistDocumentTaskState(document, now);
        if (automaticBatch) {
            // 先持久化 SPLIT/PENDING，再在提交后投递，确保服务退出后可由调度器恢复。
            enqueueAutomaticSplit(
                task.getBatchId(), task.getBatchItemId(), document);
        } else {
            updateBatchItem(task.getBatchItemId(),
                DocumentImportBatchItemStage.DONE,
                DocumentImportBatchItemStatus.COMPLETED,
                null);
        }
        return true;
    }

    /**
     * 原子完成分块任务、策略快照和 INDEX 待处理任务。
     *
     * @param task 分块任务
     * @param document 文档实体
     * @param session 分块结果
     * @param snapshotPath 分块快照路径
     * @param totalChunks 分块总数
     * @return 是否仍持有本轮分块任务执行权
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean completeSplitTask(
        DocumentImportTask task,
        tech.easyflow.ai.entity.Document document,
        DocumentImportDtos.PreviewSession session,
        String snapshotPath,
        int totalChunks) {
        Date now = new Date();
        if (!finishTask(
            task, now, DocumentImportTaskStatus.COMPLETED, null, null)) {
            return false;
        }
        mergeDocumentPreviewOptions(document, session, snapshotPath);
        updateDocumentIndexing(document, totalChunks);
        persistAppliedStrategy(task.getBatchItemId(), session.getStrategyConfig());

        Map<String, Object> payload = new LinkedHashMap<String, Object>();
        if (StringUtil.hasText(session.getSessionId())) {
            payload.put("previewSessionId", session.getSessionId());
        }
        payload.put("chunkSnapshotPath", snapshotPath);
        payload.put("totalChunks", totalChunks);
        DocumentImportTask indexTask =
            createTask(document, DocumentImportTaskPhase.INDEX, payload);
        updateBatchItem(task.getBatchItemId(),
            DocumentImportBatchItemStage.INDEX,
            DocumentImportBatchItemStatus.PENDING,
            null);
        dispatchIndexTaskAfterCommit(indexTask.getId());
        return true;
    }

    /**
     * 原子写入分块任务、文档和批次项失败状态。
     *
     * @param task 分块任务
     * @param documentId 文档 ID
     * @param errorMessage 用户可见错误信息
     * @param failureCode 稳定失败码
     * @param error 原始异常
     * @return 是否仍持有本轮分块任务执行权
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean failSplitTask(
        DocumentImportTask task,
        BigInteger documentId,
        String errorMessage,
        String failureCode,
        Throwable error) {
        Date now = new Date();
        if (!finishTask(
            task, now, DocumentImportTaskStatus.FAILED,
            errorMessage, failureCode)) {
            return false;
        }
        tech.easyflow.ai.entity.Document current = requireDocument(documentId);
        current.setProcessStatus(DocumentProcessStatus.SPLIT_FAILED.name());
        current.setProgressPercent(0);
        setDocumentTaskError(current, errorMessage, failureCode);
        persistDocumentTaskState(current, now);
        if (!interruptFailedTaskBatchInCurrentTransaction(
            task, errorMessage, failureCode, error)) {
            updateBatchItemFailure(
                task.getBatchItemId(),
                DocumentImportBatchItemStage.SPLIT,
                errorMessage,
                failureCode
            );
        }
        return true;
    }

    /**
     * 将解析结果中的图片上传到对象存储，并把 Markdown/结构化工件里的相对路径回写为可访问 URL。
     *
     * <p>该逻辑仅服务于知识库导入链路，避免把对象存储耦合下沉到通用解析 provider 或 MinerU 自身。</p>
     *
     * @param document 文档实体
     * @param parsedResult 解析结果
     * @return 已完成图片标准化的解析结果
     */
    private DocumentParsedResult normalizeParsedImagesForKnowledgeImport(tech.easyflow.ai.entity.Document document,
                                                                         DocumentParsedResult parsedResult) {
        if (document == null || parsedResult == null || parsedResult.getImages() == null || parsedResult.getImages().isEmpty()) {
            return parsedResult;
        }
        Map<String, String> storedImageUrls = uploadParsedImages(document, parsedResult.getImages());
        if (storedImageUrls.isEmpty()) {
            return parsedResult;
        }
        rewriteParsedResultImageReferences(parsedResult, storedImageUrls);
        parsedResult.getMetadata().put(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_URLS, new ArrayList<String>(new LinkedHashSet<String>(storedImageUrls.values())));
        parsedResult.getMetadata().put(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_COUNT, parsedResult.getImages().size());
        parsedResult.getMetadata().put(DocumentImportKeys.KEY_DOCUMENT_PARSE_IMAGE_STORAGE_PREFIX, buildKnowledgeParseImagePrePath(document));
        return parsedResult;
    }

    /**
     * 上传解析结果中的图片，并建立“原始相对路径/文件名 -> 最终 URL”的映射。
     *
     * @param document 文档实体
     * @param images 解析出的图片集合
     * @return 图片引用映射
     */
    private Map<String, String> uploadParsedImages(tech.easyflow.ai.entity.Document document,
                                                   List<DocumentImage> images) {
        Map<String, String> storedImageUrls = new LinkedHashMap<String, String>();
        if (document == null || images == null || images.isEmpty()) {
            return storedImageUrls;
        }
        Map<String, Integer> baseNameCounts = countImageBaseNames(images);
        String imagePrePath = buildKnowledgeParseImagePrePath(document);
        int imageIndex = 0;
        for (DocumentImage image : images) {
            if (image == null) {
                imageIndex++;
                continue;
            }
            byte[] imageBytes = resolveParsedImageContent(image);
            if (imageBytes.length == 0) {
                imageIndex++;
                continue;
            }
            String fileName = resolveParsedImageFileName(image, imageIndex);
            String mimeType = resolveParsedImageMimeType(image, fileName);
            CustomMultipartFile multipartFile = new CustomMultipartFile(imageBytes, fileName, fileName, mimeType);
            String storedUrl = storageService.save(multipartFile, imagePrePath);
            registerStoredImageUrl(storedImageUrls, image.getSourcePath(), storedUrl, baseNameCounts);
            registerStoredImageUrl(storedImageUrls, image.getName(), storedUrl, baseNameCounts);
            image.setSourcePath(storedUrl);
            image.setDataUrl(null);
            image.setContent(null);
            if (!StringUtil.hasText(image.getMimeType())) {
                image.setMimeType(mimeType);
            }
            imageIndex++;
        }
        return storedImageUrls;
    }

    /**
     * 重写解析结果中的 Markdown、图片块与结构化工件引用。
     *
     * @param parsedResult 解析结果
     * @param storedImageUrls 原始路径到最终 URL 的映射
     */
    private void rewriteParsedResultImageReferences(DocumentParsedResult parsedResult,
                                                    Map<String, String> storedImageUrls) {
        if (parsedResult == null || storedImageUrls == null || storedImageUrls.isEmpty()) {
            return;
        }
        String originalMarkdown = parsedResult.getMarkdown();
        String rewrittenMarkdown = rewriteMarkdownImageReferences(originalMarkdown, storedImageUrls);
        if (rewrittenMarkdown != null) {
            parsedResult.setMarkdown(rewrittenMarkdown);
            if (!StringUtil.hasText(parsedResult.getPreferredText()) || Objects.equals(parsedResult.getPreferredText(), originalMarkdown)) {
                parsedResult.setPreferredText(rewrittenMarkdown);
            }
            if (!StringUtil.hasText(parsedResult.getPlainText()) || Objects.equals(parsedResult.getPlainText(), originalMarkdown)) {
                parsedResult.setPlainText(rewrittenMarkdown);
            }
        }
        rewriteBlockImageReferences(parsedResult.getBlocks(), storedImageUrls);
        rewriteTableImageReferences(parsedResult.getTables(), storedImageUrls);
        rewriteDocumentImageReferences(parsedResult.getImages(), storedImageUrls);
        rewriteArtifactImageReferences(parsedResult.getArtifacts(), storedImageUrls);
    }

    /**
     * 收口关联文档已经不存在的孤儿任务。
     *
     * @param task 孤儿任务
     */
    private void finishMissingDocumentTask(DocumentImportTask task) {
        if (TransactionSynchronizationManager.isActualTransactionActive()) {
            finishMissingDocumentTaskInCurrentTransaction(task);
            return;
        }
        selfProxy.finishMissingDocumentTaskInNewTransaction(task);
    }

    /**
     * 在独立事务中原子收口缺少关联文档的任务与自动导入批次。
     *
     * @param task 孤儿任务
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void finishMissingDocumentTaskInNewTransaction(
        DocumentImportTask task) {
        finishMissingDocumentTaskInCurrentTransaction(task);
    }

    /**
     * 在当前事务中原子收口缺少关联文档的任务与自动导入批次。
     *
     * @param task 孤儿任务
     */
    private void finishMissingDocumentTaskInCurrentTransaction(
        DocumentImportTask task) {
        Date now = new Date();
        String errorMessage = "关联文档已删除，任务已自动清理";
        if (!finishTask(task, now, DocumentImportTaskStatus.FAILED, errorMessage)) {
            return;
        }
        DocumentImportBatchItemStage stage = toBatchItemStage(task.getPhase());
        finishRecoveredBatchState(
            task, stage, errorMessage, TASK_ERROR_EXECUTION_INTERRUPTED);
        LOG.warn("自动清理缺少关联文档的导入任务: taskId={}, documentId={}, phase={}, batchId={}",
            task.getId(), task.getDocumentId(), task.getPhase(), task.getBatchId());
    }

    /**
     * 尽力同步孤儿或超时任务的批次状态，批次数据缺失时不回滚任务清理结果。
     *
     * @param task 任务实体
     * @param stage 失败阶段
     * @param errorMessage 用户可见错误信息
     * @param failureCode 稳定失败码
     */
    private void finishRecoveredBatchState(DocumentImportTask task,
                                           DocumentImportBatchItemStage stage,
                                           String errorMessage,
        String failureCode) {
        try {
            boolean batchStopped = task.getBatchId() != null
                && interruptRecoveredBatch(
                    task, errorMessage, failureCode);
            if (!batchStopped) {
                updateBatchItemFailure(
                    task.getBatchItemId(),
                    stage,
                    errorMessage,
                    failureCode);
            }
        } catch (Exception error) {
            LOG.error("同步已回收任务的批次状态失败: taskId={}, batchId={}, batchItemId={}",
                task.getId(), task.getBatchId(), task.getBatchItemId(), error);
            if (task.getBatchId() != null) {
                throw error instanceof RuntimeException
                    ? (RuntimeException) error
                    : new IllegalStateException(
                        "同步已回收任务的批次状态失败", error);
            }
        }
    }

    /**
     * 在任务回收事务中强制中断所属批次。
     *
     * @param task 已回收任务
     * @param errorMessage 用户可见错误原因
     * @param failureCode 稳定失败码
     * @return 所属批次是否已经停止运行
     */
    private boolean interruptRecoveredBatch(DocumentImportTask task,
                                            String errorMessage,
                                            String failureCode) {
        Throwable error = new IllegalStateException(
            "文档导入任务恢复时触发批次中断: taskId=" + task.getId());
        if (TransactionSynchronizationManager.isActualTransactionActive()) {
            return documentImportBatchCircuitBreaker
                .interruptBatchInCurrentTransaction(
                    task.getBatchId(),
                    failureCode,
                    errorMessage,
                    error
                );
        } else {
            return documentImportBatchCircuitBreaker.interruptBatch(
                task.getBatchId(),
                failureCode,
                errorMessage,
                error
            );
        }
    }

    /**
     * 在任务失败状态事务内同步中断所属自动导入批次。
     *
     * <p>任务、文档、批次项与批次必须原子提交。批次中断写入失败时，
     * 当前失败事务整体回滚，消息保留待重试，避免留下永久 RUNNING 批次。</p>
     *
     * @param task 失败任务
     * @param errorMessage 用户可见错误原因
     * @param failureCode 稳定失败码
     * @param error 原始异常
     * @return 所属批次是否已经停止运行
     */
    private boolean interruptFailedTaskBatchInCurrentTransaction(
        DocumentImportTask task,
        String errorMessage,
        String failureCode,
        Throwable error) {
        if (task.getBatchId() == null) {
            return false;
        }
        return documentImportBatchCircuitBreaker
            .interruptBatchInCurrentTransaction(
                task.getBatchId(),
                failureCode,
                errorMessage,
                error
            );
    }

    /**
     * 将底层解析异常转换为稳定错误码。
     *
     * @param error 底层异常
     * @return 稳定错误码；无法归类时返回 {@code null}
     */
    private String resolveParseFailureCode(Throwable error) {
        Throwable current = error;
        String fallbackCode = null;
        while (current != null) {
            if (current instanceof CsvImportException csvError) {
                return csvError.getFailureCode();
            }
            if (current instanceof DocumentParseBridgeException bridgeError) {
                String bridgeCode = bridgeError.getCode();
                if ("unsupported_source".equals(bridgeCode)) {
                    return TASK_ERROR_UNSUPPORTED_DOCUMENT_SOURCE;
                }
                if ("request_build_failed".equals(bridgeCode)) {
                    return TASK_ERROR_INVALID_PARSE_REQUEST;
                }
                if ("source_load_failed".equals(bridgeCode)) {
                    return TASK_ERROR_DOCUMENT_SOURCE_UNAVAILABLE;
                }
                if ("service_not_enabled".equals(bridgeCode)) {
                    return TASK_ERROR_PARSE_SERVICE_UNAVAILABLE;
                }
                fallbackCode = TASK_ERROR_PARSE_FAILED;
            }
            if (current instanceof SocketTimeoutException) {
                return TASK_ERROR_PARSE_SERVICE_TIMEOUT;
            }
            if (current instanceof ConnectException
                || current instanceof NoRouteToHostException
                || current instanceof UnknownHostException) {
                return TASK_ERROR_PARSE_SERVICE_UNAVAILABLE;
            }
            String message = current.getMessage();
            if (StringUtil.hasText(message)) {
                String normalized = message.toLowerCase(Locale.ROOT);
                if (normalized.contains("unsupported file type")) {
                    return TASK_ERROR_UNSUPPORTED_DOCUMENT_SOURCE;
                }
                if (normalized.contains("timed out")
                    || normalized.contains("timeout")
                    || normalized.contains("超时")) {
                    return TASK_ERROR_PARSE_SERVICE_TIMEOUT;
                }
                if (HTTP_SERVER_ERROR_PATTERN.matcher(normalized).find()
                    || normalized.contains("service unavailable")
                    || normalized.contains("connection refused")
                    || normalized.contains("failed to connect")
                    || normalized.contains("no route to host")
                    || normalized.contains("unknown host")) {
                    return TASK_ERROR_PARSE_SERVICE_UNAVAILABLE;
                }
            }
            current = current.getCause();
        }
        return fallbackCode == null ? TASK_ERROR_PARSE_FAILED : fallbackCode;
    }

    /**
     * 将底层解析异常转换为可安全展示的错误信息。
     *
     * @param error 底层异常
     * @param errorCode 稳定错误码
     * @return 用户可见错误信息
     */
    private String resolveParseFailureMessage(Throwable error, String errorCode) {
        if (TASK_ERROR_PARSE_SERVICE_UNAVAILABLE.equals(errorCode)) {
            return "文档解析服务暂不可用，请稍后重试";
        }
        if (TASK_ERROR_PARSE_SERVICE_TIMEOUT.equals(errorCode)) {
            return "文档解析服务响应超时，请重试";
        }
        if (TASK_ERROR_DOCUMENT_SOURCE_UNAVAILABLE.equals(errorCode)) {
            return "文档文件读取失败，请联系管理员";
        }
        if (TASK_ERROR_UNSUPPORTED_DOCUMENT_SOURCE.equals(errorCode)) {
            return "文档格式或来源不受解析服务支持，请检查文件后继续";
        }
        if (TASK_ERROR_INVALID_PARSE_REQUEST.equals(errorCode)) {
            return "文档解析请求无效，请检查文件后继续";
        }
        if (error instanceof BusinessException) {
            return truncateError(error.getMessage());
        }
        return "文档解析失败，请检查文件或联系管理员后继续";
    }

    /**
     * 将分块异常转换为可安全展示的错误信息。
     *
     * @param error 原始异常
     * @return 用户可见错误信息
     */
    private String resolveSplitFailureMessage(Throwable error) {
        if (error instanceof BusinessException) {
            return truncateError(error.getMessage());
        }
        return "文档分块失败，请联系管理员排查后继续";
    }

    /**
     * 将解析任务标记为失败。
     *
     * @param task 解析任务
     * @param document 文档实体
     * @param errorMessage 用户可见错误信息
     * @param errorCode 稳定错误码
     * @param error 原始异常
     */
    private void markParseFailed(DocumentImportTask task,
                                 tech.easyflow.ai.entity.Document document,
                                 String errorMessage,
                                 String errorCode,
                                 Throwable error) {
        KnowledgeDocumentImportTaskAppService executor = selfProxy == null ? this : selfProxy;
        if (!executor.failParseTask(
            task, document, errorMessage, errorCode, error)) {
            LOG.warn("解析任务所有权已失效，忽略迟到失败: taskId={}, documentId={}",
                task.getId(), document.getId());
        }
    }

    /**
     * 原子写入解析任务、文档与批次项失败状态。
     *
     * @param task 解析任务
     * @param document 文档实体
     * @param errorMessage 用户可见错误信息
     * @param errorCode 稳定错误码
     * @param error 原始异常
     * @return 是否仍持有任务终态写入权
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean failParseTask(DocumentImportTask task,
                                 tech.easyflow.ai.entity.Document document,
                                 String errorMessage,
                                 String errorCode,
                                 Throwable error) {
        Date now = new Date();
        if (!finishTask(
            task, now, DocumentImportTaskStatus.FAILED,
            errorMessage, errorCode)) {
            return false;
        }
        document.setProcessStatus(DocumentProcessStatus.PARSE_FAILED.name());
        setDocumentTaskError(document, errorMessage, errorCode);
        persistDocumentTaskState(document, now);
        LOG.warn("文档解析任务失败: taskId={}, documentId={}, processStatus={}, error={}",
            task.getId(), document.getId(), DocumentProcessStatus.PARSE_FAILED.name(), errorMessage);

        if (!interruptFailedTaskBatchInCurrentTransaction(
            task, errorMessage, errorCode, error)) {
            updateBatchItemFailure(
                task.getBatchItemId(),
                DocumentImportBatchItemStage.PARSE,
                errorMessage,
                errorCode);
        }
        return true;
    }

    /**
     * 统计图片文件名，便于仅在“文件名唯一”时使用 basename 兜底匹配。
     *
     * @param images 图片集合
     * @return 文件名计数
     */
    private Map<String, Integer> countImageBaseNames(List<DocumentImage> images) {
        Map<String, Integer> counts = new LinkedHashMap<String, Integer>();
        if (images == null || images.isEmpty()) {
            return counts;
        }
        for (DocumentImage image : images) {
            if (image == null) {
                continue;
            }
            String baseName = resolveImageBaseName(image.getSourcePath(), image.getName());
            if (!StringUtil.hasText(baseName)) {
                continue;
            }
            counts.put(baseName, counts.getOrDefault(baseName, 0) + 1);
        }
        return counts;
    }

    /**
     * 构建知识库导入图片存储前缀。
     *
     * @param document 文档实体
     * @return 存储前缀
     */
    private String buildKnowledgeParseImagePrePath(tech.easyflow.ai.entity.Document document) {
        String documentSegment = buildKnowledgeParseDocumentSegment(document);
        return KNOWLEDGE_PARSE_IMAGE_CATEGORY + "/" + documentSegment + "/images";
    }

    /**
     * 构建对象存储中的文档级目录名。
     *
     * @param document 文档实体
     * @return 文档目录名
     */
    private String buildKnowledgeParseDocumentSegment(tech.easyflow.ai.entity.Document document) {
        String documentId = document == null || document.getId() == null ? "unknown" : document.getId().toString();
        String safeFileName = sanitizeStorageSegment(removeFileExtension(document == null ? null : document.getTitle()));
        return StringUtil.hasText(safeFileName) ? documentId + "_" + safeFileName : documentId;
    }

    /**
     * 去除文件扩展名，只保留主文件名。
     *
     * @param fileName 原始文件名
     * @return 主文件名
     */
    private String removeFileExtension(String fileName) {
        String pureFileName = fileName == null ? null : tech.easyflow.common.filestorage.utils.PathGeneratorUtil.getPureFileName(fileName);
        if (!StringUtil.hasText(pureFileName)) {
            return null;
        }
        int dotIndex = pureFileName.lastIndexOf('.');
        if (dotIndex <= 0) {
            return pureFileName;
        }
        return pureFileName.substring(0, dotIndex);
    }

    /**
     * 将目录片段标准化为对象存储友好的名称。
     *
     * @param rawSegment 原始片段
     * @return 规范化后的片段
     */
    private String sanitizeStorageSegment(String rawSegment) {
        if (!StringUtil.hasText(rawSegment)) {
            return null;
        }
        String normalized = rawSegment.trim()
            .replaceAll("[\\\\/]+", "_")
            .replaceAll("[^\\p{IsAlphabetic}\\p{IsDigit}_-]+", "_")
            .replaceAll("_+", "_")
            .replaceAll("^_+|_+$", "");
        if (!StringUtil.hasText(normalized)) {
            return "document";
        }
        return normalized.length() > 80 ? normalized.substring(0, 80) : normalized;
    }

    /**
     * 解析 data URL，提取二进制图片内容。
     *
     * @param dataUrl data URL
     * @return 图片字节数组
     */
    private byte[] decodeDataUrl(String dataUrl) {
        if (!StringUtil.hasText(dataUrl)) {
            return new byte[0];
        }
        int commaIndex = dataUrl.indexOf(',');
        if (commaIndex < 0 || commaIndex >= dataUrl.length() - 1) {
            return new byte[0];
        }
        try {
            return Base64.getDecoder().decode(dataUrl.substring(commaIndex + 1));
        } catch (IllegalArgumentException exception) {
            throw new BusinessException("解析文档图片数据失败");
        }
    }

    /**
     * 解析图片 MIME 类型。
     *
     * @param image 图片实体
     * @param fileName 文件名
     * @return MIME 类型
     */
    private String resolveParsedImageMimeType(DocumentImage image, String fileName) {
        if (image != null && StringUtil.hasText(image.getMimeType())) {
            return image.getMimeType();
        }
        String mimeType = URLConnection.guessContentTypeFromName(fileName);
        return StringUtil.hasText(mimeType) ? mimeType : "image/png";
    }

    /**
     * 为上传对象存储生成稳定文件名。
     *
     * @param image 图片实体
     * @param imageIndex 图片序号
     * @return 文件名
     */
    private String resolveParsedImageFileName(DocumentImage image, int imageIndex) {
        String candidate = resolveImageBaseName(image == null ? null : image.getSourcePath(), image == null ? null : image.getName());
        if (!StringUtil.hasText(candidate)) {
            candidate = "image_" + (imageIndex + 1) + ".png";
        }
        String pureFileName = tech.easyflow.common.filestorage.utils.PathGeneratorUtil.getPureFileName(candidate);
        String normalized = pureFileName.replaceAll("[^\\p{IsAlphabetic}\\p{IsDigit}._-]+", "_");
        if (!StringUtil.hasText(normalized)) {
            normalized = "image_" + (imageIndex + 1) + ".png";
        }
        if (!normalized.contains(".")) {
            normalized = normalized + ".png";
        }
        return normalized;
    }

    /**
     * 注册图片路径映射。
     *
     * @param storedImageUrls 映射表
     * @param originalPath 原始路径
     * @param storedUrl 最终 URL
     * @param baseNameCounts 文件名计数
     */
    private void registerStoredImageUrl(Map<String, String> storedImageUrls,
                                        String originalPath,
                                        String storedUrl,
                                        Map<String, Integer> baseNameCounts) {
        if (!StringUtil.hasText(originalPath) || !StringUtil.hasText(storedUrl)) {
            return;
        }
        storedImageUrls.putIfAbsent(originalPath, storedUrl);
        storedImageUrls.putIfAbsent(trimLeadingCurrentPath(originalPath), storedUrl);
        String baseName = resolveImageBaseName(originalPath, null);
        if (StringUtil.hasText(baseName) && baseNameCounts.getOrDefault(baseName, 0) == 1) {
            storedImageUrls.putIfAbsent(baseName, storedUrl);
        }
    }

    /**
     * 重写 Markdown 中的图片引用。
     *
     * @param markdown Markdown 内容
     * @param storedImageUrls 图片映射
     * @return 回写后的 Markdown
     */
    private String rewriteMarkdownImageReferences(String markdown, Map<String, String> storedImageUrls) {
        if (!StringUtil.hasText(markdown) || storedImageUrls == null || storedImageUrls.isEmpty()) {
            return markdown;
        }
        Matcher matcher = MARKDOWN_IMAGE_PATTERN.matcher(markdown);
        StringBuffer output = new StringBuffer();
        boolean replaced = false;
        while (matcher.find()) {
            String originalPath = matcher.group(1);
            String resolvedUrl = resolveStoredImageUrl(originalPath, storedImageUrls);
            if (!StringUtil.hasText(resolvedUrl)) {
                matcher.appendReplacement(output, Matcher.quoteReplacement(matcher.group(0)));
                continue;
            }
            String replacedSegment = matcher.group(0).replace("(" + originalPath + ")", "(" + resolvedUrl + ")");
            matcher.appendReplacement(output, Matcher.quoteReplacement(replacedSegment));
            replaced = true;
        }
        if (!replaced) {
            return markdown;
        }
        matcher.appendTail(output);
        return output.toString();
    }

    /**
     * 回写块级图片引用。
     *
     * @param blocks 内容块集合
     * @param storedImageUrls 图片映射
     */
    private void rewriteBlockImageReferences(List<DocumentBlock> blocks, Map<String, String> storedImageUrls) {
        if (blocks == null || blocks.isEmpty()) {
            return;
        }
        for (DocumentBlock block : blocks) {
            if (block == null || !StringUtil.hasText(block.getImagePath())) {
                continue;
            }
            String resolvedUrl = resolveStoredImageUrl(block.getImagePath(), storedImageUrls);
            if (StringUtil.hasText(resolvedUrl)) {
                block.setImagePath(resolvedUrl);
            }
        }
    }

    /**
     * 回写表格图片引用。
     *
     * @param tables 表格集合
     * @param storedImageUrls 图片映射
     */
    private void rewriteTableImageReferences(List<DocumentTable> tables, Map<String, String> storedImageUrls) {
        if (tables == null || tables.isEmpty()) {
            return;
        }
        for (DocumentTable table : tables) {
            if (table == null || !StringUtil.hasText(table.getImagePath())) {
                continue;
            }
            String resolvedUrl = resolveStoredImageUrl(table.getImagePath(), storedImageUrls);
            if (StringUtil.hasText(resolvedUrl)) {
                table.setImagePath(resolvedUrl);
            }
        }
    }

    /**
     * 回写图片实体引用。
     *
     * @param images 图片集合
     * @param storedImageUrls 图片映射
     */
    private void rewriteDocumentImageReferences(List<DocumentImage> images, Map<String, String> storedImageUrls) {
        if (images == null || images.isEmpty()) {
            return;
        }
        for (DocumentImage image : images) {
            if (image == null || !StringUtil.hasText(image.getSourcePath())) {
                continue;
            }
            String resolvedUrl = resolveStoredImageUrl(image.getSourcePath(), storedImageUrls);
            if (StringUtil.hasText(resolvedUrl)) {
                image.setSourcePath(resolvedUrl);
            }
        }
    }

    /**
     * 回写结构化工件中的图片路径。
     *
     * @param artifacts 解析工件
     * @param storedImageUrls 图片映射
     */
    private void rewriteArtifactImageReferences(DocumentParseArtifacts artifacts,
                                                Map<String, String> storedImageUrls) {
        if (artifacts == null) {
            return;
        }
        rewriteImageReferencesInNode(artifacts.getMiddleJson(), storedImageUrls);
        rewriteImageReferencesInNode(artifacts.getContentList(), storedImageUrls);
        rewriteImageReferencesInNode(artifacts.getModelOutput(), storedImageUrls);
        if (artifacts.getExtraJsonArtifacts() != null && !artifacts.getExtraJsonArtifacts().isEmpty()) {
            for (Map.Entry<String, Object> entry : artifacts.getExtraJsonArtifacts().entrySet()) {
                Object normalizedNode = normalizeArtifactNode(entry.getValue());
                rewriteImageReferencesInNode(normalizedNode, storedImageUrls);
                entry.setValue(normalizedNode);
            }
        }
    }

    private Object normalizeArtifactNode(Object node) {
        if (node == null || node instanceof Map<?, ?> || node instanceof List<?>) {
            return node;
        }
        return JSON.parse(JSON.toJSONString(node));
    }

    private byte[] resolveParsedImageContent(DocumentImage image) {
        if (image == null) {
            return new byte[0];
        }
        if (image.getContent() != null && image.getContent().length > 0) {
            return image.getContent();
        }
        return decodeDataUrl(image.getDataUrl());
    }

    /**
     * 递归回写 JSON/Object 树里的图片路径字段。
     *
     * @param node 当前节点
     * @param storedImageUrls 图片映射
     */
    @SuppressWarnings("unchecked")
    private void rewriteImageReferencesInNode(Object node, Map<String, String> storedImageUrls) {
        if (node instanceof Map<?, ?> mapNode) {
            for (Map.Entry<?, ?> entry : ((Map<?, ?>) mapNode).entrySet()) {
                Object value = entry.getValue();
                if (entry.getKey() instanceof String key) {
                    if (value instanceof String stringValue && isImageReferenceKey(key)) {
                        String resolvedUrl = resolveStoredImageUrl(stringValue, storedImageUrls);
                        if (StringUtil.hasText(resolvedUrl)) {
                            ((Map<Object, Object>) mapNode).put(entry.getKey(), resolvedUrl);
                        }
                        continue;
                    }
                    if (value instanceof List<?> listValue && isImageReferenceArrayKey(key)) {
                        rewriteImageReferenceList(listValue, storedImageUrls);
                        continue;
                    }
                }
                rewriteImageReferencesInNode(value, storedImageUrls);
            }
            return;
        }
        if (node instanceof List<?> listNode) {
            for (Object item : listNode) {
                rewriteImageReferencesInNode(item, storedImageUrls);
            }
        }
    }

    /**
     * 判断字段是否为图片路径字段。
     *
     * @param key 字段名
     * @return 是否是图片路径字段
     */
    private boolean isImageReferenceKey(String key) {
        return "img_path".equals(key)
            || "image_path".equals(key)
            || "imagePath".equals(key)
            || "sourcePath".equals(key);
    }

    private boolean isImageReferenceArrayKey(String key) {
        return "sourcePaths".equals(key)
            || DocumentImportKeys.KEY_DOCUMENT_IMAGE_REFS.equals(key);
    }

    @SuppressWarnings("unchecked")
    private void rewriteImageReferenceList(List<?> imageReferences, Map<String, String> storedImageUrls) {
        for (int index = 0; index < imageReferences.size(); index++) {
            Object item = imageReferences.get(index);
            if (item instanceof String imagePath) {
                String resolvedUrl = resolveStoredImageUrl(imagePath, storedImageUrls);
                if (StringUtil.hasText(resolvedUrl)) {
                    ((List<Object>) imageReferences).set(index, resolvedUrl);
                }
                continue;
            }
            rewriteImageReferencesInNode(item, storedImageUrls);
        }
    }

    /**
     * 根据原始路径查找最终图片 URL。
     *
     * @param originalPath 原始路径
     * @param storedImageUrls 图片映射
     * @return 最终 URL
     */
    private String resolveStoredImageUrl(String originalPath, Map<String, String> storedImageUrls) {
        if (!StringUtil.hasText(originalPath) || storedImageUrls == null || storedImageUrls.isEmpty()) {
            return null;
        }
        String exact = storedImageUrls.get(originalPath);
        if (StringUtil.hasText(exact)) {
            return exact;
        }
        String trimmedPath = trimLeadingCurrentPath(originalPath);
        exact = storedImageUrls.get(trimmedPath);
        if (StringUtil.hasText(exact)) {
            return exact;
        }
        String baseName = resolveImageBaseName(originalPath, null);
        if (StringUtil.hasText(baseName)) {
            return storedImageUrls.get(baseName);
        }
        return null;
    }

    /**
     * 获取图片基础文件名。
     *
     * @param sourcePath 原始路径
     * @param fallbackName 兜底名称
     * @return 基础文件名
     */
    private String resolveImageBaseName(String sourcePath, String fallbackName) {
        String candidate = StringUtil.hasText(sourcePath) ? sourcePath : fallbackName;
        if (!StringUtil.hasText(candidate)) {
            return null;
        }
        return tech.easyflow.common.filestorage.utils.PathGeneratorUtil.getPureFileName(trimLeadingCurrentPath(candidate));
    }

    /**
     * 去掉 Markdown 相对路径里的 "./" 前缀，便于统一匹配。
     *
     * @param path 原始路径
     * @return 规范化后的路径
     */
    private String trimLeadingCurrentPath(String path) {
        if (!StringUtil.hasText(path)) {
            return path;
        }
        return path.replaceFirst("^\\./+", "");
    }

    /**
     * 原子完成向量化任务、文档状态与批次项状态。
     *
     * @param task 向量化任务
     * @param document 文档实体
     * @param totalChunks 分块总数
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void completeIndexTask(DocumentImportTask task,
                                  tech.easyflow.ai.entity.Document document,
                                  int totalChunks) {
        Date now = new Date();
        if (!finishTask(task, now, DocumentImportTaskStatus.COMPLETED, null)) {
            throw new TaskOwnershipLostException(task.getId());
        }
        document.setProcessStatus(DocumentProcessStatus.COMPLETED.name());
        document.setTotalChunks(totalChunks);
        document.setCompletedChunks(totalChunks);
        document.setFailedChunks(0);
        document.setProgressPercent(100);
        clearDocumentTaskError(document);
        persistDocumentTaskState(document, now);
        LOG.info("文档向量化任务完成: taskId={}, documentId={}, processStatus={}, totalChunks={}",
            task.getId(), document.getId(), DocumentProcessStatus.COMPLETED.name(), totalChunks);

        updateBatchItem(task.getBatchItemId(),
            DocumentImportBatchItemStage.DONE,
            DocumentImportBatchItemStatus.COMPLETED,
            null);
    }

    /**
     * 使用通用索引失败码标记任务失败。
     *
     * @param task 索引任务
     * @param document 文档
     * @param errorMessage 用户可见错误信息
     */
    private void markIndexFailed(
        DocumentImportTask task,
        tech.easyflow.ai.entity.Document document,
        String errorMessage) {
        markIndexFailed(
            task,
            document,
            errorMessage,
            TASK_ERROR_INDEX_FAILED,
            new IllegalStateException(errorMessage)
        );
    }

    /**
     * 将索引任务标记为失败并保留阶段特定失败码。
     *
     * @param task 索引任务
     * @param document 文档
     * @param errorMessage 用户可见错误信息
     * @param failureCode 稳定失败码
     * @param error 原始异常
     */
    private void markIndexFailed(
        DocumentImportTask task,
        tech.easyflow.ai.entity.Document document,
        String errorMessage,
        String failureCode,
        Throwable error) {
        KnowledgeDocumentImportTaskAppService executor = selfProxy == null ? this : selfProxy;
        if (!executor.failIndexTask(
            task, document, errorMessage, failureCode, error)) {
            LOG.warn("向量化任务所有权已失效，忽略迟到失败: taskId={}, documentId={}",
                task.getId(), document.getId());
        }
    }

    /**
     * 生成可持久化的索引失败原因。
     *
     * @param error 原始异常
     * @param rollbackSucceeded 外部索引是否完整回滚
     * @return 对用户安全且可执行的失败原因
     */
    private String resolveIndexFailureMessage(Exception error, boolean rollbackSucceeded) {
        String message = error instanceof BusinessException && StringUtil.hasText(error.getMessage())
            ? error.getMessage()
            : DEFAULT_INDEX_FAILURE_MESSAGE;
        return rollbackSucceeded ? message : message + ROLLBACK_FAILURE_SUFFIX;
    }

    /**
     * 原子写入向量化任务、文档与批次项失败状态。
     *
     * @param task 向量化任务
     * @param document 文档实体
     * @param errorMessage 用户可见错误信息
     * @return 是否仍持有任务终态写入权
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean failIndexTask(
        DocumentImportTask task,
        tech.easyflow.ai.entity.Document document,
        String errorMessage) {
        return failIndexTask(
            task,
            document,
            errorMessage,
            TASK_ERROR_INDEX_FAILED,
            new IllegalStateException(errorMessage)
        );
    }

    /**
     * 原子写入带阶段失败码的向量化任务、文档与批次项失败状态。
     *
     * @param task 向量化任务
     * @param document 文档实体
     * @param errorMessage 用户可见错误信息
     * @param failureCode 稳定失败码
     * @param error 原始异常
     * @return 是否仍持有任务终态写入权
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean failIndexTask(DocumentImportTask task,
                                 tech.easyflow.ai.entity.Document document,
                                 String errorMessage,
                                 String failureCode,
                                 Throwable error) {
        String resolvedFailureCode = StringUtil.hasText(failureCode)
            ? failureCode
            : TASK_ERROR_INDEX_FAILED;
        Date now = new Date();
        if (!finishTask(
            task, now, DocumentImportTaskStatus.FAILED,
            errorMessage, resolvedFailureCode)) {
            return false;
        }
        tech.easyflow.ai.entity.Document current = requireDocument(document.getId());
        current.setProcessStatus(DocumentProcessStatus.INDEX_FAILED.name());
        current.setTotalChunks(defaultInt(current.getTotalChunks()));
        current.setCompletedChunks(0);
        current.setFailedChunks(defaultInt(current.getTotalChunks()));
        current.setProgressPercent(0);
        setDocumentTaskError(current, errorMessage, resolvedFailureCode);
        persistDocumentTaskState(current, now);
        LOG.warn("文档向量化任务失败: taskId={}, documentId={}, processStatus={}, completedChunks={}, totalChunks={}, error={}",
            task.getId(),
            document.getId(),
            DocumentProcessStatus.INDEX_FAILED.name(),
            defaultInt(current.getCompletedChunks()),
            defaultInt(current.getTotalChunks()),
            errorMessage);

        if (!interruptFailedTaskBatchInCurrentTransaction(
            task, errorMessage, resolvedFailureCode, error)) {
            updateBatchItemFailure(
                task.getBatchItemId(),
                DocumentImportBatchItemStage.INDEX,
                errorMessage,
                resolvedFailureCode);
        }
        return true;
    }

    /**
     * 尝试将待处理任务抢占为运行中。
     *
     * @param taskId 任务 ID
     * @return 是否抢占成功
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean tryMarkTaskRunning(BigInteger taskId) {
        DocumentImportTask task = requireTask(taskId);
        String phase = task.getPhase();
        String lockKey = "easyflow:lock:document-import:capacity:" + phase;
        LockHandle lockHandle = redisLockExecutor.tryAcquire(
            lockKey, Duration.ZERO, Duration.ofSeconds(5));
        if (lockHandle == null) {
            return false;
        }
        try {
            int globalLimit = resolvePhaseRunningLimit(phase);
            long globalRunning = documentImportTaskService.count(
                QueryWrapper.create()
                    .eq(DocumentImportTask::getPhase, phase)
                    .eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.RUNNING.name())
            );
            if (globalRunning >= Math.max(1, globalLimit)) {
                return false;
            }
            if (task.getBatchId() != null && DocumentImportTaskPhase.PARSE.name().equals(phase)) {
                long batchRunning = documentImportTaskService.count(
                    QueryWrapper.create()
                        .eq(DocumentImportTask::getBatchId, task.getBatchId())
                        .eq(DocumentImportTask::getPhase, phase)
                        .eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.RUNNING.name())
                );
                if (batchRunning >= Math.max(1, bulkProperties.getPerBatchParseMaxRunning())) {
                    return false;
                }
            }

            Date now = new Date();
            String executionToken = UUID.randomUUID().toString();
            Date leaseUntil = new Date(
                now.getTime()
                    + Math.max(
                        1L,
                        bulkProperties.getInterruptionTimeout().toMillis()));
            return documentImportTaskMapper.claimPending(
                taskId,
                executionToken,
                leaseUntil,
                now,
                resolveOperatorId()
            ) > 0;
        } finally {
            lockHandle.release();
        }
    }

    /**
     * 原子写入解析服务任务 ID，避免超时回收后迟到响应重新激活本地任务。
     *
     * @param task 本地解析任务
     * @param providerTaskId 解析服务任务 ID
     * @return 本地任务仍在运行且写入成功时返回 {@code true}
     */
    private boolean updateTaskProvider(DocumentImportTask task, String providerTaskId) {
        Date now = new Date();
        Map<String, Object> payload = new LinkedHashMap<String, Object>(task.getPayloadJson());
        payload.put("providerTaskId", providerTaskId);
        DocumentImportTask update = new DocumentImportTask();
        update.setProviderTaskId(providerTaskId);
        update.setPayloadJson(payload);
        update.setModified(now);
        update.setModifiedBy(resolveOperatorId());
        int updated = documentImportTaskMapper.updateByQuery(update,
            QueryWrapper.create()
                .eq(DocumentImportTask::getId, task.getId())
                .eq(DocumentImportTask::getPhase, DocumentImportTaskPhase.PARSE.name())
                .eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.RUNNING.name())
                .eq(DocumentImportTask::getExecutionToken, task.getExecutionToken())
                .isNull(DocumentImportTask::getProviderTaskId));
        if (updated <= 0) {
            return false;
        }
        task.setProviderTaskId(providerTaskId);
        task.setPayloadJson(payload);
        task.setModified(now);
        return true;
    }

    private void updateDocumentIndexing(tech.easyflow.ai.entity.Document document, int totalChunks) {
        Date now = new Date();
        document.setProcessStatus(DocumentProcessStatus.INDEXING.name());
        document.setTotalChunks(totalChunks);
        document.setCompletedChunks(0);
        document.setFailedChunks(0);
        document.setProgressPercent(0);
        clearDocumentTaskError(document);
        persistDocumentTaskState(document, now);
    }

    /**
     * 按当前失败或待处理状态原子领取向量化启动权。
     *
     * @param document 文档实体
     * @param totalChunks 分块总数
     * @return 是否领取成功
     */
    private boolean claimDocumentIndexing(tech.easyflow.ai.entity.Document document, int totalChunks) {
        Date now = new Date();
        String expectedStatus = document.getProcessStatus();
        tech.easyflow.ai.entity.Document update = new tech.easyflow.ai.entity.Document();
        update.setProcessStatus(DocumentProcessStatus.INDEXING.name());
        update.setTotalChunks(totalChunks);
        update.setCompletedChunks(0);
        update.setFailedChunks(0);
        update.setProgressPercent(0);
        update.setOptions(document.getOptions());
        update.setTaskModifiedAt(now);
        update.setModified(now);
        update.setModifiedBy(resolveOperatorId());
        int updated = documentMapper.updateByQuery(update,
            QueryWrapper.create()
                .eq(tech.easyflow.ai.entity.Document::getId, document.getId())
                .eq(tech.easyflow.ai.entity.Document::getProcessStatus, expectedStatus));
        if (updated <= 0) {
            return false;
        }
        document.setProcessStatus(DocumentProcessStatus.INDEXING.name());
        document.setTotalChunks(totalChunks);
        document.setCompletedChunks(0);
        document.setFailedChunks(0);
        document.setProgressPercent(0);
        document.setTaskModifiedAt(now);
        documentImportTaskStatusStreamService.publishAfterCommit(document.getId());
        return true;
    }

    /**
     * 按解析失败状态原子领取重试权。
     *
     * @param document 文档实体
     * @return 是否领取成功
     */
    private boolean claimDocumentParseRetry(tech.easyflow.ai.entity.Document document) {
        Date now = new Date();
        tech.easyflow.ai.entity.Document update = new tech.easyflow.ai.entity.Document();
        update.setProcessStatus(DocumentProcessStatus.PARSING.name());
        update.setProgressPercent(0);
        update.setTaskModifiedAt(now);
        update.setModified(now);
        update.setModifiedBy(resolveOperatorId());
        int updated = documentMapper.updateByQuery(update,
            QueryWrapper.create()
                .eq(tech.easyflow.ai.entity.Document::getId, document.getId())
                .eq(tech.easyflow.ai.entity.Document::getProcessStatus,
                    DocumentProcessStatus.PARSE_FAILED.name()));
        if (updated > 0) {
            document.setProcessStatus(DocumentProcessStatus.PARSING.name());
            return true;
        }
        return false;
    }

    /**
     * 按指定失败状态原子领取重新分块重试权。
     *
     * @param document 文档实体
     * @param expectedStatus 领取前预期的失败状态
     * @return 是否领取成功
     */
    private boolean claimDocumentSplitRetry(tech.easyflow.ai.entity.Document document,
                                            String expectedStatus) {
        Date now = new Date();
        tech.easyflow.ai.entity.Document update = new tech.easyflow.ai.entity.Document();
        update.setProcessStatus(DocumentProcessStatus.SPLITTING.name());
        update.setProgressPercent(0);
        update.setTaskModifiedAt(now);
        update.setModified(now);
        update.setModifiedBy(resolveOperatorId());
        int updated = documentMapper.updateByQuery(update,
            QueryWrapper.create()
                .eq(tech.easyflow.ai.entity.Document::getId, document.getId())
                .eq(tech.easyflow.ai.entity.Document::getProcessStatus,
                    expectedStatus));
        if (updated > 0) {
            document.setProcessStatus(DocumentProcessStatus.SPLITTING.name());
            return true;
        }
        return false;
    }

    /**
     * 移除文档对旧分块快照的引用，确保重试只能使用新分块结果。
     *
     * @param document 待重新分块的文档
     * @return 失效的旧快照路径
     */
    private String invalidateChunkSnapshot(tech.easyflow.ai.entity.Document document) {
        if (document.getOptions() == null || document.getOptions().isEmpty()) {
            return null;
        }
        String snapshotPath = optionAsString(
            document.getOptions(), DocumentImportKeys.KEY_DOCUMENT_CHUNK_SNAPSHOT_PATH);
        if (!StringUtil.hasText(snapshotPath)) {
            return null;
        }
        Map<String, Object> options =
            new LinkedHashMap<String, Object>(document.getOptions());
        options.remove(DocumentImportKeys.KEY_DOCUMENT_CHUNK_SNAPSHOT_PATH);
        document.setOptions(options);
        return snapshotPath;
    }

    private void updateDocumentIndexProgress(BigInteger documentId, int totalChunks, int completedChunks) {
        int progressPercent = Math.min(100, totalChunks <= 0 ? 0 : (completedChunks * 100) / totalChunks);
        tech.easyflow.ai.entity.Document document = requireDocument(documentId);
        Date now = new Date();
        document.setCompletedChunks(completedChunks);
        document.setFailedChunks(0);
        document.setProgressPercent(progressPercent);
        persistDocumentTaskState(document, now);
        LOG.info("文档向量化进度更新: documentId={}, completedChunks={}, totalChunks={}, progressPercent={}",
            documentId, completedChunks, totalChunks, progressPercent);
    }

    private void resetDocumentForParseRetry(tech.easyflow.ai.entity.Document document) {
        Date now = new Date();
        document.setProcessStatus(DocumentProcessStatus.PARSING.name());
        clearDocumentTaskError(document);
        document.setProgressPercent(0);
        Map<String, Object> options = copyOptions(document.getOptions());
        clearDocumentParseProgress(options);
        document.setOptions(options);
        persistDocumentTaskState(document, now);
    }

    private void updateDocumentParseProgress(tech.easyflow.ai.entity.Document document,
                                             DocumentParseTaskInfo taskInfo) {
        if (document == null || taskInfo == null) {
            return;
        }
        tech.easyflow.ai.entity.Document current = requireDocument(document.getId());
        Map<String, Object> options = copyOptions(current.getOptions());
        options.put(DocumentImportKeys.KEY_DOCUMENT_PROVIDER_TASK_ID, taskInfo.getTaskId());
        if (taskInfo.getProgressPercent() != null) {
            current.setProgressPercent(taskInfo.getProgressPercent());
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_PROGRESS_PERCENT, taskInfo.getProgressPercent());
        }
        if (StringUtil.hasText(taskInfo.getCurrentStage())) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_CURRENT_STAGE, taskInfo.getCurrentStage());
        }
        if (taskInfo.getProcessedItems() != null) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_PROCESSED_ITEMS, taskInfo.getProcessedItems());
        }
        if (taskInfo.getTotalItems() != null) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_TOTAL_ITEMS, taskInfo.getTotalItems());
        }
        if (StringUtil.hasText(taskInfo.getStatusMessage())) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_STATUS_MESSAGE, taskInfo.getStatusMessage());
        }
        current.setOptions(options);
        persistDocumentTaskState(current, new Date());
    }

    /**
     * 持久化文档任务状态并推送局部状态刷新事件。
     *
     * @param document 文档实体
     * @param now 当前时间
     */
    private void persistDocumentTaskState(tech.easyflow.ai.entity.Document document, Date now) {
        document.setTaskModifiedAt(now);
        document.setModified(now);
        document.setModifiedBy(resolveOperatorId());
        documentMapper.update(document, false);
        documentImportTaskStatusStreamService.publishAfterCommit(document.getId());
    }

    /**
     * 收口任务终态更新，避免不同阶段遗漏错误信息或完成时间。
     *
     * @param task 任务实体
     * @param now 当前时间
     * @param status 终态
     * @param errorSummary 错误摘要
     */
    private boolean finishTask(DocumentImportTask task,
                               Date now,
                               DocumentImportTaskStatus status,
                               String errorSummary) {
        return finishTask(task, now, status, errorSummary, null);
    }

    /**
     * 使用执行令牌收口任务终态。
     *
     * @param task 任务实体
     * @param now 当前时间
     * @param status 终态
     * @param errorSummary 错误摘要
     * @param failureCode 稳定失败码
     * @return 是否仍持有执行权
     */
    private boolean finishTask(DocumentImportTask task,
                               Date now,
                               DocumentImportTaskStatus status,
                               String errorSummary,
                               String failureCode) {
        int updated = documentImportTaskMapper.finishOwned(
            task.getId(),
            task.getExecutionToken(),
            status.name(),
            errorSummary,
            failureCode,
            now,
            resolveOperatorId()
        );
        if (updated > 0) {
            task.setStatus(status.name());
            task.setErrorSummary(errorSummary);
            task.setFailureCode(failureCode);
            task.setLeaseUntil(null);
            task.setFinishedAt(now);
            task.setModified(now);
            return true;
        }
        return false;
    }

    private DocumentImportDtos.PreviewSession buildPreviewSessionForDocument(DocumentCollection knowledge,
                                                                            tech.easyflow.ai.entity.Document document,
                                                                            StrategyConfig requestedStrategy) {
        if (!StringUtil.hasText(document.getContent())) {
            throw new BusinessException("文档尚未完成解析");
        }
        String sourceFormat = normalizeSourceFormat(document);
        if ("csv".equals(sourceFormat)) {
            StrategyConfig strategyConfig =
                resolveOfficeStrategyConfig(document, requestedStrategy, sourceFormat);
            String tableSnapshotPath = optionAsString(
                document.getOptions(),
                DocumentImportKeys.KEY_DOCUMENT_CSV_TABLE_SNAPSHOT_PATH);
            if (!StringUtil.hasText(tableSnapshotPath)) {
                throw new CsvImportException(
                    CsvTableSnapshotService.FAILURE_SNAPSHOT_CORRUPTED,
                    "CSV 表格快照不存在，请重新解析");
            }
            return csvTableSnapshotService.buildChunkSnapshot(
                document, tableSnapshotPath, strategyConfig);
        }
        if (isOfficeDocument(sourceFormat)) {
            return buildOfficePreviewSession(knowledge, document, requestedStrategy, sourceFormat);
        }
        AnalysisResult analysis = ragIngestionService.analyze(document.getContent(), normalizeSourceFormat(document));
        StrategyConfig strategyConfig = resolveStrategyConfig(knowledge, requestedStrategy, analysis);
        List<RagChunk> previewChunks =
            splitWithAutoParagraphFallback(analysis, requestedStrategy, strategyConfig);
        if (previewChunks == null || previewChunks.isEmpty()) {
            throw new BusinessException("未生成有效分块，请调整策略后重试");
        }
        validatePreviewChunkRanges(previewChunks, analysis.getNormalizedContent());
        List<DocumentChunk> documentChunks = buildDocumentChunks(document, previewChunks);
        DocumentImportDtos.PreviewSession session = new DocumentImportDtos.PreviewSession();
        session.setKnowledgeId(knowledge.getId());
        session.setDocumentId(document.getId());
        session.setFilePath(document.getDocumentPath());
        session.setFileName(document.getTitle());
        session.setSourceFormat(normalizeSourceFormat(document));
        session.setStrategyConfig(strategyConfig);
        session.setAnalysis(analysis);
        session.setDocument(document);
        session.setDocumentChunks(documentChunks);
        session.setPreviewChunks(previewChunks);
        session.setCreatedAt(new Date());
        return session;
    }

    /**
     * 执行分块，并在 AUTO 推荐策略未产生分块时回退到自然段长度拆分。
     *
     * @param analysis 文档分析结果
     * @param requestedStrategy 用户请求的策略配置
     * @param effectiveStrategy 解析后的实际策略配置
     * @return 分块结果
     */
    private List<RagChunk> splitWithAutoParagraphFallback(AnalysisResult analysis,
                                                          StrategyConfig requestedStrategy,
                                                          StrategyConfig effectiveStrategy) {
        List<RagChunk> chunks = ragIngestionService.split(analysis, effectiveStrategy);
        if (chunks != null && !chunks.isEmpty()) {
            return chunks;
        }
        if (requestedStrategy == null
            || !RagStrategyCodes.AUTO.equals(requestedStrategy.getStrategyCode())
            || RagStrategyCodes.PARAGRAPH_LENGTH.equals(effectiveStrategy.getStrategyCode())) {
            return chunks;
        }

        String recommendedStrategyCode = effectiveStrategy.getStrategyCode();
        StrategyConfig fallbackStrategy = effectiveStrategy.copy();
        fallbackStrategy.setStrategyCode(RagStrategyCodes.PARAGRAPH_LENGTH);
        List<RagChunk> fallbackChunks = ragIngestionService.split(analysis, fallbackStrategy);
        if (fallbackChunks != null && !fallbackChunks.isEmpty()) {
            effectiveStrategy.setStrategyCode(RagStrategyCodes.PARAGRAPH_LENGTH);
            LOG.warn(
                "AUTO 推荐策略未生成分块，已回退自然段长度拆分: recommendedStrategy={}, chunkSize={}, overlapSize={}",
                recommendedStrategyCode,
                fallbackStrategy.getChunkSize(),
                fallbackStrategy.getOverlapSize());
        }
        return fallbackChunks;
    }

    private DocumentImportDtos.PreviewSession buildOfficePreviewSession(DocumentCollection knowledge,
                                                                       tech.easyflow.ai.entity.Document document,
                                                                       StrategyConfig requestedStrategy,
                                                                       String sourceFormat) {
        StrategyConfig strategyConfig = resolveOfficeStrategyConfig(document, requestedStrategy, sourceFormat);
        List<DocumentChunk> documentChunks = buildOfficeDocumentChunks(document, sourceFormat, strategyConfig);
        if (documentChunks.isEmpty()) {
            throw new BusinessException("当前 Office 文档未生成有效分块");
        }
        AnalysisResult analysis = buildOfficeAnalysis(document, sourceFormat, strategyConfig, documentChunks.size());

        DocumentImportDtos.PreviewSession session = new DocumentImportDtos.PreviewSession();
        session.setKnowledgeId(knowledge.getId());
        session.setDocumentId(document.getId());
        session.setFilePath(document.getDocumentPath());
        session.setFileName(document.getTitle());
        session.setSourceFormat(sourceFormat);
        session.setStrategyConfig(strategyConfig);
        session.setAnalysis(analysis);
        session.setDocument(document);
        session.setDocumentChunks(documentChunks);
        session.setPreviewChunks(new ArrayList<RagChunk>());
        session.setCreatedAt(new Date());
        return session;
    }

    private StrategyConfig resolveOfficeStrategyConfig(tech.easyflow.ai.entity.Document document,
                                                       StrategyConfig requestedStrategy,
                                                       String sourceFormat) {
        StrategyConfig config = StrategyConfig.defaults();
        if (requestedStrategy != null) {
            mergeStrategyConfig(config, requestedStrategy);
        }
        if ("pptx".equals(sourceFormat)) {
            config.setStrategyCode(OFFICE_PPTX_PAGE_STRATEGY);
            config.setRowsPerChunk(null);
            return config;
        }
        Integer rowsPerChunk = requestedStrategy == null ? null : requestedStrategy.getRowsPerChunk();
        if (rowsPerChunk == null || rowsPerChunk <= 0) {
            StrategyConfig storedStrategy = null;
            try {
                storedStrategy = readStoredStrategy(document);
            } catch (Exception ignored) {
                storedStrategy = null;
            }
            rowsPerChunk = storedStrategy == null ? null : storedStrategy.getRowsPerChunk();
        }
        if (rowsPerChunk == null || rowsPerChunk <= 0) {
            rowsPerChunk = 10;
        }
        config.setStrategyCode("csv".equals(sourceFormat)
            ? TABLE_ROW_STRATEGY
            : OFFICE_XLSX_ROW_WINDOW_STRATEGY);
        config.setRowsPerChunk(rowsPerChunk);
        return config;
    }

    private AnalysisResult buildOfficeAnalysis(tech.easyflow.ai.entity.Document document,
                                               String sourceFormat,
                                               StrategyConfig strategyConfig,
                                               int totalChunks) {
        AnalysisResult analysis = new AnalysisResult();
        analysis.setSourceFormat(sourceFormat);
        analysis.setNormalizedContent(document.getContent());
        analysis.setRecommendedStrategyCode(strategyConfig.getStrategyCode());
        analysis.setRecommendedStrategyLabel(resolveStrategyLabel(sourceFormat, strategyConfig));
        analysis.setConfidence(1.0D);
        analysis.getFeatures().put("officeChunkCount", totalChunks);
        analysis.getFeatures().put("rowsPerChunk", strategyConfig.getRowsPerChunk());
        analysis.getFeatures().put("sourceFormat", sourceFormat);
        return analysis;
    }

    private ParsedKnowledgeContent buildParsedKnowledgeContent(tech.easyflow.ai.entity.Document document,
                                                               DocumentParsedResult parsedResult,
                                                               String sourceFormat) {
        String renderMarkdown = StringUtil.hasText(parsedResult.getMarkdown())
            ? parsedResult.getMarkdown()
            : resolvePreferredText(parsedResult);
        Map<String, Object> parseArtifactSummary = extractParseArtifactSummary(parsedResult, sourceFormat);
        if (!isOfficeDocument(sourceFormat)) {
            return new ParsedKnowledgeContent(resolvePreferredText(parsedResult), renderMarkdown, parseArtifactSummary);
        }
        List<DocumentChunk> documentChunks = buildOfficeDocumentChunks(document, sourceFormat,
            resolveOfficeStrategyConfig(document, null, sourceFormat), parseArtifactSummary);
        StringBuilder llmContent = new StringBuilder();
        for (DocumentChunk chunk : documentChunks) {
            if (!StringUtil.hasText(chunk.getContent())) {
                continue;
            }
            if (llmContent.length() > 0) {
                llmContent.append("\n\n");
            }
            llmContent.append(chunk.getContent().trim());
        }
        return new ParsedKnowledgeContent(llmContent.toString().trim(), renderMarkdown, parseArtifactSummary);
    }

    private Map<String, Object> extractParseArtifactSummary(DocumentParsedResult parsedResult,
                                                            String sourceFormat) {
        if (parsedResult == null || parsedResult.getArtifacts() == null
            || parsedResult.getArtifacts().getExtraJsonArtifacts() == null) {
            return new LinkedHashMap<String, Object>();
        }
        Object artifact = parsedResult.getArtifacts().getExtraJsonArtifacts().get(sourceFormat);
        Object normalizedArtifact = normalizeArtifactNode(artifact);
        if (normalizedArtifact instanceof Map<?, ?> map) {
            return new LinkedHashMap<String, Object>((Map<String, Object>) map);
        }
        return new LinkedHashMap<String, Object>();
    }

    private boolean isOfficeDocument(String sourceFormat) {
        return "pptx".equals(sourceFormat) || "xlsx".equals(sourceFormat);
    }

    private List<DocumentChunk> buildOfficeDocumentChunks(tech.easyflow.ai.entity.Document document,
                                                          String sourceFormat,
                                                          StrategyConfig strategyConfig) {
        Object rawArtifactSummary = document.getOptions() == null
            ? null
            : document.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_PARSE_ARTIFACT_SUMMARY);
        Map<String, Object> parseArtifactSummary = rawArtifactSummary instanceof Map<?, ?>
            ? new LinkedHashMap<String, Object>((Map<String, Object>) rawArtifactSummary)
            : new LinkedHashMap<String, Object>();
        return buildOfficeDocumentChunks(document, sourceFormat, strategyConfig, parseArtifactSummary);
    }

    private List<DocumentChunk> buildOfficeDocumentChunks(tech.easyflow.ai.entity.Document document,
                                                          String sourceFormat,
                                                          StrategyConfig strategyConfig,
                                                          Map<String, Object> parseArtifactSummary) {
        List<DocumentChunk> chunks;
        if ("pptx".equals(sourceFormat)) {
            chunks = buildPptxDocumentChunks(document, parseArtifactSummary);
        } else if ("xlsx".equals(sourceFormat)) {
            int rowsPerChunk = strategyConfig == null || strategyConfig.getRowsPerChunk() == null
                ? 10
                : Math.max(1, strategyConfig.getRowsPerChunk());
            chunks = buildXlsxDocumentChunks(document, parseArtifactSummary, rowsPerChunk);
        } else {
            chunks = new ArrayList<DocumentChunk>();
        }
        return enforceDocumentChunkHardLimit(chunks);
    }

    @SuppressWarnings("unchecked")
    private List<DocumentChunk> buildPptxDocumentChunks(tech.easyflow.ai.entity.Document document,
                                                        Map<String, Object> parseArtifactSummary) {
        List<DocumentChunk> chunks = new ArrayList<DocumentChunk>();
        List<Map<String, Object>> slides = readMapList(parseArtifactSummary.get("slides"));
        if (slides.isEmpty()) {
            String renderMarkdown = readDocumentRenderMarkdown(document);
            if (StringUtil.hasText(renderMarkdown)) {
                chunks.add(buildOfficeChunk(document, 1, stripMarkdownImageSyntax(renderMarkdown), renderMarkdown,
                    new LinkedHashMap<String, Object>() {{
                        put("chunkType", RagChunkTypes.SECTION);
                        put("sourceLabel", "Slide 1");
                        put("headingPath", Collections.singletonList("Slide 1"));
                    }}));
            }
            return chunks;
        }
        int sorting = 1;
        for (Map<String, Object> slide : slides) {
            int slideIndex = asInteger(slide.get("slideIndex"), sorting - 1);
            String sourceLabel = "Slide " + (slideIndex + 1);
            String title = asString(slide.get("title"));
            String ocrMarkdown = asString(slide.get("ocrMarkdown"));
            String imagePath = asString(slide.get("imagePath"));
            String imageName = asString(slide.get("imageName"));
            String renderMarkdown = buildPptxChunkRenderMarkdown(slideIndex, title, imageName, imagePath, ocrMarkdown);
            String llmContent = buildPptxChunkLlmContent(slideIndex, title, ocrMarkdown);
            Map<String, Object> options = new LinkedHashMap<String, Object>();
            options.put("chunkType", RagChunkTypes.SECTION);
            options.put("sourceLabel", sourceLabel);
            options.put("headingPath", buildHeadingPath(sourceLabel, title));
            options.put("charCount", llmContent.length());
            options.put("tokenEstimate", Math.max(1, llmContent.length() / 4));
            options.put("partNo", 1);
            options.put("partTotal", 1);
            options.put("warnings", readStringList(slide.get("warnings")));
            options.put(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN, renderMarkdown);
            options.put(DocumentImportKeys.KEY_DOCUMENT_PAGE_INDEX, slideIndex + 1);
            options.put(DocumentImportKeys.KEY_DOCUMENT_IMAGE_REFS, imagePath == null
                ? new ArrayList<String>()
                : new ArrayList<String>(Collections.singletonList(imagePath)));
            options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_ARTIFACT_SUMMARY, new LinkedHashMap<String, Object>(slide));
            chunks.add(buildOfficeChunk(document, sorting++, llmContent, renderMarkdown, options));
        }
        return chunks;
    }

    private List<DocumentChunk> buildXlsxDocumentChunks(tech.easyflow.ai.entity.Document document,
                                                        Map<String, Object> parseArtifactSummary,
                                                        int rowsPerChunk) {
        List<DocumentChunk> chunks = new ArrayList<DocumentChunk>();
        List<Map<String, Object>> sheets = readMapList(parseArtifactSummary.get("sheets"));
        List<Map<String, Object>> cellImages = readMapList(parseArtifactSummary.get("cellImages"));
        int sorting = 1;
        for (Map<String, Object> sheet : sheets) {
            String sheetName = asString(sheet.get("sheetName"));
            List<Map<String, Object>> sheetRows = readMapList(sheet.get("rows"));
            List<Map<String, Object>> sheetImages = filterSheetImages(cellImages, sheetName);
            if (sheetRows.isEmpty()) {
                if (sheetImages.isEmpty()) {
                    continue;
                }
                chunks.add(buildXlsxImageOnlyChunk(document, sorting++, sheetName, sheet, sheetImages));
                continue;
            }
            Map<String, Object> headerRow = sheetRows.get(0);
            List<Map<String, Object>> dataRows = sheetRows.size() > 1
                ? new ArrayList<Map<String, Object>>(sheetRows.subList(1, sheetRows.size()))
                : new ArrayList<Map<String, Object>>();
            if (dataRows.isEmpty()) {
                List<DocumentChunk> windowChunks = buildXlsxWindowChunks(
                    document, sorting, sheetName, sheet, headerRow,
                    new ArrayList<Map<String, Object>>(), sheetImages);
                chunks.addAll(windowChunks);
                sorting += windowChunks.size();
                continue;
            }
            for (int start = 0; start < dataRows.size(); start += rowsPerChunk) {
                int end = Math.min(start + rowsPerChunk, dataRows.size());
                List<Map<String, Object>> windowRows = new ArrayList<Map<String, Object>>(dataRows.subList(start, end));
                List<DocumentChunk> windowChunks = buildXlsxWindowChunks(
                    document, sorting, sheetName, sheet, headerRow,
                    windowRows, sheetImages);
                chunks.addAll(windowChunks);
                sorting += windowChunks.size();
            }
        }
        if (chunks.isEmpty()) {
            String renderMarkdown = readDocumentRenderMarkdown(document);
            if (StringUtil.hasText(renderMarkdown)) {
                Map<String, Object> options = new LinkedHashMap<String, Object>();
                options.put("chunkType", RagChunkTypes.SECTION);
                options.put("sourceLabel", document.getTitle());
                options.put("headingPath", Collections.singletonList(document.getTitle()));
                chunks.add(buildOfficeChunk(document, 1, stripMarkdownImageSyntax(renderMarkdown), renderMarkdown, options));
            }
        }
        return chunks;
    }

    /**
     * 构建 XLSX 行窗口分块。无图片窗口与 CSV 共用表格构建器，图片窗口保留现有 OCR 元信息。
     *
     * @param document 文档
     * @param sorting 起始排序号
     * @param sheetName Sheet 名称
     * @param sheetSummary Sheet 摘要
     * @param headerRow 表头行
     * @param windowRows 数据行
     * @param sheetImages Sheet 图片
     * @return 一个或多个分块
     */
    private List<DocumentChunk> buildXlsxWindowChunks(
        tech.easyflow.ai.entity.Document document,
        int sorting,
        String sheetName,
        Map<String, Object> sheetSummary,
        Map<String, Object> headerRow,
        List<Map<String, Object>> windowRows,
        List<Map<String, Object>> sheetImages) {
        int headerRowIndex = asInteger(headerRow.get("rowIndex"), 0);
        int rowStart = windowRows.isEmpty() ? headerRowIndex + 1 : asInteger(windowRows.get(0).get("rowIndex"), headerRowIndex) + 1;
        int rowEnd = windowRows.isEmpty()
            ? rowStart
            : asInteger(windowRows.get(windowRows.size() - 1).get("rowIndex"), rowStart - 1) + 1;
        List<Map<String, Object>> selectedImages = selectWindowImages(sheetImages, rowStart, rowEnd, windowRows.isEmpty());
        if (selectedImages.isEmpty()) {
            List<Map<String, Object>> allRows =
                new ArrayList<Map<String, Object>>();
            allRows.add(headerRow);
            allRows.addAll(windowRows);
            int maxCol = resolveMaxColumnCount(allRows);
            if (maxCol > 0) {
                List<String> headers =
                    resolveTabularRowValues(headerRow, maxCol, true);
                List<TabularRowWindowChunkBuilder.TabularRow> rows =
                    new ArrayList<TabularRowWindowChunkBuilder.TabularRow>();
                for (Map<String, Object> row : windowRows) {
                    rows.add(new TabularRowWindowChunkBuilder.TabularRow(
                        asInteger(row.get("rowIndex"), 0) + 1,
                        resolveTabularRowValues(row, maxCol, false)));
                }
                List<DocumentChunk> chunks = tabularChunkBuilder.build(
                    document.getId(), document.getCollectionId(), sheetName,
                    headers, rows, sorting, RagChunkTypes.SECTION);
                for (DocumentChunk chunk : chunks) {
                    Map<String, Object> options =
                        new LinkedHashMap<String, Object>(chunk.getOptions());
                    options.put(
                        DocumentImportKeys.KEY_DOCUMENT_ROW_START, rowStart);
                    options.put(
                        DocumentImportKeys.KEY_DOCUMENT_ROW_END, rowEnd);
                    options.put(
                        DocumentImportKeys.KEY_DOCUMENT_PARSE_ARTIFACT_SUMMARY,
                        buildXlsxChunkSummary(
                            sheetSummary, rowStart, rowEnd, selectedImages));
                    options.put(
                        DocumentImportKeys.KEY_DOCUMENT_IMAGE_REFS,
                        new ArrayList<String>());
                    chunk.setOptions(options);
                }
                return chunks;
            }
        }
        String renderMarkdown = buildXlsxChunkRenderMarkdown(sheetName, headerRow, windowRows, selectedImages);
        String llmContent = buildXlsxChunkLlmContent(sheetName, headerRow, windowRows, selectedImages);
        Map<String, Object> options = new LinkedHashMap<String, Object>();
        options.put("chunkType", RagChunkTypes.SECTION);
        options.put("sourceLabel", sheetName + " · " + rowStart + "-" + rowEnd + " 行");
        options.put("headingPath", Collections.singletonList(sheetName));
        options.put("charCount", llmContent.length());
        options.put("tokenEstimate", Math.max(1, llmContent.length() / 4));
        options.put("partNo", 1);
        options.put("partTotal", 1);
        options.put("warnings", new ArrayList<String>());
        options.put(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN, renderMarkdown);
        options.put(DocumentImportKeys.KEY_DOCUMENT_SHEET_NAME, sheetName);
        options.put(DocumentImportKeys.KEY_DOCUMENT_ROW_START, rowStart);
        options.put(DocumentImportKeys.KEY_DOCUMENT_ROW_END, rowEnd);
        options.put(DocumentImportKeys.KEY_DOCUMENT_IMAGE_REFS, collectImageRefs(selectedImages));
        options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_ARTIFACT_SUMMARY, buildXlsxChunkSummary(sheetSummary, rowStart, rowEnd, selectedImages));
        return Collections.singletonList(
            buildOfficeChunk(document, sorting, llmContent, renderMarkdown, options));
    }

    private DocumentChunk buildXlsxImageOnlyChunk(tech.easyflow.ai.entity.Document document,
                                                  int sorting,
                                                  String sheetName,
                                                  Map<String, Object> sheetSummary,
                                                  List<Map<String, Object>> sheetImages) {
        String renderMarkdown = buildXlsxImageOnlyRenderMarkdown(sheetName, sheetImages);
        String llmContent = buildXlsxImageOnlyLlmContent(sheetName, sheetImages);
        Map<String, Object> options = new LinkedHashMap<String, Object>();
        options.put("chunkType", RagChunkTypes.SECTION);
        options.put("sourceLabel", sheetName);
        options.put("headingPath", Collections.singletonList(sheetName));
        options.put("charCount", llmContent.length());
        options.put("tokenEstimate", Math.max(1, llmContent.length() / 4));
        options.put("partNo", 1);
        options.put("partTotal", 1);
        options.put("warnings", new ArrayList<String>());
        options.put(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN, renderMarkdown);
        options.put(DocumentImportKeys.KEY_DOCUMENT_SHEET_NAME, sheetName);
        options.put(DocumentImportKeys.KEY_DOCUMENT_IMAGE_REFS, collectImageRefs(sheetImages));
        options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_ARTIFACT_SUMMARY, buildXlsxChunkSummary(sheetSummary, null, null, sheetImages));
        return buildOfficeChunk(document, sorting, llmContent, renderMarkdown, options);
    }

    private DocumentChunk buildOfficeChunk(tech.easyflow.ai.entity.Document document,
                                           int sorting,
                                           String llmContent,
                                           String renderMarkdown,
                                           Map<String, Object> options) {
        DocumentChunk chunk = new DocumentChunk();
        chunk.setId(generateId(chunk));
        chunk.setDocumentId(document.getId());
        chunk.setDocumentCollectionId(document.getCollectionId());
        chunk.setSorting(sorting);
        chunk.setContent(llmContent);
        Map<String, Object> normalizedOptions = options == null
            ? new LinkedHashMap<String, Object>()
            : new LinkedHashMap<String, Object>(options);
        normalizedOptions.put(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN, renderMarkdown);
        chunk.setOptions(normalizedOptions);
        return chunk;
    }

    /**
     * 对 Office 分块应用统一的 BGE-M3 上下文硬限制。
     *
     * @param chunks Office 构建器生成的原始分块
     * @return 内容均处于安全预算内且排序连续的分块
     */
    private List<DocumentChunk> enforceDocumentChunkHardLimit(
        List<DocumentChunk> chunks) {
        List<DocumentChunk> safeChunks = new ArrayList<DocumentChunk>();
        if (chunks == null || chunks.isEmpty()) {
            return safeChunks;
        }
        int sorting = 1;
        for (DocumentChunk chunk : chunks) {
            String content = chunk == null || chunk.getContent() == null
                ? ""
                : chunk.getContent();
            List<BgeM3ChunkSafety.ChunkRange> parts =
                BgeM3ChunkSafety.splitToHardLimit(content);
            if (parts.isEmpty()) {
                continue;
            }
            for (int index = 0; index < parts.size(); index++) {
                BgeM3ChunkSafety.ChunkRange part = parts.get(index);
                String partContent =
                    content.substring(part.getStart(), part.getEnd()).trim();
                if (!StringUtil.hasText(partContent)) {
                    continue;
                }
                DocumentChunk safeChunk = parts.size() == 1
                    ? chunk
                    : copyDocumentChunkPart(chunk);
                safeChunk.setSorting(sorting++);
                safeChunk.setContent(partContent);
                Map<String, Object> options = copyOptions(safeChunk.getOptions());
                options.put("charCount", partContent.length());
                options.put("tokenEstimate", Math.max(
                    1, BgeM3ChunkSafety.estimateContentTokens(partContent)));
                options.put("partNo", index + 1);
                options.put("partTotal", parts.size());
                if (parts.size() > 1) {
                    options.put("hardSplit", Boolean.TRUE);
                    options.put(
                        "hardSplitTokenLimit",
                        RagDefaults.BGE_M3_HARD_CHUNK_TOKEN_LIMIT);
                    boundSplitRenderMarkdown(options, partContent);
                }
                safeChunk.setOptions(options);
                safeChunks.add(safeChunk);
            }
        }
        return safeChunks;
    }

    /**
     * 复制一个待继续切分的文档块，并为子块生成独立主键。
     *
     * @param source 原始分块
     * @return 不共享可变元数据的子块
     */
    private DocumentChunk copyDocumentChunkPart(DocumentChunk source) {
        DocumentChunk copy = new DocumentChunk();
        copy.setId(generateId(copy));
        copy.setDocumentId(source.getDocumentId());
        copy.setDocumentCollectionId(source.getDocumentCollectionId());
        copy.setOptions(copyOptions(source.getOptions()));
        return copy;
    }

    /**
     * 避免强制子块继续复制整页超长渲染正文。
     *
     * @param options 子块元数据
     * @param partContent 当前子块正文
     */
    private void boundSplitRenderMarkdown(
        Map<String, Object> options,
        String partContent) {
        Object renderMarkdown =
            options.get(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN);
        if (renderMarkdown instanceof String
            && !BgeM3ChunkSafety.isWithinHardLimit((String) renderMarkdown)) {
            options.put(
                DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN,
                partContent);
        }
    }

    private String buildPptxChunkRenderMarkdown(int slideIndex,
                                                String title,
                                                String imageName,
                                                String imagePath,
                                                String ocrMarkdown) {
        StringBuilder builder = new StringBuilder();
        builder.append("# Slide ").append(slideIndex + 1).append("\n\n");
        if (StringUtil.hasText(title)) {
            builder.append("## ").append(title.trim()).append("\n\n");
        }
        if (StringUtil.hasText(imagePath)) {
            builder.append("![")
                .append(StringUtil.hasText(imageName) ? imageName : "slide-" + (slideIndex + 1))
                .append("](")
                .append(imagePath)
                .append(")\n\n");
        }
        if (StringUtil.hasText(ocrMarkdown)) {
            builder.append(ocrMarkdown.trim());
        }
        return builder.toString().trim();
    }

    private String buildPptxChunkLlmContent(int slideIndex,
                                            String title,
                                            String ocrMarkdown) {
        StringBuilder builder = new StringBuilder();
        builder.append("Slide ").append(slideIndex + 1);
        if (StringUtil.hasText(title)) {
            builder.append("\n标题：").append(title.trim());
        }
        if (StringUtil.hasText(ocrMarkdown)) {
            builder.append("\n\n").append(stripMarkdownImageSyntax(ocrMarkdown));
        }
        return builder.toString().trim();
    }

    private String buildXlsxChunkRenderMarkdown(String sheetName,
                                                Map<String, Object> headerRow,
                                                List<Map<String, Object>> windowRows,
                                                List<Map<String, Object>> selectedImages) {
        StringBuilder builder = new StringBuilder();
        builder.append("# ").append(sheetName).append("\n\n");
        builder.append(buildMarkdownTable(headerRow, windowRows));
        appendXlsxImageAppendix(builder, sheetName, selectedImages);
        return builder.toString().trim();
    }

    private String buildXlsxChunkLlmContent(String sheetName,
                                            Map<String, Object> headerRow,
                                            List<Map<String, Object>> windowRows,
                                            List<Map<String, Object>> selectedImages) {
        StringBuilder builder = new StringBuilder();
        builder.append("# ").append(sheetName).append("\n\n");
        builder.append(buildMarkdownTable(headerRow, windowRows));
        if (!selectedImages.isEmpty()) {
            builder.append("\n\n图片 OCR：\n");
            for (Map<String, Object> image : selectedImages) {
                builder.append("- ")
                    .append(asString(image.get("anchorCell")))
                    .append("：")
                    .append(StringUtil.hasText(asString(image.get("ocrText"))) ? asString(image.get("ocrText")) : "无文本")
                    .append("\n");
            }
        }
        return stripMarkdownImageSyntax(builder.toString().trim());
    }

    private String buildXlsxImageOnlyRenderMarkdown(String sheetName,
                                                    List<Map<String, Object>> sheetImages) {
        StringBuilder builder = new StringBuilder();
        builder.append("# ").append(sheetName).append("\n\n");
        builder.append("## ").append(sheetName).append(" 图片内容\n\n");
        for (Map<String, Object> image : sheetImages) {
            builder.append("[IMG:").append(asString(image.get("referenceKey"))).append("]\n\n");
        }
        appendXlsxImageAppendix(builder, sheetName, sheetImages);
        return builder.toString().trim();
    }

    private String buildXlsxImageOnlyLlmContent(String sheetName,
                                                List<Map<String, Object>> sheetImages) {
        StringBuilder builder = new StringBuilder();
        builder.append("# ").append(sheetName).append("\n\n图片 OCR：\n");
        for (Map<String, Object> image : sheetImages) {
            builder.append("- ")
                .append(asString(image.get("anchorCell")))
                .append("：")
                .append(StringUtil.hasText(asString(image.get("ocrText"))) ? asString(image.get("ocrText")) : "无文本")
                .append("\n");
        }
        return builder.toString().trim();
    }

    private String buildMarkdownTable(Map<String, Object> headerRow,
                                      List<Map<String, Object>> windowRows) {
        List<Map<String, Object>> tableRows = new ArrayList<Map<String, Object>>();
        tableRows.add(headerRow);
        tableRows.addAll(windowRows);
        int maxCol = resolveMaxColumnCount(tableRows);
        if (maxCol <= 0) {
            return "_empty sheet_";
        }
        List<String> headerCells = resolveMarkdownRowValues(headerRow, maxCol, true);
        StringBuilder builder = new StringBuilder();
        builder.append("| ").append(joinMarkdownCells(headerCells)).append(" |\n");
        builder.append("|");
        for (int index = 0; index < maxCol; index++) {
            builder.append(" --- |");
        }
        builder.append("\n");
        for (Map<String, Object> row : windowRows) {
            builder.append("| ").append(joinMarkdownCells(resolveMarkdownRowValues(row, maxCol, false))).append(" |\n");
        }
        return builder.toString().trim();
    }

    private void appendXlsxImageAppendix(StringBuilder builder,
                                         String sheetName,
                                         List<Map<String, Object>> images) {
        if (images == null || images.isEmpty()) {
            return;
        }
        builder.append("\n\n## ").append(sheetName).append(" 图片说明\n\n");
        for (Map<String, Object> image : images) {
            String referenceKey = asString(image.get("referenceKey"));
            String sourcePath = asString(image.get("sourcePath"));
            builder.append("![")
                .append(referenceKey)
                .append("](")
                .append(sourcePath)
                .append(")\n\n");
            builder.append("- 占位符：[IMG:").append(referenceKey).append("]\n");
            builder.append("- 锚点：").append(asString(image.get("anchorCell"))).append("\n");
            builder.append("- OCR：")
                .append(StringUtil.hasText(asString(image.get("ocrText"))) ? asString(image.get("ocrText")) : "")
                .append("\n\n");
        }
    }

    private int resolveMaxColumnCount(List<Map<String, Object>> rows) {
        int maxCol = 0;
        for (Map<String, Object> row : rows) {
            List<Map<String, Object>> cells = readMapList(row == null ? null : row.get("cells"));
            for (Map<String, Object> cell : cells) {
                maxCol = Math.max(maxCol, asInteger(cell.get("columnIndex"), 0) + 1);
            }
        }
        return maxCol;
    }

    private List<String> resolveMarkdownRowValues(Map<String, Object> row,
                                                  int maxCol,
                                                  boolean headerRow) {
        List<String> rawValues =
            resolveTabularRowValues(row, maxCol, headerRow);
        List<String> values = new ArrayList<String>(rawValues.size());
        for (String value : rawValues) {
            values.add(escapeMarkdownCell(value));
        }
        return values;
    }

    /**
     * 将 XLSX 行转换为通用表格单元格值。
     *
     * @param row XLSX 行
     * @param maxCol 最大列数
     * @param headerRow 是否为表头
     * @return 固定列数的原始值
     */
    private List<String> resolveTabularRowValues(
        Map<String, Object> row,
        int maxCol,
        boolean headerRow) {
        List<String> values = new ArrayList<String>(Collections.nCopies(maxCol, ""));
        if (row == null) {
            return values;
        }
        for (Map<String, Object> cell : readMapList(row.get("cells"))) {
            int colIndex = asInteger(cell.get("columnIndex"), 0);
            if (colIndex < 0 || colIndex >= maxCol) {
                continue;
            }
            String baseText = asString(cell.get("text"));
            String mergedText = mergeCellTextWithImages(baseText, readStringList(cell.get("imageKeys")));
            if (headerRow && !StringUtil.hasText(mergedText)) {
                mergedText = "列" + (colIndex + 1);
            }
            values.set(colIndex, mergedText);
        }
        return values;
    }

    private String mergeCellTextWithImages(String baseText, List<String> imageKeys) {
        StringBuilder builder = new StringBuilder();
        if (StringUtil.hasText(baseText)) {
            builder.append(baseText.trim());
        }
        for (String imageKey : imageKeys) {
            if (!StringUtil.hasText(imageKey)) {
                continue;
            }
            if (builder.length() > 0) {
                builder.append('\n');
            }
            builder.append("[IMG:").append(imageKey).append("]");
        }
        return builder.toString();
    }

    private String escapeMarkdownCell(String text) {
        if (!StringUtil.hasText(text)) {
            return "";
        }
        return text.replace("|", "\\|").replace("\r", " ").replace("\n", "<br/>");
    }

    private String joinMarkdownCells(List<String> cells) {
        StringBuilder builder = new StringBuilder();
        for (int index = 0; index < cells.size(); index++) {
            if (index > 0) {
                builder.append(" | ");
            }
            builder.append(cells.get(index));
        }
        return builder.toString();
    }

    private List<Map<String, Object>> filterSheetImages(List<Map<String, Object>> cellImages,
                                                        String sheetName) {
        List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
        for (Map<String, Object> image : cellImages) {
            if (Objects.equals(sheetName, asString(image.get("sheetName")))) {
                result.add(new LinkedHashMap<String, Object>(image));
            }
        }
        return result;
    }

    private List<Map<String, Object>> selectWindowImages(List<Map<String, Object>> sheetImages,
                                                         int rowStart,
                                                         int rowEnd,
                                                         boolean noDataRows) {
        List<Map<String, Object>> selected = new ArrayList<Map<String, Object>>();
        for (Map<String, Object> image : sheetImages) {
            int imageRow = asInteger(image.get("fromRow"), 0) + 1;
            if (noDataRows || (imageRow >= rowStart && imageRow <= rowEnd) || (imageRow < rowStart && rowStart <= 2)) {
                selected.add(new LinkedHashMap<String, Object>(image));
            }
        }
        return selected;
    }

    private List<String> collectImageRefs(List<Map<String, Object>> images) {
        List<String> refs = new ArrayList<String>();
        for (Map<String, Object> image : images) {
            String sourcePath = asString(image.get("sourcePath"));
            if (StringUtil.hasText(sourcePath)) {
                refs.add(sourcePath);
            }
        }
        return refs;
    }

    private Map<String, Object> buildXlsxChunkSummary(Map<String, Object> sheetSummary,
                                                      Integer rowStart,
                                                      Integer rowEnd,
                                                      List<Map<String, Object>> images) {
        Map<String, Object> summary = new LinkedHashMap<String, Object>();
        summary.put("sheetName", asString(sheetSummary.get("sheetName")));
        summary.put("sheetIndex", sheetSummary.get("sheetIndex"));
        summary.put(DocumentImportKeys.KEY_DOCUMENT_ROW_START, rowStart);
        summary.put(DocumentImportKeys.KEY_DOCUMENT_ROW_END, rowEnd);
        summary.put("images", new ArrayList<Map<String, Object>>(images));
        return summary;
    }

    private List<Map<String, Object>> readMapList(Object value) {
        List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
        if (!(value instanceof List<?> rawList)) {
            return result;
        }
        for (Object item : rawList) {
            if (item instanceof Map<?, ?> mapItem) {
                result.add(new LinkedHashMap<String, Object>((Map<String, Object>) mapItem));
            }
        }
        return result;
    }

    private List<String> readStringList(Object value) {
        List<String> result = new ArrayList<String>();
        if (!(value instanceof List<?> rawList)) {
            return result;
        }
        for (Object item : rawList) {
            if (StringUtil.hasText(asString(item))) {
                result.add(asString(item));
            }
        }
        return result;
    }

    private List<String> buildHeadingPath(String sourceLabel, String title) {
        List<String> headingPath = new ArrayList<String>();
        headingPath.add(sourceLabel);
        if (StringUtil.hasText(title)) {
            headingPath.add(title.trim());
        }
        return headingPath;
    }

    private String readDocumentRenderMarkdown(tech.easyflow.ai.entity.Document document) {
        return document == null || document.getOptions() == null
            ? null
            : asString(document.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN));
    }

    private String stripMarkdownImageSyntax(String markdown) {
        if (!StringUtil.hasText(markdown)) {
            return markdown;
        }
        return markdown.replaceAll("!\\[[^\\]]*\\]\\([^)]*\\)", "").trim();
    }

    private String resolveStrategyLabel(String sourceFormat, StrategyConfig strategyConfig) {
        String strategyCode = strategyConfig == null ? null : strategyConfig.getStrategyCode();
        if ("pptx".equals(sourceFormat) || OFFICE_PPTX_PAGE_STRATEGY.equals(strategyCode)) {
            return "按页分块";
        }
        if ("csv".equals(sourceFormat) || TABLE_ROW_STRATEGY.equals(strategyCode)) {
            return "按表头 / 行窗口";
        }
        if ("xlsx".equals(sourceFormat) || OFFICE_XLSX_ROW_WINDOW_STRATEGY.equals(strategyCode)) {
            return "按 Sheet / 行窗口";
        }
        return ragIngestionService.toStrategyLabel(strategyCode);
    }

    /**
     * 组装处理页预览响应。
     *
     * @param document 文档实体
     * @param session 预览会话
     * @param sessionId 预览会话 ID
     * @param pageNo 当前页码
     * @param pageSize 当前页大小
     * @param pageChunks 当前页分块
     * @return 预览文件结果
     */
    private DocumentImportDtos.PreviewFileResult buildPreviewFileResult(
        tech.easyflow.ai.entity.Document document,
        DocumentImportDtos.PreviewSession session,
        String sessionId,
        int pageNo,
        int pageSize,
        List<DocumentChunk> pageChunks) {
        DocumentImportDtos.PreviewFileResult item = new DocumentImportDtos.PreviewFileResult();
        item.setPreviewSessionId(sessionId);
        item.setFilePath(document.getDocumentPath());
        item.setFileName(document.getTitle());
        item.setNormalizedContent(session.getAnalysis() == null ? null : session.getAnalysis().getNormalizedContent());
        item.setStrategyCode(session.getStrategyConfig().getStrategyCode());
        item.setStrategyLabel(resolveStrategyLabel(session.getSourceFormat(), session.getStrategyConfig()));
        item.setAnalysis(session.getAnalysis());
        item.setTotalChunks(resolveSessionTotalChunks(session));
        item.setTotalWarnings(countChunkWarnings(pageChunks));
        item.setPageNo(pageNo);
        item.setPageSize(pageSize);
        item.setChunks(toPreviewChunkResults(pageChunks));
        return item;
    }

    /**
     * 从内存会话或分片快照读取一个有界预览页。
     *
     * @param session 预览会话
     * @param pageNo 从 1 开始的页码
     * @param pageSize 单页分块数
     * @return 当前页分块
     */
    private List<DocumentChunk> loadPreviewPage(
        DocumentImportDtos.PreviewSession session,
        int pageNo,
        int pageSize) {
        long offsetValue = (long) (pageNo - 1) * pageSize;
        if (offsetValue > Integer.MAX_VALUE) {
            throw new BusinessException("预览页码超出范围");
        }
        int offset = (int) offsetValue;
        if (StringUtil.hasText(session.getChunkSnapshotPath())) {
            return documentImportChunkSnapshotService.loadPage(
                session.getChunkSnapshotPath(), offset, pageSize);
        }
        List<DocumentChunk> chunks = session.getDocumentChunks();
        if (chunks == null || offset >= chunks.size()) {
            return new ArrayList<DocumentChunk>();
        }
        int end = Math.min(offset + pageSize, chunks.size());
        return new ArrayList<DocumentChunk>(
            chunks.subList(offset, end));
    }

    /**
     * 规范化预览页码。
     *
     * @param pageNo 请求页码
     * @return 至少为 1 的页码
     */
    private int normalizePreviewPageNo(Integer pageNo) {
        if (pageNo == null) {
            return 1;
        }
        if (pageNo < 1) {
            throw new BusinessException("预览页码必须大于 0");
        }
        return pageNo;
    }

    /**
     * 规范化预览单页分块数。
     *
     * @param pageSize 请求单页分块数
     * @return 1 到 50 之间的单页分块数
     */
    private int normalizePreviewPageSize(Integer pageSize) {
        if (pageSize == null) {
            return 20;
        }
        if (pageSize < 1 || pageSize > 50) {
            throw new BusinessException(
                "预览每页分块数必须在 1 到 50 之间");
        }
        return pageSize;
    }

    private void mergeDocumentPreviewOptions(tech.easyflow.ai.entity.Document document,
                                             DocumentImportDtos.PreviewSession session,
                                             String chunkSnapshotPath) {
        Map<String, Object> options = copyOptions(document.getOptions());
        options.put(DocumentImportKeys.KEY_DOCUMENT_STRATEGY_CODE, session.getStrategyConfig().getStrategyCode());
        options.put(DocumentImportKeys.KEY_DOCUMENT_STRATEGY_LABEL, resolveStrategyLabel(session.getSourceFormat(), session.getStrategyConfig()));
        options.put(DocumentImportKeys.KEY_DOCUMENT_STRATEGY_SNAPSHOT, strategyConfigToMap(session.getStrategyConfig()));
        options.put(DocumentImportKeys.KEY_DOCUMENT_ANALYSIS_SUMMARY,
            session.getAnalysis() == null ? new LinkedHashMap<String, Object>() : session.getAnalysis().getFeatures());
        options.put(DocumentImportKeys.KEY_DOCUMENT_SOURCE_FILE_EXT, session.getSourceFormat());
        options.put(DocumentImportKeys.KEY_DOCUMENT_PREVIEW_VERSION, "v2");
        if (StringUtil.hasText(chunkSnapshotPath)) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_CHUNK_SNAPSHOT_PATH, chunkSnapshotPath);
        }

        document.setOptions(options);
    }

    private DocumentImportDtos.PreviewSession resolveIndexPreviewSession(DocumentCollection knowledge,
                                                                        tech.easyflow.ai.entity.Document document,
                                                                        String previewSessionId,
                                                                        String chunkSnapshotPath) {
        String persistedSnapshotPath = StringUtil.hasText(chunkSnapshotPath)
            ? chunkSnapshotPath
            : optionAsString(document.getOptions(), DocumentImportKeys.KEY_DOCUMENT_CHUNK_SNAPSHOT_PATH);
        if (StringUtil.hasText(persistedSnapshotPath)) {
            DocumentImportDtos.PreviewSession session =
                documentImportChunkSnapshotService.loadHeader(persistedSnapshotPath);
            assertPreviewSessionOwner(knowledge, document, session);
            return session;
        }
        if (StringUtil.hasText(previewSessionId)) {
            DocumentImportDtos.PreviewSession session = documentImportPreviewService.getRequired(previewSessionId);
            assertPreviewSessionOwner(knowledge, document, session);
            return session;
        }
        StrategyConfig storedStrategy = readStoredStrategy(document);
        DocumentImportDtos.PreviewSession rebuilt = buildPreviewSessionForDocument(knowledge, document, storedStrategy);
        String sessionId = documentImportPreviewService.put(rebuilt);
        rebuilt.setSessionId(sessionId);
        return rebuilt;
    }

    /**
     * 校验分块预览或快照归属，防止跨知识库复用分块。
     *
     * @param knowledge 当前知识库
     * @param document 当前文档
     * @param session 待校验会话
     */
    private void assertPreviewSessionOwner(DocumentCollection knowledge,
                                           tech.easyflow.ai.entity.Document document,
                                           DocumentImportDtos.PreviewSession session) {
        if (session == null || !knowledge.getId().equals(session.getKnowledgeId())
            || !document.getId().equals(session.getDocumentId())) {
            throw new BusinessException("分块快照与当前文档不匹配");
        }
    }

    @SuppressWarnings("unchecked")
    private StrategyConfig readStoredStrategy(tech.easyflow.ai.entity.Document document) {
        Object snapshot = document.getOptions() == null ? null : document.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_STRATEGY_SNAPSHOT);
        if (!(snapshot instanceof Map)) {
            throw new BusinessException("当前文档缺少分块策略，请重新生成预览");
        }
        Map<String, Object> rawSnapshot = (Map<String, Object>) snapshot;
        StrategyConfig config = StrategyConfig.defaults();
        config.setStrategyCode(asString(rawSnapshot.get("strategyCode")));
        config.setChunkSize(asInteger(rawSnapshot.get("chunkSize"), RagDefaults.CHUNK_SIZE));
        config.setOverlapSize(asInteger(rawSnapshot.get("overlapSize"), RagDefaults.OVERLAP_SIZE));
        config.setRegex(asString(rawSnapshot.get("regex")));
        config.setRetainRegexMatch(
            asBoolean(rawSnapshot.get("retainRegexMatch"), false));
        config.setRowsPerChunk(asInteger(rawSnapshot.get("rowsPerChunk"), config.getRowsPerChunk()));
        config.setMdSplitterLevel(asInteger(rawSnapshot.get("mdSplitterLevel"), RagDefaults.MD_SPLITTER_LEVEL));
        return config;
    }

    private List<DocumentChunk> buildDocumentChunks(tech.easyflow.ai.entity.Document document,
                                                    List<RagChunk> previewChunks) {
        List<DocumentChunk> chunks = new ArrayList<DocumentChunk>();
        for (int i = 0; i < previewChunks.size(); i++) {
            RagChunk previewChunk = previewChunks.get(i);
            DocumentChunk chunk = new DocumentChunk();
            chunk.setId(generateId(chunk));
            chunk.setDocumentId(document.getId());
            chunk.setDocumentCollectionId(document.getCollectionId());
            chunk.setContent(previewChunk.getContent());
            chunk.setSorting(i + 1);

            Map<String, Object> options = new LinkedHashMap<String, Object>();
            if (previewChunk.getOptions() != null) {
                options.putAll(previewChunk.getOptions());
            }
            options.put("chunkType", previewChunk.getChunkType());
            options.put("sourceLabel", previewChunk.getSourceLabel());
            options.put("headingPath", previewChunk.getHeadingPath());
            options.put("charCount", previewChunk.getCharCount());
            options.put("tokenEstimate", previewChunk.getTokenEstimate());
            options.put("qaQuestion", previewChunk.getQuestion());
            options.put("qaAnswer", previewChunk.getAnswer());
            options.put("partNo", previewChunk.getPartNo());
            options.put("partTotal", previewChunk.getPartTotal());
            options.put("warnings", previewChunk.getWarnings());
            options.put("sourceRanges", copySourceRanges(previewChunk));
            chunk.setOptions(options);
            chunks.add(chunk);
        }
        return chunks;
    }

    private DocumentImportTask createTask(tech.easyflow.ai.entity.Document document,
                                          DocumentImportTaskPhase phase,
                                          Map<String, Object> payload) {
        Date now = new Date();
        DocumentImportTask task = new DocumentImportTask();
        task.setDocumentId(document.getId());
        task.setKnowledgeId(document.getCollectionId());
        task.setBatchId(optionAsBigInteger(
            document.getOptions(), DocumentImportKeys.KEY_DOCUMENT_IMPORT_BATCH_ID));
        task.setBatchItemId(optionAsBigInteger(
            document.getOptions(), DocumentImportKeys.KEY_DOCUMENT_IMPORT_BATCH_ITEM_ID));
        task.setPhase(phase.name());
        task.setStatus(DocumentImportTaskStatus.PENDING.name());
        task.setPayloadJson(payload);
        task.setAttemptNo(0);
        task.setVersion(0);
        task.setCreated(now);
        task.setModified(now);
        task.setCreatedBy(resolveOperatorId());
        task.setModifiedBy(resolveOperatorId());
        documentImportTaskService.save(task);
        return task;
    }

    private BigInteger optionAsBigInteger(Map<String, Object> options, String key) {
        if (options == null || !StringUtil.hasText(key)) {
            return null;
        }
        Object value = options.get(key);
        if (value instanceof BigInteger) {
            return (BigInteger) value;
        }
        String text = asString(value);
        if (!StringUtil.hasText(text)) {
            return null;
        }
        try {
            return new BigInteger(text);
        } catch (NumberFormatException error) {
            return null;
        }
    }

    /**
     * 从文档选项读取字符串。
     *
     * @param options 文档选项
     * @param key 选项键
     * @return 字符串值
     */
    private String optionAsString(Map<String, Object> options, String key) {
        return options == null ? null : asString(options.get(key));
    }

    private boolean isAutomaticBatch(BigInteger batchId) {
        return batchId != null && documentImportBatchTracker.isAutoBatch(batchId);
    }

    /**
     * 获取阶段对应的全局运行上限。
     *
     * @param phase 任务阶段
     * @return 全局运行上限
     */
    private int resolvePhaseRunningLimit(String phase) {
        if (DocumentImportTaskPhase.PARSE.name().equals(phase)) {
            return bulkProperties.getParseMaxRunning();
        }
        if (DocumentImportTaskPhase.SPLIT.name().equals(phase)) {
            return bulkProperties.getSplitMaxRunning();
        }
        return bulkProperties.getIndexMaxRunning();
    }

    /**
     * 将任务阶段映射为批次文件项阶段。
     *
     * @param phase 任务阶段
     * @return 批次文件项阶段
     */
    private DocumentImportBatchItemStage toBatchItemStage(String phase) {
        if (DocumentImportTaskPhase.PARSE.name().equals(phase)) {
            return DocumentImportBatchItemStage.PARSE;
        }
        if (DocumentImportTaskPhase.SPLIT.name().equals(phase)) {
            return DocumentImportBatchItemStage.SPLIT;
        }
        return DocumentImportBatchItemStage.INDEX;
    }

    /**
     * 将任务阶段映射为文档失败状态。
     *
     * @param phase 任务阶段
     * @return 文档失败状态
     */
    private DocumentProcessStatus toFailureProcessStatus(String phase) {
        if (DocumentImportTaskPhase.PARSE.name().equals(phase)) {
            return DocumentProcessStatus.PARSE_FAILED;
        }
        if (DocumentImportTaskPhase.SPLIT.name().equals(phase)) {
            return DocumentProcessStatus.SPLIT_FAILED;
        }
        return DocumentProcessStatus.INDEX_FAILED;
    }

    /**
     * 从分块任务载荷恢复提交时的策略快照。
     *
     * @param task 分块任务
     * @return 请求策略；未传时返回默认自适应策略
     */
    private StrategyConfig resolveSplitStrategy(DocumentImportTask task) {
        String strategyJson =
            asString(task.getPayloadJson().get("strategyConfigJson"));
        if (!StringUtil.hasText(strategyJson)
            || "null".equalsIgnoreCase(strategyJson.trim())) {
            return StrategyConfig.defaults();
        }
        try {
            StrategyConfig strategy =
                JSON.parseObject(strategyJson, StrategyConfig.class);
            return strategy == null ? StrategyConfig.defaults() : strategy;
        } catch (RuntimeException error) {
            throw new BusinessException("分块策略格式错误");
        }
    }

    /**
     * 持久化批次文件实际采用的策略。
     *
     * @param itemId 批次文件项 ID
     * @param strategyConfig 实际策略
     */
    private void persistAppliedStrategy(
        BigInteger itemId,
        StrategyConfig strategyConfig) {
        if (itemId == null || strategyConfig == null) {
            return;
        }
        DocumentImportBatchItem update = new DocumentImportBatchItem();
        update.setId(itemId);
        update.setAppliedStrategyCode(strategyConfig.getStrategyCode());
        update.setStrategySnapshotJson(
            JSON.toJSONString(strategyConfigToMap(strategyConfig)));
        update.setModified(new Date());
        update.setModifiedBy(resolveOperatorId());
        // 策略回写使用部分实体，仅更新已设置字段，保留批次项其余非空列。
        documentImportBatchItemService.updateById(update);
    }

    /**
     * 清理本轮分块执行独占创建的临时工件。
     *
     * @param previewSessionId 预览会话 ID
     * @param snapshotPath 分块快照路径
     */
    private void deleteOwnedSplitArtifacts(
        String previewSessionId,
        String snapshotPath) {
        if (StringUtil.hasText(previewSessionId)) {
            documentImportPreviewService.remove(previewSessionId);
        }
        if (StringUtil.hasText(snapshotPath)) {
            deleteChunkSnapshotAfterCompletion(snapshotPath);
        }
    }

    private void updateBatchItem(BigInteger itemId,
                                 DocumentImportBatchItemStage stage,
                                 DocumentImportBatchItemStatus status,
                                 String errorSummary) {
        if (itemId != null) {
            documentImportBatchTracker.updateItem(itemId, stage, status, errorSummary);
        }
    }

    /**
     * 更新批次文件项失败状态和稳定失败码。
     *
     * @param itemId 批次文件项 ID
     * @param stage 失败阶段
     * @param errorSummary 错误摘要
     * @param failureCode 稳定失败码
     */
    private void updateBatchItemFailure(
        BigInteger itemId,
        DocumentImportBatchItemStage stage,
        String errorSummary,
        String failureCode) {
        if (itemId == null) {
            return;
        }
        documentImportBatchTracker.transitionItem(
            itemId,
            stage,
            DocumentImportBatchItemStatus.FAILED,
            errorSummary,
            true,
            0,
            failureCode
        );
    }

    private Map<String, Object> buildFilePayload(DocumentImportDtos.TaskCreateRequest request) {
        Map<String, Object> payload = new LinkedHashMap<String, Object>();
        payload.put("filePath", request.getFilePath());
        payload.put("fileName", request.getFileName());
        return payload;
    }

    private Map<String, Object> buildDocumentPayload(tech.easyflow.ai.entity.Document document) {
        Map<String, Object> payload = new LinkedHashMap<String, Object>();
        payload.put("filePath", document.getDocumentPath());
        payload.put("fileName", document.getTitle());
        return payload;
    }

    private Map<String, Object> buildInitialOptions(String fileExt) {
        Map<String, Object> options = new LinkedHashMap<String, Object>();
        options.put(DocumentImportKeys.KEY_DOCUMENT_SOURCE_FILE_EXT, fileExt);
        return options;
    }

    private StoreExecutionContext prepareStoreContext(tech.easyflow.ai.entity.Document document) {
        DocumentCollection knowledge = knowledgeService.getById(document.getCollectionId());
        if (knowledge == null) {
            throw new BusinessException("知识库不存在");
        }
        DocumentStore documentStore = knowledge.toDocumentStore();
        if (documentStore == null) {
            throw new BusinessException("向量数据库配置错误");
        }
        try {
            Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
            if (model == null) {
                throw new BusinessException("该知识库未配置向量模型");
            }
            EmbeddingModel embeddingModel = model.toEmbeddingModel();
            documentStore.setEmbeddingModel(embeddingModel);

            StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
            EmbeddingOptions embeddingOptions = new EmbeddingOptions();
            embeddingOptions.setModel(model.getModelName());
            embeddingOptions.setDimensions(knowledge.getDimensionOfVectorModel());
            options.setEmbeddingOptions(embeddingOptions);
            options.setIndexName(options.getCollectionName());
            return new StoreExecutionContext(
                knowledge,
                embeddingModel,
                documentStore,
                options,
                searcherFactory.getSearcher()
            );
        } catch (RuntimeException e) {
            DocumentStoreLifecycleSupport.closeQuietly(documentStore);
            throw e;
        }
    }

    private void storeDocumentChunks(StoreExecutionContext storeContext, List<DocumentChunk> documentChunks) {
        List<Document> documents = new ArrayList<Document>();
        for (DocumentChunk chunk : documentChunks) {
            assertEmbeddingChunkWithinHardLimit(chunk);
            Document storeDocument = new Document();
            storeDocument.setId(chunk.getId());
            storeDocument.setContent(chunk.getContent());
            storeDocument.addMetadata(KeywordSearchMetadataKeys.KNOWLEDGE_ID,
                storeContext.knowledge.getId() == null ? null : storeContext.knowledge.getId().toString());
            documents.add(storeDocument);
        }
        StoreResult result = storeContext.documentStore.store(documents, storeContext.options);
        if (result == null || !result.isSuccess()) {
            throw new BusinessException("向量化写入失败");
        }
        if (storeContext.searcher != null && !storeContext.searcher.addDocuments(documents)) {
            throw new BusinessException("关键词索引写入失败");
        }
    }

    /**
     * 在调用向量模型前校验最终分块预算，阻止遗漏入口发送超长正文。
     *
     * @param chunk 待向量化分块
     * @throws BusinessException 分块超过 BGE-M3 安全上限
     */
    private void assertEmbeddingChunkWithinHardLimit(DocumentChunk chunk) {
        String content = chunk == null ? null : chunk.getContent();
        int tokenEstimate = BgeM3ChunkSafety.estimateContentTokens(content);
        if (tokenEstimate > RagDefaults.BGE_M3_HARD_CHUNK_TOKEN_LIMIT) {
            BigInteger chunkId = chunk == null ? null : chunk.getId();
            throw new BusinessException(
                "分块内容超过向量模型上下文上限，请重新分块：chunkId="
                    + chunkId + "，tokenEstimate=" + tokenEstimate);
        }
    }

    private void persistChunkBatch(tech.easyflow.ai.entity.Document document, List<DocumentChunk> batch) {
        for (DocumentChunk chunk : batch) {
            chunk.setDocumentId(document.getId());
            chunk.setDocumentCollectionId(document.getCollectionId());
            documentChunkService.save(chunk);
        }
    }

    /**
     * 回滚已写入的向量和关键词索引。
     *
     * @param taskId 任务 ID
     * @param documentId 文档 ID
     * @param storeContext 外部存储上下文
     * @param documentChunks 待回滚分块
     * @return 两类外部索引均回滚成功时返回 {@code true}
     */
    private boolean rollbackStoredChunks(BigInteger taskId,
                                         BigInteger documentId,
                                         StoreExecutionContext storeContext,
                                         List<DocumentChunk> documentChunks) {
        try {
            Set<BigInteger> uniqueIds = new LinkedHashSet<BigInteger>();
            for (DocumentChunk chunk : documentChunks) {
                if (chunk != null && chunk.getId() != null) {
                    uniqueIds.add(chunk.getId());
                }
            }
            List<BigInteger> ids = new ArrayList<BigInteger>(uniqueIds);
            if (ids.isEmpty()) {
                return true;
            }
            LOG.warn("开始回滚文档向量化外部索引: taskId={}, documentId={}, knowledgeId={}, chunkCount={}",
                taskId,
                documentId,
                storeContext == null || storeContext.knowledge == null ? null : storeContext.knowledge.getId(),
                ids.size());
            StoreResult deleteResult = storeContext.documentStore.delete(ids, storeContext.options);
            if (deleteResult == null || !deleteResult.isSuccess()) {
                String failReason = deleteResult == null ? "未返回结果" : deleteResult.getFailReason();
                throw new IllegalStateException("向量存储回滚失败: " + failReason);
            }
            if (storeContext.searcher != null && !storeContext.searcher.deleteDocuments(ids)) {
                throw new IllegalStateException("关键词索引回滚失败");
            }
            LOG.warn("文档向量化外部索引回滚完成: taskId={}, documentId={}, knowledgeId={}, chunkCount={}",
                taskId,
                documentId,
                storeContext == null || storeContext.knowledge == null ? null : storeContext.knowledge.getId(),
                ids.size());
            return true;
        } catch (Exception e) {
            LOG.error("回滚文档向量数据失败", e);
            return false;
        }
    }

    private void updateKnowledgeAfterStore(StoreExecutionContext storeContext) {
        DocumentCollection update = new DocumentCollection();
        update.setId(storeContext.knowledge.getId());
        Map<String, Object> options = storeContext.knowledge.getOptions() == null
            ? new HashMap<String, Object>()
            : new HashMap<String, Object>(storeContext.knowledge.getOptions());
        options.put(DocumentCollection.KEY_CAN_UPDATE_EMBEDDING_MODEL, false);
        update.setOptions(options);
        knowledgeService.updateById(update);
        if (storeContext.knowledge.getDimensionOfVectorModel() == null) {
            DocumentCollection dimensionUpdate = new DocumentCollection();
            dimensionUpdate.setId(storeContext.knowledge.getId());
            dimensionUpdate.setDimensionOfVectorModel(Model.getEmbeddingDimension(storeContext.embeddingModel));
            knowledgeService.updateById(dimensionUpdate);
        }
    }

    private void closeStoreContext(StoreExecutionContext storeContext) {
        if (storeContext == null) {
            return;
        }
        DocumentStoreLifecycleSupport.closeQuietly(storeContext.documentStore);
    }

    private void clearPersistedChunks(BigInteger documentId) {
        if (documentId == null) {
            return;
        }
        documentChunkMapper.deleteByQuery(QueryWrapper.create().eq(DocumentChunk::getDocumentId, documentId));
    }

    private void ensurePreviewable(tech.easyflow.ai.entity.Document document) {
        String status = document.getProcessStatus();
        if (DocumentProcessStatus.PARSING.name().equals(status)) {
            throw new BusinessException("文档仍在解析中");
        }
        if (DocumentProcessStatus.INDEXING.name().equals(status)) {
            throw new BusinessException("文档正在向量化中");
        }
        if (DocumentProcessStatus.PARSE_FAILED.name().equals(status)) {
            throw new BusinessException("文档解析失败，请先重试解析");
        }
    }

    private boolean allowIndexStart(tech.easyflow.ai.entity.Document document) {
        String status = document.getProcessStatus();
        return DocumentProcessStatus.READY_FOR_SEGMENT.name().equals(status)
            || DocumentProcessStatus.READY_FOR_INDEX.name().equals(status)
            || DocumentProcessStatus.INDEX_FAILED.name().equals(status);
    }

    private DocumentImportDtos.TaskStartIndexResponse buildTaskStartResponse(DocumentImportTask task,
                                                                            DocumentProcessStatus processStatus) {
        DocumentImportDtos.TaskStartIndexResponse response = new DocumentImportDtos.TaskStartIndexResponse();
        response.setTaskId(task.getId());
        response.setProcessStatus(processStatus.name());
        return response;
    }

    private tech.easyflow.ai.entity.Document requireDocument(BigInteger documentId) {
        tech.easyflow.ai.entity.Document document = documentMapper.selectOneById(documentId);
        if (document == null) {
            throw new BusinessException("文档不存在");
        }
        return document;
    }

    private tech.easyflow.ai.entity.Document requireDocumentForKnowledge(BigInteger documentId, BigInteger knowledgeId) {
        tech.easyflow.ai.entity.Document document = requireDocument(documentId);
        if (document.getCollectionId() == null || document.getCollectionId().compareTo(knowledgeId) != 0) {
            throw new BusinessException("文档不存在");
        }
        return document;
    }

    private DocumentImportTask requireTask(BigInteger taskId) {
        DocumentImportTask task = documentImportTaskService.getById(taskId);
        if (task == null) {
            throw new BusinessException("任务不存在");
        }
        return task;
    }

    private DocumentCollection assertDocumentCollection(BigInteger knowledgeId) {
        DocumentCollection knowledge = knowledgeService.getById(knowledgeId);
        if (knowledge == null) {
            throw new BusinessException("知识库不存在");
        }
        if (knowledge.isFaqCollection()) {
            throw new BusinessException("FAQ知识库不支持文档上传");
        }
        return knowledge;
    }

    private String normalizeSourceFormat(tech.easyflow.ai.entity.Document document) {
        String type = document.getDocumentType();
        if (StringUtil.hasText(type)) {
            return type.toLowerCase(Locale.ROOT);
        }
        return normalizeFileExtension(document.getTitle(), document.getDocumentPath());
    }

    private String normalizeFileExtension(String fileName, String filePath) {
        String target = StringUtil.hasText(fileName) ? fileName : filePath;
        String ext = FileUtil.getFileTypeByExtension(target);
        return ext == null ? "" : ext.toLowerCase(Locale.ROOT);
    }

    private void assertSupportedImportFile(String fileExt) {
        if (!DocumentImportFormatPolicy.isSupported(fileExt)) {
            throw new BusinessException("当前仅支持 pdf/docx/txt/md/pptx/xlsx/csv 文档导入");
        }
    }

    /**
     * 判断当前文件是否应走统一文档解析桥接。
     *
     * @param fileExt 文件后缀
     * @return 是否走桥接解析
     */
    private boolean shouldUseDocumentParseBridge(String fileExt) {
        return "pdf".equals(fileExt) || "docx".equals(fileExt) || "pptx".equals(fileExt) || "xlsx".equals(fileExt);
    }

    /**
     * 提交桥接解析任务。
     *
     * @param task 任务实体
     * @param document 文档实体
     * @param fileExt 文件后缀
     */
    private void submitBridgeParseTask(DocumentImportTask task,
                                       tech.easyflow.ai.entity.Document document,
                                       String fileExt) {
        try {
            DocumentSourceRef sourceRef = buildBridgeSourceRef(task, document, fileExt);
            LOG.info("文档解析桥接任务开始提交: taskId={}, documentId={}, fileName={}, fileExt={}",
                task.getId(), document.getId(), document.getTitle(), fileExt);
            String providerTaskId = documentParseBridgeService.submit(sourceRef, DocumentParseScenario.KNOWLEDGE_IMPORT).getTaskId();
            if (!StringUtil.hasText(providerTaskId)) {
                throw new BusinessException("文档解析服务未返回任务ID");
            }
            if (!updateTaskProvider(task, providerTaskId)) {
                LOG.warn("解析任务提交完成时本地任务已结束，忽略迟到结果: taskId={}, documentId={}, providerTaskId={}",
                    task.getId(), document.getId(), providerTaskId);
                return;
            }
            LOG.info("文档解析桥接任务提交完成: taskId={}, documentId={}, providerTaskId={}",
                task.getId(), document.getId(), providerTaskId);
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            throw new BusinessException(500, 1, "文档解析失败", e);
        }
    }

    /**
     * 构建解析桥接文档源。批次上传文件由服务端生成存储地址，直接通过受信任存储服务读取；
     * 其他 URL 继续交由解析桥接层执行公网地址校验。
     *
     * @param task 导入任务
     * @param document 文档实体
     * @param fileExt 文件后缀
     * @return 解析桥接文档源
     * @throws DocumentParseBridgeException 批次文件读取失败或超过大小限制时抛出
     */
    private DocumentSourceRef buildBridgeSourceRef(DocumentImportTask task,
                                                   tech.easyflow.ai.entity.Document document,
                                                   String fileExt) {
        DocumentSourceRef sourceRef = buildBridgeSourceMetadata(document, fileExt);
        if (task.getBatchId() == null) {
            sourceRef.setFilePath(document.getDocumentPath());
            return sourceRef;
        }
        try (InputStream inputStream = storageService.readStream(document.getDocumentPath())) {
            byte[] contentBytes = DocumentInputStreamSupport.readBytes(
                inputStream,
                bulkProperties.getMaxFileSize().toBytes()
            );
            sourceRef.setContentBytes(contentBytes);
            sourceRef.setSize((long) contentBytes.length);
            return sourceRef;
        } catch (Exception error) {
            throw DocumentParseBridgeException.sourceLoadFailed("读取批量导入文件失败", error);
        }
    }

    /**
     * 构建用于解析服务精确路由的文档源元数据。
     *
     * @param document 文档实体
     * @param fileExt 文件后缀
     * @return 不包含文档内容的源元数据
     */
    private DocumentSourceRef buildBridgeSourceMetadata(tech.easyflow.ai.entity.Document document,
                                                        String fileExt) {
        DocumentSourceRef sourceRef = new DocumentSourceRef();
        sourceRef.setFileName(document.getTitle());
        sourceRef.setContentType(resolveBridgeContentType(fileExt));
        return sourceRef;
    }

    /**
     * 单次查询桥接解析任务状态并收敛结果。
     *
     * @param task 任务实体
     * @param document 文档实体
     * @param fileExt 文件后缀
     */
    private void syncBridgeParseTask(DocumentImportTask task,
                                     tech.easyflow.ai.entity.Document document,
                                     String fileExt) {
        try {
            DocumentSourceRef sourceMetadata = buildBridgeSourceMetadata(document, fileExt);
            DocumentParseTaskInfo taskInfo = documentParseBridgeService.queryTaskInfo(
                task.getProviderTaskId(),
                sourceMetadata
            );
            String providerStatus = taskInfo == null ? null : taskInfo.getStatus();
            LOG.info("文档解析桥接任务单次收敛: taskId={}, documentId={}, providerTaskId={}, providerStatus={}, hasResult={}, error={}",
                task.getId(),
                document.getId(),
                task.getProviderTaskId(),
                providerStatus,
                taskInfo != null && taskInfo.getResult() != null,
                taskInfo == null ? null : taskInfo.getError());
            if (isTaskSuccess(providerStatus)) {
                DocumentParsedResult result = taskInfo.getResult() == null
                    ? documentParseBridgeService.queryResult(task.getProviderTaskId(), sourceMetadata)
                    : taskInfo.getResult();
                markParseSuccess(task, document, result, fileExt, task.getProviderTaskId());
                return;
            }
            if (isTaskFailed(providerStatus)) {
                throw new BusinessException(taskInfo == null ? "文档解析失败" : taskInfo.getError());
            }
            if (!touchRunningTask(task)) {
                throw new TaskOwnershipLostException(task.getId());
            }
            updateDocumentParseProgress(document, taskInfo);
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            throw new BusinessException(500, 1, "文档解析失败", e);
        }
    }

    /**
     * 在事务提交后投递解析任务消息。
     *
     * @param taskId 任务 ID
     */
    private void dispatchParseTaskAfterCommit(BigInteger taskId) {
        runAfterCommit(() -> parseTaskProducer.send(taskId));
    }

    /**
     * 在事务提交后投递分块任务消息。
     *
     * @param taskId 任务 ID
     */
    private void dispatchSplitTaskAfterCommit(BigInteger taskId) {
        runAfterCommit(() -> splitTaskProducer.send(taskId));
    }

    /**
     * 在事务提交后投递向量化任务消息。
     *
     * @param taskId 任务 ID
     */
    private void dispatchIndexTaskAfterCommit(BigInteger taskId) {
        runAfterCommit(() -> indexTaskProducer.send(taskId));
    }

    /**
     * 解析桥接所需的内容类型。
     *
     * @param fileExt 文件后缀
     * @return MIME 类型
     */
    private String resolveBridgeContentType(String fileExt) {
        if ("pdf".equals(fileExt)) {
            return "application/pdf";
        }
        if ("docx".equals(fileExt)) {
            return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
        }
        if ("pptx".equals(fileExt)) {
            return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
        }
        if ("xlsx".equals(fileExt)) {
            return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
        }
        return null;
    }

    /**
     * 在事务提交后调度本地向量化兜底执行。
     *
     * @param taskId 任务 ID
     */
    private void scheduleIndexTaskFallback(BigInteger taskId) {
        scheduleTaskFallback(taskId, "index", () -> selfProxy.handleIndexTask(taskId));
    }

    /**
     * 在事务提交后调度本地解析兜底执行。
     *
     * @param taskId 任务 ID
     */
    private void scheduleParseTaskFallback(BigInteger taskId) {
        scheduleTaskFallback(taskId, "parse", () -> selfProxy.handleParseTask(taskId));
    }

    /**
     * 在事务提交后异步执行本地兜底任务。
     *
     * @param taskId 任务 ID
     * @param taskType 任务类型
     * @param action 执行动作
     */
    private void scheduleTaskFallback(BigInteger taskId, String taskType, Runnable action) {
        Runnable dispatch = () -> documentImportTaskExecutor.execute(() -> {
            try {
                LOG.info("开始执行文档导入本地兜底任务: type={}, taskId={}", taskType, taskId);
                action.run();
                LOG.info("文档导入本地兜底任务执行完成: type={}, taskId={}", taskType, taskId);
            } catch (Exception e) {
                LOG.error("执行文档导入本地兜底任务失败: type={}, taskId={}", taskType, taskId, e);
            }
        });
        runAfterCommit(dispatch);
    }

    /**
     * 在事务提交后执行指定动作；若当前无事务则立即执行。
     *
     * @param action 执行动作
     */
    private void runAfterCommit(Runnable action) {
        if (TransactionSynchronizationManager.isSynchronizationActive()
            && TransactionSynchronizationManager.isActualTransactionActive()) {
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    action.run();
                }
            });
            return;
        }
        action.run();
    }

    private String readFileContent(String filePath, String fileName) {
        try (InputStream inputStream = storageService.readStream(filePath)) {
            return com.easyagents.core.file2text.File2TextUtil.readFromStream(inputStream, fileName, null);
        } catch (IOException e) {
            throw new BusinessException("文件解析失败：" + e.getMessage());
        }
    }

    private String resolvePreferredText(DocumentParsedResult parsedResult) {
        if (parsedResult == null) {
            return null;
        }
        if (StringUtil.hasText(parsedResult.getPreferredText())) {
            return parsedResult.getPreferredText();
        }
        if (StringUtil.hasText(parsedResult.getMarkdown())) {
            return parsedResult.getMarkdown();
        }
        return parsedResult.getPlainText();
    }

    /**
     * 触摸运行中任务的修改时间，避免监控视角下任务长时间无变化。
     *
     * @param task 任务实体
     */
    private boolean touchRunningTask(DocumentImportTask task) {
        Date now = new Date();
        Date leaseUntil = new Date(
            now.getTime()
                + Math.max(
                    1L,
                    bulkProperties.getInterruptionTimeout().toMillis()));
        int updated = documentImportTaskMapper.renewLease(
            task.getId(),
            task.getExecutionToken(),
            leaseUntil,
            now,
            resolveOperatorId()
        );
        if (updated > 0) {
            task.setModified(now);
            task.setLeaseUntil(leaseUntil);
            return true;
        }
        return false;
    }

    /**
     * 校验任务是否仍由当前执行令牌持有。
     *
     * @param task 任务实体
     * @return 是否仍持有执行权
     */
    private boolean ownsRunningTask(DocumentImportTask task) {
        if (task == null || !StringUtil.hasText(task.getExecutionToken())) {
            return false;
        }
        return documentImportTaskService.count(
            QueryWrapper.create()
                .eq(DocumentImportTask::getId, task.getId())
                .eq(DocumentImportTask::getStatus,
                    DocumentImportTaskStatus.RUNNING.name())
                .eq(DocumentImportTask::getExecutionToken,
                    task.getExecutionToken())
        ) > 0;
    }

    /**
     * 登记并尝试删除持久化分块快照。
     *
     * @param snapshotPath 快照路径
     */
    private void deleteChunkSnapshotAfterCompletion(String snapshotPath) {
        if (!StringUtil.hasText(snapshotPath)) {
            return;
        }
        try {
            snapshotCleanupService.scheduleChunkSnapshot(snapshotPath);
        } catch (RuntimeException error) {
            LOG.error(
                "登记文档分块快照清理失败: path={}",
                snapshotPath,
                error);
        }
    }

    /**
     * 在重试事务提交后删除已经失效的旧分块快照。
     *
     * @param snapshotPath 旧分块快照路径
     */
    private void deleteChunkSnapshotAfterCommit(String snapshotPath) {
        if (!StringUtil.hasText(snapshotPath)) {
            return;
        }
        runAfterCommit(() -> deleteChunkSnapshotAfterCompletion(snapshotPath));
    }

    /**
     * CSV 向量化完成后登记中间表格快照清理。
     *
     * <p>清理记录持久化后由当前线程立即尝试，失败时由后台调度器继续重试。</p>
     *
     * @param document 已完成向量化的文档
     */
    private void cleanupCsvTableSnapshotAfterCompletion(
        tech.easyflow.ai.entity.Document document) {
        if (document == null || document.getOptions() == null) {
            return;
        }
        String tableSnapshotPath = optionAsString(
            document.getOptions(),
            DocumentImportKeys.KEY_DOCUMENT_CSV_TABLE_SNAPSHOT_PATH);
        if (!StringUtil.hasText(tableSnapshotPath)) {
            return;
        }
        try {
            snapshotCleanupService.scheduleCsvTableSnapshot(
                document.getCollectionId(),
                document.getId(),
                tableSnapshotPath);
        } catch (RuntimeException error) {
            LOG.error(
                "登记已完成 CSV 文档的表格快照清理失败: documentId={}, path={}",
                document.getId(), tableSnapshotPath, error);
        }
    }

    private StrategyConfig resolveStrategyConfig(DocumentCollection knowledge,
                                                StrategyConfig requestConfig,
                                                AnalysisResult analysisResult) {
        Map<String, Object> options = knowledge.getOptions() == null
            ? Collections.emptyMap()
            : knowledge.getOptions();
        String recommended = analysisResult.getRecommendedStrategyCode();
        String defaultStrategyCode = asString(options.get(DocumentImportKeys.KEY_SPLITTER_DEFAULT_STRATEGY));
        String fallbackStrategyCode = asString(options.get(DocumentImportKeys.KEY_SPLITTER_FALLBACK_STRATEGY));
        Boolean autoRecommendEnabled = asBoolean(options.get(DocumentImportKeys.KEY_SPLITTER_AUTO_RECOMMEND_ENABLED), true);

        StrategyConfig config = readProfileConfig(options, defaultStrategyCode);
        if (config == null) {
            config = StrategyConfig.defaults();
        }
        String requestedStrategyCode = requestConfig == null ? null : requestConfig.getStrategyCode();
        String strategyCode = StringUtil.hasText(requestedStrategyCode) ? requestedStrategyCode : config.getStrategyCode();
        if (!StringUtil.hasText(strategyCode) || RagStrategyCodes.AUTO.equals(strategyCode)) {
            strategyCode = Boolean.TRUE.equals(autoRecommendEnabled)
                ? recommended
                : (StringUtil.hasText(defaultStrategyCode) ? defaultStrategyCode : recommended);
        }
        if (!StringUtil.hasText(strategyCode)) {
            strategyCode = StringUtil.hasText(fallbackStrategyCode)
                ? fallbackStrategyCode
                : RagStrategyCodes.PARAGRAPH_LENGTH;
        }
        StrategyConfig profileConfig = readProfileConfig(options, strategyCode);
        if (profileConfig != null) {
            mergeStrategyConfig(config, profileConfig);
        }
        if (requestConfig != null) {
            mergeStrategyConfig(config, requestConfig);
        }
        config.setStrategyCode(strategyCode);
        if (config.getChunkSize() == null || config.getChunkSize() <= 0) {
            config.setChunkSize(RagDefaults.CHUNK_SIZE);
        }
        if (config.getOverlapSize() == null || config.getOverlapSize() < 0) {
            config.setOverlapSize(RagDefaults.OVERLAP_SIZE);
        }
        if (config.getMdSplitterLevel() == null || config.getMdSplitterLevel() <= 0) {
            config.setMdSplitterLevel(RagDefaults.MD_SPLITTER_LEVEL);
        }
        if (config.getRetainRegexMatch() == null) {
            config.setRetainRegexMatch(Boolean.FALSE);
        }
        validateStrategyOverlap(config);
        return config;
    }

    /**
     * 校验长度分块参数能够保证分块游标持续前进。
     *
     * @param config 最终生效的分块策略
     * @throws BusinessException 重叠大小不小于分块大小
     */
    private void validateStrategyOverlap(StrategyConfig config) {
        if (config.getOverlapSize() != null
            && config.getChunkSize() != null
            && config.getOverlapSize() >= config.getChunkSize()) {
            throw new BusinessException("分块重叠大小必须小于分块大小");
        }
    }

    /**
     * 任务已被看门狗或其他执行者收口时抛出的内部异常。
     */
    private static final class TaskOwnershipLostException extends RuntimeException {

        /**
         * 创建任务所有权丢失异常。
         *
         * @param taskId 任务 ID
         */
        private TaskOwnershipLostException(BigInteger taskId) {
            super("文档导入任务所有权已失效: " + taskId);
        }
    }

    @SuppressWarnings("unchecked")
    private StrategyConfig readProfileConfig(Map<String, Object> options, String strategyCode) {
        if (!StringUtil.hasText(strategyCode)) {
            return null;
        }
        Object profileObject = options.get(DocumentImportKeys.KEY_SPLITTER_STRATEGY_PROFILES);
        if (!(profileObject instanceof Map)) {
            return null;
        }
        Map<String, Object> profileMap = (Map<String, Object>) profileObject;
        Object strategyObject = profileMap.get(strategyCode);
        if (!(strategyObject instanceof Map)) {
            return null;
        }
        Map<String, Object> rawProfile = (Map<String, Object>) strategyObject;
        StrategyConfig config = StrategyConfig.defaults();
        config.setStrategyCode(strategyCode);
        config.setChunkSize(asInteger(rawProfile.get("chunkSize"), config.getChunkSize()));
        config.setOverlapSize(asInteger(rawProfile.get("overlapSize"), config.getOverlapSize()));
        config.setRegex(asString(rawProfile.get("regex")));
        if (rawProfile.containsKey("retainRegexMatch")) {
            config.setRetainRegexMatch(
                asBoolean(rawProfile.get("retainRegexMatch"), false));
        }
        config.setRowsPerChunk(asInteger(rawProfile.get("rowsPerChunk"), config.getRowsPerChunk()));
        config.setMdSplitterLevel(asInteger(rawProfile.get("mdSplitterLevel"), config.getMdSplitterLevel()));
        return config;
    }

    private void mergeStrategyConfig(StrategyConfig target, StrategyConfig source) {
        if (source == null) {
            return;
        }
        if (StringUtil.hasText(source.getStrategyCode())) {
            target.setStrategyCode(source.getStrategyCode());
        }
        if (source.getChunkSize() != null) {
            target.setChunkSize(source.getChunkSize());
        }
        if (source.getOverlapSize() != null) {
            target.setOverlapSize(source.getOverlapSize());
        }
        if (StringUtil.hasText(source.getRegex())) {
            target.setRegex(source.getRegex());
        }
        if (source.getRetainRegexMatch() != null) {
            target.setRetainRegexMatch(source.getRetainRegexMatch());
        }
        if (source.getRowsPerChunk() != null) {
            target.setRowsPerChunk(source.getRowsPerChunk());
        }
        if (source.getMdSplitterLevel() != null) {
            target.setMdSplitterLevel(source.getMdSplitterLevel());
        }
    }

    private Map<String, Object> strategyConfigToMap(StrategyConfig strategyConfig) {
        Map<String, Object> map = new LinkedHashMap<String, Object>();
        map.put("strategyCode", strategyConfig.getStrategyCode());
        map.put("chunkSize", strategyConfig.getChunkSize());
        map.put("overlapSize", strategyConfig.getOverlapSize());
        map.put("regex", strategyConfig.getRegex());
        map.put("retainRegexMatch", Boolean.TRUE.equals(
            strategyConfig.getRetainRegexMatch()));
        map.put("rowsPerChunk", strategyConfig.getRowsPerChunk());
        map.put("mdSplitterLevel", strategyConfig.getMdSplitterLevel());
        return map;
    }

    private int countChunkWarnings(List<DocumentChunk> chunks) {
        int total = 0;
        if (chunks == null) {
            return 0;
        }
        for (DocumentChunk chunk : chunks) {
            List<String> warnings = readChunkWarnings(chunk);
            total += warnings.size();
        }
        return total;
    }

    /**
     * 将分块预览转换为前端工作台使用的显式模型。
     *
     * @param chunks 分块列表
     * @return 预览分块结果
     */
    private List<DocumentImportDtos.PreviewChunkResult> toPreviewChunkResults(List<DocumentChunk> chunks) {
        List<DocumentImportDtos.PreviewChunkResult> result = new ArrayList<DocumentImportDtos.PreviewChunkResult>();
        if (chunks == null) {
            return result;
        }
        for (DocumentChunk chunk : chunks) {
            DocumentImportDtos.PreviewChunkResult item = new DocumentImportDtos.PreviewChunkResult();
            Map<String, Object> options = chunk.getOptions() == null
                ? Collections.<String, Object>emptyMap()
                : chunk.getOptions();
            item.setAnswer(asString(options.get("qaAnswer")));
            item.setCharCount(asInteger(options.get("charCount"), 0));
            item.setChunkId(chunk.getId() == null ? null : String.valueOf(chunk.getId()));
            item.setChunkType(asString(options.get("chunkType")));
            item.setContent(chunk.getContent());
            item.setRenderMarkdown(asString(options.get(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN)));
            item.setHeadingPath(readStringList(options.get("headingPath")));
            item.setPartNo(asInteger(options.get("partNo"), 1));
            item.setPartTotal(asInteger(options.get("partTotal"), 1));
            item.setQuestion(asString(options.get("qaQuestion")));
            item.setSourceLabel(asString(options.get("sourceLabel")));
            item.setTokenEstimate(asInteger(options.get("tokenEstimate"), 0));
            item.setWarnings(readChunkWarnings(chunk));
            item.setSourceRanges(copySourceRanges(chunk));
            result.add(item);
        }
        return result;
    }

    /**
     * 校验切分结果是否具备稳定原文映射能力。
     *
     * @param chunks 分块列表
     * @param normalizedContent 标准化原文
     */
    private void validatePreviewChunkRanges(List<RagChunk> chunks, String normalizedContent) {
        int maxLength = normalizedContent == null ? 0 : normalizedContent.length();
        if (maxLength <= 0) {
            throw new BusinessException("标准化原文为空，无法生成分块预览");
        }
        if (chunks == null || chunks.isEmpty()) {
            throw new BusinessException("未生成有效分块，请调整策略后重试");
        }
        for (RagChunk chunk : chunks) {
            List<DocumentImportDtos.PreviewSourceRange> ranges = copySourceRanges(chunk);
            if (ranges.isEmpty()) {
                throw new BusinessException("当前分块策略缺少稳定原文映射，请调整策略后重试");
            }
            for (DocumentImportDtos.PreviewSourceRange range : ranges) {
                int start = defaultInt(range.getStart());
                int end = defaultInt(range.getEnd());
                if (start < 0 || end <= start || end > maxLength) {
                    throw new BusinessException("当前分块策略生成了非法原文区间，请调整策略后重试");
                }
            }
        }
    }

    /**
     * 从分块元信息中复制原文区间。
     *
     * @param chunk 分块实体
     * @return 原文区间列表
     */
    @SuppressWarnings("unchecked")
    private List<DocumentImportDtos.PreviewSourceRange> copySourceRanges(RagChunk chunk) {
        List<DocumentImportDtos.PreviewSourceRange> result = new ArrayList<DocumentImportDtos.PreviewSourceRange>();
        if (chunk == null || chunk.getOptions() == null) {
            return result;
        }
        Object rawRanges = chunk.getOptions().get(SOURCE_RANGES_KEY);
        if (!(rawRanges instanceof List<?> rangeList)) {
            return result;
        }
        for (Object item : rangeList) {
            if (!(item instanceof Map<?, ?> rawRange)) {
                continue;
            }
            DocumentImportDtos.PreviewSourceRange range = new DocumentImportDtos.PreviewSourceRange();
            range.setStart(asInteger(rawRange.get("start"), null));
            range.setEnd(asInteger(rawRange.get("end"), null));
            if (range.getStart() != null && range.getEnd() != null) {
                result.add(range);
            }
        }
        return result;
    }

    @SuppressWarnings("unchecked")
    private List<DocumentImportDtos.PreviewSourceRange> copySourceRanges(DocumentChunk chunk) {
        List<DocumentImportDtos.PreviewSourceRange> result = new ArrayList<DocumentImportDtos.PreviewSourceRange>();
        if (chunk == null || chunk.getOptions() == null) {
            return result;
        }
        Object rawRanges = chunk.getOptions().get(SOURCE_RANGES_KEY);
        if (!(rawRanges instanceof List<?> rangeList)) {
            return result;
        }
        for (Object item : rangeList) {
            if (!(item instanceof Map<?, ?> rawRange)) {
                continue;
            }
            DocumentImportDtos.PreviewSourceRange range = new DocumentImportDtos.PreviewSourceRange();
            range.setStart(asInteger(rawRange.get("start"), null));
            range.setEnd(asInteger(rawRange.get("end"), null));
            if (range.getStart() != null && range.getEnd() != null) {
                result.add(range);
            }
        }
        return result;
    }

    private List<String> readChunkWarnings(DocumentChunk chunk) {
        if (chunk == null || chunk.getOptions() == null) {
            return new ArrayList<String>();
        }
        return readStringList(chunk.getOptions().get("warnings"));
    }

    private void clearDocumentParseProgress(Map<String, Object> options) {
        if (options == null || options.isEmpty()) {
            return;
        }
        options.remove(DocumentImportKeys.KEY_DOCUMENT_PARSE_PROGRESS_PERCENT);
        options.remove(DocumentImportKeys.KEY_DOCUMENT_PARSE_CURRENT_STAGE);
        options.remove(DocumentImportKeys.KEY_DOCUMENT_PARSE_PROCESSED_ITEMS);
        options.remove(DocumentImportKeys.KEY_DOCUMENT_PARSE_TOTAL_ITEMS);
        options.remove(DocumentImportKeys.KEY_DOCUMENT_PARSE_STATUS_MESSAGE);
    }

    /**
     * 将 CSV 解析元信息提升到文档有界选项中，供后续分块阶段直接定位表格快照。
     *
     * @param options 文档选项
     * @param metadata 解析元信息
     */
    private void copyCsvParseMetadata(
        Map<String, Object> options,
        Map<String, Object> metadata) {
        if (options == null || metadata == null) {
            return;
        }
        copyOptionIfPresent(
            options, metadata, DocumentImportKeys.KEY_DOCUMENT_CSV_TABLE_SNAPSHOT_PATH);
        copyOptionIfPresent(
            options, metadata, DocumentImportKeys.KEY_DOCUMENT_CSV_ENCODING);
        copyOptionIfPresent(
            options, metadata, DocumentImportKeys.KEY_DOCUMENT_CSV_ROW_COUNT);
        copyOptionIfPresent(
            options, metadata, DocumentImportKeys.KEY_DOCUMENT_CSV_COLUMN_COUNT);
    }

    /**
     * 复制一个存在的选项值。
     *
     * @param target 目标选项
     * @param source 来源选项
     * @param key 选项键
     */
    private void copyOptionIfPresent(
        Map<String, Object> target,
        Map<String, Object> source,
        String key) {
        if (source.containsKey(key) && source.get(key) != null) {
            target.put(key, source.get(key));
        }
    }

    /**
     * 返回会话的真实分块总数。
     *
     * @param session 预览会话
     * @return 总分块数
     */
    private int resolveSessionTotalChunks(
        DocumentImportDtos.PreviewSession session) {
        if (session == null) {
            return 0;
        }
        if (session.getTotalChunks() != null) {
            return session.getTotalChunks();
        }
        return session.getDocumentChunks() == null
            ? 0
            : session.getDocumentChunks().size();
    }

    private BigInteger generateId(Object entity) {
        return new BigInteger(String.valueOf(flexIdKeyGenerator.generate(entity, null)));
    }

    /**
     * 校验单次向量化分块的 ID 完整性，避免外部存储已写入后才触发数据库主键冲突。
     *
     * @param chunks 待向量化分块
     */
    private void assertUniqueChunkIds(List<DocumentChunk> chunks) {
        Set<BigInteger> uniqueIds = new HashSet<BigInteger>(chunks.size());
        assertUniqueChunkIds(chunks, uniqueIds);
    }

    /**
     * 校验批次分块 ID，并把已见 ID 保留到跨分片集合。
     *
     * @param chunks 当前批次
     * @param uniqueIds 已见 ID
     */
    private void assertUniqueChunkIds(
        List<DocumentChunk> chunks,
        Set<BigInteger> uniqueIds) {
        for (DocumentChunk chunk : chunks) {
            if (chunk == null || chunk.getId() == null) {
                throw new IllegalStateException("文档分块缺少 ID");
            }
            if (!uniqueIds.add(chunk.getId())) {
                throw new IllegalStateException("检测到重复文档分块 ID: " + chunk.getId());
            }
        }
    }

    /**
     * 将已写入分块压缩为仅含主键的回滚标记，避免索引阶段长期持有全部正文。
     *
     * @param chunks 已写入分块
     * @return 主键标记
     */
    private List<DocumentChunk> toChunkIdMarkers(List<DocumentChunk> chunks) {
        List<DocumentChunk> markers = new ArrayList<DocumentChunk>(chunks.size());
        for (DocumentChunk chunk : chunks) {
            DocumentChunk marker = new DocumentChunk();
            marker.setId(chunk.getId());
            markers.add(marker);
        }
        return markers;
    }

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

    private String truncateError(String message) {
        if (!StringUtil.hasText(message)) {
            return "任务执行失败";
        }
        String trimmed = message.trim();
        return trimmed.length() > 1000 ? trimmed.substring(0, 1000) : trimmed;
    }

    private boolean isTaskSuccess(String status) {
        String normalized = status == null ? "" : status.toUpperCase(Locale.ROOT);
        return normalized.contains("SUCCESS") || normalized.contains("COMPLETED") || normalized.contains("DONE");
    }

    private boolean isTaskFailed(String status) {
        String normalized = status == null ? "" : status.toUpperCase(Locale.ROOT);
        return normalized.contains("FAIL") || normalized.contains("ERROR") || normalized.contains("CANCEL");
    }

    /**
     * 写入文档任务错误及稳定错误码。
     *
     * @param document 文档实体
     * @param errorMessage 用户可见错误信息
     * @param errorCode 稳定错误码
     */
    private void setDocumentTaskError(tech.easyflow.ai.entity.Document document,
                                      String errorMessage,
                                      String errorCode) {
        document.setLastTaskError(errorMessage);
        Map<String, Object> options = copyOptions(document.getOptions());
        if (StringUtil.hasText(errorCode)) {
            options.put(DocumentImportKeys.KEY_DOCUMENT_TASK_ERROR_CODE, errorCode);
        } else {
            options.remove(DocumentImportKeys.KEY_DOCUMENT_TASK_ERROR_CODE);
        }
        document.setOptions(options);
    }

    /**
     * 清除文档任务错误及稳定错误码。
     *
     * @param document 文档实体
     */
    private void clearDocumentTaskError(tech.easyflow.ai.entity.Document document) {
        setDocumentTaskError(document, null, null);
    }

    private Map<String, Object> copyOptions(Map<String, Object> options) {
        return options == null ? new LinkedHashMap<String, Object>() : new LinkedHashMap<String, Object>(options);
    }

    private String asString(Object value) {
        return value == null ? null : String.valueOf(value);
    }

    private Integer asInteger(Object value, Integer defaultValue) {
        if (value == null) {
            return defaultValue;
        }
        if (value instanceof Number number) {
            return number.intValue();
        }
        if (value instanceof String text && StringUtil.hasText(text)) {
            return Integer.parseInt(text);
        }
        return defaultValue;
    }

    private Boolean asBoolean(Object value, boolean defaultValue) {
        if (value == null) {
            return defaultValue;
        }
        if (value instanceof Boolean bool) {
            return bool;
        }
        if (value instanceof Number number) {
            return number.intValue() != 0;
        }
        return Boolean.parseBoolean(String.valueOf(value));
    }

    private int defaultInt(Integer value) {
        return value == null ? 0 : value;
    }

    private static class ParsedKnowledgeContent {
        private final String documentLlmContent;
        private final String documentRenderMarkdown;
        private final Map<String, Object> parseArtifactSummary;

        private ParsedKnowledgeContent(String documentLlmContent,
                                       String documentRenderMarkdown,
                                       Map<String, Object> parseArtifactSummary) {
            this.documentLlmContent = documentLlmContent;
            this.documentRenderMarkdown = documentRenderMarkdown;
            this.parseArtifactSummary = parseArtifactSummary == null
                ? new LinkedHashMap<String, Object>()
                : new LinkedHashMap<String, Object>(parseArtifactSummary);
        }
    }

    private static class StoreExecutionContext {
        private final DocumentCollection knowledge;
        private final EmbeddingModel embeddingModel;
        private final DocumentStore documentStore;
        private final StoreOptions options;
        private final DocumentSearcher searcher;

        private StoreExecutionContext(DocumentCollection knowledge,
                                      EmbeddingModel embeddingModel,
                                      DocumentStore documentStore,
                                      StoreOptions options,
                                      DocumentSearcher searcher) {
            this.knowledge = knowledge;
            this.embeddingModel = embeddingModel;
            this.documentStore = documentStore;
            this.options = options;
            this.searcher = searcher;
        }
    }
}
