Merge branch 'main' of git.let5see.xyz:let5see/droid2api
This commit is contained in:
418
account-manager.js
Normal file
418
account-manager.js
Normal file
@@ -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;
|
||||||
22
accounts.json.example
Normal file
22
accounts.json.example
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
356
add-account.js
Normal file
356
add-account.js
Normal file
@@ -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);
|
||||||
111
auth.js
111
auth.js
@@ -4,15 +4,18 @@ import os from 'os';
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { logDebug, logError, logInfo } from './logger.js';
|
import { logDebug, logError, logInfo } from './logger.js';
|
||||||
import { getNextProxyAgent } from './proxy-manager.js';
|
import { getNextProxyAgent } from './proxy-manager.js';
|
||||||
|
import { getAccountManager, initializeAccountManager } from './account-manager.js';
|
||||||
|
|
||||||
// State management for API key and refresh
|
// State management for API key and refresh
|
||||||
let currentApiKey = null;
|
let currentApiKey = null;
|
||||||
let currentRefreshToken = null;
|
let currentRefreshToken = null;
|
||||||
let lastRefreshTime = null;
|
let lastRefreshTime = null;
|
||||||
let clientId = 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 authFilePath = null;
|
||||||
let factoryApiKey = null; // From FACTORY_API_KEY environment variable
|
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_URL = 'https://api.workos.com/user_management/authenticate';
|
||||||
const REFRESH_INTERVAL_HOURS = 6; // Refresh every 6 hours
|
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() {
|
function generateULID() {
|
||||||
// Crockford's Base32 alphabet (no I, L, O, U to avoid confusion)
|
// Crockford's Base32 alphabet (no I, L, O, U to avoid confusion)
|
||||||
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||||
|
|
||||||
// Get timestamp in milliseconds
|
// Get timestamp in milliseconds
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
||||||
// Encode timestamp to 10 characters
|
// Encode timestamp to 10 characters
|
||||||
let time = '';
|
let time = '';
|
||||||
let ts = timestamp;
|
let ts = timestamp;
|
||||||
@@ -39,14 +42,14 @@ function generateULID() {
|
|||||||
time = ENCODING[mod] + time;
|
time = ENCODING[mod] + time;
|
||||||
ts = Math.floor(ts / 32);
|
ts = Math.floor(ts / 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 16 random characters
|
// Generate 16 random characters
|
||||||
let randomPart = '';
|
let randomPart = '';
|
||||||
for (let i = 0; i < 16; i++) {
|
for (let i = 0; i < 16; i++) {
|
||||||
const rand = Math.floor(Math.random() * 32);
|
const rand = Math.floor(Math.random() * 32);
|
||||||
randomPart += ENCODING[rand];
|
randomPart += ENCODING[rand];
|
||||||
}
|
}
|
||||||
|
|
||||||
return time + randomPart;
|
return time + randomPart;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,10 +63,31 @@ function generateClientId() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load auth configuration with priority system
|
* 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() {
|
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;
|
const factoryKey = process.env.FACTORY_API_KEY;
|
||||||
if (factoryKey && factoryKey.trim() !== '') {
|
if (factoryKey && factoryKey.trim() !== '') {
|
||||||
logInfo('Using fixed API key from FACTORY_API_KEY environment variable');
|
logInfo('Using fixed API key from FACTORY_API_KEY environment variable');
|
||||||
@@ -84,22 +108,22 @@ function loadAuthConfig() {
|
|||||||
// 3. Check ~/.factory/auth.json
|
// 3. Check ~/.factory/auth.json
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
const factoryAuthPath = path.join(homeDir, '.factory', 'auth.json');
|
const factoryAuthPath = path.join(homeDir, '.factory', 'auth.json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(factoryAuthPath)) {
|
if (fs.existsSync(factoryAuthPath)) {
|
||||||
const authContent = fs.readFileSync(factoryAuthPath, 'utf-8');
|
const authContent = fs.readFileSync(factoryAuthPath, 'utf-8');
|
||||||
const authData = JSON.parse(authContent);
|
const authData = JSON.parse(authContent);
|
||||||
|
|
||||||
if (authData.refresh_token && authData.refresh_token.trim() !== '') {
|
if (authData.refresh_token && authData.refresh_token.trim() !== '') {
|
||||||
logInfo('Using refresh token from ~/.factory/auth.json');
|
logInfo('Using refresh token from ~/.factory/auth.json');
|
||||||
authSource = 'file';
|
authSource = 'file';
|
||||||
authFilePath = factoryAuthPath;
|
authFilePath = factoryAuthPath;
|
||||||
|
|
||||||
// Also load access_token if available
|
// Also load access_token if available
|
||||||
if (authData.access_token) {
|
if (authData.access_token) {
|
||||||
currentApiKey = authData.access_token.trim();
|
currentApiKey = authData.access_token.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: 'refresh', value: authData.refresh_token.trim() };
|
return { type: 'refresh', value: authData.refresh_token.trim() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +180,7 @@ async function refreshApiKey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Update tokens
|
// Update tokens
|
||||||
currentApiKey = data.access_token;
|
currentApiKey = data.access_token;
|
||||||
currentRefreshToken = data.refresh_token;
|
currentRefreshToken = data.refresh_token;
|
||||||
@@ -240,7 +264,18 @@ export async function initializeAuth() {
|
|||||||
try {
|
try {
|
||||||
const authConfig = loadAuthConfig();
|
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
|
// Using fixed FACTORY_API_KEY, no refresh needed
|
||||||
logInfo('Auth system initialized with fixed API key');
|
logInfo('Auth system initialized with fixed API key');
|
||||||
} else if (authConfig.type === 'refresh') {
|
} else if (authConfig.type === 'refresh') {
|
||||||
@@ -275,11 +310,21 @@ export async function initializeAuth() {
|
|||||||
* @param {string} clientAuthorization - Authorization header from client request (optional)
|
* @param {string} clientAuthorization - Authorization header from client request (optional)
|
||||||
*/
|
*/
|
||||||
export async function getApiKey(clientAuthorization = null) {
|
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
|
// Priority 1: FACTORY_API_KEY environment variable
|
||||||
if (authSource === 'factory_key' && factoryApiKey) {
|
if (authSource === 'factory_key' && factoryApiKey) {
|
||||||
return `Bearer ${factoryApiKey}`;
|
return `Bearer ${factoryApiKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Refresh token mechanism
|
// Priority 2: Refresh token mechanism
|
||||||
if (authSource === 'env' || authSource === 'file') {
|
if (authSource === 'env' || authSource === 'file') {
|
||||||
// Check if we need to refresh
|
// Check if we need to refresh
|
||||||
@@ -294,13 +339,45 @@ export async function getApiKey(clientAuthorization = null) {
|
|||||||
|
|
||||||
return `Bearer ${currentApiKey}`;
|
return `Bearer ${currentApiKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: Client authorization header
|
// Priority 3: Client authorization header
|
||||||
if (clientAuthorization) {
|
if (clientAuthorization) {
|
||||||
logDebug('Using client authorization header');
|
logDebug('Using client authorization header');
|
||||||
return clientAuthorization;
|
return clientAuthorization;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No authorization available
|
// 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
190
sync-accounts.sh
Executable file
190
sync-accounts.sh
Executable file
@@ -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 ""
|
||||||
Reference in New Issue
Block a user