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 }