fix(flow): harden proxy phone and checkout recovery paths

This commit is contained in:
朴圣佑
2026-05-03 13:58:33 +08:00
committed by QLHazyCoder
parent d851cc4d36
commit 29093e08db
34 changed files with 2958 additions and 293 deletions
+295 -29
View File
@@ -48,6 +48,7 @@
const HERO_SMS_LAST_PRICE_COUNTRY_LABEL_KEY = 'heroSmsLastPriceCountryLabel';
const HERO_SMS_LAST_PRICE_USER_LIMIT_KEY = 'heroSmsLastPriceUserLimit';
const HERO_SMS_LAST_PRICE_AT_KEY = 'heroSmsLastPriceAt';
const FIVE_SIM_RATE_LIMIT_ERROR_PREFIX = 'FIVE_SIM_RATE_LIMIT::';
const PHONE_CODE_WAIT_SECONDS_MIN = 15;
const PHONE_CODE_WAIT_SECONDS_MAX = 300;
const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1;
@@ -207,11 +208,23 @@
}
function resolveFiveSimCountryCandidates(state = {}) {
const codes = normalizeFiveSimCountryOrder(state?.fiveSimCountryOrder);
let codes = normalizeFiveSimCountryOrder(state?.fiveSimCountryOrder);
if (!codes.length) {
const legacyPrimary = normalizeFiveSimCountryCode(state?.fiveSimCountryId, '');
const legacyFallback = normalizeFiveSimCountryOrder(state?.fiveSimCountryFallback);
codes = normalizeFiveSimCountryOrder([
...(legacyPrimary ? [legacyPrimary] : []),
...legacyFallback,
]);
}
return codes.map((code) => ({
code,
id: code,
label: code,
label: (
code === normalizeFiveSimCountryCode(state?.fiveSimCountryId, '')
? normalizeCountryLabel(state?.fiveSimCountryLabel, code)
: code
),
}));
}
@@ -571,10 +584,12 @@
(Array.isArray(payloads) ? payloads : [])
.flatMap((payload) => collectHeroSmsPriceCandidatesIncludingZeroStock(payload, []))
);
const mergedCandidates = buildSortedUniquePriceCandidates([
...inStockCandidates,
...allCatalogCandidates,
]);
const mergedCandidates = inStockCandidates.length
? buildSortedUniquePriceCandidates([
...inStockCandidates,
...allCatalogCandidates,
])
: [];
const minCatalogPrice = allCatalogCandidates.length
? allCatalogCandidates[0]
: (mergedCandidates.length ? mergedCandidates[0] : null);
@@ -1127,6 +1142,15 @@
);
}
function buildPhoneReplacementLimitError(maxNumberReplacementAttempts, reason = '') {
const safeMax = Math.max(0, Math.floor(Number(maxNumberReplacementAttempts) || 0));
const safeReason = String(reason || 'unknown').trim() || 'unknown';
return new Error(
`步骤 9:更换 ${safeMax} 次号码后手机号验证仍未成功。最后原因:${safeReason}. `
+ `Step 9: phone verification did not succeed after ${safeMax} number replacements. Last reason: ${safeReason}.`
);
}
function sanitizePhoneCodeTimeoutError(error) {
const message = String(error?.message || '');
if (!message.startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX)) {
@@ -1357,12 +1381,16 @@
if (!apiKey) {
throw new Error('5sim API key is missing. Save it in the side panel before running the phone flow.');
}
const configuredMaxPrice = normalizeHeroSmsPriceLimit(state.fiveSimMaxPrice);
return {
provider,
apiKey,
baseUrl: normalizeUrl(state.fiveSimBaseUrl, DEFAULT_FIVE_SIM_BASE_URL).replace(/\/+$/, ''),
operator: normalizeFiveSimCountryCode(state.fiveSimOperator, DEFAULT_FIVE_SIM_OPERATOR),
product: normalizeFiveSimCountryCode(state.fiveSimProduct, DEFAULT_FIVE_SIM_PRODUCT),
maxPriceLimit: configuredMaxPrice !== null
? configuredMaxPrice
: normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice),
countryCandidates: resolveFiveSimCountryCandidates(state),
};
}
@@ -1468,9 +1496,15 @@
}
function resolveHeroSmsStockState(payload = {}) {
const physicalCount = Number(payload.physicalCount);
if (Number.isFinite(physicalCount)) {
return {
hasStockField: true,
stockCount: physicalCount,
};
}
const stockCandidates = [
payload.count,
payload.physicalCount,
payload.stock,
payload.available,
payload.quantity,
@@ -1869,6 +1903,26 @@
return candidates;
}
function collectFiveSimProductPriceCandidates(payload, product = DEFAULT_FIVE_SIM_PRODUCT, candidates = []) {
if (Array.isArray(payload)) {
payload.forEach((entry) => collectFiveSimProductPriceCandidates(entry, product, candidates));
return candidates;
}
if (!payload || typeof payload !== 'object') {
return candidates;
}
const productPayload = payload[product] || payload[String(product || '').toLowerCase()];
if (productPayload && typeof productPayload === 'object') {
const price = Number(productPayload.Price ?? productPayload.price ?? productPayload.cost);
const qty = Number(productPayload.Qty ?? productPayload.qty ?? productPayload.count);
if (Number.isFinite(price) && price > 0 && (!Number.isFinite(qty) || qty > 0)) {
candidates.push(Math.round(price * 10000) / 10000);
}
}
Object.values(payload).forEach((entry) => collectFiveSimProductPriceCandidates(entry, product, candidates));
return candidates;
}
function findLowestFiveSimPrice(payload, product = DEFAULT_FIVE_SIM_PRODUCT, countryCode = '') {
const normalizedProduct = normalizeFiveSimCountryCode(product, DEFAULT_FIVE_SIM_PRODUCT);
const normalizedCountryCode = normalizeFiveSimCountryCode(countryCode, '');
@@ -1892,6 +1946,21 @@
return /no\s+free\s+phones|no\s+phones\s+available|no\s+numbers\s+available/i.test(text);
}
function isFiveSimRateLimitError(payloadOrMessage, status = 0) {
if (Number(status) === 429) {
return true;
}
const text = describeFiveSimPayload(payloadOrMessage);
return /rate\s*limit|too\s*many\s*requests|request\s*limit|429/i.test(text);
}
function buildFiveSimRateLimitError(details = []) {
const suffix = Array.isArray(details) && details.length
? `${details.join(' | ')}`
: '。';
return new Error(`${FIVE_SIM_RATE_LIMIT_ERROR_PREFIX}5sim 购买接口触发限流,请稍后再试${suffix}`);
}
function isFiveSimTerminalError(payloadOrMessage, status = 0) {
if (Number(status) === 401 || Number(status) === 403) {
return true;
@@ -1995,7 +2064,9 @@
}
}
const maxPriceLimit = normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice);
const maxPriceLimit = config.maxPriceLimit === undefined
? normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice)
: config.maxPriceLimit;
const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority);
const preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice);
const countryPriceFloorByCountryCode = normalizeCountryPriceFloorMap(
@@ -2007,6 +2078,7 @@
const retryDelayMs = normalizePhoneActivationRetryDelayMs(state?.heroSmsActivationRetryDelayMs);
let finalNoNumbersByCountry = [];
let finalRateLimitByCountry = [];
let finalLastError = null;
for (let round = 1; round <= maxAcquireRounds; round += 1) {
@@ -2017,6 +2089,7 @@
);
}
const noNumbersByCountry = [];
const rateLimitByCountry = [];
const retryableNoNumberCountries = [];
let lastError = null;
@@ -2068,7 +2141,20 @@
const countryLabel = String(countryConfig.label || countryCode).trim() || countryCode;
const countryPriceFloor = countryPriceFloorByCountryCode.get(countryCode) ?? null;
try {
const explicitFiveSimMaxPriceLimit = normalizeHeroSmsPriceLimit(state.fiveSimMaxPrice);
let guestPricesPayload = null;
let productPricesPayload = null;
if (explicitFiveSimMaxPriceLimit !== null) {
try {
productPricesPayload = await fetchFiveSimPayload(
config,
`/guest/products/${countryCode}/${config.operator}`,
'5sim guest products'
);
} catch (_) {
productPricesPayload = null;
}
}
try {
guestPricesPayload = await fetchFiveSimPayload(
config,
@@ -2086,16 +2172,23 @@
}
const rawPriceCandidates = buildSortedUniquePriceCandidates(
collectFiveSimPriceCandidates(
(
guestPricesPayload
&& typeof guestPricesPayload === 'object'
&& !Array.isArray(guestPricesPayload)
? (guestPricesPayload?.[config.product]?.[countryCode] || guestPricesPayload?.[countryCode] || guestPricesPayload)
: guestPricesPayload
[
...(
normalizeHeroSmsPriceLimit(state.fiveSimMaxPrice) !== null
? collectFiveSimProductPriceCandidates(productPricesPayload, config.product, [])
: []
),
[]
)
...collectFiveSimPriceCandidates(
(
guestPricesPayload
&& typeof guestPricesPayload === 'object'
&& !Array.isArray(guestPricesPayload)
? (guestPricesPayload?.[config.product]?.[countryCode] || guestPricesPayload?.[countryCode] || guestPricesPayload)
: guestPricesPayload
),
[]
),
]
);
const boundedPriceCandidates = maxPriceLimit === null
? rawPriceCandidates
@@ -2106,7 +2199,14 @@
preferredPriceTier
);
const orderedPrices = orderedPricesFromCatalog.length
? orderedPricesFromCatalog
? (
explicitFiveSimMaxPriceLimit !== null
? [
explicitFiveSimMaxPriceLimit,
...orderedPricesFromCatalog.filter((price) => Number(price) !== Number(explicitFiveSimMaxPriceLimit)),
]
: orderedPricesFromCatalog
)
: (maxPriceLimit !== null ? [maxPriceLimit] : [null]);
const floorFilteredPrices = filterPriceCandidatesAboveFloor(orderedPrices, countryPriceFloor);
const hasCountryPriceFloor = (
@@ -2181,6 +2281,10 @@
break;
}
const payloadText = describeFiveSimPayload(payload);
if (isFiveSimRateLimitError(payload)) {
countryNoNumbersText = payloadText || countryNoNumbersText || 'rate limit';
continue;
}
if (isFiveSimNoNumbersError(payload)) {
countryNoNumbersText = payloadText || countryNoNumbersText || 'no free phones';
continue;
@@ -2190,6 +2294,10 @@
}
lastError = new Error(`5sim buy activation failed: ${payloadText || 'empty response'}`);
} catch (error) {
if (isFiveSimRateLimitError(error?.payload || error?.message, error?.status)) {
countryNoNumbersText = describeFiveSimPayload(error?.payload || error?.message) || countryNoNumbersText || 'rate limit';
continue;
}
if (isFiveSimTerminalError(error?.payload || error?.message, error?.status)) {
throw new Error(`5sim buy activation failed: ${describeFiveSimPayload(error?.payload || error?.message) || 'unknown terminal error'}`);
}
@@ -2210,12 +2318,18 @@
noNumbersByCountry.push(
`${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestPrice}`
);
} else if (isFiveSimRateLimitError(countryNoNumbersText)) {
rateLimitByCountry.push(`${countryLabel}: ${countryNoNumbersText || 'rate limit'}`);
} else {
noNumbersByCountry.push(`${countryLabel}: ${countryNoNumbersText || 'no free phones'}`);
retryableNoNumberCountries.push(countryLabel);
}
continue;
} catch (error) {
if (isFiveSimRateLimitError(error?.payload || error?.message, error?.status)) {
rateLimitByCountry.push(`${countryLabel}: ${describeFiveSimPayload(error?.payload || error?.message) || 'rate limit'}`);
continue;
}
if (isFiveSimTerminalError(error?.payload || error?.message, error?.status)) {
throw new Error(`5sim buy activation failed: ${describeFiveSimPayload(error?.payload || error?.message) || 'unknown terminal error'}`);
}
@@ -2236,8 +2350,13 @@
}
finalNoNumbersByCountry = noNumbersByCountry;
finalRateLimitByCountry = rateLimitByCountry;
finalLastError = lastError;
if (rateLimitByCountry.length) {
throw buildFiveSimRateLimitError(rateLimitByCountry);
}
if (
noNumbersByCountry.length
&& round < maxAcquireRounds
@@ -2259,6 +2378,9 @@
`5sim no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.`
);
}
if (finalRateLimitByCountry.length) {
throw buildFiveSimRateLimitError(finalRateLimitByCountry);
}
if (finalLastError) {
throw finalLastError;
}
@@ -2714,6 +2836,10 @@
`步骤 9:HeroSMS 正在获取手机号(第 ${round}/${maxAcquireRounds} 轮)...`,
'info'
);
await addLog(
`步骤 9:HeroSMS 正在获取手机号(第 ${round}/${maxAcquireRounds} 轮)...`,
'info'
);
}
const countryAttempts = countryCandidates.map((countryConfig, index) => ({
@@ -2935,6 +3061,10 @@
`步骤 9:HeroSMS 暂无可用号码(第 ${round}/${maxAcquireRounds} 轮);${Math.ceil(retryDelayMs / 1000)} 秒后重试。国家:${retryableNoNumberCountries.join(', ')}`,
'warn'
);
await addLog(
`步骤 9:HeroSMS 暂无可用号码(第 ${round}/${maxAcquireRounds} 轮),${Math.ceil(retryDelayMs / 1000)} 秒后重试。国家:${retryableNoNumberCountries.join(', ')}`,
'warn'
);
await sleepWithStop(retryDelayMs);
continue;
}
@@ -2945,6 +3075,7 @@
if (finalNoNumbersByCountry.length) {
throw new Error(
`HeroSMS 已尝试 ${countryCandidates.length} 个候选国家,均无可用号码:${finalNoNumbersByCountry.join(' | ')}`
+ ` HeroSMS no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.`
);
}
if (finalLastError) {
@@ -3606,7 +3737,9 @@
const successfulUses = normalizedActivation.successfulUses + 1;
const nextReusableActivation = {
...normalizedActivation,
successfulUses,
successfulUses: normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM
? 1
: successfulUses,
};
await upsertReusableActivationPool(nextReusableActivation, { state });
if (!normalizeHeroSmsReuseEnabled(state?.heroSmsReuseEnabled)) {
@@ -3907,10 +4040,147 @@
countryPriceFloorByKey.delete(countryKey);
};
const getBlockedCountryIds = () => Array.from(countrySmsFailureCounts.entries())
.filter(([, count]) => Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD)
.map(([countryId]) => countryId)
.filter(Boolean);
const getBlockedCountryIds = () => {
const activeProvider = normalizePhoneSmsProvider(
state?.phoneSmsProvider || activation?.provider || DEFAULT_PHONE_SMS_PROVIDER
);
return Array.from(countrySmsFailureCounts.entries())
.filter(([, count]) => Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD)
.map(([countryKey]) => splitCountryFailureKey(countryKey, activeProvider))
.filter((entry) => entry.provider === activeProvider)
.map((entry) => String(entry.countryKey || '').trim())
.filter(Boolean);
};
const getCountryPriceFloorById = () => {
const activeProvider = normalizePhoneSmsProvider(
state?.phoneSmsProvider || activation?.provider || DEFAULT_PHONE_SMS_PROVIDER
);
const floorById = {};
countryPriceFloorByKey.forEach((price, compoundCountryKey) => {
const numeric = normalizeHeroSmsPrice(price);
if (numeric === null || numeric <= 0) {
return;
}
const parsed = splitCountryFailureKey(compoundCountryKey, activeProvider);
if (parsed.provider !== activeProvider) {
return;
}
const keyPart = String(parsed.countryKey || '').trim();
if (!keyPart) {
return;
}
floorById[keyPart] = Math.round(numeric * 10000) / 10000;
});
return floorById;
};
const setCountryPriceFloorFromActivation = async (activationCandidate, reason = '') => {
const normalizedActivation = normalizeActivation(activationCandidate);
if (!normalizedActivation) {
return;
}
const countryKey = normalizeCountryFailureKey(
normalizedActivation.countryId,
normalizedActivation.provider
);
if (!countryKey) {
return;
}
const floorPrice = normalizeHeroSmsPrice(
normalizedActivation.price
?? normalizedActivation.maxPrice
?? normalizedActivation.selectedPrice
?? getActivationAcquiredPriceHint(normalizedActivation)
);
if (floorPrice === null || floorPrice <= 0) {
return;
}
const currentFloor = normalizeHeroSmsPrice(countryPriceFloorByKey.get(countryKey));
if (currentFloor !== null && currentFloor >= floorPrice) {
return;
}
const normalizedFloor = Math.round(floorPrice * 10000) / 10000;
countryPriceFloorByKey.set(countryKey, normalizedFloor);
const countryLabel = resolveCountryLabelByFailureKey(countryKey, normalizedActivation.provider);
await addLog(
`Step 9: ${countryLabel} will try a higher price tier (> ${normalizedFloor}) due to ${reason || 'sms timeout'}.`,
'warn'
);
};
const isPreferredActivation = (activationCandidate, stateSnapshot = {}) => (
isSameActivation(
stateSnapshot?.[PREFERRED_PHONE_ACTIVATION_STATE_KEY],
activationCandidate
)
);
const markPreferredActivationExhausted = async (reason = '') => {
if (preferredActivationExhausted || !activation || !isPreferredActivation(activation, state)) {
return;
}
preferredActivationExhausted = true;
await addLog(
`Step 9: preferred number ${activation.phoneNumber} failed (${reason || 'unknown reason'}), falling back to a new number.`,
'warn'
);
};
const rotateActivationAfterAddPhoneFailure = async (failureReason, failureCode, submitState = {}) => {
await markPreferredActivationExhausted(failureCode || failureReason);
usedNumberReplacementAttempts += 1;
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
throw buildPhoneReplacementLimitError(maxNumberReplacementAttempts, failureCode || 'add_phone_rejected');
}
await addLog(
`Step 9: replacing number after add-phone failure (${failureReason}) (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`,
'warn'
);
if (shouldCancelActivation && activation) {
await cancelPhoneActivation(state, activation);
}
await clearCurrentActivation();
activation = null;
shouldCancelActivation = false;
preferReuseExistingActivationOnAddPhone = false;
addPhoneReentryWithSameActivation = 0;
let addPhoneSnapshot = {
...pageState,
...submitState,
addPhonePage: true,
phoneVerificationPage: false,
};
try {
const returned = await returnToAddPhone(tabId);
addPhoneSnapshot = {
...addPhoneSnapshot,
...returned,
addPhonePage: true,
phoneVerificationPage: false,
};
} catch (returnError) {
await addLog(
`Step 9: failed to return to add-phone page after rejection, will continue with best-effort state. ${returnError.message}`,
'warn'
);
}
try {
const verified = await ensureAddPhonePageBeforeSubmit('after add-phone rejection');
addPhoneSnapshot = {
...addPhoneSnapshot,
...verified,
addPhonePage: true,
phoneVerificationPage: false,
};
} catch (verifyError) {
await addLog(
`Step 9: failed to verify add-phone state after rejection. ${verifyError.message}`,
'warn'
);
}
pageState = addPhoneSnapshot;
};
try {
while (true) {
@@ -3941,9 +4211,7 @@
if (addPhoneReentryWithSameActivation > 1) {
usedNumberReplacementAttempts += 1;
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
throw new Error(
`步骤 9:更换 ${maxNumberReplacementAttempts} 次号码后手机号验证仍未成功。最后原因:${formatStep9Reason('returned_to_add_phone_loop')}`
);
throw buildPhoneReplacementLimitError(maxNumberReplacementAttempts, 'returned_to_add_phone_loop');
}
await addLog(
`步骤 9:当前号码 ${activation.phoneNumber} 反复返回添加手机号页,正在更换号码(${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts})。`,
@@ -4191,9 +4459,7 @@
usedNumberReplacementAttempts += 1;
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
throw new Error(
`步骤 9:更换 ${maxNumberReplacementAttempts} 次号码后手机号验证仍未成功。最后原因:${formatStep9Reason(replaceReason)}`
);
throw buildPhoneReplacementLimitError(maxNumberReplacementAttempts, replaceReason || 'unknown');
}
if (shouldCancelActivation && activation) {