mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge pull request #825 from atoz03/feat/account-quota [skip ci]
Feat:account quota
This commit is contained in:
@@ -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: {
|
||||
|
||||
10
src/app.js
10
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()
|
||||
|
||||
@@ -1521,6 +1521,123 @@ 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 setBalanceScriptConfig(platform, accountId, scriptConfig) {
|
||||
const key = `account_balance_script:${platform}:${accountId}`
|
||||
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
|
||||
}
|
||||
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([
|
||||
|
||||
214
src/routes/admin/accountBalance.js
Normal file
214
src/routes/admin/accountBalance.js
Normal file
@@ -0,0 +1,214 @@
|
||||
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 { isBalanceScriptEnabled } = require('../../utils/featureFlags')
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
// 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 })
|
||||
}
|
||||
|
||||
if (!isBalanceScriptEnabled()) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)'
|
||||
})
|
||||
}
|
||||
|
||||
const payload = req.body || {}
|
||||
const { scriptBody } = payload
|
||||
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
|
||||
41
src/routes/admin/balanceScripts.js
Normal file
41
src/routes/admin/balanceScripts.js
Normal file
@@ -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
|
||||
@@ -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')
|
||||
@@ -37,6 +38,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)
|
||||
|
||||
748
src/services/accountBalanceService.js
Normal file
748
src/services/accountBalanceService.js
Normal file
@@ -0,0 +1,748 @@
|
||||
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 = {}) {
|
||||
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')
|
||||
}
|
||||
|
||||
// 余额脚本配置状态(用于前端控制“刷新余额”按钮)
|
||||
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 || {}
|
||||
|
||||
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,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
null,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
|
||||
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计
|
||||
let providerResult
|
||||
|
||||
if (scriptEnabled && scriptConfigured) {
|
||||
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',
|
||||
null,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
providerResult = await this._getBalanceFromProvider(provider, account)
|
||||
}
|
||||
|
||||
const isRemoteSuccess =
|
||||
providerResult.status === 'success' && ['api', 'script'].includes(providerResult.queryMethod)
|
||||
|
||||
// 仅缓存“真实远程查询成功”的结果,避免把字段/本地降级结果当作 API 结果缓存 1h
|
||||
if (isRemoteSuccess) {
|
||||
await this.redis.setAccountBalance(
|
||||
platform,
|
||||
accountId,
|
||||
providerResult,
|
||||
this.CACHE_TTL_SECONDS
|
||||
)
|
||||
}
|
||||
|
||||
const source = isRemoteSuccess ? '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,
|
||||
null,
|
||||
scriptMeta
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
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()
|
||||
let totalCost = 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 pipeline = client.pipeline()
|
||||
keys.forEach((key) => pipeline.hgetall(key))
|
||||
const results = await pipeline.exec()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if (iterations >= maxIterations) {
|
||||
this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`)
|
||||
break
|
||||
}
|
||||
} while (cursor !== '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, extraData = {}) {
|
||||
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,
|
||||
...(extraData && typeof extraData === 'object' ? extraData : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_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
|
||||
133
src/services/balanceProviders/baseBalanceProvider.js
Normal file
133
src/services/balanceProviders/baseBalanceProvider.js
Normal file
@@ -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<object>}
|
||||
* 形如:
|
||||
* {
|
||||
* 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
|
||||
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
30
src/services/balanceProviders/claudeBalanceProvider.js
Normal file
@@ -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
|
||||
@@ -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
|
||||
23
src/services/balanceProviders/genericBalanceProvider.js
Normal file
23
src/services/balanceProviders/genericBalanceProvider.js
Normal file
@@ -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
|
||||
24
src/services/balanceProviders/index.js
Normal file
24
src/services/balanceProviders/index.js
Normal file
@@ -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 }
|
||||
@@ -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
|
||||
161
src/services/balanceScriptService.js
Normal file
161
src/services/balanceScriptService.js
Normal file
@@ -0,0 +1,161 @@
|
||||
const vm = require('vm')
|
||||
const axios = require('axios')
|
||||
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||
|
||||
/**
|
||||
* 可配置脚本余额查询执行器
|
||||
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
|
||||
* - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}}
|
||||
*/
|
||||
class BalanceScriptService {
|
||||
/**
|
||||
* 执行脚本:返回标准余额结构 + 原始响应
|
||||
* @param {object} options
|
||||
* - scriptBody: string
|
||||
* - variables: Record<string,string>
|
||||
* - 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('脚本内容为空')
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
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(),
|
||||
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
|
||||
try {
|
||||
httpResponse = await axios(axiosConfig)
|
||||
} catch (error) {
|
||||
const { response } = error || {}
|
||||
const { status, data } = response || {}
|
||||
throw new Error(
|
||||
`请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
const responseData = httpResponse?.data
|
||||
|
||||
let extracted = {}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new BalanceScriptService()
|
||||
44
src/utils/featureFlags.js
Normal file
44
src/utils/featureFlags.js
Normal file
@@ -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
|
||||
}
|
||||
218
tests/accountBalanceService.test.js
Normal file
218
tests/accountBalanceService.test.js
Normal file
@@ -0,0 +1,218 @@
|
||||
// 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 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(),
|
||||
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),
|
||||
getBalanceScriptConfig: jest.fn().mockResolvedValue(null),
|
||||
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 not 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).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 })
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:append-to-body="true"
|
||||
class="balance-script-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:destroy-on-close="true"
|
||||
:model-value="show"
|
||||
:title="`配置余额脚本 - ${account?.name || ''}`"
|
||||
top="5vh"
|
||||
width="720px"
|
||||
@close="emitClose"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
|
||||
<input v-model="form.apiKey" class="input-text" placeholder="access token / key" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>请求地址(baseUrl)</label
|
||||
>
|
||||
<input v-model="form.baseUrl" class="input-text" placeholder="https://api.example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">Token(可选)</label>
|
||||
<input v-model="form.token" class="input-text" placeholder="Bearer token" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>额外参数 (extra / userId)</label
|
||||
>
|
||||
<input v-model="form.extra" class="input-text" placeholder="用户ID等" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">超时时间(秒)</label>
|
||||
<input v-model.number="form.timeoutSeconds" class="input-text" min="1" type="number" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>自动查询间隔(分钟)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.autoIntervalMinutes"
|
||||
class="input-text"
|
||||
min="0"
|
||||
type="number"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">0 表示仅手动刷新</p>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 md:col-span-2">
|
||||
可用变量:{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}apiKey{{ '}'
|
||||
}}{{ '}' }}、{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}accountId{{ '}'
|
||||
}}{{ '}' }}、{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}extra{{ '}'
|
||||
}}{{ '}' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">提取器代码</div>
|
||||
<button
|
||||
class="rounded bg-gray-200 px-2 py-1 text-xs dark:bg-gray-700"
|
||||
@click="applyPreset"
|
||||
>
|
||||
使用示例
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="form.scriptBody"
|
||||
class="min-h-[260px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
extractor 可返回:isValid、invalidMessage、remaining、unit、planName、total、used、extra
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult" class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-gray-800/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold">测试结果</span>
|
||||
<span
|
||||
:class="[
|
||||
'rounded px-2 py-0.5 text-xs',
|
||||
testResult.mapped?.status === 'success'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
|
||||
]"
|
||||
>
|
||||
{{ testResult.mapped?.status || 'unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
|
||||
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
|
||||
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
|
||||
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
|
||||
错误: {{ testResult.mapped.errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<summary class="cursor-pointer">查看 extractor 输出</summary>
|
||||
<pre class="mt-1 whitespace-pre-wrap break-all">{{
|
||||
formatJson(testResult.extracted)
|
||||
}}</pre>
|
||||
</details>
|
||||
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<summary class="cursor-pointer">查看原始响应</summary>
|
||||
<pre class="mt-1 whitespace-pre-wrap break-all">{{
|
||||
formatJson(testResult.response)
|
||||
}}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-button :loading="testing" @click="testScript">测试脚本</el-button>
|
||||
<el-button :loading="saving" type="primary" @click="saveConfig">保存配置</el-button>
|
||||
<el-button @click="emitClose">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
account: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null)
|
||||
|
||||
const presetScript = `({
|
||||
request: {
|
||||
url: "{{baseUrl}}/api/user/self",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {{apiKey}}",
|
||||
"New-Api-User": "{{extra}}"
|
||||
}
|
||||
},
|
||||
extractor: function (response) {
|
||||
if (response && response.success && response.data) {
|
||||
const quota = response.data.quota || 0;
|
||||
const used = response.data.used_quota || 0;
|
||||
return {
|
||||
planName: response.data.group || "默认套餐",
|
||||
remaining: quota / 500000,
|
||||
used: used / 500000,
|
||||
total: (quota + used) / 500000,
|
||||
unit: "USD"
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: false,
|
||||
invalidMessage: (response && response.message) || "查询失败"
|
||||
};
|
||||
}
|
||||
})`
|
||||
|
||||
const form = reactive({
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
token: '',
|
||||
extra: '',
|
||||
timeoutSeconds: 10,
|
||||
autoIntervalMinutes: 0,
|
||||
scriptBody: ''
|
||||
})
|
||||
|
||||
const buildDefaultForm = () => ({
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
token: '',
|
||||
extra: '',
|
||||
timeoutSeconds: 10,
|
||||
autoIntervalMinutes: 0,
|
||||
// 默认给出示例脚本,字段保持清空,避免“上一个账户的配置污染当前账户”
|
||||
scriptBody: presetScript
|
||||
})
|
||||
|
||||
const emitClose = () => emit('close')
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, buildDefaultForm())
|
||||
testResult.value = null
|
||||
saving.value = false
|
||||
testing.value = false
|
||||
}
|
||||
|
||||
const loadConfig = async () => {
|
||||
if (!props.account?.id || !props.account?.platform) return
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`
|
||||
)
|
||||
if (res?.success && res.data) {
|
||||
Object.assign(form, res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载脚本配置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!props.account?.id || !props.account?.platform) return
|
||||
saving.value = true
|
||||
try {
|
||||
await apiClient.put(
|
||||
`/admin/accounts/${props.account.id}/balance/script?platform=${props.account.platform}`,
|
||||
{ ...form }
|
||||
)
|
||||
showToast('已保存', 'success')
|
||||
emit('saved')
|
||||
} catch (error) {
|
||||
showToast(error.message || '保存失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testScript = async () => {
|
||||
if (!props.account?.id || !props.account?.platform) return
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
`/admin/accounts/${props.account.id}/balance/script/test?platform=${props.account.platform}`,
|
||||
{ ...form }
|
||||
)
|
||||
if (res?.success) {
|
||||
testResult.value = res.data
|
||||
showToast('测试完成', 'success')
|
||||
} else {
|
||||
showToast(res?.error || '测试失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || '测试失败', 'error')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyPreset = () => {
|
||||
form.scriptBody = presetScript
|
||||
}
|
||||
|
||||
const displayAmount = (val) => {
|
||||
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
|
||||
return Number(val).toFixed(2)
|
||||
}
|
||||
|
||||
const formatJson = (data) => {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2)
|
||||
} catch (error) {
|
||||
return String(data)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (val) {
|
||||
resetForm()
|
||||
loadConfig()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.balance-script-dialog) {
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.balance-script-dialog .el-dialog__body) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:deep(.balance-script-dialog .el-dialog__footer) {
|
||||
border-top: 1px solid rgba(229, 231, 235, 0.7);
|
||||
}
|
||||
|
||||
.input-text {
|
||||
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
|
||||
}
|
||||
</style>
|
||||
281
web/admin-spa/src/components/accounts/BalanceDisplay.vue
Normal file
281
web/admin-spa/src/components/accounts/BalanceDisplay.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="min-w-[200px] space-y-1">
|
||||
<div v-if="loading" class="flex items-center gap-2">
|
||||
<i class="fas fa-spinner fa-spin text-gray-400 dark:text-gray-500"></i>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="requestError" class="flex items-center gap-2">
|
||||
<i class="fas fa-exclamation-circle text-red-500"></i>
|
||||
<span class="text-xs text-red-600 dark:text-red-400">{{ requestError }}</span>
|
||||
<button
|
||||
class="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
|
||||
:disabled="refreshing"
|
||||
@click="reload"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="balanceData" class="space-y-1">
|
||||
<div v-if="balanceData.status === 'error' && balanceData.error" class="text-xs text-red-500">
|
||||
{{ balanceData.error }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
class="fas"
|
||||
:class="
|
||||
balanceData.balance
|
||||
? 'fa-wallet text-green-600 dark:text-green-400'
|
||||
: 'fa-chart-line text-gray-500 dark:text-gray-400'
|
||||
"
|
||||
></i>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ primaryText }}
|
||||
</span>
|
||||
<span class="rounded px-1.5 py-0.5 text-xs" :class="sourceClass">
|
||||
{{ sourceLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!hideRefresh"
|
||||
class="text-xs text-gray-500 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:text-blue-400"
|
||||
:disabled="refreshing || !canRefresh"
|
||||
:title="refreshTitle"
|
||||
@click="refresh"
|
||||
>
|
||||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 配额(如适用) -->
|
||||
<div v-if="quotaInfo" class="space-y-1">
|
||||
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>已用: {{ formatNumber(quotaInfo.used) }}</span>
|
||||
<span>剩余: {{ formatNumber(quotaInfo.remaining) }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="quotaBarClass"
|
||||
:style="{ width: `${Math.min(100, quotaInfo.percentage)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{{ quotaInfo.percentage.toFixed(1) }}% 已使用
|
||||
</span>
|
||||
<span v-if="quotaInfo.resetAt" class="text-gray-400 dark:text-gray-500">
|
||||
重置: {{ formatResetTime(quotaInfo.resetAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="balanceData.quota?.unlimited" class="flex items-center gap-2">
|
||||
<i class="fas fa-infinity text-blue-500 dark:text-blue-400"></i>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">无限制</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="balanceData.cacheExpiresAt && balanceData.source === 'cache'"
|
||||
class="text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
缓存至: {{ formatCacheExpiry(balanceData.cacheExpiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-xs text-gray-400 dark:text-gray-500">暂无余额数据</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const props = defineProps({
|
||||
accountId: { type: String, required: true },
|
||||
platform: { type: String, required: true },
|
||||
initialBalance: { type: Object, default: null },
|
||||
hideRefresh: { type: Boolean, default: false },
|
||||
autoLoad: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refreshed', 'error'])
|
||||
|
||||
const balanceData = ref(props.initialBalance)
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const requestError = ref(null)
|
||||
|
||||
const sourceClass = computed(() => {
|
||||
const source = balanceData.value?.source
|
||||
return {
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300': source === 'api',
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300': source === 'cache',
|
||||
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300': source === 'local'
|
||||
}
|
||||
})
|
||||
|
||||
const sourceLabel = computed(() => {
|
||||
const source = balanceData.value?.source
|
||||
return { api: 'API', cache: '缓存', local: '本地' }[source] || '未知'
|
||||
})
|
||||
|
||||
const quotaInfo = computed(() => {
|
||||
const quota = balanceData.value?.quota
|
||||
if (!quota || quota.unlimited) return null
|
||||
if (typeof quota.percentage !== 'number' || !Number.isFinite(quota.percentage)) return null
|
||||
return {
|
||||
used: quota.used ?? 0,
|
||||
remaining: quota.remaining ?? 0,
|
||||
percentage: quota.percentage,
|
||||
resetAt: quota.resetAt || null
|
||||
}
|
||||
})
|
||||
|
||||
const quotaBarClass = computed(() => {
|
||||
const percentage = quotaInfo.value?.percentage || 0
|
||||
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'
|
||||
if (percentage >= 70) return 'bg-yellow-500 dark:bg-yellow-600'
|
||||
return 'bg-green-500 dark:bg-green-600'
|
||||
})
|
||||
|
||||
const canRefresh = computed(() => {
|
||||
// 仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
|
||||
const data = balanceData.value
|
||||
if (!data) return false
|
||||
if (data.scriptEnabled === false) return false
|
||||
return !!data.scriptConfigured
|
||||
})
|
||||
|
||||
const refreshTitle = computed(() => {
|
||||
if (refreshing.value) return '刷新中...'
|
||||
if (!canRefresh.value) {
|
||||
if (balanceData.value?.scriptEnabled === false) {
|
||||
return '余额脚本功能已禁用'
|
||||
}
|
||||
return '请先配置余额脚本'
|
||||
}
|
||||
return '刷新余额(调用脚本配置的余额 API)'
|
||||
})
|
||||
|
||||
const primaryText = computed(() => {
|
||||
if (balanceData.value?.balance?.formattedAmount) {
|
||||
return balanceData.value.balance.formattedAmount
|
||||
}
|
||||
const dailyCost = Number(balanceData.value?.statistics?.dailyCost || 0)
|
||||
return `今日成本 ${formatCurrency(dailyCost)}`
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
if (!props.autoLoad) return
|
||||
if (!props.accountId || !props.platform) return
|
||||
|
||||
loading.value = true
|
||||
requestError.value = null
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/accounts/${props.accountId}/balance`, {
|
||||
params: { platform: props.platform, queryApi: false }
|
||||
})
|
||||
if (response?.success) {
|
||||
balanceData.value = response.data
|
||||
} else {
|
||||
requestError.value = response?.error || '加载失败'
|
||||
}
|
||||
} catch (error) {
|
||||
requestError.value = error.message || '网络错误'
|
||||
emit('error', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
if (!props.accountId || !props.platform) return
|
||||
if (refreshing.value) return
|
||||
if (!canRefresh.value) return
|
||||
|
||||
refreshing.value = true
|
||||
requestError.value = null
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/admin/accounts/${props.accountId}/balance/refresh`, {
|
||||
platform: props.platform
|
||||
})
|
||||
if (response?.success) {
|
||||
balanceData.value = response.data
|
||||
emit('refreshed', response.data)
|
||||
} else {
|
||||
requestError.value = response?.error || '刷新失败'
|
||||
}
|
||||
} catch (error) {
|
||||
requestError.value = error.message || '网络错误'
|
||||
emit('error', error)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
await load()
|
||||
}
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num === Infinity) return '∞'
|
||||
const value = Number(num)
|
||||
if (!Number.isFinite(value)) return 'N/A'
|
||||
return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const formatCurrency = (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 formatResetTime = (isoString) => {
|
||||
const date = new Date(isoString)
|
||||
const now = new Date()
|
||||
const diff = date.getTime() - now.getTime()
|
||||
if (!Number.isFinite(diff)) return '未知'
|
||||
if (diff < 0) return '已过期'
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainMinutes = minutes % 60
|
||||
if (hours >= 24) {
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}天后`
|
||||
}
|
||||
return `${hours}小时${remainMinutes}分钟`
|
||||
}
|
||||
|
||||
const formatCacheExpiry = (isoString) => {
|
||||
const date = new Date(isoString)
|
||||
if (Number.isNaN(date.getTime())) return '未知'
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initialBalance,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
balanceData.value = newVal
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.initialBalance) {
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ refresh, reload })
|
||||
</script>
|
||||
@@ -141,6 +141,28 @@
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 刷新余额按钮 -->
|
||||
<div class="relative">
|
||||
<el-tooltip :content="refreshBalanceTooltip" effect="dark" placement="bottom">
|
||||
<button
|
||||
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
|
||||
:disabled="accountsLoading || refreshingBalances || !canRefreshVisibleBalances"
|
||||
@click="refreshVisibleBalances"
|
||||
>
|
||||
<div
|
||||
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||
></div>
|
||||
<i
|
||||
:class="[
|
||||
'fas relative text-blue-500',
|
||||
refreshingBalances ? 'fa-spinner fa-spin' : 'fa-wallet'
|
||||
]"
|
||||
/>
|
||||
<span class="relative">刷新余额</span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 选择/取消选择按钮 -->
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
@@ -263,6 +285,11 @@
|
||||
>
|
||||
今日使用
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[220px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
余额/配额
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[210px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
@@ -765,6 +792,23 @@
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">暂无数据</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<BalanceDisplay
|
||||
:account-id="account.id"
|
||||
:initial-balance="account.balanceInfo"
|
||||
:platform="account.platform"
|
||||
@error="(error) => handleBalanceError(account.id, error)"
|
||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||
/>
|
||||
<div class="mt-1 text-xs">
|
||||
<button
|
||||
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||
@click="openBalanceScriptModal(account)"
|
||||
>
|
||||
配置余额脚本
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<div v-if="account.platform === 'claude'" class="space-y-2">
|
||||
<!-- OAuth 账户:显示三窗口 OAuth usage -->
|
||||
@@ -1425,6 +1469,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 余额/配额 -->
|
||||
<div class="mb-3">
|
||||
<p class="mb-1 text-xs text-gray-500 dark:text-gray-400">余额/配额</p>
|
||||
<BalanceDisplay
|
||||
:account-id="account.id"
|
||||
:initial-balance="account.balanceInfo"
|
||||
:platform="account.platform"
|
||||
@error="(error) => handleBalanceError(account.id, error)"
|
||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||
/>
|
||||
<div class="mt-1 text-xs">
|
||||
<button
|
||||
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||
@click="openBalanceScriptModal(account)"
|
||||
>
|
||||
配置余额脚本
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态信息 -->
|
||||
<div class="mb-3 space-y-2">
|
||||
<!-- 会话窗口 -->
|
||||
@@ -1906,6 +1970,13 @@
|
||||
@saved="handleScheduledTestSaved"
|
||||
/>
|
||||
|
||||
<AccountBalanceScriptModal
|
||||
:account="selectedAccountForScript"
|
||||
:show="showBalanceScriptModal"
|
||||
@close="closeBalanceScriptModal"
|
||||
@saved="handleBalanceScriptSaved"
|
||||
/>
|
||||
|
||||
<!-- 账户统计弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showAccountStatsModal"
|
||||
@@ -2062,6 +2133,8 @@ import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTes
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||
import BalanceDisplay from '@/components/accounts/BalanceDisplay.vue'
|
||||
import AccountBalanceScriptModal from '@/components/accounts/AccountBalanceScriptModal.vue'
|
||||
|
||||
// 使用确认弹窗
|
||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||
@@ -2069,6 +2142,7 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
|
||||
// 数据状态
|
||||
const accounts = ref([])
|
||||
const accountsLoading = ref(false)
|
||||
const refreshingBalances = ref(false)
|
||||
const accountsSortBy = ref('name')
|
||||
const accountsSortOrder = ref('asc')
|
||||
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
|
||||
@@ -2498,6 +2572,42 @@ const 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 = async () => {
|
||||
showToast('余额脚本已保存', 'success')
|
||||
const account = selectedAccountForScript.value
|
||||
closeBalanceScriptModal()
|
||||
|
||||
if (!account?.id || !account?.platform) {
|
||||
return
|
||||
}
|
||||
|
||||
// 重新拉取一次余额信息,用于刷新 scriptConfigured 状态(启用“刷新余额”按钮)
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/accounts/${account.id}/balance`, {
|
||||
params: { platform: account.platform, queryApi: false }
|
||||
})
|
||||
if (res?.success && res.data) {
|
||||
handleBalanceRefreshed(account.id, res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Failed to reload balance after saving script:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算排序后的账户列表
|
||||
const sortedAccounts = computed(() => {
|
||||
let sourceAccounts = accounts.value
|
||||
@@ -2768,6 +2878,104 @@ const paginatedAccounts = computed(() => {
|
||||
return sortedAccounts.value.slice(start, end)
|
||||
})
|
||||
|
||||
const canRefreshVisibleBalances = computed(() => {
|
||||
const targets = paginatedAccounts.value
|
||||
if (!Array.isArray(targets) || targets.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return targets.some((account) => {
|
||||
const info = account?.balanceInfo
|
||||
return info?.scriptEnabled !== false && !!info?.scriptConfigured
|
||||
})
|
||||
})
|
||||
|
||||
const refreshBalanceTooltip = computed(() => {
|
||||
if (accountsLoading.value) return '正在加载账户...'
|
||||
if (refreshingBalances.value) return '刷新中...'
|
||||
if (!canRefreshVisibleBalances.value) return '当前页未配置余额脚本,无法刷新'
|
||||
return '刷新当前页余额(仅对已配置余额脚本的账户生效)'
|
||||
})
|
||||
|
||||
// 余额刷新成功回调
|
||||
const handleBalanceRefreshed = (accountId, balanceInfo) => {
|
||||
accounts.value = accounts.value.map((account) => {
|
||||
if (account.id !== accountId) return account
|
||||
return { ...account, balanceInfo }
|
||||
})
|
||||
}
|
||||
|
||||
// 余额请求错误回调(仅提示,不中断页面)
|
||||
const handleBalanceError = (_accountId, error) => {
|
||||
const message = error?.message || '余额查询失败'
|
||||
showToast(message, 'error')
|
||||
}
|
||||
|
||||
// 批量刷新当前页余额(触发查询)
|
||||
const refreshVisibleBalances = async () => {
|
||||
if (refreshingBalances.value) return
|
||||
|
||||
const targets = paginatedAccounts.value
|
||||
if (!targets || targets.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const eligibleTargets = targets.filter((account) => {
|
||||
const info = account?.balanceInfo
|
||||
return info?.scriptEnabled !== false && !!info?.scriptConfigured
|
||||
})
|
||||
|
||||
if (eligibleTargets.length === 0) {
|
||||
showToast('当前页没有配置余额脚本的账户', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const skippedCount = targets.length - eligibleTargets.length
|
||||
|
||||
refreshingBalances.value = true
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
eligibleTargets.map(async (account) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/admin/accounts/${account.id}/balance/refresh`, {
|
||||
platform: account.platform
|
||||
})
|
||||
return { id: account.id, success: !!response?.success, data: response?.data || null }
|
||||
} catch (error) {
|
||||
return { id: account.id, success: false, error: error?.message || '刷新失败' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const updatedMap = results.reduce((map, item) => {
|
||||
if (item.success && item.data) {
|
||||
map[item.id] = item.data
|
||||
}
|
||||
return map
|
||||
}, {})
|
||||
|
||||
const successCount = results.filter((r) => r.success).length
|
||||
const failCount = results.length - successCount
|
||||
|
||||
const skippedText = skippedCount > 0 ? `,跳过 ${skippedCount} 个未配置脚本` : ''
|
||||
if (Object.keys(updatedMap).length > 0) {
|
||||
accounts.value = accounts.value.map((account) => {
|
||||
const balanceInfo = updatedMap[account.id]
|
||||
if (!balanceInfo) return account
|
||||
return { ...account, balanceInfo }
|
||||
})
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
showToast(`成功刷新 ${successCount} 个账户余额${skippedText}`, 'success')
|
||||
} else {
|
||||
showToast(`刷新完成:${successCount} 成功,${failCount} 失败${skippedText}`, 'warning')
|
||||
}
|
||||
} finally {
|
||||
refreshingBalances.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateSelectAllState = () => {
|
||||
const currentIds = paginatedAccounts.value.map((account) => account.id)
|
||||
const selectedInCurrentPage = currentIds.filter((id) =>
|
||||
@@ -2818,6 +3026,54 @@ const cleanupSelectedAccounts = () => {
|
||||
updateSelectAllState()
|
||||
}
|
||||
|
||||
// 异步加载余额缓存(按平台批量拉取,避免逐行请求)
|
||||
const loadBalanceCacheForAccounts = async () => {
|
||||
const current = accounts.value
|
||||
if (!Array.isArray(current) || current.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const platforms = Array.from(new Set(current.map((acc) => acc.platform).filter(Boolean)))
|
||||
if (platforms.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const responses = await Promise.all(
|
||||
platforms.map(async (platform) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/accounts/balance/platform/${platform}`, {
|
||||
params: { queryApi: false }
|
||||
})
|
||||
return { platform, success: !!res?.success, data: res?.data || [] }
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load balance cache for ${platform}:`, error)
|
||||
return { platform, success: false, data: [] }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const balanceMap = responses.reduce((map, item) => {
|
||||
if (!item.success) return map
|
||||
const list = Array.isArray(item.data) ? item.data : []
|
||||
list.forEach((entry) => {
|
||||
const accountId = entry?.data?.accountId
|
||||
if (accountId) {
|
||||
map[accountId] = entry.data
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, {})
|
||||
|
||||
if (Object.keys(balanceMap).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
accounts.value = accounts.value.map((account) => ({
|
||||
...account,
|
||||
balanceInfo: balanceMap[account.id] || account.balanceInfo || null
|
||||
}))
|
||||
}
|
||||
|
||||
// 加载账户列表
|
||||
const loadAccounts = async (forceReload = false) => {
|
||||
accountsLoading.value = true
|
||||
@@ -3010,6 +3266,11 @@ const loadAccounts = async (forceReload = false) => {
|
||||
console.debug('Claude usage loading failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// 异步加载余额缓存(按平台批量)
|
||||
loadBalanceCacheForAccounts().catch((err) => {
|
||||
console.debug('Balance cache loading failed:', err)
|
||||
})
|
||||
} catch (error) {
|
||||
showToast('加载账户失败', 'error')
|
||||
} finally {
|
||||
|
||||
312
web/admin-spa/src/views/BalanceScriptsView.vue
Normal file
312
web/admin-spa/src/views/BalanceScriptsView.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row">
|
||||
<div class="glass-strong flex-1 rounded-2xl p-4 shadow-lg">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">脚本余额配置</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
使用自定义脚本 + 模板变量适配任意余额接口
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
@click="loadConfig"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700"
|
||||
:disabled="saving"
|
||||
@click="saveConfig"
|
||||
>
|
||||
<span v-if="saving">保存中...</span>
|
||||
<span v-else>保存配置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
|
||||
<input v-model="form.apiKey" class="input-text" placeholder="sk-xxxx" type="text" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
请求地址(baseUrl)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.baseUrl"
|
||||
class="input-text"
|
||||
placeholder="https://api.example.com"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>Token(可选)</label
|
||||
>
|
||||
<input v-model="form.token" class="input-text" placeholder="Bearer token" type="text" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>超时时间(秒)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.timeoutSeconds"
|
||||
class="input-text"
|
||||
min="1"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
自动查询间隔(分钟)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.autoIntervalMinutes"
|
||||
class="input-text"
|
||||
min="0"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">模板变量</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
可用变量:{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}apiKey{{ '}'
|
||||
}}{{ '}' }}、{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}accountId{{
|
||||
'}'
|
||||
}}{{ '}' }}、{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}extra{{
|
||||
'}'
|
||||
}}{{ '}' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-strong w-full max-w-xl rounded-2xl p-4 shadow-lg">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">测试脚本</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
填入账号上下文(可选),调试 extractor 输出
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
|
||||
:disabled="testing"
|
||||
@click="testScript"
|
||||
>
|
||||
<span v-if="testing">测试中...</span>
|
||||
<span v-else>测试脚本</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">平台</label>
|
||||
<input v-model="testForm.platform" class="input-text" placeholder="例如 claude" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">账号ID</label>
|
||||
<input v-model="testForm.accountId" class="input-text" placeholder="账号标识,可选" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>额外参数 (extra)</label
|
||||
>
|
||||
<input v-model="testForm.extra" class="input-text" placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult" class="mt-4 space-y-2 rounded-xl bg-gray-50 p-3 dark:bg-gray-800/60">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-semibold text-gray-800 dark:text-gray-100">测试结果</span>
|
||||
<span
|
||||
:class="[
|
||||
'rounded px-2 py-0.5 text-xs',
|
||||
testResult.mapped?.status === 'success'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
|
||||
]"
|
||||
>
|
||||
{{ testResult.mapped?.status || 'unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
|
||||
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
|
||||
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
|
||||
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
|
||||
错误: {{ testResult.mapped.errorMessage }}
|
||||
</div>
|
||||
<div v-if="testResult.mapped?.quota">
|
||||
配额: {{ JSON.stringify(testResult.mapped.quota) }}
|
||||
</div>
|
||||
</div>
|
||||
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<summary class="cursor-pointer">查看 extractor 输出</summary>
|
||||
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
|
||||
>{{ formatJson(testResult.extracted) }}
|
||||
</pre
|
||||
>
|
||||
</details>
|
||||
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<summary class="cursor-pointer">查看原始响应</summary>
|
||||
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
|
||||
>{{ formatJson(testResult.response) }}
|
||||
</pre
|
||||
>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-strong rounded-2xl p-4 shadow-lg">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">提取器代码</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
返回对象需包含 request、extractor;支持模板变量替换
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
@click="applyPreset"
|
||||
>
|
||||
使用示例模板
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="form.scriptBody"
|
||||
class="min-h-[320px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
extractor
|
||||
返回字段(可选):isValid、invalidMessage、remaining、unit、planName、total、used、extra
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const form = reactive({
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
token: '',
|
||||
timeoutSeconds: 10,
|
||||
autoIntervalMinutes: 0,
|
||||
scriptBody: ''
|
||||
})
|
||||
|
||||
const testForm = reactive({
|
||||
platform: '',
|
||||
accountId: '',
|
||||
extra: ''
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null)
|
||||
|
||||
const presetScript = `({
|
||||
request: {
|
||||
url: "{{baseUrl}}/user/balance",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": "Bearer {{apiKey}}",
|
||||
"User-Agent": "cc-switch/1.0"
|
||||
}
|
||||
},
|
||||
extractor: function(response) {
|
||||
return {
|
||||
isValid: response.is_active || true,
|
||||
remaining: response.balance,
|
||||
unit: "USD",
|
||||
planName: response.plan || "默认套餐"
|
||||
};
|
||||
}
|
||||
})`
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/admin/balance-scripts/default')
|
||||
if (res?.success && res.data) {
|
||||
Object.assign(form, res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载配置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form }
|
||||
await apiClient.put('/admin/balance-scripts/default', payload)
|
||||
showToast('配置已保存', 'success')
|
||||
} catch (error) {
|
||||
showToast(error.message || '保存失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testScript = async () => {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
...testForm,
|
||||
scriptBody: form.scriptBody
|
||||
}
|
||||
const res = await apiClient.post('/admin/balance-scripts/default/test', payload)
|
||||
if (res?.success) {
|
||||
testResult.value = res.data
|
||||
showToast('测试完成', 'success')
|
||||
} else {
|
||||
showToast(res?.error || '测试失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message || '测试失败', 'error')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyPreset = () => {
|
||||
form.scriptBody = presetScript
|
||||
}
|
||||
|
||||
const displayAmount = (val) => {
|
||||
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
|
||||
return Number(val).toFixed(2)
|
||||
}
|
||||
|
||||
const formatJson = (data) => {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2)
|
||||
} catch (error) {
|
||||
return String(data)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyPreset()
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-text {
|
||||
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
|
||||
}
|
||||
</style>
|
||||
@@ -196,6 +196,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账户余额/配额汇总 -->
|
||||
<div class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6">
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||
账户余额/配额
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
|
||||
{{ formatCurrencyUsd(balanceSummary.totalBalance || 0) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
低余额: {{ balanceSummary.lowBalanceCount || 0 }} | 总成本:
|
||||
{{ formatCurrencyUsd(balanceSummary.totalCost || 0) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-green-600">
|
||||
<i class="fas fa-wallet" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-3">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
更新时间: {{ formatLastUpdate(balanceSummaryUpdatedAt) }}
|
||||
</p>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500"
|
||||
:disabled="loadingBalanceSummary"
|
||||
@click="loadBalanceSummary"
|
||||
>
|
||||
<i :class="['fas', loadingBalanceSummary ? 'fa-spinner fa-spin' : 'fa-sync-alt']" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">低余额账户</h3>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ lowBalanceAccounts.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingBalanceSummary"
|
||||
class="py-6 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
正在加载...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="lowBalanceAccounts.length === 0"
|
||||
class="py-6 text-center text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
全部正常
|
||||
</div>
|
||||
<div v-else class="max-h-64 space-y-2 overflow-y-auto">
|
||||
<div
|
||||
v-for="account in lowBalanceAccounts"
|
||||
:key="account.accountId"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-900/60 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ account.name || account.accountId }}
|
||||
</div>
|
||||
<span
|
||||
class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ getBalancePlatformLabel(account.platform) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span v-if="account.balance">余额: {{ account.balance.formattedAmount }}</span>
|
||||
<span v-else
|
||||
>今日成本: {{ formatCurrencyUsd(account.statistics?.dailyCost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="account.quota && typeof account.quota.percentage === 'number'" class="mt-2">
|
||||
<div
|
||||
class="mb-1 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<span>配额使用</span>
|
||||
<span class="text-red-600 dark:text-red-400">
|
||||
{{ account.quota.percentage.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-2 rounded-full bg-red-500"
|
||||
:style="{ width: `${Math.min(100, account.quota.percentage)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token统计和性能指标 -->
|
||||
<div
|
||||
class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6 lg:grid-cols-4"
|
||||
@@ -681,6 +780,8 @@ import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import Chart from 'chart.js/auto'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
@@ -732,6 +833,97 @@ const accountGroupOptions = [
|
||||
|
||||
const accountTrendUpdating = ref(false)
|
||||
|
||||
// 余额/配额汇总
|
||||
const balanceSummary = ref({
|
||||
totalBalance: 0,
|
||||
totalCost: 0,
|
||||
lowBalanceCount: 0,
|
||||
platforms: {}
|
||||
})
|
||||
const loadingBalanceSummary = ref(false)
|
||||
const balanceSummaryUpdatedAt = ref(null)
|
||||
|
||||
const getBalancePlatformLabel = (platform) => {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user