mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Merge pull request #133 from kevinconan/main
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,198 @@ router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 切换Claude账户调度状态
|
||||||
|
router.put('/claude-accounts/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
|
||||||
|
const accounts = await claudeAccountService.getAllAccounts();
|
||||||
|
const account = accounts.find(acc => acc.id === accountId);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSchedulable = !account.schedulable;
|
||||||
|
await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable });
|
||||||
|
|
||||||
|
logger.success(`🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`);
|
||||||
|
res.json({ success: true, schedulable: newSchedulable });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle Claude account schedulable status:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to toggle schedulable status', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🎮 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换Claude Console账户调度状态
|
||||||
|
router.put('/claude-console-accounts/:accountId/toggle-schedulable', 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 newSchedulable = !account.schedulable;
|
||||||
|
await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable });
|
||||||
|
|
||||||
|
logger.success(`🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`);
|
||||||
|
res.json({ success: true, schedulable: newSchedulable });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to toggle Claude Console account schedulable status:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to toggle schedulable 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,7 +59,16 @@ async function handleMessagesRequest(req, res) {
|
|||||||
|
|
||||||
let usageDataCaptured = false;
|
let usageDataCaptured = false;
|
||||||
|
|
||||||
// 使用自定义流处理器来捕获usage数据
|
// 生成会话哈希用于sticky会话
|
||||||
|
const sessionHash = sessionHelper.generateSessionHash(req.body);
|
||||||
|
|
||||||
|
// 使用统一调度选择账号(传递请求的模型)
|
||||||
|
const requestedModel = req.body.model;
|
||||||
|
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(req.apiKey, sessionHash, requestedModel);
|
||||||
|
|
||||||
|
// 根据账号类型选择对应的转发服务并调用
|
||||||
|
if (accountType === 'claude-official') {
|
||||||
|
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||||
await claudeRelayService.relayStreamRequestWithUsageCapture(req.body, req.apiKey, res, req.headers, (usageData) => {
|
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));
|
||||||
@@ -89,6 +101,41 @@ async function handleMessagesRequest(req, res) {
|
|||||||
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 +150,27 @@ 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 requestedModel = req.body.model;
|
||||||
|
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(req.apiKey, sessionHash, requestedModel);
|
||||||
|
|
||||||
|
// 根据账号类型选择对应的转发服务
|
||||||
|
let response;
|
||||||
|
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`);
|
||||||
|
logger.debug(`[DEBUG] Request URL: ${req.url}`);
|
||||||
|
logger.debug(`[DEBUG] Request path: ${req.path}`);
|
||||||
|
|
||||||
|
if (accountType === 'claude-official') {
|
||||||
|
// 官方Claude账号使用原有的转发服务
|
||||||
|
response = await claudeRelayService.relayRequest(req.body, req.apiKey, req, res, req.headers);
|
||||||
|
} else {
|
||||||
|
// Claude Console账号使用Console转发服务
|
||||||
|
logger.debug(`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`);
|
||||||
|
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,9 @@ 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,数字越小优先级越高)
|
||||||
|
schedulable = true // 是否可被调度
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const accountId = uuidv4();
|
const accountId = uuidv4();
|
||||||
@@ -60,11 +62,13 @@ 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: '',
|
||||||
status: 'active', // 有OAuth数据的账户直接设为active
|
status: 'active', // 有OAuth数据的账户直接设为active
|
||||||
errorMessage: ''
|
errorMessage: '',
|
||||||
|
schedulable: schedulable.toString() // 是否可被调度
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// 兼容旧格式
|
// 兼容旧格式
|
||||||
@@ -81,11 +85,13 @@ 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: '',
|
||||||
status: 'created', // created, active, expired, error
|
status: 'created', // created, active, expired, error
|
||||||
errorMessage: ''
|
errorMessage: '',
|
||||||
|
schedulable: schedulable.toString() // 是否可被调度
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +107,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 +312,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,
|
||||||
@@ -323,7 +331,9 @@ class ClaudeAccountService {
|
|||||||
progress: 0,
|
progress: 0,
|
||||||
remainingTime: null,
|
remainingTime: null,
|
||||||
lastRequestTime: null
|
lastRequestTime: null
|
||||||
}
|
},
|
||||||
|
// 添加调度状态
|
||||||
|
schedulable: account.schedulable !== 'false' // 默认为true,兼容历史数据
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -343,7 +353,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', 'schedulable'];
|
||||||
const updatedData = { ...accountData };
|
const updatedData = { ...accountData };
|
||||||
|
|
||||||
// 检查是否新增了 refresh token
|
// 检查是否新增了 refresh token
|
||||||
@@ -355,6 +365,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 +1020,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}`);
|
||||||
|
|||||||
493
src/services/claudeConsoleAccountService.js
Normal file
493
src/services/claudeConsoleAccountService.js
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
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'
|
||||||
|
schedulable = true // 是否可被调度
|
||||||
|
} = 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: 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: '',
|
||||||
|
// 调度控制
|
||||||
|
schedulable: schedulable.toString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
logger.debug(`[DEBUG] Saving account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`);
|
||||||
|
logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`);
|
||||||
|
|
||||||
|
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: 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,
|
||||||
|
schedulable: accountData.schedulable !== 'false' // 默认为true,只有明确设置为false才不可调度
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude Console accounts:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 获取单个账户(内部使用,包含敏感信息)
|
||||||
|
async getAccount(accountId) {
|
||||||
|
const client = redis.getClientSafe();
|
||||||
|
logger.debug(`[DEBUG] Getting account data for ID: ${accountId}`);
|
||||||
|
const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`);
|
||||||
|
|
||||||
|
if (!accountData || Object.keys(accountData).length === 0) {
|
||||||
|
logger.debug(`[DEBUG] No account data found for ID: ${accountId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`[DEBUG] Raw account data keys: ${Object.keys(accountData).join(', ')}`);
|
||||||
|
logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`);
|
||||||
|
|
||||||
|
// 解密敏感字段(只解密apiKey,apiUrl不加密)
|
||||||
|
const decryptedKey = this._decryptSensitiveData(accountData.apiKey);
|
||||||
|
logger.debug(`[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}`);
|
||||||
|
|
||||||
|
accountData.apiKey = decryptedKey;
|
||||||
|
|
||||||
|
// 解析JSON字段
|
||||||
|
const parsedModels = JSON.parse(accountData.supportedModels || '[]');
|
||||||
|
logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`);
|
||||||
|
|
||||||
|
accountData.supportedModels = parsedModels;
|
||||||
|
accountData.priority = parseInt(accountData.priority) || 50;
|
||||||
|
accountData.rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60;
|
||||||
|
accountData.isActive = accountData.isActive === 'true';
|
||||||
|
accountData.schedulable = accountData.schedulable !== 'false'; // 默认为true
|
||||||
|
|
||||||
|
if (accountData.proxy) {
|
||||||
|
accountData.proxy = JSON.parse(accountData.proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`);
|
||||||
|
|
||||||
|
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 = {};
|
||||||
|
|
||||||
|
// 处理各个字段的更新
|
||||||
|
logger.debug(`[DEBUG] Update request received with fields: ${Object.keys(updates).join(', ')}`);
|
||||||
|
logger.debug(`[DEBUG] Updates content: ${JSON.stringify(updates, null, 2)}`);
|
||||||
|
|
||||||
|
if (updates.name !== undefined) updatedData.name = updates.name;
|
||||||
|
if (updates.description !== undefined) updatedData.description = updates.description;
|
||||||
|
if (updates.apiUrl !== undefined) {
|
||||||
|
logger.debug(`[DEBUG] Updating apiUrl from frontend: ${updates.apiUrl}`);
|
||||||
|
updatedData.apiUrl = updates.apiUrl;
|
||||||
|
}
|
||||||
|
if (updates.apiKey !== undefined) {
|
||||||
|
logger.debug(`[DEBUG] Updating apiKey (length: ${updates.apiKey?.length})`);
|
||||||
|
updatedData.apiKey = this._encryptSensitiveData(updates.apiKey);
|
||||||
|
}
|
||||||
|
if (updates.priority !== undefined) updatedData.priority = updates.priority.toString();
|
||||||
|
if (updates.supportedModels !== undefined) {
|
||||||
|
logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`);
|
||||||
|
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.schedulable !== undefined) updatedData.schedulable = updates.schedulable.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();
|
||||||
|
|
||||||
|
logger.debug(`[DEBUG] Final updatedData to save: ${JSON.stringify(updatedData, null, 2)}`);
|
||||||
|
logger.debug(`[DEBUG] Updating Redis key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`);
|
||||||
|
|
||||||
|
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();
|
||||||
496
src/services/claudeConsoleRelayService.js
Normal file
496
src/services/claudeConsoleRelayService.js
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
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}`);
|
||||||
|
logger.debug(`🔍 Account supportedModels: ${JSON.stringify(account.supportedModels)}`);
|
||||||
|
logger.debug(`🔑 Account has apiKey: ${!!account.apiKey}`);
|
||||||
|
logger.debug(`📝 Request model: ${requestBody.model}`);
|
||||||
|
|
||||||
|
// 模型兼容性检查已经在调度器中完成,这里不需要再检查
|
||||||
|
|
||||||
|
// 创建代理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}`);
|
||||||
|
logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`);
|
||||||
|
logger.debug(`[DEBUG] Client headers received: ${JSON.stringify(clientHeaders)}`);
|
||||||
|
|
||||||
|
// 过滤客户端请求头
|
||||||
|
const filteredHeaders = this._filterClientHeaders(clientHeaders);
|
||||||
|
logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`);
|
||||||
|
|
||||||
|
// 准备请求配置
|
||||||
|
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,
|
||||||
|
...filteredHeaders
|
||||||
|
},
|
||||||
|
httpsAgent: proxyAgent,
|
||||||
|
timeout: config.proxy.timeout || 60000,
|
||||||
|
signal: abortController.signal,
|
||||||
|
validateStatus: () => true // 接受所有状态码
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug(`[DEBUG] Initial headers before beta: ${JSON.stringify(requestConfig.headers, null, 2)}`);
|
||||||
|
|
||||||
|
// 添加beta header如果需要
|
||||||
|
if (options.betaHeader) {
|
||||||
|
logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`);
|
||||||
|
requestConfig.headers['anthropic-beta'] = options.betaHeader;
|
||||||
|
} else {
|
||||||
|
logger.debug(`[DEBUG] No beta header to add`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
logger.debug(`📤 Sending request to Claude Console API with headers:`, JSON.stringify(requestConfig.headers, null, 2));
|
||||||
|
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}`);
|
||||||
|
logger.debug(`[DEBUG] Response headers: ${JSON.stringify(response.headers)}`);
|
||||||
|
logger.debug(`[DEBUG] Response data type: ${typeof response.data}`);
|
||||||
|
logger.debug(`[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}`);
|
||||||
|
logger.debug(`[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}`);
|
||||||
|
|
||||||
|
// 检查是否为限流错误
|
||||||
|
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);
|
||||||
|
|
||||||
|
const responseBody = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
|
||||||
|
logger.debug(`[DEBUG] Final response body to return: ${responseBody}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: response.status,
|
||||||
|
headers: response.headers,
|
||||||
|
body: responseBody,
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 不再因为模型不支持而block账号
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
// 模型兼容性检查已经在调度器中完成,这里不需要再检查
|
||||||
|
|
||||||
|
// 创建代理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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不再因为模型不支持而block账号
|
||||||
|
} 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();
|
||||||
319
src/services/unifiedClaudeScheduler.js
Normal file
319
src/services/unifiedClaudeScheduler.js
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
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, requestedModel = 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, requestedModel);
|
||||||
|
|
||||||
|
if (availableAccounts.length === 0) {
|
||||||
|
// 提供更详细的错误信息
|
||||||
|
if (requestedModel) {
|
||||||
|
throw new Error(`No available Claude accounts support the requested model: ${requestedModel}`);
|
||||||
|
} else {
|
||||||
|
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, requestedModel = null) {
|
||||||
|
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) && // 兼容旧数据
|
||||||
|
account.schedulable !== 'false') { // 检查是否可调度
|
||||||
|
|
||||||
|
// 检查是否被限流
|
||||||
|
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}, schedulable: ${account.schedulable}`);
|
||||||
|
|
||||||
|
// 注意:getAllAccounts返回的isActive是布尔值
|
||||||
|
if (account.isActive === true &&
|
||||||
|
account.status === 'active' &&
|
||||||
|
account.accountType === 'shared' &&
|
||||||
|
account.schedulable !== false) { // 检查是否可调度
|
||||||
|
|
||||||
|
// 检查模型支持(如果有请求的模型)
|
||||||
|
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
|
||||||
|
if (!account.supportedModels.includes(requestedModel)) {
|
||||||
|
logger.info(`🚫 Claude Console account ${account.name} does not support model ${requestedModel}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否被限流
|
||||||
|
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}, schedulable: ${account.schedulable}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// 检查是否可调度
|
||||||
|
if (account.schedulable === 'false') {
|
||||||
|
logger.info(`🚫 Account ${accountId} is not schedulable`);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// 检查是否可调度
|
||||||
|
if (account.schedulable === false) {
|
||||||
|
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`);
|
||||||
|
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();
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.accounts-container[data-v-aec45f3e]{min-height:calc(100vh - 300px)}.table-container[data-v-aec45f3e]{overflow-x:auto;border-radius:12px;border:1px solid rgba(0,0,0,.05)}.table-row[data-v-aec45f3e]{transition:all .2s ease}.table-row[data-v-aec45f3e]:hover{background-color:#00000005}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{c as b,r as x,q as f,x as a,z as s,L as i,Q as y,u as o,P as m,Y as _,K as u,aq as c,O as g,y as n}from"./vue-vendor-CKToUHZx.js";import{_ as v,u as w}from"./index-DhITICXu.js";/* empty css */import"./element-plus-B8Fs_0jW.js";import"./vendor-BDiMbLwQ.js";const h={class:"flex items-center justify-center min-h-screen p-6"},k={class:"glass-strong rounded-3xl p-10 w-full max-w-md shadow-2xl"},L={class:"text-center mb-8"},S={class:"w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-2xl flex items-center justify-center backdrop-blur-sm overflow-hidden"},V=["src"],I={key:1,class:"fas fa-cloud text-3xl text-gray-700"},N={key:1,class:"w-12 h-12 bg-gray-300/50 rounded animate-pulse"},q={key:0,class:"text-3xl font-bold text-white mb-2 header-title"},D={key:1,class:"h-9 w-64 bg-gray-300/50 rounded animate-pulse mx-auto mb-2"},E=["disabled"],j={key:0,class:"fas fa-sign-in-alt mr-2"},B={key:1,class:"loading-spinner mr-2"},M={key:0,class:"mt-6 p-4 bg-red-500/20 border border-red-500/30 rounded-xl text-red-800 text-sm text-center backdrop-blur-sm"},F={__name:"LoginView",setup(O){const e=w(),d=b(()=>e.oemLoading),l=x({username:"",password:""});f(()=>{e.loadOemSettings()});const p=async()=>{await e.login(l.value)};return(T,t)=>(n(),a("div",h,[s("div",k,[s("div",L,[s("div",S,[d.value?(n(),a("div",N)):(n(),a(y,{key:0},[o(e).oemSettings.siteIconData||o(e).oemSettings.siteIcon?(n(),a("img",{key:0,src:o(e).oemSettings.siteIconData||o(e).oemSettings.siteIcon,alt:"Logo",class:"w-12 h-12 object-contain",onError:t[0]||(t[0]=r=>r.target.style.display="none")},null,40,V)):(n(),a("i",I))],64))]),!d.value&&o(e).oemSettings.siteName?(n(),a("h1",q,m(o(e).oemSettings.siteName),1)):d.value?(n(),a("div",D)):i("",!0),t[3]||(t[3]=s("p",{class:"text-gray-600 text-lg"},"管理后台",-1))]),s("form",{onSubmit:_(p,["prevent"]),class:"space-y-6"},[s("div",null,[t[4]||(t[4]=s("label",{class:"block text-sm font-semibold text-gray-900 mb-3"},"用户名",-1)),u(s("input",{"onUpdate:modelValue":t[1]||(t[1]=r=>l.value.username=r),type:"text",required:"",class:"form-input w-full",placeholder:"请输入用户名"},null,512),[[c,l.value.username]])]),s("div",null,[t[5]||(t[5]=s("label",{class:"block text-sm font-semibold text-gray-900 mb-3"},"密码",-1)),u(s("input",{"onUpdate:modelValue":t[2]||(t[2]=r=>l.value.password=r),type:"password",required:"",class:"form-input w-full",placeholder:"请输入密码"},null,512),[[c,l.value.password]])]),s("button",{type:"submit",disabled:o(e).loginLoading,class:"btn btn-primary w-full py-4 px-6 text-lg font-semibold"},[o(e).loginLoading?i("",!0):(n(),a("i",j)),o(e).loginLoading?(n(),a("div",B)):i("",!0),g(" "+m(o(e).loginLoading?"登录中...":"登录"),1)],8,E)],32),o(e).loginError?(n(),a("div",M,[t[6]||(t[6]=s("i",{class:"fas fa-exclamation-triangle mr-2"},null,-1)),g(m(o(e).loginError),1)])):i("",!0)])]))}},P=v(F,[["__scopeId","data-v-4a19afbe"]]);export{P as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/* empty css */import{_ as r}from"./index-DhITICXu.js";import{x as t,y as s,z as l,Q as d,L as o,A as c,C as g,P as i}from"./vue-vendor-CKToUHZx.js";const u={class:"flex items-center gap-4"},f={class:"w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0 overflow-hidden"},y=["src"],m={key:1,class:"fas fa-cloud text-xl text-gray-700"},h={key:1,class:"w-8 h-8 bg-gray-300/50 rounded animate-pulse"},x={class:"flex flex-col justify-center min-h-[48px]"},b={class:"flex items-center gap-3"},k={key:1,class:"h-8 w-64 bg-gray-300/50 rounded animate-pulse"},_={key:0,class:"text-gray-600 text-sm leading-tight mt-0.5"},S={__name:"LogoTitle",props:{loading:{type:Boolean,default:!1},title:{type:String,default:""},subtitle:{type:String,default:""},logoSrc:{type:String,default:""},titleClass:{type:String,default:"text-gray-900"}},setup(e){const n=a=>{a.target.style.display="none"};return(a,p)=>(s(),t("div",u,[l("div",f,[e.loading?(s(),t("div",h)):(s(),t(d,{key:0},[e.logoSrc?(s(),t("img",{key:0,src:e.logoSrc,alt:"Logo",class:"w-8 h-8 object-contain",onError:n},null,40,y)):(s(),t("i",m))],64))]),l("div",x,[l("div",b,[!e.loading&&e.title?(s(),t("h1",{key:0,class:g(["text-2xl font-bold header-title leading-tight",e.titleClass])},i(e.title),3)):e.loading?(s(),t("div",k)):o("",!0),c(a.$slots,"after-title",{},void 0,!0)]),e.subtitle?(s(),t("p",_,i(e.subtitle),1)):o("",!0)])]))}},C=r(S,[["__scopeId","data-v-a75bf797"]]);export{C as L};
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
|||||||
import{aR as k,r as x,aW as C,q as O,x as m,z as e,u as i,K as T,aq as N,L as _,O as v,C as j,P as S,y as g}from"./vue-vendor-CKToUHZx.js";import{s as c}from"./toast-BvwA7Mwb.js";import{a as D,_ as F}from"./index-DhITICXu.js";import"./element-plus-B8Fs_0jW.js";import"./vendor-BDiMbLwQ.js";const E=k("settings",()=>{const l=x({siteName:"Claude Relay Service",siteIcon:"",siteIconData:"",updatedAt:null}),r=x(!1),p=x(!1),d=async()=>{r.value=!0;try{const s=await D.get("/admin/oem-settings");return s&&s.success&&(l.value={...l.value,...s.data},f()),s}catch(s){throw console.error("Failed to load OEM settings:",s),s}finally{r.value=!1}},a=async s=>{p.value=!0;try{const o=await D.put("/admin/oem-settings",s);return o&&o.success&&(l.value={...l.value,...o.data},f()),o}catch(o){throw console.error("Failed to save OEM settings:",o),o}finally{p.value=!1}},w=async()=>{const s={siteName:"Claude Relay Service",siteIcon:"",siteIconData:"",updatedAt:null};return l.value={...s},await a(s)},f=()=>{if(l.value.siteName&&(document.title=`${l.value.siteName} - 管理后台`),l.value.siteIconData||l.value.siteIcon){const s=document.querySelector('link[rel="icon"]')||document.createElement("link");s.rel="icon",s.href=l.value.siteIconData||l.value.siteIcon,document.querySelector('link[rel="icon"]')||document.head.appendChild(s)}};return{oemSettings:l,loading:r,saving:p,loadOemSettings:d,saveOemSettings:a,resetOemSettings:w,applyOemSettings:f,formatDateTime:s=>s?new Date(s).toLocaleString("zh-CN",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"}):"",validateIconFile:s=>{const o=[];return s.size>350*1024&&o.push("图标文件大小不能超过 350KB"),["image/x-icon","image/png","image/jpeg","image/jpg","image/svg+xml"].includes(s.type)||o.push("不支持的文件类型,请选择 .ico, .png, .jpg 或 .svg 文件"),{isValid:o.length===0,errors:o}},fileToBase64:s=>new Promise((o,n)=>{const t=new FileReader;t.onload=u=>o(u.target.result),t.onerror=n,t.readAsDataURL(s)})}}),B={class:"settings-container"},V={class:"card p-6"},R={key:0,class:"text-center py-12"},M={key:1,class:"table-container"},A={class:"min-w-full"},q={class:"divide-y divide-gray-200/50"},z={class:"table-row"},K={class:"px-6 py-4"},L={class:"table-row"},U={class:"px-6 py-4"},$={class:"space-y-3"},P={key:0,class:"inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg"},W=["src"],G={class:"px-6 py-6",colspan:"2"},H={class:"flex items-center justify-between"},J={class:"flex gap-3"},Q=["disabled"],X={key:0,class:"loading-spinner mr-2"},Y={key:1,class:"fas fa-save mr-2"},Z=["disabled"],ee={key:0,class:"text-sm text-gray-500"},te={__name:"SettingsView",setup(l){const r=E(),{loading:p,saving:d,oemSettings:a}=C(r),w=x();O(async()=>{try{await r.loadOemSettings()}catch{c("加载设置失败","error")}});const f=async()=>{try{const n={siteName:a.value.siteName,siteIcon:a.value.siteIcon,siteIconData:a.value.siteIconData},t=await r.saveOemSettings(n);t&&t.success?c("OEM设置保存成功","success"):c((t==null?void 0:t.message)||"保存失败","error")}catch{c("保存OEM设置失败","error")}},b=async()=>{if(confirm(`确定要重置为默认设置吗?
|
|
||||||
|
|
||||||
这将清除所有自定义的网站名称和图标设置。`))try{const n=await r.resetOemSettings();n&&n.success?c("已重置为默认设置","success"):c("重置失败","error")}catch{c("重置失败","error")}},h=async n=>{const t=n.target.files[0];if(!t)return;const u=r.validateIconFile(t);if(!u.isValid){u.errors.forEach(y=>c(y,"error"));return}try{const y=await r.fileToBase64(t);a.value.siteIconData=y}catch{c("文件读取失败","error")}n.target.value=""},I=()=>{a.value.siteIcon="",a.value.siteIconData=""},s=()=>{console.warn("Icon failed to load")},o=r.formatDateTime;return(n,t)=>(g(),m("div",B,[e("div",V,[t[12]||(t[12]=e("div",{class:"flex flex-col md:flex-row justify-between items-center gap-4 mb-6"},[e("div",null,[e("h3",{class:"text-xl font-bold text-gray-900 mb-2"},"其他设置"),e("p",{class:"text-gray-600"},"自定义网站名称和图标")])],-1)),i(p)?(g(),m("div",R,t[2]||(t[2]=[e("div",{class:"loading-spinner mx-auto mb-4"},null,-1),e("p",{class:"text-gray-500"},"正在加载设置...",-1)]))):(g(),m("div",M,[e("table",A,[e("tbody",q,[e("tr",z,[t[4]||(t[4]=e("td",{class:"px-6 py-4 whitespace-nowrap w-48"},[e("div",{class:"flex items-center"},[e("div",{class:"w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3"},[e("i",{class:"fas fa-font text-white text-xs"})]),e("div",null,[e("div",{class:"text-sm font-semibold text-gray-900"},"网站名称"),e("div",{class:"text-xs text-gray-500"},"品牌标识")])])],-1)),e("td",K,[T(e("input",{"onUpdate:modelValue":t[0]||(t[0]=u=>i(a).siteName=u),type:"text",class:"form-input w-full max-w-md",placeholder:"Claude Relay Service",maxlength:"100"},null,512),[[N,i(a).siteName]]),t[3]||(t[3]=e("p",{class:"text-xs text-gray-500 mt-1"},"将显示在浏览器标题和页面头部",-1))])]),e("tr",L,[t[9]||(t[9]=e("td",{class:"px-6 py-4 whitespace-nowrap w-48"},[e("div",{class:"flex items-center"},[e("div",{class:"w-8 h-8 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg flex items-center justify-center mr-3"},[e("i",{class:"fas fa-image text-white text-xs"})]),e("div",null,[e("div",{class:"text-sm font-semibold text-gray-900"},"网站图标"),e("div",{class:"text-xs text-gray-500"},"Favicon")])])],-1)),e("td",U,[e("div",$,[i(a).siteIconData||i(a).siteIcon?(g(),m("div",P,[e("img",{src:i(a).siteIconData||i(a).siteIcon,alt:"图标预览",class:"w-8 h-8",onError:s},null,40,W),t[6]||(t[6]=e("span",{class:"text-sm text-gray-600"},"当前图标",-1)),e("button",{onClick:I,class:"text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"},t[5]||(t[5]=[e("i",{class:"fas fa-trash mr-1"},null,-1),v("删除 ",-1)]))])):_("",!0),e("div",null,[e("input",{type:"file",ref_key:"iconFileInput",ref:w,onChange:h,accept:".ico,.png,.jpg,.jpeg,.svg",class:"hidden"},null,544),e("button",{onClick:t[1]||(t[1]=u=>n.$refs.iconFileInput.click()),class:"btn btn-success px-4 py-2"},t[7]||(t[7]=[e("i",{class:"fas fa-upload mr-2"},null,-1),v(" 上传图标 ",-1)])),t[8]||(t[8]=e("span",{class:"text-xs text-gray-500 ml-3"},"支持 .ico, .png, .jpg, .svg 格式,最大 350KB",-1))])])])]),e("tr",null,[e("td",G,[e("div",H,[e("div",J,[e("button",{onClick:f,disabled:i(d),class:j(["btn btn-primary px-6 py-3",{"opacity-50 cursor-not-allowed":i(d)}])},[i(d)?(g(),m("div",X)):(g(),m("i",Y)),v(" "+S(i(d)?"保存中...":"保存设置"),1)],10,Q),e("button",{onClick:b,class:"btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3",disabled:i(d)},t[10]||(t[10]=[e("i",{class:"fas fa-undo mr-2"},null,-1),v(" 重置为默认 ",-1)]),8,Z)]),i(a).updatedAt?(g(),m("div",ee,[t[11]||(t[11]=e("i",{class:"fas fa-clock mr-1"},null,-1)),v(" 最后更新:"+S(i(o)(i(a).updatedAt)),1)])):_("",!0)])])])])])]))])]))}},le=F(te,[["__scopeId","data-v-3508e0de"]]);export{le as default};
|
|
||||||
File diff suppressed because one or more lines are too long
5
web/admin-spa/dist/assets/index-Bv1yS6pY.css
vendored
5
web/admin-spa/dist/assets/index-Bv1yS6pY.css
vendored
File diff suppressed because one or more lines are too long
2
web/admin-spa/dist/assets/index-DhITICXu.js
vendored
2
web/admin-spa/dist/assets/index-DhITICXu.js
vendored
File diff suppressed because one or more lines are too long
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-hS0iOrU-.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-Ce1o7Q_r.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">支持的模型 (可选)--注意,ClaudeCode必须加上hiku模型!</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="每行一个模型,留空表示支持所有模型。特别注意,ClaudeCode必须加上hiku模型!"
|
||||||
|
></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,100 @@
|
|||||||
</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>
|
||||||
|
<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="每行一个模型,留空表示支持所有模型。特别注意,ClaudeCode必须加上hiku模型!"
|
||||||
|
></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 +666,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 +748,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 +777,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 +787,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 +833,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 +851,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 +956,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 +1005,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 +1076,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,48 @@
|
|||||||
<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.schedulable === false"
|
||||||
|
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-700">
|
||||||
|
<i class="fas fa-pause-circle mr-1"></i>
|
||||||
|
不可调度
|
||||||
|
</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: ((101 - (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) }}
|
||||||
@@ -198,6 +237,24 @@
|
|||||||
account.isRefreshing ? 'animate-spin' : ''
|
account.isRefreshing ? 'animate-spin' : ''
|
||||||
]"></i>
|
]"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
@click="toggleSchedulable(account)"
|
||||||
|
:disabled="account.isTogglingSchedulable"
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
|
||||||
|
account.isTogglingSchedulable
|
||||||
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
: account.schedulable
|
||||||
|
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
]"
|
||||||
|
:title="account.schedulable ? '点击禁用调度' : '点击启用调度'"
|
||||||
|
>
|
||||||
|
<i :class="[
|
||||||
|
'fas',
|
||||||
|
account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off'
|
||||||
|
]"></i>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="editAccount(account)"
|
@click="editAccount(account)"
|
||||||
class="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg text-xs font-medium hover:bg-blue-200 transition-colors"
|
class="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg text-xs font-medium hover:bg-blue-200 transition-colors"
|
||||||
@@ -251,6 +308,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 +372,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 +395,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 +516,7 @@ const formatRemainingTime = (minutes) => {
|
|||||||
return `${mins}分钟`
|
return `${mins}分钟`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 打开创建账户模态框
|
// 打开创建账户模态框
|
||||||
const openCreateAccountModal = () => {
|
const openCreateAccountModal = () => {
|
||||||
showCreateAccountModal.value = true
|
showCreateAccountModal.value = true
|
||||||
@@ -483,9 +551,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)
|
||||||
|
|
||||||
@@ -521,6 +594,41 @@ const refreshToken = async (account) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换调度状态
|
||||||
|
const toggleSchedulable = async (account) => {
|
||||||
|
if (account.isTogglingSchedulable) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
account.isTogglingSchedulable = true
|
||||||
|
|
||||||
|
let endpoint
|
||||||
|
if (account.platform === 'claude') {
|
||||||
|
endpoint = `/admin/claude-accounts/${account.id}/toggle-schedulable`
|
||||||
|
} else if (account.platform === 'claude-console') {
|
||||||
|
endpoint = `/admin/claude-console-accounts/${account.id}/toggle-schedulable`
|
||||||
|
} else {
|
||||||
|
showToast('Gemini账户暂不支持调度控制', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await apiClient.put(endpoint)
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
account.schedulable = data.schedulable
|
||||||
|
showToast(
|
||||||
|
data.schedulable ? '已启用调度' : '已禁用调度',
|
||||||
|
'success'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
showToast(data.message || '操作失败', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('切换调度状态失败', 'error')
|
||||||
|
} finally {
|
||||||
|
account.isTogglingSchedulable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理创建成功
|
// 处理创建成功
|
||||||
const handleCreateSuccess = () => {
|
const handleCreateSuccess = () => {
|
||||||
showCreateAccountModal.value = false
|
showCreateAccountModal.value = false
|
||||||
|
|||||||
Reference in New Issue
Block a user