feat: droid平台账户数据统计及调度能力

This commit is contained in:
shaw
2025-10-10 15:13:45 +08:00
parent 2fc84a6aca
commit 42db271848
21 changed files with 1424 additions and 212 deletions

View File

@@ -44,6 +44,25 @@ class DroidAccountService {
},
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'
}
/**
@@ -117,7 +136,7 @@ class DroidAccountService {
/**
* 使用 WorkOS Refresh Token 刷新并验证凭证
*/
async _refreshTokensWithWorkOS(refreshToken, proxyConfig = null) {
async _refreshTokensWithWorkOS(refreshToken, proxyConfig = null, organizationId = null) {
if (!refreshToken || typeof refreshToken !== 'string') {
throw new Error('Refresh Token 无效')
}
@@ -126,6 +145,9 @@ class DroidAccountService {
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',
@@ -184,6 +206,49 @@ class DroidAccountService {
}
}
/**
* 使用 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
}
}
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 账户
*
@@ -203,7 +268,7 @@ class DroidAccountService {
platform = 'droid',
priority = 50, // 调度优先级 (1-100)
schedulable = true, // 是否可被调度
endpointType = 'anthropic', // 默认端点类型: 'anthropic', 'openai', 'common'
endpointType = 'anthropic', // 默认端点类型: 'anthropic' 'openai'
organizationId = '',
ownerEmail = '',
ownerName = '',
@@ -215,6 +280,8 @@ class DroidAccountService {
const accountId = uuidv4()
const normalizedEndpointType = this._sanitizeEndpointType(endpointType)
let normalizedRefreshToken = refreshToken
let normalizedAccessToken = accessToken
let normalizedExpiresAt = expiresAt || ''
@@ -229,22 +296,40 @@ class DroidAccountService {
let lastRefreshAt = accessToken ? new Date().toISOString() : ''
let status = accessToken ? 'active' : 'created'
if (normalizedRefreshToken) {
try {
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
}
}
const isManualProvision =
typeof authenticationMethod === 'string' &&
authenticationMethod.toLowerCase().trim() === 'manual'
const provisioningMode = isManualProvision ? 'manual' : 'oauth'
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 (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
@@ -296,8 +381,113 @@ class DroidAccountService {
logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error)
throw new Error(`Refresh Token 验证失败:${error.message}`)
}
} else if (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 (!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,
@@ -316,7 +506,7 @@ class DroidAccountService {
status, // created, active, expired, error
errorMessage: '',
schedulable: schedulable.toString(),
endpointType, // anthropic, openai, common
endpointType: normalizedEndpointType, // anthropic openai
organizationId: normalizedOrganizationId || '',
owner: normalizedOwnerName || normalizedOwnerEmail || '',
ownerEmail: normalizedOwnerEmail || '',
@@ -334,7 +524,20 @@ class DroidAccountService {
await redis.setDroidAccount(accountId, accountData)
logger.success(`🏢 Created Droid account: ${name} (${accountId}) - Endpoint: ${endpointType}`)
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 }
}
@@ -350,6 +553,8 @@ class DroidAccountService {
// 解密敏感数据
return {
...account,
id: accountId,
endpointType: this._sanitizeEndpointType(account.endpointType),
refreshToken: this._decryptSensitiveData(account.refreshToken),
accessToken: this._decryptSensitiveData(account.accessToken)
}
@@ -362,6 +567,7 @@ class DroidAccountService {
const accounts = await redis.getAllDroidAccounts()
return accounts.map((account) => ({
...account,
endpointType: this._sanitizeEndpointType(account.endpointType),
// 不解密完整 token只返回掩码
refreshToken: account.refreshToken ? '***ENCRYPTED***' : '',
accessToken: account.accessToken
@@ -388,6 +594,10 @@ class DroidAccountService {
sanitizedUpdates.refreshToken = sanitizedUpdates.refreshToken.trim()
}
if (sanitizedUpdates.endpointType) {
sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType)
}
const parseProxyConfig = (value) => {
if (!value) {
return null
@@ -547,7 +757,11 @@ class DroidAccountService {
try {
const proxy = proxyConfig || (account.proxy ? JSON.parse(account.proxy) : null)
const refreshed = await this._refreshTokensWithWorkOS(account.refreshToken, proxy)
const refreshed = await this._refreshTokensWithWorkOS(
account.refreshToken,
proxy,
account.organizationId || null
)
// 更新账户信息
await this.updateAccount(accountId, {
@@ -673,6 +887,8 @@ class DroidAccountService {
async getSchedulableAccounts(endpointType = null) {
const allAccounts = await redis.getAllDroidAccounts()
const normalizedFilter = endpointType ? this._sanitizeEndpointType(endpointType) : null
return allAccounts
.filter((account) => {
// 基本过滤条件
@@ -681,15 +897,29 @@ class DroidAccountService {
account.schedulable === 'true' &&
account.status === 'active'
// 如果指定了端点类型,进一步过滤
if (endpointType) {
return isSchedulable && account.endpointType === endpointType
if (!isSchedulable) {
return false
}
return isSchedulable
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 用于使用
accessToken: this._decryptSensitiveData(account.accessToken)
@@ -737,7 +967,7 @@ class DroidAccountService {
})
logger.info(
`✅ Selected Droid account: ${selectedAccount.name} (${selectedAccount.id}) - Endpoint: ${selectedAccount.endpointType}`
`✅ Selected Droid account: ${selectedAccount.name} (${selectedAccount.id}) - Endpoint: ${this._sanitizeEndpointType(selectedAccount.endpointType)}`
)
return selectedAccount
@@ -747,13 +977,26 @@ class DroidAccountService {
* 获取 Factory.ai API 的完整 URL
*/
getFactoryApiUrl(endpointType, endpoint) {
const normalizedType = this._sanitizeEndpointType(endpointType)
const baseUrls = {
anthropic: `${this.factoryApiBaseUrl}/a${endpoint}`,
openai: `${this.factoryApiBaseUrl}/o${endpoint}`,
common: `${this.factoryApiBaseUrl}/o${endpoint}`
openai: `${this.factoryApiBaseUrl}/o${endpoint}`
}
return baseUrls[endpointType] || baseUrls.common
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)
}
}
}