mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 实现账户分组管理功能和优化响应式设计
主要更新: - 实现账户分组管理功能,支持创建、编辑、删除分组 - 支持将账户添加到分组进行统一调度 - 优化 API Keys 页面响应式设计,解决操作栏被隐藏的问题 - 优化账户管理页面布局,合并平台/类型列,改进操作按钮布局 - 修复代理信息显示溢出问题 - 改进表格列宽分配,充分利用屏幕空间 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
86
src/app.js
86
src/app.js
@@ -67,6 +67,24 @@ class Application {
|
||||
const claudeAccountService = require('./services/claudeAccountService');
|
||||
await claudeAccountService.initializeSessionWindows();
|
||||
|
||||
// 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
|
||||
this.app.use((req, res, next) => {
|
||||
if (req.path === '/admin-next/' && req.method === 'GET') {
|
||||
logger.warn(`🚨 INTERCEPTING /admin-next/ request at the very beginning!`);
|
||||
const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist');
|
||||
const indexPath = path.join(adminSpaPath, 'index.html');
|
||||
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
return res.sendFile(indexPath);
|
||||
} else {
|
||||
logger.error('❌ index.html not found at:', indexPath);
|
||||
return res.status(404).send('index.html not found');
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// 🛡️ 安全中间件
|
||||
this.app.use(helmet({
|
||||
contentSecurityPolicy: false, // 允许内联样式和脚本
|
||||
@@ -121,6 +139,14 @@ class Application {
|
||||
this.app.set('trust proxy', 1);
|
||||
}
|
||||
|
||||
// 调试中间件 - 拦截所有 /admin-next 请求
|
||||
this.app.use((req, res, next) => {
|
||||
if (req.path.startsWith('/admin-next')) {
|
||||
logger.info(`🔍 DEBUG: Incoming request - method: ${req.method}, path: ${req.path}, originalUrl: ${req.originalUrl}`);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// 🎨 新版管理界面静态文件服务(必须在其他路由之前)
|
||||
const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist');
|
||||
if (fs.existsSync(adminSpaPath)) {
|
||||
@@ -129,40 +155,54 @@ class Application {
|
||||
res.redirect(301, '/admin-next/');
|
||||
});
|
||||
|
||||
// 安全的静态文件服务配置
|
||||
this.app.use('/admin-next/', express.static(adminSpaPath, {
|
||||
maxAge: '1d', // 缓存静态资源1天
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
index: 'index.html',
|
||||
// 安全选项:禁止目录遍历
|
||||
dotfiles: 'deny', // 拒绝访问点文件
|
||||
redirect: false, // 禁止目录重定向
|
||||
// 自定义错误处理
|
||||
setHeaders: (res, path) => {
|
||||
// 为不同类型的文件设置适当的缓存策略
|
||||
if (path.endsWith('.js') || path.endsWith('.css')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1年缓存
|
||||
} else if (path.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
// 使用 all 方法确保捕获所有 HTTP 方法
|
||||
this.app.all('/admin-next/', (req, res) => {
|
||||
logger.info('🎯 HIT: /admin-next/ route handler triggered!');
|
||||
logger.info(`Method: ${req.method}, Path: ${req.path}, URL: ${req.url}`);
|
||||
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
return res.status(405).send('Method Not Allowed');
|
||||
}
|
||||
}));
|
||||
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(path.join(adminSpaPath, 'index.html'));
|
||||
});
|
||||
|
||||
// 处理SPA路由:所有未匹配的admin-next路径都返回index.html
|
||||
this.app.get('/admin-next/*', (req, res, next) => {
|
||||
// 安全检查:防止路径遍历攻击
|
||||
// 处理所有其他 /admin-next/* 路径(但排除根路径)
|
||||
this.app.get('/admin-next/*', (req, res) => {
|
||||
// 如果是根路径,跳过(应该由上面的路由处理)
|
||||
if (req.path === '/admin-next/') {
|
||||
logger.error('❌ ERROR: /admin-next/ should not reach here!');
|
||||
return res.status(500).send('Route configuration error');
|
||||
}
|
||||
|
||||
const requestPath = req.path.replace('/admin-next/', '');
|
||||
|
||||
// 安全检查
|
||||
if (requestPath.includes('..') || requestPath.includes('//') || requestPath.includes('\\')) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
}
|
||||
|
||||
// 如果是静态资源请求但文件不存在,返回404
|
||||
// 检查是否为静态资源
|
||||
const filePath = path.join(adminSpaPath, requestPath);
|
||||
|
||||
// 如果文件存在且是静态资源
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
// 设置缓存头
|
||||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
} else if (filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
return res.sendFile(filePath);
|
||||
}
|
||||
|
||||
// 如果是静态资源但文件不存在
|
||||
if (requestPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf)$/i)) {
|
||||
return res.status(404).send('Not found');
|
||||
}
|
||||
|
||||
// 其他路径返回index.html(SPA路由处理)
|
||||
// 其他所有路径返回 index.html(SPA 路由)
|
||||
res.sendFile(path.join(adminSpaPath, 'index.html'));
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const apiKeyService = require('../services/apiKeyService');
|
||||
const claudeAccountService = require('../services/claudeAccountService');
|
||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService');
|
||||
const geminiAccountService = require('../services/geminiAccountService');
|
||||
const accountGroupService = require('../services/accountGroupService');
|
||||
const redis = require('../models/redis');
|
||||
const { authenticateAdmin } = require('../middleware/auth');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -712,6 +713,118 @@ router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 👥 账户分组管理
|
||||
|
||||
// 创建账户分组
|
||||
router.post('/account-groups', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name, platform, description } = req.body;
|
||||
|
||||
const group = await accountGroupService.createGroup({
|
||||
name,
|
||||
platform,
|
||||
description
|
||||
});
|
||||
|
||||
res.json({ success: true, data: group });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create account group:', error);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取所有分组
|
||||
router.get('/account-groups', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform } = req.query;
|
||||
const groups = await accountGroupService.getAllGroups(platform);
|
||||
res.json({ success: true, data: groups });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get account groups:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取分组详情
|
||||
router.get('/account-groups/:groupId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params;
|
||||
const group = await accountGroupService.getGroup(groupId);
|
||||
|
||||
if (!group) {
|
||||
return res.status(404).json({ error: '分组不存在' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: group });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get account group:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新分组
|
||||
router.put('/account-groups/:groupId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const updatedGroup = await accountGroupService.updateGroup(groupId, updates);
|
||||
res.json({ success: true, data: updatedGroup });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update account group:', error);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除分组
|
||||
router.delete('/account-groups/:groupId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params;
|
||||
await accountGroupService.deleteGroup(groupId);
|
||||
res.json({ success: true, message: '分组删除成功' });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete account group:', error);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取分组成员
|
||||
router.get('/account-groups/:groupId/members', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params;
|
||||
const memberIds = await accountGroupService.getGroupMembers(groupId);
|
||||
|
||||
// 获取成员详细信息
|
||||
const members = [];
|
||||
for (const memberId of memberIds) {
|
||||
// 尝试从不同的服务获取账户信息
|
||||
let account = null;
|
||||
|
||||
// 先尝试Claude OAuth账户
|
||||
account = await claudeAccountService.getAccount(memberId);
|
||||
|
||||
// 如果找不到,尝试Claude Console账户
|
||||
if (!account) {
|
||||
account = await claudeConsoleAccountService.getAccount(memberId);
|
||||
}
|
||||
|
||||
// 如果还找不到,尝试Gemini账户
|
||||
if (!account) {
|
||||
account = await geminiAccountService.getAccount(memberId);
|
||||
}
|
||||
|
||||
if (account) {
|
||||
members.push(account);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: members });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get group members:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 🏢 Claude 账户管理
|
||||
|
||||
// 生成OAuth授权URL
|
||||
@@ -863,7 +976,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
claudeAiOauth,
|
||||
proxy,
|
||||
accountType,
|
||||
priority
|
||||
priority,
|
||||
groupId
|
||||
} = req.body;
|
||||
|
||||
if (!name) {
|
||||
@@ -871,8 +985,13 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (accountType && !['shared', 'dedicated'].includes(accountType)) {
|
||||
return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' });
|
||||
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||
return res.status(400).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' });
|
||||
}
|
||||
|
||||
// 验证priority的有效性
|
||||
@@ -892,6 +1011,11 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
priority: priority || 50 // 默认优先级为50
|
||||
});
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
if (accountType === 'group' && groupId) {
|
||||
await accountGroupService.addAccountToGroup(newAccount.id, groupId, newAccount.platform);
|
||||
}
|
||||
|
||||
logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`);
|
||||
res.json({ success: true, data: newAccount });
|
||||
} catch (error) {
|
||||
@@ -911,6 +1035,39 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) =>
|
||||
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)) {
|
||||
return res.status(400).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' });
|
||||
}
|
||||
|
||||
// 获取账户当前信息以处理分组变更
|
||||
const currentAccount = await claudeAccountService.getAccount(accountId);
|
||||
if (!currentAccount) {
|
||||
return res.status(404).json({ error: 'Account not found' });
|
||||
}
|
||||
|
||||
// 处理分组的变更
|
||||
if (updates.accountType !== undefined) {
|
||||
// 如果之前是分组类型,需要从原分组中移除
|
||||
if (currentAccount.accountType === 'group') {
|
||||
const oldGroup = await accountGroupService.getAccountGroup(accountId);
|
||||
if (oldGroup) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果新类型是分组,添加到新分组
|
||||
if (updates.accountType === 'group' && updates.groupId) {
|
||||
// 从路由知道这是 Claude OAuth 账户,平台为 'claude'
|
||||
await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude');
|
||||
}
|
||||
}
|
||||
|
||||
await claudeAccountService.updateAccount(accountId, updates);
|
||||
|
||||
logger.success(`📝 Admin updated Claude account: ${accountId}`);
|
||||
@@ -926,6 +1083,15 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res)
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
// 获取账户信息以检查是否在分组中
|
||||
const account = await claudeAccountService.getAccount(accountId);
|
||||
if (account && account.accountType === 'group') {
|
||||
const group = await accountGroupService.getAccountGroup(accountId);
|
||||
if (group) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, group.id);
|
||||
}
|
||||
}
|
||||
|
||||
await claudeAccountService.deleteAccount(accountId);
|
||||
|
||||
logger.success(`🗑️ Admin deleted Claude account: ${accountId}`);
|
||||
@@ -1026,7 +1192,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
userAgent,
|
||||
rateLimitDuration,
|
||||
proxy,
|
||||
accountType
|
||||
accountType,
|
||||
groupId
|
||||
} = req.body;
|
||||
|
||||
if (!name || !apiUrl || !apiKey) {
|
||||
@@ -1039,8 +1206,13 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (accountType && !['shared', 'dedicated'].includes(accountType)) {
|
||||
return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' });
|
||||
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||
return res.status(400).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' });
|
||||
}
|
||||
|
||||
const newAccount = await claudeConsoleAccountService.createAccount({
|
||||
@@ -1056,6 +1228,11 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
accountType: accountType || 'shared'
|
||||
});
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
if (accountType === 'group' && groupId) {
|
||||
await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude');
|
||||
}
|
||||
|
||||
logger.success(`🎮 Admin created Claude Console account: ${name}`);
|
||||
res.json({ success: true, data: newAccount });
|
||||
} catch (error) {
|
||||
@@ -1263,8 +1440,23 @@ router.post('/gemini-accounts', authenticateAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Account name is required' });
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (accountData.accountType && !['shared', 'dedicated', 'group'].includes(accountData.accountType)) {
|
||||
return res.status(400).json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' });
|
||||
}
|
||||
|
||||
// 如果是分组类型,验证groupId
|
||||
if (accountData.accountType === 'group' && !accountData.groupId) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' });
|
||||
}
|
||||
|
||||
const newAccount = await geminiAccountService.createAccount(accountData);
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
if (accountData.accountType === 'group' && accountData.groupId) {
|
||||
await accountGroupService.addAccountToGroup(newAccount.id, accountData.groupId, 'gemini');
|
||||
}
|
||||
|
||||
logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`);
|
||||
res.json({ success: true, data: newAccount });
|
||||
} catch (error) {
|
||||
|
||||
351
src/services/accountGroupService.js
Normal file
351
src/services/accountGroupService.js
Normal file
@@ -0,0 +1,351 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const logger = require('../utils/logger');
|
||||
const redis = require('../models/redis');
|
||||
|
||||
class AccountGroupService {
|
||||
constructor() {
|
||||
this.GROUPS_KEY = 'account_groups';
|
||||
this.GROUP_PREFIX = 'account_group:';
|
||||
this.GROUP_MEMBERS_PREFIX = 'account_group_members:';
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建账户分组
|
||||
* @param {Object} groupData - 分组数据
|
||||
* @param {string} groupData.name - 分组名称
|
||||
* @param {string} groupData.platform - 平台类型 (claude/gemini)
|
||||
* @param {string} groupData.description - 分组描述
|
||||
* @returns {Object} 创建的分组
|
||||
*/
|
||||
async createGroup(groupData) {
|
||||
try {
|
||||
const { name, platform, description = '' } = groupData;
|
||||
|
||||
// 验证必填字段
|
||||
if (!name || !platform) {
|
||||
throw new Error('分组名称和平台类型为必填项');
|
||||
}
|
||||
|
||||
// 验证平台类型
|
||||
if (!['claude', 'gemini'].includes(platform)) {
|
||||
throw new Error('平台类型必须是 claude 或 gemini');
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe();
|
||||
const groupId = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const group = {
|
||||
id: groupId,
|
||||
name,
|
||||
platform,
|
||||
description,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
// 保存分组数据
|
||||
await client.hmset(`${this.GROUP_PREFIX}${groupId}`, group);
|
||||
|
||||
// 添加到分组集合
|
||||
await client.sadd(this.GROUPS_KEY, groupId);
|
||||
|
||||
logger.success(`✅ 创建账户分组成功: ${name} (${platform})`);
|
||||
|
||||
return group;
|
||||
} catch (error) {
|
||||
logger.error('❌ 创建账户分组失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分组信息
|
||||
* @param {string} groupId - 分组ID
|
||||
* @param {Object} updates - 更新的字段
|
||||
* @returns {Object} 更新后的分组
|
||||
*/
|
||||
async updateGroup(groupId, updates) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
const groupKey = `${this.GROUP_PREFIX}${groupId}`;
|
||||
|
||||
// 检查分组是否存在
|
||||
const exists = await client.exists(groupKey);
|
||||
if (!exists) {
|
||||
throw new Error('分组不存在');
|
||||
}
|
||||
|
||||
// 获取现有分组数据
|
||||
const existingGroup = await client.hgetall(groupKey);
|
||||
|
||||
// 不允许修改平台类型
|
||||
if (updates.platform && updates.platform !== existingGroup.platform) {
|
||||
throw new Error('不能修改分组的平台类型');
|
||||
}
|
||||
|
||||
// 准备更新数据
|
||||
const updateData = {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 移除不允许修改的字段
|
||||
delete updateData.id;
|
||||
delete updateData.platform;
|
||||
delete updateData.createdAt;
|
||||
|
||||
// 更新分组
|
||||
await client.hmset(groupKey, updateData);
|
||||
|
||||
// 返回更新后的完整数据
|
||||
const updatedGroup = await client.hgetall(groupKey);
|
||||
|
||||
logger.success(`✅ 更新账户分组成功: ${updatedGroup.name}`);
|
||||
|
||||
return updatedGroup;
|
||||
} catch (error) {
|
||||
logger.error('❌ 更新账户分组失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分组
|
||||
* @param {string} groupId - 分组ID
|
||||
*/
|
||||
async deleteGroup(groupId) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
// 检查分组是否存在
|
||||
const group = await this.getGroup(groupId);
|
||||
if (!group) {
|
||||
throw new Error('分组不存在');
|
||||
}
|
||||
|
||||
// 检查分组是否为空
|
||||
const members = await this.getGroupMembers(groupId);
|
||||
if (members.length > 0) {
|
||||
throw new Error('分组内还有账户,无法删除');
|
||||
}
|
||||
|
||||
// 检查是否有API Key绑定此分组
|
||||
const boundApiKeys = await this.getApiKeysUsingGroup(groupId);
|
||||
if (boundApiKeys.length > 0) {
|
||||
throw new Error('还有API Key使用此分组,无法删除');
|
||||
}
|
||||
|
||||
// 删除分组数据
|
||||
await client.del(`${this.GROUP_PREFIX}${groupId}`);
|
||||
await client.del(`${this.GROUP_MEMBERS_PREFIX}${groupId}`);
|
||||
|
||||
// 从分组集合中移除
|
||||
await client.srem(this.GROUPS_KEY, groupId);
|
||||
|
||||
logger.success(`✅ 删除账户分组成功: ${group.name}`);
|
||||
} catch (error) {
|
||||
logger.error('❌ 删除账户分组失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分组详情
|
||||
* @param {string} groupId - 分组ID
|
||||
* @returns {Object|null} 分组信息
|
||||
*/
|
||||
async getGroup(groupId) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
const groupData = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`);
|
||||
|
||||
if (!groupData || Object.keys(groupData).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取成员数量
|
||||
const memberCount = await client.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`);
|
||||
|
||||
return {
|
||||
...groupData,
|
||||
memberCount: memberCount || 0
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('❌ 获取分组详情失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分组
|
||||
* @param {string} platform - 平台筛选 (可选)
|
||||
* @returns {Array} 分组列表
|
||||
*/
|
||||
async getAllGroups(platform = null) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
const groupIds = await client.smembers(this.GROUPS_KEY);
|
||||
|
||||
const groups = [];
|
||||
for (const groupId of groupIds) {
|
||||
const group = await this.getGroup(groupId);
|
||||
if (group) {
|
||||
// 如果指定了平台,进行筛选
|
||||
if (!platform || group.platform === platform) {
|
||||
groups.push(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按创建时间倒序排序
|
||||
groups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
return groups;
|
||||
} catch (error) {
|
||||
logger.error('❌ 获取分组列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加账户到分组
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} groupId - 分组ID
|
||||
* @param {string} accountPlatform - 账户平台
|
||||
*/
|
||||
async addAccountToGroup(accountId, groupId, accountPlatform) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
// 获取分组信息
|
||||
const group = await this.getGroup(groupId);
|
||||
if (!group) {
|
||||
throw new Error('分组不存在');
|
||||
}
|
||||
|
||||
// 验证平台一致性 (Claude和Claude Console视为同一平台)
|
||||
const normalizedAccountPlatform = accountPlatform === 'claude-console' ? 'claude' : accountPlatform;
|
||||
if (normalizedAccountPlatform !== group.platform) {
|
||||
throw new Error('账户平台与分组平台不匹配');
|
||||
}
|
||||
|
||||
// 添加到分组成员集合
|
||||
await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId);
|
||||
|
||||
logger.success(`✅ 添加账户到分组成功: ${accountId} -> ${group.name}`);
|
||||
} catch (error) {
|
||||
logger.error('❌ 添加账户到分组失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从分组移除账户
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} groupId - 分组ID
|
||||
*/
|
||||
async removeAccountFromGroup(accountId, groupId) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
// 从分组成员集合中移除
|
||||
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId);
|
||||
|
||||
logger.success(`✅ 从分组移除账户成功: ${accountId}`);
|
||||
} catch (error) {
|
||||
logger.error('❌ 从分组移除账户失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分组成员
|
||||
* @param {string} groupId - 分组ID
|
||||
* @returns {Array} 成员ID列表
|
||||
*/
|
||||
async getGroupMembers(groupId) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`);
|
||||
return members || [];
|
||||
} catch (error) {
|
||||
logger.error('❌ 获取分组成员失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查分组是否为空
|
||||
* @param {string} groupId - 分组ID
|
||||
* @returns {boolean} 是否为空
|
||||
*/
|
||||
async isGroupEmpty(groupId) {
|
||||
try {
|
||||
const members = await this.getGroupMembers(groupId);
|
||||
return members.length === 0;
|
||||
} catch (error) {
|
||||
logger.error('❌ 检查分组是否为空失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取使用指定分组的API Key列表
|
||||
* @param {string} groupId - 分组ID
|
||||
* @returns {Array} API Key列表
|
||||
*/
|
||||
async getApiKeysUsingGroup(groupId) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
const groupKey = `group:${groupId}`;
|
||||
|
||||
// 获取所有API Key
|
||||
const apiKeyIds = await client.smembers('api_keys');
|
||||
const boundApiKeys = [];
|
||||
|
||||
for (const keyId of apiKeyIds) {
|
||||
const keyData = await client.hgetall(`api_key:${keyId}`);
|
||||
if (keyData &&
|
||||
(keyData.claudeAccountId === groupKey ||
|
||||
keyData.geminiAccountId === groupKey)) {
|
||||
boundApiKeys.push({
|
||||
id: keyId,
|
||||
name: keyData.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return boundApiKeys;
|
||||
} catch (error) {
|
||||
logger.error('❌ 获取使用分组的API Key失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据账户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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AccountGroupService();
|
||||
@@ -68,7 +68,7 @@ class ClaudeAccountService {
|
||||
lastRefreshAt: '',
|
||||
status: 'active', // 有OAuth数据的账户直接设为active
|
||||
errorMessage: '',
|
||||
schedulable: schedulable.toString() // 是否可被调度
|
||||
schedulable: schedulable.toString(), // 是否可被调度
|
||||
};
|
||||
} else {
|
||||
// 兼容旧格式
|
||||
@@ -91,7 +91,7 @@ class ClaudeAccountService {
|
||||
lastRefreshAt: '',
|
||||
status: 'created', // created, active, expired, error
|
||||
errorMessage: '',
|
||||
schedulable: schedulable.toString() // 是否可被调度
|
||||
schedulable: schedulable.toString(), // 是否可被调度
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,6 +233,23 @@ class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 获取账户信息
|
||||
async getAccount(accountId) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId);
|
||||
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return accountData;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude account:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 获取有效的访问token
|
||||
async getValidAccessToken(accountId) {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const claudeAccountService = require('./claudeAccountService');
|
||||
const claudeConsoleAccountService = require('./claudeConsoleAccountService');
|
||||
const accountGroupService = require('./accountGroupService');
|
||||
const redis = require('../models/redis');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
@@ -11,9 +12,16 @@ class UnifiedClaudeScheduler {
|
||||
// 🎯 统一调度Claude账号(官方和Console)
|
||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
||||
try {
|
||||
// 如果API Key绑定了专属账户,优先使用
|
||||
// 1. 检查Claude OAuth账户绑定
|
||||
// 如果API Key绑定了专属账户或分组,优先使用
|
||||
if (apiKeyData.claudeAccountId) {
|
||||
// 检查是否是分组
|
||||
if (apiKeyData.claudeAccountId.startsWith('group:')) {
|
||||
const groupId = apiKeyData.claudeAccountId.replace('group:', '');
|
||||
logger.info(`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`);
|
||||
return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData);
|
||||
}
|
||||
|
||||
// 普通专属账户
|
||||
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId);
|
||||
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||
logger.info(`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`);
|
||||
@@ -360,6 +368,132 @@ class UnifiedClaudeScheduler {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 👥 从分组中选择账户
|
||||
async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null, apiKeyData = null) {
|
||||
try {
|
||||
// 获取分组信息
|
||||
const group = await accountGroupService.getGroup(groupId);
|
||||
if (!group) {
|
||||
throw new Error(`Group ${groupId} not found`);
|
||||
}
|
||||
|
||||
logger.info(`👥 Selecting account from group: ${group.name} (${group.platform})`);
|
||||
|
||||
// 如果有会话哈希,检查是否有已映射的账户
|
||||
if (sessionHash) {
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash);
|
||||
if (mappedAccount) {
|
||||
// 验证映射的账户是否属于这个分组
|
||||
const memberIds = await accountGroupService.getGroupMembers(groupId);
|
||||
if (memberIds.includes(mappedAccount.accountId)) {
|
||||
const isAvailable = await this._isAccountAvailable(mappedAccount.accountId, mappedAccount.accountType);
|
||||
if (isAvailable) {
|
||||
logger.info(`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`);
|
||||
return mappedAccount;
|
||||
}
|
||||
}
|
||||
// 如果映射的账户不可用或不在分组中,删除映射
|
||||
await this._deleteSessionMapping(sessionHash);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分组内的所有账户
|
||||
const memberIds = await accountGroupService.getGroupMembers(groupId);
|
||||
if (memberIds.length === 0) {
|
||||
throw new Error(`Group ${group.name} has no members`);
|
||||
}
|
||||
|
||||
const availableAccounts = [];
|
||||
|
||||
// 获取所有成员账户的详细信息
|
||||
for (const memberId of memberIds) {
|
||||
let account = null;
|
||||
let accountType = null;
|
||||
|
||||
// 根据平台类型获取账户
|
||||
if (group.platform === 'claude') {
|
||||
// 先尝试官方账户
|
||||
account = await redis.getClaudeAccount(memberId);
|
||||
if (account) {
|
||||
accountType = 'claude-official';
|
||||
} else {
|
||||
// 尝试Console账户
|
||||
account = await claudeConsoleAccountService.getAccount(memberId);
|
||||
if (account) {
|
||||
accountType = 'claude-console';
|
||||
}
|
||||
}
|
||||
} else if (group.platform === 'gemini') {
|
||||
// Gemini暂时不支持,预留接口
|
||||
logger.warn(`⚠️ Gemini group scheduling not yet implemented`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
logger.warn(`⚠️ Account ${memberId} not found in group ${group.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查账户是否可用
|
||||
const isActive = accountType === 'claude-official'
|
||||
? account.isActive === 'true'
|
||||
: account.isActive === true;
|
||||
|
||||
const status = accountType === 'claude-official'
|
||||
? account.status !== 'error' && account.status !== 'blocked'
|
||||
: account.status === 'active';
|
||||
|
||||
if (isActive && status && account.schedulable !== false) {
|
||||
// 检查模型支持(Console账户)
|
||||
if (accountType === 'claude-console' && requestedModel && account.supportedModels && account.supportedModels.length > 0) {
|
||||
if (!account.supportedModels.includes(requestedModel)) {
|
||||
logger.info(`🚫 Account ${account.name} in group does not support model ${requestedModel}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await this.isAccountRateLimited(account.id, accountType);
|
||||
if (!isRateLimited) {
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
accountId: account.id,
|
||||
accountType: accountType,
|
||||
priority: parseInt(account.priority) || 50,
|
||||
lastUsedAt: account.lastUsedAt || '0'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (availableAccounts.length === 0) {
|
||||
throw new Error(`No available accounts in group ${group.name}`);
|
||||
}
|
||||
|
||||
// 使用现有的优先级排序逻辑
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts);
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0];
|
||||
|
||||
// 如果有会话哈希,建立新的映射
|
||||
if (sessionHash) {
|
||||
await this._setSessionMapping(sessionHash, selectedAccount.accountId, selectedAccount.accountType);
|
||||
logger.info(`🎯 Created new sticky session mapping in group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`);
|
||||
}
|
||||
|
||||
logger.info(`🎯 Selected account from group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}`);
|
||||
|
||||
return {
|
||||
accountId: selectedAccount.accountId,
|
||||
accountType: selectedAccount.accountType
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to select account from group ${groupId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UnifiedClaudeScheduler();
|
||||
Reference in New Issue
Block a user