mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 18:39:17 +00:00
feat: droid平台账户数据统计及调度能力
This commit is contained in:
218
src/services/droidScheduler.js
Normal file
218
src/services/droidScheduler.js
Normal file
@@ -0,0 +1,218 @@
|
||||
const droidAccountService = require('./droidAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
class DroidScheduler {
|
||||
constructor() {
|
||||
this.STICKY_PREFIX = 'droid'
|
||||
}
|
||||
|
||||
_normalizeEndpointType(endpointType) {
|
||||
if (!endpointType) {
|
||||
return 'anthropic'
|
||||
}
|
||||
const normalized = String(endpointType).toLowerCase()
|
||||
if (normalized === 'openai' || normalized === 'common') {
|
||||
return 'openai'
|
||||
}
|
||||
return 'anthropic'
|
||||
}
|
||||
|
||||
_isTruthy(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase() === 'true'
|
||||
}
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
_isAccountActive(account) {
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
const isActive = this._isTruthy(account.isActive)
|
||||
if (!isActive) {
|
||||
return false
|
||||
}
|
||||
|
||||
const status = (account.status || 'active').toLowerCase()
|
||||
const unhealthyStatuses = new Set(['error', 'unauthorized', 'blocked'])
|
||||
return !unhealthyStatuses.has(status)
|
||||
}
|
||||
|
||||
_isAccountSchedulable(account) {
|
||||
return this._isTruthy(account?.schedulable ?? true)
|
||||
}
|
||||
|
||||
_matchesEndpoint(account, endpointType) {
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const accountEndpoint = this._normalizeEndpointType(account?.endpointType)
|
||||
if (normalizedEndpoint === accountEndpoint) {
|
||||
return true
|
||||
}
|
||||
|
||||
const sharedEndpoints = new Set(['anthropic', 'openai'])
|
||||
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
|
||||
}
|
||||
|
||||
_sortCandidates(candidates) {
|
||||
return [...candidates].sort((a, b) => {
|
||||
const priorityA = parseInt(a.priority, 10) || 50
|
||||
const priorityB = parseInt(b.priority, 10) || 50
|
||||
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB
|
||||
}
|
||||
|
||||
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
|
||||
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
|
||||
|
||||
if (lastUsedA !== lastUsedB) {
|
||||
return lastUsedA - lastUsedB
|
||||
}
|
||||
|
||||
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return createdA - createdB
|
||||
})
|
||||
}
|
||||
|
||||
_composeStickySessionKey(endpointType, sessionHash, apiKeyId) {
|
||||
if (!sessionHash) {
|
||||
return null
|
||||
}
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const apiKeyPart = apiKeyId || 'default'
|
||||
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
|
||||
}
|
||||
|
||||
async _loadGroupAccounts(groupId) {
|
||||
const memberIds = await accountGroupService.getGroupMembers(groupId)
|
||||
if (!memberIds || memberIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const accounts = await Promise.all(
|
||||
memberIds.map(async (memberId) => {
|
||||
try {
|
||||
return await droidAccountService.getAccount(memberId)
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ 获取 Droid 分组成员账号失败: ${memberId}`, error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return accounts.filter(
|
||||
(account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account)
|
||||
)
|
||||
}
|
||||
|
||||
async _ensureLastUsedUpdated(accountId) {
|
||||
try {
|
||||
await droidAccountService.touchLastUsedAt(accountId)
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ 更新 Droid 账号最后使用时间失败: ${accountId}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async _cleanupStickyMapping(stickyKey) {
|
||||
if (!stickyKey) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await redis.deleteSessionAccountMapping(stickyKey)
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ 清理 Droid 粘性会话映射失败: ${stickyKey}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async selectAccount(apiKeyData, endpointType, sessionHash) {
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
|
||||
|
||||
let candidates = []
|
||||
let isDedicatedBinding = false
|
||||
|
||||
if (apiKeyData?.droidAccountId) {
|
||||
const binding = apiKeyData.droidAccountId
|
||||
if (binding.startsWith('group:')) {
|
||||
const groupId = binding.substring('group:'.length)
|
||||
logger.info(
|
||||
`🤖 API Key ${apiKeyData.name || apiKeyData.id} 绑定 Droid 分组 ${groupId},按分组调度`
|
||||
)
|
||||
candidates = await this._loadGroupAccounts(groupId, normalizedEndpoint)
|
||||
} else {
|
||||
const account = await droidAccountService.getAccount(binding)
|
||||
if (account) {
|
||||
candidates = [account]
|
||||
isDedicatedBinding = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidates || candidates.length === 0) {
|
||||
candidates = await droidAccountService.getSchedulableAccounts(normalizedEndpoint)
|
||||
}
|
||||
|
||||
const filtered = candidates.filter(
|
||||
(account) =>
|
||||
account &&
|
||||
this._isAccountActive(account) &&
|
||||
this._isAccountSchedulable(account) &&
|
||||
this._matchesEndpoint(account, normalizedEndpoint)
|
||||
)
|
||||
|
||||
if (filtered.length === 0) {
|
||||
throw new Error(
|
||||
`No available Droid accounts for endpoint ${normalizedEndpoint}${apiKeyData?.droidAccountId ? ' (respecting binding)' : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
if (stickyKey && !isDedicatedBinding) {
|
||||
const mappedAccountId = await redis.getSessionAccountMapping(stickyKey)
|
||||
if (mappedAccountId) {
|
||||
const mappedAccount = filtered.find((account) => account.id === mappedAccountId)
|
||||
if (mappedAccount) {
|
||||
await redis.extendSessionAccountMappingTTL(stickyKey)
|
||||
logger.info(
|
||||
`🤖 命中 Droid 粘性会话: ${sessionHash} -> ${mappedAccount.name || mappedAccount.id}`
|
||||
)
|
||||
await this._ensureLastUsedUpdated(mappedAccount.id)
|
||||
return mappedAccount
|
||||
}
|
||||
|
||||
await this._cleanupStickyMapping(stickyKey)
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = this._sortCandidates(filtered)
|
||||
const selected = sorted[0]
|
||||
|
||||
if (!selected) {
|
||||
throw new Error(
|
||||
`No schedulable Droid account available after sorting (${normalizedEndpoint})`
|
||||
)
|
||||
}
|
||||
|
||||
if (stickyKey && !isDedicatedBinding) {
|
||||
await redis.setSessionAccountMapping(stickyKey, selected.id)
|
||||
}
|
||||
|
||||
await this._ensureLastUsedUpdated(selected.id)
|
||||
|
||||
logger.info(
|
||||
`🤖 选择 Droid 账号 ${selected.name || selected.id}(endpoint: ${normalizedEndpoint}, priority: ${selected.priority || 50})`
|
||||
)
|
||||
|
||||
return selected
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new DroidScheduler()
|
||||
Reference in New Issue
Block a user