mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-24 22:02:34 +00:00
Merge branch 'main' into antigravity
This commit is contained in:
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
|
||||
420
src/services/accountTestSchedulerService.js
Normal file
420
src/services/accountTestSchedulerService.js
Normal file
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 账户定时测试调度服务
|
||||
* 使用 node-cron 支持 crontab 表达式,为每个账户创建独立的定时任务
|
||||
*/
|
||||
|
||||
const cron = require('node-cron')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
class AccountTestSchedulerService {
|
||||
constructor() {
|
||||
// 存储每个账户的 cron 任务: Map<string, { task: ScheduledTask, cronExpression: string }>
|
||||
this.scheduledTasks = new Map()
|
||||
// 定期刷新配置的间隔 (毫秒)
|
||||
this.refreshIntervalMs = 60 * 1000
|
||||
this.refreshInterval = null
|
||||
// 当前正在测试的账户
|
||||
this.testingAccounts = new Set()
|
||||
// 是否已启动
|
||||
this.isStarted = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 cron 表达式是否有效
|
||||
* @param {string} cronExpression - cron 表达式
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validateCronExpression(cronExpression) {
|
||||
// 长度检查(防止 DoS)
|
||||
if (!cronExpression || cronExpression.length > 100) {
|
||||
return false
|
||||
}
|
||||
return cron.validate(cronExpression)
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动调度器
|
||||
*/
|
||||
async start() {
|
||||
if (this.isStarted) {
|
||||
logger.warn('⚠️ Account test scheduler is already running')
|
||||
return
|
||||
}
|
||||
|
||||
this.isStarted = true
|
||||
logger.info('🚀 Starting account test scheduler service (node-cron mode)')
|
||||
|
||||
// 初始化所有已配置账户的定时任务
|
||||
await this._refreshAllTasks()
|
||||
|
||||
// 定期刷新配置,以便动态添加/修改的配置能生效
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this._refreshAllTasks()
|
||||
}, this.refreshIntervalMs)
|
||||
|
||||
logger.info(
|
||||
`📅 Account test scheduler started (refreshing configs every ${this.refreshIntervalMs / 1000}s)`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止调度器
|
||||
*/
|
||||
stop() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval)
|
||||
this.refreshInterval = null
|
||||
}
|
||||
|
||||
// 停止所有 cron 任务
|
||||
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
|
||||
taskInfo.task.stop()
|
||||
logger.debug(`🛑 Stopped cron task for ${accountKey}`)
|
||||
}
|
||||
this.scheduledTasks.clear()
|
||||
|
||||
this.isStarted = false
|
||||
logger.info('🛑 Account test scheduler stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新所有账户的定时任务
|
||||
* @private
|
||||
*/
|
||||
async _refreshAllTasks() {
|
||||
try {
|
||||
const platforms = ['claude', 'gemini', 'openai']
|
||||
const activeAccountKeys = new Set()
|
||||
|
||||
// 并行加载所有平台的配置
|
||||
const allEnabledAccounts = await Promise.all(
|
||||
platforms.map((platform) =>
|
||||
redis
|
||||
.getEnabledTestAccounts(platform)
|
||||
.then((accounts) => accounts.map((acc) => ({ ...acc, platform })))
|
||||
.catch((error) => {
|
||||
logger.warn(`⚠️ Failed to load test accounts for platform ${platform}:`, error)
|
||||
return []
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// 展平平台数据
|
||||
const flatAccounts = allEnabledAccounts.flat()
|
||||
|
||||
for (const { accountId, cronExpression, model, platform } of flatAccounts) {
|
||||
if (!cronExpression) {
|
||||
logger.warn(
|
||||
`⚠️ Account ${accountId} (${platform}) has no valid cron expression, skipping`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const accountKey = `${platform}:${accountId}`
|
||||
activeAccountKeys.add(accountKey)
|
||||
|
||||
// 检查是否需要更新任务
|
||||
const existingTask = this.scheduledTasks.get(accountKey)
|
||||
if (existingTask) {
|
||||
// 如果 cron 表达式和模型都没变,不需要更新
|
||||
if (existingTask.cronExpression === cronExpression && existingTask.model === model) {
|
||||
continue
|
||||
}
|
||||
// 配置变了,停止旧任务
|
||||
existingTask.task.stop()
|
||||
logger.info(`🔄 Updating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
|
||||
} else {
|
||||
logger.info(`➕ Creating cron task for ${accountKey}: ${cronExpression}, model: ${model}`)
|
||||
}
|
||||
|
||||
// 创建新的 cron 任务
|
||||
this._createCronTask(accountId, platform, cronExpression, model)
|
||||
}
|
||||
|
||||
// 清理已删除或禁用的账户任务
|
||||
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
|
||||
if (!activeAccountKeys.has(accountKey)) {
|
||||
taskInfo.task.stop()
|
||||
this.scheduledTasks.delete(accountKey)
|
||||
logger.info(`➖ Removed cron task for ${accountKey} (disabled or deleted)`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Error refreshing account test tasks:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为单个账户创建 cron 任务
|
||||
* @param {string} accountId
|
||||
* @param {string} platform
|
||||
* @param {string} cronExpression
|
||||
* @param {string} model - 测试使用的模型
|
||||
* @private
|
||||
*/
|
||||
_createCronTask(accountId, platform, cronExpression, model) {
|
||||
const accountKey = `${platform}:${accountId}`
|
||||
|
||||
// 验证 cron 表达式
|
||||
if (!this.validateCronExpression(cronExpression)) {
|
||||
logger.error(`❌ Invalid cron expression for ${accountKey}: ${cronExpression}`)
|
||||
return
|
||||
}
|
||||
|
||||
const task = cron.schedule(
|
||||
cronExpression,
|
||||
async () => {
|
||||
await this._runAccountTest(accountId, platform, model)
|
||||
},
|
||||
{
|
||||
scheduled: true,
|
||||
timezone: process.env.TZ || 'Asia/Shanghai'
|
||||
}
|
||||
)
|
||||
|
||||
this.scheduledTasks.set(accountKey, {
|
||||
task,
|
||||
cronExpression,
|
||||
model,
|
||||
accountId,
|
||||
platform
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个账户测试
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台类型
|
||||
* @param {string} model - 测试使用的模型
|
||||
* @private
|
||||
*/
|
||||
async _runAccountTest(accountId, platform, model) {
|
||||
const accountKey = `${platform}:${accountId}`
|
||||
|
||||
// 避免重复测试
|
||||
if (this.testingAccounts.has(accountKey)) {
|
||||
logger.debug(`⏳ Account ${accountKey} is already being tested, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
this.testingAccounts.add(accountKey)
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`🧪 Running scheduled test for ${platform} account: ${accountId} (model: ${model})`
|
||||
)
|
||||
|
||||
let testResult
|
||||
|
||||
// 根据平台调用对应的测试方法
|
||||
switch (platform) {
|
||||
case 'claude':
|
||||
testResult = await this._testClaudeAccount(accountId, model)
|
||||
break
|
||||
case 'gemini':
|
||||
testResult = await this._testGeminiAccount(accountId, model)
|
||||
break
|
||||
case 'openai':
|
||||
testResult = await this._testOpenAIAccount(accountId, model)
|
||||
break
|
||||
default:
|
||||
testResult = {
|
||||
success: false,
|
||||
error: `Unsupported platform: ${platform}`,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 保存测试结果
|
||||
await redis.saveAccountTestResult(accountId, platform, testResult)
|
||||
|
||||
// 更新最后测试时间
|
||||
await redis.setAccountLastTestTime(accountId, platform)
|
||||
|
||||
// 记录日志
|
||||
if (testResult.success) {
|
||||
logger.info(
|
||||
`✅ Scheduled test passed for ${platform} account ${accountId} (${testResult.latencyMs}ms)`
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
`❌ Scheduled test failed for ${platform} account ${accountId}: ${testResult.error}`
|
||||
)
|
||||
}
|
||||
|
||||
return testResult
|
||||
} catch (error) {
|
||||
logger.error(`❌ Error testing ${platform} account ${accountId}:`, error)
|
||||
|
||||
const errorResult = {
|
||||
success: false,
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
await redis.saveAccountTestResult(accountId, platform, errorResult)
|
||||
await redis.setAccountLastTestTime(accountId, platform)
|
||||
|
||||
return errorResult
|
||||
} finally {
|
||||
this.testingAccounts.delete(accountKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 Claude 账户
|
||||
* @param {string} accountId
|
||||
* @param {string} model - 测试使用的模型
|
||||
* @private
|
||||
*/
|
||||
async _testClaudeAccount(accountId, model) {
|
||||
const claudeRelayService = require('./claudeRelayService')
|
||||
return await claudeRelayService.testAccountConnectionSync(accountId, model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 Gemini 账户
|
||||
* @param {string} _accountId
|
||||
* @param {string} _model
|
||||
* @private
|
||||
*/
|
||||
async _testGeminiAccount(_accountId, _model) {
|
||||
// Gemini 测试暂时返回未实现
|
||||
return {
|
||||
success: false,
|
||||
error: 'Gemini scheduled test not implemented yet',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 OpenAI 账户
|
||||
* @param {string} _accountId
|
||||
* @param {string} _model
|
||||
* @private
|
||||
*/
|
||||
async _testOpenAIAccount(_accountId, _model) {
|
||||
// OpenAI 测试暂时返回未实现
|
||||
return {
|
||||
success: false,
|
||||
error: 'OpenAI scheduled test not implemented yet',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发账户测试
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台类型
|
||||
* @param {string} model - 测试使用的模型
|
||||
* @returns {Promise<Object>} 测试结果
|
||||
*/
|
||||
async triggerTest(accountId, platform, model = 'claude-sonnet-4-5-20250929') {
|
||||
logger.info(`🎯 Manual test triggered for ${platform} account: ${accountId} (model: ${model})`)
|
||||
return await this._runAccountTest(accountId, platform, model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户测试历史
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台类型
|
||||
* @returns {Promise<Array>} 测试历史
|
||||
*/
|
||||
async getTestHistory(accountId, platform) {
|
||||
return await redis.getAccountTestHistory(accountId, platform)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户测试配置
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台类型
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async getTestConfig(accountId, platform) {
|
||||
return await redis.getAccountTestConfig(accountId, platform)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置账户测试配置
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台类型
|
||||
* @param {Object} testConfig - 测试配置 { enabled: boolean, cronExpression: string, model: string }
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setTestConfig(accountId, platform, testConfig) {
|
||||
// 验证 cron 表达式
|
||||
if (testConfig.cronExpression && !this.validateCronExpression(testConfig.cronExpression)) {
|
||||
throw new Error(`Invalid cron expression: ${testConfig.cronExpression}`)
|
||||
}
|
||||
|
||||
await redis.saveAccountTestConfig(accountId, platform, testConfig)
|
||||
logger.info(
|
||||
`📝 Test config updated for ${platform} account ${accountId}: enabled=${testConfig.enabled}, cronExpression=${testConfig.cronExpression}, model=${testConfig.model}`
|
||||
)
|
||||
|
||||
// 立即刷新任务,使配置立即生效
|
||||
if (this.isStarted) {
|
||||
await this._refreshAllTasks()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个账户的定时任务(配置变更时调用)
|
||||
* @param {string} accountId
|
||||
* @param {string} platform
|
||||
*/
|
||||
async refreshAccountTask(accountId, platform) {
|
||||
if (!this.isStarted) {
|
||||
return
|
||||
}
|
||||
|
||||
const accountKey = `${platform}:${accountId}`
|
||||
const testConfig = await redis.getAccountTestConfig(accountId, platform)
|
||||
|
||||
// 停止现有任务
|
||||
const existingTask = this.scheduledTasks.get(accountKey)
|
||||
if (existingTask) {
|
||||
existingTask.task.stop()
|
||||
this.scheduledTasks.delete(accountKey)
|
||||
}
|
||||
|
||||
// 如果启用且有有效的 cron 表达式,创建新任务
|
||||
if (testConfig?.enabled && testConfig?.cronExpression) {
|
||||
this._createCronTask(accountId, platform, testConfig.cronExpression, testConfig.model)
|
||||
logger.info(
|
||||
`🔄 Refreshed cron task for ${accountKey}: ${testConfig.cronExpression}, model: ${testConfig.model}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取调度器状态
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStatus() {
|
||||
const tasks = []
|
||||
for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) {
|
||||
tasks.push({
|
||||
accountKey,
|
||||
accountId: taskInfo.accountId,
|
||||
platform: taskInfo.platform,
|
||||
cronExpression: taskInfo.cronExpression,
|
||||
model: taskInfo.model
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
running: this.isStarted,
|
||||
refreshIntervalMs: this.refreshIntervalMs,
|
||||
scheduledTasksCount: this.scheduledTasks.size,
|
||||
scheduledTasks: tasks,
|
||||
currentlyTesting: Array.from(this.testingAccounts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单例模式
|
||||
const accountTestSchedulerService = new AccountTestSchedulerService()
|
||||
|
||||
module.exports = accountTestSchedulerService
|
||||
@@ -37,6 +37,51 @@ const ACCOUNT_CATEGORY_MAP = {
|
||||
droid: 'droid'
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化权限数据,兼容旧格式(字符串)和新格式(数组)
|
||||
* @param {string|array} permissions - 权限数据
|
||||
* @returns {array} - 权限数组,空数组表示全部服务
|
||||
*/
|
||||
function normalizePermissions(permissions) {
|
||||
if (!permissions) {
|
||||
return [] // 空 = 全部服务
|
||||
}
|
||||
if (Array.isArray(permissions)) {
|
||||
return permissions
|
||||
}
|
||||
// 尝试解析 JSON 字符串(新格式存储)
|
||||
if (typeof permissions === 'string') {
|
||||
if (permissions.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(permissions)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,继续处理为普通字符串
|
||||
}
|
||||
}
|
||||
// 旧格式 'all' 转为空数组
|
||||
if (permissions === 'all') {
|
||||
return []
|
||||
}
|
||||
// 旧单个字符串转为数组
|
||||
return [permissions]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有访问特定服务的权限
|
||||
* @param {string|array} permissions - 权限数据
|
||||
* @param {string} service - 服务名称(claude/gemini/openai/droid)
|
||||
* @returns {boolean} - 是否有权限
|
||||
*/
|
||||
function hasPermission(permissions, service) {
|
||||
const perms = normalizePermissions(permissions)
|
||||
return perms.length === 0 || perms.includes(service) // 空数组 = 全部服务
|
||||
}
|
||||
|
||||
function normalizeAccountTypeKey(type) {
|
||||
if (!type) {
|
||||
return null
|
||||
@@ -89,7 +134,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId = null,
|
||||
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
||||
droidAccountId = null,
|
||||
permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all'
|
||||
permissions = [], // 数组格式,空数组表示全部服务,如 ['claude', 'gemini']
|
||||
isActive = true,
|
||||
concurrencyLimit = 0,
|
||||
rateLimitWindow = null,
|
||||
@@ -132,7 +177,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
||||
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
||||
droidAccountId: droidAccountId || '',
|
||||
permissions: permissions || 'all',
|
||||
permissions: JSON.stringify(normalizePermissions(permissions)),
|
||||
enableModelRestriction: String(enableModelRestriction),
|
||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||
enableClientRestriction: String(enableClientRestriction || false),
|
||||
@@ -186,7 +231,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||
droidAccountId: keyData.droidAccountId,
|
||||
permissions: keyData.permissions,
|
||||
permissions: normalizePermissions(keyData.permissions),
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
@@ -338,7 +383,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||
droidAccountId: keyData.droidAccountId,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: normalizePermissions(keyData.permissions),
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||
@@ -467,7 +512,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId,
|
||||
droidAccountId: keyData.droidAccountId,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions: normalizePermissions(keyData.permissions),
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||
@@ -525,7 +570,7 @@ class ApiKeyService {
|
||||
key.isActive = key.isActive === 'true'
|
||||
key.enableModelRestriction = key.enableModelRestriction === 'true'
|
||||
key.enableClientRestriction = key.enableClientRestriction === 'true'
|
||||
key.permissions = key.permissions || 'all' // 兼容旧数据
|
||||
key.permissions = normalizePermissions(key.permissions)
|
||||
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
|
||||
key.totalCostLimit = parseFloat(key.totalCostLimit || 0)
|
||||
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
|
||||
@@ -1568,7 +1613,7 @@ class ApiKeyService {
|
||||
userId: keyData.userId,
|
||||
userUsername: keyData.userUsername,
|
||||
createdBy: keyData.createdBy,
|
||||
permissions: keyData.permissions,
|
||||
permissions: normalizePermissions(keyData.permissions),
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
||||
// 所有平台账户绑定字段
|
||||
@@ -1820,4 +1865,8 @@ const apiKeyService = new ApiKeyService()
|
||||
// 为了方便其他服务调用,导出 recordUsage 方法
|
||||
apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService)
|
||||
|
||||
// 导出权限辅助函数供路由使用
|
||||
apiKeyService.hasPermission = hasPermission
|
||||
apiKeyService.normalizePermissions = normalizePermissions
|
||||
|
||||
module.exports = apiKeyService
|
||||
|
||||
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()
|
||||
@@ -91,7 +91,9 @@ class ClaudeAccountService {
|
||||
useUnifiedClientId = false, // 是否使用统一的客户端标识
|
||||
unifiedClientId = '', // 统一的客户端标识
|
||||
expiresAt = null, // 账户订阅到期时间
|
||||
extInfo = null // 额外扩展信息
|
||||
extInfo = null, // 额外扩展信息
|
||||
maxConcurrency = 0, // 账户级用户消息串行队列:0=使用全局配置,>0=强制启用串行
|
||||
interceptWarmup = false // 拦截预热请求(标题生成、Warmup等)
|
||||
} = options
|
||||
|
||||
const accountId = uuidv4()
|
||||
@@ -136,7 +138,11 @@ class ClaudeAccountService {
|
||||
// 账户订阅到期时间
|
||||
subscriptionExpiresAt: expiresAt || '',
|
||||
// 扩展信息
|
||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
|
||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
|
||||
// 账户级用户消息串行队列限制
|
||||
maxConcurrency: maxConcurrency.toString(),
|
||||
// 拦截预热请求
|
||||
interceptWarmup: interceptWarmup.toString()
|
||||
}
|
||||
} else {
|
||||
// 兼容旧格式
|
||||
@@ -168,7 +174,11 @@ class ClaudeAccountService {
|
||||
// 账户订阅到期时间
|
||||
subscriptionExpiresAt: expiresAt || '',
|
||||
// 扩展信息
|
||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : ''
|
||||
extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '',
|
||||
// 账户级用户消息串行队列限制
|
||||
maxConcurrency: maxConcurrency.toString(),
|
||||
// 拦截预热请求
|
||||
interceptWarmup: interceptWarmup.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +226,8 @@ class ClaudeAccountService {
|
||||
useUnifiedUserAgent,
|
||||
useUnifiedClientId,
|
||||
unifiedClientId,
|
||||
extInfo: normalizedExtInfo
|
||||
extInfo: normalizedExtInfo,
|
||||
interceptWarmup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,7 +585,11 @@ class ClaudeAccountService {
|
||||
// 添加停止原因
|
||||
stoppedReason: account.stoppedReason || null,
|
||||
// 扩展信息
|
||||
extInfo: parsedExtInfo
|
||||
extInfo: parsedExtInfo,
|
||||
// 账户级用户消息串行队列限制
|
||||
maxConcurrency: parseInt(account.maxConcurrency || '0', 10),
|
||||
// 拦截预热请求
|
||||
interceptWarmup: account.interceptWarmup === 'true'
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -666,7 +681,9 @@ class ClaudeAccountService {
|
||||
'useUnifiedClientId',
|
||||
'unifiedClientId',
|
||||
'subscriptionExpiresAt',
|
||||
'extInfo'
|
||||
'extInfo',
|
||||
'maxConcurrency',
|
||||
'interceptWarmup'
|
||||
]
|
||||
const updatedData = { ...accountData }
|
||||
let shouldClearAutoStopFields = false
|
||||
@@ -681,7 +698,7 @@ class ClaudeAccountService {
|
||||
updatedData[field] = this._encryptSensitiveData(value)
|
||||
} else if (field === 'proxy') {
|
||||
updatedData[field] = value ? JSON.stringify(value) : ''
|
||||
} else if (field === 'priority') {
|
||||
} else if (field === 'priority' || field === 'maxConcurrency') {
|
||||
updatedData[field] = value.toString()
|
||||
} else if (field === 'subscriptionInfo') {
|
||||
// 处理订阅信息更新
|
||||
|
||||
@@ -68,7 +68,8 @@ class ClaudeConsoleAccountService {
|
||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||
maxConcurrentTasks = 0, // 最大并发任务数,0表示无限制
|
||||
disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||
disableAutoProtection = false, // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||
interceptWarmup = false // 拦截预热请求(标题生成、Warmup等)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
@@ -117,7 +118,8 @@ class ClaudeConsoleAccountService {
|
||||
quotaResetTime, // 额度重置时间
|
||||
quotaStoppedAt: '', // 因额度停用的时间
|
||||
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制
|
||||
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
|
||||
disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护
|
||||
interceptWarmup: interceptWarmup.toString() // 拦截预热请求
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
@@ -156,6 +158,7 @@ class ClaudeConsoleAccountService {
|
||||
quotaStoppedAt: null,
|
||||
maxConcurrentTasks, // 新增:返回并发限制配置
|
||||
disableAutoProtection, // 新增:返回自动防护开关
|
||||
interceptWarmup, // 新增:返回预热请求拦截开关
|
||||
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
||||
}
|
||||
}
|
||||
@@ -217,7 +220,9 @@ class ClaudeConsoleAccountService {
|
||||
// 并发控制相关
|
||||
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
||||
activeTaskCount,
|
||||
disableAutoProtection: accountData.disableAutoProtection === 'true'
|
||||
disableAutoProtection: accountData.disableAutoProtection === 'true',
|
||||
// 拦截预热请求
|
||||
interceptWarmup: accountData.interceptWarmup === 'true'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -375,6 +380,9 @@ class ClaudeConsoleAccountService {
|
||||
if (updates.disableAutoProtection !== undefined) {
|
||||
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
||||
}
|
||||
if (updates.interceptWarmup !== undefined) {
|
||||
updatedData.interceptWarmup = updates.interceptWarmup.toString()
|
||||
}
|
||||
|
||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
||||
|
||||
@@ -210,7 +210,17 @@ class ClaudeRelayService {
|
||||
logger.error('❌ accountId missing for queue lock in relayRequest')
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
// 获取账户信息以检查账户级串行队列配置
|
||||
const accountForQueue = await claudeAccountService.getAccount(accountId)
|
||||
const accountConfig = accountForQueue
|
||||
? { maxConcurrency: parseInt(accountForQueue.maxConcurrency || '0', 10) }
|
||||
: null
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(
|
||||
accountId,
|
||||
null,
|
||||
null,
|
||||
accountConfig
|
||||
)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
@@ -323,17 +333,46 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 发送请求到Claude API(传入回调以获取请求对象)
|
||||
const response = await this._makeClaudeRequest(
|
||||
processedBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
accountId,
|
||||
(req) => {
|
||||
upstreamRequest = req
|
||||
},
|
||||
options
|
||||
)
|
||||
// 🔄 403 重试机制:仅对 claude-official 类型账户(OAuth 或 Setup Token)
|
||||
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
|
||||
let retryCount = 0
|
||||
let response
|
||||
let shouldRetry = false
|
||||
|
||||
do {
|
||||
response = await this._makeClaudeRequest(
|
||||
processedBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
accountId,
|
||||
(req) => {
|
||||
upstreamRequest = req
|
||||
},
|
||||
options
|
||||
)
|
||||
|
||||
// 检查是否需要重试 403
|
||||
shouldRetry = response.statusCode === 403 && retryCount < maxRetries
|
||||
if (shouldRetry) {
|
||||
retryCount++
|
||||
logger.warn(
|
||||
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
|
||||
)
|
||||
await this._sleep(2000)
|
||||
}
|
||||
} while (shouldRetry)
|
||||
|
||||
// 如果进行了重试,记录最终结果
|
||||
if (retryCount > 0) {
|
||||
if (response.statusCode === 403) {
|
||||
logger.error(`🚫 403 error persists for account ${accountId} after ${retryCount} retries`)
|
||||
} else {
|
||||
logger.info(
|
||||
`✅ 403 retry successful for account ${accountId} on attempt ${retryCount}, got status ${response.statusCode}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||
// 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||
@@ -398,9 +437,10 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
// 检查是否为403状态码(禁止访问)
|
||||
// 注意:如果进行了重试,retryCount > 0;这里的 403 是重试后最终的结果
|
||||
else if (response.statusCode === 403) {
|
||||
logger.error(
|
||||
`🚫 Forbidden error (403) detected for account ${accountId}, marking as blocked`
|
||||
`🚫 Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked`
|
||||
)
|
||||
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
||||
}
|
||||
@@ -1314,7 +1354,17 @@ class ClaudeRelayService {
|
||||
logger.error('❌ accountId missing for queue lock in relayStreamRequestWithUsageCapture')
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
// 获取账户信息以检查账户级串行队列配置
|
||||
const accountForQueue = await claudeAccountService.getAccount(accountId)
|
||||
const accountConfig = accountForQueue
|
||||
? { maxConcurrency: parseInt(accountForQueue.maxConcurrency || '0', 10) }
|
||||
: null
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(
|
||||
accountId,
|
||||
null,
|
||||
null,
|
||||
accountConfig
|
||||
)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
@@ -1497,8 +1547,10 @@ class ClaudeRelayService {
|
||||
streamTransformer = null,
|
||||
requestOptions = {},
|
||||
isDedicatedOfficialAccount = false,
|
||||
onResponseStart = null // 📬 新增:收到响应头时的回调,用于提前释放队列锁
|
||||
onResponseStart = null, // 📬 新增:收到响应头时的回调,用于提前释放队列锁
|
||||
retryCount = 0 // 🔄 403 重试计数器
|
||||
) {
|
||||
const maxRetries = 2 // 最大重试次数
|
||||
// 获取账户信息用于统一 User-Agent
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
|
||||
@@ -1611,6 +1663,51 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 403 重试机制(必须在设置 res.on('data')/res.on('end') 之前处理)
|
||||
// 否则重试时旧响应的 on('end') 会与新请求产生竞态条件
|
||||
if (res.statusCode === 403) {
|
||||
const canRetry =
|
||||
this._shouldRetryOn403(accountType) &&
|
||||
retryCount < maxRetries &&
|
||||
!responseStream.headersSent
|
||||
|
||||
if (canRetry) {
|
||||
logger.warn(
|
||||
`🔄 [Stream] 403 error for account ${accountId}, retry ${retryCount + 1}/${maxRetries} after 2s`
|
||||
)
|
||||
// 消费当前响应并销毁请求
|
||||
res.resume()
|
||||
req.destroy()
|
||||
|
||||
// 等待 2 秒后递归重试
|
||||
await this._sleep(2000)
|
||||
|
||||
try {
|
||||
// 递归调用自身进行重试
|
||||
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
||||
body,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
responseStream,
|
||||
usageCallback,
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash,
|
||||
streamTransformer,
|
||||
requestOptions,
|
||||
isDedicatedOfficialAccount,
|
||||
onResponseStart,
|
||||
retryCount + 1
|
||||
)
|
||||
resolve(retryResult)
|
||||
} catch (retryError) {
|
||||
reject(retryError)
|
||||
}
|
||||
return // 重要:提前返回,不设置后续的错误处理器
|
||||
}
|
||||
}
|
||||
|
||||
// 将错误处理逻辑封装在一个异步函数中
|
||||
const handleErrorResponse = async () => {
|
||||
if (res.statusCode === 401) {
|
||||
@@ -1634,8 +1731,10 @@ class ClaudeRelayService {
|
||||
)
|
||||
}
|
||||
} else if (res.statusCode === 403) {
|
||||
// 403 处理:走到这里说明重试已用尽或不适用重试,直接标记 blocked
|
||||
// 注意:重试逻辑已在 handleErrorResponse 外部提前处理
|
||||
logger.error(
|
||||
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked`
|
||||
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked`
|
||||
)
|
||||
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
||||
} else if (res.statusCode === 529) {
|
||||
@@ -2456,28 +2555,35 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 准备测试请求的公共逻辑(供 testAccountConnection 和 testAccountConnectionSync 共用)
|
||||
async _prepareAccountForTest(accountId) {
|
||||
// 获取账户信息
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to get valid access token')
|
||||
}
|
||||
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId)
|
||||
|
||||
return { account, accessToken, proxyAgent }
|
||||
}
|
||||
|
||||
// 🧪 测试账号连接(供Admin API使用,直接复用 _makeClaudeStreamRequestWithUsageCapture)
|
||||
async testAccountConnection(accountId, responseStream) {
|
||||
const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true })
|
||||
async testAccountConnection(accountId, responseStream, model = 'claude-sonnet-4-5-20250929') {
|
||||
const testRequestBody = createClaudeTestPayload(model, { stream: true })
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId)
|
||||
|
||||
logger.info(`🧪 Testing Claude account connection: ${account.name} (${accountId})`)
|
||||
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to get valid access token')
|
||||
}
|
||||
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId)
|
||||
|
||||
// 设置响应头
|
||||
if (!responseStream.headersSent) {
|
||||
const existingConnection = responseStream.getHeader
|
||||
@@ -2526,6 +2632,125 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🧪 非流式测试账号连接(供定时任务使用)
|
||||
// 复用流式请求方法,收集结果后返回
|
||||
async testAccountConnectionSync(accountId, model = 'claude-sonnet-4-5-20250929') {
|
||||
const testRequestBody = createClaudeTestPayload(model, { stream: true })
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 使用公共方法准备测试所需的账户信息、token 和代理
|
||||
const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId)
|
||||
|
||||
logger.info(`🧪 Testing Claude account connection (sync): ${account.name} (${accountId})`)
|
||||
|
||||
// 创建一个收集器来捕获流式响应
|
||||
let responseText = ''
|
||||
let capturedUsage = null
|
||||
let capturedModel = model
|
||||
let hasError = false
|
||||
let errorMessage = ''
|
||||
|
||||
// 创建模拟的响应流对象
|
||||
const mockResponseStream = {
|
||||
headersSent: true, // 跳过设置响应头
|
||||
write: (data) => {
|
||||
// 解析 SSE 数据
|
||||
if (typeof data === 'string' && data.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = data.replace('data: ', '').trim()
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
const parsed = JSON.parse(jsonStr)
|
||||
// 提取文本内容
|
||||
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
|
||||
responseText += parsed.delta.text
|
||||
}
|
||||
// 提取 usage 信息
|
||||
if (parsed.type === 'message_delta' && parsed.usage) {
|
||||
capturedUsage = parsed.usage
|
||||
}
|
||||
// 提取模型信息
|
||||
if (parsed.type === 'message_start' && parsed.message?.model) {
|
||||
capturedModel = parsed.message.model
|
||||
}
|
||||
// 检测错误
|
||||
if (parsed.type === 'error') {
|
||||
hasError = true
|
||||
errorMessage = parsed.error?.message || 'Unknown error'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
end: () => {},
|
||||
on: () => {},
|
||||
once: () => {},
|
||||
emit: () => {},
|
||||
writable: true
|
||||
}
|
||||
|
||||
// 复用流式请求方法
|
||||
await this._makeClaudeStreamRequestWithUsageCapture(
|
||||
testRequestBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
{}, // clientHeaders - 测试不需要客户端headers
|
||||
mockResponseStream,
|
||||
null, // usageCallback - 测试不需要统计
|
||||
accountId,
|
||||
'claude-official', // accountType
|
||||
null, // sessionHash - 测试不需要会话
|
||||
null, // streamTransformer - 不需要转换,直接解析原始格式
|
||||
{}, // requestOptions
|
||||
false // isDedicatedOfficialAccount
|
||||
)
|
||||
|
||||
const latencyMs = Date.now() - startTime
|
||||
|
||||
if (hasError) {
|
||||
logger.warn(`⚠️ Test completed with error for account: ${account.name} - ${errorMessage}`)
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
latencyMs,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`✅ Test completed for account: ${account.name} (${latencyMs}ms)`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: responseText.substring(0, 200), // 截取前200字符
|
||||
latencyMs,
|
||||
model: capturedModel,
|
||||
usage: capturedUsage,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
} catch (error) {
|
||||
const latencyMs = Date.now() - startTime
|
||||
logger.error(`❌ Test account connection (sync) failed:`, error.message)
|
||||
|
||||
// 提取错误详情
|
||||
let errorMessage = error.message
|
||||
if (error.response) {
|
||||
errorMessage =
|
||||
error.response.data?.error?.message || error.response.statusText || error.message
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
statusCode: error.response?.status,
|
||||
latencyMs,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 健康检查
|
||||
async healthCheck() {
|
||||
try {
|
||||
@@ -2547,6 +2772,17 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 判断账户是否应该在 403 错误时进行重试
|
||||
// 仅 claude-official 类型账户(OAuth 或 Setup Token 授权)需要重试
|
||||
_shouldRetryOn403(accountType) {
|
||||
return accountType === 'claude-official'
|
||||
}
|
||||
|
||||
// ⏱️ 等待指定毫秒数
|
||||
_sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeRelayService()
|
||||
|
||||
@@ -121,12 +121,23 @@ class UserMessageQueueService {
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} requestId - 请求ID(可选,会自动生成)
|
||||
* @param {number} timeoutMs - 超时时间(可选,使用配置默认值)
|
||||
* @param {Object} accountConfig - 账户级配置(可选),优先级高于全局配置
|
||||
* @param {number} accountConfig.maxConcurrency - 账户级串行队列开关:>0启用,=0使用全局配置
|
||||
* @returns {Promise<{acquired: boolean, requestId: string, error?: string}>}
|
||||
*/
|
||||
async acquireQueueLock(accountId, requestId = null, timeoutMs = null) {
|
||||
async acquireQueueLock(accountId, requestId = null, timeoutMs = null, accountConfig = null) {
|
||||
const cfg = await this.getConfig()
|
||||
|
||||
if (!cfg.enabled) {
|
||||
// 账户级配置优先:maxConcurrency > 0 时强制启用,忽略全局开关
|
||||
let queueEnabled = cfg.enabled
|
||||
if (accountConfig && accountConfig.maxConcurrency > 0) {
|
||||
queueEnabled = true
|
||||
logger.debug(
|
||||
`📬 User message queue: account-level queue enabled for account ${accountId} (maxConcurrency=${accountConfig.maxConcurrency})`
|
||||
)
|
||||
}
|
||||
|
||||
if (!queueEnabled) {
|
||||
return { acquired: true, requestId: requestId || uuidv4(), skipped: true }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user