From e69ab2161d01f0a5df5869c29d543cd7bc54896d Mon Sep 17 00:00:00 2001 From: sczheng189 <724100151@qq.com> Date: Mon, 25 Aug 2025 18:58:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E5=A4=9A=E5=88=86=E7=BB=84=E8=B0=83=E5=BA=A6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加账户分组管理功能,支持创建、编辑、删除分组 - 实现基于分组的账户调度逻辑 - 添加分组权重和优先级支持 - 提供测试脚本验证多分组调度功能 - 修复代码格式化问题(统一使用LF换行符) 🤖 Generated with Claude Code Co-Authored-By: Claude --- scripts/test-group-scheduling.js | 5 +- scripts/test-multi-group.js | 379 +++++++++++++++++++++++ src/routes/admin.js | 197 +++++++++--- src/services/accountGroupService.js | 17 +- src/services/claudeAccountService.js | 7 + web/admin-spa/package-lock.json | 1 - web/admin-spa/src/views/AccountsView.vue | 65 ++-- 7 files changed, 567 insertions(+), 104 deletions(-) create mode 100644 scripts/test-multi-group.js diff --git a/scripts/test-group-scheduling.js b/scripts/test-group-scheduling.js index 4312ec65..e22a20e1 100644 --- a/scripts/test-group-scheduling.js +++ b/scripts/test-group-scheduling.js @@ -436,8 +436,9 @@ async function test8_groupMemberManagement() { const account = testData.accounts.find((a) => a.type === 'claude') // 获取账户所属分组 - const accountGroup = await accountGroupService.getAccountGroup(account.id) - if (accountGroup && accountGroup.id === claudeGroup.id) { + const accountGroups = await accountGroupService.getAccountGroup(account.id) + const hasTargetGroup = accountGroups.some((group) => group.id === claudeGroup.id) + if (hasTargetGroup) { log('✅ 账户分组查询验证通过', 'success') } else { throw new Error('账户分组查询结果不正确') diff --git a/scripts/test-multi-group.js b/scripts/test-multi-group.js new file mode 100644 index 00000000..484bc714 --- /dev/null +++ b/scripts/test-multi-group.js @@ -0,0 +1,379 @@ +/** + * 多分组功能测试脚本 + * 测试一个账户可以属于多个分组的功能 + */ + +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 86556904..368d0a33 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1449,11 +1449,14 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } @@ -1463,8 +1466,11 @@ 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) + return { ...account, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -1474,12 +1480,30 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { } catch (statsError) { logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message) // 如果获取统计失败,返回空统计 - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + try { + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { + ...account, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for account ${account.id}:`, + groupError.message + ) + return { + ...account, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } } } } @@ -1596,10 +1620,10 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => // 处理分组的变更 if (updates.accountType !== undefined) { - // 如果之前是分组类型,需要从原分组中移除 + // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { - const oldGroup = await accountGroupService.getAccountGroup(accountId) - if (oldGroup) { + const oldGroups = await accountGroupService.getAccountGroup(accountId) + for (const oldGroup of oldGroups) { await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } @@ -1631,8 +1655,8 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) // 获取账户信息以检查是否在分组中 const account = await claudeAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const group = await accountGroupService.getAccountGroup(accountId) - if (group) { + const groups = await accountGroupService.getAccountGroup(accountId) + for (const group of groups) { await accountGroupService.removeAccountFromGroup(accountId, group.id) } } @@ -1781,11 +1805,14 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } @@ -1795,8 +1822,11 @@ 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) + return { ...account, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -1808,12 +1838,30 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { `⚠️ Failed to get usage stats for Claude Console account ${account.id}:`, statsError.message ) - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + try { + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { + ...account, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for Claude Console account ${account.id}:`, + groupError.message + ) + return { + ...account, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } } } } @@ -1927,10 +1975,10 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, // 处理分组的变更 if (updates.accountType !== undefined) { - // 如果之前是分组类型,需要从原分组中移除 + // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { - const oldGroup = await accountGroupService.getAccountGroup(accountId) - if (oldGroup) { + const oldGroups = await accountGroupService.getAccountGroup(accountId) + for (const oldGroup of oldGroups) { await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } @@ -1961,8 +2009,8 @@ router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (r // 获取账户信息以检查是否在分组中 const account = await claudeConsoleAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const group = await accountGroupService.getAccountGroup(accountId) - if (group) { + const groups = await accountGroupService.getAccountGroup(accountId) + for (const group of groups) { await accountGroupService.removeAccountFromGroup(accountId, group.id) } } @@ -2071,11 +2119,14 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } @@ -2085,8 +2136,11 @@ 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) + return { ...account, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -2098,12 +2152,30 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { `⚠️ Failed to get usage stats for Bedrock account ${account.id}:`, statsError.message ) - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + try { + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { + ...account, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for account ${account.id}:`, + groupError.message + ) + return { + ...account, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } } } } @@ -2494,11 +2566,14 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } @@ -2508,8 +2583,11 @@ 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) + return { ...account, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -2522,12 +2600,30 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { statsError.message ) // 如果获取统计失败,返回空统计 - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + try { + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { + ...account, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for account ${account.id}:`, + groupError.message + ) + return { + ...account, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } } } } @@ -2607,10 +2703,10 @@ router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => // 处理分组的变更 if (updates.accountType !== undefined) { - // 如果之前是分组类型,需要从原分组中移除 + // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { - const oldGroup = await accountGroupService.getAccountGroup(accountId) - if (oldGroup) { + const oldGroups = await accountGroupService.getAccountGroup(accountId) + for (const oldGroup of oldGroups) { await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } @@ -2638,8 +2734,8 @@ router.delete('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) // 获取账户信息以检查是否在分组中 const account = await geminiAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const group = await accountGroupService.getAccountGroup(accountId) - if (group) { + const groups = await accountGroupService.getAccountGroup(accountId) + for (const group of groups) { await accountGroupService.removeAccountFromGroup(accountId, group.id) } } @@ -4977,11 +5073,14 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } diff --git a/src/services/accountGroupService.js b/src/services/accountGroupService.js index 078ba5b6..d2061266 100644 --- a/src/services/accountGroupService.js +++ b/src/services/accountGroupService.js @@ -328,25 +328,32 @@ class AccountGroupService { } /** - * 根据账户ID获取其所属的分组 + * 根据账户ID获取其所属的所有分组 * @param {string} accountId - 账户ID - * @returns {Object|null} 分组信息 + * @returns {Array} 分组信息数组 */ async getAccountGroup(accountId) { try { const client = redis.getClientSafe() const allGroupIds = await client.smembers(this.GROUPS_KEY) + const memberGroups = [] for (const groupId of allGroupIds) { const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) if (isMember) { - return await this.getGroup(groupId) + const group = await this.getGroup(groupId) + if (group) { + memberGroups.push(group) + } } } - return null + // 按创建时间倒序排序 + memberGroups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + + return memberGroups } catch (error) { - logger.error('❌ 获取账户所属分组失败:', error) + logger.error('❌ 获取账户所属分组列表失败:', error) throw error } } diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index ffd390bd..e0a0bd0f 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -609,6 +609,13 @@ class ClaudeAccountService { // 🗑️ 删除Claude账户 async deleteAccount(accountId) { try { + // 首先从所有分组中移除此账户 + const accountGroupService = require('./accountGroupService') + const groups = await accountGroupService.getAccountGroup(accountId) + for (const group of groups) { + await accountGroupService.removeAccountFromGroup(accountId, group.id) + } + const result = await redis.deleteClaudeAccount(accountId) if (result === 0) { diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json index 6d04a5f8..30b46f1e 100644 --- a/web/admin-spa/package-lock.json +++ b/web/admin-spa/package-lock.json @@ -3723,7 +3723,6 @@ "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", "dev": true, - "license": "MIT", "engines": { "node": ">=14.21.3" }, diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index f0555b6e..1e6e4793 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -240,12 +240,14 @@ > 共享 + - {{ account.groupInfo.name }} + {{ group.name }}
> // 下拉选项数据 const sortOptions = ref([ @@ -978,8 +980,8 @@ const loadAccounts = async (forceReload = false) => { // 使用缓存机制加载 API Keys 和分组数据 await Promise.all([loadApiKeys(forceReload), loadAccountGroups(forceReload)]) - // 加载分组成员关系(需要在分组数据加载完成后) - await loadGroupMembers(forceReload) + // 后端账户API已经包含分组信息,不需要单独加载分组成员关系 + // await loadGroupMembers(forceReload) const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData, azureOpenaiData] = await Promise.all(requests) @@ -992,9 +994,8 @@ const loadAccounts = async (forceReload = false) => { const boundApiKeysCount = apiKeys.value.filter( (key) => key.claudeAccountId === acc.id ).length - // 检查是否属于某个分组 - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'claude', boundApiKeysCount, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'claude', boundApiKeysCount } }) allAccounts.push(...claudeAccounts) } @@ -1002,8 +1003,8 @@ const loadAccounts = async (forceReload = false) => { if (claudeConsoleData.success) { const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => { // Claude Console账户暂时不支持直接绑定 - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'claude-console', boundApiKeysCount: 0, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'claude-console', boundApiKeysCount: 0 } }) allAccounts.push(...claudeConsoleAccounts) } @@ -1011,8 +1012,8 @@ const loadAccounts = async (forceReload = false) => { if (bedrockData.success) { const bedrockAccounts = (bedrockData.data || []).map((acc) => { // Bedrock账户暂时不支持直接绑定 - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'bedrock', boundApiKeysCount: 0, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'bedrock', boundApiKeysCount: 0 } }) allAccounts.push(...bedrockAccounts) } @@ -1023,8 +1024,8 @@ const loadAccounts = async (forceReload = false) => { const boundApiKeysCount = apiKeys.value.filter( (key) => key.geminiAccountId === acc.id ).length - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'gemini', boundApiKeysCount, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'gemini', boundApiKeysCount } }) allAccounts.push(...geminiAccounts) } @@ -1034,8 +1035,8 @@ const loadAccounts = async (forceReload = false) => { const boundApiKeysCount = apiKeys.value.filter( (key) => key.openaiAccountId === acc.id ).length - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'openai', boundApiKeysCount, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'openai', boundApiKeysCount } }) allAccounts.push(...openaiAccounts) } @@ -1131,36 +1132,6 @@ const loadAccountGroups = async (forceReload = false) => { } } -// 加载分组成员关系(缓存版本) -const loadGroupMembers = async (forceReload = false) => { - if (!forceReload && groupMembersLoaded.value) { - return // 使用缓存数据 - } - - try { - // 重置映射 - accountGroupMap.value.clear() - - // 获取所有分组的成员信息 - for (const group of accountGroups.value) { - try { - const membersResponse = await apiClient.get(`/admin/account-groups/${group.id}/members`) - if (membersResponse.success) { - const members = membersResponse.data || [] - members.forEach((member) => { - accountGroupMap.value.set(member.id, group) - }) - } - } catch (error) { - console.error(`Failed to load members for group ${group.id}:`, error) - } - } - groupMembersLoaded.value = true - } catch (error) { - console.error('Failed to load group members:', error) - } -} - // 清空缓存的函数 const clearCache = () => { apiKeysLoaded.value = false