fix(phone-sms): 补回 NexSMS provider registry 接入
补回 NexSMS provider adapter,并在 background 与 sidepanel 加载。 同时补充 registry 创建真实 NexSMS provider 的测试,避免后续回归。
This commit is contained in:
@@ -21,6 +21,7 @@ importScripts(
|
||||
'gopay-utils.js',
|
||||
'phone-sms/providers/hero-sms.js',
|
||||
'phone-sms/providers/five-sim.js',
|
||||
'phone-sms/providers/nexsms.js',
|
||||
'phone-sms/providers/madao.js',
|
||||
'phone-sms/providers/registry.js',
|
||||
'background/phone-verification-flow.js',
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// phone-sms/providers/nexsms.js — NexSMS 接码平台适配层
|
||||
(function attachNexSmsProvider(root, factory) {
|
||||
root.PhoneSmsNexSmsProvider = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createNexSmsProviderModule() {
|
||||
const PROVIDER_ID = 'nexsms';
|
||||
const DEFAULT_BASE_URL = 'https://api.nexsms.net';
|
||||
const DEFAULT_SERVICE_CODE = 'ot';
|
||||
const DEFAULT_SERVICE_LABEL = 'OpenAI';
|
||||
const DEFAULT_COUNTRY_ID = 1;
|
||||
const DEFAULT_COUNTRY_LABEL = 'Country #1';
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
|
||||
|
||||
function normalizeBaseUrl(value = '') {
|
||||
const trimmed = String(value || '').trim() || DEFAULT_BASE_URL;
|
||||
try {
|
||||
return new URL(trimmed).toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return DEFAULT_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(value = '', fallback = '') {
|
||||
return String(value || '').trim() || fallback;
|
||||
}
|
||||
|
||||
function normalizeNexSmsCountryId(value, fallback = DEFAULT_COUNTRY_ID) {
|
||||
const parsed = Math.floor(Number(value));
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
const fallbackParsed = Math.floor(Number(fallback));
|
||||
if (Number.isFinite(fallbackParsed) && fallbackParsed >= 0) {
|
||||
return fallbackParsed;
|
||||
}
|
||||
return DEFAULT_COUNTRY_ID;
|
||||
}
|
||||
|
||||
function normalizeNexSmsCountryLabel(value = '', fallback = DEFAULT_COUNTRY_LABEL) {
|
||||
return normalizeText(value, fallback);
|
||||
}
|
||||
|
||||
function normalizeNexSmsServiceCode(value = '', fallback = DEFAULT_SERVICE_CODE) {
|
||||
const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '');
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
const fallbackNormalized = String(fallback || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '');
|
||||
return fallbackNormalized || DEFAULT_SERVICE_CODE;
|
||||
}
|
||||
|
||||
function normalizeNexSmsCountryOrder(value = []) {
|
||||
const source = Array.isArray(value)
|
||||
? value
|
||||
: String(value || '')
|
||||
.split(/[\r\n,,;;]+/)
|
||||
.map((entry) => String(entry || '').trim())
|
||||
.filter(Boolean);
|
||||
const normalized = [];
|
||||
const seen = new Set();
|
||||
|
||||
source.forEach((entry) => {
|
||||
let id = -1;
|
||||
let label = '';
|
||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
id = normalizeNexSmsCountryId(entry.id ?? entry.countryId, -1);
|
||||
label = normalizeText(entry.label ?? entry.countryLabel, '');
|
||||
} else {
|
||||
const text = String(entry || '').trim();
|
||||
const structured = text.match(/^(\d+)\s*(?:[:|/-]\s*(.+))?$/);
|
||||
id = normalizeNexSmsCountryId(structured?.[1] || text, -1);
|
||||
label = normalizeText(structured?.[2], '');
|
||||
}
|
||||
if (id < 0 || seen.has(id)) {
|
||||
return;
|
||||
}
|
||||
seen.add(id);
|
||||
normalized.push({
|
||||
id,
|
||||
label: label || `Country #${id}`,
|
||||
});
|
||||
});
|
||||
|
||||
return normalized.slice(0, 20);
|
||||
}
|
||||
|
||||
function resolveCountryCandidates(state = {}) {
|
||||
const candidates = normalizeNexSmsCountryOrder(state?.nexSmsCountryOrder);
|
||||
if (candidates.length) {
|
||||
return candidates;
|
||||
}
|
||||
return [{
|
||||
id: normalizeNexSmsCountryId(state?.nexSmsCountryId, DEFAULT_COUNTRY_ID),
|
||||
label: normalizeNexSmsCountryLabel(state?.nexSmsCountryLabel, DEFAULT_COUNTRY_LABEL),
|
||||
}];
|
||||
}
|
||||
|
||||
function parsePayload(text) {
|
||||
const trimmed = String(text || '').trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function describePayload(raw) {
|
||||
if (typeof raw === 'string') {
|
||||
return raw.trim();
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
const message = normalizeText(raw.message || raw.error || raw.msg || raw.statusText, '');
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(raw);
|
||||
} catch {
|
||||
return String(raw);
|
||||
}
|
||||
}
|
||||
return String(raw || '').trim();
|
||||
}
|
||||
|
||||
function isSuccessPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return false;
|
||||
}
|
||||
return Number(payload.code) === 0;
|
||||
}
|
||||
|
||||
function resolveConfig(state = {}, deps = {}) {
|
||||
return {
|
||||
apiKey: normalizeText(state?.nexSmsApiKey),
|
||||
baseUrl: normalizeBaseUrl(state?.nexSmsBaseUrl || DEFAULT_BASE_URL),
|
||||
serviceCode: normalizeNexSmsServiceCode(state?.nexSmsServiceCode, DEFAULT_SERVICE_CODE),
|
||||
fetchImpl: deps.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null),
|
||||
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPayload(config, path, actionLabel, options = {}) {
|
||||
if (!config.fetchImpl) {
|
||||
throw new Error('NexSMS 网络请求实现不可用。');
|
||||
}
|
||||
if (!config.apiKey) {
|
||||
throw new Error('NexSMS API Key 缺失,请先在侧边栏保存接码 API Key。');
|
||||
}
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
const timeoutId = controller
|
||||
? setTimeout(() => controller.abort(), Number(config.requestTimeoutMs) || DEFAULT_REQUEST_TIMEOUT_MS)
|
||||
: null;
|
||||
|
||||
try {
|
||||
const method = String(options.method || 'GET').trim().toUpperCase() || 'GET';
|
||||
const requestUrl = new URL(path.replace(/^\/+/, ''), `${config.baseUrl.replace(/\/+$/, '')}/`);
|
||||
requestUrl.searchParams.set('apiKey', config.apiKey);
|
||||
Object.entries(options.query || {}).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
requestUrl.searchParams.set(key, String(value));
|
||||
});
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
...(options.headers && typeof options.headers === 'object' ? options.headers : {}),
|
||||
};
|
||||
const requestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: controller?.signal,
|
||||
};
|
||||
if (method !== 'GET' && method !== 'HEAD' && options.body !== undefined) {
|
||||
requestInit.body = typeof options.body === 'string'
|
||||
? options.body
|
||||
: JSON.stringify(options.body);
|
||||
if (!requestInit.headers['Content-Type']) {
|
||||
requestInit.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
const response = await config.fetchImpl(requestUrl.toString(), requestInit);
|
||||
const text = await response.text();
|
||||
const payload = parsePayload(text);
|
||||
if (!response.ok) {
|
||||
const error = new Error(`${actionLabel}失败:${describePayload(payload) || response.status}`);
|
||||
error.payload = payload;
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`${actionLabel}超时。`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBalance(state = {}, deps = {}) {
|
||||
const config = resolveConfig(state, deps);
|
||||
const payload = await fetchPayload(config, '/api/user/getBalance', 'NexSMS get balance');
|
||||
if (!isSuccessPayload(payload)) {
|
||||
throw new Error(`NexSMS get balance 失败:${describePayload(payload) || 'empty response'}`);
|
||||
}
|
||||
const balance = Number(payload?.data?.balance);
|
||||
return {
|
||||
balance: Number.isFinite(balance) ? balance : null,
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPrices(state = {}, countryConfig = {}, deps = {}) {
|
||||
const config = resolveConfig(state, deps);
|
||||
const countryId = normalizeNexSmsCountryId(countryConfig?.id, DEFAULT_COUNTRY_ID);
|
||||
return fetchPayload(config, '/api/getCountryByService', 'NexSMS getCountryByService', {
|
||||
query: {
|
||||
serviceCode: config.serviceCode,
|
||||
countryId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createProvider(deps = {}) {
|
||||
const providerDeps = {
|
||||
fetchImpl: deps.fetchImpl,
|
||||
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
};
|
||||
return {
|
||||
id: PROVIDER_ID,
|
||||
label: 'NexSMS',
|
||||
defaultCountryId: DEFAULT_COUNTRY_ID,
|
||||
defaultCountryLabel: DEFAULT_COUNTRY_LABEL,
|
||||
defaultProduct: DEFAULT_SERVICE_LABEL,
|
||||
defaultServiceCode: DEFAULT_SERVICE_CODE,
|
||||
normalizeCountryId: normalizeNexSmsCountryId,
|
||||
normalizeCountryLabel: normalizeNexSmsCountryLabel,
|
||||
normalizeCountryOrder: normalizeNexSmsCountryOrder,
|
||||
normalizeServiceCode: normalizeNexSmsServiceCode,
|
||||
resolveCountryCandidates,
|
||||
fetchBalance: (state) => fetchBalance(state, providerDeps),
|
||||
fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, providerDeps),
|
||||
describePayload,
|
||||
isSuccessPayload,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
PROVIDER_ID,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_COUNTRY_ID,
|
||||
DEFAULT_COUNTRY_LABEL,
|
||||
DEFAULT_SERVICE_CODE,
|
||||
DEFAULT_SERVICE_LABEL,
|
||||
createProvider,
|
||||
describePayload,
|
||||
isSuccessPayload,
|
||||
normalizeNexSmsCountryId,
|
||||
normalizeNexSmsCountryLabel,
|
||||
normalizeNexSmsCountryOrder,
|
||||
normalizeNexSmsServiceCode,
|
||||
};
|
||||
});
|
||||
@@ -1896,6 +1896,7 @@
|
||||
<script src="../gopay-utils.js"></script>
|
||||
<script src="../phone-sms/providers/hero-sms.js"></script>
|
||||
<script src="../phone-sms/providers/five-sim.js"></script>
|
||||
<script src="../phone-sms/providers/nexsms.js"></script>
|
||||
<script src="../phone-sms/providers/madao.js"></script>
|
||||
<script src="../phone-sms/providers/registry.js"></script>
|
||||
<script src="../icloud-utils.js"></script>
|
||||
|
||||
@@ -3,6 +3,8 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('phone-sms/providers/registry.js', 'utf8');
|
||||
const maDaoSource = fs.readFileSync('phone-sms/providers/madao.js', 'utf8');
|
||||
const nexSmsSource = fs.readFileSync('phone-sms/providers/nexsms.js', 'utf8');
|
||||
|
||||
function loadRegistry(root = {}) {
|
||||
return new Function('self', `${source}; return self.PhoneSmsProviderRegistry;`)(root);
|
||||
@@ -16,6 +18,9 @@ test('phone sms provider registry normalizes ids, order and labels consistently'
|
||||
PhoneSmsFiveSimProvider: {
|
||||
createProvider: (deps = {}) => ({ provider: '5sim', deps }),
|
||||
},
|
||||
PhoneSmsNexSmsProvider: {
|
||||
createProvider: (deps = {}) => ({ provider: 'nexsms', deps }),
|
||||
},
|
||||
PhoneSmsMaDaoProvider: {
|
||||
createProvider: (deps = {}) => ({ provider: 'madao', deps }),
|
||||
},
|
||||
@@ -52,5 +57,43 @@ test('phone sms provider registry normalizes ids, order and labels consistently'
|
||||
registry.createProvider('madao', { foo: 2 }),
|
||||
{ provider: 'madao', deps: { foo: 2 } }
|
||||
);
|
||||
assert.throws(() => registry.createProvider('nexsms'), /接码平台模块未加载:nexsms/);
|
||||
assert.deepStrictEqual(
|
||||
registry.createProvider('nexsms', { foo: 3 }),
|
||||
{ provider: 'nexsms', deps: { foo: 3 } }
|
||||
);
|
||||
});
|
||||
|
||||
test('phone sms provider registry can create the real MaDao provider module', () => {
|
||||
const maDaoModule = new Function('self', `${maDaoSource}; return self.PhoneSmsMaDaoProvider;`)({});
|
||||
const registry = loadRegistry({
|
||||
PhoneSmsMaDaoProvider: maDaoModule,
|
||||
});
|
||||
|
||||
const provider = registry.createProvider('madao', { fetchImpl: 'demo-fetch' });
|
||||
|
||||
assert.equal(provider.id, 'madao');
|
||||
assert.equal(provider.label, 'MaDao');
|
||||
assert.equal(provider.defaultProduct, 'openai');
|
||||
assert.equal(typeof provider.acquireActivation, 'function');
|
||||
assert.equal(typeof provider.pollActivation, 'function');
|
||||
assert.equal(typeof provider.releaseActivation, 'function');
|
||||
assert.equal(provider.mapTicketStatus('waiting_code'), 'waiting_code');
|
||||
});
|
||||
|
||||
test('phone sms provider registry can create the real NexSMS provider module', () => {
|
||||
const nexSmsModule = new Function('self', `${nexSmsSource}; return self.PhoneSmsNexSmsProvider;`)({});
|
||||
const registry = loadRegistry({
|
||||
PhoneSmsNexSmsProvider: nexSmsModule,
|
||||
});
|
||||
|
||||
const provider = registry.createProvider('nexsms', { fetchImpl: 'demo-fetch' });
|
||||
|
||||
assert.equal(provider.id, 'nexsms');
|
||||
assert.equal(provider.label, 'NexSMS');
|
||||
assert.equal(provider.defaultProduct, 'OpenAI');
|
||||
assert.equal(provider.defaultServiceCode, 'ot');
|
||||
assert.equal(typeof provider.fetchBalance, 'function');
|
||||
assert.equal(typeof provider.fetchPrices, 'function');
|
||||
assert.equal(provider.normalizeCountryId('8'), 8);
|
||||
assert.equal(provider.normalizeServiceCode(' OT '), 'ot');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user