| |
| |
| |
| |
| |
| |
|
|
|
|
| import fs from 'fs';
|
| import path from 'path';
|
| import { post, sleep } from './http.js';
|
| import { getUserInfo, getQuota } from './chat.js';
|
| import { register } from './protocol.js';
|
| import { generateAccountInfo } from './account.js';
|
| import { createMailProvider, closeMail } from './mail.js';
|
| import config from './config.js';
|
| import { isDbEnabled, initDb, loadAccounts, saveNewAccount, updateCookies, markDead, closeDb } from './db.js';
|
|
|
| const State = {
|
| ACTIVE: 'active',
|
| IN_USE: 'in_use',
|
| NEEDS_LOGIN: 'needs_login',
|
| EXHAUSTED: 'exhausted',
|
| DEAD: 'dead',
|
| };
|
|
|
| export class AccountPool {
|
| constructor() {
|
| this.accounts = [];
|
| this._registeringCount = 0;
|
| this._maxConcurrentRegister = 10;
|
| this._timer = null;
|
| this._logs = [];
|
| this._maxLogs = 200;
|
| this._logId = 0;
|
| this._acquireLock = Promise.resolve();
|
| }
|
|
|
| |
| |
|
|
| _addLog(message, level = 'info') {
|
| this._logId++;
|
| const entry = { id: this._logId, time: new Date().toISOString(), message, level };
|
| this._logs.push(entry);
|
| if (this._logs.length > this._maxLogs) {
|
| this._logs.splice(0, this._logs.length - this._maxLogs);
|
| }
|
| console.log(`[Pool] ${message}`);
|
| }
|
|
|
| |
| |
|
|
| getLogs(since = 0) {
|
| return this._logs.filter(l => l.id > since);
|
| }
|
|
|
| |
| |
|
|
| async init() {
|
| if (isDbEnabled()) {
|
|
|
| await initDb();
|
| this._addLog('从 PostgreSQL 加载账号...');
|
| const rows = await loadAccounts();
|
| for (const row of rows) {
|
| const cookies = new Map(Object.entries(row.cookies || {}));
|
| this.accounts.push({
|
| email: row.email,
|
| password: row.password,
|
| cookies,
|
| state: State.ACTIVE,
|
| remainingQuota: null,
|
| lastUsedAt: 0,
|
| lastCheckedAt: 0,
|
| errorCount: 0,
|
| filePath: null,
|
| });
|
| }
|
| this._addLog(`加载 ${rows.length} 个账号`);
|
| } else {
|
|
|
| const dir = path.resolve(config.outputDir);
|
| if (!fs.existsSync(dir)) {
|
| fs.mkdirSync(dir, { recursive: true });
|
| }
|
|
|
| const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
| this._addLog(`加载 ${files.length} 个账号文件...`);
|
|
|
| for (const file of files) {
|
| try {
|
| const filePath = path.join(dir, file);
|
| const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
| if (!data.email || !data.password) continue;
|
|
|
| const cookies = new Map();
|
| if (data.cookies) {
|
| for (const [k, v] of Object.entries(data.cookies)) {
|
| cookies.set(k, v);
|
| }
|
| }
|
|
|
| this.accounts.push({
|
| email: data.email,
|
| password: data.password,
|
| cookies,
|
| state: State.ACTIVE,
|
| remainingQuota: null,
|
| lastUsedAt: 0,
|
| lastCheckedAt: 0,
|
| errorCount: 0,
|
| filePath,
|
| });
|
| } catch {}
|
| }
|
| }
|
|
|
|
|
| this._addLog(`验证 ${this.accounts.length} 个账号 session...`);
|
| const checks = this.accounts.map(async (acc) => {
|
| const user = await getUserInfo(acc.cookies);
|
| if (user) {
|
| acc.state = State.ACTIVE;
|
| acc.lastCheckedAt = Date.now();
|
| const quota = await getQuota(acc.cookies);
|
| if (quota && typeof quota.remaining === 'number') {
|
| acc.remainingQuota = quota.remaining;
|
| }
|
|
|
|
|
| } else {
|
| acc.state = State.NEEDS_LOGIN;
|
| }
|
| });
|
| await Promise.allSettled(checks);
|
|
|
| const active = this.accounts.filter(a => a.state === State.ACTIVE).length;
|
| const needsLogin = this.accounts.filter(a => a.state === State.NEEDS_LOGIN).length;
|
| this._addLog(`就绪: ${active} 可用, ${needsLogin} 需重新登录`);
|
|
|
|
|
| if (needsLogin > 0) {
|
| this._addLog(`并行刷新 ${needsLogin} 个账号 session...`);
|
| const needLoginAccounts = this.accounts.filter(a => a.state === State.NEEDS_LOGIN);
|
| const BATCH = 10;
|
| for (let i = 0; i < needLoginAccounts.length; i += BATCH) {
|
| const batch = needLoginAccounts.slice(i, i + BATCH);
|
| await Promise.allSettled(batch.map(acc => this._refreshSession(acc)));
|
| }
|
| const recovered = needLoginAccounts.filter(a => a.state === State.ACTIVE).length;
|
| const dead = needLoginAccounts.filter(a => a.state === State.DEAD).length;
|
| this._addLog(`刷新结果: ${recovered} 恢复, ${dead} 失效`, recovered > 0 ? 'success' : 'warn');
|
| }
|
|
|
|
|
| this._archiveDeadAccounts();
|
|
|
|
|
| this._startBackgroundTasks();
|
|
|
|
|
| const activeAfterInit = this.accounts.filter(a => a.state === State.ACTIVE).length;
|
| if (activeAfterInit < config.pool.minAvailable && config.pool.autoRegister) {
|
| const needed = config.pool.minAvailable - activeAfterInit;
|
| this._addLog(`可用账号不足 (${activeAfterInit}/${config.pool.minAvailable}),自动注册 ${needed} 个...`);
|
| await this._registerBatch(needed);
|
| }
|
| }
|
|
|
| |
| |
|
|
| acquire() {
|
|
|
| const prev = this._acquireLock;
|
| let unlock;
|
| this._acquireLock = new Promise(r => { unlock = r; });
|
| return prev.then(() => this._doAcquire()).finally(unlock);
|
| }
|
|
|
| async _doAcquire() {
|
|
|
| const stuckTimeout = 5 * 60 * 1000;
|
| for (const acc of this.accounts) {
|
| if (acc.state === State.IN_USE && Date.now() - acc.lastUsedAt > stuckTimeout) {
|
| acc.state = State.ACTIVE;
|
| this._addLog(`回收卡死账号: ${acc.email}`, 'warn');
|
| }
|
| }
|
|
|
| let candidates = this.accounts.filter(a =>
|
| a.state === State.ACTIVE && (a.remainingQuota === null || a.remainingQuota > 0)
|
| );
|
|
|
|
|
| if (candidates.length === 0) {
|
| candidates = this.accounts.filter(a => a.state === State.ACTIVE);
|
| }
|
|
|
| if (candidates.length === 0) {
|
|
|
| for (const acc of this.accounts.filter(a => a.state === State.NEEDS_LOGIN)) {
|
| const ok = await this._refreshSession(acc);
|
| if (ok) { candidates = [acc]; break; }
|
| }
|
| }
|
|
|
|
|
| if (candidates.length === 0) {
|
| const exhausted = this.accounts.filter(a => a.state === State.EXHAUSTED);
|
| for (const acc of exhausted) {
|
| const quota = await getQuota(acc.cookies);
|
| if (quota && typeof quota.remaining === 'number' && quota.remaining > 0) {
|
| acc.remainingQuota = quota.remaining;
|
| acc.state = State.ACTIVE;
|
| acc.lastCheckedAt = Date.now();
|
| this._addLog(`额度恢复: ${acc.email} (${quota.remaining})`, 'success');
|
| candidates = [acc];
|
| break;
|
| }
|
| }
|
| }
|
|
|
| if (candidates.length === 0 && config.pool.autoRegister) {
|
|
|
| const acc = await this._registerNew();
|
| if (acc) candidates = [acc];
|
| }
|
|
|
| if (candidates.length === 0) {
|
| throw new Error('No available account in pool');
|
| }
|
|
|
|
|
|
|
|
|
|
|
| candidates.sort((a, b) => {
|
| const aLow = typeof a.remainingQuota === 'number' && a.remainingQuota <= 0;
|
| const bLow = typeof b.remainingQuota === 'number' && b.remainingQuota <= 0;
|
| if (aLow !== bLow) return aLow ? 1 : -1;
|
|
|
|
|
| const timeDiff = Math.floor(a.lastUsedAt / 1000) - Math.floor(b.lastUsedAt / 1000);
|
| if (timeDiff !== 0) return timeDiff;
|
|
|
|
|
| const aQuota = typeof a.remainingQuota === 'number' ? a.remainingQuota : 50;
|
| const bQuota = typeof b.remainingQuota === 'number' ? b.remainingQuota : 50;
|
| return bQuota - aQuota;
|
| });
|
|
|
| const account = candidates[0];
|
| account.state = State.IN_USE;
|
| account.lastUsedAt = Date.now();
|
| return account;
|
| }
|
|
|
| |
| |
| |
| |
|
|
| release(account, { success = true, quotaExhausted = false, sessionExpired = false, cost = 0 } = {}) {
|
| if (sessionExpired) {
|
| account.state = State.NEEDS_LOGIN;
|
| account.errorCount++;
|
| } else if (quotaExhausted) {
|
| account.state = State.EXHAUSTED;
|
| account.remainingQuota = 0;
|
| } else if (success) {
|
| account.state = State.ACTIVE;
|
| account.errorCount = 0;
|
|
|
| if (cost > 0 && typeof account.remainingQuota === 'number') {
|
| account.remainingQuota = Math.max(0, account.remainingQuota - cost);
|
| if (account.remainingQuota <= 0) {
|
| account.state = State.EXHAUSTED;
|
| this._addLog(`额度耗尽 (本地扣减): ${account.email} (0 remaining)`, 'warn');
|
| }
|
| }
|
| } else {
|
| account.errorCount++;
|
| account.state = account.errorCount >= 5 ? State.DEAD : State.ACTIVE;
|
| }
|
|
|
|
|
| const active = this.accounts.filter(a => a.state === State.ACTIVE).length;
|
| if (active < config.pool.minAvailable && config.pool.autoRegister) {
|
| const needed = config.pool.minAvailable - active;
|
| this._registerBatch(needed).catch(() => {});
|
| }
|
| }
|
|
|
| |
| |
|
|
| getStatus() {
|
| const counts = {};
|
| for (const s of Object.values(State)) counts[s] = 0;
|
| for (const a of this.accounts) counts[a.state]++;
|
|
|
| return {
|
| total: this.accounts.length,
|
| ...counts,
|
| accounts: this.accounts.map(a => ({
|
| email: a.email,
|
| state: a.state,
|
| remaining: a.remainingQuota,
|
| lastUsed: a.lastUsedAt ? new Date(a.lastUsedAt).toISOString() : null,
|
| })),
|
| };
|
| }
|
|
|
| |
| |
|
|
| async _refreshSession(account) {
|
| try {
|
| const resp = await post(`${config.siteBase}/api/login`, {
|
| email: account.email,
|
| password: account.password,
|
| }, {
|
| headers: {
|
| 'Origin': config.siteBase,
|
| 'Referer': `${config.siteBase}/app/auth/sign-in`,
|
| 'Accept-Language': 'en',
|
| },
|
| });
|
|
|
| if (resp.ok) {
|
| account.cookies = resp.cookies;
|
| account.state = State.ACTIVE;
|
| account.errorCount = 0;
|
| this._saveCookies(account);
|
| return true;
|
| }
|
| account.errorCount++;
|
| if (resp.status === 401) {
|
| account.state = State.DEAD;
|
| } else if (resp.status >= 500) {
|
| account.state = State.NEEDS_LOGIN;
|
| } else {
|
| account.state = account.errorCount >= 3 ? State.DEAD : State.NEEDS_LOGIN;
|
| }
|
| return false;
|
| } catch (e) {
|
| account.errorCount++;
|
| account.state = account.errorCount >= 5 ? State.DEAD : State.NEEDS_LOGIN;
|
| return false;
|
| }
|
| }
|
|
|
| |
| |
| |
| |
|
|
| async _registerBatch(count, provider) {
|
| const tasks = [];
|
| for (let i = 0; i < count; i++) {
|
| tasks.push(this._registerNew(provider));
|
|
|
| if (i < count - 1) await sleep(500);
|
| }
|
| const results = await Promise.allSettled(tasks);
|
| const success = results.filter(r => r.status === 'fulfilled' && r.value).length;
|
| this._addLog(`批量注册完成: ${success}/${count} 成功`, success > 0 ? 'success' : 'error');
|
| return success;
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| manualRegister(count = 5, provider, concurrency) {
|
| count = Math.min(Math.max(1, count), 50);
|
| if (concurrency) this._maxConcurrentRegister = Math.min(Math.max(1, concurrency), 20);
|
| const available = this._maxConcurrentRegister - this._registeringCount;
|
| const actual = Math.min(count, available);
|
| if (actual <= 0) {
|
| return { registering: this._registeringCount, queued: 0 };
|
| }
|
| this._registerBatch(actual, provider).catch(() => {});
|
| return { registering: this._registeringCount + actual, queued: actual };
|
| }
|
|
|
| |
| |
| |
|
|
| async _registerNew(overrideProvider) {
|
| if (this._registeringCount >= this._maxConcurrentRegister) return null;
|
| this._registeringCount++;
|
|
|
| try {
|
| this._addLog('开始注册新账号...');
|
|
|
| let providerName = overrideProvider || (config.mailProvider === 'manual' ? 'mailtm' : config.mailProvider);
|
| let providerOpts = config[providerName] || {};
|
|
|
| if (providerName === 'moemail' && !providerOpts.apiUrl) providerName = 'mailtm';
|
| if (providerName === 'duckmail' && !providerOpts.apiKey) providerName = 'mailtm';
|
| if (providerName === 'catchall' && !providerOpts.domain) providerName = 'mailtm';
|
| if (providerName === 'custom' && !providerOpts.createUrl) providerName = 'mailtm';
|
| providerOpts = config[providerName] || {};
|
| const mailProvider = createMailProvider(providerName, providerOpts);
|
|
|
|
|
| let lastError;
|
| for (let attempt = 1; attempt <= 2; attempt++) {
|
| try {
|
| this._addLog(`[${attempt}/2] 创建临时邮箱 (${providerName})...`);
|
| await mailProvider.createInbox();
|
| this._addLog(`[${attempt}/2] 邮箱就绪: ${mailProvider.address}`);
|
| const accountInfo = generateAccountInfo();
|
| accountInfo.email = mailProvider.address;
|
|
|
| this._addLog(`[${attempt}/2] 提交注册请求...`);
|
| const result = await register(accountInfo, mailProvider);
|
| await mailProvider.cleanup();
|
|
|
| if (result.success) {
|
| const cookies = result.cookies || new Map();
|
| const entry = {
|
| email: accountInfo.email,
|
| password: accountInfo.password,
|
| cookies,
|
| state: State.ACTIVE,
|
| remainingQuota: null,
|
| lastUsedAt: 0,
|
| lastCheckedAt: Date.now(),
|
| errorCount: 0,
|
| filePath: isDbEnabled() ? null : path.resolve(config.outputDir, `chataibot-${accountInfo.email.replace('@', '_at_')}.json`),
|
| };
|
|
|
|
|
| if (isDbEnabled()) {
|
| await saveNewAccount(entry.email, entry.password, Object.fromEntries(cookies));
|
| } else {
|
| const saveData = {
|
| site: 'chataibot.pro',
|
| email: entry.email,
|
| password: entry.password,
|
| cookies: Object.fromEntries(cookies),
|
| registeredAt: new Date().toISOString(),
|
| };
|
| fs.writeFileSync(entry.filePath, JSON.stringify(saveData, null, 2));
|
| }
|
|
|
| this.accounts.push(entry);
|
| this._addLog(`新账号就绪: ${entry.email}`, 'success');
|
| return entry;
|
| }
|
| lastError = result.error;
|
| this._addLog(`[${attempt}/2] 注册失败: ${result.error}`, 'error');
|
| } catch (e) {
|
| lastError = e.message;
|
| this._addLog(`[${attempt}/2] 注册出错: ${e.message}`, 'error');
|
| }
|
|
|
| if (attempt < 2) {
|
| await sleep(3000);
|
|
|
| try { await mailProvider.cleanup(); } catch {}
|
| }
|
| }
|
|
|
| this._addLog(`注册最终失败: ${lastError}`, 'error');
|
| return null;
|
| } finally {
|
| this._registeringCount--;
|
| }
|
| }
|
|
|
| |
| |
|
|
| _archiveDeadAccounts() {
|
| const deadAccounts = this.accounts.filter(a => a.state === State.DEAD);
|
| if (deadAccounts.length === 0) return;
|
|
|
| if (isDbEnabled()) {
|
| for (const acc of deadAccounts) {
|
| markDead(acc.email).catch(() => {});
|
| this._addLog(`归档 dead 账号: ${acc.email}`);
|
| }
|
| } else {
|
| const deadDir = path.resolve(config.outputDir, 'dead');
|
| if (!fs.existsSync(deadDir)) fs.mkdirSync(deadDir, { recursive: true });
|
|
|
| for (const acc of deadAccounts) {
|
| if (!acc.filePath || !fs.existsSync(acc.filePath)) continue;
|
| try {
|
| const dest = path.join(deadDir, path.basename(acc.filePath));
|
| fs.renameSync(acc.filePath, dest);
|
| this._addLog(`归档 dead 账号: ${acc.email}`);
|
| } catch {}
|
| }
|
| }
|
|
|
|
|
| this.accounts = this.accounts.filter(a => a.state !== State.DEAD);
|
| }
|
|
|
| |
| |
|
|
| _saveCookies(account) {
|
| if (isDbEnabled()) {
|
| updateCookies(account.email, Object.fromEntries(account.cookies)).catch(() => {});
|
| return;
|
| }
|
| if (!account.filePath) return;
|
| try {
|
| const data = JSON.parse(fs.readFileSync(account.filePath, 'utf8'));
|
| data.cookies = Object.fromEntries(account.cookies);
|
| fs.writeFileSync(account.filePath, JSON.stringify(data, null, 2));
|
| } catch {}
|
| }
|
|
|
| |
| |
|
|
| _startBackgroundTasks() {
|
| const interval = config.pool.checkInterval || 300000;
|
| this._timer = setInterval(async () => {
|
|
|
| for (const acc of this.accounts.filter(a => a.state === State.ACTIVE || a.state === State.EXHAUSTED)) {
|
| if (Date.now() - acc.lastCheckedAt < interval) continue;
|
| const quota = await getQuota(acc.cookies);
|
| if (quota && typeof quota.remaining === 'number') {
|
| acc.remainingQuota = quota.remaining;
|
| acc.lastCheckedAt = Date.now();
|
| if (acc.state === State.EXHAUSTED && quota.remaining > 0) {
|
| acc.state = State.ACTIVE;
|
| this._addLog(`额度恢复: ${acc.email} (${quota.remaining})`, 'success');
|
| }
|
| }
|
| }
|
|
|
|
|
| const needLogin = this.accounts.filter(a => a.state === State.NEEDS_LOGIN);
|
| if (needLogin.length > 0) {
|
| await Promise.allSettled(needLogin.map(acc => this._refreshSession(acc)));
|
| }
|
|
|
|
|
| this._archiveDeadAccounts();
|
|
|
|
|
| const active = this.accounts.filter(a => a.state === State.ACTIVE).length;
|
| if (active < config.pool.minAvailable && config.pool.autoRegister) {
|
| const needed = config.pool.minAvailable - active;
|
| await this._registerBatch(needed);
|
| }
|
| }, interval);
|
|
|
|
|
| if (this._timer.unref) this._timer.unref();
|
| }
|
|
|
| destroy() {
|
| if (this._timer) clearInterval(this._timer);
|
| if (isDbEnabled()) closeDb().catch(() => {});
|
| }
|
| }
|
|
|