SUPPORTED_EXTENSIONS =
- Set.of("txt", "pdf", "docx", "md", "pptx", "xlsx");
+ DocumentImportFormatPolicy.supportedExtensions();
private static final Duration BATCH_MUTATION_LOCK_LEASE = Duration.ofMinutes(30);
private final DocumentImportBatchService batchService;
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportChunkSnapshotService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportChunkSnapshotService.java
index c5166d7b..251f90dc 100644
--- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportChunkSnapshotService.java
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportChunkSnapshotService.java
@@ -1,10 +1,12 @@
package tech.easyflow.ai.documentimport.task;
import com.alibaba.fastjson2.JSON;
+import com.easyagents.rag.ingestion.model.AnalysisResult;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.easyagents.CustomMultipartFile;
+import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
@@ -13,11 +15,20 @@ import javax.annotation.Resource;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.UUID;
+import java.util.function.Consumer;
/**
* 自动导入分块快照持久化服务。
*
- * 快照写入对象存储,向量化任务只保存稳定路径,避免依赖短期预览缓存。
+ * V2 快照使用“有界分片 + 最后写入清单”的提交协议,索引任务可以按批读取,
+ * 同时保留对历史 V1 单 JSON 快照的读取兼容。
*
* @author Codex
* @since 2026-07-31
@@ -25,7 +36,11 @@ import java.nio.charset.StandardCharsets;
@Service
public class DocumentImportChunkSnapshotService {
- private static final long MAX_SNAPSHOT_BYTES = 256L * 1024L * 1024L;
+ private static final String SNAPSHOT_VERSION_V2 = "v2";
+ private static final int DEFAULT_PART_CHUNK_COUNT = 128;
+ private static final long MAX_MANIFEST_BYTES = 4L * 1024L * 1024L;
+ private static final long MAX_PART_BYTES = 4L * 1024L * 1024L;
+ private static final long MAX_LEGACY_SNAPSHOT_BYTES = 256L * 1024L * 1024L;
@Resource(name = "default")
private FileStorageService storageService;
@@ -34,61 +49,727 @@ public class DocumentImportChunkSnapshotService {
* 持久化预览会话及其最终分块。
*
* @param session 预览会话
- * @return 快照存储路径
+ * @return V2 清单存储路径
*/
public String save(DocumentImportDtos.PreviewSession session) {
- if (session == null || session.getKnowledgeId() == null || session.getDocumentId() == null
- || session.getDocumentChunks() == null || session.getDocumentChunks().isEmpty()) {
- throw new BusinessException("分块快照内容不完整");
+ validateSession(session, true);
+ try (SnapshotWriter writer = createWriter(session)) {
+ List chunks = session.getDocumentChunks();
+ for (int start = 0; start < chunks.size(); start += DEFAULT_PART_CHUNK_COUNT) {
+ int end = Math.min(start + DEFAULT_PART_CHUNK_COUNT, chunks.size());
+ writer.append(new ArrayList(chunks.subList(start, end)));
+ }
+ return writer.finish();
}
- String fileName = session.getDocumentId() + "-chunks.json";
- byte[] payload = JSON.toJSONBytes(session);
- if (payload.length > MAX_SNAPSHOT_BYTES) {
- throw new BusinessException("分块快照过大,请调整文档后重试");
- }
- CustomMultipartFile file = new CustomMultipartFile(
- payload, fileName, fileName, "application/json");
- String path = storageService.save(file,
- "knowledge-import-snapshots/" + session.getKnowledgeId() + "/" + session.getDocumentId());
- if (!StringUtil.hasText(path)) {
- throw new BusinessException("分块快照保存失败");
- }
- return path;
}
/**
- * 从稳定存储恢复预览会话。
+ * 创建流式快照写入器。
+ *
+ * @param session 仅需包含归属、策略和预览元信息的会话
+ * @return 快照写入器
+ */
+ public SnapshotWriter createWriter(DocumentImportDtos.PreviewSession session) {
+ validateSession(session, false);
+ return new SnapshotWriter(copyHeader(session));
+ }
+
+ /**
+ * 从稳定存储完整恢复预览会话。
+ *
+ * 该方法用于兼容现有调用和小型预览。大文件索引应使用
+ * {@link #forEachBatch(String, int, Consumer)}。
*
* @param path 快照路径
* @return 预览会话
*/
public DocumentImportDtos.PreviewSession load(String path) {
- if (!StringUtil.hasText(path)) {
- throw new BusinessException("分块快照不存在,请重试");
+ SnapshotReadResult readResult = readManifestOrLegacy(path);
+ if (readResult.legacySession != null) {
+ return readResult.legacySession;
}
- try (InputStream inputStream = storageService.readStream(path)) {
- byte[] payload = DocumentInputStreamSupport.readBytes(inputStream, MAX_SNAPSHOT_BYTES);
- String json = new String(payload, StandardCharsets.UTF_8);
- DocumentImportDtos.PreviewSession session =
- JSON.parseObject(json, DocumentImportDtos.PreviewSession.class);
- if (session == null || session.getDocumentChunks() == null
- || session.getDocumentChunks().isEmpty()) {
- throw new BusinessException("分块快照无有效内容,请重试");
+ DocumentImportDtos.PreviewSession session = copyHeader(readResult.manifest.getSession());
+ List chunks = new ArrayList();
+ long loadedBytes = 0L;
+ for (SnapshotPart part : readResult.manifest.getParts()) {
+ PartPayload payload = readPart(part);
+ loadedBytes += payload.byteLength;
+ if (loadedBytes > MAX_LEGACY_SNAPSHOT_BYTES) {
+ throw new BusinessException("分块快照过大,请使用分页或批量读取");
}
- return session;
- } catch (IOException error) {
- throw new BusinessException("分块快照读取失败,请重试");
+ chunks.addAll(payload.chunks);
+ }
+ session.setDocumentChunks(chunks);
+ session.setTotalChunks(chunks.size());
+ session.setChunkSnapshotPath(path);
+ return session;
+ }
+
+ /**
+ * 仅读取快照头部元信息。
+ *
+ * @param path 快照路径
+ * @return 不包含完整分块的预览会话
+ */
+ public DocumentImportDtos.PreviewSession loadHeader(String path) {
+ SnapshotReadResult readResult = readManifestOrLegacy(path);
+ if (readResult.legacySession != null) {
+ DocumentImportDtos.PreviewSession header = copyHeader(readResult.legacySession);
+ int total = readResult.legacySession.getDocumentChunks() == null
+ ? 0
+ : readResult.legacySession.getDocumentChunks().size();
+ header.setTotalChunks(total);
+ header.setChunkSnapshotPath(path);
+ return header;
+ }
+ DocumentImportDtos.PreviewSession header = copyHeader(readResult.manifest.getSession());
+ header.setTotalChunks(readResult.manifest.getTotalChunks());
+ header.setChunkSnapshotPath(path);
+ return header;
+ }
+
+ /**
+ * 分页读取快照分块。
+ *
+ * @param path 快照路径
+ * @param offset 零基偏移
+ * @param limit 最大返回数
+ * @return 指定页分块
+ */
+ public List loadPage(String path, int offset, int limit) {
+ if (offset < 0 || limit <= 0) {
+ throw new BusinessException("分块快照分页参数不合法");
+ }
+ SnapshotReadResult readResult = readManifestOrLegacy(path);
+ if (readResult.legacySession != null) {
+ List chunks = readResult.legacySession.getDocumentChunks();
+ if (chunks == null || offset >= chunks.size()) {
+ return new ArrayList();
+ }
+ int end = Math.min(offset + limit, chunks.size());
+ return new ArrayList(chunks.subList(offset, end));
+ }
+ List page = new ArrayList(limit);
+ int skipped = 0;
+ for (SnapshotPart part : readResult.manifest.getParts()) {
+ if (page.size() >= limit) {
+ break;
+ }
+ if (skipped + part.getChunkCount() <= offset) {
+ skipped += part.getChunkCount();
+ continue;
+ }
+ List chunks = readPart(part).chunks;
+ int localStart = Math.max(0, offset - skipped);
+ int localEnd = Math.min(chunks.size(), localStart + limit - page.size());
+ page.addAll(chunks.subList(localStart, localEnd));
+ skipped += chunks.size();
+ }
+ return page;
+ }
+
+ /**
+ * 按固定上限顺序消费快照分块。
+ *
+ * @param path 快照路径
+ * @param batchSize 单批最大分块数
+ * @param consumer 批处理函数
+ */
+ public void forEachBatch(
+ String path,
+ int batchSize,
+ Consumer> consumer) {
+ if (batchSize <= 0 || consumer == null) {
+ throw new IllegalArgumentException("快照批量消费参数不合法");
+ }
+ SnapshotReadResult readResult = readManifestOrLegacy(path);
+ if (readResult.legacySession != null) {
+ consumeInBatches(readResult.legacySession.getDocumentChunks(), batchSize, consumer);
+ return;
+ }
+ for (SnapshotPart part : readResult.manifest.getParts()) {
+ consumeInBatches(readPart(part).chunks, batchSize, consumer);
}
}
/**
- * 删除已完成向量化的分块快照。
+ * 删除快照清单及其全部分片。
*
- * @param path 快照路径
+ * @param path 快照清单或历史 V1 快照路径
*/
public void delete(String path) {
+ if (!StringUtil.hasText(path)) {
+ return;
+ }
+ deleteParts(path);
+ deleteManifest(path);
+ }
+
+ /**
+ * 删除快照分片并保留清单。
+ *
+ * 任一分片删除失败时立即抛出异常,清单继续作为精确重试依据。
+ *
+ * @param path 快照清单或历史 V1 快照路径
+ */
+ public void deleteParts(String path) {
+ if (!StringUtil.hasText(path)) {
+ return;
+ }
+ SnapshotReadResult readResult = readManifestOrLegacy(path);
+ if (readResult.manifest == null) {
+ return;
+ }
+ for (SnapshotPart part : readResult.manifest.getParts()) {
+ if (StringUtil.hasText(part.getPath())) {
+ storageService.delete(part.getPath());
+ }
+ }
+ }
+
+ /**
+ * 删除快照清单或历史 V1 单对象。
+ *
+ * @param path 快照清单或历史 V1 快照路径
+ */
+ public void deleteManifest(String path) {
if (StringUtil.hasText(path)) {
storageService.delete(path);
}
}
+
+ /**
+ * 校验预览会话的最小完整性。
+ *
+ * @param session 预览会话
+ * @param requireChunks 是否要求已包含分块
+ */
+ private void validateSession(
+ DocumentImportDtos.PreviewSession session,
+ boolean requireChunks) {
+ if (session == null || session.getKnowledgeId() == null || session.getDocumentId() == null) {
+ throw new BusinessException("分块快照归属信息不完整");
+ }
+ if (requireChunks
+ && (session.getDocumentChunks() == null || session.getDocumentChunks().isEmpty())) {
+ throw new BusinessException("分块快照内容不完整");
+ }
+ }
+
+ /**
+ * 复制有界会话头,移除完整正文和分块数组。
+ *
+ * @param source 原始会话
+ * @return 会话头
+ */
+ private DocumentImportDtos.PreviewSession copyHeader(
+ DocumentImportDtos.PreviewSession source) {
+ DocumentImportDtos.PreviewSession header = new DocumentImportDtos.PreviewSession();
+ header.setSessionId(source.getSessionId());
+ header.setKnowledgeId(source.getKnowledgeId());
+ header.setDocumentId(source.getDocumentId());
+ header.setFilePath(source.getFilePath());
+ header.setFileName(source.getFileName());
+ header.setSourceFormat(source.getSourceFormat());
+ header.setStrategyConfig(source.getStrategyConfig());
+ if (source.getAnalysis() != null) {
+ AnalysisResult analysis = JSON.parseObject(
+ JSON.toJSONBytes(source.getAnalysis()), AnalysisResult.class);
+ analysis.setNormalizedContent(null);
+ header.setAnalysis(analysis);
+ }
+ header.setDocument(null);
+ header.setDocumentChunks(new ArrayList());
+ header.setPreviewChunks(new ArrayList<>());
+ header.setChunkSnapshotPath(source.getChunkSnapshotPath());
+ header.setTotalChunks(source.getTotalChunks());
+ header.setCreatedAt(source.getCreatedAt());
+ return header;
+ }
+
+ /**
+ * 读取 V2 清单或历史 V1 会话。
+ *
+ * @param path 快照路径
+ * @return 快照读取结果
+ */
+ private SnapshotReadResult readManifestOrLegacy(String path) {
+ if (!StringUtil.hasText(path)) {
+ throw new BusinessException("分块快照不存在,请重试");
+ }
+ byte[] payload;
+ try (InputStream inputStream = storageService.readStream(path)) {
+ payload = DocumentInputStreamSupport.readBytes(
+ inputStream, MAX_LEGACY_SNAPSHOT_BYTES);
+ } catch (IOException error) {
+ throw new BusinessException("分块快照读取失败,请重试");
+ }
+ SnapshotManifest manifest = JSON.parseObject(payload, SnapshotManifest.class);
+ if (manifest != null && SNAPSHOT_VERSION_V2.equals(manifest.getVersion())) {
+ validateManifest(manifest);
+ return SnapshotReadResult.forManifest(manifest);
+ }
+ DocumentImportDtos.PreviewSession session = JSON.parseObject(
+ new String(payload, StandardCharsets.UTF_8),
+ DocumentImportDtos.PreviewSession.class);
+ if (session == null || session.getDocumentChunks() == null
+ || session.getDocumentChunks().isEmpty()) {
+ throw new BusinessException("分块快照无有效内容,请重试");
+ }
+ return SnapshotReadResult.forLegacy(session);
+ }
+
+ /**
+ * 校验 V2 清单完整性。
+ *
+ * @param manifest 快照清单
+ */
+ private void validateManifest(SnapshotManifest manifest) {
+ if (manifest.getSession() == null
+ || manifest.getSession().getKnowledgeId() == null
+ || manifest.getSession().getDocumentId() == null
+ || manifest.getTotalChunks() <= 0
+ || manifest.getParts() == null
+ || manifest.getParts().isEmpty()) {
+ throw new BusinessException("分块快照清单损坏,请重试");
+ }
+ int total = 0;
+ for (SnapshotPart part : manifest.getParts()) {
+ if (part == null || !StringUtil.hasText(part.getPath())
+ || part.getChunkCount() <= 0
+ || part.getByteLength() <= 0
+ || !StringUtil.hasText(part.getSha256())) {
+ throw new BusinessException("分块快照清单损坏,请重试");
+ }
+ total += part.getChunkCount();
+ }
+ if (total != manifest.getTotalChunks()) {
+ throw new BusinessException("分块快照清单计数不一致,请重试");
+ }
+ }
+
+ /**
+ * 读取并校验一个分块分片。
+ *
+ * @param part 分片清单
+ * @return 分片内容
+ */
+ private PartPayload readPart(SnapshotPart part) {
+ try (InputStream inputStream = storageService.readStream(part.getPath())) {
+ byte[] payload = DocumentInputStreamSupport.readBytes(inputStream, MAX_PART_BYTES);
+ if (payload.length != part.getByteLength()
+ || !sha256(payload).equals(part.getSha256())) {
+ throw new BusinessException("分块快照校验失败,请重试");
+ }
+ List chunks = JSON.parseArray(
+ new String(payload, StandardCharsets.UTF_8), DocumentChunk.class);
+ if (chunks == null || chunks.size() != part.getChunkCount()) {
+ throw new BusinessException("分块快照分片损坏,请重试");
+ }
+ return new PartPayload(chunks, payload.length);
+ } catch (IOException error) {
+ throw new BusinessException("分块快照分片读取失败,请重试");
+ }
+ }
+
+ /**
+ * 将列表按固定大小交给消费者。
+ *
+ * @param chunks 分块
+ * @param batchSize 批大小
+ * @param consumer 消费者
+ */
+ private void consumeInBatches(
+ List chunks,
+ int batchSize,
+ Consumer> consumer) {
+ if (chunks == null || chunks.isEmpty()) {
+ return;
+ }
+ for (int start = 0; start < chunks.size(); start += batchSize) {
+ int end = Math.min(start + batchSize, chunks.size());
+ consumer.accept(new ArrayList(chunks.subList(start, end)));
+ }
+ }
+
+ /**
+ * 计算 SHA-256。
+ *
+ * @param payload 字节内容
+ * @return 十六进制摘要
+ */
+ private String sha256(byte[] payload) {
+ try {
+ return HexFormat.of().formatHex(
+ MessageDigest.getInstance("SHA-256").digest(payload));
+ } catch (NoSuchAlgorithmException error) {
+ throw new IllegalStateException("当前运行环境不支持 SHA-256", error);
+ }
+ }
+
+ /**
+ * V2 快照流式写入器。
+ */
+ public final class SnapshotWriter implements AutoCloseable {
+
+ private final SnapshotManifest manifest;
+ private final String storagePrefix;
+ private boolean committed;
+
+ /**
+ * 创建写入器。
+ *
+ * @param session 会话头
+ */
+ private SnapshotWriter(DocumentImportDtos.PreviewSession session) {
+ this.manifest = new SnapshotManifest();
+ this.manifest.setVersion(SNAPSHOT_VERSION_V2);
+ this.manifest.setSession(session);
+ this.manifest.setCreatedAt(new Date());
+ this.storagePrefix = "knowledge-import-snapshots/"
+ + session.getKnowledgeId() + "/"
+ + session.getDocumentId() + "/"
+ + UUID.randomUUID();
+ }
+
+ /**
+ * 追加一批分块,超出单分片上限时自动二分。
+ *
+ * @param chunks 分块
+ */
+ public void append(List chunks) {
+ if (committed) {
+ throw new IllegalStateException("分块快照已提交");
+ }
+ if (chunks == null || chunks.isEmpty()) {
+ return;
+ }
+ writeBoundedPart(new ArrayList(chunks));
+ }
+
+ /**
+ * 最后写入清单并提交快照。
+ *
+ * @return 清单路径
+ */
+ public String finish() {
+ if (committed) {
+ throw new IllegalStateException("分块快照已提交");
+ }
+ if (manifest.getTotalChunks() <= 0 || manifest.getParts().isEmpty()) {
+ throw new BusinessException("分块快照内容不完整");
+ }
+ byte[] payload = JSON.toJSONBytes(manifest);
+ if (payload.length > MAX_MANIFEST_BYTES) {
+ throw new BusinessException("分块快照清单过大,请调整文档后重试");
+ }
+ String fileName = manifest.getSession().getDocumentId() + "-manifest.json";
+ String path = storageService.save(
+ new CustomMultipartFile(payload, fileName, fileName, "application/json"),
+ storagePrefix);
+ if (!StringUtil.hasText(path)) {
+ throw new BusinessException("分块快照清单保存失败");
+ }
+ committed = true;
+ return path;
+ }
+
+ /**
+ * 未提交时删除已经写入的分片。
+ */
+ @Override
+ public void close() {
+ if (committed) {
+ return;
+ }
+ for (SnapshotPart part : manifest.getParts()) {
+ if (StringUtil.hasText(part.getPath())) {
+ storageService.delete(part.getPath());
+ }
+ }
+ }
+
+ /**
+ * 写入一个有界分片。
+ *
+ * @param chunks 分块
+ */
+ private void writeBoundedPart(List chunks) {
+ byte[] payload = JSON.toJSONBytes(chunks);
+ if (payload.length > MAX_PART_BYTES && chunks.size() > 1) {
+ int midpoint = chunks.size() / 2;
+ writeBoundedPart(new ArrayList(chunks.subList(0, midpoint)));
+ writeBoundedPart(new ArrayList(chunks.subList(midpoint, chunks.size())));
+ return;
+ }
+ if (payload.length > MAX_PART_BYTES) {
+ throw new BusinessException("单个分块快照超过 4 MiB,请缩小分块后重试");
+ }
+ int partNumber = manifest.getParts().size() + 1;
+ String fileName = String.format("part-%06d.json", partNumber);
+ String path = storageService.save(
+ new CustomMultipartFile(payload, fileName, fileName, "application/json"),
+ storagePrefix);
+ if (!StringUtil.hasText(path)) {
+ throw new BusinessException("分块快照分片保存失败");
+ }
+ SnapshotPart part = new SnapshotPart();
+ part.setPath(path);
+ part.setChunkCount(chunks.size());
+ part.setByteLength(payload.length);
+ part.setSha256(sha256(payload));
+ manifest.getParts().add(part);
+ manifest.setTotalChunks(manifest.getTotalChunks() + chunks.size());
+ }
+ }
+
+ /**
+ * V2 快照清单。
+ */
+ public static class SnapshotManifest {
+
+ private String version;
+ private DocumentImportDtos.PreviewSession session;
+ private int totalChunks;
+ private List parts = new ArrayList();
+ private Date createdAt;
+
+ /**
+ * 返回版本。
+ *
+ * @return 版本
+ */
+ public String getVersion() {
+ return version;
+ }
+
+ /**
+ * 设置版本。
+ *
+ * @param version 版本
+ */
+ public void setVersion(String version) {
+ this.version = version;
+ }
+
+ /**
+ * 返回会话头。
+ *
+ * @return 会话头
+ */
+ public DocumentImportDtos.PreviewSession getSession() {
+ return session;
+ }
+
+ /**
+ * 设置会话头。
+ *
+ * @param session 会话头
+ */
+ public void setSession(DocumentImportDtos.PreviewSession session) {
+ this.session = session;
+ }
+
+ /**
+ * 返回总分块数。
+ *
+ * @return 总分块数
+ */
+ public int getTotalChunks() {
+ return totalChunks;
+ }
+
+ /**
+ * 设置总分块数。
+ *
+ * @param totalChunks 总分块数
+ */
+ public void setTotalChunks(int totalChunks) {
+ this.totalChunks = totalChunks;
+ }
+
+ /**
+ * 返回分片清单。
+ *
+ * @return 分片清单
+ */
+ public List getParts() {
+ return parts;
+ }
+
+ /**
+ * 设置分片清单。
+ *
+ * @param parts 分片清单
+ */
+ public void setParts(List parts) {
+ this.parts = parts == null
+ ? new ArrayList()
+ : parts;
+ }
+
+ /**
+ * 返回创建时间。
+ *
+ * @return 创建时间
+ */
+ public Date getCreatedAt() {
+ return createdAt;
+ }
+
+ /**
+ * 设置创建时间。
+ *
+ * @param createdAt 创建时间
+ */
+ public void setCreatedAt(Date createdAt) {
+ this.createdAt = createdAt;
+ }
+ }
+
+ /**
+ * V2 快照分片元信息。
+ */
+ public static class SnapshotPart {
+
+ private String path;
+ private int chunkCount;
+ private long byteLength;
+ private String sha256;
+
+ /**
+ * 返回存储路径。
+ *
+ * @return 存储路径
+ */
+ public String getPath() {
+ return path;
+ }
+
+ /**
+ * 设置存储路径。
+ *
+ * @param path 存储路径
+ */
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ /**
+ * 返回分块数。
+ *
+ * @return 分块数
+ */
+ public int getChunkCount() {
+ return chunkCount;
+ }
+
+ /**
+ * 设置分块数。
+ *
+ * @param chunkCount 分块数
+ */
+ public void setChunkCount(int chunkCount) {
+ this.chunkCount = chunkCount;
+ }
+
+ /**
+ * 返回字节数。
+ *
+ * @return 字节数
+ */
+ public long getByteLength() {
+ return byteLength;
+ }
+
+ /**
+ * 设置字节数。
+ *
+ * @param byteLength 字节数
+ */
+ public void setByteLength(long byteLength) {
+ this.byteLength = byteLength;
+ }
+
+ /**
+ * 返回 SHA-256。
+ *
+ * @return SHA-256
+ */
+ public String getSha256() {
+ return sha256;
+ }
+
+ /**
+ * 设置 SHA-256。
+ *
+ * @param sha256 SHA-256
+ */
+ public void setSha256(String sha256) {
+ this.sha256 = sha256;
+ }
+ }
+
+ /**
+ * 内部读取结果。
+ */
+ private static final class SnapshotReadResult {
+
+ private final SnapshotManifest manifest;
+ private final DocumentImportDtos.PreviewSession legacySession;
+
+ /**
+ * 创建读取结果。
+ *
+ * @param manifest V2 清单
+ * @param legacySession V1 会话
+ */
+ private SnapshotReadResult(
+ SnapshotManifest manifest,
+ DocumentImportDtos.PreviewSession legacySession) {
+ this.manifest = manifest;
+ this.legacySession = legacySession;
+ }
+
+ /**
+ * 创建 V2 读取结果。
+ *
+ * @param manifest V2 清单
+ * @return 读取结果
+ */
+ private static SnapshotReadResult forManifest(SnapshotManifest manifest) {
+ return new SnapshotReadResult(manifest, null);
+ }
+
+ /**
+ * 创建 V1 读取结果。
+ *
+ * @param session V1 会话
+ * @return 读取结果
+ */
+ private static SnapshotReadResult forLegacy(
+ DocumentImportDtos.PreviewSession session) {
+ return new SnapshotReadResult(null, session);
+ }
+ }
+
+ /**
+ * 内部分片载荷。
+ */
+ private static final class PartPayload {
+
+ private final List chunks;
+ private final long byteLength;
+
+ /**
+ * 创建分片载荷。
+ *
+ * @param chunks 分块
+ * @param byteLength 字节数
+ */
+ private PartPayload(List chunks, long byteLength) {
+ this.chunks = chunks;
+ this.byteLength = byteLength;
+ }
+ }
}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportFormatPolicy.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportFormatPolicy.java
new file mode 100644
index 00000000..e40035de
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportFormatPolicy.java
@@ -0,0 +1,40 @@
+package tech.easyflow.ai.documentimport.task;
+
+import java.util.Set;
+
+/**
+ * 知识库文档导入格式统一策略。
+ *
+ * @author Codex
+ * @since 2026-08-04
+ */
+public final class DocumentImportFormatPolicy {
+
+ private static final Set SUPPORTED_EXTENSIONS =
+ Set.of("txt", "pdf", "docx", "md", "pptx", "xlsx", "csv");
+
+ /**
+ * 禁止实例化格式策略工具类。
+ */
+ private DocumentImportFormatPolicy() {
+ }
+
+ /**
+ * 返回只读的支持格式集合。
+ *
+ * @return 支持的文件扩展名
+ */
+ public static Set supportedExtensions() {
+ return SUPPORTED_EXTENSIONS;
+ }
+
+ /**
+ * 判断扩展名是否属于知识库导入支持范围。
+ *
+ * @param extension 已转换为小写的文件扩展名
+ * @return 支持时返回 {@code true}
+ */
+ public static boolean isSupported(String extension) {
+ return SUPPORTED_EXTENSIONS.contains(extension);
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportSnapshotCleanupMonitor.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportSnapshotCleanupMonitor.java
new file mode 100644
index 00000000..d9954674
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportSnapshotCleanupMonitor.java
@@ -0,0 +1,42 @@
+package tech.easyflow.ai.documentimport.task;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import tech.easyflow.common.cache.DistributedScheduledLock;
+
+/**
+ * 文档导入中间快照清理调度器。
+ *
+ * @author Codex
+ * @since 2026-08-04
+ */
+@Component
+public class DocumentImportSnapshotCleanupMonitor {
+
+ private final DocumentImportSnapshotCleanupService cleanupService;
+
+ /**
+ * 创建快照清理调度器。
+ *
+ * @param cleanupService 快照清理服务
+ */
+ public DocumentImportSnapshotCleanupMonitor(
+ DocumentImportSnapshotCleanupService cleanupService) {
+ this.cleanupService = cleanupService;
+ }
+
+ /**
+ * 周期性重试到期的快照清理记录。
+ */
+ @Scheduled(
+ fixedDelayString =
+ "${easyflow.ai.document-import.snapshot-cleanup-interval:60s}",
+ initialDelayString =
+ "${easyflow.ai.document-import.snapshot-cleanup-initial-delay:60s}")
+ @DistributedScheduledLock(
+ key = "easyflow:schedule:document-import:snapshot-cleanup",
+ leaseSeconds = 300L)
+ public void cleanupPendingSnapshots() {
+ cleanupService.processPendingCleanups();
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportSnapshotCleanupService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportSnapshotCleanupService.java
new file mode 100644
index 00000000..f2f5e620
--- /dev/null
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportSnapshotCleanupService.java
@@ -0,0 +1,352 @@
+package tech.easyflow.ai.documentimport.task;
+
+import com.mybatisflex.core.keygen.impl.FlexIDKeyGenerator;
+import com.mybatisflex.core.query.QueryWrapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import tech.easyflow.ai.entity.DocumentImportSnapshotCleanup;
+import tech.easyflow.ai.mapper.DocumentImportSnapshotCleanupMapper;
+import tech.easyflow.ai.mapper.DocumentMapper;
+import tech.easyflow.common.util.StringUtil;
+
+import javax.annotation.Resource;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.util.Date;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * 文档导入中间快照可靠清理服务。
+ *
+ * 先持久化精确清单路径,再按“分片、清单”两个阶段删除。阶段推进先于
+ * 清单删除落库,确保进程在任一位置中断后都能安全继续。
+ *
+ * @author Codex
+ * @since 2026-08-04
+ */
+@Service
+public class DocumentImportSnapshotCleanupService {
+
+ public static final String TYPE_CHUNK = "CHUNK";
+ public static final String TYPE_CSV_TABLE = "CSV_TABLE";
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(DocumentImportSnapshotCleanupService.class);
+ private static final String PHASE_PARTS_PENDING = "PARTS_PENDING";
+ private static final String PHASE_MANIFEST_PENDING = "MANIFEST_PENDING";
+ private static final int PROCESS_BATCH_SIZE = 50;
+ private static final int MAX_PATH_CHARS = 2_048;
+ private static final int MAX_ERROR_CHARS = 1_024;
+ private static final Duration CLAIM_LEASE = Duration.ofHours(1);
+ private static final Duration MIN_RETRY_DELAY = Duration.ofSeconds(30);
+ private static final Duration MAX_RETRY_DELAY = Duration.ofHours(1);
+
+ @Resource
+ private DocumentImportSnapshotCleanupMapper cleanupMapper;
+
+ @Resource
+ private DocumentImportChunkSnapshotService chunkSnapshotService;
+
+ @Resource
+ private CsvTableSnapshotService csvTableSnapshotService;
+
+ @Resource
+ private DocumentMapper documentMapper;
+
+ private final FlexIDKeyGenerator keyGenerator =
+ new FlexIDKeyGenerator();
+
+ /**
+ * 登记并尝试清理分块快照。
+ *
+ * @param manifestPath 分块快照清单路径
+ */
+ public void scheduleChunkSnapshot(String manifestPath) {
+ schedule(null, null, TYPE_CHUNK, manifestPath);
+ }
+
+ /**
+ * 登记并尝试清理 CSV 表格快照。
+ *
+ * @param knowledgeId 知识库 ID
+ * @param documentId 文档 ID
+ * @param manifestPath CSV 表格清单路径
+ */
+ public void scheduleCsvTableSnapshot(
+ BigInteger knowledgeId,
+ BigInteger documentId,
+ String manifestPath) {
+ schedule(
+ knowledgeId,
+ documentId,
+ TYPE_CSV_TABLE,
+ manifestPath);
+ }
+
+ /**
+ * 处理一批已到期的快照清理记录。
+ */
+ public void processPendingCleanups() {
+ Date now = new Date();
+ List records =
+ cleanupMapper.selectDueRecords(now, PROCESS_BATCH_SIZE);
+ for (DocumentImportSnapshotCleanup record : records) {
+ processRecord(record.getId());
+ }
+ }
+
+ /**
+ * 幂等登记清理记录,并在当前没有事务时立即尝试一次。
+ *
+ * @param knowledgeId 知识库 ID
+ * @param documentId 文档 ID
+ * @param snapshotType 快照类型
+ * @param manifestPath 清单路径
+ */
+ private void schedule(
+ BigInteger knowledgeId,
+ BigInteger documentId,
+ String snapshotType,
+ String manifestPath) {
+ if (!StringUtil.hasText(manifestPath)) {
+ return;
+ }
+ if (manifestPath.length() > MAX_PATH_CHARS) {
+ throw new IllegalArgumentException("快照清单路径超过 2048 字符");
+ }
+ Date now = new Date();
+ String pathHash = sha256(manifestPath);
+ DocumentImportSnapshotCleanup record =
+ new DocumentImportSnapshotCleanup();
+ record.setId(generateId(record));
+ record.setKnowledgeId(knowledgeId);
+ record.setDocumentId(documentId);
+ record.setSnapshotType(snapshotType);
+ record.setManifestPath(manifestPath);
+ record.setPathHash(pathHash);
+ record.setPhase(PHASE_PARTS_PENDING);
+ record.setAttemptCount(0);
+ record.setNextRetryAt(now);
+ record.setCreated(now);
+ record.setModified(now);
+ cleanupMapper.insertIgnore(record);
+
+ DocumentImportSnapshotCleanup persisted =
+ cleanupMapper.selectOneByQuery(
+ QueryWrapper.create()
+ .eq(
+ DocumentImportSnapshotCleanup::getSnapshotType,
+ snapshotType)
+ .eq(
+ DocumentImportSnapshotCleanup::getPathHash,
+ pathHash));
+ if (persisted == null
+ || !Objects.equals(
+ manifestPath, persisted.getManifestPath())) {
+ throw new IllegalStateException("快照清理记录登记失败");
+ }
+ // 事务中的登记必须随业务提交;后台调度器会在提交后处理。
+ if (!TransactionSynchronizationManager
+ .isActualTransactionActive()) {
+ processRecord(persisted.getId());
+ }
+ }
+
+ /**
+ * 领取并处理一条清理记录。
+ *
+ * @param recordId 清理记录 ID
+ */
+ private void processRecord(BigInteger recordId) {
+ Date now = new Date();
+ String executionToken = UUID.randomUUID().toString();
+ Date leaseUntil = new Date(
+ now.getTime() + CLAIM_LEASE.toMillis());
+ if (cleanupMapper.claim(
+ recordId, executionToken, leaseUntil, now) <= 0) {
+ return;
+ }
+
+ DocumentImportSnapshotCleanup record =
+ cleanupMapper.selectOneByQuery(
+ QueryWrapper.create()
+ .eq(DocumentImportSnapshotCleanup::getId, recordId)
+ .eq(
+ DocumentImportSnapshotCleanup::getExecutionToken,
+ executionToken));
+ if (record == null) {
+ return;
+ }
+ try {
+ if (PHASE_PARTS_PENDING.equals(record.getPhase())) {
+ deleteParts(record);
+ if (cleanupMapper.advanceToManifest(
+ recordId, executionToken, new Date()) <= 0) {
+ throw new IllegalStateException(
+ "快照清理阶段推进失败");
+ }
+ record.setPhase(PHASE_MANIFEST_PENDING);
+ }
+ if (!PHASE_MANIFEST_PENDING.equals(record.getPhase())) {
+ throw new IllegalStateException(
+ "未知快照清理阶段: " + record.getPhase());
+ }
+ deleteManifest(record);
+ clearCsvSnapshotPointer(record);
+ if (cleanupMapper.deleteCompleted(
+ recordId, executionToken) <= 0) {
+ LOG.warn(
+ "快照对象已清理,但清理记录终态删除未命中: cleanupId={}, path={}",
+ recordId, record.getManifestPath());
+ }
+ } catch (RuntimeException error) {
+ releaseForRetry(record, executionToken, error);
+ }
+ }
+
+ /**
+ * 删除记录指向的全部快照分片。
+ *
+ * @param record 清理记录
+ */
+ private void deleteParts(
+ DocumentImportSnapshotCleanup record) {
+ if (TYPE_CHUNK.equals(record.getSnapshotType())) {
+ chunkSnapshotService.deleteParts(
+ record.getManifestPath());
+ return;
+ }
+ if (TYPE_CSV_TABLE.equals(record.getSnapshotType())) {
+ csvTableSnapshotService.deleteParts(
+ record.getManifestPath());
+ return;
+ }
+ throw new IllegalStateException(
+ "未知快照类型: " + record.getSnapshotType());
+ }
+
+ /**
+ * 删除记录指向的快照清单。
+ *
+ * @param record 清理记录
+ */
+ private void deleteManifest(
+ DocumentImportSnapshotCleanup record) {
+ if (TYPE_CHUNK.equals(record.getSnapshotType())) {
+ chunkSnapshotService.deleteManifest(
+ record.getManifestPath());
+ return;
+ }
+ if (TYPE_CSV_TABLE.equals(record.getSnapshotType())) {
+ csvTableSnapshotService.deleteManifest(
+ record.getManifestPath());
+ return;
+ }
+ throw new IllegalStateException(
+ "未知快照类型: " + record.getSnapshotType());
+ }
+
+ /**
+ * CSV 清单删除完成后原子移除仍指向该清单的文档选项。
+ *
+ * @param record 清理记录
+ */
+ private void clearCsvSnapshotPointer(
+ DocumentImportSnapshotCleanup record) {
+ if (TYPE_CSV_TABLE.equals(record.getSnapshotType())
+ && record.getDocumentId() != null) {
+ documentMapper.clearCsvSnapshotPath(
+ record.getDocumentId(),
+ record.getManifestPath(),
+ new Date());
+ }
+ }
+
+ /**
+ * 释放失败记录并按指数退避安排下一次重试。
+ *
+ * @param record 清理记录
+ * @param executionToken 执行令牌
+ * @param error 清理异常
+ */
+ private void releaseForRetry(
+ DocumentImportSnapshotCleanup record,
+ String executionToken,
+ RuntimeException error) {
+ Date now = new Date();
+ int attempts = Math.max(
+ 1,
+ record.getAttemptCount() == null
+ ? 1
+ : record.getAttemptCount());
+ long multiplier = 1L << Math.min(7, attempts - 1);
+ long delayMillis = Math.min(
+ MAX_RETRY_DELAY.toMillis(),
+ MIN_RETRY_DELAY.toMillis() * multiplier);
+ String errorMessage = boundedError(error);
+ int released = cleanupMapper.releaseForRetry(
+ record.getId(),
+ executionToken,
+ new Date(now.getTime() + delayMillis),
+ errorMessage,
+ now);
+ LOG.error(
+ "文档导入快照清理失败,已安排重试: cleanupId={}, type={}, path={}, released={}",
+ record.getId(),
+ record.getSnapshotType(),
+ record.getManifestPath(),
+ released,
+ error);
+ }
+
+ /**
+ * 返回有界错误摘要。
+ *
+ * @param error 清理异常
+ * @return 有界摘要
+ */
+ private String boundedError(RuntimeException error) {
+ String message = error.getMessage();
+ String summary = error.getClass().getSimpleName()
+ + (StringUtil.hasText(message) ? ": " + message : "");
+ return summary.length() <= MAX_ERROR_CHARS
+ ? summary
+ : summary.substring(0, MAX_ERROR_CHARS);
+ }
+
+ /**
+ * 计算清单路径 SHA-256。
+ *
+ * @param value 清单路径
+ * @return 十六进制摘要
+ */
+ private String sha256(String value) {
+ try {
+ return HexFormat.of().formatHex(
+ MessageDigest.getInstance("SHA-256").digest(
+ value.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException error) {
+ throw new IllegalStateException(
+ "当前运行环境不支持 SHA-256", error);
+ }
+ }
+
+ /**
+ * 生成清理记录主键。
+ *
+ * @param entity 清理记录
+ * @return 主键
+ */
+ private BigInteger generateId(Object entity) {
+ return new BigInteger(
+ String.valueOf(keyGenerator.generate(entity, null)));
+ }
+}
diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/KnowledgeDocumentImportTaskAppService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/KnowledgeDocumentImportTaskAppService.java
index 5d754305..c3a58941 100644
--- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/KnowledgeDocumentImportTaskAppService.java
+++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/KnowledgeDocumentImportTaskAppService.java
@@ -119,6 +119,7 @@ public class KnowledgeDocumentImportTaskAppService {
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";
@@ -135,6 +136,8 @@ public class KnowledgeDocumentImportTaskAppService {
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;
@@ -182,6 +185,12 @@ public class KnowledgeDocumentImportTaskAppService {
@Resource
private DocumentImportChunkSnapshotService documentImportChunkSnapshotService;
+ @Resource
+ private CsvTableSnapshotService csvTableSnapshotService;
+
+ @Resource
+ private DocumentImportSnapshotCleanupService snapshotCleanupService;
+
@Resource
private RagIngestionService ragIngestionService;
@@ -713,20 +722,43 @@ public class KnowledgeDocumentImportTaskAppService {
tech.easyflow.ai.entity.Document document = requireDocumentForKnowledge(request.getDocumentId(), knowledge.getId());
ensurePreviewable(document);
- StrategyConfig requestedStrategy = null;
- if (request.getFiles() != null && !request.getFiles().isEmpty()) {
- requestedStrategy = request.getFiles().get(0).getStrategyConfig();
+ 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());
+ }
}
- DocumentImportDtos.PreviewSession session = buildPreviewSessionForDocument(knowledge, document, requestedStrategy);
- String sessionId = documentImportPreviewService.put(session);
-
- DocumentImportDtos.PreviewFileResult item = buildPreviewFileResult(document, session, sessionId);
+ List 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(session.getDocumentChunks().size());
+ response.setTotalChunks(resolveSessionTotalChunks(session));
return Result.ok(response);
}
@@ -755,16 +787,22 @@ public class KnowledgeDocumentImportTaskAppService {
request.getPreviewSessionId(),
StringUtil.hasText(request.getPreviewSessionId()) ? null : existingSnapshotPath
);
- int totalChunks = session.getDocumentChunks().size();
+ int totalChunks = resolveSessionTotalChunks(session);
if (totalChunks <= 0) {
throw new BusinessException("未生成有效分块,无法开始向量化");
}
- boolean createdSnapshot = StringUtil.hasText(request.getPreviewSessionId())
- || !StringUtil.hasText(existingSnapshotPath);
- String snapshotPath = createdSnapshot
- ? documentImportChunkSnapshotService.save(session)
- : existingSnapshotPath;
+ 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) {
@@ -993,11 +1031,13 @@ public class KnowledgeDocumentImportTaskAppService {
knowledge, document, requestedStrategy);
previewSessionId = documentImportPreviewService.put(session);
session.setSessionId(previewSessionId);
- int totalChunks = session.getDocumentChunks().size();
+ int totalChunks = resolveSessionTotalChunks(session);
if (totalChunks <= 0) {
throw new BusinessException("未生成有效分块,无法开始向量化");
}
- snapshotPath = documentImportChunkSnapshotService.save(session);
+ snapshotPath = StringUtil.hasText(session.getChunkSnapshotPath())
+ ? session.getChunkSnapshotPath()
+ : documentImportChunkSnapshotService.save(session);
if (!touchRunningTask(task) || !executionLock.renew()) {
throw new TaskOwnershipLostException(task.getId());
}
@@ -1015,8 +1055,11 @@ public class KnowledgeDocumentImportTaskAppService {
String errorMessage = truncateError(error.getMessage());
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, TASK_ERROR_SPLIT_FAILED)) {
+ task, task.getDocumentId(), errorMessage, failureCode)) {
LOG.warn("分块任务所有权已失效,忽略迟到失败: taskId={}", taskId);
}
} finally {
@@ -1182,37 +1225,57 @@ public class KnowledgeDocumentImportTaskAppService {
List storedChunks = new ArrayList();
try {
DocumentCollection knowledge = assertDocumentCollection(task.getKnowledgeId());
- DocumentImportDtos.PreviewSession session = resolveIndexPreviewSession(
- knowledge,
- document,
- asString(task.getPayloadJson().get("previewSessionId")),
- asString(task.getPayloadJson().get("chunkSnapshotPath"))
- );
- List chunks = session.getDocumentChunks();
- if (chunks == null || chunks.isEmpty()) {
- throw new BusinessException("预览会话无有效分块");
- }
- assertUniqueChunkIds(chunks);
-
if (!touchRunningTask(task) || !executionLock.renew()) {
throw new TaskOwnershipLostException(task.getId());
}
clearPersistedChunks(document.getId());
storeContext = prepareStoreContext(document);
- int totalChunks = chunks.size();
- int completedChunks = 0;
- for (int start = 0; start < chunks.size(); start += INDEX_BATCH_SIZE) {
- int end = Math.min(start + INDEX_BATCH_SIZE, chunks.size());
- List batch = new ArrayList(chunks.subList(start, end));
+ 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 uniqueChunkIds =
+ new HashSet(Math.min(totalChunks, 65_536));
+ int[] completedChunks = new int[]{0};
+ java.util.function.Consumer> batchConsumer = batch -> {
+ assertUniqueChunkIds(batch, uniqueChunkIds);
LOG.info("文档向量化任务开始处理批次: taskId={}, documentId={}, batchStart={}, batchEnd={}, batchSize={}, totalChunks={}",
- taskId, document.getId(), start, end, batch.size(), totalChunks);
- storeDocumentChunks(storeContext, batch);
- storedChunks.addAll(batch);
+ taskId, document.getId(), completedChunks[0],
+ completedChunks[0] + batch.size(), batch.size(), totalChunks);
+ // 提前登记当前批次,确保外部存储部分成功后仍能覆盖回滚范围。
+ storedChunks.addAll(toChunkIdMarkers(batch));
+ storeDocumentChunks(activeStoreContext, batch);
persistChunkBatch(document, batch);
- completedChunks += batch.size();
- updateDocumentIndexProgress(document.getId(), totalChunks, completedChunks);
- if (!touchRunningTask(task) || !executionLock.renew()) {
- throw new TaskOwnershipLostException(task.getId());
+ 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 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(
+ chunks.subList(start, end)));
}
}
updateKnowledgeAfterStore(storeContext);
@@ -1222,6 +1285,7 @@ public class KnowledgeDocumentImportTaskAppService {
}
deleteChunkSnapshotAfterCompletion(
asString(task.getPayloadJson().get("chunkSnapshotPath")));
+ cleanupCsvTableSnapshotAfterCompletion(document);
} catch (TaskOwnershipLostException ownershipLost) {
// 旧执行失去 token 后不能清理可能已由新执行写入的索引或分块。
LOG.warn("向量化任务执行令牌已失效,停止迟到回滚: taskId={}, documentId={}",
@@ -1239,9 +1303,13 @@ public class KnowledgeDocumentImportTaskAppService {
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));
+ resolveIndexFailureMessage(e, rollbackSucceeded),
+ failureCode);
} finally {
closeStoreContext(storeContext);
}
@@ -1272,6 +1340,35 @@ public class KnowledgeDocumentImportTaskAppService {
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());
@@ -1283,11 +1380,22 @@ public class KnowledgeDocumentImportTaskAppService {
markParseSuccess(task, document, parsedResult, fileExt, null);
}
- private void markParseSuccess(DocumentImportTask task,
- tech.easyflow.ai.entity.Document document,
- DocumentParsedResult parsedResult,
- String sourceFormat,
- String providerTaskId) {
+ /**
+ * 发布解析结果并原子推进文档和批次状态。
+ *
+ * @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)) {
@@ -1301,6 +1409,7 @@ public class KnowledgeDocumentImportTaskAppService {
}
if (parsedResult.getMetadata() != null && !parsedResult.getMetadata().isEmpty()) {
options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_METADATA, new LinkedHashMap(parsedResult.getMetadata()));
+ copyCsvParseMetadata(options, parsedResult.getMetadata());
}
if (parsedResult.getWarnings() != null && !parsedResult.getWarnings().isEmpty()) {
options.put(DocumentImportKeys.KEY_DOCUMENT_PARSE_WARNINGS, new ArrayList(parsedResult.getWarnings()));
@@ -1337,11 +1446,12 @@ public class KnowledgeDocumentImportTaskAppService {
if (!selfProxy.completeParseTask(task, document, automaticBatch)) {
LOG.warn("解析任务所有权已失效,忽略迟到结果: taskId={}, documentId={}",
task.getId(), document.getId());
- return;
+ return false;
}
LOG.info("文档解析任务完成: taskId={}, documentId={}, processStatus={}, providerTaskId={}, contentLength={}",
task.getId(), document.getId(), document.getProcessStatus(), providerTaskId,
parsedKnowledgeContent.documentLlmContent.length());
+ return true;
}
/**
@@ -1603,6 +1713,9 @@ public class KnowledgeDocumentImportTaskAppService {
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)) {
@@ -2628,6 +2741,20 @@ public class KnowledgeDocumentImportTaskAppService {
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);
}
@@ -2742,7 +2869,9 @@ public class KnowledgeDocumentImportTaskAppService {
if (rowsPerChunk == null || rowsPerChunk <= 0) {
rowsPerChunk = 10;
}
- config.setStrategyCode(OFFICE_XLSX_ROW_WINDOW_STRATEGY);
+ config.setStrategyCode("csv".equals(sourceFormat)
+ ? TABLE_ROW_STRATEGY
+ : OFFICE_XLSX_ROW_WINDOW_STRATEGY);
config.setRowsPerChunk(rowsPerChunk);
return config;
}
@@ -2906,14 +3035,21 @@ public class KnowledgeDocumentImportTaskAppService {
? new ArrayList