417 lines
16 KiB
JavaScript
417 lines
16 KiB
JavaScript
'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;
|
|
});
|