浏览代码

feat: 增加 OAuth 流程6分钟超时处理,优化验证码请求时间窗口逻辑

QLHazyCoder 4 月之前
父节点
当前提交
4add7477d1

+ 100 - 16
background.js

@@ -136,6 +136,7 @@ const HUMAN_STEP_DELAY_MIN = 700;
 const HUMAN_STEP_DELAY_MAX = 2200;
 const STEP6_MAX_ATTEMPTS = 3;
 const STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS = 8;
+const OAUTH_FLOW_TIMEOUT_MS = 6 * 60 * 1000;
 const SUB2API_STEP1_RESPONSE_TIMEOUT_MS = 90000;
 const SUB2API_STEP9_RESPONSE_TIMEOUT_MS = 120000;
 const DEFAULT_SUB2API_URL = 'https://sub2api.hisence.fun/admin/accounts';
@@ -315,6 +316,7 @@ const DEFAULT_STATE = {
   autoRunCountdownNote: '',
   signupVerificationRequestedAt: null,
   loginVerificationRequestedAt: null,
+  oauthFlowDeadlineAt: null,
   currentHotmailAccountId: null,
   preferredIcloudHost: '',
 };
@@ -3879,6 +3881,7 @@ function getDownstreamStateResets(step) {
       lastEmailTimestamp: null,
       signupVerificationRequestedAt: null,
       loginVerificationRequestedAt: null,
+      oauthFlowDeadlineAt: null,
       lastSignupCode: null,
       lastLoginCode: null,
       localhostUrl: null,
@@ -3890,6 +3893,7 @@ function getDownstreamStateResets(step) {
       lastEmailTimestamp: null,
       signupVerificationRequestedAt: null,
       loginVerificationRequestedAt: null,
+      oauthFlowDeadlineAt: null,
       lastSignupCode: null,
       lastLoginCode: null,
       localhostUrl: null,
@@ -3900,6 +3904,7 @@ function getDownstreamStateResets(step) {
       lastEmailTimestamp: null,
       signupVerificationRequestedAt: null,
       loginVerificationRequestedAt: null,
+      oauthFlowDeadlineAt: null,
       lastSignupCode: null,
       lastLoginCode: null,
       localhostUrl: null,
@@ -3909,6 +3914,7 @@ function getDownstreamStateResets(step) {
     return {
       lastLoginCode: null,
       loginVerificationRequestedAt: null,
+      oauthFlowDeadlineAt: null,
       localhostUrl: null,
     };
   }
@@ -4569,7 +4575,10 @@ async function handleStepData(step, payload) {
         if (!isLocalhostOAuthCallbackUrl(payload.localhostUrl)) {
           throw new Error('步骤 9 返回了无效的 localhost OAuth 回调地址。');
         }
-        await setState({ localhostUrl: payload.localhostUrl });
+        await setState({
+          localhostUrl: payload.localhostUrl,
+          oauthFlowDeadlineAt: null,
+        });
         broadcastDataUpdate({ localhostUrl: payload.localhostUrl });
       }
       break;
@@ -5689,6 +5698,7 @@ const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({
   completeStepFromBackground,
   getErrorMessage,
   getLoginAuthStateLabel,
+  getOAuthFlowStepTimeoutMs,
   getState,
   isStep6RecoverableResult,
   isStep6SuccessResult,
@@ -5697,6 +5707,7 @@ const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({
   sendToContentScriptResilient,
   shouldSkipLoginVerificationForCpaCallback,
   skipLoginVerificationStepsForCpaCallback,
+  startOAuthFlowTimeoutWindow,
   STEP6_MAX_ATTEMPTS,
   throwIfStopped,
 });
@@ -5707,6 +5718,8 @@ const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({
   confirmCustomVerificationStepBypass: verificationFlowHelpers.confirmCustomVerificationStepBypass,
   ensureStep8VerificationPageReady,
   executeStep7: (...args) => executeStep7(...args),
+  getOAuthFlowRemainingMs,
+  getOAuthFlowStepTimeoutMs,
   getPanelMode,
   getMailConfig,
   getState,
@@ -6180,6 +6193,64 @@ async function refreshOAuthUrlBeforeStep6(state) {
   return refreshResult.oauthUrl;
 }
 
+function buildOAuthFlowTimeoutError(step, actionLabel = '后续授权流程') {
+  return new Error(
+    `步骤 ${step}:从拿到 OAuth 登录地址开始,${Math.round(OAUTH_FLOW_TIMEOUT_MS / 60000)} 分钟内未完成${actionLabel},结束当前链路,准备从步骤 7 重新开始。`
+  );
+}
+
+function normalizeOAuthFlowDeadlineAt(value) {
+  const numeric = Number(value);
+  if (!Number.isFinite(numeric) || numeric <= 0) {
+    return null;
+  }
+  return Math.floor(numeric);
+}
+
+async function startOAuthFlowTimeoutWindow(options = {}) {
+  const step = Number(options.step) || 7;
+  const deadlineAt = Date.now() + OAUTH_FLOW_TIMEOUT_MS;
+  await setState({ oauthFlowDeadlineAt: deadlineAt });
+  await addLog(`步骤 ${step}:已拿到新的 OAuth 登录地址,开始 6 分钟倒计时。`, 'info');
+  return deadlineAt;
+}
+
+async function getOAuthFlowRemainingMs(options = {}) {
+  const step = Number(options.step) || 7;
+  const actionLabel = String(options.actionLabel || '后续授权流程').trim() || '后续授权流程';
+  const state = options.state || await getState();
+  const deadlineAt = normalizeOAuthFlowDeadlineAt(state?.oauthFlowDeadlineAt);
+  if (!deadlineAt) {
+    return null;
+  }
+
+  const remainingMs = deadlineAt - Date.now();
+  if (remainingMs <= 0) {
+    throw buildOAuthFlowTimeoutError(step, actionLabel);
+  }
+
+  return remainingMs;
+}
+
+async function getOAuthFlowStepTimeoutMs(defaultTimeoutMs, options = {}) {
+  const normalizedDefault = Math.max(1000, Number(defaultTimeoutMs) || 1000);
+  const reserveMs = Math.max(0, Number(options.reserveMs) || 0);
+  const remainingMs = await getOAuthFlowRemainingMs(options);
+  if (remainingMs === null) {
+    return normalizedDefault;
+  }
+
+  const budgetMs = remainingMs - reserveMs;
+  if (budgetMs <= 0) {
+    throw buildOAuthFlowTimeoutError(
+      Number(options.step) || 7,
+      String(options.actionLabel || '后续授权流程').trim() || '后续授权流程'
+    );
+  }
+
+  return Math.max(1000, Math.min(normalizedDefault, budgetMs));
+}
+
 function isStep6SuccessResult(result) {
   return result?.step6Outcome === 'success';
 }
@@ -6259,8 +6330,9 @@ async function getLoginAuthStateFromContent(options = {}) {
       payload: {},
     },
     {
-      timeoutMs: 15000,
-      retryDelayMs: 600,
+      timeoutMs: options.timeoutMs ?? 15000,
+      retryDelayMs: options.retryDelayMs ?? 600,
+      responseTimeoutMs: options.responseTimeoutMs ?? (options.timeoutMs ?? 15000),
       logMessage,
     }
   );
@@ -6272,8 +6344,8 @@ async function getLoginAuthStateFromContent(options = {}) {
   return result || {};
 }
 
-async function ensureStep8VerificationPageReady() {
-  const pageState = await getLoginAuthStateFromContent();
+async function ensureStep8VerificationPageReady(options = {}) {
+  const pageState = await getLoginAuthStateFromContent(options);
   if (pageState.state === 'verification_page') {
     return pageState;
   }
@@ -6287,6 +6359,7 @@ async function skipLoginVerificationStepsForCpaCallback() {
   await setState({
     lastLoginCode: null,
     loginVerificationRequestedAt: null,
+    oauthFlowDeadlineAt: null,
   });
   await setStepStatus(7, 'skipped');
   await addLog('步骤 7:当前已选择“第七步回调”,直接跳过步骤 7、8。', 'warn');
@@ -6438,7 +6511,7 @@ async function waitForStep8Ready(tabId, timeoutMs = STEP8_READY_WAIT_TIMEOUT_MS)
         flow: 'auth',
         logLabel: '步骤 9:检测到认证页重试页,正在点击“重试”恢复',
         step: 8,
-        timeoutMs: Math.max(5000, Math.min(12000, timeoutMs)),
+        timeoutMs: Math.max(1000, Math.min(12000, timeoutMs)),
       });
       retryRecovered = true;
       await sleepWithStop(250);
@@ -6465,9 +6538,11 @@ async function waitForStep8Ready(tabId, timeoutMs = STEP8_READY_WAIT_TIMEOUT_MS)
   throw new Error('步骤 9:长时间未进入 OAuth 同意页,无法定位“继续”按钮。');
 }
 
-async function prepareStep8DebuggerClick(tabId) {
+async function prepareStep8DebuggerClick(tabId, options = {}) {
+  const timeoutMs = options.timeoutMs ?? 15000;
+  const responseTimeoutMs = options.responseTimeoutMs ?? timeoutMs;
   await ensureStep8SignupPageReady(tabId, {
-    timeoutMs: 15000,
+    timeoutMs,
     logMessage: '步骤 9:认证页内容脚本已失联,正在恢复后继续定位按钮...',
   });
   const result = await sendToContentScriptResilient('signup-page', {
@@ -6475,7 +6550,8 @@ async function prepareStep8DebuggerClick(tabId) {
     source: 'background',
     payload: {},
   }, {
-    timeoutMs: 15000,
+    timeoutMs,
+    responseTimeoutMs,
     retryDelayMs: 600,
     logMessage: '步骤 9:认证页正在切换,等待 OAuth 同意页按钮重新就绪...',
   });
@@ -6487,9 +6563,11 @@ async function prepareStep8DebuggerClick(tabId) {
   return result;
 }
 
-async function triggerStep8ContentStrategy(tabId, strategy) {
+async function triggerStep8ContentStrategy(tabId, strategy, options = {}) {
+  const timeoutMs = options.timeoutMs ?? 15000;
+  const responseTimeoutMs = options.responseTimeoutMs ?? timeoutMs;
   await ensureStep8SignupPageReady(tabId, {
-    timeoutMs: 15000,
+    timeoutMs,
     logMessage: '步骤 9:认证页内容脚本已失联,正在恢复后继续点击“继续”按钮...',
   });
   const result = await sendToContentScriptResilient('signup-page', {
@@ -6501,7 +6579,8 @@ async function triggerStep8ContentStrategy(tabId, strategy) {
       enabledTimeoutMs: 3000,
     },
   }, {
-    timeoutMs: 15000,
+    timeoutMs,
+    responseTimeoutMs,
     retryDelayMs: 600,
     logMessage: '步骤 9:认证页正在切换,等待“继续”按钮重新就绪...',
   });
@@ -6514,8 +6593,11 @@ async function triggerStep8ContentStrategy(tabId, strategy) {
 }
 
 async function recoverAuthRetryPageOnTab(tabId, payload = {}, options = {}) {
+  const readyTimeoutMs = options.readyTimeoutMs ?? 15000;
+  const timeoutMs = options.timeoutMs ?? 15000;
+  const responseTimeoutMs = options.responseTimeoutMs ?? timeoutMs;
   await ensureStep8SignupPageReady(tabId, {
-    timeoutMs: options.readyTimeoutMs ?? 15000,
+    timeoutMs: readyTimeoutMs,
     retryDelayMs: options.retryDelayMs ?? 600,
     logMessage: options.readyLogMessage || '步骤 9:认证页内容脚本已失联,正在恢复后继续处理重试页...',
   });
@@ -6524,7 +6606,8 @@ async function recoverAuthRetryPageOnTab(tabId, payload = {}, options = {}) {
     source: 'background',
     payload,
   }, {
-    timeoutMs: options.timeoutMs ?? 15000,
+    timeoutMs,
+    responseTimeoutMs,
     retryDelayMs: options.retryDelayMs ?? 600,
     logMessage: options.logMessage || '步骤 9:认证页正在切换,等待“重试”按钮重新就绪...',
   });
@@ -6603,7 +6686,7 @@ async function waitForStep8ClickEffect(tabId, baselineUrl, timeoutMs = STEP8_CLI
         flow: 'auth',
         logLabel: '步骤 9:点击“继续”后进入重试页,正在点击“重试”恢复',
         step: 8,
-        timeoutMs: Math.max(5000, Math.min(12000, timeoutMs)),
+        timeoutMs: Math.max(1000, Math.min(12000, timeoutMs)),
       });
       return {
         progressed: false,
@@ -6616,7 +6699,7 @@ async function waitForStep8ClickEffect(tabId, baselineUrl, timeoutMs = STEP8_CLI
       if (!recovered) {
         recovered = true;
         await ensureStep8SignupPageReady(tabId, {
-          timeoutMs: Math.max(3000, Math.min(8000, timeoutMs)),
+          timeoutMs: Math.max(1000, Math.min(8000, timeoutMs)),
           logMessage: '步骤 9:点击后认证页正在重载,正在等待内容脚本重新就绪...',
         }).catch(() => null);
         continue;
@@ -6662,6 +6745,7 @@ const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({
   clickWithDebugger,
   completeStepFromBackground,
   ensureStep8SignupPageReady,
+  getOAuthFlowStepTimeoutMs,
   getStep8CallbackUrlFromNavigation,
   getStep8CallbackUrlFromTabUpdate,
   getStep8EffectLabel,

+ 63 - 7
background/steps/confirm-oauth.js

@@ -9,6 +9,7 @@
       clickWithDebugger,
       completeStepFromBackground,
       ensureStep8SignupPageReady,
+      getOAuthFlowStepTimeoutMs,
       getStep8CallbackUrlFromNavigation,
       getStep8CallbackUrlFromTabUpdate,
       getStep8EffectLabel,
@@ -39,6 +40,13 @@
 
       await addLog('步骤 9:正在监听 localhost 回调地址...');
 
+      const callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
+        ? await getOAuthFlowStepTimeoutMs(120000, {
+          step: 9,
+          actionLabel: 'OAuth localhost 回调',
+        })
+        : 120000;
+
       return new Promise((resolve, reject) => {
         let resolved = false;
         let signupTabId = null;
@@ -74,7 +82,7 @@
 
         const timeout = setTimeout(() => {
           rejectStep9(new Error('120 秒内未捕获到 localhost 回调跳转,步骤 9 的点击可能被拦截了。'));
-        }, 120000);
+        }, callbackTimeoutMs);
 
         setStep8PendingReject((error) => {
           rejectStep9(error);
@@ -114,13 +122,26 @@
             chrome.webNavigation.onCommitted.addListener(deps.getWebNavCommittedListener());
             chrome.tabs.onUpdated.addListener(deps.getStep8TabUpdatedListener());
             await ensureStep8SignupPageReady(signupTabId, {
-              timeoutMs: 15000,
+              timeoutMs: typeof getOAuthFlowStepTimeoutMs === 'function'
+                ? await getOAuthFlowStepTimeoutMs(15000, {
+                  step: 9,
+                  actionLabel: '等待 OAuth 同意页内容脚本就绪',
+                })
+                : 15000,
               logMessage: '步骤 9:认证页内容脚本尚未就绪,正在等待页面恢复...',
             });
 
             for (let round = 1; round <= STEP8_MAX_ROUNDS && !resolved; round++) {
               throwIfStep8SettledOrStopped(resolved);
-              const pageState = await waitForStep8Ready(signupTabId, STEP8_READY_WAIT_TIMEOUT_MS);
+              const pageState = await waitForStep8Ready(
+                signupTabId,
+                typeof getOAuthFlowStepTimeoutMs === 'function'
+                  ? await getOAuthFlowStepTimeoutMs(STEP8_READY_WAIT_TIMEOUT_MS, {
+                    step: 9,
+                    actionLabel: '等待 OAuth 同意页出现',
+                  })
+                  : STEP8_READY_WAIT_TIMEOUT_MS
+              );
               if (!pageState?.consentReady) {
                 await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS);
                 continue;
@@ -131,18 +152,45 @@
               await addLog(`步骤 9:第 ${round}/${STEP8_MAX_ROUNDS} 轮尝试点击“继续”(${strategy.label})...`);
 
               if (strategy.mode === 'debugger') {
-                const clickTarget = await prepareStep8DebuggerClick(signupTabId);
+                const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
+                  ? await getOAuthFlowStepTimeoutMs(15000, {
+                    step: 9,
+                    actionLabel: '定位 OAuth 同意页继续按钮',
+                  })
+                  : 15000;
+                const clickTarget = await prepareStep8DebuggerClick(signupTabId, {
+                  timeoutMs: clickActionTimeoutMs,
+                  responseTimeoutMs: clickActionTimeoutMs,
+                });
                 throwIfStep8SettledOrStopped(resolved);
                 await clickWithDebugger(signupTabId, clickTarget?.rect);
               } else {
-                await triggerStep8ContentStrategy(signupTabId, strategy.strategy);
+                const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
+                  ? await getOAuthFlowStepTimeoutMs(15000, {
+                    step: 9,
+                    actionLabel: '点击 OAuth 同意页继续按钮',
+                  })
+                  : 15000;
+                await triggerStep8ContentStrategy(signupTabId, strategy.strategy, {
+                  timeoutMs: clickActionTimeoutMs,
+                  responseTimeoutMs: clickActionTimeoutMs,
+                });
               }
 
               if (resolved) {
                 return;
               }
 
-              const effect = await waitForStep8ClickEffect(signupTabId, pageState.url);
+              const effect = await waitForStep8ClickEffect(
+                signupTabId,
+                pageState.url,
+                typeof getOAuthFlowStepTimeoutMs === 'function'
+                  ? await getOAuthFlowStepTimeoutMs(15000, {
+                    step: 9,
+                    actionLabel: '等待 OAuth 同意页点击生效',
+                  })
+                  : 15000
+              );
               if (resolved) {
                 return;
               }
@@ -163,7 +211,15 @@
               }
 
               await addLog(`步骤 9:${strategy.label} 本轮点击后页面无反应,正在刷新认证页后重试(下一轮 ${round + 1}/${STEP8_MAX_ROUNDS})...`, 'warn');
-              await reloadStep8ConsentPage(signupTabId);
+              await reloadStep8ConsentPage(
+                signupTabId,
+                typeof getOAuthFlowStepTimeoutMs === 'function'
+                  ? await getOAuthFlowStepTimeoutMs(30000, {
+                    step: 9,
+                    actionLabel: '刷新 OAuth 同意页',
+                  })
+                  : 30000
+              );
               await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS);
             }
           } catch (err) {

+ 33 - 3
background/steps/fetch-login-code.js

@@ -9,6 +9,8 @@
       confirmCustomVerificationStepBypass,
       ensureStep8VerificationPageReady,
       executeStep7,
+      getOAuthFlowRemainingMs,
+      getOAuthFlowStepTimeoutMs,
       getPanelMode,
       getMailConfig,
       getState,
@@ -29,6 +31,28 @@
       throwIfStopped,
     } = deps;
 
+    async function getStep8ReadyTimeoutMs(actionLabel) {
+      if (typeof getOAuthFlowStepTimeoutMs !== 'function') {
+        return 15000;
+      }
+
+      return getOAuthFlowStepTimeoutMs(15000, {
+        step: 8,
+        actionLabel,
+      });
+    }
+
+    function getStep8RemainingTimeResolver() {
+      if (typeof getOAuthFlowRemainingMs !== 'function') {
+        return undefined;
+      }
+
+      return async (details = {}) => getOAuthFlowRemainingMs({
+        step: 8,
+        actionLabel: details.actionLabel || '登录验证码流程',
+      });
+    }
+
     async function runStep8Attempt(state) {
       const mail = getMailConfig(state);
       if (mail.error) throw new Error(mail.error);
@@ -45,7 +69,9 @@
       }
 
       throwIfStopped();
-      await ensureStep8VerificationPageReady();
+      await ensureStep8VerificationPageReady({
+        timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪'),
+      });
       await addLog('步骤 8:登录验证码页面已就绪,开始获取验证码。', 'info');
 
       if (shouldUseCustomRegistrationEmail(state)) {
@@ -82,7 +108,8 @@
       let step7ReplayCompleted = false;
 
       await resolveVerificationStep(8, state, mail, {
-        filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : Math.max(0, stepStartedAt - 60000),
+        filterAfterTimestamp: stepStartedAt,
+        getRemainingTimeMs: getStep8RemainingTimeResolver(),
         requestFreshCodeFirst: false,
         resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925')
           ? 0
@@ -98,7 +125,9 @@
             logMessage: '步骤 8:正在重新获取最新 CPA OAuth 链接,并快速重走步骤 7...',
             postStepDelayMs: 1200,
           });
-          await ensureStep8VerificationPageReady();
+          await ensureStep8VerificationPageReady({
+            timeoutMs: await getStep8ReadyTimeoutMs('重放步骤 7 后重新确认登录验证码页'),
+          });
           await addLog('步骤 8:登录验证码页面已重新就绪,开始回填刚才获取到的验证码。', 'info');
         } : undefined,
       });
@@ -122,6 +151,7 @@
         await setState({
           lastLoginCode: null,
           loginVerificationRequestedAt: null,
+          oauthFlowDeadlineAt: null,
         });
         await setStepStatus(8, 'skipped');
         await addLog('步骤 8:当前已选择“第七步回调”,本轮无需获取登录验证码。', 'warn');

+ 1 - 1
background/steps/fetch-signup-code.js

@@ -87,7 +87,7 @@
       }
 
       await resolveVerificationStep(4, state, mail, {
-        filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt,
+        filterAfterTimestamp: stepStartedAt,
         requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true,
         resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925')
           ? 0

+ 13 - 1
background/steps/oauth-login.js

@@ -7,6 +7,7 @@
       completeStepFromBackground,
       getErrorMessage,
       getLoginAuthStateLabel,
+      getOAuthFlowStepTimeoutMs,
       getState,
       isStep6RecoverableResult,
       isStep6SuccessResult,
@@ -15,6 +16,7 @@
       sendToContentScriptResilient,
       shouldSkipLoginVerificationForCpaCallback,
       skipLoginVerificationStepsForCpaCallback,
+      startOAuthFlowTimeoutWindow,
       STEP6_MAX_ATTEMPTS,
       throwIfStopped,
     } = deps;
@@ -38,6 +40,15 @@
           const currentState = attempt === 1 ? state : await getState();
           const password = currentState.password || currentState.customPassword || '';
           const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState);
+          if (typeof startOAuthFlowTimeoutWindow === 'function') {
+            await startOAuthFlowTimeoutWindow({ step: 7, oauthUrl });
+          }
+          const loginTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
+            ? await getOAuthFlowStepTimeoutMs(180000, {
+              step: 7,
+              actionLabel: 'OAuth 登录并进入验证码页',
+            })
+            : 180000;
 
           if (attempt === 1) {
             await addLog('步骤 7:正在打开最新 OAuth 链接并登录...');
@@ -59,7 +70,8 @@
               },
             },
             {
-              timeoutMs: 180000,
+              timeoutMs: loginTimeoutMs,
+              responseTimeoutMs: loginTimeoutMs,
               retryDelayMs: 700,
               logMessage: '步骤 7:认证页正在切换,等待页面重新就绪后继续登录...',
             }

+ 21 - 4
background/tab-runtime.js

@@ -547,7 +547,12 @@
     }
 
     async function sendToContentScriptResilient(source, message, options = {}) {
-      const { timeoutMs = 30000, retryDelayMs = 600, logMessage = '' } = options;
+      const {
+        timeoutMs = 30000,
+        retryDelayMs = 600,
+        logMessage = '',
+        responseTimeoutMs,
+      } = options;
       const start = Date.now();
       let lastError = null;
       let logged = false;
@@ -558,7 +563,11 @@
         attempt += 1;
 
         try {
-          return await sendToContentScript(source, message);
+          return await sendToContentScript(
+            source,
+            message,
+            responseTimeoutMs !== undefined ? { responseTimeoutMs } : {}
+          );
         } catch (err) {
           const retryable = isRetryableContentScriptTransportError(err);
           if (!retryable) {
@@ -579,7 +588,11 @@
     }
 
     async function sendToMailContentScriptResilient(mail, message, options = {}) {
-      const { timeoutMs = 45000, maxRecoveryAttempts = 2 } = options;
+      const {
+        timeoutMs = 45000,
+        maxRecoveryAttempts = 2,
+        responseTimeoutMs,
+      } = options;
       const start = Date.now();
       let lastError = null;
       let recoveries = 0;
@@ -589,7 +602,11 @@
         throwIfStopped();
 
         try {
-          return await sendToContentScript(mail.source, message);
+          return await sendToContentScript(
+            mail.source,
+            message,
+            responseTimeoutMs !== undefined ? { responseTimeoutMs } : {}
+          );
         } catch (err) {
           if (!isRetryableContentScriptTransportError(err)) {
             throw err;

+ 110 - 34
background/verification-flow.js

@@ -134,7 +134,58 @@
       };
     }
 
-    async function requestVerificationCodeResend(step) {
+    async function getRemainingTimeBudgetMs(step, options = {}, actionLabel = '') {
+      const resolver = typeof options.getRemainingTimeMs === 'function'
+        ? options.getRemainingTimeMs
+        : null;
+      if (!resolver) {
+        return null;
+      }
+
+      const value = await resolver({ step, actionLabel });
+      const numeric = Number(value);
+      if (!Number.isFinite(numeric)) {
+        return null;
+      }
+
+      return Math.max(0, Math.floor(numeric));
+    }
+
+    async function getResponseTimeoutMsForStep(step, options = {}, fallbackMs = 30000, actionLabel = '') {
+      const remainingMs = await getRemainingTimeBudgetMs(step, options, actionLabel);
+      if (remainingMs === null) {
+        return Math.max(1000, Number(fallbackMs) || 1000);
+      }
+
+      return Math.max(1000, Math.min(Math.max(1000, Number(fallbackMs) || 1000), remainingMs));
+    }
+
+    async function applyMailPollingTimeBudget(step, payload, options = {}, actionLabel = '') {
+      const nextPayload = { ...payload };
+      const intervalMs = Math.max(1, Number(nextPayload.intervalMs) || 3000);
+      const baseMaxAttempts = Math.max(1, Number(nextPayload.maxAttempts) || 1);
+      const remainingMs = await getRemainingTimeBudgetMs(step, options, actionLabel);
+
+      if (remainingMs !== null) {
+        nextPayload.maxAttempts = Math.max(
+          1,
+          Math.min(baseMaxAttempts, Math.floor(Math.max(0, remainingMs - 1000) / intervalMs) + 1)
+        );
+      }
+
+      const defaultResponseTimeoutMs = Math.max(45000, nextPayload.maxAttempts * intervalMs + 25000);
+      const responseTimeoutMs = remainingMs === null
+        ? defaultResponseTimeoutMs
+        : Math.max(1000, Math.min(defaultResponseTimeoutMs, remainingMs));
+
+      return {
+        payload: nextPayload,
+        responseTimeoutMs,
+        timeoutMs: responseTimeoutMs,
+      };
+    }
+
+    async function requestVerificationCodeResend(step, options = {}) {
       throwIfStopped();
       const signupTabId = await getTabId('signup-page');
       if (!signupTabId) {
@@ -150,6 +201,13 @@
         step,
         source: 'background',
         payload: {},
+      }, {
+        responseTimeoutMs: await getResponseTimeoutMsForStep(
+          step,
+          options,
+          30000,
+          `重新发送${getVerificationCodeLabel(step)}验证码`
+        ),
       });
 
       if (result && result.error) {
@@ -211,7 +269,7 @@
       for (let round = 1; round <= totalRounds; round++) {
         throwIfStopped();
         if (round > 1) {
-          lastResendAt = await requestVerificationCodeResend(step);
+          lastResendAt = await requestVerificationCodeResend(step, pollOverrides);
           usedResendRequests += 1;
           if (onResendRequestedAt) {
             const nextFilterAfterTimestamp = await onResendRequestedAt(lastResendAt);
@@ -237,17 +295,24 @@
           }
 
           try {
+            const timedPoll = await applyMailPollingTimeBudget(
+              step,
+              payload,
+              pollOverrides,
+              `轮询${getVerificationCodeLabel(step)}验证码邮箱`
+            );
             const result = await sendToMailContentScriptResilient(
               mail,
               {
                 type: 'POLL_EMAIL',
                 step,
                 source: 'background',
-                payload,
+                payload: timedPoll.payload,
               },
               {
-                timeoutMs: 45000,
+                timeoutMs: timedPoll.timeoutMs,
                 maxRecoveryAttempts: 2,
+                responseTimeoutMs: timedPoll.responseTimeoutMs,
               }
             );
 
@@ -307,23 +372,26 @@
 
       if (mail.provider === HOTMAIL_PROVIDER) {
         const hotmailPollConfig = getHotmailVerificationPollConfig(step);
-        return pollHotmailVerificationCode(step, state, {
+        const timedPoll = await applyMailPollingTimeBudget(step, {
           ...getVerificationPollPayload(step, state),
           ...hotmailPollConfig,
           ...cleanPollOverrides,
-        });
+        }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`);
+        return pollHotmailVerificationCode(step, state, timedPoll.payload);
       }
       if (mail.provider === LUCKMAIL_PROVIDER) {
-        return pollLuckmailVerificationCode(step, state, {
+        const timedPoll = await applyMailPollingTimeBudget(step, {
           ...getVerificationPollPayload(step, state),
           ...cleanPollOverrides,
-        });
+        }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`);
+        return pollLuckmailVerificationCode(step, state, timedPoll.payload);
       }
       if (mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) {
-        return pollCloudflareTempEmailVerificationCode(step, state, {
+        const timedPoll = await applyMailPollingTimeBudget(step, {
           ...getVerificationPollPayload(step, state),
           ...cleanPollOverrides,
-        });
+        }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`);
+        return pollCloudflareTempEmailVerificationCode(step, state, timedPoll.payload);
       }
 
       if (Number(pollOverrides.resendIntervalMs) > 0) {
@@ -348,7 +416,7 @@
       for (let round = 1; round <= maxRounds; round++) {
         throwIfStopped();
         if (round > 1) {
-          const requestedAt = await requestVerificationCodeResend(step);
+          const requestedAt = await requestVerificationCodeResend(step, pollOverrides);
           usedResendRequests += 1;
           if (typeof onResendRequestedAt === 'function') {
             const nextFilterAfterTimestamp = await onResendRequestedAt(requestedAt);
@@ -365,17 +433,24 @@
         });
 
         try {
+          const timedPoll = await applyMailPollingTimeBudget(
+            step,
+            payload,
+            pollOverrides,
+            `轮询${getVerificationCodeLabel(step)}验证码邮箱`
+          );
           const result = await sendToMailContentScriptResilient(
             mail,
             {
               type: 'POLL_EMAIL',
               step,
               source: 'background',
-              payload,
+              payload: timedPoll.payload,
             },
             {
-              timeoutMs: 45000,
+              timeoutMs: timedPoll.timeoutMs,
               maxRecoveryAttempts: 2,
+              responseTimeoutMs: timedPoll.responseTimeoutMs,
             }
           );
 
@@ -410,7 +485,7 @@
       throw lastError || new Error(`步骤 ${step}:无法获取新的${getVerificationCodeLabel(step)}验证码。`);
     }
 
-    async function submitVerificationCode(step, code) {
+    async function submitVerificationCode(step, code, options = {}) {
       const signupTabId = await getTabId('signup-page');
       if (!signupTabId) {
         throw new Error('认证页面标签页已关闭,无法填写验证码。');
@@ -422,6 +497,13 @@
         step,
         source: 'background',
         payload: { code },
+      }, {
+        responseTimeoutMs: await getResponseTimeoutMsForStep(
+          step,
+          options,
+          step === 7 ? 45000 : 30000,
+          `填写${getVerificationCodeLabel(step)}验证码`
+        ),
       });
 
       if (result && result.error) {
@@ -459,22 +541,7 @@
       const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0);
       let lastResendAt = Number(options.lastResendAt) || 0;
 
-      const updateFilterAfterTimestampForVerificationStep = async (requestedAt) => {
-        if ((step !== 4 && step !== 8) || !requestedAt) {
-          return nextFilterAfterTimestamp;
-        }
-
-        if (mail.provider === HOTMAIL_PROVIDER) {
-          nextFilterAfterTimestamp = getHotmailVerificationRequestTimestamp(step, {
-            ...state,
-            ...(step === 4
-              ? { signupVerificationRequestedAt: requestedAt }
-              : { loginVerificationRequestedAt: requestedAt }),
-          });
-        } else {
-          nextFilterAfterTimestamp = Math.max(0, Number(requestedAt) - 60000);
-        }
-
+      const updateFilterAfterTimestampForVerificationStep = async (_requestedAt) => {
         return nextFilterAfterTimestamp;
       };
 
@@ -483,7 +550,7 @@
           await addLog(`步骤 ${step}:当前自动重新发送验证码次数为 0,将直接使用当前时间窗口轮询邮箱。`, 'info');
         } else {
           try {
-            lastResendAt = await requestVerificationCodeResend(step);
+            lastResendAt = await requestVerificationCodeResend(step, options);
             remainingAutomaticResendCount -= 1;
             await updateFilterAfterTimestampForVerificationStep(lastResendAt);
             await addLog(`步骤 ${step}:已先请求一封新的${getVerificationCodeLabel(step)}验证码,再开始轮询邮箱。`, 'warn');
@@ -499,14 +566,23 @@
       if (mail.provider === HOTMAIL_PROVIDER) {
           const initialDelayMs = Number(options.initialDelayMs ?? hotmailPollConfig.initialDelayMs) || 0;
           if (initialDelayMs > 0) {
+            const remainingMs = await getRemainingTimeBudgetMs(
+              step,
+              options,
+              `等待${getVerificationCodeLabel(step)}验证码邮件到达`
+            );
+            const delayMs = remainingMs === null
+              ? initialDelayMs
+              : Math.min(initialDelayMs, Math.max(0, remainingMs));
             await addLog(`步骤 ${step}:等待 ${Math.round(initialDelayMs / 1000)} 秒,让 Hotmail 验证码邮件先到达...`, 'info');
-            await sleepWithStop(initialDelayMs);
+            await sleepWithStop(delayMs);
           }
         }
 
         for (let attempt = 1; attempt <= maxSubmitAttempts; attempt++) {
           const pollOptions = {
             excludeCodes: [...rejectedCodes],
+            getRemainingTimeMs: options.getRemainingTimeMs,
             maxResendRequests: remainingAutomaticResendCount,
             resendIntervalMs,
             lastResendAt,
@@ -533,7 +609,7 @@
             });
           }
           throwIfStopped();
-          const submitResult = await submitVerificationCode(step, result.code);
+          const submitResult = await submitVerificationCode(step, result.code, options);
 
           if (submitResult.invalidCode) {
             rejectedCodes.add(result.code);
@@ -559,7 +635,7 @@
               continue;
             }
 
-            lastResendAt = await requestVerificationCodeResend(step);
+            lastResendAt = await requestVerificationCodeResend(step, options);
             remainingAutomaticResendCount -= 1;
             await updateFilterAfterTimestampForVerificationStep(lastResendAt);
             await addLog(`步骤 ${step}:提交失败后已请求新验证码(${attempt + 1}/${maxSubmitAttempts})...`, 'warn');

+ 53 - 0
tests/background-step6-retry-limit.test.js

@@ -76,3 +76,56 @@ test('step 7 retries up to configured limit and then fails', async () => {
   assert.equal(events.sendCalls, 3);
   assert.equal(events.completed, 0);
 });
+
+test('step 7 starts a new oauth timeout window for each refreshed oauth url', async () => {
+  const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
+  const globalScope = {};
+  const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
+
+  const events = {
+    startedWindows: [],
+    timeoutRequests: [],
+  };
+
+  const executor = api.createStep7Executor({
+    addLog: async () => {},
+    completeStepFromBackground: async () => {},
+    getErrorMessage: (error) => error?.message || String(error || ''),
+    getLoginAuthStateLabel: (state) => state || 'unknown',
+    getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs, options) => {
+      events.timeoutRequests.push({ defaultTimeoutMs, options });
+      return 5000;
+    },
+    getState: async () => ({ email: 'user@example.com', password: 'secret' }),
+    isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
+    isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
+    refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
+    reuseOrCreateTab: async () => {},
+    sendToContentScriptResilient: async (_source, _message, options) => ({
+      step6Outcome: 'success',
+      usedTimeoutMs: options.timeoutMs,
+    }),
+    shouldSkipLoginVerificationForCpaCallback: () => false,
+    skipLoginVerificationStepsForCpaCallback: async () => {},
+    startOAuthFlowTimeoutWindow: async (payload) => {
+      events.startedWindows.push(payload);
+    },
+    STEP6_MAX_ATTEMPTS: 3,
+    throwIfStopped: () => {},
+  });
+
+  await executor.executeStep7({ email: 'user@example.com', password: 'secret' });
+
+  assert.deepStrictEqual(events.startedWindows, [
+    { step: 7, oauthUrl: 'https://oauth.example/latest' },
+  ]);
+  assert.deepStrictEqual(events.timeoutRequests, [
+    {
+      defaultTimeoutMs: 180000,
+      options: {
+        step: 7,
+        actionLabel: 'OAuth 登录并进入验证码页',
+      },
+    },
+  ]);
+});

+ 26 - 6
tests/background-step7-recovery.test.js

@@ -9,10 +9,13 @@ const api = new Function('self', `${source}; return self.MultiPageBackgroundStep
 test('step 8 refreshes CPA oauth via step 7 replay before submitting verification code', async () => {
   const calls = {
     ensureReady: 0,
+    ensureReadyOptions: [],
     executeStep7: 0,
     sleep: [],
     resolveOptions: null,
   };
+  const realDateNow = Date.now;
+  Date.now = () => 123456;
 
   const executor = api.createStep8Executor({
     addLog: async () => {},
@@ -23,13 +26,16 @@ test('step 8 refreshes CPA oauth via step 7 replay before submitting verificatio
     },
     CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
     confirmCustomVerificationStepBypass: async () => {},
-    ensureStep8VerificationPageReady: async () => {
+    ensureStep8VerificationPageReady: async (options) => {
       calls.ensureReady += 1;
+      calls.ensureReadyOptions.push(options || null);
       return { state: 'verification_page' };
     },
     executeStep7: async () => {
       calls.executeStep7 += 1;
     },
+    getOAuthFlowRemainingMs: async () => 5000,
+    getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 5000),
     getMailConfig: () => ({
       provider: 'qq',
       label: 'QQ 邮箱',
@@ -61,17 +67,28 @@ test('step 8 refreshes CPA oauth via step 7 replay before submitting verificatio
     throwIfStopped: () => {},
   });
 
-  await executor.executeStep8({
-    email: 'user@example.com',
-    password: 'secret',
-    oauthUrl: 'https://oauth.example/latest',
-  });
+  try {
+    await executor.executeStep8({
+      email: 'user@example.com',
+      password: 'secret',
+      oauthUrl: 'https://oauth.example/latest',
+    });
+  } finally {
+    Date.now = realDateNow;
+  }
 
   assert.equal(typeof calls.resolveOptions.beforeSubmit, 'function');
   assert.equal(calls.ensureReady, 2);
   assert.equal(calls.executeStep7, 1);
   assert.deepStrictEqual(calls.sleep, [1200]);
+  assert.equal(calls.resolveOptions.filterAfterTimestamp, 123456);
+  assert.equal(typeof calls.resolveOptions.getRemainingTimeMs, 'function');
+  assert.equal(await calls.resolveOptions.getRemainingTimeMs({ actionLabel: '登录验证码流程' }), 5000);
   assert.equal(calls.resolveOptions.resendIntervalMs, 25000);
+  assert.deepStrictEqual(calls.ensureReadyOptions, [
+    { timeoutMs: 5000 },
+    { timeoutMs: 5000 },
+  ]);
 });
 
 test('step 8 disables resend interval for 2925 mailbox polling', async () => {
@@ -88,6 +105,8 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => {
     confirmCustomVerificationStepBypass: async () => {},
     ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }),
     executeStep7: async () => {},
+    getOAuthFlowRemainingMs: async () => 8000,
+    getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
     getMailConfig: () => ({
       provider: '2925',
       label: '2925 邮箱',
@@ -124,4 +143,5 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => {
 
   assert.equal(capturedOptions.resendIntervalMs, 0);
   assert.equal(capturedOptions.beforeSubmit, undefined);
+  assert.equal(typeof capturedOptions.getRemainingTimeMs, 'function');
 });

+ 79 - 19
tests/verification-flow-polling.test.js

@@ -109,6 +109,70 @@ test('verification flow runs beforeSubmit hook before filling the code', async (
   ]);
 });
 
+test('verification flow caps mail polling timeout to the remaining oauth budget', async () => {
+  const mailPollCalls = [];
+
+  const helpers = api.createVerificationFlowHelpers({
+    addLog: async () => {},
+    chrome: {
+      tabs: {
+        update: async () => {},
+      },
+    },
+    CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
+    completeStepFromBackground: async () => {},
+    confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
+    getHotmailVerificationPollConfig: () => ({}),
+    getHotmailVerificationRequestTimestamp: () => 0,
+    getState: async () => ({}),
+    getTabId: async () => 1,
+    HOTMAIL_PROVIDER: 'hotmail-api',
+    isStopError: () => false,
+    LUCKMAIL_PROVIDER: 'luckmail-api',
+    MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
+    MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
+    pollCloudflareTempEmailVerificationCode: async () => ({}),
+    pollHotmailVerificationCode: async () => ({}),
+    pollLuckmailVerificationCode: async () => ({}),
+    sendToContentScript: async (_source, message) => {
+      if (message.type === 'FILL_CODE') {
+        return {};
+      }
+      return {};
+    },
+    sendToMailContentScriptResilient: async (_mail, message, options) => {
+      mailPollCalls.push({
+        payload: message.payload,
+        options,
+      });
+      return { code: '654321', emailTimestamp: 123 };
+    },
+    setState: async () => {},
+    setStepStatus: async () => {},
+    sleepWithStop: async () => {},
+    throwIfStopped: () => {},
+    VERIFICATION_POLL_MAX_ROUNDS: 5,
+  });
+
+  await helpers.resolveVerificationStep(
+    8,
+    {
+      email: 'user@example.com',
+      lastLoginCode: null,
+    },
+    { provider: 'qq', label: 'QQ 邮箱' },
+    {
+      getRemainingTimeMs: async () => 5000,
+      resendIntervalMs: 0,
+    }
+  );
+
+  assert.ok(mailPollCalls.length >= 1);
+  assert.equal(mailPollCalls[0].options.timeoutMs, 5000);
+  assert.equal(mailPollCalls[0].options.responseTimeoutMs, 5000);
+  assert.equal(mailPollCalls[0].payload.maxAttempts, 2);
+});
+
 test('verification flow keeps Hotmail request timestamp filtering on the first poll', async () => {
   const pollPayloads = [];
 
@@ -162,10 +226,8 @@ test('verification flow keeps Hotmail request timestamp filtering on the first p
   assert.equal(pollPayloads[0].filterAfterTimestamp, 87654);
 });
 
-test('verification flow refreshes Hotmail signup filter timestamp after step 4 resend', async () => {
+test('verification flow keeps fixed filter timestamp after step 4 resend', async () => {
   const pollPayloads = [];
-  const realDateNow = Date.now;
-  Date.now = () => 200000;
 
   let submitCount = 0;
 
@@ -213,24 +275,22 @@ test('verification flow refreshes Hotmail signup filter timestamp after step 4 r
     VERIFICATION_POLL_MAX_ROUNDS: 5,
   });
 
-  try {
-    await helpers.resolveVerificationStep(
-      4,
-      {
-        email: 'user@example.com',
-        signupVerificationRequestedAt: 100000,
-        lastSignupCode: null,
-      },
-      { provider: 'hotmail-api', label: 'Hotmail' },
-      {}
-    );
-  } finally {
-    Date.now = realDateNow;
-  }
+  await helpers.resolveVerificationStep(
+    4,
+    {
+      email: 'user@example.com',
+      signupVerificationRequestedAt: 100000,
+      lastSignupCode: null,
+    },
+    { provider: 'hotmail-api', label: 'Hotmail' },
+    {
+      filterAfterTimestamp: 123456,
+    }
+  );
 
   assert.equal(pollPayloads.length, 2);
-  assert.equal(pollPayloads[0].filterAfterTimestamp, 85000);
-  assert.equal(pollPayloads[1].filterAfterTimestamp, 185000);
+  assert.equal(pollPayloads[0].filterAfterTimestamp, 123456);
+  assert.equal(pollPayloads[1].filterAfterTimestamp, 123456);
 });
 
 test('verification flow uses configured signup resend count for step 4', async () => {

+ 1 - 1
项目完整链路说明.md

@@ -246,7 +246,7 @@
 补充行为:
 
 - `2925` provider 会拉长单轮轮询窗口,并关闭 Step 4 / 8 的自动重发间隔,减少因邮件延迟过早判负。
-- Hotmail 在 Step 4 / 8 首轮轮询时会优先使用最近一次验证码请求时间作为时间窗口;如果中途触发重发,轮询窗口也会同步前移,避免旧邮件反复命中
+- Step 4 / 8 会在当前验证码步骤启动时固定一次邮件筛选时间窗;本步后续即使触发重新发送验证码,也继续沿用这一次启动时间作为筛选基准,不再随着重发时间前移
 - 自动重新发送验证码次数现在使用 sidepanel 里的单一“验证码重发”配置;普通邮箱仍按 25 秒间隔节流,Hotmail / 2925 不走这个 25 秒间隔。Step 4 若启用先请求新验证码,会先消耗一次当前步骤的自动重发次数。
 - CPA 模式下,Step 8 在真正回填登录验证码前,会先刷新最新 OAuth 地址并快速重走一次 Step 7,降低验证码等待过久导致 OAuth 过期的概率。