339 lines
17 KiB
JavaScript
339 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'quota-monitor-'));
|
|
const Config = require('../src/core/config');
|
|
Config.DATA_DIR = tempDir;
|
|
Config.DB_PATH = path.join(tempDir, 'accounts.db');
|
|
const database = require('../src/core/database');
|
|
const { runQuotaMonitor, _private } = require('../src/services/quota_monitor_service');
|
|
const { sendFeishuQuotaReport } = require('../src/services/feishu_sync_service');
|
|
const schedulerPrivate = require('../src/services/quota_monitor_scheduler')._private;
|
|
|
|
(async () => {
|
|
try {
|
|
database.initDB();
|
|
const tables = database.getDb().prepare(`
|
|
SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'quota_monitor_%' ORDER BY name
|
|
`).all().map(row => row.name);
|
|
assert.deepStrictEqual(tables, ['quota_monitor_items', 'quota_monitor_runs']);
|
|
assert(database.getDb().pragma('table_info(quota_monitor_runs)').some(row => row.name === 'skipped_401_count'));
|
|
|
|
const scheduledWrites = [];
|
|
const scheduledSession = JSON.stringify({ accessToken: 'old-session-access', sessionToken: 'scheduled-session-token' });
|
|
const scheduledSessionResult = JSON.stringify({ accessToken: 'new-session-access', sessionToken: 'scheduled-session-token' });
|
|
const scheduledOnly = await _private.queryAccount({
|
|
email: 'scheduled-session-only@gmail.com',
|
|
openai_auth_json: null,
|
|
chatgpt_session: scheduledSession,
|
|
chatgpt_status: '已登录',
|
|
proxy_ip: '',
|
|
}, {
|
|
database: {
|
|
updateAccountOpenAIPlanStatus: (...args) => { scheduledWrites.push(['planStatus', ...args]); return true; },
|
|
updateAccountChatgptResult: (...args) => { scheduledWrites.push(['session', ...args]); return true; },
|
|
updateAccountOpenAIAuth: (...args) => { scheduledWrites.push(['auth', ...args]); return true; },
|
|
updateAccountOpenAIPlan: (...args) => { scheduledWrites.push(['plan', ...args]); return true; },
|
|
},
|
|
onLog: () => {},
|
|
delay: async () => {},
|
|
query: async (authJson, _proxy, _log, options) => {
|
|
assert.deepStrictEqual(authJson, {});
|
|
assert.strictEqual(options.sessionText, scheduledSession);
|
|
assert.strictEqual(options.expectedEmail, 'scheduled-session-only@gmail.com');
|
|
return {
|
|
success: true,
|
|
authJson: {
|
|
access_token: 'new-session-access',
|
|
usage: { rate_limit: { primary_window: { used_percent: 100 } } },
|
|
},
|
|
sessionText: scheduledSessionResult,
|
|
details: { usage: { rate_limit: { primary_window: { used_percent: 100 } } }, errors: [] },
|
|
};
|
|
},
|
|
});
|
|
assert.strictEqual(scheduledOnly.success, true, '定时查询不能因缺少授权 JSON 跳过 Session 账号');
|
|
assert(scheduledWrites.some(call => call[0] === 'session' && call[3] === scheduledSessionResult));
|
|
assert(scheduledWrites.some(call => call[0] === 'auth'));
|
|
|
|
const accounts = [
|
|
['steady@example.com', 'steady-pass', 99, 'steady-session', 'proxy-a:1080:user:pass'],
|
|
['new-high@example.com', 'new-pass', 100, 'new-session', 'proxy-b:1080:user:pass'],
|
|
['new-high-two@example.com', 'new-pass-two', 100, 'new-session-two', 'proxy-b2:1080:user:pass'],
|
|
['already-high@example.com', 'already-pass', 100, 'already-session', 'proxy-b3:1080:user:pass'],
|
|
['steady-failure@example.com', 'failure-pass', null, 'failure-session', 'proxy-c:1080:user:pass'],
|
|
['stale-usage@example.com', 'stale-pass', null, 'stale-session', 'proxy-d:1080:user:pass'],
|
|
['below@example.com', 'below-pass', 98.9, 'below-session', 'proxy-e:1080:user:pass'],
|
|
['proxy-fallback@example.com', 'fallback-pass', 97, 'fallback-session', 'proxy-f:1080:user:pass'],
|
|
];
|
|
for (const [email, password, usage, session, proxy] of accounts) {
|
|
database.upsertAccount({ email, password, recovery_email: `${email}.recovery`, secret_key: `2fa-${email}`, status: '已充值提取' });
|
|
database.updateAccountProxyIp(email, proxy);
|
|
database.updateAccountChatgptResult(email, '已登录', JSON.stringify({ accessToken: session }));
|
|
database.updateAccountOpenAIAuth(email, '已提取 JSON', JSON.stringify({
|
|
access_token: email,
|
|
refresh_token: `refresh-${email}`,
|
|
plan_type: 'plus',
|
|
usage: usage == null
|
|
? { rate_limit: { primary_window: { used_percent: 100 } } }
|
|
: { rate_limit: { primary_window: { used_percent: usage } } },
|
|
}));
|
|
}
|
|
database.updateAccountRefundStatus('steady@example.com', '已退款');
|
|
database.updateAccountRefundProcessStatus('already-high@example.com', '已发起');
|
|
|
|
const previousRunId = database.createQuotaMonitorRun({ taskId: 'previous', triggerType: 'scheduled', threshold: 99, eligibleCount: 2 });
|
|
database.saveQuotaMonitorItems(previousRunId, [
|
|
{ email: 'steady@example.com', category: 'threshold', usagePercent: 99, isNew: true },
|
|
{ email: 'steady-failure@example.com', category: 'failure', failureReason: 'Token失效(401)', isNew: true },
|
|
]);
|
|
database.completeQuotaMonitorRun(previousRunId, {
|
|
successCount: 1,
|
|
thresholdCount: 1,
|
|
failureCount: 1,
|
|
thresholdNewCount: 1,
|
|
failureNewCount: 1,
|
|
deliveryStatus: 'sent',
|
|
});
|
|
|
|
const proxyCalls = [];
|
|
const query = async (authJson, proxy, _log, options) => {
|
|
proxyCalls.push([authJson.access_token, proxy, options.connectionMode]);
|
|
if (authJson.access_token === 'steady-failure@example.com') {
|
|
const error = new Error('access_token 无法查询套餐和用量');
|
|
error.code = 'OPENAI_ACCOUNT_QUERY_UNAUTHORIZED';
|
|
return { success: false, authJson, error };
|
|
}
|
|
if (authJson.access_token === 'stale-usage@example.com') {
|
|
return { success: true, authJson, details: { usage: null, errors: [{ source: 'usage', status: 503 }] } };
|
|
}
|
|
if (authJson.access_token === 'proxy-fallback@example.com' && options.connectionMode === 'browser_proxy') {
|
|
const error = new Error('账号代理连接失败');
|
|
error.code = 'OPENAI_PLAN_PROXY_CONNECTION_FAILED';
|
|
return { success: false, authJson, error };
|
|
}
|
|
const source = accounts.find(item => item[0] === authJson.access_token);
|
|
const usedPercent = source[2];
|
|
return {
|
|
success: true,
|
|
authJson: { ...authJson, usage: { rate_limit: { primary_window: { used_percent: usedPercent } } } },
|
|
details: { usage: { rate_limit: { primary_window: { used_percent: usedPercent } } }, errors: [] },
|
|
};
|
|
};
|
|
let sentReport = null;
|
|
let syncedRefundEntries = null;
|
|
const refundCalls = [];
|
|
let activeRefunds = 0;
|
|
let maxActiveRefunds = 0;
|
|
const result = await runQuotaMonitor({
|
|
database,
|
|
taskId: 'quota-current',
|
|
triggerType: 'manual',
|
|
threshold: 100,
|
|
concurrency: 2,
|
|
autoRefundEnabled: true,
|
|
query,
|
|
loadRefundCredentials: async () => ({ accessToken: 'owner-token', proxies: ['socks5://proxy:1080'] }),
|
|
executeRefund: async payload => {
|
|
activeRefunds += 1;
|
|
maxActiveRefunds = Math.max(maxActiveRefunds, activeRefunds);
|
|
refundCalls.push(payload.expectedEmail);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
activeRefunds -= 1;
|
|
return { success: true, cleanupOk: true };
|
|
},
|
|
sendReport: async payload => { sentReport = payload; return { success: true }; },
|
|
syncRefundStatuses: async payload => {
|
|
syncedRefundEntries = payload.entries.map(item => ({
|
|
email: item.email,
|
|
status: item.refundProcessStatus,
|
|
result: item.refundResult,
|
|
}));
|
|
return { success: true, updatedCount: payload.entries.length, skippedCount: 0 };
|
|
},
|
|
delay: async () => {},
|
|
});
|
|
|
|
assert.strictEqual(result.success, true);
|
|
assert.strictEqual(result.eligibleCount, 7);
|
|
assert.strictEqual(result.successCount, 5);
|
|
assert.strictEqual(result.thresholdCount, 3);
|
|
assert.strictEqual(result.failureCount, 2);
|
|
assert.strictEqual(result.thresholdPreviousCount, 1);
|
|
assert.strictEqual(result.failurePreviousCount, 1);
|
|
assert.strictEqual(result.thresholdNewCount, 3);
|
|
assert.strictEqual(result.failureNewCount, 1);
|
|
assert.strictEqual(result.thresholdRemovedCount, 1);
|
|
assert.strictEqual(result.failureRecoveredCount, 0);
|
|
assert.strictEqual(result.refundCandidateCount, 2);
|
|
assert.strictEqual(result.refundAttemptedCount, 2);
|
|
assert.strictEqual(result.refundSuccessCount, 2);
|
|
assert.strictEqual(result.refundFailureCount, 0);
|
|
assert.strictEqual(result.refundSkippedCount, 0);
|
|
assert.deepStrictEqual(refundCalls, ['new-high@example.com', 'new-high-two@example.com']);
|
|
assert.strictEqual(maxActiveRefunds, 1, '自动退款必须固定单路串行');
|
|
assert.deepStrictEqual(syncedRefundEntries, [
|
|
{ email: 'new-high@example.com', status: '已发起', result: '成功' },
|
|
{ email: 'new-high-two@example.com', status: '已发起', result: '成功' },
|
|
], '额度自动退款结束后应只回写本轮已处理账号的退款流程状态');
|
|
assert.strictEqual(database.getAccount('new-high@example.com').refund_process_status, '已发起');
|
|
assert.strictEqual(database.getAccount('new-high-two@example.com').refund_process_status, '已发起');
|
|
assert.strictEqual(database.getAccount('already-high@example.com').refund_process_status, '已发起');
|
|
assert.strictEqual(database.getAccount('new-high@example.com').account_refund_status, '无状态', '自动退款不得修改账号退款字段');
|
|
assert(proxyCalls.some(call => call[0] === 'new-high@example.com' && call[1] === 'proxy-b:1080:user:pass'));
|
|
assert(!proxyCalls.some(call => call[0] === 'steady@example.com'), '退款状态为已退款的账号必须跳过查询');
|
|
assert.strictEqual(proxyCalls.filter(call => call[0] === 'proxy-fallback@example.com' && call[2] === 'browser_proxy').length, 1, '明确的代理连接失败不应重复代理请求');
|
|
assert(proxyCalls.some(call => call[0] === 'proxy-fallback@example.com' && call[1] === '' && call[2] === 'direct'), '代理连接失败后必须自动直连');
|
|
|
|
assert(sentReport, '应发送飞书报告');
|
|
assert.strictEqual(sentReport.files.length, 1);
|
|
assert(sentReport.files[0].name.startsWith('额度监控结果_'));
|
|
const reportCsv = sentReport.files[0].content.toString('utf8');
|
|
assert(reportCsv.startsWith(`\uFEFF${_private.CSV_HEADERS.map(value => `"${value}"`).join(',')}`));
|
|
assert(!reportCsv.includes('steady@example.com'), '退款状态为已退款的账号不得进入报告');
|
|
assert(reportCsv.includes('"无状态","已发起","100%","new-high@example.com","new-pass"'));
|
|
assert(reportCsv.includes('"new-high@example.com.recovery","2fa-new-high@example.com","{""accessToken"":""new-session""}","查询成功","成功","","是",""'));
|
|
assert(reportCsv.includes('"查询成功","成功","","是",""'));
|
|
assert(reportCsv.includes('"无状态","未发起","","steady-failure@example.com"'));
|
|
assert(reportCsv.includes('Token失效(401)'));
|
|
assert(reportCsv.includes('"stale-usage@example.com"'));
|
|
assert(reportCsv.includes('本轮未返回额度数据'));
|
|
assert(!reportCsv.includes('proxy-fallback@example.com'), '直连成功且额度未达到阈值的账号不应进入报告');
|
|
|
|
const currentRun = database.getQuotaMonitorRun(result.runId);
|
|
assert.strictEqual(currentRun.status, 'completed');
|
|
assert.strictEqual(currentRun.delivery_status, 'sent');
|
|
const storedItems = database.getQuotaMonitorItems(result.runId);
|
|
assert.strictEqual(storedItems.length, 5);
|
|
assert.strictEqual(currentRun.auto_refund_enabled, 1);
|
|
assert.strictEqual(currentRun.refund_success_count, 2);
|
|
assert.strictEqual(Object.prototype.hasOwnProperty.call(storedItems[0], 'password'), false, '报告历史不得保存密码');
|
|
assert.strictEqual(database.getAccount('steady@example.com').status, '已充值提取');
|
|
|
|
const skip401Email = 'skip-401@example.com';
|
|
database.upsertAccount({
|
|
email: skip401Email,
|
|
password: 'skip-pass',
|
|
recovery_email: 'skip-recovery@example.com',
|
|
secret_key: 'skip-2fa',
|
|
status: '已充值提取',
|
|
});
|
|
database.updateAccountOpenAIAuth(skip401Email, '已提取 JSON', JSON.stringify({
|
|
access_token: 'skip-access',
|
|
refresh_token: 'skip-refresh',
|
|
}));
|
|
database.updateAccountOpenAIPlanStatus(skip401Email, 'Token失效(401)');
|
|
for (const account of database.getAccountsByStatuses(['已充值提取'])) {
|
|
if (account.email !== skip401Email) database.updateAccountRefundStatus(account.email, '已退款');
|
|
}
|
|
let skippedQueryCalls = 0;
|
|
let skippedReport = null;
|
|
const skip401Result = await runQuotaMonitor({
|
|
database,
|
|
taskId: 'quota-skip-401',
|
|
triggerType: 'manual',
|
|
threshold: 100,
|
|
concurrency: 2,
|
|
skip401Enabled: true,
|
|
autoRefundEnabled: false,
|
|
query: async () => {
|
|
skippedQueryCalls += 1;
|
|
throw new Error('明确 401 账号不应进入查询');
|
|
},
|
|
sendReport: async payload => { skippedReport = payload; return { success: true }; },
|
|
delay: async () => {},
|
|
});
|
|
assert.strictEqual(skippedQueryCalls, 0, '开启设置后明确 401 账号不得再次发起套餐请求');
|
|
assert.strictEqual(skip401Result.eligibleCount, 0);
|
|
assert.strictEqual(skip401Result.skipped401Count, 1);
|
|
assert.strictEqual(skip401Result.failureCount, 0, '跳过 401 不应重复计入查询失败');
|
|
assert(skippedReport.text.includes('明确 401 跳过 1'));
|
|
assert.strictEqual(database.getQuotaMonitorRun(skip401Result.runId).skipped_401_count, 1);
|
|
|
|
const feishuRequests = [];
|
|
const feishuHttpClient = {
|
|
async request(config) {
|
|
feishuRequests.push(config);
|
|
if (config.url.endsWith('/auth/v3/tenant_access_token/internal')) {
|
|
return { status: 200, data: { code: 0, tenant_access_token: 'tenant-token' } };
|
|
}
|
|
if (config.url.endsWith('/im/v1/files')) {
|
|
return { status: 200, data: { code: 0, data: { file_key: `file-${feishuRequests.length}` } } };
|
|
}
|
|
if (config.url.endsWith('/im/v1/messages')) {
|
|
return { status: 200, data: { code: 0, data: {} } };
|
|
}
|
|
throw new Error(`unexpected request ${config.url}`);
|
|
},
|
|
};
|
|
const sent = await sendFeishuQuotaReport({
|
|
database: { getSetting: (key, fallback) => ({
|
|
feishu_notify_mode: 'private',
|
|
feishu_notify_receive_id_type: 'email',
|
|
feishu_notify_receive_id: 'owner@example.com',
|
|
}[key] || fallback) },
|
|
credentialStore: { loadFeishuCredentials: async () => ({ appId: 'cli_test', appSecret: 'secret' }) },
|
|
httpClient: feishuHttpClient,
|
|
text: '监控完成',
|
|
files: [{ name: 'report.csv', content: Buffer.from('test') }],
|
|
});
|
|
assert.strictEqual(sent.fileCount, 1);
|
|
const upload = feishuRequests.find(request => request.url.endsWith('/im/v1/files'));
|
|
assert(upload && upload.data instanceof FormData);
|
|
assert.strictEqual(Object.keys(upload.headers).some(key => key.toLowerCase() === 'content-type'), false, 'multipart 边界必须由运行时生成');
|
|
const messages = feishuRequests.filter(request => request.url.endsWith('/im/v1/messages'));
|
|
assert.strictEqual(messages.length, 2);
|
|
assert.strictEqual(messages[0].data.msg_type, 'text');
|
|
assert.strictEqual(messages[1].data.msg_type, 'file');
|
|
|
|
const fakeSettings = new Map([
|
|
['quota_monitor_enabled', '1'],
|
|
['quota_monitor_interval_value', '2'],
|
|
['quota_monitor_interval_unit', 'hours'],
|
|
['quota_monitor_concurrency', '20'],
|
|
['quota_monitor_threshold', '100'],
|
|
['quota_monitor_skip_401_enabled', '1'],
|
|
['quota_monitor_auto_refund_enabled', '1'],
|
|
]);
|
|
const fakeDb = {
|
|
getSetting: (key, fallback) => fakeSettings.has(key) ? fakeSettings.get(key) : fallback,
|
|
setSetting: (key, value) => fakeSettings.set(key, value),
|
|
};
|
|
const config = schedulerPrivate.readConfig(fakeDb);
|
|
assert.strictEqual(config.enabled, true);
|
|
assert.strictEqual(config.intervalMs, 2 * 60 * 60 * 1000);
|
|
assert.strictEqual(config.concurrency, 10);
|
|
assert.strictEqual(config.threshold, 100);
|
|
assert.strictEqual(config.skip401Enabled, true);
|
|
assert.strictEqual(config.autoRefundEnabled, true);
|
|
assert(schedulerPrivate.ensureNextRun(config, fakeDb, true) instanceof Date);
|
|
|
|
const root = path.join(__dirname, '..');
|
|
const html = fs.readFileSync(path.join(root, 'public', 'index.html'), 'utf8');
|
|
const app = fs.readFileSync(path.join(root, 'public', 'app.js'), 'utf8');
|
|
const server = fs.readFileSync(path.join(root, 'server.js'), 'utf8');
|
|
assert.match(html, /额度监控与自动退款/);
|
|
assert.match(html, /@click="runQuotaMonitorNow\(\)"/);
|
|
assert.match(html, /quota_monitor_concurrency/);
|
|
assert.match(html, /quota_monitor_threshold/);
|
|
assert.match(html, /quota_monitor_skip_401_enabled/);
|
|
assert.match(html, /quota_monitor_auto_refund_enabled/);
|
|
assert.match(html, /quota_monitor_interval_unit[\s\S]*settings-select/);
|
|
assert.match(html, /feishu_notify_mode[\s\S]*settings-select/);
|
|
assert.match(app, /\/api\/quota-monitor\/run/);
|
|
assert.match(server, /\/api\/quota-monitor/);
|
|
|
|
console.log('quota_monitor.test.js: PASS');
|
|
} finally {
|
|
try { database.getDb().close(); } catch (_) {}
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
})().catch(error => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|