fix(phone): 修正 5sim 复用订单保留逻辑

This commit is contained in:
InkCrow
2026-05-18 12:26:55 +08:00
committed by QLHazyCoder
parent 025d55e655
commit dc66cc47b6
4 changed files with 276 additions and 78 deletions
+79 -32
View File
@@ -1384,7 +1384,7 @@
const recordedAt = Math.max(0, Number(record?.recordedAt) || 0);
return {
...normalized,
provider: PHONE_SMS_PROVIDER_HERO,
provider: normalized.provider,
source: 'free-manual-reuse',
...(recordedAt ? { recordedAt } : {}),
};
@@ -3901,25 +3901,10 @@
const config = resolvePhoneConfig(state);
if (config.provider === PHONE_SMS_PROVIDER_5SIM) {
const reuseProduct = normalizeFiveSimCountryCode(
normalizedActivation.serviceCode || config.product || DEFAULT_FIVE_SIM_PRODUCT,
DEFAULT_FIVE_SIM_PRODUCT
);
const reuseNumber = String(normalizedActivation.phoneNumber || '').replace(/[^\d]/g, '');
if (!reuseNumber) {
throw new Error('5sim 复用手机号失败:手机号缺失。');
}
const payload = await fetchFiveSimPayload(
config,
`/user/reuse/${reuseProduct}/${reuseNumber}`,
'5sim reuse activation'
);
const nextActivation = parseFiveSimActivationPayload(payload, normalizedActivation);
if (!nextActivation) {
const text = describeFiveSimPayload(payload);
throw createPhoneSmsActionFailureError('5sim reuse activation', text || 'empty response');
}
return nextActivation;
return {
...normalizedActivation,
source: '5sim-retained-reuse',
};
}
if (config.provider === PHONE_SMS_PROVIDER_NEXSMS) {
throw new Error('NexSMS 当前流程不支持复用手机号订单。');
@@ -4078,6 +4063,38 @@
return normalizeActivation(activation)?.source === 'free-auto-reuse';
}
function isFiveSimRetainedReuseActivation(activation) {
const normalizedActivation = normalizeActivation(activation);
return Boolean(
normalizedActivation
&& normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM
&& normalizedActivation.source === '5sim-retained-reuse'
);
}
function shouldRetireFreeReusableActivationOnFailure(state, activation) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
return false;
}
if (normalizedActivation.phoneCodeReceived) {
return false;
}
if (isFreeAutoReuseActivation(normalizedActivation)) {
return true;
}
const savedFreeActivation = normalizeFreeReusablePhoneActivation(
state?.[FREE_REUSABLE_PHONE_ACTIVATION_STATE_KEY]
);
return Boolean(
savedFreeActivation
&& (
isSameActivation(savedFreeActivation, normalizedActivation)
|| phoneNumbersMatch(savedFreeActivation.phoneNumber, normalizedActivation.phoneNumber)
)
);
}
async function banPhoneActivation(state = {}, activation) {
try {
if (shouldSkipTerminalStatusForFreeReuse(state, activation)) {
@@ -4139,6 +4156,15 @@
message: '免费复用手机号激活记录缺失。',
};
}
if (normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM) {
return {
ok: true,
activation: {
...normalizedActivation,
source: 'free-auto-reuse',
},
};
}
if (!String(normalizedActivation.activationId || '').trim()) {
return {
ok: false,
@@ -5293,8 +5319,13 @@
const normalizedActivation = normalizeActivation(activation);
return Boolean(
normalizedActivation
&& normalizedActivation.provider === PHONE_SMS_PROVIDER_HERO
&& normalizedActivation.source === 'hero-sms-new'
&& (
(
normalizedActivation.provider === PHONE_SMS_PROVIDER_HERO
&& normalizedActivation.source === 'hero-sms-new'
)
|| normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM
)
&& normalizedActivation.phoneCodeReceived
);
}
@@ -5304,12 +5335,21 @@
return false;
}
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation || normalizedActivation.provider !== PHONE_SMS_PROVIDER_HERO) {
if (
!normalizedActivation
|| (
normalizedActivation.provider !== PHONE_SMS_PROVIDER_HERO
&& normalizedActivation.provider !== PHONE_SMS_PROVIDER_5SIM
)
) {
return false;
}
if (isFreeAutoReuseActivation(normalizedActivation)) {
return true;
}
if (isFiveSimRetainedReuseActivation(normalizedActivation)) {
return true;
}
if (normalizedActivation.source === 'free-manual-reuse') {
return true;
}
@@ -5345,7 +5385,10 @@
const normalizedActivation = normalizeActivation(activation);
if (
!normalizedActivation
|| normalizedActivation.provider !== PHONE_SMS_PROVIDER_HERO
|| (
normalizedActivation.provider !== PHONE_SMS_PROVIDER_HERO
&& normalizedActivation.provider !== PHONE_SMS_PROVIDER_5SIM
)
|| !normalizedActivation.phoneCodeReceived
|| isFreeAutoReuseActivation(normalizedActivation)
) {
@@ -5412,7 +5455,10 @@
const normalizedActivation = normalizeActivation(activation);
if (
!normalizedActivation
|| normalizedActivation.provider !== PHONE_SMS_PROVIDER_HERO
|| (
normalizedActivation.provider !== PHONE_SMS_PROVIDER_HERO
&& normalizedActivation.provider !== PHONE_SMS_PROVIDER_5SIM
)
|| isFreeAutoReuseActivation(normalizedActivation)
) {
return;
@@ -5439,7 +5485,7 @@
}
const maxUses = Math.max(1, Math.floor(Number(savedActivation.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES));
const successfulUses = Math.min(maxUses, Math.max(1, normalizeUseCount(savedActivation.successfulUses)));
const successfulUses = Math.min(maxUses, normalizeUseCount(savedActivation.successfulUses) + 1);
if (successfulUses >= maxUses) {
await clearFreeReusableActivation();
await addLog(
@@ -6643,7 +6689,7 @@
`步骤 9:当前号码 ${activation.phoneNumber} 反复返回添加手机号页,正在更换号码(${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts})。`,
'warn'
);
if (isFreeAutoReuseActivation(activation)) {
if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) {
await retireFreeReusableActivation(
`自动白嫖复用号码 ${activation.phoneNumber} 反复返回添加手机号页。`
);
@@ -6703,7 +6749,7 @@
activation,
await getState()
);
if (isFreeAutoReuseActivation(activation)) {
if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) {
await retireFreeReusableActivation(
`自动白嫖复用号码 ${activation.phoneNumber} 被目标站拒绝。`
);
@@ -6844,7 +6890,7 @@
activation,
await getState()
);
if (isFreeAutoReuseActivation(activation)) {
if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) {
await retireFreeReusableActivation(
`自动白嫖复用号码 ${activation.phoneNumber} 被目标站拒绝。`
);
@@ -6922,8 +6968,9 @@
const latestSuccessState = await getState();
if (shouldSkipTerminalStatusForFreeReuse(latestSuccessState, activation)) {
const terminalProviderLabel = getPhoneSmsProviderLabel(activation.provider);
await addLog(
`步骤 9:已跳过 HeroSMS 完成状态,保留 ${activation.phoneNumber} 供白嫖复用。`,
`步骤 9:已跳过 ${terminalProviderLabel} 完成状态,保留 ${activation.phoneNumber} 供白嫖复用。`,
'info'
);
await markFreeReusableActivationAfterInitialSuccess(latestSuccessState, activation);
@@ -6973,7 +7020,7 @@
if (shouldCancelActivation && activation) {
await cancelPhoneActivation(state, activation);
}
if (isFreeAutoReuseActivation(activation)) {
if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) {
await retireFreeReusableActivation(
`自动白嫖复用号码 ${activation.phoneNumber} 在失败后被更换。`
);
@@ -7042,7 +7089,7 @@
) {
throw error;
}
if (isFreeAutoReuseActivation(activation)) {
if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) {
await retireFreeReusableActivation(
`自动白嫖复用号码 ${activation.phoneNumber} 执行失败:${errorMessage || 'unknown error'}`
);
+6 -8
View File
@@ -746,14 +746,12 @@
if (!phoneDigits) {
throw new Error('可复用的 5sim 手机号无效。');
}
const config = resolveConfig(state, deps);
const numberWithoutPlus = String(normalizedActivation.phoneNumber || '')
.replace(/^\+/, '')
.replace(/[^0-9]+/g, '');
const payload = await fetchJson(config, `/v1/user/reuse/${DEFAULT_PRODUCT}/${numberWithoutPlus || phoneDigits}`, {
actionLabel: '5sim 复用手机号',
});
return normalizeActivation(payload, normalizedActivation);
// 5sim 的 /user/reuse 会按手机号重新创建付费订单,并不是保留原订单继续收短信。
// 真正的复用应继续轮询原 activationId,避免产生新的订单。
return {
...normalizedActivation,
source: '5sim-retained-reuse',
};
}
async function finishActivation(state = {}, activation, deps = {}) {
+3 -6
View File
@@ -71,7 +71,7 @@ test('5sim provider maps countries and prices', async () => {
assert.deepStrictEqual(entries, [{ cost: 10, count: 2, inStock: true }]);
});
test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation', async () => {
test('5sim provider buys, checks, finishes, cancels, bans, and keeps original activation on reuse', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url, options = {}) => {
@@ -92,9 +92,7 @@ test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation
if (parsed.pathname === '/v1/user/finish/1001') return createTextResponse({ status: 'FINISHED' });
if (parsed.pathname === '/v1/user/cancel/1001') return createTextResponse({ status: 'CANCELED' });
if (parsed.pathname === '/v1/user/ban/1001') return createTextResponse({ status: 'BANNED' });
if (parsed.pathname === '/v1/user/reuse/openai/84901123456') {
return createTextResponse({ id: 1002, phone: '+84901123456', country: 'vietnam', status: 'PENDING' });
}
if (parsed.pathname.includes('/reuse/')) throw new Error(`5sim free reuse should not create a new order: ${parsed.pathname}`);
throw new Error(`unexpected ${parsed.pathname}`);
},
sleepWithStop: async () => {},
@@ -113,7 +111,7 @@ test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation
assert.equal(activation.activationId, '1001');
assert.equal(activation.countryId, 'vietnam');
assert.equal(code, '112233');
assert.equal(reused.activationId, '1002');
assert.equal(reused.activationId, '1001');
const buy = requests.find((entry) => entry.url.pathname.includes('/buy/activation'));
assert.equal(buy.url.searchParams.get('maxPrice'), '12');
assert.equal(buy.url.searchParams.get('reuse'), '1');
@@ -127,7 +125,6 @@ test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation
'/v1/user/finish/1001',
'/v1/user/cancel/1001',
'/v1/user/ban/1001',
'/v1/user/reuse/openai/84901123456',
]
);
});
+188 -32
View File
@@ -2872,7 +2872,7 @@ test('phone verification helper treats HeroSMS STATUS_WAIT_RETRY payload status
assert.equal(statusUpdates[0], 'STATUS_WAIT_RETRY:846171');
});
test('phone verification helper reuses 5sim number via product-plus-number endpoint', async () => {
test('phone verification helper reuses 5sim by keeping the original activation', async () => {
const requests = [];
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
@@ -2880,20 +2880,7 @@ test('phone verification helper reuses 5sim number via product-plus-number endpo
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl.pathname);
if (parsedUrl.pathname !== '/v1/user/reuse/openai/447911123456') {
throw new Error(`Unexpected 5sim request: ${parsedUrl.pathname}`);
}
return {
ok: true,
status: 200,
text: async () => JSON.stringify({
id: 700002,
phone: '+447911123456',
country: 'england',
country_name: 'England',
product: 'openai',
}),
};
throw new Error(`Unexpected 5sim request: ${parsedUrl.pathname}`);
},
getState: async () => ({
phoneSmsProvider: '5sim',
@@ -2929,17 +2916,17 @@ test('phone verification helper reuses 5sim number via product-plus-number endpo
);
assert.deepStrictEqual(nextActivation, {
activationId: '700002',
phoneNumber: '+447911123456',
activationId: '600001',
phoneNumber: '+44 7911-123-456',
provider: '5sim',
serviceCode: 'openai',
countryId: 'england',
countryCode: 'england',
countryLabel: 'England',
successfulUses: 0,
maxUses: 1,
source: '5sim-retained-reuse',
});
assert.deepStrictEqual(requests, ['/v1/user/reuse/openai/447911123456']);
assert.deepStrictEqual(requests, []);
});
test('phone verification helper acquires a number from NexSMS with ordered fallback countries', async () => {
@@ -6101,6 +6088,180 @@ test('phone verification helper preserves newly saved free-reuse activation afte
);
});
test('phone verification helper auto free-reuses 5sim by polling the retained order without reuse or finish', async () => {
const requests = [];
let currentState = {
phoneSmsProvider: '5sim',
fiveSimApiKey: 'demo-key',
fiveSimCountryOrder: ['vietnam'],
fiveSimOperator: 'any',
freePhoneReuseEnabled: true,
freePhoneReuseAutoEnabled: true,
verificationResendCount: 0,
phoneCodeWaitSeconds: 60,
phoneCodeTimeoutWindows: 1,
phoneCodePollIntervalSeconds: 1,
phoneCodePollMaxRounds: 1,
currentPhoneActivation: null,
reusablePhoneActivation: null,
freeReusablePhoneActivation: {
activationId: 'five-free-1',
phoneNumber: '+84901122334',
provider: '5sim',
serviceCode: 'openai',
countryId: 'vietnam',
countryLabel: '越南 (Vietnam)',
successfulUses: 1,
maxUses: 3,
source: 'free-manual-reuse',
},
};
const fiveSimSource = fs.readFileSync('phone-sms/providers/five-sim.js', 'utf8');
const fiveSimModule = new Function('self', `${fiveSimSource}; return self.PhoneSmsFiveSimProvider;`)({});
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
createFiveSimProvider: fiveSimModule.createProvider,
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
if (parsedUrl.pathname === '/v1/user/check/five-free-1') {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({
id: 'five-free-1',
phone: '+84901122334',
status: 'RECEIVED',
sms: [{ code: '334455' }],
}),
};
}
if (parsedUrl.pathname.includes('/reuse/') || parsedUrl.pathname.includes('/finish/')) {
throw new Error(`5sim free reuse should not call terminal/reuse endpoint: ${parsedUrl.pathname}`);
}
throw new Error(`Unexpected 5sim path: ${parsedUrl.pathname}`);
},
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'SUBMIT_PHONE_NUMBER') {
assert.equal(message.payload.phoneNumber, '+84901122334');
return { phoneVerificationPage: true, url: 'https://auth.openai.com/phone-verification' };
}
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
assert.equal(message.payload.code, '334455');
return { success: true, consentReady: true, url: 'https://auth.openai.com/authorize' };
}
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const result = await helpers.completePhoneVerificationFlow(1, {
addPhonePage: true,
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
});
assert.deepStrictEqual(result, {
success: true,
consentReady: true,
url: 'https://auth.openai.com/authorize',
});
assert.equal(currentState.freeReusablePhoneActivation.provider, '5sim');
assert.equal(currentState.freeReusablePhoneActivation.activationId, 'five-free-1');
assert.equal(currentState.freeReusablePhoneActivation.successfulUses, 2);
assert.equal(currentState.reusablePhoneActivation, null);
assert.deepStrictEqual(requests.map((url) => url.pathname), ['/v1/user/check/five-free-1']);
});
test('phone verification helper retires failed 5sim free-reuse record instead of retrying stale order', async () => {
const requests = [];
let currentState = {
phoneSmsProvider: '5sim',
fiveSimApiKey: 'demo-key',
fiveSimCountryOrder: ['vietnam'],
fiveSimOperator: 'any',
freePhoneReuseEnabled: true,
freePhoneReuseAutoEnabled: true,
verificationResendCount: 0,
phoneCodeWaitSeconds: 60,
phoneCodeTimeoutWindows: 1,
phoneCodePollIntervalSeconds: 1,
phoneCodePollMaxRounds: 1,
currentPhoneActivation: null,
reusablePhoneActivation: null,
freeReusablePhoneActivation: {
activationId: 'five-free-stale',
phoneNumber: '+84901122999',
provider: '5sim',
serviceCode: 'openai',
countryId: 'vietnam',
countryLabel: '越南 (Vietnam)',
successfulUses: 1,
maxUses: 3,
source: 'free-manual-reuse',
},
};
const fiveSimSource = fs.readFileSync('phone-sms/providers/five-sim.js', 'utf8');
const fiveSimModule = new Function('self', `${fiveSimSource}; return self.PhoneSmsFiveSimProvider;`)({});
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
createFiveSimProvider: fiveSimModule.createProvider,
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
if (parsedUrl.pathname === '/v1/user/check/five-free-stale') {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({
id: 'five-free-stale',
phone: '+84901122999',
status: 'FINISHED',
sms: [],
}),
};
}
throw new Error(`Unexpected 5sim path: ${parsedUrl.pathname}`);
},
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'SUBMIT_PHONE_NUMBER') {
assert.equal(message.payload.phoneNumber, '+84901122999');
return { phoneVerificationPage: true, url: 'https://auth.openai.com/phone-verification' };
}
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await assert.rejects(
helpers.completePhoneVerificationFlow(1, {
addPhonePage: true,
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
}),
/5sim 查询验证码失败:订单状态 FINISHED|5sim 订单在收到短信前已结束:FINISHED/
);
assert.equal(currentState.freeReusablePhoneActivation, null);
assert.deepStrictEqual(requests.map((url) => url.pathname), ['/v1/user/check/five-free-stale']);
});
test('phone verification helper replaces number immediately when resend is throttled and does not spam resend clicks', async () => {
const requests = [];
const messages = [];
@@ -8321,7 +8482,7 @@ test('phone verification helper routes 5sim buy, check, and finish by current ac
);
});
test('phone verification helper routes 5sim reusable activation through reuse endpoint', async () => {
test('phone verification helper keeps 5sim reusable activation on the original order', async () => {
const requests = [];
let currentState = {
phoneSmsProvider: '5sim',
@@ -8357,14 +8518,11 @@ test('phone verification helper routes 5sim reusable activation through reuse en
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
if (parsedUrl.pathname === '/v1/user/reuse/openai/84901122334') {
return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4002, phone: '+84901122334', country: 'vietnam', status: 'PENDING' }) };
if (parsedUrl.pathname === '/v1/user/check/4001') {
return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4001, phone: '+84901122334', status: 'RECEIVED', sms: [{ code: '654321' }] }) };
}
if (parsedUrl.pathname === '/v1/user/check/4002') {
return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4002, phone: '+447911223344', status: 'RECEIVED', sms: [{ code: '654321' }] }) };
}
if (parsedUrl.pathname === '/v1/user/finish/4002') {
return { ok: true, status: 200, text: async () => JSON.stringify({ status: 'FINISHED' }) };
if (parsedUrl.pathname.includes('/reuse/') || parsedUrl.pathname.includes('/finish/')) {
throw new Error(`5sim free reuse should not call terminal/reuse endpoint: ${parsedUrl.pathname}`);
}
throw new Error(`Unexpected 5sim path: ${parsedUrl.pathname}`);
},
@@ -8397,14 +8555,12 @@ test('phone verification helper routes 5sim reusable activation through reuse en
consentReady: true,
url: 'https://auth.openai.com/authorize',
});
assert.equal(currentState.reusablePhoneActivation.activationId, '4002');
assert.equal(currentState.reusablePhoneActivation.successfulUses, 1);
assert.equal(currentState.reusablePhoneActivation.activationId, '4001');
assert.equal(currentState.reusablePhoneActivation.successfulUses, 2);
assert.deepStrictEqual(
requests.map((url) => url.pathname),
[
'/v1/user/reuse/openai/84901122334',
'/v1/user/check/4002',
'/v1/user/finish/4002',
'/v1/user/check/4001',
]
);
});