diff --git a/VERSION b/VERSION index 599d6ff7..5bd0c3a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.125 +1.1.126 diff --git a/scripts/test-multi-group.js b/scripts/test-multi-group.js deleted file mode 100644 index 484bc714..00000000 --- a/scripts/test-multi-group.js +++ /dev/null @@ -1,379 +0,0 @@ -/** - * 多分组功能测试脚本 - * 测试一个账户可以属于多个分组的功能 - */ - -require('dotenv').config() -const redis = require('../src/models/redis') -const accountGroupService = require('../src/services/accountGroupService') -const claudeAccountService = require('../src/services/claudeAccountService') - -// 测试配置 -const TEST_PREFIX = 'multi_group_test_' -const CLEANUP_ON_FINISH = true - -// 测试数据存储 -const testData = { - groups: [], - accounts: [] -} - -// 颜色输出 -const colors = { - green: '\x1b[32m', - red: '\x1b[31m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - reset: '\x1b[0m' -} - -function log(message, type = 'info') { - const color = - { - success: colors.green, - error: colors.red, - warning: colors.yellow, - info: colors.blue - }[type] || colors.reset - - console.log(`${color}${message}${colors.reset}`) -} - -async function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -// 清理测试数据 -async function cleanup() { - log('\n🧹 清理测试数据...', 'info') - - // 删除测试账户 - for (const account of testData.accounts) { - try { - await claudeAccountService.deleteAccount(account.id) - log(`✅ 删除测试账户: ${account.name}`, 'success') - } catch (error) { - log(`❌ 删除账户失败: ${error.message}`, 'error') - } - } - - // 删除测试分组 - for (const group of testData.groups) { - try { - // 先移除所有成员 - const members = await accountGroupService.getGroupMembers(group.id) - for (const memberId of members) { - await accountGroupService.removeAccountFromGroup(memberId, group.id) - } - - await accountGroupService.deleteGroup(group.id) - log(`✅ 删除测试分组: ${group.name}`, 'success') - } catch (error) { - log(`❌ 删除分组失败: ${error.message}`, 'error') - } - } -} - -// 测试1: 创建测试数据 -async function test1_createTestData() { - log('\n📝 测试1: 创建测试数据', 'info') - - try { - // 创建3个测试分组 - const group1 = await accountGroupService.createGroup({ - name: `${TEST_PREFIX}高优先级组`, - platform: 'claude', - description: '高优先级账户分组' - }) - testData.groups.push(group1) - log(`✅ 创建分组1: ${group1.name}`, 'success') - - const group2 = await accountGroupService.createGroup({ - name: `${TEST_PREFIX}备用组`, - platform: 'claude', - description: '备用账户分组' - }) - testData.groups.push(group2) - log(`✅ 创建分组2: ${group2.name}`, 'success') - - const group3 = await accountGroupService.createGroup({ - name: `${TEST_PREFIX}专用组`, - platform: 'claude', - description: '专用账户分组' - }) - testData.groups.push(group3) - log(`✅ 创建分组3: ${group3.name}`, 'success') - - // 创建测试账户 - const account1 = await claudeAccountService.createAccount({ - name: `${TEST_PREFIX}测试账户1`, - email: 'test1@example.com', - refreshToken: 'test_refresh_token_1', - accountType: 'group' - }) - testData.accounts.push(account1) - log(`✅ 创建测试账户1: ${account1.name}`, 'success') - - const account2 = await claudeAccountService.createAccount({ - name: `${TEST_PREFIX}测试账户2`, - email: 'test2@example.com', - refreshToken: 'test_refresh_token_2', - accountType: 'group' - }) - testData.accounts.push(account2) - log(`✅ 创建测试账户2: ${account2.name}`, 'success') - - log(`✅ 测试数据创建完成: 3个分组, 2个账户`, 'success') - } catch (error) { - log(`❌ 测试1失败: ${error.message}`, 'error') - throw error - } -} - -// 测试2: 账户加入多个分组 -async function test2_addAccountToMultipleGroups() { - log('\n📝 测试2: 账户加入多个分组', 'info') - - try { - const [group1, group2, group3] = testData.groups - const [account1, account2] = testData.accounts - - // 账户1加入分组1和分组2 - await accountGroupService.addAccountToGroup(account1.id, group1.id, 'claude') - log(`✅ 账户1加入分组1: ${group1.name}`, 'success') - - await accountGroupService.addAccountToGroup(account1.id, group2.id, 'claude') - log(`✅ 账户1加入分组2: ${group2.name}`, 'success') - - // 账户2加入分组2和分组3 - await accountGroupService.addAccountToGroup(account2.id, group2.id, 'claude') - log(`✅ 账户2加入分组2: ${group2.name}`, 'success') - - await accountGroupService.addAccountToGroup(account2.id, group3.id, 'claude') - log(`✅ 账户2加入分组3: ${group3.name}`, 'success') - - log(`✅ 多分组关系建立完成`, 'success') - } catch (error) { - log(`❌ 测试2失败: ${error.message}`, 'error') - throw error - } -} - -// 测试3: 验证多分组关系 -async function test3_verifyMultiGroupRelationships() { - log('\n📝 测试3: 验证多分组关系', 'info') - - try { - const [group1, group2, group3] = testData.groups - const [account1, account2] = testData.accounts - - // 验证账户1的分组关系 - const account1Groups = await accountGroupService.getAccountGroup(account1.id) - log(`📊 账户1所属分组数量: ${account1Groups.length}`, 'info') - - const account1GroupNames = account1Groups.map((g) => g.name).sort() - const expectedAccount1Groups = [group1.name, group2.name].sort() - - if (JSON.stringify(account1GroupNames) === JSON.stringify(expectedAccount1Groups)) { - log(`✅ 账户1分组关系正确: [${account1GroupNames.join(', ')}]`, 'success') - } else { - throw new Error( - `账户1分组关系错误,期望: [${expectedAccount1Groups.join(', ')}], 实际: [${account1GroupNames.join(', ')}]` - ) - } - - // 验证账户2的分组关系 - const account2Groups = await accountGroupService.getAccountGroup(account2.id) - log(`📊 账户2所属分组数量: ${account2Groups.length}`, 'info') - - const account2GroupNames = account2Groups.map((g) => g.name).sort() - const expectedAccount2Groups = [group2.name, group3.name].sort() - - if (JSON.stringify(account2GroupNames) === JSON.stringify(expectedAccount2Groups)) { - log(`✅ 账户2分组关系正确: [${account2GroupNames.join(', ')}]`, 'success') - } else { - throw new Error( - `账户2分组关系错误,期望: [${expectedAccount2Groups.join(', ')}], 实际: [${account2GroupNames.join(', ')}]` - ) - } - - log(`✅ 多分组关系验证通过`, 'success') - } catch (error) { - log(`❌ 测试3失败: ${error.message}`, 'error') - throw error - } -} - -// 测试4: 验证分组成员关系 -async function test4_verifyGroupMemberships() { - log('\n📝 测试4: 验证分组成员关系', 'info') - - try { - const [group1, group2, group3] = testData.groups - const [account1, account2] = testData.accounts - - // 验证分组1的成员 - const group1Members = await accountGroupService.getGroupMembers(group1.id) - if (group1Members.includes(account1.id) && group1Members.length === 1) { - log(`✅ 分组1成员正确: [${account1.name}]`, 'success') - } else { - throw new Error(`分组1成员错误,期望: [${account1.id}], 实际: [${group1Members.join(', ')}]`) - } - - // 验证分组2的成员(应该包含两个账户) - const group2Members = await accountGroupService.getGroupMembers(group2.id) - const expectedGroup2Members = [account1.id, account2.id].sort() - const actualGroup2Members = group2Members.sort() - - if (JSON.stringify(actualGroup2Members) === JSON.stringify(expectedGroup2Members)) { - log(`✅ 分组2成员正确: [${account1.name}, ${account2.name}]`, 'success') - } else { - throw new Error( - `分组2成员错误,期望: [${expectedGroup2Members.join(', ')}], 实际: [${actualGroup2Members.join(', ')}]` - ) - } - - // 验证分组3的成员 - const group3Members = await accountGroupService.getGroupMembers(group3.id) - if (group3Members.includes(account2.id) && group3Members.length === 1) { - log(`✅ 分组3成员正确: [${account2.name}]`, 'success') - } else { - throw new Error(`分组3成员错误,期望: [${account2.id}], 实际: [${group3Members.join(', ')}]`) - } - - log(`✅ 分组成员关系验证通过`, 'success') - } catch (error) { - log(`❌ 测试4失败: ${error.message}`, 'error') - throw error - } -} - -// 测试5: 从部分分组中移除账户 -async function test5_removeFromPartialGroups() { - log('\n📝 测试5: 从部分分组中移除账户', 'info') - - try { - const [group1, group2] = testData.groups - const [account1] = testData.accounts - - // 将账户1从分组1中移除(但仍在分组2中) - await accountGroupService.removeAccountFromGroup(account1.id, group1.id) - log(`✅ 从分组1中移除账户1`, 'success') - - // 验证账户1现在只属于分组2 - const account1Groups = await accountGroupService.getAccountGroup(account1.id) - if (account1Groups.length === 1 && account1Groups[0].id === group2.id) { - log(`✅ 账户1现在只属于分组2: ${account1Groups[0].name}`, 'success') - } else { - const groupNames = account1Groups.map((g) => g.name) - throw new Error(`账户1分组状态错误,期望只在分组2中,实际: [${groupNames.join(', ')}]`) - } - - // 验证分组1现在为空 - const group1Members = await accountGroupService.getGroupMembers(group1.id) - if (group1Members.length === 0) { - log(`✅ 分组1现在为空`, 'success') - } else { - throw new Error(`分组1应该为空,但还有成员: [${group1Members.join(', ')}]`) - } - - // 验证分组2仍有两个成员 - const group2Members = await accountGroupService.getGroupMembers(group2.id) - if (group2Members.length === 2) { - log(`✅ 分组2仍有两个成员`, 'success') - } else { - throw new Error(`分组2应该有2个成员,实际: ${group2Members.length}个`) - } - - log(`✅ 部分移除测试通过`, 'success') - } catch (error) { - log(`❌ 测试5失败: ${error.message}`, 'error') - throw error - } -} - -// 测试6: 账户完全移除时的分组清理 -async function test6_accountDeletionGroupCleanup() { - log('\n📝 测试6: 账户删除时的分组清理', 'info') - - try { - const [, group2, group3] = testData.groups // 跳过第一个元素 - const [account1, account2] = testData.accounts - - // 记录删除前的状态 - const beforeGroup2Members = await accountGroupService.getGroupMembers(group2.id) - const beforeGroup3Members = await accountGroupService.getGroupMembers(group3.id) - - log(`📊 删除前分组2成员数: ${beforeGroup2Members.length}`, 'info') - log(`📊 删除前分组3成员数: ${beforeGroup3Members.length}`, 'info') - - // 删除账户2(这应该会触发从所有分组中移除的逻辑) - await claudeAccountService.deleteAccount(account2.id) - log(`✅ 删除账户2: ${account2.name}`, 'success') - - // 从测试数据中移除,避免cleanup时重复删除 - testData.accounts = testData.accounts.filter((acc) => acc.id !== account2.id) - - // 等待一下确保删除操作完成 - await sleep(500) - - // 验证分组2现在只有账户1 - const afterGroup2Members = await accountGroupService.getGroupMembers(group2.id) - if (afterGroup2Members.length === 1 && afterGroup2Members[0] === account1.id) { - log(`✅ 分组2现在只有账户1`, 'success') - } else { - throw new Error(`分组2成员状态错误,期望只有账户1,实际: [${afterGroup2Members.join(', ')}]`) - } - - // 验证分组3现在为空 - const afterGroup3Members = await accountGroupService.getGroupMembers(group3.id) - if (afterGroup3Members.length === 0) { - log(`✅ 分组3现在为空`, 'success') - } else { - throw new Error(`分组3应该为空,但还有成员: [${afterGroup3Members.join(', ')}]`) - } - - log(`✅ 账户删除的分组清理测试通过`, 'success') - } catch (error) { - log(`❌ 测试6失败: ${error.message}`, 'error') - throw error - } -} - -// 主测试函数 -async function runTests() { - log('\n🚀 开始多分组功能测试\n', 'info') - - try { - // 连接Redis - await redis.connect() - log('✅ Redis连接成功', 'success') - - // 执行测试 - await test1_createTestData() - await test2_addAccountToMultipleGroups() - await test3_verifyMultiGroupRelationships() - await test4_verifyGroupMemberships() - await test5_removeFromPartialGroups() - await test6_accountDeletionGroupCleanup() - - log('\n🎉 所有测试通过!多分组功能工作正常', 'success') - } catch (error) { - log(`\n❌ 测试失败: ${error.message}`, 'error') - console.error(error) - } finally { - // 清理测试数据 - if (CLEANUP_ON_FINISH) { - await cleanup() - } else { - log('\n⚠️ 测试数据未清理,请手动清理', 'warning') - } - - // 关闭Redis连接 - await redis.disconnect() - process.exit(0) - } -} - -// 运行测试 -runTests() diff --git a/src/routes/admin.js b/src/routes/admin.js index 5308b0e8..31792278 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -749,6 +749,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { if (updates.tokenLimit !== undefined) { finalUpdates.tokenLimit = updates.tokenLimit } + if (updates.rateLimitCost !== undefined) { + finalUpdates.rateLimitCost = updates.rateLimitCost + } if (updates.concurrencyLimit !== undefined) { finalUpdates.concurrencyLimit = updates.concurrencyLimit } @@ -1235,7 +1238,7 @@ router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => { deletedAt: key.deletedAt, deletedBy: key.deletedBy, deletedByType: key.deletedByType, - canRestore: false // Deleted keys cannot be restored per requirement + canRestore: true // 已删除的API Key可以恢复 })) logger.success(`📋 Admin retrieved ${enrichedKeys.length} deleted API keys`) @@ -1248,6 +1251,123 @@ router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => { } }) +// 🔄 恢复已删除的API Key +router.post('/api-keys/:keyId/restore', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const adminUsername = req.session?.admin?.username || 'unknown' + + // 调用服务层的恢复方法 + const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin') + + if (result.success) { + logger.success(`✅ Admin ${adminUsername} restored API key: ${keyId}`) + return res.json({ + success: true, + message: 'API Key 已成功恢复', + apiKey: result.apiKey + }) + } else { + return res.status(400).json({ + success: false, + error: 'Failed to restore API key' + }) + } + } catch (error) { + logger.error('❌ Failed to restore API key:', error) + + // 根据错误类型返回适当的响应 + if (error.message === 'API key not found') { + return res.status(404).json({ + success: false, + error: 'API Key 不存在' + }) + } else if (error.message === 'API key is not deleted') { + return res.status(400).json({ + success: false, + error: '该 API Key 未被删除,无需恢复' + }) + } + + return res.status(500).json({ + success: false, + error: '恢复 API Key 失败', + message: error.message + }) + } +}) + +// 🗑️ 彻底删除API Key(物理删除) +router.delete('/api-keys/:keyId/permanent', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const adminUsername = req.session?.admin?.username || 'unknown' + + // 调用服务层的彻底删除方法 + const result = await apiKeyService.permanentDeleteApiKey(keyId) + + if (result.success) { + logger.success(`🗑️ Admin ${adminUsername} permanently deleted API key: ${keyId}`) + return res.json({ + success: true, + message: 'API Key 已彻底删除' + }) + } + } catch (error) { + logger.error('❌ Failed to permanently delete API key:', error) + + if (error.message === 'API key not found') { + return res.status(404).json({ + success: false, + error: 'API Key 不存在' + }) + } else if (error.message === '只能彻底删除已经删除的API Key') { + return res.status(400).json({ + success: false, + error: '只能彻底删除已经删除的API Key' + }) + } + + return res.status(500).json({ + success: false, + error: '彻底删除 API Key 失败', + message: error.message + }) + } +}) + +// 🧹 清空所有已删除的API Keys +router.delete('/api-keys/deleted/clear-all', authenticateAdmin, async (req, res) => { + try { + const adminUsername = req.session?.admin?.username || 'unknown' + + // 调用服务层的清空方法 + const result = await apiKeyService.clearAllDeletedApiKeys() + + logger.success( + `🧹 Admin ${adminUsername} cleared deleted API keys: ${result.successCount}/${result.total}` + ) + + return res.json({ + success: true, + message: `成功清空 ${result.successCount} 个已删除的 API Keys`, + details: { + total: result.total, + successCount: result.successCount, + failedCount: result.failedCount, + errors: result.errors + } + }) + } catch (error) { + logger.error('❌ Failed to clear all deleted API keys:', error) + return res.status(500).json({ + success: false, + error: '清空已删除的 API Keys 失败', + message: error.message + }) + } +}) + // 👥 账户分组管理 // 创建账户分组 @@ -1620,15 +1740,18 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter( - (account) => !account.groupInfos || account.groupInfos.length === 0 - ) + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts } else { // 筛选特定分组的账户 - accounts = accounts.filter( - (account) => - account.groupInfos && account.groupInfos.some((group) => group.id === groupId) - ) + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) } } @@ -1637,7 +1760,7 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id) - const groupInfos = await accountGroupService.getAccountGroup(account.id) + const groupInfos = await accountGroupService.getAccountGroups(account.id) // 获取会话窗口使用统计(仅对有活跃窗口的账户) let sessionWindowUsage = null @@ -1695,7 +1818,7 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message) // 如果获取统计失败,返回空统计 try { - const groupInfos = await accountGroupService.getAccountGroup(account.id) + const groupInfos = await accountGroupService.getAccountGroups(account.id) return { ...account, groupInfos, @@ -1748,6 +1871,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { platform = 'claude', priority, groupId, + groupIds, autoStopOnWarning } = req.body @@ -1762,9 +1886,11 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) } - // 如果是分组类型,验证groupId - if (accountType === 'group' && !groupId) { - return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + // 如果是分组类型,验证groupId或groupIds + if (accountType === 'group' && !groupId && (!groupIds || groupIds.length === 0)) { + return res + .status(400) + .json({ error: 'Group ID or Group IDs are required for group type accounts' }) } // 验证priority的有效性 @@ -1790,8 +1916,14 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { }) // 如果是分组类型,将账户添加到分组 - if (accountType === 'group' && groupId) { - await accountGroupService.addAccountToGroup(newAccount.id, groupId, newAccount.platform) + if (accountType === 'group') { + if (groupIds && groupIds.length > 0) { + // 使用多分组设置 + await accountGroupService.setAccountGroups(newAccount.id, groupIds, newAccount.platform) + } else if (groupId) { + // 兼容单分组模式 + await accountGroupService.addAccountToGroup(newAccount.id, groupId, newAccount.platform) + } } logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`) @@ -1825,9 +1957,15 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) } - // 如果更新为分组类型,验证groupId - if (updates.accountType === 'group' && !updates.groupId) { - return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + // 如果更新为分组类型,验证groupId或groupIds + if ( + updates.accountType === 'group' && + !updates.groupId && + (!updates.groupIds || updates.groupIds.length === 0) + ) { + return res + .status(400) + .json({ error: 'Group ID or Group IDs are required for group type accounts' }) } // 获取账户当前信息以处理分组变更 @@ -1840,16 +1978,24 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => if (updates.accountType !== undefined) { // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { - const oldGroups = await accountGroupService.getAccountGroup(accountId) - for (const oldGroup of oldGroups) { - await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) - } + await accountGroupService.removeAccountFromAllGroups(accountId) } // 如果新类型是分组,添加到新分组 - if (updates.accountType === 'group' && updates.groupId) { - // 从路由知道这是 Claude OAuth 账户,平台为 'claude' - await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude') + if (updates.accountType === 'group') { + // 处理多分组/单分组的兼容性 + if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) { + if (updates.groupIds && updates.groupIds.length > 0) { + // 使用多分组设置 + await accountGroupService.setAccountGroups(accountId, updates.groupIds, 'claude') + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(accountId) + } + } else if (updates.groupId) { + // 兼容单分组模式 + await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude') + } } } @@ -2023,15 +2169,18 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter( - (account) => !account.groupInfos || account.groupInfos.length === 0 - ) + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts } else { // 筛选特定分组的账户 - accounts = accounts.filter( - (account) => - account.groupInfos && account.groupInfos.some((group) => group.id === groupId) - ) + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) } } @@ -2040,7 +2189,7 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id) - const groupInfos = await accountGroupService.getAccountGroup(account.id) + const groupInfos = await accountGroupService.getAccountGroups(account.id) return { ...account, @@ -2059,7 +2208,7 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { statsError.message ) try { - const groupInfos = await accountGroupService.getAccountGroup(account.id) + const groupInfos = await accountGroupService.getAccountGroups(account.id) return { ...account, // 转换schedulable为布尔值 @@ -2153,7 +2302,7 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => { // 如果是分组类型,将账户添加到分组 if (accountType === 'group' && groupId) { - await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude') + await accountGroupService.addAccountToGroup(newAccount.id, groupId) } logger.success(`🎮 Admin created Claude Console account: ${name}`) @@ -2199,15 +2348,26 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, if (updates.accountType !== undefined) { // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { - const oldGroups = await accountGroupService.getAccountGroup(accountId) + const oldGroups = await accountGroupService.getAccountGroups(accountId) for (const oldGroup of oldGroups) { await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } - // 如果新类型是分组,添加到新分组 - if (updates.accountType === 'group' && updates.groupId) { - // Claude Console 账户在分组中被视为 'claude' 平台 - await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude') + // 如果新类型是分组,处理多分组支持 + if (updates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) { + // 如果明确提供了 groupIds 参数(包括空数组) + if (updates.groupIds && updates.groupIds.length > 0) { + // 设置新的多分组 + await accountGroupService.setAccountGroups(accountId, updates.groupIds, 'claude') + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(accountId) + } + } else if (updates.groupId) { + // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 + await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude') + } } } @@ -2341,15 +2501,18 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter( - (account) => !account.groupInfos || account.groupInfos.length === 0 - ) + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts } else { // 筛选特定分组的账户 - accounts = accounts.filter( - (account) => - account.groupInfos && account.groupInfos.some((group) => group.id === groupId) - ) + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) } } @@ -2358,7 +2521,7 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id) - const groupInfos = await accountGroupService.getAccountGroup(account.id) + const groupInfos = await accountGroupService.getAccountGroups(account.id) return { ...account, @@ -2375,7 +2538,7 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { statsError.message ) try { - const groupInfos = await accountGroupService.getAccountGroup(account.id) + const groupInfos = await accountGroupService.getAccountGroups(account.id) return { ...account, groupInfos, @@ -2788,15 +2951,18 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter( - (account) => !account.groupInfos || account.groupInfos.length === 0 - ) + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts } else { // 筛选特定分组的账户 - accounts = accounts.filter( - (account) => - account.groupInfos && account.groupInfos.some((group) => group.id === groupId) - ) + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) } } @@ -2805,7 +2971,7 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id) - const groupInfos = await accountGroupService.getAccountGroup(account.id) + const groupInfos = await accountGroupService.getAccountGroups(account.id) return { ...account, @@ -2823,7 +2989,7 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { ) // 如果获取统计失败,返回空统计 try { - const groupInfos = await accountGroupService.getAccountGroup(account.id) + const groupInfos = await accountGroupService.getAccountGroups(account.id) return { ...account, groupInfos, @@ -2927,14 +3093,26 @@ router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => if (updates.accountType !== undefined) { // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { - const oldGroups = await accountGroupService.getAccountGroup(accountId) + const oldGroups = await accountGroupService.getAccountGroups(accountId) for (const oldGroup of oldGroups) { await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } - // 如果新类型是分组,添加到新分组 - if (updates.accountType === 'group' && updates.groupId) { - await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'gemini') + // 如果新类型是分组,处理多分组支持 + if (updates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) { + // 如果明确提供了 groupIds 参数(包括空数组) + if (updates.groupIds && updates.groupIds.length > 0) { + // 设置新的多分组 + await accountGroupService.setAccountGroups(accountId, updates.groupIds, 'gemini') + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(accountId) + } + } else if (updates.groupId) { + // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 + await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'gemini') + } } } @@ -5299,15 +5477,18 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter( - (account) => !account.groupInfos || account.groupInfos.length === 0 - ) + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts } else { // 筛选特定分组的账户 - accounts = accounts.filter( - (account) => - account.groupInfos && account.groupInfos.some((group) => group.id === groupId) - ) + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) } } @@ -5628,10 +5809,81 @@ router.put( // 获取所有 Azure OpenAI 账户 router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => { try { - const accounts = await azureOpenaiAccountService.getAllAccounts() + const { platform, groupId } = req.query + let accounts = await azureOpenaiAccountService.getAllAccounts() + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'azure_openai') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息和分组信息 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id) + const groupInfos = await accountGroupService.getAccountGroups(account.id) + return { + ...account, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (error) { + logger.debug(`Failed to get usage stats for Azure OpenAI account ${account.id}:`, error) + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + return { + ...account, + groupInfos, + usage: { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.debug(`Failed to get group info for account ${account.id}:`, groupError) + return { + ...account, + groupInfos: [], + usage: { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + } + }) + ) + res.json({ success: true, - data: accounts + data: accountsWithStats }) } catch (error) { logger.error('Failed to fetch Azure OpenAI accounts:', error) @@ -5657,6 +5909,7 @@ router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => { supportedModels, proxy, groupId, + groupIds, priority, isActive, schedulable @@ -5736,6 +5989,17 @@ router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => { schedulable: schedulable !== false }) + // 如果是分组类型,将账户添加到分组 + if (accountType === 'group') { + if (groupIds && groupIds.length > 0) { + // 使用多分组设置 + await accountGroupService.setAccountGroups(account.id, groupIds, 'azure_openai') + } else if (groupId) { + // 兼容单分组模式 + await accountGroupService.addAccountToGroup(account.id, groupId, 'azure_openai') + } + } + res.json({ success: true, data: account, diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index 3233b1f4..cac72503 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -407,6 +407,317 @@ router.post('/api/user-stats', async (req, res) => { } }) +// 📊 批量查询统计数据接口 +router.post('/api/batch-stats', async (req, res) => { + try { + const { apiIds } = req.body + + // 验证输入 + if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) { + return res.status(400).json({ + error: 'Invalid input', + message: 'API IDs array is required' + }) + } + + // 限制最多查询 30 个 + if (apiIds.length > 30) { + return res.status(400).json({ + error: 'Too many keys', + message: 'Maximum 30 API keys can be queried at once' + }) + } + + // 验证所有 ID 格式 + const uuidRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i + const invalidIds = apiIds.filter((id) => !uuidRegex.test(id)) + if (invalidIds.length > 0) { + return res.status(400).json({ + error: 'Invalid API ID format', + message: `Invalid API IDs: ${invalidIds.join(', ')}` + }) + } + + const individualStats = [] + const aggregated = { + totalKeys: apiIds.length, + activeKeys: 0, + usage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + }, + dailyUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + }, + monthlyUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + } + } + + // 并行查询所有 API Key 数据(复用单key查询逻辑) + const results = await Promise.allSettled( + apiIds.map(async (apiId) => { + const keyData = await redis.getApiKey(apiId) + + if (!keyData || Object.keys(keyData).length === 0) { + return { error: 'Not found', apiId } + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + return { error: 'Disabled', apiId } + } + + // 检查是否过期 + if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { + return { error: 'Expired', apiId } + } + + // 复用单key查询的逻辑:获取使用统计 + const usage = await redis.getUsageStats(apiId) + + // 获取费用统计(与单key查询一致) + const costStats = await redis.getCostStats(apiId) + + return { + apiId, + name: keyData.name, + description: keyData.description || '', + isActive: true, + createdAt: keyData.createdAt, + usage: usage.total || {}, + dailyStats: { + ...usage.daily, + cost: costStats.daily + }, + monthlyStats: { + ...usage.monthly, + cost: costStats.monthly + }, + totalCost: costStats.total + } + }) + ) + + // 处理结果并聚合 + results.forEach((result) => { + if (result.status === 'fulfilled' && result.value && !result.value.error) { + const stats = result.value + aggregated.activeKeys++ + + // 聚合总使用量 + if (stats.usage) { + aggregated.usage.requests += stats.usage.requests || 0 + aggregated.usage.inputTokens += stats.usage.inputTokens || 0 + aggregated.usage.outputTokens += stats.usage.outputTokens || 0 + aggregated.usage.cacheCreateTokens += stats.usage.cacheCreateTokens || 0 + aggregated.usage.cacheReadTokens += stats.usage.cacheReadTokens || 0 + aggregated.usage.allTokens += stats.usage.allTokens || 0 + } + + // 聚合总费用 + aggregated.usage.cost += stats.totalCost || 0 + + // 聚合今日使用量 + aggregated.dailyUsage.requests += stats.dailyStats.requests || 0 + aggregated.dailyUsage.inputTokens += stats.dailyStats.inputTokens || 0 + aggregated.dailyUsage.outputTokens += stats.dailyStats.outputTokens || 0 + aggregated.dailyUsage.cacheCreateTokens += stats.dailyStats.cacheCreateTokens || 0 + aggregated.dailyUsage.cacheReadTokens += stats.dailyStats.cacheReadTokens || 0 + aggregated.dailyUsage.allTokens += stats.dailyStats.allTokens || 0 + aggregated.dailyUsage.cost += stats.dailyStats.cost || 0 + + // 聚合本月使用量 + aggregated.monthlyUsage.requests += stats.monthlyStats.requests || 0 + aggregated.monthlyUsage.inputTokens += stats.monthlyStats.inputTokens || 0 + aggregated.monthlyUsage.outputTokens += stats.monthlyStats.outputTokens || 0 + aggregated.monthlyUsage.cacheCreateTokens += stats.monthlyStats.cacheCreateTokens || 0 + aggregated.monthlyUsage.cacheReadTokens += stats.monthlyStats.cacheReadTokens || 0 + aggregated.monthlyUsage.allTokens += stats.monthlyStats.allTokens || 0 + aggregated.monthlyUsage.cost += stats.monthlyStats.cost || 0 + + // 添加到个体统计 + individualStats.push({ + apiId: stats.apiId, + name: stats.name, + isActive: true, + usage: stats.usage, + dailyUsage: { + ...stats.dailyStats, + formattedCost: CostCalculator.formatCost(stats.dailyStats.cost || 0) + }, + monthlyUsage: { + ...stats.monthlyStats, + formattedCost: CostCalculator.formatCost(stats.monthlyStats.cost || 0) + } + }) + } + }) + + // 格式化费用显示 + aggregated.usage.formattedCost = CostCalculator.formatCost(aggregated.usage.cost) + aggregated.dailyUsage.formattedCost = CostCalculator.formatCost(aggregated.dailyUsage.cost) + aggregated.monthlyUsage.formattedCost = CostCalculator.formatCost(aggregated.monthlyUsage.cost) + + logger.api(`📊 Batch stats query for ${apiIds.length} keys from ${req.ip || 'unknown'}`) + + return res.json({ + success: true, + data: { + aggregated, + individual: individualStats + } + }) + } catch (error) { + logger.error('❌ Failed to process batch stats query:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve batch statistics' + }) + } +}) + +// 📊 批量模型统计查询接口 +router.post('/api/batch-model-stats', async (req, res) => { + try { + const { apiIds, period = 'daily' } = req.body + + // 验证输入 + if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) { + return res.status(400).json({ + error: 'Invalid input', + message: 'API IDs array is required' + }) + } + + // 限制最多查询 30 个 + if (apiIds.length > 30) { + return res.status(400).json({ + error: 'Too many keys', + message: 'Maximum 30 API keys can be queried at once' + }) + } + + const client = redis.getClientSafe() + const tzDate = redis.getDateInTimezone() + const today = redis.getDateStringInTimezone() + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` + + const modelUsageMap = new Map() + + // 并行查询所有 API Key 的模型统计 + await Promise.all( + apiIds.map(async (apiId) => { + const pattern = + period === 'daily' + ? `usage:${apiId}:model:daily:*:${today}` + : `usage:${apiId}:model:monthly:*:${currentMonth}` + + const keys = await client.keys(pattern) + + for (const key of keys) { + const match = key.match( + period === 'daily' + ? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ + : /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ + ) + + if (!match) { + continue + } + + const model = match[1] + const data = await client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + if (!modelUsageMap.has(model)) { + modelUsageMap.set(model, { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0 + }) + } + + const modelUsage = modelUsageMap.get(model) + modelUsage.requests += parseInt(data.requests) || 0 + modelUsage.inputTokens += parseInt(data.inputTokens) || 0 + modelUsage.outputTokens += parseInt(data.outputTokens) || 0 + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + modelUsage.allTokens += parseInt(data.allTokens) || 0 + } + } + }) + ) + + // 转换为数组并计算费用 + const modelStats = [] + for (const [model, usage] of modelUsageMap) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + const costData = CostCalculator.calculateCost(usageData, model) + + modelStats.push({ + model, + requests: usage.requests, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheCreateTokens: usage.cacheCreateTokens, + cacheReadTokens: usage.cacheReadTokens, + allTokens: usage.allTokens, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing + }) + } + + // 按总 token 数降序排列 + modelStats.sort((a, b) => b.allTokens - a.allTokens) + + logger.api(`📊 Batch model stats query for ${apiIds.length} keys, period: ${period}`) + + return res.json({ + success: true, + data: modelStats, + period + }) + } catch (error) { + logger.error('❌ Failed to process batch model stats query:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve batch model statistics' + }) + } +}) + // 📊 用户模型统计查询接口 - 安全的自查询接口 router.post('/api/user-model-stats', async (req, res) => { try { diff --git a/src/services/accountGroupService.js b/src/services/accountGroupService.js index d2061266..7268dad5 100644 --- a/src/services/accountGroupService.js +++ b/src/services/accountGroupService.js @@ -13,7 +13,7 @@ class AccountGroupService { * 创建账户分组 * @param {Object} groupData - 分组数据 * @param {string} groupData.name - 分组名称 - * @param {string} groupData.platform - 平台类型 (claude/gemini) + * @param {string} groupData.platform - 平台类型 (claude/gemini/openai) * @param {string} groupData.description - 分组描述 * @returns {Object} 创建的分组 */ @@ -327,12 +327,36 @@ class AccountGroupService { } } + /** + * 根据账户ID获取其所属的分组(兼容性方法,返回单个分组) + * @param {string} accountId - 账户ID + * @returns {Object|null} 分组信息 + */ + async getAccountGroup(accountId) { + try { + const client = redis.getClientSafe() + const allGroupIds = await client.smembers(this.GROUPS_KEY) + + for (const groupId of allGroupIds) { + const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + if (isMember) { + return await this.getGroup(groupId) + } + } + + return null + } catch (error) { + logger.error('❌ 获取账户所属分组失败:', error) + throw error + } + } + /** * 根据账户ID获取其所属的所有分组 * @param {string} accountId - 账户ID * @returns {Array} 分组信息数组 */ - async getAccountGroup(accountId) { + async getAccountGroups(accountId) { try { const client = redis.getClientSafe() const allGroupIds = await client.smembers(this.GROUPS_KEY) @@ -357,6 +381,49 @@ class AccountGroupService { throw error } } + + /** + * 批量设置账户的分组 + * @param {string} accountId - 账户ID + * @param {Array} groupIds - 分组ID数组 + * @param {string} accountPlatform - 账户平台 + */ + async setAccountGroups(accountId, groupIds, accountPlatform) { + try { + // 首先移除账户的所有现有分组 + await this.removeAccountFromAllGroups(accountId) + + // 然后添加到新的分组中 + for (const groupId of groupIds) { + await this.addAccountToGroup(accountId, groupId, accountPlatform) + } + + logger.success(`✅ 批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`) + } catch (error) { + logger.error('❌ 批量设置账户分组失败:', error) + throw error + } + } + + /** + * 从所有分组中移除账户 + * @param {string} accountId - 账户ID + */ + async removeAccountFromAllGroups(accountId) { + try { + const client = redis.getClientSafe() + const allGroupIds = await client.smembers(this.GROUPS_KEY) + + for (const groupId of allGroupIds) { + await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + } + + logger.success(`✅ 从所有分组移除账户成功: ${accountId}`) + } catch (error) { + logger.error('❌ 从所有分组移除账户失败:', error) + throw error + } + } } module.exports = new AccountGroupService() diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index ca33869f..60e0e2d2 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -437,6 +437,139 @@ class ApiKeyService { } } + // 🔄 恢复已删除的API Key + async restoreApiKey(keyId, restoredBy = 'system', restoredByType = 'system') { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 检查是否确实是已删除的key + if (keyData.isDeleted !== 'true') { + throw new Error('API key is not deleted') + } + + // 准备更新的数据 + const updatedData = { ...keyData } + updatedData.isActive = 'true' + updatedData.restoredAt = new Date().toISOString() + updatedData.restoredBy = restoredBy + updatedData.restoredByType = restoredByType + + // 从更新的数据中移除删除相关的字段 + delete updatedData.isDeleted + delete updatedData.deletedAt + delete updatedData.deletedBy + delete updatedData.deletedByType + + // 保存更新后的数据 + await redis.setApiKey(keyId, updatedData) + + // 使用Redis的hdel命令删除不需要的字段 + const keyName = `apikey:${keyId}` + await redis.client.hdel(keyName, 'isDeleted', 'deletedAt', 'deletedBy', 'deletedByType') + + // 重新建立哈希映射(恢复API Key的使用能力) + if (keyData.apiKey) { + await redis.setApiKeyHash(keyData.apiKey, { + id: keyId, + name: keyData.name, + isActive: 'true' + }) + } + + logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`) + + return { success: true, apiKey: updatedData } + } catch (error) { + logger.error('❌ Failed to restore API key:', error) + throw error + } + } + + // 🗑️ 彻底删除API Key(物理删除) + async permanentDeleteApiKey(keyId) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 确保只能彻底删除已经软删除的key + if (keyData.isDeleted !== 'true') { + throw new Error('只能彻底删除已经删除的API Key') + } + + // 删除所有相关的使用统计数据 + const today = new Date().toISOString().split('T')[0] + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0] + + // 删除每日统计 + await redis.client.del(`usage:daily:${today}:${keyId}`) + await redis.client.del(`usage:daily:${yesterday}:${keyId}`) + + // 删除月度统计 + const currentMonth = today.substring(0, 7) + await redis.client.del(`usage:monthly:${currentMonth}:${keyId}`) + + // 删除所有相关的统计键(通过模式匹配) + const usageKeys = await redis.client.keys(`usage:*:${keyId}*`) + if (usageKeys.length > 0) { + await redis.client.del(...usageKeys) + } + + // 删除API Key本身 + await redis.deleteApiKey(keyId) + + logger.success(`🗑️ Permanently deleted API key: ${keyId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to permanently delete API key:', error) + throw error + } + } + + // 🧹 清空所有已删除的API Keys + async clearAllDeletedApiKeys() { + try { + const allKeys = await this.getAllApiKeys(true) + const deletedKeys = allKeys.filter((key) => key.isDeleted === 'true') + + let successCount = 0 + let failedCount = 0 + const errors = [] + + for (const key of deletedKeys) { + try { + await this.permanentDeleteApiKey(key.id) + successCount++ + } catch (error) { + failedCount++ + errors.push({ + keyId: key.id, + keyName: key.name, + error: error.message + }) + } + } + + logger.success(`🧹 Cleared deleted API keys: ${successCount} success, ${failedCount} failed`) + + return { + success: true, + total: deletedKeys.length, + successCount, + failedCount, + errors + } + } catch (error) { + logger.error('❌ Failed to clear all deleted API keys:', error) + throw error + } + } + // 📊 记录使用情况(支持缓存token和账户级别统计) async recordUsage( keyId, diff --git a/src/services/azureOpenaiAccountService.js b/src/services/azureOpenaiAccountService.js index 8ff86a99..e7d5754d 100644 --- a/src/services/azureOpenaiAccountService.js +++ b/src/services/azureOpenaiAccountService.js @@ -249,6 +249,10 @@ async function updateAccount(accountId, updates) { // 删除账户 async function deleteAccount(accountId) { + // 首先从所有分组中移除此账户 + const accountGroupService = require('./accountGroupService') + await accountGroupService.removeAccountFromAllGroups(accountId) + const client = redisClient.getClientSafe() const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}` diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index e236d626..1540b74b 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -621,10 +621,7 @@ class ClaudeAccountService { try { // 首先从所有分组中移除此账户 const accountGroupService = require('./accountGroupService') - const groups = await accountGroupService.getAccountGroup(accountId) - for (const group of groups) { - await accountGroupService.removeAccountFromGroup(accountId, group.id) - } + await accountGroupService.removeAccountFromAllGroups(accountId) const result = await redis.deleteClaudeAccount(accountId) diff --git a/web/admin-spa/.env.example b/web/admin-spa/.env.example index e0f33e8b..d0bd7a97 100644 --- a/web/admin-spa/.env.example +++ b/web/admin-spa/.env.example @@ -23,6 +23,14 @@ VITE_APP_TITLE=Claude Relay Service - 管理后台 # 格式:http://proxy-host:port #VITE_HTTP_PROXY=http://127.0.0.1:7890 +# ========== 教程页面配置 ========== + +# API 基础前缀(可选) +# 用于教程页面显示的自定义 API 前缀 +# 如果不配置,则使用当前浏览器访问地址 +# 示例:https://api.example.com 或 https://relay.mysite.com +# VITE_API_BASE_PREFIX=https://api.example.com + # ========== 使用说明 ========== # 1. 复制此文件为 .env.local 进行本地配置 # 2. .env.local 文件不会被提交到版本控制 diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 35e3f776..b7055c1e 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -244,19 +244,47 @@ >选择分组 *
- +
+ +
+
+ 暂无可用分组 +
+ + +
+ +
+
+
+
+ +