'use strict'; const assert = require('assert'); const { executeCardPayRecharge, executeCardPayRechargeWithRetry, pollCardPayTask, parseRechargeSession, safeRechargeFailure, shouldRetryRechargeFailure, rechargeNeedsManualConfirmation, createRechargeOperationId, } = require('../src/services/recharge_task_service'); function jwt(payload) { const encode = value => Buffer.from(JSON.stringify(value)).toString('base64url'); return `${encode({ alg: 'none' })}.${encode(payload)}.`; } function session(email = 'target@gmail.com') { return JSON.stringify({ user: { email }, expires: '2099-01-01T00:00:00.000Z', accessToken: jwt({ exp: 4070908800 }), sessionToken: 'must-not-be-used', }); } (async () => { const parsed = parseRechargeSession(session('Target@Gmail.com'), 'target@gmail.com', { nowMs: 1 }); assert(parsed.session.accessToken.includes('.')); assert.strictEqual(parsed.session.sessionToken, 'must-not-be-used'); assert.strictEqual(parsed.session.user.email, 'Target@Gmail.com'); assert.deepStrictEqual(JSON.parse(parsed.loginContent), parsed.session); const operationId = createRechargeOperationId('Target@Gmail.com', 'PRIVATE-CARD'); assert(operationId.startsWith('cardpay-v1-')); assert.strictEqual( operationId, createRechargeOperationId('target@gmail.com', 'PRIVATE-CARD'), '同一账号和卡密重试必须得到稳定幂等操作号', ); assert.notStrictEqual(operationId, createRechargeOperationId('target@gmail.com', 'OTHER-CARD')); assert.strictEqual(operationId.includes('PRIVATE-CARD'), false, '幂等操作号不得暴露卡密'); assert.throws( () => parseRechargeSession(session('other@gmail.com'), 'target@gmail.com'), error => error && error.code === 'RECHARGE_SESSION_ACCOUNT_MISMATCH', ); assert.doesNotThrow( () => parseRechargeSession(session('firstlast@gmail.com'), 'First.Last@gmail.com'), 'Gmail Session 返回无点号地址时应识别为同一充值账号', ); assert.doesNotThrow( () => parseRechargeSession(JSON.stringify({ user: { email: 'firstlast@gmail.com' }, expires: '2099-01-01T00:00:00.000Z', accessToken: jwt({ exp: 4070908800, 'https://api.openai.com/profile': { email: 'firstlast@googlemail.com' }, }), }), 'First.Last@gmail.com'), 'Session user 和 accessToken 都应按 Gmail 点号及大小写规则校验', ); assert.throws( () => parseRechargeSession(session('firstlast@example.com'), 'first.last@example.com'), error => error && error.code === 'RECHARGE_SESSION_ACCOUNT_MISMATCH', '非 Gmail 邮箱不得忽略用户名中的点', ); assert.throws( () => parseRechargeSession(session('first.last+tag@gmail.com'), 'first.last@gmail.com'), error => error && error.code === 'RECHARGE_SESSION_ACCOUNT_MISMATCH', '本次兼容仅忽略 Gmail 用户名中的点和大小写,不放宽 +tag 校验', ); assert.throws( () => parseRechargeSession(JSON.stringify({ user: { email: 'target@gmail.com' } }), 'target@gmail.com'), error => error && error.code === 'RECHARGE_ACCESS_TOKEN_MISSING', ); assert.throws( () => parseRechargeSession(JSON.stringify({ user: { email: 'target@gmail.com' }, expires: '2099-01-01T00:00:00.000Z', accessToken: jwt({ exp: 4070908800, 'https://api.openai.com/profile': { email: 'other@gmail.com' }, }), }), 'target@gmail.com'), error => error && error.code === 'RECHARGE_SESSION_ACCOUNT_MISMATCH', ); assert.throws( () => parseRechargeSession(JSON.stringify({ user: { email: 'target@gmail.com' }, expires: '2020-01-01T00:00:00.000Z', accessToken: jwt({ exp: 4070908800 }), }), 'target@gmail.com'), error => error && error.code === 'RECHARGE_SESSION_EXPIRED', ); const calls = []; const client = { async prepareCardPay(input) { calls.push({ stage: 'prepare', input }); return { success: true, prepareToken: 'local-prepare', taskId: '41' }; }, async confirmCardPay(input) { calls.push({ stage: 'confirm', input }); return { success: true, status: 'processing', taskId: '41' }; }, async getTask(input) { calls.push({ stage: 'task', input }); return { success: true, status: 'success', taskId: '41', accountEmail: 'target@gmail.com' }; }, }; const result = await executeCardPayRecharge({ client, email: 'target@gmail.com', cardKey: 'unit-card-key', sessionText: session('target@gmail.com'), operationId: 'unit-operation-123456', nowMs: 1, now: () => 100, pollIntervalMs: 10, pollTimeoutMs: 100, requestMaxAttempts: 1, sleep: async () => {}, onPrepared: async info => calls.push({ stage: 'prepared_callback', input: info }), onConfirmed: async info => calls.push({ stage: 'confirmed_callback', input: info }), onPoll: async info => calls.push({ stage: 'poll_callback', input: info }), }); assert.deepStrictEqual(result, { success: true, taskId: '41', status: 'success' }); assert.deepStrictEqual(calls.map(call => call.stage), [ 'prepare', 'prepared_callback', 'confirm', 'confirmed_callback', 'task', 'poll_callback', ]); const expectedSession = JSON.parse(session('target@gmail.com')); assert.deepStrictEqual(JSON.parse(calls[0].input.loginContent), expectedSession); assert.strictEqual(calls[2].input.loginContent, calls[0].input.loginContent); assert.strictEqual(Object.prototype.hasOwnProperty.call(calls[0].input, 'accessToken'), false); assert.strictEqual(Object.prototype.hasOwnProperty.call(calls[0].input, 'session'), false); assert.strictEqual(JSON.parse(calls[0].input.loginContent).sessionToken, 'must-not-be-used'); assert.strictEqual(Object.prototype.hasOwnProperty.call(calls[2].input, 'cardKey'), false); assert.notStrictEqual(calls[0].input.operationId, ''); assert.strictEqual(calls[0].input.maxAttempts, 1); assert.strictEqual(calls[2].input.maxAttempts, 1); let delayedClock = 0; let delayedQueries = 0; const delayedResult = await pollCardPayTask({ client: { async getTask() { delayedQueries += 1; if (delayedQueries === 1) { throw Object.assign(new Error('temporary'), { code: 'GPTPAY_NETWORK_ERROR', retryable: true, }); } if (delayedQueries === 2) return { success: true, taskId: '77', status: 'pending' }; return { success: true, taskId: '77', status: 'success' }; }, }, taskId: '77', expectedEmail: 'target@gmail.com', pollIntervalMs: 10, pollTimeoutMs: 100, now: () => delayedClock, sleep: async delay => { delayedClock += delay; }, }); assert.deepStrictEqual(delayedResult, { success: true, taskId: '77', status: 'success' }); assert.strictEqual(delayedQueries, 3, '短时查询错误后必须继续核对同一个任务号'); let timeoutClock = 0; const timeoutError = await pollCardPayTask({ client: { async getTask() { return { success: true, taskId: '78', status: 'pending' }; }, }, taskId: '78', expectedEmail: 'target@gmail.com', pollIntervalMs: 10, pollTimeoutMs: 30, now: () => timeoutClock, sleep: async delay => { timeoutClock += delay; }, }).then(() => null, error => error); assert.strictEqual(timeoutError.code, 'RECHARGE_TASK_TIMEOUT'); assert.strictEqual(timeoutError.taskId, '78'); assert.strictEqual(shouldRetryRechargeFailure(timeoutError), false, '已有任务号仍在处理中时不能重新预创建'); let manualClock = 0; await assert.rejects( pollCardPayTask({ client: { async getTask() { return { success: true, taskId: '79', status: 'manual_confirmation' }; }, }, taskId: '79', expectedEmail: 'target@gmail.com', pollIntervalMs: 10, pollTimeoutMs: 30, now: () => manualClock, sleep: async delay => { manualClock += delay; }, }), error => error && error.code === 'RECHARGE_RESULT_PENDING' && error.taskId === '79', '主系统明确人工确认状态时才应停止自动查询', ); let confirmRecoveryClock = 0; let confirmRecoveryPrepareCalls = 0; let confirmRecoveryQueries = 0; const confirmRecoveryResult = await executeCardPayRechargeWithRetry({ client: { async prepareCardPay() { confirmRecoveryPrepareCalls += 1; return { success: true, prepareToken: 'recover-token', taskId: '80' }; }, async confirmCardPay() { throw Object.assign(new Error('confirm response lost'), { code: 'GPTPAY_NETWORK_ERROR', retryable: true, }); }, async getTask() { confirmRecoveryQueries += 1; return confirmRecoveryQueries < 2 ? { success: true, taskId: '80', status: 'pending' } : { success: true, taskId: '80', status: 'success' }; }, }, email: 'target@gmail.com', cardKey: 'RECOVER-SAME-CARD', sessionText: session('target@gmail.com'), operationId: 'confirm-recovery-operation', nowMs: 1, pollIntervalMs: 10, pollTimeoutMs: 50, now: () => confirmRecoveryClock, sleep: async delay => { confirmRecoveryClock += delay; }, retrySleep: async () => {}, }); assert.deepStrictEqual(confirmRecoveryResult, { success: true, taskId: '80', status: 'success' }); assert.strictEqual(confirmRecoveryPrepareCalls, 1, '确认响应异常但已有任务号时不得重新创建支付任务'); let terminalRetryClock = 0; const terminalRetryOperationIds = []; let terminalRetryTaskNo = 90; const terminalRetryResult = await executeCardPayRechargeWithRetry({ client: { async prepareCardPay(input) { terminalRetryOperationIds.push(input.operationId); terminalRetryTaskNo += 1; return { success: true, prepareToken: `terminal-token-${terminalRetryTaskNo}`, taskId: String(terminalRetryTaskNo), }; }, async confirmCardPay(input) { return { success: true, status: 'processing', taskId: String(input.expectedTaskId) }; }, async getTask(input) { return input.taskId === '91' ? { success: true, taskId: '91', status: 'failed', failed: true } : { success: true, taskId: '92', status: 'success', failed: false }; }, }, email: 'target@gmail.com', cardKey: 'TERMINAL-RETRY-SAME-CARD', sessionText: session('target@gmail.com'), operationId: 'terminal-retry-operation', nowMs: 1, maxAttempts: 3, pollIntervalMs: 10, pollTimeoutMs: 100, now: () => terminalRetryClock, sleep: async delay => { terminalRetryClock += delay; }, retrySleep: async () => {}, }); assert.deepStrictEqual(terminalRetryResult, { success: true, taskId: '92', status: 'success' }); assert.deepStrictEqual(terminalRetryOperationIds, [ 'terminal-retry-operation', 'terminal-retry-operation-retry-2', ], '只有远端任务明确失败后才能创建新的幂等尝试'); const retryPrepareCalls = []; const retryEvents = []; const retryDelays = []; const retryClient = { async prepareCardPay(input) { retryPrepareCalls.push(input); if (retryPrepareCalls.length < 3) { throw Object.assign(new Error('private-card-lookup-detail'), { code: 'GPTPAY_BUSINESS_REJECTED', errorCode: 'card_not_found', }); } return { success: true, prepareToken: 'retry-prepare-token', taskId: '88' }; }, async confirmCardPay() { return { success: true, status: 'success', taskId: '88' }; }, async getTask() { throw new Error('must not query an immediate success'); }, }; const retriedResult = await executeCardPayRechargeWithRetry({ client: retryClient, email: 'target@gmail.com', cardKey: 'SAME-CARD-FOR-ALL-ATTEMPTS', sessionText: session('target@gmail.com'), operationId: 'stable-operation-for-three-attempts', nowMs: 1, maxAttempts: 3, retryDelayMs: 10, retrySleep: async delay => retryDelays.push(delay), onAttempt: async info => retryEvents.push({ stage: 'attempt', ...info }), onRetry: async info => retryEvents.push({ stage: 'retry', ...info }), }); assert.deepStrictEqual(retriedResult, { success: true, taskId: '88', status: 'success' }); assert.strictEqual(retryPrepareCalls.length, 3, '直接失败后最多应尝试三次'); assert.deepStrictEqual( retryPrepareCalls.map(input => input.cardKey), Array(3).fill('SAME-CARD-FOR-ALL-ATTEMPTS'), '每次重试必须使用完全相同的卡密', ); assert.deepStrictEqual( retryPrepareCalls.map(input => input.operationId), Array(3).fill('stable-operation-for-three-attempts'), '每次重试必须复用同一个幂等操作号', ); assert.deepStrictEqual(retryDelays, [10, 20]); assert.deepStrictEqual( retryEvents.filter(event => event.stage === 'attempt').map(event => event.attempt), [1, 2, 3], ); assert.strictEqual( JSON.stringify(retryEvents).includes('private-card-lookup-detail'), false, '重试事件不得暴露上游原始错误内容', ); const duplicateClient = { async prepareCardPay() { return { success: false, requiresDuplicateConfirm: true }; }, async confirmCardPay() { throw new Error('must not confirm'); }, async getTask() { throw new Error('must not query'); }, }; await assert.rejects( executeCardPayRecharge({ client: duplicateClient, email: 'target@gmail.com', cardKey: 'unit-card-key', sessionText: session('target@gmail.com'), operationId: 'unit-operation-duplicate', nowMs: 1, }), error => error && error.code === 'RECHARGE_DUPLICATE_CONFIRM_REQUIRED', ); const safe = safeRechargeFailure(Object.assign(new Error('secret-card-token'), { code: 'GPTPAY_API_REJECTED', })); assert.deepStrictEqual(safe, { code: 'GPTPAY_API_REJECTED', message: '充值接口签名、权限或请求被拒绝', }); assert.strictEqual(JSON.stringify(safe).includes('secret-card-token'), false); const safeLineMismatch = safeRechargeFailure(Object.assign(new Error('private-upstream-message'), { code: 'GPTPAY_BUSINESS_REJECTED', errorCode: 'fulfillment_mode_mismatch', requestId: 'safe-request-123', })); assert.deepStrictEqual(safeLineMismatch, { code: 'GPTPAY_BUSINESS_REJECTED', message: '卡密不是卡付线路,当前充值入口无法处理', errorCode: 'fulfillment_mode_mismatch', requestId: 'safe-request-123', }); assert.strictEqual(JSON.stringify(safeLineMismatch).includes('private-upstream-message'), false); assert.strictEqual(shouldRetryRechargeFailure(Object.assign(new Error('lookup'), { code: 'GPTPAY_BUSINESS_REJECTED', errorCode: 'card_not_found', })), true, '卡密暂未查到时应使用同一卡密重试'); assert.strictEqual(shouldRetryRechargeFailure(Object.assign(new Error('line'), { code: 'GPTPAY_BUSINESS_REJECTED', errorCode: 'fulfillment_mode_mismatch', })), false, '线路固定不匹配不应空转重试'); assert.strictEqual(shouldRetryRechargeFailure(Object.assign(new Error('failed'), { code: 'RECHARGE_TASK_FAILED', })), true, '主系统明确执行失败应进入同卡重试'); assert.strictEqual(shouldRetryRechargeFailure(Object.assign(new Error('session'), { code: 'RECHARGE_SESSION_INVALID', })), false, '本地 Session 无效不应调用外部接口重试'); assert.strictEqual(rechargeNeedsManualConfirmation(Object.assign(new Error('business'), { code: 'GPTPAY_BUSINESS_REJECTED', errorCode: 'card_not_found', }), '501'), false, '已有远端任务号时应继续对账同一任务,不能伪装成人工待确认'); assert.strictEqual(rechargeNeedsManualConfirmation(Object.assign(new Error('manual'), { code: 'RECHARGE_RESULT_PENDING', taskId: '501', }), '501'), true, '只有主系统明确结果待确认时才应等待人工确认'); assert.strictEqual(rechargeNeedsManualConfirmation(Object.assign(new Error('business'), { code: 'GPTPAY_BUSINESS_REJECTED', errorCode: 'card_not_found', }), null), false, '预创建前明确拒绝且没有任务号时可显示失败'); assert.strictEqual(rechargeNeedsManualConfirmation(Object.assign(new Error('terminal'), { code: 'RECHARGE_TASK_FAILED', taskId: '502', }), '502'), false, '远端任务明确终止失败时应显示充值失败'); assert.strictEqual(rechargeNeedsManualConfirmation(Object.assign(new Error('network'), { code: 'GPTPAY_NETWORK_ERROR', retryable: true, }), null), true, '没有任务号但修改请求结果未知时也应等待人工确认'); console.log('recharge_task_service.test.js: PASS'); })().catch(error => { console.error(error); process.exitCode = 1; });