From 4863a3732828a5452a6a6951f02a1e08a3d9e1af Mon Sep 17 00:00:00 2001 From: sususu Date: Tue, 16 Dec 2025 18:31:07 +0800 Subject: [PATCH 01/36] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Claude=20Cod?= =?UTF-8?q?e=20=E9=81=A5=E6=B5=8B=E7=AB=AF=E7=82=B9=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=97=A5=E5=BF=97=E7=BA=A7=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 /api/event_logging/batch 端点处理客户端遥测请求 - 将遥测相关请求日志改为 debug 级别,减少日志噪音 --- src/middleware/auth.js | 14 +++++++++++--- src/routes/api.js | 5 +++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index a5568323..29fbc5e1 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1112,9 +1112,13 @@ const requestLogger = (req, res, next) => { const referer = req.get('Referer') || 'none' // 记录请求开始 + const isDebugRoute = req.originalUrl.includes('event_logging') if (req.originalUrl !== '/health') { - // 避免健康检查日志过多 - logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) + if (isDebugRoute) { + logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) + } else { + logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) + } } res.on('finish', () => { @@ -1146,7 +1150,11 @@ const requestLogger = (req, res, next) => { logMetadata ) } else if (req.originalUrl !== '/health') { - logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata) + if (isDebugRoute) { + logger.debug(`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`, logMetadata) + } else { + logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata) + } } // API Key相关日志 diff --git a/src/routes/api.js b/src/routes/api.js index 8fe0676b..b2ee018c 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -1362,5 +1362,10 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => } }) +// Claude Code 客户端遥测端点 - 返回成功响应避免 404 日志 +router.post('/event_logging/batch', (req, res) => { + res.status(200).json({ success: true }) +}) + module.exports = router module.exports.handleMessagesRequest = handleMessagesRequest From 0994eb346fe7b46a6111fdf5b7282a544b64bb9e Mon Sep 17 00:00:00 2001 From: sususu Date: Tue, 16 Dec 2025 18:32:11 +0800 Subject: [PATCH 02/36] format --- src/middleware/auth.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 29fbc5e1..484d5743 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1151,7 +1151,10 @@ const requestLogger = (req, res, next) => { ) } else if (req.originalUrl !== '/health') { if (isDebugRoute) { - logger.debug(`🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`, logMetadata) + logger.debug( + `🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`, + logMetadata + ) } else { logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata) } From 33ea26f2ac094a77db0a9075a2d7313bf41a2d83 Mon Sep 17 00:00:00 2001 From: Guccbai <1456714872@qq.com> Date: Wed, 17 Dec 2025 11:35:11 +0800 Subject: [PATCH 03/36] =?UTF-8?q?feat(permissions):=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E6=9D=83=E9=99=90=E4=BB=8E=E5=8D=95=E9=80=89=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=A4=9A=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 API Key 的服务权限从单选改为多选,支持同时选择多个服务 - 移除"全部服务"选项,空数组表示允许访问全部服务 - 后端自动兼容旧格式('all' -> [], 'claude' -> ['claude']) - 前端 radio 改为 checkbox,更新账户选择器联动逻辑 修改文件: - apiKeyService.js: 添加 normalizePermissions/hasPermission 函数 - api.js, droidRoutes.js, openaiRoutes.js, unified.js, openaiGeminiRoutes.js, geminiHandlers.js: 使用新权限验证函数 - admin/apiKeys.js: 支持数组格式权限验证 - CreateApiKeyModal.vue, EditApiKeyModal.vue: UI 改为 checkbox 多选 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/handlers/geminiHandlers.js | 3 +- src/routes/admin/apiKeys.js | 86 ++++++++++++------- src/routes/api.js | 6 +- src/routes/droidRoutes.js | 4 +- src/routes/openaiGeminiRoutes.js | 4 +- src/routes/openaiRoutes.js | 3 +- src/routes/unified.js | 3 +- src/services/apiKeyService.js | 55 ++++++++++-- .../components/apikeys/CreateApiKeyModal.vue | 49 +++++------ .../components/apikeys/EditApiKeyModal.vue | 61 ++++++------- 10 files changed, 163 insertions(+), 111 deletions(-) diff --git a/src/handlers/geminiHandlers.js b/src/handlers/geminiHandlers.js index 87295d31..063fec9b 100644 --- a/src/handlers/geminiHandlers.js +++ b/src/handlers/geminiHandlers.js @@ -86,8 +86,7 @@ function generateSessionHash(req) { * 检查 API Key 权限 */ function checkPermissions(apiKeyData, requiredPermission = 'gemini') { - const permissions = apiKeyData?.permissions || 'all' - return permissions === 'all' || permissions === requiredPermission + return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission) } /** diff --git a/src/routes/admin/apiKeys.js b/src/routes/admin/apiKeys.js index d88444bd..5994f56d 100644 --- a/src/routes/admin/apiKeys.js +++ b/src/routes/admin/apiKeys.js @@ -8,6 +8,43 @@ const config = require('../../../config/config') const router = express.Router() +// 有效的权限值列表 +const VALID_PERMISSIONS = ['claude', 'gemini', 'openai', 'droid'] + +/** + * 验证权限数组格式 + * @param {any} permissions - 权限值(可以是数组或其他) + * @returns {string|null} - 返回错误消息,null 表示验证通过 + */ +function validatePermissions(permissions) { + // 空值或未定义表示全部服务 + if (permissions === undefined || permissions === null || permissions === '') { + return null + } + // 兼容旧格式字符串 + if (typeof permissions === 'string') { + if (permissions === 'all' || VALID_PERMISSIONS.includes(permissions)) { + return null + } + return `Invalid permissions value. Must be an array of: ${VALID_PERMISSIONS.join(', ')}` + } + // 新格式数组 + if (Array.isArray(permissions)) { + // 空数组表示全部服务 + if (permissions.length === 0) { + return null + } + // 验证数组中的每个值 + for (const perm of permissions) { + if (!VALID_PERMISSIONS.includes(perm)) { + return `Invalid permission value "${perm}". Valid values are: ${VALID_PERMISSIONS.join(', ')}` + } + } + return null + } + return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}` +} + // 👥 用户管理 (用于API Key分配) // 获取所有用户列表(用于API Key分配) @@ -1382,16 +1419,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { } } - // 验证服务权限字段 - if ( - permissions !== undefined && - permissions !== null && - permissions !== '' && - !['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions) - ) { - return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' - }) + // 验证服务权限字段(支持数组格式) + const permissionsError = validatePermissions(permissions) + if (permissionsError) { + return res.status(400).json({ error: permissionsError }) } const newKey = await apiKeyService.generateApiKey({ @@ -1481,15 +1512,10 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { .json({ error: 'Base name must be less than 90 characters to allow for numbering' }) } - if ( - permissions !== undefined && - permissions !== null && - permissions !== '' && - !['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions) - ) { - return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' - }) + // 验证服务权限字段(支持数组格式) + const batchPermissionsError = validatePermissions(permissions) + if (batchPermissionsError) { + return res.status(400).json({ error: batchPermissionsError }) } // 生成批量API Keys @@ -1592,13 +1618,12 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { }) } - if ( - updates.permissions !== undefined && - !['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions) - ) { - return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' - }) + // 验证服务权限字段(支持数组格式) + if (updates.permissions !== undefined) { + const updatePermissionsError = validatePermissions(updates.permissions) + if (updatePermissionsError) { + return res.status(400).json({ error: updatePermissionsError }) + } } logger.info( @@ -1873,11 +1898,10 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { } if (permissions !== undefined) { - // 验证权限值 - if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) { - return res.status(400).json({ - error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' - }) + // 验证服务权限字段(支持数组格式) + const singlePermissionsError = validatePermissions(permissions) + if (singlePermissionsError) { + return res.status(400).json({ error: singlePermissionsError }) } updates.permissions = permissions } diff --git a/src/routes/api.js b/src/routes/api.js index 3defdc19..bf7abae7 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -111,11 +111,7 @@ async function handleMessagesRequest(req, res) { const startTime = Date.now() // Claude 服务权限校验,阻止未授权的 Key - if ( - req.apiKey.permissions && - req.apiKey.permissions !== 'all' && - req.apiKey.permissions !== 'claude' - ) { + if (!apiKeyService.hasPermission(req.apiKey.permissions, 'claude')) { return res.status(403).json({ error: { type: 'permission_error', diff --git a/src/routes/droidRoutes.js b/src/routes/droidRoutes.js index f8479cde..b6d9932a 100644 --- a/src/routes/droidRoutes.js +++ b/src/routes/droidRoutes.js @@ -4,12 +4,12 @@ const { authenticateApiKey } = require('../middleware/auth') const droidRelayService = require('../services/droidRelayService') const sessionHelper = require('../utils/sessionHelper') const logger = require('../utils/logger') +const apiKeyService = require('../services/apiKeyService') const router = express.Router() function hasDroidPermission(apiKeyData) { - const permissions = apiKeyData?.permissions || 'all' - return permissions === 'all' || permissions === 'droid' + return apiKeyService.hasPermission(apiKeyData?.permissions, 'droid') } /** diff --git a/src/routes/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js index fd74ad86..70795d9e 100644 --- a/src/routes/openaiGeminiRoutes.js +++ b/src/routes/openaiGeminiRoutes.js @@ -6,6 +6,7 @@ const geminiAccountService = require('../services/geminiAccountService') const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler') const { getAvailableModels } = require('../services/geminiRelayService') const crypto = require('crypto') +const apiKeyService = require('../services/apiKeyService') // 生成会话哈希 function generateSessionHash(req) { @@ -21,8 +22,7 @@ function generateSessionHash(req) { // 检查 API Key 权限 function checkPermissions(apiKeyData, requiredPermission = 'gemini') { - const permissions = apiKeyData.permissions || 'all' - return permissions === 'all' || permissions === requiredPermission + return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission) } // 转换 OpenAI 消息格式到 Gemini 格式 diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index 7faf9e87..7f1b04f1 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -20,8 +20,7 @@ function createProxyAgent(proxy) { // 检查 API Key 是否具备 OpenAI 权限 function checkOpenAIPermissions(apiKeyData) { - const permissions = apiKeyData?.permissions || 'all' - return permissions === 'all' || permissions === 'openai' + return apiKeyService.hasPermission(apiKeyData?.permissions, 'openai') } function normalizeHeaders(headers = {}) { diff --git a/src/routes/unified.js b/src/routes/unified.js index a8a8e69d..57c4fe80 100644 --- a/src/routes/unified.js +++ b/src/routes/unified.js @@ -8,6 +8,7 @@ const { handleStreamGenerateContent: geminiHandleStreamGenerateContent } = require('../handlers/geminiHandlers') const openaiRoutes = require('./openaiRoutes') +const apiKeyService = require('../services/apiKeyService') const router = express.Router() @@ -73,7 +74,7 @@ async function routeToBackend(req, res, requestedModel) { return await openaiRoutes.handleResponses(req, res) } else if (backend === 'gemini') { // Gemini 后端 - if (permissions !== 'all' && permissions !== 'gemini') { + if (!apiKeyService.hasPermission(permissions, 'gemini')) { return res.status(403).json({ error: { message: 'This API key does not have permission to access Gemini', diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 0e9e7597..ef3f3b80 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -37,6 +37,43 @@ const ACCOUNT_CATEGORY_MAP = { droid: 'droid' } +/** + * 规范化权限数据,兼容旧格式(字符串)和新格式(数组) + * @param {string|array} permissions - 权限数据 + * @returns {array} - 权限数组,空数组表示全部服务 + */ +function normalizePermissions(permissions) { + if (!permissions) return [] // 空 = 全部服务 + if (Array.isArray(permissions)) return permissions + // 尝试解析 JSON 字符串(新格式存储) + if (typeof permissions === 'string') { + if (permissions.startsWith('[')) { + try { + const parsed = JSON.parse(permissions) + if (Array.isArray(parsed)) return parsed + } catch (e) { + // 解析失败,继续处理为普通字符串 + } + } + // 旧格式 'all' 转为空数组 + if (permissions === 'all') return [] + // 旧单个字符串转为数组 + return [permissions] + } + return [] +} + +/** + * 检查是否有访问特定服务的权限 + * @param {string|array} permissions - 权限数据 + * @param {string} service - 服务名称(claude/gemini/openai/droid) + * @returns {boolean} - 是否有权限 + */ +function hasPermission(permissions, service) { + const perms = normalizePermissions(permissions) + return perms.length === 0 || perms.includes(service) // 空数组 = 全部服务 +} + function normalizeAccountTypeKey(type) { if (!type) { return null @@ -89,7 +126,7 @@ class ApiKeyService { azureOpenaiAccountId = null, bedrockAccountId = null, // 添加 Bedrock 账号ID支持 droidAccountId = null, - permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all' + permissions = [], // 数组格式,空数组表示全部服务,如 ['claude', 'gemini'] isActive = true, concurrencyLimit = 0, rateLimitWindow = null, @@ -132,7 +169,7 @@ class ApiKeyService { azureOpenaiAccountId: azureOpenaiAccountId || '', bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID droidAccountId: droidAccountId || '', - permissions: permissions || 'all', + permissions: JSON.stringify(normalizePermissions(permissions)), enableModelRestriction: String(enableModelRestriction), restrictedModels: JSON.stringify(restrictedModels || []), enableClientRestriction: String(enableClientRestriction || false), @@ -186,7 +223,7 @@ class ApiKeyService { azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID droidAccountId: keyData.droidAccountId, - permissions: keyData.permissions, + permissions: normalizePermissions(keyData.permissions), enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels: JSON.parse(keyData.restrictedModels), enableClientRestriction: keyData.enableClientRestriction === 'true', @@ -338,7 +375,7 @@ class ApiKeyService { azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID droidAccountId: keyData.droidAccountId, - permissions: keyData.permissions || 'all', + permissions: normalizePermissions(keyData.permissions), tokenLimit: parseInt(keyData.tokenLimit), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), @@ -467,7 +504,7 @@ class ApiKeyService { azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, droidAccountId: keyData.droidAccountId, - permissions: keyData.permissions || 'all', + permissions: normalizePermissions(keyData.permissions), tokenLimit: parseInt(keyData.tokenLimit), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), @@ -525,7 +562,7 @@ class ApiKeyService { key.isActive = key.isActive === 'true' key.enableModelRestriction = key.enableModelRestriction === 'true' key.enableClientRestriction = key.enableClientRestriction === 'true' - key.permissions = key.permissions || 'all' // 兼容旧数据 + key.permissions = normalizePermissions(key.permissions) key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) key.totalCostLimit = parseFloat(key.totalCostLimit || 0) key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0) @@ -1568,7 +1605,7 @@ class ApiKeyService { userId: keyData.userId, userUsername: keyData.userUsername, createdBy: keyData.createdBy, - permissions: keyData.permissions, + permissions: normalizePermissions(keyData.permissions), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), totalCostLimit: parseFloat(keyData.totalCostLimit || 0), // 所有平台账户绑定字段 @@ -1820,4 +1857,8 @@ const apiKeyService = new ApiKeyService() // 为了方便其他服务调用,导出 recordUsage 方法 apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService) +// 导出权限辅助函数供路由使用 +apiKeyService.hasPermission = hasPermission +apiKeyService.normalizePermissions = normalizePermissions + module.exports = apiKeyService diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue index 056c3e73..bd464e16 100644 --- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue @@ -579,55 +579,46 @@ -
+
-

- 控制此 API Key 可以访问哪些服务 + 不选择任何服务表示允许访问全部服务

@@ -662,7 +653,7 @@ v-model="form.claudeAccountId" :accounts="localAccounts.claude" default-option-text="使用共享账号池" - :disabled="form.permissions !== 'all' && form.permissions !== 'claude'" + :disabled="form.permissions.length > 0 && !form.permissions.includes('claude')" :groups="localAccounts.claudeGroups" placeholder="请选择Claude账号" platform="claude" @@ -676,7 +667,7 @@ v-model="form.geminiAccountId" :accounts="localAccounts.gemini" default-option-text="使用共享账号池" - :disabled="form.permissions !== 'all' && form.permissions !== 'gemini'" + :disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')" :groups="localAccounts.geminiGroups" placeholder="请选择Gemini账号" platform="gemini" @@ -690,7 +681,7 @@ v-model="form.openaiAccountId" :accounts="localAccounts.openai" default-option-text="使用共享账号池" - :disabled="form.permissions !== 'all' && form.permissions !== 'openai'" + :disabled="form.permissions.length > 0 && !form.permissions.includes('openai')" :groups="localAccounts.openaiGroups" placeholder="请选择OpenAI账号" platform="openai" @@ -704,7 +695,7 @@ v-model="form.bedrockAccountId" :accounts="localAccounts.bedrock" default-option-text="使用共享账号池" - :disabled="form.permissions !== 'all' && form.permissions !== 'openai'" + :disabled="form.permissions.length > 0 && !form.permissions.includes('claude')" :groups="[]" placeholder="请选择Bedrock账号" platform="bedrock" @@ -718,7 +709,7 @@ v-model="form.droidAccountId" :accounts="localAccounts.droid" default-option-text="使用共享账号池" - :disabled="form.permissions !== 'all' && form.permissions !== 'droid'" + :disabled="form.permissions.length > 0 && !form.permissions.includes('droid')" :groups="localAccounts.droidGroups" placeholder="请选择Droid账号" platform="droid" @@ -966,7 +957,7 @@ const form = reactive({ expirationMode: 'fixed', // 过期模式:fixed(固定) 或 activation(激活) activationDays: 30, // 激活后有效天数 activationUnit: 'days', // 激活时间单位:hours 或 days - permissions: 'all', + permissions: [], // 数组格式,空数组表示全部服务 claudeAccountId: '', geminiAccountId: '', openaiAccountId: '', diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index fd831c2b..749039bf 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -412,55 +412,46 @@ -
+
-

- 控制此 API Key 可以访问哪些服务 + 不选择任何服务表示允许访问全部服务

@@ -495,7 +486,7 @@ v-model="form.claudeAccountId" :accounts="localAccounts.claude" default-option-text="使用共享账号池" - :disabled="form.permissions !== 'all' && form.permissions !== 'claude'" + :disabled="form.permissions.length > 0 && !form.permissions.includes('claude')" :groups="localAccounts.claudeGroups" placeholder="请选择Claude账号" platform="claude" @@ -509,7 +500,7 @@ v-model="form.geminiAccountId" :accounts="localAccounts.gemini" default-option-text="使用共享账号池" - :disabled="form.permissions !== 'all' && form.permissions !== 'gemini'" + :disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')" :groups="localAccounts.geminiGroups" placeholder="请选择Gemini账号" platform="gemini" @@ -523,7 +514,7 @@ v-model="form.openaiAccountId" :accounts="localAccounts.openai" default-option-text="使用共享账号池" - :disabled="form.permissions !== 'all' && form.permissions !== 'openai'" + :disabled="form.permissions.length > 0 && !form.permissions.includes('openai')" :groups="localAccounts.openaiGroups" placeholder="请选择OpenAI账号" platform="openai" @@ -537,7 +528,7 @@ v-model="form.bedrockAccountId" :accounts="localAccounts.bedrock" default-option-text="使用共享账号池" - :disabled="form.permissions !== 'all' && form.permissions !== 'openai'" + :disabled="form.permissions.length > 0 && !form.permissions.includes('claude')" :groups="[]" placeholder="请选择Bedrock账号" platform="bedrock" @@ -551,7 +542,7 @@ v-model="form.droidAccountId" :accounts="localAccounts.droid" default-option-text="使用共享账号池" - :disabled="form.permissions !== 'all' && form.permissions !== 'droid'" + :disabled="form.permissions.length > 0 && !form.permissions.includes('droid')" :groups="localAccounts.droidGroups" placeholder="请选择Droid账号" platform="droid" @@ -800,7 +791,7 @@ const form = reactive({ dailyCostLimit: '', totalCostLimit: '', weeklyOpusCostLimit: '', - permissions: 'all', + permissions: [], // 数组格式,空数组表示全部服务 claudeAccountId: '', geminiAccountId: '', openaiAccountId: '', @@ -1241,7 +1232,17 @@ onMounted(async () => { form.dailyCostLimit = props.apiKey.dailyCostLimit || '' form.totalCostLimit = props.apiKey.totalCostLimit || '' form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || '' - form.permissions = props.apiKey.permissions || 'all' + // 处理权限数据,兼容旧格式(字符串)和新格式(数组) + const perms = props.apiKey.permissions + if (Array.isArray(perms)) { + form.permissions = perms + } else if (perms === 'all' || !perms) { + form.permissions = [] + } else if (typeof perms === 'string') { + form.permissions = [perms] + } else { + form.permissions = [] + } // 处理 Claude 账号(区分 OAuth 和 Console) if (props.apiKey.claudeConsoleAccountId) { form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}` From 09cf951cdc3ac6738bfd611d38946c08d109a045 Mon Sep 17 00:00:00 2001 From: guoyongchang Date: Fri, 19 Dec 2025 10:25:43 +0800 Subject: [PATCH 04/36] [feat/cron-test-support]done. --- package-lock.json | 18 +- package.json | 1 + pnpm-lock.yaml | 71 +++ src/app.js | 22 + src/models/redis.js | 262 ++++++++++- src/routes/admin/claudeAccounts.js | 195 +++++++++ src/services/accountTestSchedulerService.js | 411 ++++++++++++++++++ src/services/claudeRelayService.js | 158 ++++++- .../accounts/AccountScheduledTestModal.vue | 401 +++++++++++++++++ web/admin-spa/src/views/AccountsView.vue | 57 +++ 10 files changed, 1571 insertions(+), 25 deletions(-) create mode 100644 src/services/accountTestSchedulerService.js create mode 100644 web/admin-spa/src/components/accounts/AccountScheduledTestModal.vue diff --git a/package-lock.json b/package-lock.json index c6dccd11..0b0c9e70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "ioredis": "^5.3.2", "ldapjs": "^3.0.7", "morgan": "^1.10.0", + "node-cron": "^4.2.1", "nodemailer": "^7.0.6", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", @@ -891,7 +892,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -3000,7 +3000,6 @@ "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3082,7 +3081,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3538,7 +3536,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -4426,7 +4423,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4483,7 +4479,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7034,6 +7029,15 @@ "node": ">= 0.6" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://repo.colipu.com/repository/npm-hosted-colipu/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", @@ -7582,7 +7586,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9101,7 +9104,6 @@ "resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz", "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", diff --git a/package.json b/package.json index 2b7ffa25..6ef88e60 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "ioredis": "^5.3.2", "ldapjs": "^3.0.7", "morgan": "^1.10.0", + "node-cron": "^4.2.1", "nodemailer": "^7.0.6", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dafee4e7..9e8dc0fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: morgan: specifier: ^1.10.0 version: 1.10.1 + node-cron: + specifier: ^4.2.1 + version: 4.2.1 nodemailer: specifier: ^7.0.6 version: 7.0.11 @@ -108,6 +111,9 @@ importers: prettier: specifier: ^3.6.2 version: 3.7.4 + prettier-plugin-tailwindcss: + specifier: ^0.7.2 + version: 0.7.2(prettier@3.7.4) supertest: specifier: ^6.3.3 version: 6.3.4 @@ -2144,6 +2150,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -2302,6 +2312,61 @@ packages: resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} engines: {node: '>=6.0.0'} + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + prettier@3.7.4: resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} engines: {node: '>=14'} @@ -5692,6 +5757,8 @@ snapshots: negotiator@0.6.4: {} + node-cron@4.2.1: {} + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -5840,6 +5907,10 @@ snapshots: dependencies: fast-diff: 1.3.0 + prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4): + dependencies: + prettier: 3.7.4 + prettier@3.7.4: {} pretty-format@29.7.0: diff --git a/src/app.js b/src/app.js index 7af1e7e9..41edc483 100644 --- a/src/app.js +++ b/src/app.js @@ -661,6 +661,19 @@ class Application { '🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)' ) } + + // 🧪 启动账户定时测试调度器 + // 根据配置定期测试账户连通性并保存测试历史 + const accountTestSchedulerEnabled = + process.env.ACCOUNT_TEST_SCHEDULER_ENABLED !== 'false' && + config.accountTestScheduler?.enabled !== false + if (accountTestSchedulerEnabled) { + const accountTestSchedulerService = require('./services/accountTestSchedulerService') + accountTestSchedulerService.start() + logger.info('🧪 Account test scheduler service started') + } else { + logger.info('🧪 Account test scheduler service disabled') + } } setupGracefulShutdown() { @@ -715,6 +728,15 @@ class Application { logger.error('❌ Error stopping cost rank service:', error) } + // 停止账户定时测试调度器 + try { + const accountTestSchedulerService = require('./services/accountTestSchedulerService') + accountTestSchedulerService.stop() + logger.info('🧪 Account test scheduler service stopped') + } catch (error) { + logger.error('❌ Error stopping account test scheduler service:', error) + } + // 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏) try { logger.info('🔢 Cleaning up all concurrency counters...') diff --git a/src/models/redis.js b/src/models/redis.js index b75c0936..502254cd 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -96,7 +96,25 @@ class RedisClient { logger.warn('⚠️ Redis connection closed') }) - await this.client.connect() + // 只有在 lazyConnect 模式下才需要手动调用 connect() + // 如果 Redis 已经连接或正在连接中,则跳过 + if ( + this.client.status !== 'connecting' && + this.client.status !== 'connect' && + this.client.status !== 'ready' + ) { + await this.client.connect() + } else { + // 等待 ready 状态 + await new Promise((resolve, reject) => { + if (this.client.status === 'ready') { + resolve() + } else { + this.client.once('ready', resolve) + this.client.once('error', reject) + } + }) + } return this.client } catch (error) { logger.error('💥 Failed to connect to Redis:', error) @@ -3157,4 +3175,246 @@ redisClient.scanConcurrencyQueueStatsKeys = async function () { } } +// ============================================================================ +// 账户测试历史相关操作 +// ============================================================================ + +const ACCOUNT_TEST_HISTORY_MAX = 5 // 保留最近5次测试记录 +const ACCOUNT_TEST_HISTORY_TTL = 86400 * 30 // 30天过期 + +/** + * 保存账户测试结果 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 (claude/gemini/openai等) + * @param {Object} testResult - 测试结果对象 + * @param {boolean} testResult.success - 是否成功 + * @param {string} testResult.message - 测试消息/响应 + * @param {number} testResult.latencyMs - 延迟毫秒数 + * @param {string} testResult.error - 错误信息(如有) + * @param {string} testResult.timestamp - 测试时间戳 + */ +redisClient.saveAccountTestResult = async function (accountId, platform, testResult) { + const key = `account:test_history:${platform}:${accountId}` + try { + const record = JSON.stringify({ + ...testResult, + timestamp: testResult.timestamp || new Date().toISOString() + }) + + // 使用 LPUSH + LTRIM 保持最近5条记录 + const client = this.getClientSafe() + await client.lpush(key, record) + await client.ltrim(key, 0, ACCOUNT_TEST_HISTORY_MAX - 1) + await client.expire(key, ACCOUNT_TEST_HISTORY_TTL) + + logger.debug(`📝 Saved test result for ${platform} account ${accountId}`) + } catch (error) { + logger.error(`Failed to save test result for ${accountId}:`, error) + } +} + +/** + * 获取账户测试历史 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @returns {Promise} 测试历史记录数组(最新在前) + */ +redisClient.getAccountTestHistory = async function (accountId, platform) { + const key = `account:test_history:${platform}:${accountId}` + try { + const client = this.getClientSafe() + const records = await client.lrange(key, 0, -1) + return records.map((r) => JSON.parse(r)) + } catch (error) { + logger.error(`Failed to get test history for ${accountId}:`, error) + return [] + } +} + +/** + * 获取账户最新测试结果 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @returns {Promise} 最新测试结果 + */ +redisClient.getAccountLatestTestResult = async function (accountId, platform) { + const key = `account:test_history:${platform}:${accountId}` + try { + const client = this.getClientSafe() + const record = await client.lindex(key, 0) + return record ? JSON.parse(record) : null + } catch (error) { + logger.error(`Failed to get latest test result for ${accountId}:`, error) + return null + } +} + +/** + * 批量获取多个账户的测试历史 + * @param {Array<{accountId: string, platform: string}>} accounts - 账户列表 + * @returns {Promise} 以 accountId 为 key 的测试历史映射 + */ +redisClient.getAccountsTestHistory = async function (accounts) { + const result = {} + try { + const client = this.getClientSafe() + const pipeline = client.pipeline() + + for (const { accountId, platform } of accounts) { + const key = `account:test_history:${platform}:${accountId}` + pipeline.lrange(key, 0, -1) + } + + const responses = await pipeline.exec() + + accounts.forEach(({ accountId }, index) => { + const [err, records] = responses[index] + if (!err && records) { + result[accountId] = records.map((r) => JSON.parse(r)) + } else { + result[accountId] = [] + } + }) + } catch (error) { + logger.error('Failed to get batch test history:', error) + } + return result +} + +/** + * 保存定时测试配置 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @param {Object} config - 配置对象 + * @param {boolean} config.enabled - 是否启用定时测试 + * @param {string} config.cronExpression - Cron 表达式 (如 "0 8 * * *" 表示每天8点) + * @param {string} config.model - 测试使用的模型 + */ +redisClient.saveAccountTestConfig = async function (accountId, platform, testConfig) { + const key = `account:test_config:${platform}:${accountId}` + try { + const client = this.getClientSafe() + await client.hset(key, { + enabled: testConfig.enabled ? 'true' : 'false', + cronExpression: testConfig.cronExpression || '0 8 * * *', // 默认每天早上8点 + model: testConfig.model || 'claude-sonnet-4-5-20250929', // 默认模型 + updatedAt: new Date().toISOString() + }) + } catch (error) { + logger.error(`Failed to save test config for ${accountId}:`, error) + } +} + +/** + * 获取定时测试配置 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @returns {Promise} 配置对象 + */ +redisClient.getAccountTestConfig = async function (accountId, platform) { + const key = `account:test_config:${platform}:${accountId}` + try { + const client = this.getClientSafe() + const testConfig = await client.hgetall(key) + if (!testConfig || Object.keys(testConfig).length === 0) { + return null + } + // 向后兼容:如果存在旧的 testHour 字段,转换为 cron 表达式 + let { cronExpression } = testConfig + if (!cronExpression && testConfig.testHour) { + const hour = parseInt(testConfig.testHour, 10) + cronExpression = `0 ${hour} * * *` + } + return { + enabled: testConfig.enabled === 'true', + cronExpression: cronExpression || '0 8 * * *', + model: testConfig.model || 'claude-sonnet-4-5-20250929', + updatedAt: testConfig.updatedAt + } + } catch (error) { + logger.error(`Failed to get test config for ${accountId}:`, error) + return null + } +} + +/** + * 获取所有启用定时测试的账户 + * @param {string} platform - 平台类型 + * @returns {Promise} 账户ID列表及 cron 配置 + */ +redisClient.getEnabledTestAccounts = async function (platform) { + const accountIds = [] + let cursor = '0' + + try { + const client = this.getClientSafe() + do { + const [newCursor, keys] = await client.scan( + cursor, + 'MATCH', + `account:test_config:${platform}:*`, + 'COUNT', + 100 + ) + cursor = newCursor + + for (const key of keys) { + const testConfig = await client.hgetall(key) + if (testConfig && testConfig.enabled === 'true') { + const accountId = key.replace(`account:test_config:${platform}:`, '') + // 向后兼容:如果存在旧的 testHour 字段,转换为 cron 表达式 + let { cronExpression } = testConfig + if (!cronExpression && testConfig.testHour) { + const hour = parseInt(testConfig.testHour, 10) + cronExpression = `0 ${hour} * * *` + } + accountIds.push({ + accountId, + cronExpression: cronExpression || '0 8 * * *', + model: testConfig.model || 'claude-sonnet-4-5-20250929' + }) + } + } + } while (cursor !== '0') + + return accountIds + } catch (error) { + logger.error(`Failed to get enabled test accounts for ${platform}:`, error) + return [] + } +} + +/** + * 保存账户上次测试时间(用于调度器判断是否需要测试) + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + */ +redisClient.setAccountLastTestTime = async function (accountId, platform) { + const key = `account:last_test:${platform}:${accountId}` + try { + const client = this.getClientSafe() + await client.set(key, Date.now().toString(), 'EX', 86400 * 7) // 7天过期 + } catch (error) { + logger.error(`Failed to set last test time for ${accountId}:`, error) + } +} + +/** + * 获取账户上次测试时间 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @returns {Promise} 上次测试时间戳 + */ +redisClient.getAccountLastTestTime = async function (accountId, platform) { + const key = `account:last_test:${platform}:${accountId}` + try { + const client = this.getClientSafe() + const timestamp = await client.get(key) + return timestamp ? parseInt(timestamp, 10) : null + } catch (error) { + logger.error(`Failed to get last test time for ${accountId}:`, error) + return null + } +} + module.exports = redisClient diff --git a/src/routes/admin/claudeAccounts.js b/src/routes/admin/claudeAccounts.js index 13dd1a63..ef06c6e1 100644 --- a/src/routes/admin/claudeAccounts.js +++ b/src/routes/admin/claudeAccounts.js @@ -903,4 +903,199 @@ router.post('/claude-accounts/:accountId/test', authenticateAdmin, async (req, r } }) +// ============================================================================ +// 账户定时测试相关端点 +// ============================================================================ + +// 获取账户测试历史 +router.get('/claude-accounts/:accountId/test-history', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + + try { + const history = await redis.getAccountTestHistory(accountId, 'claude') + return res.json({ + success: true, + data: { + accountId, + platform: 'claude', + history + } + }) + } catch (error) { + logger.error(`❌ Failed to get test history for account ${accountId}:`, error) + return res.status(500).json({ + error: 'Failed to get test history', + message: error.message + }) + } +}) + +// 获取账户定时测试配置 +router.get('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + + try { + const testConfig = await redis.getAccountTestConfig(accountId, 'claude') + return res.json({ + success: true, + data: { + accountId, + platform: 'claude', + config: testConfig || { enabled: false, cronExpression: '0 8 * * *', model: 'claude-sonnet-4-5-20250929' } + } + }) + } catch (error) { + logger.error(`❌ Failed to get test config for account ${accountId}:`, error) + return res.status(500).json({ + error: 'Failed to get test config', + message: error.message + }) + } +}) + +// 设置账户定时测试配置 +router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const { enabled, cronExpression, model } = req.body + + try { + // 验证参数 + if (typeof enabled !== 'boolean') { + return res.status(400).json({ + error: 'Invalid parameter', + message: 'enabled must be a boolean' + }) + } + + // 验证 cron 表达式 + if (!cronExpression || typeof cronExpression !== 'string') { + return res.status(400).json({ + error: 'Invalid parameter', + message: 'cronExpression is required and must be a string' + }) + } + + // 使用 node-cron 验证表达式 + const cron = require('node-cron') + if (!cron.validate(cronExpression)) { + return res.status(400).json({ + error: 'Invalid parameter', + message: `Invalid cron expression: ${cronExpression}. Format: "minute hour day month weekday" (e.g., "0 8 * * *" for daily at 8:00)` + }) + } + + // 验证模型(可选,有默认值) + const testModel = model || 'claude-sonnet-4-5-20250929' + + // 检查账户是否存在 + const account = await claudeAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ + error: 'Account not found' + }) + } + + // 保存配置 + await redis.saveAccountTestConfig(accountId, 'claude', { + enabled, + cronExpression, + model: testModel + }) + + logger.success( + `📝 Updated test config for Claude account ${accountId}: enabled=${enabled}, cronExpression=${cronExpression}, model=${testModel}` + ) + + return res.json({ + success: true, + message: 'Test config updated successfully', + data: { + accountId, + platform: 'claude', + config: { enabled, cronExpression, model: testModel } + } + }) + } catch (error) { + logger.error(`❌ Failed to update test config for account ${accountId}:`, error) + return res.status(500).json({ + error: 'Failed to update test config', + message: error.message + }) + } +}) + +// 手动触发账户测试(非流式,返回JSON结果) +router.post('/claude-accounts/:accountId/test-sync', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + + try { + // 检查账户是否存在 + const account = await claudeAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ + error: 'Account not found' + }) + } + + logger.info(`🧪 Manual sync test triggered for Claude account: ${accountId}`) + + // 执行测试 + const testResult = await claudeRelayService.testAccountConnectionSync(accountId) + + // 保存测试结果到历史 + await redis.saveAccountTestResult(accountId, 'claude', testResult) + await redis.setAccountLastTestTime(accountId, 'claude') + + return res.json({ + success: true, + data: { + accountId, + platform: 'claude', + result: testResult + } + }) + } catch (error) { + logger.error(`❌ Failed to run sync test for account ${accountId}:`, error) + return res.status(500).json({ + error: 'Failed to run test', + message: error.message + }) + } +}) + +// 批量获取多个账户的测试历史 +router.post('/claude-accounts/batch-test-history', authenticateAdmin, async (req, res) => { + const { accountIds } = req.body + + try { + if (!Array.isArray(accountIds) || accountIds.length === 0) { + return res.status(400).json({ + error: 'Invalid parameter', + message: 'accountIds must be a non-empty array' + }) + } + + // 限制批量查询数量 + const limitedIds = accountIds.slice(0, 100) + + const accounts = limitedIds.map((accountId) => ({ + accountId, + platform: 'claude' + })) + + const historyMap = await redis.getAccountsTestHistory(accounts) + + return res.json({ + success: true, + data: historyMap + }) + } catch (error) { + logger.error('❌ Failed to get batch test history:', error) + return res.status(500).json({ + error: 'Failed to get batch test history', + message: error.message + }) + } +}) + module.exports = router diff --git a/src/services/accountTestSchedulerService.js b/src/services/accountTestSchedulerService.js new file mode 100644 index 00000000..c545e5ec --- /dev/null +++ b/src/services/accountTestSchedulerService.js @@ -0,0 +1,411 @@ +/** + * 账户定时测试调度服务 + * 使用 node-cron 支持 crontab 表达式,为每个账户创建独立的定时任务 + */ + +const cron = require('node-cron') +const redis = require('../models/redis') +const logger = require('../utils/logger') + +class AccountTestSchedulerService { + constructor() { + // 存储每个账户的 cron 任务: Map + this.scheduledTasks = new Map() + // 定期刷新配置的间隔 (毫秒) + this.refreshIntervalMs = 60 * 1000 + this.refreshInterval = null + // 测试并发限制 + this.maxConcurrentTests = 3 + // 当前正在测试的账户 + this.testingAccounts = new Set() + // 是否已启动 + this.isStarted = false + } + + /** + * 验证 cron 表达式是否有效 + * @param {string} cronExpression - cron 表达式 + * @returns {boolean} + */ + validateCronExpression(cronExpression) { + return cron.validate(cronExpression) + } + + /** + * 启动调度器 + */ + async start() { + if (this.isStarted) { + logger.warn('⚠️ Account test scheduler is already running') + return + } + + this.isStarted = true + logger.info('🚀 Starting account test scheduler service (node-cron mode)') + + // 初始化所有已配置账户的定时任务 + await this._refreshAllTasks() + + // 定期刷新配置,以便动态添加/修改的配置能生效 + this.refreshInterval = setInterval(() => { + this._refreshAllTasks() + }, this.refreshIntervalMs) + + logger.info( + `📅 Account test scheduler started (refreshing configs every ${this.refreshIntervalMs / 1000}s)` + ) + } + + /** + * 停止调度器 + */ + stop() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval) + this.refreshInterval = null + } + + // 停止所有 cron 任务 + for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) { + taskInfo.task.stop() + logger.debug(`🛑 Stopped cron task for ${accountKey}`) + } + this.scheduledTasks.clear() + + this.isStarted = false + logger.info('🛑 Account test scheduler stopped') + } + + /** + * 刷新所有账户的定时任务 + * @private + */ + async _refreshAllTasks() { + try { + const platforms = ['claude', 'gemini', 'openai'] + const activeAccountKeys = new Set() + + for (const platform of platforms) { + const enabledAccounts = await redis.getEnabledTestAccounts(platform) + + for (const { accountId, cronExpression, model } of enabledAccounts) { + if (!cronExpression) { + logger.warn( + `⚠️ Account ${accountId} (${platform}) has no valid cron expression, skipping` + ) + continue + } + + const accountKey = `${platform}:${accountId}` + activeAccountKeys.add(accountKey) + + // 检查是否需要更新任务 + const existingTask = this.scheduledTasks.get(accountKey) + if (existingTask) { + // 如果 cron 表达式和模型都没变,不需要更新 + if (existingTask.cronExpression === cronExpression && existingTask.model === model) { + continue + } + // 配置变了,停止旧任务 + existingTask.task.stop() + logger.info( + `🔄 Updating cron task for ${accountKey}: ${cronExpression}, model: ${model}` + ) + } else { + logger.info( + `➕ Creating cron task for ${accountKey}: ${cronExpression}, model: ${model}` + ) + } + + // 创建新的 cron 任务 + this._createCronTask(accountId, platform, cronExpression, model) + } + } + + // 清理已删除或禁用的账户任务 + for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) { + if (!activeAccountKeys.has(accountKey)) { + taskInfo.task.stop() + this.scheduledTasks.delete(accountKey) + logger.info(`➖ Removed cron task for ${accountKey} (disabled or deleted)`) + } + } + } catch (error) { + logger.error('❌ Error refreshing account test tasks:', error) + } + } + + /** + * 为单个账户创建 cron 任务 + * @param {string} accountId + * @param {string} platform + * @param {string} cronExpression + * @param {string} model - 测试使用的模型 + * @private + */ + _createCronTask(accountId, platform, cronExpression, model) { + const accountKey = `${platform}:${accountId}` + + // 验证 cron 表达式 + if (!this.validateCronExpression(cronExpression)) { + logger.error(`❌ Invalid cron expression for ${accountKey}: ${cronExpression}`) + return + } + + const task = cron.schedule( + cronExpression, + async () => { + await this._runAccountTest(accountId, platform, model) + }, + { + scheduled: true, + timezone: process.env.TZ || 'Asia/Shanghai' + } + ) + + this.scheduledTasks.set(accountKey, { + task, + cronExpression, + model, + accountId, + platform + }) + } + + /** + * 执行单个账户测试 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @param {string} model - 测试使用的模型 + * @private + */ + async _runAccountTest(accountId, platform, model) { + const accountKey = `${platform}:${accountId}` + + // 避免重复测试 + if (this.testingAccounts.has(accountKey)) { + logger.debug(`⏳ Account ${accountKey} is already being tested, skipping`) + return + } + + this.testingAccounts.add(accountKey) + + try { + logger.info( + `🧪 Running scheduled test for ${platform} account: ${accountId} (model: ${model})` + ) + + let testResult + + // 根据平台调用对应的测试方法 + switch (platform) { + case 'claude': + testResult = await this._testClaudeAccount(accountId, model) + break + case 'gemini': + testResult = await this._testGeminiAccount(accountId, model) + break + case 'openai': + testResult = await this._testOpenAIAccount(accountId, model) + break + default: + testResult = { + success: false, + error: `Unsupported platform: ${platform}`, + timestamp: new Date().toISOString() + } + } + + // 保存测试结果 + await redis.saveAccountTestResult(accountId, platform, testResult) + + // 更新最后测试时间 + await redis.setAccountLastTestTime(accountId, platform) + + // 记录日志 + if (testResult.success) { + logger.info( + `✅ Scheduled test passed for ${platform} account ${accountId} (${testResult.latencyMs}ms)` + ) + } else { + logger.warn( + `❌ Scheduled test failed for ${platform} account ${accountId}: ${testResult.error}` + ) + } + + return testResult + } catch (error) { + logger.error(`❌ Error testing ${platform} account ${accountId}:`, error) + + const errorResult = { + success: false, + error: error.message, + timestamp: new Date().toISOString() + } + + await redis.saveAccountTestResult(accountId, platform, errorResult) + await redis.setAccountLastTestTime(accountId, platform) + + return errorResult + } finally { + this.testingAccounts.delete(accountKey) + } + } + + /** + * 测试 Claude 账户 + * @param {string} accountId + * @param {string} model - 测试使用的模型 + * @private + */ + async _testClaudeAccount(accountId, model) { + const claudeRelayService = require('./claudeRelayService') + return await claudeRelayService.testAccountConnectionSync(accountId, model) + } + + /** + * 测试 Gemini 账户 + * @param {string} _accountId + * @param {string} _model + * @private + */ + async _testGeminiAccount(_accountId, _model) { + // Gemini 测试暂时返回未实现 + return { + success: false, + error: 'Gemini scheduled test not implemented yet', + timestamp: new Date().toISOString() + } + } + + /** + * 测试 OpenAI 账户 + * @param {string} _accountId + * @param {string} _model + * @private + */ + async _testOpenAIAccount(_accountId, _model) { + // OpenAI 测试暂时返回未实现 + return { + success: false, + error: 'OpenAI scheduled test not implemented yet', + timestamp: new Date().toISOString() + } + } + + /** + * 手动触发账户测试 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @param {string} model - 测试使用的模型 + * @returns {Promise} 测试结果 + */ + async triggerTest(accountId, platform, model = 'claude-sonnet-4-5-20250929') { + logger.info(`🎯 Manual test triggered for ${platform} account: ${accountId} (model: ${model})`) + return await this._runAccountTest(accountId, platform, model) + } + + /** + * 获取账户测试历史 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @returns {Promise} 测试历史 + */ + async getTestHistory(accountId, platform) { + return await redis.getAccountTestHistory(accountId, platform) + } + + /** + * 获取账户测试配置 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @returns {Promise} + */ + async getTestConfig(accountId, platform) { + return await redis.getAccountTestConfig(accountId, platform) + } + + /** + * 设置账户测试配置 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @param {Object} testConfig - 测试配置 { enabled: boolean, cronExpression: string, model: string } + * @returns {Promise} + */ + async setTestConfig(accountId, platform, testConfig) { + // 验证 cron 表达式 + if (testConfig.cronExpression && !this.validateCronExpression(testConfig.cronExpression)) { + throw new Error(`Invalid cron expression: ${testConfig.cronExpression}`) + } + + await redis.saveAccountTestConfig(accountId, platform, testConfig) + logger.info( + `📝 Test config updated for ${platform} account ${accountId}: enabled=${testConfig.enabled}, cronExpression=${testConfig.cronExpression}, model=${testConfig.model}` + ) + + // 立即刷新任务,使配置立即生效 + if (this.isStarted) { + await this._refreshAllTasks() + } + } + + /** + * 更新单个账户的定时任务(配置变更时调用) + * @param {string} accountId + * @param {string} platform + */ + async refreshAccountTask(accountId, platform) { + if (!this.isStarted) { + return + } + + const accountKey = `${platform}:${accountId}` + const testConfig = await redis.getAccountTestConfig(accountId, platform) + + // 停止现有任务 + const existingTask = this.scheduledTasks.get(accountKey) + if (existingTask) { + existingTask.task.stop() + this.scheduledTasks.delete(accountKey) + } + + // 如果启用且有有效的 cron 表达式,创建新任务 + if (testConfig?.enabled && testConfig?.cronExpression) { + this._createCronTask(accountId, platform, testConfig.cronExpression, testConfig.model) + logger.info( + `🔄 Refreshed cron task for ${accountKey}: ${testConfig.cronExpression}, model: ${testConfig.model}` + ) + } + } + + /** + * 获取调度器状态 + * @returns {Object} + */ + getStatus() { + const tasks = [] + for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) { + tasks.push({ + accountKey, + accountId: taskInfo.accountId, + platform: taskInfo.platform, + cronExpression: taskInfo.cronExpression, + model: taskInfo.model + }) + } + + return { + running: this.isStarted, + refreshIntervalMs: this.refreshIntervalMs, + maxConcurrentTests: this.maxConcurrentTests, + scheduledTasksCount: this.scheduledTasks.size, + scheduledTasks: tasks, + currentlyTesting: Array.from(this.testingAccounts) + } + } +} + +// 单例模式 +const accountTestSchedulerService = new AccountTestSchedulerService() + +module.exports = accountTestSchedulerService diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 36671fee..001ee313 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -2456,28 +2456,35 @@ class ClaudeRelayService { } } + // 🔧 准备测试请求的公共逻辑(供 testAccountConnection 和 testAccountConnectionSync 共用) + async _prepareAccountForTest(accountId) { + // 获取账户信息 + const account = await claudeAccountService.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // 获取有效的访问token + const accessToken = await claudeAccountService.getValidAccessToken(accountId) + if (!accessToken) { + throw new Error('Failed to get valid access token') + } + + // 获取代理配置 + const proxyAgent = await this._getProxyAgent(accountId) + + return { account, accessToken, proxyAgent } + } + // 🧪 测试账号连接(供Admin API使用,直接复用 _makeClaudeStreamRequestWithUsageCapture) - async testAccountConnection(accountId, responseStream) { - const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true }) + async testAccountConnection(accountId, responseStream, model = 'claude-sonnet-4-5-20250929') { + const testRequestBody = createClaudeTestPayload(model, { stream: true }) try { - // 获取账户信息 - const account = await claudeAccountService.getAccount(accountId) - if (!account) { - throw new Error('Account not found') - } + const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId) logger.info(`🧪 Testing Claude account connection: ${account.name} (${accountId})`) - // 获取有效的访问token - const accessToken = await claudeAccountService.getValidAccessToken(accountId) - if (!accessToken) { - throw new Error('Failed to get valid access token') - } - - // 获取代理配置 - const proxyAgent = await this._getProxyAgent(accountId) - // 设置响应头 if (!responseStream.headersSent) { const existingConnection = responseStream.getHeader @@ -2526,6 +2533,125 @@ class ClaudeRelayService { } } + // 🧪 非流式测试账号连接(供定时任务使用) + // 复用流式请求方法,收集结果后返回 + async testAccountConnectionSync(accountId, model = 'claude-sonnet-4-5-20250929') { + const testRequestBody = createClaudeTestPayload(model, { stream: true }) + const startTime = Date.now() + + try { + // 使用公共方法准备测试所需的账户信息、token 和代理 + const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId) + + logger.info(`🧪 Testing Claude account connection (sync): ${account.name} (${accountId})`) + + // 创建一个收集器来捕获流式响应 + let responseText = '' + let capturedUsage = null + let capturedModel = model + let hasError = false + let errorMessage = '' + + // 创建模拟的响应流对象 + const mockResponseStream = { + headersSent: true, // 跳过设置响应头 + write: (data) => { + // 解析 SSE 数据 + if (typeof data === 'string' && data.startsWith('data: ')) { + try { + const jsonStr = data.replace('data: ', '').trim() + if (jsonStr && jsonStr !== '[DONE]') { + const parsed = JSON.parse(jsonStr) + // 提取文本内容 + if (parsed.type === 'content_block_delta' && parsed.delta?.text) { + responseText += parsed.delta.text + } + // 提取 usage 信息 + if (parsed.type === 'message_delta' && parsed.usage) { + capturedUsage = parsed.usage + } + // 提取模型信息 + if (parsed.type === 'message_start' && parsed.message?.model) { + capturedModel = parsed.message.model + } + // 检测错误 + if (parsed.type === 'error') { + hasError = true + errorMessage = parsed.error?.message || 'Unknown error' + } + } + } catch { + // 忽略解析错误 + } + } + return true + }, + end: () => {}, + on: () => {}, + once: () => {}, + emit: () => {}, + writable: true + } + + // 复用流式请求方法 + await this._makeClaudeStreamRequestWithUsageCapture( + testRequestBody, + accessToken, + proxyAgent, + {}, // clientHeaders - 测试不需要客户端headers + mockResponseStream, + null, // usageCallback - 测试不需要统计 + accountId, + 'claude-official', // accountType + null, // sessionHash - 测试不需要会话 + null, // streamTransformer - 不需要转换,直接解析原始格式 + {}, // requestOptions + false // isDedicatedOfficialAccount + ) + + const latencyMs = Date.now() - startTime + + if (hasError) { + logger.warn(`⚠️ Test completed with error for account: ${account.name} - ${errorMessage}`) + return { + success: false, + error: errorMessage, + latencyMs, + timestamp: new Date().toISOString() + } + } + + logger.info(`✅ Test completed for account: ${account.name} (${latencyMs}ms)`) + + return { + success: true, + message: responseText.substring(0, 200), // 截取前200字符 + latencyMs, + model: capturedModel, + usage: capturedUsage, + timestamp: new Date().toISOString() + } + } catch (error) { + const latencyMs = Date.now() - startTime + logger.error(`❌ Test account connection (sync) failed:`, error.message) + + // 提取错误详情 + let errorMessage = error.message + if (error.response) { + errorMessage = + error.response.data?.error?.message || error.response.statusText || error.message + } + + return { + success: false, + error: errorMessage, + statusCode: error.response?.status, + latencyMs, + timestamp: new Date().toISOString() + } + } + } + // 🎯 健康检查 async healthCheck() { try { diff --git a/web/admin-spa/src/components/accounts/AccountScheduledTestModal.vue b/web/admin-spa/src/components/accounts/AccountScheduledTestModal.vue new file mode 100644 index 00000000..5246e3bb --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountScheduledTestModal.vue @@ -0,0 +1,401 @@ + + + diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 5faf4fec..65e0bcfe 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1238,6 +1238,15 @@ 测试 + + + + + + + + +
+
+

低余额账户

+ + {{ lowBalanceAccounts.length }} 个 + +
+ +
+ 正在加载... +
+
+ 全部正常 +
+
+
+
+
+ {{ account.name || account.accountId }} +
+ + {{ getBalancePlatformLabel(account.platform) }} + +
+
+ 余额: {{ account.balance.formattedAmount }} + 今日成本: {{ formatCurrencyUsd(account.statistics?.dailyCost || 0) }} +
+
+
+ 配额使用 + + {{ account.quota.percentage.toFixed(1) }}% + +
+
+
+
+
+
+
+
+ +
{ + const map = { + claude: 'Claude', + 'claude-console': 'Claude Console', + gemini: 'Gemini', + 'gemini-api': 'Gemini API', + openai: 'OpenAI', + 'openai-responses': 'OpenAI Responses', + azure_openai: 'Azure OpenAI', + bedrock: 'Bedrock', + droid: 'Droid', + ccr: 'CCR' + } + return map[platform] || platform +} + +const lowBalanceAccounts = computed(() => { + const result = [] + const platforms = balanceSummary.value?.platforms || {} + + Object.entries(platforms).forEach(([platform, data]) => { + const list = Array.isArray(data?.accounts) ? data.accounts : [] + list.forEach((entry) => { + const accountData = entry?.data + if (!accountData) return + + const amount = accountData.balance?.amount + const percentage = accountData.quota?.percentage + + const isLowBalance = typeof amount === 'number' && amount < 10 + const isHighUsage = typeof percentage === 'number' && percentage > 90 + + if (isLowBalance || isHighUsage) { + result.push({ + ...accountData, + name: entry?.name || accountData.accountId, + platform: accountData.platform || platform + }) + } + }) + }) + + return result +}) + +const formatCurrencyUsd = (amount) => { + const value = Number(amount) + if (!Number.isFinite(value)) return '$0.00' + if (value >= 1) return `$${value.toFixed(2)}` + if (value >= 0.01) return `$${value.toFixed(3)}` + return `$${value.toFixed(6)}` +} + +const formatLastUpdate = (isoString) => { + if (!isoString) return '未知' + const date = new Date(isoString) + if (Number.isNaN(date.getTime())) return '未知' + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) +} + +const loadBalanceSummary = async () => { + loadingBalanceSummary.value = true + try { + const response = await apiClient.get('/admin/accounts/balance/summary') + if (response?.success) { + balanceSummary.value = response.data || { + totalBalance: 0, + totalCost: 0, + lowBalanceCount: 0, + platforms: {} + } + balanceSummaryUpdatedAt.value = new Date().toISOString() + } + } catch (error) { + console.debug('加载余额汇总失败:', error) + showToast('加载余额汇总失败', 'error') + } finally { + loadingBalanceSummary.value = false + } +} + // 自动刷新相关 const autoRefreshEnabled = ref(false) const autoRefreshInterval = ref(30) // 秒 @@ -1488,7 +1680,7 @@ async function refreshAllData() { isRefreshing.value = true try { - await Promise.all([loadDashboardData(), refreshChartsData()]) + await Promise.all([loadDashboardData(), refreshChartsData(), loadBalanceSummary()]) } finally { isRefreshing.value = false } From ce496ed9e69b7198218c12fe0fb8e61088d8cc6b Mon Sep 17 00:00:00 2001 From: atoz03 Date: Sat, 13 Dec 2025 00:40:01 +0800 Subject: [PATCH 14/36] =?UTF-8?q?feat=EF=BC=9A=E5=8D=95=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=BD=99=E9=A2=9D=E8=84=9A=E6=9C=AC=20+=20?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E6=8C=89=E9=92=AE=E5=8D=B3=E7=94=A8=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E2=80=9D=EF=BC=8C=E5=B9=B6=E5=8E=BB=E6=8E=89=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E9=A1=B5=E9=9D=A2/=E6=A0=87=E7=AD=BE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 具体改动 - 后端 - src/models/redis.js:新增脚本配置存取 account_balance_script:{platform}:{accountId}。 - src/services/accountBalanceService.js:支持脚本查询。若账户有脚本配置且 queryApi=true,调用 balanceScriptService.execute 获取余额/配额,缓存后返回。 - src/routes/admin/accountBalance.js:新增接口 - GET /admin/accounts/:id/balance/script?platform=... - PUT /admin/accounts/:id/balance/script?platform=... - POST /admin/accounts/:id/balance/script/test?platform=... - 前端 - 新增弹窗 AccountBalanceScriptModal,在账户管理页每个账户“余额/配额”下方有“配置余额脚本”按钮,支持填写 baseUrl/apiKey/token/extra/超时/自动间隔、编写脚本、测试、保存。 - 将余额脚本独立路由/标签移除。 - 格式/ lint 已通过(新组件及 AccountsView)。 --- src/models/redis.js | 22 ++ src/routes/admin/accountBalance.js | 73 ++++ src/routes/admin/balanceScripts.js | 41 +++ src/routes/admin/index.js | 2 + src/services/accountBalanceService.js | 83 ++++- src/services/balanceScriptService.js | 241 ++++++++++++++ .../accounts/AccountBalanceScriptModal.vue | 262 +++++++++++++++ web/admin-spa/src/views/AccountsView.vue | 43 +++ .../src/views/BalanceScriptsView.vue | 312 ++++++++++++++++++ 9 files changed, 1062 insertions(+), 17 deletions(-) create mode 100644 src/routes/admin/balanceScripts.js create mode 100644 src/services/balanceScriptService.js create mode 100644 web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue create mode 100644 web/admin-spa/src/views/BalanceScriptsView.vue diff --git a/src/models/redis.js b/src/models/redis.js index 48edb116..3adc51d1 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1614,6 +1614,28 @@ class RedisClient { await this.client.del(key, localKey) } + // 🧩 账户余额脚本配置 + async setBalanceScriptConfig(platform, accountId, config) { + const key = `account_balance_script:${platform}:${accountId}` + await this.client.set(key, JSON.stringify(config || {})) + } + + async getBalanceScriptConfig(platform, accountId) { + const key = `account_balance_script:${platform}:${accountId}` + const raw = await this.client.get(key) + if (!raw) return null + try { + return JSON.parse(raw) + } catch (error) { + return null + } + } + + async deleteBalanceScriptConfig(platform, accountId) { + const key = `account_balance_script:${platform}:${accountId}` + return await this.client.del(key) + } + // 📈 系统统计 async getSystemStats() { const keys = await Promise.all([ diff --git a/src/routes/admin/accountBalance.js b/src/routes/admin/accountBalance.js index 2acffd7b..2f55c850 100644 --- a/src/routes/admin/accountBalance.js +++ b/src/routes/admin/accountBalance.js @@ -2,6 +2,7 @@ const express = require('express') const { authenticateAdmin } = require('../../middleware/auth') const logger = require('../../utils/logger') const accountBalanceService = require('../../services/accountBalanceService') +const balanceScriptService = require('../../services/balanceScriptService') const router = express.Router() @@ -127,4 +128,76 @@ router.delete('/accounts/:accountId/balance/cache', authenticateAdmin, async (re } }) +// 6) 获取/保存/测试余额脚本配置(单账户) +router.get('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform } = req.query + + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + const config = await accountBalanceService.redis.getBalanceScriptConfig(valid.platform, accountId) + return res.json({ success: true, data: config || null }) + } catch (error) { + logger.error('获取余额脚本配置失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +router.put('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform } = req.query + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + const payload = req.body || {} + await accountBalanceService.redis.setBalanceScriptConfig(valid.platform, accountId, payload) + return res.json({ success: true, data: payload }) + } catch (error) { + logger.error('保存余额脚本配置失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +router.post('/accounts/:accountId/balance/script/test', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform } = req.query + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + const payload = req.body || {} + const scriptBody = payload.scriptBody + if (!scriptBody) { + return res.status(400).json({ success: false, error: '脚本内容不能为空' }) + } + + const result = await balanceScriptService.execute({ + scriptBody, + timeoutSeconds: payload.timeoutSeconds || 10, + variables: { + baseUrl: payload.baseUrl || '', + apiKey: payload.apiKey || '', + token: payload.token || '', + accountId, + platform: valid.platform, + extra: payload.extra || '' + } + }) + + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('测试余额脚本失败', error) + return res.status(400).json({ success: false, error: error.message }) + } +}) + module.exports = router diff --git a/src/routes/admin/balanceScripts.js b/src/routes/admin/balanceScripts.js new file mode 100644 index 00000000..ef7ffa01 --- /dev/null +++ b/src/routes/admin/balanceScripts.js @@ -0,0 +1,41 @@ +const express = require('express') +const { authenticateAdmin } = require('../../middleware/auth') +const balanceScriptService = require('../../services/balanceScriptService') +const router = express.Router() + +// 获取全部脚本配置列表 +router.get('/balance-scripts', authenticateAdmin, (req, res) => { + const items = balanceScriptService.listConfigs() + return res.json({ success: true, data: items }) +}) + +// 获取单个脚本配置 +router.get('/balance-scripts/:name', authenticateAdmin, (req, res) => { + const { name } = req.params + const config = balanceScriptService.getConfig(name || 'default') + return res.json({ success: true, data: config }) +}) + +// 保存脚本配置 +router.put('/balance-scripts/:name', authenticateAdmin, (req, res) => { + try { + const { name } = req.params + const saved = balanceScriptService.saveConfig(name || 'default', req.body || {}) + return res.json({ success: true, data: saved }) + } catch (error) { + return res.status(400).json({ success: false, error: error.message }) + } +}) + +// 测试脚本(不落库) +router.post('/balance-scripts/:name/test', authenticateAdmin, async (req, res) => { + try { + const { name } = req.params + const result = await balanceScriptService.testScript(name || 'default', req.body || {}) + return res.json({ success: true, data: result }) + } catch (error) { + return res.status(400).json({ success: false, error: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/admin/index.js b/src/routes/admin/index.js index f7deafd6..f5cb2268 100644 --- a/src/routes/admin/index.js +++ b/src/routes/admin/index.js @@ -22,6 +22,7 @@ const droidAccountsRoutes = require('./droidAccounts') const dashboardRoutes = require('./dashboard') const usageStatsRoutes = require('./usageStats') const accountBalanceRoutes = require('./accountBalance') +const balanceScriptsRoutes = require('./balanceScripts') const systemRoutes = require('./system') const concurrencyRoutes = require('./concurrency') const claudeRelayConfigRoutes = require('./claudeRelayConfig') @@ -38,6 +39,7 @@ router.use('/', droidAccountsRoutes) router.use('/', dashboardRoutes) router.use('/', usageStatsRoutes) router.use('/', accountBalanceRoutes) +router.use('/', balanceScriptsRoutes) router.use('/', systemRoutes) router.use('/', concurrencyRoutes) router.use('/', claudeRelayConfigRoutes) diff --git a/src/services/accountBalanceService.js b/src/services/accountBalanceService.js index 5ef882cf..370ed680 100644 --- a/src/services/accountBalanceService.js +++ b/src/services/accountBalanceService.js @@ -1,6 +1,8 @@ const redis = require('../models/redis') +const balanceScriptService = require('./balanceScriptService') const logger = require('../utils/logger') const CostCalculator = require('../utils/costCalculator') +const redis = require('../models/redis') class AccountBalanceService { constructor(options = {}) { @@ -321,25 +323,32 @@ class AccountBalanceService { } // 强制查询:调用 Provider,失败自动降级到本地统计 - const provider = this.providers.get(platform) - if (!provider) { - return this._buildResponse( - { - status: 'error', - errorMessage: `不支持的平台: ${platform}`, - balance: quotaFromLocal.balance, - currency: quotaFromLocal.currency || 'USD', - quota: quotaFromLocal.quota, - statistics: localStatistics, - lastRefreshAt: new Date().toISOString() - }, - accountId, - platform, - 'local' - ) + const scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId) + let providerResult + + if (scriptConfig && scriptConfig.scriptBody) { + providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform) + } else { + const provider = this.providers.get(platform) + if (!provider) { + return this._buildResponse( + { + status: 'error', + errorMessage: `不支持的平台: ${platform}`, + balance: quotaFromLocal.balance, + currency: quotaFromLocal.currency || 'USD', + quota: quotaFromLocal.quota, + statistics: localStatistics, + lastRefreshAt: new Date().toISOString() + }, + accountId, + platform, + 'local' + ) + } + providerResult = await this._getBalanceFromProvider(provider, account) } - const providerResult = await this._getBalanceFromProvider(provider, account) await this.redis.setAccountBalance(platform, accountId, providerResult, this.CACHE_TTL_SECONDS) const source = providerResult.status === 'success' ? 'api' : 'local' @@ -360,6 +369,46 @@ class AccountBalanceService { ) } + async _getBalanceFromScript(scriptConfig, accountId, platform) { + try { + const result = await balanceScriptService.execute({ + scriptBody: scriptConfig.scriptBody, + timeoutSeconds: scriptConfig.timeoutSeconds || 10, + variables: { + baseUrl: scriptConfig.baseUrl || '', + apiKey: scriptConfig.apiKey || '', + token: scriptConfig.token || '', + accountId, + platform, + extra: scriptConfig.extra || '' + } + }) + + const mapped = result?.mapped || {} + return { + status: mapped.status || 'error', + balance: typeof mapped.balance === 'number' ? mapped.balance : null, + currency: mapped.currency || 'USD', + quota: mapped.quota || null, + queryMethod: 'api', + rawData: mapped.rawData || result?.response?.data || null, + lastRefreshAt: new Date().toISOString(), + errorMessage: mapped.errorMessage || '' + } + } catch (error) { + return { + status: 'error', + balance: null, + currency: 'USD', + quota: null, + queryMethod: 'api', + rawData: null, + lastRefreshAt: new Date().toISOString(), + errorMessage: error.message || '脚本执行失败' + } + } + } + async _getBalanceFromProvider(provider, account) { try { const result = await provider.queryBalance(account) diff --git a/src/services/balanceScriptService.js b/src/services/balanceScriptService.js new file mode 100644 index 00000000..36a996b9 --- /dev/null +++ b/src/services/balanceScriptService.js @@ -0,0 +1,241 @@ +const fs = require('fs') +const path = require('path') +const vm = require('vm') +const axios = require('axios') +const logger = require('../utils/logger') + +/** + * 可配置脚本余额查询服务 + * - 存储位置:data/balanceScripts.json + * - 脚本格式:({ request: {...}, extractor: function(response){...} }) + * - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}} + */ +class BalanceScriptService { + constructor() { + this.filePath = path.join(__dirname, '..', '..', 'data', 'balanceScripts.json') + this.ensureStore() + } + + ensureStore() { + const dir = path.dirname(this.filePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + if (!fs.existsSync(this.filePath)) { + fs.writeFileSync(this.filePath, JSON.stringify({}, null, 2)) + } + } + + loadAll() { + try { + const raw = fs.readFileSync(this.filePath, 'utf8') + return JSON.parse(raw || '{}') + } catch (error) { + logger.error('读取余额脚本配置失败', error) + return {} + } + } + + saveAll(data) { + fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2)) + } + + listConfigs() { + const all = this.loadAll() + return Object.values(all) + } + + getConfig(name) { + const all = this.loadAll() + if (all[name]) { + return all[name] + } + return { + name, + baseUrl: '', + apiKey: '', + token: '', + timeoutSeconds: 10, + autoIntervalMinutes: 0, + scriptBody: + "({\n request: {\n url: \"{{baseUrl}}/user/balance\",\n method: \"GET\",\n headers: {\n \"Authorization\": \"Bearer {{apiKey}}\",\n \"User-Agent\": \"cc-switch/1.0\"\n }\n },\n extractor: function(response) {\n return {\n isValid: !response.error,\n remaining: response.balance,\n unit: \"USD\"\n };\n }\n})", + updatedAt: null + } + } + + saveConfig(name, payload) { + const all = this.loadAll() + const config = { + ...this.getConfig(name), + ...payload, + name, + updatedAt: new Date().toISOString() + } + all[name] = config + this.saveAll(all) + return config + } + + /** + * 执行脚本:返回标准余额结构 + 原始响应 + * @param {object} options + * - scriptBody: string + * - variables: Record + * - timeoutSeconds: number + */ + async execute(options = {}) { + const scriptBody = options.scriptBody?.trim() + if (!scriptBody) { + throw new Error('脚本内容为空') + } + + const timeoutMs = Math.max(1, (options.timeoutSeconds || 10) * 1000) + const sandbox = { + console, + Math, + Date + } + + let scriptResult + try { + const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})` + const script = new vm.Script(wrapped, { timeout: timeoutMs }) + scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs }) + } catch (error) { + throw new Error(`脚本解析失败: ${error.message}`) + } + + if (!scriptResult || typeof scriptResult !== 'object') { + throw new Error('脚本返回格式无效(需返回 { request, extractor })') + } + + const variables = options.variables || {} + const request = this.applyTemplates(scriptResult.request || {}, variables) + const extractor = scriptResult.extractor + + if (!request.url) { + throw new Error('脚本 request.url 不能为空') + } + + const axiosConfig = { + url: request.url, + method: (request.method || 'GET').toUpperCase(), + headers: request.headers || {}, + timeout: timeoutMs + } + + if (request.params) { + axiosConfig.params = request.params + } + if (request.body || request.data) { + axiosConfig.data = request.body || request.data + } + + let httpResponse = null + try { + httpResponse = await axios(axiosConfig) + } catch (error) { + const status = error.response?.status + const data = error.response?.data + throw new Error(`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`) + } + + const responseData = httpResponse?.data + let extracted = {} + if (typeof extractor === 'function') { + try { + extracted = extractor(responseData) || {} + } catch (error) { + throw new Error(`extractor 执行失败: ${error.message}`) + } + } + + const mapped = this.mapExtractorResult(extracted, responseData) + return { + mapped, + extracted, + response: { + status: httpResponse?.status, + headers: httpResponse?.headers, + data: responseData + } + } + } + + applyTemplates(value, variables) { + if (typeof value === 'string') { + return value.replace(/{{(\w+)}}/g, (_, key) => { + const trimmed = key.trim() + return variables[trimmed] !== undefined ? String(variables[trimmed]) : '' + }) + } + if (Array.isArray(value)) { + return value.map((item) => this.applyTemplates(item, variables)) + } + if (value && typeof value === 'object') { + const result = {} + Object.keys(value).forEach((k) => { + result[k] = this.applyTemplates(value[k], variables) + }) + return result + } + return value + } + + mapExtractorResult(result = {}, responseData) { + const isValid = result.isValid !== false + const remaining = Number(result.remaining) + const total = Number(result.total) + const used = Number(result.used) + const currency = result.unit || 'USD' + + const quota = + Number.isFinite(total) || Number.isFinite(used) + ? { + total: Number.isFinite(total) ? total : null, + used: Number.isFinite(used) ? used : null, + remaining: Number.isFinite(remaining) ? remaining : null, + percentage: + Number.isFinite(total) && total > 0 && Number.isFinite(used) + ? (used / total) * 100 + : null + } + : null + + return { + status: isValid ? 'success' : 'error', + errorMessage: isValid ? '' : result.invalidMessage || '套餐无效', + balance: Number.isFinite(remaining) ? remaining : null, + currency, + quota, + planName: result.planName || null, + extra: result.extra || null, + rawData: responseData || result.raw + } + } + + async testScript(name, payload = {}) { + const config = payload.useBodyConfig ? this.getConfig(name) : this.getConfig(name) + const scriptBody = payload.scriptBody || config.scriptBody + const timeoutSeconds = payload.timeoutSeconds || config.timeoutSeconds + const variables = { + baseUrl: payload.baseUrl || config.baseUrl, + apiKey: payload.apiKey || config.apiKey, + token: payload.token || config.token, + accountId: payload.accountId || '', + platform: payload.platform || '', + extra: payload.extra || '' + } + + const result = await this.execute({ scriptBody, variables, timeoutSeconds }) + return { + name, + variables, + mapped: result.mapped, + extracted: result.extracted, + response: result.response + } + } +} + +module.exports = new BalanceScriptService() diff --git a/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue new file mode 100644 index 00000000..ef60d447 --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 1d1179e2..aaf82171 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -804,6 +804,14 @@ @error="(error) => handleBalanceError(account.id, error)" @refreshed="(data) => handleBalanceRefreshed(account.id, data)" /> +
+ +
@@ -1475,6 +1483,14 @@ @error="(error) => handleBalanceError(account.id, error)" @refreshed="(data) => handleBalanceRefreshed(account.id, data)" /> +
+ +
@@ -1958,6 +1974,13 @@ @saved="handleScheduledTestSaved" /> + + { showToast('定时测试配置已保存', 'success') } +// 余额脚本配置 +const showBalanceScriptModal = ref(false) +const selectedAccountForScript = ref(null) + +const openBalanceScriptModal = (account) => { + selectedAccountForScript.value = account + showBalanceScriptModal.value = true +} + +const closeBalanceScriptModal = () => { + showBalanceScriptModal.value = false + selectedAccountForScript.value = null +} + +const handleBalanceScriptSaved = () => { + showToast('余额脚本已保存', 'success') + closeBalanceScriptModal() +} + // 计算排序后的账户列表 const sortedAccounts = computed(() => { let sourceAccounts = accounts.value diff --git a/web/admin-spa/src/views/BalanceScriptsView.vue b/web/admin-spa/src/views/BalanceScriptsView.vue new file mode 100644 index 00000000..1e4334da --- /dev/null +++ b/web/admin-spa/src/views/BalanceScriptsView.vue @@ -0,0 +1,312 @@ + + + + + From 26ca696b91d57b3e5daf0f5198a43e96bba6b171 Mon Sep 17 00:00:00 2001 From: atoz03 Date: Sat, 13 Dec 2025 00:50:53 +0800 Subject: [PATCH 15/36] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E4=BA=86=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=A3=B0=E6=98=8E=20redis=20=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=E5=90=AF=E5=8A=A8=E6=8A=A5=E9=94=99=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E4=BD=99=E9=A2=9D=E8=84=9A=E6=9C=AC=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=8E=A5=E5=85=A5=E8=B4=A6=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/accountBalanceService.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/accountBalanceService.js b/src/services/accountBalanceService.js index 370ed680..478e2d47 100644 --- a/src/services/accountBalanceService.js +++ b/src/services/accountBalanceService.js @@ -2,7 +2,6 @@ const redis = require('../models/redis') const balanceScriptService = require('./balanceScriptService') const logger = require('../utils/logger') const CostCalculator = require('../utils/costCalculator') -const redis = require('../models/redis') class AccountBalanceService { constructor(options = {}) { From f6f4b5cfece2d88433e22b4e0eca157334b65739 Mon Sep 17 00:00:00 2001 From: atoz03 Date: Sun, 14 Dec 2025 13:43:02 +0800 Subject: [PATCH 16/36] =?UTF-8?q?feat(admin):=20=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E9=A9=B1=E5=8A=A8=E7=9A=84=E4=BD=99=E9=A2=9D?= =?UTF-8?q?/=E9=85=8D=E9=A2=9D=E5=88=B7=E6=96=B0=E4=B8=8E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=AB=AF=E4=BD=93=E9=AA=8C=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 明确刷新语义:仅脚本启用且已配置时触发远程查询;未配置时前端禁用并提示\n- 新增余额脚本安全开关 BALANCE_SCRIPT_ENABLED(默认开启),脚本测试接口受控\n- Redis 增加单账户脚本配置存取,响应透出 scriptEnabled/scriptConfigured 供 UI 判定\n- accountBalanceService:本地统计汇总改用 SCAN+pipeline,避免 KEYS;仅缓存远程成功结果,避免失败/降级覆盖有效缓存\n- 管理端体验:刷新按钮按配置状态灰置;脚本弹窗内容可滚动、底部操作栏固定,并 append-to-body 使弹窗跟随当前视窗 --- config/config.example.js | 8 ++ src/models/redis.js | 8 +- src/routes/admin/accountBalance.js | 9 +- src/routes/admin/index.js | 2 - src/services/accountBalanceService.js | 114 ++++++++++----- src/services/balanceScriptService.js | 132 ++++-------------- src/utils/featureFlags.js | 44 ++++++ tests/accountBalanceService.test.js | 82 ++++++++++- .../accounts/AccountBalanceScriptModal.vue | 35 ++++- .../components/accounts/BalanceDisplay.vue | 26 +++- web/admin-spa/src/views/AccountsView.vue | 65 +++++++-- 11 files changed, 358 insertions(+), 167 deletions(-) create mode 100644 src/utils/featureFlags.js diff --git a/config/config.example.js b/config/config.example.js index 9cf26002..e5e0c340 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -205,6 +205,14 @@ const config = { hotReload: process.env.HOT_RELOAD === 'true' }, + // 💰 账户余额相关配置 + accountBalance: { + // 是否允许执行自定义余额脚本(安全开关) + // 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启 + // 默认保持开启;如需禁用请显式设置:BALANCE_SCRIPT_ENABLED=false + enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false' + }, + // 📬 用户消息队列配置 // 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算 userMessageQueue: { diff --git a/src/models/redis.js b/src/models/redis.js index 3adc51d1..be90c749 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1615,15 +1615,17 @@ class RedisClient { } // 🧩 账户余额脚本配置 - async setBalanceScriptConfig(platform, accountId, config) { + async setBalanceScriptConfig(platform, accountId, scriptConfig) { const key = `account_balance_script:${platform}:${accountId}` - await this.client.set(key, JSON.stringify(config || {})) + await this.client.set(key, JSON.stringify(scriptConfig || {})) } async getBalanceScriptConfig(platform, accountId) { const key = `account_balance_script:${platform}:${accountId}` const raw = await this.client.get(key) - if (!raw) return null + if (!raw) { + return null + } try { return JSON.parse(raw) } catch (error) { diff --git a/src/routes/admin/accountBalance.js b/src/routes/admin/accountBalance.js index 2f55c850..5669cf40 100644 --- a/src/routes/admin/accountBalance.js +++ b/src/routes/admin/accountBalance.js @@ -3,6 +3,7 @@ const { authenticateAdmin } = require('../../middleware/auth') const logger = require('../../utils/logger') const accountBalanceService = require('../../services/accountBalanceService') const balanceScriptService = require('../../services/balanceScriptService') +const { isBalanceScriptEnabled } = require('../../utils/featureFlags') const router = express.Router() @@ -47,7 +48,7 @@ router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) = } }) -// 2) 强制刷新账户余额(触发 Provider) +// 2) 强制刷新账户余额(强制触发查询:优先脚本;Provider 仅为降级) // POST /admin/accounts/:accountId/balance/refresh // Body: { platform: 'xxx' } router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => { @@ -174,6 +175,12 @@ router.post('/accounts/:accountId/balance/script/test', authenticateAdmin, async return res.status(valid.status).json({ success: false, error: valid.error }) } + if (!isBalanceScriptEnabled()) { + return res + .status(403) + .json({ success: false, error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)' }) + } + const payload = req.body || {} const scriptBody = payload.scriptBody if (!scriptBody) { diff --git a/src/routes/admin/index.js b/src/routes/admin/index.js index f5cb2268..f7deafd6 100644 --- a/src/routes/admin/index.js +++ b/src/routes/admin/index.js @@ -22,7 +22,6 @@ const droidAccountsRoutes = require('./droidAccounts') const dashboardRoutes = require('./dashboard') const usageStatsRoutes = require('./usageStats') const accountBalanceRoutes = require('./accountBalance') -const balanceScriptsRoutes = require('./balanceScripts') const systemRoutes = require('./system') const concurrencyRoutes = require('./concurrency') const claudeRelayConfigRoutes = require('./claudeRelayConfig') @@ -39,7 +38,6 @@ router.use('/', droidAccountsRoutes) router.use('/', dashboardRoutes) router.use('/', usageStatsRoutes) router.use('/', accountBalanceRoutes) -router.use('/', balanceScriptsRoutes) router.use('/', systemRoutes) router.use('/', concurrencyRoutes) router.use('/', claudeRelayConfigRoutes) diff --git a/src/services/accountBalanceService.js b/src/services/accountBalanceService.js index 478e2d47..3265c4b8 100644 --- a/src/services/accountBalanceService.js +++ b/src/services/accountBalanceService.js @@ -2,6 +2,7 @@ const redis = require('../models/redis') const balanceScriptService = require('./balanceScriptService') const logger = require('../utils/logger') const CostCalculator = require('../utils/costCalculator') +const { isBalanceScriptEnabled } = require('../utils/featureFlags') class AccountBalanceService { constructor(options = {}) { @@ -277,6 +278,20 @@ class AccountBalanceService { throw new Error('账户缺少 id') } + // 余额脚本配置状态(用于前端控制“刷新余额”按钮) + let scriptConfig = null + let scriptConfigured = false + if (typeof this.redis?.getBalanceScriptConfig === 'function') { + scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId) + scriptConfigured = !!( + scriptConfig && + scriptConfig.scriptBody && + String(scriptConfig.scriptBody).trim().length > 0 + ) + } + const scriptEnabled = isBalanceScriptEnabled() + const scriptMeta = { scriptEnabled, scriptConfigured } + const localBalance = await this._getBalanceFromLocal(accountId, platform) const localStatistics = localBalance.statistics || {} @@ -300,7 +315,8 @@ class AccountBalanceService { accountId, platform, 'cache', - cached.ttlSeconds + cached.ttlSeconds, + scriptMeta ) } } @@ -317,15 +333,16 @@ class AccountBalanceService { }, accountId, platform, - 'local' + 'local', + null, + scriptMeta ) } - // 强制查询:调用 Provider,失败自动降级到本地统计 - const scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId) + // 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计 let providerResult - if (scriptConfig && scriptConfig.scriptBody) { + if (scriptEnabled && scriptConfigured) { providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform) } else { const provider = this.providers.get(platform) @@ -342,15 +359,28 @@ class AccountBalanceService { }, accountId, platform, - 'local' + 'local', + null, + scriptMeta ) } providerResult = await this._getBalanceFromProvider(provider, account) } - await this.redis.setAccountBalance(platform, accountId, providerResult, this.CACHE_TTL_SECONDS) + const isRemoteSuccess = + providerResult.status === 'success' && ['api', 'script'].includes(providerResult.queryMethod) - const source = providerResult.status === 'success' ? 'api' : 'local' + // 仅缓存“真实远程查询成功”的结果,避免把字段/本地降级结果当作 API 结果缓存 1h + if (isRemoteSuccess) { + await this.redis.setAccountBalance( + platform, + accountId, + providerResult, + this.CACHE_TTL_SECONDS + ) + } + + const source = isRemoteSuccess ? 'api' : 'local' return this._buildResponse( { @@ -364,7 +394,9 @@ class AccountBalanceService { }, accountId, platform, - source + source, + null, + scriptMeta ) } @@ -507,35 +539,50 @@ class AccountBalanceService { async _sumModelCostsByKeysPattern(pattern) { try { const client = this.redis.getClientSafe() - const keys = await client.keys(pattern) - if (!keys || keys.length === 0) { - return 0 - } - - const pipeline = client.pipeline() - keys.forEach((key) => pipeline.hgetall(key)) - const results = await pipeline.exec() - let totalCost = 0 - for (let i = 0; i < results.length; i += 1) { - const [, data] = results[i] || [] - if (!data || Object.keys(data).length === 0) { + let cursor = '0' + const scanCount = 200 + let iterations = 0 + const maxIterations = 2000 + + do { + const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', scanCount) + cursor = nextCursor + iterations += 1 + + if (!keys || keys.length === 0) { continue } - const parts = String(keys[i]).split(':') - const model = parts[4] || 'unknown' + const pipeline = client.pipeline() + keys.forEach((key) => pipeline.hgetall(key)) + const results = await pipeline.exec() - const usage = { - input_tokens: parseInt(data.inputTokens || 0), - output_tokens: parseInt(data.outputTokens || 0), - cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0), - cache_read_input_tokens: parseInt(data.cacheReadTokens || 0) + for (let i = 0; i < results.length; i += 1) { + const [, data] = results[i] || [] + if (!data || Object.keys(data).length === 0) { + continue + } + + const parts = String(keys[i]).split(':') + const model = parts[4] || 'unknown' + + const usage = { + input_tokens: parseInt(data.inputTokens || 0), + output_tokens: parseInt(data.outputTokens || 0), + cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0), + cache_read_input_tokens: parseInt(data.cacheReadTokens || 0) + } + + const costResult = CostCalculator.calculateCost(usage, model) + totalCost += costResult.costs.total || 0 } - const costResult = CostCalculator.calculateCost(usage, model) - totalCost += costResult.costs.total || 0 - } + if (iterations >= maxIterations) { + this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`) + break + } + } while (cursor !== '0') return totalCost } catch (error) { @@ -610,7 +657,7 @@ class AccountBalanceService { return new Date(resetAtMs).toISOString() } - _buildResponse(balanceData, accountId, platform, source, ttlSeconds = null) { + _buildResponse(balanceData, accountId, platform, source, ttlSeconds = null, extraData = {}) { const now = new Date() const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null @@ -642,7 +689,8 @@ class AccountBalanceService { lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(), cacheExpiresAt, status: balanceData.status || 'success', - error: balanceData.errorMessage || null + error: balanceData.errorMessage || null, + ...(extraData && typeof extraData === 'object' ? extraData : {}) } } } diff --git a/src/services/balanceScriptService.js b/src/services/balanceScriptService.js index 36a996b9..5bf06801 100644 --- a/src/services/balanceScriptService.js +++ b/src/services/balanceScriptService.js @@ -1,81 +1,13 @@ -const fs = require('fs') -const path = require('path') const vm = require('vm') const axios = require('axios') -const logger = require('../utils/logger') +const { isBalanceScriptEnabled } = require('../utils/featureFlags') /** - * 可配置脚本余额查询服务 - * - 存储位置:data/balanceScripts.json + * 可配置脚本余额查询执行器 * - 脚本格式:({ request: {...}, extractor: function(response){...} }) * - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}} */ class BalanceScriptService { - constructor() { - this.filePath = path.join(__dirname, '..', '..', 'data', 'balanceScripts.json') - this.ensureStore() - } - - ensureStore() { - const dir = path.dirname(this.filePath) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - if (!fs.existsSync(this.filePath)) { - fs.writeFileSync(this.filePath, JSON.stringify({}, null, 2)) - } - } - - loadAll() { - try { - const raw = fs.readFileSync(this.filePath, 'utf8') - return JSON.parse(raw || '{}') - } catch (error) { - logger.error('读取余额脚本配置失败', error) - return {} - } - } - - saveAll(data) { - fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2)) - } - - listConfigs() { - const all = this.loadAll() - return Object.values(all) - } - - getConfig(name) { - const all = this.loadAll() - if (all[name]) { - return all[name] - } - return { - name, - baseUrl: '', - apiKey: '', - token: '', - timeoutSeconds: 10, - autoIntervalMinutes: 0, - scriptBody: - "({\n request: {\n url: \"{{baseUrl}}/user/balance\",\n method: \"GET\",\n headers: {\n \"Authorization\": \"Bearer {{apiKey}}\",\n \"User-Agent\": \"cc-switch/1.0\"\n }\n },\n extractor: function(response) {\n return {\n isValid: !response.error,\n remaining: response.balance,\n unit: \"USD\"\n };\n }\n})", - updatedAt: null - } - } - - saveConfig(name, payload) { - const all = this.loadAll() - const config = { - ...this.getConfig(name), - ...payload, - name, - updatedAt: new Date().toISOString() - } - all[name] = config - this.saveAll(all) - return config - } - /** * 执行脚本:返回标准余额结构 + 原始响应 * @param {object} options @@ -84,6 +16,12 @@ class BalanceScriptService { * - timeoutSeconds: number */ async execute(options = {}) { + if (!isBalanceScriptEnabled()) { + const error = new Error('余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)') + error.code = 'BALANCE_SCRIPT_DISABLED' + throw error + } + const scriptBody = options.scriptBody?.trim() if (!scriptBody) { throw new Error('脚本内容为空') @@ -99,7 +37,7 @@ class BalanceScriptService { let scriptResult try { const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})` - const script = new vm.Script(wrapped, { timeout: timeoutMs }) + const script = new vm.Script(wrapped) scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs }) } catch (error) { throw new Error(`脚本解析失败: ${error.message}`) @@ -111,12 +49,16 @@ class BalanceScriptService { const variables = options.variables || {} const request = this.applyTemplates(scriptResult.request || {}, variables) - const extractor = scriptResult.extractor + const { extractor } = scriptResult - if (!request.url) { + if (!request?.url || typeof request.url !== 'string') { throw new Error('脚本 request.url 不能为空') } + if (typeof extractor !== 'function') { + throw new Error('脚本 extractor 必须是函数') + } + const axiosConfig = { url: request.url, method: (request.method || 'GET').toUpperCase(), @@ -131,23 +73,24 @@ class BalanceScriptService { axiosConfig.data = request.body || request.data } - let httpResponse = null + let httpResponse try { httpResponse = await axios(axiosConfig) } catch (error) { - const status = error.response?.status - const data = error.response?.data - throw new Error(`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`) + const { response } = error || {} + const { status, data } = response || {} + throw new Error( + `请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}` + ) } const responseData = httpResponse?.data + let extracted = {} - if (typeof extractor === 'function') { - try { - extracted = extractor(responseData) || {} - } catch (error) { - throw new Error(`extractor 执行失败: ${error.message}`) - } + try { + extracted = extractor(responseData) || {} + } catch (error) { + throw new Error(`extractor 执行失败: ${error.message}`) } const mapped = this.mapExtractorResult(extracted, responseData) @@ -213,29 +156,6 @@ class BalanceScriptService { rawData: responseData || result.raw } } - - async testScript(name, payload = {}) { - const config = payload.useBodyConfig ? this.getConfig(name) : this.getConfig(name) - const scriptBody = payload.scriptBody || config.scriptBody - const timeoutSeconds = payload.timeoutSeconds || config.timeoutSeconds - const variables = { - baseUrl: payload.baseUrl || config.baseUrl, - apiKey: payload.apiKey || config.apiKey, - token: payload.token || config.token, - accountId: payload.accountId || '', - platform: payload.platform || '', - extra: payload.extra || '' - } - - const result = await this.execute({ scriptBody, variables, timeoutSeconds }) - return { - name, - variables, - mapped: result.mapped, - extracted: result.extracted, - response: result.response - } - } } module.exports = new BalanceScriptService() diff --git a/src/utils/featureFlags.js b/src/utils/featureFlags.js new file mode 100644 index 00000000..35802d55 --- /dev/null +++ b/src/utils/featureFlags.js @@ -0,0 +1,44 @@ +let config = {} +try { + // config/config.js 可能在某些环境不存在(例如仅拷贝了 config.example.js) + // 为保证可运行,这里做容错处理 + // eslint-disable-next-line global-require + config = require('../../config/config') +} catch (error) { + config = {} +} + +const parseBooleanEnv = (value) => { + if (typeof value === 'boolean') { + return value + } + if (typeof value !== 'string') { + return false + } + const normalized = value.trim().toLowerCase() + return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on' +} + +/** + * 是否允许执行“余额脚本”(安全开关) + * 默认开启,便于保持现有行为;如需禁用请显式设置 BALANCE_SCRIPT_ENABLED=false(环境变量优先) + */ +const isBalanceScriptEnabled = () => { + if ( + process.env.BALANCE_SCRIPT_ENABLED !== undefined && + process.env.BALANCE_SCRIPT_ENABLED !== '' + ) { + return parseBooleanEnv(process.env.BALANCE_SCRIPT_ENABLED) + } + + const fromConfig = + config?.accountBalance?.enableBalanceScript ?? + config?.features?.balanceScriptEnabled ?? + config?.security?.enableBalanceScript + + return typeof fromConfig === 'boolean' ? fromConfig : true +} + +module.exports = { + isBalanceScriptEnabled +} diff --git a/tests/accountBalanceService.test.js b/tests/accountBalanceService.test.js index 9510b9b3..f3b60d78 100644 --- a/tests/accountBalanceService.test.js +++ b/tests/accountBalanceService.test.js @@ -11,6 +11,16 @@ const accountBalanceServiceModule = require('../src/services/accountBalanceServi const { AccountBalanceService } = accountBalanceServiceModule describe('AccountBalanceService', () => { + const originalBalanceScriptEnabled = process.env.BALANCE_SCRIPT_ENABLED + + afterEach(() => { + if (originalBalanceScriptEnabled === undefined) { + delete process.env.BALANCE_SCRIPT_ENABLED + } else { + process.env.BALANCE_SCRIPT_ENABLED = originalBalanceScriptEnabled + } + }) + const mockLogger = { debug: jest.fn(), info: jest.fn(), @@ -24,6 +34,7 @@ describe('AccountBalanceService', () => { getAccountBalance: jest.fn().mockResolvedValue(null), setAccountBalance: jest.fn().mockResolvedValue(undefined), deleteAccountBalance: jest.fn().mockResolvedValue(undefined), + getBalanceScriptConfig: jest.fn().mockResolvedValue(null), getAccountUsageStats: jest.fn().mockResolvedValue({ total: { requests: 10 }, daily: { requests: 2, cost: 20 }, @@ -87,7 +98,7 @@ describe('AccountBalanceService', () => { expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z') }) - it('should cache provider errors and fallback to local when queryApi=true', async () => { + it('should not cache provider errors and fallback to local when queryApi=true', async () => { const mockRedis = buildMockRedis() const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) @@ -106,12 +117,78 @@ describe('AccountBalanceService', () => { useCache: false }) - expect(mockRedis.setAccountBalance).toHaveBeenCalled() + expect(mockRedis.setAccountBalance).not.toHaveBeenCalled() expect(result.data.source).toBe('local') expect(result.data.status).toBe('error') expect(result.data.error).toBe('boom') }) + it('should ignore script config when balance script is disabled', async () => { + process.env.BALANCE_SCRIPT_ENABLED = 'false' + + const mockRedis = buildMockRedis() + mockRedis.getBalanceScriptConfig.mockResolvedValue({ + scriptBody: '({ request: { url: \"http://example.com\" }, extractor: function(){ return {} } })' + }) + + const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) + service._computeMonthlyCost = jest.fn().mockResolvedValue(0) + service._computeTotalCost = jest.fn().mockResolvedValue(0) + + const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 1, currency: 'USD' }) } + service.registerProvider('openai', provider) + + const scriptSpy = jest.spyOn(service, '_getBalanceFromScript') + + const account = { id: 'acct-script-off', name: 'S' } + const result = await service._getAccountBalanceForAccount(account, 'openai', { + queryApi: true, + useCache: false + }) + + expect(provider.queryBalance).toHaveBeenCalled() + expect(scriptSpy).not.toHaveBeenCalled() + expect(result.data.source).toBe('api') + }) + + it('should prefer script when configured and enabled', async () => { + process.env.BALANCE_SCRIPT_ENABLED = 'true' + + const mockRedis = buildMockRedis() + mockRedis.getBalanceScriptConfig.mockResolvedValue({ + scriptBody: '({ request: { url: \"http://example.com\" }, extractor: function(){ return {} } })' + }) + + const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) + service._computeMonthlyCost = jest.fn().mockResolvedValue(0) + service._computeTotalCost = jest.fn().mockResolvedValue(0) + + const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 2, currency: 'USD' }) } + service.registerProvider('openai', provider) + + jest.spyOn(service, '_getBalanceFromScript').mockResolvedValue({ + status: 'success', + balance: 3, + currency: 'USD', + quota: null, + queryMethod: 'script', + rawData: { ok: true }, + lastRefreshAt: '2025-01-01T00:00:00Z', + errorMessage: '' + }) + + const account = { id: 'acct-script-on', name: 'T' } + const result = await service._getAccountBalanceForAccount(account, 'openai', { + queryApi: true, + useCache: false + }) + + expect(provider.queryBalance).not.toHaveBeenCalled() + expect(result.data.source).toBe('api') + expect(result.data.balance.amount).toBeCloseTo(3, 6) + expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z') + }) + it('should count low balance once per account in summary', async () => { const mockRedis = buildMockRedis() const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) @@ -139,4 +216,3 @@ describe('AccountBalanceService', () => { expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1) }) }) - diff --git a/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue index ef60d447..13fd97ce 100644 --- a/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue +++ b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue @@ -1,7 +1,12 @@ @@ -256,6 +263,22 @@ watch(