mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
1. Antigravity 账户适配: - 新增 GeminiBalanceProvider,支持 Antigravity 账户的额度查询(API 模式) - AccountBalanceService 增加 queryMode 逻辑与安全限制 - 前端 BalanceDisplay 适配 Antigravity 配额显示 2. 流式响应增强: - 优化 thoughtSignature 捕获与回填,支持思维链透传 - 修复工具调用签名校验 3. 其他: - 请求体大小限制提升至 100MB - .gitignore 更新
765 lines
23 KiB
JavaScript
765 lines
23 KiB
JavaScript
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 queryMode = this._parseQueryMode(options.queryApi)
|
||
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)
|
||
|
||
// 安全限制:queryApi=auto 仅用于 Antigravity(gemini + oauthProvider=antigravity)账户
|
||
const effectiveQueryMode =
|
||
queryMode === 'auto' && !(platform === 'gemini' && account?.oauthProvider === 'antigravity')
|
||
? 'local'
|
||
: queryMode
|
||
|
||
// local: 仅本地统计/缓存;auto: 优先缓存,无缓存则尝试远程 Provider(并缓存结果)
|
||
if (effectiveQueryMode !== 'api') {
|
||
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
|
||
)
|
||
}
|
||
}
|
||
|
||
if (effectiveQueryMode === 'local') {
|
||
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
|
||
}
|
||
|
||
_parseQueryMode(value) {
|
||
if (value === 'auto') {
|
||
return 'auto'
|
||
}
|
||
const parsed = this._parseBoolean(value)
|
||
return parsed ? 'api' : 'local'
|
||
}
|
||
|
||
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
|