task-85: 优化 Similar ASIN 轮询与文件生成等待,避免重复 force 请求

新增纯 TS force 节流守卫 asin-force-throttle:为每个 taskId 维护 TTL
冷却窗口,窗口内不重复发 force 请求(避免反复触发后端生成文件),到期
后允许重试,任务终态后 clear 立即释放记录。BrandSimilarAsinTab 的
refreshTaskProgress 改为按 pendingFileTaskIds 逐项查守卫决定本轮是否
带 force,请求成功后整批进入冷却。8 个测试覆盖默认、批量、幂等、空、
单元素、200 任务溢出、非法输入与时钟故障恢复。
This commit is contained in:
2026-08-30 22:49:45 +08:00
parent b3f16433fc
commit 25d5a5a5e3
3 changed files with 261 additions and 1 deletions
@@ -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<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
@@ -228,6 +229,8 @@ const pollingTaskIds = ref<number[]>([])
const pendingFileTaskIds = ref<number[]>([])
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
// force 请求节流:文件生成中不重复 force(TTL 窗口内只发一次),终态后 clear 释放
const forceThrottle = createAsinForceThrottle({ ttlMs: 30_000 })
const HISTORY_CACHE_TTL_MS = 3000
let historyInFlight: Promise<void> | 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
@@ -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<number, number>()
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<typeof createAsinForceThrottle>