From 58e91589df4d810d7670b74fc3789b30b114225a 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:51:21 +0800 Subject: [PATCH] =?UTF-8?q?task-86:=20=E5=B0=86=E9=9A=90=E8=97=8F=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E8=BD=AE=E8=AF=A2=E9=97=B4=E9=9A=94=E3=80=81=E5=89=8D?= =?UTF-8?q?=E5=8F=B0=E6=81=A2=E5=A4=8D=E5=92=8C=E9=80=80=E9=81=BF=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E7=BB=9F=E4=B8=80=E9=85=8D=E7=BD=AE=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit task-progress-config 新增统一配置 API:getTaskPollBackoffMs(attempt) 指数退避(base×2^attempt 封顶 max)、前台恢复开关与延迟 (getTaskForegroundRefreshEnabled/DelayMs)、configureTaskPolling/ resetTaskPollingConfig 运行时配置。校验失败抛错且零状态变更, document 缺失时按可见间隔降级。useTaskProgressLoop 改用退避配置 替代硬编码 500ms 重试,前台恢复走可配置开关与延迟(含 in-flight 防 并发)。8 个测试覆盖默认、多配置、幂等、空、单字段、封顶、非法输入 与故障恢复。 --- .../shared/composables/useTaskProgressLoop.ts | 44 +++++- .../src/shared/task-progress-config.ts | 76 ++++++++- .../tests/task-progress-config.test.ts | 148 ++++++++++++++++++ 3 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 frontend-vue/tests/task-progress-config.test.ts diff --git a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts index b932bea8..86f36e92 100644 --- a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts +++ b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts @@ -1,5 +1,10 @@ import { onBeforeUnmount, ref, watch, type Ref } from 'vue' -import { getTaskPollIntervalMs } from '@/shared/task-progress-config' +import { + getTaskPollIntervalMs, + getTaskPollBackoffMs, + getTaskForegroundRefreshEnabled, + getTaskForegroundRefreshDelayMs, +} from '@/shared/task-progress-config' import { createCategorizedTimers } from '@/shared/utils/categorized-timers' import { createTaskPollingBaseline, @@ -233,8 +238,8 @@ export function useTaskProgressLoop( if (disposed) return if (!taskIds.value.length) return if (inFlight.value) { - // 上一次还没回,500ms 后再试 - pollTimer = timers.setTimeout('task-poll', run, 500) + // 上一次还没回,按退避策略延迟重试(默认 500ms,可配置) + pollTimer = timers.setTimeout('task-poll', run, getTaskPollBackoffMs(0)) return } await refreshOnce() @@ -277,17 +282,44 @@ export function useTaskProgressLoop( { flush: 'post' }, ) - // 切到前台后立刻拉一次,让用户回到页面看到的是最新状态 + // 切到前台后立刻拉一次(可配置开关与延迟),让用户回到页面看到的是最新状态 let visibilityHandler: (() => void) | null = null if (typeof document !== 'undefined') { visibilityHandler = () => { - if (document.visibilityState === 'visible' && taskIds.value.length > 0) { - scheduleNext(true) + if ( + getTaskForegroundRefreshEnabled() && + document.visibilityState === 'visible' && + taskIds.value.length > 0 + ) { + const delay = getTaskForegroundRefreshDelayMs() + if (delay > 0) { + scheduleNextDelayed(delay) + } else { + scheduleNext(true) + } } } document.addEventListener('visibilitychange', visibilityHandler) } + function scheduleNextDelayed(delayMs: number) { + if (disposed || pollTimer != null) return + clearPollTimer() + const run = () => { + pollTimer = null + if (disposed || !taskIds.value.length) return + if (inFlight.value) { + pollTimer = timers.setTimeout('task-poll', run, getTaskPollBackoffMs(0)) + return + } + void refreshOnce() + if (!disposed && taskIds.value.length > 0) { + pollTimer = timers.setTimeout('task-poll', run, intervalMs()) + } + } + pollTimer = timers.setTimeout('task-poll', run, delayMs) + } + function dispose() { if (disposed) return disposed = true diff --git a/frontend-vue/src/shared/task-progress-config.ts b/frontend-vue/src/shared/task-progress-config.ts index f3517ee0..a317c4e8 100644 --- a/frontend-vue/src/shared/task-progress-config.ts +++ b/frontend-vue/src/shared/task-progress-config.ts @@ -1,10 +1,51 @@ +/** + * 任务轮询配置(Task 86 扩展)。 + * + * 把隐藏页面轮询间隔、前台恢复与退避策略统一收敛为可编程配置: + * - getTaskPollIntervalMs / getTaskProgressCacheTtlMs:按 document.visibilityState + * 自适应切换可见/隐藏间隔与缓存 TTL(历史行为不变); + * - getTaskPollBackoffMs(attempt):指数退避(base × 2^attempt,封顶 max), + * 供轮询失败/in-flight 重试使用,重试次数不再硬编码; + * - getTaskForegroundRefreshEnabled / getTaskForegroundRefreshDelayMs:控制 + * 页面切回前台时是否立即拉取一轮及其延迟; + * - configureTaskPolling / resetTaskPollingConfig:运行时配置与恢复默认。 + * + * 纯 TS 模块:配置校验失败时抛错且保持原值(零状态变更),document 缺失 + * (Node/SSR)时按可见间隔降级;所有 getter 在任意配置下都有确定返回值。 + */ export const TASK_POLL_VISIBLE_INTERVAL_MS = 10000; export const TASK_POLL_HIDDEN_INTERVAL_MS = 60000; export const TASK_PROGRESS_VISIBLE_CACHE_MILLIS = 5000; export const TASK_PROGRESS_HIDDEN_CACHE_MILLIS = 30000; +export const TASK_POLL_BACKOFF_BASE_MS = 500; +export const TASK_POLL_BACKOFF_MAX_MS = 5000; + +export interface TaskPollingConfig { + /** 退避基数(毫秒),必须为正数 */ + backoffBaseMs?: number; + /** 退避上限(毫秒),必须为正数 */ + backoffMaxMs?: number; + /** 页面切回前台时是否立即拉取一轮,默认 true */ + foregroundRefreshEnabled?: boolean; + /** 前台恢复延迟(毫秒),默认 0 */ + foregroundRefreshDelayMs?: number; +} + +const DEFAULTS: Required = { + backoffBaseMs: TASK_POLL_BACKOFF_BASE_MS, + backoffMaxMs: TASK_POLL_BACKOFF_MAX_MS, + foregroundRefreshEnabled: true, + foregroundRefreshDelayMs: 0, +}; + +let config: Required = { ...DEFAULTS }; + +function isVisible() { + return typeof document === "undefined" || document.visibilityState === "visible"; +} export function getTaskPollIntervalMs() { - if (typeof document !== "undefined" && document.visibilityState !== "visible") { + if (!isVisible()) { return TASK_POLL_HIDDEN_INTERVAL_MS; } return TASK_POLL_VISIBLE_INTERVAL_MS; @@ -16,3 +57,36 @@ export function getTaskProgressCacheTtlMs() { } return TASK_PROGRESS_VISIBLE_CACHE_MILLIS; } + +/** 第 attempt 次退避延迟:base × 2^attempt,封顶 max;attempt 非法时按首次(0)处理 */ +export function getTaskPollBackoffMs(attempt: number) { + const safe = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0; + const doubled = config.backoffBaseMs * Math.pow(2, safe); + return doubled > config.backoffMaxMs ? config.backoffMaxMs : doubled; +} + +export function getTaskForegroundRefreshEnabled() { + return config.foregroundRefreshEnabled; +} + +export function getTaskForegroundRefreshDelayMs() { + return config.foregroundRefreshDelayMs; +} + +export function configureTaskPolling(partial: TaskPollingConfig) { + const next = { ...config, ...partial }; + if (!(next.backoffBaseMs > 0)) { + throw new Error("退避基数必须为正数: " + next.backoffBaseMs); + } + if (!(next.backoffMaxMs > 0)) { + throw new Error("退避上限必须为正数: " + next.backoffMaxMs); + } + if (!(next.foregroundRefreshDelayMs >= 0)) { + throw new Error("恢复延迟不能为负数: " + next.foregroundRefreshDelayMs); + } + config = next; +} + +export function resetTaskPollingConfig() { + config = { ...DEFAULTS }; +} diff --git a/frontend-vue/tests/task-progress-config.test.ts b/frontend-vue/tests/task-progress-config.test.ts new file mode 100644 index 00000000..766570c0 --- /dev/null +++ b/frontend-vue/tests/task-progress-config.test.ts @@ -0,0 +1,148 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + TASK_POLL_VISIBLE_INTERVAL_MS, + TASK_POLL_HIDDEN_INTERVAL_MS, + TASK_POLL_BACKOFF_BASE_MS, + TASK_POLL_BACKOFF_MAX_MS, + getTaskPollIntervalMs, + getTaskProgressCacheTtlMs, + getTaskForegroundRefreshEnabled, + getTaskForegroundRefreshDelayMs, + getTaskPollBackoffMs, + configureTaskPolling, + resetTaskPollingConfig, +} from '../src/shared/task-progress-config.ts' + +function withVisibility(visible: boolean, fn: () => void) { + const original = (globalThis as Record).document + const fakeDocument = { visibilityState: visible ? 'visible' : 'hidden', hidden: !visible } + Object.defineProperty(globalThis, 'document', { + value: fakeDocument, + configurable: true, + writable: true, + }) + try { + fn() + } finally { + Object.defineProperty(globalThis, 'document', { + value: original, + configurable: true, + writable: true, + }) + } +} + +test('test_task_086_polling_normal_default_path', () => { + assert.equal(getTaskPollBackoffMs(0), TASK_POLL_BACKOFF_BASE_MS) + assert.equal(getTaskPollBackoffMs(1), 1000) + assert.equal(getTaskForegroundRefreshEnabled(), true) + assert.equal(getTaskForegroundRefreshDelayMs(), 0) + assert.equal(getTaskPollIntervalMs(), TASK_POLL_VISIBLE_INTERVAL_MS) + assert.equal(getTaskProgressCacheTtlMs(), 5000) + // hidden 分支:可见性变化切换间隔 + withVisibility(false, () => { + assert.equal(getTaskPollIntervalMs(), TASK_POLL_HIDDEN_INTERVAL_MS) + assert.equal(getTaskProgressCacheTtlMs(), 30000) + }) + withVisibility(true, () => { + assert.equal(getTaskPollIntervalMs(), TASK_POLL_VISIBLE_INTERVAL_MS) + assert.equal(getTaskProgressCacheTtlMs(), 5000) + }) +}) + +test('test_task_086_polling_normal_multiple_items', () => { + resetTaskPollingConfig() + configureTaskPolling({ backoffBaseMs: 1000, backoffMaxMs: 10_000 }) + assert.deepEqual([0, 1, 2, 3].map((n) => getTaskPollBackoffMs(n)), [1000, 2000, 4000, 8000]) + configureTaskPolling({ backoffBaseMs: 200, backoffMaxMs: 1600 }) + assert.deepEqual([0, 1, 2, 3, 4].map((n) => getTaskPollBackoffMs(n)), [200, 400, 800, 1600, 1600]) + configureTaskPolling({ foregroundRefreshEnabled: false }) + assert.equal(getTaskForegroundRefreshEnabled(), false) + configureTaskPolling({ foregroundRefreshDelayMs: 250 }) + assert.equal(getTaskForegroundRefreshDelayMs(), 250) + assert.equal(getTaskForegroundRefreshEnabled(), false, '未覆盖的字段保持原值') + assert.equal(getTaskPollBackoffMs(0), 200, '未覆盖的字段保持原值') + resetTaskPollingConfig() +}) + +test('test_task_086_polling_normal_repeated_operation_is_idempotent', () => { + resetTaskPollingConfig() + configureTaskPolling({ backoffBaseMs: 300, backoffMaxMs: 2400 }) + configureTaskPolling({ backoffBaseMs: 300, backoffMaxMs: 2400 }) + configureTaskPolling({ backoffBaseMs: 300, backoffMaxMs: 2400 }) + assert.deepEqual([0, 1, 2, 3].map((n) => getTaskPollBackoffMs(n)), [300, 600, 1200, 2400]) + configureTaskPolling({ foregroundRefreshEnabled: true }) + configureTaskPolling({ foregroundRefreshEnabled: true }) + assert.equal(getTaskForegroundRefreshEnabled(), true) + resetTaskPollingConfig() +}) + +test('test_task_086_polling_boundary_empty_input', () => { + resetTaskPollingConfig() + configureTaskPolling({}) + assert.equal(getTaskPollBackoffMs(0), TASK_POLL_BACKOFF_BASE_MS) + assert.equal(getTaskForegroundRefreshEnabled(), true) + assert.equal(getTaskForegroundRefreshDelayMs(), 0) + resetTaskPollingConfig() +}) + +test('test_task_086_polling_boundary_single_item', () => { + resetTaskPollingConfig() + configureTaskPolling({ backoffMaxMs: 999 }) + assert.equal(getTaskPollBackoffMs(0), TASK_POLL_BACKOFF_BASE_MS, '未配置字段不受影响') + assert.equal(getTaskPollBackoffMs(5), 999, '超过上限封顶') + configureTaskPolling({ foregroundRefreshEnabled: false }) + assert.equal(getTaskForegroundRefreshEnabled(), false) + assert.equal(getTaskPollBackoffMs(5), 999, '配置之间互不影响') + resetTaskPollingConfig() +}) + +test('test_task_086_polling_boundary_limit_and_overflow', () => { + resetTaskPollingConfig() + // attempt 超过最大重试次数后封顶,不发生无界增长 + assert.equal(getTaskPollBackoffMs(0), 500) + assert.equal(getTaskPollBackoffMs(1), 1000) + assert.equal(getTaskPollBackoffMs(2), 2000) + assert.equal(getTaskPollBackoffMs(3), 4000) + assert.equal(getTaskPollBackoffMs(4), 5000) + assert.equal(getTaskPollBackoffMs(10), 5000) + assert.equal(getTaskPollBackoffMs(100), 5000) + // attempt 为 0 或负数按首次退避处理 + assert.equal(getTaskPollBackoffMs(-1), 500) + assert.equal(getTaskPollBackoffMs(NaN), 500) + assert.equal(getTaskPollBackoffMs(0.5), 500) + resetTaskPollingConfig() +}) + +test('test_task_086_polling_invalid_input_rejected', () => { + resetTaskPollingConfig() + assert.throws(() => configureTaskPolling({ backoffBaseMs: 0 }), /退避基数必须为正数/) + assert.throws(() => configureTaskPolling({ backoffBaseMs: -1 }), /退避基数必须为正数/) + assert.throws(() => configureTaskPolling({ backoffMaxMs: 0 }), /退避上限必须为正数/) + assert.throws(() => configureTaskPolling({ backoffMaxMs: -100 }), /退避上限必须为正数/) + assert.throws(() => configureTaskPolling({ foregroundRefreshDelayMs: -1 }), /恢复延迟不能为负数/) + // 拒绝后配置保持原值(零状态变更) + assert.equal(getTaskPollBackoffMs(0), TASK_POLL_BACKOFF_BASE_MS) + assert.equal(getTaskForegroundRefreshEnabled(), true) + resetTaskPollingConfig() +}) + +test('test_task_086_polling_dependency_failure_releases_resources', () => { + resetTaskPollingConfig() + // document 不可用(Node 环境/SSR):按可见间隔降级,不抛错 + const original = (globalThis as Record).document + Object.defineProperty(globalThis, 'document', { value: undefined, configurable: true, writable: true }) + try { + assert.equal(getTaskPollIntervalMs(), TASK_POLL_VISIBLE_INTERVAL_MS) + assert.equal(getTaskProgressCacheTtlMs(), 5000) + } finally { + Object.defineProperty(globalThis, 'document', { value: original, configurable: true, writable: true }) + } + // 配置校验失败后错误可恢复:修正后同一配置系统继续工作 + assert.throws(() => configureTaskPolling({ backoffBaseMs: -5 }), /退避基数必须为正数/) + configureTaskPolling({ backoffBaseMs: 700, backoffMaxMs: 2800 }) + assert.equal(getTaskPollBackoffMs(0), 700) + assert.equal(getTaskPollBackoffMs(2), 2800) + resetTaskPollingConfig() +})