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 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 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()