fix: 完善自动导入异常中断与恢复
- 自动导入基础设施异常触发批次熔断,保留完整日志并输出安全错误信息 - 增加恢复令牌与租约围栏、无文档失败项重建及消息退避机制 - 前端展示中断状态并在状态请求失败后自动恢复轮询 - 补充批次中断迁移、配置与并发异常路径测试
This commit is contained in:
@@ -153,4 +153,95 @@ describe('documentImportBatchStatus', () => {
|
||||
expect(wrapper.find('.batch-status__continue').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('中断批次展示异常原因、错误标识和继续入口', async () => {
|
||||
apiMocks.get.mockResolvedValue({
|
||||
data: {
|
||||
...createBatch('RUNNING', 0),
|
||||
failedCount: 2,
|
||||
interruptCode: 'redis_unavailable',
|
||||
interruptedAt: '2026-08-07T09:25:28+08:00',
|
||||
interruptMessage: '缓存与消息服务异常,自动导入已中断',
|
||||
pendingCount: 0,
|
||||
status: 'INTERRUPTED',
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
|
||||
const wrapper = mount(DocumentImportBatchStatus, {
|
||||
props: {
|
||||
knowledgeId: 'knowledge-1',
|
||||
manageable: true,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const alert = wrapper.find('.batch-status__alert');
|
||||
expect(alert.exists()).toBe(true);
|
||||
expect(alert.text()).toContain('缓存与消息服务异常,自动导入已中断');
|
||||
expect(alert.text()).toContain('redis_unavailable');
|
||||
expect(wrapper.find('.batch-status__count').text()).toContain('2 / 2');
|
||||
expect(wrapper.find('.batch-status__continue').exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('状态接口暂时失败时展示错误并自动退避恢复轮询', async () => {
|
||||
vi.useFakeTimers();
|
||||
apiMocks.get
|
||||
.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
.mockResolvedValueOnce({
|
||||
data: createBatch('RUNNING', 1),
|
||||
errorCode: 0,
|
||||
});
|
||||
|
||||
const wrapper = mount(DocumentImportBatchStatus, {
|
||||
props: { knowledgeId: 'knowledge-1' },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.batch-status--error').exists()).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.get).toHaveBeenCalledTimes(2);
|
||||
expect(wrapper.find('.batch-status--error').exists()).toBe(false);
|
||||
expect(wrapper.find('.batch-status').exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('运行批次轮询失败时保留旧状态、展示错误并自动恢复', async () => {
|
||||
vi.useFakeTimers();
|
||||
apiMocks.get
|
||||
.mockResolvedValueOnce({
|
||||
data: createBatch('RUNNING', 1),
|
||||
errorCode: 0,
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
.mockResolvedValueOnce({
|
||||
data: createBatch('COMPLETED', 2),
|
||||
errorCode: 0,
|
||||
});
|
||||
|
||||
const wrapper = mount(DocumentImportBatchStatus, {
|
||||
props: { knowledgeId: 'knowledge-1' },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.batch-status__summary').exists()).toBe(true);
|
||||
expect(wrapper.find('.batch-status__load-error').exists()).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.get).toHaveBeenCalledTimes(3);
|
||||
expect(wrapper.find('.batch-status__load-error').exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain(
|
||||
'documentCollection.importDoc.batchCompleted',
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,15 +3,19 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { ElButton, ElProgress } from 'element-plus';
|
||||
import { ElAlert, ElButton, ElProgress } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
interface BatchStatus {
|
||||
batchId: string;
|
||||
cancelledCount?: number;
|
||||
completedCount: number;
|
||||
failedCount: number;
|
||||
importMode: 'AUTO' | 'MANUAL';
|
||||
interruptCode?: string;
|
||||
interruptedAt?: string;
|
||||
interruptMessage?: string;
|
||||
pendingCount: number;
|
||||
processingCount: number;
|
||||
progressPercent: number;
|
||||
@@ -43,9 +47,12 @@ const props = defineProps({
|
||||
const emit = defineEmits(['continued']);
|
||||
const batch = ref<BatchStatus>();
|
||||
const continuing = ref(false);
|
||||
const loadError = ref('');
|
||||
const refreshing = ref(false);
|
||||
let pollTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
let disposed = false;
|
||||
let refreshGeneration = 0;
|
||||
let pollDelayMs = 3000;
|
||||
|
||||
const canContinue = computed(
|
||||
() =>
|
||||
@@ -55,11 +62,15 @@ const canContinue = computed(
|
||||
Number(batch.value?.failedCount || 0) > 0,
|
||||
);
|
||||
|
||||
const processedCount = computed(
|
||||
() =>
|
||||
const processedCount = computed(() => {
|
||||
const processed =
|
||||
Number(batch.value?.completedCount || 0) +
|
||||
Number(batch.value?.processingCount || 0),
|
||||
);
|
||||
Number(batch.value?.processingCount || 0) +
|
||||
Number(batch.value?.failedCount || 0) +
|
||||
Number(batch.value?.skippedCount || 0) +
|
||||
Number(batch.value?.cancelledCount || 0);
|
||||
return Math.min(Number(batch.value?.totalCount || 0), processed);
|
||||
});
|
||||
|
||||
const allFailed = computed(
|
||||
() =>
|
||||
@@ -86,9 +97,35 @@ const statusLabel = computed(() => {
|
||||
return $t('documentCollection.importDoc.batchRunning');
|
||||
});
|
||||
|
||||
const interruptMetadata = computed(() => {
|
||||
if (batch.value?.status !== 'INTERRUPTED') {
|
||||
return '';
|
||||
}
|
||||
const details: string[] = [];
|
||||
if (batch.value.interruptCode) {
|
||||
details.push(
|
||||
`${$t('documentCollection.importDoc.interruptCode')} ${
|
||||
batch.value.interruptCode
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (batch.value.interruptedAt) {
|
||||
const interruptedAt = new Date(batch.value.interruptedAt);
|
||||
details.push(
|
||||
`${$t('documentCollection.importDoc.interruptedAt')} ${
|
||||
Number.isNaN(interruptedAt.getTime())
|
||||
? batch.value.interruptedAt
|
||||
: interruptedAt.toLocaleString()
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return details.join(' · ');
|
||||
});
|
||||
|
||||
async function refresh(hideCompletedOnRestore = false) {
|
||||
if (!props.knowledgeId) return;
|
||||
const currentGeneration = ++refreshGeneration;
|
||||
refreshing.value = true;
|
||||
try {
|
||||
const response = await api.get('/api/v1/document/import/batch/current', {
|
||||
params: { knowledgeId: props.knowledgeId },
|
||||
@@ -96,14 +133,28 @@ async function refresh(hideCompletedOnRestore = false) {
|
||||
if (disposed || currentGeneration !== refreshGeneration) {
|
||||
return;
|
||||
}
|
||||
const restoredBatch =
|
||||
response.errorCode === 0 ? response.data || undefined : undefined;
|
||||
if (response.errorCode !== 0) {
|
||||
loadError.value =
|
||||
response.message ||
|
||||
$t('documentCollection.importDoc.batchStatusLoadFailed');
|
||||
return;
|
||||
}
|
||||
loadError.value = '';
|
||||
pollDelayMs = 3000;
|
||||
const restoredBatch = response.data || undefined;
|
||||
batch.value =
|
||||
hideCompletedOnRestore && restoredBatch?.status === 'COMPLETED'
|
||||
? undefined
|
||||
: restoredBatch;
|
||||
} catch {
|
||||
if (!disposed && currentGeneration === refreshGeneration) {
|
||||
loadError.value = $t(
|
||||
'documentCollection.importDoc.batchStatusLoadFailed',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!disposed && currentGeneration === refreshGeneration) {
|
||||
refreshing.value = false;
|
||||
schedulePoll();
|
||||
}
|
||||
}
|
||||
@@ -112,8 +163,16 @@ async function refresh(hideCompletedOnRestore = false) {
|
||||
function schedulePoll() {
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
if (loadError.value) {
|
||||
const retryDelay = pollDelayMs;
|
||||
pollDelayMs = Math.min(pollDelayMs * 2, 30_000);
|
||||
pollTimer = setTimeout(refresh, retryDelay);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
disposed ||
|
||||
!batch.value ||
|
||||
batch.value.status === 'COMPLETED' ||
|
||||
batch.value.status === 'PARTIAL_SUCCEEDED' ||
|
||||
@@ -160,6 +219,8 @@ watch(
|
||||
const knowledgeChanged = knowledgeId !== previousKnowledgeId;
|
||||
if (knowledgeChanged) {
|
||||
batch.value = undefined;
|
||||
loadError.value = '';
|
||||
pollDelayMs = 3000;
|
||||
}
|
||||
refresh(knowledgeChanged);
|
||||
},
|
||||
@@ -230,6 +291,46 @@ watch(
|
||||
>
|
||||
{{ $t('documentCollection.importDoc.continueBatch') }}
|
||||
</ElButton>
|
||||
<ElAlert
|
||||
v-if="batch.status === 'INTERRUPTED'"
|
||||
class="batch-status__alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="
|
||||
batch.interruptMessage ||
|
||||
$t('documentCollection.importDoc.interruptFallback')
|
||||
"
|
||||
:description="interruptMetadata"
|
||||
/>
|
||||
<div v-if="loadError" class="batch-status__load-error">
|
||||
<ElAlert
|
||||
class="batch-status__alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="loadError"
|
||||
/>
|
||||
<ElButton type="primary" link :loading="refreshing" @click="refresh()">
|
||||
{{ $t('documentCollection.importDoc.retry') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else-if="loadError"
|
||||
class="batch-status batch-status--error"
|
||||
:aria-label="$t('documentCollection.importDoc.batchStatus')"
|
||||
>
|
||||
<ElAlert
|
||||
class="batch-status__alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="loadError"
|
||||
/>
|
||||
<ElButton type="primary" link :loading="refreshing" @click="refresh()">
|
||||
{{ $t('documentCollection.importDoc.retry') }}
|
||||
</ElButton>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -237,6 +338,7 @@ watch(
|
||||
.batch-status {
|
||||
display: flex;
|
||||
flex: 1 1 360px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: min(360px, 100%);
|
||||
@@ -301,6 +403,25 @@ watch(
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.batch-status__alert {
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
|
||||
.batch-status__load-error {
|
||||
display: flex;
|
||||
flex: 1 0 100%;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.batch-status__load-error .batch-status__alert {
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.batch-status--error .batch-status__alert {
|
||||
flex-basis: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.batch-status {
|
||||
flex-basis: 100%;
|
||||
|
||||
@@ -85,7 +85,7 @@ const props = defineProps({
|
||||
const emits = defineEmits(['viewDoc', 'continueProcess']);
|
||||
|
||||
const STREAM_RECONNECT_DELAY = 1500;
|
||||
const STREAM_RELOAD_DELAY = 250;
|
||||
const STREAM_RELOAD_DELAY = 3000;
|
||||
|
||||
const pageDataRef = ref();
|
||||
const retryingDocumentIds = ref<Set<string>>(new Set());
|
||||
@@ -254,7 +254,7 @@ const scheduleReload = () => {
|
||||
}
|
||||
reloadTimer = setTimeout(() => {
|
||||
reloadTimer = null;
|
||||
pageDataRef.value?.reload?.();
|
||||
pageDataRef.value?.reload?.({ silent: true });
|
||||
}, STREAM_RELOAD_DELAY);
|
||||
};
|
||||
|
||||
@@ -535,6 +535,7 @@ watch(
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
clearReloadTimer();
|
||||
openTaskStatusStream();
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user