diff --git a/VERSION b/VERSION index 01793f68..fc81279d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.175 +1.1.176 diff --git a/src/app.js b/src/app.js index 42e8ce26..67454ea8 100644 --- a/src/app.js +++ b/src/app.js @@ -14,6 +14,7 @@ const cacheMonitor = require('./utils/cacheMonitor') // Import routes const apiRoutes = require('./routes/api') +const unifiedRoutes = require('./routes/unified') const adminRoutes = require('./routes/admin') const webRoutes = require('./routes/web') const apiStatsRoutes = require('./routes/apiStats') @@ -55,6 +56,11 @@ class Application { logger.info('🔄 Initializing pricing service...') await pricingService.initialize() + // 📋 初始化模型服务 + logger.info('🔄 Initializing model service...') + const modelService = require('./services/modelService') + await modelService.initialize() + // 📊 初始化缓存监控 await this.initializeCacheMonitoring() @@ -251,6 +257,7 @@ class Application { // 🛣️ 路由 this.app.use('/api', apiRoutes) + this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等) this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同 this.app.use('/admin', adminRoutes) this.app.use('/users', userRoutes) @@ -262,7 +269,8 @@ class Application { this.app.use('/gemini', geminiRoutes) // 保留原有路径以保持向后兼容 this.app.use('/openai/gemini', openaiGeminiRoutes) this.app.use('/openai/claude', openaiClaudeRoutes) - this.app.use('/openai', openaiRoutes) + this.app.use('/openai', unifiedRoutes) // 复用统一智能路由,支持 /openai/v1/chat/completions + this.app.use('/openai', openaiRoutes) // Codex API 路由(/openai/responses, /openai/v1/responses) // Droid 路由:支持多种 Factory.ai 端点 this.app.use('/droid', droidRoutes) // Droid (Factory.ai) API 转发 this.app.use('/azure', azureOpenaiRoutes) @@ -630,6 +638,15 @@ class Application { logger.error('❌ Error cleaning up pricing service:', error) } + // 清理 model service 的文件监听器 + try { + const modelService = require('./services/modelService') + modelService.cleanup() + logger.info('📋 Model service cleaned up') + } catch (error) { + logger.error('❌ Error cleaning up model service:', error) + } + // 停止限流清理服务 try { const rateLimitCleanupService = require('./services/rateLimitCleanupService') diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 378f1565..c39655e0 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -61,8 +61,7 @@ const resolveConcurrencyConfig = () => { const TOKEN_COUNT_PATHS = new Set([ '/v1/messages/count_tokens', '/api/v1/messages/count_tokens', - '/claude/v1/messages/count_tokens', - '/droid/claude/v1/messages/count_tokens' + '/claude/v1/messages/count_tokens' ]) function extractApiKey(req) { diff --git a/src/routes/admin.js b/src/routes/admin.js index fe49e72e..0206c6c1 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -32,6 +32,7 @@ const ProxyHelper = require('../utils/proxyHelper') const router = express.Router() +// 🛠️ 工具函数:处理可为空的时间字段 function normalizeNullableDate(value) { if (value === undefined || value === null) { return null @@ -43,13 +44,45 @@ function normalizeNullableDate(value) { return value } -function formatSubscriptionExpiry(account) { +// 🛠️ 工具函数:映射前端字段名到后端字段名 +/** + * 映射前端的 expiresAt 字段到后端的 subscriptionExpiresAt 字段 + * @param {Object} updates - 更新对象 + * @param {string} accountType - 账户类型 (如 'Claude', 'OpenAI' 等) + * @param {string} accountId - 账户 ID + * @returns {Object} 映射后的更新对象 + */ +function mapExpiryField(updates, accountType, accountId) { + const mappedUpdates = { ...updates } + if ('expiresAt' in mappedUpdates) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + delete mappedUpdates.expiresAt + logger.info( + `Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}` + ) + } + return mappedUpdates +} + +/** + * 格式化账户数据,确保前端获取正确的过期时间字段 + * 将 subscriptionExpiresAt(订阅过期时间)映射到 expiresAt 供前端使用 + * 保留原始的 tokenExpiresAt(OAuth token过期时间)供内部使用 + * @param {Object} account - 账户对象 + * @returns {Object} 格式化后的账户对象 + */ +function formatAccountExpiry(account) { if (!account || typeof account !== 'object') { return account } - const rawSubscription = account.subscriptionExpiresAt - const rawToken = account.tokenExpiresAt !== undefined ? account.tokenExpiresAt : account.expiresAt + const rawSubscription = Object.prototype.hasOwnProperty.call(account, 'subscriptionExpiresAt') + ? account.subscriptionExpiresAt + : null + + const rawToken = Object.prototype.hasOwnProperty.call(account, 'tokenExpiresAt') + ? account.tokenExpiresAt + : account.expiresAt const subscriptionExpiresAt = normalizeNullableDate(rawSubscription) const tokenExpiresAt = normalizeNullableDate(rawToken) @@ -2112,7 +2145,6 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { try { const usageStats = await redis.getAccountUsageStats(account.id, 'openai') const groupInfos = await accountGroupService.getAccountGroups(account.id) - const formattedAccount = formatSubscriptionExpiry(account) // 获取会话窗口使用统计(仅对有活跃窗口的账户) let sessionWindowUsage = null @@ -2154,6 +2186,7 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { } } + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, // 转换schedulable为布尔值 @@ -2171,7 +2204,7 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { // 如果获取统计失败,返回空统计 try { const groupInfos = await accountGroupService.getAccountGroups(account.id) - const formattedAccount = formatSubscriptionExpiry(account) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, groupInfos, @@ -2187,7 +2220,7 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { `⚠️ Failed to get group info for account ${account.id}:`, groupError.message ) - const formattedAccount = formatSubscriptionExpiry(account) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, groupInfos: [], @@ -2203,8 +2236,7 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { }) ) - const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) - return res.json({ success: true, data: formattedAccounts }) + return res.json({ success: true, data: accountsWithStats }) } catch (error) { logger.error('❌ Failed to get Claude accounts:', error) return res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message }) @@ -2301,8 +2333,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { useUnifiedUserAgent, useUnifiedClientId, unifiedClientId, - expiresAt, - subscriptionExpiresAt + expiresAt } = req.body if (!name) { @@ -2346,7 +2377,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { useUnifiedUserAgent: useUnifiedUserAgent === true, // 默认为false useUnifiedClientId: useUnifiedClientId === true, // 默认为false unifiedClientId: unifiedClientId || '', // 统一的客户端标识 - expiresAt: subscriptionExpiresAt ?? expiresAt ?? null // 账户订阅到期时间 + expiresAt: expiresAt || null // 账户订阅到期时间 }) // 如果是分组类型,将账户添加到分组 @@ -2361,8 +2392,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { } logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`) - const responseAccount = formatSubscriptionExpiry(newAccount) - return res.json({ success: true, data: responseAccount }) + const formattedAccount = formatAccountExpiry(newAccount) + return res.json({ success: true, data: formattedAccount }) } catch (error) { logger.error('❌ Failed to create Claude account:', error) return res @@ -2377,16 +2408,24 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => const { accountId } = req.params const updates = req.body + // ✅ 【修改】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt(提前到参数验证之前) + const mappedUpdates = mapExpiryField(updates, 'Claude', accountId) + // 验证priority的有效性 if ( - updates.priority !== undefined && - (typeof updates.priority !== 'number' || updates.priority < 1 || updates.priority > 100) + mappedUpdates.priority !== undefined && + (typeof mappedUpdates.priority !== 'number' || + mappedUpdates.priority < 1 || + mappedUpdates.priority > 100) ) { return res.status(400).json({ error: 'Priority must be a number between 1 and 100' }) } // 验证accountType的有效性 - if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { + if ( + mappedUpdates.accountType && + !['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType) + ) { return res .status(400) .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) @@ -2394,9 +2433,9 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => // 如果更新为分组类型,验证groupId或groupIds if ( - updates.accountType === 'group' && - !updates.groupId && - (!updates.groupIds || updates.groupIds.length === 0) + mappedUpdates.accountType === 'group' && + !mappedUpdates.groupId && + (!mappedUpdates.groupIds || mappedUpdates.groupIds.length === 0) ) { return res .status(400) @@ -2410,41 +2449,30 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => } // 处理分组的变更 - if (updates.accountType !== undefined) { + if (mappedUpdates.accountType !== undefined) { // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { await accountGroupService.removeAccountFromAllGroups(accountId) } // 如果新类型是分组,添加到新分组 - if (updates.accountType === 'group') { + if (mappedUpdates.accountType === 'group') { // 处理多分组/单分组的兼容性 - if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) { - if (updates.groupIds && updates.groupIds.length > 0) { + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { + if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { // 使用多分组设置 - await accountGroupService.setAccountGroups(accountId, updates.groupIds, 'claude') + await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude') } else { // groupIds 为空数组,从所有分组中移除 await accountGroupService.removeAccountFromAllGroups(accountId) } - } else if (updates.groupId) { + } else if (mappedUpdates.groupId) { // 兼容单分组模式 - await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude') + await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude') } } } - // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt - const mappedUpdates = { ...updates } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt - } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { - mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt - } - if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { - delete mappedUpdates.expiresAt - } - await claudeAccountService.updateAccount(accountId, mappedUpdates) logger.success(`📝 Admin updated Claude account: ${accountId}`) @@ -2645,14 +2673,13 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { } // 为每个账户添加使用统计信息 - const accountsWithStats = await Promise.all( accounts.map(async (account) => { - const formattedAccount = formatSubscriptionExpiry(account) try { const usageStats = await redis.getAccountUsageStats(account.id, 'openai') const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, // 转换schedulable为布尔值 @@ -2671,6 +2698,7 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { ) try { const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, // 转换schedulable为布尔值 @@ -2687,6 +2715,7 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { `⚠️ Failed to get group info for Claude Console account ${account.id}:`, groupError.message ) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, groupInfos: [], @@ -2701,8 +2730,7 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { }) ) - const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) - return res.json({ success: true, data: formattedAccounts }) + return res.json({ success: true, data: accountsWithStats }) } catch (error) { logger.error('❌ Failed to get Claude Console accounts:', error) return res @@ -2773,8 +2801,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => { } logger.success(`🎮 Admin created Claude Console account: ${name}`) - const responseAccount = formatSubscriptionExpiry(newAccount) - return res.json({ success: true, data: responseAccount }) + const formattedAccount = formatAccountExpiry(newAccount) + return res.json({ success: true, data: formattedAccount }) } catch (error) { logger.error('❌ Failed to create Claude Console account:', error) return res @@ -2789,20 +2817,29 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, const { accountId } = req.params const updates = req.body + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'Claude Console', accountId) + // 验证priority的有效性(1-100) - if (updates.priority !== undefined && (updates.priority < 1 || updates.priority > 100)) { + if ( + mappedUpdates.priority !== undefined && + (mappedUpdates.priority < 1 || mappedUpdates.priority > 100) + ) { return res.status(400).json({ error: 'Priority must be between 1 and 100' }) } // 验证accountType的有效性 - if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { + if ( + mappedUpdates.accountType && + !['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType) + ) { return res .status(400) .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) } // 如果更新为分组类型,验证groupId - if (updates.accountType === 'group' && !updates.groupId) { + if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) { return res.status(400).json({ error: 'Group ID is required for group type accounts' }) } @@ -2813,7 +2850,7 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, } // 处理分组的变更 - if (updates.accountType !== undefined) { + if (mappedUpdates.accountType !== undefined) { // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { const oldGroups = await accountGroupService.getAccountGroups(accountId) @@ -2822,34 +2859,23 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, } } // 如果新类型是分组,处理多分组支持 - if (updates.accountType === 'group') { - if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) { + if (mappedUpdates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { // 如果明确提供了 groupIds 参数(包括空数组) - if (updates.groupIds && updates.groupIds.length > 0) { + if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { // 设置新的多分组 - await accountGroupService.setAccountGroups(accountId, updates.groupIds, 'claude') + await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude') } else { // groupIds 为空数组,从所有分组中移除 await accountGroupService.removeAccountFromAllGroups(accountId) } - } else if (updates.groupId) { + } else if (mappedUpdates.groupId) { // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 - await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude') + await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude') } } } - // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt - const mappedUpdates = { ...updates } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt - } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { - mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt - } - if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { - delete mappedUpdates.expiresAt - } - await claudeConsoleAccountService.updateAccount(accountId, mappedUpdates) logger.success(`📝 Admin updated Claude Console account: ${accountId}`) @@ -3076,11 +3102,11 @@ router.get('/ccr-accounts', authenticateAdmin, async (req, res) => { // 为每个账户添加使用统计信息 const accountsWithStats = await Promise.all( accounts.map(async (account) => { - const formattedAccount = formatSubscriptionExpiry(account) try { const usageStats = await redis.getAccountUsageStats(account.id) const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, // 转换schedulable为布尔值 @@ -3099,6 +3125,7 @@ router.get('/ccr-accounts', authenticateAdmin, async (req, res) => { ) try { const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, // 转换schedulable为布尔值 @@ -3116,7 +3143,7 @@ router.get('/ccr-accounts', authenticateAdmin, async (req, res) => { groupError.message ) return { - ...formattedAccount, + ...account, groupInfos: [], usage: { daily: { tokens: 0, requests: 0, allTokens: 0 }, @@ -3129,8 +3156,7 @@ router.get('/ccr-accounts', authenticateAdmin, async (req, res) => { }) ) - const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) - return res.json({ success: true, data: formattedAccounts }) + return res.json({ success: true, data: accountsWithStats }) } catch (error) { logger.error('❌ Failed to get CCR accounts:', error) return res.status(500).json({ error: 'Failed to get CCR accounts', message: error.message }) @@ -3199,8 +3225,8 @@ router.post('/ccr-accounts', authenticateAdmin, async (req, res) => { } logger.success(`🔧 Admin created CCR account: ${name}`) - const responseAccount = formatSubscriptionExpiry(newAccount) - return res.json({ success: true, data: responseAccount }) + const formattedAccount = formatAccountExpiry(newAccount) + return res.json({ success: true, data: formattedAccount }) } catch (error) { logger.error('❌ Failed to create CCR account:', error) return res.status(500).json({ error: 'Failed to create CCR account', message: error.message }) @@ -3213,20 +3239,29 @@ router.put('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => { const { accountId } = req.params const updates = req.body + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'CCR', accountId) + // 验证priority的有效性(1-100) - if (updates.priority !== undefined && (updates.priority < 1 || updates.priority > 100)) { + if ( + mappedUpdates.priority !== undefined && + (mappedUpdates.priority < 1 || mappedUpdates.priority > 100) + ) { return res.status(400).json({ error: 'Priority must be between 1 and 100' }) } // 验证accountType的有效性 - if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { + if ( + mappedUpdates.accountType && + !['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType) + ) { return res .status(400) .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) } // 如果更新为分组类型,验证groupId - if (updates.accountType === 'group' && !updates.groupId) { + if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) { return res.status(400).json({ error: 'Group ID is required for group type accounts' }) } @@ -3237,7 +3272,7 @@ router.put('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => { } // 处理分组的变更 - if (updates.accountType !== undefined) { + if (mappedUpdates.accountType !== undefined) { // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { const oldGroups = await accountGroupService.getAccountGroups(accountId) @@ -3246,34 +3281,23 @@ router.put('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => { } } // 如果新类型是分组,处理多分组支持 - if (updates.accountType === 'group') { - if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) { + if (mappedUpdates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { // 如果明确提供了 groupIds 参数(包括空数组) - if (updates.groupIds && updates.groupIds.length > 0) { + if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { // 设置新的多分组 - await accountGroupService.setAccountGroups(accountId, updates.groupIds, 'claude') + await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude') } else { // groupIds 为空数组,从所有分组中移除 await accountGroupService.removeAccountFromAllGroups(accountId) } - } else if (updates.groupId) { + } else if (mappedUpdates.groupId) { // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 - await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude') + await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude') } } } - // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt - const mappedUpdates = { ...updates } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt - } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { - mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt - } - if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { - delete mappedUpdates.expiresAt - } - await ccrAccountService.updateAccount(accountId, mappedUpdates) logger.success(`📝 Admin updated CCR account: ${accountId}`) @@ -3488,11 +3512,11 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { // 为每个账户添加使用统计信息 const accountsWithStats = await Promise.all( accounts.map(async (account) => { - const formattedAccount = formatSubscriptionExpiry(account) try { const usageStats = await redis.getAccountUsageStats(account.id, 'openai') const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, groupInfos, @@ -3509,6 +3533,7 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { ) try { const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, groupInfos, @@ -3524,7 +3549,7 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { groupError.message ) return { - ...formattedAccount, + ...account, groupInfos: [], usage: { daily: { tokens: 0, requests: 0, allTokens: 0 }, @@ -3537,8 +3562,7 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { }) ) - const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) - return res.json({ success: true, data: formattedAccounts }) + return res.json({ success: true, data: accountsWithStats }) } catch (error) { logger.error('❌ Failed to get Bedrock accounts:', error) return res.status(500).json({ error: 'Failed to get Bedrock accounts', message: error.message }) @@ -3600,8 +3624,8 @@ router.post('/bedrock-accounts', authenticateAdmin, async (req, res) => { } logger.success(`☁️ Admin created Bedrock account: ${name}`) - const responseAccount = formatSubscriptionExpiry(result.data) - return res.json({ success: true, data: responseAccount }) + const formattedAccount = formatAccountExpiry(formattedAccount) + return res.json({ success: true, data: result.data }) } catch (error) { logger.error('❌ Failed to create Bedrock account:', error) return res @@ -3616,13 +3640,19 @@ router.put('/bedrock-accounts/:accountId', authenticateAdmin, async (req, res) = const { accountId } = req.params const updates = req.body + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'Bedrock', accountId) + // 验证priority的有效性(1-100) - if (updates.priority !== undefined && (updates.priority < 1 || updates.priority > 100)) { + if ( + mappedUpdates.priority !== undefined && + (mappedUpdates.priority < 1 || mappedUpdates.priority > 100) + ) { return res.status(400).json({ error: 'Priority must be between 1 and 100' }) } // 验证accountType的有效性 - if (updates.accountType && !['shared', 'dedicated'].includes(updates.accountType)) { + if (mappedUpdates.accountType && !['shared', 'dedicated'].includes(mappedUpdates.accountType)) { return res .status(400) .json({ error: 'Invalid account type. Must be "shared" or "dedicated"' }) @@ -3630,25 +3660,14 @@ router.put('/bedrock-accounts/:accountId', authenticateAdmin, async (req, res) = // 验证credentialType的有效性 if ( - updates.credentialType && - !['default', 'access_key', 'bearer_token'].includes(updates.credentialType) + mappedUpdates.credentialType && + !['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType) ) { return res.status(400).json({ error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"' }) } - // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt - const mappedUpdates = { ...updates } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt - } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { - mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt - } - if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { - delete mappedUpdates.expiresAt - } - const result = await bedrockAccountService.updateAccount(accountId, mappedUpdates) if (!result.success) { @@ -3968,11 +3987,11 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { // 为每个账户添加使用统计信息(与Claude账户相同的逻辑) const accountsWithStats = await Promise.all( accounts.map(async (account) => { - const formattedAccount = formatSubscriptionExpiry(account) try { const usageStats = await redis.getAccountUsageStats(account.id, 'openai') const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, groupInfos, @@ -3990,6 +4009,7 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { // 如果获取统计失败,返回空统计 try { const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, groupInfos, @@ -4005,7 +4025,7 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { groupError.message ) return { - ...formattedAccount, + ...account, groupInfos: [], usage: { daily: { tokens: 0, requests: 0, allTokens: 0 }, @@ -4018,8 +4038,7 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { }) ) - const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) - return res.json({ success: true, data: formattedAccounts }) + return res.json({ success: true, data: accountsWithStats }) } catch (error) { logger.error('❌ Failed to get Gemini accounts:', error) return res.status(500).json({ error: 'Failed to get accounts', message: error.message }) @@ -4059,8 +4078,8 @@ router.post('/gemini-accounts', authenticateAdmin, async (req, res) => { } logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`) - const responseAccount = formatSubscriptionExpiry(newAccount) - return res.json({ success: true, data: responseAccount }) + const formattedAccount = formatAccountExpiry(formattedAccount) + return res.json({ success: true, data: newAccount }) } catch (error) { logger.error('❌ Failed to create Gemini account:', error) return res.status(500).json({ error: 'Failed to create account', message: error.message }) @@ -4091,8 +4110,11 @@ router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => return res.status(404).json({ error: 'Account not found' }) } + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'Gemini', accountId) + // 处理分组的变更 - if (updates.accountType !== undefined) { + if (mappedUpdates.accountType !== undefined) { // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { const oldGroups = await accountGroupService.getAccountGroups(accountId) @@ -4101,39 +4123,27 @@ router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => } } // 如果新类型是分组,处理多分组支持 - if (updates.accountType === 'group') { - if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) { + if (mappedUpdates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { // 如果明确提供了 groupIds 参数(包括空数组) - if (updates.groupIds && updates.groupIds.length > 0) { + if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { // 设置新的多分组 - await accountGroupService.setAccountGroups(accountId, updates.groupIds, 'gemini') + await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'gemini') } else { // groupIds 为空数组,从所有分组中移除 await accountGroupService.removeAccountFromAllGroups(accountId) } - } else if (updates.groupId) { + } else if (mappedUpdates.groupId) { // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 - await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'gemini') + await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'gemini') } } } - // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt - const mappedUpdates = { ...updates } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt - } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { - mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt - } - if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { - delete mappedUpdates.expiresAt - } - const updatedAccount = await geminiAccountService.updateAccount(accountId, mappedUpdates) logger.success(`📝 Admin updated Gemini account: ${accountId}`) - const responseAccount = formatSubscriptionExpiry(updatedAccount) - return res.json({ success: true, data: responseAccount }) + return res.json({ success: true, data: updatedAccount }) } catch (error) { logger.error('❌ Failed to update Gemini account:', error) return res.status(500).json({ error: 'Failed to update account', message: error.message }) @@ -7281,7 +7291,7 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => { try { const usageStats = await redis.getAccountUsageStats(account.id, 'openai') const groupInfos = await fetchAccountGroups(account.id) - const formattedAccount = formatSubscriptionExpiry(account) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, groupInfos, @@ -7294,7 +7304,7 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => { } catch (error) { logger.debug(`Failed to get usage stats for OpenAI account ${account.id}:`, error) const groupInfos = await fetchAccountGroups(account.id) - const formattedAccount = formatSubscriptionExpiry(account) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, groupInfos, @@ -7310,11 +7320,9 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => { logger.info(`获取 OpenAI 账户列表: ${accountsWithStats.length} 个账户`) - const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) - return res.json({ success: true, - data: formattedAccounts + data: accountsWithStats }) } catch (error) { logger.error('获取 OpenAI 账户列表失败:', error) @@ -7340,8 +7348,7 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => { rateLimitDuration, priority, needsImmediateRefresh, // 是否需要立即刷新 - requireRefreshSuccess, // 是否必须刷新成功才能创建 - subscriptionExpiresAt + requireRefreshSuccess // 是否必须刷新成功才能创建 } = req.body if (!name) { @@ -7363,8 +7370,7 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => { accountInfo: accountInfo || {}, proxy: proxy || null, isActive: true, - schedulable: true, - subscriptionExpiresAt: subscriptionExpiresAt || null + schedulable: true } // 如果需要立即刷新且必须成功(OpenAI 手动模式) @@ -7400,11 +7406,9 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => { logger.success(`✅ 创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`) - const responseAccount = formatSubscriptionExpiry(refreshedAccount) - return res.json({ success: true, - data: responseAccount, + data: refreshedAccount, message: '账户创建成功,并已获取完整 token 信息' }) } catch (refreshError) { @@ -7466,11 +7470,9 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => { logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`) - const responseAccount = formatSubscriptionExpiry(createdAccount) - return res.json({ success: true, - data: responseAccount + data: createdAccount }) } catch (error) { logger.error('创建 OpenAI 账户失败:', error) @@ -7487,17 +7489,24 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => { try { const { id } = req.params const updates = req.body - const { needsImmediateRefresh, requireRefreshSuccess } = updates + + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'OpenAI', id) + + const { needsImmediateRefresh, requireRefreshSuccess } = mappedUpdates // 验证accountType的有效性 - if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { + if ( + mappedUpdates.accountType && + !['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType) + ) { return res .status(400) .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) } // 如果更新为分组类型,验证groupId - if (updates.accountType === 'group' && !updates.groupId) { + if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) { return res.status(400).json({ error: 'Group ID is required for group type accounts' }) } @@ -7508,18 +7517,18 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => { } // 如果更新了 Refresh Token,需要验证其有效性 - if (updates.openaiOauth?.refreshToken && needsImmediateRefresh && requireRefreshSuccess) { + if (mappedUpdates.openaiOauth?.refreshToken && needsImmediateRefresh && requireRefreshSuccess) { // 先更新 token 信息 const tempUpdateData = {} - if (updates.openaiOauth.refreshToken) { - tempUpdateData.refreshToken = updates.openaiOauth.refreshToken + if (mappedUpdates.openaiOauth.refreshToken) { + tempUpdateData.refreshToken = mappedUpdates.openaiOauth.refreshToken } - if (updates.openaiOauth.accessToken) { - tempUpdateData.accessToken = updates.openaiOauth.accessToken + if (mappedUpdates.openaiOauth.accessToken) { + tempUpdateData.accessToken = mappedUpdates.openaiOauth.accessToken } // 更新代理配置(如果有) - if (updates.proxy !== undefined) { - tempUpdateData.proxy = updates.proxy + if (mappedUpdates.proxy !== undefined) { + tempUpdateData.proxy = mappedUpdates.proxy } // 临时更新账户以测试新的 token @@ -7595,7 +7604,7 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => { } // 处理分组的变更 - if (updates.accountType !== undefined) { + if (mappedUpdates.accountType !== undefined) { // 如果之前是分组类型,需要从原分组中移除 if (currentAccount.accountType === 'group') { const oldGroup = await accountGroupService.getAccountGroup(id) @@ -7604,65 +7613,50 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => { } } // 如果新类型是分组,添加到新分组 - if (updates.accountType === 'group' && updates.groupId) { - await accountGroupService.addAccountToGroup(id, updates.groupId, 'openai') + if (mappedUpdates.accountType === 'group' && mappedUpdates.groupId) { + await accountGroupService.addAccountToGroup(id, mappedUpdates.groupId, 'openai') } } // 准备更新数据 - const updateData = { ...updates } + const updateData = { ...mappedUpdates } // 处理敏感数据加密 - if (updates.openaiOauth) { - updateData.openaiOauth = updates.openaiOauth + if (mappedUpdates.openaiOauth) { + updateData.openaiOauth = mappedUpdates.openaiOauth // 编辑时不允许直接输入 ID Token,只能通过刷新获取 - if (updates.openaiOauth.accessToken) { - updateData.accessToken = updates.openaiOauth.accessToken + if (mappedUpdates.openaiOauth.accessToken) { + updateData.accessToken = mappedUpdates.openaiOauth.accessToken } - if (updates.openaiOauth.refreshToken) { - updateData.refreshToken = updates.openaiOauth.refreshToken + if (mappedUpdates.openaiOauth.refreshToken) { + updateData.refreshToken = mappedUpdates.openaiOauth.refreshToken } - if (updates.openaiOauth.expires_in) { + if (mappedUpdates.openaiOauth.expires_in) { updateData.expiresAt = new Date( - Date.now() + updates.openaiOauth.expires_in * 1000 + Date.now() + mappedUpdates.openaiOauth.expires_in * 1000 ).toISOString() } } // 更新账户信息 - if (updates.accountInfo) { - updateData.accountId = updates.accountInfo.accountId || currentAccount.accountId - updateData.chatgptUserId = updates.accountInfo.chatgptUserId || currentAccount.chatgptUserId + if (mappedUpdates.accountInfo) { + updateData.accountId = mappedUpdates.accountInfo.accountId || currentAccount.accountId + updateData.chatgptUserId = + mappedUpdates.accountInfo.chatgptUserId || currentAccount.chatgptUserId updateData.organizationId = - updates.accountInfo.organizationId || currentAccount.organizationId + mappedUpdates.accountInfo.organizationId || currentAccount.organizationId updateData.organizationRole = - updates.accountInfo.organizationRole || currentAccount.organizationRole + mappedUpdates.accountInfo.organizationRole || currentAccount.organizationRole updateData.organizationTitle = - updates.accountInfo.organizationTitle || currentAccount.organizationTitle - updateData.planType = updates.accountInfo.planType || currentAccount.planType - updateData.email = updates.accountInfo.email || currentAccount.email + mappedUpdates.accountInfo.organizationTitle || currentAccount.organizationTitle + updateData.planType = mappedUpdates.accountInfo.planType || currentAccount.planType + updateData.email = mappedUpdates.accountInfo.email || currentAccount.email updateData.emailVerified = - updates.accountInfo.emailVerified !== undefined - ? updates.accountInfo.emailVerified + mappedUpdates.accountInfo.emailVerified !== undefined + ? mappedUpdates.accountInfo.emailVerified : currentAccount.emailVerified } - const hasOauthExpiry = Boolean(updates.openaiOauth?.expires_in) - - // 处理订阅过期时间字段:优先使用 subscriptionExpiresAt,兼容旧版的 expiresAt - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - updateData.subscriptionExpiresAt = updates.subscriptionExpiresAt - } else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt') && !hasOauthExpiry) { - updateData.subscriptionExpiresAt = updates.expiresAt - } - - if ( - !hasOauthExpiry && - Object.prototype.hasOwnProperty.call(updateData, 'subscriptionExpiresAt') - ) { - delete updateData.expiresAt - } - const updatedAccount = await openaiAccountService.updateAccount(id, updateData) // 如果需要刷新但不强制成功(非关键更新) @@ -7677,8 +7671,7 @@ router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => { } logger.success(`📝 Admin updated OpenAI account: ${id}`) - const responseAccount = formatSubscriptionExpiry(updatedAccount) - return res.json({ success: true, data: responseAccount }) + return res.json({ success: true, data: updatedAccount }) } catch (error) { logger.error('❌ Failed to update OpenAI account:', error) return res.status(500).json({ error: 'Failed to update account', message: error.message }) @@ -7759,11 +7752,9 @@ router.put('/openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => `✅ ${account.enabled ? '启用' : '禁用'} OpenAI 账户: ${account.name} (ID: ${id})` ) - const responseAccount = formatSubscriptionExpiry(account) - return res.json({ success: true, - data: responseAccount + data: account }) } catch (error) { logger.error('切换 OpenAI 账户状态失败:', error) @@ -7869,10 +7860,10 @@ router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => { // 为每个账户添加使用统计信息和分组信息 const accountsWithStats = await Promise.all( accounts.map(async (account) => { - const formattedAccount = formatSubscriptionExpiry(account) try { const usageStats = await redis.getAccountUsageStats(account.id, 'openai') const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, groupInfos, @@ -7886,6 +7877,7 @@ router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => { logger.debug(`Failed to get usage stats for Azure OpenAI account ${account.id}:`, error) try { const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, groupInfos, @@ -7898,7 +7890,7 @@ router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => { } catch (groupError) { logger.debug(`Failed to get group info for account ${account.id}:`, groupError) return { - ...formattedAccount, + ...account, groupInfos: [], usage: { daily: { requests: 0, tokens: 0, allTokens: 0 }, @@ -7911,11 +7903,9 @@ router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => { }) ) - const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) - res.json({ success: true, - data: formattedAccounts + data: accountsWithStats }) } catch (error) { logger.error('Failed to fetch Azure OpenAI accounts:', error) @@ -8034,11 +8024,9 @@ router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => { } } - const responseAccount = formatSubscriptionExpiry(account) - res.json({ success: true, - data: responseAccount, + data: account, message: 'Azure OpenAI account created successfully' }) } catch (error) { @@ -8057,23 +8045,14 @@ router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => const { id } = req.params const updates = req.body - // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt - const mappedUpdates = { ...updates } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt - } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { - mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt - } - if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { - delete mappedUpdates.expiresAt - } + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'Azure OpenAI', id) const account = await azureOpenaiAccountService.updateAccount(id, mappedUpdates) - const responseAccount = formatSubscriptionExpiry(account) res.json({ success: true, - data: responseAccount, + data: account, message: 'Azure OpenAI account updated successfully' }) } catch (error) { @@ -8326,7 +8305,6 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) => // 处理额度信息、使用统计和绑定的 API Key 数量 const accountsWithStats = await Promise.all( accounts.map(async (account) => { - const formattedAccount = formatSubscriptionExpiry(account) try { // 检查是否需要重置额度 const today = redis.getDateStringInTimezone() @@ -8380,6 +8358,7 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) => logger.info(`OpenAI-Responses account ${account.id} has ${boundCount} bound API keys`) } + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, boundApiKeysCount: boundCount, @@ -8391,6 +8370,7 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) => } } catch (error) { logger.error(`Failed to process OpenAI-Responses account ${account.id}:`, error) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, boundApiKeysCount: 0, @@ -8404,9 +8384,7 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) => }) ) - const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) - - res.json({ success: true, data: formattedAccounts }) + res.json({ success: true, data: accountsWithStats }) } catch (error) { logger.error('Failed to get OpenAI-Responses accounts:', error) res.status(500).json({ success: false, message: error.message }) @@ -8417,8 +8395,8 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) => router.post('/openai-responses-accounts', authenticateAdmin, async (req, res) => { try { const account = await openaiResponsesAccountService.createAccount(req.body) - const responseAccount = formatSubscriptionExpiry(account) - res.json({ success: true, data: responseAccount }) + const formattedAccount = formatAccountExpiry(account) + res.json({ success: true, data: formattedAccount }) } catch (error) { logger.error('Failed to create OpenAI-Responses account:', error) res.status(500).json({ @@ -8434,27 +8412,19 @@ router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) const { id } = req.params const updates = req.body + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'OpenAI-Responses', id) + // 验证priority的有效性(1-100) - if (updates.priority !== undefined) { - const priority = parseInt(updates.priority) + if (mappedUpdates.priority !== undefined) { + const priority = parseInt(mappedUpdates.priority) if (isNaN(priority) || priority < 1 || priority > 100) { return res.status(400).json({ success: false, message: 'Priority must be a number between 1 and 100' }) } - updates.priority = priority.toString() - } - - // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt - const mappedUpdates = { ...updates } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt - } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { - mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt - } - if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { - delete mappedUpdates.expiresAt + mappedUpdates.priority = priority.toString() } const result = await openaiResponsesAccountService.updateAccount(id, mappedUpdates) @@ -8463,13 +8433,7 @@ router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) return res.status(400).json(result) } - const updatedAccountData = await openaiResponsesAccountService.getAccount(id) - if (updatedAccountData) { - updatedAccountData.apiKey = '***' - } - const responseAccount = formatSubscriptionExpiry(updatedAccountData) - - res.json({ success: true, data: responseAccount }) + res.json({ success: true, ...result }) } catch (error) { logger.error('Failed to update OpenAI-Responses account:', error) res.status(500).json({ @@ -8799,7 +8763,6 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => { // 添加使用统计 const accountsWithStats = await Promise.all( accounts.map(async (account) => { - const formattedAccount = formatSubscriptionExpiry(account) try { const usageStats = await redis.getAccountUsageStats(account.id, 'droid') let groupInfos = [] @@ -8828,6 +8791,7 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => { return count }, 0) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, schedulable: account.schedulable === 'true', @@ -8841,6 +8805,7 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => { } } catch (error) { logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message) + const formattedAccount = formatAccountExpiry(account) return { ...formattedAccount, boundApiKeysCount: 0, @@ -8855,9 +8820,7 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => { }) ) - const formattedAccounts = accountsWithStats.map(formatSubscriptionExpiry) - - return res.json({ success: true, data: formattedAccounts }) + 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 }) @@ -8914,8 +8877,8 @@ router.post('/droid-accounts', authenticateAdmin, async (req, res) => { } logger.success(`Created Droid account: ${account.name} (${account.id})`) - const responseAccount = formatSubscriptionExpiry(account) - return res.json({ success: true, data: responseAccount }) + 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 }) @@ -8927,7 +8890,11 @@ router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => { try { const { id } = req.params const updates = { ...req.body } - const { accountType: rawAccountType, groupId, groupIds } = updates + + // ✅ 【新增】映射字段名:前端的 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' }) @@ -8949,26 +8916,15 @@ router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => { const normalizedGroupIds = Array.isArray(groupIds) ? groupIds.filter((gid) => typeof gid === 'string' && gid.trim()) : [] - const hasGroupIdsField = Object.prototype.hasOwnProperty.call(updates, 'groupIds') - const hasGroupIdField = Object.prototype.hasOwnProperty.call(updates, 'groupId') + const hasGroupIdsField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds') + const hasGroupIdField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupId') const targetAccountType = rawAccountType || currentAccount.accountType || 'shared' - delete updates.groupId - delete updates.groupIds + delete mappedUpdates.groupId + delete mappedUpdates.groupIds if (rawAccountType) { - updates.accountType = targetAccountType - } - - // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt - const mappedUpdates = { ...updates } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - mappedUpdates.subscriptionExpiresAt = updates.subscriptionExpiresAt - } else if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'expiresAt')) { - mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt - } - if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'subscriptionExpiresAt')) { - delete mappedUpdates.expiresAt + mappedUpdates.accountType = targetAccountType } const account = await droidAccountService.updateAccount(id, mappedUpdates) @@ -9003,8 +8959,7 @@ router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => { } } - const responseAccount = formatSubscriptionExpiry(account) - return res.json({ success: true, data: responseAccount }) + 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 }) diff --git a/src/routes/api.js b/src/routes/api.js index f784cae6..2d365440 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -11,7 +11,6 @@ const logger = require('../utils/logger') const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper') const sessionHelper = require('../utils/sessionHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') - const router = express.Router() function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { @@ -722,40 +721,23 @@ router.post('/v1/messages', authenticateApiKey, handleMessagesRequest) // 🚀 Claude API messages 端点 - /claude/v1/messages (别名) router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest) -// 📋 模型列表端点 - Claude Code 客户端需要 +// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini router.get('/v1/models', authenticateApiKey, async (req, res) => { try { - // 返回支持的模型列表 - const models = [ - { - id: 'claude-3-5-sonnet-20241022', - object: 'model', - created: 1669599635, - owned_by: 'anthropic' - }, - { - id: 'claude-3-5-haiku-20241022', - object: 'model', - created: 1669599635, - owned_by: 'anthropic' - }, - { - id: 'claude-3-opus-20240229', - object: 'model', - created: 1669599635, - owned_by: 'anthropic' - }, - { - id: 'claude-sonnet-4-20250514', - object: 'model', - created: 1669599635, - owned_by: 'anthropic' - } - ] + const modelService = require('../services/modelService') + + // 从 modelService 获取所有支持的模型 + const models = modelService.getAllModels() + + // 可选:根据 API Key 的模型限制过滤 + let filteredModels = models + if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) { + filteredModels = models.filter((model) => req.apiKey.restrictedModels.includes(model.id)) + } res.json({ object: 'list', - data: models + data: filteredModels }) } catch (error) { logger.error('❌ Models list error:', error) diff --git a/src/routes/droidRoutes.js b/src/routes/droidRoutes.js index 5ae6c418..f712de19 100644 --- a/src/routes/droidRoutes.js +++ b/src/routes/droidRoutes.js @@ -60,49 +60,6 @@ router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => { } }) -router.post('/claude/v1/messages/count_tokens', authenticateApiKey, async (req, res) => { - try { - const requestBody = { ...req.body } - if ('stream' in requestBody) { - delete requestBody.stream - } - const sessionHash = sessionHelper.generateSessionHash(requestBody) - - if (!hasDroidPermission(req.apiKey)) { - logger.security( - `🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}` - ) - return res.status(403).json({ - error: 'permission_denied', - message: '此 API Key 未启用 Droid 权限' - }) - } - - const result = await droidRelayService.relayRequest( - requestBody, - req.apiKey, - req, - res, - req.headers, - { - endpointType: 'anthropic', - sessionHash, - customPath: '/a/v1/messages/count_tokens', - skipUsageRecord: true, - disableStreaming: true - } - ) - - res.status(result.statusCode).set(result.headers).send(result.body) - } catch (error) { - logger.error('Droid Claude count_tokens relay error:', error) - res.status(500).json({ - error: 'internal_server_error', - message: error.message - }) - } -}) - // OpenAI 端点 - /v1/responses router.post(['/openai/v1/responses', '/openai/responses'], authenticateApiKey, async (req, res) => { try { diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js index f5db5665..87a9d38f 100644 --- a/src/routes/openaiClaudeRoutes.js +++ b/src/routes/openaiClaudeRoutes.js @@ -490,3 +490,4 @@ router.post('/v1/completions', authenticateApiKey, async (req, res) => { }) module.exports = router +module.exports.handleChatCompletion = handleChatCompletion diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index 13776c8d..bfebcd0f 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -919,3 +919,4 @@ router.get('/key-info', authenticateApiKey, async (req, res) => { }) module.exports = router +module.exports.handleResponses = handleResponses diff --git a/src/routes/unified.js b/src/routes/unified.js new file mode 100644 index 00000000..c1f320d2 --- /dev/null +++ b/src/routes/unified.js @@ -0,0 +1,225 @@ +const express = require('express') +const { authenticateApiKey } = require('../middleware/auth') +const logger = require('../utils/logger') +const { handleChatCompletion } = require('./openaiClaudeRoutes') +const { + handleGenerateContent: geminiHandleGenerateContent, + handleStreamGenerateContent: geminiHandleStreamGenerateContent +} = require('./geminiRoutes') +const openaiRoutes = require('./openaiRoutes') + +const router = express.Router() + +// 🔍 根据模型名称检测后端类型 +function detectBackendFromModel(modelName) { + if (!modelName) { + return 'claude' // 默认 Claude + } + + // 首先尝试使用 modelService 查找模型的 provider + try { + const modelService = require('../services/modelService') + const provider = modelService.getModelProvider(modelName) + + if (provider === 'anthropic') { + return 'claude' + } + if (provider === 'openai') { + return 'openai' + } + if (provider === 'google') { + return 'gemini' + } + } catch (error) { + logger.warn(`⚠️ Failed to detect backend from modelService: ${error.message}`) + } + + // 降级到前缀匹配作为后备方案 + const model = modelName.toLowerCase() + + // Claude 模型 + if (model.startsWith('claude-')) { + return 'claude' + } + + // OpenAI 模型 + if ( + model.startsWith('gpt-') || + model.startsWith('o1-') || + model.startsWith('o3-') || + model === 'chatgpt-4o-latest' + ) { + return 'openai' + } + + // Gemini 模型 + if (model.startsWith('gemini-')) { + return 'gemini' + } + + // 默认使用 Claude + return 'claude' +} + +// 🚀 智能后端路由处理器 +async function routeToBackend(req, res, requestedModel) { + const backend = detectBackendFromModel(requestedModel) + + logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`) + + // 检查权限 + const permissions = req.apiKey.permissions || 'all' + + if (backend === 'claude') { + // Claude 后端:通过 OpenAI 兼容层 + if (permissions !== 'all' && permissions !== 'claude') { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Claude', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + await handleChatCompletion(req, res, req.apiKey) + } else if (backend === 'openai') { + // OpenAI 后端 + if (permissions !== 'all' && permissions !== 'openai') { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access OpenAI', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + return await openaiRoutes.handleResponses(req, res) + } else if (backend === 'gemini') { + // Gemini 后端 + if (permissions !== 'all' && permissions !== 'gemini') { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // 转换为 Gemini 格式 + const geminiRequest = { + model: requestedModel, + messages: req.body.messages, + temperature: req.body.temperature || 0.7, + max_tokens: req.body.max_tokens || 4096, + stream: req.body.stream || false + } + + req.body = geminiRequest + + if (geminiRequest.stream) { + return await geminiHandleStreamGenerateContent(req, res) + } else { + return await geminiHandleGenerateContent(req, res) + } + } else { + return res.status(500).json({ + error: { + message: `Unsupported backend: ${backend}`, + type: 'server_error', + code: 'unsupported_backend' + } + }) + } +} + +// 🔄 OpenAI 兼容的 chat/completions 端点(智能后端路由) +router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { + try { + // 验证必需参数 + if (!req.body.messages || !Array.isArray(req.body.messages) || req.body.messages.length === 0) { + return res.status(400).json({ + error: { + message: 'Messages array is required and cannot be empty', + type: 'invalid_request_error', + code: 'invalid_request' + } + }) + } + + const requestedModel = req.body.model || 'claude-3-5-sonnet-20241022' + req.body.model = requestedModel // 确保模型已设置 + + // 使用统一的后端路由处理器 + await routeToBackend(req, res, requestedModel) + } catch (error) { + logger.error('❌ OpenAI chat/completions error:', error) + if (!res.headersSent) { + res.status(500).json({ + error: { + message: 'Internal server error', + type: 'server_error', + code: 'internal_error' + } + }) + } + } +}) + +// 🔄 OpenAI 兼容的 completions 端点(传统格式,智能后端路由) +router.post('/v1/completions', authenticateApiKey, async (req, res) => { + try { + // 验证必需参数 + if (!req.body.prompt) { + return res.status(400).json({ + error: { + message: 'Prompt is required', + type: 'invalid_request_error', + code: 'invalid_request' + } + }) + } + + // 将传统 completions 格式转换为 chat 格式 + const originalBody = req.body + const requestedModel = originalBody.model || 'claude-3-5-sonnet-20241022' + + req.body = { + model: requestedModel, + messages: [ + { + role: 'user', + content: originalBody.prompt + } + ], + max_tokens: originalBody.max_tokens, + temperature: originalBody.temperature, + top_p: originalBody.top_p, + stream: originalBody.stream, + stop: originalBody.stop, + n: originalBody.n || 1, + presence_penalty: originalBody.presence_penalty, + frequency_penalty: originalBody.frequency_penalty, + logit_bias: originalBody.logit_bias, + user: originalBody.user + } + + // 使用统一的后端路由处理器 + await routeToBackend(req, res, requestedModel) + } catch (error) { + logger.error('❌ OpenAI completions error:', error) + if (!res.headersSent) { + res.status(500).json({ + error: { + message: 'Failed to process completion request', + type: 'server_error', + code: 'internal_error' + } + }) + } + } +}) + +module.exports = router +module.exports.detectBackendFromModel = detectBackendFromModel +module.exports.routeToBackend = routeToBackend diff --git a/src/services/azureOpenaiAccountService.js b/src/services/azureOpenaiAccountService.js index daf947db..1f8ded80 100644 --- a/src/services/azureOpenaiAccountService.js +++ b/src/services/azureOpenaiAccountService.js @@ -65,19 +65,6 @@ const AZURE_OPENAI_ACCOUNT_KEY_PREFIX = 'azure_openai:account:' const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts' const ACCOUNT_SESSION_MAPPING_PREFIX = 'azure_openai_session_account_mapping:' -function normalizeSubscriptionExpiresAt(value) { - if (value === undefined || value === null || value === '') { - return '' - } - - const date = value instanceof Date ? value : new Date(value) - if (Number.isNaN(date.getTime())) { - return '' - } - - return date.toISOString() -} - // 加密函数 function encrypt(text) { if (!text) { @@ -142,11 +129,15 @@ async function createAccount(accountData) { supportedModels: JSON.stringify( accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k'] ), + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + // 注意:Azure OpenAI 使用 API Key 认证,没有 OAuth token,因此没有 expiresAt + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, + // 状态字段 isActive: accountData.isActive !== false ? 'true' : 'false', status: 'active', schedulable: accountData.schedulable !== false ? 'true' : 'false', - subscriptionExpiresAt: normalizeSubscriptionExpiresAt(accountData.subscriptionExpiresAt || ''), createdAt: now, updatedAt: now } @@ -166,10 +157,7 @@ async function createAccount(accountData) { } logger.info(`Created Azure OpenAI account: ${accountId}`) - return { - ...account, - subscriptionExpiresAt: account.subscriptionExpiresAt || null - } + return account } // 获取账户 @@ -204,11 +192,6 @@ async function getAccount(accountId) { } } - accountData.subscriptionExpiresAt = - accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' - ? accountData.subscriptionExpiresAt - : null - return accountData } @@ -240,11 +223,10 @@ async function updateAccount(accountId, updates) { : JSON.stringify(updates.supportedModels) } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt) - } else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { - updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt) - delete updates.expiresAt + // ✅ 直接保存 subscriptionExpiresAt(如果提供) + // Azure OpenAI 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段 + if (updates.subscriptionExpiresAt !== undefined) { + // 直接保存,不做任何调整 } // 更新账户类型时处理共享账户集合 @@ -273,10 +255,6 @@ async function updateAccount(accountId, updates) { } } - if (!updatedAccount.subscriptionExpiresAt) { - updatedAccount.subscriptionExpiresAt = null - } - return updatedAccount } @@ -337,7 +315,10 @@ async function getAllAccounts() { ...accountData, isActive: accountData.isActive === 'true', schedulable: accountData.schedulable !== 'false', - subscriptionExpiresAt: accountData.subscriptionExpiresAt || null + + // ✅ 前端显示订阅过期时间(业务字段) + expiresAt: accountData.subscriptionExpiresAt || null, + platform: 'azure-openai' }) } } @@ -365,6 +346,19 @@ async function getSharedAccounts() { return accounts } +/** + * 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ +function isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() +} + // 选择可用账户 async function selectAvailableAccount(sessionId = null) { // 如果有会话ID,尝试获取之前分配的账户 @@ -386,9 +380,17 @@ async function selectAvailableAccount(sessionId = null) { const sharedAccounts = await getSharedAccounts() // 过滤出可用的账户 - const availableAccounts = sharedAccounts.filter( - (acc) => acc.isActive === 'true' && acc.schedulable === 'true' - ) + const availableAccounts = sharedAccounts.filter((acc) => { + // ✅ 检查账户订阅是否过期 + if (isSubscriptionExpired(acc)) { + logger.debug( + `⏰ Skipping expired Azure OpenAI account: ${acc.name}, expired at ${acc.subscriptionExpiresAt}` + ) + return false + } + + return acc.isActive === 'true' && acc.schedulable === 'true' + }) if (availableAccounts.length === 0) { throw new Error('No available Azure OpenAI accounts') diff --git a/src/services/bedrockAccountService.js b/src/services/bedrockAccountService.js index 6345f523..cd404b13 100644 --- a/src/services/bedrockAccountService.js +++ b/src/services/bedrockAccountService.js @@ -6,19 +6,6 @@ const config = require('../../config/config') const bedrockRelayService = require('./bedrockRelayService') const LRUCache = require('../utils/lruCache') -function normalizeSubscriptionExpiresAt(value) { - if (value === undefined || value === null || value === '') { - return '' - } - - const date = value instanceof Date ? value : new Date(value) - if (Number.isNaN(date.getTime())) { - return '' - } - - return date.toISOString() -} - class BedrockAccountService { constructor() { // 加密相关常量 @@ -53,8 +40,7 @@ class BedrockAccountService { accountType = 'shared', // 'dedicated' or 'shared' priority = 50, // 调度优先级 (1-100,数字越小优先级越高) schedulable = true, // 是否可被调度 - credentialType = 'default', // 'default', 'access_key', 'bearer_token' - subscriptionExpiresAt = null + credentialType = 'default' // 'default', 'access_key', 'bearer_token' } = options const accountId = uuidv4() @@ -70,7 +56,11 @@ class BedrockAccountService { priority, schedulable, credentialType, - subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt), + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + // 注意:Bedrock 使用 AWS 凭证,没有 OAuth token,因此没有 expiresAt + subscriptionExpiresAt: options.subscriptionExpiresAt || null, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), type: 'bedrock' // 标识这是Bedrock账户 @@ -99,7 +89,6 @@ class BedrockAccountService { priority, schedulable, credentialType, - subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, createdAt: accountData.createdAt, type: 'bedrock' } @@ -122,11 +111,6 @@ class BedrockAccountService { account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) } - account.subscriptionExpiresAt = - account.subscriptionExpiresAt && account.subscriptionExpiresAt !== '' - ? account.subscriptionExpiresAt - : null - logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`) return { @@ -163,12 +147,15 @@ class BedrockAccountService { priority: account.priority, schedulable: account.schedulable, credentialType: account.credentialType, + + // ✅ 前端显示订阅过期时间(业务字段) + expiresAt: account.subscriptionExpiresAt || null, + createdAt: account.createdAt, updatedAt: account.updatedAt, type: 'bedrock', - hasCredentials: !!account.awsCredentials, - expiresAt: account.expiresAt || null, - subscriptionExpiresAt: account.subscriptionExpiresAt || null + platform: 'bedrock', + hasCredentials: !!account.awsCredentials }) } } @@ -234,14 +221,6 @@ class BedrockAccountService { account.credentialType = updates.credentialType } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - account.subscriptionExpiresAt = normalizeSubscriptionExpiresAt( - updates.subscriptionExpiresAt - ) - } else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { - account.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt) - } - // 更新AWS凭证 if (updates.awsCredentials !== undefined) { if (updates.awsCredentials) { @@ -256,6 +235,12 @@ class BedrockAccountService { logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`) } + // ✅ 直接保存 subscriptionExpiresAt(如果提供) + // Bedrock 没有 token 刷新逻辑,不会覆盖此字段 + if (updates.subscriptionExpiresAt !== undefined) { + account.subscriptionExpiresAt = updates.subscriptionExpiresAt + } + account.updatedAt = new Date().toISOString() await client.set(`bedrock_account:${accountId}`, JSON.stringify(account)) @@ -276,9 +261,7 @@ class BedrockAccountService { schedulable: account.schedulable, credentialType: account.credentialType, updatedAt: account.updatedAt, - type: 'bedrock', - expiresAt: account.expiresAt || null, - subscriptionExpiresAt: account.subscriptionExpiresAt || null + type: 'bedrock' } } } catch (error) { @@ -315,9 +298,17 @@ class BedrockAccountService { return { success: false, error: 'Failed to get accounts' } } - const availableAccounts = accountsResult.data.filter( - (account) => account.isActive && account.schedulable - ) + const availableAccounts = accountsResult.data.filter((account) => { + // ✅ 检查账户订阅是否过期 + if (this.isSubscriptionExpired(account)) { + logger.debug( + `⏰ Skipping expired Bedrock account: ${account.name}, expired at ${account.subscriptionExpiresAt || account.expiresAt}` + ) + return false + } + + return account.isActive && account.schedulable + }) if (availableAccounts.length === 0) { return { success: false, error: 'No available Bedrock accounts' } @@ -385,6 +376,19 @@ class BedrockAccountService { } } + /** + * 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ + isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() + } + // 🔑 生成加密密钥(缓存优化) _generateEncryptionKey() { if (!this._encryptionKeyCache) { diff --git a/src/services/ccrAccountService.js b/src/services/ccrAccountService.js index 3f4967fe..fa967d58 100644 --- a/src/services/ccrAccountService.js +++ b/src/services/ccrAccountService.js @@ -6,19 +6,6 @@ const logger = require('../utils/logger') const config = require('../../config/config') const LRUCache = require('../utils/lruCache') -function normalizeSubscriptionExpiresAt(value) { - if (value === undefined || value === null || value === '') { - return '' - } - - const date = value instanceof Date ? value : new Date(value) - if (Number.isNaN(date.getTime())) { - return '' - } - - return date.toISOString() -} - class CcrAccountService { constructor() { // 加密相关常量 @@ -62,8 +49,7 @@ class CcrAccountService { accountType = 'shared', // 'dedicated' or 'shared' schedulable = true, // 是否可被调度 dailyQuota = 0, // 每日额度限制(美元),0表示不限制 - quotaResetTime = '00:00', // 额度重置时间(HH:mm格式) - subscriptionExpiresAt = null + quotaResetTime = '00:00' // 额度重置时间(HH:mm格式) } = options // 验证必填字段 @@ -90,6 +76,11 @@ class CcrAccountService { proxy: proxy ? JSON.stringify(proxy) : '', isActive: isActive.toString(), accountType, + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + // 注意:CCR 使用 API Key 认证,没有 OAuth token,因此没有 expiresAt + subscriptionExpiresAt: options.subscriptionExpiresAt || null, + createdAt: new Date().toISOString(), lastUsedAt: '', status: 'active', @@ -105,8 +96,7 @@ class CcrAccountService { // 使用与统计一致的时区日期,避免边界问题 lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区) quotaResetTime, // 额度重置时间 - quotaStoppedAt: '', // 因额度停用的时间 - subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt) + quotaStoppedAt: '' // 因额度停用的时间 } const client = redis.getClientSafe() @@ -142,8 +132,7 @@ class CcrAccountService { dailyUsage: 0, lastResetDate: accountData.lastResetDate, quotaResetTime, - quotaStoppedAt: null, - subscriptionExpiresAt: accountData.subscriptionExpiresAt || null + quotaStoppedAt: null } } @@ -181,14 +170,16 @@ class CcrAccountService { errorMessage: accountData.errorMessage, rateLimitInfo, schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度 + + // ✅ 前端显示订阅过期时间(业务字段) + expiresAt: accountData.subscriptionExpiresAt || null, + // 额度管理相关 dailyQuota: parseFloat(accountData.dailyQuota || '0'), dailyUsage: parseFloat(accountData.dailyUsage || '0'), lastResetDate: accountData.lastResetDate || '', quotaResetTime: accountData.quotaResetTime || '00:00', - quotaStoppedAt: accountData.quotaStoppedAt || null, - expiresAt: accountData.expiresAt || null, - subscriptionExpiresAt: accountData.subscriptionExpiresAt || null + quotaStoppedAt: accountData.quotaStoppedAt || null }) } } @@ -243,11 +234,6 @@ class CcrAccountService { `[DEBUG] Final CCR account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}` ) - accountData.subscriptionExpiresAt = - accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' - ? accountData.subscriptionExpiresAt - : null - return accountData } @@ -311,12 +297,10 @@ class CcrAccountService { updatedData.quotaResetTime = updates.quotaResetTime } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt( - updates.subscriptionExpiresAt - ) - } else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { - updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt) + // ✅ 直接保存 subscriptionExpiresAt(如果提供) + // CCR 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段 + if (updates.subscriptionExpiresAt !== undefined) { + updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt } await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData) @@ -929,6 +913,19 @@ class CcrAccountService { throw error } } + + /** + * ⏰ 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ + isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() + } } module.exports = new CcrAccountService() diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 66f47369..40b6a59c 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -787,13 +787,13 @@ class ClaudeAccountService { } /** - * 检查账户是否未过期 + * 检查账户订阅是否过期 * @param {Object} account - 账户对象 - * @returns {boolean} - 如果未设置过期时间或未过期返回 true + * @returns {boolean} - true: 已过期, false: 未过期 */ - isAccountNotExpired(account) { + isSubscriptionExpired(account) { if (!account.subscriptionExpiresAt) { - return true // 未设置过期时间,视为永不过期 + return false // 未设置过期时间,视为永不过期 } const expiryDate = new Date(account.subscriptionExpiresAt) @@ -803,10 +803,10 @@ class ClaudeAccountService { logger.debug( `⏰ Account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}` ) - return false + return true } - return true + return false } // 🎯 智能选择可用账户(支持sticky会话和模型过滤) @@ -819,7 +819,7 @@ class ClaudeAccountService { account.isActive === 'true' && account.status !== 'error' && account.schedulable !== 'false' && - this.isAccountNotExpired(account) + !this.isSubscriptionExpired(account) ) // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 @@ -915,7 +915,7 @@ class ClaudeAccountService { boundAccount.isActive === 'true' && boundAccount.status !== 'error' && boundAccount.schedulable !== 'false' && - this.isAccountNotExpired(boundAccount) + !this.isSubscriptionExpired(boundAccount) ) { logger.info( `🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` @@ -937,7 +937,7 @@ class ClaudeAccountService { account.status !== 'error' && account.schedulable !== 'false' && (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 - this.isAccountNotExpired(account) + !this.isSubscriptionExpired(account) ) // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index 2a70a530..dbdee522 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -6,19 +6,6 @@ const logger = require('../utils/logger') const config = require('../../config/config') const LRUCache = require('../utils/lruCache') -function normalizeSubscriptionExpiresAt(value) { - if (value === undefined || value === null || value === '') { - return '' - } - - const date = value instanceof Date ? value : new Date(value) - if (Number.isNaN(date.getTime())) { - return '' - } - - return date.toISOString() -} - class ClaudeConsoleAccountService { constructor() { // 加密相关常量 @@ -65,8 +52,7 @@ class ClaudeConsoleAccountService { accountType = 'shared', // 'dedicated' or 'shared' schedulable = true, // 是否可被调度 dailyQuota = 0, // 每日额度限制(美元),0表示不限制 - quotaResetTime = '00:00', // 额度重置时间(HH:mm格式) - subscriptionExpiresAt = null + quotaResetTime = '00:00' // 额度重置时间(HH:mm格式) } = options // 验证必填字段 @@ -97,6 +83,11 @@ class ClaudeConsoleAccountService { lastUsedAt: '', status: 'active', errorMessage: '', + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + // 注意:Claude Console 没有 OAuth token,因此没有 expiresAt(token过期) + subscriptionExpiresAt: options.subscriptionExpiresAt || null, + // 限流相关 rateLimitedAt: '', rateLimitStatus: '', @@ -108,8 +99,7 @@ class ClaudeConsoleAccountService { // 使用与统计一致的时区日期,避免边界问题 lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区) quotaResetTime, // 额度重置时间 - quotaStoppedAt: '', // 因额度停用的时间 - subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt) + quotaStoppedAt: '' // 因额度停用的时间 } const client = redis.getClientSafe() @@ -145,8 +135,7 @@ class ClaudeConsoleAccountService { dailyUsage: 0, lastResetDate: accountData.lastResetDate, quotaResetTime, - quotaStoppedAt: null, - subscriptionExpiresAt: accountData.subscriptionExpiresAt || null + quotaStoppedAt: null } } @@ -184,14 +173,16 @@ class ClaudeConsoleAccountService { errorMessage: accountData.errorMessage, rateLimitInfo, schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度 + + // ✅ 前端显示订阅过期时间(业务字段) + expiresAt: accountData.subscriptionExpiresAt || null, + // 额度管理相关 dailyQuota: parseFloat(accountData.dailyQuota || '0'), dailyUsage: parseFloat(accountData.dailyUsage || '0'), lastResetDate: accountData.lastResetDate || '', quotaResetTime: accountData.quotaResetTime || '00:00', - quotaStoppedAt: accountData.quotaStoppedAt || null, - expiresAt: accountData.expiresAt || null, - subscriptionExpiresAt: accountData.subscriptionExpiresAt || null + quotaStoppedAt: accountData.quotaStoppedAt || null }) } } @@ -242,11 +233,6 @@ class ClaudeConsoleAccountService { accountData.proxy = JSON.parse(accountData.proxy) } - accountData.subscriptionExpiresAt = - accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' - ? accountData.subscriptionExpiresAt - : null - logger.debug( `[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}` ) @@ -341,12 +327,10 @@ class ClaudeConsoleAccountService { updatedData.quotaStoppedAt = updates.quotaStoppedAt } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt( - updates.subscriptionExpiresAt - ) - } else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { - updatedData.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt) + // ✅ 直接保存 subscriptionExpiresAt(如果提供) + // Claude Console 没有 token 刷新逻辑,不会覆盖此字段 + if (updates.subscriptionExpiresAt !== undefined) { + updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt } // 处理账户类型变更 @@ -1270,6 +1254,19 @@ class ClaudeConsoleAccountService { throw error } } + + /** + * ⏰ 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ + isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() + } } module.exports = new ClaudeConsoleAccountService() diff --git a/src/services/droidAccountService.js b/src/services/droidAccountService.js index 34fc2c59..a27ac935 100644 --- a/src/services/droidAccountService.js +++ b/src/services/droidAccountService.js @@ -794,7 +794,11 @@ class DroidAccountService { description, refreshToken: this._encryptSensitiveData(normalizedRefreshToken), accessToken: this._encryptSensitiveData(normalizedAccessToken), - expiresAt: normalizedExpiresAt || '', + expiresAt: normalizedExpiresAt || '', // OAuth Token 过期时间(技术字段,自动刷新) + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + subscriptionExpiresAt: options.subscriptionExpiresAt || null, + proxy: proxy ? JSON.stringify(proxy) : '', isActive: isActive.toString(), accountType, @@ -880,6 +884,11 @@ class DroidAccountService { accessToken: account.accessToken ? maskToken(this._decryptSensitiveData(account.accessToken)) : '', + + // ✅ 前端显示订阅过期时间(业务字段) + expiresAt: account.subscriptionExpiresAt || null, + platform: account.platform || 'droid', + apiKeyCount: (() => { const parsedCount = this._parseApiKeyEntries(account.apiKeys).length if (account.apiKeyCount === undefined || account.apiKeyCount === null) { @@ -1020,6 +1029,12 @@ class DroidAccountService { } } + // ✅ 如果通过路由映射更新了 subscriptionExpiresAt,直接保存 + // subscriptionExpiresAt 是业务字段,与 token 刷新独立 + if (sanitizedUpdates.subscriptionExpiresAt !== undefined) { + // 直接保存,不做任何调整 + } + if (sanitizedUpdates.proxy === undefined) { sanitizedUpdates.proxy = account.proxy || '' } @@ -1374,6 +1389,19 @@ class DroidAccountService { return hoursSinceRefresh >= this.refreshIntervalHours } + /** + * 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ + isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() + } + /** * 获取有效的 access token(自动刷新) */ @@ -1419,6 +1447,14 @@ class DroidAccountService { const isSchedulable = this._isTruthy(account.schedulable) const status = typeof account.status === 'string' ? account.status.toLowerCase() : '' + // ✅ 检查账户订阅是否过期 + if (this.isSubscriptionExpired(account)) { + logger.debug( + `⏰ Skipping expired Droid account: ${account.name}, expired at ${account.subscriptionExpiresAt}` + ) + return false + } + if (!isActive || !isSchedulable || status !== 'active') { return false } diff --git a/src/services/droidRelayService.js b/src/services/droidRelayService.js index 38cd9a6b..45f1b229 100644 --- a/src/services/droidRelayService.js +++ b/src/services/droidRelayService.js @@ -7,8 +7,10 @@ const apiKeyService = require('./apiKeyService') const redis = require('../models/redis') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const logger = require('../utils/logger') +const runtimeAddon = require('../utils/runtimeAddon') const SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.' +const RUNTIME_EVENT_FMT_PAYLOAD = 'fmtPayload' /** * Droid API 转发服务 @@ -23,7 +25,7 @@ class DroidRelayService { openai: '/o/v1/responses' } - this.userAgent = 'factory-cli/0.19.4' + this.userAgent = 'factory-cli/0.19.12' this.systemPrompt = SYSTEM_PROMPT this.API_KEY_STICKY_PREFIX = 'droid_api_key' } @@ -246,11 +248,34 @@ class DroidRelayService { // 处理请求体(注入 system prompt 等) const streamRequested = !disableStreaming && this._isStreamRequested(normalizedRequestBody) - const processedBody = this._processRequestBody(normalizedRequestBody, normalizedEndpoint, { + let processedBody = this._processRequestBody(normalizedRequestBody, normalizedEndpoint, { disableStreaming, streamRequested }) + const extensionPayload = { + body: processedBody, + endpoint: normalizedEndpoint, + rawRequest: normalizedRequestBody, + originalRequest: requestBody + } + + const extensionResult = runtimeAddon.emitSync(RUNTIME_EVENT_FMT_PAYLOAD, extensionPayload) + const resolvedPayload = + extensionResult && typeof extensionResult === 'object' ? extensionResult : extensionPayload + + if (resolvedPayload && typeof resolvedPayload === 'object') { + if (resolvedPayload.abortResponse && typeof resolvedPayload.abortResponse === 'object') { + return resolvedPayload.abortResponse + } + + if (resolvedPayload.body && typeof resolvedPayload.body === 'object') { + processedBody = resolvedPayload.body + } else if (resolvedPayload !== extensionPayload) { + processedBody = resolvedPayload + } + } + // 发送请求 const isStreaming = streamRequested diff --git a/src/services/droidScheduler.js b/src/services/droidScheduler.js index 67add5ea..627dac35 100644 --- a/src/services/droidScheduler.js +++ b/src/services/droidScheduler.js @@ -171,7 +171,7 @@ class DroidScheduler { if (filtered.length === 0) { throw new Error( - `No available Droid accounts for endpoint ${normalizedEndpoint}${apiKeyData?.droidAccountId ? ' (respecting binding)' : ''}` + `No available accounts for endpoint ${normalizedEndpoint}${apiKeyData?.droidAccountId ? ' (respecting binding)' : ''}` ) } @@ -196,9 +196,7 @@ class DroidScheduler { const selected = sorted[0] if (!selected) { - throw new Error( - `No schedulable Droid account available after sorting (${normalizedEndpoint})` - ) + throw new Error(`No schedulable account available after sorting (${normalizedEndpoint})`) } if (stickyKey && !isDedicatedBinding) { diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index c1a91acf..134600ec 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -42,19 +42,6 @@ function generateEncryptionKey() { return _encryptionKeyCache } -function normalizeSubscriptionExpiresAt(value) { - if (value === undefined || value === null || value === '') { - return '' - } - - const date = value instanceof Date ? value : new Date(value) - if (Number.isNaN(date.getTime())) { - return '' - } - - return date.toISOString() -} - // Gemini 账户键前缀 const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:' const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts' @@ -346,10 +333,6 @@ async function createAccount(accountData) { let refreshToken = '' let expiresAt = '' - const subscriptionExpiresAt = normalizeSubscriptionExpiresAt( - accountData.subscriptionExpiresAt || '' - ) - if (accountData.geminiOauth || accountData.accessToken) { // 如果提供了完整的 OAuth 数据 if (accountData.geminiOauth) { @@ -401,10 +384,13 @@ async function createAccount(accountData) { geminiOauth: geminiOauth ? encrypt(geminiOauth) : '', accessToken: accessToken ? encrypt(accessToken) : '', refreshToken: refreshToken ? encrypt(refreshToken) : '', - expiresAt, + expiresAt, // OAuth Token 过期时间(技术字段,自动刷新) // 只有OAuth方式才有scopes,手动添加的没有 scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '', + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, + // 代理设置 proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '', @@ -421,8 +407,7 @@ async function createAccount(accountData) { createdAt: now, updatedAt: now, lastUsedAt: '', - lastRefreshAt: '', - subscriptionExpiresAt + lastRefreshAt: '' } // 保存到 Redis @@ -446,10 +431,6 @@ async function createAccount(accountData) { } } - if (!returnAccount.subscriptionExpiresAt) { - returnAccount.subscriptionExpiresAt = null - } - return returnAccount } @@ -486,10 +467,6 @@ async function getAccount(accountId) { // 转换 schedulable 字符串为布尔值(与 claudeConsoleAccountService 保持一致) accountData.schedulable = accountData.schedulable !== 'false' // 默认为true,只有明确设置为'false'才为false - if (!accountData.subscriptionExpiresAt) { - accountData.subscriptionExpiresAt = null - } - return accountData } @@ -503,10 +480,6 @@ async function updateAccount(accountId, updates) { const now = new Date().toISOString() updates.updatedAt = now - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt) - } - // 检查是否新增了 refresh token // existingAccount.refreshToken 已经是解密后的值了(从 getAccount 返回) const oldRefreshToken = existingAccount.refreshToken || '' @@ -551,15 +524,23 @@ async function updateAccount(accountId, updates) { } } - // 如果新增了 refresh token,更新过期时间为10分钟 + // ✅ 关键:如果新增了 refresh token,只更新 token 过期时间 + // 不要覆盖 subscriptionExpiresAt if (needUpdateExpiry) { const newExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString() - updates.expiresAt = newExpiry + updates.expiresAt = newExpiry // 只更新 OAuth Token 过期时间 + // ⚠️ 重要:不要修改 subscriptionExpiresAt logger.info( - `🔄 New refresh token added for Gemini account ${accountId}, setting expiry to 10 minutes` + `🔄 New refresh token added for Gemini account ${accountId}, setting token expiry to 10 minutes` ) } + // ✅ 如果通过路由映射更新了 subscriptionExpiresAt,直接保存 + // subscriptionExpiresAt 是业务字段,与 token 刷新独立 + if (updates.subscriptionExpiresAt !== undefined) { + // 直接保存,不做任何调整 + } + // 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token if (updates.geminiOauth && !oldRefreshToken) { const oauthData = @@ -616,10 +597,6 @@ async function updateAccount(accountId, updates) { } } - if (!updatedAccount.subscriptionExpiresAt) { - updatedAccount.subscriptionExpiresAt = null - } - return updatedAccount } @@ -677,13 +654,25 @@ async function getAllAccounts() { // 转换 schedulable 字符串为布尔值(与 getAccount 保持一致) accountData.schedulable = accountData.schedulable !== 'false' // 默认为true,只有明确设置为'false'才为false + const tokenExpiresAt = accountData.expiresAt || null + const subscriptionExpiresAt = + accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' + ? accountData.subscriptionExpiresAt + : null + // 不解密敏感字段,只返回基本信息 accounts.push({ ...accountData, geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '', accessToken: accountData.accessToken ? '[ENCRYPTED]' : '', refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '', - subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, + + // ✅ 前端显示订阅过期时间(业务字段) + // 注意:前端看到的 expiresAt 实际上是 subscriptionExpiresAt + tokenExpiresAt, + subscriptionExpiresAt, + expiresAt: subscriptionExpiresAt, + // 添加 scopes 字段用于判断认证方式 // 处理空字符串和默认值的情况 scopes: @@ -762,8 +751,17 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) { for (const accountId of sharedAccountIds) { const account = await getAccount(accountId) - if (account && account.isActive === 'true' && !isRateLimited(account)) { + if ( + account && + account.isActive === 'true' && + !isRateLimited(account) && + !isSubscriptionExpired(account) + ) { availableAccounts.push(account) + } else if (account && isSubscriptionExpired(account)) { + logger.debug( + `⏰ Skipping expired Gemini account: ${account.name}, expired at ${account.subscriptionExpiresAt}` + ) } } @@ -818,6 +816,19 @@ function isTokenExpired(account) { return now >= expiryTime - buffer } +/** + * 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ +function isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() +} + // 检查账户是否被限流 function isRateLimited(account) { if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { diff --git a/src/services/modelService.js b/src/services/modelService.js new file mode 100644 index 00000000..1bee9dba --- /dev/null +++ b/src/services/modelService.js @@ -0,0 +1,266 @@ +const fs = require('fs') +const path = require('path') +const logger = require('../utils/logger') + +/** + * 模型服务 + * 管理系统支持的 AI 模型列表 + * 与 pricingService 独立,专注于"支持哪些模型"而不是"如何计费" + */ +class ModelService { + constructor() { + this.modelsFile = path.join(process.cwd(), 'data', 'supported_models.json') + this.supportedModels = null + this.fileWatcher = null + } + + /** + * 初始化模型服务 + */ + async initialize() { + try { + this.loadModels() + this.setupFileWatcher() + logger.success('✅ Model service initialized successfully') + } catch (error) { + logger.error('❌ Failed to initialize model service:', error) + } + } + + /** + * 加载支持的模型配置 + */ + loadModels() { + try { + if (fs.existsSync(this.modelsFile)) { + const data = fs.readFileSync(this.modelsFile, 'utf8') + this.supportedModels = JSON.parse(data) + + const totalModels = Object.values(this.supportedModels).reduce( + (sum, config) => sum + config.models.length, + 0 + ) + + logger.info(`📋 Loaded ${totalModels} supported models from configuration`) + } else { + logger.warn('⚠️ Supported models file not found, using defaults') + this.supportedModels = this.getDefaultModels() + + // 创建默认配置文件 + this.saveDefaultConfig() + } + } catch (error) { + logger.error('❌ Failed to load supported models:', error) + this.supportedModels = this.getDefaultModels() + } + } + + /** + * 获取默认模型配置(后备方案) + */ + getDefaultModels() { + return { + claude: { + provider: 'anthropic', + description: 'Claude models from Anthropic', + models: [ + 'claude-sonnet-4-5-20250929', + 'claude-opus-4-1-20250805', + 'claude-sonnet-4-20250514', + 'claude-opus-4-20250514', + 'claude-3-7-sonnet-20250219', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022', + 'claude-3-opus-20240229', + 'claude-3-haiku-20240307' + ] + }, + openai: { + provider: 'openai', + description: 'OpenAI GPT models', + models: [ + 'gpt-4o', + 'gpt-4o-mini', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4-turbo', + 'gpt-4', + 'gpt-3.5-turbo', + 'o3', + 'o4-mini', + 'chatgpt-4o-latest' + ] + }, + gemini: { + provider: 'google', + description: 'Google Gemini models', + models: [ + 'gemini-1.5-pro', + 'gemini-1.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-exp', + 'gemini-2.0-flash-thinking', + 'gemini-2.0-flash-thinking-exp', + 'gemini-2.0-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', + 'gemini-2.5-pro' + ] + } + } + } + + /** + * 保存默认配置到文件 + */ + saveDefaultConfig() { + try { + const dataDir = path.dirname(this.modelsFile) + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }) + } + + fs.writeFileSync(this.modelsFile, JSON.stringify(this.supportedModels, null, 2)) + logger.info('💾 Created default supported_models.json configuration') + } catch (error) { + logger.error('❌ Failed to save default config:', error) + } + } + + /** + * 获取所有支持的模型(OpenAI API 格式) + */ + getAllModels() { + const models = [] + const now = Math.floor(Date.now() / 1000) + + for (const [_service, config] of Object.entries(this.supportedModels)) { + for (const modelId of config.models) { + models.push({ + id: modelId, + object: 'model', + created: now, + owned_by: config.provider + }) + } + } + + return models.sort((a, b) => { + // 先按 provider 排序,再按 model id 排序 + if (a.owned_by !== b.owned_by) { + return a.owned_by.localeCompare(b.owned_by) + } + return a.id.localeCompare(b.id) + }) + } + + /** + * 按 provider 获取模型 + * @param {string} provider - 'anthropic', 'openai', 'google' 等 + */ + getModelsByProvider(provider) { + return this.getAllModels().filter((m) => m.owned_by === provider) + } + + /** + * 检查模型是否被支持 + * @param {string} modelId - 模型 ID + */ + isModelSupported(modelId) { + if (!modelId) { + return false + } + return this.getAllModels().some((m) => m.id === modelId) + } + + /** + * 获取模型的 provider + * @param {string} modelId - 模型 ID + */ + getModelProvider(modelId) { + const model = this.getAllModels().find((m) => m.id === modelId) + return model ? model.owned_by : null + } + + /** + * 重新加载模型配置 + */ + reloadModels() { + logger.info('🔄 Reloading supported models configuration...') + this.loadModels() + } + + /** + * 设置文件监听器(监听配置文件变化) + */ + setupFileWatcher() { + try { + // 如果已有监听器,先关闭 + if (this.fileWatcher) { + this.fileWatcher.close() + this.fileWatcher = null + } + + // 只有文件存在时才设置监听器 + if (!fs.existsSync(this.modelsFile)) { + logger.debug('📋 Models file does not exist yet, skipping file watcher setup') + return + } + + // 使用 fs.watchFile 监听文件变化 + const watchOptions = { + persistent: true, + interval: 60000 // 每60秒检查一次 + } + + let lastMtime = fs.statSync(this.modelsFile).mtimeMs + + fs.watchFile(this.modelsFile, watchOptions, (curr, _prev) => { + if (curr.mtimeMs !== lastMtime) { + lastMtime = curr.mtimeMs + logger.info('📋 Detected change in supported_models.json, reloading...') + this.reloadModels() + } + }) + + // 保存引用以便清理 + this.fileWatcher = { + close: () => fs.unwatchFile(this.modelsFile) + } + + logger.info('👁️ File watcher set up for supported_models.json') + } catch (error) { + logger.error('❌ Failed to setup file watcher:', error) + } + } + + /** + * 获取服务状态 + */ + getStatus() { + const totalModels = this.supportedModels + ? Object.values(this.supportedModels).reduce((sum, config) => sum + config.models.length, 0) + : 0 + + return { + initialized: this.supportedModels !== null, + totalModels, + providers: this.supportedModels ? Object.keys(this.supportedModels) : [], + fileExists: fs.existsSync(this.modelsFile) + } + } + + /** + * 清理资源 + */ + cleanup() { + if (this.fileWatcher) { + this.fileWatcher.close() + this.fileWatcher = null + logger.debug('📋 Model service file watcher closed') + } + } +} + +module.exports = new ModelService() diff --git a/src/services/openaiAccountService.js b/src/services/openaiAccountService.js index b85425f1..cf045e9c 100644 --- a/src/services/openaiAccountService.js +++ b/src/services/openaiAccountService.js @@ -194,19 +194,6 @@ function buildCodexUsageSnapshot(accountData) { } } -function normalizeSubscriptionExpiresAt(value) { - if (value === undefined || value === null || value === '') { - return '' - } - - const date = value instanceof Date ? value : new Date(value) - if (Number.isNaN(date.getTime())) { - return '' - } - - return date.toISOString() -} - // 刷新访问令牌 async function refreshAccessToken(refreshToken, proxy = null) { try { @@ -347,6 +334,19 @@ function isTokenExpired(account) { return new Date(account.expiresAt) <= new Date() } +/** + * 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ +function isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() +} + // 刷新账户的 access token(带分布式锁) async function refreshAccountToken(accountId) { let lockAcquired = false @@ -530,13 +530,6 @@ async function createAccount(accountData) { // 处理账户信息 const accountInfo = accountData.accountInfo || {} - const tokenExpiresAt = oauthData.expires_in - ? new Date(Date.now() + oauthData.expires_in * 1000).toISOString() - : '' - const subscriptionExpiresAt = normalizeSubscriptionExpiresAt( - accountData.subscriptionExpiresAt || accountInfo.subscriptionExpiresAt || '' - ) - // 检查邮箱是否已经是加密格式(包含冒号分隔的32位十六进制字符) const isEmailEncrypted = accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':' @@ -573,8 +566,13 @@ async function createAccount(accountData) { email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''), emailVerified: accountInfo.emailVerified === true ? 'true' : 'false', // 过期时间 - expiresAt: tokenExpiresAt, - subscriptionExpiresAt, + expiresAt: oauthData.expires_in + ? new Date(Date.now() + oauthData.expires_in * 1000).toISOString() + : new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // OAuth Token 过期时间(技术字段) + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, + // 状态字段 isActive: accountData.isActive !== false ? 'true' : 'false', status: 'active', @@ -599,10 +597,7 @@ async function createAccount(accountData) { } logger.info(`Created OpenAI account: ${accountId}`) - return { - ...account, - subscriptionExpiresAt: account.subscriptionExpiresAt || null - } + return account } // 获取账户 @@ -645,11 +640,6 @@ async function getAccount(accountId) { } } - accountData.subscriptionExpiresAt = - accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' - ? accountData.subscriptionExpiresAt - : null - return accountData } @@ -683,16 +673,18 @@ async function updateAccount(accountId, updates) { updates.email = encrypt(updates.email) } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt) - } - // 处理代理配置 if (updates.proxy) { updates.proxy = typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy) } + // ✅ 如果通过路由映射更新了 subscriptionExpiresAt,直接保存 + // subscriptionExpiresAt 是业务字段,与 token 刷新独立 + if (updates.subscriptionExpiresAt !== undefined) { + // 直接保存,不做任何调整 + } + // 更新账户类型时处理共享账户集合 const client = redisClient.getClientSafe() if (updates.accountType && updates.accountType !== existingAccount.accountType) { @@ -719,10 +711,6 @@ async function updateAccount(accountId, updates) { } } - if (!updatedAccount.subscriptionExpiresAt) { - updatedAccount.subscriptionExpiresAt = null - } - return updatedAccount } @@ -805,7 +793,11 @@ async function getAllAccounts() { } } - const subscriptionExpiresAt = accountData.subscriptionExpiresAt || null + const tokenExpiresAt = accountData.expiresAt || null + const subscriptionExpiresAt = + accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' + ? accountData.subscriptionExpiresAt + : null // 不解密敏感字段,只返回基本信息 accounts.push({ @@ -815,13 +807,18 @@ async function getAllAccounts() { openaiOauth: maskedOauth, accessToken: maskedAccessToken, refreshToken: maskedRefreshToken, + + // ✅ 前端显示订阅过期时间(业务字段) + tokenExpiresAt, + subscriptionExpiresAt, + expiresAt: subscriptionExpiresAt, + // 添加 scopes 字段用于判断认证方式 // 处理空字符串的情况 scopes: accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [], // 添加 hasRefreshToken 标记 hasRefreshToken: hasRefreshTokenFlag, - subscriptionExpiresAt, // 添加限流状态信息(统一格式) rateLimitStatus: rateLimitInfo ? { @@ -940,8 +937,17 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) { for (const accountId of sharedAccountIds) { const account = await getAccount(accountId) - if (account && account.isActive === 'true' && !isRateLimited(account)) { + if ( + account && + account.isActive === 'true' && + !isRateLimited(account) && + !isSubscriptionExpired(account) + ) { availableAccounts.push(account) + } else if (account && isSubscriptionExpired(account)) { + logger.debug( + `⏰ Skipping expired OpenAI account: ${account.name}, expired at ${account.subscriptionExpiresAt}` + ) } } diff --git a/src/services/openaiResponsesAccountService.js b/src/services/openaiResponsesAccountService.js index 2a67f83d..41e61c92 100644 --- a/src/services/openaiResponsesAccountService.js +++ b/src/services/openaiResponsesAccountService.js @@ -5,19 +5,6 @@ const logger = require('../utils/logger') const config = require('../../config/config') const LRUCache = require('../utils/lruCache') -function normalizeSubscriptionExpiresAt(value) { - if (value === undefined || value === null || value === '') { - return '' - } - - const date = value instanceof Date ? value : new Date(value) - if (Number.isNaN(date.getTime())) { - return '' - } - - return date.toISOString() -} - class OpenAIResponsesAccountService { constructor() { // 加密相关常量 @@ -62,8 +49,7 @@ class OpenAIResponsesAccountService { schedulable = true, // 是否可被调度 dailyQuota = 0, // 每日额度限制(美元),0表示不限制 quotaResetTime = '00:00', // 额度重置时间(HH:mm格式) - rateLimitDuration = 60, // 限流时间(分钟) - subscriptionExpiresAt = null + rateLimitDuration = 60 // 限流时间(分钟) } = options // 验证必填字段 @@ -89,6 +75,11 @@ class OpenAIResponsesAccountService { isActive: isActive.toString(), accountType, schedulable: schedulable.toString(), + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + // 注意:OpenAI-Responses 使用 API Key 认证,没有 OAuth token,因此没有 expiresAt + subscriptionExpiresAt: options.subscriptionExpiresAt || null, + createdAt: new Date().toISOString(), lastUsedAt: '', status: 'active', @@ -102,8 +93,7 @@ class OpenAIResponsesAccountService { dailyUsage: '0', lastResetDate: redis.getDateStringInTimezone(), quotaResetTime, - quotaStoppedAt: '', - subscriptionExpiresAt: normalizeSubscriptionExpiresAt(subscriptionExpiresAt) + quotaStoppedAt: '' } // 保存到 Redis @@ -113,7 +103,6 @@ class OpenAIResponsesAccountService { return { ...accountData, - subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, apiKey: '***' // 返回时隐藏敏感信息 } } @@ -140,11 +129,6 @@ class OpenAIResponsesAccountService { } } - accountData.subscriptionExpiresAt = - accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' - ? accountData.subscriptionExpiresAt - : null - return accountData } @@ -172,11 +156,10 @@ class OpenAIResponsesAccountService { : updates.baseApi } - if (Object.prototype.hasOwnProperty.call(updates, 'subscriptionExpiresAt')) { - updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.subscriptionExpiresAt) - } else if (Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { - updates.subscriptionExpiresAt = normalizeSubscriptionExpiresAt(updates.expiresAt) - delete updates.expiresAt + // ✅ 直接保存 subscriptionExpiresAt(如果提供) + // OpenAI-Responses 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段 + if (updates.subscriptionExpiresAt !== undefined) { + // 直接保存,不做任何调整 } // 更新 Redis @@ -240,6 +223,10 @@ class OpenAIResponsesAccountService { // 转换 isActive 字段为布尔值 account.isActive = account.isActive === 'true' + // ✅ 前端显示订阅过期时间(业务字段) + account.expiresAt = account.subscriptionExpiresAt || null + account.platform = account.platform || 'openai-responses' + accounts.push(account) } } @@ -285,10 +272,10 @@ class OpenAIResponsesAccountService { accountData.schedulable = accountData.schedulable !== 'false' // 转换 isActive 字段为布尔值 accountData.isActive = accountData.isActive === 'true' - accountData.subscriptionExpiresAt = - accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' - ? accountData.subscriptionExpiresAt - : null + + // ✅ 前端显示订阅过期时间(业务字段) + accountData.expiresAt = accountData.subscriptionExpiresAt || null + accountData.platform = accountData.platform || 'openai-responses' accounts.push(accountData) } @@ -536,6 +523,25 @@ class OpenAIResponsesAccountService { return { success: true, message: 'Account status reset successfully' } } + // ⏰ 检查账户订阅是否已过期 + isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置过期时间,视为永不过期 + } + + const expiryDate = new Date(account.subscriptionExpiresAt) + const now = new Date() + + if (expiryDate <= now) { + logger.debug( + `⏰ OpenAI-Responses Account ${account.name} (${account.id}) subscription expired at ${account.subscriptionExpiresAt}` + ) + return true + } + + return false + } + // 获取限流信息 _getRateLimitInfo(accountData) { if (accountData.rateLimitStatus !== 'limited') { diff --git a/src/services/openaiToClaude.js b/src/services/openaiToClaude.js index 10c8ae24..1f335f0e 100644 --- a/src/services/openaiToClaude.js +++ b/src/services/openaiToClaude.js @@ -31,10 +31,25 @@ class OpenAIToClaudeConverter { stream: openaiRequest.stream || false } - // Claude Code 必需的系统消息 + // 定义 Claude Code 的默认系统提示词 const claudeCodeSystemMessage = "You are Claude Code, Anthropic's official CLI for Claude." - claudeRequest.system = claudeCodeSystemMessage + // 如果 OpenAI 请求中包含系统消息,提取并检查 + const systemMessage = this._extractSystemMessage(openaiRequest.messages) + if (systemMessage && systemMessage.includes('You are currently in Xcode')) { + // Xcode 系统提示词 + claudeRequest.system = systemMessage + logger.info( + `🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)` + ) + logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`) + } else { + // 使用 Claude Code 默认系统提示词 + claudeRequest.system = claudeCodeSystemMessage + logger.debug( + `📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}` + ) + } // 处理停止序列 if (openaiRequest.stop) { diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index fcfb0453..42ab7546 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -545,6 +545,14 @@ class UnifiedClaudeScheduler { continue } + // 检查订阅是否过期 + if (claudeConsoleAccountService.isSubscriptionExpired(account)) { + logger.debug( + `⏰ Claude Console account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}` + ) + continue + } + // 主动触发一次额度检查,确保状态即时生效 try { await claudeConsoleAccountService.checkQuotaUsage(account.id) @@ -642,6 +650,14 @@ class UnifiedClaudeScheduler { continue } + // 检查订阅是否过期 + if (ccrAccountService.isSubscriptionExpired(account)) { + logger.debug( + `⏰ CCR account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}` + ) + continue + } + // 检查是否被限流 const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id) const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id) @@ -774,6 +790,13 @@ class UnifiedClaudeScheduler { ) { return false } + // 检查订阅是否过期 + if (claudeConsoleAccountService.isSubscriptionExpired(account)) { + logger.debug( + `⏰ Claude Console account ${account.name} (${accountId}) expired at ${account.subscriptionExpiresAt} (session check)` + ) + return false + } // 检查是否超额 try { await claudeConsoleAccountService.checkQuotaUsage(accountId) @@ -832,6 +855,13 @@ class UnifiedClaudeScheduler { if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel, 'in session check')) { return false } + // 检查订阅是否过期 + if (ccrAccountService.isSubscriptionExpired(account)) { + logger.debug( + `⏰ CCR account ${account.name} (${accountId}) expired at ${account.subscriptionExpiresAt} (session check)` + ) + return false + } // 检查是否超额 try { await ccrAccountService.checkQuotaUsage(accountId) @@ -1353,6 +1383,14 @@ class UnifiedClaudeScheduler { continue } + // 检查订阅是否过期 + if (ccrAccountService.isSubscriptionExpired(account)) { + logger.debug( + `⏰ CCR account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}` + ) + continue + } + // 检查是否被限流或超额 const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id) const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id) diff --git a/src/services/unifiedOpenAIScheduler.js b/src/services/unifiedOpenAIScheduler.js index bef1a686..4e5dd679 100644 --- a/src/services/unifiedOpenAIScheduler.js +++ b/src/services/unifiedOpenAIScheduler.js @@ -211,6 +211,15 @@ class UnifiedOpenAIScheduler { error.statusCode = 403 // Forbidden - 调度被禁止 throw error } + + // ⏰ 检查 OpenAI-Responses 专属账户订阅是否过期 + if (openaiResponsesAccountService.isSubscriptionExpired(boundAccount)) { + const errorMsg = `Dedicated account ${boundAccount.name} subscription has expired` + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = 403 // Forbidden - 订阅已过期 + throw error + } } // 专属账户:可选的模型检查(只有明确配置了supportedModels且不为空才检查) @@ -461,6 +470,14 @@ class UnifiedOpenAIScheduler { } } + // ⏰ 检查订阅是否过期 + if (openaiResponsesAccountService.isSubscriptionExpired(account)) { + logger.debug( + `⏭️ Skipping OpenAI-Responses account ${account.name} - subscription expired` + ) + continue + } + // OpenAI-Responses 账户默认支持所有模型 // 因为它们是第三方兼容 API,模型支持由第三方决定 @@ -536,6 +553,11 @@ class UnifiedOpenAIScheduler { logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`) return false } + // ⏰ 检查订阅是否过期 + if (openaiResponsesAccountService.isSubscriptionExpired(account)) { + logger.info(`🚫 OpenAI-Responses account ${accountId} subscription expired`) + return false + } // 检查并清除过期的限流状态 const isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(accountId) diff --git a/src/utils/runtimeAddon.js b/src/utils/runtimeAddon.js new file mode 100644 index 00000000..7c2e78ea --- /dev/null +++ b/src/utils/runtimeAddon.js @@ -0,0 +1,121 @@ +const fs = require('fs') +const path = require('path') +const logger = require('./logger') + +const ADDON_DIRECTORIES = [ + path.join(process.cwd(), '.local', 'ext'), + path.join(process.cwd(), '.local', 'extensions') +] + +class RuntimeAddonBus { + constructor() { + this._handlers = new Map() + this._initialized = false + } + + register(eventId, handler) { + if (!eventId || typeof handler !== 'function') { + return + } + + if (!this._handlers.has(eventId)) { + this._handlers.set(eventId, []) + } + + this._handlers.get(eventId).push(handler) + } + + emitSync(eventId, payload) { + this._ensureInitialized() + + if (!eventId) { + return payload + } + + const handlers = this._handlers.get(eventId) + if (!handlers || handlers.length === 0) { + return payload + } + + let current = payload + + for (const handler of handlers) { + try { + const result = handler(current) + if (typeof result !== 'undefined') { + current = result + } + } catch (error) { + this._log('warn', `本地扩展处理 ${eventId} 失败: ${error.message}`, error) + } + } + + return current + } + + _ensureInitialized() { + if (this._initialized) { + return + } + + this._initialized = true + const loadedPaths = new Set() + + for (const dir of ADDON_DIRECTORIES) { + if (!dir || !fs.existsSync(dir)) { + continue + } + + let entries = [] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch (error) { + this._log('warn', `读取本地扩展目录 ${dir} 失败: ${error.message}`, error) + continue + } + + for (const entry of entries) { + if (!entry.isFile()) { + continue + } + + if (!entry.name.endsWith('.js')) { + continue + } + + const targetPath = path.join(dir, entry.name) + + if (loadedPaths.has(targetPath)) { + continue + } + + loadedPaths.add(targetPath) + + try { + const registrar = require(targetPath) + if (typeof registrar === 'function') { + registrar(this) + } + } catch (error) { + this._log('warn', `加载本地扩展 ${entry.name} 失败: ${error.message}`, error) + } + } + } + } + + _log(level, message, error) { + const targetLevel = typeof level === 'string' ? level : 'info' + const loggerMethod = + logger && typeof logger[targetLevel] === 'function' ? logger[targetLevel].bind(logger) : null + + if (loggerMethod) { + loggerMethod(message, error) + } else if (targetLevel === 'error') { + console.error(message, error) + } else { + console.log(message, error) + } + } +} + +module.exports = new RuntimeAddonBus() diff --git a/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue b/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue index 046c3332..09673fa2 100644 --- a/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue +++ b/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue @@ -304,7 +304,25 @@ const selectQuickOption = (value) => { // 更新自定义过期时间 const updateCustomExpiryPreview = () => { if (localForm.customExpireDate) { - localForm.expiresAt = new Date(localForm.customExpireDate).toISOString() + try { + // 手动解析日期时间字符串,确保它被正确解释为本地时间 + const [datePart, timePart] = localForm.customExpireDate.split('T') + const [year, month, day] = datePart.split('-').map(Number) + const [hours, minutes] = timePart.split(':').map(Number) + + // 使用构造函数创建本地时间的 Date 对象,然后转换为 UTC ISO 字符串 + const localDate = new Date(year, month - 1, day, hours, minutes, 0, 0) + + // 验证日期有效性 + if (isNaN(localDate.getTime())) { + console.error('Invalid date:', localForm.customExpireDate) + return + } + + localForm.expiresAt = localDate.toISOString() + } catch (error) { + console.error('Failed to parse custom expire date:', error) + } } } diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 0bea28a8..da6a169e 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -4841,11 +4841,23 @@ const handleGroupRefresh = async () => { // 处理 API Key 管理模态框刷新 const handleApiKeyRefresh = async () => { // 刷新账户信息以更新 API Key 数量 - if (props.account?.id) { + if (!props.account?.id) { + return + } + + const refreshers = [ + typeof accountsStore.fetchDroidAccounts === 'function' + ? accountsStore.fetchDroidAccounts + : null, + typeof accountsStore.fetchAllAccounts === 'function' ? accountsStore.fetchAllAccounts : null + ].filter(Boolean) + + for (const refresher of refreshers) { try { - await accountsStore.fetchAccounts() + await refresher() + return } catch (error) { - console.error('Failed to refresh account data:', error) + console.error('刷新账户列表失败:', error) } } } diff --git a/web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue b/web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue index c1c64efe..60e675c9 100644 --- a/web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue +++ b/web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue @@ -20,12 +20,28 @@
- +