| const { v4: uuidv4 } = require('uuid') |
| const crypto = require('crypto') |
| const axios = require('axios') |
| const redis = require('../models/redis') |
| const config = require('../../config/config') |
| const logger = require('../utils/logger') |
| const { maskToken } = require('../utils/tokenMask') |
| const ProxyHelper = require('../utils/proxyHelper') |
| const LRUCache = require('../utils/lruCache') |
|
|
| |
| |
| |
| |
| |
| |
| class DroidAccountService { |
| constructor() { |
| |
| this.oauthTokenUrl = 'https://api.workos.com/user_management/authenticate' |
| this.factoryApiBaseUrl = 'https://app.factory.ai/api/llm' |
|
|
| this.workosClientId = 'client_01HNM792M5G5G1A2THWPXKFMXB' |
|
|
| |
| this.refreshIntervalHours = 6 |
| this.tokenValidHours = 8 |
|
|
| |
| this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' |
| this.ENCRYPTION_SALT = 'droid-account-salt' |
|
|
| |
| this._encryptionKeyCache = null |
|
|
| |
| this._decryptCache = new LRUCache(500) |
|
|
| |
| setInterval( |
| () => { |
| this._decryptCache.cleanup() |
| logger.info('🧹 Droid decrypt cache cleanup completed', this._decryptCache.getStats()) |
| }, |
| 10 * 60 * 1000 |
| ) |
|
|
| this.supportedEndpointTypes = new Set(['anthropic', 'openai']) |
| } |
|
|
| _sanitizeEndpointType(endpointType) { |
| if (!endpointType) { |
| return 'anthropic' |
| } |
|
|
| const normalized = String(endpointType).toLowerCase() |
| if (normalized === 'openai' || normalized === 'common') { |
| return 'openai' |
| } |
|
|
| if (this.supportedEndpointTypes.has(normalized)) { |
| return normalized |
| } |
|
|
| return 'anthropic' |
| } |
|
|
| _isTruthy(value) { |
| if (value === undefined || value === null) { |
| return false |
| } |
| if (typeof value === 'boolean') { |
| return value |
| } |
| if (typeof value === 'string') { |
| const normalized = value.trim().toLowerCase() |
| if (normalized === 'true') { |
| return true |
| } |
| if (normalized === 'false') { |
| return false |
| } |
| return normalized.length > 0 && normalized !== '0' && normalized !== 'no' |
| } |
| return Boolean(value) |
| } |
|
|
| |
| |
| |
| _generateEncryptionKey() { |
| if (!this._encryptionKeyCache) { |
| this._encryptionKeyCache = crypto.scryptSync( |
| config.security.encryptionKey, |
| this.ENCRYPTION_SALT, |
| 32 |
| ) |
| logger.info('🔑 Droid encryption key derived and cached for performance optimization') |
| } |
| return this._encryptionKeyCache |
| } |
|
|
| |
| |
| |
| _encryptSensitiveData(text) { |
| if (!text) { |
| return '' |
| } |
|
|
| const key = this._generateEncryptionKey() |
| const iv = crypto.randomBytes(16) |
| const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) |
|
|
| let encrypted = cipher.update(text, 'utf8', 'hex') |
| encrypted += cipher.final('hex') |
|
|
| return `${iv.toString('hex')}:${encrypted}` |
| } |
|
|
| |
| |
| |
| _decryptSensitiveData(encryptedText) { |
| if (!encryptedText) { |
| return '' |
| } |
|
|
| |
| const cacheKey = crypto.createHash('sha256').update(encryptedText).digest('hex') |
| const cached = this._decryptCache.get(cacheKey) |
| if (cached !== undefined) { |
| return cached |
| } |
|
|
| try { |
| const key = this._generateEncryptionKey() |
| const parts = encryptedText.split(':') |
| const iv = Buffer.from(parts[0], 'hex') |
| const encrypted = parts[1] |
|
|
| const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) |
| let decrypted = decipher.update(encrypted, 'hex', 'utf8') |
| decrypted += decipher.final('utf8') |
|
|
| |
| this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) |
|
|
| return decrypted |
| } catch (error) { |
| logger.error('❌ Failed to decrypt Droid data:', error) |
| return '' |
| } |
| } |
|
|
| _parseApiKeyEntries(rawEntries) { |
| if (!rawEntries) { |
| return [] |
| } |
|
|
| if (Array.isArray(rawEntries)) { |
| return rawEntries |
| } |
|
|
| if (typeof rawEntries === 'string') { |
| try { |
| const parsed = JSON.parse(rawEntries) |
| return Array.isArray(parsed) ? parsed : [] |
| } catch (error) { |
| logger.warn('⚠️ Failed to parse Droid API Key entries:', error.message) |
| return [] |
| } |
| } |
|
|
| return [] |
| } |
|
|
| _buildApiKeyEntries(apiKeys, existingEntries = [], clearExisting = false) { |
| const now = new Date().toISOString() |
| const normalizedExisting = Array.isArray(existingEntries) ? existingEntries : [] |
|
|
| const entries = clearExisting |
| ? [] |
| : normalizedExisting |
| .filter((entry) => entry && entry.id && entry.encryptedKey) |
| .map((entry) => ({ |
| ...entry, |
| status: entry.status || 'active' |
| })) |
|
|
| const hashSet = new Set(entries.map((entry) => entry.hash).filter(Boolean)) |
|
|
| if (!Array.isArray(apiKeys) || apiKeys.length === 0) { |
| return entries |
| } |
|
|
| for (const rawKey of apiKeys) { |
| if (typeof rawKey !== 'string') { |
| continue |
| } |
|
|
| const trimmed = rawKey.trim() |
| if (!trimmed) { |
| continue |
| } |
|
|
| const hash = crypto.createHash('sha256').update(trimmed).digest('hex') |
| if (hashSet.has(hash)) { |
| continue |
| } |
|
|
| hashSet.add(hash) |
|
|
| entries.push({ |
| id: uuidv4(), |
| hash, |
| encryptedKey: this._encryptSensitiveData(trimmed), |
| createdAt: now, |
| lastUsedAt: '', |
| usageCount: '0', |
| status: 'active', |
| errorMessage: '' |
| }) |
| } |
|
|
| return entries |
| } |
|
|
| _maskApiKeyEntries(entries) { |
| if (!Array.isArray(entries)) { |
| return [] |
| } |
|
|
| return entries.map((entry) => ({ |
| id: entry.id, |
| createdAt: entry.createdAt || '', |
| lastUsedAt: entry.lastUsedAt || '', |
| usageCount: entry.usageCount || '0', |
| status: entry.status || 'active', |
| errorMessage: entry.errorMessage || '' |
| })) |
| } |
|
|
| _decryptApiKeyEntry(entry) { |
| if (!entry || !entry.encryptedKey) { |
| return null |
| } |
|
|
| const apiKey = this._decryptSensitiveData(entry.encryptedKey) |
| if (!apiKey) { |
| return null |
| } |
|
|
| const usageCountNumber = Number(entry.usageCount) |
|
|
| return { |
| id: entry.id, |
| key: apiKey, |
| hash: entry.hash || '', |
| createdAt: entry.createdAt || '', |
| lastUsedAt: entry.lastUsedAt || '', |
| usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0, |
| status: entry.status || 'active', |
| errorMessage: entry.errorMessage || '' |
| } |
| } |
|
|
| async getDecryptedApiKeyEntries(accountId) { |
| if (!accountId) { |
| return [] |
| } |
|
|
| const accountData = await redis.getDroidAccount(accountId) |
| if (!accountData) { |
| return [] |
| } |
|
|
| const entries = this._parseApiKeyEntries(accountData.apiKeys) |
| return entries |
| .map((entry) => this._decryptApiKeyEntry(entry)) |
| .filter((entry) => entry && entry.key) |
| } |
|
|
| async touchApiKeyUsage(accountId, keyId) { |
| if (!accountId || !keyId) { |
| return |
| } |
|
|
| try { |
| const accountData = await redis.getDroidAccount(accountId) |
| if (!accountData) { |
| return |
| } |
|
|
| const entries = this._parseApiKeyEntries(accountData.apiKeys) |
| const index = entries.findIndex((entry) => entry.id === keyId) |
|
|
| if (index === -1) { |
| return |
| } |
|
|
| const updatedEntry = { ...entries[index] } |
| updatedEntry.lastUsedAt = new Date().toISOString() |
| const usageCount = Number(updatedEntry.usageCount) |
| updatedEntry.usageCount = String( |
| Number.isFinite(usageCount) && usageCount >= 0 ? usageCount + 1 : 1 |
| ) |
|
|
| entries[index] = updatedEntry |
|
|
| accountData.apiKeys = JSON.stringify(entries) |
| accountData.apiKeyCount = String(entries.length) |
|
|
| await redis.setDroidAccount(accountId, accountData) |
| } catch (error) { |
| logger.warn(`⚠️ Failed to update API key usage for Droid account ${accountId}:`, error) |
| } |
| } |
|
|
| |
| |
| |
| async removeApiKeyEntry(accountId, keyId) { |
| if (!accountId || !keyId) { |
| return { removed: false, remainingCount: 0 } |
| } |
|
|
| try { |
| const accountData = await redis.getDroidAccount(accountId) |
| if (!accountData) { |
| return { removed: false, remainingCount: 0 } |
| } |
|
|
| const entries = this._parseApiKeyEntries(accountData.apiKeys) |
| if (!entries || entries.length === 0) { |
| return { removed: false, remainingCount: 0 } |
| } |
|
|
| const filtered = entries.filter((entry) => entry && entry.id !== keyId) |
| if (filtered.length === entries.length) { |
| return { removed: false, remainingCount: entries.length } |
| } |
|
|
| accountData.apiKeys = filtered.length ? JSON.stringify(filtered) : '' |
| accountData.apiKeyCount = String(filtered.length) |
|
|
| await redis.setDroidAccount(accountId, accountData) |
|
|
| logger.warn( |
| `🚫 已删除 Droid API Key ${keyId}(Account: ${accountId}),剩余 ${filtered.length}` |
| ) |
|
|
| return { removed: true, remainingCount: filtered.length } |
| } catch (error) { |
| logger.error(`❌ 删除 Droid API Key 失败:${keyId}(Account: ${accountId})`, error) |
| return { removed: false, remainingCount: 0, error } |
| } |
| } |
|
|
| |
| |
| |
| async markApiKeyAsError(accountId, keyId, errorMessage = '') { |
| if (!accountId || !keyId) { |
| return { marked: false, error: '参数无效' } |
| } |
|
|
| try { |
| const accountData = await redis.getDroidAccount(accountId) |
| if (!accountData) { |
| return { marked: false, error: '账户不存在' } |
| } |
|
|
| const entries = this._parseApiKeyEntries(accountData.apiKeys) |
| if (!entries || entries.length === 0) { |
| return { marked: false, error: '无API Key条目' } |
| } |
|
|
| let marked = false |
| const updatedEntries = entries.map((entry) => { |
| if (entry && entry.id === keyId) { |
| marked = true |
| return { |
| ...entry, |
| status: 'error', |
| errorMessage: errorMessage || 'API Key异常' |
| } |
| } |
| return entry |
| }) |
|
|
| if (!marked) { |
| return { marked: false, error: '未找到指定的API Key' } |
| } |
|
|
| accountData.apiKeys = JSON.stringify(updatedEntries) |
| await redis.setDroidAccount(accountId, accountData) |
|
|
| logger.warn( |
| `⚠️ 已标记 Droid API Key ${keyId} 为异常状态(Account: ${accountId}):${errorMessage}` |
| ) |
|
|
| return { marked: true } |
| } catch (error) { |
| logger.error(`❌ 标记 Droid API Key 异常状态失败:${keyId}(Account: ${accountId})`, error) |
| return { marked: false, error: error.message } |
| } |
| } |
|
|
| |
| |
| |
| async _refreshTokensWithWorkOS(refreshToken, proxyConfig = null, organizationId = null) { |
| if (!refreshToken || typeof refreshToken !== 'string') { |
| throw new Error('Refresh Token 无效') |
| } |
|
|
| const formData = new URLSearchParams() |
| formData.append('grant_type', 'refresh_token') |
| formData.append('refresh_token', refreshToken) |
| formData.append('client_id', this.workosClientId) |
| if (organizationId) { |
| formData.append('organization_id', organizationId) |
| } |
|
|
| const requestOptions = { |
| method: 'POST', |
| url: this.oauthTokenUrl, |
| headers: { |
| 'Content-Type': 'application/x-www-form-urlencoded' |
| }, |
| data: formData.toString(), |
| timeout: 30000 |
| } |
|
|
| if (proxyConfig) { |
| const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) |
| if (proxyAgent) { |
| requestOptions.httpAgent = proxyAgent |
| requestOptions.httpsAgent = proxyAgent |
| requestOptions.proxy = false |
| logger.info( |
| `🌐 使用代理验证 Droid Refresh Token: ${ProxyHelper.getProxyDescription(proxyConfig)}` |
| ) |
| } |
| } |
|
|
| const response = await axios(requestOptions) |
| if (!response.data || !response.data.access_token) { |
| throw new Error('WorkOS OAuth 返回数据无效') |
| } |
|
|
| const { |
| access_token, |
| refresh_token, |
| user, |
| organization_id, |
| expires_in, |
| token_type, |
| authentication_method |
| } = response.data |
|
|
| let expiresAt = response.data.expires_at || '' |
| if (!expiresAt) { |
| const expiresInSeconds = |
| typeof expires_in === 'number' && Number.isFinite(expires_in) |
| ? expires_in |
| : this.tokenValidHours * 3600 |
| expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString() |
| } |
|
|
| return { |
| accessToken: access_token, |
| refreshToken: refresh_token || refreshToken, |
| expiresAt, |
| expiresIn: typeof expires_in === 'number' && Number.isFinite(expires_in) ? expires_in : null, |
| user: user || null, |
| organizationId: organization_id || '', |
| tokenType: token_type || 'Bearer', |
| authenticationMethod: authentication_method || '' |
| } |
| } |
|
|
| |
| |
| |
| async _fetchFactoryOrgIds(accessToken, proxyConfig = null) { |
| if (!accessToken) { |
| return [] |
| } |
|
|
| const requestOptions = { |
| method: 'GET', |
| url: 'https://app.factory.ai/api/cli/org', |
| headers: { |
| Authorization: `Bearer ${accessToken}`, |
| 'Content-Type': 'application/json', |
| Accept: 'application/json', |
| 'x-factory-client': 'cli', |
| 'User-Agent': this.userAgent |
| }, |
| timeout: 15000 |
| } |
|
|
| if (proxyConfig) { |
| const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) |
| if (proxyAgent) { |
| requestOptions.httpAgent = proxyAgent |
| requestOptions.httpsAgent = proxyAgent |
| requestOptions.proxy = false |
| } |
| } |
|
|
| try { |
| const response = await axios(requestOptions) |
| const data = response.data || {} |
| if (Array.isArray(data.workosOrgIds) && data.workosOrgIds.length > 0) { |
| return data.workosOrgIds |
| } |
| logger.warn('⚠️ 未从 Factory CLI 接口获取到 workosOrgIds') |
| return [] |
| } catch (error) { |
| logger.warn('⚠️ 获取 Factory 组织信息失败:', error.message) |
| return [] |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async createAccount(options = {}) { |
| const { |
| name = 'Unnamed Droid Account', |
| description = '', |
| refreshToken = '', |
| accessToken = '', |
| expiresAt = '', |
| proxy = null, |
| isActive = true, |
| accountType = 'shared', |
| platform = 'droid', |
| priority = 50, |
| schedulable = true, |
| endpointType = 'anthropic', |
| organizationId = '', |
| ownerEmail = '', |
| ownerName = '', |
| userId = '', |
| tokenType = 'Bearer', |
| authenticationMethod = '', |
| expiresIn = null, |
| apiKeys = [] |
| } = options |
|
|
| const accountId = uuidv4() |
|
|
| const normalizedEndpointType = this._sanitizeEndpointType(endpointType) |
|
|
| let normalizedRefreshToken = refreshToken |
| let normalizedAccessToken = accessToken |
| let normalizedExpiresAt = expiresAt || '' |
| let normalizedExpiresIn = expiresIn |
| let normalizedOrganizationId = organizationId || '' |
| let normalizedOwnerEmail = ownerEmail || '' |
| let normalizedOwnerName = ownerName || '' |
| let normalizedOwnerDisplayName = ownerName || ownerEmail || '' |
| let normalizedUserId = userId || '' |
| let normalizedTokenType = tokenType || 'Bearer' |
| let normalizedAuthenticationMethod = authenticationMethod || '' |
| let lastRefreshAt = accessToken ? new Date().toISOString() : '' |
| let status = accessToken ? 'active' : 'created' |
|
|
| const apiKeyEntries = this._buildApiKeyEntries(apiKeys) |
| const hasApiKeys = apiKeyEntries.length > 0 |
|
|
| if (hasApiKeys) { |
| normalizedAuthenticationMethod = 'api_key' |
| normalizedAccessToken = '' |
| normalizedRefreshToken = '' |
| normalizedExpiresAt = '' |
| normalizedExpiresIn = null |
| lastRefreshAt = '' |
| status = 'active' |
| } |
|
|
| const normalizedAuthMethod = |
| typeof normalizedAuthenticationMethod === 'string' |
| ? normalizedAuthenticationMethod.toLowerCase().trim() |
| : '' |
|
|
| const isApiKeyProvision = normalizedAuthMethod === 'api_key' |
| const isManualProvision = normalizedAuthMethod === 'manual' |
|
|
| const provisioningMode = isApiKeyProvision ? 'api_key' : isManualProvision ? 'manual' : 'oauth' |
|
|
| if (isApiKeyProvision) { |
| logger.info( |
| `🔍 [Droid api_key] 初始密钥 - AccountName: ${name}, KeyCount: ${apiKeyEntries.length}` |
| ) |
| } else { |
| logger.info( |
| `🔍 [Droid ${provisioningMode}] 初始令牌 - AccountName: ${name}, AccessToken: ${ |
| normalizedAccessToken || '[empty]' |
| }, RefreshToken: ${normalizedRefreshToken || '[empty]'}` |
| ) |
| } |
|
|
| let proxyConfig = null |
| if (proxy && typeof proxy === 'object') { |
| proxyConfig = proxy |
| } else if (typeof proxy === 'string' && proxy.trim()) { |
| try { |
| proxyConfig = JSON.parse(proxy) |
| } catch (error) { |
| logger.warn('⚠️ Droid 代理配置解析失败,已忽略:', error.message) |
| proxyConfig = null |
| } |
| } |
|
|
| if (!isApiKeyProvision && normalizedRefreshToken && isManualProvision) { |
| try { |
| const refreshed = await this._refreshTokensWithWorkOS(normalizedRefreshToken, proxyConfig) |
|
|
| logger.info( |
| `🔍 [Droid manual] 刷新后令牌 - AccountName: ${name}, AccessToken: ${refreshed.accessToken || '[empty]'}, RefreshToken: ${refreshed.refreshToken || '[empty]'}, ExpiresAt: ${refreshed.expiresAt || '[empty]'}, ExpiresIn: ${ |
| refreshed.expiresIn !== null && refreshed.expiresIn !== undefined |
| ? refreshed.expiresIn |
| : '[empty]' |
| }` |
| ) |
|
|
| normalizedAccessToken = refreshed.accessToken |
| normalizedRefreshToken = refreshed.refreshToken |
| normalizedExpiresAt = refreshed.expiresAt || normalizedExpiresAt |
| normalizedTokenType = refreshed.tokenType || normalizedTokenType |
| normalizedAuthenticationMethod = |
| refreshed.authenticationMethod || normalizedAuthenticationMethod |
| if (refreshed.expiresIn !== null) { |
| normalizedExpiresIn = refreshed.expiresIn |
| } |
| if (refreshed.organizationId) { |
| normalizedOrganizationId = refreshed.organizationId |
| } |
|
|
| if (refreshed.user) { |
| const userInfo = refreshed.user |
| if (typeof userInfo.email === 'string' && userInfo.email.trim()) { |
| normalizedOwnerEmail = userInfo.email.trim() |
| } |
| const nameParts = [] |
| if (typeof userInfo.first_name === 'string' && userInfo.first_name.trim()) { |
| nameParts.push(userInfo.first_name.trim()) |
| } |
| if (typeof userInfo.last_name === 'string' && userInfo.last_name.trim()) { |
| nameParts.push(userInfo.last_name.trim()) |
| } |
| const derivedName = |
| nameParts.join(' ').trim() || |
| (typeof userInfo.name === 'string' ? userInfo.name.trim() : '') || |
| (typeof userInfo.display_name === 'string' ? userInfo.display_name.trim() : '') |
|
|
| if (derivedName) { |
| normalizedOwnerName = derivedName |
| normalizedOwnerDisplayName = derivedName |
| } else if (normalizedOwnerEmail) { |
| normalizedOwnerName = normalizedOwnerName || normalizedOwnerEmail |
| normalizedOwnerDisplayName = |
| normalizedOwnerDisplayName || normalizedOwnerEmail || normalizedOwnerName |
| } |
|
|
| if (typeof userInfo.id === 'string' && userInfo.id.trim()) { |
| normalizedUserId = userInfo.id.trim() |
| } |
| } |
|
|
| lastRefreshAt = new Date().toISOString() |
| status = 'active' |
| logger.success(`✅ 使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`) |
| } catch (error) { |
| logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error) |
| throw new Error(`Refresh Token 验证失败:${error.message}`) |
| } |
| } else if (!isApiKeyProvision && normalizedRefreshToken && !isManualProvision) { |
| try { |
| const orgIds = await this._fetchFactoryOrgIds(normalizedAccessToken, proxyConfig) |
| const selectedOrgId = |
| normalizedOrganizationId || |
| (Array.isArray(orgIds) |
| ? orgIds.find((id) => typeof id === 'string' && id.trim()) |
| : null) || |
| '' |
|
|
| if (!selectedOrgId) { |
| logger.warn(`⚠️ [Droid oauth] 未获取到组织ID,跳过 WorkOS 刷新: ${name} (${accountId})`) |
| } else { |
| const refreshed = await this._refreshTokensWithWorkOS( |
| normalizedRefreshToken, |
| proxyConfig, |
| selectedOrgId |
| ) |
|
|
| logger.info( |
| `🔍 [Droid oauth] 组织刷新后令牌 - AccountName: ${name}, AccessToken: ${refreshed.accessToken || '[empty]'}, RefreshToken: ${refreshed.refreshToken || '[empty]'}, OrganizationId: ${ |
| refreshed.organizationId || selectedOrgId |
| }, ExpiresAt: ${refreshed.expiresAt || '[empty]'}` |
| ) |
|
|
| normalizedAccessToken = refreshed.accessToken |
| normalizedRefreshToken = refreshed.refreshToken |
| normalizedExpiresAt = refreshed.expiresAt || normalizedExpiresAt |
| normalizedTokenType = refreshed.tokenType || normalizedTokenType |
| normalizedAuthenticationMethod = |
| refreshed.authenticationMethod || normalizedAuthenticationMethod |
| if (refreshed.expiresIn !== null && refreshed.expiresIn !== undefined) { |
| normalizedExpiresIn = refreshed.expiresIn |
| } |
| if (refreshed.organizationId) { |
| normalizedOrganizationId = refreshed.organizationId |
| } else { |
| normalizedOrganizationId = selectedOrgId |
| } |
|
|
| if (refreshed.user) { |
| const userInfo = refreshed.user |
| if (typeof userInfo.email === 'string' && userInfo.email.trim()) { |
| normalizedOwnerEmail = userInfo.email.trim() |
| } |
| const nameParts = [] |
| if (typeof userInfo.first_name === 'string' && userInfo.first_name.trim()) { |
| nameParts.push(userInfo.first_name.trim()) |
| } |
| if (typeof userInfo.last_name === 'string' && userInfo.last_name.trim()) { |
| nameParts.push(userInfo.last_name.trim()) |
| } |
| const derivedName = |
| nameParts.join(' ').trim() || |
| (typeof userInfo.name === 'string' ? userInfo.name.trim() : '') || |
| (typeof userInfo.display_name === 'string' ? userInfo.display_name.trim() : '') |
|
|
| if (derivedName) { |
| normalizedOwnerName = derivedName |
| normalizedOwnerDisplayName = derivedName |
| } else if (normalizedOwnerEmail) { |
| normalizedOwnerName = normalizedOwnerName || normalizedOwnerEmail |
| normalizedOwnerDisplayName = |
| normalizedOwnerDisplayName || normalizedOwnerEmail || normalizedOwnerName |
| } |
|
|
| if (typeof userInfo.id === 'string' && userInfo.id.trim()) { |
| normalizedUserId = userInfo.id.trim() |
| } |
| } |
|
|
| lastRefreshAt = new Date().toISOString() |
| status = 'active' |
| } |
| } catch (error) { |
| logger.warn(`⚠️ [Droid oauth] 初始化刷新失败: ${name} (${accountId}) - ${error.message}`) |
| } |
| } |
|
|
| if (!isApiKeyProvision && !normalizedExpiresAt) { |
| let expiresInSeconds = null |
| if (typeof normalizedExpiresIn === 'number' && Number.isFinite(normalizedExpiresIn)) { |
| expiresInSeconds = normalizedExpiresIn |
| } else if ( |
| typeof normalizedExpiresIn === 'string' && |
| normalizedExpiresIn.trim() && |
| !Number.isNaN(Number(normalizedExpiresIn)) |
| ) { |
| expiresInSeconds = Number(normalizedExpiresIn) |
| } |
|
|
| if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) { |
| expiresInSeconds = this.tokenValidHours * 3600 |
| } |
|
|
| normalizedExpiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString() |
| normalizedExpiresIn = expiresInSeconds |
| } |
|
|
| logger.info( |
| `🔍 [Droid ${provisioningMode}] 写入前令牌快照 - AccountName: ${name}, AccessToken: ${normalizedAccessToken || '[empty]'}, RefreshToken: ${normalizedRefreshToken || '[empty]'}, ExpiresAt: ${normalizedExpiresAt || '[empty]'}, ExpiresIn: ${ |
| normalizedExpiresIn !== null && normalizedExpiresIn !== undefined |
| ? normalizedExpiresIn |
| : '[empty]' |
| }` |
| ) |
|
|
| const accountData = { |
| id: accountId, |
| name, |
| description, |
| refreshToken: this._encryptSensitiveData(normalizedRefreshToken), |
| accessToken: this._encryptSensitiveData(normalizedAccessToken), |
| expiresAt: normalizedExpiresAt || '', |
|
|
| |
| subscriptionExpiresAt: options.subscriptionExpiresAt || null, |
|
|
| proxy: proxy ? JSON.stringify(proxy) : '', |
| isActive: isActive.toString(), |
| accountType, |
| platform, |
| priority: priority.toString(), |
| createdAt: new Date().toISOString(), |
| lastUsedAt: '', |
| lastRefreshAt, |
| status, |
| errorMessage: '', |
| schedulable: schedulable.toString(), |
| endpointType: normalizedEndpointType, |
| organizationId: normalizedOrganizationId || '', |
| owner: normalizedOwnerName || normalizedOwnerEmail || '', |
| ownerEmail: normalizedOwnerEmail || '', |
| ownerName: normalizedOwnerName || '', |
| ownerDisplayName: |
| normalizedOwnerDisplayName || normalizedOwnerName || normalizedOwnerEmail || '', |
| userId: normalizedUserId || '', |
| tokenType: normalizedTokenType || 'Bearer', |
| authenticationMethod: normalizedAuthenticationMethod || '', |
| expiresIn: |
| normalizedExpiresIn !== null && normalizedExpiresIn !== undefined |
| ? String(normalizedExpiresIn) |
| : '', |
| apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '', |
| apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0', |
| apiKeyStrategy: hasApiKeys ? 'random_sticky' : '' |
| } |
|
|
| await redis.setDroidAccount(accountId, accountData) |
|
|
| logger.success( |
| `🏢 Created Droid account: ${name} (${accountId}) - Endpoint: ${normalizedEndpointType}` |
| ) |
|
|
| try { |
| const verifyAccount = await this.getAccount(accountId) |
| logger.info( |
| `🔍 [Droid ${provisioningMode}] Redis 写入后验证 - AccountName: ${name}, AccessToken: ${verifyAccount?.accessToken || '[empty]'}, RefreshToken: ${verifyAccount?.refreshToken || '[empty]'}, ExpiresAt: ${verifyAccount?.expiresAt || '[empty]'}` |
| ) |
| } catch (verifyError) { |
| logger.warn( |
| `⚠️ [Droid ${provisioningMode}] 写入后验证失败: ${name} (${accountId}) - ${verifyError.message}` |
| ) |
| } |
| return { id: accountId, ...accountData } |
| } |
|
|
| |
| |
| |
| async getAccount(accountId) { |
| const account = await redis.getDroidAccount(accountId) |
| if (!account || Object.keys(account).length === 0) { |
| return null |
| } |
|
|
| |
| const apiKeyEntries = this._parseApiKeyEntries(account.apiKeys) |
|
|
| return { |
| ...account, |
| id: accountId, |
| endpointType: this._sanitizeEndpointType(account.endpointType), |
| refreshToken: this._decryptSensitiveData(account.refreshToken), |
| accessToken: this._decryptSensitiveData(account.accessToken), |
| apiKeys: this._maskApiKeyEntries(apiKeyEntries), |
| apiKeyCount: apiKeyEntries.length |
| } |
| } |
|
|
| |
| |
| |
| async getAllAccounts() { |
| const accounts = await redis.getAllDroidAccounts() |
| return accounts.map((account) => ({ |
| ...account, |
| endpointType: this._sanitizeEndpointType(account.endpointType), |
| |
| refreshToken: account.refreshToken ? '***ENCRYPTED***' : '', |
| accessToken: account.accessToken |
| ? maskToken(this._decryptSensitiveData(account.accessToken)) |
| : '', |
|
|
| |
| expiresAt: account.subscriptionExpiresAt || null, |
| platform: account.platform || 'droid', |
|
|
| apiKeyCount: (() => { |
| const parsedCount = this._parseApiKeyEntries(account.apiKeys).length |
| if (account.apiKeyCount === undefined || account.apiKeyCount === null) { |
| return parsedCount |
| } |
| const numeric = Number(account.apiKeyCount) |
| return Number.isFinite(numeric) && numeric >= 0 ? numeric : parsedCount |
| })() |
| })) |
| } |
|
|
| |
| |
| |
| async updateAccount(accountId, updates) { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| throw new Error(`Droid account not found: ${accountId}`) |
| } |
|
|
| const storedAccount = await redis.getDroidAccount(accountId) |
| const hasStoredAccount = |
| storedAccount && typeof storedAccount === 'object' && Object.keys(storedAccount).length > 0 |
| const sanitizedUpdates = { ...updates } |
|
|
| if (typeof sanitizedUpdates.accessToken === 'string') { |
| sanitizedUpdates.accessToken = sanitizedUpdates.accessToken.trim() |
| } |
| if (typeof sanitizedUpdates.refreshToken === 'string') { |
| sanitizedUpdates.refreshToken = sanitizedUpdates.refreshToken.trim() |
| } |
|
|
| if (sanitizedUpdates.endpointType) { |
| sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType) |
| } |
|
|
| const parseProxyConfig = (value) => { |
| if (!value) { |
| return null |
| } |
| if (typeof value === 'object') { |
| return value |
| } |
| if (typeof value === 'string' && value.trim()) { |
| try { |
| return JSON.parse(value) |
| } catch (error) { |
| logger.warn('⚠️ Failed to parse stored Droid proxy config:', error.message) |
| } |
| } |
| return null |
| } |
|
|
| let proxyConfig = null |
| if (updates.proxy !== undefined) { |
| if (updates.proxy && typeof updates.proxy === 'object') { |
| proxyConfig = updates.proxy |
| sanitizedUpdates.proxy = JSON.stringify(updates.proxy) |
| } else if (typeof updates.proxy === 'string' && updates.proxy.trim()) { |
| proxyConfig = parseProxyConfig(updates.proxy) |
| sanitizedUpdates.proxy = updates.proxy |
| } else { |
| sanitizedUpdates.proxy = '' |
| } |
| } else if (account.proxy) { |
| proxyConfig = parseProxyConfig(account.proxy) |
| } |
|
|
| const hasNewRefreshToken = |
| typeof sanitizedUpdates.refreshToken === 'string' && sanitizedUpdates.refreshToken |
|
|
| if (hasNewRefreshToken) { |
| try { |
| const refreshed = await this._refreshTokensWithWorkOS( |
| sanitizedUpdates.refreshToken, |
| proxyConfig |
| ) |
|
|
| sanitizedUpdates.accessToken = refreshed.accessToken |
| sanitizedUpdates.refreshToken = refreshed.refreshToken || sanitizedUpdates.refreshToken |
| sanitizedUpdates.expiresAt = |
| refreshed.expiresAt || sanitizedUpdates.expiresAt || account.expiresAt || '' |
|
|
| if (refreshed.expiresIn !== null && refreshed.expiresIn !== undefined) { |
| sanitizedUpdates.expiresIn = String(refreshed.expiresIn) |
| } |
|
|
| sanitizedUpdates.tokenType = refreshed.tokenType || account.tokenType || 'Bearer' |
| sanitizedUpdates.authenticationMethod = |
| refreshed.authenticationMethod || account.authenticationMethod || '' |
| sanitizedUpdates.organizationId = |
| sanitizedUpdates.organizationId || |
| refreshed.organizationId || |
| account.organizationId || |
| '' |
| sanitizedUpdates.lastRefreshAt = new Date().toISOString() |
| sanitizedUpdates.status = 'active' |
| sanitizedUpdates.errorMessage = '' |
|
|
| if (refreshed.user) { |
| const userInfo = refreshed.user |
| const email = typeof userInfo.email === 'string' ? userInfo.email.trim() : '' |
| if (email) { |
| sanitizedUpdates.ownerEmail = email |
| } |
|
|
| const nameParts = [] |
| if (typeof userInfo.first_name === 'string' && userInfo.first_name.trim()) { |
| nameParts.push(userInfo.first_name.trim()) |
| } |
| if (typeof userInfo.last_name === 'string' && userInfo.last_name.trim()) { |
| nameParts.push(userInfo.last_name.trim()) |
| } |
|
|
| const derivedName = |
| nameParts.join(' ').trim() || |
| (typeof userInfo.name === 'string' ? userInfo.name.trim() : '') || |
| (typeof userInfo.display_name === 'string' ? userInfo.display_name.trim() : '') |
|
|
| if (derivedName) { |
| sanitizedUpdates.ownerName = derivedName |
| sanitizedUpdates.ownerDisplayName = derivedName |
| sanitizedUpdates.owner = derivedName |
| } else if (sanitizedUpdates.ownerEmail) { |
| sanitizedUpdates.ownerName = sanitizedUpdates.ownerName || sanitizedUpdates.ownerEmail |
| sanitizedUpdates.ownerDisplayName = |
| sanitizedUpdates.ownerDisplayName || sanitizedUpdates.ownerEmail |
| sanitizedUpdates.owner = sanitizedUpdates.owner || sanitizedUpdates.ownerEmail |
| } |
|
|
| if (typeof userInfo.id === 'string' && userInfo.id.trim()) { |
| sanitizedUpdates.userId = userInfo.id.trim() |
| } |
| } |
| } catch (error) { |
| logger.error('❌ 使用新的 Refresh Token 更新 Droid 账户失败:', error) |
| throw new Error(`Refresh Token 验证失败:${error.message || '未知错误'}`) |
| } |
| } |
|
|
| |
| |
| if (sanitizedUpdates.subscriptionExpiresAt !== undefined) { |
| |
| } |
|
|
| if (sanitizedUpdates.proxy === undefined) { |
| sanitizedUpdates.proxy = account.proxy || '' |
| } |
|
|
| |
| const existingApiKeyEntries = this._parseApiKeyEntries( |
| hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'apiKeys') |
| ? storedAccount.apiKeys |
| : '' |
| ) |
| const newApiKeysInput = Array.isArray(updates.apiKeys) ? updates.apiKeys : [] |
| const removeApiKeysInput = Array.isArray(updates.removeApiKeys) ? updates.removeApiKeys : [] |
| const wantsClearApiKeys = Boolean(updates.clearApiKeys) |
| const rawApiKeyMode = |
| typeof updates.apiKeyUpdateMode === 'string' |
| ? updates.apiKeyUpdateMode.trim().toLowerCase() |
| : '' |
|
|
| let apiKeyUpdateMode = ['append', 'replace', 'delete', 'update'].includes(rawApiKeyMode) |
| ? rawApiKeyMode |
| : '' |
|
|
| if (!apiKeyUpdateMode) { |
| if (wantsClearApiKeys) { |
| apiKeyUpdateMode = 'replace' |
| } else if (removeApiKeysInput.length > 0) { |
| apiKeyUpdateMode = 'delete' |
| } else { |
| apiKeyUpdateMode = 'append' |
| } |
| } |
|
|
| if (sanitizedUpdates.apiKeys !== undefined) { |
| delete sanitizedUpdates.apiKeys |
| } |
| if (sanitizedUpdates.clearApiKeys !== undefined) { |
| delete sanitizedUpdates.clearApiKeys |
| } |
| if (sanitizedUpdates.apiKeyUpdateMode !== undefined) { |
| delete sanitizedUpdates.apiKeyUpdateMode |
| } |
| if (sanitizedUpdates.removeApiKeys !== undefined) { |
| delete sanitizedUpdates.removeApiKeys |
| } |
|
|
| let mergedApiKeys = existingApiKeyEntries |
| let apiKeysUpdated = false |
| let addedCount = 0 |
| let removedCount = 0 |
|
|
| if (apiKeyUpdateMode === 'delete') { |
| const removalHashes = new Set() |
|
|
| for (const candidate of removeApiKeysInput) { |
| if (typeof candidate !== 'string') { |
| continue |
| } |
| const trimmed = candidate.trim() |
| if (!trimmed) { |
| continue |
| } |
| const hash = crypto.createHash('sha256').update(trimmed).digest('hex') |
| removalHashes.add(hash) |
| } |
|
|
| if (removalHashes.size > 0) { |
| mergedApiKeys = existingApiKeyEntries.filter( |
| (entry) => entry && entry.hash && !removalHashes.has(entry.hash) |
| ) |
| removedCount = existingApiKeyEntries.length - mergedApiKeys.length |
| apiKeysUpdated = removedCount > 0 |
|
|
| if (!apiKeysUpdated) { |
| logger.warn( |
| `⚠️ 删除模式未匹配任何 Droid API Key: ${accountId} (提供 ${removalHashes.size} 条)` |
| ) |
| } |
| } else if (removeApiKeysInput.length > 0) { |
| logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`) |
| } |
| } else if (apiKeyUpdateMode === 'update') { |
| |
| mergedApiKeys = [...existingApiKeyEntries] |
| const updatedHashes = new Set() |
|
|
| for (const updateItem of newApiKeysInput) { |
| if (!updateItem || typeof updateItem !== 'object') { |
| continue |
| } |
|
|
| const key = updateItem.key || updateItem.apiKey || '' |
| if (!key || typeof key !== 'string') { |
| continue |
| } |
|
|
| const trimmed = key.trim() |
| if (!trimmed) { |
| continue |
| } |
|
|
| const hash = crypto.createHash('sha256').update(trimmed).digest('hex') |
| updatedHashes.add(hash) |
|
|
| |
| const existingIndex = mergedApiKeys.findIndex((entry) => entry && entry.hash === hash) |
|
|
| if (existingIndex !== -1) { |
| |
| const existingEntry = mergedApiKeys[existingIndex] |
| mergedApiKeys[existingIndex] = { |
| ...existingEntry, |
| status: updateItem.status || existingEntry.status || 'active', |
| errorMessage: |
| updateItem.errorMessage !== undefined |
| ? updateItem.errorMessage |
| : existingEntry.errorMessage || '', |
| lastUsedAt: |
| updateItem.lastUsedAt !== undefined |
| ? updateItem.lastUsedAt |
| : existingEntry.lastUsedAt || '', |
| usageCount: |
| updateItem.usageCount !== undefined |
| ? String(updateItem.usageCount) |
| : existingEntry.usageCount || '0' |
| } |
| apiKeysUpdated = true |
| } |
| } |
|
|
| if (!apiKeysUpdated) { |
| logger.warn( |
| `⚠️ 更新模式未匹配任何 Droid API Key: ${accountId} (提供 ${updatedHashes.size} 个哈希)` |
| ) |
| } |
| } else { |
| const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys |
| const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length |
|
|
| mergedApiKeys = this._buildApiKeyEntries( |
| newApiKeysInput, |
| existingApiKeyEntries, |
| clearExisting |
| ) |
|
|
| addedCount = Math.max(mergedApiKeys.length - baselineCount, 0) |
| apiKeysUpdated = clearExisting || addedCount > 0 |
| } |
|
|
| if (apiKeysUpdated) { |
| sanitizedUpdates.apiKeys = mergedApiKeys.length ? JSON.stringify(mergedApiKeys) : '' |
| sanitizedUpdates.apiKeyCount = String(mergedApiKeys.length) |
|
|
| if (apiKeyUpdateMode === 'delete') { |
| logger.info( |
| `🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}` |
| ) |
| } else if (apiKeyUpdateMode === 'update') { |
| logger.info( |
| `🔑 更新模式更新 Droid API keys for ${accountId}: 更新了 ${newApiKeysInput.length} 个 API Key 的状态信息` |
| ) |
| } else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) { |
| logger.info( |
| `🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}` |
| ) |
| } else { |
| logger.info( |
| `🔑 追加模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}` |
| ) |
| } |
|
|
| if (mergedApiKeys.length > 0) { |
| sanitizedUpdates.authenticationMethod = 'api_key' |
| sanitizedUpdates.status = sanitizedUpdates.status || 'active' |
| } else if (!sanitizedUpdates.accessToken && !account.accessToken) { |
| const shouldPreserveApiKeyMode = |
| account.authenticationMethod && |
| account.authenticationMethod.toLowerCase().trim() === 'api_key' && |
| (apiKeyUpdateMode === 'replace' || apiKeyUpdateMode === 'delete') |
|
|
| sanitizedUpdates.authenticationMethod = shouldPreserveApiKeyMode |
| ? 'api_key' |
| : account.authenticationMethod === 'api_key' |
| ? '' |
| : account.authenticationMethod |
| } |
| } |
|
|
| const encryptedUpdates = { ...sanitizedUpdates } |
|
|
| if (sanitizedUpdates.refreshToken !== undefined) { |
| encryptedUpdates.refreshToken = this._encryptSensitiveData(sanitizedUpdates.refreshToken) |
| } |
| if (sanitizedUpdates.accessToken !== undefined) { |
| encryptedUpdates.accessToken = this._encryptSensitiveData(sanitizedUpdates.accessToken) |
| } |
|
|
| const baseAccountData = hasStoredAccount ? { ...storedAccount } : { id: accountId } |
|
|
| const updatedData = { |
| ...baseAccountData, |
| ...encryptedUpdates |
| } |
|
|
| if (!Object.prototype.hasOwnProperty.call(updatedData, 'refreshToken')) { |
| updatedData.refreshToken = |
| hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'refreshToken') |
| ? storedAccount.refreshToken |
| : this._encryptSensitiveData(account.refreshToken) |
| } |
|
|
| if (!Object.prototype.hasOwnProperty.call(updatedData, 'accessToken')) { |
| updatedData.accessToken = |
| hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'accessToken') |
| ? storedAccount.accessToken |
| : this._encryptSensitiveData(account.accessToken) |
| } |
|
|
| if (!Object.prototype.hasOwnProperty.call(updatedData, 'proxy')) { |
| updatedData.proxy = hasStoredAccount ? storedAccount.proxy || '' : account.proxy || '' |
| } |
|
|
| await redis.setDroidAccount(accountId, updatedData) |
| logger.info(`✅ Updated Droid account: ${accountId}`) |
|
|
| return this.getAccount(accountId) |
| } |
|
|
| |
| |
| |
| async deleteAccount(accountId) { |
| await redis.deleteDroidAccount(accountId) |
| logger.success(`🗑️ Deleted Droid account: ${accountId}`) |
| } |
|
|
| |
| |
| |
| |
| |
| async refreshAccessToken(accountId, proxyConfig = null) { |
| const account = await this.getAccount(accountId) |
| if (!account) { |
| throw new Error(`Droid account not found: ${accountId}`) |
| } |
|
|
| if (!account.refreshToken) { |
| throw new Error(`Droid account ${accountId} has no refresh token`) |
| } |
|
|
| logger.info(`🔄 Refreshing Droid account token: ${account.name} (${accountId})`) |
|
|
| try { |
| const proxy = proxyConfig || (account.proxy ? JSON.parse(account.proxy) : null) |
| const refreshed = await this._refreshTokensWithWorkOS( |
| account.refreshToken, |
| proxy, |
| account.organizationId || null |
| ) |
|
|
| |
| await this.updateAccount(accountId, { |
| accessToken: refreshed.accessToken, |
| refreshToken: refreshed.refreshToken || account.refreshToken, |
| expiresAt: refreshed.expiresAt, |
| expiresIn: |
| refreshed.expiresIn !== null && refreshed.expiresIn !== undefined |
| ? String(refreshed.expiresIn) |
| : account.expiresIn, |
| tokenType: refreshed.tokenType || account.tokenType || 'Bearer', |
| authenticationMethod: refreshed.authenticationMethod || account.authenticationMethod || '', |
| organizationId: refreshed.organizationId || account.organizationId, |
| lastRefreshAt: new Date().toISOString(), |
| status: 'active', |
| errorMessage: '' |
| }) |
|
|
| |
| if (refreshed.user) { |
| const { user } = refreshed |
| const updates = {} |
| logger.info( |
| `✅ Droid token refreshed for: ${user.email} (${user.first_name} ${user.last_name})` |
| ) |
| logger.info(` Organization ID: ${refreshed.organizationId || 'N/A'}`) |
|
|
| if (typeof user.email === 'string' && user.email.trim()) { |
| updates.ownerEmail = user.email.trim() |
| } |
| const nameParts = [] |
| if (typeof user.first_name === 'string' && user.first_name.trim()) { |
| nameParts.push(user.first_name.trim()) |
| } |
| if (typeof user.last_name === 'string' && user.last_name.trim()) { |
| nameParts.push(user.last_name.trim()) |
| } |
| const derivedName = |
| nameParts.join(' ').trim() || |
| (typeof user.name === 'string' ? user.name.trim() : '') || |
| (typeof user.display_name === 'string' ? user.display_name.trim() : '') |
|
|
| if (derivedName) { |
| updates.ownerName = derivedName |
| updates.ownerDisplayName = derivedName |
| updates.owner = derivedName |
| } else if (updates.ownerEmail) { |
| updates.owner = updates.ownerEmail |
| updates.ownerName = updates.ownerEmail |
| updates.ownerDisplayName = updates.ownerEmail |
| } |
|
|
| if (typeof user.id === 'string' && user.id.trim()) { |
| updates.userId = user.id.trim() |
| } |
|
|
| if (Object.keys(updates).length > 0) { |
| await this.updateAccount(accountId, updates) |
| } |
| } |
|
|
| logger.success(`✅ Droid account token refreshed successfully: ${accountId}`) |
|
|
| return { |
| accessToken: refreshed.accessToken, |
| refreshToken: refreshed.refreshToken || account.refreshToken, |
| expiresAt: refreshed.expiresAt |
| } |
| } catch (error) { |
| logger.error(`❌ Failed to refresh Droid account token: ${accountId}`, error) |
|
|
| |
| await this.updateAccount(accountId, { |
| status: 'error', |
| errorMessage: error.message || 'Token refresh failed' |
| }) |
|
|
| throw error |
| } |
| } |
|
|
| |
| |
| |
| shouldRefreshToken(account) { |
| if (!account.lastRefreshAt) { |
| return true |
| } |
|
|
| const lastRefreshTime = new Date(account.lastRefreshAt).getTime() |
| const hoursSinceRefresh = (Date.now() - lastRefreshTime) / (1000 * 60 * 60) |
|
|
| return hoursSinceRefresh >= this.refreshIntervalHours |
| } |
|
|
| |
| |
| |
| |
| |
| isSubscriptionExpired(account) { |
| if (!account.subscriptionExpiresAt) { |
| return false |
| } |
| const expiryDate = new Date(account.subscriptionExpiresAt) |
| return expiryDate <= new Date() |
| } |
|
|
| |
| |
| |
| async getValidAccessToken(accountId) { |
| let account = await this.getAccount(accountId) |
| if (!account) { |
| throw new Error(`Droid account not found: ${accountId}`) |
| } |
|
|
| if ( |
| typeof account.authenticationMethod === 'string' && |
| account.authenticationMethod.toLowerCase().trim() === 'api_key' |
| ) { |
| throw new Error(`Droid account ${accountId} 已配置为 API Key 模式,不能获取 Access Token`) |
| } |
|
|
| |
| if (this.shouldRefreshToken(account)) { |
| logger.info(`🔄 Droid account token needs refresh: ${accountId}`) |
| const proxyConfig = account.proxy ? JSON.parse(account.proxy) : null |
| await this.refreshAccessToken(accountId, proxyConfig) |
| account = await this.getAccount(accountId) |
| } |
|
|
| if (!account.accessToken) { |
| throw new Error(`Droid account ${accountId} has no valid access token`) |
| } |
|
|
| return account.accessToken |
| } |
|
|
| |
| |
| |
| async getSchedulableAccounts(endpointType = null) { |
| const allAccounts = await redis.getAllDroidAccounts() |
|
|
| const normalizedFilter = endpointType ? this._sanitizeEndpointType(endpointType) : null |
|
|
| return allAccounts |
| .filter((account) => { |
| const isActive = this._isTruthy(account.isActive) |
| const isSchedulable = this._isTruthy(account.schedulable) |
| const status = typeof account.status === 'string' ? account.status.toLowerCase() : '' |
|
|
| |
| if (this.isSubscriptionExpired(account)) { |
| logger.debug( |
| `⏰ Skipping expired Droid account: ${account.name}, expired at ${account.subscriptionExpiresAt}` |
| ) |
| return false |
| } |
|
|
| if (!isActive || !isSchedulable || status !== 'active') { |
| return false |
| } |
|
|
| if (!normalizedFilter) { |
| return true |
| } |
|
|
| const accountEndpoint = this._sanitizeEndpointType(account.endpointType) |
|
|
| if (normalizedFilter === 'openai') { |
| return accountEndpoint === 'openai' || accountEndpoint === 'anthropic' |
| } |
|
|
| if (normalizedFilter === 'anthropic') { |
| return accountEndpoint === 'anthropic' || accountEndpoint === 'openai' |
| } |
|
|
| return accountEndpoint === normalizedFilter |
| }) |
| .map((account) => ({ |
| ...account, |
| endpointType: this._sanitizeEndpointType(account.endpointType), |
| priority: parseInt(account.priority, 10) || 50, |
| |
| accessToken: this._decryptSensitiveData(account.accessToken) |
| })) |
| .sort((a, b) => a.priority - b.priority) |
| } |
|
|
| |
| |
| |
| async selectAccount(endpointType = null) { |
| let accounts = await this.getSchedulableAccounts(endpointType) |
|
|
| if (accounts.length === 0 && endpointType) { |
| logger.warn( |
| `No Droid accounts found for endpoint ${endpointType}, falling back to any available account` |
| ) |
| accounts = await this.getSchedulableAccounts(null) |
| } |
|
|
| if (accounts.length === 0) { |
| throw new Error( |
| `No schedulable Droid accounts available${endpointType ? ` for endpoint type: ${endpointType}` : ''}` |
| ) |
| } |
|
|
| |
| let selectedAccount = accounts[0] |
| for (const account of accounts) { |
| if (account.priority < selectedAccount.priority) { |
| selectedAccount = account |
| } else if (account.priority === selectedAccount.priority) { |
| |
| const selectedLastUsed = new Date(selectedAccount.lastUsedAt || 0).getTime() |
| const accountLastUsed = new Date(account.lastUsedAt || 0).getTime() |
| if (accountLastUsed < selectedLastUsed) { |
| selectedAccount = account |
| } |
| } |
| } |
|
|
| |
| await this.updateAccount(selectedAccount.id, { |
| lastUsedAt: new Date().toISOString() |
| }) |
|
|
| logger.info( |
| `✅ Selected Droid account: ${selectedAccount.name} (${selectedAccount.id}) - Endpoint: ${this._sanitizeEndpointType(selectedAccount.endpointType)}` |
| ) |
|
|
| return selectedAccount |
| } |
|
|
| |
| |
| |
| getFactoryApiUrl(endpointType, endpoint) { |
| const normalizedType = this._sanitizeEndpointType(endpointType) |
| const baseUrls = { |
| anthropic: `${this.factoryApiBaseUrl}/a${endpoint}`, |
| openai: `${this.factoryApiBaseUrl}/o${endpoint}` |
| } |
|
|
| return baseUrls[normalizedType] || baseUrls.openai |
| } |
|
|
| async touchLastUsedAt(accountId) { |
| if (!accountId) { |
| return |
| } |
|
|
| try { |
| const client = redis.getClientSafe() |
| await client.hset(`droid:account:${accountId}`, 'lastUsedAt', new Date().toISOString()) |
| } catch (error) { |
| logger.warn(`⚠️ Failed to update lastUsedAt for Droid account ${accountId}:`, error) |
| } |
| } |
| } |
|
|
| |
| module.exports = new DroidAccountService() |
|
|