refactor: 收敛接码 provider 生命周期边界
This commit is contained in:
+334
-3401
File diff suppressed because it is too large
Load Diff
@@ -150,6 +150,10 @@
|
|||||||
return String(value || '').trim() || fallback;
|
return String(value || '').trim() || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCountryKey(value) {
|
||||||
|
return normalizeFiveSimCountryId(value, '');
|
||||||
|
}
|
||||||
|
|
||||||
function formatFiveSimCountryLabel(id = '', englishValue = '', fallback = DEFAULT_COUNTRY_LABEL) {
|
function formatFiveSimCountryLabel(id = '', englishValue = '', fallback = DEFAULT_COUNTRY_LABEL) {
|
||||||
const countryId = normalizeFiveSimCountryId(id, '');
|
const countryId = normalizeFiveSimCountryId(id, '');
|
||||||
const english = normalizeFiveSimCountryLabel(englishValue || countryId || fallback, fallback);
|
const english = normalizeFiveSimCountryLabel(englishValue || countryId || fallback, fallback);
|
||||||
@@ -181,6 +185,42 @@
|
|||||||
return String(Math.round(numeric * 10000) / 10000);
|
return String(Math.round(numeric * 10000) / 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePriceLimit(value = '') {
|
||||||
|
const normalized = normalizeFiveSimMaxPrice(value);
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const numeric = Number(normalized);
|
||||||
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePriceRange(state = {}) {
|
||||||
|
const minPriceLimit = normalizePriceLimit(state?.fiveSimMinPrice);
|
||||||
|
const maxPriceLimit = normalizePriceLimit(state?.fiveSimMaxPrice);
|
||||||
|
return {
|
||||||
|
minPriceLimit,
|
||||||
|
maxPriceLimit,
|
||||||
|
hasMinPriceLimit: minPriceLimit !== null,
|
||||||
|
hasMaxPriceLimit: maxPriceLimit !== null,
|
||||||
|
invalidRange: minPriceLimit !== null && maxPriceLimit !== null && minPriceLimit > maxPriceLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPriceRangeText(minPriceLimit = null, maxPriceLimit = null) {
|
||||||
|
const minPrice = normalizePriceLimit(minPriceLimit);
|
||||||
|
const maxPrice = normalizePriceLimit(maxPriceLimit);
|
||||||
|
if (minPrice !== null && maxPrice !== null) {
|
||||||
|
return `${minPrice}~${maxPrice}`;
|
||||||
|
}
|
||||||
|
if (minPrice !== null) {
|
||||||
|
return `${minPrice}~`;
|
||||||
|
}
|
||||||
|
if (maxPrice !== null) {
|
||||||
|
return `~${maxPrice}`;
|
||||||
|
}
|
||||||
|
return 'unbounded';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFiveSimCountryFallback(value = []) {
|
function normalizeFiveSimCountryFallback(value = []) {
|
||||||
const source = Array.isArray(value)
|
const source = Array.isArray(value)
|
||||||
? value
|
? value
|
||||||
@@ -405,6 +445,13 @@
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCountryLabel(state = {}, countryId = DEFAULT_COUNTRY_ID) {
|
||||||
|
const countryKey = normalizeCountryKey(countryId);
|
||||||
|
const matched = resolveCountryCandidates(state)
|
||||||
|
.find((entry) => normalizeCountryKey(entry.id) === countryKey);
|
||||||
|
return matched?.label || formatFiveSimCountryLabel(countryKey, countryKey, countryKey || DEFAULT_COUNTRY_LABEL);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchBalance(state = {}, deps = {}) {
|
async function fetchBalance(state = {}, deps = {}) {
|
||||||
const config = resolveConfig(state, deps);
|
const config = resolveConfig(state, deps);
|
||||||
const payload = await fetchJson(config, '/v1/user/profile', {
|
const payload = await fetchJson(config, '/v1/user/profile', {
|
||||||
@@ -592,6 +639,40 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveActivationCountry(activation = {}, state = {}) {
|
||||||
|
const normalizedActivation = normalizeActivation(activation)
|
||||||
|
|| (activation && typeof activation === 'object' ? activation : {});
|
||||||
|
const countryId = normalizeFiveSimCountryId(
|
||||||
|
normalizedActivation.countryCode ?? normalizedActivation.countryId ?? normalizedActivation.country,
|
||||||
|
DEFAULT_COUNTRY_ID
|
||||||
|
);
|
||||||
|
const matched = resolveCountryCandidates(state)
|
||||||
|
.find((entry) => normalizeCountryKey(entry.id) === countryId);
|
||||||
|
if (matched) {
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: countryId,
|
||||||
|
code: countryId,
|
||||||
|
label: normalizeFiveSimCountryLabel(
|
||||||
|
normalizedActivation.countryLabel,
|
||||||
|
formatFiveSimCountryLabel(countryId, countryId, countryId)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivationCountryKey(activation = {}) {
|
||||||
|
return normalizeCountryKey(activation?.countryCode ?? activation?.countryId ?? activation?.country);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivationPrice(activation = {}) {
|
||||||
|
return normalizePrice(
|
||||||
|
activation?.selectedPrice
|
||||||
|
?? activation?.price
|
||||||
|
?? activation?.maxPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isNoNumbersPayload(payloadOrMessage) {
|
function isNoNumbersPayload(payloadOrMessage) {
|
||||||
const text = describePayload(payloadOrMessage);
|
const text = describePayload(payloadOrMessage);
|
||||||
return /no\s+free\s+phones|no\s+numbers|not\s+found/i.test(text);
|
return /no\s+free\s+phones|no\s+numbers|not\s+found/i.test(text);
|
||||||
@@ -806,6 +887,22 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function prepareActivationForReuse(state = {}, activation, _options = {}, deps = {}) {
|
||||||
|
try {
|
||||||
|
const retainedActivation = await reuseActivation(state, activation, deps);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
activation: retainedActivation,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'reuse_check_failed',
|
||||||
|
message: error.message || '5sim 复用手机号基线检查失败。',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function finishActivation(state = {}, activation, deps = {}) {
|
async function finishActivation(state = {}, activation, deps = {}) {
|
||||||
const normalizedActivation = normalizeActivation(activation);
|
const normalizedActivation = normalizeActivation(activation);
|
||||||
if (!normalizedActivation) return '';
|
if (!normalizedActivation) return '';
|
||||||
@@ -836,6 +933,25 @@
|
|||||||
return describePayload(payload);
|
return describePayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestAdditionalSms() {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateActivation(state = {}, activation, options = {}, deps = {}) {
|
||||||
|
const releaseAction = String(options?.releaseAction || '').trim().toLowerCase() === 'ban'
|
||||||
|
? 'ban'
|
||||||
|
: 'cancel';
|
||||||
|
if (releaseAction === 'ban') {
|
||||||
|
await banActivation(state, activation, deps);
|
||||||
|
} else {
|
||||||
|
await cancelActivation(state, activation, deps);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
currentTicketId: String(activation?.activationId || activation?.id || ''),
|
||||||
|
nextActivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function extractVerificationCode(rawCodeOrText) {
|
function extractVerificationCode(rawCodeOrText) {
|
||||||
const trimmed = String(rawCodeOrText || '').trim();
|
const trimmed = String(rawCodeOrText || '').trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -942,9 +1058,18 @@
|
|||||||
addLog: deps.addLog,
|
addLog: deps.addLog,
|
||||||
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
|
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
|
||||||
};
|
};
|
||||||
|
const capabilities = Object.freeze({
|
||||||
|
supportsReusableActivation: true,
|
||||||
|
supportsAutomaticFreeReuse: true,
|
||||||
|
supportsFreeReusePreservation: true,
|
||||||
|
supportsPageResend: false,
|
||||||
|
supportsPageResendProbe: true,
|
||||||
|
requiresCountrySelection: true,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
id: PROVIDER_ID,
|
id: PROVIDER_ID,
|
||||||
label: '5sim',
|
label: '5sim',
|
||||||
|
capabilities,
|
||||||
defaultCountryId: DEFAULT_COUNTRY_ID,
|
defaultCountryId: DEFAULT_COUNTRY_ID,
|
||||||
defaultCountryLabel: DEFAULT_COUNTRY_LABEL,
|
defaultCountryLabel: DEFAULT_COUNTRY_LABEL,
|
||||||
supportedCountries: SUPPORTED_COUNTRY_ITEMS,
|
supportedCountries: SUPPORTED_COUNTRY_ITEMS,
|
||||||
@@ -954,18 +1079,38 @@
|
|||||||
normalizeCountryLabel: normalizeFiveSimCountryLabel,
|
normalizeCountryLabel: normalizeFiveSimCountryLabel,
|
||||||
formatCountryLabel: formatFiveSimCountryLabel,
|
formatCountryLabel: formatFiveSimCountryLabel,
|
||||||
normalizeCountryFallback: normalizeFiveSimCountryFallback,
|
normalizeCountryFallback: normalizeFiveSimCountryFallback,
|
||||||
|
normalizeCountryKey,
|
||||||
normalizeMaxPrice: normalizeFiveSimMaxPrice,
|
normalizeMaxPrice: normalizeFiveSimMaxPrice,
|
||||||
normalizeOperator: normalizeFiveSimOperator,
|
normalizeOperator: normalizeFiveSimOperator,
|
||||||
|
normalizeActivation,
|
||||||
resolveCountryCandidates,
|
resolveCountryCandidates,
|
||||||
|
resolveCountryLabel,
|
||||||
|
resolveActivationCountry,
|
||||||
|
getActivationCountryKey,
|
||||||
|
getActivationPrice,
|
||||||
requestActivation: (state, options) => requestActivation(state, options, providerDeps),
|
requestActivation: (state, options) => requestActivation(state, options, providerDeps),
|
||||||
reuseActivation: (state, activation) => reuseActivation(state, activation, providerDeps),
|
reuseActivation: (state, activation) => reuseActivation(state, activation, providerDeps),
|
||||||
finishActivation: (state, activation) => finishActivation(state, activation, providerDeps),
|
finishActivation: (state, activation) => finishActivation(state, activation, providerDeps),
|
||||||
cancelActivation: (state, activation) => cancelActivation(state, activation, providerDeps),
|
cancelActivation: (state, activation) => cancelActivation(state, activation, providerDeps),
|
||||||
banActivation: (state, activation) => banActivation(state, activation, providerDeps),
|
banActivation: (state, activation) => banActivation(state, activation, providerDeps),
|
||||||
|
requestAdditionalSms: (state, activation) => requestAdditionalSms(state, activation, providerDeps),
|
||||||
|
rotateActivation: (state, activation, options) => rotateActivation(state, activation, options, providerDeps),
|
||||||
pollActivationCode: (state, activation, options) => pollActivationCode(state, activation, options, providerDeps),
|
pollActivationCode: (state, activation, options) => pollActivationCode(state, activation, options, providerDeps),
|
||||||
|
prepareActivationForReuse: (state, activation, options) => prepareActivationForReuse(state, activation, options, providerDeps),
|
||||||
|
canPersistReusableActivation: () => true,
|
||||||
|
canPreserveActivationForFreeReuse: (_state, activation) => Boolean(
|
||||||
|
normalizeActivation(activation)
|
||||||
|
&& activation
|
||||||
|
&& typeof activation === 'object'
|
||||||
|
&& activation.phoneCodeReceived
|
||||||
|
),
|
||||||
|
shouldUsePageResend: () => false,
|
||||||
|
shouldProbePageResend: () => true,
|
||||||
fetchBalance: (state) => fetchBalance(state, providerDeps),
|
fetchBalance: (state) => fetchBalance(state, providerDeps),
|
||||||
fetchCountries: (state) => fetchCountries(state, providerDeps),
|
fetchCountries: (state) => fetchCountries(state, providerDeps),
|
||||||
fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, providerDeps),
|
fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, providerDeps),
|
||||||
|
resolvePriceRange,
|
||||||
|
formatPriceRangeText,
|
||||||
collectPriceEntries,
|
collectPriceEntries,
|
||||||
describePayload,
|
describePayload,
|
||||||
};
|
};
|
||||||
@@ -983,8 +1128,13 @@
|
|||||||
normalizeFiveSimCountryFallback,
|
normalizeFiveSimCountryFallback,
|
||||||
normalizeFiveSimCountryId,
|
normalizeFiveSimCountryId,
|
||||||
normalizeFiveSimCountryLabel,
|
normalizeFiveSimCountryLabel,
|
||||||
|
normalizeCountryKey,
|
||||||
formatFiveSimCountryLabel,
|
formatFiveSimCountryLabel,
|
||||||
normalizeFiveSimMaxPrice,
|
normalizeFiveSimMaxPrice,
|
||||||
normalizeFiveSimOperator,
|
normalizeFiveSimOperator,
|
||||||
|
resolvePriceRange,
|
||||||
|
formatPriceRangeText,
|
||||||
|
normalizeActivation,
|
||||||
|
resolveActivationCountry,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+1300
-5
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,19 @@
|
|||||||
const DEFAULT_SERVICE = 'openai';
|
const DEFAULT_SERVICE = 'openai';
|
||||||
const DEFAULT_MODE = 'routing_plan';
|
const DEFAULT_MODE = 'routing_plan';
|
||||||
const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
|
const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
|
||||||
|
const DEFAULT_POLL_TIMEOUT_MS = 180000;
|
||||||
|
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
||||||
|
const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::';
|
||||||
|
const COUNTRY_BY_PHONE_PREFIX = Object.freeze([
|
||||||
|
{ prefix: '84', id: 'VN', label: 'Vietnam' },
|
||||||
|
{ prefix: '66', id: 'TH', label: 'Thailand' },
|
||||||
|
{ prefix: '62', id: 'ID', label: 'Indonesia' },
|
||||||
|
{ prefix: '44', id: 'GB', label: 'United Kingdom' },
|
||||||
|
{ prefix: '81', id: 'JP', label: 'Japan' },
|
||||||
|
{ prefix: '49', id: 'DE', label: 'Germany' },
|
||||||
|
{ prefix: '33', id: 'FR', label: 'France' },
|
||||||
|
{ prefix: '1', id: 'US', label: 'USA' },
|
||||||
|
]);
|
||||||
|
|
||||||
function normalizeText(value = '', fallback = '') {
|
function normalizeText(value = '', fallback = '') {
|
||||||
return String(value || '').trim() || fallback;
|
return String(value || '').trim() || fallback;
|
||||||
@@ -44,6 +57,52 @@
|
|||||||
return lowered.replace(/[^a-z0-9_-]+/g, '');
|
return lowered.replace(/[^a-z0-9_-]+/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCountryKey(value) {
|
||||||
|
return normalizeCountry(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionDisplayName(regionCode, locale = 'en') {
|
||||||
|
const normalizedRegionCode = normalizeCountry(regionCode);
|
||||||
|
const normalizedLocale = normalizeText(locale);
|
||||||
|
if (!/^[A-Z]{2}$/.test(normalizedRegionCode) || !normalizedLocale || typeof Intl?.DisplayNames !== 'function') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return String(
|
||||||
|
new Intl.DisplayNames([normalizedLocale], { type: 'region' }).of(normalizedRegionCode) || ''
|
||||||
|
).trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCountryLabel(value = '', countryCode = '') {
|
||||||
|
const label = normalizeText(value);
|
||||||
|
if (label) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
const normalizedCountryCode = normalizeCountry(countryCode);
|
||||||
|
if (!normalizedCountryCode) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return getRegionDisplayName(normalizedCountryCode, 'en') || normalizedCountryCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferCountryFromPhoneNumber(phoneNumber = '') {
|
||||||
|
const digits = String(phoneNumber || '').replace(/\D+/g, '');
|
||||||
|
if (!digits) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = COUNTRY_BY_PHONE_PREFIX.find((entry) => digits.startsWith(entry.prefix));
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: normalizeCountry(match.id),
|
||||||
|
label: normalizeCountryLabel(match.label, match.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeBoolean(value, fallback = false) {
|
function normalizeBoolean(value, fallback = false) {
|
||||||
if (value === undefined || value === null || value === '') {
|
if (value === undefined || value === null || value === '') {
|
||||||
return Boolean(fallback);
|
return Boolean(fallback);
|
||||||
@@ -245,6 +304,68 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeActivation(record = {}, fallback = {}) {
|
||||||
|
const direct = normalizeActivationFromAcquire(record, fallback);
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
const source = record && typeof record === 'object' && !Array.isArray(record) ? record : {};
|
||||||
|
const ticketId = normalizeText(source.activationId || source.ticketId || source.id || fallback.activationId);
|
||||||
|
const phoneNumber = normalizeText(source.phoneNumber || source.phone || fallback.phoneNumber);
|
||||||
|
if (!ticketId || !phoneNumber) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const inferredCountry = inferCountryFromPhoneNumber(phoneNumber);
|
||||||
|
const countryId = normalizeCountry(source.countryId ?? source.country ?? fallback.countryId ?? inferredCountry?.id);
|
||||||
|
const price = normalizePrice(source.madaoPrice ?? source.price ?? fallback.madaoPrice ?? fallback.price);
|
||||||
|
return {
|
||||||
|
activationId: ticketId,
|
||||||
|
phoneNumber,
|
||||||
|
provider: PROVIDER_ID,
|
||||||
|
serviceCode: normalizeText(source.serviceCode || source.service || fallback.serviceCode, DEFAULT_SERVICE),
|
||||||
|
countryId,
|
||||||
|
countryLabel: normalizeCountryLabel(source.countryLabel || source.country_label || fallback.countryLabel, countryId),
|
||||||
|
maxUses: Math.max(1, Math.floor(Number(source.maxUses ?? fallback.maxUses) || 1)),
|
||||||
|
successfulUses: Math.max(0, Math.floor(Number(source.successfulUses ?? fallback.successfulUses) || 0)),
|
||||||
|
...(source.source ? { source: normalizeText(source.source) } : {}),
|
||||||
|
...(source.phoneCodeReceived ? { phoneCodeReceived: true } : {}),
|
||||||
|
...(source.phoneCodeReceivedAt ? { phoneCodeReceivedAt: Math.max(0, Number(source.phoneCodeReceivedAt) || 0) } : {}),
|
||||||
|
...(source.madaoProviderId ? { madaoProviderId: normalizeProviderId(source.madaoProviderId) } : {}),
|
||||||
|
...(source.madaoRoutingPlanId ? { madaoRoutingPlanId: normalizeText(source.madaoRoutingPlanId) } : {}),
|
||||||
|
...(source.madaoRoutingPlanName ? { madaoRoutingPlanName: normalizeText(source.madaoRoutingPlanName) } : {}),
|
||||||
|
...(source.madaoRoutingItemId ? { madaoRoutingItemId: normalizeText(source.madaoRoutingItemId) } : {}),
|
||||||
|
...(source.madaoAcquirePath ? { madaoAcquirePath: mapAcquirePath(source.madaoAcquirePath) } : {}),
|
||||||
|
...(source.madaoStatus ? { madaoStatus: mapTicketStatus(source.madaoStatus) } : {}),
|
||||||
|
...(price !== null ? { madaoPrice: price } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCountryLabel(_state = {}, countryId = '') {
|
||||||
|
return normalizeCountryLabel('', countryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActivationCountry(activation = {}) {
|
||||||
|
const normalizedActivation = normalizeActivation(activation)
|
||||||
|
|| (activation && typeof activation === 'object' ? activation : {});
|
||||||
|
const inferredCountry = inferCountryFromPhoneNumber(normalizedActivation.phoneNumber);
|
||||||
|
const countryId = normalizeCountry(normalizedActivation.countryId ?? normalizedActivation.country ?? inferredCountry?.id);
|
||||||
|
return {
|
||||||
|
id: countryId,
|
||||||
|
label: normalizeCountryLabel(normalizedActivation.countryLabel || inferredCountry?.label, countryId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivationCountryKey(activation = {}) {
|
||||||
|
const normalizedActivation = normalizeActivation(activation)
|
||||||
|
|| (activation && typeof activation === 'object' ? activation : {});
|
||||||
|
const inferredCountry = inferCountryFromPhoneNumber(normalizedActivation.phoneNumber);
|
||||||
|
return normalizeCountryKey(normalizedActivation.countryId ?? normalizedActivation.country ?? inferredCountry?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivationPrice(activation = {}) {
|
||||||
|
return normalizePrice(activation?.madaoPrice ?? activation?.selectedPrice ?? activation?.price ?? activation?.maxPrice);
|
||||||
|
}
|
||||||
|
|
||||||
function extractVerificationCode(value = '') {
|
function extractVerificationCode(value = '') {
|
||||||
const text = String(value || '').trim();
|
const text = String(value || '').trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -315,6 +436,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollActivationCode(state = {}, activation, options = {}, deps = {}) {
|
async function pollActivationCode(state = {}, activation, options = {}, deps = {}) {
|
||||||
|
const configuredTimeoutMs = Number(options.timeoutMs);
|
||||||
|
const timeoutMs = Number.isFinite(configuredTimeoutMs) && configuredTimeoutMs > 0
|
||||||
|
? Math.max(1000, configuredTimeoutMs)
|
||||||
|
: 0;
|
||||||
|
if (!timeoutMs) {
|
||||||
const payload = await pollActivation(state, activation, deps);
|
const payload = await pollActivation(state, activation, deps);
|
||||||
const code = extractCodeFromPollPayload(payload);
|
const code = extractCodeFromPollPayload(payload);
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -329,6 +455,58 @@
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const intervalMs = Math.max(1000, Number(options.intervalMs) || DEFAULT_POLL_INTERVAL_MS);
|
||||||
|
const maxRoundsRaw = Math.floor(Number(options.maxRounds));
|
||||||
|
const maxRounds = Number.isFinite(maxRoundsRaw) && maxRoundsRaw > 0 ? maxRoundsRaw : 0;
|
||||||
|
const start = Date.now();
|
||||||
|
let pollCount = 0;
|
||||||
|
let lastResponse = '';
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (maxRounds > 0 && pollCount >= maxRounds) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
deps.throwIfStopped?.();
|
||||||
|
const payload = await pollActivation(state, activation, deps);
|
||||||
|
const code = extractCodeFromPollPayload(payload);
|
||||||
|
const statusText = normalizeText(
|
||||||
|
payload?.status
|
||||||
|
|| payload?.madaoStatus
|
||||||
|
|| payload?.message
|
||||||
|
|| payload?.text
|
||||||
|
|| describePayload(payload),
|
||||||
|
'PENDING'
|
||||||
|
);
|
||||||
|
lastResponse = statusText;
|
||||||
|
pollCount += 1;
|
||||||
|
if (typeof options.onStatus === 'function') {
|
||||||
|
await options.onStatus({
|
||||||
|
activation,
|
||||||
|
elapsedMs: Date.now() - start,
|
||||||
|
pollCount,
|
||||||
|
statusText,
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (code) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
if (/^(cancelled|canceled|failed|expired|timeout)$/i.test(statusText)) {
|
||||||
|
throw new Error(`MaDao 订单在收到短信前已结束:${statusText}`);
|
||||||
|
}
|
||||||
|
if (typeof options.onWaitingForCode === 'function') {
|
||||||
|
await options.onWaitingForCode({
|
||||||
|
activation,
|
||||||
|
elapsedMs: Date.now() - start,
|
||||||
|
pollCount,
|
||||||
|
statusText,
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await deps.sleepWithStop?.(intervalMs);
|
||||||
|
}
|
||||||
|
throw new Error(`${PHONE_CODE_TIMEOUT_ERROR_PREFIX}等待手机验证码超时。${lastResponse ? ` MaDao 最后状态:${lastResponse}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function releaseActivation(state = {}, activation, action = 'cancel', deps = {}) {
|
async function releaseActivation(state = {}, activation, action = 'cancel', deps = {}) {
|
||||||
const config = resolveConfig(state, deps);
|
const config = resolveConfig(state, deps);
|
||||||
const ticketId = normalizeText(activation?.activationId || activation?.ticketId);
|
const ticketId = normalizeText(activation?.activationId || activation?.ticketId);
|
||||||
@@ -398,15 +576,62 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function finishActivation(state = {}, activation, deps = {}) {
|
||||||
|
return releaseActivation(state, activation, 'finish', deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelActivation(state = {}, activation, deps = {}) {
|
||||||
|
return releaseActivation(state, activation, 'cancel', deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function banActivation(state = {}, activation, deps = {}) {
|
||||||
|
return releaseActivation(state, activation, 'ban', deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reuseActivation(_state = {}, activation) {
|
||||||
|
return activation && typeof activation === 'object' ? { ...activation } : activation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestAdditionalSms() {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCountryCandidates() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
function createProvider(deps = {}) {
|
function createProvider(deps = {}) {
|
||||||
|
const capabilities = Object.freeze({
|
||||||
|
supportsReusableActivation: false,
|
||||||
|
supportsAutomaticFreeReuse: false,
|
||||||
|
supportsFreeReusePreservation: false,
|
||||||
|
supportsPageResend: false,
|
||||||
|
supportsPageResendProbe: false,
|
||||||
|
supportsRouteReplace: true,
|
||||||
|
requiresCountrySelection: false,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
id: PROVIDER_ID,
|
id: PROVIDER_ID,
|
||||||
label: 'MaDao',
|
label: 'MaDao',
|
||||||
|
capabilities,
|
||||||
defaultProduct: DEFAULT_SERVICE,
|
defaultProduct: DEFAULT_SERVICE,
|
||||||
|
normalizeCountryId: normalizeCountry,
|
||||||
|
normalizeCountryLabel,
|
||||||
|
normalizeCountryKey,
|
||||||
|
normalizeActivation,
|
||||||
|
resolveCountryLabel,
|
||||||
|
resolveActivationCountry,
|
||||||
|
getActivationCountryKey,
|
||||||
|
getActivationPrice,
|
||||||
|
requestActivation: (state, options = {}, runtimeDeps = {}) => acquireActivation(state, options, {
|
||||||
|
...deps,
|
||||||
|
...runtimeDeps,
|
||||||
|
}),
|
||||||
acquireActivation: (state, options = {}, runtimeDeps = {}) => acquireActivation(state, options, {
|
acquireActivation: (state, options = {}, runtimeDeps = {}) => acquireActivation(state, options, {
|
||||||
...deps,
|
...deps,
|
||||||
...runtimeDeps,
|
...runtimeDeps,
|
||||||
}),
|
}),
|
||||||
|
reuseActivation,
|
||||||
pollActivation: (state, activation, runtimeDeps = {}) => pollActivation(state, activation, {
|
pollActivation: (state, activation, runtimeDeps = {}) => pollActivation(state, activation, {
|
||||||
...deps,
|
...deps,
|
||||||
...runtimeDeps,
|
...runtimeDeps,
|
||||||
@@ -419,10 +644,32 @@
|
|||||||
...deps,
|
...deps,
|
||||||
...runtimeDeps,
|
...runtimeDeps,
|
||||||
}),
|
}),
|
||||||
|
finishActivation: (state, activation, runtimeDeps = {}) => finishActivation(state, activation, {
|
||||||
|
...deps,
|
||||||
|
...runtimeDeps,
|
||||||
|
}),
|
||||||
|
cancelActivation: (state, activation, runtimeDeps = {}) => cancelActivation(state, activation, {
|
||||||
|
...deps,
|
||||||
|
...runtimeDeps,
|
||||||
|
}),
|
||||||
|
banActivation: (state, activation, runtimeDeps = {}) => banActivation(state, activation, {
|
||||||
|
...deps,
|
||||||
|
...runtimeDeps,
|
||||||
|
}),
|
||||||
|
requestAdditionalSms,
|
||||||
rotateActivation: (state, activation, options = {}, runtimeDeps = {}) => rotateActivation(state, activation, options, {
|
rotateActivation: (state, activation, options = {}, runtimeDeps = {}) => rotateActivation(state, activation, options, {
|
||||||
...deps,
|
...deps,
|
||||||
...runtimeDeps,
|
...runtimeDeps,
|
||||||
}),
|
}),
|
||||||
|
prepareActivationForReuse: async () => ({
|
||||||
|
ok: false,
|
||||||
|
reason: 'prepare_unsupported',
|
||||||
|
message: 'MaDao 不支持自动白嫖复用准备。',
|
||||||
|
}),
|
||||||
|
canPersistReusableActivation: () => false,
|
||||||
|
canPreserveActivationForFreeReuse: () => false,
|
||||||
|
shouldUsePageResend: () => false,
|
||||||
|
shouldProbePageResend: () => false,
|
||||||
replaceRoutingActivation: (state, activation, options = {}, runtimeDeps = {}) => replaceRoutingActivation(state, activation, options, {
|
replaceRoutingActivation: (state, activation, options = {}, runtimeDeps = {}) => replaceRoutingActivation(state, activation, options, {
|
||||||
...deps,
|
...deps,
|
||||||
...runtimeDeps,
|
...runtimeDeps,
|
||||||
@@ -431,7 +678,9 @@
|
|||||||
extractCodeFromPollPayload,
|
extractCodeFromPollPayload,
|
||||||
mapAcquirePath,
|
mapAcquirePath,
|
||||||
mapTicketStatus,
|
mapTicketStatus,
|
||||||
|
normalizeActivation,
|
||||||
normalizeActivationFromAcquire,
|
normalizeActivationFromAcquire,
|
||||||
|
resolveCountryCandidates,
|
||||||
resolveConfig: (state = {}, runtimeDeps = {}) => resolveConfig(state, {
|
resolveConfig: (state = {}, runtimeDeps = {}) => resolveConfig(state, {
|
||||||
...deps,
|
...deps,
|
||||||
...runtimeDeps,
|
...runtimeDeps,
|
||||||
@@ -450,12 +699,22 @@
|
|||||||
extractCodeFromPollPayload,
|
extractCodeFromPollPayload,
|
||||||
mapAcquirePath,
|
mapAcquirePath,
|
||||||
mapTicketStatus,
|
mapTicketStatus,
|
||||||
|
normalizeActivation,
|
||||||
normalizeActivationFromAcquire,
|
normalizeActivationFromAcquire,
|
||||||
|
normalizeCountry,
|
||||||
|
normalizeCountryKey,
|
||||||
|
normalizeCountryLabel,
|
||||||
|
resolveActivationCountry,
|
||||||
pollActivation,
|
pollActivation,
|
||||||
pollActivationCode,
|
pollActivationCode,
|
||||||
releaseActivation,
|
releaseActivation,
|
||||||
|
finishActivation,
|
||||||
|
cancelActivation,
|
||||||
|
banActivation,
|
||||||
replaceRoutingActivation,
|
replaceRoutingActivation,
|
||||||
|
requestAdditionalSms,
|
||||||
resolveConfig,
|
resolveConfig,
|
||||||
|
resolveCountryCandidates,
|
||||||
rotateActivation,
|
rotateActivation,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,15 @@
|
|||||||
const DEFAULT_COUNTRY_ID = 1;
|
const DEFAULT_COUNTRY_ID = 1;
|
||||||
const DEFAULT_COUNTRY_LABEL = 'Country #1';
|
const DEFAULT_COUNTRY_LABEL = 'Country #1';
|
||||||
const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
|
const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
|
||||||
|
const DEFAULT_ACQUIRE_RETRY_ROUNDS = 3;
|
||||||
|
const DEFAULT_ACQUIRE_RETRY_DELAY_MS = 2000;
|
||||||
|
const DEFAULT_POLL_TIMEOUT_MS = 180000;
|
||||||
|
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
||||||
|
const MAX_PRICE_CANDIDATES = 8;
|
||||||
|
const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::';
|
||||||
|
const ACQUIRE_PRIORITY_COUNTRY = 'country';
|
||||||
|
const ACQUIRE_PRIORITY_PRICE = 'price';
|
||||||
|
const ACQUIRE_PRIORITY_PRICE_HIGH = 'price_high';
|
||||||
|
|
||||||
function normalizeBaseUrl(value = '') {
|
function normalizeBaseUrl(value = '') {
|
||||||
const trimmed = String(value || '').trim() || DEFAULT_BASE_URL;
|
const trimmed = String(value || '').trim() || DEFAULT_BASE_URL;
|
||||||
@@ -39,6 +48,11 @@
|
|||||||
return normalizeText(value, fallback);
|
return normalizeText(value, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCountryKey(value) {
|
||||||
|
const countryId = normalizeNexSmsCountryId(value, -1);
|
||||||
|
return countryId >= 0 ? String(countryId) : '';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNexSmsServiceCode(value = '', fallback = DEFAULT_SERVICE_CODE) {
|
function normalizeNexSmsServiceCode(value = '', fallback = DEFAULT_SERVICE_CODE) {
|
||||||
const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '');
|
const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '');
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
@@ -48,6 +62,44 @@
|
|||||||
return fallbackNormalized || DEFAULT_SERVICE_CODE;
|
return fallbackNormalized || DEFAULT_SERVICE_CODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePriceLimit(value) {
|
||||||
|
if (value === undefined || value === null || String(value).trim() === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.round(parsed * 10000) / 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAcquirePriority(value = '') {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
if (normalized === ACQUIRE_PRIORITY_PRICE) {
|
||||||
|
return ACQUIRE_PRIORITY_PRICE;
|
||||||
|
}
|
||||||
|
if (normalized === ACQUIRE_PRIORITY_PRICE_HIGH) {
|
||||||
|
return ACQUIRE_PRIORITY_PRICE_HIGH;
|
||||||
|
}
|
||||||
|
return ACQUIRE_PRIORITY_COUNTRY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeActivationRetryRounds(value) {
|
||||||
|
const parsed = Math.floor(Number(value));
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return DEFAULT_ACQUIRE_RETRY_ROUNDS;
|
||||||
|
}
|
||||||
|
return Math.max(1, Math.min(10, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeActivationRetryDelayMs(value) {
|
||||||
|
const parsed = Math.floor(Number(value));
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return DEFAULT_ACQUIRE_RETRY_DELAY_MS;
|
||||||
|
}
|
||||||
|
return Math.max(500, Math.min(30000, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNexSmsCountryOrder(value = []) {
|
function normalizeNexSmsCountryOrder(value = []) {
|
||||||
const source = Array.isArray(value)
|
const source = Array.isArray(value)
|
||||||
? value
|
? value
|
||||||
@@ -94,6 +146,13 @@
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCountryLabel(state = {}, countryId = DEFAULT_COUNTRY_ID) {
|
||||||
|
const countryKey = normalizeCountryKey(countryId);
|
||||||
|
const matched = resolveCountryCandidates(state)
|
||||||
|
.find((entry) => normalizeCountryKey(entry.id) === countryKey);
|
||||||
|
return matched?.label || (countryKey ? `Country #${countryKey}` : DEFAULT_COUNTRY_LABEL);
|
||||||
|
}
|
||||||
|
|
||||||
function parsePayload(text) {
|
function parsePayload(text) {
|
||||||
const trimmed = String(text || '').trim();
|
const trimmed = String(text || '').trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -131,6 +190,24 @@
|
|||||||
return Number(payload.code) === 0;
|
return Number(payload.code) === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNoNumbersError(payloadOrMessage) {
|
||||||
|
const text = describePayload(payloadOrMessage);
|
||||||
|
return /numbers?\s+not\s+found|暂无可用|no\s+numbers|no\s+stock|库存.*0|not\s+available/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPendingMessage(payloadOrMessage) {
|
||||||
|
const text = describePayload(payloadOrMessage);
|
||||||
|
return /no\s+sms|暂无短信|waiting|not\s+arrived|empty|未收到|短信为空|no\s+records/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalError(payloadOrMessage, status = 0) {
|
||||||
|
if (Number(status) === 401 || Number(status) === 403) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const text = describePayload(payloadOrMessage);
|
||||||
|
return /invalid\s*api\s*key|bad[_\s-]*key|wrong[_\s-]*key|unauthorized|forbidden|no\s*balance|insufficient\s*balance|余额不足|账号.*封禁|banned/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveConfig(state = {}, deps = {}) {
|
function resolveConfig(state = {}, deps = {}) {
|
||||||
return {
|
return {
|
||||||
apiKey: normalizeText(state?.nexSmsApiKey),
|
apiKey: normalizeText(state?.nexSmsApiKey),
|
||||||
@@ -226,14 +303,648 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSortedUniquePriceCandidates(values = []) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
values
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0)
|
||||||
|
.map((value) => Math.round(value * 10000) / 10000)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.sort((left, right) => left - right)
|
||||||
|
.slice(0, MAX_PRICE_CANDIDATES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPriceCandidates(countryData = {}) {
|
||||||
|
const candidates = [];
|
||||||
|
const pushCandidate = (value) => {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isFinite(numeric) && numeric > 0) {
|
||||||
|
candidates.push(Math.round(numeric * 10000) / 10000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pushCandidate(countryData.minPrice);
|
||||||
|
pushCandidate(countryData.medianPrice);
|
||||||
|
pushCandidate(countryData.maxPrice);
|
||||||
|
if (countryData.priceMap && typeof countryData.priceMap === 'object') {
|
||||||
|
Object.entries(countryData.priceMap).forEach(([priceKey, count]) => {
|
||||||
|
const availableCount = Number(count);
|
||||||
|
if (!Number.isFinite(availableCount) || availableCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushCandidate(priceKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return buildSortedUniquePriceCandidates(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePriceRange(state = {}) {
|
||||||
|
const minPriceLimit = normalizePriceLimit(state?.nexSmsMinPrice ?? state?.heroSmsMinPrice);
|
||||||
|
const maxPriceLimit = normalizePriceLimit(state?.nexSmsMaxPrice ?? state?.heroSmsMaxPrice);
|
||||||
|
return {
|
||||||
|
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 filterPriceCandidatesAboveFloor(prices = [], minExclusivePrice = null) {
|
||||||
|
const floor = normalizePriceLimit(minExclusivePrice);
|
||||||
|
if (floor === null || floor <= 0) {
|
||||||
|
return Array.isArray(prices) ? [...prices] : [];
|
||||||
|
}
|
||||||
|
return (Array.isArray(prices) ? prices : []).filter((value) => {
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) && numeric > floor;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderPriceCandidates(prices = [], acquirePriority = ACQUIRE_PRIORITY_COUNTRY, preferredPrice = null) {
|
||||||
|
const ordered = buildSortedUniquePriceCandidates(prices);
|
||||||
|
if (acquirePriority === ACQUIRE_PRIORITY_PRICE_HIGH) {
|
||||||
|
ordered.reverse();
|
||||||
|
}
|
||||||
|
const preferred = normalizePriceLimit(preferredPrice);
|
||||||
|
if (preferred === null) {
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
return [preferred, ...ordered.filter((price) => price !== preferred)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePriceFloorMap(rawMap = {}, normalizeCountryKey) {
|
||||||
|
const normalizedMap = new Map();
|
||||||
|
if (!rawMap || typeof rawMap !== 'object') {
|
||||||
|
return normalizedMap;
|
||||||
|
}
|
||||||
|
Object.entries(rawMap).forEach(([rawCountryKey, rawPrice]) => {
|
||||||
|
const countryKey = String(
|
||||||
|
typeof normalizeCountryKey === 'function'
|
||||||
|
? normalizeCountryKey(rawCountryKey)
|
||||||
|
: rawCountryKey
|
||||||
|
).trim();
|
||||||
|
if (!countryKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedPrice = normalizePriceLimit(rawPrice);
|
||||||
|
if (normalizedPrice === null || normalizedPrice <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
normalizedMap.set(countryKey, normalizedPrice);
|
||||||
|
});
|
||||||
|
return normalizedMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPriceRangeText(minPriceLimit = null, maxPriceLimit = null) {
|
||||||
|
const minPrice = normalizePriceLimit(minPriceLimit);
|
||||||
|
const maxPrice = normalizePriceLimit(maxPriceLimit);
|
||||||
|
if (minPrice !== null && maxPrice !== null) {
|
||||||
|
return `${minPrice}~${maxPrice}`;
|
||||||
|
}
|
||||||
|
if (minPrice !== null) {
|
||||||
|
return `${minPrice}~`;
|
||||||
|
}
|
||||||
|
if (maxPrice !== null) {
|
||||||
|
return `~${maxPrice}`;
|
||||||
|
}
|
||||||
|
return 'unbounded';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCountryPricePlan(state = {}, countryConfig = {}, deps = {}) {
|
||||||
|
const config = resolveConfig(state, deps);
|
||||||
|
const countryId = normalizeNexSmsCountryId(countryConfig?.id, -1);
|
||||||
|
if (countryId < 0) {
|
||||||
|
throw new Error(`NexSMS 国家 ID 无效:${countryConfig?.id}`);
|
||||||
|
}
|
||||||
|
const payload = await fetchPayload(config, '/api/getCountryByService', 'NexSMS getCountryByService', {
|
||||||
|
query: {
|
||||||
|
serviceCode: config.serviceCode,
|
||||||
|
countryId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!isSuccessPayload(payload)) {
|
||||||
|
throw new Error(`NexSMS getCountryByService 失败:${describePayload(payload) || 'empty response'}`);
|
||||||
|
}
|
||||||
|
const countryData = payload && typeof payload === 'object' && !Array.isArray(payload)
|
||||||
|
? (payload.data || {})
|
||||||
|
: {};
|
||||||
|
const countryLabel = normalizeNexSmsCountryLabel(
|
||||||
|
countryData.countryName || countryConfig?.label,
|
||||||
|
`Country #${countryId}`
|
||||||
|
);
|
||||||
|
const prices = collectPriceCandidates(countryData);
|
||||||
|
const minCatalogPrice = prices.length
|
||||||
|
? prices[0]
|
||||||
|
: (() => {
|
||||||
|
const minPrice = Number(countryData.minPrice);
|
||||||
|
return Number.isFinite(minPrice) && minPrice > 0
|
||||||
|
? Math.round(minPrice * 10000) / 10000
|
||||||
|
: null;
|
||||||
|
})();
|
||||||
|
const priceRange = resolvePriceRange(state);
|
||||||
|
const filteredPrices = filterPriceCandidatesWithinRange(
|
||||||
|
prices,
|
||||||
|
priceRange.minPriceLimit,
|
||||||
|
priceRange.maxPriceLimit
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
countryId,
|
||||||
|
countryLabel,
|
||||||
|
prices: filteredPrices,
|
||||||
|
userLimit: priceRange.maxPriceLimit,
|
||||||
|
minCatalogPrice,
|
||||||
|
rawPayload: payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeActivation(payload, fallback = {}) {
|
||||||
|
const source = payload && typeof payload === 'object' && !Array.isArray(payload)
|
||||||
|
? payload
|
||||||
|
: {};
|
||||||
|
const data = isSuccessPayload(source) ? (source.data || {}) : source;
|
||||||
|
const phoneCandidates = Array.isArray(data.phoneNumbers)
|
||||||
|
? data.phoneNumbers
|
||||||
|
: (Array.isArray(data.numbers) ? data.numbers : []);
|
||||||
|
const phoneNumber = normalizeText(
|
||||||
|
data.phoneNumber
|
||||||
|
|| data.phone
|
||||||
|
|| phoneCandidates[0]
|
||||||
|
|| source.phoneNumber
|
||||||
|
|| source.phone
|
||||||
|
|| fallback.phoneNumber
|
||||||
|
);
|
||||||
|
if (!phoneNumber) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const countryId = normalizeNexSmsCountryId(
|
||||||
|
data.countryId ?? source.countryId ?? fallback.countryId,
|
||||||
|
DEFAULT_COUNTRY_ID
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
activationId: normalizeText(data.activationId || source.activationId || fallback.activationId, phoneNumber),
|
||||||
|
phoneNumber,
|
||||||
|
provider: PROVIDER_ID,
|
||||||
|
serviceCode: normalizeNexSmsServiceCode(
|
||||||
|
data.serviceCode || source.serviceCode || fallback.serviceCode || DEFAULT_SERVICE_CODE,
|
||||||
|
DEFAULT_SERVICE_CODE
|
||||||
|
),
|
||||||
|
countryId,
|
||||||
|
countryLabel: normalizeNexSmsCountryLabel(
|
||||||
|
data.countryName || source.countryName || data.countryLabel || source.countryLabel || fallback.countryLabel,
|
||||||
|
`Country #${countryId}`
|
||||||
|
),
|
||||||
|
successfulUses: Math.max(0, Math.floor(Number(fallback.successfulUses) || 0)),
|
||||||
|
maxUses: 1,
|
||||||
|
...(fallback.selectedPrice !== undefined ? { selectedPrice: Number(fallback.selectedPrice) } : {}),
|
||||||
|
...(fallback.price !== undefined ? { price: Number(fallback.price) } : {}),
|
||||||
|
...(fallback.maxPrice !== undefined ? { maxPrice: Number(fallback.maxPrice) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActivationCountry(activation = {}, state = {}) {
|
||||||
|
const normalizedActivation = normalizeActivation(activation)
|
||||||
|
|| (activation && typeof activation === 'object' ? activation : {});
|
||||||
|
const countryId = normalizeNexSmsCountryId(
|
||||||
|
normalizedActivation.countryId ?? normalizedActivation.country,
|
||||||
|
DEFAULT_COUNTRY_ID
|
||||||
|
);
|
||||||
|
const matched = resolveCountryCandidates(state)
|
||||||
|
.find((entry) => normalizeNexSmsCountryId(entry.id, -1) === countryId);
|
||||||
|
if (matched) {
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: countryId,
|
||||||
|
label: normalizeNexSmsCountryLabel(normalizedActivation.countryLabel, `Country #${countryId}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivationCountryKey(activation = {}) {
|
||||||
|
return normalizeCountryKey(activation?.countryId ?? activation?.country);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivationPrice(activation = {}) {
|
||||||
|
return Number.isFinite(Number(activation?.selectedPrice))
|
||||||
|
? Math.round(Number(activation.selectedPrice) * 10000) / 10000
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestActivation(state = {}, options = {}, deps = {}) {
|
||||||
|
const config = resolveConfig(state, deps);
|
||||||
|
const allCountryCandidates = resolveCountryCandidates(state);
|
||||||
|
if (!allCountryCandidates.length) {
|
||||||
|
throw new Error('步骤 9:NexSMS 未选择国家,请先在接码设置中至少选择 1 个国家。');
|
||||||
|
}
|
||||||
|
const blockedCountryIds = new Set(
|
||||||
|
(Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : [])
|
||||||
|
.map((value) => normalizeNexSmsCountryId(value, -1))
|
||||||
|
.filter((id) => id >= 0)
|
||||||
|
);
|
||||||
|
let countryCandidates = allCountryCandidates.filter((entry) => {
|
||||||
|
const id = normalizeNexSmsCountryId(entry.id, -1);
|
||||||
|
return id >= 0 && !blockedCountryIds.has(id);
|
||||||
|
});
|
||||||
|
if (!countryCandidates.length) {
|
||||||
|
countryCandidates = allCountryCandidates;
|
||||||
|
if (blockedCountryIds.size && typeof deps.addLog === 'function') {
|
||||||
|
await deps.addLog('步骤 9:已选国家均达到临时收码失败跳过阈值,本轮解除跳过并重新尝试。', 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const acquirePriority = normalizeAcquirePriority(state?.heroSmsAcquirePriority);
|
||||||
|
const priceRange = resolvePriceRange(state);
|
||||||
|
if (priceRange.invalidRange) {
|
||||||
|
throw new Error(`NexSMS 价格区间无效:最低购买价 ${priceRange.minPriceLimit} 高于价格上限 ${priceRange.maxPriceLimit}。`);
|
||||||
|
}
|
||||||
|
const hasPriceBounds = priceRange.hasMinPriceLimit || priceRange.hasMaxPriceLimit;
|
||||||
|
const preferredPriceTier = normalizePriceLimit(state?.heroSmsPreferredPrice);
|
||||||
|
const countryPriceFloorByCountryId = normalizePriceFloorMap(
|
||||||
|
options?.countryPriceFloorByCountryId,
|
||||||
|
(value) => String(normalizeNexSmsCountryId(value, -1))
|
||||||
|
);
|
||||||
|
const maxAcquireRounds = Math.max(2, normalizeActivationRetryRounds(state?.heroSmsActivationRetryRounds));
|
||||||
|
const retryDelayMs = normalizeActivationRetryDelayMs(state?.heroSmsActivationRetryDelayMs);
|
||||||
|
let finalNoNumbersByCountry = [];
|
||||||
|
let finalLastError = null;
|
||||||
|
|
||||||
|
for (let round = 1; round <= maxAcquireRounds; round += 1) {
|
||||||
|
if (maxAcquireRounds > 1 && typeof deps.addLog === 'function') {
|
||||||
|
await deps.addLog(`步骤 9:NexSMS 正在获取手机号(第 ${round}/${maxAcquireRounds} 轮)...`, 'info');
|
||||||
|
}
|
||||||
|
const candidateAttempts = countryCandidates.map((countryConfig, index) => ({
|
||||||
|
index,
|
||||||
|
countryConfig,
|
||||||
|
pricePlan: null,
|
||||||
|
orderingPrice: Number.POSITIVE_INFINITY,
|
||||||
|
}));
|
||||||
|
if (
|
||||||
|
(acquirePriority === ACQUIRE_PRIORITY_PRICE || acquirePriority === ACQUIRE_PRIORITY_PRICE_HIGH)
|
||||||
|
&& candidateAttempts.length > 1
|
||||||
|
) {
|
||||||
|
for (const attempt of candidateAttempts) {
|
||||||
|
try {
|
||||||
|
const pricePlan = await resolveCountryPricePlan(state, attempt.countryConfig, deps);
|
||||||
|
attempt.pricePlan = pricePlan;
|
||||||
|
const orderedForRanking = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier);
|
||||||
|
const rangeFilteredForRanking = filterPriceCandidatesWithinRange(
|
||||||
|
orderedForRanking,
|
||||||
|
priceRange.minPriceLimit,
|
||||||
|
priceRange.maxPriceLimit
|
||||||
|
);
|
||||||
|
const rankingPrices = rangeFilteredForRanking.length
|
||||||
|
? rangeFilteredForRanking
|
||||||
|
: (hasPriceBounds ? [] : orderedForRanking);
|
||||||
|
attempt.orderingPrice = Array.isArray(rankingPrices) && rankingPrices.length
|
||||||
|
? Number(rankingPrices[0])
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
} catch (error) {
|
||||||
|
attempt.lookupError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidateAttempts.sort((left, right) => {
|
||||||
|
if (left.orderingPrice !== right.orderingPrice) {
|
||||||
|
return acquirePriority === ACQUIRE_PRIORITY_PRICE_HIGH
|
||||||
|
? (right.orderingPrice - left.orderingPrice)
|
||||||
|
: (left.orderingPrice - right.orderingPrice);
|
||||||
|
}
|
||||||
|
return left.index - right.index;
|
||||||
|
});
|
||||||
|
if (typeof deps.addLog === 'function') {
|
||||||
|
const rankingSummary = candidateAttempts.map((attempt) => {
|
||||||
|
const id = normalizeNexSmsCountryId(attempt.countryConfig.id, -1);
|
||||||
|
const label = normalizeText(attempt.countryConfig.label, `Country #${id}`);
|
||||||
|
return Number.isFinite(attempt.orderingPrice)
|
||||||
|
? `${label}:${attempt.orderingPrice}`
|
||||||
|
: `${label}:无`;
|
||||||
|
}).join(' | ');
|
||||||
|
await deps.addLog(`步骤 9:NexSMS 价格优先排序:${rankingSummary}`, 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const noNumbersByCountry = [];
|
||||||
|
const retryableNoNumberCountries = [];
|
||||||
|
let lastError = null;
|
||||||
|
for (const attempt of candidateAttempts) {
|
||||||
|
const countryId = normalizeNexSmsCountryId(attempt.countryConfig.id, -1);
|
||||||
|
const countryLabel = normalizeNexSmsCountryLabel(attempt.countryConfig.label, `Country #${countryId}`);
|
||||||
|
const countryPriceFloor = countryPriceFloorByCountryId.get(String(countryId)) ?? null;
|
||||||
|
let pricePlan = attempt.pricePlan;
|
||||||
|
if (!pricePlan) {
|
||||||
|
try {
|
||||||
|
pricePlan = await resolveCountryPricePlan(state, attempt.countryConfig, deps);
|
||||||
|
} catch (error) {
|
||||||
|
if (isTerminalError(error?.payload || error?.message, error?.status)) {
|
||||||
|
throw new Error(`NexSMS price lookup 失败:${describePayload(error?.payload || error?.message) || 'unknown terminal error'}`);
|
||||||
|
}
|
||||||
|
lastError = error;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(pricePlan.prices) || !pricePlan.prices.length) {
|
||||||
|
if (
|
||||||
|
pricePlan.userLimit !== null
|
||||||
|
&& pricePlan.minCatalogPrice !== null
|
||||||
|
&& pricePlan.minCatalogPrice > pricePlan.userLimit
|
||||||
|
) {
|
||||||
|
noNumbersByCountry.push(`${countryLabel}: 价格上限 ${pricePlan.userLimit} 内暂无可用号码;平台最低价=${pricePlan.minCatalogPrice}`);
|
||||||
|
} else {
|
||||||
|
const reason = describePayload(pricePlan.rawPayload) || '无可用价格档位';
|
||||||
|
noNumbersByCountry.push(`${countryLabel}: ${reason}`);
|
||||||
|
retryableNoNumberCountries.push(countryLabel);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const orderedPrices = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier);
|
||||||
|
const rangeFilteredPrices = filterPriceCandidatesWithinRange(
|
||||||
|
orderedPrices,
|
||||||
|
priceRange.minPriceLimit,
|
||||||
|
priceRange.maxPriceLimit
|
||||||
|
);
|
||||||
|
const candidatePrices = rangeFilteredPrices.length
|
||||||
|
? rangeFilteredPrices
|
||||||
|
: (hasPriceBounds ? [] : orderedPrices);
|
||||||
|
const floorFilteredPrices = filterPriceCandidatesAboveFloor(candidatePrices, countryPriceFloor);
|
||||||
|
const hasCountryPriceFloor = (
|
||||||
|
countryPriceFloor !== null
|
||||||
|
&& Number.isFinite(Number(countryPriceFloor))
|
||||||
|
&& Number(countryPriceFloor) > 0
|
||||||
|
);
|
||||||
|
const hasAlternativeCountries = candidateAttempts.some((entry) => (
|
||||||
|
normalizeNexSmsCountryId(entry?.countryConfig?.id, -1)
|
||||||
|
!== normalizeNexSmsCountryId(attempt?.countryConfig?.id, -1)
|
||||||
|
));
|
||||||
|
const pricesToTry = hasCountryPriceFloor
|
||||||
|
? (floorFilteredPrices.length ? floorFilteredPrices : (hasAlternativeCountries ? [] : candidatePrices.slice(0, 1)))
|
||||||
|
: (floorFilteredPrices.length ? floorFilteredPrices : candidatePrices);
|
||||||
|
if (!pricesToTry.length) {
|
||||||
|
if (priceRange.hasMinPriceLimit && !rangeFilteredPrices.length) {
|
||||||
|
noNumbersByCountry.push(`${countryLabel}: 价格区间 ${formatPriceRangeText(priceRange.minPriceLimit, priceRange.maxPriceLimit)} 内暂无可用号码`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
countryPriceFloor !== null
|
||||||
|
&& Array.isArray(pricePlan.prices)
|
||||||
|
&& pricePlan.prices.length > 0
|
||||||
|
) {
|
||||||
|
noNumbersByCountry.push(`${countryLabel}: 当前回退尝试没有高于 ${countryPriceFloor} 的价格档位`);
|
||||||
|
} else {
|
||||||
|
noNumbersByCountry.push(`${countryLabel}: ${describePayload(pricePlan.rawPayload) || '暂无可用号码'}`);
|
||||||
|
retryableNoNumberCountries.push(countryLabel);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let boughtActivation = null;
|
||||||
|
for (const price of pricesToTry) {
|
||||||
|
try {
|
||||||
|
const payload = await fetchPayload(config, '/api/order/purchase', 'NexSMS purchase', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
serviceCode: config.serviceCode,
|
||||||
|
countryId,
|
||||||
|
quantity: 1,
|
||||||
|
price,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!isSuccessPayload(payload)) {
|
||||||
|
if (isNoNumbersError(payload)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isTerminalError(payload)) {
|
||||||
|
throw new Error(`NexSMS purchase 失败:${describePayload(payload) || 'empty response'}`);
|
||||||
|
}
|
||||||
|
lastError = new Error(`NexSMS purchase 失败:${describePayload(payload) || 'empty response'}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boughtActivation = normalizeActivation(payload, {
|
||||||
|
countryId,
|
||||||
|
countryLabel,
|
||||||
|
serviceCode: config.serviceCode,
|
||||||
|
selectedPrice: price,
|
||||||
|
});
|
||||||
|
if (!boughtActivation) {
|
||||||
|
lastError = new Error('NexSMS 购买成功,但未返回手机号。');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return boughtActivation;
|
||||||
|
} catch (error) {
|
||||||
|
if (isTerminalError(error?.payload || error?.message, error?.status)) {
|
||||||
|
throw new Error(`NexSMS purchase 失败:${describePayload(error?.payload || error?.message) || 'unknown terminal error'}`);
|
||||||
|
}
|
||||||
|
if (isNoNumbersError(error?.payload || error?.message)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!boughtActivation) {
|
||||||
|
const fallbackReason = describePayload(pricePlan.rawPayload) || '暂无可用号码';
|
||||||
|
noNumbersByCountry.push(`${countryLabel}: ${fallbackReason}`);
|
||||||
|
retryableNoNumberCountries.push(countryLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalNoNumbersByCountry = noNumbersByCountry;
|
||||||
|
finalLastError = lastError;
|
||||||
|
if (
|
||||||
|
noNumbersByCountry.length
|
||||||
|
&& round < maxAcquireRounds
|
||||||
|
&& retryableNoNumberCountries.length > 0
|
||||||
|
) {
|
||||||
|
if (typeof deps.addLog === 'function') {
|
||||||
|
await deps.addLog(
|
||||||
|
`步骤 9:NexSMS 暂无可用号码(第 ${round}/${maxAcquireRounds} 轮);${Math.ceil(retryDelayMs / 1000)} 秒后重试。国家:${retryableNoNumberCountries.join(', ')}。`,
|
||||||
|
'warn'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await deps.sleepWithStop?.(retryDelayMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalNoNumbersByCountry.length) {
|
||||||
|
throw new Error(`NexSMS 已尝试 ${countryCandidates.length} 个候选国家,均无可用号码:${finalNoNumbersByCountry.join(' | ')}。`);
|
||||||
|
}
|
||||||
|
if (finalLastError) {
|
||||||
|
throw finalLastError;
|
||||||
|
}
|
||||||
|
throw new Error('NexSMS 获取手机号失败。');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractVerificationCode(value = '') {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return text.match(/\b(\d{4,8})\b/)?.[1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollActivationCode(state = {}, activation, options = {}, deps = {}) {
|
||||||
|
const normalizedActivation = normalizeActivation(activation);
|
||||||
|
if (!normalizedActivation) {
|
||||||
|
throw new Error('缺少手机号接码订单。');
|
||||||
|
}
|
||||||
|
const config = resolveConfig(state, deps);
|
||||||
|
const timeoutMs = Math.max(1000, Number(options.timeoutMs) || DEFAULT_POLL_TIMEOUT_MS);
|
||||||
|
const intervalMs = Math.max(1000, Number(options.intervalMs) || DEFAULT_POLL_INTERVAL_MS);
|
||||||
|
const maxRoundsRaw = Math.floor(Number(options.maxRounds));
|
||||||
|
const maxRounds = Number.isFinite(maxRoundsRaw) && maxRoundsRaw > 0 ? maxRoundsRaw : 0;
|
||||||
|
const start = Date.now();
|
||||||
|
let pollCount = 0;
|
||||||
|
let lastResponse = '';
|
||||||
|
|
||||||
|
const emitWaitingForCode = async (statusText) => {
|
||||||
|
if (typeof options.onWaitingForCode === 'function') {
|
||||||
|
await options.onWaitingForCode({
|
||||||
|
activation: normalizedActivation,
|
||||||
|
elapsedMs: Date.now() - start,
|
||||||
|
pollCount,
|
||||||
|
statusText,
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (maxRounds > 0 && pollCount >= maxRounds) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
deps.throwIfStopped?.();
|
||||||
|
const payload = await fetchPayload(config, '/api/sms/messages', 'NexSMS get sms messages', {
|
||||||
|
query: {
|
||||||
|
phoneNumber: normalizedActivation.phoneNumber,
|
||||||
|
format: 'json_latest',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = describePayload(payload);
|
||||||
|
lastResponse = text;
|
||||||
|
pollCount += 1;
|
||||||
|
if (typeof options.onStatus === 'function') {
|
||||||
|
await options.onStatus({
|
||||||
|
activation: normalizedActivation,
|
||||||
|
elapsedMs: Date.now() - start,
|
||||||
|
pollCount,
|
||||||
|
statusText: text || 'PENDING',
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isSuccessPayload(payload)) {
|
||||||
|
const code = extractVerificationCode(payload?.data?.code || payload?.data?.text || payload?.data?.message || '');
|
||||||
|
if (code) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
await emitWaitingForCode(text || 'PENDING');
|
||||||
|
await deps.sleepWithStop?.(intervalMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isPendingMessage(payload)) {
|
||||||
|
await emitWaitingForCode(text || 'PENDING');
|
||||||
|
await deps.sleepWithStop?.(intervalMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isTerminalError(payload)) {
|
||||||
|
throw new Error(`NexSMS get sms messages 失败:${text || 'unknown terminal error'}`);
|
||||||
|
}
|
||||||
|
await emitWaitingForCode(text || 'PENDING');
|
||||||
|
await deps.sleepWithStop?.(intervalMs);
|
||||||
|
}
|
||||||
|
throw new Error(`${PHONE_CODE_TIMEOUT_ERROR_PREFIX}等待手机验证码超时。${lastResponse ? ` NexSMS 最后状态:${lastResponse}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeActivation(state = {}, activation, deps = {}, actionLabel = 'NexSMS close activation') {
|
||||||
|
const normalizedActivation = normalizeActivation(activation);
|
||||||
|
if (!normalizedActivation) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const config = resolveConfig(state, deps);
|
||||||
|
const payload = await fetchPayload(config, '/api/close/activation', actionLabel, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
phoneNumber: normalizedActivation.phoneNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!isSuccessPayload(payload)) {
|
||||||
|
throw new Error(`NexSMS close activation 失败:${describePayload(payload) || 'empty response'}`);
|
||||||
|
}
|
||||||
|
return describePayload(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reuseActivation() {
|
||||||
|
throw new Error('NexSMS 当前流程不支持复用手机号订单。');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishActivation() {
|
||||||
|
return 'NexSMS complete skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelActivation(state = {}, activation, deps = {}) {
|
||||||
|
return closeActivation(state, activation, deps, 'NexSMS close activation');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function banActivation(state = {}, activation, deps = {}) {
|
||||||
|
return closeActivation(state, activation, deps, 'NexSMS close activation');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestAdditionalSms() {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateActivation(state = {}, activation, options = {}, deps = {}) {
|
||||||
|
const releaseAction = String(options?.releaseAction || '').trim().toLowerCase() === 'ban'
|
||||||
|
? 'ban'
|
||||||
|
: 'cancel';
|
||||||
|
if (releaseAction === 'ban') {
|
||||||
|
await banActivation(state, activation, deps);
|
||||||
|
} else {
|
||||||
|
await cancelActivation(state, activation, deps);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
currentTicketId: String(activation?.activationId || activation?.phoneNumber || ''),
|
||||||
|
nextActivation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createProvider(deps = {}) {
|
function createProvider(deps = {}) {
|
||||||
const providerDeps = {
|
const providerDeps = {
|
||||||
fetchImpl: deps.fetchImpl,
|
fetchImpl: deps.fetchImpl,
|
||||||
|
sleepWithStop: deps.sleepWithStop,
|
||||||
|
throwIfStopped: deps.throwIfStopped,
|
||||||
|
addLog: deps.addLog,
|
||||||
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
|
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
|
||||||
};
|
};
|
||||||
|
const capabilities = Object.freeze({
|
||||||
|
supportsReusableActivation: false,
|
||||||
|
supportsAutomaticFreeReuse: false,
|
||||||
|
supportsFreeReusePreservation: false,
|
||||||
|
supportsPageResend: true,
|
||||||
|
supportsPageResendProbe: true,
|
||||||
|
requiresCountrySelection: true,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
id: PROVIDER_ID,
|
id: PROVIDER_ID,
|
||||||
label: 'NexSMS',
|
label: 'NexSMS',
|
||||||
|
capabilities,
|
||||||
defaultCountryId: DEFAULT_COUNTRY_ID,
|
defaultCountryId: DEFAULT_COUNTRY_ID,
|
||||||
defaultCountryLabel: DEFAULT_COUNTRY_LABEL,
|
defaultCountryLabel: DEFAULT_COUNTRY_LABEL,
|
||||||
defaultProduct: DEFAULT_SERVICE_LABEL,
|
defaultProduct: DEFAULT_SERVICE_LABEL,
|
||||||
@@ -241,12 +952,37 @@
|
|||||||
normalizeCountryId: normalizeNexSmsCountryId,
|
normalizeCountryId: normalizeNexSmsCountryId,
|
||||||
normalizeCountryLabel: normalizeNexSmsCountryLabel,
|
normalizeCountryLabel: normalizeNexSmsCountryLabel,
|
||||||
normalizeCountryOrder: normalizeNexSmsCountryOrder,
|
normalizeCountryOrder: normalizeNexSmsCountryOrder,
|
||||||
|
normalizeCountryKey,
|
||||||
normalizeServiceCode: normalizeNexSmsServiceCode,
|
normalizeServiceCode: normalizeNexSmsServiceCode,
|
||||||
|
normalizeActivation,
|
||||||
resolveCountryCandidates,
|
resolveCountryCandidates,
|
||||||
|
resolveCountryLabel,
|
||||||
|
resolveActivationCountry,
|
||||||
|
getActivationCountryKey,
|
||||||
|
getActivationPrice,
|
||||||
|
requestActivation: (state, options) => requestActivation(state, options, providerDeps),
|
||||||
|
reuseActivation: (state, activation) => reuseActivation(state, activation, providerDeps),
|
||||||
|
finishActivation: (state, activation) => finishActivation(state, activation, providerDeps),
|
||||||
|
cancelActivation: (state, activation) => cancelActivation(state, activation, providerDeps),
|
||||||
|
banActivation: (state, activation) => banActivation(state, activation, providerDeps),
|
||||||
|
requestAdditionalSms: (state, activation) => requestAdditionalSms(state, activation, providerDeps),
|
||||||
|
rotateActivation: (state, activation, options) => rotateActivation(state, activation, options, providerDeps),
|
||||||
|
pollActivationCode: (state, activation, options) => pollActivationCode(state, activation, options, providerDeps),
|
||||||
|
prepareActivationForReuse: reuseActivation,
|
||||||
|
canPersistReusableActivation: () => false,
|
||||||
|
canPreserveActivationForFreeReuse: () => false,
|
||||||
|
shouldUsePageResend: () => true,
|
||||||
|
shouldProbePageResend: () => true,
|
||||||
fetchBalance: (state) => fetchBalance(state, providerDeps),
|
fetchBalance: (state) => fetchBalance(state, providerDeps),
|
||||||
fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, providerDeps),
|
fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, providerDeps),
|
||||||
|
resolveCountryPricePlan: (state, countryConfig) => resolveCountryPricePlan(state, countryConfig, providerDeps),
|
||||||
|
resolvePriceRange,
|
||||||
|
formatPriceRangeText,
|
||||||
describePayload,
|
describePayload,
|
||||||
isSuccessPayload,
|
isSuccessPayload,
|
||||||
|
isNoNumbersError,
|
||||||
|
isPendingMessage,
|
||||||
|
isTerminalError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +999,9 @@
|
|||||||
normalizeNexSmsCountryId,
|
normalizeNexSmsCountryId,
|
||||||
normalizeNexSmsCountryLabel,
|
normalizeNexSmsCountryLabel,
|
||||||
normalizeNexSmsCountryOrder,
|
normalizeNexSmsCountryOrder,
|
||||||
|
normalizeCountryKey,
|
||||||
normalizeNexSmsServiceCode,
|
normalizeNexSmsServiceCode,
|
||||||
|
normalizeActivation,
|
||||||
|
resolveActivationCountry,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user