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