mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
feat(api): add Claude OAuth usage endpoint with async loading
Add dedicated API endpoint to fetch Claude account OAuth usage data asynchronously, improving user experience by eliminating the need for multiple page refreshes to view session window statistics. Backend changes: - Add GET /admin/claude-accounts/usage endpoint for batch fetching - Implement fetchOAuthUsage() to call Claude API /api/oauth/usage - Add buildClaudeUsageSnapshot() to construct frontend data structure - Add updateClaudeUsageSnapshot() to persist data to Redis - Add _toNumberOrNull() helper for safe type conversion - Update getAllAccounts() to return claudeUsage from Redis cache Data structure: - Store three window types: 5h, 7d, 7d-Opus - Track utilization percentage and reset timestamps - Calculate remaining seconds for each window Performance optimizations: - Concurrent batch requests using Promise.allSettled - Graceful error handling per account - Non-blocking async execution
This commit is contained in:
@@ -2103,6 +2103,61 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 批量获取 Claude 账户的 OAuth Usage 数据
|
||||||
|
router.get('/claude-accounts/usage', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const accounts = await redis.getAllClaudeAccounts()
|
||||||
|
|
||||||
|
// 批量并发获取所有活跃 OAuth 账户的 Usage
|
||||||
|
const usagePromises = accounts.map(async (account) => {
|
||||||
|
// 检查是否为 OAuth 账户:scopes 包含 OAuth 相关权限
|
||||||
|
const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : []
|
||||||
|
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||||
|
|
||||||
|
// 仅为 OAuth 授权的活跃账户调用 usage API
|
||||||
|
if (
|
||||||
|
isOAuth &&
|
||||||
|
account.isActive === 'true' &&
|
||||||
|
account.accessToken &&
|
||||||
|
account.status === 'active'
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const usageData = await claudeAccountService.fetchOAuthUsage(account.id)
|
||||||
|
if (usageData) {
|
||||||
|
await claudeAccountService.updateClaudeUsageSnapshot(account.id, usageData)
|
||||||
|
}
|
||||||
|
// 重新读取更新后的数据
|
||||||
|
const updatedAccount = await redis.getClaudeAccount(account.id)
|
||||||
|
return {
|
||||||
|
accountId: account.id,
|
||||||
|
claudeUsage: claudeAccountService.buildClaudeUsageSnapshot(updatedAccount)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to fetch OAuth usage for ${account.id}:`, error.message)
|
||||||
|
return { accountId: account.id, claudeUsage: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Setup Token 账户不调用 usage API,直接返回 null
|
||||||
|
return { accountId: account.id, claudeUsage: null }
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(usagePromises)
|
||||||
|
|
||||||
|
// 转换为 { accountId: usage } 映射
|
||||||
|
const usageMap = {}
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
usageMap[result.value.accountId] = result.value.claudeUsage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ success: true, data: usageMap })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to fetch Claude accounts usage:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch usage data', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 创建新的Claude账户
|
// 创建新的Claude账户
|
||||||
router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -451,6 +451,14 @@ class ClaudeAccountService {
|
|||||||
// 获取会话窗口信息
|
// 获取会话窗口信息
|
||||||
const sessionWindowInfo = await this.getSessionWindowInfo(account.id)
|
const sessionWindowInfo = await this.getSessionWindowInfo(account.id)
|
||||||
|
|
||||||
|
// 构建 Claude Usage 快照(从 Redis 读取)
|
||||||
|
const claudeUsage = this.buildClaudeUsageSnapshot(account)
|
||||||
|
|
||||||
|
// 判断授权类型:检查 scopes 是否包含 OAuth 相关权限
|
||||||
|
const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : []
|
||||||
|
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||||
|
const authType = isOAuth ? 'oauth' : 'setup-token'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
@@ -463,6 +471,7 @@ class ClaudeAccountService {
|
|||||||
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
|
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
|
||||||
priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50
|
priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50
|
||||||
platform: account.platform || 'claude', // 添加平台标识,用于前端区分
|
platform: account.platform || 'claude', // 添加平台标识,用于前端区分
|
||||||
|
authType, // OAuth 或 Setup Token
|
||||||
createdAt: account.createdAt,
|
createdAt: account.createdAt,
|
||||||
lastUsedAt: account.lastUsedAt,
|
lastUsedAt: account.lastUsedAt,
|
||||||
lastRefreshAt: account.lastRefreshAt,
|
lastRefreshAt: account.lastRefreshAt,
|
||||||
@@ -493,6 +502,8 @@ class ClaudeAccountService {
|
|||||||
remainingTime: null,
|
remainingTime: null,
|
||||||
lastRequestTime: null
|
lastRequestTime: null
|
||||||
},
|
},
|
||||||
|
// 添加 Claude Usage 信息(三窗口)
|
||||||
|
claudeUsage: claudeUsage || null,
|
||||||
// 添加调度状态
|
// 添加调度状态
|
||||||
schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据
|
schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据
|
||||||
// 添加自动停止调度设置
|
// 添加自动停止调度设置
|
||||||
@@ -1131,6 +1142,16 @@ class ClaudeAccountService {
|
|||||||
return `${maskedUsername}@${domain}`
|
return `${maskedUsername}@${domain}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔢 安全转换为数字或null
|
||||||
|
_toNumberOrNull(value) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
// 🧹 清理错误账户
|
// 🧹 清理错误账户
|
||||||
async cleanupErrorAccounts() {
|
async cleanupErrorAccounts() {
|
||||||
try {
|
try {
|
||||||
@@ -1578,6 +1599,176 @@ class ClaudeAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📊 获取 OAuth Usage 数据
|
||||||
|
async fetchOAuthUsage(accountId, accessToken = null, agent = null) {
|
||||||
|
try {
|
||||||
|
const accountData = await redis.getClaudeAccount(accountId)
|
||||||
|
if (!accountData || Object.keys(accountData).length === 0) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有提供 accessToken,使用账号存储的 token
|
||||||
|
if (!accessToken) {
|
||||||
|
accessToken = this._decryptSensitiveData(accountData.accessToken)
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('No access token available')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有提供 agent,创建代理
|
||||||
|
if (!agent) {
|
||||||
|
agent = this._createProxyAgent(accountData.proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`📊 Fetching OAuth usage for account: ${accountData.name} (${accountId})`)
|
||||||
|
|
||||||
|
// 请求 OAuth usage 接口
|
||||||
|
const response = await axios.get('https://api.anthropic.com/api/oauth/usage', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
'anthropic-beta': 'oauth-2025-04-20',
|
||||||
|
'User-Agent': 'claude-cli/1.0.56 (external, cli)',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9'
|
||||||
|
},
|
||||||
|
httpsAgent: agent,
|
||||||
|
timeout: 15000
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 200 && response.data) {
|
||||||
|
logger.debug('✅ Successfully fetched OAuth usage data:', {
|
||||||
|
accountId,
|
||||||
|
fiveHour: response.data.five_hour?.utilization,
|
||||||
|
sevenDay: response.data.seven_day?.utilization,
|
||||||
|
sevenDayOpus: response.data.seven_day_opus?.utilization
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(`⚠️ Failed to fetch OAuth usage for account ${accountId}: ${response.status}`)
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
// 403 错误通常表示使用的是 Setup Token 而非 OAuth
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
logger.debug(
|
||||||
|
`⚠️ OAuth usage API returned 403 for account ${accountId}. This account likely uses Setup Token instead of OAuth.`
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他错误正常记录
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to fetch OAuth usage for account ${accountId}:`,
|
||||||
|
error.response?.data || error.message
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📊 构建 Claude Usage 快照(从 Redis 数据)
|
||||||
|
buildClaudeUsageSnapshot(accountData) {
|
||||||
|
const updatedAt = accountData.claudeUsageUpdatedAt
|
||||||
|
|
||||||
|
const fiveHourUtilization = this._toNumberOrNull(accountData.claudeFiveHourUtilization)
|
||||||
|
const fiveHourResetsAt = accountData.claudeFiveHourResetsAt
|
||||||
|
const sevenDayUtilization = this._toNumberOrNull(accountData.claudeSevenDayUtilization)
|
||||||
|
const sevenDayResetsAt = accountData.claudeSevenDayResetsAt
|
||||||
|
const sevenDayOpusUtilization = this._toNumberOrNull(accountData.claudeSevenDayOpusUtilization)
|
||||||
|
const sevenDayOpusResetsAt = accountData.claudeSevenDayOpusResetsAt
|
||||||
|
|
||||||
|
const hasFiveHourData = fiveHourUtilization !== null || fiveHourResetsAt
|
||||||
|
const hasSevenDayData = sevenDayUtilization !== null || sevenDayResetsAt
|
||||||
|
const hasSevenDayOpusData = sevenDayOpusUtilization !== null || sevenDayOpusResetsAt
|
||||||
|
|
||||||
|
if (!updatedAt && !hasFiveHourData && !hasSevenDayData && !hasSevenDayOpusData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
fiveHour: {
|
||||||
|
utilization: fiveHourUtilization,
|
||||||
|
resetsAt: fiveHourResetsAt,
|
||||||
|
remainingSeconds: fiveHourResetsAt
|
||||||
|
? Math.max(0, Math.floor((new Date(fiveHourResetsAt).getTime() - now) / 1000))
|
||||||
|
: null
|
||||||
|
},
|
||||||
|
sevenDay: {
|
||||||
|
utilization: sevenDayUtilization,
|
||||||
|
resetsAt: sevenDayResetsAt,
|
||||||
|
remainingSeconds: sevenDayResetsAt
|
||||||
|
? Math.max(0, Math.floor((new Date(sevenDayResetsAt).getTime() - now) / 1000))
|
||||||
|
: null
|
||||||
|
},
|
||||||
|
sevenDayOpus: {
|
||||||
|
utilization: sevenDayOpusUtilization,
|
||||||
|
resetsAt: sevenDayOpusResetsAt,
|
||||||
|
remainingSeconds: sevenDayOpusResetsAt
|
||||||
|
? Math.max(0, Math.floor((new Date(sevenDayOpusResetsAt).getTime() - now) / 1000))
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📊 更新 Claude Usage 快照到 Redis
|
||||||
|
async updateClaudeUsageSnapshot(accountId, usageData) {
|
||||||
|
if (!usageData || typeof usageData !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {}
|
||||||
|
|
||||||
|
// 5小时窗口
|
||||||
|
if (usageData.five_hour) {
|
||||||
|
if (usageData.five_hour.utilization !== undefined) {
|
||||||
|
updates.claudeFiveHourUtilization = String(usageData.five_hour.utilization)
|
||||||
|
}
|
||||||
|
if (usageData.five_hour.resets_at) {
|
||||||
|
updates.claudeFiveHourResetsAt = usageData.five_hour.resets_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7天窗口
|
||||||
|
if (usageData.seven_day) {
|
||||||
|
if (usageData.seven_day.utilization !== undefined) {
|
||||||
|
updates.claudeSevenDayUtilization = String(usageData.seven_day.utilization)
|
||||||
|
}
|
||||||
|
if (usageData.seven_day.resets_at) {
|
||||||
|
updates.claudeSevenDayResetsAt = usageData.seven_day.resets_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7天Opus窗口
|
||||||
|
if (usageData.seven_day_opus) {
|
||||||
|
if (usageData.seven_day_opus.utilization !== undefined) {
|
||||||
|
updates.claudeSevenDayOpusUtilization = String(usageData.seven_day_opus.utilization)
|
||||||
|
}
|
||||||
|
if (usageData.seven_day_opus.resets_at) {
|
||||||
|
updates.claudeSevenDayOpusResetsAt = usageData.seven_day_opus.resets_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.claudeUsageUpdatedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
const accountData = await redis.getClaudeAccount(accountId)
|
||||||
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
|
Object.assign(accountData, updates)
|
||||||
|
await redis.setClaudeAccount(accountId, accountData)
|
||||||
|
logger.debug(
|
||||||
|
`📊 Updated Claude usage snapshot for account ${accountId}:`,
|
||||||
|
Object.keys(updates)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 📊 获取账号 Profile 信息并更新账号类型
|
// 📊 获取账号 Profile 信息并更新账号类型
|
||||||
async fetchAndUpdateAccountProfile(accountId, accessToken = null, agent = null) {
|
async fetchAndUpdateAccountProfile(accountId, accessToken = null, agent = null) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user