mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
1582 lines
52 KiB
JavaScript
1582 lines
52 KiB
JavaScript
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')
|
||
|
||
/**
|
||
* Droid 账户管理服务
|
||
*
|
||
* 支持 WorkOS OAuth 集成,管理 Droid (Factory.ai) 账户
|
||
* 提供账户创建、token 刷新、代理配置等功能
|
||
*/
|
||
class DroidAccountService {
|
||
constructor() {
|
||
// WorkOS OAuth 配置
|
||
this.oauthTokenUrl = 'https://api.workos.com/user_management/authenticate'
|
||
this.factoryApiBaseUrl = 'https://api.factory.ai/api/llm'
|
||
|
||
this.workosClientId = 'client_01HNM792M5G5G1A2THWPXKFMXB'
|
||
|
||
// Token 刷新策略
|
||
this.refreshIntervalHours = 6 // 每6小时刷新一次
|
||
this.tokenValidHours = 8 // Token 有效期8小时
|
||
|
||
// 加密相关常量
|
||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||
this.ENCRYPTION_SALT = 'droid-account-salt'
|
||
|
||
// 🚀 性能优化:缓存派生的加密密钥
|
||
this._encryptionKeyCache = null
|
||
|
||
// 🔄 解密结果缓存
|
||
this._decryptCache = new LRUCache(500)
|
||
|
||
// 🧹 定期清理缓存(每10分钟)
|
||
setInterval(
|
||
() => {
|
||
this._decryptCache.cleanup()
|
||
logger.info('🧹 Droid decrypt cache cleanup completed', this._decryptCache.getStats())
|
||
},
|
||
10 * 60 * 1000
|
||
)
|
||
|
||
this.supportedEndpointTypes = new Set(['anthropic', 'openai', 'comm'])
|
||
}
|
||
|
||
_sanitizeEndpointType(endpointType) {
|
||
if (!endpointType) {
|
||
return 'anthropic'
|
||
}
|
||
|
||
const normalized = String(endpointType).toLowerCase()
|
||
if (normalized === 'openai') {
|
||
return 'openai'
|
||
}
|
||
|
||
if (normalized === 'comm') {
|
||
return 'comm'
|
||
}
|
||
|
||
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')
|
||
|
||
// 💾 存入缓存(5分钟过期)
|
||
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)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除指定的 Droid API Key 条目
|
||
*/
|
||
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 }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 标记指定的 Droid API Key 条目为异常状态
|
||
*/
|
||
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 }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 使用 WorkOS Refresh Token 刷新并验证凭证
|
||
*/
|
||
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 || ''
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 使用 Factory CLI 接口获取组织 ID 列表
|
||
*/
|
||
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 []
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建 Droid 账户
|
||
*
|
||
* @param {Object} options - 账户配置选项
|
||
* @returns {Promise<Object>} 创建的账户信息
|
||
*/
|
||
async createAccount(options = {}) {
|
||
const {
|
||
name = 'Unnamed Droid Account',
|
||
description = '',
|
||
refreshToken = '', // WorkOS refresh token
|
||
accessToken = '', // WorkOS access token (可选)
|
||
expiresAt = '', // Token 过期时间
|
||
proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' }
|
||
isActive = true,
|
||
accountType = 'shared', // 'dedicated' or 'shared'
|
||
platform = 'droid',
|
||
priority = 50, // 调度优先级 (1-100)
|
||
schedulable = true, // 是否可被调度
|
||
endpointType = 'anthropic', // 默认端点类型: 'anthropic', 'openai' 或 'comm'
|
||
organizationId = '',
|
||
ownerEmail = '',
|
||
ownerName = '',
|
||
userId = '',
|
||
tokenType = 'Bearer',
|
||
authenticationMethod = '',
|
||
expiresIn = null,
|
||
apiKeys = [],
|
||
userAgent = '' // 自定义 User-Agent
|
||
} = 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 || '', // OAuth Token 过期时间(技术字段,自动刷新)
|
||
|
||
// ✅ 新增:账户订阅到期时间(业务字段,手动管理)
|
||
subscriptionExpiresAt: options.subscriptionExpiresAt || null,
|
||
|
||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||
isActive: isActive.toString(),
|
||
accountType,
|
||
platform,
|
||
priority: priority.toString(),
|
||
createdAt: new Date().toISOString(),
|
||
lastUsedAt: '',
|
||
lastRefreshAt,
|
||
status, // created, active, expired, error
|
||
errorMessage: '',
|
||
schedulable: schedulable.toString(),
|
||
endpointType: normalizedEndpointType, // anthropic, openai 或 comm
|
||
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' : '',
|
||
userAgent: userAgent || '' // 自定义 User-Agent
|
||
}
|
||
|
||
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 }
|
||
}
|
||
|
||
/**
|
||
* 获取 Droid 账户信息
|
||
*/
|
||
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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取所有 Droid 账户
|
||
*/
|
||
async getAllAccounts() {
|
||
const accounts = await redis.getAllDroidAccounts()
|
||
return accounts.map((account) => ({
|
||
...account,
|
||
endpointType: this._sanitizeEndpointType(account.endpointType),
|
||
// 不解密完整 token,只返回掩码
|
||
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
|
||
})()
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* 更新 Droid 账户
|
||
*/
|
||
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)
|
||
}
|
||
|
||
// 处理 userAgent 字段
|
||
if (typeof sanitizedUpdates.userAgent === 'string') {
|
||
sanitizedUpdates.userAgent = sanitizedUpdates.userAgent.trim()
|
||
}
|
||
|
||
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 || '未知错误'}`)
|
||
}
|
||
}
|
||
|
||
// ✅ 如果通过路由映射更新了 subscriptionExpiresAt,直接保存
|
||
// subscriptionExpiresAt 是业务字段,与 token 刷新独立
|
||
if (sanitizedUpdates.subscriptionExpiresAt !== undefined) {
|
||
// 直接保存,不做任何调整
|
||
}
|
||
|
||
if (sanitizedUpdates.proxy === undefined) {
|
||
sanitizedUpdates.proxy = account.proxy || ''
|
||
}
|
||
|
||
// 使用 Redis 中的原始数据获取加密的 API Key 条目
|
||
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') {
|
||
// 更新模式:根据提供的 key 匹配现有条目并更新状态
|
||
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)
|
||
}
|
||
|
||
/**
|
||
* 删除 Droid 账户
|
||
*/
|
||
async deleteAccount(accountId) {
|
||
await redis.deleteDroidAccount(accountId)
|
||
logger.success(`🗑️ Deleted Droid account: ${accountId}`)
|
||
}
|
||
|
||
/**
|
||
* 刷新 Droid 账户的 access token
|
||
*
|
||
* 使用 WorkOS OAuth refresh token 刷新 access token
|
||
*/
|
||
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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查 token 是否需要刷新
|
||
*/
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 检查账户订阅是否过期
|
||
* @param {Object} account - 账户对象
|
||
* @returns {boolean} - true: 已过期, false: 未过期
|
||
*/
|
||
isSubscriptionExpired(account) {
|
||
if (!account.subscriptionExpiresAt) {
|
||
return false // 未设置视为永不过期
|
||
}
|
||
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||
return expiryDate <= new Date()
|
||
}
|
||
|
||
/**
|
||
* 获取有效的 access token(自动刷新)
|
||
*/
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 获取可调度的 Droid 账户列表
|
||
*/
|
||
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'
|
||
}
|
||
|
||
// comm 端点可以使用任何类型的账户
|
||
if (normalizedFilter === 'comm') {
|
||
return true
|
||
}
|
||
|
||
return accountEndpoint === normalizedFilter
|
||
})
|
||
.map((account) => ({
|
||
...account,
|
||
endpointType: this._sanitizeEndpointType(account.endpointType),
|
||
priority: parseInt(account.priority, 10) || 50,
|
||
// 解密 accessToken 用于使用
|
||
accessToken: this._decryptSensitiveData(account.accessToken)
|
||
}))
|
||
.sort((a, b) => a.priority - b.priority) // 按优先级排序
|
||
}
|
||
|
||
/**
|
||
* 选择一个可用的 Droid 账户(简单轮询)
|
||
*/
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 获取 Factory.ai API 的完整 URL
|
||
*/
|
||
getFactoryApiUrl(endpointType, endpoint) {
|
||
const normalizedType = this._sanitizeEndpointType(endpointType)
|
||
const baseUrls = {
|
||
anthropic: `${this.factoryApiBaseUrl}/a${endpoint}`,
|
||
openai: `${this.factoryApiBaseUrl}/o${endpoint}`,
|
||
comm: `${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()
|