From ec6e03587a85a7f6fdff4d40ce4098b82a9c3c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Wed, 26 Aug 2026 18:58:42 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=AE=8C=E6=88=90=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根据关联文档真实状态识别已完成的历史失败批次 - 完成后自动移除提示并低频复查失败或中断批次 --- .../DocumentImportBatchDtos.java | 19 +++++ .../task/DocumentImportBatchAppService.java | 75 ++++++++++++++++- .../DocumentImportBatchAppServiceTest.java | 84 +++++++++++++++++++ .../DocumentImportBatchStatus.test.ts | 64 ++++++++++++-- .../DocumentImportBatchStatus.vue | 24 +++--- 5 files changed, 248 insertions(+), 18 deletions(-) diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/DocumentImportBatchDtos.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/DocumentImportBatchDtos.java index a0149fb6..e7bd7c8c 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/DocumentImportBatchDtos.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/DocumentImportBatchDtos.java @@ -252,6 +252,7 @@ public final class DocumentImportBatchDtos { private BigInteger batchId; private String importMode; private String status; + private Boolean actualCompleted; private Integer totalCount; private Long totalBytes; private Integer completedCount; @@ -292,6 +293,24 @@ public final class DocumentImportBatchDtos { this.status = status; } + /** + * 获取批次关联任务是否已经按真实文档状态全部完成。 + * + * @return 全部完成时返回 {@code true} + */ + public Boolean getActualCompleted() { + return actualCompleted; + } + + /** + * 设置批次关联任务是否已经按真实文档状态全部完成。 + * + * @param actualCompleted 是否全部完成 + */ + public void setActualCompleted(Boolean actualCompleted) { + this.actualCompleted = actualCompleted; + } + public Integer getTotalCount() { return totalCount; } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportBatchAppService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportBatchAppService.java index c7d07703..e3ad7e5a 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportBatchAppService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/documentimport/task/DocumentImportBatchAppService.java @@ -21,6 +21,7 @@ import tech.easyflow.ai.enums.DocumentImportBatchItemStage; import tech.easyflow.ai.enums.DocumentImportBatchItemStatus; import tech.easyflow.ai.enums.DocumentImportBatchStatus; import tech.easyflow.ai.enums.DocumentImportMode; +import tech.easyflow.ai.enums.DocumentProcessStatus; import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper; import tech.easyflow.ai.mapper.DocumentImportBatchMapper; import tech.easyflow.ai.mapper.DocumentMapper; @@ -435,7 +436,79 @@ public class DocumentImportBatchAppService { .orderBy(DocumentImportBatch::getCreated, false) .limit(1) ); - return batch == null ? null : batchTracker.toStatusResponse(batch); + if (batch == null) { + return null; + } + DocumentImportBatchDtos.StatusResponse response = + batchTracker.toStatusResponse(batch); + response.setActualCompleted(isAutoBatchActuallyCompleted(batch)); + return response; + } + + /** + * 根据批次项及其关联文档的真实状态判断失败批次是否已经完成。 + * + *

正常完成批次直接返回成功;仅对部分失败或中断批次执行补充查询, + * 避免运行中轮询产生额外数据库压力。批次项数量不完整、文档缺失、 + * 跨知识库或文档仍未完成时均保持失败提示。

+ * + * @param batch 自动导入批次 + * @return 批次关联任务是否已经全部完成 + */ + private boolean isAutoBatchActuallyCompleted(DocumentImportBatch batch) { + if (DocumentImportBatchStatus.COMPLETED.name().equals(batch.getStatus())) { + return true; + } + if (!DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name().equals(batch.getStatus()) + && !DocumentImportBatchStatus.INTERRUPTED.name().equals(batch.getStatus())) { + return false; + } + List items = itemService.list( + QueryWrapper.create() + .eq(DocumentImportBatchItem::getBatchId, batch.getId()) + .orderBy(DocumentImportBatchItem::getId, true) + ); + int totalCount = valueOrZero(batch.getTotalCount()); + if (items == null || totalCount <= 0 || items.size() != totalCount) { + return false; + } + + Set unresolvedDocumentIds = new LinkedHashSet(); + for (DocumentImportBatchItem item : items) { + String itemStatus = item.getStatus(); + if (DocumentImportBatchItemStatus.COMPLETED.name().equals(itemStatus) + || DocumentImportBatchItemStatus.SKIPPED.name().equals(itemStatus) + || DocumentImportBatchItemStatus.CANCELLED.name().equals(itemStatus)) { + continue; + } + if (item.getDocumentId() == null + || !batch.getKnowledgeId().equals(item.getKnowledgeId())) { + return false; + } + unresolvedDocumentIds.add(item.getDocumentId()); + } + if (unresolvedDocumentIds.isEmpty()) { + return true; + } + + List completedDocuments = + documentMapper.selectListByQuery( + QueryWrapper.create() + .select(tech.easyflow.ai.entity.Document::getId) + .eq(tech.easyflow.ai.entity.Document::getCollectionId, + batch.getKnowledgeId()) + .eq(tech.easyflow.ai.entity.Document::getProcessStatus, + DocumentProcessStatus.COMPLETED.name()) + .in(tech.easyflow.ai.entity.Document::getId, + unresolvedDocumentIds) + ); + if (completedDocuments == null) { + return false; + } + Set completedDocumentIds = completedDocuments.stream() + .map(tech.easyflow.ai.entity.Document::getId) + .collect(java.util.stream.Collectors.toSet()); + return completedDocumentIds.containsAll(unresolvedDocumentIds); } /** diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/documentimport/task/DocumentImportBatchAppServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/documentimport/task/DocumentImportBatchAppServiceTest.java index d1715b5b..6fd45211 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/documentimport/task/DocumentImportBatchAppServiceTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/documentimport/task/DocumentImportBatchAppServiceTest.java @@ -19,6 +19,7 @@ import tech.easyflow.ai.enums.DocumentImportBatchItemStage; import tech.easyflow.ai.enums.DocumentImportBatchItemStatus; import tech.easyflow.ai.enums.DocumentImportBatchStatus; import tech.easyflow.ai.enums.DocumentImportMode; +import tech.easyflow.ai.enums.DocumentProcessStatus; import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper; import tech.easyflow.ai.mapper.DocumentImportBatchMapper; import tech.easyflow.ai.mapper.DocumentMapper; @@ -47,6 +48,89 @@ import java.util.function.BooleanSupplier; */ public class DocumentImportBatchAppServiceTest { + /** + * 验证历史失败批次关联文档均已完成时返回真实完成标记。 + */ + @Test + public void latestAutoBatchShouldDetectActuallyCompletedDocuments() { + TestContext context = createContext(); + DocumentImportBatch batch = context.batchService.getOne( + QueryWrapper.create() + ); + batch.setImportMode(DocumentImportMode.AUTO.name()); + batch.setStatus(DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name()); + batch.setTotalCount(2); + + DocumentImportBatchItem completed = uploadedItem( + BigInteger.valueOf(11), batch.getId() + ); + completed.setStatus(DocumentImportBatchItemStatus.COMPLETED.name()); + completed.setDocumentId(BigInteger.valueOf(101)); + DocumentImportBatchItem staleFailed = uploadedItem( + BigInteger.valueOf(12), batch.getId() + ); + staleFailed.setStatus(DocumentImportBatchItemStatus.FAILED.name()); + staleFailed.setDocumentId(BigInteger.valueOf(102)); + Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class))) + .thenReturn(List.of(completed, staleFailed)); + + tech.easyflow.ai.entity.Document recovered = + new tech.easyflow.ai.entity.Document(); + recovered.setId(staleFailed.getDocumentId()); + recovered.setCollectionId(batch.getKnowledgeId()); + recovered.setProcessStatus(DocumentProcessStatus.COMPLETED.name()); + Mockito.when(context.documentMapper.selectListByQuery(Mockito.any())) + .thenReturn(List.of(recovered)); + + DocumentImportBatchDtos.StatusResponse batchResponse = + new DocumentImportBatchDtos.StatusResponse(); + batchResponse.setStatus(batch.getStatus()); + Mockito.when(context.batchTracker.toStatusResponse(batch)) + .thenReturn(batchResponse); + + DocumentImportBatchDtos.StatusResponse response = + context.service.getLatestAutoBatch(batch.getKnowledgeId()); + + Assert.assertTrue(response.getActualCompleted()); + Mockito.verify(context.documentMapper) + .selectListByQuery(Mockito.any(QueryWrapper.class)); + } + + /** + * 验证失败项关联文档仍未完成时继续保留批次提示。 + */ + @Test + public void latestAutoBatchShouldKeepIncompleteFailureVisible() { + TestContext context = createContext(); + DocumentImportBatch batch = context.batchService.getOne( + QueryWrapper.create() + ); + batch.setImportMode(DocumentImportMode.AUTO.name()); + batch.setStatus(DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name()); + batch.setTotalCount(1); + + DocumentImportBatchItem failed = uploadedItem( + BigInteger.valueOf(13), batch.getId() + ); + failed.setStatus(DocumentImportBatchItemStatus.FAILED.name()); + failed.setDocumentId(BigInteger.valueOf(103)); + Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class))) + .thenReturn(List.of(failed)); + Mockito.when(context.documentMapper.selectListByQuery(Mockito.any())) + .thenReturn(List.of()); + + DocumentImportBatchDtos.StatusResponse batchResponse = + new DocumentImportBatchDtos.StatusResponse(); + batchResponse.setStatus(batch.getStatus()); + Mockito.when(context.batchTracker.toStatusResponse(batch)) + .thenReturn(batchResponse); + + DocumentImportBatchDtos.StatusResponse response = + context.service.getLatestAutoBatch(batch.getKnowledgeId()); + + Assert.assertFalse(response.getActualCompleted()); + } + /** * 验证上传领取通过单条多表更新同步刷新批次进度时间。 */ diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentImportBatchStatus.test.ts b/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentImportBatchStatus.test.ts index 052e4b6b..a3c28fdd 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentImportBatchStatus.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentImportBatchStatus.test.ts @@ -51,7 +51,7 @@ describe('documentImportBatchStatus', () => { wrapper.unmount(); }); - it('当前页面跟踪的批次完成后继续展示结果', async () => { + it('当前页面跟踪的批次完成后移除提示', async () => { vi.useFakeTimers(); apiMocks.get .mockResolvedValueOnce({ @@ -73,10 +73,62 @@ describe('documentImportBatchStatus', () => { await vi.advanceTimersByTimeAsync(3000); await flushPromises(); + expect(wrapper.find('.batch-status').exists()).toBe(false); + wrapper.unmount(); + }); + + it('失败批次关联任务实际完成后不再展示提示', async () => { + apiMocks.get.mockResolvedValue({ + data: { + ...createBatch('RUNNING', 0), + actualCompleted: true, + failedCount: 2, + pendingCount: 0, + status: 'PARTIAL_SUCCEEDED', + }, + errorCode: 0, + }); + + const wrapper = mount(DocumentImportBatchStatus, { + props: { knowledgeId: 'knowledge-1' }, + }); + await flushPromises(); + + expect(wrapper.find('.batch-status').exists()).toBe(false); + wrapper.unmount(); + }); + + it('失败批次低频复查后在关联任务完成时移除提示', async () => { + vi.useFakeTimers(); + const failedBatch = { + ...createBatch('RUNNING', 0), + actualCompleted: false, + failedCount: 2, + pendingCount: 0, + status: 'PARTIAL_SUCCEEDED', + }; + apiMocks.get + .mockResolvedValueOnce({ + data: failedBatch, + errorCode: 0, + }) + .mockResolvedValueOnce({ + data: { ...failedBatch, actualCompleted: true }, + errorCode: 0, + }); + + const wrapper = mount(DocumentImportBatchStatus, { + props: { knowledgeId: 'knowledge-1' }, + }); + await flushPromises(); + expect(wrapper.find('.batch-status').exists()).toBe(true); - expect(wrapper.text()).toContain( - 'documentCollection.importDoc.batchCompleted', - ); + + await vi.advanceTimersByTimeAsync(15_000); + await flushPromises(); + + expect(apiMocks.get).toHaveBeenCalledTimes(2); + expect(wrapper.find('.batch-status').exists()).toBe(false); wrapper.unmount(); }); @@ -239,9 +291,7 @@ describe('documentImportBatchStatus', () => { expect(apiMocks.get).toHaveBeenCalledTimes(3); expect(wrapper.find('.batch-status__load-error').exists()).toBe(false); - expect(wrapper.text()).toContain( - 'documentCollection.importDoc.batchCompleted', - ); + expect(wrapper.find('.batch-status').exists()).toBe(false); wrapper.unmount(); }); }); diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentImportBatchStatus.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentImportBatchStatus.vue index d387329e..647e2b46 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentImportBatchStatus.vue +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentImportBatchStatus.vue @@ -8,6 +8,7 @@ import { ElAlert, ElButton, ElProgress } from 'element-plus'; import { api } from '#/api/request'; interface BatchStatus { + actualCompleted?: boolean; batchId: string; cancelledCount?: number; completedCount: number; @@ -53,6 +54,7 @@ let pollTimer: null | ReturnType = null; let disposed = false; let refreshGeneration = 0; let pollDelayMs = 3000; +const TERMINAL_RECHECK_DELAY_MS = 15_000; const canContinue = computed( () => @@ -122,7 +124,7 @@ const interruptMetadata = computed(() => { return details.join(' · '); }); -async function refresh(hideCompletedOnRestore = false) { +async function refresh() { if (!props.knowledgeId) return; const currentGeneration = ++refreshGeneration; refreshing.value = true; @@ -142,10 +144,9 @@ async function refresh(hideCompletedOnRestore = false) { loadError.value = ''; pollDelayMs = 3000; const restoredBatch = response.data || undefined; - batch.value = - hideCompletedOnRestore && restoredBatch?.status === 'COMPLETED' - ? undefined - : restoredBatch; + const completed = + restoredBatch?.status === 'COMPLETED' || restoredBatch?.actualCompleted; + batch.value = completed ? undefined : restoredBatch; } catch { if (!disposed && currentGeneration === refreshGeneration) { loadError.value = $t( @@ -175,13 +176,16 @@ function schedulePoll() { if ( !batch.value || batch.value.status === 'COMPLETED' || - batch.value.status === 'PARTIAL_SUCCEEDED' || - batch.value.status === 'INTERRUPTED' || batch.value.status === 'CANCELLED' ) { return; } - pollTimer = setTimeout(refresh, 3000); + const delay = + batch.value.status === 'PARTIAL_SUCCEEDED' || + batch.value.status === 'INTERRUPTED' + ? TERMINAL_RECHECK_DELAY_MS + : 3000; + pollTimer = setTimeout(refresh, delay); } async function continueBatch() { @@ -204,7 +208,7 @@ async function continueBatch() { onMounted(() => { disposed = false; - refresh(true); + refresh(); }); onBeforeUnmount(() => { @@ -222,7 +226,7 @@ watch( loadError.value = ''; pollDelayMs = 3000; } - refresh(knowledgeChanged); + refresh(); }, );