From 5caca86ad1c4b1bfe862136280356d738b1ab68f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 30 Aug 2026 22:47:41 +0800 Subject: [PATCH] =?UTF-8?q?task-84:=20=E4=BC=98=E5=8C=96=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E6=8A=93=E5=8F=96=E9=98=9F=E5=88=97=E7=8A=B6=E6=80=81=E5=90=88?= =?UTF-8?q?=E5=B9=B6=EF=BC=8C=E6=B6=88=E9=99=A4=20historyItems=20=E7=9A=84?= =?UTF-8?q?=E7=BA=BF=E6=80=A7=E9=87=8D=E5=A4=8D=E6=9F=A5=E6=89=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增纯 TS 合并模块 merge-history-items:以 taskId→行列表 二级索引替代 原 mergeProgress 的 [...map.values()].find() 全表扫描(O(n×m)→O(n+m))。 命中语义与原实现一致(taskId 相同且 resultId 相同或 incoming 无 resultId),命中后删除旧 key 写入新 key,shopName 变化不再留下幽灵 重复条目,同批内后到条目可命中先到新增。BrandShopDataCrawlTab 的 mergeProgress 改用该模块。9 个测试覆盖默认、批量、幂等、空、单元素、 maxItems 限流、非法输入、keyOf 故障零变更与幽灵条目场景。 --- .../components/BrandShopDataCrawlTab.vue | 11 +- .../src/shared/merge-history-items.ts | 119 +++++++++++ .../tests/merge-history-items.test.ts | 200 ++++++++++++++++++ 3 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 frontend-vue/src/shared/merge-history-items.ts create mode 100644 frontend-vue/tests/merge-history-items.test.ts diff --git a/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue b/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue index 1717c0c2..bad94ea0 100644 --- a/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue @@ -127,6 +127,7 @@ import { getPywebviewApi } from '@/shared/bridges/pywebview' import { getTaskPollIntervalMs } from '@/shared/task-progress-config' import { createCategorizedTimers } from '@/shared/utils/categorized-timers' import { saveUrlWithProgress } from '@/shared/utils/download-progress' +import { mergeHistoryItems } from '@/shared/merge-history-items' import { addShopDataCrawlCandidate, createShopDataCrawlTask, @@ -357,13 +358,9 @@ function mergeProgress(detail: ShopDataCrawlTaskDetailVo) { if (!taskId) return taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail } const incoming = (detail.items || []).map((item) => ({ ...item, taskId: item.taskId || taskId, taskStatus: item.taskStatus || detail.task?.status, error: item.error || detail.task?.errorMessage })) - const map = new Map(historyItems.value.map((item) => [historyKey(item), item])) - for (const item of incoming) { - const existing = [...map.values()].find((row) => row.taskId === item.taskId && (row.resultId === item.resultId || !item.resultId)) - if (existing) map.set(historyKey(existing), { ...existing, ...item }) - else map.set(historyKey(item), item) - } - historyItems.value = [...map.values()] + historyItems.value = mergeHistoryItems(historyItems.value, incoming, { + keyOf: historyKey, + }).items saveQueueState() } function isTaskDetail(row: ShopDataCrawlTaskDetailVo | ShopDataCrawlHistoryItem): row is ShopDataCrawlTaskDetailVo { diff --git a/frontend-vue/src/shared/merge-history-items.ts b/frontend-vue/src/shared/merge-history-items.ts new file mode 100644 index 00000000..b266d076 --- /dev/null +++ b/frontend-vue/src/shared/merge-history-items.ts @@ -0,0 +1,119 @@ +/** + * 历史条目合并(Task 84)。 + * + * 消除店铺抓取队列状态合并中 historyItems 的线性重复查找:原实现 + * `[...map.values()].find(...)` 对每个 incoming 条目全表扫描(O(n×m)), + * 且命中后沿用旧 key 写回,shopName 变化时会留下幽灵重复条目。本模块以 + * taskId → 行列表 二级索引替代全表扫描(O(n+m)),命中后删除旧 key、 + * 写入新 key,同批内后到条目可命中先到条目,结果幂等。 + * + * 命中语义与原 mergeProgress 一致:incoming 的 taskId 为正数,且 + * existing.resultId 与 incoming.resultId 相同(或 incoming 无 resultId + * 时命中同 taskId 的第一行)。key 相同(taskId/resultId/shopName 均未变) + * 的更新保持原有位置;key 变化的更新视为内容更新,写入新 key。 + * + * 纯 TS 无副作用模块:入参列表永不修改(先全量建索引再产出新数组); + * keyOf 抛错时整个合并失败且零状态变更,可恢复后继续工作。maxItems 为 + * 可选的输出上限(默认不限制),超过时保留最晚到达的条目。 + */ +export interface MergeHistoryItemsOptions { + /** 从条目提取唯一 key 的函数 */ + keyOf: (item: T) => string + /** 输出条目的最大条数(默认不限制),超过时驱逐最旧条目 */ + maxItems?: number +} + +export interface MergeHistoryItemsResult { + /** 合并后的条目数组(新数组,原数组不修改) */ + items: T[] + /** 命中现有条目并替换的条数 */ + updatedCount: number + /** 作为新条目追加的条数 */ + addedCount: number +} + +/** 命中语义:同 taskId,且 resultId 相同或 incoming 无 resultId(与原 mergeProgress 一致) */ +function matches( + existing: Record, + incoming: Record, +): boolean { + const incomingTaskId = incoming.taskId + if (typeof incomingTaskId !== 'number' || incomingTaskId <= 0) return false + if (existing.taskId !== incomingTaskId) return false + const incomingResultId = incoming.resultId + if (typeof incomingResultId === 'number' && incomingResultId > 0) { + return existing.resultId === incomingResultId + } + return true +} + +interface IndexedRow { + key: string + row: T +} + +export function mergeHistoryItems( + existing: T[], + incoming: T[], + options: MergeHistoryItemsOptions, +): MergeHistoryItemsResult { + if (!Array.isArray(existing)) throw new Error('existing 必须是数组') + if (!Array.isArray(incoming)) throw new Error('incoming 必须是数组') + const { keyOf, maxItems } = options ?? {} + if (typeof keyOf !== 'function') throw new Error('keyOf 必须是函数') + if (maxItems != null && !(maxItems > 0)) { + throw new Error('maxItems 必须为正数: ' + maxItems) + } + + const byKey = new Map() + const byTaskId = new Map[]>() + + function indexRow(row: T) { + const key = keyOf(row) + byKey.set(key, row) + const taskId = (row as Record).taskId + if (typeof taskId === 'number' && taskId > 0) { + const list = byTaskId.get(taskId) + if (list) list.push({ key, row }) + else byTaskId.set(taskId, [{ key, row }]) + } + } + + // 第一遍建索引:keyOf 抛错发生在任何状态变更之前 + for (const row of existing) indexRow(row) + + let updatedCount = 0 + let addedCount = 0 + for (const row of incoming) { + const taskId = (row as Record).taskId + let hit: IndexedRow | undefined + if (typeof taskId === 'number' && taskId > 0) { + const list = byTaskId.get(taskId) + if (list) { + hit = list.find((entry) => + matches(entry.row as Record, row as Record), + ) + } + } + if (hit) { + const oldKey = hit.key + const newKey = keyOf(row) + if (newKey !== oldKey) { + byKey.delete(oldKey) + hit.key = newKey + } + byKey.set(newKey, row) + hit.row = row + updatedCount += 1 + } else { + indexRow(row) + addedCount += 1 + } + } + + const items = [...byKey.values()] + if (maxItems != null && items.length > maxItems) { + return { items: items.slice(items.length - maxItems), updatedCount, addedCount } + } + return { items, updatedCount, addedCount } +} diff --git a/frontend-vue/tests/merge-history-items.test.ts b/frontend-vue/tests/merge-history-items.test.ts new file mode 100644 index 00000000..81b62573 --- /dev/null +++ b/frontend-vue/tests/merge-history-items.test.ts @@ -0,0 +1,200 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mergeHistoryItems } from '../src/shared/merge-history-items.ts' + +interface Item { + taskId?: number + resultId?: number + shopName: string + taskStatus?: string + error?: string +} + +const keyOf = (item: Item) => `${item.taskId || 0}:${item.resultId || 0}:${item.shopName}` +const item = (over: Partial): Item => ({ shopName: 'a', ...over }) + +test('test_task_084_merge_normal_default_path', () => { + const existing = [item({ taskId: 1, resultId: 1, taskStatus: 'RUNNING' })] + const incoming = [item({ taskId: 1, resultId: 1, taskStatus: 'SUCCESS' })] + const { items, updatedCount, addedCount } = mergeHistoryItems(existing, incoming, { keyOf }) + assert.equal(items.length, 1) + assert.equal(items[0].taskStatus, 'SUCCESS') + assert.deepEqual(items[0], { taskId: 1, resultId: 1, shopName: 'a', taskStatus: 'SUCCESS' }) + assert.equal(updatedCount, 1) + assert.equal(addedCount, 0) + assert.equal(existing.length, 1, '入参列表不得被修改') +}) + +test('test_task_084_merge_normal_multiple_items', () => { + const existing = Array.from({ length: 5 }, (_, i) => + item({ taskId: 1, resultId: i + 1, shopName: `s${i + 1}`, taskStatus: 'RUNNING' }), + ) + const incoming = Array.from({ length: 5 }, (_, i) => + item({ taskId: 2, resultId: i + 1, shopName: `t${i + 1}`, taskStatus: 'SUCCESS' }), + ) + const { items, addedCount, updatedCount } = mergeHistoryItems(existing, incoming, { keyOf }) + assert.equal(items.length, 10) + assert.equal(addedCount, 5) + assert.equal(updatedCount, 0) + // 已有条目在前,新条目追加在后,顺序稳定 + assert.deepEqual( + items.map((r) => r.shopName), + ['s1', 's2', 's3', 's4', 's5', 't1', 't2', 't3', 't4', 't5'], + ) + // 批量更新多条:命中后替换原位置,不改变顺序 + const update = Array.from({ length: 5 }, (_, i) => + item({ taskId: 1, resultId: i + 1, shopName: `s${i + 1}`, taskStatus: 'SUCCESS' }), + ) + const again = mergeHistoryItems(items, update, { keyOf }) + assert.equal(again.items.length, 10) + assert.equal(again.updatedCount, 5) + assert.equal(again.addedCount, 0) + assert.deepEqual( + again.items.map((r) => r.taskStatus), + ['SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS', 'SUCCESS'], + ) + assert.deepEqual( + again.items.slice(0, 5).map((r) => r.shopName), + ['s1', 's2', 's3', 's4', 's5'], + ) +}) + +test('test_task_084_merge_normal_repeated_operation_is_idempotent', () => { + const existing = [item({ taskId: 1, resultId: 1, taskStatus: 'RUNNING' })] + const incoming = [item({ taskId: 1, resultId: 1, taskStatus: 'SUCCESS' })] + const once = mergeHistoryItems(existing, incoming, { keyOf }) + const twice = mergeHistoryItems(once.items, incoming, { keyOf }) + const thrice = mergeHistoryItems(twice.items, incoming, { keyOf }) + for (const result of [once, twice, thrice]) { + assert.equal(result.items.length, 1) + assert.equal(result.items[0].taskStatus, 'SUCCESS') + } + assert.equal(twice.updatedCount, 1) + assert.equal(twice.addedCount, 0) + assert.equal(thrice.updatedCount, 1) + assert.equal(thrice.addedCount, 0) +}) + +test('test_task_084_merge_boundary_empty_input', () => { + const { items, updatedCount, addedCount } = mergeHistoryItems([], [], { keyOf }) + assert.deepEqual(items, []) + assert.equal(updatedCount, 0) + assert.equal(addedCount, 0) + const existing = [item({ taskId: 1, resultId: 1 })] + const noop = mergeHistoryItems(existing, [], { keyOf }) + assert.deepEqual(noop.items, existing) + assert.equal(noop.updatedCount, 0) + assert.equal(noop.addedCount, 0) +}) + +test('test_task_084_merge_boundary_single_item', () => { + const { items, addedCount, updatedCount } = mergeHistoryItems( + [], + [item({ taskId: 7, resultId: 9, taskStatus: 'RUNNING' })], + { keyOf }, + ) + assert.equal(items.length, 1) + assert.equal(items[0].taskId, 7) + assert.equal(items[0].resultId, 9) + assert.equal(addedCount, 1) + assert.equal(updatedCount, 0) +}) + +test('test_task_084_merge_boundary_limit_and_overflow', () => { + const incoming = Array.from({ length: 5 }, (_, i) => item({ taskId: 1, resultId: i + 1 })) + const { items } = mergeHistoryItems([], incoming, { keyOf, maxItems: 3 }) + assert.equal(items.length, 3) + assert.deepEqual( + items.map((r) => r.resultId), + [3, 4, 5], + ) + // 合并已满列表:仍受 maxItems 约束 + const full = mergeHistoryItems(items, [item({ taskId: 2, resultId: 1 })], { keyOf, maxItems: 3 }) + assert.equal(full.items.length, 3) + assert.equal(full.items[2].taskId, 2) +}) + +test('test_task_084_merge_invalid_input_rejected', () => { + assert.throws( + () => mergeHistoryItems([], null as never, { keyOf }), + /incoming 必须是数组/, + ) + assert.throws( + () => mergeHistoryItems(null as never, [], { keyOf }), + /existing 必须是数组/, + ) + assert.throws( + () => mergeHistoryItems([], [], null as never), + /keyOf 必须是函数/, + ) + assert.throws( + () => mergeHistoryItems([], [], { keyOf, maxItems: 0 }), + /maxItems 必须为正数/, + ) + assert.throws( + () => mergeHistoryItems([], [], { keyOf, maxItems: -1 }), + /maxItems 必须为正数/, + ) + // 非法 taskId/resultId 条目:不匹配任何现有行,按新条目追加,不抛错 + const { items, addedCount } = mergeHistoryItems( + [item({ taskId: 1, resultId: 1 })], + [item({ taskId: -1, resultId: 1, shopName: 'bad' }), item({ taskId: 0 })], + { keyOf }, + ) + assert.equal(items.length, 3) + assert.equal(addedCount, 2) +}) + +test('test_task_084_merge_dependency_failure_releases_resources', () => { + const existing = [item({ taskId: 1, resultId: 1 })] + const incoming = [item({ taskId: 1, resultId: 1, taskStatus: 'SUCCESS' })] + let broken = true + const faultyKeyOf = (row: Item) => { + if (broken) throw new Error('key down') + return keyOf(row) + } + assert.throws(() => mergeHistoryItems(existing, incoming, { keyOf: faultyKeyOf }), /key down/) + // 失败路径零状态变更:入参未被修改 + assert.deepEqual(existing, [item({ taskId: 1, resultId: 1 })]) + // 错误可恢复:修复后同一组输入成功 + broken = false + const { items, updatedCount } = mergeHistoryItems(existing, incoming, { keyOf: faultyKeyOf }) + assert.equal(items.length, 1) + assert.equal(items[0].taskStatus, 'SUCCESS') + assert.equal(updatedCount, 1) +}) + +test('test_task_084_merge_dependency_failure_removes_ghost_duplicates', () => { + // 线性 find 语义下的幽灵重复行场景:同 taskId/resultId 但不同 shopName 的 + // incoming 命中现有行后,旧 key 不得留下重复条目,且再次合并可精确命中 + const existing = [item({ taskId: 1, resultId: 1, shopName: 'a', taskStatus: 'RUNNING' })] + const incoming = [item({ taskId: 1, resultId: 1, shopName: 'b', taskStatus: 'SUCCESS' })] + const once = mergeHistoryItems(existing, incoming, { keyOf }) + assert.equal(once.items.length, 1) + assert.deepEqual(once.items[0], { + taskId: 1, + resultId: 1, + shopName: 'b', + taskStatus: 'SUCCESS', + }) + assert.equal(once.updatedCount, 1) + const twice = mergeHistoryItems(once.items, incoming, { keyOf }) + assert.equal(twice.items.length, 1) + assert.equal(twice.updatedCount, 1) + assert.equal(twice.addedCount, 0) + // 同一批次内后到条目命中先到新增条目:不残留重复 + const batch = [ + item({ taskId: 1, resultId: 1, shopName: 'x', taskStatus: 'RUNNING' }), + item({ taskId: 1, resultId: 1, shopName: 'y', taskStatus: 'SUCCESS' }), + ] + const chained = mergeHistoryItems([], batch, { keyOf }) + assert.equal(chained.items.length, 1) + assert.deepEqual(chained.items[0], { + taskId: 1, + resultId: 1, + shopName: 'y', + taskStatus: 'SUCCESS', + }) + assert.equal(chained.updatedCount, 1) + assert.equal(chained.addedCount, 1) +})