const express = require('express') const crypto = require('crypto') const droidAccountService = require('../../services/droidAccountService') const accountGroupService = require('../../services/accountGroupService') const redis = require('../../models/redis') const { authenticateAdmin } = require('../../middleware/auth') const logger = require('../../utils/logger') const { startDeviceAuthorization, pollDeviceAuthorization, WorkOSDeviceAuthError } = require('../../utils/workosOAuthHelper') const webhookNotifier = require('../../utils/webhookNotifier') const { formatAccountExpiry, mapExpiryField } = require('./utils') const router = express.Router() // ==================== Droid 账户管理 API ==================== // 生成 Droid 设备码授权信息 router.post('/droid-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { try { const { proxy } = req.body || {} const deviceAuth = await startDeviceAuthorization(proxy || null) const sessionId = crypto.randomUUID() const expiresAt = new Date(Date.now() + deviceAuth.expiresIn * 1000).toISOString() await redis.setOAuthSession(sessionId, { deviceCode: deviceAuth.deviceCode, userCode: deviceAuth.userCode, verificationUri: deviceAuth.verificationUri, verificationUriComplete: deviceAuth.verificationUriComplete, interval: deviceAuth.interval, proxy: proxy || null, createdAt: new Date().toISOString(), expiresAt }) logger.success('🤖 生成 Droid 设备码授权信息成功', { sessionId }) return res.json({ success: true, data: { sessionId, userCode: deviceAuth.userCode, verificationUri: deviceAuth.verificationUri, verificationUriComplete: deviceAuth.verificationUriComplete, expiresIn: deviceAuth.expiresIn, interval: deviceAuth.interval, instructions: [ '1. 使用下方验证码进入授权页面并确认访问权限。', '2. 在授权页面登录 Factory / Droid 账户并点击允许。', '3. 回到此处点击"完成授权"完成凭证获取。' ] } }) } catch (error) { const message = error instanceof WorkOSDeviceAuthError ? error.message : error.message || '未知错误' logger.error('❌ 生成 Droid 设备码授权失败:', message) return res.status(500).json({ error: 'Failed to start Droid device authorization', message }) } }) // 交换 Droid 授权码 router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res) => { const { sessionId, proxy } = req.body || {} try { if (!sessionId) { return res.status(400).json({ error: 'Session ID is required' }) } const oauthSession = await redis.getOAuthSession(sessionId) if (!oauthSession) { return res.status(400).json({ error: 'Invalid or expired OAuth session' }) } if (oauthSession.expiresAt && new Date() > new Date(oauthSession.expiresAt)) { await redis.deleteOAuthSession(sessionId) return res .status(400) .json({ error: 'OAuth session has expired, please generate a new authorization URL' }) } if (!oauthSession.deviceCode) { await redis.deleteOAuthSession(sessionId) return res.status(400).json({ error: 'OAuth session missing device code, please retry' }) } const proxyConfig = proxy || oauthSession.proxy || null const tokens = await pollDeviceAuthorization(oauthSession.deviceCode, proxyConfig) await redis.deleteOAuthSession(sessionId) logger.success('🤖 成功获取 Droid 访问令牌', { sessionId }) return res.json({ success: true, data: { tokens } }) } catch (error) { if (error instanceof WorkOSDeviceAuthError) { if (error.code === 'authorization_pending' || error.code === 'slow_down') { const oauthSession = await redis.getOAuthSession(sessionId) const expiresAt = oauthSession?.expiresAt ? new Date(oauthSession.expiresAt) : null const remainingSeconds = expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime()) ? Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)) : null return res.json({ success: false, pending: true, error: error.code, message: error.message, retryAfter: error.retryAfter || Number(oauthSession?.interval) || 5, expiresIn: remainingSeconds }) } if (error.code === 'expired_token') { await redis.deleteOAuthSession(sessionId) return res.status(400).json({ error: 'Device code expired', message: '授权已过期,请重新生成设备码并再次授权' }) } logger.error('❌ Droid 授权失败:', error.message) return res.status(500).json({ error: 'Failed to exchange Droid authorization code', message: error.message, errorCode: error.code }) } logger.error('❌ 交换 Droid 授权码失败:', error) return res.status(500).json({ error: 'Failed to exchange Droid authorization code', message: error.message }) } }) // 获取所有 Droid 账户 router.get('/droid-accounts', authenticateAdmin, async (req, res) => { try { const accounts = await droidAccountService.getAllAccounts() const allApiKeys = await redis.getAllApiKeys() // 添加使用统计 const accountsWithStats = await Promise.all( accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id, 'droid') let groupInfos = [] try { groupInfos = await accountGroupService.getAccountGroups(account.id) } catch (groupError) { logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError) groupInfos = [] } const groupIds = groupInfos.map((group) => group.id) const boundApiKeysCount = allApiKeys.reduce((count, key) => { const binding = key.droidAccountId if (!binding) { return count } if (binding === account.id) { return count + 1 } if (binding.startsWith('group:')) { const groupId = binding.substring('group:'.length) if (groupIds.includes(groupId)) { return count + 1 } } return count }, 0) const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, schedulable: account.schedulable === 'true', boundApiKeysCount, groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, averages: usageStats.averages } } } catch (error) { logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message) const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, boundApiKeysCount: 0, groupInfos: [], usage: { daily: { tokens: 0, requests: 0 }, total: { tokens: 0, requests: 0 }, averages: { rpm: 0, tpm: 0 } } } } }) ) return res.json({ success: true, data: accountsWithStats }) } catch (error) { logger.error('Failed to get Droid accounts:', error) return res.status(500).json({ error: 'Failed to get Droid accounts', message: error.message }) } }) // 创建 Droid 账户 router.post('/droid-accounts', authenticateAdmin, async (req, res) => { try { const { accountType: rawAccountType = 'shared', groupId, groupIds } = req.body const normalizedAccountType = rawAccountType || 'shared' if (!['shared', 'dedicated', 'group'].includes(normalizedAccountType)) { return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' }) } const normalizedGroupIds = Array.isArray(groupIds) ? groupIds.filter((id) => typeof id === 'string' && id.trim()) : [] if ( normalizedAccountType === 'group' && normalizedGroupIds.length === 0 && (!groupId || typeof groupId !== 'string' || !groupId.trim()) ) { return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' }) } const accountPayload = { ...req.body, accountType: normalizedAccountType } delete accountPayload.groupId delete accountPayload.groupIds const account = await droidAccountService.createAccount(accountPayload) if (normalizedAccountType === 'group') { try { if (normalizedGroupIds.length > 0) { await accountGroupService.setAccountGroups(account.id, normalizedGroupIds, 'droid') } else if (typeof groupId === 'string' && groupId.trim()) { await accountGroupService.addAccountToGroup(account.id, groupId, 'droid') } } catch (groupError) { logger.error(`Failed to attach Droid account ${account.id} to groups:`, groupError) return res.status(500).json({ error: 'Failed to bind Droid account to groups', message: groupError.message }) } } logger.success(`Created Droid account: ${account.name} (${account.id})`) const formattedAccount = formatAccountExpiry(account) return res.json({ success: true, data: formattedAccount }) } catch (error) { logger.error('Failed to create Droid account:', error) return res.status(500).json({ error: 'Failed to create Droid account', message: error.message }) } }) // 更新 Droid 账户 router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => { try { const { id } = req.params const updates = { ...req.body } // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt const mappedUpdates = mapExpiryField(updates, 'Droid', id) const { accountType: rawAccountType, groupId, groupIds } = mappedUpdates if (rawAccountType && !['shared', 'dedicated', 'group'].includes(rawAccountType)) { return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' }) } if ( rawAccountType === 'group' && (!groupId || typeof groupId !== 'string' || !groupId.trim()) && (!Array.isArray(groupIds) || groupIds.length === 0) ) { return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' }) } const currentAccount = await droidAccountService.getAccount(id) if (!currentAccount) { return res.status(404).json({ error: 'Droid account not found' }) } const normalizedGroupIds = Array.isArray(groupIds) ? groupIds.filter((gid) => typeof gid === 'string' && gid.trim()) : [] const hasGroupIdsField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds') const hasGroupIdField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupId') const targetAccountType = rawAccountType || currentAccount.accountType || 'shared' delete mappedUpdates.groupId delete mappedUpdates.groupIds if (rawAccountType) { mappedUpdates.accountType = targetAccountType } const account = await droidAccountService.updateAccount(id, mappedUpdates) try { if (currentAccount.accountType === 'group' && targetAccountType !== 'group') { await accountGroupService.removeAccountFromAllGroups(id) } else if (targetAccountType === 'group') { if (hasGroupIdsField) { if (normalizedGroupIds.length > 0) { await accountGroupService.setAccountGroups(id, normalizedGroupIds, 'droid') } else { await accountGroupService.removeAccountFromAllGroups(id) } } else if (hasGroupIdField && typeof groupId === 'string' && groupId.trim()) { await accountGroupService.setAccountGroups(id, [groupId], 'droid') } } } catch (groupError) { logger.error(`Failed to update Droid account ${id} groups:`, groupError) return res.status(500).json({ error: 'Failed to update Droid account groups', message: groupError.message }) } if (targetAccountType === 'group') { try { account.groupInfos = await accountGroupService.getAccountGroups(id) } catch (groupFetchError) { logger.debug(`Failed to fetch group infos for Droid account ${id}:`, groupFetchError) } } return res.json({ success: true, data: account }) } catch (error) { logger.error(`Failed to update Droid account ${req.params.id}:`, error) return res.status(500).json({ error: 'Failed to update Droid account', message: error.message }) } }) // 切换 Droid 账户调度状态 router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => { try { const { id } = req.params const account = await droidAccountService.getAccount(id) if (!account) { return res.status(404).json({ error: 'Droid account not found' }) } const currentSchedulable = account.schedulable === true || account.schedulable === 'true' const newSchedulable = !currentSchedulable await droidAccountService.updateAccount(id, { schedulable: newSchedulable ? 'true' : 'false' }) const updatedAccount = await droidAccountService.getAccount(id) const actualSchedulable = updatedAccount ? updatedAccount.schedulable === true || updatedAccount.schedulable === 'true' : newSchedulable if (!actualSchedulable) { await webhookNotifier.sendAccountAnomalyNotification({ accountId: account.id, accountName: account.name || 'Droid Account', platform: 'droid', status: 'disabled', errorCode: 'DROID_MANUALLY_DISABLED', reason: '账号已被管理员手动禁用调度', timestamp: new Date().toISOString() }) } logger.success( `🔄 Admin toggled Droid account schedulable status: ${id} -> ${ actualSchedulable ? 'schedulable' : 'not schedulable' }` ) return res.json({ success: true, schedulable: actualSchedulable }) } catch (error) { logger.error('❌ Failed to toggle Droid account schedulable status:', error) return res .status(500) .json({ error: 'Failed to toggle schedulable status', message: error.message }) } }) // 获取单个 Droid 账户详细信息 router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => { try { const { id } = req.params // 获取账户基本信息 const account = await droidAccountService.getAccount(id) if (!account) { return res.status(404).json({ error: 'Not Found', message: 'Droid account not found' }) } // 获取使用统计信息 let usageStats try { usageStats = await redis.getAccountUsageStats(account.id, 'droid') } catch (error) { logger.debug(`Failed to get usage stats for Droid account ${account.id}:`, error) usageStats = { daily: { tokens: 0, requests: 0, allTokens: 0 }, total: { tokens: 0, requests: 0, allTokens: 0 }, averages: { rpm: 0, tpm: 0 } } } // 获取分组信息 let groupInfos = [] try { groupInfos = await accountGroupService.getAccountGroups(account.id) } catch (error) { logger.debug(`Failed to get group infos for Droid account ${account.id}:`, error) groupInfos = [] } // 获取绑定的 API Key 数量 const allApiKeys = await redis.getAllApiKeys() const groupIds = groupInfos.map((group) => group.id) const boundApiKeysCount = allApiKeys.reduce((count, key) => { const binding = key.droidAccountId if (!binding) { return count } if (binding === account.id) { return count + 1 } if (binding.startsWith('group:')) { const groupId = binding.substring('group:'.length) if (groupIds.includes(groupId)) { return count + 1 } } return count }, 0) // 获取解密的 API Keys(用于管理界面) let decryptedApiKeys = [] try { decryptedApiKeys = await droidAccountService.getDecryptedApiKeyEntries(id) } catch (error) { logger.debug(`Failed to get decrypted API keys for Droid account ${account.id}:`, error) decryptedApiKeys = [] } // 返回完整的账户信息,包含实际的 API Keys const accountDetails = { ...account, // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt expiresAt: account.subscriptionExpiresAt || null, schedulable: account.schedulable === 'true', boundApiKeysCount, groupInfos, // 包含实际的 API Keys(用于管理界面) apiKeys: decryptedApiKeys.map((entry) => ({ key: entry.key, id: entry.id, usageCount: entry.usageCount || 0, lastUsedAt: entry.lastUsedAt || null, status: entry.status || 'active', // 使用实际的状态,默认为 active errorMessage: entry.errorMessage || '', // 包含错误信息 createdAt: entry.createdAt || null })), usage: { daily: usageStats.daily, total: usageStats.total, averages: usageStats.averages } } return res.json({ success: true, data: accountDetails }) } catch (error) { logger.error(`Failed to get Droid account ${req.params.id}:`, error) return res.status(500).json({ error: 'Failed to get Droid account', message: error.message }) } }) // 删除 Droid 账户 router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => { try { const { id } = req.params await droidAccountService.deleteAccount(id) return res.json({ success: true, message: 'Droid account deleted successfully' }) } catch (error) { logger.error(`Failed to delete Droid account ${req.params.id}:`, error) return res.status(500).json({ error: 'Failed to delete Droid account', message: error.message }) } }) // 刷新 Droid 账户 token router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req, res) => { try { const { id } = req.params const result = await droidAccountService.refreshAccessToken(id) return res.json({ success: true, data: result }) } catch (error) { logger.error(`Failed to refresh Droid account token ${req.params.id}:`, error) return res.status(500).json({ error: 'Failed to refresh token', message: error.message }) } }) module.exports = router