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 01/19] =?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 f6ed420401e9275c5c65880cfa437bdee9f9ed79 Mon Sep 17 00:00:00 2001 From: atoz03 Date: Fri, 12 Dec 2025 22:53:05 +0800 Subject: [PATCH 02/19] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E4=BD=99=E9=A2=9D/=E9=85=8D=E9=A2=9D?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E4=B8=8E=E5=B1=95=E7=A4=BA=20=20=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20accountBalanceService=20=E4=B8=8E=E5=A4=9A?= =?UTF-8?q?=20Provider=20=E9=80=82=E9=85=8D=EF=BC=88Claude/Claude=20Consol?= =?UTF-8?q?e/OpenAI=20Responses/=E9=80=9A=E7=94=A8=EF=BC=89=20=20=20-=20Re?= =?UTF-8?q?dis=20=E5=A2=9E=E5=8A=A0=E4=BD=99=E9=A2=9D=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E4=B8=8E=E6=9C=AC=E5=9C=B0=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E8=AF=BB=E5=86=99=20=20=20-=20=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=AB=AF=E6=96=B0=E5=A2=9E=20/admin/accounts/balance?= =?UTF-8?q?=20=E7=9B=B8=E5=85=B3=E6=8E=A5=E5=8F=A3=E4=B8=8E=E6=B1=87?= =?UTF-8?q?=E6=80=BB=E6=8E=A5=E5=8F=A3=EF=BC=8C=E5=B9=B6=E5=9C=A8=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=90=AF=E5=8A=A8=E6=97=B6=E6=B3=A8=E5=86=8C=20Provid?= =?UTF-8?q?er=20=20=20-=20=E5=90=8E=E5=8F=B0=E5=89=8D=E7=AB=AF=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=BD=99=E9=A2=9D=E7=BB=84=E4=BB=B6=E4=B8=8E=20Dashbo?= =?UTF-8?q?ard=20=E4=BD=99=E9=A2=9D/=E9=85=8D=E9=A2=9D=E6=B1=87=E6=80=BB?= =?UTF-8?q?=E3=80=81=E4=BD=8E=E4=BD=99=E9=A2=9D/=E9=AB=98=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=8F=90=E7=A4=BA=20=20=20-=20=E8=A1=A5=E5=85=85=20ac?= =?UTF-8?q?countBalanceService=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.js | 10 + src/models/redis.js | 93 +++ src/routes/admin/accountBalance.js | 130 ++++ src/routes/admin/index.js | 2 + src/services/accountBalanceService.js | 652 ++++++++++++++++++ .../balanceProviders/baseBalanceProvider.js | 133 ++++ .../balanceProviders/claudeBalanceProvider.js | 30 + .../claudeConsoleBalanceProvider.js | 14 + .../genericBalanceProvider.js | 23 + src/services/balanceProviders/index.js | 24 + .../openaiResponsesBalanceProvider.js | 54 ++ tests/accountBalanceService.test.js | 142 ++++ .../components/accounts/BalanceDisplay.vue | 261 +++++++ web/admin-spa/src/views/AccountsView.vue | 173 +++++ web/admin-spa/src/views/DashboardView.vue | 194 +++++- 15 files changed, 1934 insertions(+), 1 deletion(-) create mode 100644 src/routes/admin/accountBalance.js create mode 100644 src/services/accountBalanceService.js create mode 100644 src/services/balanceProviders/baseBalanceProvider.js create mode 100644 src/services/balanceProviders/claudeBalanceProvider.js create mode 100644 src/services/balanceProviders/claudeConsoleBalanceProvider.js create mode 100644 src/services/balanceProviders/genericBalanceProvider.js create mode 100644 src/services/balanceProviders/index.js create mode 100644 src/services/balanceProviders/openaiResponsesBalanceProvider.js create mode 100644 tests/accountBalanceService.test.js create mode 100644 web/admin-spa/src/components/accounts/BalanceDisplay.vue diff --git a/src/app.js b/src/app.js index 41edc483..061a00fc 100644 --- a/src/app.js +++ b/src/app.js @@ -52,6 +52,16 @@ class Application { await redis.connect() logger.success('✅ Redis connected successfully') + // 💳 初始化账户余额查询服务(Provider 注册) + try { + const accountBalanceService = require('./services/accountBalanceService') + const { registerAllProviders } = require('./services/balanceProviders') + registerAllProviders(accountBalanceService) + logger.info('✅ 账户余额查询服务已初始化') + } catch (error) { + logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message) + } + // 💰 初始化价格服务 logger.info('🔄 Initializing pricing service...') await pricingService.initialize() diff --git a/src/models/redis.js b/src/models/redis.js index 6cffa6a9..48edb116 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1521,6 +1521,99 @@ class RedisClient { return await this.client.del(key) } + // 💰 账户余额缓存(API 查询结果) + async setAccountBalance(platform, accountId, balanceData, ttl = 3600) { + const key = `account_balance:${platform}:${accountId}` + + const payload = { + balance: + balanceData && balanceData.balance !== null && balanceData.balance !== undefined + ? String(balanceData.balance) + : '', + currency: balanceData?.currency || 'USD', + lastRefreshAt: balanceData?.lastRefreshAt || new Date().toISOString(), + queryMethod: balanceData?.queryMethod || 'api', + status: balanceData?.status || 'success', + errorMessage: balanceData?.errorMessage || balanceData?.error || '', + rawData: balanceData?.rawData ? JSON.stringify(balanceData.rawData) : '', + quota: balanceData?.quota ? JSON.stringify(balanceData.quota) : '' + } + + await this.client.hset(key, payload) + await this.client.expire(key, ttl) + } + + async getAccountBalance(platform, accountId) { + const key = `account_balance:${platform}:${accountId}` + const [data, ttlSeconds] = await Promise.all([this.client.hgetall(key), this.client.ttl(key)]) + + if (!data || Object.keys(data).length === 0) { + return null + } + + let rawData = null + if (data.rawData) { + try { + rawData = JSON.parse(data.rawData) + } catch (error) { + rawData = null + } + } + + let quota = null + if (data.quota) { + try { + quota = JSON.parse(data.quota) + } catch (error) { + quota = null + } + } + + return { + balance: data.balance ? parseFloat(data.balance) : null, + currency: data.currency || 'USD', + lastRefreshAt: data.lastRefreshAt || null, + queryMethod: data.queryMethod || null, + status: data.status || null, + errorMessage: data.errorMessage || '', + rawData, + quota, + ttlSeconds: Number.isFinite(ttlSeconds) ? ttlSeconds : null + } + } + + // 📊 账户余额缓存(本地统计) + async setLocalBalance(platform, accountId, statisticsData, ttl = 300) { + const key = `account_balance_local:${platform}:${accountId}` + + await this.client.hset(key, { + estimatedBalance: JSON.stringify(statisticsData || {}), + lastCalculated: new Date().toISOString() + }) + await this.client.expire(key, ttl) + } + + async getLocalBalance(platform, accountId) { + const key = `account_balance_local:${platform}:${accountId}` + const data = await this.client.hgetall(key) + + if (!data || !data.estimatedBalance) { + return null + } + + try { + return JSON.parse(data.estimatedBalance) + } catch (error) { + return null + } + } + + async deleteAccountBalance(platform, accountId) { + const key = `account_balance:${platform}:${accountId}` + const localKey = `account_balance_local:${platform}:${accountId}` + await this.client.del(key, localKey) + } + // 📈 系统统计 async getSystemStats() { const keys = await Promise.all([ diff --git a/src/routes/admin/accountBalance.js b/src/routes/admin/accountBalance.js new file mode 100644 index 00000000..2acffd7b --- /dev/null +++ b/src/routes/admin/accountBalance.js @@ -0,0 +1,130 @@ +const express = require('express') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const accountBalanceService = require('../../services/accountBalanceService') + +const router = express.Router() + +const ensureValidPlatform = (rawPlatform) => { + const normalized = accountBalanceService.normalizePlatform(rawPlatform) + if (!normalized) { + return { ok: false, status: 400, error: '缺少 platform 参数' } + } + + const supported = accountBalanceService.getSupportedPlatforms() + if (!supported.includes(normalized)) { + return { ok: false, status: 400, error: `不支持的平台: ${normalized}` } + } + + return { ok: true, platform: normalized } +} + +// 1) 获取账户余额(默认本地统计优先,可选触发 Provider) +// GET /admin/accounts/:accountId/balance?platform=xxx&queryApi=false +router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform, queryApi } = req.query + + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + const balance = await accountBalanceService.getAccountBalance(accountId, valid.platform, { + queryApi + }) + + if (!balance) { + return res.status(404).json({ success: false, error: 'Account not found' }) + } + + return res.json(balance) + } catch (error) { + logger.error('获取账户余额失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +// 2) 强制刷新账户余额(触发 Provider) +// POST /admin/accounts/:accountId/balance/refresh +// Body: { platform: 'xxx' } +router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform } = req.body || {} + + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + logger.info(`手动刷新余额: ${valid.platform}:${accountId}`) + + const balance = await accountBalanceService.refreshAccountBalance(accountId, valid.platform) + if (!balance) { + return res.status(404).json({ success: false, error: 'Account not found' }) + } + + return res.json(balance) + } catch (error) { + logger.error('刷新账户余额失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +// 3) 批量获取平台所有账户余额 +// GET /admin/accounts/balance/platform/:platform?queryApi=false +router.get('/accounts/balance/platform/:platform', authenticateAdmin, async (req, res) => { + try { + const { platform } = req.params + const { queryApi } = req.query + + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + const balances = await accountBalanceService.getAllAccountsBalance(valid.platform, { queryApi }) + + return res.json({ success: true, data: balances }) + } catch (error) { + logger.error('批量获取余额失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +// 4) 获取余额汇总(Dashboard 用) +// GET /admin/accounts/balance/summary +router.get('/accounts/balance/summary', authenticateAdmin, async (req, res) => { + try { + const summary = await accountBalanceService.getBalanceSummary() + return res.json({ success: true, data: summary }) + } catch (error) { + logger.error('获取余额汇总失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +// 5) 清除缓存 +// DELETE /admin/accounts/:accountId/balance/cache?platform=xxx +router.delete('/accounts/:accountId/balance/cache', 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 }) + } + + await accountBalanceService.clearCache(accountId, valid.platform) + + return res.json({ success: true, message: '缓存已清除' }) + } catch (error) { + logger.error('清除缓存失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/admin/index.js b/src/routes/admin/index.js index c91aa5e7..f7deafd6 100644 --- a/src/routes/admin/index.js +++ b/src/routes/admin/index.js @@ -21,6 +21,7 @@ const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts') const droidAccountsRoutes = require('./droidAccounts') const dashboardRoutes = require('./dashboard') const usageStatsRoutes = require('./usageStats') +const accountBalanceRoutes = require('./accountBalance') const systemRoutes = require('./system') const concurrencyRoutes = require('./concurrency') const claudeRelayConfigRoutes = require('./claudeRelayConfig') @@ -36,6 +37,7 @@ router.use('/', openaiResponsesAccountsRoutes) router.use('/', droidAccountsRoutes) router.use('/', dashboardRoutes) router.use('/', usageStatsRoutes) +router.use('/', accountBalanceRoutes) router.use('/', systemRoutes) router.use('/', concurrencyRoutes) router.use('/', claudeRelayConfigRoutes) diff --git a/src/services/accountBalanceService.js b/src/services/accountBalanceService.js new file mode 100644 index 00000000..5ef882cf --- /dev/null +++ b/src/services/accountBalanceService.js @@ -0,0 +1,652 @@ +const redis = require('../models/redis') +const logger = require('../utils/logger') +const CostCalculator = require('../utils/costCalculator') + +class AccountBalanceService { + constructor(options = {}) { + this.redis = options.redis || redis + this.logger = options.logger || logger + + this.providers = new Map() + + this.CACHE_TTL_SECONDS = 3600 + this.LOCAL_TTL_SECONDS = 300 + + this.LOW_BALANCE_THRESHOLD = 10 + this.HIGH_USAGE_THRESHOLD_PERCENT = 90 + this.DEFAULT_CONCURRENCY = 10 + } + + getSupportedPlatforms() { + return [ + 'claude', + 'claude-console', + 'gemini', + 'gemini-api', + 'openai', + 'openai-responses', + 'azure_openai', + 'bedrock', + 'droid', + 'ccr' + ] + } + + normalizePlatform(platform) { + if (!platform) { + return null + } + + const value = String(platform).trim().toLowerCase() + + // 兼容实施文档与历史命名 + if (value === 'claude-official') { + return 'claude' + } + if (value === 'azure-openai') { + return 'azure_openai' + } + + // 保持前端平台键一致 + return value + } + + registerProvider(platform, provider) { + const normalized = this.normalizePlatform(platform) + if (!normalized) { + throw new Error('registerProvider: 缺少 platform') + } + if (!provider || typeof provider.queryBalance !== 'function') { + throw new Error(`registerProvider: Provider 无效 (${normalized})`) + } + this.providers.set(normalized, provider) + } + + async getAccountBalance(accountId, platform, options = {}) { + const normalizedPlatform = this.normalizePlatform(platform) + const account = await this.getAccount(accountId, normalizedPlatform) + if (!account) { + return null + } + return await this._getAccountBalanceForAccount(account, normalizedPlatform, options) + } + + async refreshAccountBalance(accountId, platform) { + const normalizedPlatform = this.normalizePlatform(platform) + const account = await this.getAccount(accountId, normalizedPlatform) + if (!account) { + return null + } + + return await this._getAccountBalanceForAccount(account, normalizedPlatform, { + queryApi: true, + useCache: false + }) + } + + async getAllAccountsBalance(platform, options = {}) { + const normalizedPlatform = this.normalizePlatform(platform) + const accounts = await this.getAllAccountsByPlatform(normalizedPlatform) + const queryApi = this._parseBoolean(options.queryApi) || false + const useCache = options.useCache !== false + + const results = await this._mapWithConcurrency( + accounts, + this.DEFAULT_CONCURRENCY, + async (acc) => { + try { + const balance = await this._getAccountBalanceForAccount(acc, normalizedPlatform, { + queryApi, + useCache + }) + return { ...balance, name: acc.name || '' } + } catch (error) { + this.logger.error(`批量获取余额失败: ${normalizedPlatform}:${acc?.id}`, error) + return { + success: true, + data: { + accountId: acc?.id, + platform: normalizedPlatform, + balance: null, + quota: null, + statistics: {}, + source: 'local', + lastRefreshAt: new Date().toISOString(), + cacheExpiresAt: null, + status: 'error', + error: error.message || '批量查询失败' + }, + name: acc?.name || '' + } + } + } + ) + + return results + } + + async getBalanceSummary() { + const platforms = this.getSupportedPlatforms() + + const summary = { + totalBalance: 0, + totalCost: 0, + lowBalanceCount: 0, + platforms: {} + } + + for (const platform of platforms) { + const accounts = await this.getAllAccountsByPlatform(platform) + const platformData = { + count: accounts.length, + totalBalance: 0, + totalCost: 0, + lowBalanceCount: 0, + accounts: [] + } + + const balances = await this._mapWithConcurrency( + accounts, + this.DEFAULT_CONCURRENCY, + async (acc) => { + const balance = await this._getAccountBalanceForAccount(acc, platform, { + queryApi: false, + useCache: true + }) + return { ...balance, name: acc.name || '' } + } + ) + + for (const item of balances) { + platformData.accounts.push(item) + + const amount = item?.data?.balance?.amount + const percentage = item?.data?.quota?.percentage + const totalCost = Number(item?.data?.statistics?.totalCost || 0) + + const hasAmount = typeof amount === 'number' && Number.isFinite(amount) + const isLowBalance = hasAmount && amount < this.LOW_BALANCE_THRESHOLD + const isHighUsage = + typeof percentage === 'number' && + Number.isFinite(percentage) && + percentage > this.HIGH_USAGE_THRESHOLD_PERCENT + + if (hasAmount) { + platformData.totalBalance += amount + } + + if (isLowBalance || isHighUsage) { + platformData.lowBalanceCount += 1 + summary.lowBalanceCount += 1 + } + + platformData.totalCost += totalCost + } + + summary.platforms[platform] = platformData + summary.totalBalance += platformData.totalBalance + summary.totalCost += platformData.totalCost + } + + return summary + } + + async clearCache(accountId, platform) { + const normalizedPlatform = this.normalizePlatform(platform) + if (!normalizedPlatform) { + throw new Error('缺少 platform 参数') + } + + await this.redis.deleteAccountBalance(normalizedPlatform, accountId) + this.logger.info(`余额缓存已清除: ${normalizedPlatform}:${accountId}`) + } + + async getAccount(accountId, platform) { + if (!accountId || !platform) { + return null + } + + const serviceMap = { + claude: require('./claudeAccountService'), + 'claude-console': require('./claudeConsoleAccountService'), + gemini: require('./geminiAccountService'), + 'gemini-api': require('./geminiApiAccountService'), + openai: require('./openaiAccountService'), + 'openai-responses': require('./openaiResponsesAccountService'), + azure_openai: require('./azureOpenaiAccountService'), + bedrock: require('./bedrockAccountService'), + droid: require('./droidAccountService'), + ccr: require('./ccrAccountService') + } + + const service = serviceMap[platform] + if (!service || typeof service.getAccount !== 'function') { + return null + } + + return await service.getAccount(accountId) + } + + async getAllAccountsByPlatform(platform) { + if (!platform) { + return [] + } + + const serviceMap = { + claude: require('./claudeAccountService'), + 'claude-console': require('./claudeConsoleAccountService'), + gemini: require('./geminiAccountService'), + 'gemini-api': require('./geminiApiAccountService'), + openai: require('./openaiAccountService'), + 'openai-responses': require('./openaiResponsesAccountService'), + azure_openai: require('./azureOpenaiAccountService'), + bedrock: require('./bedrockAccountService'), + droid: require('./droidAccountService'), + ccr: require('./ccrAccountService') + } + + const service = serviceMap[platform] + if (!service) { + return [] + } + + // Bedrock 特殊:返回 { success, data } + if (platform === 'bedrock' && typeof service.getAllAccounts === 'function') { + const result = await service.getAllAccounts() + return result?.success ? result.data || [] : [] + } + + if (platform === 'openai-responses') { + return await service.getAllAccounts(true) + } + + if (typeof service.getAllAccounts !== 'function') { + return [] + } + + return await service.getAllAccounts() + } + + async _getAccountBalanceForAccount(account, platform, options = {}) { + const queryApi = this._parseBoolean(options.queryApi) || false + const useCache = options.useCache !== false + + const accountId = account?.id + if (!accountId) { + throw new Error('账户缺少 id') + } + + const localBalance = await this._getBalanceFromLocal(accountId, platform) + const localStatistics = localBalance.statistics || {} + + const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics) + + // 非强制查询:优先读缓存 + if (!queryApi) { + if (useCache) { + const cached = await this.redis.getAccountBalance(platform, accountId) + if (cached && cached.status === 'success') { + return this._buildResponse( + { + status: cached.status, + errorMessage: cached.errorMessage, + balance: quotaFromLocal.balance ?? cached.balance, + currency: quotaFromLocal.currency || cached.currency || 'USD', + quota: quotaFromLocal.quota || cached.quota || null, + statistics: localStatistics, + lastRefreshAt: cached.lastRefreshAt + }, + accountId, + platform, + 'cache', + cached.ttlSeconds + ) + } + } + + return this._buildResponse( + { + status: 'success', + errorMessage: null, + balance: quotaFromLocal.balance, + currency: quotaFromLocal.currency || 'USD', + quota: quotaFromLocal.quota, + statistics: localStatistics, + lastRefreshAt: localBalance.lastCalculated + }, + accountId, + platform, + 'local' + ) + } + + // 强制查询:调用 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 providerResult = await this._getBalanceFromProvider(provider, account) + await this.redis.setAccountBalance(platform, accountId, providerResult, this.CACHE_TTL_SECONDS) + + const source = providerResult.status === 'success' ? 'api' : 'local' + + return this._buildResponse( + { + status: providerResult.status, + errorMessage: providerResult.errorMessage, + balance: quotaFromLocal.balance ?? providerResult.balance, + currency: quotaFromLocal.currency || providerResult.currency || 'USD', + quota: quotaFromLocal.quota || providerResult.quota || null, + statistics: localStatistics, + lastRefreshAt: providerResult.lastRefreshAt + }, + accountId, + platform, + source + ) + } + + async _getBalanceFromProvider(provider, account) { + try { + const result = await provider.queryBalance(account) + return { + status: 'success', + balance: typeof result?.balance === 'number' ? result.balance : null, + currency: result?.currency || 'USD', + quota: result?.quota || null, + queryMethod: result?.queryMethod || 'api', + rawData: result?.rawData || null, + lastRefreshAt: new Date().toISOString(), + errorMessage: '' + } + } catch (error) { + return { + status: 'error', + balance: null, + currency: 'USD', + quota: null, + queryMethod: 'api', + rawData: null, + lastRefreshAt: new Date().toISOString(), + errorMessage: error.message || '查询失败' + } + } + } + + async _getBalanceFromLocal(accountId, platform) { + const cached = await this.redis.getLocalBalance(platform, accountId) + if (cached && cached.statistics) { + return cached + } + + const statistics = await this._computeLocalStatistics(accountId) + const localBalance = { + status: 'success', + balance: null, + currency: 'USD', + statistics, + queryMethod: 'local', + lastCalculated: new Date().toISOString() + } + + await this.redis.setLocalBalance(platform, accountId, localBalance, this.LOCAL_TTL_SECONDS) + return localBalance + } + + async _computeLocalStatistics(accountId) { + const safeNumber = (value) => { + const num = Number(value) + return Number.isFinite(num) ? num : 0 + } + + try { + const usageStats = await this.redis.getAccountUsageStats(accountId) + const dailyCost = safeNumber(usageStats?.daily?.cost || 0) + const monthlyCost = await this._computeMonthlyCost(accountId) + const totalCost = await this._computeTotalCost(accountId) + + return { + totalCost, + dailyCost, + monthlyCost, + totalRequests: safeNumber(usageStats?.total?.requests || 0), + dailyRequests: safeNumber(usageStats?.daily?.requests || 0), + monthlyRequests: safeNumber(usageStats?.monthly?.requests || 0) + } + } catch (error) { + this.logger.debug(`本地统计计算失败: ${accountId}`, error) + return { + totalCost: 0, + dailyCost: 0, + monthlyCost: 0, + totalRequests: 0, + dailyRequests: 0, + monthlyRequests: 0 + } + } + } + + async _computeMonthlyCost(accountId) { + const tzDate = this.redis.getDateInTimezone(new Date()) + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + + const pattern = `account_usage:model:monthly:${accountId}:*:${currentMonth}` + return await this._sumModelCostsByKeysPattern(pattern) + } + + async _computeTotalCost(accountId) { + const pattern = `account_usage:model:monthly:${accountId}:*:*` + return await this._sumModelCostsByKeysPattern(pattern) + } + + 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) { + 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 + } + + return totalCost + } catch (error) { + this.logger.debug(`汇总模型费用失败: ${pattern}`, error) + return 0 + } + } + + _buildQuotaFromLocal(account, statistics) { + if (!account || !Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) { + return { balance: null, currency: null, quota: null } + } + + const dailyQuota = Number(account.dailyQuota || 0) + const used = Number(statistics?.dailyCost || 0) + + const resetAt = this._computeNextResetAt(account.quotaResetTime || '00:00') + + // 不限制 + if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) { + return { + balance: null, + currency: 'USD', + quota: { + daily: Infinity, + used, + remaining: Infinity, + percentage: 0, + unlimited: true, + resetAt + } + } + } + + const remaining = Math.max(0, dailyQuota - used) + const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0 + + return { + balance: remaining, + currency: 'USD', + quota: { + daily: dailyQuota, + used, + remaining, + resetAt, + percentage: Math.round(percentage * 100) / 100 + } + } + } + + _computeNextResetAt(resetTime) { + const now = new Date() + const tzNow = this.redis.getDateInTimezone(now) + const offsetMs = tzNow.getTime() - now.getTime() + + const [h, m] = String(resetTime || '00:00') + .split(':') + .map((n) => parseInt(n, 10)) + + const resetHour = Number.isFinite(h) ? h : 0 + const resetMinute = Number.isFinite(m) ? m : 0 + + const year = tzNow.getUTCFullYear() + const month = tzNow.getUTCMonth() + const day = tzNow.getUTCDate() + + let resetAtMs = Date.UTC(year, month, day, resetHour, resetMinute, 0, 0) - offsetMs + if (resetAtMs <= now.getTime()) { + resetAtMs += 24 * 60 * 60 * 1000 + } + + return new Date(resetAtMs).toISOString() + } + + _buildResponse(balanceData, accountId, platform, source, ttlSeconds = null) { + const now = new Date() + + const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null + const currency = balanceData.currency || 'USD' + + let cacheExpiresAt = null + if (source === 'cache') { + const ttl = + typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : this.CACHE_TTL_SECONDS + cacheExpiresAt = new Date(Date.now() + ttl * 1000).toISOString() + } + + return { + success: true, + data: { + accountId, + platform, + balance: + typeof amount === 'number' + ? { + amount, + currency, + formattedAmount: this._formatCurrency(amount, currency) + } + : null, + quota: balanceData.quota || null, + statistics: balanceData.statistics || {}, + source, + lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(), + cacheExpiresAt, + status: balanceData.status || 'success', + error: balanceData.errorMessage || null + } + } + } + + _formatCurrency(amount, currency = 'USD') { + try { + if (typeof amount !== 'number' || !Number.isFinite(amount)) { + return 'N/A' + } + return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount) + } catch (error) { + return `$${amount.toFixed(2)}` + } + } + + _parseBoolean(value) { + if (typeof value === 'boolean') { + return value + } + if (typeof value !== 'string') { + return null + } + const normalized = value.trim().toLowerCase() + if (normalized === 'true' || normalized === '1' || normalized === 'yes') { + return true + } + if (normalized === 'false' || normalized === '0' || normalized === 'no') { + return false + } + return null + } + + async _mapWithConcurrency(items, limit, mapper) { + const concurrency = Math.max(1, Number(limit) || 1) + const list = Array.isArray(items) ? items : [] + + const results = new Array(list.length) + let nextIndex = 0 + + const workers = new Array(Math.min(concurrency, list.length)).fill(null).map(async () => { + while (nextIndex < list.length) { + const currentIndex = nextIndex + nextIndex += 1 + results[currentIndex] = await mapper(list[currentIndex], currentIndex) + } + }) + + await Promise.all(workers) + return results + } +} + +const accountBalanceService = new AccountBalanceService() +module.exports = accountBalanceService +module.exports.AccountBalanceService = AccountBalanceService diff --git a/src/services/balanceProviders/baseBalanceProvider.js b/src/services/balanceProviders/baseBalanceProvider.js new file mode 100644 index 00000000..ececd2e5 --- /dev/null +++ b/src/services/balanceProviders/baseBalanceProvider.js @@ -0,0 +1,133 @@ +const axios = require('axios') +const logger = require('../../utils/logger') +const ProxyHelper = require('../../utils/proxyHelper') + +/** + * Provider 抽象基类 + * 各平台 Provider 需继承并实现 queryBalance(account) + */ +class BaseBalanceProvider { + constructor(platform) { + this.platform = platform + this.logger = logger + } + + /** + * 查询余额(抽象方法) + * @param {object} account - 账户对象 + * @returns {Promise} + * 形如: + * { + * balance: number|null, + * currency?: string, + * quota?: { daily, used, remaining, resetAt, percentage, unlimited? }, + * queryMethod?: 'api'|'field'|'local', + * rawData?: any + * } + */ + async queryBalance(_account) { + throw new Error('queryBalance 方法必须由子类实现') + } + + /** + * 通用 HTTP 请求方法(支持代理) + * @param {string} url + * @param {object} options + * @param {object} account + */ + async makeRequest(url, options = {}, account = {}) { + const config = { + url, + method: options.method || 'GET', + headers: options.headers || {}, + timeout: options.timeout || 15000, + data: options.data, + params: options.params, + responseType: options.responseType + } + + const proxyConfig = account.proxyConfig || account.proxy + if (proxyConfig) { + const agent = ProxyHelper.createProxyAgent(proxyConfig) + if (agent) { + config.httpAgent = agent + config.httpsAgent = agent + config.proxy = false + } + } + + try { + const response = await axios(config) + return { + success: true, + data: response.data, + status: response.status, + headers: response.headers + } + } catch (error) { + const status = error.response?.status + const message = error.response?.data?.message || error.message || '请求失败' + this.logger.debug(`余额 Provider HTTP 请求失败: ${url} (${this.platform})`, { + status, + message + }) + return { success: false, status, error: message } + } + } + + /** + * 从账户字段读取 dailyQuota / dailyUsage(通用降级方案) + * 注意:部分平台 dailyUsage 字段可能不是实时值,最终以 AccountBalanceService 的本地统计为准 + */ + readQuotaFromFields(account) { + const dailyQuota = Number(account?.dailyQuota || 0) + const dailyUsage = Number(account?.dailyUsage || 0) + + // 无限制 + if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) { + return { + balance: null, + currency: 'USD', + quota: { + daily: Infinity, + used: Number.isFinite(dailyUsage) ? dailyUsage : 0, + remaining: Infinity, + percentage: 0, + unlimited: true + }, + queryMethod: 'field' + } + } + + const used = Number.isFinite(dailyUsage) ? dailyUsage : 0 + const remaining = Math.max(0, dailyQuota - used) + const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0 + + return { + balance: remaining, + currency: 'USD', + quota: { + daily: dailyQuota, + used, + remaining, + percentage: Math.round(percentage * 100) / 100 + }, + queryMethod: 'field' + } + } + + parseCurrency(data) { + return data?.currency || data?.Currency || 'USD' + } + + async safeExecute(fn, fallbackValue = null) { + try { + return await fn() + } catch (error) { + this.logger.error(`余额 Provider 执行失败: ${this.platform}`, error) + return fallbackValue + } + } +} + +module.exports = BaseBalanceProvider diff --git a/src/services/balanceProviders/claudeBalanceProvider.js b/src/services/balanceProviders/claudeBalanceProvider.js new file mode 100644 index 00000000..89783028 --- /dev/null +++ b/src/services/balanceProviders/claudeBalanceProvider.js @@ -0,0 +1,30 @@ +const BaseBalanceProvider = require('./baseBalanceProvider') +const claudeAccountService = require('../claudeAccountService') + +class ClaudeBalanceProvider extends BaseBalanceProvider { + constructor() { + super('claude') + } + + /** + * Claude(OAuth):优先尝试获取 OAuth usage(用于配额/使用信息),不强行提供余额金额 + */ + async queryBalance(account) { + this.logger.debug(`查询 Claude 余额(OAuth usage): ${account?.id}`) + + // 仅 OAuth 账户可用;失败时降级 + const usageData = await claudeAccountService.fetchOAuthUsage(account.id).catch(() => null) + if (!usageData) { + return { balance: null, currency: 'USD', queryMethod: 'local' } + } + + return { + balance: null, + currency: 'USD', + queryMethod: 'api', + rawData: usageData + } + } +} + +module.exports = ClaudeBalanceProvider diff --git a/src/services/balanceProviders/claudeConsoleBalanceProvider.js b/src/services/balanceProviders/claudeConsoleBalanceProvider.js new file mode 100644 index 00000000..f5441047 --- /dev/null +++ b/src/services/balanceProviders/claudeConsoleBalanceProvider.js @@ -0,0 +1,14 @@ +const BaseBalanceProvider = require('./baseBalanceProvider') + +class ClaudeConsoleBalanceProvider extends BaseBalanceProvider { + constructor() { + super('claude-console') + } + + async queryBalance(account) { + this.logger.debug(`查询 Claude Console 余额(字段): ${account?.id}`) + return this.readQuotaFromFields(account) + } +} + +module.exports = ClaudeConsoleBalanceProvider diff --git a/src/services/balanceProviders/genericBalanceProvider.js b/src/services/balanceProviders/genericBalanceProvider.js new file mode 100644 index 00000000..6b3efe2b --- /dev/null +++ b/src/services/balanceProviders/genericBalanceProvider.js @@ -0,0 +1,23 @@ +const BaseBalanceProvider = require('./baseBalanceProvider') + +class GenericBalanceProvider extends BaseBalanceProvider { + constructor(platform) { + super(platform) + } + + async queryBalance(account) { + this.logger.debug(`${this.platform} 暂无专用余额 API,实现降级策略`) + + if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) { + return this.readQuotaFromFields(account) + } + + return { + balance: null, + currency: 'USD', + queryMethod: 'local' + } + } +} + +module.exports = GenericBalanceProvider diff --git a/src/services/balanceProviders/index.js b/src/services/balanceProviders/index.js new file mode 100644 index 00000000..d55fda5b --- /dev/null +++ b/src/services/balanceProviders/index.js @@ -0,0 +1,24 @@ +const ClaudeBalanceProvider = require('./claudeBalanceProvider') +const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider') +const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider') +const GenericBalanceProvider = require('./genericBalanceProvider') + +function registerAllProviders(balanceService) { + // Claude + balanceService.registerProvider('claude', new ClaudeBalanceProvider()) + balanceService.registerProvider('claude-console', new ClaudeConsoleBalanceProvider()) + + // OpenAI / Codex + balanceService.registerProvider('openai-responses', new OpenAIResponsesBalanceProvider()) + balanceService.registerProvider('openai', new GenericBalanceProvider('openai')) + balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai')) + + // 其他平台(降级) + balanceService.registerProvider('gemini', new GenericBalanceProvider('gemini')) + balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api')) + balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock')) + balanceService.registerProvider('droid', new GenericBalanceProvider('droid')) + balanceService.registerProvider('ccr', new GenericBalanceProvider('ccr')) +} + +module.exports = { registerAllProviders } diff --git a/src/services/balanceProviders/openaiResponsesBalanceProvider.js b/src/services/balanceProviders/openaiResponsesBalanceProvider.js new file mode 100644 index 00000000..9ff8433e --- /dev/null +++ b/src/services/balanceProviders/openaiResponsesBalanceProvider.js @@ -0,0 +1,54 @@ +const BaseBalanceProvider = require('./baseBalanceProvider') + +class OpenAIResponsesBalanceProvider extends BaseBalanceProvider { + constructor() { + super('openai-responses') + } + + /** + * OpenAI-Responses: + * - 优先使用 dailyQuota 字段(如果配置了额度) + * - 可选:尝试调用兼容 API(不同服务商实现不一,失败自动降级) + */ + async queryBalance(account) { + this.logger.debug(`查询 OpenAI Responses 余额: ${account?.id}`) + + // 配置了额度时直接返回(字段法) + if (account?.dailyQuota && Number(account.dailyQuota) > 0) { + return this.readQuotaFromFields(account) + } + + // 尝试调用 usage 接口(兼容性不保证) + if (account?.apiKey && account?.baseApi) { + const baseApi = String(account.baseApi).replace(/\/$/, '') + const response = await this.makeRequest( + `${baseApi}/v1/usage`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${account.apiKey}`, + 'Content-Type': 'application/json' + } + }, + account + ) + + if (response.success) { + return { + balance: null, + currency: this.parseCurrency(response.data), + queryMethod: 'api', + rawData: response.data + } + } + } + + return { + balance: null, + currency: 'USD', + queryMethod: 'local' + } + } +} + +module.exports = OpenAIResponsesBalanceProvider diff --git a/tests/accountBalanceService.test.js b/tests/accountBalanceService.test.js new file mode 100644 index 00000000..9510b9b3 --- /dev/null +++ b/tests/accountBalanceService.test.js @@ -0,0 +1,142 @@ +// Mock logger,避免测试输出污染控制台 +jest.mock('../src/utils/logger', () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() +})) + +const accountBalanceServiceModule = require('../src/services/accountBalanceService') + +const { AccountBalanceService } = accountBalanceServiceModule + +describe('AccountBalanceService', () => { + const mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + } + + const buildMockRedis = () => ({ + getLocalBalance: jest.fn().mockResolvedValue(null), + setLocalBalance: jest.fn().mockResolvedValue(undefined), + getAccountBalance: jest.fn().mockResolvedValue(null), + setAccountBalance: jest.fn().mockResolvedValue(undefined), + deleteAccountBalance: jest.fn().mockResolvedValue(undefined), + getAccountUsageStats: jest.fn().mockResolvedValue({ + total: { requests: 10 }, + daily: { requests: 2, cost: 20 }, + monthly: { requests: 5 } + }), + getDateInTimezone: (date) => new Date(date.getTime() + 8 * 3600 * 1000) + }) + + it('should normalize platform aliases', () => { + const service = new AccountBalanceService({ redis: buildMockRedis(), logger: mockLogger }) + expect(service.normalizePlatform('claude-official')).toBe('claude') + expect(service.normalizePlatform('azure-openai')).toBe('azure_openai') + expect(service.normalizePlatform('gemini-api')).toBe('gemini-api') + }) + + it('should build local quota/balance from dailyQuota and local dailyCost', async () => { + const mockRedis = buildMockRedis() + const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) + + service._computeMonthlyCost = jest.fn().mockResolvedValue(30) + service._computeTotalCost = jest.fn().mockResolvedValue(123.45) + + const account = { id: 'acct-1', name: 'A', dailyQuota: '100', quotaResetTime: '00:00' } + const result = await service._getAccountBalanceForAccount(account, 'claude-console', { + queryApi: false, + useCache: true + }) + + expect(result.success).toBe(true) + expect(result.data.source).toBe('local') + expect(result.data.balance.amount).toBeCloseTo(80, 6) + expect(result.data.quota.percentage).toBeCloseTo(20, 6) + expect(result.data.statistics.totalCost).toBeCloseTo(123.45, 6) + expect(mockRedis.setLocalBalance).toHaveBeenCalled() + }) + + it('should use cached balance when account has no dailyQuota', async () => { + const mockRedis = buildMockRedis() + mockRedis.getAccountBalance.mockResolvedValue({ + status: 'success', + balance: 12.34, + currency: 'USD', + quota: null, + errorMessage: '', + lastRefreshAt: '2025-01-01T00:00:00Z', + ttlSeconds: 120 + }) + + const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) + service._computeMonthlyCost = jest.fn().mockResolvedValue(0) + service._computeTotalCost = jest.fn().mockResolvedValue(0) + + const account = { id: 'acct-2', name: 'B' } + const result = await service._getAccountBalanceForAccount(account, 'openai', { + queryApi: false, + useCache: true + }) + + expect(result.data.source).toBe('cache') + expect(result.data.balance.amount).toBeCloseTo(12.34, 6) + expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z') + }) + + it('should cache provider errors and fallback to local when queryApi=true', async () => { + const mockRedis = buildMockRedis() + const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) + + service._computeMonthlyCost = jest.fn().mockResolvedValue(0) + service._computeTotalCost = jest.fn().mockResolvedValue(0) + + service.registerProvider('openai', { + queryBalance: () => { + throw new Error('boom') + } + }) + + const account = { id: 'acct-3', name: 'C' } + const result = await service._getAccountBalanceForAccount(account, 'openai', { + queryApi: true, + useCache: false + }) + + expect(mockRedis.setAccountBalance).toHaveBeenCalled() + expect(result.data.source).toBe('local') + expect(result.data.status).toBe('error') + expect(result.data.error).toBe('boom') + }) + + it('should count low balance once per account in summary', async () => { + const mockRedis = buildMockRedis() + const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) + + service.getSupportedPlatforms = () => ['claude-console'] + service.getAllAccountsByPlatform = async () => [{ id: 'acct-4', name: 'D' }] + service._getAccountBalanceForAccount = async () => ({ + success: true, + data: { + accountId: 'acct-4', + platform: 'claude-console', + balance: { amount: 5, currency: 'USD', formattedAmount: '$5.00' }, + quota: { percentage: 95 }, + statistics: { totalCost: 1 }, + source: 'local', + lastRefreshAt: '2025-01-01T00:00:00Z', + cacheExpiresAt: null, + status: 'success', + error: null + } + }) + + const summary = await service.getBalanceSummary() + expect(summary.lowBalanceCount).toBe(1) + expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1) + }) +}) + diff --git a/web/admin-spa/src/components/accounts/BalanceDisplay.vue b/web/admin-spa/src/components/accounts/BalanceDisplay.vue new file mode 100644 index 00000000..18d301f3 --- /dev/null +++ b/web/admin-spa/src/components/accounts/BalanceDisplay.vue @@ -0,0 +1,261 @@ + + + diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 65e0bcfe..1d1179e2 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -141,6 +141,32 @@ + +
+ + + +
+ + + + +
+
+

低余额账户

+ + {{ 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 03/19] =?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 04/19] =?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 05/19] =?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(