diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 962d14ee..7674a3cd 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -4,6 +4,53 @@ const config = require('../../config/config') const redis = require('../models/redis') const logger = require('../utils/logger') +const ACCOUNT_TYPE_CONFIG = { + claude: { prefix: 'claude_account:', category: 'claude' }, + 'claude-console': { prefix: 'claude_console_account:', category: 'claude' }, + openai: { prefix: 'openai_account:', category: 'openai' }, + 'openai-responses': { prefix: 'openai_responses_account:', category: 'openai' }, + 'azure-openai': { prefix: 'azure_openai:account:', category: 'openai' }, + gemini: { prefix: 'gemini_account:', category: 'gemini' }, + droid: { prefix: 'droid:account:', category: 'droid' } +} + +const DEFAULT_LAST_USAGE_TYPES = [ + 'claude', + 'claude-console', + 'openai', + 'openai-responses', + 'azure-openai', + 'gemini', + 'droid' +] + +function normalizeAccountTypeKey(type) { + if (!type) { + return null + } + const lower = String(type).toLowerCase() + if (lower === 'claude_console') { + return 'claude-console' + } + if (lower === 'openai_responses' || lower === 'openai-response' || lower === 'openai-responses') { + return 'openai-responses' + } + if (lower === 'azure_openai' || lower === 'azureopenai' || lower === 'azure-openai') { + return 'azure-openai' + } + return lower +} + +function sanitizeAccountIdForType(accountId, accountType) { + if (!accountId || typeof accountId !== 'string') { + return accountId + } + if (accountType === 'openai-responses') { + return accountId.replace(/^responses:/, '') + } + return accountId +} + class ApiKeyService { constructor() { this.prefix = config.security.apiKeyPrefix @@ -418,6 +465,7 @@ class ApiKeyService { try { let apiKeys = await redis.getAllApiKeys() const client = redis.getClientSafe() + const accountInfoCache = new Map() // 默认过滤掉已删除的API Keys if (!includeDeleted) { @@ -524,6 +572,48 @@ class ApiKeyService { if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) { delete key.ccrAccountId } + + let lastUsageRecord = null + try { + const usageRecords = await redis.getUsageRecords(key.id, 1) + if (Array.isArray(usageRecords) && usageRecords.length > 0) { + lastUsageRecord = usageRecords[0] + } + } catch (error) { + logger.debug(`加载 API Key ${key.id} 的使用记录失败:`, error) + } + + if (lastUsageRecord && (lastUsageRecord.accountId || lastUsageRecord.accountType)) { + const resolvedAccount = await this._resolveLastUsageAccount( + key, + lastUsageRecord, + accountInfoCache, + client + ) + + if (resolvedAccount) { + key.lastUsage = { + accountId: resolvedAccount.accountId, + rawAccountId: lastUsageRecord.accountId || resolvedAccount.accountId, + accountType: resolvedAccount.accountType, + accountCategory: resolvedAccount.accountCategory, + accountName: resolvedAccount.accountName, + recordedAt: lastUsageRecord.timestamp || key.lastUsedAt || null + } + } else { + key.lastUsage = { + accountId: null, + rawAccountId: lastUsageRecord.accountId || null, + accountType: 'deleted', + accountCategory: 'deleted', + accountName: '已删除', + recordedAt: lastUsageRecord.timestamp || key.lastUsedAt || null + } + } + } else { + key.lastUsage = null + } + delete key.apiKey // 不返回哈希后的key } @@ -1161,6 +1251,125 @@ class ApiKeyService { } } + async _fetchAccountInfo(accountId, accountType, cache, client) { + if (!client || !accountId || !accountType) { + return null + } + + const cacheKey = `${accountType}:${accountId}` + if (cache.has(cacheKey)) { + return cache.get(cacheKey) + } + + const accountConfig = ACCOUNT_TYPE_CONFIG[accountType] + if (!accountConfig) { + cache.set(cacheKey, null) + return null + } + + const redisKey = `${accountConfig.prefix}${accountId}` + let accountData = null + try { + accountData = await client.hgetall(redisKey) + } catch (error) { + logger.debug(`加载账号信息失败 ${redisKey}:`, error) + } + + if (accountData && Object.keys(accountData).length > 0) { + const displayName = + accountData.name || + accountData.displayName || + accountData.email || + accountData.username || + accountData.description || + accountId + + const info = { id: accountId, name: displayName } + cache.set(cacheKey, info) + return info + } + + cache.set(cacheKey, null) + return null + } + + async _resolveLastUsageAccount(apiKey, usageRecord, cache, client) { + if (!client || !usageRecord) { + return null + } + + const candidateIds = new Set() + const addId = (value) => { + if (!value) { + return + } + candidateIds.add(value) + if (typeof value === 'string' && value.startsWith('responses:')) { + candidateIds.add(value.replace(/^responses:/, '')) + } + } + + addId(usageRecord.accountId) + addId(apiKey?.openaiAccountId) + addId(apiKey?.azureOpenaiAccountId) + addId(apiKey?.claudeAccountId) + addId(apiKey?.claudeConsoleAccountId) + addId(apiKey?.geminiAccountId) + addId(apiKey?.droidAccountId) + + const candidateTypes = [] + const addType = (type) => { + const normalized = normalizeAccountTypeKey(type) + if (normalized && !candidateTypes.includes(normalized)) { + candidateTypes.push(normalized) + } + } + + addType(usageRecord.accountType) + if (apiKey?.claudeAccountId) { + addType('claude') + } + if (apiKey?.claudeConsoleAccountId) { + addType('claude-console') + } + if (apiKey?.geminiAccountId) { + addType('gemini') + } + if (apiKey?.openaiAccountId) { + addType(apiKey.openaiAccountId.startsWith('responses:') ? 'openai-responses' : 'openai') + } + if (apiKey?.azureOpenaiAccountId) { + addType('azure-openai') + } + if (apiKey?.droidAccountId) { + addType('droid') + } + + DEFAULT_LAST_USAGE_TYPES.forEach(addType) + + for (const type of candidateTypes) { + const accountConfig = ACCOUNT_TYPE_CONFIG[type] + if (!accountConfig) { + continue + } + + for (const candidateId of candidateIds) { + const normalizedId = sanitizeAccountIdForType(candidateId, type) + const accountInfo = await this._fetchAccountInfo(normalizedId, type, cache, client) + if (accountInfo) { + return { + accountId: accountInfo.id, + accountName: accountInfo.name, + accountType: type, + accountCategory: accountConfig.category + } + } + } + } + + return null + } + // 🔔 发布计费事件(内部方法) async _publishBillingEvent(eventData) { try { diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index f24d66bf..5ba8ca85 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -686,15 +686,33 @@ class="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-gray-300" style="font-size: 13px" > - - {{ formatLastUsed(key.lastUsedAt) }} - - 从未使用 +
+ + {{ formatLastUsed(key.lastUsedAt) }} + + 从未使用 + + {{ getLastUsageDisplayName(key) }} + + ({{ getLastUsageTypeLabel(key) }}) + + + + 暂无使用账号 + +
费用

-
- 最后使用 - {{ - formatLastUsed(key.lastUsedAt) - }} +
+
+ 最后使用 + + {{ key.lastUsedAt ? formatLastUsed(key.lastUsedAt) : '从未使用' }} + +
+
+ 账号 + + {{ getLastUsageDisplayName(key) }} + + ({{ getLastUsageTypeLabel(key) }}) + + + 暂无使用账号 +
@@ -1765,10 +1803,33 @@ class="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-gray-300" style="font-size: 13px" > - - {{ formatLastUsed(key.lastUsedAt) }} - - 从未使用 +
+ + {{ formatLastUsed(key.lastUsedAt) }} + + 从未使用 + + {{ getLastUsageDisplayName(key) }} + + ({{ getLastUsageTypeLabel(key) }}) + + + + 暂无使用账号 + +
@@ -3676,6 +3737,100 @@ const formatLastUsed = (dateString) => { return date.toLocaleDateString('zh-CN') } +const ACCOUNT_TYPE_LABELS = { + claude: 'Claude', + openai: 'OpenAI', + gemini: 'Gemini', + droid: 'Droid', + deleted: '已删除', + other: '其他' +} + +const MAX_LAST_USAGE_NAME_LENGTH = 16 + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +const normalizeFrontendAccountCategory = (type) => { + if (!type) return 'other' + const lower = String(type).toLowerCase() + if (lower === 'claude-console' || lower === 'claude_console' || lower === 'claude') { + return 'claude' + } + if ( + lower === 'openai' || + lower === 'openai-responses' || + lower === 'openai_responses' || + lower === 'azure-openai' || + lower === 'azure_openai' + ) { + return 'openai' + } + if (lower === 'gemini') { + return 'gemini' + } + if (lower === 'droid') { + return 'droid' + } + return 'other' +} + +const getLastUsageInfo = (apiKey) => apiKey?.lastUsage || null + +const hasLastUsageAccount = (apiKey) => { + const info = getLastUsageInfo(apiKey) + return !!(info && (info.accountName || info.accountId || info.rawAccountId)) +} + +const isLikelyDeletedUsage = (info) => { + if (!info) return false + if (info.accountCategory === 'deleted') return true + + const rawId = typeof info.rawAccountId === 'string' ? info.rawAccountId.trim() : '' + const accountName = typeof info.accountName === 'string' ? info.accountName.trim() : '' + const accountType = + typeof info.accountType === 'string' ? info.accountType.trim().toLowerCase() : '' + + if (!rawId) return false + + const looksLikeUuid = UUID_PATTERN.test(rawId) + const nameMissingOrSame = !accountName || accountName === rawId + const typeUnknown = + !accountType || accountType === 'unknown' || ACCOUNT_TYPE_LABELS[accountType] === undefined + + return looksLikeUuid && nameMissingOrSame && typeUnknown +} + +const getLastUsageBaseName = (info) => { + if (!info) return '未知账号' + if (isLikelyDeletedUsage(info)) { + return '已删除' + } + return info.accountName || info.accountId || info.rawAccountId || '未知账号' +} + +const getLastUsageFullName = (apiKey) => getLastUsageBaseName(getLastUsageInfo(apiKey)) + +const getLastUsageDisplayName = (apiKey) => { + const full = getLastUsageFullName(apiKey) + return full.length > MAX_LAST_USAGE_NAME_LENGTH + ? `${full.slice(0, MAX_LAST_USAGE_NAME_LENGTH)}...` + : full +} + +const getLastUsageTypeLabel = (apiKey) => { + const info = getLastUsageInfo(apiKey) + if (isLikelyDeletedUsage(info)) { + return ACCOUNT_TYPE_LABELS.deleted + } + const category = info?.accountCategory || normalizeFrontendAccountCategory(info?.accountType) + return ACCOUNT_TYPE_LABELS[category] || ACCOUNT_TYPE_LABELS.other +} + +const isLastUsageDeleted = (apiKey) => { + const info = getLastUsageInfo(apiKey) + return isLikelyDeletedUsage(info) +} + // 清除搜索 const clearSearch = () => { searchKeyword.value = '' @@ -3785,7 +3940,9 @@ const exportToExcel = () => { Token数: formatTokenCount(periodTokens), 输入Token: formatTokenCount(periodInputTokens), 输出Token: formatTokenCount(periodOutputTokens), - 最后使用时间: key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用' + 最后使用时间: key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用', + 最后使用账号: getLastUsageFullName(key), + 最后使用类型: getLastUsageTypeLabel(key) } // 添加分模型统计