fix(search): assemble batched SSE results (#6186)

This commit is contained in:
jxxghp
2026-07-26 08:55:34 +08:00
parent ba48a25982
commit 42ca5b8f94
6 changed files with 164 additions and 18 deletions
+42
View File
@@ -0,0 +1,42 @@
import { SearchReplaceBatchCollector, isSearchReplaceBatchEvent } from '@/utils/searchStream'
import { describe, expect, it } from 'vitest'
/** 构造一个与后端最终结果分批协议一致的测试事件。 */
function createBatch(batchIndex: number, items: number[]) {
return {
batch_count: 3,
batch_index: batchIndex,
items,
replace_batch: true,
total_items: 5,
type: batchIndex === 0 ? 'replace' : 'append',
}
}
describe('SearchReplaceBatchCollector', () => {
it('returns one atomic snapshot after every ordered batch arrives', () => {
const collector = new SearchReplaceBatchCollector<number>()
expect(collector.append(createBatch(0, [1, 2]))).toBeNull()
expect(collector.append(createBatch(1, [3, 4]))).toBeNull()
expect(collector.append(createBatch(2, [5]))).toEqual([1, 2, 3, 4, 5])
})
it('rejects a missing batch and accepts a fresh sequence afterwards', () => {
const collector = new SearchReplaceBatchCollector<number>()
expect(collector.append(createBatch(0, [1, 2]))).toBeNull()
expect(() => collector.append(createBatch(2, [5]))).toThrow('搜索结果批次顺序不连续')
expect(collector.append(createBatch(0, [1, 2]))).toBeNull()
expect(collector.append(createBatch(1, [3, 4]))).toBeNull()
expect(collector.append(createBatch(2, [5]))).toEqual([1, 2, 3, 4, 5])
})
it('rejects malformed batch metadata', () => {
const malformedEvent = { ...createBatch(0, [1, 2]), batch_count: 0 }
expect(isSearchReplaceBatchEvent(malformedEvent)).toBe(false)
expect(() => new SearchReplaceBatchCollector<number>().append(malformedEvent)).toThrow('搜索结果批次格式无效')
})
})
+79
View File
@@ -0,0 +1,79 @@
/** 后端最终搜索结果分批事件的传输字段。 */
export interface SearchReplaceBatchEvent<T> extends Record<string, unknown> {
batch_count: number
batch_index: number
items: T[]
replace_batch: true
total_items: number
}
/** 判断搜索流事件是否为结构完整的最终结果批次。 */
export function isSearchReplaceBatchEvent<T>(event: Record<string, unknown>): event is SearchReplaceBatchEvent<T> {
return (
event.replace_batch === true &&
Number.isInteger(event.batch_index) &&
Number(event.batch_index) >= 0 &&
Number.isInteger(event.batch_count) &&
Number(event.batch_count) > 0 &&
Number(event.batch_index) < Number(event.batch_count) &&
Number.isInteger(event.total_items) &&
Number(event.total_items) >= 0 &&
Array.isArray(event.items)
)
}
/**
* 按 SSE 顺序收集最终搜索结果批次,仅在全部批次完整到达后返回可提交快照。
*/
export class SearchReplaceBatchCollector<T> {
private batchCount = 0
private expectedTotalItems = 0
private items: T[] = []
private nextBatchIndex = 0
/** 清除未完成批次,供新搜索、断流回退和组件卸载时复用。 */
reset(): void {
this.batchCount = 0
this.expectedTotalItems = 0
this.items = []
this.nextBatchIndex = 0
}
/**
* 接收一个最终结果批次;未收齐时返回 null,完整且数量一致时返回结果快照。
*/
append(event: Record<string, unknown>): T[] | null {
if (!isSearchReplaceBatchEvent<T>(event)) {
this.reset()
throw new Error('搜索结果批次格式无效')
}
if (event.batch_index === 0) {
this.reset()
this.batchCount = event.batch_count
this.expectedTotalItems = event.total_items
}
if (
event.batch_index !== this.nextBatchIndex ||
event.batch_count !== this.batchCount ||
event.total_items !== this.expectedTotalItems
) {
this.reset()
throw new Error('搜索结果批次顺序不连续')
}
this.items.push(...event.items)
this.nextBatchIndex += 1
if (this.nextBatchIndex < this.batchCount) return null
if (this.items.length !== this.expectedTotalItems) {
this.reset()
throw new Error('搜索结果批次数量不完整')
}
const completedItems = this.items
this.reset()
return completedItems
}
}