diff --git a/README.md b/README.md index e87ebfe..523fb4c 100644 --- a/README.md +++ b/README.md @@ -605,7 +605,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 这一步只负责等待注册完成后的页面状态稳定: - 固定等待 20 秒 -- 不再清理 `chatgpt.com / openai.com` 相关 cookies +- 默认不清理 `chatgpt.com / openai.com` 相关 cookies;侧栏开启第六步 `清 Cookies` 后才会在等待结束后执行清理 - 等待完成后直接进入后续 OAuth 登录链路 diff --git a/background.js b/background.js index a50e985..d8b0884 100644 --- a/background.js +++ b/background.js @@ -253,7 +253,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_CODEX2API_URL = 'http://localhost:8080/admin/accounts'; -const DEFAULT_GPC_HELPER_API_URL = 'https://gopay.hwork.pro'; +const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top'; const DEFAULT_SUB2API_GROUP_NAME = 'codex'; const DEFAULT_SUB2API_PROXY_NAME = ''; const DEFAULT_SUB2API_ACCOUNT_PRIORITY = 1; @@ -621,6 +621,7 @@ const PERSISTED_SETTING_DEFAULTS = { gopayOtp: '', gopayPin: '', gopayHelperApiUrl: DEFAULT_GPC_HELPER_API_URL, + gopayHelperApiKey: '', gopayHelperCardKey: '', gopayHelperPhoneNumber: '', gopayHelperCountryCode: '+86', @@ -637,6 +638,18 @@ const PERSISTED_SETTING_DEFAULTS = { gopayHelperFlowId: '', gopayHelperChallengeId: '', gopayHelperStartPayload: null, + gopayHelperTaskId: '', + gopayHelperTaskStatus: '', + gopayHelperStatusText: '', + gopayHelperRemoteStage: '', + gopayHelperApiWaitingFor: '', + gopayHelperApiInputDeadlineAt: '', + gopayHelperApiInputWaitSeconds: 0, + gopayHelperLastInputError: '', + gopayHelperOtpInvalidCount: 0, + gopayHelperFailureStage: '', + gopayHelperFailureDetail: '', + gopayHelperTaskPayload: null, gopayHelperBalance: '', gopayHelperBalancePayload: null, gopayHelperBalanceUpdatedAt: 0, @@ -647,6 +660,7 @@ const PERSISTED_SETTING_DEFAULTS = { autoRunDelayEnabled: false, autoRunDelayMinutes: 30, autoStepDelaySeconds: null, + step6CookieCleanupEnabled: false, phoneVerificationEnabled: false, freePhoneReuseEnabled: true, freePhoneReuseAutoEnabled: true, @@ -788,6 +802,18 @@ const DEFAULT_STATE = { gopayHelperFlowId: '', gopayHelperChallengeId: '', gopayHelperStartPayload: null, + gopayHelperTaskId: '', + gopayHelperTaskStatus: '', + gopayHelperStatusText: '', + gopayHelperRemoteStage: '', + gopayHelperApiWaitingFor: '', + gopayHelperApiInputDeadlineAt: '', + gopayHelperApiInputWaitSeconds: 0, + gopayHelperLastInputError: '', + gopayHelperOtpInvalidCount: 0, + gopayHelperFailureStage: '', + gopayHelperFailureDetail: '', + gopayHelperTaskPayload: null, gopayHelperOrderCreatedAt: 0, gopayHelperPinPayload: null, gopayHelperResolvedOtp: '', @@ -2338,12 +2364,17 @@ function normalizePersistentSettingValue(key, value) { ); case 'gopayHelperApiUrl': { + const legacyGpcHelperApiUrl = 'https://gpc.leftcode.xyz'; const defaultGpcHelperApiUrl = PERSISTED_SETTING_DEFAULTS.gopayHelperApiUrl - || (typeof DEFAULT_GPC_HELPER_API_URL !== 'undefined' ? DEFAULT_GPC_HELPER_API_URL : 'https://gopay.hwork.pro'); - return self.GoPayUtils?.normalizeGpcHelperBaseUrl + || (typeof DEFAULT_GPC_HELPER_API_URL !== 'undefined' ? DEFAULT_GPC_HELPER_API_URL : 'https://gpc.qlhazycoder.top'); + const normalizedGpcHelperApiUrl = self.GoPayUtils?.normalizeGpcHelperBaseUrl ? self.GoPayUtils.normalizeGpcHelperBaseUrl(value || defaultGpcHelperApiUrl) : String(value || defaultGpcHelperApiUrl).trim().replace(/\/+$/g, ''); + return normalizedGpcHelperApiUrl === legacyGpcHelperApiUrl + ? defaultGpcHelperApiUrl + : normalizedGpcHelperApiUrl; } + case 'gopayHelperApiKey': case 'gopayHelperCardKey': case 'gopayHelperReferenceId': case 'gopayHelperGoPayGuid': @@ -2351,18 +2382,31 @@ function normalizePersistentSettingValue(key, value) { case 'gopayHelperNextAction': case 'gopayHelperFlowId': case 'gopayHelperChallengeId': + case 'gopayHelperTaskId': + case 'gopayHelperTaskStatus': + case 'gopayHelperStatusText': + case 'gopayHelperRemoteStage': + case 'gopayHelperApiWaitingFor': + case 'gopayHelperApiInputDeadlineAt': + case 'gopayHelperLastInputError': + case 'gopayHelperFailureStage': + case 'gopayHelperFailureDetail': case 'gopayHelperBalance': case 'gopayHelperBalanceError': return String(value || '').trim(); case 'gopayHelperBalancePayload': case 'gopayHelperStartPayload': + case 'gopayHelperTaskPayload': return value && typeof value === 'object' && !Array.isArray(value) ? value : null; case 'gopayHelperBalanceUpdatedAt': + case 'gopayHelperApiInputWaitSeconds': + case 'gopayHelperOtpInvalidCount': return Math.max(0, Number(value) || 0); case 'autoRunSkipFailures': case 'oauthFlowTimeoutEnabled': case 'gopayHelperLocalSmsHelperEnabled': case 'autoRunDelayEnabled': + case 'step6CookieCleanupEnabled': case 'phoneVerificationEnabled': case 'freePhoneReuseEnabled': case 'freePhoneReuseAutoEnabled': @@ -7594,7 +7638,8 @@ function getErrorMessage(error) { if (typeof loggingStatus !== 'undefined' && loggingStatus?.getErrorMessage) { return loggingStatus.getErrorMessage(error); } - return String(typeof error === 'string' ? error : error?.message || ''); + return String(typeof error === 'string' ? error : error?.message || '') + .replace(/^GPC_TASK_ENDED::/i, ''); } function isCloudflareSecurityBlockedError(error) { @@ -7805,6 +7850,11 @@ function isPlusCheckoutNonFreeTrialFailure(error) { return /PLUS_CHECKOUT_NON_FREE_TRIAL::|今日应付金额不是\s*0|没有免费试用资格/i.test(message); } +function isGpcTaskEndedFailure(error) { + const message = String(typeof error === 'string' ? error : error?.message || ''); + return /GPC_TASK_ENDED::/i.test(message); +} + function isGoPayCheckoutRestartRequiredFailure(error) { const message = getErrorMessage(error); return /GOPAY_RESTART_FROM_STEP6::|GOPAY_RETRY_REQUIRED::/i.test(message); @@ -7875,6 +7925,18 @@ function getDownstreamStateResets(step, state = {}) { gopayHelperFlowId: '', gopayHelperChallengeId: '', gopayHelperStartPayload: null, + gopayHelperTaskId: '', + gopayHelperTaskStatus: '', + gopayHelperStatusText: '', + gopayHelperRemoteStage: '', + gopayHelperApiWaitingFor: '', + gopayHelperApiInputDeadlineAt: '', + gopayHelperApiInputWaitSeconds: 0, + gopayHelperLastInputError: '', + gopayHelperOtpInvalidCount: 0, + gopayHelperFailureStage: '', + gopayHelperFailureDetail: '', + gopayHelperTaskPayload: null, gopayHelperOrderCreatedAt: 0, gopayHelperPinPayload: null, gopayHelperResolvedOtp: '', @@ -7964,6 +8026,7 @@ function getDownstreamStateResets(step, state = {}) { plusManualConfirmationTitle: '', plusManualConfirmationMessage: '', gopayHelperResolvedOtp: '', + gopayHelperLastInputError: '', gopayHelperOtpRequestId: '', gopayHelperOtpReferenceId: '', } : {}), @@ -9930,22 +9993,42 @@ function resolveGpcHelperBaseUrl(apiUrl = '') { let normalized = String(apiUrl || DEFAULT_GPC_HELPER_API_URL).trim().replace(/\/+$/g, ''); normalized = normalized.replace(/\/api\/checkout\/start$/i, ''); normalized = normalized.replace(/\/api\/gopay\/(?:otp|pin)$/i, ''); + normalized = normalized.replace(/\/api\/gp\/tasks(?:\/[^/?#]+)?(?:\/(?:otp|pin|stop))?(?:\?.*)?$/i, ''); + normalized = normalized.replace(/\/api\/gp\/balance(?:\?.*)?$/i, ''); normalized = normalized.replace(/\/api\/card\/balance(?:\?.*)?$/i, ''); + normalized = normalized.replace(/\/api\/card\/redeem-api-key(?:\?.*)?$/i, ''); return normalized || DEFAULT_GPC_HELPER_API_URL; } -function buildGpcCardBalanceRequestUrl(apiUrl = '', cardKey = '') { +function buildGpcApiKeyBalanceRequestUrl(apiUrl = '') { + if (self.GoPayUtils?.buildGpcApiKeyBalanceUrl) { + return self.GoPayUtils.buildGpcApiKeyBalanceUrl(apiUrl); + } if (self.GoPayUtils?.buildGpcCardBalanceUrl) { - return self.GoPayUtils.buildGpcCardBalanceUrl(apiUrl, cardKey); + return self.GoPayUtils.buildGpcCardBalanceUrl(apiUrl); } const baseUrl = resolveGpcHelperBaseUrl(apiUrl); if (!baseUrl) { return ''; } - return `${baseUrl}/api/card/balance?card_key=${encodeURIComponent(String(cardKey || '').trim())}`; + return `${baseUrl}/api/gp/balance`; } -function formatGpcCardBalancePayload(payload = {}) { +function buildGpcApiKeyHeaders(apiKey = '', extraHeaders = {}) { + if (self.GoPayUtils?.buildGpcApiKeyHeaders) { + return self.GoPayUtils.buildGpcApiKeyHeaders(apiKey, extraHeaders); + } + const headers = { + ...(extraHeaders && typeof extraHeaders === 'object' ? extraHeaders : {}), + }; + const normalizedApiKey = String(apiKey || '').trim(); + if (normalizedApiKey) { + headers['X-API-Key'] = normalizedApiKey; + } + return headers; +} + +function formatGpcApiKeyBalancePayload(payload = {}) { if (self.GoPayUtils?.formatGpcBalancePayload) { return self.GoPayUtils.formatGpcBalancePayload(payload); } @@ -9953,30 +10036,40 @@ function formatGpcCardBalancePayload(payload = {}) { return ''; } const remaining = payload.remaining_uses ?? payload.remainingUses ?? payload.balance ?? payload.remaining; + const total = payload.total_uses ?? payload.totalUses; + const used = payload.used_uses ?? payload.usedUses; const status = String(payload.card_status || payload.cardStatus || payload.status || '').trim(); return [ - remaining !== undefined && remaining !== null && String(remaining).trim() !== '' ? `余额 ${remaining}` : '', + remaining !== undefined && remaining !== null && String(remaining).trim() !== '' + ? (total !== undefined && total !== null && String(total).trim() !== '' ? `余额 ${remaining}/${total}` : `余额 ${remaining}`) + : '', + used !== undefined && used !== null && String(used).trim() !== '' ? `已用 ${used}` : '', status ? `状态 ${status}` : '', ].filter(Boolean).join(','); } -async function refreshGpcCardBalance(state = {}, options = {}) { +async function refreshGpcApiKeyBalance(state = {}, options = {}) { const apiUrl = resolveGpcHelperBaseUrl(state?.gopayHelperApiUrl || DEFAULT_GPC_HELPER_API_URL); - const cardKey = String(state?.gopayHelperCardKey || state?.gpcCardKey || state?.cardKey || '').trim(); + const apiKey = String( + state?.gopayHelperApiKey + || state?.gpcApiKey + || state?.apiKey + || '' + ).trim(); if (!apiUrl) { throw new Error('缺少 GPC API 地址。'); } - if (!cardKey) { - throw new Error('缺少 GPC 卡密。'); + if (!apiKey) { + throw new Error('缺少 GPC API Key。'); } - const requestUrl = buildGpcCardBalanceRequestUrl(apiUrl, cardKey); + const requestUrl = buildGpcApiKeyBalanceRequestUrl(apiUrl); if (!requestUrl) { throw new Error('缺少 GPC API 地址。'); } const response = await fetch(requestUrl, { method: 'GET', - headers: { Accept: 'application/json' }, + headers: buildGpcApiKeyHeaders(apiKey, { Accept: 'application/json' }), }); const rawText = await response.text(); let payload = {}; @@ -9985,20 +10078,28 @@ async function refreshGpcCardBalance(state = {}, options = {}) { } catch { payload = { raw: rawText }; } - const balanceText = formatGpcCardBalancePayload(payload) || rawText || '未知'; + const balancePayload = self.GoPayUtils?.unwrapGpcResponse + ? self.GoPayUtils.unwrapGpcResponse(payload) + : (payload?.data && typeof payload === 'object' ? payload.data : payload); + const balanceText = formatGpcApiKeyBalancePayload(payload) || rawText || '未知'; const updates = { gopayHelperBalance: balanceText, - gopayHelperBalancePayload: payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : { raw: String(payload || '') }, + gopayHelperBalancePayload: balancePayload && typeof balancePayload === 'object' && !Array.isArray(balancePayload) ? balancePayload : { raw: String(balancePayload || '') }, gopayHelperBalanceUpdatedAt: Date.now(), gopayHelperBalanceError: '', }; - const flowId = String(payload?.flow_id || payload?.flowId || '').trim(); + const flowId = String(balancePayload?.flow_id || balancePayload?.flowId || '').trim(); if (flowId) { updates.gopayHelperFlowId = flowId; } - if (!response.ok || payload?.ok === false) { - const detail = payload?.error || payload?.message || payload?.detail || `HTTP ${response.status}`; + const unifiedOk = self.GoPayUtils?.isGpcUnifiedResponseOk + ? self.GoPayUtils.isGpcUnifiedResponseOk(payload) + : true; + if (!response.ok || payload?.ok === false || !unifiedOk) { + const detail = self.GoPayUtils?.extractGpcResponseErrorDetail + ? self.GoPayUtils.extractGpcResponseErrorDetail(payload, response.status) + : (payload?.data?.detail || payload?.error || payload?.message || payload?.detail || `HTTP ${response.status}`); const errorUpdates = { ...updates, gopayHelperBalanceError: String(detail || '余额查询失败') }; await setPersistentSettings(errorUpdates); broadcastDataUpdate(errorUpdates); @@ -10017,6 +10118,8 @@ async function refreshGpcCardBalance(state = {}, options = {}) { return { balance: balanceText, payload, updatedAt: updates.gopayHelperBalanceUpdatedAt }; } +const refreshGpcCardBalance = refreshGpcApiKeyBalance; + const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoRunController({ addLog, appendAccountRunRecord: (...args) => appendAndBroadcastAccountRunRecord(...args), @@ -10041,6 +10144,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR isAddPhoneAuthFailure, isPhoneSmsPlatformRateLimitFailure, isPlusCheckoutNonFreeTrialFailure, + isGpcTaskEndedFailure, isRestartCurrentAttemptError, isStep4Route405RecoveryLimitFailure, isSignupUserAlreadyExistsFailure, @@ -10917,7 +11021,9 @@ const step5Executor = self.MultiPageBackgroundStep5?.createStep5Executor({ }); const step6Executor = self.MultiPageBackgroundStep6?.createStep6Executor({ addLog, + chrome, completeStepFromBackground, + getErrorMessage, registrationSuccessWaitMs: STEP6_REGISTRATION_SUCCESS_WAIT_MS, sleepWithStop, }); @@ -11081,7 +11187,7 @@ const stepExecutorsByKey = { 'fill-password': (state) => step3Executor.executeStep3(state), 'fetch-signup-code': (state) => step4Executor.executeStep4(state), 'fill-profile': (state) => step5Executor.executeStep5(state), - 'wait-registration-success': () => step6Executor.executeStep6(), + 'wait-registration-success': (state) => step6Executor.executeStep6(state), 'plus-checkout-create': (state) => plusCheckoutCreateExecutor.executePlusCheckoutCreate(state), 'plus-checkout-billing': (state) => plusCheckoutBillingExecutor.executePlusCheckoutBilling(state), 'gopay-subscription-confirm': (state) => goPayManualConfirmExecutor.executeGoPayManualConfirm(state), @@ -11965,8 +12071,8 @@ async function rerunStep7ForStep8Recovery(options = {}) { } } -async function executeStep6() { - return step6Executor.executeStep6(); +async function executeStep6(state = null) { + return step6Executor.executeStep6(state || await getState()); } // ============================================================ diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 7abc462..7c9e063 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -23,6 +23,7 @@ getState, hasSavedProgress, isAddPhoneAuthFailure, + isGpcTaskEndedFailure, isPhoneSmsPlatformRateLimitFailure, isPlusCheckoutNonFreeTrialFailure, isRestartCurrentAttemptError, @@ -529,6 +530,9 @@ && isAddPhoneAuthFailure(err); const blockedByPlusNonFreeTrial = typeof isPlusCheckoutNonFreeTrialFailure === 'function' && isPlusCheckoutNonFreeTrialFailure(err); + const blockedByGpcTaskEnded = typeof isGpcTaskEndedFailure === 'function' + ? isGpcTaskEndedFailure(err) + : /GPC_TASK_ENDED::/i.test(err?.message || String(err || '')); const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function' && !keepSameEmailUntilAddPhone && isSignupUserAlreadyExistsFailure(err); @@ -537,6 +541,7 @@ const canRetry = !blockedByAddPhone && !blockedByPhoneNoSupply && !blockedByPlusNonFreeTrial + && !blockedByGpcTaskEnded && !blockedBySignupUserAlreadyExists && autoRunSkipFailures && attemptRun < maxAttemptsForRound; @@ -650,6 +655,41 @@ break; } + if (blockedByGpcTaskEnded) { + roundSummary.status = 'failed'; + roundSummary.finalFailureReason = reason; + await setState({ + autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), + }); + await appendRoundRecordIfNeeded('failed', reason); + cancelPendingCommands('当前轮因 GPC 任务已结束。'); + await broadcastStopToContentScripts(); + if (!autoRunSkipFailures) { + await addLog( + `第 ${targetRun}/${totalRuns} 轮 GPC 任务已结束,自动重试未开启,当前自动运行将停止。`, + 'warn' + ); + stoppedEarly = true; + await broadcastAutoRunStatus('stopped', { + currentRun: targetRun, + totalRuns, + attemptRun, + sessionId: 0, + }); + break; + } + + await addLog(`第 ${targetRun}/${totalRuns} 轮 GPC 任务已结束,本轮将直接失败并跳过剩余重试。`, 'warn'); + await addLog( + targetRun < totalRuns + ? `第 ${targetRun}/${totalRuns} 轮因 GPC 任务结束提前结束,自动流程将继续下一轮。` + : `第 ${targetRun}/${totalRuns} 轮因 GPC 任务结束提前结束,已无后续轮次,本次自动运行结束。`, + 'warn' + ); + forceFreshTabsNextRun = true; + break; + } + if (blockedBySignupUserAlreadyExists) { roundSummary.status = 'failed'; roundSummary.finalFailureReason = reason; diff --git a/background/logging-status.js b/background/logging-status.js index 797a3af..06a3145 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -72,7 +72,8 @@ } function getErrorMessage(error) { - return String(typeof error === 'string' ? error : error?.message || ''); + return String(typeof error === 'string' ? error : error?.message || '') + .replace(/^GPC_TASK_ENDED::/i, ''); } function isVerificationMailPollingError(error) { diff --git a/background/message-router.js b/background/message-router.js index 9960bde..ca8b3fb 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -1031,7 +1031,7 @@ case 'REFRESH_GPC_CARD_BALANCE': { if (typeof refreshGpcCardBalance !== 'function') { - throw new Error('GPC 卡密余额查询能力尚未接入。'); + throw new Error('GPC API Key 余额查询能力尚未接入。'); } const state = await getState(); const result = await refreshGpcCardBalance({ diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index 4914884..c22cb30 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -7,7 +7,7 @@ const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal'; const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; - const DEFAULT_GPC_HELPER_API_URL = 'https://gopay.hwork.pro'; + const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top'; function createPlusCheckoutCreateExecutor(deps = {}) { const { @@ -16,7 +16,6 @@ completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, fetch: fetchImpl = null, - markCurrentRegistrationAccountUsed = null, registerTab, sendTabMessageUntilStopped, setState, @@ -95,121 +94,17 @@ return String(value || '').trim().toLowerCase() === 'sms' ? 'sms' : 'whatsapp'; } - function resolveGpcHelperCardKey(state = {}) { - const cardKey = String(state?.gopayHelperCardKey || state?.gpcCardKey || state?.cardKey || '').trim(); - if (!cardKey) { - throw new Error('创建 GPC 订单失败:缺少卡密。'); - } - return cardKey; - } - - function resolveGpcHelperCustomerEmail(state = {}) { - const email = String( - state?.email - || state?.currentEmail - || state?.registrationEmail - || state?.accountEmail - || state?.mailboxEmail + function resolveGpcHelperApiKey(state = {}) { + const apiKey = String( + state?.gopayHelperApiKey + || state?.gpcApiKey + || state?.apiKey || '' - ).trim().toLowerCase(); - if (!email) { - throw new Error('创建 GPC 订单失败:缺少当前轮邮箱。'); + ).trim(); + if (!apiKey) { + throw new Error('创建 GPC 订单失败:缺少 API Key。'); } - return email; - } - - function parseGpcAmount(value) { - if (typeof value === 'number') { - return Number.isFinite(value) ? { amount: value, raw: String(value) } : null; - } - if (typeof value !== 'string') { - return null; - } - const raw = String(value || '').trim(); - if (!raw || !/\d/.test(raw)) { - return null; - } - const match = raw.match(/([+-]?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{1,2})|[+-]?\d+(?:[.,]\d{1,2})?)/); - if (!match) { - return null; - } - let numericText = String(match[1] || '').trim(); - const lastComma = numericText.lastIndexOf(','); - const lastDot = numericText.lastIndexOf('.'); - if (lastComma > -1 && lastDot > -1) { - const decimalSeparator = lastComma > lastDot ? ',' : '.'; - const thousandsSeparator = decimalSeparator === ',' ? '.' : ','; - numericText = numericText - .replace(new RegExp(`\\${thousandsSeparator}`, 'g'), '') - .replace(decimalSeparator, '.'); - } else if (lastComma > -1) { - numericText = numericText.replace(',', '.'); - } - const amount = Number(numericText.replace(/[^\d.+-]/g, '')); - return Number.isFinite(amount) ? { amount, raw } : null; - } - - function isGpcAmountKey(key = '') { - const normalized = String(key || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_'); - if (!normalized) { - return false; - } - if (/(?:^|_)(?:id|guid|uuid|phone|country|postal|zip|code|count|status|time|timestamp|created|updated|expires|challenge|client|reference|currency|state)(?:_|$)/i.test(normalized)) { - return false; - } - return /(?:amount|balance|total|due|payable|gross|subtotal|price|charge)/i.test(normalized); - } - - function findGpcNonZeroAmount(payload = {}) { - const seen = new Set(); - function visit(value, path = [], depth = 0) { - if (value == null || depth > 10) { - return null; - } - const key = path[path.length - 1] || ''; - if (isGpcAmountKey(key)) { - const parsed = parseGpcAmount(value); - if (parsed && Math.abs(parsed.amount) >= 0.005) { - return { ...parsed, path: path.join('.') }; - } - } - if (typeof value !== 'object') { - return null; - } - if (seen.has(value)) { - return null; - } - seen.add(value); - if (Array.isArray(value)) { - for (let index = 0; index < value.length; index += 1) { - const found = visit(value[index], [...path, String(index)], depth + 1); - if (found) return found; - } - return null; - } - for (const [childKey, childValue] of Object.entries(value)) { - const found = visit(childValue, [...path, childKey], depth + 1); - if (found) return found; - } - return null; - } - return visit(payload); - } - - async function abortGpcNonFreeTrialIfNeeded(data = {}, state = {}) { - const nonZeroAmount = findGpcNonZeroAmount(data); - if (!nonZeroAmount) { - return; - } - const amountLabel = nonZeroAmount.raw || String(nonZeroAmount.amount); - await addLog(`步骤 6:GPC 接口返回余额非 0(${amountLabel}),当前账号没有免费试用资格,将跳过当前账号。`, 'warn'); - if (typeof markCurrentRegistrationAccountUsed === 'function') { - await markCurrentRegistrationAccountUsed(state, { - reason: 'plus-checkout-non-free-trial', - logPrefix: 'GPC:当前账号没有免费试用资格', - }); - } - throw new Error(`PLUS_CHECKOUT_NON_FREE_TRIAL::步骤 6:GPC 接口返回余额非 0(${amountLabel}),当前账号没有免费试用资格,已跳过支付提交。`); + return apiKey; } function normalizeGpcHelperBaseUrl(apiUrl = '') { @@ -220,7 +115,10 @@ let normalized = String(apiUrl || DEFAULT_GPC_HELPER_API_URL).trim().replace(/\/+$/g, ''); normalized = normalized.replace(/\/api\/checkout\/start$/i, ''); normalized = normalized.replace(/\/api\/gopay\/(?:otp|pin)$/i, ''); + normalized = normalized.replace(/\/api\/gp\/tasks(?:\/[^/?#]+)?(?:\/(?:otp|pin|stop))?(?:\?.*)?$/i, ''); + normalized = normalized.replace(/\/api\/gp\/balance(?:\?.*)?$/i, ''); normalized = normalized.replace(/\/api\/card\/balance(?:\?.*)?$/i, ''); + normalized = normalized.replace(/\/api\/card\/redeem-api-key(?:\?.*)?$/i, ''); return normalized || DEFAULT_GPC_HELPER_API_URL; } @@ -237,6 +135,47 @@ return `${baseUrl}${normalizedPath}`; } + function buildGpcTaskCreateUrl(apiUrl = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.buildGpcTaskCreateUrl) { + return rootScope.GoPayUtils.buildGpcTaskCreateUrl(apiUrl); + } + return buildGpcHelperApiUrl(apiUrl, '/api/gp/tasks'); + } + + function unwrapGpcResponse(payload = {}) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.unwrapGpcResponse) { + return rootScope.GoPayUtils.unwrapGpcResponse(payload); + } + if (payload && typeof payload === 'object' && !Array.isArray(payload) + && Object.prototype.hasOwnProperty.call(payload, 'data') + && (Object.prototype.hasOwnProperty.call(payload, 'code') || Object.prototype.hasOwnProperty.call(payload, 'message'))) { + return payload.data ?? {}; + } + return payload; + } + + function isGpcUnifiedResponseOk(payload = {}) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.isGpcUnifiedResponseOk) { + return rootScope.GoPayUtils.isGpcUnifiedResponseOk(payload); + } + if (!payload || typeof payload !== 'object' || !Object.prototype.hasOwnProperty.call(payload, 'code')) { + return true; + } + const code = Number(payload.code); + return Number.isFinite(code) ? code >= 200 && code < 300 : String(payload.code || '').trim() === '200'; + } + + function getGpcResponseErrorDetail(payload = {}, status = 0) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.extractGpcResponseErrorDetail) { + return rootScope.GoPayUtils.extractGpcResponseErrorDetail(payload, status); + } + return payload?.data?.detail || payload?.detail || payload?.message || payload?.error || `HTTP ${status || 0}`; + } + async function fetchJsonWithTimeout(url, options = {}, timeoutMs = 30000) { const fetcher = typeof fetchImpl === 'function' ? fetchImpl @@ -283,14 +222,14 @@ if (!token) { throw new Error('创建 GPC 订单失败:缺少 accessToken。'); } - const apiUrl = buildGpcHelperApiUrl(state?.gopayHelperApiUrl, '/api/checkout/start'); + const apiUrl = buildGpcTaskCreateUrl(state?.gopayHelperApiUrl); if (!apiUrl) { throw new Error('创建 GPC 订单失败:缺少 API 地址。'); } const phoneNumber = String(state?.gopayHelperPhoneNumber || '').trim(); const countryCode = normalizeHelperCountryCode(state?.gopayHelperCountryCode || '86'); const pin = String(state?.gopayHelperPin || '').trim(); - const cardKey = resolveGpcHelperCardKey(state); + const apiKey = resolveGpcHelperApiKey(state); if (!phoneNumber) { throw new Error('创建 GPC 订单失败:缺少手机号。'); } @@ -300,32 +239,11 @@ throwIfStopped(); const payload = { - token, - entry_point: 'all_plans_pricing_modal', - plan_name: 'chatgptplusplan', - billing_details: { country: 'ID', currency: 'IDR' }, - promo_campaign: { - promo_campaign_id: 'plus-1-month-free', - is_coupon_from_query_param: false, - }, - checkout_ui_mode: 'custom', - proxy: { type: 'direct', url: '' }, - tax_region: { - country: 'US', - line1: '1208 Oakdale Street', - city: 'Jonesboro', - postal_code: '72401', - state: 'AR', - }, - customer_email: resolveGpcHelperCustomerEmail(state), - card_key: cardKey, - gopay_link: { - type: 'gopay', - country_code: countryCode, - phone_number: normalizeHelperPhoneNumber(phoneNumber, countryCode), - phone_mode: 'manual', - otp_channel: normalizeGpcOtpChannel(state?.gopayHelperOtpChannel), - }, + access_token: token, + phone_mode: 'manual', + country_code: countryCode, + phone_number: normalizeHelperPhoneNumber(phoneNumber, countryCode), + otp_channel: normalizeGpcOtpChannel(state?.gopayHelperOtpChannel), }; const orderCreatedAt = Date.now(); @@ -335,38 +253,26 @@ Accept: '*/*', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Content-Type': 'application/json', + 'X-API-Key': apiKey, }, body: JSON.stringify(payload), }, 30000); - const referenceId = String(data?.reference_id || data?.referenceId || '').trim(); - const gopayGuid = String(data?.gopay_guid || data?.gopayGuid || '').trim(); - const redirectUrl = String(data?.redirect_url || data?.redirectUrl || '').trim(); - const nextAction = String(data?.next_action || data?.nextAction || '').trim(); - const flowId = String(data?.flow_id || data?.flowId || '').trim(); - const challengeId = String(data?.challenge_id || data?.challengeId || '').trim(); + const taskData = unwrapGpcResponse(data); + const taskId = String(taskData?.task_id || taskData?.taskId || '').trim(); - if (response?.ok) { - await abortGpcNonFreeTrialIfNeeded(data, state); - } - - if (!response?.ok || !referenceId) { - const rootScope = typeof self !== 'undefined' ? self : globalThis; - const detail = rootScope.GoPayUtils?.extractGpcResponseErrorDetail - ? rootScope.GoPayUtils.extractGpcResponseErrorDetail(data, response?.status || 0) - : (data?.detail || data?.message || data?.error || `HTTP ${response?.status || 0}`); + if (!response?.ok || !isGpcUnifiedResponseOk(data) || !taskId) { + const detail = getGpcResponseErrorDetail(data, response?.status || 0); throw new Error(`创建 GPC 订单失败:${detail}`); } return { - referenceId, - gopayGuid, - redirectUrl, - nextAction, - flowId, - challengeId, + taskId, + taskStatus: String(taskData?.status || '').trim(), + statusText: String(taskData?.status_text || taskData?.statusText || '').trim(), + remoteStage: String(taskData?.remote_stage || taskData?.remoteStage || '').trim(), orderCreatedAt, - responsePayload: data && typeof data === 'object' && !Array.isArray(data) ? data : null, + responsePayload: taskData && typeof taskData === 'object' && !Array.isArray(taskData) ? taskData : null, country: 'ID', currency: 'IDR', checkoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER, @@ -398,16 +304,21 @@ plusCheckoutCountry: result.country || 'ID', plusCheckoutCurrency: result.currency || 'IDR', plusCheckoutSource: result.checkoutSource, - gopayHelperReferenceId: result.referenceId, - gopayHelperGoPayGuid: result.gopayGuid, - gopayHelperRedirectUrl: result.redirectUrl, - gopayHelperNextAction: result.nextAction, - gopayHelperFlowId: result.flowId, - gopayHelperChallengeId: result.challengeId, - gopayHelperStartPayload: result.responsePayload, + gopayHelperTaskId: result.taskId, + gopayHelperTaskStatus: result.taskStatus, + gopayHelperStatusText: result.statusText, + gopayHelperRemoteStage: result.remoteStage, + gopayHelperTaskPayload: result.responsePayload, + gopayHelperReferenceId: '', + gopayHelperGoPayGuid: '', + gopayHelperRedirectUrl: '', + gopayHelperNextAction: '', + gopayHelperFlowId: '', + gopayHelperChallengeId: '', + gopayHelperStartPayload: null, gopayHelperOrderCreatedAt: result.orderCreatedAt || Date.now(), }); - await addLog('步骤 6:GPC 订单已创建,准备继续下一步。', 'info'); + await addLog(`步骤 6:GPC 任务已创建(task_id: ${result.taskId}),准备继续下一步。`, 'info'); await completeStepFromBackground(6, { plusCheckoutCountry: result.country || 'ID', plusCheckoutCurrency: result.currency || 'IDR', diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 78168f2..d294ca6 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -10,7 +10,8 @@ const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal'; const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; - const DEFAULT_GPC_HELPER_API_URL = 'https://gopay.hwork.pro'; + const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top'; + const GPC_TASK_POLL_INTERVAL_MS = 3000; const PAYMENT_METHOD_CONFIGS = { [PLUS_PAYMENT_METHOD_PAYPAL]: { id: PLUS_PAYMENT_METHOD_PAYPAL, @@ -92,7 +93,7 @@ function isGpcHelperCheckout(state = {}) { return normalizePlusPaymentMethod(state?.plusPaymentMethod) === PLUS_PAYMENT_METHOD_GPC_HELPER || (normalizeText(state?.plusCheckoutSource) === PLUS_PAYMENT_METHOD_GPC_HELPER - && Boolean(state?.gopayHelperReferenceId)); + && Boolean(state?.gopayHelperTaskId || state?.gopayHelperReferenceId)); } function compactCountryText(value = '') { @@ -123,7 +124,10 @@ let normalized = String(apiUrl || DEFAULT_GPC_HELPER_API_URL).trim().replace(/\/+$/g, ''); normalized = normalized.replace(/\/api\/checkout\/start$/i, ''); normalized = normalized.replace(/\/api\/gopay\/(?:otp|pin)$/i, ''); + normalized = normalized.replace(/\/api\/gp\/tasks(?:\/[^/?#]+)?(?:\/(?:otp|pin|stop))?(?:\?.*)?$/i, ''); + normalized = normalized.replace(/\/api\/gp\/balance(?:\?.*)?$/i, ''); normalized = normalized.replace(/\/api\/card\/balance(?:\?.*)?$/i, ''); + normalized = normalized.replace(/\/api\/card\/redeem-api-key(?:\?.*)?$/i, ''); return normalized || DEFAULT_GPC_HELPER_API_URL; } @@ -153,7 +157,6 @@ const payload = { reference_id: String(input.reference_id ?? input.referenceId ?? '').trim(), otp: String(input.otp ?? input.code ?? '').trim().replace(/[^\d]/g, ''), - card_key: String(input.card_key ?? input.cardKey ?? '').trim(), }; const gopayGuid = String(input.gopay_guid ?? input.gopayGuid ?? '').trim(); const redirectUrl = String(input.redirect_url ?? input.redirectUrl ?? '').trim(); @@ -183,7 +186,6 @@ challenge_id: String(input.challenge_id ?? input.challengeId ?? '').trim(), gopay_guid: String(input.gopay_guid ?? input.gopayGuid ?? '').trim(), pin: String(input.pin ?? '').trim().replace(/[^\d]/g, ''), - card_key: String(input.card_key ?? input.cardKey ?? '').trim(), }; const redirectUrl = String(input.redirect_url ?? input.redirectUrl ?? '').trim(); const flowId = String(input.flow_id ?? input.flowId ?? '').trim(); @@ -207,11 +209,160 @@ return rootScope.GoPayUtils.extractGpcResponseErrorDetail(payload, status); } if (payload && typeof payload === 'object') { - return payload.detail || payload.message || payload.error || payload.error_description || payload.reason || `HTTP ${status || 0}`; + return payload?.data?.detail || payload.detail || payload.message || payload.error || payload.error_description || payload.reason || `HTTP ${status || 0}`; } return `HTTP ${status || 0}`; } + function unwrapGpcResponse(payload = {}) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.unwrapGpcResponse) { + return rootScope.GoPayUtils.unwrapGpcResponse(payload); + } + if (payload && typeof payload === 'object' && !Array.isArray(payload) + && Object.prototype.hasOwnProperty.call(payload, 'data') + && (Object.prototype.hasOwnProperty.call(payload, 'code') || Object.prototype.hasOwnProperty.call(payload, 'message'))) { + return payload.data ?? {}; + } + return payload; + } + + function isGpcUnifiedResponseOk(payload = {}) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.isGpcUnifiedResponseOk) { + return rootScope.GoPayUtils.isGpcUnifiedResponseOk(payload); + } + if (!payload || typeof payload !== 'object' || !Object.prototype.hasOwnProperty.call(payload, 'code')) { + return true; + } + const code = Number(payload.code); + return Number.isFinite(code) ? code >= 200 && code < 300 : String(payload.code || '').trim() === '200'; + } + + function buildGpcTaskQueryUrl(apiUrl = '', taskId = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.buildGpcTaskQueryUrl) { + return rootScope.GoPayUtils.buildGpcTaskQueryUrl(apiUrl, taskId); + } + const baseUrl = normalizeGpcHelperBaseUrl(apiUrl); + return `${baseUrl}/api/gp/tasks/${encodeURIComponent(String(taskId || '').trim())}`; + } + + function buildGpcTaskActionUrl(apiUrl = '', taskId = '', action = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.buildGpcTaskActionUrl) { + return rootScope.GoPayUtils.buildGpcTaskActionUrl(apiUrl, taskId, action); + } + const baseUrl = normalizeGpcHelperBaseUrl(apiUrl); + const normalizedAction = String(action || '').trim().replace(/^\/+|\/+$/g, ''); + return `${baseUrl}/api/gp/tasks/${encodeURIComponent(String(taskId || '').trim())}/${normalizedAction}`; + } + + function buildGpcTaskOtpPayload(input = {}) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.buildGpcTaskOtpPayload) { + return rootScope.GoPayUtils.buildGpcTaskOtpPayload(input); + } + return { + otp: String(input.otp ?? input.code ?? '').trim().replace(/[^\d]/g, ''), + }; + } + + function buildGpcTaskPinPayload(input = {}) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.buildGpcTaskPinPayload) { + return rootScope.GoPayUtils.buildGpcTaskPinPayload(input); + } + return { + pin: String(input.pin ?? '').trim().replace(/[^\d]/g, ''), + }; + } + + function buildGpcApiKeyHeaders(apiKey = '', extraHeaders = {}) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.buildGpcApiKeyHeaders) { + return rootScope.GoPayUtils.buildGpcApiKeyHeaders(apiKey, extraHeaders); + } + const headers = { + ...(extraHeaders && typeof extraHeaders === 'object' ? extraHeaders : {}), + }; + const normalizedApiKey = String(apiKey || '').trim(); + if (normalizedApiKey) { + headers['X-API-Key'] = normalizedApiKey; + } + return headers; + } + + function normalizeGpcTaskData(payload = {}) { + const data = unwrapGpcResponse(payload); + const task = data && typeof data === 'object' && !Array.isArray(data) ? { ...data } : {}; + task.task_id = String(task.task_id || task.taskId || '').trim(); + task.status = String(task.status || '').trim().toLowerCase(); + task.status_text = String(task.status_text || task.statusText || '').trim(); + task.phone_mode = String(task.phone_mode || task.phoneMode || '').trim().toLowerCase(); + task.remote_stage = String(task.remote_stage || task.remoteStage || '').trim().toLowerCase(); + task.api_waiting_for = String(task.api_waiting_for || task.apiWaitingFor || '').trim().toLowerCase(); + task.api_input_deadline_at = String(task.api_input_deadline_at || task.apiInputDeadlineAt || '').trim(); + task.api_input_wait_seconds = Math.max(0, Number(task.api_input_wait_seconds ?? task.apiInputWaitSeconds) || 0); + task.last_input_error = String(task.last_input_error || task.lastInputError || '').trim(); + task.otp_invalid_count = Math.max(0, Number(task.otp_invalid_count ?? task.otpInvalidCount) || 0); + task.failure_stage = String(task.failure_stage || task.failureStage || '').trim(); + task.failure_detail = String(task.failure_detail || task.failureDetail || '').trim(); + task.error_message = String(task.error_message || task.errorMessage || '').trim(); + return task; + } + + async function setGpcTaskState(taskData = {}) { + const task = normalizeGpcTaskData(taskData); + const updates = { + gopayHelperTaskId: task.task_id, + gopayHelperTaskStatus: task.status, + gopayHelperStatusText: task.status_text, + gopayHelperRemoteStage: task.remote_stage, + gopayHelperApiWaitingFor: task.api_waiting_for, + gopayHelperApiInputDeadlineAt: task.api_input_deadline_at, + gopayHelperApiInputWaitSeconds: task.api_input_wait_seconds, + gopayHelperLastInputError: task.last_input_error, + gopayHelperOtpInvalidCount: task.otp_invalid_count, + gopayHelperFailureStage: task.failure_stage, + gopayHelperFailureDetail: task.failure_detail, + gopayHelperTaskPayload: task && typeof task === 'object' && !Array.isArray(task) ? task : null, + }; + await setState(updates); + if (typeof broadcastDataUpdate === 'function') { + broadcastDataUpdate(updates); + } + return task; + } + + async function fetchGpcTaskStatus(apiUrl, taskId, apiKey) { + const requestUrl = buildGpcTaskQueryUrl(apiUrl, taskId); + const { response, data } = await fetchJsonWithTimeout(requestUrl, { + method: 'GET', + headers: buildGpcApiKeyHeaders(apiKey, { Accept: 'application/json' }), + }, 30000); + if (!response?.ok || !isGpcUnifiedResponseOk(data)) { + throw new Error(getGpcResponseErrorDetail(data, response?.status || 0)); + } + return setGpcTaskState(data); + } + + async function postGpcTaskAction(apiUrl, taskId, action, payload = {}, apiKey = '', timeoutMs = 30000) { + const requestUrl = buildGpcTaskActionUrl(apiUrl, taskId, action); + const { response, data } = await fetchJsonWithTimeout(requestUrl, { + method: 'POST', + headers: buildGpcApiKeyHeaders(apiKey, { + Accept: 'application/json', + 'Content-Type': 'application/json', + }), + body: JSON.stringify(payload), + }, timeoutMs); + if (!response?.ok || !isGpcUnifiedResponseOk(data)) { + throw new Error(getGpcResponseErrorDetail(data, response?.status || 0)); + } + return setGpcTaskState(data); + } + async function postGpcJsonWithFallback(apiUrl, endpointPath, primaryPayload, fallbackPayload, timeoutMs = 30000) { const requestUrl = `${apiUrl}${endpointPath}`; const send = (payload) => fetchJsonWithTimeout(requestUrl, { @@ -312,35 +463,80 @@ return Number.isFinite(parsed) ? parsed : 0; } - function buildLocalSmsHelperOtpUrl(state = {}, referenceId = '') { + function normalizeLocalSmsHelperCountryCode(value = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.normalizeGoPayCountryCode) { + return rootScope.GoPayUtils.normalizeGoPayCountryCode(value || '+86'); + } + const digits = String(value || '+86').replace(/\D/g, ''); + return digits ? `+${digits}` : '+86'; + } + + function normalizeLocalSmsHelperPhoneE164(phone = '', countryCode = '+86') { + const rawPhone = String(phone || '').trim(); + if (!rawPhone) { + return ''; + } + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const normalizedCountryCode = normalizeLocalSmsHelperCountryCode(countryCode); + const normalizedPhone = rootScope.GoPayUtils?.normalizeGoPayPhone + ? rootScope.GoPayUtils.normalizeGoPayPhone(rawPhone) + : rawPhone.replace(/[^\d+]/g, ''); + const phoneDigits = normalizedPhone.replace(/\D/g, ''); + if (!phoneDigits) { + return ''; + } + if (normalizedPhone.startsWith('+')) { + return `+${phoneDigits}`; + } + const countryDigits = normalizedCountryCode.replace(/\D/g, '') || '86'; + let nationalNumber = phoneDigits; + if (countryDigits && nationalNumber.startsWith(countryDigits) && nationalNumber.length > countryDigits.length) { + nationalNumber = nationalNumber.slice(countryDigits.length); + } + return `+${countryDigits}${nationalNumber}`; + } + + function buildLocalSmsHelperOtpUrl(state = {}, taskId = '', options = {}) { const baseUrl = normalizeLocalSmsHelperBaseUrl(state?.gopayHelperLocalSmsHelperUrl); - const url = new URL(`${baseUrl}/otp`); - const normalizedReferenceId = String(referenceId || '').trim(); - const phoneNumber = String(state?.gopayHelperPhoneNumber || '').trim(); + const url = new URL(`${baseUrl}/latest-otp`); + const normalizedTaskId = String(taskId || '').trim(); + const phoneNumber = normalizeLocalSmsHelperPhoneE164( + state?.gopayHelperPhoneNumber, + state?.gopayHelperCountryCode || '+86' + ); + const afterOverrideMs = normalizeEpochMilliseconds(options?.afterMs || options?.after_ms || 0); const orderCreatedAt = normalizeEpochMilliseconds( state?.gopayHelperOrderCreatedAt + || state?.gopayHelperTaskPayload?.created_at + || state?.gopayHelperTaskPayload?.createdAt || state?.gopayHelperStartPayload?.order_created_at || state?.gopayHelperStartPayload?.orderCreatedAt || state?.gopayHelperStartPayload?.created_at || state?.gopayHelperStartPayload?.createdAt ); - if (normalizedReferenceId) { - url.searchParams.set('reference_id', normalizedReferenceId); + if (normalizedTaskId) { + url.searchParams.set('task_id', normalizedTaskId); + url.searchParams.set('reference_id', normalizedTaskId); } if (phoneNumber) { - url.searchParams.set('phone_number', phoneNumber); + url.searchParams.set('phone', phoneNumber); } - if (orderCreatedAt > 0) { - url.searchParams.set('after_ms', String(orderCreatedAt)); + url.searchParams.set('consume', '1'); + const effectiveAfterMs = Math.max(orderCreatedAt, afterOverrideMs); + if (effectiveAfterMs > 0) { + url.searchParams.set('after_ms', String(effectiveAfterMs)); } return url.toString(); } - async function pollLocalSmsHelperOtp(state = {}, referenceId = '') { - const timeoutSeconds = Math.max(10, Math.min(300, Number(state?.gopayHelperLocalSmsTimeoutSeconds) || 90)); + async function pollLocalSmsHelperOtp(state = {}, taskId = '', options = {}) { + const timeoutSeconds = Math.max(1, Math.min(300, Number(options?.timeoutSeconds ?? options?.timeout_seconds ?? state?.gopayHelperLocalSmsTimeoutSeconds) || 90)); const pollIntervalSeconds = Math.max(1, Math.min(30, Number(state?.gopayHelperLocalSmsPollIntervalSeconds) || 2)); + const singleAttempt = Boolean(options?.singleAttempt || options?.single_attempt); + const requestTimeoutMs = Math.max(1000, Math.min(8000, Number(options?.requestTimeoutMs ?? options?.request_timeout_ms) || pollIntervalSeconds * 1000)); const deadline = Date.now() + timeoutSeconds * 1000; - const requestUrl = buildLocalSmsHelperOtpUrl(state, referenceId); + const requestUrl = buildLocalSmsHelperOtpUrl(state, taskId, options); let lastMessage = ''; while (Date.now() <= deadline) { throwIfStopped(); @@ -348,7 +544,7 @@ const { response, data } = await fetchJsonWithTimeout(requestUrl, { method: 'GET', headers: { Accept: 'application/json' }, - }, Math.min(8000, Math.max(1000, pollIntervalSeconds * 1000))); + }, requestTimeoutMs); const otp = normalizeIncomingGpcSmsOtp(data || {}); if (response?.ok && otp) { await setState({ @@ -364,12 +560,56 @@ } catch (error) { lastMessage = error?.message || String(error || '未知错误'); } + if (singleAttempt) { + break; + } await sleepWithStop(pollIntervalSeconds * 1000); } throw new Error(lastMessage || '本地 SMS Helper 等待 OTP 超时。'); } - async function requestGpcOtpInput({ title = '', message = '', referenceId = '' }) { + function buildGpcTaskEndedError(task = {}, fallbackMessage = '') { + const detail = buildGpcTaskTerminalError(task) || fallbackMessage || 'GPC 任务已结束,请重新创建任务。'; + return new Error(`GPC_TASK_ENDED::${detail}`); + } + + function isGpcTaskInputDeadlineExpired(task = {}) { + const deadlineMs = normalizeEpochMilliseconds(task?.api_input_deadline_at || task?.apiInputDeadlineAt || ''); + return deadlineMs > 0 && Date.now() > deadlineMs; + } + + function buildGpcInputDeadlineError(task = {}, label = '输入') { + const stage = String(task?.remote_stage || '').trim(); + const detail = `${label}提交已超时,请重新创建任务。`; + return new Error(`GPC_TASK_ENDED::${detail}${stage ? `(${stage})` : ''}`); + } + + function normalizeSixDigitOtp(value = '') { + const otp = String(value || '').trim().replace(/[^\d]/g, ''); + return /^\d{6}$/.test(otp) ? otp : ''; + } + + function normalizeSixDigitPin(value = '') { + const pin = String(value || '').trim().replace(/[^\d]/g, ''); + return /^\d{6}$/.test(pin) ? pin : ''; + } + + function isGpcOtpFormatConflict(error) { + return /OTP\s*必须是\s*6\s*位数字|OTP.*6.*digit|task_conflict/i.test(error?.message || String(error || '')); + } + + async function requestGpcOtpInput({ title = '', message = '', taskId = '', lastInputError = '', inputDeadlineAt = '' }) { + const existingState = await getStateInternal(); + const existingRequestId = String(existingState?.plusManualConfirmationRequestId || '').trim(); + if ( + existingState?.plusManualConfirmationPending + && existingRequestId + && String(existingState?.plusManualConfirmationMethod || '').trim().toLowerCase() === 'gopay-otp' + && String(existingState?.gopayHelperOtpReferenceId || '').trim() === String(taskId || '').trim() + ) { + return waitForGpcOtpInput(existingRequestId, { inputDeadlineAt }); + } + const requestId = `otp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const payload = { plusManualConfirmationPending: true, @@ -378,18 +618,45 @@ plusManualConfirmationMethod: 'gopay-otp', plusManualConfirmationTitle: title || 'GPC OTP 验证', plusManualConfirmationMessage: message || '请输入 OTP 验证码', - gopayHelperOtpRequestId: requestId, - gopayHelperOtpReferenceId: referenceId, + gopayHelperLastInputError: String(lastInputError || '').trim(), + gopayHelperApiInputDeadlineAt: String(inputDeadlineAt || '').trim(), gopayHelperResolvedOtp: '', + gopayHelperOtpRequestId: requestId, + gopayHelperOtpReferenceId: taskId, }; await setState(payload); if (typeof broadcastDataUpdate === 'function') { broadcastDataUpdate(payload); } + return waitForGpcOtpInput(requestId, { inputDeadlineAt }); + } + + function waitForGpcOtpInput(requestId = '', options = {}) { + const deadlineMs = normalizeEpochMilliseconds(options?.inputDeadlineAt || options?.input_deadline_at || 0); return new Promise((resolve, reject) => { const checkInterval = setInterval(async () => { try { throwIfStopped(); + if (deadlineMs > 0 && Date.now() > deadlineMs) { + clearInterval(checkInterval); + const clearPayload = { + plusManualConfirmationPending: false, + plusManualConfirmationRequestId: '', + plusManualConfirmationStep: 0, + plusManualConfirmationMethod: '', + plusManualConfirmationTitle: '', + plusManualConfirmationMessage: '', + gopayHelperResolvedOtp: '', + gopayHelperOtpRequestId: '', + gopayHelperOtpReferenceId: '', + }; + await setState(clearPayload); + if (typeof broadcastDataUpdate === 'function') { + broadcastDataUpdate(clearPayload); + } + reject(new Error('OTP 输入超时')); + return; + } const currentState = await getStateInternal(); if (!currentState?.plusManualConfirmationPending || currentState?.plusManualConfirmationRequestId !== requestId) { clearInterval(checkInterval); @@ -408,122 +675,308 @@ }); } + function isGpcTaskOtpWait(task = {}) { + return task?.phone_mode === 'manual' && task?.api_waiting_for === 'otp'; + } + + function isGpcTaskPinWait(task = {}) { + return task?.phone_mode === 'manual' + && (task?.api_waiting_for === 'pin' || task?.status === 'otp_ready'); + } + + function isGpcTaskTerminal(status = '') { + return ['completed', 'failed', 'expired', 'discarded'].includes(String(status || '').trim().toLowerCase()); + } + + function buildGpcTaskTerminalError(task = {}) { + const status = String(task?.status || '').trim().toLowerCase(); + const remoteStage = String(task?.remote_stage || task?.remoteStage || '').trim(); + const failureStage = String(task?.failure_stage || task?.failureStage || '').trim(); + const detail = String( + task?.error_message + || task?.errorMessage + || task?.failure_detail + || task?.failureDetail + || task?.last_input_error + || task?.lastInputError + || task?.status_text + || task?.statusText + || task?.message + || task?.detail + || task?.data?.detail + || '' + ).trim(); + if (detail) { + return failureStage && !detail.includes(failureStage) + ? `${detail}(${failureStage})` + : detail; + } + if (/api_otp_timeout/i.test(remoteStage)) { + return 'GPC OTP 超时,请重新创建任务'; + } + if (/api_pin_timeout/i.test(remoteStage)) { + return 'GPC PIN 超时,请重新创建任务'; + } + if (failureStage) { + return `GPC 任务失败:${failureStage}`; + } + return `任务状态 ${status || '未知'}`; + } + + async function stopGpcTaskBestEffort(apiUrl, taskId, apiKey, reason = '') { + if (!apiUrl || !taskId || !apiKey) { + return; + } + try { + const task = await postGpcTaskAction(apiUrl, taskId, 'stop', {}, apiKey, 15000); + const statusText = task?.status_text || task?.status || '已停止'; + await addLog(`步骤 7:已请求停止 GPC 任务(${statusText})。`, 'warn'); + } catch (error) { + await addLog(`步骤 7:停止 GPC 任务失败${reason ? `(${reason})` : ''}:${error?.message || String(error || '未知错误')}`, 'warn'); + } + } + + async function clearGpcTaskRuntimeState() { + const updates = { + plusManualConfirmationPending: false, + plusManualConfirmationRequestId: '', + plusManualConfirmationStep: 0, + plusManualConfirmationMethod: '', + plusManualConfirmationTitle: '', + plusManualConfirmationMessage: '', + gopayHelperTaskId: '', + gopayHelperTaskStatus: '', + gopayHelperStatusText: '', + gopayHelperRemoteStage: '', + gopayHelperApiWaitingFor: '', + gopayHelperApiInputDeadlineAt: '', + gopayHelperApiInputWaitSeconds: 0, + gopayHelperLastInputError: '', + gopayHelperOtpInvalidCount: 0, + gopayHelperFailureStage: '', + gopayHelperFailureDetail: '', + gopayHelperTaskPayload: null, + gopayHelperPinPayload: null, + gopayHelperResolvedOtp: '', + gopayHelperOtpRequestId: '', + gopayHelperOtpReferenceId: '', + }; + await setState(updates); + if (typeof broadcastDataUpdate === 'function') { + broadcastDataUpdate(updates); + } + } + + function isGpcTaskEndedError(error) { + return /^GPC_TASK_ENDED::/i.test(error?.message || String(error || '')); + } + + async function resolveGpcTaskOtp(state = {}, taskId = '', options = {}) { + let otp = ''; + const useLocalSmsHelper = Boolean(state?.gopayHelperLocalSmsHelperEnabled); + const retryCount = Math.max(0, Number(options?.retryCount) || 0); + const helperAfterMs = normalizeEpochMilliseconds(options?.afterMs || options?.after_ms || 0); + const lastInputError = String(options?.lastInputError || options?.last_input_error || '').trim(); + if (useLocalSmsHelper) { + try { + await addLog( + retryCount > 0 || lastInputError + ? `步骤 7:${lastInputError || 'OTP 校验未通过'},正在从本地 OTP Helper 等待新的 GPC OTP...` + : '步骤 7:正在从本地 OTP Helper 等待 GPC OTP...', + 'info' + ); + otp = await pollLocalSmsHelperOtp(state, taskId, { + afterMs: helperAfterMs, + singleAttempt: true, + requestTimeoutMs: 2000, + timeoutSeconds: 2, + }); + await addLog('步骤 7:本地 OTP Helper 已读取到 GPC OTP,准备提交验证。', 'ok'); + } catch (error) { + await addLog(`步骤 7:本地 OTP Helper 暂未读取到新 OTP:${error?.message || String(error || '未知错误')},继续等待远端任务状态更新。`, 'warn'); + } + } + if (otp) { + return otp; + } + if (useLocalSmsHelper) { + return ''; + } + await addLog('步骤 7:等待用户输入 OTP...', 'info'); + return requestGpcOtpInput({ + title: 'GPC OTP 验证', + message: retryCount > 0 || lastInputError + ? `${lastInputError || '上一次 OTP 校验未通过'},请重新输入正确的 OTP 验证码(task_id: ${taskId})` + : `请输入收到的 OTP 验证码(task_id: ${taskId})`, + lastInputError, + inputDeadlineAt: options?.inputDeadlineAt || options?.input_deadline_at || '', + taskId, + }); + } + async function executeGpcHelperBilling(state = {}) { - const referenceId = String(state?.gopayHelperReferenceId || '').trim(); + const taskId = String(state?.gopayHelperTaskId || '').trim(); const apiUrl = normalizeGpcHelperBaseUrl(state?.gopayHelperApiUrl || ''); - const cardKey = String(state?.gopayHelperCardKey || state?.gpcCardKey || state?.cardKey || '').trim(); - if (!referenceId) { - throw new Error('步骤 7:GPC 模式缺少 reference_id,请先执行步骤 6。'); + const apiKey = String( + state?.gopayHelperApiKey + || state?.gpcApiKey + || state?.apiKey + || '' + ).trim(); + const deadline = Date.now() + Math.max(120, Math.min(1200, Number(state?.gopayHelperTaskTimeoutSeconds) || 900)) * 1000; + let otpSubmitCount = 0; + let otpLastSubmittedAt = 0; + let lastSubmittedOtp = ''; + let pinSubmitted = false; + let terminalReached = false; + + if (!taskId) { + throw new Error('步骤 7:GPC 模式缺少 task_id,请先执行步骤 6。'); } if (!apiUrl) { throw new Error('步骤 7:GPC 模式缺少 API 地址。'); } - if (!cardKey) { - throw new Error('步骤 7:GPC 模式缺少卡密。'); - } - await addLog(`步骤 7:GPC 模式开始 OTP 验证(reference_id: ${referenceId})...`, 'info'); - let otp = ''; - const useLocalSmsHelper = Boolean(state?.gopayHelperLocalSmsHelperEnabled); - if (useLocalSmsHelper) { - try { - await addLog('步骤 7:正在从本地 SMS Helper 等待 GPC OTP...', 'info'); - otp = await pollLocalSmsHelperOtp(state, referenceId); - await addLog('步骤 7:本地 SMS Helper 已读取到 GPC OTP,准备提交验证。', 'ok'); - } catch (error) { - await addLog(`步骤 7:本地 SMS Helper 未能自动读取 OTP:${error?.message || String(error || '未知错误')},改为手动输入。`, 'warn'); - } - } - if (!otp) { - await addLog('步骤 7:等待用户输入 OTP...', 'info'); - otp = await requestGpcOtpInput({ - title: 'GPC OTP 验证', - message: `请输入收到的 OTP 验证码(reference_id: ${referenceId})`, - referenceId, - }); + if (!apiKey) { + throw new Error('步骤 7:GPC 模式缺少 API Key。'); } - const flowId = state?.gopayHelperFlowId - || state?.gopayHelperStartPayload?.flow_id - || state?.gopayHelperStartPayload?.flowId - || state?.gopayHelperBalancePayload?.flow_id - || state?.gopayHelperBalancePayload?.flowId - || ''; - const baseInput = { - reference_id: referenceId, - otp, - card_key: cardKey, - gopay_guid: state?.gopayHelperGoPayGuid || '', - redirect_url: state?.gopayHelperRedirectUrl || '', - flow_id: flowId, - }; - await addLog('步骤 7:正在提交 OTP...', 'info'); - const otpResponse = await postGpcJsonWithFallback( - apiUrl, - '/api/gopay/otp', - buildGpcOtpPayload(baseInput), - buildGpcOtpRetryPayload(baseInput), - 30000 - ); - if (!otpResponse?.response?.ok) { - throw new Error(`步骤 7:OTP 验证失败:${getGpcResponseErrorDetail(otpResponse?.data, otpResponse?.response?.status || 0)}`); - } - - const otpData = otpResponse?.data || {}; - const challengeId = String( - otpData?.challenge_id - || otpData?.challengeId - || state?.gopayHelperChallengeId - || state?.gopayHelperStartPayload?.challenge_id - || state?.gopayHelperStartPayload?.challengeId - || '' - ).trim(); - const nextFlowId = String(otpData?.flow_id || otpData?.flowId || baseInput.flow_id || '').trim(); - const gopayGuid = String(otpData?.gopay_guid || otpData?.gopayGuid || state?.gopayHelperGoPayGuid || '').trim(); - const redirectUrl = String(otpData?.redirect_url || otpData?.redirectUrl || state?.gopayHelperRedirectUrl || '').trim(); - if (!challengeId) { - throw new Error('步骤 7:GPC OTP 验证后未返回 challenge_id。'); - } - const pin = String(state?.gopayHelperPin || '').trim().replace(/[^\d]/g, ''); + const rawPin = String(state?.gopayHelperPin || '').trim(); + const pinDigits = rawPin.replace(/[^\d]/g, ''); + const pin = normalizeSixDigitPin(rawPin); if (!pin) { - throw new Error('步骤 7:GPC 模式缺少 PIN 配置。'); + if (taskId && apiUrl && apiKey) { + await stopGpcTaskBestEffort(apiUrl, taskId, apiKey, 'PIN 配置错误'); + } + throw new Error(pinDigits + ? '步骤 7:GPC PIN 必须是 6 位数字,请检查侧边栏配置。' + : '步骤 7:GPC 模式缺少 PIN 配置。'); } - await setState({ - gopayHelperChallengeId: challengeId, - gopayHelperFlowId: nextFlowId, - gopayHelperGoPayGuid: gopayGuid, - gopayHelperRedirectUrl: redirectUrl, - }); + await addLog(`步骤 7:GPC 模式开始轮询任务(task_id: ${taskId})...`, 'info'); + try { + while (Date.now() <= deadline) { + throwIfStopped(); + const task = await fetchGpcTaskStatus(apiUrl, taskId, apiKey); + const statusText = task?.status_text || task?.status || '处理中'; + const remoteStage = task?.remote_stage || ''; + const waitingFor = task?.api_waiting_for || ''; + await addLog(`步骤 7:GPC 任务状态:${statusText}${remoteStage ? `(${remoteStage})` : ''}${waitingFor ? `,等待 ${waitingFor.toUpperCase()}` : ''}`, 'info'); - await addLog('步骤 7:正在提交 PIN...', 'info'); - const pinInput = { - reference_id: referenceId, - challenge_id: challengeId, - gopay_guid: gopayGuid, - redirect_url: redirectUrl, - flow_id: nextFlowId, - pin, - card_key: cardKey, - }; - const pinResponse = await postGpcJsonWithFallback( - apiUrl, - '/api/gopay/pin', - buildGpcPinPayload(pinInput), - buildGpcPinRetryPayload(pinInput), - 30000 - ); - if (!pinResponse?.response?.ok) { - throw new Error(`步骤 7:PIN 验证失败:${getGpcResponseErrorDetail(pinResponse?.data, pinResponse?.response?.status || 0)}`); + if (task.status === 'completed') { + terminalReached = true; + await setState({ + plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER, + }); + await addLog('步骤 7:GPC 任务已完成,准备继续下一步。', 'ok'); + await completeStepFromBackground(7, { + plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER, + }); + return; + } + + if (['failed', 'expired', 'discarded'].includes(task.status)) { + terminalReached = true; + throw buildGpcTaskEndedError(task, 'GPC 任务已结束,请重新创建任务。'); + } + + if (isGpcTaskOtpWait(task)) { + if (isGpcTaskInputDeadlineExpired(task)) { + throw buildGpcInputDeadlineError(task, 'OTP'); + } + if (task.last_input_error) { + await addLog( + `步骤 7:${task.last_input_error}${task.otp_invalid_count ? `(OTP 错误 ${task.otp_invalid_count} 次)` : ''}`, + 'warn' + ); + } + let otp = ''; + try { + otp = await resolveGpcTaskOtp(state, taskId, { + retryCount: otpSubmitCount, + afterMs: otpLastSubmittedAt, + lastInputError: task.last_input_error, + inputDeadlineAt: task.api_input_deadline_at, + }); + } catch (error) { + if (/OTP\s*输入(?:已取消|超时)/i.test(error?.message || String(error || ''))) { + throw new Error(`GPC_TASK_ENDED::${error?.message || 'OTP 输入已取消'},已结束当前 GPC 任务。`); + } + throw error; + } + if (!otp) { + await sleepWithStop(GPC_TASK_POLL_INTERVAL_MS); + continue; + } + const normalizedOtp = normalizeSixDigitOtp(otp); + if (!normalizedOtp) { + await addLog('步骤 7:OTP 必须是 6 位数字,等待重新输入。', 'warn'); + await sleepWithStop(GPC_TASK_POLL_INTERVAL_MS); + continue; + } + if (task.last_input_error && lastSubmittedOtp && normalizedOtp === lastSubmittedOtp) { + await addLog('步骤 7:本地 OTP Helper 返回的仍是上次已失败 OTP,等待新的验证码。', 'warn'); + await sleepWithStop(GPC_TASK_POLL_INTERVAL_MS); + continue; + } + await addLog('步骤 7:正在提交 OTP...', 'info'); + try { + await postGpcTaskAction( + apiUrl, + taskId, + 'otp', + buildGpcTaskOtpPayload({ otp: normalizedOtp }), + apiKey, + 30000 + ); + } catch (error) { + if (isGpcOtpFormatConflict(error)) { + await addLog(`步骤 7:OTP 提交被拒绝:${error?.message || String(error || 'OTP 格式错误')},等待重新输入。`, 'warn'); + await sleepWithStop(GPC_TASK_POLL_INTERVAL_MS); + continue; + } + throw error; + } + otpSubmitCount += 1; + otpLastSubmittedAt = Date.now(); + lastSubmittedOtp = normalizedOtp; + await addLog('步骤 7:OTP 已提交,继续等待 GPC 任务状态更新。', 'ok'); + } else if (isGpcTaskPinWait(task) && !pinSubmitted) { + if (isGpcTaskInputDeadlineExpired(task)) { + throw buildGpcInputDeadlineError(task, 'PIN'); + } + await addLog('步骤 7:正在提交 PIN...', 'info'); + let pinTask = null; + try { + pinTask = await postGpcTaskAction( + apiUrl, + taskId, + 'pin', + buildGpcTaskPinPayload({ pin }), + apiKey, + 30000 + ); + } catch (error) { + throw new Error(`GPC_TASK_ENDED::${error?.message || String(error || 'PIN 提交失败,请重新创建任务。')}`); + } + pinSubmitted = true; + await setState({ + gopayHelperPinPayload: pinTask, + }); + await addLog('步骤 7:PIN 已提交,继续轮询直到任务完成。', 'ok'); + } + + await sleepWithStop(GPC_TASK_POLL_INTERVAL_MS); + } + throw new Error('步骤 7:GPC 任务轮询超时。'); + } catch (error) { + if (!terminalReached) { + await stopGpcTaskBestEffort(apiUrl, taskId, apiKey, error?.message || '流程中断'); + } + if (isGpcTaskEndedError(error)) { + await clearGpcTaskRuntimeState(); + } + throw error; } - - await setState({ - plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER, - gopayHelperPinPayload: pinResponse?.data || null, - }); - await addLog('步骤 7:GPC 支付完成,准备继续下一步。', 'ok'); - await completeStepFromBackground(7, { - plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER, - }); } function resolveMeiguodizhiCountryCode(value = '') { diff --git a/background/steps/wait-registration-success.js b/background/steps/wait-registration-success.js index 76ffe46..945bfd2 100644 --- a/background/steps/wait-registration-success.js +++ b/background/steps/wait-registration-success.js @@ -2,21 +2,152 @@ root.MultiPageBackgroundStep6 = factory(); })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep6Module() { const DEFAULT_REGISTRATION_SUCCESS_WAIT_MS = 20000; + const STEP6_COOKIE_CLEAR_DOMAINS = [ + 'chatgpt.com', + 'chat.openai.com', + 'openai.com', + 'auth.openai.com', + 'auth0.openai.com', + 'accounts.openai.com', + ]; + const STEP6_COOKIE_CLEAR_ORIGINS = [ + 'https://chatgpt.com', + 'https://chat.openai.com', + 'https://auth.openai.com', + 'https://auth0.openai.com', + 'https://accounts.openai.com', + 'https://openai.com', + ]; + + function normalizeStep6CookieDomain(domain) { + return String(domain || '').trim().replace(/^\.+/, '').toLowerCase(); + } + + function shouldClearStep6Cookie(cookie) { + const domain = normalizeStep6CookieDomain(cookie?.domain); + if (!domain) return false; + return STEP6_COOKIE_CLEAR_DOMAINS.some((target) => ( + domain === target || domain.endsWith(`.${target}`) + )); + } + + function buildStep6CookieRemovalUrl(cookie) { + const host = normalizeStep6CookieDomain(cookie?.domain); + const rawPath = String(cookie?.path || '/'); + const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`; + return `https://${host}${path}`; + } + + async function collectStep6Cookies(chromeApi) { + if (!chromeApi.cookies?.getAll) { + return []; + } + + const stores = chromeApi.cookies.getAllCookieStores + ? await chromeApi.cookies.getAllCookieStores() + : [{ id: undefined }]; + const cookies = []; + const seen = new Set(); + + for (const store of stores) { + const storeId = store?.id; + const batch = await chromeApi.cookies.getAll(storeId ? { storeId } : {}); + for (const cookie of batch || []) { + if (!shouldClearStep6Cookie(cookie)) continue; + const key = [ + cookie.storeId || storeId || '', + cookie.domain || '', + cookie.path || '', + cookie.name || '', + cookie.partitionKey ? JSON.stringify(cookie.partitionKey) : '', + ].join('|'); + if (seen.has(key)) continue; + seen.add(key); + cookies.push(cookie); + } + } + + return cookies; + } + + async function removeStep6Cookie(chromeApi, cookie, getErrorMessage) { + const details = { + url: buildStep6CookieRemovalUrl(cookie), + name: cookie.name, + }; + if (cookie.storeId) { + details.storeId = cookie.storeId; + } + if (cookie.partitionKey) { + details.partitionKey = cookie.partitionKey; + } + + try { + const result = await chromeApi.cookies.remove(details); + return Boolean(result); + } catch (error) { + console.warn('[MultiPage:step6] remove cookie failed', { + domain: cookie?.domain, + name: cookie?.name, + message: getErrorMessage(error), + }); + return false; + } + } function createStep6Executor(deps = {}) { const { addLog = async () => {}, + chrome: chromeApi = globalThis.chrome, completeStepFromBackground, + getErrorMessage = (error) => error?.message || String(error || '未知错误'), registrationSuccessWaitMs = DEFAULT_REGISTRATION_SUCCESS_WAIT_MS, sleepWithStop = async (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0))), } = deps; - async function executeStep6() { + async function clearCookiesIfEnabled(state = {}) { + if (!state?.step6CookieCleanupEnabled) { + return; + } + if (!chromeApi?.cookies?.getAll || !chromeApi.cookies?.remove) { + await addLog('步骤 6:当前浏览器不支持 cookies API,跳过第六步 Cookies 清理。', 'warn'); + return; + } + + try { + await addLog('步骤 6:已开启 Cookies 清理,正在清理 ChatGPT / OpenAI cookies...', 'info'); + const cookies = await collectStep6Cookies(chromeApi); + let removedCount = 0; + for (const cookie of cookies) { + if (await removeStep6Cookie(chromeApi, cookie, getErrorMessage)) { + removedCount += 1; + } + } + + if (chromeApi.browsingData?.removeCookies) { + try { + await chromeApi.browsingData.removeCookies({ + since: 0, + origins: STEP6_COOKIE_CLEAR_ORIGINS, + }); + } catch (error) { + await addLog(`步骤 6:browsingData 补扫 cookies 失败:${getErrorMessage(error)}`, 'warn'); + } + } + + await addLog(`步骤 6:已清理 ${removedCount} 个 ChatGPT / OpenAI cookies。`, 'ok'); + } catch (error) { + await addLog(`步骤 6:Cookies 清理失败,已跳过并继续后续流程:${getErrorMessage(error)}`, 'warn'); + } + } + + async function executeStep6(state = {}) { const waitMs = Math.max(0, Math.floor(Number(registrationSuccessWaitMs) || 0)); if (waitMs > 0) { await addLog(`步骤 6:等待 ${Math.round(waitMs / 1000)} 秒,确认注册成功并让页面稳定...`, 'info'); await sleepWithStop(waitMs); } + await clearCookiesIfEnabled(state); await addLog('步骤 6:注册成功等待完成,准备继续获取 OAuth 链接并登录。', 'ok'); await completeStepFromBackground(6); } diff --git a/docs/使用教程/使用教程.md b/docs/使用教程/使用教程.md index dcc8110..8946cc0 100644 --- a/docs/使用教程/使用教程.md +++ b/docs/使用教程/使用教程.md @@ -28,6 +28,7 @@ | 06 | PayPal 注册与绑卡使用教程 | `PayPal`、注册、绑卡、钱包、身份认证、右上角通知 | `docs/使用教程/分部分/06-PayPal-注册与绑卡使用教程.md` | | 07 | ChatGPT Plus 订阅说明(待人工审核) | `ChatGPT Plus`、订阅说明、支付流程说明、人工审核 | `docs/使用教程/分部分/07-0元试用-ChatGPT-Plus-教程.md` | | 08 | Clash Verge 非港轮询配置 | `Clash Verge`、`非港轮询`、扩展脚本、规则模式、系统代理 | `docs/使用教程/分部分/08-Clash-Verge-非港轮询配置.md` | +| 09 | GPC 卡密与 API 使用说明 | `GPC`、充值卡密、`API` 卡密、`API Key`、自动化扩展、短信 `Helper` | `docs/使用教程/分部分/09-GPC-卡密与-API-使用说明.md` | --- @@ -38,7 +39,7 @@ - `HeroSMS` 手机接码扩展使用教程 - 节点检测与纯净度检查网站使用教程 -如果后续收到这些主题的新增内容,优先新开部分,而不是硬塞到现有 8 个文件里。 +如果后续收到这些主题的新增内容,优先新开部分,而不是硬塞到现有 9 个文件里。 --- @@ -68,3 +69,4 @@ 3. 根据你当前使用的邮箱方案,选择 `03`、`04`、`05` 4. 如果需要支付和订阅,再看 `06`、`07` 5. 如果需要代理配置,再看 `08` +6. 如果需要 `GPC` 卡密、`API Key` 或自动化接入,再看 `09` diff --git a/docs/使用教程/使用教程书写模板.md b/docs/使用教程/使用教程书写模板.md index 95c3d0d..7feb77c 100644 --- a/docs/使用教程/使用教程书写模板.md +++ b/docs/使用教程/使用教程书写模板.md @@ -78,6 +78,7 @@ AI 维护时默认按下面的范围归类: | 06 | PayPal 注册与绑卡使用教程 | `PayPal`、注册、绑卡、钱包、身份认证、右上角通知 | `docs/使用教程/分部分/06-PayPal-注册与绑卡使用教程.md` | | 07 | ChatGPT Plus 订阅说明(待人工审核) | `ChatGPT Plus`、订阅说明、支付流程说明、人工审核 | `docs/使用教程/分部分/07-0元试用-ChatGPT-Plus-教程.md` | | 08 | Clash Verge 非港轮询配置 | `Clash Verge`、`非港轮询`、扩展脚本、规则模式、系统代理 | `docs/使用教程/分部分/08-Clash-Verge-非港轮询配置.md` | +| 09 | GPC 卡密与 API 使用说明 | `GPC`、充值卡密、`API` 卡密、`API Key`、自动化扩展、短信 `Helper` | `docs/使用教程/分部分/09-GPC-卡密与-API-使用说明.md` | --- @@ -85,7 +86,7 @@ AI 维护时默认按下面的范围归类: 满足任意一条,就应该新建部分,而不是硬塞到旧文件里: -1. 新内容的主题和现有 8 个部分都不匹配 +1. 新内容的主题和现有 9 个部分都不匹配 2. 新内容会让已有文件明显跑题 3. 新内容已经形成独立功能块,后续大概率会继续单独维护 4. 新内容虽然在总述里提到过,但当前还没有专门的部分文件 diff --git a/docs/使用教程/分部分/09-GPC-卡密与-API-使用说明.md b/docs/使用教程/分部分/09-GPC-卡密与-API-使用说明.md new file mode 100644 index 0000000..ee4b69e --- /dev/null +++ b/docs/使用教程/分部分/09-GPC-卡密与-API-使用说明.md @@ -0,0 +1,129 @@ +# 第九部分:GPC 卡密与 API 使用说明 + +## 部分信息 + +- `section_slug`: `gpc-card-code-and-api` +- `适用主题`: `GPC`、充值卡密、`API` 卡密、`API Key`、自动化扩展、短信 `Helper` +- `维护方式`: `直接更新本文件` + +## 适用场景 + +- 需要区分 `GPC` 充值卡密和 `API` 卡密 +- 号商需要给指定账号直接开通 `ChatGPT Plus` +- 自动化扩展插件需要接入 `GPC` 模式 +- 中转站、账号系统或反代系统需要批量自动注册、补号和订阅 +- 需要配合短信 `Helper` 自动补号、自动提交验证码 + +## 准备内容 + +- 已获取对应类型的 `GPC` 卡密 +- 可以打开 `GPC` 前台 +- 如果使用 `API` 模式,需要准备自动化扩展插件 +- 如果需要全自动补号,需要准备并配置短信 `Helper` +- 如果还没有 `API` 次数套餐,可以先打开购买页面:[https://pay.ldxp.cn/item/9u90k7](https://pay.ldxp.cn/item/9u90k7) + +## 核心区别 + +| 类型 | 适合对象 | 主要用途 | 使用方式 | +|---|---|---|---| +| 充值卡密 | 号商、人工直冲用户 | 给指定账号直接开通 `ChatGPT Plus` | 在 `GPC` 前台选择充值卡密登录后创建充值任务 | +| `API` 卡密 | 自动化插件、中转站、反代系统 | 批量自动补号、自动注册、自动订阅 | 先兑换成 `API Key`,再填入自动化扩展或系统 | + +简单理解: + +- 充值卡密 = 给某个账号直接充值 `Plus` +- `API` 卡密 = 兑换接口额度,用于自动化扩展或系统集成 + +## 核心优势 + +- 私有协议链路,流程更稳定 +- 授权 `CODEX` 后,99% 场景不风控、不弹 `add-phone` +- 无需额外接码平台,配合短信 `Helper` 即可自动处理验证码 +- `API` 模式适合自动化补号、批量注册、批量订阅和反代系统接入 +- 充值卡密适合单账号直冲,`API` 卡密适合系统化批量接入 + +## 操作步骤 + +### 方案一:使用充值卡密直冲 + +充值卡密主要用于给指定账号直接开通 `ChatGPT Plus`,适合人工或半自动的账号直冲场景。客户只需要提供要充值账号的授权信息,不需要自己准备 `GoPay`。 + +#### 第一步:获取充值卡密 + +先确认手里拿到的是充值卡密,而不是 `API` 卡密。 + +#### 第二步:打开 `GPC` 前台 + +进入 `GPC` 前台页面后,选择充值卡密相关入口。 + +#### 第三步:选择充值卡密登录 + +在登录方式中选择 `充值卡密登录`,然后输入充值卡密。 + +#### 第四步:填写要充值账号的授权信息 + +按照页面提示输入需要开通 `Plus` 的账号授权信息。 + +#### 第五步:创建充值任务 + +确认账号信息无误后创建充值任务,并等待系统执行。 + +#### 第六步:确认任务结果 + +任务成功后,会扣减 1 次卡密次数。此时对应账号的 `ChatGPT Plus` 开通流程即完成。 + +### 方案二:使用 `API` 卡密接入自动化扩展 + +`API` 卡密本身不是直接拿来请求任务接口的。使用前需要先兑换出 `API Key`,后续由自动化插件或系统使用 `API Key` 调用。 + +#### 第一步:购买 `API` 次数套餐 + +打开下面页面购买对应次数套餐: + +[https://pay.ldxp.cn/item/9u90k7](https://pay.ldxp.cn/item/9u90k7) + +#### 第二步:兑换 `API Key` + +购买后打开下面页面兑换: + +[https://gpc.qlhazycoder.top/兑换](https://gpc.qlhazycoder.top/%E5%85%91%E6%8D%A2) + +输入 `API` 卡密后,会获得一个 `API Key`。 + +#### 第三步:填写到自动化扩展 + +打开自动化扩展插件后,按下面顺序配置: + +1. 启用 `GPC 模式` +2. 填写兑换得到的 `API Key` +3. 填写 `GPC` 相关参数 +4. 如需全自动补号,开启并配置短信 `Helper` +5. 保存配置 + +配置保存后,自动化扩展即可开始执行注册账号和开通流程。 + +## 常见问题 + +### 充值卡密可以填到自动化扩展里吗? + +不建议。充值卡密主要用于 `GPC` 前台的账号直冲流程;自动化扩展应使用 `API` 卡密兑换出来的 `API Key`。 + +### `API` 卡密可以直接请求任务接口吗? + +不可以。`API` 卡密需要先到兑换页面换成 `API Key`,后续请求接口或填写扩展配置时使用的是 `API Key`。 + +### 忘记 `API Key` 怎么办? + +可以回到兑换页面,用原来的 `API` 卡密再次查看对应的 `API Key`。 + +### 什么时候会扣减次数? + +充值卡密通常在充值任务成功后扣减 1 次。`API` 模式的次数扣减以接口任务执行结果和系统记录为准。 + +## 注意事项 + +- 充值卡密和 `API` 卡密用途不同,请不要混用 +- `API` 卡密需要先兑换成 `API Key`,再填入自动化扩展 +- `API Key` 请妥善保存,不要公开泄露 +- 忘记 `API Key` 时,可以回到兑换页面用原 `API` 卡密再次查看 +- 全自动补号场景需要同时确认短信 `Helper` 已正确启用 diff --git a/gopay-utils.js b/gopay-utils.js index 53eb4c1..0fe3e9b 100644 --- a/gopay-utils.js +++ b/gopay-utils.js @@ -4,7 +4,8 @@ const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal'; const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; - const DEFAULT_GPC_HELPER_API_URL = 'https://gopay.hwork.pro'; + const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top'; + const LEGACY_GPC_HELPER_API_URL = 'https://gpc.leftcode.xyz'; function normalizePlusPaymentMethod(value = '') { const normalized = String(value || '').trim().toLowerCase(); @@ -62,7 +63,13 @@ normalized = normalized.replace(/\/+$/g, ''); normalized = normalized.replace(/\/api\/checkout\/start$/i, ''); normalized = normalized.replace(/\/api\/gopay\/(?:otp|pin)$/i, ''); + normalized = normalized.replace(/\/api\/gp\/tasks(?:\/[^/?#]+)?(?:\/(?:otp|pin|stop))?(?:\?.*)?$/i, ''); + normalized = normalized.replace(/\/api\/gp\/balance(?:\?.*)?$/i, ''); normalized = normalized.replace(/\/api\/card\/balance(?:\?.*)?$/i, ''); + normalized = normalized.replace(/\/api\/card\/redeem-api-key(?:\?.*)?$/i, ''); + if (normalized === LEGACY_GPC_HELPER_API_URL) { + return DEFAULT_GPC_HELPER_API_URL; + } return normalized || DEFAULT_GPC_HELPER_API_URL; } @@ -75,12 +82,85 @@ return `${baseUrl}${normalizedPath}`; } - function buildGpcCardBalanceUrl(apiUrl = '', cardKey = '') { - const endpoint = buildGpcHelperApiUrl(apiUrl, '/api/card/balance'); - if (!endpoint) { + function buildGpcApiKeyBalanceUrl(apiUrl = '') { + return buildGpcHelperApiUrl(apiUrl, '/api/gp/balance'); + } + + function buildGpcCardBalanceUrl(apiUrl = '') { + return buildGpcApiKeyBalanceUrl(apiUrl); + } + + function buildGpcApiKeyHeaders(apiKey = '', extraHeaders = {}) { + const headers = { + ...(extraHeaders && typeof extraHeaders === 'object' ? extraHeaders : {}), + }; + const normalizedApiKey = String(apiKey || '').trim(); + if (normalizedApiKey) { + headers['X-API-Key'] = normalizedApiKey; + } + return headers; + } + + function buildGpcTaskCreateUrl(apiUrl = '') { + return buildGpcHelperApiUrl(apiUrl, '/api/gp/tasks'); + } + + function normalizeGpcTaskId(value = '') { + return String(value || '').trim(); + } + + function buildGpcTaskQueryUrl(apiUrl = '', taskId = '') { + const normalizedTaskId = normalizeGpcTaskId(taskId); + return buildGpcHelperApiUrl(apiUrl, `/api/gp/tasks/${encodeURIComponent(normalizedTaskId)}`); + } + + function buildGpcTaskActionUrl(apiUrl = '', taskId = '', action = '') { + const normalizedTaskId = normalizeGpcTaskId(taskId); + const normalizedAction = String(action || '').trim().replace(/^\/+|\/+$/g, ''); + return buildGpcHelperApiUrl(apiUrl, `/api/gp/tasks/${encodeURIComponent(normalizedTaskId)}/${normalizedAction}`); + } + + function unwrapGpcResponse(payload = {}) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return payload; + } + const hasUnifiedShape = Object.prototype.hasOwnProperty.call(payload, 'data') + && ( + Object.prototype.hasOwnProperty.call(payload, 'code') + || Object.prototype.hasOwnProperty.call(payload, 'message') + ); + return hasUnifiedShape ? (payload.data ?? {}) : payload; + } + + function isGpcUnifiedResponseOk(payload = {}) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return true; + } + if (!Object.prototype.hasOwnProperty.call(payload, 'code')) { + return payload.ok !== false; + } + const code = Number(payload.code); + if (Number.isFinite(code)) { + return code >= 200 && code < 300; + } + return String(payload.code || '').trim() === '200'; + } + + function formatGpcErrorField(field) { + if (field === undefined || field === null) { return ''; } - return `${endpoint}?card_key=${encodeURIComponent(String(cardKey || '').trim())}`; + if (typeof field === 'string') { + return field.trim(); + } + if (typeof field !== 'object') { + return String(field).trim(); + } + const key = Array.isArray(field.loc) + ? field.loc.join('.') + : String(field.field || field.path || field.name || field.param || '').trim(); + const message = String(field.msg || field.message || field.error || field.detail || field.reason || '').trim(); + return [key, message].filter(Boolean).join(': ') || JSON.stringify(field); } function extractGpcResponseErrorDetail(payload = {}, status = 0) { @@ -93,6 +173,27 @@ return 'GOPAY已经绑了订阅,需要手动解绑'; } + const data = payload.data; + if (data && typeof data === 'object' && !Array.isArray(data)) { + const nestedDetail = data.detail ?? data.error ?? data.reason; + if (nestedDetail !== undefined && nestedDetail !== null && String(nestedDetail).trim()) { + const nestedText = String(nestedDetail).trim(); + return /account\s+already\s+linked/i.test(nestedText) + ? 'GOPAY已经绑了订阅,需要手动解绑' + : nestedText; + } + const fields = data.fields ?? data.errors; + if (Array.isArray(fields) && fields.length > 0) { + const formatted = fields + .map(formatGpcErrorField) + .filter(Boolean) + .join('; '); + if (formatted) { + return formatted; + } + } + } + const direct = payload.detail ?? payload.message ?? payload.error @@ -136,7 +237,6 @@ const payload = { reference_id: String(input.reference_id ?? input.referenceId ?? '').trim(), otp: normalizeGoPayOtp(input.otp ?? input.code ?? ''), - card_key: String(input.card_key ?? input.cardKey ?? '').trim(), }; const flowId = String(input.flow_id ?? input.flowId ?? '').trim(); const gopayGuid = String(input.gopay_guid ?? input.gopayGuid ?? '').trim(); @@ -158,7 +258,6 @@ challenge_id: String(input.challenge_id ?? input.challengeId ?? '').trim(), gopay_guid: String(input.gopay_guid ?? input.gopayGuid ?? '').trim(), pin: normalizeGoPayPin(input.pin ?? ''), - card_key: String(input.card_key ?? input.cardKey ?? '').trim(), }; const flowId = String(input.flow_id ?? input.flowId ?? '').trim(); const redirectUrl = String(input.redirect_url ?? input.redirectUrl ?? '').trim(); @@ -172,25 +271,45 @@ return { ...payload, challengeId: payload.challenge_id }; } + function buildGpcTaskOtpPayload(input = {}) { + return { + otp: normalizeGoPayOtp(input.otp ?? input.code ?? ''), + }; + } + + function buildGpcTaskPinPayload(input = {}) { + return { + pin: normalizeGoPayPin(input.pin ?? ''), + }; + } + function formatGpcBalancePayload(payload = {}) { - if (!payload || typeof payload !== 'object') { + const data = unwrapGpcResponse(payload); + if (!data || typeof data !== 'object') { return ''; } const candidates = [ - payload.remaining_uses, - payload.remainingUses, - payload.balance, - payload.remaining, - payload.uses, - payload.available_uses, - payload.availableUses, + data.remaining_uses, + data.remainingUses, + data.balance, + data.remaining, + data.uses, + data.available_uses, + data.availableUses, ]; const firstValue = candidates.find((value) => value !== undefined && value !== null && String(value).trim() !== ''); - const status = String(payload.card_status || payload.cardStatus || payload.status || '').trim(); - const flowId = String(payload.flow_id || payload.flowId || '').trim(); + const totalUses = data.total_uses ?? data.totalUses; + const usedUses = data.used_uses ?? data.usedUses; + const status = String(data.status || data.card_status || data.cardStatus || '').trim(); + const flowId = String(data.flow_id || data.flowId || '').trim(); const parts = []; if (firstValue !== undefined) { - parts.push(`余额 ${firstValue}`); + parts.push(totalUses !== undefined && totalUses !== null && String(totalUses).trim() !== '' + ? `余额 ${firstValue}/${totalUses}` + : `余额 ${firstValue}`); + } + if (usedUses !== undefined && usedUses !== null && String(usedUses).trim() !== '') { + parts.push(`已用 ${usedUses}`); } if (status) { parts.push(`状态 ${status}`); @@ -208,14 +327,23 @@ PLUS_PAYMENT_METHOD_GOPAY, PLUS_PAYMENT_METHOD_PAYPAL, buildGpcCardBalanceUrl, + buildGpcApiKeyBalanceUrl, + buildGpcApiKeyHeaders, buildGpcHelperApiUrl, buildGpcOtpPayload, buildGpcOtpRetryPayload, buildGpcPinPayload, buildGpcPinRetryPayload, + buildGpcTaskActionUrl, + buildGpcTaskCreateUrl, + buildGpcTaskOtpPayload, + buildGpcTaskPinPayload, + buildGpcTaskQueryUrl, extractGpcResponseErrorDetail, formatGpcBalancePayload, + isGpcUnifiedResponseOk, normalizeGpcHelperBaseUrl, + normalizeGpcTaskId, normalizeGoPayCountryCode, normalizeGoPayPhone, normalizeGoPayPhoneForCountry, @@ -223,5 +351,6 @@ normalizeGoPayPin, normalizeGpcOtpChannel, normalizePlusPaymentMethod, + unwrapGpcResponse, }; }); diff --git a/manifest.json b/manifest.json index ad93ca3..0532b5f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "codex-oauth-automation-extension", - "version": "6.7", - "version_name": "Ultra6.7", + "version": "7.0", + "version_name": "Ultra7.0", "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", diff --git a/scripts/gpc_sms_helper_macos.py b/scripts/gpc_sms_helper_macos.py index 65092b9..7300bf4 100644 --- a/scripts/gpc_sms_helper_macos.py +++ b/scripts/gpc_sms_helper_macos.py @@ -74,6 +74,11 @@ def extract_gopay_otp(text: str, require_keywords: bool = True) -> Optional[str] return None +def normalize_phone_key(value: object) -> str: + digits = re.sub(r"\D+", "", str(value or "")) + return f"+{digits}" if digits else "" + + def mac_message_time_to_datetime(value: int | float | None) -> dt.datetime: if not value: return dt.datetime.now(dt.timezone.utc) @@ -113,24 +118,52 @@ def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) - try: conn = sqlite3.connect(str(copied)) conn.row_factory = sqlite3.Row - rows = conn.execute( - """ - SELECT - message.ROWID AS rowid, - message.guid AS guid, - message.text AS text, - message.date AS date, - message.service AS service, - handle.id AS handle - FROM message - LEFT JOIN handle ON message.handle_id = handle.ROWID - WHERE message.ROWID > ? - AND message.text IS NOT NULL - ORDER BY message.ROWID DESC - LIMIT ? - """, - (max(0, int(after_rowid or 0)), max(1, int(limit or 80))), - ).fetchall() + params = (max(0, int(after_rowid or 0)), max(1, int(limit or 80))) + try: + rows = conn.execute( + """ + SELECT + message.ROWID AS rowid, + message.guid AS guid, + message.text AS text, + message.date AS date, + message.service AS service, + message.destination_caller_id AS destination_caller_id, + message.account AS account, + handle.id AS handle, + chat.last_addressed_handle AS last_addressed_handle, + chat.account_login AS account_login + FROM message + LEFT JOIN handle ON message.handle_id = handle.ROWID + LEFT JOIN chat_message_join ON chat_message_join.message_id = message.ROWID + LEFT JOIN chat ON chat.ROWID = chat_message_join.chat_id + WHERE message.ROWID > ? + AND message.text IS NOT NULL + ORDER BY message.ROWID DESC + LIMIT ? + """, + params, + ).fetchall() + except sqlite3.OperationalError: + rows = conn.execute( + """ + SELECT + message.ROWID AS rowid, + message.guid AS guid, + message.text AS text, + message.date AS date, + message.service AS service, + message.account AS account, + handle.id AS handle + FROM message + LEFT JOIN handle ON message.handle_id = handle.ROWID + WHERE message.ROWID > ? + AND message.text IS NOT NULL + ORDER BY message.ROWID DESC + LIMIT ? + """, + params, + ).fetchall() return [dict(row) for row in rows] finally: try: @@ -140,8 +173,17 @@ def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) - shutil.rmtree(copied.parent, ignore_errors=True) +def get_record_phone(row: dict) -> str: + for key in ("destination_caller_id", "last_addressed_handle", "account_login", "account"): + phone = normalize_phone_key(row.get(key)) + if phone: + return phone + return "" + + def make_otp_record(row: dict, otp: str) -> dict: received_at = mac_message_time_to_datetime(row.get("date")).isoformat() + phone_e164 = get_record_phone(row) return { "otp": otp, "code": otp, @@ -149,6 +191,8 @@ def make_otp_record(row: dict, otp: str) -> dict: "rowid": int(row.get("rowid") or 0), "sender": str(row.get("handle") or ""), "service": str(row.get("service") or ""), + "account_phone": phone_e164, + "phone_e164": phone_e164, "received_at": received_at, "message_text": str(row.get("text") or ""), } @@ -189,19 +233,86 @@ def parse_timestamp_ms(value: object) -> int: return int(numeric) -def select_otp_record(state: dict, after_ms: int = 0) -> Optional[dict]: +def record_matches_phone(record: dict, phone: str = "") -> bool: + wanted = normalize_phone_key(phone) + if not wanted: + return True + return normalize_phone_key(record.get("phone_e164") or record.get("account_phone")) == wanted + + +def select_otp_record(state: dict, after_ms: int = 0, phone: str = "") -> Optional[dict]: records = state.get("otps") if not isinstance(records, list): records = [] if after_ms > 0: for record in records: - if isinstance(record, dict) and parse_timestamp_ms(record.get("received_at")) >= after_ms: + if ( + isinstance(record, dict) + and record_matches_phone(record, phone) + and parse_timestamp_ms(record.get("received_at")) >= after_ms + ): return record return None record = state.get("last_otp") or None - if isinstance(record, dict): + if isinstance(record, dict) and record_matches_phone(record, phone): return record - return records[0] if records and isinstance(records[0], dict) else None + for record in records: + if isinstance(record, dict) and record_matches_phone(record, phone): + return record + return None + + +def consume_otp_record(phone: str = "", record: Optional[dict] = None) -> None: + wanted = normalize_phone_key(phone) + consumed_message_id = str((record or {}).get("message_id") or "").strip() + consumed_rowid = int((record or {}).get("rowid") or 0) + + def is_consumed_record(item: object) -> bool: + if not isinstance(item, dict): + return False + if consumed_message_id and str(item.get("message_id") or "").strip() == consumed_message_id: + return True + if consumed_rowid and int(item.get("rowid") or 0) == consumed_rowid: + return True + return False + + def is_same_record(left: object, right: object) -> bool: + if not isinstance(left, dict) or not isinstance(right, dict): + return False + left_message_id = str(left.get("message_id") or "").strip() + right_message_id = str(right.get("message_id") or "").strip() + if left_message_id and right_message_id and left_message_id == right_message_id: + return True + left_rowid = int(left.get("rowid") or 0) + right_rowid = int(right.get("rowid") or 0) + if left_rowid > 0 and right_rowid > 0 and left_rowid == right_rowid: + return True + return left == right + + with STATE_LOCK: + records = STATE.get("otps") + if not isinstance(records, list): + records = [] + + if consumed_message_id or consumed_rowid: + next_records = [item for item in records if isinstance(item, dict) and not is_consumed_record(item)] + elif wanted: + removed_once = False + next_records = [] + for item in records: + if not isinstance(item, dict): + continue + if not removed_once and record_matches_phone(item, wanted): + removed_once = True + continue + next_records.append(item) + else: + next_records = [] + + STATE["otps"] = next_records + last_otp = STATE.get("last_otp") + if isinstance(last_otp, dict) and not any(is_same_record(last_otp, item) for item in STATE["otps"]): + STATE["last_otp"] = STATE["otps"][0] if STATE["otps"] else None def scan_once(db_path: Path, require_keywords: bool = True) -> None: @@ -263,14 +374,15 @@ class HelperHandler(BaseHTTPRequestHandler): query = parse_qs(parsed.query) consume = str(query.get("consume", ["0"])[0]).strip().lower() in {"1", "true", "yes"} after_ms = parse_timestamp_ms(query.get("after_ms", query.get("after", ["0"]))[0]) + phone = str((query.get("phone") or query.get("phone_e164") or query.get("phone_number") or [""])[0]).strip() state = get_state() - record = select_otp_record(state, after_ms=after_ms) + record = select_otp_record(state, after_ms=after_ms, phone=phone) if not record: write_json(self, 200, {"ok": True, "otp": "", "code": "", "status": "waiting", "message": "未查询到验证码"}) return payload = {"ok": True, "status": "found", **record} if consume: - update_state(last_otp=None) + consume_otp_record(phone=phone, record=record) write_json(self, 200, payload) return write_json(self, 404, {"ok": False, "error": "not_found"}) diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 2c4fc3f..a661317 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -1971,6 +1971,16 @@ header { .data-select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); } [data-theme="dark"] .data-select { color-scheme: dark; } +.data-input[readonly] { + color: var(--text-secondary); + background: var(--bg-surface); + cursor: default; +} + +.gpc-helper-api-input { + font-family: 'JetBrains Mono', monospace; +} + .editable-list-picker { position: relative; flex: 1; @@ -2114,6 +2124,47 @@ header { justify-content: flex-end; } +.auto-delay-setting-pair { + flex-wrap: nowrap; +} + +#row-auto-delay-settings .setting-group-primary { + flex: 0 0 auto; + min-width: 116px; +} + +#row-auto-delay-settings .setting-group-secondary { + margin-left: 0; + min-width: 0; +} + +.step6-cookie-cleanup-setting { + gap: 8px; +} + +.auto-run-delay-setting { + margin-left: auto !important; +} + +.oauth-flow-timeout-setting { + gap: 8px; + min-width: 0; +} + +.oauth-flow-timeout-caption { + min-width: 0 !important; + overflow: hidden; + text-overflow: ellipsis; +} + +#row-auto-delay-settings .setting-caption { + min-width: auto; +} + +.setting-caption-left { + text-align: left; +} + .plus-payment-method-select { flex: 0 0 156px; min-width: 156px; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index b1b62d2..ac485c2 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -283,15 +283,23 @@ +
- 延迟 -
-
+ 第六步 +
+ +
+ 延迟
-
- 步间间隔 -
- - -
-
@@ -578,6 +588,30 @@
+
+ 步间间隔 +
+ + +
+
+
+ +
+ 授权总超时 +
+
+ + 关闭后只取消 Step 7 后链总预算 +
线程间隔
@@ -588,20 +622,6 @@
-
- 授权总超时 -
- - 关闭后只取消 Step 7 后链总预算 -
-
OAuth 等待中... diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 3234b20..2d6b8bd 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -187,6 +187,7 @@ const payPalAccountMenu = document.getElementById('paypal-account-menu'); const btnAddPayPalAccount = document.getElementById('btn-add-paypal-account'); const rowGpcHelperApi = document.getElementById('row-gpc-helper-api'); const inputGpcHelperApi = document.getElementById('input-gpc-helper-api'); +const btnGpcHelperConvertApiKey = document.getElementById('btn-gpc-helper-convert-api-key'); const rowGpcHelperCardKey = document.getElementById('row-gpc-helper-card-key'); const inputGpcHelperCardKey = document.getElementById('input-gpc-helper-card-key'); const btnToggleGpcHelperCardKey = document.getElementById('btn-toggle-gpc-helper-card-key'); @@ -375,6 +376,7 @@ const btnCfDomainMode = document.getElementById('btn-cf-domain-mode'); const inputRunCount = document.getElementById('input-run-count'); const inputAutoSkipFailures = document.getElementById('input-auto-skip-failures'); const inputAutoSkipFailuresThreadIntervalMinutes = document.getElementById('input-auto-skip-failures-thread-interval-minutes'); +const inputStep6CookieCleanupEnabled = document.getElementById('input-step6-cookie-cleanup-enabled'); const inputAutoDelayEnabled = document.getElementById('input-auto-delay-enabled'); const inputAutoDelayMinutes = document.getElementById('input-auto-delay-minutes'); const inputAutoStepDelaySeconds = document.getElementById('input-auto-step-delay-seconds'); @@ -507,7 +509,8 @@ const stepsList = document.querySelector('.steps-list'); const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal'; const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; -const DEFAULT_GPC_HELPER_API_URL = 'https://gopay.hwork.pro'; +const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top'; +const GPC_HELPER_PORTAL_URL = 'https://gpc.qlhazycoder.top/'; const DEFAULT_PLUS_PAYMENT_METHOD = PLUS_PAYMENT_METHOD_PAYPAL; const SIGNUP_METHOD_EMAIL = 'email'; const SIGNUP_METHOD_PHONE = 'phone'; @@ -2736,7 +2739,7 @@ function applyCloudMailSettingsState(state = {}) { function collectSettingsPayload() { const defaultGpcHelperApiUrl = typeof DEFAULT_GPC_HELPER_API_URL !== 'undefined' ? DEFAULT_GPC_HELPER_API_URL - : 'https://gopay.hwork.pro'; + : 'https://gpc.qlhazycoder.top'; const { domains, activeDomain } = getCloudflareDomainsFromState(); const selectedCloudflareDomain = normalizeCloudflareDomainValue( !cloudflareDomainEditMode ? selectCfDomain.value : activeDomain @@ -3324,13 +3327,12 @@ function collectSettingsPayload() { ? String(inputGoPayPin.value || '') : String(latestState?.gopayPin || '')), gopayHelperApiUrl: window.GoPayUtils?.normalizeGpcHelperBaseUrl - ? window.GoPayUtils.normalizeGpcHelperBaseUrl(typeof inputGpcHelperApi !== 'undefined' && inputGpcHelperApi ? inputGpcHelperApi.value : (latestState?.gopayHelperApiUrl || defaultGpcHelperApiUrl)) - : (typeof inputGpcHelperApi !== 'undefined' && inputGpcHelperApi - ? String(inputGpcHelperApi.value || defaultGpcHelperApiUrl).trim().replace(/\/+$/g, '') - : String(latestState?.gopayHelperApiUrl || defaultGpcHelperApiUrl).trim()), - gopayHelperCardKey: typeof inputGpcHelperCardKey !== 'undefined' && inputGpcHelperCardKey + ? window.GoPayUtils.normalizeGpcHelperBaseUrl(defaultGpcHelperApiUrl) + : String(defaultGpcHelperApiUrl).trim().replace(/\/+$/g, ''), + gopayHelperApiKey: typeof inputGpcHelperCardKey !== 'undefined' && inputGpcHelperCardKey ? String(inputGpcHelperCardKey.value || '').trim() - : String(latestState?.gopayHelperCardKey || '').trim(), + : String(latestState?.gopayHelperApiKey || latestState?.gopayHelperCardKey || '').trim(), + gopayHelperCardKey: '', gopayHelperCountryCode: window.GoPayUtils?.normalizeGoPayCountryCode ? window.GoPayUtils.normalizeGoPayCountryCode(typeof selectGpcHelperCountryCode !== 'undefined' && selectGpcHelperCountryCode ? selectGpcHelperCountryCode.value : latestState?.gopayHelperCountryCode) : (typeof selectGpcHelperCountryCode !== 'undefined' && selectGpcHelperCountryCode @@ -3403,6 +3405,9 @@ function collectSettingsPayload() { cloudMailDomain: normalizeCloudflareTempEmailDomainValue((typeof inputCloudMailDomain !== 'undefined' && inputCloudMailDomain) ? inputCloudMailDomain.value : ''), autoRunSkipFailures: inputAutoSkipFailures.checked, autoRunFallbackThreadIntervalMinutes: normalizeAutoRunThreadIntervalMinutes(inputAutoSkipFailuresThreadIntervalMinutes.value), + step6CookieCleanupEnabled: typeof inputStep6CookieCleanupEnabled !== 'undefined' && inputStep6CookieCleanupEnabled + ? Boolean(inputStep6CookieCleanupEnabled.checked) + : false, autoRunDelayEnabled: inputAutoDelayEnabled.checked, autoRunDelayMinutes: normalizeAutoDelayMinutes(inputAutoDelayMinutes.value), autoStepDelaySeconds: normalizeAutoStepDelaySeconds(inputAutoStepDelaySeconds.value), @@ -7224,6 +7229,7 @@ function updatePlusModeUI() { row.style.display = enabled && selectedMethod === paypalValue ? '' : 'none'; }); [ + typeof rowGpcHelperApi !== 'undefined' ? rowGpcHelperApi : null, typeof rowGpcHelperCardKey !== 'undefined' ? rowGpcHelperCardKey : null, typeof rowGpcHelperCountryCode !== 'undefined' ? rowGpcHelperCountryCode : null, typeof rowGpcHelperPhone !== 'undefined' ? rowGpcHelperPhone : null, @@ -7473,7 +7479,7 @@ async function openPlusManualConfirmationDialog(options = {}) { validate: (value) => { const normalized = String(value || '').trim().replace(/[^\d]/g, ''); if (!normalized) return '请输入 OTP 验证码。'; - if (normalized.length < 4) return 'OTP 验证码长度过短,请检查。'; + if (!/^\d{6}$/.test(normalized)) return 'OTP 必须是 6 位数字,请检查。'; return ''; }, }, @@ -7966,11 +7972,11 @@ function applySettingsState(state) { if (typeof inputGpcHelperApi !== 'undefined' && inputGpcHelperApi) { const defaultGpcHelperApiUrl = typeof DEFAULT_GPC_HELPER_API_URL !== 'undefined' ? DEFAULT_GPC_HELPER_API_URL - : 'https://gopay.hwork.pro'; - inputGpcHelperApi.value = state?.gopayHelperApiUrl || defaultGpcHelperApiUrl; + : 'https://gpc.qlhazycoder.top'; + inputGpcHelperApi.value = `${defaultGpcHelperApiUrl.replace(/\/+$/g, '')}/`; } if (typeof inputGpcHelperCardKey !== 'undefined' && inputGpcHelperCardKey) { - inputGpcHelperCardKey.value = state?.gopayHelperCardKey || ''; + inputGpcHelperCardKey.value = state?.gopayHelperApiKey || state?.gopayHelperCardKey || ''; } if (typeof selectGpcHelperCountryCode !== 'undefined' && selectGpcHelperCountryCode) { const normalizedCountryCode = window.GoPayUtils?.normalizeGoPayCountryCode @@ -8218,6 +8224,9 @@ function applySettingsState(state) { setCloudflareDomainEditMode(false, { clearInput: true }); inputAutoSkipFailures.checked = Boolean(state?.autoRunSkipFailures); inputAutoSkipFailuresThreadIntervalMinutes.value = String(normalizeAutoRunThreadIntervalMinutes(state?.autoRunFallbackThreadIntervalMinutes)); + if (typeof inputStep6CookieCleanupEnabled !== 'undefined' && inputStep6CookieCleanupEnabled) { + inputStep6CookieCleanupEnabled.checked = Boolean(state?.step6CookieCleanupEnabled); + } inputAutoDelayEnabled.checked = Boolean(state?.autoRunDelayEnabled); inputAutoDelayMinutes.value = String(normalizeAutoDelayMinutes(state?.autoRunDelayMinutes)); inputAutoStepDelaySeconds.value = formatAutoStepDelayInputValue(state?.autoStepDelaySeconds); @@ -11407,14 +11416,18 @@ btnGpcCardKeyPurchase?.addEventListener('click', () => { openExternalUrl('https://pay.ldxp.cn/shop/gpc'); }); +btnGpcHelperConvertApiKey?.addEventListener('click', () => { + openExternalUrl(GPC_HELPER_PORTAL_URL); +}); + btnGpcHelperBalance?.addEventListener('click', async () => { try { const response = await chrome.runtime.sendMessage({ type: 'REFRESH_GPC_CARD_BALANCE', source: 'sidepanel', payload: { - gopayHelperApiUrl: inputGpcHelperApi?.value || DEFAULT_GPC_HELPER_API_URL, - gopayHelperCardKey: inputGpcHelperCardKey?.value || '', + gopayHelperApiUrl: DEFAULT_GPC_HELPER_API_URL, + gopayHelperApiKey: inputGpcHelperCardKey?.value || '', gopayHelperCountryCode: selectGpcHelperCountryCode?.value || '+86', reason: 'manual', }, @@ -12348,6 +12361,11 @@ inputAutoDelayEnabled.addEventListener('change', () => { saveSettings({ silent: true }).catch(() => { }); }); +inputStep6CookieCleanupEnabled?.addEventListener('change', () => { + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => { }); +}); + inputAutoDelayMinutes.addEventListener('input', () => { markSettingsDirty(true); scheduleSettingsAutoSave(); @@ -13483,6 +13501,13 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { inputAutoDelayEnabled.checked = Boolean(message.payload.autoRunDelayEnabled); updateAutoDelayInputState(); } + if ( + message.payload.step6CookieCleanupEnabled !== undefined + && typeof inputStep6CookieCleanupEnabled !== 'undefined' + && inputStep6CookieCleanupEnabled + ) { + inputStep6CookieCleanupEnabled.checked = Boolean(message.payload.step6CookieCleanupEnabled); + } if (message.payload.autoRunDelayMinutes !== undefined) { inputAutoDelayMinutes.value = String(normalizeAutoDelayMinutes(message.payload.autoRunDelayMinutes)); } diff --git a/tests/auto-run-add-phone-stop.test.js b/tests/auto-run-add-phone-stop.test.js index 41c082f..bcc8cd7 100644 --- a/tests/auto-run-add-phone-stop.test.js +++ b/tests/auto-run-add-phone-stop.test.js @@ -332,6 +332,167 @@ test('auto-run controller treats phone-number supply exhaustion as round-fatal a assert.equal(runtime.state.autoRunSessionId, 0); }); +test('auto-run controller treats ended GPC task as round-fatal and skips same-round retries', 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) => String(error?.message || error || '').replace(/^GPC_TASK_ENDED::/i, ''), + getFirstUnfinishedStep: () => 1, + getPendingAutoRunTimerPlan: () => null, + getRunningSteps: () => [], + getState: async () => ({ + ...currentState, + stepStatuses: { ...(currentState.stepStatuses || {}) }, + tabRegistry: { ...(currentState.tabRegistry || {}) }, + sourceLastUrls: { ...(currentState.sourceLastUrls || {}) }, + }), + getStopRequested: () => false, + hasSavedProgress: () => false, + isAddPhoneAuthFailure: () => false, + isGpcTaskEndedFailure: (error) => /GPC_TASK_ENDED::/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; + if (events.runCalls === 1) { + throw new Error('GPC_TASK_ENDED::等待 OTP 超过 60 秒,任务已超时'); + } + }, + 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(2, { + autoRunSkipFailures: true, + mode: 'restart', + }); + + assert.equal(events.runCalls, 2, 'ended GPC task should fail current round and continue next round'); + assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false); + assert.equal(events.accountRecords.length, 1); + assert.equal(events.accountRecords[0].status, 'failed'); + assert.match(events.accountRecords[0].reason, /等待 OTP/); + assert.ok(events.logs.some(({ message }) => /GPC 任务.*继续下一轮|继续下一轮/.test(message))); +}); + test('auto-run controller keeps same-round retrying for step9 local replacement exhaustion errors', async () => { const events = { logs: [], @@ -1150,4 +1311,3 @@ test('auto-run controller retries 5sim rate limit failures instead of treating c assert.equal(runtime.state.autoRunActive, false); assert.equal(runtime.state.autoRunSessionId, 0); }); - diff --git a/tests/background-account-history-settings.test.js b/tests/background-account-history-settings.test.js index 92a8d27..00264dc 100644 --- a/tests/background-account-history-settings.test.js +++ b/tests/background-account-history-settings.test.js @@ -150,13 +150,16 @@ const self = { .replace(/\\/+$/g, '') .replace(/\\/api\\/checkout\\/start$/i, '') .replace(/\\/api\\/gopay\\/(?:otp|pin)$/i, '') - .replace(/\\/api\\/card\\/balance(?:\\?.*)?$/i, ''); + .replace(/\\/api\\/gp\\/tasks(?:\\/[^/?#]+)?(?:\\/(?:otp|pin|stop))?(?:\\?.*)?$/i, '') + .replace(/\\/api\\/gp\\/balance(?:\\?.*)?$/i, '') + .replace(/\\/api\\/card\\/balance(?:\\?.*)?$/i, '') + .replace(/\\/api\\/card\\/redeem-api-key(?:\\?.*)?$/i, ''); }, }, }; const PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null, - gopayHelperApiUrl: 'https://gopay.hwork.pro', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top', mailProvider: '163', }; function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : (value === 'codex2api' ? 'codex2api' : 'cpa'); } @@ -191,11 +194,19 @@ return { assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'paypal'), 'paypal'); assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'unknown'), 'paypal'); assert.equal( - api.normalizePersistentSettingValue('gopayHelperApiUrl', ' https://gopay.hwork.pro/api/checkout/start '), - 'https://gopay.hwork.pro' + api.normalizePersistentSettingValue('gopayHelperApiUrl', ' https://gpc.qlhazycoder.top/api/checkout/start '), + 'https://gpc.qlhazycoder.top' ); - assert.equal(api.normalizePersistentSettingValue('gopayHelperApiUrl', ''), 'https://gopay.hwork.pro'); - assert.equal(api.normalizePersistentSettingValue('gopayHelperCardKey', ' card_123 '), 'card_123'); + assert.equal( + api.normalizePersistentSettingValue('gopayHelperApiUrl', ' https://gpc.qlhazycoder.top/api/gp/tasks/task_1/pin '), + 'https://gpc.qlhazycoder.top' + ); + assert.equal( + api.normalizePersistentSettingValue('gopayHelperApiUrl', ' https://gpc.qlhazycoder.top/api/gp/balance '), + 'https://gpc.qlhazycoder.top' + ); + assert.equal(api.normalizePersistentSettingValue('gopayHelperApiUrl', ''), 'https://gpc.qlhazycoder.top'); + assert.equal(api.normalizePersistentSettingValue('gopayHelperApiKey', ' gpc-123 '), 'gpc-123'); assert.equal(api.normalizePersistentSettingValue('gopayHelperCountryCode', ' 86 '), '+86'); assert.equal(api.normalizePersistentSettingValue('gopayHelperPhoneNumber', ' +86 138-0013-8000 '), '+8613800138000'); assert.equal(api.normalizePersistentSettingValue('gopayHelperPin', ' 12-34-56 '), '123456'); diff --git a/tests/background-logging-status-module.test.js b/tests/background-logging-status-module.test.js index 64f5e08..e7e5bc5 100644 --- a/tests/background-logging-status-module.test.js +++ b/tests/background-logging-status-module.test.js @@ -46,4 +46,8 @@ test('logging/status add-phone detection ignores step 2 phone-entry switch failu assert.equal(loggingStatus.getLoginAuthStateLabel('phone_verification_page'), '手机验证码页'); assert.equal(loggingStatus.getLoginAuthStateLabel('add_email_page'), '添加邮箱页'); assert.equal(loggingStatus.getLoginAuthStateLabel('oauth_consent_page'), 'OAuth 授权页'); + assert.equal( + loggingStatus.getErrorMessage(new Error('GPC_TASK_ENDED::GPC OTP 超时,请重新创建任务')), + 'GPC OTP 超时,请重新创建任务' + ); }); diff --git a/tests/background-message-router-step2-skip.test.js b/tests/background-message-router-step2-skip.test.js index 0c80804..34eccae 100644 --- a/tests/background-message-router-step2-skip.test.js +++ b/tests/background-message-router-step2-skip.test.js @@ -44,6 +44,9 @@ function createRouter(overrides = {}) { clearStopRequest: () => {}, closeLocalhostCallbackTabs: async () => {}, closeTabsByUrlPrefix: async () => {}, + completeStepFromBackground: async (step, payload) => { + events.notifyCompletions.push({ step, payload, via: 'completeStepFromBackground' }); + }, deleteHotmailAccount: async () => {}, deleteHotmailAccounts: async () => {}, deleteIcloudAlias: async () => {}, @@ -540,7 +543,7 @@ test('message router refreshes GPC balance through explicit sidepanel message', const state = { plusPaymentMethod: 'gpc-helper', gopayHelperApiUrl: 'http://localhost:18473/', - gopayHelperCardKey: 'state_card', + gopayHelperApiKey: 'state_api_key', }; const { router, events } = createRouter({ state }); @@ -548,7 +551,7 @@ test('message router refreshes GPC balance through explicit sidepanel message', type: 'REFRESH_GPC_CARD_BALANCE', source: 'sidepanel', payload: { - gopayHelperCardKey: 'payload_card', + gopayHelperApiKey: 'payload_api_key', reason: 'manual', }, }, {}); @@ -556,6 +559,6 @@ test('message router refreshes GPC balance through explicit sidepanel message', assert.deepStrictEqual(response, { ok: true, balance: '余额 3' }); assert.equal(events.balanceRefreshes.length, 1); assert.equal(events.balanceRefreshes[0].state.gopayHelperApiUrl, 'http://localhost:18473/'); - assert.equal(events.balanceRefreshes[0].state.gopayHelperCardKey, 'payload_card'); + assert.equal(events.balanceRefreshes[0].state.gopayHelperApiKey, 'payload_api_key'); assert.deepStrictEqual(events.balanceRefreshes[0].options, { reason: 'manual' }); }); diff --git a/tests/background-step6-retry-limit.test.js b/tests/background-step6-retry-limit.test.js index bcef5b3..09cfaaf 100644 --- a/tests/background-step6-retry-limit.test.js +++ b/tests/background-step6-retry-limit.test.js @@ -32,6 +32,63 @@ test('step 6 waits for registration success and completes from background', asyn assert.ok(events.logs.some(({ message }) => /等待 20 秒/.test(message))); }); +test('step 6 only clears cookies when cleanup switch is enabled', async () => { + const source = fs.readFileSync('background/steps/wait-registration-success.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep6;`)(globalScope); + + const events = { + removedCookies: [], + browsingDataCalls: [], + completedSteps: [], + }; + const chromeApi = { + cookies: { + getAllCookieStores: async () => [{ id: 'store-a' }], + getAll: async () => [ + { domain: '.chatgpt.com', path: '/auth', name: 'session', storeId: 'store-a' }, + { domain: '.example.com', path: '/', name: 'keep', storeId: 'store-a' }, + ], + remove: async (details) => { + events.removedCookies.push(details); + return details; + }, + }, + browsingData: { + removeCookies: async (details) => { + events.browsingDataCalls.push(details); + }, + }, + }; + + const executor = api.createStep6Executor({ + addLog: async () => {}, + chrome: chromeApi, + completeStepFromBackground: async (step) => { + events.completedSteps.push(step); + }, + sleepWithStop: async () => {}, + }); + + await executor.executeStep6({ step6CookieCleanupEnabled: false }); + + assert.deepStrictEqual(events.removedCookies, []); + assert.deepStrictEqual(events.browsingDataCalls, []); + + await executor.executeStep6({ step6CookieCleanupEnabled: true }); + + assert.deepStrictEqual(events.completedSteps, [6, 6]); + assert.deepStrictEqual(events.removedCookies, [ + { + url: 'https://chatgpt.com/auth', + name: 'session', + storeId: 'store-a', + }, + ]); + assert.equal(events.browsingDataCalls.length, 1); + assert.ok(events.browsingDataCalls[0].origins.includes('https://chatgpt.com')); +}); + test('step 7 retries up to configured limit and then fails', async () => { const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); const globalScope = {}; diff --git a/tests/gopay-utils.test.js b/tests/gopay-utils.test.js index 476c291..002a58f 100644 --- a/tests/gopay-utils.test.js +++ b/tests/gopay-utils.test.js @@ -24,88 +24,90 @@ test('GoPay utils keeps GPC helper payment method distinct', () => { assert.equal(api.normalizePlusPaymentMethod('unknown'), 'paypal'); }); -test('GoPay utils builds GPC card balance URL from helper endpoints', () => { +test('GoPay utils builds GPC queue task and balance URLs from helper endpoints', () => { const api = loadGoPayUtils(); - assert.equal(api.DEFAULT_GPC_HELPER_API_URL, 'https://gopay.hwork.pro'); - assert.equal(api.normalizeGpcHelperBaseUrl(''), 'https://gopay.hwork.pro'); + assert.equal(api.DEFAULT_GPC_HELPER_API_URL, 'https://gpc.qlhazycoder.top'); + assert.equal(api.normalizeGpcHelperBaseUrl(''), 'https://gpc.qlhazycoder.top'); assert.equal( api.buildGpcHelperApiUrl('', '/api/checkout/start'), - 'https://gopay.hwork.pro/api/checkout/start' + 'https://gpc.qlhazycoder.top/api/checkout/start' ); assert.equal( - api.buildGpcCardBalanceUrl('http://localhost:18473/', ' card key/1 '), - 'http://localhost:18473/api/card/balance?card_key=card%20key%2F1' + api.buildGpcApiKeyBalanceUrl('http://localhost:18473/'), + 'http://localhost:18473/api/gp/balance' ); assert.equal( - api.buildGpcCardBalanceUrl('https://gopay.hwork.pro/api/checkout/start', 'GPC-1'), - 'https://gopay.hwork.pro/api/card/balance?card_key=GPC-1' + api.buildGpcCardBalanceUrl('https://gpc.qlhazycoder.top/api/gp/balance'), + 'https://gpc.qlhazycoder.top/api/gp/balance' + ); + assert.deepEqual( + api.buildGpcApiKeyHeaders(' gpc-123 ', { Accept: 'application/json' }), + { Accept: 'application/json', 'X-API-Key': 'gpc-123' } ); assert.equal( - api.buildGpcCardBalanceUrl('https://gopay.hwork.pro/api/card/balance?card_key=old', 'new'), - 'https://gopay.hwork.pro/api/card/balance?card_key=new' + api.buildGpcTaskCreateUrl('https://gpc.qlhazycoder.top/api/checkout/start'), + 'https://gpc.qlhazycoder.top/api/gp/tasks' + ); + assert.equal( + api.buildGpcTaskQueryUrl('https://gpc.qlhazycoder.top/api/gp/tasks/task_old?card_key=old', 'task/1'), + 'https://gpc.qlhazycoder.top/api/gp/tasks/task%2F1' + ); + assert.equal( + api.buildGpcTaskActionUrl('https://gpc.qlhazycoder.top/api/gp/tasks/task_old/stop', 'task_1', 'pin'), + 'https://gpc.qlhazycoder.top/api/gp/tasks/task_1/pin' ); }); -test('GoPay utils builds GPC OTP/PIN payloads with card_key and flow_id', () => { +test('GoPay utils builds GPC queue OTP/PIN payloads without card_key', () => { const api = loadGoPayUtils(); assert.deepEqual( - api.buildGpcOtpPayload({ - reference_id: ' ref_1 ', - otp: ' 12-34 56 ', - card_key: ' card_1 ', - gopay_guid: ' guid_1 ', - flow_id: ' flow_1 ', - redirect_url: 'https://pm-redirects.stripe.com/test', - }), - { - reference_id: 'ref_1', - otp: '123456', - card_key: 'card_1', - flow_id: 'flow_1', - gopay_guid: 'guid_1', - redirect_url: 'https://pm-redirects.stripe.com/test', - } + api.buildGpcTaskOtpPayload({ otp: ' 12-34 56 ', card_key: ' card_1 ', reference_id: 'ref_1' }), + { otp: '123456' } ); assert.deepEqual( - api.buildGpcOtpRetryPayload({ referenceId: 'ref_1', otp: '123456', cardKey: 'card_1', flowId: 'flow_1' }), - { - reference_id: 'ref_1', - otp: '123456', - card_key: 'card_1', - flow_id: 'flow_1', - code: '123456', - } - ); - assert.deepEqual( - api.buildGpcPinPayload({ - referenceId: 'ref_1', - challengeId: 'challenge_1', - gopayGuid: 'guid_1', - pin: '65-43-21', - cardKey: 'card_1', - flowId: 'flow_1', - }), - { - reference_id: 'ref_1', - challenge_id: 'challenge_1', - gopay_guid: 'guid_1', - pin: '654321', - card_key: 'card_1', - flow_id: 'flow_1', - } + api.buildGpcTaskPinPayload({ pin: '65-43-21', cardKey: 'card_1', challengeId: 'challenge_1' }), + { pin: '654321' } ); }); test('GoPay utils formats balance and maps linked-account errors', () => { const api = loadGoPayUtils(); assert.equal( - api.formatGpcBalancePayload({ remaining_uses: 12, card_status: 'active', flow_id: 'flow_1' }), - '余额 12,状态 active,flow_id flow_1' + api.formatGpcBalancePayload({ remaining_uses: 12, status: 'active', used_uses: 2, flow_id: 'flow_1' }), + '余额 12,已用 2,状态 active,flow_id flow_1' + ); + assert.equal( + api.formatGpcBalancePayload({ + code: 200, + message: 'ok', + data: { remaining_uses: 0, total_uses: 3, used_uses: 3, status: 'active' }, + }), + '余额 0/3,已用 3,状态 active' + ); + assert.deepEqual( + api.unwrapGpcResponse({ code: 200, message: 'ok', data: { task_id: 'task_1' } }), + { task_id: 'task_1' } ); assert.equal( api.extractGpcResponseErrorDetail({ errors: [{ loc: ['body', 'otp'], msg: 'Field required' }] }, 422), 'body.otp: Field required' ); + assert.equal( + api.extractGpcResponseErrorDetail({ + code: 400, + message: 'invalid_param', + data: { detail: '手机号不能为空', fields: [{ field: 'phone_number', message: '必填' }] }, + }, 400), + '手机号不能为空' + ); + assert.equal( + api.extractGpcResponseErrorDetail({ + code: 400, + message: 'invalid_param', + data: { fields: [{ field: 'phone_number', message: '必填' }] }, + }, 400), + 'phone_number: 必填' + ); assert.equal( api.extractGpcResponseErrorDetail({ error_messages: ['account already linked'] }, 406), 'GOPAY已经绑了订阅,需要手动解绑' diff --git a/tests/gpc-sms-helper-script.test.js b/tests/gpc-sms-helper-script.test.js index 79574d4..f3ebb59 100644 --- a/tests/gpc-sms-helper-script.test.js +++ b/tests/gpc-sms-helper-script.test.js @@ -79,3 +79,123 @@ print(json.dumps(payload)) none_after_fresh: true, }); }); + +test('GPC SMS helper selects and consumes cached OTP records by phone', () => { + const code = ` +import importlib.util +import json + +script_path = ${JSON.stringify(scriptPath)} +spec = importlib.util.spec_from_file_location("gpc_sms_helper_macos", script_path) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +record_a = { + "otp": "111111", + "code": "111111", + "message_id": "a", + "rowid": 1, + "phone_e164": "+8615808505050", + "account_phone": "+8615808505050", + "received_at": "2026-05-05T00:00:00+00:00", +} +record_b = { + "otp": "222222", + "code": "222222", + "message_id": "b", + "rowid": 2, + "phone_e164": "+8618984829950", + "account_phone": "+8618984829950", + "received_at": "2026-05-05T00:00:10+00:00", +} +module.STATE.update({"last_otp": record_b, "otps": [record_b, record_a]}) +selected_a = module.select_otp_record(module.get_state(), phone="+8615808505050") +module.consume_otp_record(phone="+8615808505050", record=selected_a) +state_after = module.get_state() +payload = { + "selected_a": selected_a["otp"], + "selected_b_after": module.select_otp_record(state_after, phone="+8618984829950")["otp"], + "selected_a_after": module.select_otp_record(state_after, phone="+8615808505050") is None, + "global_after": module.select_otp_record(state_after)["otp"], +} +print(json.dumps(payload)) +`; + const run = runPython(['-c', code], { + timeout: 3000, + env: { GPC_SMS_HELPER_ALLOW_NON_MAC: '1' }, + }); + if (!run) { + return; + } + assert.equal(run.status, 0, run.stderr); + assert.deepEqual(JSON.parse(run.stdout.trim()), { + selected_a: '111111', + selected_b_after: '222222', + selected_a_after: true, + global_after: '222222', + }); +}); + +test('GPC SMS helper consume keeps newer same-phone OTP when consuming an older record', () => { + const code = ` +import importlib.util +import json + +script_path = ${JSON.stringify(scriptPath)} +spec = importlib.util.spec_from_file_location("gpc_sms_helper_macos", script_path) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +record_old = { + "otp": "111111", + "code": "111111", + "message_id": "old", + "rowid": 1, + "phone_e164": "+8613800138000", + "account_phone": "+8613800138000", + "received_at": "2026-05-05T00:00:00+00:00", +} +record_new = { + "otp": "222222", + "code": "222222", + "message_id": "new", + "rowid": 2, + "phone_e164": "+8613800138000", + "account_phone": "+8613800138000", + "received_at": "2026-05-05T00:00:10+00:00", +} +record_other = { + "otp": "333333", + "code": "333333", + "message_id": "other", + "rowid": 3, + "phone_e164": "+8618984829950", + "account_phone": "+8618984829950", + "received_at": "2026-05-05T00:00:20+00:00", +} +module.STATE.update({"last_otp": record_new, "otps": [record_new, record_old, record_other]}) +module.consume_otp_record(phone="+8613800138000", record=record_old) +state_after = module.get_state() +payload = { + "same_phone_after": module.select_otp_record(state_after, phone="+8613800138000")["otp"], + "other_phone_after": module.select_otp_record(state_after, phone="+8618984829950")["otp"], + "records_after": [item["message_id"] for item in state_after.get("otps", [])], + "global_after": module.select_otp_record(state_after)["otp"], +} +print(json.dumps(payload)) +`; + const run = runPython(['-c', code], { + timeout: 3000, + env: { GPC_SMS_HELPER_ALLOW_NON_MAC: '1' }, + }); + if (!run) { + return; + } + assert.equal(run.status, 0, run.stderr); + assert.deepEqual(JSON.parse(run.stdout.trim()), { + same_phone_after: '222222', + other_phone_after: '333333', + records_after: ['new', 'other'], + global_after: '222222', + }); +}); diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index 4fabdec..075d776 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -84,6 +84,7 @@ function createExecutorHarness({ getState = null, markCurrentRegistrationAccountUsed = async () => {}, probeIpProxyExit = null, + onSetState = null, submitRedirectUrl = 'https://www.paypal.com/checkoutnow', }) { const api = loadPlusCheckoutBillingModule(); @@ -93,6 +94,7 @@ function createExecutorHarness({ injectedAllFrames: false, logs: [], messages: [], + sleeps: [], states: [], waitedUrls: [], }; @@ -158,8 +160,13 @@ function createExecutorHarness({ getTabId: async () => null, isTabAlive: async () => false, markCurrentRegistrationAccountUsed, - setState: async (updates) => events.states.push(updates), - sleepWithStop: async () => {}, + setState: async (updates) => { + events.states.push(updates); + if (typeof onSetState === 'function') { + await onSetState(updates, events); + } + }, + sleepWithStop: async (ms) => events.sleeps.push(ms), waitForTabCompleteUntilStopped: async () => checkoutTab, waitForTabUrlMatchUntilStopped: async (tabId, matcher) => { events.waitedUrls.push({ tabId }); @@ -798,30 +805,68 @@ test('Plus checkout billing reports when the payment iframe exists but cannot re ); }); -test('GPC billing normalizes API URL and submits OTP then PIN with card_key and flow_id', async () => { + +function createGpcTaskResponse(data) { + return { + code: 200, + message: 'ok', + data: { + task_id: 'task_123', + phone_mode: 'manual', + status_text: data.status === 'completed' ? '充值完成' : (data.status === 'otp_ready' ? '等待 PIN' : '处理中'), + api_input_deadline_at: data.api_input_deadline_at ?? new Date(Date.now() + 60000).toISOString(), + ...data, + }, + }; +} + +test('GPC billing polls queue task, submits WhatsApp OTP then PIN, and waits until completed', async () => { const fetchCalls = []; let currentState = { plusManualConfirmationPending: true, plusManualConfirmationRequestId: '', }; + let pollCount = 0; const { events, executor } = createExecutorHarness({ frames: [], stateByFrame: {}, getState: async () => currentState, fetchImpl: async (url, options = {}) => { fetchCalls.push({ url, options }); - if (url.endsWith('/api/gopay/otp')) { + if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_123') { + pollCount += 1; + if (pollCount === 1) { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ status: 'active', remote_stage: 'whatsapp_otp_wait', api_waiting_for: 'otp' }), + }; + } + if (pollCount === 2) { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ status: 'otp_ready', status_text: '等待 PIN', remote_stage: 'otp_ready', api_waiting_for: 'pin' }), + }; + } return { ok: true, status: 200, - json: async () => ({ reference_id: 'ref_123', challenge_id: 'challenge_456' }), + json: async () => createGpcTaskResponse({ status: 'completed', status_text: '充值完成', remote_stage: 'completed' }), }; } - if (url.endsWith('/api/gopay/pin')) { + if (url.endsWith('/api/gp/tasks/task_123/otp')) { return { ok: true, status: 200, - json: async () => ({ stage: 'gopay_complete' }), + json: async () => createGpcTaskResponse({ status: 'otp_ready', status_text: '等待 PIN', remote_stage: 'otp_ready', api_waiting_for: 'pin' }), + }; + } + if (url.endsWith('/api/gp/tasks/task_123/pin')) { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ status: 'active', status_text: '处理中', remote_stage: 'payment_processing' }), }; } throw new Error(`unexpected url: ${url}`); @@ -831,12 +876,10 @@ test('GPC billing normalizes API URL and submits OTP then PIN with card_key and const run = executor.executePlusCheckoutBilling({ plusPaymentMethod: 'gpc-helper', plusCheckoutSource: 'gpc-helper', - gopayHelperReferenceId: 'ref_123', - gopayHelperGoPayGuid: 'guid_789', - gopayHelperApiUrl: 'https://gopay.hwork.pro/api/checkout/start', + gopayHelperTaskId: 'task_123', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/api/gp/tasks/task_old/otp', gopayHelperPin: '654321', - gopayHelperCardKey: 'card_billing_123', - gopayHelperFlowId: 'flow_billing_123', + gopayHelperApiKey: 'gpc_billing_123', }); await new Promise((resolve) => setTimeout(resolve, 20)); @@ -850,53 +893,70 @@ test('GPC billing normalizes API URL and submits OTP then PIN with card_key and await run; - assert.equal(fetchCalls[0].url, 'https://gopay.hwork.pro/api/gopay/otp'); - assert.deepEqual(JSON.parse(fetchCalls[0].options.body), { - reference_id: 'ref_123', - otp: '123456', - card_key: 'card_billing_123', - flow_id: 'flow_billing_123', - gopay_guid: 'guid_789', - }); - assert.equal(fetchCalls[1].url, 'https://gopay.hwork.pro/api/gopay/pin'); - assert.deepEqual(JSON.parse(fetchCalls[1].options.body), { - reference_id: 'ref_123', - challenge_id: 'challenge_456', - gopay_guid: 'guid_789', - pin: '654321', - card_key: 'card_billing_123', - flow_id: 'flow_billing_123', - }); + assert.equal(fetchCalls[0].url, 'https://gpc.qlhazycoder.top/api/gp/tasks/task_123'); + assert.equal(fetchCalls[0].options.headers['X-API-Key'], 'gpc_billing_123'); + const otpCall = fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_123/otp')); + const pinCall = fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_123/pin')); + assert.deepEqual(JSON.parse(otpCall.options.body), { otp: '123456' }); + assert.equal(otpCall.options.headers['X-API-Key'], 'gpc_billing_123'); + assert.deepEqual(JSON.parse(pinCall.options.body), { pin: '654321' }); + assert.equal(pinCall.options.headers['X-API-Key'], 'gpc_billing_123'); + assert.ok(fetchCalls.findIndex((call) => call.url.endsWith('/api/gp/tasks/task_123/pin')) < fetchCalls.length - 1); + assert.equal(events.states.some((state) => state.gopayHelperTaskId === 'task_123' && state.gopayHelperTaskStatus === 'completed'), true); assert.equal(events.completed[0].step, 7); assert.equal(events.completed[0].payload.plusCheckoutSource, 'gpc-helper'); + assert.ok(events.sleeps.includes(3000)); }); -test('GPC billing reads OTP from local SMS helper when enabled', async () => { +test('GPC billing reads SMS OTP from local helper for sms_otp_wait', async () => { const fetchCalls = []; + let pollCount = 0; const { events, executor } = createExecutorHarness({ frames: [], stateByFrame: {}, fetchImpl: async (url, options = {}) => { fetchCalls.push({ url, options }); - if (url.startsWith('http://127.0.0.1:18767/otp')) { + if (url.startsWith('http://127.0.0.1:18767/latest-otp')) { return { ok: true, status: 200, json: async () => ({ ok: true, otp: '654321', message_id: 'sms-1' }), }; } - if (url.endsWith('/api/gopay/otp')) { + if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_sms') { + pollCount += 1; + if (pollCount === 1) { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ task_id: 'task_sms', status: 'active', remote_stage: 'sms_otp_wait', api_waiting_for: 'otp' }), + }; + } + if (pollCount === 2) { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ task_id: 'task_sms', status: 'otp_ready', remote_stage: 'otp_ready', api_waiting_for: 'pin' }), + }; + } return { ok: true, status: 200, - json: async () => ({ reference_id: 'ref_sms', challenge_id: 'challenge_sms' }), + json: async () => createGpcTaskResponse({ task_id: 'task_sms', status: 'completed', remote_stage: 'completed' }), }; } - if (url.endsWith('/api/gopay/pin')) { + if (url.endsWith('/api/gp/tasks/task_sms/otp')) { return { ok: true, status: 200, - json: async () => ({ stage: 'gopay_complete' }), + json: async () => createGpcTaskResponse({ task_id: 'task_sms', status: 'otp_ready', remote_stage: 'otp_ready', api_waiting_for: 'pin' }), + }; + } + if (url.endsWith('/api/gp/tasks/task_sms/pin')) { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ task_id: 'task_sms', status: 'active', remote_stage: 'payment_processing' }), }; } throw new Error(`unexpected url: ${url}`); @@ -906,10 +966,224 @@ test('GPC billing reads OTP from local SMS helper when enabled', async () => { await executor.executePlusCheckoutBilling({ plusPaymentMethod: 'gpc-helper', plusCheckoutSource: 'gpc-helper', - gopayHelperReferenceId: 'ref_sms', - gopayHelperApiUrl: 'https://gopay.hwork.pro/', + gopayHelperTaskId: 'task_sms', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', gopayHelperPin: '654321', - gopayHelperCardKey: 'card_sms', + gopayHelperApiKey: 'gpc_sms', + gopayHelperOtpChannel: 'sms', + gopayHelperLocalSmsHelperEnabled: true, + gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767', + gopayHelperCountryCode: '+86', + gopayHelperPhoneNumber: '13800138000', + gopayHelperOrderCreatedAt: 1710000000000, + }); + + assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false); + assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '654321'), true); + const helperUrl = new URL(fetchCalls[1].url); + assert.equal(helperUrl.origin + helperUrl.pathname, 'http://127.0.0.1:18767/latest-otp'); + assert.equal(helperUrl.searchParams.get('task_id'), 'task_sms'); + assert.equal(helperUrl.searchParams.get('reference_id'), 'task_sms'); + assert.equal(helperUrl.searchParams.get('phone'), '+8613800138000'); + assert.equal(helperUrl.searchParams.get('consume'), '1'); + assert.equal(helperUrl.searchParams.get('after_ms'), '1710000000000'); + assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_sms/otp')).options.body), { + otp: '654321', + }); + assert.equal(events.completed[0].step, 7); +}); + +test('GPC billing can read WhatsApp OTP from local helper when enabled', async () => { + const fetchCalls = []; + let pollCount = 0; + const { events, executor } = createExecutorHarness({ + frames: [], + stateByFrame: {}, + fetchImpl: async (url, options = {}) => { + fetchCalls.push({ url, options }); + if (url.startsWith('http://127.0.0.1:18767/latest-otp')) { + return { + ok: true, + status: 200, + json: async () => ({ ok: true, otp: '765432', message_id: 'wa-1' }), + }; + } + if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_wa') { + pollCount += 1; + if (pollCount === 1) { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ task_id: 'task_wa', status: 'active', remote_stage: 'whatsapp_otp_wait', api_waiting_for: 'otp' }), + }; + } + if (pollCount === 2) { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ task_id: 'task_wa', status: 'otp_ready', remote_stage: 'otp_ready', api_waiting_for: 'pin' }), + }; + } + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ task_id: 'task_wa', status: 'completed', remote_stage: 'completed' }), + }; + } + if (url.endsWith('/api/gp/tasks/task_wa/otp')) { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ task_id: 'task_wa', status: 'otp_ready', remote_stage: 'otp_ready', api_waiting_for: 'pin' }), + }; + } + if (url.endsWith('/api/gp/tasks/task_wa/pin')) { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ task_id: 'task_wa', status: 'active', remote_stage: 'payment_processing' }), + }; + } + throw new Error(`unexpected url: ${url}`); + }, + }); + + await executor.executePlusCheckoutBilling({ + plusPaymentMethod: 'gpc-helper', + plusCheckoutSource: 'gpc-helper', + gopayHelperTaskId: 'task_wa', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', + gopayHelperPin: '654321', + gopayHelperApiKey: 'gpc_wa', + gopayHelperOtpChannel: 'whatsapp', + gopayHelperLocalSmsHelperEnabled: true, + gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767', + gopayHelperCountryCode: '+86', + gopayHelperPhoneNumber: '18984829950', + }); + + assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false); + assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '765432'), true); + const helperUrl = new URL(fetchCalls.find((call) => call.url.startsWith('http://127.0.0.1:18767/latest-otp')).url); + assert.equal(helperUrl.searchParams.get('phone'), '+8618984829950'); + assert.equal(helperUrl.searchParams.get('consume'), '1'); + assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_wa/otp')).options.body), { + otp: '765432', + }); + assert.equal(events.completed[0].step, 7); +}); + + +test('GPC billing helper mode does not open OTP dialog when helper has no code and task times out', async () => { + const fetchCalls = []; + const { events, executor } = createExecutorHarness({ + frames: [], + stateByFrame: {}, + fetchImpl: async (url, options = {}) => { + fetchCalls.push({ url, options }); + if (url.startsWith('http://127.0.0.1:18767/latest-otp')) { + return { + ok: true, + status: 200, + json: async () => ({ ok: true, status: 'waiting', otp: '', message: '未查询到验证码' }), + }; + } + if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_timeout') { + const queryCount = fetchCalls.filter((call) => call.url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_timeout').length; + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse(queryCount === 1 + ? { + task_id: 'task_timeout', + status: 'active', + remote_stage: 'whatsapp_otp_wait', + api_waiting_for: 'otp', + } + : { + task_id: 'task_timeout', + status: 'failed', + status_text: '充值失败', + remote_stage: 'api_otp_timeout', + error_message: '等待 OTP 超过 60 秒,任务已超时', + }), + }; + } + throw new Error(`unexpected url: ${url}`); + }, + }); + + await assert.rejects( + () => executor.executePlusCheckoutBilling({ + plusPaymentMethod: 'gpc-helper', + plusCheckoutSource: 'gpc-helper', + gopayHelperTaskId: 'task_timeout', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', + gopayHelperPin: '654321', + gopayHelperApiKey: 'gpc_timeout', + gopayHelperOtpChannel: 'whatsapp', + gopayHelperLocalSmsHelperEnabled: true, + gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767', + gopayHelperPhoneNumber: '+8613800138000', + }), + /GPC_TASK_ENDED::等待 OTP 超过 60 秒,任务已超时/ + ); + + assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false); + assert.equal(fetchCalls.some((call) => call.url.endsWith('/api/gp/tasks/task_timeout/otp')), false); + assert.equal(fetchCalls.some((call) => call.url.endsWith('/api/gp/tasks/task_timeout/stop')), false); + assert.ok(events.sleeps.includes(3000)); +}); + +test('GPC billing helper mode requests newer OTP after invalid OTP error', async () => { + const fetchCalls = []; + let taskPollCount = 0; + let helperCallCount = 0; + let otpPostCount = 0; + const { events, executor } = createExecutorHarness({ + frames: [], + stateByFrame: {}, + fetchImpl: async (url, options = {}) => { + fetchCalls.push({ url, options }); + if (url.startsWith('http://127.0.0.1:18767/latest-otp')) { + helperCallCount += 1; + return { + ok: true, + status: 200, + json: async () => ({ ok: true, otp: helperCallCount === 1 ? '111111' : '222222', message_id: `sms-${helperCallCount}` }), + }; + } + if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_retry') { + taskPollCount += 1; + if (taskPollCount === 1) { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_retry', status: 'active', remote_stage: 'sms_otp_wait', api_waiting_for: 'otp' }) }; + } + if (taskPollCount === 2) { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_retry', status: 'active', remote_stage: 'sms_otp_wait', api_waiting_for: 'otp', last_input_error: 'OTP 校验失败,请重新输入正确的 OTP', otp_invalid_count: 1 }) }; + } + if (taskPollCount === 3) { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_retry', status: 'otp_ready', remote_stage: 'otp_ready', api_waiting_for: 'pin' }) }; + } + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_retry', status: 'completed', remote_stage: 'completed' }) }; + } + if (url.endsWith('/api/gp/tasks/task_retry/otp')) { + otpPostCount += 1; + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_retry', status: 'active', remote_stage: 'otp_submitted_local' }) }; + } + if (url.endsWith('/api/gp/tasks/task_retry/pin')) { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_retry', status: 'active', remote_stage: 'payment_processing' }) }; + } + throw new Error(`unexpected url: ${url}`); + }, + }); + + await executor.executePlusCheckoutBilling({ + plusPaymentMethod: 'gpc-helper', + plusCheckoutSource: 'gpc-helper', + gopayHelperTaskId: 'task_retry', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', + gopayHelperPin: '654321', + gopayHelperApiKey: 'gpc_retry', gopayHelperOtpChannel: 'sms', gopayHelperLocalSmsHelperEnabled: true, gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767', @@ -917,81 +1191,101 @@ test('GPC billing reads OTP from local SMS helper when enabled', async () => { gopayHelperOrderCreatedAt: 1710000000000, }); - assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false); - assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '654321'), true); - const helperUrl = new URL(fetchCalls[0].url); - assert.equal(helperUrl.origin + helperUrl.pathname, 'http://127.0.0.1:18767/otp'); - assert.equal(helperUrl.searchParams.get('reference_id'), 'ref_sms'); - assert.equal(helperUrl.searchParams.get('phone_number'), '+8613800138000'); - assert.equal(helperUrl.searchParams.get('after_ms'), '1710000000000'); - assert.deepEqual(JSON.parse(fetchCalls[1].options.body), { - reference_id: 'ref_sms', - otp: '654321', - card_key: 'card_sms', - }); + const otpBodies = fetchCalls + .filter((call) => call.url.endsWith('/api/gp/tasks/task_retry/otp')) + .map((call) => JSON.parse(call.options.body)); + assert.deepEqual(otpBodies, [{ otp: '111111' }, { otp: '222222' }]); + assert.equal(otpPostCount, 2); + const helperUrls = fetchCalls.filter((call) => call.url.startsWith('http://127.0.0.1:18767/latest-otp')).map((call) => new URL(call.url)); + assert.equal(helperUrls.length, 2); + assert.equal(helperUrls[0].searchParams.get('phone'), '+8613800138000'); + assert.equal(helperUrls[0].searchParams.get('consume'), '1'); + assert.equal(helperUrls[0].searchParams.get('after_ms'), '1710000000000'); + assert.equal(helperUrls[1].searchParams.get('phone'), '+8613800138000'); + assert.equal(helperUrls[1].searchParams.get('consume'), '1'); + assert.ok(Number(helperUrls[1].searchParams.get('after_ms')) > 1710000000000); + assert.equal(events.logs.some((entry) => /OTP 校验失败/.test(entry.message)), true); assert.equal(events.completed[0].step, 7); }); -test('GPC billing can read WhatsApp OTP from local helper when enabled', async () => { +test('GPC billing manual OTP wrong input opens next dialog only after previous one closes', async () => { const fetchCalls = []; + let currentState = { + plusManualConfirmationPending: true, + plusManualConfirmationRequestId: '', + }; + let pendingDialogCount = 0; + let pollCount = 0; const { events, executor } = createExecutorHarness({ frames: [], stateByFrame: {}, + getState: async () => currentState, fetchImpl: async (url, options = {}) => { fetchCalls.push({ url, options }); - if (url.startsWith('http://127.0.0.1:18767/otp')) { - return { - ok: true, - status: 200, - json: async () => ({ ok: true, otp: '765432', message_id: 'wa-1' }), - }; + if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_manual_retry') { + pollCount += 1; + if (pollCount === 1) { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_manual_retry', status: 'active', remote_stage: 'whatsapp_otp_wait', api_waiting_for: 'otp' }) }; + } + if (pollCount === 2) { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_manual_retry', status: 'active', remote_stage: 'whatsapp_otp_wait', api_waiting_for: 'otp', last_input_error: 'OTP 校验失败,请重新输入正确的 OTP', otp_invalid_count: 1 }) }; + } + if (pollCount === 3) { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_manual_retry', status: 'otp_ready', remote_stage: 'otp_ready', api_waiting_for: 'pin' }) }; + } + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_manual_retry', status: 'completed', remote_stage: 'completed' }) }; } - if (url.endsWith('/api/gopay/otp')) { - return { - ok: true, - status: 200, - json: async () => ({ reference_id: 'ref_wa', challenge_id: 'challenge_wa' }), - }; + if (url.endsWith('/api/gp/tasks/task_manual_retry/otp')) { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_manual_retry', status: 'active', remote_stage: 'otp_submitted_local' }) }; } - if (url.endsWith('/api/gopay/pin')) { - return { - ok: true, - status: 200, - json: async () => ({ stage: 'gopay_complete' }), - }; + if (url.endsWith('/api/gp/tasks/task_manual_retry/pin')) { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_manual_retry', status: 'active', remote_stage: 'payment_processing' }) }; } throw new Error(`unexpected url: ${url}`); }, + onSetState: async (updates) => { + if (updates?.plusManualConfirmationMethod !== 'gopay-otp') { + return; + } + pendingDialogCount += 1; + const resolvedOtp = pendingDialogCount === 1 ? '111111' : '222222'; + setTimeout(() => { + currentState = { + plusManualConfirmationPending: false, + plusManualConfirmationRequestId: updates.plusManualConfirmationRequestId, + gopayHelperResolvedOtp: resolvedOtp, + }; + }, 0); + }, }); - await executor.executePlusCheckoutBilling({ + const run = executor.executePlusCheckoutBilling({ plusPaymentMethod: 'gpc-helper', plusCheckoutSource: 'gpc-helper', - gopayHelperReferenceId: 'ref_wa', - gopayHelperApiUrl: 'https://gopay.hwork.pro/', + gopayHelperTaskId: 'task_manual_retry', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', gopayHelperPin: '654321', - gopayHelperCardKey: 'card_wa', - gopayHelperOtpChannel: 'whatsapp', - gopayHelperLocalSmsHelperEnabled: true, - gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767', - gopayHelperPhoneNumber: '+8613800138000', + gopayHelperApiKey: 'gpc_manual_retry', }); - assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false); - assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '765432'), true); - const helperUrl = new URL(fetchCalls[0].url); - assert.equal(helperUrl.origin + helperUrl.pathname, 'http://127.0.0.1:18767/otp'); - assert.equal(helperUrl.searchParams.get('reference_id'), 'ref_wa'); - assert.equal(helperUrl.searchParams.get('phone_number'), '+8613800138000'); - assert.deepEqual(JSON.parse(fetchCalls[1].options.body), { - reference_id: 'ref_wa', - otp: '765432', - card_key: 'card_wa', - }); + await new Promise((resolve) => setTimeout(resolve, 20)); + const firstPending = events.states.find((state) => state.plusManualConfirmationMethod === 'gopay-otp'); + assert.ok(firstPending); + assert.equal(events.states.filter((state) => state.plusManualConfirmationMethod === 'gopay-otp').length, 1); + await new Promise((resolve) => setTimeout(resolve, 650)); + const pendingDialogs = events.states.filter((state) => state.plusManualConfirmationMethod === 'gopay-otp'); + assert.equal(pendingDialogs.length, 2); + assert.notEqual(pendingDialogs[1].plusManualConfirmationRequestId, firstPending.plusManualConfirmationRequestId); + assert.match(pendingDialogs[1].plusManualConfirmationMessage, /OTP 校验失败/); + await run; + const otpBodies = fetchCalls + .filter((call) => call.url.endsWith('/api/gp/tasks/task_manual_retry/otp')) + .map((call) => JSON.parse(call.options.body)); + assert.deepEqual(otpBodies, [{ otp: '111111' }, { otp: '222222' }]); assert.equal(events.completed[0].step, 7); }); -test('GPC billing retries OTP with compatibility field after HTTP 400', async () => { +test('GPC billing manual OTP cancel stops task and ends current round', async () => { const fetchCalls = []; let currentState = { plusManualConfirmationPending: true, @@ -1003,26 +1297,11 @@ test('GPC billing retries OTP with compatibility field after HTTP 400', async () getState: async () => currentState, fetchImpl: async (url, options = {}) => { fetchCalls.push({ url, options }); - if (url.endsWith('/api/gopay/otp') && fetchCalls.filter((call) => call.url.endsWith('/api/gopay/otp')).length === 1) { - return { - ok: false, - status: 400, - json: async () => ({ error: 'otp field invalid' }), - }; + if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_cancel') { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_cancel', status: 'active', remote_stage: 'whatsapp_otp_wait', api_waiting_for: 'otp' }) }; } - if (url.endsWith('/api/gopay/otp')) { - return { - ok: true, - status: 200, - json: async () => ({ challenge_id: 'challenge_retry' }), - }; - } - if (url.endsWith('/api/gopay/pin')) { - return { - ok: true, - status: 200, - json: async () => ({ stage: 'gopay_complete' }), - }; + if (url.endsWith('/api/gp/tasks/task_cancel/stop')) { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_cancel', status: 'discarded', status_text: '已停止' }) }; } throw new Error(`unexpected url: ${url}`); }, @@ -1031,35 +1310,145 @@ test('GPC billing retries OTP with compatibility field after HTTP 400', async () const run = executor.executePlusCheckoutBilling({ plusPaymentMethod: 'gpc-helper', plusCheckoutSource: 'gpc-helper', - gopayHelperReferenceId: 'ref_retry', - gopayHelperGoPayGuid: 'guid_retry', - gopayHelperRedirectUrl: 'https://pm-redirects.stripe.com/retry', - gopayHelperApiUrl: 'http://localhost:18473/', + gopayHelperTaskId: 'task_cancel', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', gopayHelperPin: '654321', - gopayHelperCardKey: 'card_retry', - gopayHelperFlowId: 'flow_retry', + gopayHelperApiKey: 'gpc_cancel', }); await new Promise((resolve) => setTimeout(resolve, 20)); const pending = events.states.find((state) => state.plusManualConfirmationMethod === 'gopay-otp'); + assert.ok(pending); currentState = { plusManualConfirmationPending: false, plusManualConfirmationRequestId: pending.plusManualConfirmationRequestId, - gopayHelperResolvedOtp: '123456', + gopayHelperResolvedOtp: '', }; - await run; - - assert.equal(fetchCalls.filter((call) => call.url.endsWith('/api/gopay/otp')).length, 2); - assert.deepEqual(JSON.parse(fetchCalls[1].options.body), { - reference_id: 'ref_retry', - otp: '123456', - card_key: 'card_retry', - flow_id: 'flow_retry', - gopay_guid: 'guid_retry', - redirect_url: 'https://pm-redirects.stripe.com/retry', - code: '123456', - }); - assert.equal(events.logs.some((entry) => /兼容字段重试/.test(entry.message)), true); - assert.equal(events.completed[0].step, 7); + await assert.rejects(run, /GPC_TASK_ENDED::OTP 输入已取消,已结束当前 GPC 任务。/); + const stopCall = fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_cancel/stop')); + assert.ok(stopCall); + assert.equal(stopCall.options.headers['X-API-Key'], 'gpc_cancel'); + assert.equal(events.completed.length, 0); +}); + +test('GPC billing PIN failure ends task without retrying PIN', async () => { + const fetchCalls = []; + let pollCount = 0; + const { executor } = createExecutorHarness({ + frames: [], + stateByFrame: {}, + fetchImpl: async (url, options = {}) => { + fetchCalls.push({ url, options }); + if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_pin_failed') { + pollCount += 1; + if (pollCount === 1) { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_pin_failed', status: 'otp_ready', status_text: '等待 PIN', remote_stage: 'otp_ready', api_waiting_for: 'pin' }) }; + } + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_pin_failed', status: 'failed', status_text: '充值失败', remote_stage: 'gopay_validate_pin', failure_stage: 'gopay_validate_pin', failure_detail: 'PIN 校验失败', error_message: 'GoPay PIN validation failed' }) }; + } + if (url.endsWith('/api/gp/tasks/task_pin_failed/pin')) { + return { ok: true, status: 200, json: async () => createGpcTaskResponse({ task_id: 'task_pin_failed', status: 'active', remote_stage: 'pin_submitted_local' }) }; + } + throw new Error(`unexpected url: ${url}`); + }, + }); + + await assert.rejects( + () => executor.executePlusCheckoutBilling({ + plusPaymentMethod: 'gpc-helper', + plusCheckoutSource: 'gpc-helper', + gopayHelperTaskId: 'task_pin_failed', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', + gopayHelperPin: '654321', + gopayHelperApiKey: 'gpc_pin_failed', + }), + /GPC_TASK_ENDED::GoPay PIN validation failed(gopay_validate_pin)/ + ); + + assert.equal(fetchCalls.filter((call) => call.url.endsWith('/api/gp/tasks/task_pin_failed/pin')).length, 1); + assert.equal(fetchCalls.some((call) => call.url.endsWith('/api/gp/tasks/task_pin_failed/stop')), false); +}); + +for (const terminalStatus of ['failed', 'expired', 'discarded']) { + test(`GPC billing throws readable error for terminal ${terminalStatus} task`, async () => { + const fetchCalls = []; + const { executor } = createExecutorHarness({ + frames: [], + stateByFrame: {}, + fetchImpl: async (url, options = {}) => { + fetchCalls.push({ url, options }); + if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_bad') { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ + task_id: 'task_bad', + status: terminalStatus, + status_text: terminalStatus, + error_message: '用户可读失败原因', + }), + }; + } + throw new Error(`unexpected url: ${url}`); + }, + }); + + await assert.rejects( + () => executor.executePlusCheckoutBilling({ + plusPaymentMethod: 'gpc-helper', + plusCheckoutSource: 'gpc-helper', + gopayHelperTaskId: 'task_bad', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', + gopayHelperPin: '654321', + gopayHelperApiKey: 'gpc_bad', + }), + /GPC_TASK_ENDED::用户可读失败原因/ + ); + + assert.equal(fetchCalls.some((call) => call.url.endsWith('/api/gp/tasks/task_bad/stop')), false); + }); +} + +test('GPC billing stops task best-effort when flow is interrupted before terminal state', async () => { + const fetchCalls = []; + const { executor } = createExecutorHarness({ + frames: [], + stateByFrame: {}, + fetchImpl: async (url, options = {}) => { + fetchCalls.push({ url, options }); + if (url === 'https://gpc.qlhazycoder.top/api/gp/tasks/task_stop') { + return { + ok: false, + status: 500, + json: async () => ({ code: 500, message: 'server_error', data: { detail: '临时失败' } }), + }; + } + if (url.endsWith('/api/gp/tasks/task_stop/stop')) { + return { + ok: true, + status: 200, + json: async () => createGpcTaskResponse({ task_id: 'task_stop', status: 'discarded', status_text: '已停止' }), + }; + } + throw new Error(`unexpected url: ${url}`); + }, + }); + + await assert.rejects( + () => executor.executePlusCheckoutBilling({ + plusPaymentMethod: 'gpc-helper', + plusCheckoutSource: 'gpc-helper', + gopayHelperTaskId: 'task_stop', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', + gopayHelperPin: '654321', + gopayHelperApiKey: 'gpc_stop', + }), + /临时失败/ + ); + + const stopCall = fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_stop/stop')); + assert.ok(stopCall); + assert.deepEqual(JSON.parse(stopCall.options.body), {}); + assert.equal(stopCall.options.headers['X-API-Key'], 'gpc_stop'); }); diff --git a/tests/plus-checkout-create-wait.test.js b/tests/plus-checkout-create-wait.test.js index efc6605..0795137 100644 --- a/tests/plus-checkout-create-wait.test.js +++ b/tests/plus-checkout-create-wait.test.js @@ -107,7 +107,7 @@ test('GoPay plus checkout create forwards gopay payment method to the checkout c assert.deepStrictEqual(events[0]?.payload, { paymentMethod: 'gopay' }); }); -test('GPC checkout injects Plus script before reading ChatGPT session token and sends card_key', async () => { +test('GPC checkout injects Plus script before reading ChatGPT session token and sends X-API-Key', async () => { const events = []; const fetchCalls = []; const executor = api.createPlusCheckoutCreateExecutor({ @@ -129,10 +129,16 @@ test('GPC checkout injects Plus script before reading ChatGPT session token and ok: true, status: 200, json: async () => ({ - reference_id: 'ref_123', - gopay_guid: 'guid_456', - next_action: 'enter_otp', - flow_id: 'flow_789', + code: 200, + message: 'ok', + data: { + task_id: 'task_123', + status: 'active', + status_text: '处理中', + phone_mode: 'manual', + remote_stage: 'checkout_start', + otp_channel: 'whatsapp', + }, }), }; }, @@ -149,12 +155,12 @@ test('GPC checkout injects Plus script before reading ChatGPT session token and await executor.executePlusCheckoutCreate({ email: 'Current.Round+GPC@Example.COM', plusPaymentMethod: 'gpc-helper', - gopayHelperApiUrl: 'https://gopay.hwork.pro/', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', gopayHelperPhoneNumber: '+8613800138000', gopayPhone: '', gopayHelperCountryCode: '+86', gopayHelperPin: '123456', - gopayHelperCardKey: 'card_test_123', + gopayHelperApiKey: 'gpc_test_123', }); const readyIndex = events.findIndex((event) => event.type === 'ready'); @@ -167,20 +173,27 @@ test('GPC checkout injects Plus script before reading ChatGPT session token and includeAccessToken: true, }); assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url, 'https://gopay.hwork.pro/api/checkout/start'); + assert.equal(fetchCalls[0].url, 'https://gpc.qlhazycoder.top/api/gp/tasks'); const helperPayload = JSON.parse(fetchCalls[0].options.body); - assert.equal(helperPayload.customer_email, 'current.round+gpc@example.com'); - assert.equal(helperPayload.card_key, 'card_test_123'); - assert.deepEqual(helperPayload.gopay_link, { - type: 'gopay', + assert.deepEqual(helperPayload, { + access_token: 'session-access-token', + phone_mode: 'manual', country_code: '86', phone_number: '13800138000', - phone_mode: 'manual', otp_channel: 'whatsapp', }); + assert.equal(fetchCalls[0].options.headers['X-API-Key'], 'gpc_test_123'); + assert.equal(Object.prototype.hasOwnProperty.call(helperPayload, 'card_key'), false); + assert.equal(Object.prototype.hasOwnProperty.call(helperPayload, 'customer_email'), false); + assert.equal(Object.prototype.hasOwnProperty.call(helperPayload, 'checkout_ui_mode'), false); + assert.equal(Object.prototype.hasOwnProperty.call(helperPayload, 'gopay_link'), false); + assert.equal(Object.prototype.hasOwnProperty.call(helperPayload, 'plan_name'), false); assert.equal(events.find((event) => event.type === 'set-state')?.payload?.plusCheckoutSource, 'gpc-helper'); - assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperReferenceId, 'ref_123'); - assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperFlowId, 'flow_789'); + assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperTaskId, 'task_123'); + assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperTaskStatus, 'active'); + assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperStatusText, '处理中'); + assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperRemoteStage, 'checkout_start'); + assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperReferenceId, ''); assert.ok(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperOrderCreatedAt > 0); assert.equal(events.find((event) => event.type === 'complete')?.step, 6); assert.equal(events.find((event) => event.type === 'complete')?.payload?.plusCheckoutSource, 'gpc-helper'); @@ -203,7 +216,11 @@ test('GPC checkout forwards selected SMS OTP channel', async () => { return { ok: true, status: 200, - json: async () => ({ reference_id: 'ref_sms', next_action: 'enter_otp' }), + json: async () => ({ + code: 200, + message: 'ok', + data: { task_id: 'task_sms', status: 'active', phone_mode: 'manual', remote_stage: 'checkout_start' }, + }), }; }, registerTab: async () => {}, @@ -216,25 +233,25 @@ test('GPC checkout forwards selected SMS OTP channel', async () => { await executor.executePlusCheckoutCreate({ email: 'sms@example.com', plusPaymentMethod: 'gpc-helper', - gopayHelperApiUrl: 'https://gopay.hwork.pro/', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', gopayHelperPhoneNumber: '+8613800138000', gopayHelperCountryCode: '+86', gopayHelperPin: '123456', - gopayHelperCardKey: 'card_sms', + gopayHelperApiKey: 'gpc_sms', gopayHelperOtpChannel: 'sms', }); const helperPayload = JSON.parse(fetchCalls[0].options.body); - assert.equal(helperPayload.gopay_link.phone_mode, 'manual'); - assert.equal(helperPayload.gopay_link.otp_channel, 'sms'); + assert.equal(helperPayload.phone_mode, 'manual'); + assert.equal(helperPayload.otp_channel, 'sms'); + assert.equal(fetchCalls[0].options.headers['X-API-Key'], 'gpc_sms'); + assert.equal(Object.prototype.hasOwnProperty.call(helperPayload, 'card_key'), false); }); -test('GPC checkout treats non-zero API amount as non-free-trial and does not create order', async () => { - const markCalls = []; +test('GPC checkout surfaces unified queue API errors', async () => { const fetchCalls = []; - const events = []; const executor = api.createPlusCheckoutCreateExecutor({ - addLog: async (message, level = 'info') => events.push({ type: 'log', message, level }), + addLog: async () => {}, chrome: { tabs: { create: async () => { @@ -243,27 +260,20 @@ test('GPC checkout treats non-zero API amount as non-free-trial and does not cre remove: async () => {}, }, }, - completeStepFromBackground: async () => { - throw new Error('should not complete step 6 for non-free-trial checkout'); - }, + completeStepFromBackground: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {}, fetch: async (url, options = {}) => { fetchCalls.push({ url, options }); return { - ok: true, - status: 200, + ok: false, + status: 400, json: async () => ({ - reference_id: 'ref_paid', - gopay_guid: 'guid_paid', - next_action: 'enter_otp', - checkout: { amount_due: 'Rp 29.000' }, + code: 400, + message: 'invalid_param', + data: { detail: 'access_token 无效' }, }), }; }, - markCurrentRegistrationAccountUsed: async (state, options) => { - markCalls.push({ state, options }); - return { updated: true }; - }, registerTab: async () => {}, sendTabMessageUntilStopped: async () => {}, setState: async () => {}, @@ -275,22 +285,19 @@ test('GPC checkout treats non-zero API amount as non-free-trial and does not cre () => executor.executePlusCheckoutCreate({ email: 'paid@example.com', plusPaymentMethod: 'gpc-helper', - gopayHelperApiUrl: 'https://gopay.hwork.pro/', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', chatgptAccessToken: 'state-access-token', gopayHelperPhoneNumber: '+8613800138000', gopayHelperCountryCode: '+86', gopayHelperPin: '123456', - gopayHelperCardKey: 'card_paid_456', + gopayHelperApiKey: 'gpc_paid_456', }), - /PLUS_CHECKOUT_NON_FREE_TRIAL::.*余额非 0/ + /创建 GPC 订单失败:access_token 无效/ ); assert.equal(fetchCalls.length, 1); - assert.equal(JSON.parse(fetchCalls[0].options.body).card_key, 'card_paid_456'); - assert.equal(markCalls.length, 1); - assert.equal(markCalls[0].state.email, 'paid@example.com'); - assert.equal(markCalls[0].options.reason, 'plus-checkout-non-free-trial'); - assert.equal(events.some((event) => event.type === 'log' && /订单已创建/.test(event.message)), false); + assert.equal(Object.prototype.hasOwnProperty.call(JSON.parse(fetchCalls[0].options.body), 'card_key'), false); + assert.equal(fetchCalls[0].options.headers['X-API-Key'], 'gpc_paid_456'); }); test('GPC checkout does not fall back to browser GoPay phone fields', async () => { @@ -319,7 +326,7 @@ test('GPC checkout does not fall back to browser GoPay phone fields', async () = await assert.rejects( () => executor.executePlusCheckoutCreate({ plusPaymentMethod: 'gpc-helper', - gopayHelperApiUrl: 'https://gopay.hwork.pro/', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', chatgptAccessToken: 'state-access-token', email: 'helper-phone-test@example.com', gopayPhone: '+8613800138000', @@ -327,13 +334,13 @@ test('GPC checkout does not fall back to browser GoPay phone fields', async () = gopayPin: '123456', gopayHelperPhoneNumber: '', gopayHelperPin: '123456', - gopayHelperCardKey: 'card_phone_test', + gopayHelperApiKey: 'gpc_phone_test', }), /缺少手机号/ ); }); -test('GPC checkout rejects missing card key before calling helper API', async () => { +test('GPC checkout rejects missing API Key before calling helper API', async () => { const executor = api.createPlusCheckoutCreateExecutor({ addLog: async () => {}, chrome: { @@ -347,7 +354,7 @@ test('GPC checkout rejects missing card key before calling helper API', async () completeStepFromBackground: async () => {}, ensureContentScriptReadyOnTabUntilStopped: async () => {}, fetch: async () => { - throw new Error('should not call helper API without card key'); + throw new Error('should not call helper API without API Key'); }, registerTab: async () => {}, sendTabMessageUntilStopped: async () => {}, @@ -359,14 +366,14 @@ test('GPC checkout rejects missing card key before calling helper API', async () await assert.rejects( () => executor.executePlusCheckoutCreate({ plusPaymentMethod: 'gpc-helper', - gopayHelperApiUrl: 'https://gopay.hwork.pro/', + gopayHelperApiUrl: 'https://gpc.qlhazycoder.top/', chatgptAccessToken: 'state-access-token', email: 'missing-card@example.com', gopayHelperPhoneNumber: '+8613800138000', gopayHelperCountryCode: '+86', gopayHelperPin: '123456', - gopayHelperCardKey: '', + gopayHelperApiKey: '', }), - /缺少卡密/ + /缺少 API Key/ ); }); diff --git a/tests/sidepanel-plus-payment-method.test.js b/tests/sidepanel-plus-payment-method.test.js index dc0f5fa..fa0f09f 100644 --- a/tests/sidepanel-plus-payment-method.test.js +++ b/tests/sidepanel-plus-payment-method.test.js @@ -205,7 +205,7 @@ return { }); }); -test('sidepanel Plus UI shows GPC fields and purchase button only for GPC without API input', () => { +test('sidepanel Plus UI shows GPC fields and purchase button only for GPC', () => { const bundle = [ extractFunction('normalizePlusPaymentMethod'), extractFunction('getSelectedPlusPaymentMethod'), @@ -253,7 +253,7 @@ return { assert.equal(api.rowPayPalAccount.style.display, 'none'); assert.equal(api.btnGpcCardKeyPurchase.style.display, ''); - assert.equal(api.rows.rowGpcHelperApi.style.display, 'none'); + assert.equal(api.rows.rowGpcHelperApi.style.display, ''); assert.equal(api.rows.rowGpcHelperCardKey.style.display, ''); assert.equal(api.rows.rowGpcHelperPhone.style.display, ''); assert.equal(api.rows.rowGpcHelperOtpChannel.style.display, ''); diff --git a/tests/step-definitions-module.test.js b/tests/step-definitions-module.test.js index b4c1d21..e4620a8 100644 --- a/tests/step-definitions-module.test.js +++ b/tests/step-definitions-module.test.js @@ -136,8 +136,11 @@ test('sidepanel html exposes Plus mode, PayPal, and GoPay settings', () => { assert.match(html, /