diff --git a/background.js b/background.js index 6aedfc6..32691c6 100644 --- a/background.js +++ b/background.js @@ -874,6 +874,7 @@ const PERSISTED_SETTING_DEFAULTS = { heroSmsApiKey: '', heroSmsReuseEnabled: DEFAULT_HERO_SMS_REUSE_ENABLED, heroSmsAcquirePriority: DEFAULT_HERO_SMS_ACQUIRE_PRIORITY, + heroSmsMinPrice: '', heroSmsMaxPrice: '', heroSmsPreferredPrice: '', heroSmsCountryId: HERO_SMS_COUNTRY_ID, @@ -885,6 +886,7 @@ const PERSISTED_SETTING_DEFAULTS = { fiveSimCountryLabel: FIVE_SIM_COUNTRY_LABEL, fiveSimCountryFallback: [], fiveSimCountryOrder: [...DEFAULT_FIVE_SIM_COUNTRY_ORDER], + fiveSimMinPrice: '', fiveSimMaxPrice: '', fiveSimOperator: FIVE_SIM_OPERATOR, nexSmsApiKey: '', @@ -2796,6 +2798,7 @@ function normalizePersistentSettingValue(key, value) { return Boolean(value); case 'heroSmsAcquirePriority': return normalizeHeroSmsAcquirePriority(value); + case 'heroSmsMinPrice': case 'heroSmsMaxPrice': return normalizeHeroSmsMaxPrice(value); case 'heroSmsPreferredPrice': @@ -2823,6 +2826,7 @@ function normalizePersistentSettingValue(key, value) { return normalizeFiveSimCountryFallback(value); case 'fiveSimCountryOrder': return normalizeFiveSimCountryOrder(value); + case 'fiveSimMinPrice': case 'fiveSimMaxPrice': return normalizeFiveSimMaxPrice(value); case 'fiveSimOperator': diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index ff61791..f61bfb3 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -396,6 +396,60 @@ return Math.round(parsed * 10000) / 10000; } + function resolvePhonePriceRange(state = {}, provider = DEFAULT_PHONE_SMS_PROVIDER) { + const normalizedProvider = normalizePhoneSmsProvider(provider); + const minPriceLimit = normalizedProvider === PHONE_SMS_PROVIDER_5SIM + ? normalizeHeroSmsPriceLimit(state?.fiveSimMinPrice) + : normalizeHeroSmsPriceLimit(state?.heroSmsMinPrice); + const maxPriceLimit = normalizedProvider === PHONE_SMS_PROVIDER_5SIM + ? normalizeHeroSmsPriceLimit(state?.fiveSimMaxPrice) + : normalizeHeroSmsPriceLimit(state?.heroSmsMaxPrice); + return { + provider: normalizedProvider, + minPriceLimit, + maxPriceLimit, + hasMinPriceLimit: minPriceLimit !== null, + hasMaxPriceLimit: maxPriceLimit !== null, + invalidRange: minPriceLimit !== null && maxPriceLimit !== null && minPriceLimit > maxPriceLimit, + }; + } + + function isPriceWithinRange(price, minPriceLimit = null, maxPriceLimit = null) { + const numeric = Number(price); + if (!Number.isFinite(numeric) || numeric <= 0) { + return false; + } + const normalized = Math.round(numeric * 10000) / 10000; + if (minPriceLimit !== null && normalized < minPriceLimit) { + return false; + } + if (maxPriceLimit !== null && normalized > maxPriceLimit) { + return false; + } + return true; + } + + function filterPriceCandidatesWithinRange(prices = [], minPriceLimit = null, maxPriceLimit = null) { + return (Array.isArray(prices) ? prices : []).filter((price) => ( + isPriceWithinRange(price, minPriceLimit, maxPriceLimit) + )); + } + + function formatPhonePriceRangeText(minPriceLimit = null, maxPriceLimit = null) { + const minPrice = normalizeHeroSmsPriceLimit(minPriceLimit); + const maxPrice = normalizeHeroSmsPriceLimit(maxPriceLimit); + if (minPrice !== null && maxPrice !== null) { + return `${minPrice}~${maxPrice}`; + } + if (minPrice !== null) { + return `${minPrice}~`; + } + if (maxPrice !== null) { + return `~${maxPrice}`; + } + return 'unbounded'; + } + function isPhoneNumberUsedError(value) { const text = String(value || '').trim(); if (!text) { @@ -2030,7 +2084,7 @@ if (!text) { return false; } - return /no\s+numbers\s+available\s+across|no\s+free\s+phones|numbers?\s+not\s+found|no\s+numbers\s+within\s+maxprice|step\s*9:\s*(?:5sim|nexsms)\s+countries\s+are\s+empty|\bNO_NUMBERS\b/i.test(text); + return /no\s+numbers\s+available\s+across|no\s+free\s+phones|numbers?\s+not\s+found|no\s+numbers\s+within\s+(?:maxprice|price\s+range)|price\s+range\s+is\s+invalid|step\s*9:\s*(?:5sim|nexsms)\s+countries\s+are\s+empty|\bNO_NUMBERS\b/i.test(text); } function resolveNoSupplyDiagnosticsContext(state = {}, providerOrder = []) { @@ -2040,22 +2094,37 @@ const heroCountryCount = resolveCountryCandidates(state).length; const fiveSimCountryCount = resolveFiveSimCountryCandidates(state).length; const nexSmsCountryCount = resolveNexSmsCountryCandidates(state).length; - const maxPrice = normalizeHeroSmsPriceLimit(state?.heroSmsMaxPrice); + const activeProvider = normalizePhoneSmsProvider(state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER); + const priceRange = resolvePhonePriceRange(state, activeProvider); + const minPrice = priceRange.minPriceLimit; + const maxPrice = priceRange.maxPriceLimit; const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority); return { order, heroCountryCount, fiveSimCountryCount, nexSmsCountryCount, + minPrice, maxPrice, + priceRangeInvalid: priceRange.invalidRange, + priceRangeText: formatPhonePriceRangeText(minPrice, maxPrice), acquirePriority, }; } function formatNoSupplySuggestion(context = {}) { const suggestions = []; + const minPrice = Number(context?.minPrice); const maxPrice = Number(context?.maxPrice); - if (!Number.isFinite(maxPrice) || maxPrice <= 0) { + const hasMinPrice = Number.isFinite(minPrice) && minPrice > 0; + const hasMaxPrice = Number.isFinite(maxPrice) && maxPrice > 0; + if (context?.priceRangeInvalid) { + suggestions.push('先修正价格区间(最低购买价不能高于价格上限)'); + } else if (hasMinPrice && hasMaxPrice) { + suggestions.push(`先适当放宽价格区间(当前 ${context.priceRangeText || `${minPrice}~${maxPrice}`})`); + } else if (hasMinPrice) { + suggestions.push(`可适当降低最低购买价(当前 ${context.priceRangeText || `${minPrice}~`})`); + } else if (!hasMaxPrice) { suggestions.push('先设置价格上限(建议 >= 0.12)'); } else if (maxPrice < 0.12) { suggestions.push('先提高价格上限(当前偏低)'); @@ -2118,11 +2187,13 @@ latestState && typeof latestState === 'object' ? latestState : state, providerOrder ); + const minPriceText = context.minPrice === null ? '未设置' : String(context.minPrice); const maxPriceText = context.maxPrice === null ? '未设置' : String(context.maxPrice); + const priceRangeText = context.priceRangeText || formatPhonePriceRangeText(context.minPrice, context.maxPrice); const providerOrderText = context.order.join(' > '); const suggestion = formatNoSupplySuggestion(context); await addLog( - `Step 9 diagnostics: 无号连续失败 ${nextStreak} 次;maxPrice=${maxPriceText};providerOrder=${providerOrderText};国家数 HeroSMS=${context.heroCountryCount}, 5sim=${context.fiveSimCountryCount}, NexSMS=${context.nexSmsCountryCount}。建议:${suggestion}。`, + `Step 9 diagnostics: 无号连续失败 ${nextStreak} 次;priceRange=${priceRangeText};minPrice=${minPriceText};maxPrice=${maxPriceText};providerOrder=${providerOrderText};国家数 HeroSMS=${context.heroCountryCount}, 5sim=${context.fiveSimCountryCount}, NexSMS=${context.nexSmsCountryCount}。建议:${suggestion}。`, nextStreak >= 2 ? 'warn' : 'info' ); return true; @@ -2229,6 +2300,7 @@ let retriedWithUpdatedPrice = false; let retriedWithoutPrice = false; const userLimit = normalizeHeroSmsPriceLimit(options.userLimit); + const userMinLimit = normalizeHeroSmsPriceLimit(options.userMinLimit); while (true) { try { @@ -2249,6 +2321,11 @@ `HeroSMS ${action} failed: WRONG_MAX_PRICE requires ${updatedMaxPrice}, which exceeds configured maxPrice=${userLimit}.` ); } + if (userMinLimit !== null && updatedMaxPrice < userMinLimit) { + throw new Error( + `HeroSMS ${action} failed: WRONG_MAX_PRICE requires ${updatedMaxPrice}, which is below configured minPrice=${userMinLimit}.` + ); + } nextMaxPrice = updatedMaxPrice; retriedWithUpdatedPrice = true; continue; @@ -2326,7 +2403,7 @@ return candidates; } - function findLowestFiveSimPrice(payload, product = DEFAULT_FIVE_SIM_PRODUCT, countryCode = '') { + function findLowestFiveSimPrice(payload, product = DEFAULT_FIVE_SIM_PRODUCT, countryCode = '', priceRange = {}) { const normalizedProduct = normalizeFiveSimCountryCode(product, DEFAULT_FIVE_SIM_PRODUCT); const normalizedCountryCode = normalizeFiveSimCountryCode(countryCode, ''); const root = payload && typeof payload === 'object' @@ -2337,7 +2414,11 @@ ? (root?.[normalizedCountryCode] || root) : root ); - const candidates = collectFiveSimPriceCandidates(countryPayload, []); + const candidates = filterPriceCandidatesWithinRange( + buildSortedUniquePriceCandidates(collectFiveSimPriceCandidates(countryPayload, [])), + priceRange?.minPriceLimit ?? null, + priceRange?.maxPriceLimit ?? null + ); if (!candidates.length) { return null; } @@ -2372,7 +2453,7 @@ return /not\s+enough\s+balance|no\s+balance|unauthorized|invalid\s+token|forbidden|bad\s+key|wrong\s+key|banned/i.test(text); } - async function resolveFiveSimLowestPrice(config, countryCode) { + async function resolveFiveSimLowestPrice(config, countryCode, priceRange = {}) { try { const payload = await fetchFiveSimPayload( config, @@ -2385,7 +2466,7 @@ }, } ); - return findLowestFiveSimPrice(payload, config.product, countryCode); + return findLowestFiveSimPrice(payload, config.product, countryCode, priceRange); } catch { return null; } @@ -2467,9 +2548,15 @@ } } - const maxPriceLimit = config.maxPriceLimit === undefined - ? normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice) - : config.maxPriceLimit; + const priceRange = resolvePhonePriceRange(state, PHONE_SMS_PROVIDER_5SIM); + if (priceRange.invalidRange) { + throw new Error( + `5sim price range is invalid: minPrice=${priceRange.minPriceLimit} exceeds maxPrice=${priceRange.maxPriceLimit}.` + ); + } + const maxPriceLimit = priceRange.maxPriceLimit; + const minPriceLimit = priceRange.minPriceLimit; + const hasPriceBounds = priceRange.hasMinPriceLimit || priceRange.hasMaxPriceLimit; const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority); const preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice); const countryPriceFloorByCountryCode = normalizeCountryPriceFloorMap( @@ -2504,7 +2591,7 @@ const rankedCandidates = []; for (const [index, countryConfig] of countryCandidates.entries()) { const countryCode = normalizeFiveSimCountryCode(countryConfig.code || countryConfig.id || '', 'thailand'); - const lowestPrice = await resolveFiveSimLowestPrice(config, countryCode); + const lowestPrice = await resolveFiveSimLowestPrice(config, countryCode, priceRange); rankedCandidates.push({ index, countryConfig, @@ -2593,11 +2680,13 @@ ), ] ); - const boundedPriceCandidates = maxPriceLimit === null - ? rawPriceCandidates - : rawPriceCandidates.filter((price) => Number(price) <= maxPriceLimit); + const rangeFilteredPriceCandidates = filterPriceCandidatesWithinRange( + rawPriceCandidates, + minPriceLimit, + maxPriceLimit + ); const orderedPricesFromCatalog = reorderPriceCandidates( - boundedPriceCandidates, + rangeFilteredPriceCandidates, acquirePriority, preferredPriceTier ); @@ -2610,8 +2699,16 @@ ] : orderedPricesFromCatalog ) - : (maxPriceLimit !== null ? [maxPriceLimit] : [null]); - const floorFilteredPrices = filterPriceCandidatesAboveFloor(orderedPrices, countryPriceFloor); + : ( + minPriceLimit !== null + ? (maxPriceLimit !== null ? [maxPriceLimit] : []) + : (maxPriceLimit !== null ? [maxPriceLimit] : [null]) + ); + const rangeCheckedPrices = filterPriceCandidatesWithinRange(orderedPrices, minPriceLimit, maxPriceLimit); + const candidatePrices = rangeCheckedPrices.length + ? rangeCheckedPrices + : (hasPriceBounds ? [] : orderedPrices); + const floorFilteredPrices = filterPriceCandidatesAboveFloor(candidatePrices, countryPriceFloor); const hasCountryPriceFloor = ( countryPriceFloor !== null && Number.isFinite(Number(countryPriceFloor)) @@ -2628,13 +2725,21 @@ ? ( floorFilteredPrices.length ? floorFilteredPrices - : (hasAlternativeCountries ? [] : orderedPrices.slice(0, 1)) + : (hasAlternativeCountries ? [] : candidatePrices.slice(0, 1)) ) - : (floorFilteredPrices.length ? floorFilteredPrices : orderedPrices); + : (floorFilteredPrices.length ? floorFilteredPrices : candidatePrices); if (!pricesToTry.length) { const lowestCatalog = rawPriceCandidates.length ? rawPriceCandidates[0] : null; if ( + minPriceLimit !== null + && !rangeFilteredPriceCandidates.length + && rawPriceCandidates.length + ) { + noNumbersByCountry.push( + `${countryLabel}: no numbers within price range ${formatPhonePriceRangeText(minPriceLimit, maxPriceLimit)}; visible tiers=${rawPriceCandidates.join(', ')}` + ); + } else if ( maxPriceLimit !== null && lowestCatalog !== null && Number(lowestCatalog) > Number(maxPriceLimit) @@ -2642,7 +2747,7 @@ noNumbersByCountry.push( `${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestCatalog}` ); - } else if (countryPriceFloor !== null && boundedPriceCandidates.length) { + } else if (countryPriceFloor !== null && rangeFilteredPriceCandidates.length) { noNumbersByCountry.push( `${countryLabel}: no higher price tier above ${countryPriceFloor} for current fallback attempt` ); @@ -2716,10 +2821,20 @@ return acquiredActivation; } - const lowestPrice = rawPriceCandidates.length ? rawPriceCandidates[0] : await resolveFiveSimLowestPrice(config, countryCode); - if (maxPriceLimit !== null && lowestPrice !== null && Number(lowestPrice) > Number(maxPriceLimit)) { + const lowestCatalogPrice = rawPriceCandidates.length + ? rawPriceCandidates[0] + : await resolveFiveSimLowestPrice(config, countryCode); + if ( + minPriceLimit !== null + && !rangeFilteredPriceCandidates.length + && rawPriceCandidates.length + ) { noNumbersByCountry.push( - `${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestPrice}` + `${countryLabel}: no numbers within price range ${formatPhonePriceRangeText(minPriceLimit, maxPriceLimit)}; visible tiers=${rawPriceCandidates.join(', ')}` + ); + } else if (maxPriceLimit !== null && lowestCatalogPrice !== null && Number(lowestCatalogPrice) > Number(maxPriceLimit)) { + noNumbersByCountry.push( + `${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestCatalogPrice}` ); } else if (isFiveSimRateLimitError(countryNoNumbersText)) { rateLimitByCountry.push(`${countryLabel}: ${countryNoNumbersText || 'rate limit'}`); @@ -2737,10 +2852,17 @@ throw new Error(`5sim buy activation failed: ${describeFiveSimPayload(error?.payload || error?.message) || 'unknown terminal error'}`); } if (isFiveSimNoNumbersError(error?.payload || error?.message)) { - const lowestPrice = await resolveFiveSimLowestPrice(config, countryCode); - if (maxPriceLimit !== null && lowestPrice !== null && lowestPrice > maxPriceLimit) { + const lowestRangePrice = await resolveFiveSimLowestPrice(config, countryCode, priceRange); + const lowestCatalogPrice = lowestRangePrice === null + ? await resolveFiveSimLowestPrice(config, countryCode) + : lowestRangePrice; + if (minPriceLimit !== null && lowestRangePrice === null) { noNumbersByCountry.push( - `${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestPrice}` + `${countryLabel}: no numbers within price range ${formatPhonePriceRangeText(minPriceLimit, maxPriceLimit)}` + ); + } else if (maxPriceLimit !== null && lowestCatalogPrice !== null && lowestCatalogPrice > maxPriceLimit) { + noNumbersByCountry.push( + `${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestCatalogPrice}` ); } else { noNumbersByCountry.push(`${countryLabel}: ${describeFiveSimPayload(error?.payload || error?.message) || 'no free phones'}`); @@ -2957,6 +3079,15 @@ } const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority); + const priceRange = resolvePhonePriceRange(state, PHONE_SMS_PROVIDER_NEXSMS); + if (priceRange.invalidRange) { + throw new Error( + `NexSMS price range is invalid: minPrice=${priceRange.minPriceLimit} exceeds maxPrice=${priceRange.maxPriceLimit}.` + ); + } + const minPriceLimit = priceRange.minPriceLimit; + const maxPriceLimit = priceRange.maxPriceLimit; + const hasPriceBounds = priceRange.hasMinPriceLimit || priceRange.hasMaxPriceLimit; const preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice); const countryPriceFloorByCountryId = normalizeCountryPriceFloorMap( options?.countryPriceFloorByCountryId, @@ -2995,8 +3126,16 @@ const pricePlan = await resolveNexSmsCountryPricePlan(config, attempt.countryConfig, state); attempt.pricePlan = pricePlan; const orderedForRanking = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier); - attempt.orderingPrice = Array.isArray(orderedForRanking) && orderedForRanking.length - ? Number(orderedForRanking[0]) + const rangeFilteredForRanking = filterPriceCandidatesWithinRange( + orderedForRanking, + minPriceLimit, + maxPriceLimit + ); + const rankingPrices = rangeFilteredForRanking.length + ? rangeFilteredForRanking + : (hasPriceBounds ? [] : orderedForRanking); + attempt.orderingPrice = Array.isArray(rankingPrices) && rankingPrices.length + ? Number(rankingPrices[0]) : Number.POSITIVE_INFINITY; } catch (error) { attempt.pricePlan = null; @@ -3061,7 +3200,15 @@ } const orderedPrices = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier); - const floorFilteredPrices = filterPriceCandidatesAboveFloor(orderedPrices, countryPriceFloor); + const rangeFilteredPrices = filterPriceCandidatesWithinRange( + orderedPrices, + minPriceLimit, + maxPriceLimit + ); + const candidatePrices = rangeFilteredPrices.length + ? rangeFilteredPrices + : (hasPriceBounds ? [] : orderedPrices); + const floorFilteredPrices = filterPriceCandidatesAboveFloor(candidatePrices, countryPriceFloor); const hasCountryPriceFloor = ( countryPriceFloor !== null && Number.isFinite(Number(countryPriceFloor)) @@ -3077,10 +3224,16 @@ ? ( floorFilteredPrices.length ? floorFilteredPrices - : (hasAlternativeCountries ? [] : orderedPrices.slice(0, 1)) + : (hasAlternativeCountries ? [] : candidatePrices.slice(0, 1)) ) - : (floorFilteredPrices.length ? floorFilteredPrices : orderedPrices); + : (floorFilteredPrices.length ? floorFilteredPrices : candidatePrices); if (!pricesToTry.length) { + if (priceRange.hasMinPriceLimit && !rangeFilteredPrices.length) { + noNumbersByCountry.push( + `${countryLabel}: no numbers within price range ${formatPhonePriceRangeText(minPriceLimit, maxPriceLimit)}` + ); + continue; + } if ( countryPriceFloor !== null && Array.isArray(pricePlan.prices) @@ -3217,6 +3370,15 @@ } } const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority); + const priceRange = resolvePhonePriceRange(state, PHONE_SMS_PROVIDER_HERO); + if (priceRange.invalidRange) { + throw new Error( + `HeroSMS price range is invalid: minPrice=${priceRange.minPriceLimit} exceeds maxPrice=${priceRange.maxPriceLimit}.` + ); + } + const minPriceLimit = priceRange.minPriceLimit; + const maxPriceLimit = priceRange.maxPriceLimit; + const hasPriceBounds = priceRange.hasMinPriceLimit || priceRange.hasMaxPriceLimit; const preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice); const countryPriceFloorByCountryId = normalizeCountryPriceFloorMap( options?.countryPriceFloorByCountryId, @@ -3257,8 +3419,16 @@ for (const attempt of countryAttempts) { const pricePlan = await resolvePhoneActivationPricePlan(config, attempt.countryConfig, state); const orderedPrices = reorderPriceCandidates(pricePlan?.prices, acquirePriority, preferredPriceTier); + const rangeFilteredForRanking = filterPriceCandidatesWithinRange( + orderedPrices, + minPriceLimit, + maxPriceLimit + ); + const rankingPrices = rangeFilteredForRanking.length + ? rangeFilteredForRanking + : (hasPriceBounds ? [] : orderedPrices); const numericPrices = Array.isArray(orderedPrices) - ? orderedPrices + ? rankingPrices .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && value > 0) : []; @@ -3308,7 +3478,15 @@ let noNumbersObservedInCountry = false; const orderedPrices = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier); - const floorFilteredPrices = filterPriceCandidatesAboveFloor(orderedPrices, countryPriceFloor); + const rangeFilteredPrices = filterPriceCandidatesWithinRange( + orderedPrices, + minPriceLimit, + maxPriceLimit + ); + const candidatePrices = rangeFilteredPrices.length + ? rangeFilteredPrices + : (hasPriceBounds ? [] : orderedPrices); + const floorFilteredPrices = filterPriceCandidatesAboveFloor(candidatePrices, countryPriceFloor); const hasCountryPriceFloor = ( countryPriceFloor !== null && Number.isFinite(Number(countryPriceFloor)) @@ -3325,9 +3503,9 @@ ? ( floorFilteredPrices.length ? floorFilteredPrices - : (hasAlternativeCountries ? [] : orderedPrices.slice(0, 1)) + : (hasAlternativeCountries ? [] : candidatePrices.slice(0, 1)) ) - : (floorFilteredPrices.length ? floorFilteredPrices : orderedPrices); + : (floorFilteredPrices.length ? floorFilteredPrices : candidatePrices); const rawTierText = Array.isArray(pricePlan?.prices) && pricePlan.prices.length ? pricePlan.prices .map((value) => (value === null || value === undefined ? 'auto' : String(value))) @@ -3347,6 +3525,12 @@ ); } if (!pricesToTry.length) { + if (priceRange.hasMinPriceLimit && !rangeFilteredPrices.length) { + noNumbersByCountry.push( + `${countryConfig.label}: no numbers within price range ${formatPhonePriceRangeText(minPriceLimit, maxPriceLimit)}` + ); + continue; + } if ( countryPriceFloor !== null && Array.isArray(pricePlan.prices) @@ -3388,6 +3572,7 @@ maxPrice, { userLimit: pricePlan.userLimit, + userMinLimit: minPriceLimit, fixedPrice, } ); diff --git a/docs/md/手机接码价格区间与最低购买价开发方案.md b/docs/md/手机接码价格区间与最低购买价开发方案.md new file mode 100644 index 0000000..0d0827d --- /dev/null +++ b/docs/md/手机接码价格区间与最低购买价开发方案.md @@ -0,0 +1,182 @@ +# 手机接码价格区间与最低购买价开发方案 + +## 1. 需求结论 + +本次需求不是把“价格上限”改成一个可解析字符串,而是要在现有接码价格逻辑上新增一个独立的最低购买价边界,让用户能配置: + +- 只设上限 +- 只设下限 +- 上下限都设 +- 仍然保留“指定档位”作为独立偏好 + +最终结论: + +1. 不建议把上限文本框直接改成“范围字符串”。 +2. 建议在当前价格控制区内新增一个独立的“最低购买价”输入。 +3. 价格区间采用**闭区间**语义,两个边界都可空。 +4. `指定档位` 不并入区间字段,只在区间内才生效。 +5. 现有的内部换档下限 `countryPriceFloorByCountryId` 继续保留,不和用户可配最低价混用。 + +## 2. 需求符合性分析 + +### 2.1 符合的部分 + +- 低价号过滤是明确业务诉求,和当前“只看上限”确实不匹配。 +- 用户希望继续保留指定档位,说明这是“筛选 + 偏好”组合,而不是单纯范围匹配。 +- 当前价格控制区本来就集中在同一块,适合扩展为区间控制,不需要拆散到别的区域。 + +### 2.2 不足和潜在冲突 + +1. **直接复用上限做范围解析不稳** + - 会把“上限”语义和“范围”语义混在一起。 + - 还会让 `指定档位` 的优先级变得不清楚。 + +2. **最低价不能复用 `heroSmsPreferredPrice`** + - `heroSmsPreferredPrice` 是“指定档位”,不是下限。 + - 两者语义完全不同,混用会造成维护和排障困难。 + +3. **用户最低价和内部换档下限不是同一层** + - `countryPriceFloorByCountryId` 是 Step 9 内部避让逻辑。 + - 用户最低购买价是配置项。 + - 两者必须分开,否则会互相污染。 + +4. **区间反转是边界问题** + - 如果最低价大于上限,逻辑上是无效区间。 + - 这个状态必须显式处理,不能默默当成正常配置。 + +5. **区间和指定档位的关系要明确** + - 指定档位只有在区间内才应参与排序。 + - 超出区间的指定档位应忽略,不应反向突破边界。 + +## 3. 设计方案 + +### 3.1 字段模型 + +建议保留现有字段结构,只新增最低价字段: + +- `heroSmsMinPrice` +- `heroSmsMaxPrice` +- `heroSmsPreferredPrice` +- `fiveSimMinPrice` +- `fiveSimMaxPrice` + +其中: + +- `heroSmsMinPrice` 兼容 HeroSMS / NexSMS +- `fiveSimMinPrice` 仅服务 5sim +- `heroSmsPreferredPrice` 保持原语义,不改名 + +### 3.2 UI 位置 + +价格区仍放在当前 `row-hero-sms-max-price` 这一块里,不另起炉灶。 + +建议布局为: + +- `最低购买价` +- `价格上限` +- `指定档位` +- `号码复用` + +这样用户一眼就能看出这四项是一组策略,不会散到别的位置。 + +### 3.3 逻辑规则 + +1. 先按价格区间过滤候选。 +2. 再应用指定档位排序。 +3. 再按平台自身规则进行换档、重试、兜底。 +4. 当区间内没有可用价格时,显示“无可用号源”类提示,而不是继续尝试区间外价格。 + +### 3.4 取号语义 + +建议统一成如下顺序: + +```mermaid +flowchart TD + A["读取当前 provider 的最低价/上限/指定档位"] --> B["收集平台候选价格"] + B --> C["过滤掉区间外价格"] + C --> D{"指定档位是否仍在区间内"} + D -->|是| E["优先尝试指定档位"] + D -->|否| F["忽略指定档位"] + E --> G["按平台规则排序并取号"] + F --> G + G --> H{"是否仍无可用号源"} + H -->|是| I["输出范围相关诊断/提示"] + H -->|否| J["继续验证码和后续流程"] +``` + +## 4. 开发清单 + +### 阶段 1:字段与持久化 + +目标: + +- 新增最低价字段默认值 +- 新增归一化逻辑 +- 新增保存/回显/导入兼容 +- 不破坏旧配置 + +自检项: + +- 保存后能否正确回显 +- 旧配置导入后是否保持可用 +- 新旧字段是否存在命名冲突 +- 是否有乱码或字段缺失 + +### 阶段 2:UI 改造 + +目标: + +- 在现有价格区内新增最低购买价输入 +- 调整文案为“价格区间” +- 保持控件布局整齐,不把新输入塞到别的区域 + +自检项: + +- 侧边栏宽度下是否挤压、换行错乱 +- 输入框宽度是否足够 +- 切换 provider 时回显是否正确 +- 文案是否统一、无错别字、无乱码 + +### 阶段 3:取号与预览逻辑 + +目标: + +- HeroSMS / NexSMS / 5sim 都按区间过滤候选 +- 指定档位仅在区间内生效 +- 预览文案同步展示区间后的结果 +- 无号源提示能反映“区间过窄/区间反转/无可用档位” + +自检项: + +- 只设上限是否仍与现有行为一致 +- 只设下限是否能正常过滤低价 +- 上下限同时设置是否只保留区间内候选 +- 指定档位超出区间时是否被正确忽略 +- 与 `countryPriceFloorByCountryId` 是否仍然隔离 + +### 阶段 4:测试与全量复审 + +目标: + +- 补齐单测 +- 回归 sidepanel、background、preview、保存/切换 +- 全量检查无遗漏后再提交 + +自检项: + +- 运行测试是否通过 +- 新增字段是否所有路径都覆盖 +- 是否还有未同步的文案/默认值/回显逻辑 +- 是否存在边界问题或乱码 + +## 5. 完成标准 + +本需求只有在以下条件同时满足时才算完成: + +1. 新的最低购买价能正确保存、回显、切换、导入。 +2. 价格区间在 HeroSMS / NexSMS / 5sim 的候选过滤中生效。 +3. 指定档位不会突破区间边界。 +4. 预览与无号源提示和真实取号逻辑一致。 +5. 代码、测试、文案、注释都没有乱码和明显冲突。 +6. 全量复审通过后再写中文提交信息并提交。 + diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 92edae9..80c1406 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -1464,7 +1464,7 @@ placeholder="ot" />