Compare commits
3 Commits
develop
...
1870ac4028
| Author | SHA1 | Date | |
|---|---|---|---|
| 1870ac4028 | |||
| 2a383ef3f2 | |||
| 34ff62d317 |
@@ -252,6 +252,7 @@ public final class DocumentImportBatchDtos {
|
|||||||
private BigInteger batchId;
|
private BigInteger batchId;
|
||||||
private String importMode;
|
private String importMode;
|
||||||
private String status;
|
private String status;
|
||||||
|
private Boolean actualCompleted;
|
||||||
private Integer totalCount;
|
private Integer totalCount;
|
||||||
private Long totalBytes;
|
private Long totalBytes;
|
||||||
private Integer completedCount;
|
private Integer completedCount;
|
||||||
@@ -292,6 +293,24 @@ public final class DocumentImportBatchDtos {
|
|||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取批次关联任务是否已经按真实文档状态全部完成。
|
||||||
|
*
|
||||||
|
* @return 全部完成时返回 {@code true}
|
||||||
|
*/
|
||||||
|
public Boolean getActualCompleted() {
|
||||||
|
return actualCompleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置批次关联任务是否已经按真实文档状态全部完成。
|
||||||
|
*
|
||||||
|
* @param actualCompleted 是否全部完成
|
||||||
|
*/
|
||||||
|
public void setActualCompleted(Boolean actualCompleted) {
|
||||||
|
this.actualCompleted = actualCompleted;
|
||||||
|
}
|
||||||
|
|
||||||
public Integer getTotalCount() {
|
public Integer getTotalCount() {
|
||||||
return totalCount;
|
return totalCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import tech.easyflow.ai.enums.DocumentImportBatchItemStage;
|
|||||||
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
|
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
|
||||||
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
|
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
|
||||||
import tech.easyflow.ai.enums.DocumentImportMode;
|
import tech.easyflow.ai.enums.DocumentImportMode;
|
||||||
|
import tech.easyflow.ai.enums.DocumentProcessStatus;
|
||||||
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
|
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
|
||||||
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
|
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
|
||||||
import tech.easyflow.ai.mapper.DocumentMapper;
|
import tech.easyflow.ai.mapper.DocumentMapper;
|
||||||
@@ -435,7 +436,79 @@ public class DocumentImportBatchAppService {
|
|||||||
.orderBy(DocumentImportBatch::getCreated, false)
|
.orderBy(DocumentImportBatch::getCreated, false)
|
||||||
.limit(1)
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据批次项及其关联文档的真实状态判断失败批次是否已经完成。
|
||||||
|
*
|
||||||
|
* <p>正常完成批次直接返回成功;仅对部分失败或中断批次执行补充查询,
|
||||||
|
* 避免运行中轮询产生额外数据库压力。批次项数量不完整、文档缺失、
|
||||||
|
* 跨知识库或文档仍未完成时均保持失败提示。</p>
|
||||||
|
*
|
||||||
|
* @param batch 自动导入批次
|
||||||
|
* @return 批次关联任务是否已经全部完成
|
||||||
|
*/
|
||||||
|
private boolean isAutoBatchActuallyCompleted(DocumentImportBatch batch) {
|
||||||
|
if (DocumentImportBatchStatus.COMPLETED.name().equals(batch.getStatus())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name().equals(batch.getStatus())
|
||||||
|
&& !DocumentImportBatchStatus.INTERRUPTED.name().equals(batch.getStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
List<DocumentImportBatchItem> items = itemService.list(
|
||||||
|
QueryWrapper.create()
|
||||||
|
.eq(DocumentImportBatchItem::getBatchId, batch.getId())
|
||||||
|
.orderBy(DocumentImportBatchItem::getId, true)
|
||||||
|
);
|
||||||
|
int totalCount = valueOrZero(batch.getTotalCount());
|
||||||
|
if (items == null || totalCount <= 0 || items.size() != totalCount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<BigInteger> unresolvedDocumentIds = new LinkedHashSet<BigInteger>();
|
||||||
|
for (DocumentImportBatchItem item : items) {
|
||||||
|
String itemStatus = item.getStatus();
|
||||||
|
if (DocumentImportBatchItemStatus.COMPLETED.name().equals(itemStatus)
|
||||||
|
|| DocumentImportBatchItemStatus.SKIPPED.name().equals(itemStatus)
|
||||||
|
|| DocumentImportBatchItemStatus.CANCELLED.name().equals(itemStatus)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.getDocumentId() == null
|
||||||
|
|| !batch.getKnowledgeId().equals(item.getKnowledgeId())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
unresolvedDocumentIds.add(item.getDocumentId());
|
||||||
|
}
|
||||||
|
if (unresolvedDocumentIds.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<tech.easyflow.ai.entity.Document> completedDocuments =
|
||||||
|
documentMapper.selectListByQuery(
|
||||||
|
QueryWrapper.create()
|
||||||
|
.select(tech.easyflow.ai.entity.Document::getId)
|
||||||
|
.eq(tech.easyflow.ai.entity.Document::getCollectionId,
|
||||||
|
batch.getKnowledgeId())
|
||||||
|
.eq(tech.easyflow.ai.entity.Document::getProcessStatus,
|
||||||
|
DocumentProcessStatus.COMPLETED.name())
|
||||||
|
.in(tech.easyflow.ai.entity.Document::getId,
|
||||||
|
unresolvedDocumentIds)
|
||||||
|
);
|
||||||
|
if (completedDocuments == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Set<BigInteger> completedDocumentIds = completedDocuments.stream()
|
||||||
|
.map(tech.easyflow.ai.entity.Document::getId)
|
||||||
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
|
return completedDocumentIds.containsAll(unresolvedDocumentIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import tech.easyflow.ai.enums.DocumentImportBatchItemStage;
|
|||||||
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
|
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
|
||||||
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
|
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
|
||||||
import tech.easyflow.ai.enums.DocumentImportMode;
|
import tech.easyflow.ai.enums.DocumentImportMode;
|
||||||
|
import tech.easyflow.ai.enums.DocumentProcessStatus;
|
||||||
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
|
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
|
||||||
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
|
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
|
||||||
import tech.easyflow.ai.mapper.DocumentMapper;
|
import tech.easyflow.ai.mapper.DocumentMapper;
|
||||||
@@ -47,6 +48,89 @@ import java.util.function.BooleanSupplier;
|
|||||||
*/
|
*/
|
||||||
public class DocumentImportBatchAppServiceTest {
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证上传领取通过单条多表更新同步刷新批次进度时间。
|
* 验证上传领取通过单条多表更新同步刷新批次进度时间。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe('documentImportBatchStatus', () => {
|
|||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当前页面跟踪的批次完成后继续展示结果', async () => {
|
it('当前页面跟踪的批次完成后移除提示', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
apiMocks.get
|
apiMocks.get
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@@ -73,10 +73,62 @@ describe('documentImportBatchStatus', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(3000);
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
await flushPromises();
|
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.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();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,9 +291,7 @@ describe('documentImportBatchStatus', () => {
|
|||||||
|
|
||||||
expect(apiMocks.get).toHaveBeenCalledTimes(3);
|
expect(apiMocks.get).toHaveBeenCalledTimes(3);
|
||||||
expect(wrapper.find('.batch-status__load-error').exists()).toBe(false);
|
expect(wrapper.find('.batch-status__load-error').exists()).toBe(false);
|
||||||
expect(wrapper.text()).toContain(
|
expect(wrapper.find('.batch-status').exists()).toBe(false);
|
||||||
'documentCollection.importDoc.batchCompleted',
|
|
||||||
);
|
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ElAlert, ElButton, ElProgress } from 'element-plus';
|
|||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
|
|
||||||
interface BatchStatus {
|
interface BatchStatus {
|
||||||
|
actualCompleted?: boolean;
|
||||||
batchId: string;
|
batchId: string;
|
||||||
cancelledCount?: number;
|
cancelledCount?: number;
|
||||||
completedCount: number;
|
completedCount: number;
|
||||||
@@ -53,6 +54,7 @@ let pollTimer: null | ReturnType<typeof setTimeout> = null;
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
let refreshGeneration = 0;
|
let refreshGeneration = 0;
|
||||||
let pollDelayMs = 3000;
|
let pollDelayMs = 3000;
|
||||||
|
const TERMINAL_RECHECK_DELAY_MS = 15_000;
|
||||||
|
|
||||||
const canContinue = computed(
|
const canContinue = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -122,7 +124,7 @@ const interruptMetadata = computed(() => {
|
|||||||
return details.join(' · ');
|
return details.join(' · ');
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refresh(hideCompletedOnRestore = false) {
|
async function refresh() {
|
||||||
if (!props.knowledgeId) return;
|
if (!props.knowledgeId) return;
|
||||||
const currentGeneration = ++refreshGeneration;
|
const currentGeneration = ++refreshGeneration;
|
||||||
refreshing.value = true;
|
refreshing.value = true;
|
||||||
@@ -142,10 +144,9 @@ async function refresh(hideCompletedOnRestore = false) {
|
|||||||
loadError.value = '';
|
loadError.value = '';
|
||||||
pollDelayMs = 3000;
|
pollDelayMs = 3000;
|
||||||
const restoredBatch = response.data || undefined;
|
const restoredBatch = response.data || undefined;
|
||||||
batch.value =
|
const completed =
|
||||||
hideCompletedOnRestore && restoredBatch?.status === 'COMPLETED'
|
restoredBatch?.status === 'COMPLETED' || restoredBatch?.actualCompleted;
|
||||||
? undefined
|
batch.value = completed ? undefined : restoredBatch;
|
||||||
: restoredBatch;
|
|
||||||
} catch {
|
} catch {
|
||||||
if (!disposed && currentGeneration === refreshGeneration) {
|
if (!disposed && currentGeneration === refreshGeneration) {
|
||||||
loadError.value = $t(
|
loadError.value = $t(
|
||||||
@@ -175,13 +176,16 @@ function schedulePoll() {
|
|||||||
if (
|
if (
|
||||||
!batch.value ||
|
!batch.value ||
|
||||||
batch.value.status === 'COMPLETED' ||
|
batch.value.status === 'COMPLETED' ||
|
||||||
batch.value.status === 'PARTIAL_SUCCEEDED' ||
|
|
||||||
batch.value.status === 'INTERRUPTED' ||
|
|
||||||
batch.value.status === 'CANCELLED'
|
batch.value.status === 'CANCELLED'
|
||||||
) {
|
) {
|
||||||
return;
|
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() {
|
async function continueBatch() {
|
||||||
@@ -204,7 +208,7 @@ async function continueBatch() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
disposed = false;
|
disposed = false;
|
||||||
refresh(true);
|
refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -222,7 +226,7 @@ watch(
|
|||||||
loadError.value = '';
|
loadError.value = '';
|
||||||
pollDelayMs = 3000;
|
pollDelayMs = 3000;
|
||||||
}
|
}
|
||||||
refresh(knowledgeChanged);
|
refresh();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user