diff --git a/background/mail-2925-session.js b/background/mail-2925-session.js index 07ab952..410fa97 100644 --- a/background/mail-2925-session.js +++ b/background/mail-2925-session.js @@ -168,6 +168,10 @@ } } + function normalizeMailboxEmail(value = '') { + return String(value || '').trim().toLowerCase(); + } + async function setCurrentMail2925Account(accountId, options = {}) { const { logMessage = '', updateLastUsedAt = false } = options; const state = await getState(); @@ -401,10 +405,13 @@ forceRelogin = false, actionLabel = '确保 2925 邮箱登录态', allowLoginWhenOnLoginPage = true, + expectedMailboxEmail = '', } = options; + const normalizedExpectedMailboxEmail = normalizeMailboxEmail(expectedMailboxEmail); + let account = null; - if (forceRelogin) { + if (forceRelogin || (allowLoginWhenOnLoginPage && normalizedExpectedMailboxEmail)) { account = await ensureMail2925AccountForFlow({ allowAllocate: true, preferredAccountId: accountId, @@ -481,16 +488,16 @@ } } - if (!forceRelogin && !isMail2925LoginUrl(openedUrl)) { + if (!forceRelogin && !isMail2925LoginUrl(openedUrl) && !normalizedExpectedMailboxEmail) { await addLog('2925:当前邮箱页未跳转到登录页,将直接复用已登录会话。', 'info'); return buildSuccessPayload(); } - if (!forceRelogin && !allowLoginWhenOnLoginPage) { + if (!forceRelogin && isMail2925LoginUrl(openedUrl) && !allowLoginWhenOnLoginPage) { await failMailboxSession(`2925:${actionLabel}失败,当前页面已跳转到登录页,且当前未启用 2925 账号池,不执行自动登录。`); } - if (!account) { + if (!account && (forceRelogin || allowLoginWhenOnLoginPage)) { account = await ensureMail2925AccountForFlow({ allowAllocate: true, preferredAccountId: accountId, @@ -523,9 +530,10 @@ step: 0, source: 'background', payload: { - email: account.email, - password: account.password, + email: account?.email || '', + password: account?.password || '', forceLogin: forceRelogin, + allowLoginWhenOnLoginPage, }, }, { @@ -550,6 +558,28 @@ if (result?.limitReached) { throw new Error(`${MAIL2925_LIMIT_ERROR_PREFIX}${result.limitMessage || '子邮箱已达上限邮箱'}`); } + const actualMailboxEmail = normalizeMailboxEmail(result?.mailboxEmail || ''); + if (normalizedExpectedMailboxEmail && actualMailboxEmail && actualMailboxEmail !== normalizedExpectedMailboxEmail) { + if (allowLoginWhenOnLoginPage) { + await addLog( + `2925:当前邮箱页显示账号 ${actualMailboxEmail},与目标账号 ${normalizedExpectedMailboxEmail} 不一致,准备登出当前账号并登录目标账号。`, + 'warn' + ); + return ensureMail2925MailboxSession({ + accountId: account?.id || accountId || null, + forceRelogin: true, + allowLoginWhenOnLoginPage: true, + expectedMailboxEmail: normalizedExpectedMailboxEmail, + actionLabel, + }); + } + await failMailboxSession( + `2925:${actionLabel}失败,当前邮箱页显示账号 ${actualMailboxEmail},与目标账号 ${normalizedExpectedMailboxEmail} 不一致,且当前未启用 2925 账号池。` + ); + } + if (normalizedExpectedMailboxEmail && !actualMailboxEmail && result?.currentView === 'mailbox') { + await addLog('2925:未能识别当前邮箱页顶部邮箱地址,已跳过邮箱一致性校验。', 'warn'); + } if (!result?.loggedIn) { await failMailboxSession(`2925:${actionLabel}失败,登录后仍未进入收件箱。`); } diff --git a/background/steps/fetch-signup-code.js b/background/steps/fetch-signup-code.js index c1d9124..066baee 100644 --- a/background/steps/fetch-signup-code.js +++ b/background/steps/fetch-signup-code.js @@ -24,6 +24,20 @@ throwIfStopped, } = deps; + function getExpectedMail2925MailboxEmail(state = {}) { + if (Boolean(state?.mail2925UseAccountPool)) { + const currentAccountId = String(state?.currentMail2925AccountId || '').trim(); + const accounts = Array.isArray(state?.mail2925Accounts) ? state.mail2925Accounts : []; + const currentAccount = accounts.find((account) => String(account?.id || '') === currentAccountId) || null; + const accountEmail = String(currentAccount?.email || '').trim().toLowerCase(); + if (accountEmail) { + return accountEmail; + } + } + + return String(state?.mail2925BaseEmail || '').trim().toLowerCase(); + } + async function focusOrOpenMailTab(mail) { const alive = await isTabAlive(mail.source); if (alive) { @@ -111,6 +125,7 @@ accountId: state.currentMail2925AccountId || null, forceRelogin: false, allowLoginWhenOnLoginPage: Boolean(state?.mail2925UseAccountPool), + expectedMailboxEmail: getExpectedMail2925MailboxEmail(state), actionLabel: '步骤 4:确认 2925 邮箱登录态', }); } else { diff --git a/content/mail-2925.js b/content/mail-2925.js index 0bbb977..09e9581 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -515,8 +515,9 @@ function detectMail2925ViewState() { return { view: 'limit', limitMessage }; } - if (findMailItems().length > 0) { - return { view: 'mailbox', limitMessage: '' }; + const mailboxEmail = getMail2925DisplayedMailboxEmail(); + if (findMailItems().length > 0 || mailboxEmail) { + return { view: 'mailbox', limitMessage: '', mailboxEmail }; } if (findMail2925LoginPasswordInput() && findMail2925LoginEmailInput()) { @@ -531,6 +532,62 @@ function detectMail2925ViewState() { return { view: 'unknown', limitMessage: '' }; } +function getMail2925DisplayedMailboxEmail() { + const directSelectors = [ + '[class*="user"] [class*="mail"]', + '[class*="user"] [class*="email"]', + '[class*="account"] [class*="mail"]', + '[class*="account"] [class*="email"]', + '[class*="header"] [class*="mail"]', + '[class*="header"] [class*="email"]', + ]; + + for (const selector of directSelectors) { + const candidates = document.querySelectorAll(selector); + for (const candidate of candidates) { + if (!isVisibleNode(candidate) || isMailItemNode(candidate)) { + continue; + } + const email = extractEmails(candidate.textContent || candidate.innerText || '')[0] || ''; + if (email) { + return email; + } + } + } + + const topCandidates = Array.from(document.querySelectorAll('body *')) + .filter((node) => { + if (!isVisibleNode(node) || isMailItemNode(node)) { + return false; + } + const rect = typeof node.getBoundingClientRect === 'function' + ? node.getBoundingClientRect() + : null; + if (!rect) return false; + return rect.top >= 0 && rect.top <= Math.max(window.innerHeight * 0.35, 280); + }) + .map((node) => { + const email = extractEmails(node.textContent || node.innerText || '')[0] || ''; + return { node, email }; + }) + .filter((entry) => entry.email); + + if (!topCandidates.length) { + return ''; + } + + topCandidates.sort((left, right) => { + const leftRect = left.node.getBoundingClientRect(); + const rightRect = right.node.getBoundingClientRect(); + if (leftRect.top !== rightRect.top) { + return leftRect.top - rightRect.top; + } + return leftRect.left - rightRect.left; + }); + + return topCandidates[0]?.email || ''; +} + function isCheckboxChecked(node) { const checkbox = node?.matches?.('input[type="checkbox"], [role="checkbox"]') ? node @@ -873,6 +930,7 @@ async function ensureMail2925Session(payload = {}) { const email = String(payload?.email || '').trim(); const password = String(payload?.password || ''); const forceLogin = Boolean(payload?.forceLogin); + const allowLoginWhenOnLoginPage = payload?.allowLoginWhenOnLoginPage !== false; log(`步骤 0:2925 登录态检查开始,当前地址 ${location.href},forceLogin=${forceLogin ? 'true' : 'false'}`, 'info'); for (let attempt = 0; attempt < 10; attempt += 1) { @@ -893,9 +951,19 @@ async function ensureMail2925Session(payload = {}) { ok: true, loggedIn: true, currentView: 'mailbox', + mailboxEmail: currentState.mailboxEmail || '', }; } if (currentState.view === 'login') { + if (!forceLogin && !allowLoginWhenOnLoginPage) { + return { + ok: false, + loggedIn: false, + currentView: 'login', + requiresLogin: true, + mailboxEmail: '', + }; + } break; } await sleep(500); @@ -908,6 +976,7 @@ async function ensureMail2925Session(payload = {}) { ok: true, loggedIn: true, currentView: 'mailbox', + mailboxEmail: loginState.mailboxEmail || '', }; } if (loginState.view === 'limit') { @@ -919,6 +988,15 @@ async function ensureMail2925Session(payload = {}) { limitMessage: loginState.limitMessage, }; } + if (!forceLogin && !allowLoginWhenOnLoginPage && loginState.view === 'login') { + return { + ok: false, + loggedIn: false, + currentView: 'login', + requiresLogin: true, + mailboxEmail: '', + }; + } const emailInput = findMail2925LoginEmailInput(); const passwordInput = findMail2925LoginPasswordInput(); @@ -951,6 +1029,7 @@ async function ensureMail2925Session(payload = {}) { loggedIn: true, currentView: 'mailbox', usedCredentials: true, + mailboxEmail: finalState.mailboxEmail || getMail2925DisplayedMailboxEmail() || '', }; } diff --git a/tests/background-mail2925-entry-behavior.test.js b/tests/background-mail2925-entry-behavior.test.js index 58fb798..cd9cc07 100644 --- a/tests/background-mail2925-entry-behavior.test.js +++ b/tests/background-mail2925-entry-behavior.test.js @@ -266,3 +266,163 @@ test('ensureMail2925MailboxSession logs in when login page is detected and accou assert.equal(readyCalls, 1); assert.equal(result.result.loggedIn, true); }); + +test('ensureMail2925MailboxSession relogs with selected account when mailbox page email mismatches and pool is on', async () => { + let currentState = { + autoRunning: false, + mail2925UseAccountPool: true, + mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([ + { id: 'acc-1', email: 'target@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 }, + ]), + currentMail2925AccountId: 'acc-1', + }; + const openedUrls = []; + const sendPayloads = []; + + const manager = api.createMail2925SessionManager({ + addLog: async () => {}, + broadcastDataUpdate: () => {}, + chrome: { + tabs: { + get: async () => ({ id: 9, url: openedUrls.at(-1) || 'https://2925.com/#/mailList' }), + }, + cookies: { + getAll: async () => [], + remove: async () => ({ ok: true }), + }, + browsingData: { + removeCookies: async () => {}, + }, + }, + ensureContentScriptReadyOnTab: async () => {}, + findMail2925Account: mail2925Utils.findMail2925Account, + getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus, + getState: async () => currentState, + isAutoRunLockedState: () => false, + isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable, + MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS, + normalizeMail2925Account: mail2925Utils.normalizeMail2925Account, + normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts, + pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun, + reuseOrCreateTab: async (_source, url) => { + openedUrls.push(url); + return 9; + }, + sendToMailContentScriptResilient: async (_mail, message) => { + sendPayloads.push(message.payload); + if (sendPayloads.length === 1) { + return { + loggedIn: true, + currentView: 'mailbox', + mailboxEmail: 'wrong@2925.com', + }; + } + return { + loggedIn: true, + currentView: 'mailbox', + mailboxEmail: 'target@2925.com', + }; + }, + setPersistentSettings: async (payload) => { + currentState = { ...currentState, ...payload }; + }, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList, + waitForTabUrlMatch: async () => ({ url: 'https://2925.com/login/' }), + }); + + const result = await manager.ensureMail2925MailboxSession({ + accountId: 'acc-1', + forceRelogin: false, + allowLoginWhenOnLoginPage: true, + expectedMailboxEmail: 'target@2925.com', + actionLabel: '步骤 4:确认 2925 邮箱登录态', + }); + + assert.equal(result.result.loggedIn, true); + assert.deepStrictEqual(openedUrls, [ + 'https://2925.com/#/mailList', + 'https://2925.com/login/', + ]); + assert.equal(sendPayloads.length, 2); + assert.equal(sendPayloads[0].allowLoginWhenOnLoginPage, true); + assert.equal(sendPayloads[1].forceLogin, true); +}); + +test('ensureMail2925MailboxSession stops when mailbox page email mismatches and pool is off', async () => { + let currentState = { + autoRunning: true, + autoRunPhase: 'running', + mail2925UseAccountPool: false, + mail2925Accounts: [], + currentMail2925AccountId: null, + }; + const stopCalls = []; + let sendCalls = 0; + + const manager = api.createMail2925SessionManager({ + addLog: async () => {}, + broadcastDataUpdate: () => {}, + chrome: { + tabs: { + get: async () => ({ id: 9, url: 'https://2925.com/#/mailList' }), + }, + cookies: { + getAll: async () => [], + remove: async () => ({ ok: true }), + }, + browsingData: { + removeCookies: async () => {}, + }, + }, + ensureContentScriptReadyOnTab: async () => {}, + findMail2925Account: mail2925Utils.findMail2925Account, + getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus, + getState: async () => currentState, + isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running', + isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable, + MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS, + normalizeMail2925Account: mail2925Utils.normalizeMail2925Account, + normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts, + pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun, + requestStop: async (options = {}) => { + stopCalls.push(options); + }, + reuseOrCreateTab: async () => 9, + sendToMailContentScriptResilient: async () => { + sendCalls += 1; + return { + loggedIn: true, + currentView: 'mailbox', + mailboxEmail: 'wrong@2925.com', + }; + }, + setPersistentSettings: async (payload) => { + currentState = { ...currentState, ...payload }; + }, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + throwIfStopped: () => {}, + upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList, + }); + + await assert.rejects( + () => manager.ensureMail2925MailboxSession({ + accountId: null, + forceRelogin: false, + allowLoginWhenOnLoginPage: false, + expectedMailboxEmail: 'target@2925.com', + actionLabel: '步骤 4:确认 2925 邮箱登录态', + }), + /流程已被用户停止。/ + ); + + assert.equal(sendCalls, 1); + assert.equal(stopCalls.length, 1); + assert.match(stopCalls[0].logMessage, /与目标账号 target@2925\.com 不一致/); +}); diff --git a/tests/mail-2925-content.test.js b/tests/mail-2925-content.test.js index ddbe65d..e36e182 100644 --- a/tests/mail-2925-content.test.js +++ b/tests/mail-2925-content.test.js @@ -12,6 +12,61 @@ test('ensureMail2925Session waits 1 second after filling credentials before clic assert.match(source, /fillInput\(passwordInput,\s*password\);[\s\S]*?await sleep\(200\);[\s\S]*?await sleep\(1000\);[\s\S]*?simulateClick\(loginButton\);/); }); +test('detectMail2925ViewState treats top mailbox email as mailbox view', () => { + const bundle = [ + extractFunction('normalizeNodeText'), + extractFunction('isVisibleNode'), + extractFunction('isMailItemNode'), + extractFunction('resolveActionTarget'), + extractFunction('findMailItems'), + extractFunction('extractEmails'), + extractFunction('getMail2925DisplayedMailboxEmail'), + extractFunction('detectMail2925ViewState'), + ].join('\n'); + + const api = new Function(` +const MAIL_ITEM_SELECTORS = ['.mail-item']; +const MAIL_ITEM_SELECTOR_GROUP = '.mail-item'; +const MAIL2925_REMEMBER_LOGIN_PATTERNS = []; +const MAIL2925_AGREEMENT_PATTERNS = []; +const document = { + querySelectorAll(selector) { + if (selector === '.mail-item') return []; + if (selector === 'body *') return [headerEmail]; + if (selector.includes('[class*="user"]')) return [headerEmail]; + return []; + }, + body: { + innerText: 'QLHazycoder qlhazycoder@2925.com', + textContent: 'QLHazycoder qlhazycoder@2925.com', + }, +}; +const window = { + innerHeight: 900, + getComputedStyle() { + return { display: 'block', visibility: 'visible' }; + }, +}; +const headerEmail = { + hidden: false, + textContent: 'qlhazycoder@2925.com', + innerText: 'qlhazycoder@2925.com', + getBoundingClientRect() { return { top: 40, left: 400, width: 120, height: 20 }; }, + closest() { return null; }, +}; +function detectMail2925LimitMessage() { return ''; } +function findMail2925LoginPasswordInput() { return null; } +function findMail2925LoginEmailInput() { return null; } +function getPageTextSample() { return 'qlhazycoder@2925.com'; } +${bundle} +return { detectMail2925ViewState }; +`)(); + + const state = api.detectMail2925ViewState(); + assert.equal(state.view, 'mailbox'); + assert.equal(state.mailboxEmail, 'qlhazycoder@2925.com'); +}); + function extractFunction(name) { const markers = [`async function ${name}(`, `function ${name}(`]; const start = markers diff --git a/tests/sidepanel-mail2925-base-email.test.js b/tests/sidepanel-mail2925-base-email.test.js index 5e24210..8b17251 100644 --- a/tests/sidepanel-mail2925-base-email.test.js +++ b/tests/sidepanel-mail2925-base-email.test.js @@ -176,6 +176,8 @@ const inputAccountRunHistoryHelperBaseUrl = { value: '' }; const inputMail2925UseAccountPool = { checked: true }; const inputInbucketHost = { value: '' }; const inputInbucketMailbox = { value: '' }; +const inputHotmailRemoteBaseUrl = { value: '' }; +const inputHotmailLocalBaseUrl = { value: '' }; const inputLuckmailApiKey = { value: '' }; const inputLuckmailBaseUrl = { value: '' }; const selectLuckmailEmailType = { value: 'ms_graph' }; @@ -184,6 +186,13 @@ const inputTempEmailBaseUrl = { value: '' }; const inputTempEmailAdminAuth = { value: '' }; const inputTempEmailCustomAuth = { value: '' }; const inputTempEmailReceiveMailbox = { value: '' }; +const inputAutoSkipFailures = { checked: false }; +const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' }; +const inputAutoDelayEnabled = { checked: false }; +const inputAutoDelayMinutes = { value: '30' }; +const inputAutoStepDelaySeconds = { value: '' }; +const inputVerificationResendCount = { value: '4' }; +const DEFAULT_VERIFICATION_RESEND_COUNT = 4; function getCloudflareDomainsFromState() { return { domains: [], activeDomain: '' }; } @@ -203,6 +212,10 @@ function normalizeLuckmailEmailType(value) { return String(value || '').trim() | function normalizeCloudflareTempEmailBaseUrlValue(value) { return String(value || '').trim(); } function normalizeCloudflareTempEmailReceiveMailboxValue(value) { return String(value || '').trim(); } function normalizeAccountRunHistoryHelperBaseUrlValue(value) { return String(value || '').trim(); } +function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; } +function normalizeAutoDelayMinutes(value) { return Number(value) || 30; } +function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); } +function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; } ${bundle} return { collectSettingsPayload }; `)(); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 6183c2d..889d113 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -332,7 +332,7 @@ 补充行为: - `2925` provider 会关闭 Step 4 / 8 的自动重发间隔 25 秒节流;每次“重新发送验证码”之间,会在邮箱页内部执行一轮固定 15 次的刷新轮询,不再因 OAuth 剩余时间预算而缩短。 -- 当 provider 为 `2925` 时,Step 4 会优先直接打开当前 2925 邮箱页:如果页面仍停留在收件箱,就直接复用当前已登录页面;如果页面已经跳到登录页,则只有在启用了 2925 账号池时才会自动登录,未启用账号池时会直接复用现有停止逻辑结束流程。Step 8 不再额外承接这套登录态处理。 +- 当 provider 为 `2925` 时,Step 4 会优先直接打开当前 2925 邮箱页,并先比对页面顶部显示的邮箱地址是否与当前目标邮箱一致:如果一致,就直接复用当前已登录页面;如果不一致且启用了 2925 账号池,则会先清理 cookie 再登录当前选中的账号;如果不一致且未启用账号池,则直接复用现有停止逻辑结束流程。Step 8 不再额外承接这套登录态处理。 - 普通邮箱仍会携带 `filterAfterTimestamp` 做时间窗筛选;`2925` 当前既不依赖时间窗,也不再做“新旧邮件快照差集”比较,而是每次刷新后直接遍历当前列表中的匹配邮件。 - 自动重新发送验证码次数现在使用 sidepanel 里的单一“验证码重发”配置;普通邮箱仍按 25 秒间隔节流,Hotmail / 2925 不走这个 25 秒间隔。Step 4 若启用先请求新验证码,会先消耗一次当前步骤的自动重发次数。 - 验证码提交重试上限当前为 15 次;页面明确拒绝验证码时,会在上限内继续拉取新验证码并重提。 @@ -560,7 +560,7 @@ 1. 用户在 sidepanel 的 2925 账号池中保存 `email / password` 2. sidepanel 中会单独展示 `提供邮箱 / 接收邮箱` 模式切换,以及独立的 `2925 号池` 开关 / 当前账号下拉框;这样即使切到 receive 模式,账号池设置也不会被别名基邮箱行一起隐藏 3. 只有当 sidepanel 中的 `mail2925UseAccountPool` 开关开启时,provide 模式下的别名基邮箱才会优先取当前账号池选中的 2925 账号邮箱;关闭时会回退到原来的手填 `mail2925BaseEmail` -4. 手动点击 `登录` 或自动流程进入 Step 4 前,后台会先打开当前 2925 邮箱页:如果仍停留在收件箱则直接复用;如果跳到登录页,则仅在号池模式开启时才自动登录,关闭号池时直接调用现有停止逻辑结束流程 +4. 手动点击 `登录` 或自动流程进入 Step 4 前,后台会先打开当前 2925 邮箱页,并读取页面顶部当前邮箱地址:如果仍停留在收件箱且顶部邮箱与目标邮箱一致,则直接复用;如果顶部邮箱不一致且启用了号池模式,则先清理 cookie 后登录当前选中的账号;如果顶部邮箱不一致且未启用号池模式,则直接调用现有停止逻辑结束流程;如果页面跳到登录页,则仍然只有号池模式开启时才自动登录 5. 一旦轮询期间出现“子邮箱已达上限邮箱”,后台会先判断是否启用了号池模式:若已启用且还有下一个可用账号,则把当前账号禁用 24 小时并自动切到下一个账号重新登录;若未启用,则直接调用现有停止逻辑结束流程 6. 如果登录页已经识别到账号密码输入框,内容脚本会在填完账号密码后额外等待 1 秒再点击登录;若点击登录后 40 秒内仍未进入收件箱,且当前正处于自动运行中,则后台会直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态;这类情况常见于图片验证、行为验证或其他阻断登录的中间页 7. 如果没有下一个可用账号,或当前未启用号池模式,则不会继续消耗自动重试次数,而是直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态