mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: apikey显示最后调度的账号
This commit is contained in:
@@ -4,6 +4,53 @@ const config = require('../../config/config')
|
|||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const logger = require('../utils/logger')
|
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 {
|
class ApiKeyService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.prefix = config.security.apiKeyPrefix
|
this.prefix = config.security.apiKeyPrefix
|
||||||
@@ -418,6 +465,7 @@ class ApiKeyService {
|
|||||||
try {
|
try {
|
||||||
let apiKeys = await redis.getAllApiKeys()
|
let apiKeys = await redis.getAllApiKeys()
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
|
const accountInfoCache = new Map()
|
||||||
|
|
||||||
// 默认过滤掉已删除的API Keys
|
// 默认过滤掉已删除的API Keys
|
||||||
if (!includeDeleted) {
|
if (!includeDeleted) {
|
||||||
@@ -524,6 +572,48 @@ class ApiKeyService {
|
|||||||
if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) {
|
if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) {
|
||||||
delete 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
|
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) {
|
async _publishBillingEvent(eventData) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -686,15 +686,33 @@
|
|||||||
class="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-gray-300"
|
class="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-gray-300"
|
||||||
style="font-size: 13px"
|
style="font-size: 13px"
|
||||||
>
|
>
|
||||||
<span
|
<div class="flex flex-col leading-tight">
|
||||||
v-if="key.lastUsedAt"
|
<span
|
||||||
class="cursor-help"
|
v-if="key.lastUsedAt"
|
||||||
style="font-size: 13px"
|
class="cursor-help"
|
||||||
:title="new Date(key.lastUsedAt).toLocaleString('zh-CN')"
|
style="font-size: 13px"
|
||||||
>
|
:title="new Date(key.lastUsedAt).toLocaleString('zh-CN')"
|
||||||
{{ formatLastUsed(key.lastUsedAt) }}
|
>
|
||||||
</span>
|
{{ formatLastUsed(key.lastUsedAt) }}
|
||||||
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
|
</span>
|
||||||
|
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
|
||||||
|
<span
|
||||||
|
v-if="hasLastUsageAccount(key)"
|
||||||
|
class="mt-1 text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
:title="getLastUsageFullName(key)"
|
||||||
|
>
|
||||||
|
{{ getLastUsageDisplayName(key) }}
|
||||||
|
<span
|
||||||
|
v-if="!isLastUsageDeleted(key)"
|
||||||
|
class="ml-1 text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
({{ getLastUsageTypeLabel(key) }})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
暂无使用账号
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<!-- 创建时间 -->
|
<!-- 创建时间 -->
|
||||||
<td
|
<td
|
||||||
@@ -1258,11 +1276,31 @@
|
|||||||
<p class="text-xs text-gray-500 dark:text-gray-400">费用</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">费用</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-center justify-between">
|
<div class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
<span class="text-xs text-gray-600 dark:text-gray-400">最后使用</span>
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
|
<span>最后使用</span>
|
||||||
formatLastUsed(key.lastUsedAt)
|
<span class="font-medium text-gray-700 dark:text-gray-300">
|
||||||
}}</span>
|
{{ key.lastUsedAt ? formatLastUsed(key.lastUsedAt) : '从未使用' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex items-center justify-between">
|
||||||
|
<span>账号</span>
|
||||||
|
<span
|
||||||
|
v-if="hasLastUsageAccount(key)"
|
||||||
|
class="truncate text-gray-500 dark:text-gray-400"
|
||||||
|
style="max-width: 180px"
|
||||||
|
:title="getLastUsageFullName(key)"
|
||||||
|
>
|
||||||
|
{{ getLastUsageDisplayName(key) }}
|
||||||
|
<span
|
||||||
|
v-if="!isLastUsageDeleted(key)"
|
||||||
|
class="ml-1 text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
({{ getLastUsageTypeLabel(key) }})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-400 dark:text-gray-500">暂无使用账号</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1765,10 +1803,33 @@
|
|||||||
class="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-gray-300"
|
class="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-gray-300"
|
||||||
style="font-size: 13px"
|
style="font-size: 13px"
|
||||||
>
|
>
|
||||||
<span v-if="key.lastUsedAt" style="font-size: 13px">
|
<div class="flex flex-col leading-tight">
|
||||||
{{ formatLastUsed(key.lastUsedAt) }}
|
<span
|
||||||
</span>
|
v-if="key.lastUsedAt"
|
||||||
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
|
class="cursor-help"
|
||||||
|
style="font-size: 13px"
|
||||||
|
:title="new Date(key.lastUsedAt).toLocaleString('zh-CN')"
|
||||||
|
>
|
||||||
|
{{ formatLastUsed(key.lastUsedAt) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
|
||||||
|
<span
|
||||||
|
v-if="hasLastUsageAccount(key)"
|
||||||
|
class="mt-1 text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
:title="getLastUsageFullName(key)"
|
||||||
|
>
|
||||||
|
{{ getLastUsageDisplayName(key) }}
|
||||||
|
<span
|
||||||
|
v-if="!isLastUsageDeleted(key)"
|
||||||
|
class="ml-1 text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
({{ getLastUsageTypeLabel(key) }})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
暂无使用账号
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="operations-column operations-cell px-3 py-3">
|
<td class="operations-column operations-cell px-3 py-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -3676,6 +3737,100 @@ const formatLastUsed = (dateString) => {
|
|||||||
return date.toLocaleDateString('zh-CN')
|
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 = () => {
|
const clearSearch = () => {
|
||||||
searchKeyword.value = ''
|
searchKeyword.value = ''
|
||||||
@@ -3785,7 +3940,9 @@ const exportToExcel = () => {
|
|||||||
Token数: formatTokenCount(periodTokens),
|
Token数: formatTokenCount(periodTokens),
|
||||||
输入Token: formatTokenCount(periodInputTokens),
|
输入Token: formatTokenCount(periodInputTokens),
|
||||||
输出Token: formatTokenCount(periodOutputTokens),
|
输出Token: formatTokenCount(periodOutputTokens),
|
||||||
最后使用时间: key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用'
|
最后使用时间: key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用',
|
||||||
|
最后使用账号: getLastUsageFullName(key),
|
||||||
|
最后使用类型: getLastUsageTypeLabel(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加分模型统计
|
// 添加分模型统计
|
||||||
|
|||||||
Reference in New Issue
Block a user