diff --git a/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue b/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue index 9a53c1a6..c23fd90f 100644 --- a/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandSimilarAsinTab.vue @@ -211,6 +211,7 @@ import { getTaskPollIntervalMs } from '@/shared/task-progress-config' import { getStoredApiSecret } from '@/shared/utils/api-secret-store' import { createCategorizedTimers } from '@/shared/utils/categorized-timers' import { saveUrlWithProgress } from '@/shared/utils/download-progress' +import { createAsinForceThrottle } from '@/shared/asin-force-throttle' const selectedFileNames = ref([]) const uploadedFiles = ref([]) @@ -228,6 +229,8 @@ const pollingTaskIds = ref([]) const pendingFileTaskIds = ref([]) const pollTimer = ref(null) const pollingInFlight = ref(false) +// force 请求节流:文件生成中不重复 force(TTL 窗口内只发一次),终态后 clear 释放 +const forceThrottle = createAsinForceThrottle({ ttlMs: 30_000 }) const HISTORY_CACHE_TTL_MS = 3000 let historyInFlight: Promise | null = null let lastHistoryLoadedAt = 0 @@ -608,6 +611,7 @@ function addPendingFileTask(taskId: number) { function removePendingFileTask(taskId: number) { pendingFileTaskIds.value = pendingFileTaskIds.value.filter((id) => id !== taskId) + forceThrottle.clear(taskId) if (!pollingTaskIds.value.includes(taskId)) { const next = { ...liveProgressItems.value } delete next[taskId] @@ -683,7 +687,12 @@ async function refreshTaskProgress() { let shouldRefreshHistory = false const taskIds = Array.from(new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value])) if (taskIds.length) { - const batch = await getSimilarAsinTaskProgressBatch(taskIds, { force: pendingFileTaskIds.value.length > 0 }) + const force = pendingFileTaskIds.value.some((taskId) => forceThrottle.shouldForce(taskId)) + const batch = await getSimilarAsinTaskProgressBatch(taskIds, { force }) + if (force) { + // force 请求已发出,进入冷却窗口;TTL 内不再重复 force,避免重复触发文件生成 + pendingFileTaskIds.value.forEach((taskId) => forceThrottle.markForce(taskId)) + } for (const detail of batch.items || []) { const task = detail.task if (!task?.id) continue diff --git a/frontend-vue/src/shared/asin-force-throttle.ts b/frontend-vue/src/shared/asin-force-throttle.ts new file mode 100644 index 00000000..da9b366e --- /dev/null +++ b/frontend-vue/src/shared/asin-force-throttle.ts @@ -0,0 +1,110 @@ +/** + * Similar ASIN force 请求节流守卫(Task 85)。 + * + * 原实现只要 pendingFileTaskIds 非空,每轮轮询都以 force=true 请求批量 + * 进度,可能反复触发后端重复生成文件。本守卫为每个 taskId 维护一个 TTL + * 窗口:窗口内 shouldForce 返回 false(不重复发 force 请求),到期后 + * 允许重试(文件生成可能仍在进行,但后端可再次确认);任务终态后调用 + * clear 立即释放记录,窗口表不会无界增长。 + * + * 纯 TS 无副作用模块:markForce 对非法 taskId fail-fast 抛错,读取路径 + * (shouldForce/isThrottled/clear/stats)对非法 id 宽容;时钟抛错时 + * 读取失败但记录不丢失,恢复后可继续工作。 + */ +export interface AsinForceThrottleOptions { + /** force 请求的冷却窗口(毫秒),必须为正数 */ + ttlMs: number + /** 时钟来源;默认 Date.now(),测试可注入虚拟时钟 */ + now?: () => number +} + +export interface AsinForceThrottleStats { + /** 已执行的 force 标记次数 */ + forcedCount: number + /** 被节流跳过的 force 请求次数 */ + skippedCount: number + /** 当前处于冷却窗口内的 taskId 数 */ + activeCount: number +} + +export function createAsinForceThrottle(options: AsinForceThrottleOptions) { + const ttlMs = options.ttlMs + const now = options.now ?? Date.now + if (!(ttlMs > 0)) { + throw new Error('ttlMs 必须为正数: ' + ttlMs) + } + + const cooldowns = new Map() + let forcedCount = 0 + let skippedCount = 0 + + function validateTaskId(taskId: number): boolean { + return Number.isFinite(taskId) && taskId > 0 && Number.isInteger(taskId) + } + + function isThrottled(taskId: number): boolean { + if (!validateTaskId(taskId)) return false + const at = now() + const until = cooldowns.get(taskId) + if (until == null) return false + if (at >= until) { + cooldowns.delete(taskId) + return false + } + return true + } + + function shouldForce(taskId: number): boolean { + if (!validateTaskId(taskId)) return false + if (isThrottled(taskId)) { + skippedCount += 1 + return false + } + return true + } + + function markForce(taskId: number) { + if (!validateTaskId(taskId)) { + throw new Error('taskId 必须是正整数: ' + taskId) + } + if (isThrottled(taskId)) return + cooldowns.set(taskId, now() + ttlMs) + forcedCount += 1 + } + + function clear(taskId: number): boolean { + if (!validateTaskId(taskId)) return false + return cooldowns.delete(taskId) + } + + function clearAll() { + cooldowns.clear() + } + + function stats(): AsinForceThrottleStats { + const at = now() + for (const [taskId, until] of cooldowns) { + if (at >= until) cooldowns.delete(taskId) + } + return { + forcedCount, + skippedCount, + activeCount: cooldowns.size, + } + } + + return { + get activeCount() { + stats() + return cooldowns.size + }, + shouldForce, + markForce, + isThrottled, + clear, + clearAll, + stats, + } +} + +export type AsinForceThrottle = ReturnType diff --git a/frontend-vue/tests/asin-force-throttle.test.ts b/frontend-vue/tests/asin-force-throttle.test.ts new file mode 100644 index 00000000..8e1e7964 --- /dev/null +++ b/frontend-vue/tests/asin-force-throttle.test.ts @@ -0,0 +1,141 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createAsinForceThrottle } from '../src/shared/asin-force-throttle.ts' + +function clock(start = 1000) { + let now = start + return { + now: () => now, + tick: (ms: number) => { + now += ms + }, + } +} + +test('test_task_085_asin_polling_normal_default_path', () => { + const c = clock() + const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now }) + assert.equal(throttle.shouldForce(101), true) + throttle.markForce(101) + assert.equal(throttle.shouldForce(101), false) + assert.equal(throttle.isThrottled(101), true) + c.tick(29_999) + assert.equal(throttle.shouldForce(101), false) + c.tick(2) + assert.equal(throttle.shouldForce(101), true) + assert.equal(throttle.isThrottled(101), false) + const stats = throttle.stats() + assert.equal(stats.forcedCount, 1) + assert.equal(stats.skippedCount, 2) + assert.equal(stats.activeCount, 0) +}) + +test('test_task_085_asin_polling_normal_multiple_items', () => { + const c = clock() + const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now }) + const ids = [201, 202, 203, 204, 205] + for (const id of ids) throttle.markForce(id) + assert.equal(throttle.activeCount, 5) + for (const id of ids) assert.equal(throttle.shouldForce(id), false) + // 不同 taskId 的 TTL 相互独立 + c.tick(31_000) + for (const id of ids) assert.equal(throttle.shouldForce(id), true) + throttle.markForce(202) + assert.equal(throttle.shouldForce(202), false) + assert.equal(throttle.shouldForce(201), true) +}) + +test('test_task_085_asin_polling_normal_repeated_operation_is_idempotent', () => { + const c = clock() + const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now }) + throttle.markForce(301) + throttle.markForce(301) + throttle.markForce(301) + assert.equal(throttle.activeCount, 1) + assert.equal(throttle.shouldForce(301), false) + assert.equal(throttle.stats().forcedCount, 1) + // 重复检查不改变状态 + assert.equal(throttle.shouldForce(301), false) + assert.equal(throttle.isThrottled(301), true) + assert.equal(throttle.stats().forcedCount, 1) +}) + +test('test_task_085_asin_polling_boundary_empty_input', () => { + const c = clock() + const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now }) + assert.equal(throttle.activeCount, 0) + assert.equal(throttle.shouldForce(401), true) + throttle.clear(401) + assert.equal(throttle.activeCount, 0) + // clear 不存在的 taskId 无副作用 + throttle.clearAll() + assert.deepEqual(throttle.stats(), { forcedCount: 0, skippedCount: 0, activeCount: 0 }) +}) + +test('test_task_085_asin_polling_boundary_single_item', () => { + const c = clock() + const throttle = createAsinForceThrottle({ ttlMs: 1000, now: c.now }) + assert.equal(throttle.shouldForce(501), true) + throttle.markForce(501) + assert.equal(throttle.isThrottled(501), true) + assert.equal(throttle.activeCount, 1) + c.tick(1000) + assert.equal(throttle.isThrottled(501), false) + assert.equal(throttle.activeCount, 0) +}) + +test('test_task_085_asin_polling_boundary_limit_and_overflow', () => { + const c = clock() + const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now }) + // 大量任务:所有任务都被记录,未标记的任务不被节流 + for (let i = 1; i <= 200; i++) throttle.markForce(i) + assert.equal(throttle.activeCount, 200) + assert.equal(throttle.shouldForce(1), false) + assert.equal(throttle.shouldForce(200), false) + assert.equal(throttle.shouldForce(201), true) + // TTL 到期后整批可重新 force(重试窗口),记录不无界增长 + c.tick(60_001) + assert.equal(throttle.shouldForce(1), true) + assert.equal(throttle.activeCount, 0) + throttle.markForce(1) + assert.equal(throttle.activeCount, 1) +}) + +test('test_task_085_asin_polling_invalid_input_rejected', () => { + const c = clock() + const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now }) + assert.throws(() => throttle.markForce(0), /taskId 必须是正整数/) + assert.throws(() => throttle.markForce(-1), /taskId 必须是正整数/) + assert.throws(() => throttle.markForce(NaN), /taskId 必须是正整数/) + assert.throws(() => throttle.markForce(1.5), /taskId 必须是正整数/) + // 读取路径宽容:非法 id 安全返回 + assert.equal(throttle.shouldForce(0), false) + assert.equal(throttle.isThrottled(-1), false) + assert.equal(throttle.clear(0), false) + assert.throws(() => createAsinForceThrottle({ ttlMs: 0 }), /ttlMs 必须为正数/) +}) + +test('test_task_085_asin_polling_dependency_failure_releases_resources', () => { + let now = 1000 + let broken = false + const faultyNow = () => { + if (broken) throw new Error('clock down') + return now + } + const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: faultyNow }) + throttle.markForce(701) + assert.equal(throttle.activeCount, 1) + // 时钟故障时读取抛错,但记录不丢失 + broken = true + assert.throws(() => throttle.shouldForce(701), /clock down/) + assert.throws(() => throttle.isThrottled(701), /clock down/) + assert.throws(() => throttle.stats(), /clock down/) + // 恢复后同一实例继续工作,记录仍在 + broken = false + assert.equal(throttle.isThrottled(701), true) + assert.equal(throttle.shouldForce(701), false) + // 终态后 clear 释放记录 + throttle.clear(701) + assert.equal(throttle.activeCount, 0) + assert.equal(throttle.shouldForce(701), true) +})