mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 新增标准Claude Console API账号支持
This commit is contained in:
28
scripts/test-claude-console-url.js
Executable file
28
scripts/test-claude-console-url.js
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// 测试Claude Console账号URL处理
|
||||||
|
|
||||||
|
const testUrls = [
|
||||||
|
'https://api.example.com',
|
||||||
|
'https://api.example.com/',
|
||||||
|
'https://api.example.com/v1/messages',
|
||||||
|
'https://api.example.com/v1/messages/',
|
||||||
|
'https://api.example.com:8080',
|
||||||
|
'https://api.example.com:8080/v1/messages'
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('🧪 Testing Claude Console URL handling:\n');
|
||||||
|
|
||||||
|
testUrls.forEach(url => {
|
||||||
|
// 模拟账号服务的URL处理逻辑
|
||||||
|
const cleanUrl = url.replace(/\/$/, ''); // 移除末尾斜杠
|
||||||
|
const apiEndpoint = cleanUrl.endsWith('/v1/messages')
|
||||||
|
? cleanUrl
|
||||||
|
: `${cleanUrl}/v1/messages`;
|
||||||
|
|
||||||
|
console.log(`Input: ${url}`);
|
||||||
|
console.log(`Output: ${apiEndpoint}`);
|
||||||
|
console.log('---');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n✅ URL normalization logic test completed');
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const apiKeyService = require('../services/apiKeyService');
|
const apiKeyService = require('../services/apiKeyService');
|
||||||
const claudeAccountService = require('../services/claudeAccountService');
|
const claudeAccountService = require('../services/claudeAccountService');
|
||||||
|
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService');
|
||||||
const geminiAccountService = require('../services/geminiAccountService');
|
const geminiAccountService = require('../services/geminiAccountService');
|
||||||
const redis = require('../models/redis');
|
const redis = require('../models/redis');
|
||||||
const { authenticateAdmin } = require('../middleware/auth');
|
const { authenticateAdmin } = require('../middleware/auth');
|
||||||
@@ -703,7 +704,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
refreshToken,
|
refreshToken,
|
||||||
claudeAiOauth,
|
claudeAiOauth,
|
||||||
proxy,
|
proxy,
|
||||||
accountType
|
accountType,
|
||||||
|
priority
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -715,6 +717,11 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' });
|
return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证priority的有效性
|
||||||
|
if (priority !== undefined && (typeof priority !== 'number' || priority < 1 || priority > 100)) {
|
||||||
|
return res.status(400).json({ error: 'Priority must be a number between 1 and 100' });
|
||||||
|
}
|
||||||
|
|
||||||
const newAccount = await claudeAccountService.createAccount({
|
const newAccount = await claudeAccountService.createAccount({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -723,7 +730,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
refreshToken,
|
refreshToken,
|
||||||
claudeAiOauth,
|
claudeAiOauth,
|
||||||
proxy,
|
proxy,
|
||||||
accountType: accountType || 'shared' // 默认为共享类型
|
accountType: accountType || 'shared', // 默认为共享类型
|
||||||
|
priority: priority || 50 // 默认优先级为50
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`);
|
logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`);
|
||||||
@@ -740,6 +748,11 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) =>
|
|||||||
const { accountId } = req.params;
|
const { accountId } = req.params;
|
||||||
const updates = req.body;
|
const updates = req.body;
|
||||||
|
|
||||||
|
// 验证priority的有效性
|
||||||
|
if (updates.priority !== undefined && (typeof updates.priority !== 'number' || updates.priority < 1 || updates.priority > 100)) {
|
||||||
|
return res.status(400).json({ error: 'Priority must be a number between 1 and 100' });
|
||||||
|
}
|
||||||
|
|
||||||
await claudeAccountService.updateAccount(accountId, updates);
|
await claudeAccountService.updateAccount(accountId, updates);
|
||||||
|
|
||||||
logger.success(`📝 Admin updated Claude account: ${accountId}`);
|
logger.success(`📝 Admin updated Claude account: ${accountId}`);
|
||||||
@@ -780,6 +793,154 @@ router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🎮 Claude Console 账户管理
|
||||||
|
|
||||||
|
// 获取所有Claude Console账户
|
||||||
|
router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const accounts = await claudeConsoleAccountService.getAllAccounts();
|
||||||
|
|
||||||
|
// 为每个账户添加使用统计信息
|
||||||
|
const accountsWithStats = await Promise.all(accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const usageStats = await redis.getAccountUsageStats(account.id);
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (statsError) {
|
||||||
|
logger.warn(`⚠️ 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 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ success: true, data: accountsWithStats });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude Console accounts:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get Claude Console accounts', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建新的Claude Console账户
|
||||||
|
router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
apiUrl,
|
||||||
|
apiKey,
|
||||||
|
priority,
|
||||||
|
supportedModels,
|
||||||
|
userAgent,
|
||||||
|
rateLimitDuration,
|
||||||
|
proxy,
|
||||||
|
accountType
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!name || !apiUrl || !apiKey) {
|
||||||
|
return res.status(400).json({ error: 'Name, API URL and API Key are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证priority的有效性(1-100)
|
||||||
|
if (priority !== undefined && (priority < 1 || priority > 100)) {
|
||||||
|
return res.status(400).json({ error: 'Priority must be between 1 and 100' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证accountType的有效性
|
||||||
|
if (accountType && !['shared', 'dedicated'].includes(accountType)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccount = await claudeConsoleAccountService.createAccount({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
apiUrl,
|
||||||
|
apiKey,
|
||||||
|
priority: priority || 50,
|
||||||
|
supportedModels: supportedModels || [],
|
||||||
|
userAgent,
|
||||||
|
rateLimitDuration: rateLimitDuration || 60,
|
||||||
|
proxy,
|
||||||
|
accountType: accountType || 'shared'
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.success(`🎮 Admin created Claude Console account: ${name}`);
|
||||||
|
res.json({ success: true, data: newAccount });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create Claude Console account:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create Claude Console account', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新Claude Console账户
|
||||||
|
router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
// 验证priority的有效性(1-100)
|
||||||
|
if (updates.priority !== undefined && (updates.priority < 1 || updates.priority > 100)) {
|
||||||
|
return res.status(400).json({ error: 'Priority must be between 1 and 100' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await claudeConsoleAccountService.updateAccount(accountId, updates);
|
||||||
|
|
||||||
|
logger.success(`📝 Admin updated Claude Console account: ${accountId}`);
|
||||||
|
res.json({ success: true, message: 'Claude Console account updated successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Claude Console account:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update Claude Console account', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除Claude Console账户
|
||||||
|
router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
|
||||||
|
await claudeConsoleAccountService.deleteAccount(accountId);
|
||||||
|
|
||||||
|
logger.success(`🗑️ Admin deleted Claude Console account: ${accountId}`);
|
||||||
|
res.json({ success: true, message: 'Claude Console account deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete Claude Console account:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete Claude Console account', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 切换Claude Console账户状态
|
||||||
|
router.put('/claude-console-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
|
||||||
|
const account = await claudeConsoleAccountService.getAccount(accountId);
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = !account.isActive;
|
||||||
|
await claudeConsoleAccountService.updateAccount(accountId, { isActive: newStatus });
|
||||||
|
|
||||||
|
logger.success(`🔄 Admin toggled Claude Console account status: ${accountId} -> ${newStatus ? 'active' : 'inactive'}`);
|
||||||
|
res.json({ success: true, isActive: newStatus });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle Claude Console account status:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to toggle account status', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 🤖 Gemini 账户管理
|
// 🤖 Gemini 账户管理
|
||||||
|
|
||||||
// 生成 Gemini OAuth 授权 URL
|
// 生成 Gemini OAuth 授权 URL
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const claudeRelayService = require('../services/claudeRelayService');
|
const claudeRelayService = require('../services/claudeRelayService');
|
||||||
|
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService');
|
||||||
|
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler');
|
||||||
const apiKeyService = require('../services/apiKeyService');
|
const apiKeyService = require('../services/apiKeyService');
|
||||||
const { authenticateApiKey } = require('../middleware/auth');
|
const { authenticateApiKey } = require('../middleware/auth');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const redis = require('../models/redis');
|
const redis = require('../models/redis');
|
||||||
|
const sessionHelper = require('../utils/sessionHelper');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -56,8 +59,16 @@ async function handleMessagesRequest(req, res) {
|
|||||||
|
|
||||||
let usageDataCaptured = false;
|
let usageDataCaptured = false;
|
||||||
|
|
||||||
// 使用自定义流处理器来捕获usage数据
|
// 生成会话哈希用于sticky会话
|
||||||
await claudeRelayService.relayStreamRequestWithUsageCapture(req.body, req.apiKey, res, req.headers, (usageData) => {
|
const sessionHash = sessionHelper.generateSessionHash(req.body);
|
||||||
|
|
||||||
|
// 使用统一调度选择账号
|
||||||
|
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(req.apiKey, sessionHash);
|
||||||
|
|
||||||
|
// 根据账号类型选择对应的转发服务并调用
|
||||||
|
if (accountType === 'claude-official') {
|
||||||
|
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||||
|
await claudeRelayService.relayStreamRequestWithUsageCapture(req.body, req.apiKey, res, req.headers, (usageData) => {
|
||||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||||
logger.info('🎯 Usage callback triggered with complete data:', JSON.stringify(usageData, null, 2));
|
logger.info('🎯 Usage callback triggered with complete data:', JSON.stringify(usageData, null, 2));
|
||||||
|
|
||||||
@@ -88,7 +99,42 @@ async function handleMessagesRequest(req, res) {
|
|||||||
} else {
|
} else {
|
||||||
logger.warn('⚠️ Usage callback triggered but data is incomplete:', JSON.stringify(usageData));
|
logger.warn('⚠️ Usage callback triggered but data is incomplete:', JSON.stringify(usageData));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Claude Console账号使用Console转发服务(需要传递accountId)
|
||||||
|
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(req.body, req.apiKey, res, req.headers, (usageData) => {
|
||||||
|
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||||
|
logger.info('🎯 Usage callback triggered with complete data:', JSON.stringify(usageData, null, 2));
|
||||||
|
|
||||||
|
if (usageData && usageData.input_tokens !== undefined && usageData.output_tokens !== undefined) {
|
||||||
|
const inputTokens = usageData.input_tokens || 0;
|
||||||
|
const outputTokens = usageData.output_tokens || 0;
|
||||||
|
const cacheCreateTokens = usageData.cache_creation_input_tokens || 0;
|
||||||
|
const cacheReadTokens = usageData.cache_read_input_tokens || 0;
|
||||||
|
const model = usageData.model || 'unknown';
|
||||||
|
|
||||||
|
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||||
|
const usageAccountId = usageData.accountId;
|
||||||
|
apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, usageAccountId).catch(error => {
|
||||||
|
logger.error('❌ Failed to record stream usage:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新时间窗口内的token计数
|
||||||
|
if (req.rateLimitInfo) {
|
||||||
|
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
|
||||||
|
redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens).catch(error => {
|
||||||
|
logger.error('❌ Failed to update rate limit token count:', error);
|
||||||
|
});
|
||||||
|
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`);
|
||||||
|
}
|
||||||
|
|
||||||
|
usageDataCaptured = true;
|
||||||
|
logger.api(`📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens`);
|
||||||
|
} else {
|
||||||
|
logger.warn('⚠️ Usage callback triggered but data is incomplete:', JSON.stringify(usageData));
|
||||||
|
}
|
||||||
|
}, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
// 流式请求完成后 - 如果没有捕获到usage数据,记录警告但不进行估算
|
// 流式请求完成后 - 如果没有捕获到usage数据,记录警告但不进行估算
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -103,7 +149,21 @@ async function handleMessagesRequest(req, res) {
|
|||||||
apiKeyName: req.apiKey.name
|
apiKeyName: req.apiKey.name
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await claudeRelayService.relayRequest(req.body, req.apiKey, req, res, req.headers);
|
// 生成会话哈希用于sticky会话
|
||||||
|
const sessionHash = sessionHelper.generateSessionHash(req.body);
|
||||||
|
|
||||||
|
// 使用统一调度选择账号
|
||||||
|
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(req.apiKey, sessionHash);
|
||||||
|
|
||||||
|
// 根据账号类型选择对应的转发服务
|
||||||
|
let response;
|
||||||
|
if (accountType === 'claude-official') {
|
||||||
|
// 官方Claude账号使用原有的转发服务
|
||||||
|
response = await claudeRelayService.relayRequest(req.body, req.apiKey, req, res, req.headers);
|
||||||
|
} else {
|
||||||
|
// Claude Console账号使用Console转发服务
|
||||||
|
response = await claudeConsoleRelayService.relayRequest(req.body, req.apiKey, req, res, req.headers, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('📡 Claude API response received', {
|
logger.info('📡 Claude API response received', {
|
||||||
statusCode: response.statusCode,
|
statusCode: response.statusCode,
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ class ClaudeAccountService {
|
|||||||
claudeAiOauth = null, // Claude标准格式的OAuth数据
|
claudeAiOauth = null, // Claude标准格式的OAuth数据
|
||||||
proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' }
|
proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' }
|
||||||
isActive = true,
|
isActive = true,
|
||||||
accountType = 'shared' // 'dedicated' or 'shared'
|
accountType = 'shared', // 'dedicated' or 'shared'
|
||||||
|
priority = 50 // 调度优先级 (1-100,数字越小优先级越高)
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const accountId = uuidv4();
|
const accountId = uuidv4();
|
||||||
@@ -60,6 +61,7 @@ class ClaudeAccountService {
|
|||||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||||
isActive: isActive.toString(),
|
isActive: isActive.toString(),
|
||||||
accountType: accountType, // 账号类型:'dedicated' 或 'shared'
|
accountType: accountType, // 账号类型:'dedicated' 或 'shared'
|
||||||
|
priority: priority.toString(), // 调度优先级
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
lastUsedAt: '',
|
lastUsedAt: '',
|
||||||
lastRefreshAt: '',
|
lastRefreshAt: '',
|
||||||
@@ -81,6 +83,7 @@ class ClaudeAccountService {
|
|||||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||||
isActive: isActive.toString(),
|
isActive: isActive.toString(),
|
||||||
accountType: accountType, // 账号类型:'dedicated' 或 'shared'
|
accountType: accountType, // 账号类型:'dedicated' 或 'shared'
|
||||||
|
priority: priority.toString(), // 调度优先级
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
lastUsedAt: '',
|
lastUsedAt: '',
|
||||||
lastRefreshAt: '',
|
lastRefreshAt: '',
|
||||||
@@ -101,6 +104,7 @@ class ClaudeAccountService {
|
|||||||
isActive,
|
isActive,
|
||||||
proxy,
|
proxy,
|
||||||
accountType,
|
accountType,
|
||||||
|
priority,
|
||||||
status: accountData.status,
|
status: accountData.status,
|
||||||
createdAt: accountData.createdAt,
|
createdAt: accountData.createdAt,
|
||||||
expiresAt: accountData.expiresAt,
|
expiresAt: accountData.expiresAt,
|
||||||
@@ -305,6 +309,7 @@ class ClaudeAccountService {
|
|||||||
status: account.status,
|
status: account.status,
|
||||||
errorMessage: account.errorMessage,
|
errorMessage: account.errorMessage,
|
||||||
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
|
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
|
||||||
|
priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50
|
||||||
createdAt: account.createdAt,
|
createdAt: account.createdAt,
|
||||||
lastUsedAt: account.lastUsedAt,
|
lastUsedAt: account.lastUsedAt,
|
||||||
lastRefreshAt: account.lastRefreshAt,
|
lastRefreshAt: account.lastRefreshAt,
|
||||||
@@ -343,7 +348,7 @@ class ClaudeAccountService {
|
|||||||
throw new Error('Account not found');
|
throw new Error('Account not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth', 'accountType'];
|
const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth', 'accountType', 'priority'];
|
||||||
const updatedData = { ...accountData };
|
const updatedData = { ...accountData };
|
||||||
|
|
||||||
// 检查是否新增了 refresh token
|
// 检查是否新增了 refresh token
|
||||||
@@ -355,6 +360,8 @@ class ClaudeAccountService {
|
|||||||
updatedData[field] = this._encryptSensitiveData(value);
|
updatedData[field] = this._encryptSensitiveData(value);
|
||||||
} else if (field === 'proxy') {
|
} else if (field === 'proxy') {
|
||||||
updatedData[field] = value ? JSON.stringify(value) : '';
|
updatedData[field] = value ? JSON.stringify(value) : '';
|
||||||
|
} else if (field === 'priority') {
|
||||||
|
updatedData[field] = value.toString();
|
||||||
} else if (field === 'claudeAiOauth') {
|
} else if (field === 'claudeAiOauth') {
|
||||||
// 更新 Claude AI OAuth 数据
|
// 更新 Claude AI OAuth 数据
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -1008,7 +1015,7 @@ class ClaudeAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`✅ Session window initialization completed:`);
|
logger.success('✅ Session window initialization completed:');
|
||||||
logger.success(` 📊 Total accounts: ${accounts.length}`);
|
logger.success(` 📊 Total accounts: ${accounts.length}`);
|
||||||
logger.success(` ✅ Initialized: ${initializedCount}`);
|
logger.success(` ✅ Initialized: ${initializedCount}`);
|
||||||
logger.success(` ⏭️ Skipped (existing): ${skippedCount}`);
|
logger.success(` ⏭️ Skipped (existing): ${skippedCount}`);
|
||||||
|
|||||||
457
src/services/claudeConsoleAccountService.js
Normal file
457
src/services/claudeConsoleAccountService.js
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||||
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
|
const redis = require('../models/redis');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const config = require('../../config/config');
|
||||||
|
|
||||||
|
class ClaudeConsoleAccountService {
|
||||||
|
constructor() {
|
||||||
|
// 加密相关常量
|
||||||
|
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc';
|
||||||
|
this.ENCRYPTION_SALT = 'claude-console-salt';
|
||||||
|
|
||||||
|
// Redis键前缀
|
||||||
|
this.ACCOUNT_KEY_PREFIX = 'claude_console_account:';
|
||||||
|
this.SHARED_ACCOUNTS_KEY = 'shared_claude_console_accounts';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🏢 创建Claude Console账户
|
||||||
|
async createAccount(options = {}) {
|
||||||
|
const {
|
||||||
|
name = 'Claude Console Account',
|
||||||
|
description = '',
|
||||||
|
apiUrl = '',
|
||||||
|
apiKey = '',
|
||||||
|
priority = 50, // 默认优先级50(1-100)
|
||||||
|
supportedModels = [], // 支持的模型列表,空数组表示支持所有
|
||||||
|
userAgent = 'claude-cli/1.0.61 (console, cli)',
|
||||||
|
rateLimitDuration = 60, // 限流时间(分钟)
|
||||||
|
proxy = null,
|
||||||
|
isActive = true,
|
||||||
|
accountType = 'shared' // 'dedicated' or 'shared'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if (!apiUrl || !apiKey) {
|
||||||
|
throw new Error('API URL and API Key are required for Claude Console account');
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId = uuidv4();
|
||||||
|
|
||||||
|
const accountData = {
|
||||||
|
id: accountId,
|
||||||
|
platform: 'claude-console',
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
apiUrl: this._encryptSensitiveData(apiUrl),
|
||||||
|
apiKey: this._encryptSensitiveData(apiKey),
|
||||||
|
priority: priority.toString(),
|
||||||
|
supportedModels: JSON.stringify(supportedModels),
|
||||||
|
userAgent,
|
||||||
|
rateLimitDuration: rateLimitDuration.toString(),
|
||||||
|
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||||
|
isActive: isActive.toString(),
|
||||||
|
accountType,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUsedAt: '',
|
||||||
|
status: 'active',
|
||||||
|
errorMessage: '',
|
||||||
|
// 限流相关
|
||||||
|
rateLimitedAt: '',
|
||||||
|
rateLimitStatus: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
await client.hset(
|
||||||
|
`${this.ACCOUNT_KEY_PREFIX}${accountId}`,
|
||||||
|
accountData
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果是共享账户,添加到共享账户集合
|
||||||
|
if (accountType === 'shared') {
|
||||||
|
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🏢 Created Claude Console account: ${name} (${accountId})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: accountId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
apiUrl,
|
||||||
|
priority,
|
||||||
|
supportedModels,
|
||||||
|
userAgent,
|
||||||
|
rateLimitDuration,
|
||||||
|
isActive,
|
||||||
|
proxy,
|
||||||
|
accountType,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: accountData.createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📋 获取所有Claude Console账户
|
||||||
|
async getAllAccounts() {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`);
|
||||||
|
const accounts = [];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const accountData = await client.hgetall(key);
|
||||||
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
|
// 获取限流状态信息
|
||||||
|
const rateLimitInfo = this._getRateLimitInfo(accountData);
|
||||||
|
|
||||||
|
accounts.push({
|
||||||
|
id: accountData.id,
|
||||||
|
platform: accountData.platform,
|
||||||
|
name: accountData.name,
|
||||||
|
description: accountData.description,
|
||||||
|
apiUrl: this._maskApiUrl(this._decryptSensitiveData(accountData.apiUrl)),
|
||||||
|
priority: parseInt(accountData.priority) || 50,
|
||||||
|
supportedModels: JSON.parse(accountData.supportedModels || '[]'),
|
||||||
|
userAgent: accountData.userAgent,
|
||||||
|
rateLimitDuration: parseInt(accountData.rateLimitDuration) || 60,
|
||||||
|
isActive: accountData.isActive === 'true',
|
||||||
|
proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null,
|
||||||
|
accountType: accountData.accountType || 'shared',
|
||||||
|
status: accountData.status,
|
||||||
|
errorMessage: accountData.errorMessage,
|
||||||
|
createdAt: accountData.createdAt,
|
||||||
|
lastUsedAt: accountData.lastUsedAt,
|
||||||
|
rateLimitStatus: rateLimitInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude Console accounts:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 获取单个账户(内部使用,包含敏感信息)
|
||||||
|
async getAccount(accountId) {
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`);
|
||||||
|
|
||||||
|
if (!accountData || Object.keys(accountData).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密敏感字段
|
||||||
|
accountData.apiUrl = this._decryptSensitiveData(accountData.apiUrl);
|
||||||
|
accountData.apiKey = this._decryptSensitiveData(accountData.apiKey);
|
||||||
|
|
||||||
|
// 解析JSON字段
|
||||||
|
accountData.supportedModels = JSON.parse(accountData.supportedModels || '[]');
|
||||||
|
accountData.priority = parseInt(accountData.priority) || 50;
|
||||||
|
accountData.rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60;
|
||||||
|
accountData.isActive = accountData.isActive === 'true';
|
||||||
|
|
||||||
|
if (accountData.proxy) {
|
||||||
|
accountData.proxy = JSON.parse(accountData.proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📝 更新账户
|
||||||
|
async updateAccount(accountId, updates) {
|
||||||
|
try {
|
||||||
|
const existingAccount = await this.getAccount(accountId);
|
||||||
|
if (!existingAccount) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
const updatedData = {};
|
||||||
|
|
||||||
|
// 处理各个字段的更新
|
||||||
|
if (updates.name !== undefined) updatedData.name = updates.name;
|
||||||
|
if (updates.description !== undefined) updatedData.description = updates.description;
|
||||||
|
if (updates.apiUrl !== undefined) updatedData.apiUrl = this._encryptSensitiveData(updates.apiUrl);
|
||||||
|
if (updates.apiKey !== undefined) updatedData.apiKey = this._encryptSensitiveData(updates.apiKey);
|
||||||
|
if (updates.priority !== undefined) updatedData.priority = updates.priority.toString();
|
||||||
|
if (updates.supportedModels !== undefined) updatedData.supportedModels = JSON.stringify(updates.supportedModels);
|
||||||
|
if (updates.userAgent !== undefined) updatedData.userAgent = updates.userAgent;
|
||||||
|
if (updates.rateLimitDuration !== undefined) updatedData.rateLimitDuration = updates.rateLimitDuration.toString();
|
||||||
|
if (updates.proxy !== undefined) updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '';
|
||||||
|
if (updates.isActive !== undefined) updatedData.isActive = updates.isActive.toString();
|
||||||
|
|
||||||
|
// 处理账户类型变更
|
||||||
|
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
|
||||||
|
updatedData.accountType = updates.accountType;
|
||||||
|
|
||||||
|
if (updates.accountType === 'shared') {
|
||||||
|
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId);
|
||||||
|
} else {
|
||||||
|
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedData.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await client.hset(
|
||||||
|
`${this.ACCOUNT_KEY_PREFIX}${accountId}`,
|
||||||
|
updatedData
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.success(`📝 Updated Claude Console account: ${accountId}`);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Claude Console account:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🗑️ 删除账户
|
||||||
|
async deleteAccount(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
const account = await this.getAccount(accountId);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从Redis删除
|
||||||
|
await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`);
|
||||||
|
|
||||||
|
// 从共享账户集合中移除
|
||||||
|
if (account.accountType === 'shared') {
|
||||||
|
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🗑️ Deleted Claude Console account: ${accountId}`);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete Claude Console account:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 🚫 标记账号为限流状态
|
||||||
|
async markAccountRateLimited(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
const account = await this.getAccount(accountId);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
rateLimitedAt: new Date().toISOString(),
|
||||||
|
rateLimitStatus: 'limited'
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.hset(
|
||||||
|
`${this.ACCOUNT_KEY_PREFIX}${accountId}`,
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.warn(`🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to mark Claude Console account as rate limited: ${accountId}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 移除账号的限流状态
|
||||||
|
async removeAccountRateLimit(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
|
||||||
|
await client.hdel(
|
||||||
|
`${this.ACCOUNT_KEY_PREFIX}${accountId}`,
|
||||||
|
'rateLimitedAt',
|
||||||
|
'rateLimitStatus'
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 检查账号是否处于限流状态
|
||||||
|
async isAccountRateLimited(accountId) {
|
||||||
|
try {
|
||||||
|
const account = await this.getAccount(accountId);
|
||||||
|
if (!account) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
||||||
|
const rateLimitedAt = new Date(account.rateLimitedAt);
|
||||||
|
const now = new Date();
|
||||||
|
const minutesSinceRateLimit = (now - rateLimitedAt) / (1000 * 60);
|
||||||
|
|
||||||
|
// 使用账户配置的限流时间
|
||||||
|
const rateLimitDuration = account.rateLimitDuration || 60;
|
||||||
|
|
||||||
|
if (minutesSinceRateLimit >= rateLimitDuration) {
|
||||||
|
await this.removeAccountRateLimit(accountId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to check rate limit status for Claude Console account: ${accountId}`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚫 标记账号为封锁状态(模型不支持等原因)
|
||||||
|
async blockAccount(accountId, reason) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
status: 'blocked',
|
||||||
|
errorMessage: reason,
|
||||||
|
blockedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.hset(
|
||||||
|
`${this.ACCOUNT_KEY_PREFIX}${accountId}`,
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.warn(`🚫 Claude Console account blocked: ${accountId} - ${reason}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to block Claude Console account: ${accountId}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🌐 创建代理agent
|
||||||
|
_createProxyAgent(proxyConfig) {
|
||||||
|
if (!proxyConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig;
|
||||||
|
|
||||||
|
if (proxy.type === 'socks5') {
|
||||||
|
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '';
|
||||||
|
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`;
|
||||||
|
return new SocksProxyAgent(socksUrl);
|
||||||
|
} else if (proxy.type === 'http' || proxy.type === 'https') {
|
||||||
|
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '';
|
||||||
|
const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`;
|
||||||
|
return new HttpsProxyAgent(httpUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('⚠️ Invalid proxy configuration:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔐 加密敏感数据
|
||||||
|
_encryptSensitiveData(data) {
|
||||||
|
if (!data) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = this._generateEncryptionKey();
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
|
||||||
|
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv);
|
||||||
|
let encrypted = cipher.update(data, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
|
||||||
|
return iv.toString('hex') + ':' + encrypted;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Encryption error:', error);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔓 解密敏感数据
|
||||||
|
_decryptSensitiveData(encryptedData) {
|
||||||
|
if (!encryptedData) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (encryptedData.includes(':')) {
|
||||||
|
const parts = encryptedData.split(':');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const key = this._generateEncryptionKey();
|
||||||
|
const iv = Buffer.from(parts[0], 'hex');
|
||||||
|
const encrypted = parts[1];
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv);
|
||||||
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return encryptedData;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Decryption error:', error);
|
||||||
|
return encryptedData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔑 生成加密密钥
|
||||||
|
_generateEncryptionKey() {
|
||||||
|
return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎭 掩码API URL
|
||||||
|
_maskApiUrl(apiUrl) {
|
||||||
|
if (!apiUrl) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(apiUrl);
|
||||||
|
return `${url.protocol}//${url.hostname}/***`;
|
||||||
|
} catch {
|
||||||
|
return '***';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📊 获取限流信息
|
||||||
|
_getRateLimitInfo(accountData) {
|
||||||
|
if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) {
|
||||||
|
const rateLimitedAt = new Date(accountData.rateLimitedAt);
|
||||||
|
const now = new Date();
|
||||||
|
const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60));
|
||||||
|
const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60;
|
||||||
|
const minutesRemaining = Math.max(0, rateLimitDuration - minutesSinceRateLimit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRateLimited: minutesRemaining > 0,
|
||||||
|
rateLimitedAt: accountData.rateLimitedAt,
|
||||||
|
minutesSinceRateLimit,
|
||||||
|
minutesRemaining
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitedAt: null,
|
||||||
|
minutesSinceRateLimit: 0,
|
||||||
|
minutesRemaining: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ClaudeConsoleAccountService();
|
||||||
527
src/services/claudeConsoleRelayService.js
Normal file
527
src/services/claudeConsoleRelayService.js
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const claudeConsoleAccountService = require('./claudeConsoleAccountService');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const config = require('../../config/config');
|
||||||
|
|
||||||
|
class ClaudeConsoleRelayService {
|
||||||
|
constructor() {
|
||||||
|
this.defaultUserAgent = 'claude-cli/1.0.61 (console, cli)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 转发请求到Claude Console API
|
||||||
|
async relayRequest(requestBody, apiKeyData, clientRequest, clientResponse, clientHeaders, accountId, options = {}) {
|
||||||
|
let abortController = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await claudeConsoleAccountService.getAccount(accountId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Claude Console Claude account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`);
|
||||||
|
logger.debug(`🌐 Account API URL: ${account.apiUrl}`);
|
||||||
|
|
||||||
|
// 检查模型支持
|
||||||
|
if (account.supportedModels && account.supportedModels.length > 0) {
|
||||||
|
const requestedModel = requestBody.model;
|
||||||
|
if (requestedModel && !account.supportedModels.includes(requestedModel)) {
|
||||||
|
logger.warn(`🚫 Model not supported by Claude Console account ${account.name}: ${requestedModel}`);
|
||||||
|
|
||||||
|
// 标记账户为blocked
|
||||||
|
await claudeConsoleAccountService.blockAccount(accountId, `Model ${requestedModel} not supported`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
error: {
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
message: `Model ${requestedModel} is not supported by this account`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建代理agent
|
||||||
|
const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy);
|
||||||
|
|
||||||
|
// 创建AbortController用于取消请求
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
// 设置客户端断开监听器
|
||||||
|
const handleClientDisconnect = () => {
|
||||||
|
logger.info('🔌 Client disconnected, aborting Claude Console Claude request');
|
||||||
|
if (abortController && !abortController.signal.aborted) {
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听客户端断开事件
|
||||||
|
if (clientRequest) {
|
||||||
|
clientRequest.once('close', handleClientDisconnect);
|
||||||
|
}
|
||||||
|
if (clientResponse) {
|
||||||
|
clientResponse.once('close', handleClientDisconnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建完整的API URL
|
||||||
|
const cleanUrl = account.apiUrl.replace(/\/$/, ''); // 移除末尾斜杠
|
||||||
|
const apiEndpoint = cleanUrl.endsWith('/v1/messages')
|
||||||
|
? cleanUrl
|
||||||
|
: `${cleanUrl}/v1/messages`;
|
||||||
|
|
||||||
|
logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`);
|
||||||
|
|
||||||
|
// 准备请求配置
|
||||||
|
const requestConfig = {
|
||||||
|
method: 'POST',
|
||||||
|
url: apiEndpoint,
|
||||||
|
data: requestBody,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': account.apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'User-Agent': account.userAgent || this.defaultUserAgent,
|
||||||
|
...this._filterClientHeaders(clientHeaders)
|
||||||
|
},
|
||||||
|
httpsAgent: proxyAgent,
|
||||||
|
timeout: config.proxy.timeout || 60000,
|
||||||
|
signal: abortController.signal,
|
||||||
|
validateStatus: () => true // 接受所有状态码
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加beta header如果需要
|
||||||
|
if (options.betaHeader) {
|
||||||
|
requestConfig.headers['anthropic-beta'] = options.betaHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const response = await axios(requestConfig);
|
||||||
|
|
||||||
|
// 移除监听器(请求成功完成)
|
||||||
|
if (clientRequest) {
|
||||||
|
clientRequest.removeListener('close', handleClientDisconnect);
|
||||||
|
}
|
||||||
|
if (clientResponse) {
|
||||||
|
clientResponse.removeListener('close', handleClientDisconnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`🔗 Claude Console API response: ${response.status}`);
|
||||||
|
|
||||||
|
// 检查是否为限流错误
|
||||||
|
if (response.status === 429) {
|
||||||
|
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`);
|
||||||
|
await claudeConsoleAccountService.markAccountRateLimited(accountId);
|
||||||
|
} else if (response.status === 200 || response.status === 201) {
|
||||||
|
// 如果请求成功,检查并移除限流状态
|
||||||
|
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId);
|
||||||
|
if (isRateLimited) {
|
||||||
|
await claudeConsoleAccountService.removeAccountRateLimit(accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后使用时间
|
||||||
|
await this._updateLastUsedTime(accountId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: response.status,
|
||||||
|
headers: response.headers,
|
||||||
|
body: typeof response.data === 'string' ? response.data : JSON.stringify(response.data),
|
||||||
|
accountId
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// 处理特定错误
|
||||||
|
if (error.name === 'AbortError' || error.code === 'ECONNABORTED') {
|
||||||
|
logger.info('Request aborted due to client disconnect');
|
||||||
|
throw new Error('Client disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('❌ Claude Console Claude relay request failed:', error.message);
|
||||||
|
|
||||||
|
// 检查是否是模型不支持导致的错误
|
||||||
|
if (error.response && error.response.data && error.response.data.error) {
|
||||||
|
const errorMessage = error.response.data.error.message || '';
|
||||||
|
if (errorMessage.includes('model') && errorMessage.includes('not supported')) {
|
||||||
|
await claudeConsoleAccountService.blockAccount(accountId, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🌊 处理流式响应
|
||||||
|
async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback, accountId, streamTransformer = null, options = {}) {
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await claudeConsoleAccountService.getAccount(accountId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Claude Console Claude account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`📡 Processing streaming Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`);
|
||||||
|
logger.debug(`🌐 Account API URL: ${account.apiUrl}`);
|
||||||
|
|
||||||
|
// 检查模型支持
|
||||||
|
if (account.supportedModels && account.supportedModels.length > 0) {
|
||||||
|
const requestedModel = requestBody.model;
|
||||||
|
if (requestedModel && !account.supportedModels.includes(requestedModel)) {
|
||||||
|
logger.warn(`🚫 Model not supported by Claude Console account ${account.name}: ${requestedModel}`);
|
||||||
|
|
||||||
|
// 标记账户为blocked
|
||||||
|
await claudeConsoleAccountService.blockAccount(accountId, `Model ${requestedModel} not supported`);
|
||||||
|
|
||||||
|
// 对于流式响应,需要写入错误并结束流
|
||||||
|
const errorResponse = JSON.stringify({
|
||||||
|
error: {
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
message: `Model ${requestedModel} is not supported by this account`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
responseStream.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
responseStream.end(errorResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建代理agent
|
||||||
|
const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy);
|
||||||
|
|
||||||
|
// 发送流式请求
|
||||||
|
await this._makeClaudeConsoleStreamRequest(
|
||||||
|
requestBody,
|
||||||
|
account,
|
||||||
|
proxyAgent,
|
||||||
|
clientHeaders,
|
||||||
|
responseStream,
|
||||||
|
accountId,
|
||||||
|
usageCallback,
|
||||||
|
streamTransformer,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新最后使用时间
|
||||||
|
await this._updateLastUsedTime(accountId);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Claude Console Claude stream relay failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🌊 发送流式请求到Claude Console API
|
||||||
|
async _makeClaudeConsoleStreamRequest(body, account, proxyAgent, clientHeaders, responseStream, accountId, usageCallback, streamTransformer = null, requestOptions = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let aborted = false;
|
||||||
|
|
||||||
|
// 构建完整的API URL
|
||||||
|
const cleanUrl = account.apiUrl.replace(/\/$/, ''); // 移除末尾斜杠
|
||||||
|
const apiEndpoint = cleanUrl.endsWith('/v1/messages')
|
||||||
|
? cleanUrl
|
||||||
|
: `${cleanUrl}/v1/messages`;
|
||||||
|
|
||||||
|
logger.debug(`🎯 Final API endpoint for stream: ${apiEndpoint}`);
|
||||||
|
|
||||||
|
// 准备请求配置
|
||||||
|
const requestConfig = {
|
||||||
|
method: 'POST',
|
||||||
|
url: apiEndpoint,
|
||||||
|
data: body,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': account.apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'User-Agent': account.userAgent || this.defaultUserAgent,
|
||||||
|
...this._filterClientHeaders(clientHeaders)
|
||||||
|
},
|
||||||
|
httpsAgent: proxyAgent,
|
||||||
|
timeout: config.proxy.timeout || 60000,
|
||||||
|
responseType: 'stream'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加beta header如果需要
|
||||||
|
if (requestOptions.betaHeader) {
|
||||||
|
requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const request = axios(requestConfig);
|
||||||
|
|
||||||
|
request.then(response => {
|
||||||
|
logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`);
|
||||||
|
|
||||||
|
// 错误响应处理
|
||||||
|
if (response.status !== 200) {
|
||||||
|
logger.error(`❌ Claude Console API returned error status: ${response.status}`);
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
claudeConsoleAccountService.markAccountRateLimited(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集错误数据
|
||||||
|
let errorData = '';
|
||||||
|
response.data.on('data', chunk => {
|
||||||
|
errorData += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
response.data.on('end', () => {
|
||||||
|
if (!responseStream.destroyed) {
|
||||||
|
responseStream.write('event: error\n');
|
||||||
|
responseStream.write(`data: ${JSON.stringify({
|
||||||
|
error: 'Claude Console API error',
|
||||||
|
status: response.status,
|
||||||
|
details: errorData,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})}\n\n`);
|
||||||
|
responseStream.end();
|
||||||
|
}
|
||||||
|
reject(new Error(`Claude Console API error: ${response.status}`));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功响应,检查并移除限流状态
|
||||||
|
claudeConsoleAccountService.isAccountRateLimited(accountId).then(isRateLimited => {
|
||||||
|
if (isRateLimited) {
|
||||||
|
claudeConsoleAccountService.removeAccountRateLimit(accountId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
if (!responseStream.headersSent) {
|
||||||
|
responseStream.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
let finalUsageReported = false;
|
||||||
|
let collectedUsageData = {};
|
||||||
|
|
||||||
|
// 处理流数据
|
||||||
|
response.data.on('data', chunk => {
|
||||||
|
try {
|
||||||
|
if (aborted) return;
|
||||||
|
|
||||||
|
const chunkStr = chunk.toString();
|
||||||
|
buffer += chunkStr;
|
||||||
|
|
||||||
|
// 处理完整的SSE行
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
// 转发数据并解析usage
|
||||||
|
if (lines.length > 0 && !responseStream.destroyed) {
|
||||||
|
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '');
|
||||||
|
|
||||||
|
// 应用流转换器如果有
|
||||||
|
if (streamTransformer) {
|
||||||
|
const transformed = streamTransformer(linesToForward);
|
||||||
|
if (transformed) {
|
||||||
|
responseStream.write(transformed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
responseStream.write(linesToForward);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析SSE数据寻找usage信息
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ') && line.length > 6) {
|
||||||
|
try {
|
||||||
|
const jsonStr = line.slice(6);
|
||||||
|
const data = JSON.parse(jsonStr);
|
||||||
|
|
||||||
|
// 收集usage数据
|
||||||
|
if (data.type === 'message_start' && data.message && data.message.usage) {
|
||||||
|
collectedUsageData.input_tokens = data.message.usage.input_tokens || 0;
|
||||||
|
collectedUsageData.cache_creation_input_tokens = data.message.usage.cache_creation_input_tokens || 0;
|
||||||
|
collectedUsageData.cache_read_input_tokens = data.message.usage.cache_read_input_tokens || 0;
|
||||||
|
collectedUsageData.model = data.message.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'message_delta' && data.usage && data.usage.output_tokens !== undefined) {
|
||||||
|
collectedUsageData.output_tokens = data.usage.output_tokens || 0;
|
||||||
|
|
||||||
|
if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) {
|
||||||
|
usageCallback({ ...collectedUsageData, accountId });
|
||||||
|
finalUsageReported = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查错误
|
||||||
|
if (data.type === 'error' && data.error) {
|
||||||
|
const errorMessage = data.error.message || '';
|
||||||
|
if (errorMessage.includes('model') && errorMessage.includes('not supported')) {
|
||||||
|
claudeConsoleAccountService.blockAccount(accountId, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error processing Claude Console stream data:', error);
|
||||||
|
if (!responseStream.destroyed) {
|
||||||
|
responseStream.write('event: error\n');
|
||||||
|
responseStream.write(`data: ${JSON.stringify({
|
||||||
|
error: 'Stream processing error',
|
||||||
|
message: error.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})}\n\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
response.data.on('end', () => {
|
||||||
|
try {
|
||||||
|
// 处理缓冲区中剩余的数据
|
||||||
|
if (buffer.trim() && !responseStream.destroyed) {
|
||||||
|
if (streamTransformer) {
|
||||||
|
const transformed = streamTransformer(buffer);
|
||||||
|
if (transformed) {
|
||||||
|
responseStream.write(transformed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
responseStream.write(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保流正确结束
|
||||||
|
if (!responseStream.destroyed) {
|
||||||
|
responseStream.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('🌊 Claude Console Claude stream response completed');
|
||||||
|
resolve();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error processing stream end:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
response.data.on('error', error => {
|
||||||
|
logger.error('❌ Claude Console stream error:', error);
|
||||||
|
if (!responseStream.destroyed) {
|
||||||
|
responseStream.write('event: error\n');
|
||||||
|
responseStream.write(`data: ${JSON.stringify({
|
||||||
|
error: 'Stream error',
|
||||||
|
message: error.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})}\n\n`);
|
||||||
|
responseStream.end();
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
}).catch(error => {
|
||||||
|
if (aborted) return;
|
||||||
|
|
||||||
|
logger.error('❌ Claude Console Claude stream request error:', error.message);
|
||||||
|
|
||||||
|
// 检查是否是429错误
|
||||||
|
if (error.response && error.response.status === 429) {
|
||||||
|
claudeConsoleAccountService.markAccountRateLimited(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送错误响应
|
||||||
|
if (!responseStream.headersSent) {
|
||||||
|
responseStream.writeHead(error.response?.status || 500, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseStream.destroyed) {
|
||||||
|
responseStream.write('event: error\n');
|
||||||
|
responseStream.write(`data: ${JSON.stringify({
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})}\n\n`);
|
||||||
|
responseStream.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理客户端断开连接
|
||||||
|
responseStream.on('close', () => {
|
||||||
|
logger.debug('🔌 Client disconnected, cleaning up Claude Console stream');
|
||||||
|
aborted = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 过滤客户端请求头
|
||||||
|
_filterClientHeaders(clientHeaders) {
|
||||||
|
const sensitiveHeaders = [
|
||||||
|
'x-api-key',
|
||||||
|
'authorization',
|
||||||
|
'host',
|
||||||
|
'content-length',
|
||||||
|
'connection',
|
||||||
|
'proxy-authorization',
|
||||||
|
'content-encoding',
|
||||||
|
'transfer-encoding'
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredHeaders = {};
|
||||||
|
|
||||||
|
Object.keys(clientHeaders || {}).forEach(key => {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
if (!sensitiveHeaders.includes(lowerKey)) {
|
||||||
|
filteredHeaders[key] = clientHeaders[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🕐 更新最后使用时间
|
||||||
|
async _updateLastUsedTime(accountId) {
|
||||||
|
try {
|
||||||
|
const client = require('../models/redis').getClientSafe();
|
||||||
|
await client.hset(
|
||||||
|
`claude_console_account:${accountId}`,
|
||||||
|
'lastUsedAt',
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ Failed to update last used time for Claude Console account ${accountId}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎯 健康检查
|
||||||
|
async healthCheck() {
|
||||||
|
try {
|
||||||
|
const accounts = await claudeConsoleAccountService.getAllAccounts();
|
||||||
|
const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active');
|
||||||
|
|
||||||
|
return {
|
||||||
|
healthy: activeAccounts.length > 0,
|
||||||
|
activeAccounts: activeAccounts.length,
|
||||||
|
totalAccounts: accounts.length,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Claude Console Claude health check failed:', error);
|
||||||
|
return {
|
||||||
|
healthy: false,
|
||||||
|
error: error.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ClaudeConsoleRelayService();
|
||||||
294
src/services/unifiedClaudeScheduler.js
Normal file
294
src/services/unifiedClaudeScheduler.js
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
const claudeAccountService = require('./claudeAccountService');
|
||||||
|
const claudeConsoleAccountService = require('./claudeConsoleAccountService');
|
||||||
|
const redis = require('../models/redis');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
class UnifiedClaudeScheduler {
|
||||||
|
constructor() {
|
||||||
|
this.SESSION_MAPPING_PREFIX = 'unified_claude_session_mapping:';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎯 统一调度Claude账号(官方和Console)
|
||||||
|
async selectAccountForApiKey(apiKeyData, sessionHash = null) {
|
||||||
|
try {
|
||||||
|
// 如果API Key绑定了专属账户,优先使用
|
||||||
|
if (apiKeyData.claudeAccountId) {
|
||||||
|
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId);
|
||||||
|
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||||
|
logger.info(`🎯 Using bound dedicated Claude account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`);
|
||||||
|
return {
|
||||||
|
accountId: apiKeyData.claudeAccountId,
|
||||||
|
accountType: 'claude-official'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
logger.warn(`⚠️ Bound Claude account ${apiKeyData.claudeAccountId} is not available, falling back to pool`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有会话哈希,检查是否有已映射的账户
|
||||||
|
if (sessionHash) {
|
||||||
|
const mappedAccount = await this._getSessionMapping(sessionHash);
|
||||||
|
if (mappedAccount) {
|
||||||
|
// 验证映射的账户是否仍然可用
|
||||||
|
const isAvailable = await this._isAccountAvailable(mappedAccount.accountId, mappedAccount.accountType);
|
||||||
|
if (isAvailable) {
|
||||||
|
logger.info(`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`);
|
||||||
|
return mappedAccount;
|
||||||
|
} else {
|
||||||
|
logger.warn(`⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account`);
|
||||||
|
await this._deleteSessionMapping(sessionHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有可用账户
|
||||||
|
const availableAccounts = await this._getAllAvailableAccounts(apiKeyData);
|
||||||
|
|
||||||
|
if (availableAccounts.length === 0) {
|
||||||
|
throw new Error('No available Claude accounts (neither official nor console)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按优先级和最后使用时间排序
|
||||||
|
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: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId: selectedAccount.accountId,
|
||||||
|
accountType: selectedAccount.accountType
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to select account for API key:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📋 获取所有可用账户(合并官方和Console)
|
||||||
|
async _getAllAvailableAccounts(apiKeyData) {
|
||||||
|
const availableAccounts = [];
|
||||||
|
|
||||||
|
// 如果API Key绑定了专属Claude账户,优先返回
|
||||||
|
if (apiKeyData.claudeAccountId) {
|
||||||
|
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId);
|
||||||
|
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error' && boundAccount.status !== 'blocked') {
|
||||||
|
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id);
|
||||||
|
if (!isRateLimited) {
|
||||||
|
logger.info(`🎯 Using bound dedicated Claude account: ${boundAccount.name} (${apiKeyData.claudeAccountId})`);
|
||||||
|
return [{
|
||||||
|
...boundAccount,
|
||||||
|
accountId: boundAccount.id,
|
||||||
|
accountType: 'claude-official',
|
||||||
|
priority: parseInt(boundAccount.priority) || 50,
|
||||||
|
lastUsedAt: boundAccount.lastUsedAt || '0'
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`⚠️ Bound Claude account ${apiKeyData.claudeAccountId} is not available`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取官方Claude账户(共享池)
|
||||||
|
const claudeAccounts = await redis.getAllClaudeAccounts();
|
||||||
|
for (const account of claudeAccounts) {
|
||||||
|
if (account.isActive === 'true' &&
|
||||||
|
account.status !== 'error' &&
|
||||||
|
account.status !== 'blocked' &&
|
||||||
|
(account.accountType === 'shared' || !account.accountType)) { // 兼容旧数据
|
||||||
|
|
||||||
|
// 检查是否被限流
|
||||||
|
const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id);
|
||||||
|
if (!isRateLimited) {
|
||||||
|
availableAccounts.push({
|
||||||
|
...account,
|
||||||
|
accountId: account.id,
|
||||||
|
accountType: 'claude-official',
|
||||||
|
priority: parseInt(account.priority) || 50, // 默认优先级50
|
||||||
|
lastUsedAt: account.lastUsedAt || '0'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取Claude Console账户
|
||||||
|
const consoleAccounts = await claudeConsoleAccountService.getAllAccounts();
|
||||||
|
logger.info(`📋 Found ${consoleAccounts.length} total Claude Console accounts`);
|
||||||
|
|
||||||
|
for (const account of consoleAccounts) {
|
||||||
|
logger.info(`🔍 Checking Claude Console account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}`);
|
||||||
|
|
||||||
|
// 注意:getAllAccounts返回的isActive是布尔值
|
||||||
|
if (account.isActive === true &&
|
||||||
|
account.status === 'active' &&
|
||||||
|
account.accountType === 'shared') {
|
||||||
|
|
||||||
|
// 检查是否被限流
|
||||||
|
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id);
|
||||||
|
if (!isRateLimited) {
|
||||||
|
availableAccounts.push({
|
||||||
|
...account,
|
||||||
|
accountId: account.id,
|
||||||
|
accountType: 'claude-console',
|
||||||
|
priority: parseInt(account.priority) || 50,
|
||||||
|
lastUsedAt: account.lastUsedAt || '0'
|
||||||
|
});
|
||||||
|
logger.info(`✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`❌ Claude Console account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter(a => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter(a => a.accountType === 'claude-console').length})`);
|
||||||
|
return availableAccounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔢 按优先级和最后使用时间排序账户
|
||||||
|
_sortAccountsByPriority(accounts) {
|
||||||
|
return accounts.sort((a, b) => {
|
||||||
|
// 首先按优先级排序(数字越小优先级越高)
|
||||||
|
if (a.priority !== b.priority) {
|
||||||
|
return a.priority - b.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||||
|
const aLastUsed = new Date(a.lastUsedAt || 0).getTime();
|
||||||
|
const bLastUsed = new Date(b.lastUsedAt || 0).getTime();
|
||||||
|
return aLastUsed - bLastUsed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 检查账户是否可用
|
||||||
|
async _isAccountAvailable(accountId, accountType) {
|
||||||
|
try {
|
||||||
|
if (accountType === 'claude-official') {
|
||||||
|
const account = await redis.getClaudeAccount(accountId);
|
||||||
|
if (!account || account.isActive !== 'true' || account.status === 'error') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !(await claudeAccountService.isAccountRateLimited(accountId));
|
||||||
|
} else if (accountType === 'claude-console') {
|
||||||
|
const account = await claudeConsoleAccountService.getAccount(accountId);
|
||||||
|
if (!account || !account.isActive || account.status !== 'active') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !(await claudeConsoleAccountService.isAccountRateLimited(accountId));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔗 获取会话映射
|
||||||
|
async _getSessionMapping(sessionHash) {
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`);
|
||||||
|
|
||||||
|
if (mappingData) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(mappingData);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('⚠️ Failed to parse session mapping:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💾 设置会话映射
|
||||||
|
async _setSessionMapping(sessionHash, accountId, accountType) {
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
const mappingData = JSON.stringify({ accountId, accountType });
|
||||||
|
|
||||||
|
// 设置1小时过期
|
||||||
|
await client.setex(
|
||||||
|
`${this.SESSION_MAPPING_PREFIX}${sessionHash}`,
|
||||||
|
3600,
|
||||||
|
mappingData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🗑️ 删除会话映射
|
||||||
|
async _deleteSessionMapping(sessionHash) {
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚫 标记账户为限流状态
|
||||||
|
async markAccountRateLimited(accountId, accountType, sessionHash = null) {
|
||||||
|
try {
|
||||||
|
if (accountType === 'claude-official') {
|
||||||
|
await claudeAccountService.markAccountRateLimited(accountId, sessionHash);
|
||||||
|
} else if (accountType === 'claude-console') {
|
||||||
|
await claudeConsoleAccountService.markAccountRateLimited(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除会话映射
|
||||||
|
if (sessionHash) {
|
||||||
|
await this._deleteSessionMapping(sessionHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 移除账户的限流状态
|
||||||
|
async removeAccountRateLimit(accountId, accountType) {
|
||||||
|
try {
|
||||||
|
if (accountType === 'claude-official') {
|
||||||
|
await claudeAccountService.removeAccountRateLimit(accountId);
|
||||||
|
} else if (accountType === 'claude-console') {
|
||||||
|
await claudeConsoleAccountService.removeAccountRateLimit(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 检查账户是否处于限流状态
|
||||||
|
async isAccountRateLimited(accountId, accountType) {
|
||||||
|
try {
|
||||||
|
if (accountType === 'claude-official') {
|
||||||
|
return await claudeAccountService.isAccountRateLimited(accountId);
|
||||||
|
} else if (accountType === 'claude-console') {
|
||||||
|
return await claudeConsoleAccountService.isAccountRateLimited(accountId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to check rate limit status: ${accountId} (${accountType})`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚫 标记Claude Console账户为封锁状态(模型不支持)
|
||||||
|
async blockConsoleAccount(accountId, reason) {
|
||||||
|
try {
|
||||||
|
await claudeConsoleAccountService.blockAccount(accountId, reason);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to block console account: ${accountId}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new UnifiedClaudeScheduler();
|
||||||
4
web/admin-spa/dist/index.html
vendored
4
web/admin-spa/dist/index.html
vendored
@@ -18,12 +18,12 @@
|
|||||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
|
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
|
||||||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
|
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
|
||||||
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
|
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
|
||||||
<script type="module" crossorigin src="/admin-next/assets/index-DhITICXu.js"></script>
|
<script type="module" crossorigin src="/admin-next/assets/index-yITK4-m_.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/admin-next/assets/vue-vendor-CKToUHZx.js">
|
<link rel="modulepreload" crossorigin href="/admin-next/assets/vue-vendor-CKToUHZx.js">
|
||||||
<link rel="modulepreload" crossorigin href="/admin-next/assets/vendor-BDiMbLwQ.js">
|
<link rel="modulepreload" crossorigin href="/admin-next/assets/vendor-BDiMbLwQ.js">
|
||||||
<link rel="modulepreload" crossorigin href="/admin-next/assets/element-plus-B8Fs_0jW.js">
|
<link rel="modulepreload" crossorigin href="/admin-next/assets/element-plus-B8Fs_0jW.js">
|
||||||
<link rel="stylesheet" crossorigin href="/admin-next/assets/element-plus-CPnoEkWW.css">
|
<link rel="stylesheet" crossorigin href="/admin-next/assets/element-plus-CPnoEkWW.css">
|
||||||
<link rel="stylesheet" crossorigin href="/admin-next/assets/index-Bv1yS6pY.css">
|
<link rel="stylesheet" crossorigin href="/admin-next/assets/index-DX8B4Y8f.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -53,6 +53,15 @@
|
|||||||
>
|
>
|
||||||
<span class="text-sm text-gray-700">Claude</span>
|
<span class="text-sm text-gray-700">Claude</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
v-model="form.platform"
|
||||||
|
value="claude-console"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-gray-700">Claude Console</span>
|
||||||
|
</label>
|
||||||
<label class="flex items-center cursor-pointer">
|
<label class="flex items-center cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -65,7 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isEdit">
|
<div v-if="!isEdit && form.platform !== 'claude-console'">
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-3">添加方式</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-3">添加方式</label>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<label class="flex items-center cursor-pointer">
|
<label class="flex items-center cursor-pointer">
|
||||||
@@ -168,8 +177,107 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude Console 特定字段 -->
|
||||||
|
<div v-if="form.platform === 'claude-console' && !isEdit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-3">API URL *</label>
|
||||||
|
<input
|
||||||
|
v-model="form.apiUrl"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="form-input w-full"
|
||||||
|
:class="{ 'border-red-500': errors.apiUrl }"
|
||||||
|
placeholder="例如:https://api.example.com"
|
||||||
|
>
|
||||||
|
<p v-if="errors.apiUrl" class="text-red-500 text-xs mt-1">{{ errors.apiUrl }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-3">API Key *</label>
|
||||||
|
<input
|
||||||
|
v-model="form.apiKey"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="form-input w-full"
|
||||||
|
:class="{ 'border-red-500': errors.apiKey }"
|
||||||
|
placeholder="请输入API Key"
|
||||||
|
>
|
||||||
|
<p v-if="errors.apiKey" class="text-red-500 text-xs mt-1">{{ errors.apiKey }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-3">支持的模型 (可选)</label>
|
||||||
|
<div class="mb-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addPresetModel('claude-sonnet-4-20250514')"
|
||||||
|
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
|
||||||
|
>
|
||||||
|
+ claude-sonnet-4-20250514
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addPresetModel('claude-opus-4-20250514')"
|
||||||
|
class="px-3 py-1 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
||||||
|
>
|
||||||
|
+ claude-opus-4-20250514
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addPresetModel('claude-3-5-haiku-20241022')"
|
||||||
|
class="px-3 py-1 text-xs bg-green-100 text-green-700 rounded-lg hover:bg-purple-200 transition-colors"
|
||||||
|
>
|
||||||
|
+ claude-3-5-haiku-20241022
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="form.supportedModels"
|
||||||
|
rows="3"
|
||||||
|
class="form-input w-full resize-none"
|
||||||
|
placeholder="每行一个模型,例如: claude-3-opus-20240229 claude-3-sonnet-20240229 留空表示支持所有模型"
|
||||||
|
></textarea>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">留空表示支持所有模型。如果指定模型,请求中的模型不在列表内将不会调度到此账号</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-3">自定义 User-Agent (可选)</label>
|
||||||
|
<input
|
||||||
|
v-model="form.userAgent"
|
||||||
|
type="text"
|
||||||
|
class="form-input w-full"
|
||||||
|
placeholder="默认:claude-cli/1.0.61 (console, cli)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-3">限流时间 (分钟)</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.rateLimitDuration"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="form-input w-full"
|
||||||
|
placeholder="默认60分钟"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">当账号返回429错误时,暂停调度的时间(分钟)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude和Claude Console的优先级设置 -->
|
||||||
|
<div v-if="(form.platform === 'claude' || form.platform === 'claude-console')">
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-3">调度优先级 (1-100)</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.priority"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
class="form-input w-full"
|
||||||
|
placeholder="数字越小优先级越高,默认50"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">数字越小优先级越高,建议范围:1-100</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 手动输入 Token 字段 -->
|
<!-- 手动输入 Token 字段 -->
|
||||||
<div v-if="form.addType === 'manual'" class="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-200">
|
<div v-if="form.addType === 'manual' && form.platform !== 'claude-console'" class="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||||
<div class="flex items-start gap-3 mb-4">
|
<div class="flex items-start gap-3 mb-4">
|
||||||
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
|
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
|
||||||
<i class="fas fa-info text-white text-sm"></i>
|
<i class="fas fa-info text-white text-sm"></i>
|
||||||
@@ -235,7 +343,7 @@
|
|||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="form.addType === 'oauth'"
|
v-if="form.addType === 'oauth' && form.platform !== 'claude-console'"
|
||||||
type="button"
|
type="button"
|
||||||
@click="nextStep"
|
@click="nextStep"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@@ -331,8 +439,93 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude和Claude Console的优先级设置(编辑模式) -->
|
||||||
|
<div v-if="(form.platform === 'claude' || form.platform === 'claude-console')">
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-3">调度优先级 (1-100)</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.priority"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
class="form-input w-full"
|
||||||
|
placeholder="数字越小优先级越高"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">数字越小优先级越高,建议范围:1-100</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude Console 特定字段(编辑模式)-->
|
||||||
|
<div v-if="form.platform === 'claude-console'" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-3">API URL</label>
|
||||||
|
<input
|
||||||
|
v-model="form.apiUrl"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="form-input w-full"
|
||||||
|
placeholder="例如:https://api.example.com"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-3">API Key</label>
|
||||||
|
<input
|
||||||
|
v-model="form.apiKey"
|
||||||
|
type="password"
|
||||||
|
class="form-input w-full"
|
||||||
|
placeholder="留空表示不更新"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">留空表示不更新 API Key</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-3">支持的模型 (可选)</label>
|
||||||
|
<div class="mb-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addPresetModel('claude-sonnet-4-20250514')"
|
||||||
|
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
|
||||||
|
>
|
||||||
|
+ claude-sonnet-4-20250514
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addPresetModel('claude-opus-4-20250514')"
|
||||||
|
class="px-3 py-1 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
||||||
|
>
|
||||||
|
+ claude-opus-4-20250514
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="form.supportedModels"
|
||||||
|
rows="3"
|
||||||
|
class="form-input w-full resize-none"
|
||||||
|
placeholder="每行一个模型,留空表示支持所有模型"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-3">自定义 User-Agent (可选)</label>
|
||||||
|
<input
|
||||||
|
v-model="form.userAgent"
|
||||||
|
type="text"
|
||||||
|
class="form-input w-full"
|
||||||
|
placeholder="默认:claude-cli/1.0.61 (console, cli)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-3">限流时间 (分钟)</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.rateLimitDuration"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="form-input w-full"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Token 更新 -->
|
<!-- Token 更新 -->
|
||||||
<div class="bg-amber-50 p-4 rounded-lg border border-amber-200">
|
<div v-if="form.platform !== 'claude-console'" class="bg-amber-50 p-4 rounded-lg border border-amber-200">
|
||||||
<div class="flex items-start gap-3 mb-4">
|
<div class="flex items-start gap-3 mb-4">
|
||||||
<div class="w-8 h-8 bg-amber-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
|
<div class="w-8 h-8 bg-amber-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
|
||||||
<i class="fas fa-key text-white text-sm"></i>
|
<i class="fas fa-key text-white text-sm"></i>
|
||||||
@@ -466,13 +659,22 @@ const form = ref({
|
|||||||
projectId: props.account?.projectId || '',
|
projectId: props.account?.projectId || '',
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
refreshToken: '',
|
refreshToken: '',
|
||||||
proxy: initProxyConfig()
|
proxy: initProxyConfig(),
|
||||||
|
// Claude Console 特定字段
|
||||||
|
apiUrl: props.account?.apiUrl || '',
|
||||||
|
apiKey: props.account?.apiKey || '',
|
||||||
|
priority: props.account?.priority || 50,
|
||||||
|
supportedModels: props.account?.supportedModels?.join('\n') || '',
|
||||||
|
userAgent: props.account?.userAgent || '',
|
||||||
|
rateLimitDuration: props.account?.rateLimitDuration || 60
|
||||||
})
|
})
|
||||||
|
|
||||||
// 表单验证错误
|
// 表单验证错误
|
||||||
const errors = ref({
|
const errors = ref({
|
||||||
name: '',
|
name: '',
|
||||||
accessToken: ''
|
accessToken: '',
|
||||||
|
apiUrl: '',
|
||||||
|
apiKey: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算是否可以进入下一步
|
// 计算是否可以进入下一步
|
||||||
@@ -539,6 +741,7 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
|||||||
if (form.value.platform === 'claude') {
|
if (form.value.platform === 'claude') {
|
||||||
// Claude使用claudeAiOauth字段
|
// Claude使用claudeAiOauth字段
|
||||||
data.claudeAiOauth = tokenInfo.claudeAiOauth || tokenInfo
|
data.claudeAiOauth = tokenInfo.claudeAiOauth || tokenInfo
|
||||||
|
data.priority = form.value.priority || 50
|
||||||
} else if (form.value.platform === 'gemini') {
|
} else if (form.value.platform === 'gemini') {
|
||||||
// Gemini使用geminiOauth字段
|
// Gemini使用geminiOauth字段
|
||||||
data.geminiOauth = tokenInfo.tokens || tokenInfo
|
data.geminiOauth = tokenInfo.tokens || tokenInfo
|
||||||
@@ -567,6 +770,8 @@ const createAccount = async () => {
|
|||||||
// 清除之前的错误
|
// 清除之前的错误
|
||||||
errors.value.name = ''
|
errors.value.name = ''
|
||||||
errors.value.accessToken = ''
|
errors.value.accessToken = ''
|
||||||
|
errors.value.apiUrl = ''
|
||||||
|
errors.value.apiKey = ''
|
||||||
|
|
||||||
let hasError = false
|
let hasError = false
|
||||||
|
|
||||||
@@ -575,7 +780,17 @@ const createAccount = async () => {
|
|||||||
hasError = true
|
hasError = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.value.addType === 'manual' && (!form.value.accessToken || form.value.accessToken.trim() === '')) {
|
// Claude Console 验证
|
||||||
|
if (form.value.platform === 'claude-console') {
|
||||||
|
if (!form.value.apiUrl || form.value.apiUrl.trim() === '') {
|
||||||
|
errors.value.apiUrl = '请填写 API URL'
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
if (!form.value.apiKey || form.value.apiKey.trim() === '') {
|
||||||
|
errors.value.apiKey = '请填写 API Key'
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
} else if (form.value.addType === 'manual' && (!form.value.accessToken || form.value.accessToken.trim() === '')) {
|
||||||
errors.value.accessToken = '请填写 Access Token'
|
errors.value.accessToken = '请填写 Access Token'
|
||||||
hasError = true
|
hasError = true
|
||||||
}
|
}
|
||||||
@@ -611,6 +826,7 @@ const createAccount = async () => {
|
|||||||
expiresAt: Date.now() + expiresInMs,
|
expiresAt: Date.now() + expiresInMs,
|
||||||
scopes: ['user:inference']
|
scopes: ['user:inference']
|
||||||
}
|
}
|
||||||
|
data.priority = form.value.priority || 50
|
||||||
} else if (form.value.platform === 'gemini') {
|
} else if (form.value.platform === 'gemini') {
|
||||||
// Gemini手动模式需要构建geminiOauth对象
|
// Gemini手动模式需要构建geminiOauth对象
|
||||||
const expiresInMs = form.value.refreshToken
|
const expiresInMs = form.value.refreshToken
|
||||||
@@ -628,11 +844,23 @@ const createAccount = async () => {
|
|||||||
if (form.value.projectId) {
|
if (form.value.projectId) {
|
||||||
data.projectId = form.value.projectId
|
data.projectId = form.value.projectId
|
||||||
}
|
}
|
||||||
|
} else if (form.value.platform === 'claude-console') {
|
||||||
|
// Claude Console 账户特定数据
|
||||||
|
data.apiUrl = form.value.apiUrl
|
||||||
|
data.apiKey = form.value.apiKey
|
||||||
|
data.priority = form.value.priority || 50
|
||||||
|
data.supportedModels = form.value.supportedModels
|
||||||
|
? form.value.supportedModels.split('\n').filter(m => m.trim())
|
||||||
|
: []
|
||||||
|
data.userAgent = form.value.userAgent || null
|
||||||
|
data.rateLimitDuration = form.value.rateLimitDuration || 60
|
||||||
}
|
}
|
||||||
|
|
||||||
let result
|
let result
|
||||||
if (form.value.platform === 'claude') {
|
if (form.value.platform === 'claude') {
|
||||||
result = await accountsStore.createClaudeAccount(data)
|
result = await accountsStore.createClaudeAccount(data)
|
||||||
|
} else if (form.value.platform === 'claude-console') {
|
||||||
|
result = await accountsStore.createClaudeConsoleAccount(data)
|
||||||
} else {
|
} else {
|
||||||
result = await accountsStore.createGeminiAccount(data)
|
result = await accountsStore.createGeminiAccount(data)
|
||||||
}
|
}
|
||||||
@@ -721,8 +949,29 @@ const updateAccount = async () => {
|
|||||||
data.projectId = form.value.projectId
|
data.projectId = form.value.projectId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude 官方账号优先级更新
|
||||||
|
if (props.account.platform === 'claude') {
|
||||||
|
data.priority = form.value.priority || 50
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude Console 特定更新
|
||||||
|
if (props.account.platform === 'claude-console') {
|
||||||
|
data.apiUrl = form.value.apiUrl
|
||||||
|
if (form.value.apiKey) {
|
||||||
|
data.apiKey = form.value.apiKey
|
||||||
|
}
|
||||||
|
data.priority = form.value.priority || 50
|
||||||
|
data.supportedModels = form.value.supportedModels
|
||||||
|
? form.value.supportedModels.split('\n').filter(m => m.trim())
|
||||||
|
: []
|
||||||
|
data.userAgent = form.value.userAgent || null
|
||||||
|
data.rateLimitDuration = form.value.rateLimitDuration || 60
|
||||||
|
}
|
||||||
|
|
||||||
if (props.account.platform === 'claude') {
|
if (props.account.platform === 'claude') {
|
||||||
await accountsStore.updateClaudeAccount(props.account.id, data)
|
await accountsStore.updateClaudeAccount(props.account.id, data)
|
||||||
|
} else if (props.account.platform === 'claude-console') {
|
||||||
|
await accountsStore.updateClaudeConsoleAccount(props.account.id, data)
|
||||||
} else {
|
} else {
|
||||||
await accountsStore.updateGeminiAccount(props.account.id, data)
|
await accountsStore.updateGeminiAccount(props.account.id, data)
|
||||||
}
|
}
|
||||||
@@ -749,6 +998,46 @@ watch(() => form.value.accessToken, () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听API URL变化,清除错误
|
||||||
|
watch(() => form.value.apiUrl, () => {
|
||||||
|
if (errors.value.apiUrl && form.value.apiUrl?.trim()) {
|
||||||
|
errors.value.apiUrl = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听API Key变化,清除错误
|
||||||
|
watch(() => form.value.apiKey, () => {
|
||||||
|
if (errors.value.apiKey && form.value.apiKey?.trim()) {
|
||||||
|
errors.value.apiKey = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听平台变化,重置表单
|
||||||
|
watch(() => form.value.platform, (newPlatform) => {
|
||||||
|
if (newPlatform === 'claude-console') {
|
||||||
|
form.value.addType = 'manual' // Claude Console 只支持手动模式
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加预设模型
|
||||||
|
const addPresetModel = (modelName) => {
|
||||||
|
// 获取当前模型列表
|
||||||
|
const currentModels = form.value.supportedModels
|
||||||
|
? form.value.supportedModels.split('\n').filter(m => m.trim())
|
||||||
|
: []
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
if (currentModels.includes(modelName)) {
|
||||||
|
showToast(`模型 ${modelName} 已存在`, 'info')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到列表
|
||||||
|
currentModels.push(modelName)
|
||||||
|
form.value.supportedModels = currentModels.join('\n')
|
||||||
|
showToast(`已添加模型 ${modelName}`, 'success')
|
||||||
|
}
|
||||||
|
|
||||||
// 监听账户变化,更新表单
|
// 监听账户变化,更新表单
|
||||||
watch(() => props.account, (newAccount) => {
|
watch(() => props.account, (newAccount) => {
|
||||||
if (newAccount) {
|
if (newAccount) {
|
||||||
@@ -780,7 +1069,14 @@ watch(() => props.account, (newAccount) => {
|
|||||||
projectId: newAccount.projectId || '',
|
projectId: newAccount.projectId || '',
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
refreshToken: '',
|
refreshToken: '',
|
||||||
proxy: proxyConfig
|
proxy: proxyConfig,
|
||||||
|
// Claude Console 特定字段
|
||||||
|
apiUrl: newAccount.apiUrl || '',
|
||||||
|
apiKey: '', // 编辑模式不显示现有的 API Key
|
||||||
|
priority: newAccount.priority || 50,
|
||||||
|
supportedModels: newAccount.supportedModels?.join('\n') || '',
|
||||||
|
userAgent: newAccount.userAgent || '',
|
||||||
|
rateLimitDuration: newAccount.rateLimitDuration || 60
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { apiClient } from '@/config/api'
|
|||||||
export const useAccountsStore = defineStore('accounts', () => {
|
export const useAccountsStore = defineStore('accounts', () => {
|
||||||
// 状态
|
// 状态
|
||||||
const claudeAccounts = ref([])
|
const claudeAccounts = ref([])
|
||||||
|
const claudeConsoleAccounts = ref([])
|
||||||
const geminiAccounts = ref([])
|
const geminiAccounts = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
@@ -32,6 +33,25 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取Claude Console账户列表
|
||||||
|
const fetchClaudeConsoleAccounts = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/admin/claude-console-accounts')
|
||||||
|
if (response.success) {
|
||||||
|
claudeConsoleAccounts.value = response.data || []
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '获取Claude Console账户失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取Gemini账户列表
|
// 获取Gemini账户列表
|
||||||
const fetchGeminiAccounts = async () => {
|
const fetchGeminiAccounts = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -58,6 +78,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchClaudeAccounts(),
|
fetchClaudeAccounts(),
|
||||||
|
fetchClaudeConsoleAccounts(),
|
||||||
fetchGeminiAccounts()
|
fetchGeminiAccounts()
|
||||||
])
|
])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -88,6 +109,26 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建Claude Console账户
|
||||||
|
const createClaudeConsoleAccount = async (data) => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/admin/claude-console-accounts', data)
|
||||||
|
if (response.success) {
|
||||||
|
await fetchClaudeConsoleAccounts()
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '创建Claude Console账户失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 创建Gemini账户
|
// 创建Gemini账户
|
||||||
const createGeminiAccount = async (data) => {
|
const createGeminiAccount = async (data) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -128,6 +169,26 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新Claude Console账户
|
||||||
|
const updateClaudeConsoleAccount = async (id, data) => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/admin/claude-console-accounts/${id}`, data)
|
||||||
|
if (response.success) {
|
||||||
|
await fetchClaudeConsoleAccounts()
|
||||||
|
return response
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '更新Claude Console账户失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 更新Gemini账户
|
// 更新Gemini账户
|
||||||
const updateGeminiAccount = async (id, data) => {
|
const updateGeminiAccount = async (id, data) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -153,14 +214,21 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const endpoint = platform === 'claude'
|
let endpoint
|
||||||
? `/admin/claude-accounts/${id}/toggle`
|
if (platform === 'claude') {
|
||||||
: `/admin/gemini-accounts/${id}/toggle`
|
endpoint = `/admin/claude-accounts/${id}/toggle`
|
||||||
|
} else if (platform === 'claude-console') {
|
||||||
|
endpoint = `/admin/claude-console-accounts/${id}/toggle`
|
||||||
|
} else {
|
||||||
|
endpoint = `/admin/gemini-accounts/${id}/toggle`
|
||||||
|
}
|
||||||
|
|
||||||
const response = await apiClient.put(endpoint)
|
const response = await apiClient.put(endpoint)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (platform === 'claude') {
|
if (platform === 'claude') {
|
||||||
await fetchClaudeAccounts()
|
await fetchClaudeAccounts()
|
||||||
|
} else if (platform === 'claude-console') {
|
||||||
|
await fetchClaudeConsoleAccounts()
|
||||||
} else {
|
} else {
|
||||||
await fetchGeminiAccounts()
|
await fetchGeminiAccounts()
|
||||||
}
|
}
|
||||||
@@ -181,14 +249,21 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const endpoint = platform === 'claude'
|
let endpoint
|
||||||
? `/admin/claude-accounts/${id}`
|
if (platform === 'claude') {
|
||||||
: `/admin/gemini-accounts/${id}`
|
endpoint = `/admin/claude-accounts/${id}`
|
||||||
|
} else if (platform === 'claude-console') {
|
||||||
|
endpoint = `/admin/claude-console-accounts/${id}`
|
||||||
|
} else {
|
||||||
|
endpoint = `/admin/gemini-accounts/${id}`
|
||||||
|
}
|
||||||
|
|
||||||
const response = await apiClient.delete(endpoint)
|
const response = await apiClient.delete(endpoint)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (platform === 'claude') {
|
if (platform === 'claude') {
|
||||||
await fetchClaudeAccounts()
|
await fetchClaudeAccounts()
|
||||||
|
} else if (platform === 'claude-console') {
|
||||||
|
await fetchClaudeConsoleAccounts()
|
||||||
} else {
|
} else {
|
||||||
await fetchGeminiAccounts()
|
await fetchGeminiAccounts()
|
||||||
}
|
}
|
||||||
@@ -284,6 +359,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 排序账户
|
// 排序账户
|
||||||
const sortAccounts = (field) => {
|
const sortAccounts = (field) => {
|
||||||
if (sortBy.value === field) {
|
if (sortBy.value === field) {
|
||||||
@@ -297,6 +373,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
// 重置store
|
// 重置store
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
claudeAccounts.value = []
|
claudeAccounts.value = []
|
||||||
|
claudeConsoleAccounts.value = []
|
||||||
geminiAccounts.value = []
|
geminiAccounts.value = []
|
||||||
loading.value = false
|
loading.value = false
|
||||||
error.value = null
|
error.value = null
|
||||||
@@ -307,6 +384,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
claudeAccounts,
|
claudeAccounts,
|
||||||
|
claudeConsoleAccounts,
|
||||||
geminiAccounts,
|
geminiAccounts,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
@@ -315,11 +393,14 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
fetchClaudeAccounts,
|
fetchClaudeAccounts,
|
||||||
|
fetchClaudeConsoleAccounts,
|
||||||
fetchGeminiAccounts,
|
fetchGeminiAccounts,
|
||||||
fetchAllAccounts,
|
fetchAllAccounts,
|
||||||
createClaudeAccount,
|
createClaudeAccount,
|
||||||
|
createClaudeConsoleAccount,
|
||||||
createGeminiAccount,
|
createGeminiAccount,
|
||||||
updateClaudeAccount,
|
updateClaudeAccount,
|
||||||
|
updateClaudeConsoleAccount,
|
||||||
updateGeminiAccount,
|
updateGeminiAccount,
|
||||||
toggleAccount,
|
toggleAccount,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
|
|||||||
@@ -60,6 +60,11 @@
|
|||||||
<i v-if="accountsSortBy === 'status'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
<i v-if="accountsSortBy === 'status'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||||
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||||
</th>
|
</th>
|
||||||
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('priority')">
|
||||||
|
优先级
|
||||||
|
<i v-if="accountsSortBy === 'priority'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||||
|
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||||
|
</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">代理</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">代理</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">今日使用</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">今日使用</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">会话窗口</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">会话窗口</th>
|
||||||
@@ -95,13 +100,21 @@
|
|||||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">
|
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">
|
||||||
<i class="fas fa-robot mr-1"></i>Gemini
|
<i class="fas fa-robot mr-1"></i>Gemini
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="account.platform === 'claude-console'"
|
||||||
|
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-purple-100 text-purple-800">
|
||||||
|
<i class="fas fa-terminal mr-1"></i>Claude Console
|
||||||
|
</span>
|
||||||
<span v-else
|
<span v-else
|
||||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-800">
|
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-800">
|
||||||
<i class="fas fa-brain mr-1"></i>Claude
|
<i class="fas fa-brain mr-1"></i>Claude
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<span v-if="account.scopes && account.scopes.length > 0"
|
<span v-if="account.platform === 'claude-console'"
|
||||||
|
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800">
|
||||||
|
<i class="fas fa-key mr-1"></i>API Key
|
||||||
|
</span>
|
||||||
|
<span v-else-if="account.scopes && account.scopes.length > 0"
|
||||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
|
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
|
||||||
<i class="fas fa-lock mr-1"></i>OAuth
|
<i class="fas fa-lock mr-1"></i>OAuth
|
||||||
</span>
|
</span>
|
||||||
@@ -113,22 +126,43 @@
|
|||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
|
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
|
||||||
|
account.status === 'blocked' ? 'bg-orange-100 text-orange-800' :
|
||||||
account.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
|
account.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
|
||||||
<div :class="['w-2 h-2 rounded-full mr-2',
|
<div :class="['w-2 h-2 rounded-full mr-2',
|
||||||
|
account.status === 'blocked' ? 'bg-orange-500' :
|
||||||
account.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
|
account.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
|
||||||
{{ account.isActive ? '正常' : '异常' }}
|
{{ account.status === 'blocked' ? '已封锁' : account.isActive ? '正常' : '异常' }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="account.rateLimitStatus && account.rateLimitStatus.isRateLimited"
|
<span v-if="account.rateLimitStatus && account.rateLimitStatus.isRateLimited"
|
||||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">
|
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
限流中 ({{ account.rateLimitStatus.minutesRemaining }}分钟)
|
限流中 ({{ account.rateLimitStatus.minutesRemaining }}分钟)
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="account.status === 'blocked' && account.errorMessage"
|
||||||
|
class="text-xs text-gray-500 mt-1 max-w-xs truncate"
|
||||||
|
:title="account.errorMessage">
|
||||||
|
{{ account.errorMessage }}
|
||||||
|
</span>
|
||||||
<span v-if="account.accountType === 'dedicated'"
|
<span v-if="account.accountType === 'dedicated'"
|
||||||
class="text-xs text-gray-500">
|
class="text-xs text-gray-500">
|
||||||
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
|
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div v-if="account.platform === 'claude' || account.platform === 'claude-console'" class="flex items-center gap-2">
|
||||||
|
<div class="w-16 bg-gray-200 rounded-full h-2">
|
||||||
|
<div class="bg-gradient-to-r from-green-500 to-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
:style="{ width: ((100 - (account.priority || 50)) + '%') }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-700 font-medium min-w-[20px]">
|
||||||
|
{{ account.priority || 50 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-400 text-sm">
|
||||||
|
<span class="text-xs">N/A</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||||
<div v-if="formatProxyDisplay(account.proxy)" class="text-xs bg-blue-50 px-2 py-1 rounded font-mono">
|
<div v-if="formatProxyDisplay(account.proxy)" class="text-xs bg-blue-50 px-2 py-1 rounded font-mono">
|
||||||
{{ formatProxyDisplay(account.proxy) }}
|
{{ formatProxyDisplay(account.proxy) }}
|
||||||
@@ -251,6 +285,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
|||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
import { apiClient } from '@/config/api'
|
import { apiClient } from '@/config/api'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { useAccountsStore } from '@/stores/accounts'
|
||||||
import AccountForm from '@/components/accounts/AccountForm.vue'
|
import AccountForm from '@/components/accounts/AccountForm.vue'
|
||||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|
||||||
@@ -314,8 +349,9 @@ const sortedAccounts = computed(() => {
|
|||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
accountsLoading.value = true
|
accountsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const [claudeData, geminiData, apiKeysData] = await Promise.all([
|
const [claudeData, claudeConsoleData, geminiData, apiKeysData] = await Promise.all([
|
||||||
apiClient.get('/admin/claude-accounts'),
|
apiClient.get('/admin/claude-accounts'),
|
||||||
|
apiClient.get('/admin/claude-console-accounts'),
|
||||||
apiClient.get('/admin/gemini-accounts'),
|
apiClient.get('/admin/gemini-accounts'),
|
||||||
apiClient.get('/admin/api-keys')
|
apiClient.get('/admin/api-keys')
|
||||||
])
|
])
|
||||||
@@ -336,6 +372,14 @@ const loadAccounts = async () => {
|
|||||||
allAccounts.push(...claudeAccounts)
|
allAccounts.push(...claudeAccounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (claudeConsoleData.success) {
|
||||||
|
const claudeConsoleAccounts = (claudeConsoleData.data || []).map(acc => {
|
||||||
|
// Claude Console账户暂时不支持直接绑定
|
||||||
|
return { ...acc, platform: 'claude-console', boundApiKeysCount: 0 }
|
||||||
|
})
|
||||||
|
allAccounts.push(...claudeConsoleAccounts)
|
||||||
|
}
|
||||||
|
|
||||||
if (geminiData.success) {
|
if (geminiData.success) {
|
||||||
const geminiAccounts = (geminiData.data || []).map(acc => {
|
const geminiAccounts = (geminiData.data || []).map(acc => {
|
||||||
// 计算每个Gemini账户绑定的API Key数量
|
// 计算每个Gemini账户绑定的API Key数量
|
||||||
@@ -449,6 +493,7 @@ const formatRemainingTime = (minutes) => {
|
|||||||
return `${mins}分钟`
|
return `${mins}分钟`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 打开创建账户模态框
|
// 打开创建账户模态框
|
||||||
const openCreateAccountModal = () => {
|
const openCreateAccountModal = () => {
|
||||||
showCreateAccountModal.value = true
|
showCreateAccountModal.value = true
|
||||||
@@ -483,9 +528,14 @@ const deleteAccount = async (account) => {
|
|||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const endpoint = account.platform === 'claude'
|
let endpoint
|
||||||
? `/admin/claude-accounts/${account.id}`
|
if (account.platform === 'claude') {
|
||||||
: `/admin/gemini-accounts/${account.id}`
|
endpoint = `/admin/claude-accounts/${account.id}`
|
||||||
|
} else if (account.platform === 'claude-console') {
|
||||||
|
endpoint = `/admin/claude-console-accounts/${account.id}`
|
||||||
|
} else {
|
||||||
|
endpoint = `/admin/gemini-accounts/${account.id}`
|
||||||
|
}
|
||||||
|
|
||||||
const data = await apiClient.delete(endpoint)
|
const data = await apiClient.delete(endpoint)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user