diff --git a/.gitignore b/.gitignore index 28a23c9..ea8cd9f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ /data/account-run-history.txt /data/account-run-history.json -.npm-test.log \ No newline at end of file +.npm-test.log +.omx/ diff --git a/README.md b/README.md index 1ea3427..5ddedd7 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,17 @@ http(s):///management.html#/oauth Step 1 和 Step 10 都依赖这个地址。 +### `SUB2API` + +当 `来源 = SUB2API` 时,需要配置: + +- `SUB2API`:后台账号管理页地址 +- `账号 / 密码`:SUB2API 管理员登录信息 +- `分组`:目标 OpenAI 分组,留空时默认 `codex` +- `默认代理`:创建账号时必须绑定的代理名称或代理 ID,默认 `shadowrocket` + +插件会在 Step 1 和 Step 10 自动从 `/api/v1/admin/proxies/all` 解析这个代理,并分别把 `proxy_id` 传给 OAuth 链接生成、授权码交换和账号创建请求。如果名称匹配到多个代理,请改填代理 ID。 + ### `Mail` 支持五种验证码来源: diff --git a/background.js b/background.js index 8b82fea..559d010 100644 --- a/background.js +++ b/background.js @@ -141,6 +141,7 @@ const SUB2API_STEP1_RESPONSE_TIMEOUT_MS = 90000; const SUB2API_STEP9_RESPONSE_TIMEOUT_MS = 120000; const DEFAULT_SUB2API_URL = 'https://sub2api.hisence.fun/admin/accounts'; const DEFAULT_SUB2API_GROUP_NAME = 'codex'; +const DEFAULT_SUB2API_PROXY_NAME = 'shadowrocket'; const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback'; const AUTO_RUN_TIMER_ALARM_NAME = 'auto-run-timer'; const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start'; @@ -215,6 +216,7 @@ const PERSISTED_SETTING_DEFAULTS = { sub2apiEmail: '', sub2apiPassword: '', sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME, + sub2apiDefaultProxyName: DEFAULT_SUB2API_PROXY_NAME, customPassword: '', autoRunSkipFailures: false, autoRunFallbackThreadIntervalMinutes: 0, @@ -287,6 +289,7 @@ const DEFAULT_STATE = { sub2apiOAuthState: null, // SUB2API OpenAI Auth state。 sub2apiGroupId: null, // SUB2API 目标分组 ID。 sub2apiDraftName: null, // SUB2API 本轮预生成的账号名称。 + sub2apiProxyId: null, // SUB2API 本轮使用的代理 ID。 flowStartTime: null, // 当前流程开始时间。 tabRegistry: {}, // 程序维护的标签页注册表。 sourceLastUrls: {}, // 各来源页面最近一次打开的地址记录。 @@ -828,6 +831,8 @@ function normalizePersistentSettingValue(key, value) { return String(value || ''); case 'sub2apiGroupName': return String(value || '').trim(); + case 'sub2apiDefaultProxyName': + return String(value || '').trim() || DEFAULT_SUB2API_PROXY_NAME; case 'customPassword': return String(value || ''); case 'autoRunSkipFailures': @@ -3839,6 +3844,14 @@ function isVerificationMailPollingError(error) { return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message); } +function isAddPhoneAuthFailure(error) { + if (typeof loggingStatus !== 'undefined' && loggingStatus?.isAddPhoneAuthFailure) { + return loggingStatus.isAddPhoneAuthFailure(error); + } + const message = getErrorMessage(error); + return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message); +} + function getLoginAuthStateLabel(state) { if (typeof loggingStatus !== 'undefined' && loggingStatus?.getLoginAuthStateLabel) { return loggingStatus.getLoginAuthStateLabel(state); @@ -5176,6 +5189,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR getState, getStopRequested: () => stopRequested, hasSavedProgress, + isAddPhoneAuthFailure, isRestartCurrentAttemptError, isStopError, launchAutoRunTimerPlan, @@ -5783,6 +5797,7 @@ const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({ getLoginAuthStateLabel, getOAuthFlowStepTimeoutMs, getState, + isAddPhoneAuthFailure, isStep6RecoverableResult, isStep6SuccessResult, refreshOAuthUrlBeforeStep6, @@ -6361,7 +6376,7 @@ async function getPostStep6AutoRestartDecision(step, error) { }; } - if (isAddPhoneAuthUrl(errorMessage)) { + if (isAddPhoneAuthFailure(error) || isAddPhoneAuthUrl(errorMessage)) { return { shouldRestart: false, blockedByAddPhone: true, @@ -6435,6 +6450,11 @@ async function ensureStep8VerificationPageReady(options = {}) { throw new Error(`STEP8_RESTART_STEP7::步骤 8:当前认证页进入登录超时报错页,请回到步骤 7 重新开始。${urlPart}`.trim()); } + if (pageState.state === 'add_phone_page') { + const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; + throw new Error(`步骤 8:当前认证页进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim()); + } + const stateLabel = getLoginAuthStateLabel(pageState.state); const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; throw new Error(`当前未进入登录验证码页面,请先重新完成步骤 7。当前状态:${stateLabel}.${urlPart}`.trim()); diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index e7ccff8..40d905e 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -21,6 +21,7 @@ getRunningSteps, getState, hasSavedProgress, + isAddPhoneAuthFailure, isRestartCurrentAttemptError, isStopError, launchAutoRunTimerPlan, @@ -456,12 +457,36 @@ const reason = getErrorMessage(err); roundSummary.failureReasons.push(reason); - const canRetry = autoRunSkipFailures && attemptRun < maxAttemptsForRound; + const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err); + const canRetry = !blockedByAddPhone && autoRunSkipFailures && attemptRun < maxAttemptsForRound; + + if (blockedByAddPhone) { + roundSummary.status = 'failed'; + roundSummary.finalFailureReason = reason; + } await setState({ autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), }); + if (blockedByAddPhone) { + await appendRoundRecordIfNeeded('failed', reason); + cancelPendingCommands('当前轮因认证流程进入 add-phone 已终止。'); + await broadcastStopToContentScripts(); + await addLog( + `第 ${targetRun}/${totalRuns} 轮触发 add-phone/手机号页,当前自动运行将立即停止,不再重试或进入下一轮。`, + 'warn' + ); + stoppedEarly = true; + await broadcastAutoRunStatus('stopped', { + currentRun: targetRun, + totalRuns, + attemptRun, + sessionId: 0, + }); + break; + } + if (canRetry) { const retryIndex = attemptRun; if (isRestartCurrentAttemptError(err)) { diff --git a/background/logging-status.js b/background/logging-status.js index 83a0ea7..f685fd3 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -61,6 +61,11 @@ return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message); } + function isAddPhoneAuthFailure(error) { + const message = getErrorMessage(error); + return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message); + } + function getLoginAuthStateLabel(state) { state = state === 'oauth_consent_page' ? 'unknown' : state; switch (state) { @@ -148,6 +153,7 @@ return { addLog, getAutoRunStatusPayload, + isAddPhoneAuthFailure, getErrorMessage, getFirstUnfinishedStep, getLoginAuthStateLabel, diff --git a/background/message-router.js b/background/message-router.js index ab29842..fbf5dd4 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -106,6 +106,7 @@ if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null; if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; + if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; if (Object.keys(updates).length) { await setState(updates); } diff --git a/background/panel-bridge.js b/background/panel-bridge.js index 61ef8ba..b25af20 100644 --- a/background/panel-bridge.js +++ b/background/panel-bridge.js @@ -120,6 +120,7 @@ sub2apiEmail: state.sub2apiEmail, sub2apiPassword: state.sub2apiPassword, sub2apiGroupName: groupName, + sub2apiDefaultProxyName: state.sub2apiDefaultProxyName, logStep: 6, }, }, { diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index 19cba8d..facf21e 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -9,6 +9,10 @@ getLoginAuthStateLabel, getOAuthFlowStepTimeoutMs, getState, + isAddPhoneAuthFailure = (error) => { + const message = String(typeof error === 'string' ? error : error?.message || ''); + return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message); + }, isStep6RecoverableResult, isStep6SuccessResult, refreshOAuthUrlBeforeStep6, @@ -19,11 +23,6 @@ throwIfStopped, } = deps; - function isAddPhoneAuthFailure(error) { - const message = String(typeof error === 'string' ? error : error?.message || ''); - return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|add-phone|手机号页面|手机号码|手机号|phone\s+number|telephone/i.test(message); - } - async function executeStep7(state) { if (!state.email) { throw new Error('缺少邮箱地址,请先完成步骤 3。'); diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js index 17d0cdc..7edfe5d 100644 --- a/background/steps/platform-verify.js +++ b/background/steps/platform-verify.js @@ -142,6 +142,8 @@ sub2apiEmail: state.sub2apiEmail, sub2apiPassword: state.sub2apiPassword, sub2apiGroupName: state.sub2apiGroupName, + sub2apiDefaultProxyName: state.sub2apiDefaultProxyName, + sub2apiProxyId: state.sub2apiProxyId, sub2apiSessionId: state.sub2apiSessionId, sub2apiOAuthState: state.sub2apiOAuthState, sub2apiGroupId: state.sub2apiGroupId, diff --git a/background/verification-flow.js b/background/verification-flow.js index 61b8d0a..055e2e7 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -642,6 +642,11 @@ continue; } + if (submitResult.addPhonePage) { + const urlPart = submitResult.url ? ` URL: ${submitResult.url}` : ''; + throw new Error(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim()); + } + await setState({ lastEmailTimestamp: result.emailTimestamp, [stateKey]: result.code, diff --git a/content/signup-page.js b/content/signup-page.js index 3f29e75..47000f5 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -1442,7 +1442,7 @@ async function waitForVerificationSubmitOutcome(step, timeout) { } if (step === 8 && isAddPhonePageReady()) { - return { success: true, addPhonePage: true }; + return { success: true, addPhonePage: true, url: location.href }; } await sleep(150); @@ -1499,7 +1499,7 @@ async function fillVerificationCode(step, payload) { if (outcome.invalidCode) { log(`步骤 ${step}:验证码被拒绝:${outcome.errorText}`, 'warn'); } else if (outcome.addPhonePage) { - log(`步骤 ${step}:验证码已通过,并已跳转到手机号页面。`, 'ok'); + log(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程将停止自动授权。`, 'warn'); } else { log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok'); } @@ -1541,7 +1541,7 @@ async function fillVerificationCode(step, payload) { if (outcome.invalidCode) { log(`步骤 ${step}:验证码被拒绝:${outcome.errorText}`, 'warn'); } else if (outcome.addPhonePage) { - log(`步骤 ${step}:验证码已通过,并已跳转到手机号页面。`, 'ok'); + log(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程将停止自动授权。`, 'warn'); } else { log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok'); } diff --git a/content/sub2api-panel.js b/content/sub2api-panel.js index 8be143b..1633b31 100644 --- a/content/sub2api-panel.js +++ b/content/sub2api-panel.js @@ -4,6 +4,7 @@ console.log('[MultiPage:sub2api-panel] Content script loaded on', location.href) const SUB2API_PANEL_LISTENER_SENTINEL = 'data-multipage-sub2api-panel-listener'; const SUB2API_DEFAULT_GROUP_NAME = 'codex'; +const SUB2API_DEFAULT_PROXY_NAME = 'shadowrocket'; const SUB2API_DEFAULT_REDIRECT_URI = 'http://localhost:1455/auth/callback'; const SUB2API_DEFAULT_CONCURRENCY = 10; const SUB2API_DEFAULT_PRIORITY = 1; @@ -188,6 +189,149 @@ async function getGroupByName(origin, token, groupName) { return group; } +function normalizeSub2ApiProxyPreference(value) { + return String(value || '').trim(); +} + +function resolveSub2ApiProxyPreference(payload = {}, backgroundState = {}) { + if (payload.sub2apiDefaultProxyName !== undefined) { + return normalizeSub2ApiProxyPreference(payload.sub2apiDefaultProxyName) || SUB2API_DEFAULT_PROXY_NAME; + } + if (backgroundState.sub2apiDefaultProxyName !== undefined) { + return normalizeSub2ApiProxyPreference(backgroundState.sub2apiDefaultProxyName) || SUB2API_DEFAULT_PROXY_NAME; + } + return SUB2API_DEFAULT_PROXY_NAME; +} + +function normalizeProxyId(value) { + if (value === undefined || value === null || value === '') { + return null; + } + const normalized = Number(value); + if (!Number.isSafeInteger(normalized) || normalized <= 0) { + return null; + } + return normalized; +} + +function buildProxyDisplayName(proxy = {}) { + const id = normalizeProxyId(proxy.id); + const name = String(proxy.name || '').trim(); + const protocol = String(proxy.protocol || '').trim(); + const host = String(proxy.host || '').trim(); + const port = proxy.port === undefined || proxy.port === null ? '' : String(proxy.port).trim(); + const address = protocol && host && port ? `${protocol}://${host}:${port}` : ''; + const parts = [ + name || '(未命名代理)', + id ? `#${id}` : '', + address, + ].filter(Boolean); + return parts.join(' '); +} + +function buildProxySearchText(proxy = {}) { + return [ + proxy.id, + proxy.name, + proxy.protocol, + proxy.host, + proxy.port, + buildProxyDisplayName(proxy), + ] + .filter((value) => value !== undefined && value !== null && value !== '') + .map((value) => String(value).trim().toLowerCase()) + .filter(Boolean) + .join(' '); +} + +function isActiveProxy(proxy = {}) { + const status = String(proxy.status || '').trim().toLowerCase(); + return !status || status === 'active'; +} + +function findSub2ApiProxy(proxies = [], preference = '') { + const activeProxies = (Array.isArray(proxies) ? proxies : []) + .filter(isActiveProxy) + .filter((proxy) => normalizeProxyId(proxy.id)); + const normalizedPreference = normalizeSub2ApiProxyPreference(preference).toLowerCase(); + const preferredId = normalizeProxyId(normalizedPreference); + + if (preferredId) { + const matchedById = activeProxies.find((proxy) => normalizeProxyId(proxy.id) === preferredId); + return { + proxy: matchedById || null, + reason: matchedById ? 'id' : 'missing-id', + candidates: activeProxies, + }; + } + + if (normalizedPreference) { + const exactMatches = activeProxies.filter((proxy) => { + const name = String(proxy.name || '').trim().toLowerCase(); + return name === normalizedPreference; + }); + if (exactMatches.length === 1) { + return { proxy: exactMatches[0], reason: 'name', candidates: activeProxies }; + } + if (exactMatches.length > 1) { + return { proxy: null, reason: 'ambiguous-name', candidates: exactMatches }; + } + + const fuzzyMatches = activeProxies.filter((proxy) => buildProxySearchText(proxy).includes(normalizedPreference)); + if (fuzzyMatches.length === 1) { + return { proxy: fuzzyMatches[0], reason: 'fuzzy', candidates: activeProxies }; + } + if (fuzzyMatches.length > 1) { + return { proxy: null, reason: 'ambiguous-fuzzy', candidates: fuzzyMatches }; + } + + return { proxy: null, reason: 'missing-name', candidates: activeProxies }; + } + + if (activeProxies.length === 1) { + return { proxy: activeProxies[0], reason: 'single-active', candidates: activeProxies }; + } + return { + proxy: null, + reason: activeProxies.length ? 'no-preference' : 'none-active', + candidates: activeProxies, + }; +} + +async function resolveSub2ApiProxy(origin, token, preference = '') { + const proxies = await requestJson(origin, '/api/v1/admin/proxies/all?with_count=true', { + method: 'GET', + token, + }); + if (!Array.isArray(proxies)) { + throw new Error('SUB2API 代理列表返回格式异常,无法自动选择代理。'); + } + + const { proxy, reason, candidates } = findSub2ApiProxy(proxies, preference); + if (proxy) { + return proxy; + } + + const configured = normalizeSub2ApiProxyPreference(preference) || '(未配置)'; + const available = (candidates || []) + .slice(0, 8) + .map(buildProxyDisplayName) + .join(',') || '无可用代理'; + if (reason === 'ambiguous-name' || reason === 'ambiguous-fuzzy') { + throw new Error(`SUB2API 默认代理“${configured}”匹配到多个代理,请改填代理 ID。候选:${available}`); + } + if (reason === 'missing-id') { + throw new Error(`SUB2API 默认代理 ID “${configured}”不存在或未启用。可用代理:${available}`); + } + if (reason === 'missing-name') { + throw new Error(`SUB2API 默认代理“${configured}”不存在或未启用。可用代理:${available}`); + } + if (reason === 'no-preference') { + throw new Error(`SUB2API 存在多个可用代理,请在侧边栏填写默认代理名称或 ID。可用代理:${available}`); + } + throw new Error('SUB2API 没有可用代理;当前流程要求账号必须绑定代理。'); +} + function buildDraftAccountName(groupName) { const prefix = (groupName || SUB2API_DEFAULT_GROUP_NAME) .trim() @@ -306,9 +450,13 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) { const { origin, token } = await loginSub2Api(payload); const group = await getGroupByName(origin, token, groupName); + const proxyPreference = resolveSub2ApiProxyPreference(payload); + const proxy = await resolveSub2ApiProxy(origin, token, proxyPreference); + const proxyId = normalizeProxyId(proxy.id); const draftName = buildDraftAccountName(group.name || groupName); log(`步骤 ${logStep}:已登录 SUB2API,使用分组 ${group.name}(#${group.id})。`); + log(`步骤 ${logStep}:已选择 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`); log(`步骤 ${logStep}:正在向 SUB2API 生成 OpenAI Auth 链接,回调地址为 ${redirectUri}。`); const authData = await requestJson(origin, '/api/v1/admin/openai/generate-auth-url', { @@ -316,6 +464,7 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) { token, body: { redirect_uri: redirectUri, + proxy_id: proxyId, }, }); @@ -334,6 +483,7 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) { sub2apiOAuthState: oauthState, sub2apiGroupId: group.id, sub2apiDraftName: draftName, + sub2apiProxyId: proxyId, }; if (report) { reportComplete(1, result); @@ -354,6 +504,10 @@ async function step9_submitOpenAiCallback(payload = {}) { || buildDraftAccountName(payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME); const { origin, token } = await loginSub2Api(payload); + const proxyPreference = resolveSub2ApiProxyPreference(payload, backgroundState); + const preferredProxyId = normalizeProxyId(payload.sub2apiProxyId || backgroundState.sub2apiProxyId); + const proxy = await resolveSub2ApiProxy(origin, token, preferredProxyId || proxyPreference); + const proxyId = normalizeProxyId(proxy.id); const group = payload.sub2apiGroupId ? { id: payload.sub2apiGroupId, name: payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME } : await getGroupByName(origin, token, payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME); @@ -366,6 +520,7 @@ async function step9_submitOpenAiCallback(payload = {}) { } log('步骤 10:正在向 SUB2API 交换 OpenAI 授权码...'); + log(`步骤 10:使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`); const exchangeData = await requestJson(origin, '/api/v1/admin/openai/exchange-code', { method: 'POST', token, @@ -373,6 +528,7 @@ async function step9_submitOpenAiCallback(payload = {}) { session_id: sessionId, code: callback.code, state: callback.state, + proxy_id: proxyId, }, }); @@ -391,6 +547,7 @@ async function step9_submitOpenAiCallback(payload = {}) { concurrency: SUB2API_DEFAULT_CONCURRENCY, priority: SUB2API_DEFAULT_PRIORITY, rate_multiplier: SUB2API_DEFAULT_RATE_MULTIPLIER, + proxy_id: proxyId, group_ids: [groupId], auto_pause_on_expired: true, }; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 47705e4..008e0f7 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -140,6 +140,11 @@ 分组 +
账户密码
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index bac22b2..5874b20 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -74,6 +74,8 @@ const rowSub2ApiPassword = document.getElementById('row-sub2api-password'); const inputSub2ApiPassword = document.getElementById('input-sub2api-password'); const rowSub2ApiGroup = document.getElementById('row-sub2api-group'); const inputSub2ApiGroup = document.getElementById('input-sub2api-group'); +const rowSub2ApiDefaultProxy = document.getElementById('row-sub2api-default-proxy'); +const inputSub2ApiDefaultProxy = document.getElementById('input-sub2api-default-proxy'); const selectMailProvider = document.getElementById('select-mail-provider'); const btnMailLogin = document.getElementById('btn-mail-login'); const rowMail2925Mode = document.getElementById('row-mail-2925-mode'); @@ -202,6 +204,8 @@ const VERIFICATION_RESEND_COUNT_MIN = 0; const VERIFICATION_RESEND_COUNT_MAX = 20; const DEFAULT_VERIFICATION_RESEND_COUNT = 4; const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit'; +const DEFAULT_CPA_CALLBACK_MODE = 'step8'; +const DEFAULT_SUB2API_PROXY_NAME = 'shadowrocket'; const MAIL_2925_MODE_PROVIDE = 'provide'; const MAIL_2925_MODE_RECEIVE = 'receive'; const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE; @@ -1306,6 +1310,7 @@ function collectSettingsPayload() { sub2apiEmail: inputSub2ApiEmail.value.trim(), sub2apiPassword: inputSub2ApiPassword.value, sub2apiGroupName: inputSub2ApiGroup.value.trim(), + sub2apiDefaultProxyName: inputSub2ApiDefaultProxy.value.trim() || DEFAULT_SUB2API_PROXY_NAME, customPassword: inputPassword.value, mailProvider: selectMailProvider.value, mail2925Mode: getSelectedMail2925Mode(), @@ -1676,6 +1681,7 @@ function applySettingsState(state) { inputSub2ApiEmail.value = state?.sub2apiEmail || ''; inputSub2ApiPassword.value = state?.sub2apiPassword || ''; inputSub2ApiGroup.value = state?.sub2apiGroupName || ''; + inputSub2ApiDefaultProxy.value = state?.sub2apiDefaultProxyName || DEFAULT_SUB2API_PROXY_NAME; const restoredMailProvider = isCustomMailProvider(state?.mailProvider) || [ICLOUD_PROVIDER, 'hotmail-api', GMAIL_PROVIDER, 'luckmail-api', '163', '163-vip', 'qq', 'inbucket', '2925', 'cloudflare-temp-email'].includes(String(state?.mailProvider || '').trim()) ? String(state?.mailProvider || '163').trim() @@ -2470,6 +2476,7 @@ function updatePanelModeUI() { rowSub2ApiEmail.style.display = useSub2Api ? '' : 'none'; rowSub2ApiPassword.style.display = useSub2Api ? '' : 'none'; rowSub2ApiGroup.style.display = useSub2Api ? '' : 'none'; + rowSub2ApiDefaultProxy.style.display = useSub2Api ? '' : 'none'; const step9Btn = document.querySelector('.step-btn[data-step-key="platform-verify"]'); if (step9Btn) { @@ -3701,6 +3708,14 @@ inputSub2ApiGroup.addEventListener('blur', () => { saveSettings({ silent: true }).catch(() => { }); }); +inputSub2ApiDefaultProxy.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); +}); +inputSub2ApiDefaultProxy.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => { }); +}); + inputEmailPrefix.addEventListener('input', () => { maybeClearGeneratedAliasAfterEmailPrefixChange().catch(() => { }); syncManagedAliasBaseEmailDraftFromInput(); diff --git a/tests/auto-run-add-phone-stop.test.js b/tests/auto-run-add-phone-stop.test.js new file mode 100644 index 0000000..af16ce7 --- /dev/null +++ b/tests/auto-run-add-phone-stop.test.js @@ -0,0 +1,167 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background/auto-run-controller.js', 'utf8'); +const globalScope = {}; +const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope); + +test('auto-run controller does not retry add-phone failures even when auto retry is enabled', async () => { + const events = { + logs: [], + broadcasts: [], + accountRecords: [], + runCalls: 0, + }; + + let currentState = { + stepStatuses: {}, + vpsUrl: 'https://example.com/vps', + vpsPassword: 'secret', + customPassword: '', + autoRunSkipFailures: true, + autoRunFallbackThreadIntervalMinutes: 0, + autoRunDelayEnabled: false, + autoRunDelayMinutes: 30, + autoStepDelaySeconds: null, + mailProvider: '163', + emailGenerator: 'duck', + gmailBaseEmail: '', + mail2925BaseEmail: '', + emailPrefix: 'demo', + inbucketHost: '', + inbucketMailbox: '', + cloudflareDomain: '', + cloudflareDomains: [], + tabRegistry: {}, + sourceLastUrls: {}, + autoRunRoundSummaries: [], + }; + + const runtime = { + state: { + autoRunActive: false, + autoRunCurrentRun: 0, + autoRunTotalRuns: 1, + autoRunAttemptRun: 0, + autoRunSessionId: 0, + }, + get() { + return { ...this.state }; + }, + set(updates = {}) { + this.state = { ...this.state, ...updates }; + }, + }; + + let sessionSeed = 0; + + const controller = api.createAutoRunController({ + addLog: async (message, level = 'info') => { + events.logs.push({ message, level }); + }, + appendAccountRunRecord: async (status, _state, reason) => { + events.accountRecords.push({ status, reason }); + return { status, reason }; + }, + AUTO_RUN_MAX_RETRIES_PER_ROUND: 3, + AUTO_RUN_RETRY_DELAY_MS: 3000, + AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry', + AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds', + broadcastAutoRunStatus: async (phase, payload = {}) => { + events.broadcasts.push({ phase, ...payload }); + currentState = { + ...currentState, + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase), + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun, + autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns, + autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun, + autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId, + }; + }, + broadcastStopToContentScripts: async () => {}, + cancelPendingCommands: () => {}, + clearStopRequest: () => {}, + createAutoRunSessionId: () => { + sessionSeed += 1; + return sessionSeed; + }, + getAutoRunStatusPayload: (phase, payload = {}) => ({ + autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase), + autoRunPhase: phase, + autoRunCurrentRun: payload.currentRun ?? 0, + autoRunTotalRuns: payload.totalRuns ?? 1, + autoRunAttemptRun: payload.attemptRun ?? 0, + autoRunSessionId: payload.sessionId ?? 0, + }), + getErrorMessage: (error) => error?.message || String(error || ''), + getFirstUnfinishedStep: () => 1, + getPendingAutoRunTimerPlan: () => null, + getRunningSteps: () => [], + getState: async () => ({ + ...currentState, + stepStatuses: { ...(currentState.stepStatuses || {}) }, + tabRegistry: { ...(currentState.tabRegistry || {}) }, + sourceLastUrls: { ...(currentState.sourceLastUrls || {}) }, + }), + getStopRequested: () => false, + hasSavedProgress: () => false, + isAddPhoneAuthFailure: (error) => /add-phone|手机号页面|手机号页|手机号码|手机号/i.test(error?.message || String(error || '')), + isRestartCurrentAttemptError: () => false, + isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。', + launchAutoRunTimerPlan: async () => false, + normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)), + persistAutoRunTimerPlan: async () => ({}), + resetState: async () => { + currentState = { + ...currentState, + stepStatuses: {}, + tabRegistry: {}, + sourceLastUrls: {}, + }; + }, + runAutoSequenceFromStep: async () => { + events.runCalls += 1; + throw new Error('步骤 8:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone'); + }, + runtime, + setState: async (updates = {}) => { + currentState = { + ...currentState, + ...updates, + stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses, + tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry, + sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls, + }; + }, + sleepWithStop: async () => {}, + throwIfAutoRunSessionStopped: (sessionId) => { + if (sessionId && sessionId !== runtime.state.autoRunSessionId) { + throw new Error('流程已被用户停止。'); + } + }, + waitForRunningStepsToFinish: async () => currentState, + chrome: { + runtime: { + sendMessage() { + return Promise.resolve(); + }, + }, + }, + }); + + await controller.autoRunLoop(1, { + autoRunSkipFailures: true, + mode: 'restart', + }); + + assert.equal(events.runCalls, 1, 'add-phone fatal failure should stop before the next auto attempt starts'); + assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'add-phone fatal failure should not enter retrying phase'); + assert.equal(events.accountRecords.length, 1, 'fatal add-phone should still persist a failed round record'); + assert.equal(events.accountRecords[0].status, 'failed'); + assert.match(events.accountRecords[0].reason, /add-phone/); + assert.ok(events.logs.some(({ message }) => /add-phone\/手机号页/.test(message))); + assert.equal(runtime.state.autoRunActive, false); + assert.equal(runtime.state.autoRunSessionId, 0); +}); diff --git a/tests/auto-run-step6-restart.test.js b/tests/auto-run-step6-restart.test.js index 0cde267..b643b0f 100644 --- a/tests/auto-run-step6-restart.test.js +++ b/tests/auto-run-step6-restart.test.js @@ -53,6 +53,7 @@ function extractFunction(name) { } const bundle = [ + extractFunction('isAddPhoneAuthFailure'), extractFunction('isAddPhoneAuthUrl'), extractFunction('isAddPhoneAuthState'), extractFunction('getPostStep6AutoRestartDecision'), @@ -198,6 +199,22 @@ test('auto-run stops restarting once add-phone is detected', async () => { assert.ok(result.events.logs.some(({ message }) => /进入 add-phone/.test(message))); }); +test('auto-run stops restarting on generic phone-page failure messages even without add-phone url', async () => { + const harness = createHarness({ + failureStep: 9, + failureBudget: 1, + failureMessage: '步骤 8:当前认证页进入手机号页面,当前流程无法继续自动授权。', + authState: { state: 'password_page', url: 'https://auth.openai.com/log-in' }, + }); + + const result = await harness.runAndCaptureError(); + + assert.ok(result?.error); + assert.equal(result.events.invalidations.length, 0); + assert.deepStrictEqual(result.events.steps, [7, 8, 9]); + assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message))); +}); + test('auto-run stop errors after step 7 are rethrown immediately instead of restarting', async () => { const harness = createHarness({ failureStep: 9, diff --git a/tests/background-account-history-settings.test.js b/tests/background-account-history-settings.test.js index a732000..3b91b03 100644 --- a/tests/background-account-history-settings.test.js +++ b/tests/background-account-history-settings.test.js @@ -61,6 +61,7 @@ const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373'; const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL; const DEFAULT_HOTMAIL_REMOTE_BASE_URL = ''; const DEFAULT_VERIFICATION_RESEND_COUNT = 4; +const DEFAULT_SUB2API_PROXY_NAME = 'shadowrocket'; const HOTMAIL_SERVICE_MODE_REMOTE = 'remote'; const HOTMAIL_SERVICE_MODE_LOCAL = 'local'; const VERIFICATION_RESEND_COUNT_MIN = 0; @@ -109,4 +110,12 @@ return { api.normalizeAccountRunHistoryHelperBaseUrl(''), 'http://127.0.0.1:17373' ); + assert.equal( + api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ''), + 'shadowrocket' + ); + assert.equal( + api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ' proxy-a '), + 'proxy-a' + ); }); diff --git a/tests/background-step7-recovery.test.js b/tests/background-step7-recovery.test.js index 4a1c3a9..75b39fe 100644 --- a/tests/background-step7-recovery.test.js +++ b/tests/background-step7-recovery.test.js @@ -139,3 +139,65 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => { assert.equal(capturedOptions.beforeSubmit, undefined); assert.equal(typeof capturedOptions.getRemainingTimeMs, 'function'); }); + +test('step 8 does not rerun step 7 when verification submit lands on add-phone', async () => { + const calls = { + executeStep7: 0, + logs: [], + }; + + const executor = api.createStep8Executor({ + addLog: async (message, level = 'info') => { + calls.logs.push({ message, level }); + }, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + confirmCustomVerificationStepBypass: async () => {}, + ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }), + executeStep7: async () => { + calls.executeStep7 += 1; + }, + getOAuthFlowRemainingMs: async () => 8000, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000), + getMailConfig: () => ({ + provider: 'qq', + label: 'QQ 邮箱', + source: 'mail-qq', + url: 'https://mail.qq.com', + navigateOnReuse: false, + }), + getState: async () => ({ email: 'user@example.com', password: 'secret' }), + getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2), + HOTMAIL_PROVIDER: 'hotmail-api', + isTabAlive: async () => true, + isVerificationMailPollingError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + resolveVerificationStep: async () => { + throw new Error('步骤 8:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone'); + }, + reuseOrCreateTab: async () => {}, + setState: async () => {}, + setStepStatus: async () => {}, + shouldUseCustomRegistrationEmail: () => false, + sleepWithStop: async () => {}, + STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, + STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8, + throwIfStopped: () => {}, + }); + + await assert.rejects( + () => executor.executeStep8({ + email: 'user@example.com', + password: 'secret', + oauthUrl: 'https://oauth.example/latest', + }), + /add-phone/ + ); + + assert.equal(calls.executeStep7, 0); + assert.ok(!calls.logs.some(({ message }) => /准备从步骤 7 重新开始/.test(message))); +}); diff --git a/tests/sidepanel-account-records-manager.test.js b/tests/sidepanel-account-records-manager.test.js index 74165ab..f89f508 100644 --- a/tests/sidepanel-account-records-manager.test.js +++ b/tests/sidepanel-account-records-manager.test.js @@ -82,6 +82,7 @@ test('sidepanel html contains account records overlay and manager script', () => assert.match(html, /id="account-records-list"/); assert.match(html, /id="account-records-stats"/); assert.match(html, /id="btn-clear-account-records"/); + assert.match(html, /id="input-sub2api-default-proxy"/); assert.notEqual(managerIndex, -1); assert.notEqual(sidepanelIndex, -1); assert.ok(managerIndex < sidepanelIndex); diff --git a/tests/sub2api-panel-proxy.test.js b/tests/sub2api-panel-proxy.test.js new file mode 100644 index 0000000..80f26d7 --- /dev/null +++ b/tests/sub2api-panel-proxy.test.js @@ -0,0 +1,178 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function createJsonResponse(payload, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(payload), + }; +} + +function createSub2ApiPanelContext(fetchCalls = []) { + const storage = new Map(); + const documentElement = { + attrs: new Map(), + getAttribute(name) { + return this.attrs.get(name) || null; + }, + setAttribute(name, value) { + this.attrs.set(name, String(value)); + }, + }; + + const context = { + URL, + console: { log() {} }, + setTimeout() {}, + document: { documentElement }, + location: { + href: 'https://sub.example/admin/accounts', + origin: 'https://sub.example', + pathname: '/admin/accounts', + replace() {}, + }, + chrome: { + runtime: { + onMessage: { addListener() {} }, + sendMessage: async () => ({ + email: 'flow@example.com', + sub2apiDefaultProxyName: 'shadowrocket', + }), + }, + }, + localStorage: { + setItem(key, value) { storage.set(`local:${key}`, String(value)); }, + removeItem(key) { storage.delete(`local:${key}`); }, + }, + sessionStorage: { + removeItem(key) { storage.delete(`session:${key}`); }, + }, + log() {}, + reportComplete(step, payload) { + context.completed.push({ step, payload }); + }, + reportReady() {}, + reportError() {}, + resetStopState() {}, + throwIfStopped() {}, + isStopError() { return false; }, + completed: [], + fetch: async (url, options = {}) => { + const parsed = new URL(url); + const body = options.body ? JSON.parse(options.body) : null; + fetchCalls.push({ path: parsed.pathname, search: parsed.search, method: options.method || 'GET', body }); + + if (parsed.pathname === '/api/v1/auth/login') { + return createJsonResponse({ + code: 0, + data: { + access_token: 'admin-token', + refresh_token: 'refresh-admin', + expires_in: 3600, + user: { id: 1 }, + }, + }); + } + if (parsed.pathname === '/api/v1/admin/groups/all') { + return createJsonResponse({ + code: 0, + data: [{ id: 5, name: 'codex', platform: 'openai' }], + }); + } + if (parsed.pathname === '/api/v1/admin/proxies/all') { + return createJsonResponse({ + code: 0, + data: [{ + id: 7, + name: 'shadowrocket', + protocol: 'socks5', + host: '127.0.0.1', + port: 1080, + status: 'active', + }], + }); + } + if (parsed.pathname === '/api/v1/admin/openai/generate-auth-url') { + return createJsonResponse({ + code: 0, + data: { + auth_url: 'https://auth.openai.com/oauth?state=oauth-state', + session_id: 'session-1', + state: 'oauth-state', + }, + }); + } + if (parsed.pathname === '/api/v1/admin/openai/exchange-code') { + return createJsonResponse({ + code: 0, + data: { + access_token: 'openai-access', + refresh_token: 'openai-refresh', + expires_at: 1770000000, + email: 'flow@example.com', + }, + }); + } + if (parsed.pathname === '/api/v1/admin/accounts') { + return createJsonResponse({ + code: 0, + data: { id: 11 }, + }); + } + + return createJsonResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404); + }, + }; + + vm.createContext(context); + vm.runInContext(fs.readFileSync('content/sub2api-panel.js', 'utf8'), context); + return context; +} + +test('SUB2API step 1 selects the configured default proxy before generating OAuth URL', async () => { + const fetchCalls = []; + const context = createSub2ApiPanelContext(fetchCalls); + + const result = await vm.runInContext(` + step1_generateOpenAiAuthUrl({ + sub2apiEmail: 'admin@example.com', + sub2apiPassword: 'secret', + sub2apiGroupName: 'codex', + sub2apiDefaultProxyName: 'shadowrocket' + }, { report: false }) + `, context); + + const generateCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/generate-auth-url'); + assert.equal(result.sub2apiProxyId, 7); + assert.equal(generateCall.body.proxy_id, 7); +}); + +test('SUB2API step 10 uses the same proxy for code exchange and account creation', async () => { + const fetchCalls = []; + const context = createSub2ApiPanelContext(fetchCalls); + + await vm.runInContext(` + step9_submitOpenAiCallback({ + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + sub2apiUrl: 'https://sub.example/admin/accounts', + sub2apiEmail: 'admin@example.com', + sub2apiPassword: 'secret', + sub2apiGroupName: 'codex', + sub2apiSessionId: 'session-1', + sub2apiOAuthState: 'oauth-state', + sub2apiGroupId: 5, + sub2apiProxyId: 7 + }) + `, context); + + const exchangeCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/exchange-code'); + const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts'); + + assert.equal(exchangeCall.body.proxy_id, 7); + assert.equal(createCall.body.proxy_id, 7); + assert.equal(createCall.body.group_ids[0], 5); + assert.equal(context.completed[0].step, 10); +}); diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 7a279da..4769263 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -109,6 +109,71 @@ test('verification flow runs beforeSubmit hook before filling the code', async ( ]); }); +test('verification flow treats add-phone after login code submit as fatal instead of completing step 8', async () => { + const events = []; + + const helpers = api.createVerificationFlowHelpers({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + completeStepFromBackground: async (_step, payload) => { + events.push(['complete', payload.code]); + }, + 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') { + events.push(['submit', message.payload.code]); + return { + addPhonePage: true, + url: 'https://auth.openai.com/add-phone', + }; + } + return {}; + }, + sendToMailContentScriptResilient: async () => ({ + code: '654321', + emailTimestamp: 123, + }), + setState: async (payload) => { + events.push(['state', payload.lastLoginCode || payload.lastSignupCode]); + }, + setStepStatus: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + }); + + await assert.rejects( + () => helpers.resolveVerificationStep( + 8, + { email: 'user@example.com', lastLoginCode: null }, + { provider: 'qq', label: 'QQ 邮箱' }, + {} + ), + /验证码提交后页面进入手机号页面/ + ); + + assert.deepStrictEqual(events, [ + ['submit', '654321'], + ]); +}); + test('verification flow caps mail polling timeout to the remaining oauth budget', async () => { const mailPollCalls = []; diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 27e83fe..070c067 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -314,8 +314,9 @@ 2. 打开邮箱页或 API 轮询入口 3. 轮询登录验证码 4. 回填登录验证码 -5. 如遇邮箱轮询类失败,则按有限次数回到 Step 7 重试 -6. 获取到登录验证码后不再触发“刷新 OAuth 并重走 Step 7”的前置回放,直接在当前验证码页提交并继续进入 Step 9 +5. 如果登录验证码提交后页面进入 `add-phone / 手机号页`,则立即判为 fatal 错误,不再把步骤 8 视为成功 +6. 如遇邮箱轮询类失败或显式的 `STEP8_RESTART_STEP7` 恢复错误,则按有限次数回到 Step 7 重试 +7. 获取到登录验证码后不再触发“刷新 OAuth 并重走 Step 7”的前置回放,直接在当前验证码页提交并继续进入 Step 9 ### Step 9 @@ -449,7 +450,9 @@ 4. 每轮执行前重置必要运行态 5. 执行 `runAutoSequenceFromStep` - 步骤 7 内部仍保留登录态恢复的有限重试,但 `add-phone / 手机号页` 属于立即跳出的不可重试错误 + - 步骤 8 若在验证码提交后进入 `add-phone / 手机号页`,会直接抛出 fatal 错误,不再先标记步骤成功 - 一旦进入步骤 7~10,遇到普通报错且认证流程未进入 `add-phone`,则自动回到步骤 7 无限重开 + - 如果命中 `add-phone / 手机号页` 这类 fatal 错误,则不仅不会回到步骤 7 重开,也不会进入 controller 的下一次自动重试 attempt - 如果是手动停止,则立即退出自动流程,不会再触发“回到步骤 7 重开” 6. 如果失败,根据设置决定: - 立即停止 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 1a72531..8a094ff 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -43,13 +43,13 @@ - `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,支持清理记录,并在启用独立本地同步配置后把完整快照同步到本地 helper。 - `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail` 与 `mail2925BaseEmail`,避免自动流程重置后丢失别名基邮箱配置。 - `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口。 -- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层。 +- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定。 - `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息。 - `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API 地址、步骤跳转相关判断。 - `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。 - `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail / 2925 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成。 - `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。 -- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数。 +- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;步骤 8 提交登录验证码后若页面进入 `add-phone / 手机号页`,会直接抛出 fatal 错误而不是把当前步骤视为成功。 ## `background/steps/` @@ -124,7 +124,8 @@ - `tests/auth-page-recovery.test.js`:测试认证页共享恢复层的重试页识别与恢复行为。 - `tests/auto-run-fresh-attempt-reset.test.js`:测试自动运行在新一轮开始前会重置旧运行时上下文,并补充 `gmailBaseEmail` / `mail2925BaseEmail` 在 fresh reset 后仍被保留的回归验证。 - `tests/auto-run-timer-session-guard.test.js`:测试自动运行的旧计时计划在 Stop 使 session 失效后,不能再把已停止的自动流程重新拉起。 -- `tests/auto-run-step6-restart.test.js`:测试自动运行在后半段授权链路遇错时会回到步骤 7 重开,并在命中 add-phone 时停止重开。 +- `tests/auto-run-add-phone-stop.test.js`:测试自动运行控制器在开启自动重试时,遇到 `add-phone / 手机号页` 也会立即停止,不会再开新 attempt。 +- `tests/auto-run-step6-restart.test.js`:测试自动运行在后半段授权链路遇错时会回到步骤 7 重开,并在命中 add-phone 或泛化手机号页 fatal 错误时停止重开。 - `tests/auto-step-random-delay.test.js`:测试自动运行步间延迟与旧配置键兼容解析。 - `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。 - `tests/background-account-run-history-module.test.js`:测试邮箱记录模块已接入、导出工厂,能够保留并归一化停止记录、归一化失败标签、计算自动重试次数,并支持清理后同步完整快照。 @@ -179,4 +180,4 @@ - `tests/step9-localhost-cleanup-scope.test.js`:测试 localhost callback 与路径前缀残留页的精确清理范围,当前对应步骤 10 收尾。 - `tests/step9-status-diagnostics.test.js`:测试平台回调成功判定、红色错误态过滤,以及成功徽标与失败提示并存时的优先级,当前对应步骤 10。 - `tests/verification-stop-propagation.test.js`:测试验证码流程在 Stop 场景下的错误传播与不中途降级。 -- `tests/verification-flow-polling.test.js`:测试 2925 长轮询参数,以及验证码提交流程中的 `beforeSubmit` 钩子执行顺序。 +- `tests/verification-flow-polling.test.js`:测试 2925 长轮询参数、验证码提交流程中的 `beforeSubmit` 钩子执行顺序,以及步骤 8 提交验证码后进入手机号页时不会误判成功。