diff --git a/account-manager.js b/account-manager.js new file mode 100644 index 0000000..75b207f --- /dev/null +++ b/account-manager.js @@ -0,0 +1,418 @@ +import fs from 'fs'; +import path from 'path'; +import fetch from 'node-fetch'; +import { logInfo, logDebug, logError } from './logger.js'; +import { getNextProxyAgent } from './proxy-manager.js'; + +/** + * Account Manager - 管理多个 OAuth 账号的选择、刷新和统计 + * + * 设计思路: + * 1. 支持多个 refresh_token 账号池 + * 2. 基于健康度加权轮询选择账号 + * 3. 自动刷新 access_token + * 4. 401/402 时自动禁用异常账号 + */ + +const REFRESH_URL = 'https://api.workos.com/user_management/authenticate'; +const REFRESH_INTERVAL_HOURS = 6; +const CLIENT_ID = 'client_01HNM792M5G5G1A2THWPXKFMXB'; + +class AccountManager { + constructor() { + this.accounts = []; + this.settings = { + algorithm: 'weighted', // 'weighted' or 'simple' + refresh_interval_hours: REFRESH_INTERVAL_HOURS, + disable_on_401: true, + disable_on_402: true + }; + this.simpleIndex = 0; + this.configPath = null; + this.endpointStats = {}; // 端点统计 + } + + /** + * 从配置文件加载账号 + */ + async loadAccounts(configPath = null) { + // 优先检查环境变量中的多账号配置 + const envAccounts = process.env.OAUTH_ACCOUNTS; + if (envAccounts) { + try { + const parsed = JSON.parse(envAccounts); + this.accounts = this._normalizeAccounts(parsed); + logInfo(`AccountManager: 从环境变量加载了 ${this.accounts.length} 个账号`); + return true; + } catch (e) { + logError('AccountManager: 解析 OAUTH_ACCOUNTS 环境变量失败', e); + } + } + + // 检查配置文件 + const possiblePaths = [ + configPath, + path.join(process.cwd(), 'accounts.json'), + path.join(process.cwd(), 'oauth_accounts.json') + ].filter(Boolean); + + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + try { + const content = fs.readFileSync(p, 'utf-8'); + const data = JSON.parse(content); + + this.accounts = this._normalizeAccounts(data.accounts || data); + if (data.settings) { + this.settings = { ...this.settings, ...data.settings }; + } + this.configPath = p; + + logInfo(`AccountManager: 从 ${p} 加载了 ${this.accounts.length} 个账号`); + return true; + } catch (e) { + logError(`AccountManager: 读取配置文件 ${p} 失败`, e); + } + } + } + + logInfo('AccountManager: 未找到多账号配置,将回退到单账号模式'); + return false; + } + + /** + * 标准化账号数据结构 + */ + _normalizeAccounts(accounts) { + if (!Array.isArray(accounts)) { + accounts = [accounts]; + } + + return accounts.map((acc, index) => ({ + id: acc.id || `account_${index + 1}`, + name: acc.name || `账号${index + 1}`, + refresh_token: acc.refresh_token, + access_token: acc.access_token || null, + last_refresh: acc.last_refresh ? new Date(acc.last_refresh).getTime() : null, + status: acc.status || 'active', // 'active', 'disabled', 'rate_limited' + disable_reason: acc.disable_reason || null, + stats: acc.stats || { success: 0, fail: 0 } + })); + } + + /** + * 获取活跃账号列表 + */ + getActiveAccounts() { + return this.accounts.filter(acc => acc.status === 'active'); + } + + /** + * 选择一个账号(基于配置的算法) + */ + selectAccount() { + const activeAccounts = this.getActiveAccounts(); + + if (activeAccounts.length === 0) { + throw new Error('AccountManager: 没有可用账号 - 所有账号已被禁用'); + } + + if (activeAccounts.length === 1) { + return activeAccounts[0]; + } + + if (this.settings.algorithm === 'simple') { + return this._simpleSelect(activeAccounts); + } else { + return this._weightedSelect(activeAccounts); + } + } + + /** + * 简单轮询算法 + */ + _simpleSelect(accounts) { + const account = accounts[this.simpleIndex % accounts.length]; + this.simpleIndex = (this.simpleIndex + 1) % accounts.length; + logDebug(`AccountManager: 简单轮询选择账号 ${account.id}`); + return account; + } + + /** + * 基于健康度的加权选择算法 + */ + _weightedSelect(accounts) { + const weights = accounts.map(acc => { + const total = acc.stats.success + acc.stats.fail; + if (total === 0) { + return 1; // 新账号默认权重 + } + // 成功率 + 0.1(确保失败账号也有机会恢复) + return (acc.stats.success / total) + 0.1; + }); + + const totalWeight = weights.reduce((sum, w) => sum + w, 0); + let random = Math.random() * totalWeight; + + for (let i = 0; i < weights.length; i++) { + random -= weights[i]; + if (random <= 0) { + const acc = accounts[i]; + logDebug(`AccountManager: 加权选择账号 ${acc.id}, 健康度: ${((weights[i] - 0.1) * 100).toFixed(1)}%`); + return acc; + } + } + + return accounts[accounts.length - 1]; + } + + /** + * 获取账号的 access_token(自动刷新) + */ + async getAccessToken(account) { + // 检查是否需要刷新 + const needsRefresh = !account.access_token || this._shouldRefresh(account); + + if (needsRefresh) { + await this._refreshToken(account); + } + + return account.access_token; + } + + /** + * 检查是否需要刷新 token + */ + _shouldRefresh(account) { + if (!account.last_refresh) { + return true; + } + const hoursSinceRefresh = (Date.now() - account.last_refresh) / (1000 * 60 * 60); + return hoursSinceRefresh >= this.settings.refresh_interval_hours; + } + + /** + * 刷新账号的 access_token + */ + async _refreshToken(account) { + logInfo(`AccountManager: 刷新账号 ${account.id} 的 token...`); + + try { + const formData = new URLSearchParams(); + formData.append('grant_type', 'refresh_token'); + formData.append('refresh_token', account.refresh_token); + formData.append('client_id', CLIENT_ID); + + const proxyAgentInfo = getNextProxyAgent(REFRESH_URL); + const fetchOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: formData.toString() + }; + + if (proxyAgentInfo?.agent) { + fetchOptions.agent = proxyAgentInfo.agent; + } + + const response = await fetch(REFRESH_URL, fetchOptions); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`刷新失败: ${response.status} ${errorText}`); + } + + const data = await response.json(); + + // 更新账号信息 + account.access_token = data.access_token; + account.refresh_token = data.refresh_token; // 更新 refresh_token + account.last_refresh = Date.now(); + + // 记录用户信息 + if (data.user) { + account.email = data.user.email; + logInfo(`AccountManager: 账号 ${account.id} 刷新成功 - ${data.user.email}`); + } + + // 保存配置 + this._saveConfig(); + + return data.access_token; + + } catch (error) { + logError(`AccountManager: 账号 ${account.id} 刷新失败`, error); + + // 刷新失败可能是 refresh_token 失效,禁用账号 + if (error.message.includes('401') || error.message.includes('invalid')) { + this.disableAccount(account.id, 'refresh_token_invalid'); + } + + throw error; + } + } + + /** + * 记录请求结果 + */ + recordResult(accountId, endpoint, success, statusCode) { + const account = this.accounts.find(acc => acc.id === accountId); + if (!account) return; + + if (success) { + account.stats.success++; + } else { + account.stats.fail++; + + // 检查是否需要禁用账号 + if (statusCode === 401 && this.settings.disable_on_401) { + this.disableAccount(accountId, '401_unauthorized'); + } else if (statusCode === 402 && this.settings.disable_on_402) { + this.disableAccount(accountId, '402_payment_required'); + } + } + + // 更新端点统计 + if (!this.endpointStats[endpoint]) { + this.endpointStats[endpoint] = { success: 0, fail: 0 }; + } + if (success) { + this.endpointStats[endpoint].success++; + } else { + this.endpointStats[endpoint].fail++; + } + } + + /** + * 禁用账号 + */ + disableAccount(accountId, reason) { + const account = this.accounts.find(acc => acc.id === accountId); + if (!account) return; + + account.status = 'disabled'; + account.disable_reason = reason; + account.disabled_at = new Date().toISOString(); + + logError(`AccountManager: 账号 ${accountId} 已禁用,原因: ${reason}`); + + // 保存配置 + this._saveConfig(); + + // 记录到废弃账号文件 + this._logDeprecatedAccount(account); + } + + /** + * 记录废弃账号到文件 + */ + _logDeprecatedAccount(account) { + try { + const logPath = path.join(process.cwd(), 'deprecated_accounts.txt'); + const logEntry = `[${new Date().toISOString()}] ${account.id} (${account.email || 'unknown'}) - ${account.disable_reason}\n`; + fs.appendFileSync(logPath, logEntry, 'utf-8'); + } catch (e) { + logError('AccountManager: 记录废弃账号失败', e); + } + } + + /** + * 保存配置到文件 + */ + _saveConfig() { + if (!this.configPath) return; + + try { + const data = { + accounts: this.accounts.map(acc => ({ + id: acc.id, + name: acc.name, + refresh_token: acc.refresh_token, + access_token: acc.access_token, + last_refresh: acc.last_refresh ? new Date(acc.last_refresh).toISOString() : null, + status: acc.status, + disable_reason: acc.disable_reason, + email: acc.email, + stats: acc.stats + })), + settings: this.settings, + last_updated: new Date().toISOString() + }; + + fs.writeFileSync(this.configPath, JSON.stringify(data, null, 2), 'utf-8'); + logDebug(`AccountManager: 配置已保存到 ${this.configPath}`); + } catch (e) { + logError('AccountManager: 保存配置失败', e); + } + } + + /** + * 获取状态信息(用于 /status 页面) + */ + getStatus() { + return { + total_accounts: this.accounts.length, + active_accounts: this.getActiveAccounts().length, + algorithm: this.settings.algorithm, + accounts: this.accounts.map(acc => ({ + id: acc.id, + name: acc.name, + email: acc.email || 'unknown', + status: acc.status, + disable_reason: acc.disable_reason, + stats: acc.stats, + health: this._calculateHealth(acc), + last_refresh: acc.last_refresh ? new Date(acc.last_refresh).toISOString() : null + })), + endpoint_stats: this.endpointStats + }; + } + + /** + * 计算账号健康度 + */ + _calculateHealth(account) { + const total = account.stats.success + account.stats.fail; + if (total === 0) return 100; + return Math.round((account.stats.success / total) * 100); + } + + /** + * 检查是否有多账号配置 + */ + hasMultipleAccounts() { + return this.accounts.length > 1; + } + + /** + * 获取账号数量 + */ + getAccountCount() { + return this.accounts.length; + } +} + +// 单例实例 +let accountManagerInstance = null; + +/** + * 获取 AccountManager 实例 + */ +export function getAccountManager() { + if (!accountManagerInstance) { + accountManagerInstance = new AccountManager(); + } + return accountManagerInstance; +} + +/** + * 初始化 AccountManager + */ +export async function initializeAccountManager() { + const manager = getAccountManager(); + const loaded = await manager.loadAccounts(); + return loaded; +} + +export default AccountManager; diff --git a/accounts.json.example b/accounts.json.example new file mode 100644 index 0000000..c1d564a --- /dev/null +++ b/accounts.json.example @@ -0,0 +1,22 @@ +{ + "accounts": [ + { + "id": "account_1", + "name": "主账号", + "refresh_token": "YOUR_REFRESH_TOKEN_1", + "status": "active" + }, + { + "id": "account_2", + "name": "备用账号", + "refresh_token": "YOUR_REFRESH_TOKEN_2", + "status": "active" + } + ], + "settings": { + "algorithm": "weighted", + "refresh_interval_hours": 6, + "disable_on_401": true, + "disable_on_402": true + } +} diff --git a/add-account.js b/add-account.js new file mode 100644 index 0000000..ecace47 --- /dev/null +++ b/add-account.js @@ -0,0 +1,356 @@ +#!/usr/bin/env node +/** + * add-account.js - OAuth 账号授权辅助工具 + * + * 用途:简化多账号 OAuth refresh_token 的获取流程 + * + * 使用方式: + * node add-account.js # 交互式添加账号 + * node add-account.js --loop # 连续添加多个账号 + * + * 流程: + * 1. 向 WorkOS 请求设备授权码 + * 2. 自动打开浏览器(或显示链接) + * 3. 用户在浏览器完成登录 + * 4. 脚本轮询获取 access_token + refresh_token + * 5. 自动保存到 accounts.json + */ + +import fs from 'fs'; +import path from 'path'; +import fetch from 'node-fetch'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// WorkOS 配置(从 Factory CLI 逆向获取) +const WORKOS_CLIENT_ID = 'client_01HNM792M5G5G1A2THWPXKFMXB'; +const WORKOS_AUTHORIZE_URL = 'https://api.workos.com/user_management/authorize/device'; +const WORKOS_TOKEN_URL = 'https://api.workos.com/user_management/authenticate'; + +// 配置 +const ACCOUNTS_FILE = path.join(process.cwd(), 'accounts.json'); +const POLL_INTERVAL_MS = 2000; // 轮询间隔 2 秒 +const POLL_TIMEOUT_MS = 300000; // 轮询超时 5 分钟 + +/** + * 请求设备授权码 + */ +async function requestDeviceCode() { + console.log('\n🔐 正在请求设备授权码...'); + + const response = await fetch(WORKOS_AUTHORIZE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + client_id: WORKOS_CLIENT_ID + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`请求设备授权码失败: ${response.status} ${error}`); + } + + const data = await response.json(); + return data; +} + +/** + * 轮询获取 token + */ +async function pollForToken(deviceCode) { + const startTime = Date.now(); + + while (Date.now() - startTime < POLL_TIMEOUT_MS) { + try { + const formData = new URLSearchParams(); + formData.append('grant_type', 'urn:ietf:params:oauth:grant-type:device_code'); + formData.append('device_code', deviceCode); + formData.append('client_id', WORKOS_CLIENT_ID); + + const response = await fetch(WORKOS_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: formData.toString() + }); + + if (response.ok) { + const data = await response.json(); + return data; + } + + const errorData = await response.json().catch(() => ({})); + + if (errorData.error === 'authorization_pending') { + // 用户还未授权,继续轮询 + process.stdout.write('.'); + } else if (errorData.error === 'slow_down') { + // 需要降低轮询速度 + await sleep(POLL_INTERVAL_MS * 2); + continue; + } else if (errorData.error === 'expired_token') { + throw new Error('授权码已过期,请重新开始'); + } else if (errorData.error === 'access_denied') { + throw new Error('授权被拒绝'); + } + + } catch (err) { + if (err.message.includes('授权')) { + throw err; + } + // 网络错误,继续轮询 + } + + await sleep(POLL_INTERVAL_MS); + } + + throw new Error('授权超时(5分钟),请重新开始'); +} + +/** + * 保存账号到配置文件 + */ +function saveAccount(tokenData) { + let accounts = { accounts: [], settings: {} }; + + // 读取现有配置 + if (fs.existsSync(ACCOUNTS_FILE)) { + try { + accounts = JSON.parse(fs.readFileSync(ACCOUNTS_FILE, 'utf-8')); + } catch (e) { + console.warn('⚠️ 无法读取现有配置,将创建新文件'); + } + } + + // 确保 accounts 数组存在 + if (!accounts.accounts) { + accounts.accounts = []; + } + + // 提取用户信息 + const user = tokenData.user || {}; + const email = user.email || 'unknown'; + + // 检查是否已存在此账号 + const existingIndex = accounts.accounts.findIndex(acc => acc.email === email); + if (existingIndex >= 0) { + // 更新现有账号 + accounts.accounts[existingIndex] = { + ...accounts.accounts[existingIndex], + refresh_token: tokenData.refresh_token, + access_token: tokenData.access_token, + last_refresh: new Date().toISOString(), + status: 'active' + }; + console.log(`\n✅ 账号已更新: ${email}`); + } else { + // 添加新账号 + const newAccount = { + id: `account_${accounts.accounts.length + 1}`, + name: user.first_name ? `${user.first_name} ${user.last_name}` : email, + email: email, + refresh_token: tokenData.refresh_token, + access_token: tokenData.access_token, + last_refresh: new Date().toISOString(), + status: 'active', + stats: { success: 0, fail: 0 } + }; + accounts.accounts.push(newAccount); + console.log(`\n✅ 新账号已添加: ${email}`); + } + + // 设置默认配置 + if (!accounts.settings || Object.keys(accounts.settings).length === 0) { + accounts.settings = { + algorithm: 'weighted', + refresh_interval_hours: 6, + disable_on_401: true, + disable_on_402: true + }; + } + + // 保存配置 + accounts.last_updated = new Date().toISOString(); + fs.writeFileSync(ACCOUNTS_FILE, JSON.stringify(accounts, null, 2), 'utf-8'); + console.log(`📁 配置已保存到: ${ACCOUNTS_FILE}`); + + return accounts; +} + +/** + * 尝试自动打开浏览器 + */ +async function openBrowser(url) { + const { exec } = await import('child_process'); + const platform = process.platform; + + return new Promise((resolve) => { + let command; + if (platform === 'darwin') { + command = `open "${url}"`; + } else if (platform === 'win32') { + command = `start "" "${url}"`; + } else { + command = `xdg-open "${url}"`; + } + + exec(command, (error) => { + if (error) { + console.log('\n⚠️ 无法自动打开浏览器,请手动打开以下链接'); + resolve(false); + } else { + resolve(true); + } + }); + }); +} + +/** + * 睡眠函数 + */ +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * 显示当前账号状态 + */ +function showAccountStatus() { + if (!fs.existsSync(ACCOUNTS_FILE)) { + console.log('\n📋 当前没有已配置的账号'); + return; + } + + try { + const accounts = JSON.parse(fs.readFileSync(ACCOUNTS_FILE, 'utf-8')); + console.log('\n📋 当前已配置的账号:'); + console.log('─'.repeat(50)); + + if (!accounts.accounts || accounts.accounts.length === 0) { + console.log(' (无)'); + } else { + accounts.accounts.forEach((acc, i) => { + const status = acc.status === 'active' ? '🟢' : '🔴'; + console.log(` ${i + 1}. ${status} ${acc.email || acc.name} [${acc.id}]`); + }); + } + console.log('─'.repeat(50)); + } catch (e) { + console.log('\n⚠️ 无法读取账号配置'); + } +} + +/** + * 读取用户输入 + */ +function readline(question) { + return new Promise((resolve) => { + const rl = require('readline').createInterface({ + input: process.stdin, + output: process.stdout + }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +/** + * 主流程 + */ +async function addAccount() { + console.log('═'.repeat(50)); + console.log(' 🔐 OAuth 账号授权工具'); + console.log('═'.repeat(50)); + + try { + // 1. 请求设备授权码 + const deviceAuth = await requestDeviceCode(); + + console.log('\n📱 请在浏览器中完成授权:'); + console.log('─'.repeat(50)); + console.log(` 验证码: ${deviceAuth.user_code}`); + console.log(` 链接: ${deviceAuth.verification_uri_complete || deviceAuth.verification_uri}`); + console.log('─'.repeat(50)); + + // 2. 尝试自动打开浏览器 + const browserOpened = await openBrowser( + deviceAuth.verification_uri_complete || deviceAuth.verification_uri + ); + + if (browserOpened) { + console.log('\n🌐 已自动打开浏览器,请完成登录...'); + } else { + console.log('\n👆 请复制上面的链接到浏览器打开'); + } + + // 3. 轮询获取 token + console.log('\n⏳ 等待授权中'); + const tokenData = await pollForToken(deviceAuth.device_code); + + // 4. 保存账号 + const accounts = saveAccount(tokenData); + + console.log(`\n🎉 授权成功!当前共有 ${accounts.accounts.length} 个账号`); + + return true; + + } catch (error) { + console.error(`\n❌ 错误: ${error.message}`); + return false; + } +} + +/** + * 入口函数 + */ +async function main() { + const args = process.argv.slice(2); + const loopMode = args.includes('--loop') || args.includes('-l'); + + // 显示当前状态 + showAccountStatus(); + + do { + const success = await addAccount(); + + if (loopMode && success) { + console.log('\n━'.repeat(50)); + console.log('按 Enter 继续添加下一个账号,或输入 q 退出'); + + // 等待用户输入 + const readlineModule = await import('readline'); + const rl = readlineModule.createInterface({ + input: process.stdin, + output: process.stdout + }); + + const answer = await new Promise(resolve => { + rl.question('> ', (ans) => { + rl.close(); + resolve(ans); + }); + }); + + if (answer.toLowerCase() === 'q') { + break; + } + } else if (!loopMode) { + break; + } + } while (loopMode); + + // 显示最终状态 + showAccountStatus(); + console.log('\n✨ 完成!'); +} + +// 运行 +main().catch(console.error); diff --git a/auth.js b/auth.js index fed0395..c9c4d93 100644 --- a/auth.js +++ b/auth.js @@ -4,15 +4,18 @@ import os from 'os'; import fetch from 'node-fetch'; import { logDebug, logError, logInfo } from './logger.js'; import { getNextProxyAgent } from './proxy-manager.js'; +import { getAccountManager, initializeAccountManager } from './account-manager.js'; // State management for API key and refresh let currentApiKey = null; let currentRefreshToken = null; let lastRefreshTime = null; let clientId = null; -let authSource = null; // 'env' or 'file' or 'factory_key' or 'client' +let authSource = null; // 'env' or 'file' or 'factory_key' or 'client' or 'multi_account' let authFilePath = null; let factoryApiKey = null; // From FACTORY_API_KEY environment variable +let multiAccountMode = false; // 是否启用多账号模式 +let lastSelectedAccountId = null; // 记录最后选择的账号ID,用于结果回调 const REFRESH_URL = 'https://api.workos.com/user_management/authenticate'; const REFRESH_INTERVAL_HOURS = 6; // Refresh every 6 hours @@ -27,10 +30,10 @@ const TOKEN_VALID_HOURS = 8; // Token valid for 8 hours function generateULID() { // Crockford's Base32 alphabet (no I, L, O, U to avoid confusion) const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; - + // Get timestamp in milliseconds const timestamp = Date.now(); - + // Encode timestamp to 10 characters let time = ''; let ts = timestamp; @@ -39,14 +42,14 @@ function generateULID() { time = ENCODING[mod] + time; ts = Math.floor(ts / 32); } - + // Generate 16 random characters let randomPart = ''; for (let i = 0; i < 16; i++) { const rand = Math.floor(Math.random() * 32); randomPart += ENCODING[rand]; } - + return time + randomPart; } @@ -60,10 +63,31 @@ function generateClientId() { /** * Load auth configuration with priority system - * Priority: FACTORY_API_KEY > refresh token mechanism > client authorization + * Priority: accounts.json (multi-account) > FACTORY_API_KEY > refresh token mechanism > client authorization */ function loadAuthConfig() { - // 1. Check FACTORY_API_KEY environment variable (highest priority) + // 0. Check accounts.json for multi-account mode (highest priority) + const accountsPath = path.join(process.cwd(), 'accounts.json'); + try { + if (fs.existsSync(accountsPath)) { + const accountsContent = fs.readFileSync(accountsPath, 'utf-8'); + const accountsData = JSON.parse(accountsContent); + + if (accountsData.accounts && accountsData.accounts.length > 0) { + const activeAccounts = accountsData.accounts.filter(acc => acc.status === 'active'); + if (activeAccounts.length > 0) { + logInfo(`Found accounts.json with ${activeAccounts.length} active account(s), enabling multi-account mode`); + multiAccountMode = true; + authSource = 'multi_account'; + return { type: 'multi_account', value: accountsPath }; + } + } + } + } catch (error) { + logError('Error reading accounts.json', error); + } + + // 1. Check FACTORY_API_KEY environment variable const factoryKey = process.env.FACTORY_API_KEY; if (factoryKey && factoryKey.trim() !== '') { logInfo('Using fixed API key from FACTORY_API_KEY environment variable'); @@ -84,22 +108,22 @@ function loadAuthConfig() { // 3. Check ~/.factory/auth.json const homeDir = os.homedir(); const factoryAuthPath = path.join(homeDir, '.factory', 'auth.json'); - + try { if (fs.existsSync(factoryAuthPath)) { const authContent = fs.readFileSync(factoryAuthPath, 'utf-8'); const authData = JSON.parse(authContent); - + if (authData.refresh_token && authData.refresh_token.trim() !== '') { logInfo('Using refresh token from ~/.factory/auth.json'); authSource = 'file'; authFilePath = factoryAuthPath; - + // Also load access_token if available if (authData.access_token) { currentApiKey = authData.access_token.trim(); } - + return { type: 'refresh', value: authData.refresh_token.trim() }; } } @@ -156,7 +180,7 @@ async function refreshApiKey() { } const data = await response.json(); - + // Update tokens currentApiKey = data.access_token; currentRefreshToken = data.refresh_token; @@ -240,7 +264,18 @@ export async function initializeAuth() { try { const authConfig = loadAuthConfig(); - if (authConfig.type === 'factory_key') { + if (authConfig.type === 'multi_account') { + // Multi-account mode - initialize AccountManager + const loaded = await initializeAccountManager(); + if (loaded) { + const manager = getAccountManager(); + logInfo(`Auth system initialized with multi-account mode (${manager.getAccountCount()} accounts)`); + } else { + logError('Failed to initialize AccountManager, falling back to client authorization'); + authSource = 'client'; + multiAccountMode = false; + } + } else if (authConfig.type === 'factory_key') { // Using fixed FACTORY_API_KEY, no refresh needed logInfo('Auth system initialized with fixed API key'); } else if (authConfig.type === 'refresh') { @@ -275,11 +310,21 @@ export async function initializeAuth() { * @param {string} clientAuthorization - Authorization header from client request (optional) */ export async function getApiKey(clientAuthorization = null) { + // Priority 0: Multi-account mode + if (authSource === 'multi_account' && multiAccountMode) { + const manager = getAccountManager(); + const account = manager.selectAccount(); + lastSelectedAccountId = account.id; + + const accessToken = await manager.getAccessToken(account); + return `Bearer ${accessToken}`; + } + // Priority 1: FACTORY_API_KEY environment variable if (authSource === 'factory_key' && factoryApiKey) { return `Bearer ${factoryApiKey}`; } - + // Priority 2: Refresh token mechanism if (authSource === 'env' || authSource === 'file') { // Check if we need to refresh @@ -294,13 +339,45 @@ export async function getApiKey(clientAuthorization = null) { return `Bearer ${currentApiKey}`; } - + // Priority 3: Client authorization header if (clientAuthorization) { logDebug('Using client authorization header'); return clientAuthorization; } - + // No authorization available - throw new Error('No authorization available. Please configure FACTORY_API_KEY, refresh token, or provide client authorization.'); + throw new Error('No authorization available. Please configure accounts.json, FACTORY_API_KEY, refresh token, or provide client authorization.'); } + +/** + * Record authentication result for multi-account mode + * @param {string} endpoint - The API endpoint called + * @param {boolean} success - Whether the request was successful + * @param {number} statusCode - HTTP status code + */ +export function recordAuthResult(endpoint, success, statusCode) { + if (authSource === 'multi_account' && multiAccountMode && lastSelectedAccountId) { + const manager = getAccountManager(); + manager.recordResult(lastSelectedAccountId, endpoint, success, statusCode); + } +} + +/** + * Get auth status for /status endpoint + */ +export function getAuthStatus() { + if (authSource === 'multi_account' && multiAccountMode) { + const manager = getAccountManager(); + return { + mode: 'multi_account', + ...manager.getStatus() + }; + } + + return { + mode: authSource, + multiAccountMode: false + }; +} + diff --git a/sync-accounts.sh b/sync-accounts.sh new file mode 100755 index 0000000..4f3c212 --- /dev/null +++ b/sync-accounts.sh @@ -0,0 +1,190 @@ +#!/bin/bash +# +# sync-accounts.sh - 账号配置增量同步脚本 +# +# 用途:从本地机器增量同步 accounts.json 到远程无头服务器 +# +# 使用方式: +# ./sync-accounts.sh # 使用默认配置 +# ./sync-accounts.sh user@server.com # 指定服务器 +# ./sync-accounts.sh user@server.com /path/to/droid2api # 指定路径 +# +# 前置要求: +# 1. 配置 SSH 免密登录:ssh-copy-id user@server.com +# 2. 远程服务器安装 jq:apt install jq 或 yum install jq +# + +set -e + +# ========== 配置区域 ========== +# 可以修改这些默认值,或通过命令行参数覆盖 +DEFAULT_SERVER="user@your-server.com" +DEFAULT_REMOTE_PATH="/opt/droid2api" +LOCAL_FILE="accounts.json" + +# 部署方式: pm2 | docker | docker-compose | none +DEPLOY_TYPE="${DEPLOY_TYPE:-docker-compose}" +DOCKER_SERVICE_NAME="${DOCKER_SERVICE_NAME:-droid2api}" +PM2_APP_NAME="${PM2_APP_NAME:-droid2api}" +# ============================== + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[OK]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# 解析命令行参数 +SERVER="${1:-$DEFAULT_SERVER}" +REMOTE_PATH="${2:-$DEFAULT_REMOTE_PATH}" +REMOTE_FILE="$REMOTE_PATH/accounts.json" + +# 显示帮助 +if [[ "$1" == "-h" || "$1" == "--help" ]]; then + echo "用法: $0 [user@server] [remote_path]" + echo "" + echo "环境变量:" + echo " DEPLOY_TYPE 部署方式: pm2 | docker | docker-compose | none (默认: docker-compose)" + echo " DOCKER_SERVICE_NAME Docker 服务名 (默认: droid2api)" + echo " PM2_APP_NAME PM2 应用名 (默认: droid2api)" + echo "" + echo "示例:" + echo " $0 root@192.168.1.100 /opt/droid2api" + echo " DEPLOY_TYPE=pm2 $0 user@server.com" + exit 0 +fi + +echo "" +echo "═══════════════════════════════════════════════════════════════" +echo " 📦 账号配置增量同步工具" +echo "═══════════════════════════════════════════════════════════════" +echo "" + +# 检查本地文件是否存在 +if [[ ! -f "$LOCAL_FILE" ]]; then + log_error "本地文件 $LOCAL_FILE 不存在" + log_info "请先运行 'node add-account.js' 添加账号" + exit 1 +fi + +# 显示连接信息 +log_info "目标服务器: $SERVER" +log_info "远程路径: $REMOTE_FILE" +log_info "部署方式: $DEPLOY_TYPE" +echo "" + +# 创建临时目录 +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +# Step 1: 下载远程现有配置 +log_info "正在获取远程现有配置..." +if ssh "$SERVER" "test -f $REMOTE_FILE" 2>/dev/null; then + scp -q "$SERVER:$REMOTE_FILE" "$TEMP_DIR/remote_accounts.json" + REMOTE_COUNT=$(jq '.accounts | length' "$TEMP_DIR/remote_accounts.json" 2>/dev/null || echo 0) + log_success "远程现有 $REMOTE_COUNT 个账号" +else + log_warn "远程配置不存在,将创建新文件" + echo '{"accounts":[],"settings":{}}' > "$TEMP_DIR/remote_accounts.json" + REMOTE_COUNT=0 +fi + +# Step 2: 显示本地账号数量 +LOCAL_COUNT=$(jq '.accounts | length' "$LOCAL_FILE") +log_info "本地现有 $LOCAL_COUNT 个账号" + +# Step 3: 合并配置(按 email 去重,本地优先) +log_info "正在合并配置..." +jq -s ' + # 使用第一个文件(远程)的 accounts 作为基础 + (.[0].accounts // []) as $remote | + # 使用第二个文件(本地)的 accounts + (.[1].accounts // []) as $local | + # 获取远程账号的 email 列表 + ($remote | map(.email)) as $remote_emails | + # 过滤出本地新增的账号(email 不在远程列表中的) + ($local | map(select(.email as $e | $remote_emails | index($e) | not))) as $new_local | + # 更新远程已存在的账号(用本地数据覆盖同 email 的远程账号) + ($remote | map( + . as $r | + ($local | map(select(.email == $r.email)) | .[0]) as $l | + if $l then $l else $r end + )) as $updated_remote | + # 合并:更新后的远程 + 本地新增 + { + accounts: ($updated_remote + $new_local), + settings: (.[1].settings // .[0].settings // {}), + last_updated: (now | strftime("%Y-%m-%dT%H:%M:%SZ")) + } +' "$TEMP_DIR/remote_accounts.json" "$LOCAL_FILE" > "$TEMP_DIR/merged_accounts.json" + +MERGED_COUNT=$(jq '.accounts | length' "$TEMP_DIR/merged_accounts.json") +NEW_COUNT=$((MERGED_COUNT - REMOTE_COUNT)) + +if [[ $NEW_COUNT -gt 0 ]]; then + log_success "新增 $NEW_COUNT 个账号,合并后共 $MERGED_COUNT 个账号" +else + log_info "无新增账号,共 $MERGED_COUNT 个账号" +fi + +# Step 4: 显示账号列表 +echo "" +log_info "合并后的账号列表:" +echo "──────────────────────────────────────────────────────────────" +jq -r '.accounts[] | " \(.status == "active" | if . then "🟢" else "🔴" end) \(.email // .name) [\(.id)]"' "$TEMP_DIR/merged_accounts.json" +echo "──────────────────────────────────────────────────────────────" +echo "" + +# Step 5: 确认上传 +read -p "确认上传到服务器? (y/N) " -n 1 -r +echo "" +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_warn "已取消" + exit 0 +fi + +# Step 6: 上传合并后的配置 +log_info "正在上传配置..." +scp -q "$TEMP_DIR/merged_accounts.json" "$SERVER:$REMOTE_FILE" +log_success "配置已上传" + +# Step 7: 重启服务(根据部署方式) +echo "" +case "$DEPLOY_TYPE" in + pm2) + log_info "正在重启 PM2 服务..." + ssh "$SERVER" "pm2 restart $PM2_APP_NAME" 2>/dev/null && \ + log_success "PM2 服务已重启" || \ + log_warn "PM2 重启失败,请手动重启" + ;; + docker) + log_info "正在重启 Docker 容器..." + ssh "$SERVER" "docker restart $DOCKER_SERVICE_NAME" 2>/dev/null && \ + log_success "Docker 容器已重启" || \ + log_warn "Docker 重启失败,请手动重启" + ;; + docker-compose) + log_info "正在重启 Docker Compose 服务..." + ssh "$SERVER" "cd $REMOTE_PATH && docker-compose restart" 2>/dev/null && \ + log_success "Docker Compose 服务已重启" || \ + log_warn "Docker Compose 重启失败,请手动重启" + ;; + none) + log_warn "已跳过服务重启,请手动重启服务以生效" + ;; + *) + log_warn "未知的部署方式: $DEPLOY_TYPE,请手动重启服务" + ;; +esac + +echo "" +echo "═══════════════════════════════════════════════════════════════" +log_success "同步完成!" +echo "═══════════════════════════════════════════════════════════════" +echo ""