mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 21:17:30 +00:00
feat: 大规模性能优化 - Redis Pipeline 批量操作、索引系统、连接池优化
This commit is contained in:
@@ -7,6 +7,56 @@ class AccountGroupService {
|
||||
this.GROUPS_KEY = 'account_groups'
|
||||
this.GROUP_PREFIX = 'account_group:'
|
||||
this.GROUP_MEMBERS_PREFIX = 'account_group_members:'
|
||||
this.REVERSE_INDEX_PREFIX = 'account_groups_reverse:'
|
||||
this.REVERSE_INDEX_MIGRATED_KEY = 'account_groups_reverse:migrated'
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保反向索引存在(启动时自动调用)
|
||||
* 检查是否已迁移,如果没有则自动回填
|
||||
*/
|
||||
async ensureReverseIndexes() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
if (!client) return
|
||||
|
||||
// 检查是否已迁移
|
||||
const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY)
|
||||
if (migrated === 'true') {
|
||||
logger.debug('📁 账户分组反向索引已存在,跳过回填')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('📁 开始回填账户分组反向索引...')
|
||||
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||
if (allGroupIds.length === 0) {
|
||||
await client.set(this.REVERSE_INDEX_MIGRATED_KEY, 'true')
|
||||
return
|
||||
}
|
||||
|
||||
let totalOperations = 0
|
||||
|
||||
for (const groupId of allGroupIds) {
|
||||
const group = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`)
|
||||
if (!group || !group.platform) continue
|
||||
|
||||
const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`)
|
||||
if (members.length === 0) continue
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
for (const accountId of members) {
|
||||
pipeline.sadd(`${this.REVERSE_INDEX_PREFIX}${group.platform}:${accountId}`, groupId)
|
||||
}
|
||||
await pipeline.exec()
|
||||
totalOperations += members.length
|
||||
}
|
||||
|
||||
await client.set(this.REVERSE_INDEX_MIGRATED_KEY, 'true')
|
||||
logger.success(`📁 账户分组反向索引回填完成,共 ${totalOperations} 条`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 账户分组反向索引回填失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +100,7 @@ class AccountGroupService {
|
||||
// 添加到分组集合
|
||||
await client.sadd(this.GROUPS_KEY, groupId)
|
||||
|
||||
logger.success(`✅ 创建账户分组成功: ${name} (${platform})`)
|
||||
logger.success(`创建账户分组成功: ${name} (${platform})`)
|
||||
|
||||
return group
|
||||
} catch (error) {
|
||||
@@ -101,7 +151,7 @@ class AccountGroupService {
|
||||
// 返回更新后的完整数据
|
||||
const updatedGroup = await client.hgetall(groupKey)
|
||||
|
||||
logger.success(`✅ 更新账户分组成功: ${updatedGroup.name}`)
|
||||
logger.success(`更新账户分组成功: ${updatedGroup.name}`)
|
||||
|
||||
return updatedGroup
|
||||
} catch (error) {
|
||||
@@ -143,7 +193,7 @@ class AccountGroupService {
|
||||
// 从分组集合中移除
|
||||
await client.srem(this.GROUPS_KEY, groupId)
|
||||
|
||||
logger.success(`✅ 删除账户分组成功: ${group.name}`)
|
||||
logger.success(`删除账户分组成功: ${group.name}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 删除账户分组失败:', error)
|
||||
throw error
|
||||
@@ -234,7 +284,10 @@ class AccountGroupService {
|
||||
// 添加到分组成员集合
|
||||
await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
|
||||
logger.success(`✅ 添加账户到分组成功: ${accountId} -> ${group.name}`)
|
||||
// 维护反向索引
|
||||
await client.sadd(`account_groups_reverse:${group.platform}:${accountId}`, groupId)
|
||||
|
||||
logger.success(`添加账户到分组成功: ${accountId} -> ${group.name}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 添加账户到分组失败:', error)
|
||||
throw error
|
||||
@@ -245,15 +298,26 @@ class AccountGroupService {
|
||||
* 从分组移除账户
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} groupId - 分组ID
|
||||
* @param {string} platform - 平台(可选,如果不传则从分组获取)
|
||||
*/
|
||||
async removeAccountFromGroup(accountId, groupId) {
|
||||
async removeAccountFromGroup(accountId, groupId, platform = null) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 从分组成员集合中移除
|
||||
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
|
||||
logger.success(`✅ 从分组移除账户成功: ${accountId}`)
|
||||
// 维护反向索引
|
||||
let groupPlatform = platform
|
||||
if (!groupPlatform) {
|
||||
const group = await this.getGroup(groupId)
|
||||
groupPlatform = group?.platform
|
||||
}
|
||||
if (groupPlatform) {
|
||||
await client.srem(`account_groups_reverse:${groupPlatform}:${accountId}`, groupId)
|
||||
}
|
||||
|
||||
logger.success(`从分组移除账户成功: ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 从分组移除账户失败:', error)
|
||||
throw error
|
||||
@@ -399,7 +463,7 @@ class AccountGroupService {
|
||||
await this.addAccountToGroup(accountId, groupId, accountPlatform)
|
||||
}
|
||||
|
||||
logger.success(`✅ 批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
|
||||
logger.success(`批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 批量设置账户分组失败:', error)
|
||||
throw error
|
||||
@@ -409,8 +473,9 @@ class AccountGroupService {
|
||||
/**
|
||||
* 从所有分组中移除账户
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} platform - 平台(可选,用于清理反向索引)
|
||||
*/
|
||||
async removeAccountFromAllGroups(accountId) {
|
||||
async removeAccountFromAllGroups(accountId, platform = null) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY)
|
||||
@@ -419,12 +484,127 @@ class AccountGroupService {
|
||||
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId)
|
||||
}
|
||||
|
||||
logger.success(`✅ 从所有分组移除账户成功: ${accountId}`)
|
||||
// 清理反向索引
|
||||
if (platform) {
|
||||
await client.del(`account_groups_reverse:${platform}:${accountId}`)
|
||||
} else {
|
||||
// 如果没有指定平台,清理所有可能的平台
|
||||
const platforms = ['claude', 'gemini', 'openai', 'droid']
|
||||
const pipeline = client.pipeline()
|
||||
for (const p of platforms) {
|
||||
pipeline.del(`account_groups_reverse:${p}:${accountId}`)
|
||||
}
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
logger.success(`从所有分组移除账户成功: ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 从所有分组移除账户失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取多个账户的分组信息(性能优化版本,使用反向索引)
|
||||
* @param {Array<string>} accountIds - 账户ID数组
|
||||
* @param {string} platform - 平台类型
|
||||
* @param {Object} options - 选项
|
||||
* @param {boolean} options.skipMemberCount - 是否跳过 memberCount(默认 true)
|
||||
* @returns {Map<string, Array>} accountId -> 分组信息数组的映射
|
||||
*/
|
||||
async batchGetAccountGroupsByIndex(accountIds, platform, options = {}) {
|
||||
const { skipMemberCount = true } = options
|
||||
|
||||
if (!accountIds || accountIds.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// Pipeline 批量获取所有账户的分组ID
|
||||
const pipeline = client.pipeline()
|
||||
for (const accountId of accountIds) {
|
||||
pipeline.smembers(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`)
|
||||
}
|
||||
const groupIdResults = await pipeline.exec()
|
||||
|
||||
// 收集所有需要的分组ID
|
||||
const uniqueGroupIds = new Set()
|
||||
const accountGroupIdsMap = new Map()
|
||||
let hasAnyGroups = false
|
||||
accountIds.forEach((accountId, i) => {
|
||||
const [err, groupIds] = groupIdResults[i]
|
||||
const ids = err ? [] : groupIds || []
|
||||
accountGroupIdsMap.set(accountId, ids)
|
||||
ids.forEach((id) => {
|
||||
uniqueGroupIds.add(id)
|
||||
hasAnyGroups = true
|
||||
})
|
||||
})
|
||||
|
||||
// 如果反向索引全空,回退到原方法(兼容未迁移的数据)
|
||||
if (!hasAnyGroups) {
|
||||
const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY)
|
||||
if (migrated !== 'true') {
|
||||
logger.debug('📁 Reverse index not migrated, falling back to getAccountGroups')
|
||||
const result = new Map()
|
||||
for (const accountId of accountIds) {
|
||||
try {
|
||||
const groups = await this.getAccountGroups(accountId)
|
||||
result.set(accountId, groups)
|
||||
} catch {
|
||||
result.set(accountId, [])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// 批量获取分组详情
|
||||
const groupDetailsMap = new Map()
|
||||
if (uniqueGroupIds.size > 0) {
|
||||
const detailPipeline = client.pipeline()
|
||||
const groupIdArray = Array.from(uniqueGroupIds)
|
||||
for (const groupId of groupIdArray) {
|
||||
detailPipeline.hgetall(`${this.GROUP_PREFIX}${groupId}`)
|
||||
if (!skipMemberCount) {
|
||||
detailPipeline.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`)
|
||||
}
|
||||
}
|
||||
const detailResults = await detailPipeline.exec()
|
||||
|
||||
const step = skipMemberCount ? 1 : 2
|
||||
for (let i = 0; i < groupIdArray.length; i++) {
|
||||
const groupId = groupIdArray[i]
|
||||
const [err1, groupData] = detailResults[i * step]
|
||||
if (!err1 && groupData && Object.keys(groupData).length > 0) {
|
||||
const group = { ...groupData }
|
||||
if (!skipMemberCount) {
|
||||
const [err2, memberCount] = detailResults[i * step + 1]
|
||||
group.memberCount = err2 ? 0 : memberCount || 0
|
||||
}
|
||||
groupDetailsMap.set(groupId, group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建最终结果
|
||||
const result = new Map()
|
||||
for (const [accountId, groupIds] of accountGroupIdsMap) {
|
||||
const groups = groupIds
|
||||
.map((gid) => groupDetailsMap.get(gid))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
result.set(accountId, groups)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('❌ 批量获取账户分组失败:', error)
|
||||
return new Map(accountIds.map((id) => [id, []]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AccountGroupService()
|
||||
|
||||
642
src/services/apiKeyIndexService.js
Normal file
642
src/services/apiKeyIndexService.js
Normal file
@@ -0,0 +1,642 @@
|
||||
/**
|
||||
* API Key 索引服务
|
||||
* 维护 Sorted Set 索引以支持高效分页查询
|
||||
*/
|
||||
|
||||
const { randomUUID } = require('crypto')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
class ApiKeyIndexService {
|
||||
constructor() {
|
||||
this.redis = null
|
||||
this.INDEX_VERSION_KEY = 'apikey:index:version'
|
||||
this.CURRENT_VERSION = 2 // 版本升级,触发重建
|
||||
this.isBuilding = false
|
||||
this.buildProgress = { current: 0, total: 0 }
|
||||
|
||||
// 索引键名
|
||||
this.INDEX_KEYS = {
|
||||
CREATED_AT: 'apikey:idx:createdAt',
|
||||
LAST_USED_AT: 'apikey:idx:lastUsedAt',
|
||||
NAME: 'apikey:idx:name',
|
||||
ACTIVE_SET: 'apikey:set:active',
|
||||
DELETED_SET: 'apikey:set:deleted',
|
||||
ALL_SET: 'apikey:idx:all',
|
||||
TAGS_ALL: 'apikey:tags:all' // 所有标签的集合
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化服务
|
||||
*/
|
||||
init(redis) {
|
||||
this.redis = redis
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时检查并重建索引
|
||||
*/
|
||||
async checkAndRebuild() {
|
||||
if (!this.redis) {
|
||||
logger.warn('⚠️ ApiKeyIndexService: Redis not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const version = await client.get(this.INDEX_VERSION_KEY)
|
||||
|
||||
// 始终检查并回填 hash_map(幂等操作,确保升级兼容)
|
||||
this.rebuildHashMap().catch((err) => {
|
||||
logger.error('❌ API Key hash_map 回填失败:', err)
|
||||
})
|
||||
|
||||
if (parseInt(version) >= this.CURRENT_VERSION) {
|
||||
logger.info('✅ API Key 索引已是最新版本')
|
||||
return
|
||||
}
|
||||
|
||||
// 后台异步重建,不阻塞启动
|
||||
this.rebuildIndexes().catch((err) => {
|
||||
logger.error('❌ API Key 索引重建失败:', err)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ 检查 API Key 索引版本失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回填 apikey:hash_map(升级兼容)
|
||||
* 扫描所有 API Key,确保 hash -> keyId 映射存在
|
||||
*/
|
||||
async rebuildHashMap() {
|
||||
if (!this.redis) return
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const keyIds = await this.redis.scanApiKeyIds()
|
||||
|
||||
let rebuilt = 0
|
||||
const BATCH_SIZE = 100
|
||||
|
||||
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
|
||||
const batch = keyIds.slice(i, i + BATCH_SIZE)
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
// 批量获取 API Key 数据
|
||||
for (const keyId of batch) {
|
||||
pipeline.hgetall(`apikey:${keyId}`)
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
// 检查并回填缺失的映射
|
||||
const fillPipeline = client.pipeline()
|
||||
let needFill = false
|
||||
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const keyData = results[j]?.[1]
|
||||
if (keyData && keyData.apiKey) {
|
||||
// keyData.apiKey 存储的是哈希值
|
||||
const exists = await client.hexists('apikey:hash_map', keyData.apiKey)
|
||||
if (!exists) {
|
||||
fillPipeline.hset('apikey:hash_map', keyData.apiKey, batch[j])
|
||||
rebuilt++
|
||||
needFill = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needFill) {
|
||||
await fillPipeline.exec()
|
||||
}
|
||||
}
|
||||
|
||||
if (rebuilt > 0) {
|
||||
logger.info(`🔧 回填了 ${rebuilt} 个 API Key 到 hash_map`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ 回填 hash_map 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查索引是否可用
|
||||
*/
|
||||
async isIndexReady() {
|
||||
if (!this.redis || this.isBuilding) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const version = await client.get(this.INDEX_VERSION_KEY)
|
||||
return parseInt(version) >= this.CURRENT_VERSION
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建所有索引
|
||||
*/
|
||||
async rebuildIndexes() {
|
||||
if (this.isBuilding) {
|
||||
logger.warn('⚠️ API Key 索引正在重建中,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
this.isBuilding = true
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
logger.info('🔨 开始重建 API Key 索引...')
|
||||
|
||||
// 0. 先删除版本号,让 _checkIndexReady 返回 false,查询回退到 SCAN
|
||||
await client.del(this.INDEX_VERSION_KEY)
|
||||
|
||||
// 1. 清除旧索引
|
||||
const indexKeys = Object.values(this.INDEX_KEYS)
|
||||
for (const key of indexKeys) {
|
||||
await client.del(key)
|
||||
}
|
||||
// 清除标签索引(用 SCAN 避免阻塞)
|
||||
let cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'apikey:tag:*', 'COUNT', 100)
|
||||
cursor = newCursor
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys)
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
// 2. 扫描所有 API Key
|
||||
const keyIds = await this.redis.scanApiKeyIds()
|
||||
this.buildProgress = { current: 0, total: keyIds.length }
|
||||
|
||||
logger.info(`📊 发现 ${keyIds.length} 个 API Key,开始建立索引...`)
|
||||
|
||||
// 3. 批量处理(每批 500 个)
|
||||
const BATCH_SIZE = 500
|
||||
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
|
||||
const batch = keyIds.slice(i, i + BATCH_SIZE)
|
||||
const apiKeys = await this.redis.batchGetApiKeys(batch)
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
for (const apiKey of apiKeys) {
|
||||
if (!apiKey || !apiKey.id) continue
|
||||
|
||||
const keyId = apiKey.id
|
||||
const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : 0
|
||||
const lastUsedAt = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).getTime() : 0
|
||||
const name = (apiKey.name || '').toLowerCase()
|
||||
const isActive = apiKey.isActive === true || apiKey.isActive === 'true'
|
||||
const isDeleted = apiKey.isDeleted === true || apiKey.isDeleted === 'true'
|
||||
|
||||
// 创建时间索引
|
||||
pipeline.zadd(this.INDEX_KEYS.CREATED_AT, createdAt, keyId)
|
||||
|
||||
// 最后使用时间索引
|
||||
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
|
||||
|
||||
// 名称索引(用于排序,存储格式:name\0keyId)
|
||||
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${name}\x00${keyId}`)
|
||||
|
||||
// 全部集合
|
||||
pipeline.sadd(this.INDEX_KEYS.ALL_SET, keyId)
|
||||
|
||||
// 状态集合
|
||||
if (isDeleted) {
|
||||
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
} else if (isActive) {
|
||||
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
}
|
||||
|
||||
// 标签索引
|
||||
const tags = Array.isArray(apiKey.tags) ? apiKey.tags : []
|
||||
for (const tag of tags) {
|
||||
if (tag && typeof tag === 'string') {
|
||||
pipeline.sadd(`apikey:tag:${tag}`, keyId)
|
||||
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag) // 维护标签集合
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
this.buildProgress.current = Math.min(i + BATCH_SIZE, keyIds.length)
|
||||
|
||||
// 每批次后短暂让出 CPU
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
// 4. 更新版本号
|
||||
await client.set(this.INDEX_VERSION_KEY, this.CURRENT_VERSION)
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
|
||||
logger.success(`✅ API Key 索引重建完成,共 ${keyIds.length} 条,耗时 ${duration}s`)
|
||||
} catch (error) {
|
||||
logger.error('❌ API Key 索引重建失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
this.isBuilding = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加单个 API Key 到索引
|
||||
*/
|
||||
async addToIndex(apiKey) {
|
||||
if (!this.redis || !apiKey || !apiKey.id) return
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const keyId = apiKey.id
|
||||
const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : Date.now()
|
||||
const lastUsedAt = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).getTime() : 0
|
||||
const name = (apiKey.name || '').toLowerCase()
|
||||
const isActive = apiKey.isActive === true || apiKey.isActive === 'true'
|
||||
const isDeleted = apiKey.isDeleted === true || apiKey.isDeleted === 'true'
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
pipeline.zadd(this.INDEX_KEYS.CREATED_AT, createdAt, keyId)
|
||||
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
|
||||
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${name}\x00${keyId}`)
|
||||
pipeline.sadd(this.INDEX_KEYS.ALL_SET, keyId)
|
||||
|
||||
if (isDeleted) {
|
||||
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
} else if (isActive) {
|
||||
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
} else {
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
}
|
||||
|
||||
// 标签索引
|
||||
const tags = Array.isArray(apiKey.tags) ? apiKey.tags : []
|
||||
for (const tag of tags) {
|
||||
if (tag && typeof tag === 'string') {
|
||||
pipeline.sadd(`apikey:tag:${tag}`, keyId)
|
||||
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
} catch (error) {
|
||||
logger.error(`❌ 添加 API Key ${apiKey.id} 到索引失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新索引(状态、名称、标签变化时调用)
|
||||
*/
|
||||
async updateIndex(keyId, updates, oldData = {}) {
|
||||
if (!this.redis || !keyId) return
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
// 更新名称索引
|
||||
if (updates.name !== undefined) {
|
||||
const oldName = (oldData.name || '').toLowerCase()
|
||||
const newName = (updates.name || '').toLowerCase()
|
||||
if (oldName !== newName) {
|
||||
pipeline.zrem(this.INDEX_KEYS.NAME, `${oldName}\x00${keyId}`)
|
||||
pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${newName}\x00${keyId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新最后使用时间索引
|
||||
if (updates.lastUsedAt !== undefined) {
|
||||
const lastUsedAt = updates.lastUsedAt ? new Date(updates.lastUsedAt).getTime() : 0
|
||||
pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId)
|
||||
}
|
||||
|
||||
// 更新状态集合
|
||||
if (updates.isActive !== undefined || updates.isDeleted !== undefined) {
|
||||
const isActive = updates.isActive ?? oldData.isActive
|
||||
const isDeleted = updates.isDeleted ?? oldData.isDeleted
|
||||
|
||||
if (isDeleted === true || isDeleted === 'true') {
|
||||
pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
} else if (isActive === true || isActive === 'true') {
|
||||
pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
} else {
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新标签索引
|
||||
const removedTags = []
|
||||
if (updates.tags !== undefined) {
|
||||
const oldTags = Array.isArray(oldData.tags) ? oldData.tags : []
|
||||
const newTags = Array.isArray(updates.tags) ? updates.tags : []
|
||||
|
||||
// 移除旧标签
|
||||
for (const tag of oldTags) {
|
||||
if (tag && !newTags.includes(tag)) {
|
||||
pipeline.srem(`apikey:tag:${tag}`, keyId)
|
||||
removedTags.push(tag)
|
||||
}
|
||||
}
|
||||
// 添加新标签
|
||||
for (const tag of newTags) {
|
||||
if (tag && typeof tag === 'string') {
|
||||
pipeline.sadd(`apikey:tag:${tag}`, keyId)
|
||||
pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
|
||||
// 检查被移除的标签集合是否为空,为空则从 tags:all 移除
|
||||
for (const tag of removedTags) {
|
||||
const count = await client.scard(`apikey:tag:${tag}`)
|
||||
if (count === 0) {
|
||||
await client.srem(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ 更新 API Key ${keyId} 索引失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从索引中移除 API Key
|
||||
*/
|
||||
async removeFromIndex(keyId, oldData = {}) {
|
||||
if (!this.redis || !keyId) return
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
const name = (oldData.name || '').toLowerCase()
|
||||
|
||||
pipeline.zrem(this.INDEX_KEYS.CREATED_AT, keyId)
|
||||
pipeline.zrem(this.INDEX_KEYS.LAST_USED_AT, keyId)
|
||||
pipeline.zrem(this.INDEX_KEYS.NAME, `${name}\x00${keyId}`)
|
||||
pipeline.srem(this.INDEX_KEYS.ALL_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId)
|
||||
pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId)
|
||||
|
||||
// 移除标签索引
|
||||
const tags = Array.isArray(oldData.tags) ? oldData.tags : []
|
||||
for (const tag of tags) {
|
||||
if (tag) {
|
||||
pipeline.srem(`apikey:tag:${tag}`, keyId)
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
|
||||
// 检查标签集合是否为空,为空则从 tags:all 移除
|
||||
for (const tag of tags) {
|
||||
if (tag) {
|
||||
const count = await client.scard(`apikey:tag:${tag}`)
|
||||
if (count === 0) {
|
||||
await client.srem(this.INDEX_KEYS.TAGS_ALL, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ 从索引移除 API Key ${keyId} 失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用索引进行分页查询
|
||||
* 使用 ZINTERSTORE 优化,避免全量拉回内存
|
||||
*/
|
||||
async queryWithIndex(options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc',
|
||||
isActive,
|
||||
tag,
|
||||
excludeDeleted = true
|
||||
} = options
|
||||
|
||||
const client = this.redis.getClientSafe()
|
||||
const tempSets = []
|
||||
|
||||
try {
|
||||
// 1. 构建筛选集合
|
||||
let filterSet = this.INDEX_KEYS.ALL_SET
|
||||
|
||||
// 状态筛选
|
||||
if (isActive === true || isActive === 'true') {
|
||||
// 筛选活跃的
|
||||
filterSet = this.INDEX_KEYS.ACTIVE_SET
|
||||
} else if (isActive === false || isActive === 'false') {
|
||||
// 筛选未激活的 = ALL - ACTIVE (- DELETED if excludeDeleted)
|
||||
const tempKey = `apikey:tmp:inactive:${randomUUID()}`
|
||||
if (excludeDeleted) {
|
||||
await client.sdiffstore(
|
||||
tempKey,
|
||||
this.INDEX_KEYS.ALL_SET,
|
||||
this.INDEX_KEYS.ACTIVE_SET,
|
||||
this.INDEX_KEYS.DELETED_SET
|
||||
)
|
||||
} else {
|
||||
await client.sdiffstore(tempKey, this.INDEX_KEYS.ALL_SET, this.INDEX_KEYS.ACTIVE_SET)
|
||||
}
|
||||
await client.expire(tempKey, 60)
|
||||
filterSet = tempKey
|
||||
tempSets.push(tempKey)
|
||||
} else if (excludeDeleted) {
|
||||
// 排除已删除:ALL - DELETED
|
||||
const tempKey = `apikey:tmp:notdeleted:${randomUUID()}`
|
||||
await client.sdiffstore(tempKey, this.INDEX_KEYS.ALL_SET, this.INDEX_KEYS.DELETED_SET)
|
||||
await client.expire(tempKey, 60)
|
||||
filterSet = tempKey
|
||||
tempSets.push(tempKey)
|
||||
}
|
||||
|
||||
// 标签筛选
|
||||
if (tag) {
|
||||
const tagSet = `apikey:tag:${tag}`
|
||||
const tempKey = `apikey:tmp:tag:${randomUUID()}`
|
||||
await client.sinterstore(tempKey, filterSet, tagSet)
|
||||
await client.expire(tempKey, 60)
|
||||
filterSet = tempKey
|
||||
tempSets.push(tempKey)
|
||||
}
|
||||
|
||||
// 2. 获取筛选后的 keyId 集合
|
||||
const filterMembers = await client.smembers(filterSet)
|
||||
if (filterMembers.length === 0) {
|
||||
// 没有匹配的数据
|
||||
return {
|
||||
items: [],
|
||||
pagination: { page: 1, pageSize, total: 0, totalPages: 1 },
|
||||
availableTags: await this._getAvailableTags(client)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 排序
|
||||
let sortedKeyIds
|
||||
|
||||
if (sortBy === 'name') {
|
||||
// 优化:只拉筛选后 keyId 的 name 字段,避免全量扫描 name 索引
|
||||
const pipeline = client.pipeline()
|
||||
for (const keyId of filterMembers) {
|
||||
pipeline.hget(`apikey:${keyId}`, 'name')
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
// 组装并排序
|
||||
const items = filterMembers.map((keyId, i) => ({
|
||||
keyId,
|
||||
name: (results[i]?.[1] || '').toLowerCase()
|
||||
}))
|
||||
items.sort((a, b) =>
|
||||
sortOrder === 'desc' ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name)
|
||||
)
|
||||
sortedKeyIds = items.map((item) => item.keyId)
|
||||
} else {
|
||||
// createdAt / lastUsedAt 索引成员是 keyId,可以用 ZINTERSTORE
|
||||
const sortIndex = this._getSortIndex(sortBy)
|
||||
const tempSortedKey = `apikey:tmp:sorted:${randomUUID()}`
|
||||
tempSets.push(tempSortedKey)
|
||||
|
||||
// 将 filterSet 转换为 Sorted Set(所有分数为 0)
|
||||
const filterZsetKey = `apikey:tmp:filter:${randomUUID()}`
|
||||
tempSets.push(filterZsetKey)
|
||||
|
||||
const zaddArgs = []
|
||||
for (const member of filterMembers) {
|
||||
zaddArgs.push(0, member)
|
||||
}
|
||||
await client.zadd(filterZsetKey, ...zaddArgs)
|
||||
await client.expire(filterZsetKey, 60)
|
||||
|
||||
// ZINTERSTORE:取交集,使用排序索引的分数(WEIGHTS 0 1)
|
||||
await client.zinterstore(tempSortedKey, 2, filterZsetKey, sortIndex, 'WEIGHTS', 0, 1)
|
||||
await client.expire(tempSortedKey, 60)
|
||||
|
||||
// 获取排序后的 keyId
|
||||
sortedKeyIds =
|
||||
sortOrder === 'desc'
|
||||
? await client.zrevrange(tempSortedKey, 0, -1)
|
||||
: await client.zrange(tempSortedKey, 0, -1)
|
||||
}
|
||||
|
||||
// 4. 分页
|
||||
const total = sortedKeyIds.length
|
||||
const totalPages = Math.max(Math.ceil(total / pageSize), 1)
|
||||
const validPage = Math.min(Math.max(1, page), totalPages)
|
||||
const start = (validPage - 1) * pageSize
|
||||
const pageKeyIds = sortedKeyIds.slice(start, start + pageSize)
|
||||
|
||||
// 5. 获取数据
|
||||
const items = await this.redis.batchGetApiKeys(pageKeyIds)
|
||||
|
||||
// 6. 获取所有标签
|
||||
const availableTags = await this._getAvailableTags(client)
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page: validPage,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages
|
||||
},
|
||||
availableTags
|
||||
}
|
||||
} finally {
|
||||
// 7. 清理临时集合
|
||||
for (const tempKey of tempSets) {
|
||||
client.del(tempKey).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排序索引键名
|
||||
*/
|
||||
_getSortIndex(sortBy) {
|
||||
switch (sortBy) {
|
||||
case 'createdAt':
|
||||
return this.INDEX_KEYS.CREATED_AT
|
||||
case 'lastUsedAt':
|
||||
return this.INDEX_KEYS.LAST_USED_AT
|
||||
case 'name':
|
||||
return this.INDEX_KEYS.NAME
|
||||
default:
|
||||
return this.INDEX_KEYS.CREATED_AT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用标签(从 tags:all 集合)
|
||||
*/
|
||||
async _getAvailableTags(client) {
|
||||
try {
|
||||
const tags = await client.smembers(this.INDEX_KEYS.TAGS_ALL)
|
||||
return tags.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 lastUsedAt 索引(供 recordUsage 调用)
|
||||
*/
|
||||
async updateLastUsedAt(keyId, lastUsedAt) {
|
||||
if (!this.redis || !keyId) return
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const timestamp = lastUsedAt ? new Date(lastUsedAt).getTime() : Date.now()
|
||||
await client.zadd(this.INDEX_KEYS.LAST_USED_AT, timestamp, keyId)
|
||||
} catch (error) {
|
||||
logger.error(`❌ 更新 API Key ${keyId} lastUsedAt 索引失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取索引状态
|
||||
*/
|
||||
async getStatus() {
|
||||
if (!this.redis) {
|
||||
return { ready: false, building: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.redis.getClientSafe()
|
||||
const version = await client.get(this.INDEX_VERSION_KEY)
|
||||
const totalCount = await client.scard(this.INDEX_KEYS.ALL_SET)
|
||||
|
||||
return {
|
||||
ready: parseInt(version) >= this.CURRENT_VERSION,
|
||||
building: this.isBuilding,
|
||||
progress: this.buildProgress,
|
||||
version: parseInt(version) || 0,
|
||||
currentVersion: this.CURRENT_VERSION,
|
||||
totalIndexed: totalCount
|
||||
}
|
||||
} catch {
|
||||
return { ready: false, building: this.isBuilding }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单例
|
||||
const apiKeyIndexService = new ApiKeyIndexService()
|
||||
|
||||
module.exports = apiKeyIndexService
|
||||
@@ -166,6 +166,22 @@ class ApiKeyService {
|
||||
logger.warn(`Failed to add key ${keyId} to cost rank indexes:`, err.message)
|
||||
}
|
||||
|
||||
// 同步添加到 API Key 索引(用于分页查询优化)
|
||||
try {
|
||||
const apiKeyIndexService = require('./apiKeyIndexService')
|
||||
await apiKeyIndexService.addToIndex({
|
||||
id: keyId,
|
||||
name: keyData.name,
|
||||
createdAt: keyData.createdAt,
|
||||
lastUsedAt: keyData.lastUsedAt,
|
||||
isActive: keyData.isActive === 'true',
|
||||
isDeleted: false,
|
||||
tags: JSON.parse(keyData.tags || '[]')
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to add key ${keyId} to API Key index:`, err.message)
|
||||
}
|
||||
|
||||
logger.success(`🔑 Generated new API key: ${name} (${keyId})`)
|
||||
|
||||
return {
|
||||
@@ -493,6 +509,11 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🏷️ 获取所有标签(轻量级,使用 SCAN + Pipeline)
|
||||
async getAllTags() {
|
||||
return await redis.scanAllApiKeyTags()
|
||||
}
|
||||
|
||||
// 📋 获取所有API Keys
|
||||
async getAllApiKeys(includeDeleted = false) {
|
||||
try {
|
||||
@@ -657,6 +678,266 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 快速获取所有 API Keys(使用 Pipeline 批量操作,性能优化版)
|
||||
* 适用于 dashboard、usage-costs 等需要大量 API Key 数据的场景
|
||||
* @param {boolean} includeDeleted - 是否包含已删除的 API Keys
|
||||
* @returns {Promise<Array>} API Keys 列表
|
||||
*/
|
||||
async getAllApiKeysFast(includeDeleted = false) {
|
||||
try {
|
||||
// 1. 使用 SCAN 获取所有 API Key IDs
|
||||
const keyIds = await redis.scanApiKeyIds()
|
||||
if (keyIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 2. 批量获取基础数据
|
||||
let apiKeys = await redis.batchGetApiKeys(keyIds)
|
||||
|
||||
// 3. 过滤已删除的
|
||||
if (!includeDeleted) {
|
||||
apiKeys = apiKeys.filter((key) => !key.isDeleted)
|
||||
}
|
||||
|
||||
// 4. 批量获取统计数据(单次 Pipeline)
|
||||
const activeKeyIds = apiKeys.map((k) => k.id)
|
||||
const statsMap = await redis.batchGetApiKeyStats(activeKeyIds)
|
||||
|
||||
// 5. 合并数据
|
||||
for (const key of apiKeys) {
|
||||
const stats = statsMap.get(key.id) || {}
|
||||
|
||||
// 处理 usage 数据
|
||||
const usageTotal = stats.usageTotal || {}
|
||||
const usageDaily = stats.usageDaily || {}
|
||||
const usageMonthly = stats.usageMonthly || {}
|
||||
|
||||
// 计算平均 RPM/TPM
|
||||
const createdAt = stats.createdAt ? new Date(stats.createdAt) : new Date()
|
||||
const daysSinceCreated = Math.max(
|
||||
1,
|
||||
Math.ceil((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24))
|
||||
)
|
||||
const totalMinutes = daysSinceCreated * 24 * 60
|
||||
// 兼容旧数据格式:优先读 totalXxx,fallback 到 xxx
|
||||
const totalRequests = parseInt(usageTotal.totalRequests || usageTotal.requests) || 0
|
||||
const totalTokens = parseInt(usageTotal.totalTokens || usageTotal.tokens) || 0
|
||||
let inputTokens = parseInt(usageTotal.totalInputTokens || usageTotal.inputTokens) || 0
|
||||
let outputTokens = parseInt(usageTotal.totalOutputTokens || usageTotal.outputTokens) || 0
|
||||
let cacheCreateTokens =
|
||||
parseInt(usageTotal.totalCacheCreateTokens || usageTotal.cacheCreateTokens) || 0
|
||||
let cacheReadTokens =
|
||||
parseInt(usageTotal.totalCacheReadTokens || usageTotal.cacheReadTokens) || 0
|
||||
|
||||
// 旧数据兼容:没有 input/output 分离时做 30/70 拆分
|
||||
const totalFromSeparate = inputTokens + outputTokens
|
||||
if (totalFromSeparate === 0 && totalTokens > 0) {
|
||||
inputTokens = Math.round(totalTokens * 0.3)
|
||||
outputTokens = Math.round(totalTokens * 0.7)
|
||||
cacheCreateTokens = 0
|
||||
cacheReadTokens = 0
|
||||
}
|
||||
|
||||
// allTokens:优先读存储值,否则计算,最后 fallback 到 totalTokens
|
||||
const allTokens =
|
||||
parseInt(usageTotal.totalAllTokens || usageTotal.allTokens) ||
|
||||
inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens ||
|
||||
totalTokens
|
||||
|
||||
key.usage = {
|
||||
total: {
|
||||
requests: totalRequests,
|
||||
tokens: allTokens, // 与 getUsageStats 语义一致:包含 cache 的总 tokens
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
allTokens,
|
||||
cost: stats.costStats?.total || 0
|
||||
},
|
||||
daily: {
|
||||
requests: parseInt(usageDaily.totalRequests || usageDaily.requests) || 0,
|
||||
tokens: parseInt(usageDaily.totalTokens || usageDaily.tokens) || 0
|
||||
},
|
||||
monthly: {
|
||||
requests: parseInt(usageMonthly.totalRequests || usageMonthly.requests) || 0,
|
||||
tokens: parseInt(usageMonthly.totalTokens || usageMonthly.tokens) || 0
|
||||
},
|
||||
averages: {
|
||||
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
|
||||
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100
|
||||
},
|
||||
totalCost: stats.costStats?.total || 0
|
||||
}
|
||||
|
||||
// 费用统计
|
||||
key.totalCost = stats.costStats?.total || 0
|
||||
key.dailyCost = stats.dailyCost || 0
|
||||
key.weeklyOpusCost = stats.weeklyOpusCost || 0
|
||||
|
||||
// 并发
|
||||
key.currentConcurrency = stats.concurrency || 0
|
||||
|
||||
// 类型转换
|
||||
key.tokenLimit = parseInt(key.tokenLimit) || 0
|
||||
key.concurrencyLimit = parseInt(key.concurrencyLimit) || 0
|
||||
key.rateLimitWindow = parseInt(key.rateLimitWindow) || 0
|
||||
key.rateLimitRequests = parseInt(key.rateLimitRequests) || 0
|
||||
key.rateLimitCost = parseFloat(key.rateLimitCost) || 0
|
||||
key.dailyCostLimit = parseFloat(key.dailyCostLimit) || 0
|
||||
key.totalCostLimit = parseFloat(key.totalCostLimit) || 0
|
||||
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit) || 0
|
||||
key.activationDays = parseInt(key.activationDays) || 0
|
||||
key.isActive = key.isActive === 'true' || key.isActive === true
|
||||
key.enableModelRestriction =
|
||||
key.enableModelRestriction === 'true' || key.enableModelRestriction === true
|
||||
key.enableClientRestriction =
|
||||
key.enableClientRestriction === 'true' || key.enableClientRestriction === true
|
||||
key.isActivated = key.isActivated === 'true' || key.isActivated === true
|
||||
key.permissions = key.permissions || 'all'
|
||||
key.activationUnit = key.activationUnit || 'days'
|
||||
key.expirationMode = key.expirationMode || 'fixed'
|
||||
key.activatedAt = key.activatedAt || null
|
||||
|
||||
// Rate limit 窗口数据
|
||||
if (key.rateLimitWindow > 0) {
|
||||
const rl = stats.rateLimit || {}
|
||||
key.currentWindowRequests = rl.requests || 0
|
||||
key.currentWindowTokens = rl.tokens || 0
|
||||
key.currentWindowCost = rl.cost || 0
|
||||
|
||||
if (rl.windowStart) {
|
||||
const now = Date.now()
|
||||
const windowDuration = key.rateLimitWindow * 60 * 1000
|
||||
const windowEndTime = rl.windowStart + windowDuration
|
||||
|
||||
if (now < windowEndTime) {
|
||||
key.windowStartTime = rl.windowStart
|
||||
key.windowEndTime = windowEndTime
|
||||
key.windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
||||
} else {
|
||||
key.windowStartTime = null
|
||||
key.windowEndTime = null
|
||||
key.windowRemainingSeconds = 0
|
||||
key.currentWindowRequests = 0
|
||||
key.currentWindowTokens = 0
|
||||
key.currentWindowCost = 0
|
||||
}
|
||||
} else {
|
||||
key.windowStartTime = null
|
||||
key.windowEndTime = null
|
||||
key.windowRemainingSeconds = null
|
||||
}
|
||||
} else {
|
||||
key.currentWindowRequests = 0
|
||||
key.currentWindowTokens = 0
|
||||
key.currentWindowCost = 0
|
||||
key.windowStartTime = null
|
||||
key.windowEndTime = null
|
||||
key.windowRemainingSeconds = null
|
||||
}
|
||||
|
||||
// JSON 字段解析(兼容已解析的数组和未解析的字符串)
|
||||
if (Array.isArray(key.restrictedModels)) {
|
||||
// 已解析,保持不变
|
||||
} else if (key.restrictedModels) {
|
||||
try {
|
||||
key.restrictedModels = JSON.parse(key.restrictedModels)
|
||||
} catch {
|
||||
key.restrictedModels = []
|
||||
}
|
||||
} else {
|
||||
key.restrictedModels = []
|
||||
}
|
||||
if (Array.isArray(key.allowedClients)) {
|
||||
// 已解析,保持不变
|
||||
} else if (key.allowedClients) {
|
||||
try {
|
||||
key.allowedClients = JSON.parse(key.allowedClients)
|
||||
} catch {
|
||||
key.allowedClients = []
|
||||
}
|
||||
} else {
|
||||
key.allowedClients = []
|
||||
}
|
||||
if (Array.isArray(key.tags)) {
|
||||
// 已解析,保持不变
|
||||
} else if (key.tags) {
|
||||
try {
|
||||
key.tags = JSON.parse(key.tags)
|
||||
} catch {
|
||||
key.tags = []
|
||||
}
|
||||
} else {
|
||||
key.tags = []
|
||||
}
|
||||
|
||||
// 生成掩码key后再清理敏感字段
|
||||
if (key.apiKey) {
|
||||
key.maskedKey = `${this.prefix}****${key.apiKey.slice(-4)}`
|
||||
}
|
||||
delete key.apiKey
|
||||
delete key.ccrAccountId
|
||||
|
||||
// 不获取 lastUsage(太慢),设为 null
|
||||
key.lastUsage = null
|
||||
}
|
||||
|
||||
return apiKeys
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API keys (fast):', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 API Keys 的轻量版本(仅绑定字段,用于计算绑定数)
|
||||
* @returns {Promise<Array>} 包含绑定字段的 API Keys 列表
|
||||
*/
|
||||
async getAllApiKeysLite() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keyIds = await redis.scanApiKeyIds()
|
||||
|
||||
if (keyIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Pipeline 只获取绑定相关字段
|
||||
const pipeline = client.pipeline()
|
||||
for (const keyId of keyIds) {
|
||||
pipeline.hmget(
|
||||
`apikey:${keyId}`,
|
||||
'claudeAccountId',
|
||||
'geminiAccountId',
|
||||
'openaiAccountId',
|
||||
'droidAccountId',
|
||||
'isDeleted'
|
||||
)
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
return keyIds
|
||||
.map((id, i) => {
|
||||
const [err, fields] = results[i]
|
||||
if (err) return null
|
||||
return {
|
||||
id,
|
||||
claudeAccountId: fields[0] || null,
|
||||
geminiAccountId: fields[1] || null,
|
||||
openaiAccountId: fields[2] || null,
|
||||
droidAccountId: fields[3] || null,
|
||||
isDeleted: fields[4] === 'true'
|
||||
}
|
||||
})
|
||||
.filter((k) => k && !k.isDeleted)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API keys (lite):', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 📝 更新API Key
|
||||
async updateApiKey(keyId, updates) {
|
||||
try {
|
||||
@@ -730,6 +1011,19 @@ class ApiKeyService {
|
||||
// keyData.apiKey 存储的就是 hashedKey(见generateApiKey第123行)
|
||||
await redis.setApiKey(keyId, updatedData, keyData.apiKey)
|
||||
|
||||
// 同步更新 API Key 索引
|
||||
try {
|
||||
const apiKeyIndexService = require('./apiKeyIndexService')
|
||||
await apiKeyIndexService.updateIndex(keyId, updates, {
|
||||
name: keyData.name,
|
||||
isActive: keyData.isActive === 'true',
|
||||
isDeleted: keyData.isDeleted === 'true',
|
||||
tags: JSON.parse(keyData.tags || '[]')
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to update API Key index for ${keyId}:`, err.message)
|
||||
}
|
||||
|
||||
logger.success(`📝 Updated API key: ${keyId}, hashMap updated`)
|
||||
|
||||
return { success: true }
|
||||
@@ -772,6 +1066,23 @@ class ApiKeyService {
|
||||
logger.warn(`Failed to remove key ${keyId} from cost rank indexes:`, err.message)
|
||||
}
|
||||
|
||||
// 更新 API Key 索引(标记为已删除)
|
||||
try {
|
||||
const apiKeyIndexService = require('./apiKeyIndexService')
|
||||
await apiKeyIndexService.updateIndex(
|
||||
keyId,
|
||||
{ isDeleted: true, isActive: false },
|
||||
{
|
||||
name: keyData.name,
|
||||
isActive: keyData.isActive === 'true',
|
||||
isDeleted: false,
|
||||
tags: JSON.parse(keyData.tags || '[]')
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to update API Key index for deleted key ${keyId}:`, err.message)
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Soft deleted API key: ${keyId} by ${deletedBy} (${deletedByType})`)
|
||||
|
||||
return { success: true }
|
||||
@@ -831,7 +1142,24 @@ class ApiKeyService {
|
||||
logger.warn(`Failed to add restored key ${keyId} to cost rank indexes:`, err.message)
|
||||
}
|
||||
|
||||
logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`)
|
||||
// 更新 API Key 索引(恢复为活跃状态)
|
||||
try {
|
||||
const apiKeyIndexService = require('./apiKeyIndexService')
|
||||
await apiKeyIndexService.updateIndex(
|
||||
keyId,
|
||||
{ isDeleted: false, isActive: true },
|
||||
{
|
||||
name: keyData.name,
|
||||
isActive: false,
|
||||
isDeleted: true,
|
||||
tags: JSON.parse(keyData.tags || '[]')
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to update API Key index for restored key ${keyId}:`, err.message)
|
||||
}
|
||||
|
||||
logger.success(`Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`)
|
||||
|
||||
return { success: true, apiKey: updatedData }
|
||||
} catch (error) {
|
||||
@@ -866,9 +1194,20 @@ class ApiKeyService {
|
||||
await redis.client.del(`usage:monthly:${currentMonth}:${keyId}`)
|
||||
|
||||
// 删除所有相关的统计键(通过模式匹配)
|
||||
const usageKeys = await redis.client.keys(`usage:*:${keyId}*`)
|
||||
const usageKeys = await redis.scanKeys(`usage:*:${keyId}*`)
|
||||
if (usageKeys.length > 0) {
|
||||
await redis.client.del(...usageKeys)
|
||||
await redis.batchDelChunked(usageKeys)
|
||||
}
|
||||
|
||||
// 从 API Key 索引中移除
|
||||
try {
|
||||
const apiKeyIndexService = require('./apiKeyIndexService')
|
||||
await apiKeyIndexService.removeFromIndex(keyId, {
|
||||
name: keyData.name,
|
||||
tags: JSON.parse(keyData.tags || '[]')
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to remove key ${keyId} from API Key index:`, err.message)
|
||||
}
|
||||
|
||||
// 删除API Key本身
|
||||
@@ -886,8 +1225,8 @@ class ApiKeyService {
|
||||
// 🧹 清空所有已删除的API Keys
|
||||
async clearAllDeletedApiKeys() {
|
||||
try {
|
||||
const allKeys = await this.getAllApiKeys(true)
|
||||
const deletedKeys = allKeys.filter((key) => key.isDeleted === 'true')
|
||||
const allKeys = await this.getAllApiKeysFast(true)
|
||||
const deletedKeys = allKeys.filter((key) => key.isDeleted === true)
|
||||
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
@@ -982,9 +1321,18 @@ class ApiKeyService {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (keyData && Object.keys(keyData).length > 0) {
|
||||
// 更新最后使用时间
|
||||
keyData.lastUsedAt = new Date().toISOString()
|
||||
const lastUsedAt = new Date().toISOString()
|
||||
keyData.lastUsedAt = lastUsedAt
|
||||
await redis.setApiKey(keyId, keyData)
|
||||
|
||||
// 同步更新 lastUsedAt 索引
|
||||
try {
|
||||
const apiKeyIndexService = require('./apiKeyIndexService')
|
||||
await apiKeyIndexService.updateLastUsedAt(keyId, lastUsedAt)
|
||||
} catch (err) {
|
||||
// 索引更新失败不影响主流程
|
||||
}
|
||||
|
||||
// 记录账户级别的使用统计(只统计实际处理请求的账户)
|
||||
if (accountId) {
|
||||
await redis.incrementAccountUsage(
|
||||
@@ -1192,9 +1540,18 @@ class ApiKeyService {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (keyData && Object.keys(keyData).length > 0) {
|
||||
// 更新最后使用时间
|
||||
keyData.lastUsedAt = new Date().toISOString()
|
||||
const lastUsedAt = new Date().toISOString()
|
||||
keyData.lastUsedAt = lastUsedAt
|
||||
await redis.setApiKey(keyId, keyData)
|
||||
|
||||
// 同步更新 lastUsedAt 索引
|
||||
try {
|
||||
const apiKeyIndexService = require('./apiKeyIndexService')
|
||||
await apiKeyIndexService.updateLastUsedAt(keyId, lastUsedAt)
|
||||
} catch (err) {
|
||||
// 索引更新失败不影响主流程
|
||||
}
|
||||
|
||||
// 记录账户级别的使用统计(只统计实际处理请求的账户)
|
||||
if (accountId) {
|
||||
await redis.incrementAccountUsage(
|
||||
@@ -1493,12 +1850,12 @@ class ApiKeyService {
|
||||
// 👤 获取用户的API Keys
|
||||
async getUserApiKeys(userId, includeDeleted = false) {
|
||||
try {
|
||||
const allKeys = await redis.getAllApiKeys()
|
||||
const allKeys = await this.getAllApiKeysFast(includeDeleted)
|
||||
let userKeys = allKeys.filter((key) => key.userId === userId)
|
||||
|
||||
// 默认过滤掉已删除的API Keys
|
||||
// 默认过滤掉已删除的API Keys(Fast版本返回布尔值)
|
||||
if (!includeDeleted) {
|
||||
userKeys = userKeys.filter((key) => key.isDeleted !== 'true')
|
||||
userKeys = userKeys.filter((key) => !key.isDeleted)
|
||||
}
|
||||
|
||||
// Populate usage stats for each user's API key (same as getAllApiKeys does)
|
||||
@@ -1512,9 +1869,9 @@ class ApiKeyService {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
description: key.description,
|
||||
key: key.apiKey ? `${this.prefix}****${key.apiKey.slice(-4)}` : null, // 只显示前缀和后4位
|
||||
key: key.maskedKey || null, // Fast版本已提供maskedKey
|
||||
tokenLimit: parseInt(key.tokenLimit || 0),
|
||||
isActive: key.isActive === 'true',
|
||||
isActive: key.isActive === true, // Fast版本返回布尔值
|
||||
createdAt: key.createdAt,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
expiresAt: key.expiresAt,
|
||||
@@ -1738,7 +2095,7 @@ class ApiKeyService {
|
||||
}
|
||||
|
||||
// 获取所有API Keys
|
||||
const allKeys = await this.getAllApiKeys()
|
||||
const allKeys = await this.getAllApiKeysFast()
|
||||
|
||||
// 筛选绑定到此账号的 API Keys
|
||||
let boundKeys = []
|
||||
@@ -1788,13 +2145,13 @@ class ApiKeyService {
|
||||
// 🧹 清理过期的API Keys
|
||||
async cleanupExpiredKeys() {
|
||||
try {
|
||||
const apiKeys = await redis.getAllApiKeys()
|
||||
const apiKeys = await this.getAllApiKeysFast()
|
||||
const now = new Date()
|
||||
let cleanedCount = 0
|
||||
|
||||
for (const key of apiKeys) {
|
||||
// 检查是否已过期且仍处于激活状态
|
||||
if (key.expiresAt && new Date(key.expiresAt) < now && key.isActive === 'true') {
|
||||
// 检查是否已过期且仍处于激活状态(Fast版本返回布尔值)
|
||||
if (key.expiresAt && new Date(key.expiresAt) < now && key.isActive === true) {
|
||||
// 将过期的 API Key 标记为禁用状态,而不是直接删除
|
||||
await this.updateApiKey(key.id, { isActive: false })
|
||||
logger.info(`🔒 API Key ${key.id} (${key.name}) has expired and been disabled`)
|
||||
|
||||
@@ -150,6 +150,7 @@ async function createAccount(accountData) {
|
||||
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
||||
await redisClient.addToIndex('azure_openai:account:index', accountId)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (account.accountType === 'shared') {
|
||||
@@ -270,6 +271,9 @@ async function deleteAccount(accountId) {
|
||||
// 从Redis中删除账户数据
|
||||
await client.del(accountKey)
|
||||
|
||||
// 从索引中移除
|
||||
await redisClient.removeFromIndex('azure_openai:account:index', accountId)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
|
||||
|
||||
@@ -279,16 +283,22 @@ async function deleteAccount(accountId) {
|
||||
|
||||
// 获取所有账户
|
||||
async function getAllAccounts() {
|
||||
const client = redisClient.getClientSafe()
|
||||
const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`)
|
||||
const accountIds = await redisClient.getAllIdsByIndex(
|
||||
'azure_openai:account:index',
|
||||
`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`,
|
||||
/^azure_openai:account:(.+)$/
|
||||
)
|
||||
|
||||
if (!keys || keys.length === 0) {
|
||||
if (!accountIds || accountIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const keys = accountIds.map((id) => `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
const dataList = await redisClient.batchHgetallChunked(keys)
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 不返回敏感数据给前端
|
||||
delete accountData.apiKey
|
||||
|
||||
@@ -73,6 +73,7 @@ class BedrockAccountService {
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
|
||||
await redis.addToIndex('bedrock_account:index', accountId)
|
||||
|
||||
logger.info(`✅ 创建Bedrock账户成功 - ID: ${accountId}, 名称: ${name}, 区域: ${region}`)
|
||||
|
||||
@@ -127,11 +128,17 @@ class BedrockAccountService {
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keys = await client.keys('bedrock_account:*')
|
||||
const accountIds = await redis.getAllIdsByIndex(
|
||||
'bedrock_account:index',
|
||||
'bedrock_account:*',
|
||||
/^bedrock_account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `bedrock_account:${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redis.batchGetChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.get(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData) {
|
||||
const account = JSON.parse(accountData)
|
||||
|
||||
@@ -280,6 +287,7 @@ class BedrockAccountService {
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
await client.del(`bedrock_account:${accountId}`)
|
||||
await redis.removeFromIndex('bedrock_account:index', accountId)
|
||||
|
||||
logger.info(`✅ 删除Bedrock账户成功 - ID: ${accountId}`)
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ class BillingEventPublisher {
|
||||
// MKSTREAM: 如果 stream 不存在则创建
|
||||
await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM')
|
||||
|
||||
logger.success(`✅ Created consumer group: ${groupName}`)
|
||||
logger.success(`Created consumer group: ${groupName}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.message.includes('BUSYGROUP')) {
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { createEncryptor } = require('../utils/commonHelper')
|
||||
|
||||
class CcrAccountService {
|
||||
constructor() {
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'ccr-account-salt'
|
||||
|
||||
// Redis键前缀
|
||||
this.ACCOUNT_KEY_PREFIX = 'ccr_account:'
|
||||
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
this._decryptCache = new LRUCache(500)
|
||||
// 使用 commonHelper 的加密器
|
||||
this._encryptor = createEncryptor('ccr-account-salt')
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||
this._encryptor.clearCache()
|
||||
logger.info('🧹 CCR account decrypt cache cleanup completed', this._encryptor.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
@@ -106,6 +97,7 @@ class CcrAccountService {
|
||||
logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
||||
await redis.addToIndex('ccr_account:index', accountId)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (accountType === 'shared') {
|
||||
@@ -139,12 +131,17 @@ class CcrAccountService {
|
||||
// 📋 获取所有CCR账户
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
const accountIds = await redis.getAllIdsByIndex(
|
||||
'ccr_account:index',
|
||||
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||
/^ccr_account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redis.batchHgetallChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
@@ -331,6 +328,9 @@ class CcrAccountService {
|
||||
// 从共享账户集合中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 从索引中移除
|
||||
await redis.removeFromIndex('ccr_account:index', accountId)
|
||||
|
||||
// 删除账户数据
|
||||
const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
@@ -403,7 +403,7 @@ class CcrAccountService {
|
||||
`ℹ️ CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded`
|
||||
)
|
||||
} else {
|
||||
logger.success(`✅ Removed rate limit for CCR account: ${accountId}`)
|
||||
logger.success(`Removed rate limit for CCR account: ${accountId}`)
|
||||
}
|
||||
|
||||
await client.hmset(accountKey, {
|
||||
@@ -488,7 +488,7 @@ class CcrAccountService {
|
||||
errorMessage: ''
|
||||
})
|
||||
|
||||
logger.success(`✅ Removed overload status for CCR account: ${accountId}`)
|
||||
logger.success(`Removed overload status for CCR account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error)
|
||||
@@ -606,70 +606,12 @@ class CcrAccountService {
|
||||
|
||||
// 🔐 加密敏感数据
|
||||
_encryptSensitiveData(data) {
|
||||
if (!data) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}`
|
||||
} catch (error) {
|
||||
logger.error('❌ CCR encryption error:', error)
|
||||
return data
|
||||
}
|
||||
return this._encryptor.encrypt(data)
|
||||
}
|
||||
|
||||
// 🔓 解密敏感数据
|
||||
_decryptSensitiveData(encryptedData) {
|
||||
if (!encryptedData) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = encryptedData.split(':')
|
||||
if (parts.length === 2) {
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = Buffer.from(parts[0], 'hex')
|
||||
const encrypted = parts[1]
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||
|
||||
return decrypted
|
||||
} else {
|
||||
logger.error('❌ Invalid CCR encrypted data format')
|
||||
return encryptedData
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ CCR decryption error:', error)
|
||||
return encryptedData
|
||||
}
|
||||
}
|
||||
|
||||
// 🔑 生成加密密钥
|
||||
_generateEncryptionKey() {
|
||||
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
|
||||
if (!this._encryptionKeyCache) {
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
return this._encryptor.decrypt(encryptedData)
|
||||
}
|
||||
|
||||
// 🔍 获取限流状态信息
|
||||
@@ -843,7 +785,7 @@ class CcrAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Reset daily usage for ${resetCount} CCR accounts`)
|
||||
logger.success(`Reset daily usage for ${resetCount} CCR accounts`)
|
||||
return { success: true, resetCount }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all CCR daily usage:', error)
|
||||
@@ -915,7 +857,7 @@ class CcrAccountService {
|
||||
await client.hset(accountKey, updates)
|
||||
await client.hdel(accountKey, ...fieldsToDelete)
|
||||
|
||||
logger.success(`✅ Reset all error status for CCR account ${accountId}`)
|
||||
logger.success(`Reset all error status for CCR account ${accountId}`)
|
||||
|
||||
// 异步发送 Webhook 通知(忽略错误)
|
||||
try {
|
||||
|
||||
@@ -1570,7 +1570,7 @@ class ClaudeAccountService {
|
||||
'rateLimitAutoStopped'
|
||||
)
|
||||
|
||||
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
|
||||
logger.success(`Rate limit removed for account: ${accountData.name} (${accountId})`)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -2242,7 +2242,7 @@ class ClaudeAccountService {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
logger.success(`✅ Profile update completed: ${successCount} success, ${failureCount} failed`)
|
||||
logger.success(`Profile update completed: ${successCount} success, ${failureCount} failed`)
|
||||
|
||||
return {
|
||||
totalAccounts: accounts.length,
|
||||
@@ -2310,11 +2310,11 @@ class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success('✅ Session window initialization completed:')
|
||||
logger.success(` 📊 Total accounts: ${accounts.length}`)
|
||||
logger.success(` ✅ Valid windows: ${validWindowCount}`)
|
||||
logger.success(` ⏰ Expired windows: ${expiredWindowCount}`)
|
||||
logger.success(` 📭 No windows: ${noWindowCount}`)
|
||||
logger.success('Session window initialization completed:')
|
||||
logger.success(` Total accounts: ${accounts.length}`)
|
||||
logger.success(` Valid windows: ${validWindowCount}`)
|
||||
logger.success(` Expired windows: ${expiredWindowCount}`)
|
||||
logger.success(` No windows: ${noWindowCount}`)
|
||||
|
||||
return {
|
||||
total: accounts.length,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { getCachedConfig, setCachedConfig, deleteCachedConfig } = require('../utils/performanceOptimizer')
|
||||
|
||||
class ClaudeCodeHeadersService {
|
||||
constructor() {
|
||||
@@ -41,6 +42,9 @@ class ClaudeCodeHeadersService {
|
||||
'sec-fetch-mode',
|
||||
'accept-encoding'
|
||||
]
|
||||
|
||||
// Headers 缓存 TTL(60秒)
|
||||
this.headersCacheTtl = 60000
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,6 +151,9 @@ class ClaudeCodeHeadersService {
|
||||
|
||||
await redis.getClient().setex(key, 86400 * 7, JSON.stringify(data)) // 7天过期
|
||||
|
||||
// 更新内存缓存,避免延迟
|
||||
setCachedConfig(key, extractedHeaders, this.headersCacheTtl)
|
||||
|
||||
logger.info(`✅ Stored Claude Code headers for account ${accountId}, version: ${version}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to store Claude Code headers for account ${accountId}:`, error)
|
||||
@@ -154,18 +161,27 @@ class ClaudeCodeHeadersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号的 Claude Code headers
|
||||
* 获取账号的 Claude Code headers(带内存缓存)
|
||||
*/
|
||||
async getAccountHeaders(accountId) {
|
||||
const cacheKey = `claude_code_headers:${accountId}`
|
||||
|
||||
// 检查内存缓存
|
||||
const cached = getCachedConfig(cacheKey)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = `claude_code_headers:${accountId}`
|
||||
const data = await redis.getClient().get(key)
|
||||
const data = await redis.getClient().get(cacheKey)
|
||||
|
||||
if (data) {
|
||||
const parsed = JSON.parse(data)
|
||||
logger.debug(
|
||||
`📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}`
|
||||
)
|
||||
// 缓存到内存
|
||||
setCachedConfig(cacheKey, parsed.headers, this.headersCacheTtl)
|
||||
return parsed.headers
|
||||
}
|
||||
|
||||
@@ -183,8 +199,10 @@ class ClaudeCodeHeadersService {
|
||||
*/
|
||||
async clearAccountHeaders(accountId) {
|
||||
try {
|
||||
const key = `claude_code_headers:${accountId}`
|
||||
await redis.getClient().del(key)
|
||||
const cacheKey = `claude_code_headers:${accountId}`
|
||||
await redis.getClient().del(cacheKey)
|
||||
// 删除内存缓存
|
||||
deleteCachedConfig(cacheKey)
|
||||
logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to clear Claude Code headers for account ${accountId}:`, error)
|
||||
|
||||
@@ -129,6 +129,7 @@ class ClaudeConsoleAccountService {
|
||||
logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
||||
await redis.addToIndex('claude_console_account:index', accountId)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (accountType === 'shared') {
|
||||
@@ -167,11 +168,18 @@ class ClaudeConsoleAccountService {
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
const accountIds = await redis.getAllIdsByIndex(
|
||||
'claude_console_account:index',
|
||||
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||
/^claude_console_account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redis.batchHgetallChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i]
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
if (!accountData.id) {
|
||||
logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据,执行清理: ${key}`)
|
||||
@@ -449,6 +457,7 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
// 从Redis删除
|
||||
await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
await redis.removeFromIndex('claude_console_account:index', accountId)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
if (account.accountType === 'shared') {
|
||||
@@ -577,7 +586,7 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
|
||||
await client.hset(accountKey, updateData)
|
||||
logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`)
|
||||
logger.success(`Rate limit removed and account re-enabled: ${accountId}`)
|
||||
}
|
||||
} else {
|
||||
if (await client.hdel(accountKey, 'rateLimitAutoStopped')) {
|
||||
@@ -585,7 +594,7 @@ class ClaudeConsoleAccountService {
|
||||
`ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during rate limit recovery`
|
||||
)
|
||||
}
|
||||
logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
|
||||
logger.success(`Rate limit removed for Claude Console account: ${accountId}`)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
@@ -858,7 +867,7 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
|
||||
await client.hset(accountKey, updateData)
|
||||
logger.success(`✅ Blocked status removed and account re-enabled: ${accountId}`)
|
||||
logger.success(`Blocked status removed and account re-enabled: ${accountId}`)
|
||||
}
|
||||
} else {
|
||||
if (await client.hdel(accountKey, 'blockedAutoStopped')) {
|
||||
@@ -866,7 +875,7 @@ class ClaudeConsoleAccountService {
|
||||
`ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during blocked status recovery`
|
||||
)
|
||||
}
|
||||
logger.success(`✅ Blocked status removed for Claude Console account: ${accountId}`)
|
||||
logger.success(`Blocked status removed for Claude Console account: ${accountId}`)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
@@ -967,7 +976,7 @@ class ClaudeConsoleAccountService {
|
||||
|
||||
await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus')
|
||||
|
||||
logger.success(`✅ Overload status removed for Claude Console account: ${accountId}`)
|
||||
logger.success(`Overload status removed for Claude Console account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -1416,7 +1425,7 @@ class ClaudeConsoleAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`)
|
||||
logger.success(`Reset daily usage for ${resetCount} Claude Console accounts`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset all daily usage:', error)
|
||||
}
|
||||
@@ -1489,7 +1498,7 @@ class ClaudeConsoleAccountService {
|
||||
await client.hset(accountKey, updates)
|
||||
await client.hdel(accountKey, ...fieldsToDelete)
|
||||
|
||||
logger.success(`✅ Reset all error status for Claude Console account ${accountId}`)
|
||||
logger.success(`Reset all error status for Claude Console account ${accountId}`)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
|
||||
@@ -18,8 +18,8 @@ const DEFAULT_CONFIG = {
|
||||
// 用户消息队列配置
|
||||
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
|
||||
userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
|
||||
userMessageQueueTimeoutMs: 5000, // 队列等待超时(毫秒),优化后锁持有时间短无需长等待
|
||||
userMessageQueueLockTtlMs: 5000, // 锁TTL(毫秒),请求发送后立即释放无需长TTL
|
||||
userMessageQueueTimeoutMs: 60000, // 队列等待超时(毫秒)
|
||||
userMessageQueueLockTtlMs: 120000, // 锁TTL(毫秒)
|
||||
// 并发请求排队配置
|
||||
concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭)
|
||||
concurrentRequestQueueMaxSize: 3, // 固定最小排队数(默认3)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const https = require('https')
|
||||
const zlib = require('zlib')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const { filterForClaude } = require('../utils/headerFilter')
|
||||
@@ -17,6 +16,17 @@ const requestIdentityService = require('./requestIdentityService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
const { isStreamWritable } = require('../utils/streamHelper')
|
||||
const {
|
||||
getHttpsAgentForStream,
|
||||
getHttpsAgentForNonStream,
|
||||
getPricingData
|
||||
} = require('../utils/performanceOptimizer')
|
||||
|
||||
// structuredClone polyfill for Node < 17
|
||||
const safeClone =
|
||||
typeof structuredClone === 'function'
|
||||
? structuredClone
|
||||
: (obj) => JSON.parse(JSON.stringify(obj))
|
||||
|
||||
class ClaudeRelayService {
|
||||
constructor() {
|
||||
@@ -684,8 +694,8 @@ class ClaudeRelayService {
|
||||
return body
|
||||
}
|
||||
|
||||
// 深拷贝请求体
|
||||
const processedBody = JSON.parse(JSON.stringify(body))
|
||||
// 使用 safeClone 替代 JSON.parse(JSON.stringify()) 提升性能
|
||||
const processedBody = safeClone(body)
|
||||
|
||||
// 验证并限制max_tokens参数
|
||||
this._validateAndLimitMaxTokens(processedBody)
|
||||
@@ -815,15 +825,15 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取模型定价配置文件
|
||||
// 使用缓存的定价数据
|
||||
const pricingFilePath = path.join(__dirname, '../../data/model_pricing.json')
|
||||
const pricingData = getPricingData(pricingFilePath)
|
||||
|
||||
if (!fs.existsSync(pricingFilePath)) {
|
||||
if (!pricingData) {
|
||||
logger.warn('⚠️ Model pricing file not found, skipping max_tokens validation')
|
||||
return
|
||||
}
|
||||
|
||||
const pricingData = JSON.parse(fs.readFileSync(pricingFilePath, 'utf8'))
|
||||
const model = body.model || 'claude-sonnet-4-20250514'
|
||||
|
||||
// 查找对应模型的配置
|
||||
@@ -989,20 +999,20 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 🌐 获取代理Agent(使用统一的代理工具)
|
||||
async _getProxyAgent(accountId) {
|
||||
async _getProxyAgent(accountId, account = null) {
|
||||
try {
|
||||
const accountData = await claudeAccountService.getAllAccounts()
|
||||
const account = accountData.find((acc) => acc.id === accountId)
|
||||
// 优先使用传入的 account 对象,避免重复查询
|
||||
const accountData = account || (await claudeAccountService.getAccount(accountId))
|
||||
|
||||
if (!account || !account.proxy) {
|
||||
if (!accountData || !accountData.proxy) {
|
||||
logger.debug('🌐 No proxy configured for Claude account')
|
||||
return null
|
||||
}
|
||||
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(account.proxy)
|
||||
const proxyAgent = ProxyHelper.createProxyAgent(accountData.proxy)
|
||||
if (proxyAgent) {
|
||||
logger.info(
|
||||
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(account.proxy)}`
|
||||
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(accountData.proxy)}`
|
||||
)
|
||||
}
|
||||
return proxyAgent
|
||||
@@ -1096,9 +1106,7 @@ class ClaudeRelayService {
|
||||
headers['User-Agent'] = userAgent
|
||||
headers['Accept'] = acceptHeader
|
||||
|
||||
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
||||
|
||||
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
||||
logger.debug(`🔗 Request User-Agent: ${headers['User-Agent']}`)
|
||||
|
||||
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
||||
const modelId = requestPayload?.model || body?.model
|
||||
@@ -1191,19 +1199,22 @@ class ClaudeRelayService {
|
||||
path: requestPath + (url.search || ''),
|
||||
method: 'POST',
|
||||
headers,
|
||||
agent: proxyAgent,
|
||||
agent: proxyAgent || getHttpsAgentForNonStream(),
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let responseData = Buffer.alloc(0)
|
||||
// 使用数组收集 chunks,避免 O(n²) 的 Buffer.concat
|
||||
const chunks = []
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
responseData = Buffer.concat([responseData, chunk])
|
||||
chunks.push(chunk)
|
||||
})
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
// 一次性合并所有 chunks
|
||||
const responseData = Buffer.concat(chunks)
|
||||
let responseBody = ''
|
||||
|
||||
// 根据Content-Encoding处理响应数据
|
||||
@@ -1586,7 +1597,7 @@ class ClaudeRelayService {
|
||||
path: url.pathname + (url.search || ''),
|
||||
method: 'POST',
|
||||
headers,
|
||||
agent: proxyAgent,
|
||||
agent: proxyAgent || getHttpsAgentForStream(),
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,65 @@
|
||||
const redis = require('../models/redis')
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// HMGET 需要的字段
|
||||
const USAGE_FIELDS = [
|
||||
'totalInputTokens',
|
||||
'inputTokens',
|
||||
'totalOutputTokens',
|
||||
'outputTokens',
|
||||
'totalCacheCreateTokens',
|
||||
'cacheCreateTokens',
|
||||
'totalCacheReadTokens',
|
||||
'cacheReadTokens'
|
||||
]
|
||||
|
||||
class CostInitService {
|
||||
/**
|
||||
* 带并发限制的并行执行
|
||||
*/
|
||||
async parallelLimit(items, fn, concurrency = 20) {
|
||||
let index = 0
|
||||
const results = []
|
||||
|
||||
async function worker() {
|
||||
while (index < items.length) {
|
||||
const currentIndex = index++
|
||||
try {
|
||||
results[currentIndex] = await fn(items[currentIndex], currentIndex)
|
||||
} catch (error) {
|
||||
results[currentIndex] = { error }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array(Math.min(concurrency, items.length)).fill().map(worker))
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 SCAN 获取匹配的 keys(带去重)
|
||||
*/
|
||||
async scanKeysWithDedup(client, pattern, count = 500) {
|
||||
const seen = new Set()
|
||||
const allKeys = []
|
||||
let cursor = '0'
|
||||
|
||||
do {
|
||||
const [newCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count)
|
||||
cursor = newCursor
|
||||
|
||||
for (const key of keys) {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
allKeys.push(key)
|
||||
}
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
return allKeys
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化所有API Key的费用数据
|
||||
* 扫描历史使用记录并计算费用
|
||||
@@ -12,25 +68,55 @@ class CostInitService {
|
||||
try {
|
||||
logger.info('💰 Starting cost initialization for all API Keys...')
|
||||
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
// 用 scanApiKeyIds 获取 ID,然后过滤已删除的
|
||||
const allKeyIds = await redis.scanApiKeyIds()
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 批量检查 isDeleted 状态,过滤已删除的 key
|
||||
const FILTER_BATCH = 100
|
||||
const apiKeyIds = []
|
||||
|
||||
for (let i = 0; i < allKeyIds.length; i += FILTER_BATCH) {
|
||||
const batch = allKeyIds.slice(i, i + FILTER_BATCH)
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
for (const keyId of batch) {
|
||||
pipeline.hget(`apikey:${keyId}`, 'isDeleted')
|
||||
}
|
||||
|
||||
const results = await pipeline.exec()
|
||||
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
const [err, isDeleted] = results[j]
|
||||
if (!err && isDeleted !== 'true') {
|
||||
apiKeyIds.push(batch[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`💰 Found ${apiKeyIds.length} active API Keys to process (filtered ${allKeyIds.length - apiKeyIds.length} deleted)`)
|
||||
|
||||
let processedCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
for (const apiKey of apiKeys) {
|
||||
try {
|
||||
await this.initializeApiKeyCosts(apiKey.id, client)
|
||||
processedCount++
|
||||
// 优化6: 并行处理 + 并发限制
|
||||
await this.parallelLimit(
|
||||
apiKeyIds,
|
||||
async (apiKeyId) => {
|
||||
try {
|
||||
await this.initializeApiKeyCosts(apiKeyId, client)
|
||||
processedCount++
|
||||
|
||||
if (processedCount % 10 === 0) {
|
||||
logger.info(`💰 Processed ${processedCount} API Keys...`)
|
||||
if (processedCount % 100 === 0) {
|
||||
logger.info(`💰 Processed ${processedCount}/${apiKeyIds.length} API Keys...`)
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++
|
||||
logger.error(`❌ Failed to initialize costs for API Key ${apiKeyId}:`, error)
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++
|
||||
logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error)
|
||||
}
|
||||
}
|
||||
},
|
||||
20 // 并发数
|
||||
)
|
||||
|
||||
logger.success(
|
||||
`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`
|
||||
@@ -46,32 +132,60 @@ class CostInitService {
|
||||
* 初始化单个API Key的费用数据
|
||||
*/
|
||||
async initializeApiKeyCosts(apiKeyId, client) {
|
||||
// 获取所有时间的模型使用统计
|
||||
const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`)
|
||||
// 优化4: 使用 SCAN 获取 keys(带去重)
|
||||
const modelKeys = await this.scanKeysWithDedup(client, `usage:${apiKeyId}:model:*:*:*`)
|
||||
|
||||
if (modelKeys.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 优化5: 使用 Pipeline + HMGET 批量获取数据
|
||||
const BATCH_SIZE = 100
|
||||
const allData = []
|
||||
|
||||
for (let i = 0; i < modelKeys.length; i += BATCH_SIZE) {
|
||||
const batch = modelKeys.slice(i, i + BATCH_SIZE)
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
for (const key of batch) {
|
||||
pipeline.hmget(key, ...USAGE_FIELDS)
|
||||
}
|
||||
|
||||
const results = await pipeline.exec()
|
||||
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
const [err, values] = results[j]
|
||||
if (err) continue
|
||||
|
||||
// 将数组转换为对象
|
||||
const data = {}
|
||||
let hasData = false
|
||||
for (let k = 0; k < USAGE_FIELDS.length; k++) {
|
||||
if (values[k] !== null) {
|
||||
data[USAGE_FIELDS[k]] = values[k]
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasData) {
|
||||
allData.push({ key: batch[j], data })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按日期分组统计
|
||||
const dailyCosts = new Map() // date -> cost
|
||||
const monthlyCosts = new Map() // month -> cost
|
||||
const hourlyCosts = new Map() // hour -> cost
|
||||
const dailyCosts = new Map()
|
||||
const monthlyCosts = new Map()
|
||||
const hourlyCosts = new Map()
|
||||
|
||||
for (const key of modelKeys) {
|
||||
// 解析key格式: usage:{keyId}:model:{period}:{model}:{date}
|
||||
for (const { key, data } of allData) {
|
||||
const match = key.match(
|
||||
/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/
|
||||
)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
if (!match) continue
|
||||
|
||||
const [, , period, model, dateStr] = match
|
||||
|
||||
// 获取使用数据
|
||||
const data = await client.hgetall(key)
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算费用
|
||||
const usage = {
|
||||
input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0,
|
||||
output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0,
|
||||
@@ -84,47 +198,34 @@ class CostInitService {
|
||||
const costResult = CostCalculator.calculateCost(usage, model)
|
||||
const cost = costResult.costs.total
|
||||
|
||||
// 根据period分组累加费用
|
||||
if (period === 'daily') {
|
||||
const currentCost = dailyCosts.get(dateStr) || 0
|
||||
dailyCosts.set(dateStr, currentCost + cost)
|
||||
dailyCosts.set(dateStr, (dailyCosts.get(dateStr) || 0) + cost)
|
||||
} else if (period === 'monthly') {
|
||||
const currentCost = monthlyCosts.get(dateStr) || 0
|
||||
monthlyCosts.set(dateStr, currentCost + cost)
|
||||
monthlyCosts.set(dateStr, (monthlyCosts.get(dateStr) || 0) + cost)
|
||||
} else if (period === 'hourly') {
|
||||
const currentCost = hourlyCosts.get(dateStr) || 0
|
||||
hourlyCosts.set(dateStr, currentCost + cost)
|
||||
hourlyCosts.set(dateStr, (hourlyCosts.get(dateStr) || 0) + cost)
|
||||
}
|
||||
}
|
||||
|
||||
// 将计算出的费用写入Redis
|
||||
const promises = []
|
||||
// 使用 SET NX EX 只补缺失的键,不覆盖已存在的
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
// 写入每日费用
|
||||
// 写入每日费用(只补缺失)
|
||||
for (const [date, cost] of dailyCosts) {
|
||||
const key = `usage:cost:daily:${apiKeyId}:${date}`
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 30) // 30天过期
|
||||
)
|
||||
pipeline.set(key, cost.toString(), 'EX', 86400 * 30, 'NX')
|
||||
}
|
||||
|
||||
// 写入每月费用
|
||||
// 写入每月费用(只补缺失)
|
||||
for (const [month, cost] of monthlyCosts) {
|
||||
const key = `usage:cost:monthly:${apiKeyId}:${month}`
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 90) // 90天过期
|
||||
)
|
||||
pipeline.set(key, cost.toString(), 'EX', 86400 * 90, 'NX')
|
||||
}
|
||||
|
||||
// 写入每小时费用
|
||||
// 写入每小时费用(只补缺失)
|
||||
for (const [hour, cost] of hourlyCosts) {
|
||||
const key = `usage:cost:hourly:${apiKeyId}:${hour}`
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 7) // 7天过期
|
||||
)
|
||||
pipeline.set(key, cost.toString(), 'EX', 86400 * 7, 'NX')
|
||||
}
|
||||
|
||||
// 计算总费用
|
||||
@@ -133,37 +234,25 @@ class CostInitService {
|
||||
totalCost += cost
|
||||
}
|
||||
|
||||
// 写入总费用 - 修复:只在总费用不存在时初始化,避免覆盖现有累计值
|
||||
// 写入总费用(只补缺失)
|
||||
if (totalCost > 0) {
|
||||
const totalKey = `usage:cost:total:${apiKeyId}`
|
||||
// 先检查总费用是否已存在
|
||||
const existingTotal = await client.get(totalKey)
|
||||
|
||||
if (!existingTotal || parseFloat(existingTotal) === 0) {
|
||||
// 仅在总费用不存在或为0时才初始化
|
||||
promises.push(client.set(totalKey, totalCost.toString()))
|
||||
pipeline.set(totalKey, totalCost.toString())
|
||||
logger.info(`💰 Initialized total cost for API Key ${apiKeyId}: $${totalCost.toFixed(6)}`)
|
||||
} else {
|
||||
// 如果总费用已存在,保持不变,避免覆盖累计值
|
||||
// 注意:这个逻辑防止因每日费用键过期(30天)导致的错误覆盖
|
||||
// 如果需要强制重新计算,请先手动删除 usage:cost:total:{keyId} 键
|
||||
const existing = parseFloat(existingTotal)
|
||||
const calculated = totalCost
|
||||
|
||||
if (calculated > existing * 1.1) {
|
||||
// 如果计算值比现有值大 10% 以上,记录警告(可能是数据不一致)
|
||||
if (totalCost > existing * 1.1) {
|
||||
logger.warn(
|
||||
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${calculated.toFixed(6)} (from last 30 days). Keeping existing value to prevent data loss.`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
`💰 Skipping total cost initialization for API Key ${apiKeyId} - existing: $${existing.toFixed(6)}, calculated: $${calculated.toFixed(6)}`
|
||||
`💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${totalCost.toFixed(6)} (from last 30 days). Keeping existing value.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
await pipeline.exec()
|
||||
|
||||
logger.debug(
|
||||
`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}`
|
||||
@@ -172,41 +261,66 @@ class CostInitService {
|
||||
|
||||
/**
|
||||
* 检查是否需要初始化费用数据
|
||||
* 使用 SCAN 代替 KEYS,正确处理 cursor
|
||||
*/
|
||||
async needsInitialization() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 检查是否有任何费用数据
|
||||
const costKeys = await client.keys('usage:cost:*')
|
||||
// 正确循环 SCAN 检查是否有任何费用数据
|
||||
let cursor = '0'
|
||||
let hasCostData = false
|
||||
|
||||
// 如果没有费用数据,需要初始化
|
||||
if (costKeys.length === 0) {
|
||||
do {
|
||||
const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'usage:cost:*', 'COUNT', 100)
|
||||
cursor = newCursor
|
||||
if (keys.length > 0) {
|
||||
hasCostData = true
|
||||
break
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
if (!hasCostData) {
|
||||
logger.info('💰 No cost data found, initialization needed')
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查是否有使用数据但没有对应的费用数据
|
||||
const sampleKeys = await client.keys('usage:*:model:daily:*:*')
|
||||
if (sampleKeys.length > 10) {
|
||||
// 抽样检查
|
||||
const sampleSize = Math.min(10, sampleKeys.length)
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)]
|
||||
// 抽样检查使用数据是否有对应的费用数据
|
||||
cursor = '0'
|
||||
let samplesChecked = 0
|
||||
const maxSamples = 10
|
||||
|
||||
do {
|
||||
const [newCursor, usageKeys] = await client.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
'usage:*:model:daily:*:*',
|
||||
'COUNT',
|
||||
100
|
||||
)
|
||||
cursor = newCursor
|
||||
|
||||
for (const usageKey of usageKeys) {
|
||||
if (samplesChecked >= maxSamples) break
|
||||
|
||||
const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
const [, keyId, , date] = match
|
||||
const costKey = `usage:cost:daily:${keyId}:${date}`
|
||||
const hasCost = await client.exists(costKey)
|
||||
|
||||
if (!hasCost) {
|
||||
logger.info(
|
||||
`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`
|
||||
)
|
||||
return true
|
||||
}
|
||||
samplesChecked++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (samplesChecked >= maxSamples) break
|
||||
} while (cursor !== '0')
|
||||
|
||||
logger.info('💰 Cost data appears to be up to date')
|
||||
return false
|
||||
|
||||
@@ -103,7 +103,7 @@ class CostRankService {
|
||||
}
|
||||
|
||||
this.isInitialized = true
|
||||
logger.success('✅ CostRankService initialized')
|
||||
logger.success('CostRankService initialized')
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize CostRankService:', error)
|
||||
throw error
|
||||
@@ -391,17 +391,32 @@ class CostRankService {
|
||||
return {}
|
||||
}
|
||||
|
||||
const status = {}
|
||||
|
||||
// 使用 Pipeline 批量获取
|
||||
const pipeline = client.pipeline()
|
||||
for (const timeRange of VALID_TIME_RANGES) {
|
||||
const meta = await client.hgetall(RedisKeys.metaKey(timeRange))
|
||||
status[timeRange] = {
|
||||
lastUpdate: meta.lastUpdate || null,
|
||||
keyCount: parseInt(meta.keyCount || 0),
|
||||
status: meta.status || 'unknown',
|
||||
updateDuration: parseInt(meta.updateDuration || 0)
|
||||
}
|
||||
pipeline.hgetall(RedisKeys.metaKey(timeRange))
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
const status = {}
|
||||
VALID_TIME_RANGES.forEach((timeRange, i) => {
|
||||
const [err, meta] = results[i]
|
||||
if (err || !meta) {
|
||||
status[timeRange] = {
|
||||
lastUpdate: null,
|
||||
keyCount: 0,
|
||||
status: 'unknown',
|
||||
updateDuration: 0
|
||||
}
|
||||
} else {
|
||||
status[timeRange] = {
|
||||
lastUpdate: meta.lastUpdate || null,
|
||||
keyCount: parseInt(meta.keyCount || 0),
|
||||
status: meta.status || 'unknown',
|
||||
updateDuration: parseInt(meta.updateDuration || 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { maskToken } = require('../utils/tokenMask')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { createEncryptor, isTruthy } = require('../utils/commonHelper')
|
||||
|
||||
/**
|
||||
* Droid 账户管理服务
|
||||
@@ -26,24 +26,14 @@ class DroidAccountService {
|
||||
this.refreshIntervalHours = 6 // 每6小时刷新一次
|
||||
this.tokenValidHours = 8 // Token 有效期8小时
|
||||
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'droid-account-salt'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存
|
||||
this._decryptCache = new LRUCache(500)
|
||||
// 使用 commonHelper 的加密器
|
||||
this._encryptor = createEncryptor('droid-account-salt')
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info('🧹 Droid decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
setInterval(() => {
|
||||
this._encryptor.clearCache()
|
||||
logger.info('🧹 Droid decrypt cache cleanup completed', this._encryptor.getStats())
|
||||
}, 10 * 60 * 1000)
|
||||
|
||||
this.supportedEndpointTypes = new Set(['anthropic', 'openai', 'comm'])
|
||||
}
|
||||
@@ -69,92 +59,19 @@ class DroidAccountService {
|
||||
return 'anthropic'
|
||||
}
|
||||
|
||||
// 使用 commonHelper 的 isTruthy
|
||||
_isTruthy(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true') {
|
||||
return true
|
||||
}
|
||||
if (normalized === 'false') {
|
||||
return false
|
||||
}
|
||||
return normalized.length > 0 && normalized !== '0' && normalized !== 'no'
|
||||
}
|
||||
return Boolean(value)
|
||||
return isTruthy(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成加密密钥(缓存优化)
|
||||
*/
|
||||
_generateEncryptionKey() {
|
||||
if (!this._encryptionKeyCache) {
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
logger.info('🔑 Droid encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密敏感数据
|
||||
*/
|
||||
// 加密敏感数据
|
||||
_encryptSensitiveData(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
|
||||
return `${iv.toString('hex')}:${encrypted}`
|
||||
return this._encryptor.encrypt(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密敏感数据(带缓存)
|
||||
*/
|
||||
// 解密敏感数据(带缓存)
|
||||
_decryptSensitiveData(encryptedText) {
|
||||
if (!encryptedText) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(encryptedText).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = this._generateEncryptionKey()
|
||||
const parts = encryptedText.split(':')
|
||||
const iv = Buffer.from(parts[0], 'hex')
|
||||
const encrypted = parts[1]
|
||||
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to decrypt Droid data:', error)
|
||||
return ''
|
||||
}
|
||||
return this._encryptor.decrypt(encryptedText)
|
||||
}
|
||||
|
||||
_parseApiKeyEntries(rawEntries) {
|
||||
@@ -683,7 +600,7 @@ class DroidAccountService {
|
||||
|
||||
lastRefreshAt = new Date().toISOString()
|
||||
status = 'active'
|
||||
logger.success(`✅ 使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`)
|
||||
logger.success(`使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error)
|
||||
throw new Error(`Refresh Token 验证失败:${error.message}`)
|
||||
@@ -1368,7 +1285,7 @@ class DroidAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Droid account token refreshed successfully: ${accountId}`)
|
||||
logger.success(`Droid account token refreshed successfully: ${accountId}`)
|
||||
|
||||
return {
|
||||
accessToken: refreshed.accessToken,
|
||||
|
||||
@@ -609,7 +609,7 @@ class DroidRelayService {
|
||||
' [stream]'
|
||||
)
|
||||
|
||||
logger.success(`✅ Droid stream completed - Account: ${account.name}`)
|
||||
logger.success(`Droid stream completed - Account: ${account.name}`)
|
||||
} else {
|
||||
logger.success(
|
||||
`✅ Droid stream completed - Account: ${account.name}, usage recording skipped`
|
||||
|
||||
@@ -2,103 +2,31 @@ const droidAccountService = require('./droidAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { isTruthy, isAccountHealthy, sortAccountsByPriority, normalizeEndpointType } = require('../utils/commonHelper')
|
||||
|
||||
class DroidScheduler {
|
||||
constructor() {
|
||||
this.STICKY_PREFIX = 'droid'
|
||||
}
|
||||
|
||||
_normalizeEndpointType(endpointType) {
|
||||
if (!endpointType) {
|
||||
return 'anthropic'
|
||||
}
|
||||
const normalized = String(endpointType).toLowerCase()
|
||||
if (normalized === 'openai') {
|
||||
return 'openai'
|
||||
}
|
||||
if (normalized === 'comm') {
|
||||
return 'comm'
|
||||
}
|
||||
if (normalized === 'anthropic') {
|
||||
return 'anthropic'
|
||||
}
|
||||
return 'anthropic'
|
||||
}
|
||||
|
||||
_isTruthy(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase() === 'true'
|
||||
}
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
_isAccountActive(account) {
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
const isActive = this._isTruthy(account.isActive)
|
||||
if (!isActive) {
|
||||
return false
|
||||
}
|
||||
|
||||
const status = (account.status || 'active').toLowerCase()
|
||||
const unhealthyStatuses = new Set(['error', 'unauthorized', 'blocked'])
|
||||
return !unhealthyStatuses.has(status)
|
||||
}
|
||||
|
||||
_isAccountSchedulable(account) {
|
||||
return this._isTruthy(account?.schedulable ?? true)
|
||||
return isTruthy(account?.schedulable ?? true)
|
||||
}
|
||||
|
||||
_matchesEndpoint(account, endpointType) {
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const accountEndpoint = this._normalizeEndpointType(account?.endpointType)
|
||||
if (normalizedEndpoint === accountEndpoint) {
|
||||
return true
|
||||
}
|
||||
|
||||
// comm 端点可以使用任何类型的账户
|
||||
if (normalizedEndpoint === 'comm') {
|
||||
return true
|
||||
}
|
||||
|
||||
const normalizedEndpoint = normalizeEndpointType(endpointType)
|
||||
const accountEndpoint = normalizeEndpointType(account?.endpointType)
|
||||
if (normalizedEndpoint === accountEndpoint) return true
|
||||
if (normalizedEndpoint === 'comm') return true
|
||||
const sharedEndpoints = new Set(['anthropic', 'openai'])
|
||||
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
|
||||
}
|
||||
|
||||
_sortCandidates(candidates) {
|
||||
return [...candidates].sort((a, b) => {
|
||||
const priorityA = parseInt(a.priority, 10) || 50
|
||||
const priorityB = parseInt(b.priority, 10) || 50
|
||||
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB
|
||||
}
|
||||
|
||||
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
|
||||
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
|
||||
|
||||
if (lastUsedA !== lastUsedB) {
|
||||
return lastUsedA - lastUsedB
|
||||
}
|
||||
|
||||
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return createdA - createdB
|
||||
})
|
||||
}
|
||||
|
||||
_composeStickySessionKey(endpointType, sessionHash, apiKeyId) {
|
||||
if (!sessionHash) {
|
||||
return null
|
||||
}
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const normalizedEndpoint = normalizeEndpointType(endpointType)
|
||||
const apiKeyPart = apiKeyId || 'default'
|
||||
return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}`
|
||||
}
|
||||
@@ -121,7 +49,7 @@ class DroidScheduler {
|
||||
)
|
||||
|
||||
return accounts.filter(
|
||||
(account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account)
|
||||
(account) => account && isAccountHealthy(account) && this._isAccountSchedulable(account)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -145,7 +73,7 @@ class DroidScheduler {
|
||||
}
|
||||
|
||||
async selectAccount(apiKeyData, endpointType, sessionHash) {
|
||||
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||
const normalizedEndpoint = normalizeEndpointType(endpointType)
|
||||
const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id)
|
||||
|
||||
let candidates = []
|
||||
@@ -175,7 +103,7 @@ class DroidScheduler {
|
||||
const filtered = candidates.filter(
|
||||
(account) =>
|
||||
account &&
|
||||
this._isAccountActive(account) &&
|
||||
isAccountHealthy(account) &&
|
||||
this._isAccountSchedulable(account) &&
|
||||
this._matchesEndpoint(account, normalizedEndpoint)
|
||||
)
|
||||
@@ -203,7 +131,7 @@ class DroidScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = this._sortCandidates(filtered)
|
||||
const sorted = sortAccountsByPriority(filtered)
|
||||
const selected = sorted[0]
|
||||
|
||||
if (!selected) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const redisClient = require('../models/redis')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const https = require('https')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
@@ -15,7 +14,10 @@ const {
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { createEncryptor } = require('../utils/commonHelper')
|
||||
|
||||
// Gemini 账户键前缀
|
||||
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
|
||||
|
||||
// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
|
||||
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
|
||||
@@ -34,91 +36,15 @@ const keepAliveAgent = new https.Agent({
|
||||
|
||||
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
|
||||
|
||||
// 加密相关常量
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'gemini-account-salt'
|
||||
const IV_LENGTH = 16
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
|
||||
let _encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
const decryptCache = new LRUCache(500)
|
||||
|
||||
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
|
||||
function generateEncryptionKey() {
|
||||
if (!_encryptionKeyCache) {
|
||||
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||
logger.info('🔑 Gemini encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return _encryptionKeyCache
|
||||
}
|
||||
|
||||
// Gemini 账户键前缀
|
||||
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
|
||||
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
|
||||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:'
|
||||
|
||||
// 加密函数
|
||||
function encrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
const key = generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(IV_LENGTH)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(text)
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()])
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
||||
}
|
||||
|
||||
// 解密函数
|
||||
function decrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
||||
const cached = decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = generateEncryptionKey()
|
||||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
||||
const ivHex = text.substring(0, 32)
|
||||
const encryptedHex = text.substring(33) // 跳过冒号
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
const result = decrypted.toString()
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
||||
|
||||
// 📊 定期打印缓存统计
|
||||
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
|
||||
decryptCache.printStats()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
// 使用 commonHelper 的加密器
|
||||
const encryptor = createEncryptor('gemini-account-salt')
|
||||
const { encrypt, decrypt } = encryptor
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
decryptCache.cleanup()
|
||||
logger.info('🧹 Gemini decrypt cache cleanup completed', decryptCache.getStats())
|
||||
encryptor.clearCache()
|
||||
logger.info('🧹 Gemini decrypt cache cleanup completed', encryptor.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
@@ -426,6 +352,7 @@ async function createAccount(accountData) {
|
||||
// 保存到 Redis
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`, account)
|
||||
await redisClient.addToIndex('gemini_account:index', id)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (account.accountType === 'shared') {
|
||||
@@ -623,19 +550,20 @@ async function deleteAccount(accountId) {
|
||||
// 从 Redis 删除
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
await redisClient.removeFromIndex('gemini_account:index', accountId)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
if (account.accountType === 'shared') {
|
||||
await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
|
||||
// 清理会话映射
|
||||
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
|
||||
for (const key of sessionMappings) {
|
||||
const mappedAccountId = await client.get(key)
|
||||
if (mappedAccountId === accountId) {
|
||||
await client.del(key)
|
||||
}
|
||||
// 清理会话映射(使用反向索引)
|
||||
const sessionHashes = await client.smembers(`gemini_account_sessions:${accountId}`)
|
||||
if (sessionHashes.length > 0) {
|
||||
const pipeline = client.pipeline()
|
||||
sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`))
|
||||
pipeline.del(`gemini_account_sessions:${accountId}`)
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
logger.info(`Deleted Gemini account: ${accountId}`)
|
||||
@@ -645,11 +573,17 @@ async function deleteAccount(accountId) {
|
||||
// 获取所有账户
|
||||
async function getAllAccounts() {
|
||||
const client = redisClient.getClientSafe()
|
||||
const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`)
|
||||
const accountIds = await redisClient.getAllIdsByIndex(
|
||||
'gemini_account:index',
|
||||
`${GEMINI_ACCOUNT_KEY_PREFIX}*`,
|
||||
/^gemini_account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `${GEMINI_ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redisClient.batchHgetallChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
|
||||
@@ -752,6 +686,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
3600, // 1小时过期
|
||||
account.id
|
||||
)
|
||||
await client.sadd(`gemini_account_sessions:${account.id}`, sessionHash)
|
||||
await client.expire(`gemini_account_sessions:${account.id}`, 3600)
|
||||
}
|
||||
|
||||
return account
|
||||
@@ -811,6 +747,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
// 创建粘性会话映射
|
||||
if (sessionHash) {
|
||||
await client.setex(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, selectedAccount.id)
|
||||
await client.sadd(`gemini_account_sessions:${selectedAccount.id}`, sessionHash)
|
||||
await client.expire(`gemini_account_sessions:${selectedAccount.id}`, 3600)
|
||||
}
|
||||
|
||||
return selectedAccount
|
||||
@@ -1684,8 +1622,7 @@ module.exports = {
|
||||
setupUser,
|
||||
encrypt,
|
||||
decrypt,
|
||||
generateEncryptionKey,
|
||||
decryptCache, // 暴露缓存对象以便测试和监控
|
||||
encryptor, // 暴露加密器以便测试和监控
|
||||
countTokens,
|
||||
generateContent,
|
||||
generateContentStream,
|
||||
|
||||
@@ -85,7 +85,7 @@ class GeminiApiAccountService {
|
||||
// 保存到 Redis
|
||||
await this._saveAccount(accountId, accountData)
|
||||
|
||||
logger.success(`🚀 Created Gemini-API account: ${name} (${accountId})`)
|
||||
logger.success(`Created Gemini-API account: ${name} (${accountId})`)
|
||||
|
||||
return {
|
||||
...accountData,
|
||||
@@ -172,6 +172,9 @@ class GeminiApiAccountService {
|
||||
// 从共享账户列表中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 从索引中移除
|
||||
await redis.removeFromIndex('gemini_api_account:index', accountId)
|
||||
|
||||
// 删除账户数据
|
||||
await client.del(key)
|
||||
|
||||
@@ -223,11 +226,17 @@ class GeminiApiAccountService {
|
||||
}
|
||||
|
||||
// 直接从 Redis 获取所有账户(包括非共享账户)
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
for (const key of keys) {
|
||||
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
|
||||
const allAccountIds = await redis.getAllIdsByIndex(
|
||||
'gemini_api_account:index',
|
||||
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||
/^gemini_api_account:(.+)$/
|
||||
)
|
||||
const keys = allAccountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const dataList = await redis.batchHgetallChunked(keys)
|
||||
for (let i = 0; i < allAccountIds.length; i++) {
|
||||
const accountId = allAccountIds[i]
|
||||
if (!accountIds.includes(accountId)) {
|
||||
const accountData = await client.hgetall(key)
|
||||
const accountData = dataList[i]
|
||||
if (accountData && accountData.id) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || accountData.isActive === 'true') {
|
||||
@@ -576,6 +585,9 @@ class GeminiApiAccountService {
|
||||
// 保存账户数据
|
||||
await client.hset(key, accountData)
|
||||
|
||||
// 添加到索引
|
||||
await redis.addToIndex('gemini_api_account:index', accountId)
|
||||
|
||||
// 添加到共享账户列表
|
||||
if (accountData.accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
@@ -18,7 +18,7 @@ class ModelService {
|
||||
(sum, config) => sum + config.models.length,
|
||||
0
|
||||
)
|
||||
logger.success(`✅ Model service initialized with ${totalModels} models`)
|
||||
logger.success(`Model service initialized with ${totalModels} models`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const redisClient = require('../models/redis')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const axios = require('axios')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const config = require('../../config/config')
|
||||
@@ -13,104 +12,23 @@ const {
|
||||
logTokenUsage,
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const { createEncryptor } = require('../utils/commonHelper')
|
||||
|
||||
// 加密相关常量
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'openai-account-salt'
|
||||
const IV_LENGTH = 16
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
|
||||
let _encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
const decryptCache = new LRUCache(500)
|
||||
|
||||
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
|
||||
function generateEncryptionKey() {
|
||||
if (!_encryptionKeyCache) {
|
||||
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||
logger.info('🔑 OpenAI encryption key derived and cached for performance optimization')
|
||||
}
|
||||
return _encryptionKeyCache
|
||||
}
|
||||
// 使用 commonHelper 的加密器
|
||||
const encryptor = createEncryptor('openai-account-salt')
|
||||
const { encrypt, decrypt } = encryptor
|
||||
|
||||
// OpenAI 账户键前缀
|
||||
const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:'
|
||||
const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts'
|
||||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'openai_session_account_mapping:'
|
||||
|
||||
// 加密函数
|
||||
function encrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
const key = generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(IV_LENGTH)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(text)
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()])
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
||||
}
|
||||
|
||||
// 解密函数
|
||||
function decrypt(text) {
|
||||
if (!text || text === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 检查是否是有效的加密格式(至少需要 32 个字符的 IV + 冒号 + 加密文本)
|
||||
if (text.length < 33 || text.charAt(32) !== ':') {
|
||||
logger.warn('Invalid encrypted text format, returning empty string', {
|
||||
textLength: text ? text.length : 0,
|
||||
char32: text && text.length > 32 ? text.charAt(32) : 'N/A',
|
||||
first50: text ? text.substring(0, 50) : 'N/A'
|
||||
})
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
|
||||
const cached = decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const key = generateEncryptionKey()
|
||||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
||||
const ivHex = text.substring(0, 32)
|
||||
const encryptedHex = text.substring(33) // 跳过冒号
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
const result = decrypted.toString()
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
|
||||
|
||||
// 📊 定期打印缓存统计
|
||||
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
|
||||
decryptCache.printStats()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
decryptCache.cleanup()
|
||||
logger.info('🧹 OpenAI decrypt cache cleanup completed', decryptCache.getStats())
|
||||
encryptor.clearCache()
|
||||
logger.info('🧹 OpenAI decrypt cache cleanup completed', encryptor.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
@@ -591,6 +509,7 @@ async function createAccount(accountData) {
|
||||
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
||||
await redisClient.addToIndex('openai:account:index', accountId)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (account.accountType === 'shared') {
|
||||
@@ -725,19 +644,20 @@ async function deleteAccount(accountId) {
|
||||
// 从 Redis 删除
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
await redisClient.removeFromIndex('openai:account:index', accountId)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
if (account.accountType === 'shared') {
|
||||
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
|
||||
// 清理会话映射
|
||||
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
|
||||
for (const key of sessionMappings) {
|
||||
const mappedAccountId = await client.get(key)
|
||||
if (mappedAccountId === accountId) {
|
||||
await client.del(key)
|
||||
}
|
||||
// 清理会话映射(使用反向索引)
|
||||
const sessionHashes = await client.smembers(`openai_account_sessions:${accountId}`)
|
||||
if (sessionHashes.length > 0) {
|
||||
const pipeline = client.pipeline()
|
||||
sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`))
|
||||
pipeline.del(`openai_account_sessions:${accountId}`)
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
logger.info(`Deleted OpenAI account: ${accountId}`)
|
||||
@@ -747,11 +667,17 @@ async function deleteAccount(accountId) {
|
||||
// 获取所有账户
|
||||
async function getAllAccounts() {
|
||||
const client = redisClient.getClientSafe()
|
||||
const keys = await client.keys(`${OPENAI_ACCOUNT_KEY_PREFIX}*`)
|
||||
const accountIds = await redisClient.getAllIdsByIndex(
|
||||
'openai:account:index',
|
||||
`${OPENAI_ACCOUNT_KEY_PREFIX}*`,
|
||||
/^openai:account:(.+)$/
|
||||
)
|
||||
const keys = accountIds.map((id) => `${OPENAI_ACCOUNT_KEY_PREFIX}${id}`)
|
||||
const accounts = []
|
||||
const dataList = await redisClient.batchHgetallChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const accountData = dataList[i]
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
const codexUsage = buildCodexUsageSnapshot(accountData)
|
||||
|
||||
@@ -926,6 +852,9 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
3600, // 1小时过期
|
||||
account.id
|
||||
)
|
||||
// 反向索引:accountId -> sessionHash(用于删除账户时快速清理)
|
||||
await client.sadd(`openai_account_sessions:${account.id}`, sessionHash)
|
||||
await client.expire(`openai_account_sessions:${account.id}`, 3600)
|
||||
}
|
||||
|
||||
return account
|
||||
@@ -976,6 +905,8 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
3600, // 1小时过期
|
||||
selectedAccount.id
|
||||
)
|
||||
await client.sadd(`openai_account_sessions:${selectedAccount.id}`, sessionHash)
|
||||
await client.expire(`openai_account_sessions:${selectedAccount.id}`, 3600)
|
||||
}
|
||||
|
||||
return selectedAccount
|
||||
@@ -1278,6 +1209,5 @@ module.exports = {
|
||||
updateCodexUsageSnapshot,
|
||||
encrypt,
|
||||
decrypt,
|
||||
generateEncryptionKey,
|
||||
decryptCache // 暴露缓存对象以便测试和监控
|
||||
encryptor // 暴露加密器以便测试和监控
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ class OpenAIResponsesAccountService {
|
||||
// 保存到 Redis
|
||||
await this._saveAccount(accountId, accountData)
|
||||
|
||||
logger.success(`🚀 Created OpenAI-Responses account: ${name} (${accountId})`)
|
||||
logger.success(`Created OpenAI-Responses account: ${name} (${accountId})`)
|
||||
|
||||
return {
|
||||
...accountData,
|
||||
@@ -180,6 +180,9 @@ class OpenAIResponsesAccountService {
|
||||
// 从共享账户列表中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 从索引中移除
|
||||
await redis.removeFromIndex('openai_responses_account:index', accountId)
|
||||
|
||||
// 删除账户数据
|
||||
await client.del(key)
|
||||
|
||||
@@ -191,97 +194,62 @@ class OpenAIResponsesAccountService {
|
||||
// 获取所有账户
|
||||
async getAllAccounts(includeInactive = false) {
|
||||
const client = redis.getClientSafe()
|
||||
const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY)
|
||||
|
||||
// 使用索引获取所有账户ID
|
||||
const accountIds = await redis.getAllIdsByIndex(
|
||||
'openai_responses_account:index',
|
||||
`${this.ACCOUNT_KEY_PREFIX}*`,
|
||||
/^openai_responses_account:(.+)$/
|
||||
)
|
||||
if (accountIds.length === 0) return []
|
||||
|
||||
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
|
||||
// Pipeline 批量查询所有账户数据
|
||||
const pipeline = client.pipeline()
|
||||
keys.forEach((key) => pipeline.hgetall(key))
|
||||
const results = await pipeline.exec()
|
||||
|
||||
const accounts = []
|
||||
results.forEach(([err, accountData], index) => {
|
||||
if (err || !accountData || !accountData.id) return
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (account) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || account.isActive === 'true') {
|
||||
// 隐藏敏感信息
|
||||
account.apiKey = '***'
|
||||
// 过滤非活跃账户
|
||||
if (!includeInactive && accountData.isActive !== 'true') return
|
||||
|
||||
// 获取限流状态信息(与普通OpenAI账号保持一致的格式)
|
||||
const rateLimitInfo = this._getRateLimitInfo(account)
|
||||
// 隐藏敏感信息
|
||||
accountData.apiKey = '***'
|
||||
|
||||
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
|
||||
account.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||
? {
|
||||
isRateLimited: true,
|
||||
rateLimitedAt: account.rateLimitedAt || null,
|
||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
||||
}
|
||||
: {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
|
||||
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
|
||||
account.schedulable = account.schedulable !== 'false'
|
||||
// 转换 isActive 字段为布尔值
|
||||
account.isActive = account.isActive === 'true'
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
account.expiresAt = account.subscriptionExpiresAt || null
|
||||
account.platform = account.platform || 'openai-responses'
|
||||
|
||||
accounts.push(account)
|
||||
// 解析 JSON 字段
|
||||
if (accountData.proxy) {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
} catch {
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 直接从 Redis 获取所有账户(包括非共享账户)
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
for (const key of keys) {
|
||||
const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '')
|
||||
if (!accountIds.includes(accountId)) {
|
||||
const accountData = await client.hgetall(key)
|
||||
if (accountData && accountData.id) {
|
||||
// 过滤非活跃账户
|
||||
if (includeInactive || accountData.isActive === 'true') {
|
||||
// 隐藏敏感信息
|
||||
accountData.apiKey = '***'
|
||||
// 解析 JSON 字段
|
||||
if (accountData.proxy) {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
} catch (e) {
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取限流状态信息(与普通OpenAI账号保持一致的格式)
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
|
||||
// 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致)
|
||||
accountData.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||
? {
|
||||
isRateLimited: true,
|
||||
rateLimitedAt: accountData.rateLimitedAt || null,
|
||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
||||
}
|
||||
: {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
|
||||
// 转换 schedulable 字段为布尔值(前端需要布尔值来判断)
|
||||
accountData.schedulable = accountData.schedulable !== 'false'
|
||||
// 转换 isActive 字段为布尔值
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
|
||||
// ✅ 前端显示订阅过期时间(业务字段)
|
||||
accountData.expiresAt = accountData.subscriptionExpiresAt || null
|
||||
accountData.platform = accountData.platform || 'openai-responses'
|
||||
|
||||
accounts.push(accountData)
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
accountData.rateLimitStatus = rateLimitInfo.isRateLimited
|
||||
? {
|
||||
isRateLimited: true,
|
||||
rateLimitedAt: accountData.rateLimitedAt || null,
|
||||
minutesRemaining: rateLimitInfo.remainingMinutes || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
|
||||
// 转换字段类型
|
||||
accountData.schedulable = accountData.schedulable !== 'false'
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
accountData.expiresAt = accountData.subscriptionExpiresAt || null
|
||||
accountData.platform = accountData.platform || 'openai-responses'
|
||||
|
||||
accounts.push(accountData)
|
||||
})
|
||||
|
||||
return accounts
|
||||
}
|
||||
@@ -644,6 +612,9 @@ class OpenAIResponsesAccountService {
|
||||
// 保存账户数据
|
||||
await client.hset(key, accountData)
|
||||
|
||||
// 添加到索引
|
||||
await redis.addToIndex('openai_responses_account:index', accountId)
|
||||
|
||||
// 添加到共享账户列表
|
||||
if (accountData.accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
@@ -7,6 +7,11 @@ const apiKeyService = require('./apiKeyService')
|
||||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||
const config = require('../../config/config')
|
||||
const crypto = require('crypto')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
|
||||
// lastUsedAt 更新节流(每账户 60 秒内最多更新一次,使用 LRU 防止内存泄漏)
|
||||
const lastUsedAtThrottle = new LRUCache(1000) // 最多缓存 1000 个账户
|
||||
const LAST_USED_AT_THROTTLE_MS = 60000
|
||||
|
||||
// 抽取缓存写入 token,兼容多种字段命名
|
||||
function extractCacheCreationTokens(usageData) {
|
||||
@@ -39,6 +44,21 @@ class OpenAIResponsesRelayService {
|
||||
this.defaultTimeout = config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
// 节流更新 lastUsedAt
|
||||
async _throttledUpdateLastUsedAt(accountId) {
|
||||
const now = Date.now()
|
||||
const lastUpdate = lastUsedAtThrottle.get(accountId)
|
||||
|
||||
if (lastUpdate && now - lastUpdate < LAST_USED_AT_THROTTLE_MS) {
|
||||
return // 跳过更新
|
||||
}
|
||||
|
||||
lastUsedAtThrottle.set(accountId, now, LAST_USED_AT_THROTTLE_MS)
|
||||
await openaiResponsesAccountService.updateAccount(accountId, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 处理请求转发
|
||||
async handleRequest(req, res, account, apiKeyData) {
|
||||
let abortController = null
|
||||
@@ -259,10 +279,8 @@ class OpenAIResponsesRelayService {
|
||||
return res.status(response.status).json(errorData)
|
||||
}
|
||||
|
||||
// 更新最后使用时间
|
||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
// 更新最后使用时间(节流)
|
||||
await this._throttledUpdateLastUsedAt(account.id)
|
||||
|
||||
// 处理流式响应
|
||||
if (req.body?.stream && response.data && typeof response.data.pipe === 'function') {
|
||||
|
||||
@@ -105,7 +105,7 @@ class PricingService {
|
||||
// 设置文件监听器
|
||||
this.setupFileWatcher()
|
||||
|
||||
logger.success('💰 Pricing service initialized successfully')
|
||||
logger.success('Pricing service initialized successfully')
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize pricing service:', error)
|
||||
}
|
||||
@@ -298,7 +298,7 @@ class PricingService {
|
||||
this.pricingData = jsonData
|
||||
this.lastUpdated = new Date()
|
||||
|
||||
logger.success(`💰 Downloaded pricing data for ${Object.keys(jsonData).length} models`)
|
||||
logger.success(`Downloaded pricing data for ${Object.keys(jsonData).length} models`)
|
||||
|
||||
// 设置或重新设置文件监听器
|
||||
this.setupFileWatcher()
|
||||
@@ -762,7 +762,7 @@ class PricingService {
|
||||
this.lastUpdated = new Date()
|
||||
|
||||
const modelCount = Object.keys(jsonData).length
|
||||
logger.success(`💰 Reloaded pricing data for ${modelCount} models from file`)
|
||||
logger.success(`Reloaded pricing data for ${modelCount} models from file`)
|
||||
|
||||
// 显示一些统计信息
|
||||
const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length
|
||||
|
||||
@@ -6,6 +6,7 @@ const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper')
|
||||
const { isSchedulable, sortAccountsByPriority } = require('../utils/commonHelper')
|
||||
|
||||
/**
|
||||
* Check if account is Pro (not Max)
|
||||
@@ -38,16 +39,6 @@ class UnifiedClaudeScheduler {
|
||||
this.SESSION_MAPPING_PREFIX = 'unified_claude_session_mapping:'
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||
_isSchedulable(schedulable) {
|
||||
// 如果是 undefined 或 null,默认为可调度
|
||||
if (schedulable === undefined || schedulable === null) {
|
||||
return true
|
||||
}
|
||||
// 明确设置为 false(布尔值)或 'false'(字符串)时不可调度
|
||||
return schedulable !== false && schedulable !== 'false'
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否支持请求的模型
|
||||
_isModelSupportedByAccount(account, accountType, requestedModel, context = '') {
|
||||
if (!requestedModel) {
|
||||
@@ -286,7 +277,7 @@ class UnifiedClaudeScheduler {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||
if (!isSchedulable(boundAccount.schedulable)) {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
|
||||
)
|
||||
@@ -319,7 +310,7 @@ class UnifiedClaudeScheduler {
|
||||
boundConsoleAccount &&
|
||||
boundConsoleAccount.isActive === true &&
|
||||
boundConsoleAccount.status === 'active' &&
|
||||
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||
isSchedulable(boundConsoleAccount.schedulable)
|
||||
) {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
@@ -354,7 +345,7 @@ class UnifiedClaudeScheduler {
|
||||
if (
|
||||
boundBedrockAccountResult.success &&
|
||||
boundBedrockAccountResult.data.isActive === true &&
|
||||
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
) {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
@@ -436,7 +427,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -496,7 +487,7 @@ class UnifiedClaudeScheduler {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||
if (!isSchedulable(boundAccount.schedulable)) {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable})`
|
||||
)
|
||||
@@ -530,7 +521,7 @@ class UnifiedClaudeScheduler {
|
||||
boundConsoleAccount &&
|
||||
boundConsoleAccount.isActive === true &&
|
||||
boundConsoleAccount.status === 'active' &&
|
||||
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||
isSchedulable(boundConsoleAccount.schedulable)
|
||||
) {
|
||||
// 主动触发一次额度检查
|
||||
try {
|
||||
@@ -579,7 +570,7 @@ class UnifiedClaudeScheduler {
|
||||
if (
|
||||
boundBedrockAccountResult.success &&
|
||||
boundBedrockAccountResult.data.isActive === true &&
|
||||
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
) {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})`
|
||||
@@ -609,7 +600,7 @@ class UnifiedClaudeScheduler {
|
||||
account.status !== 'blocked' &&
|
||||
account.status !== 'temp_error' &&
|
||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查是否可调度
|
||||
|
||||
@@ -691,7 +682,7 @@ class UnifiedClaudeScheduler {
|
||||
currentAccount.isActive === true &&
|
||||
currentAccount.status === 'active' &&
|
||||
currentAccount.accountType === 'shared' &&
|
||||
this._isSchedulable(currentAccount.schedulable)
|
||||
isSchedulable(currentAccount.schedulable)
|
||||
) {
|
||||
// 检查是否可调度
|
||||
|
||||
@@ -826,7 +817,7 @@ class UnifiedClaudeScheduler {
|
||||
if (
|
||||
account.isActive === true &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
@@ -870,7 +861,7 @@ class UnifiedClaudeScheduler {
|
||||
account.isActive === true &&
|
||||
account.status === 'active' &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
|
||||
@@ -949,21 +940,6 @@ class UnifiedClaudeScheduler {
|
||||
return availableAccounts
|
||||
}
|
||||
|
||||
// 🔢 按优先级和最后使用时间排序账户
|
||||
_sortAccountsByPriority(accounts) {
|
||||
return accounts.sort((a, b) => {
|
||||
// 首先按优先级排序(数字越小优先级越高)
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority
|
||||
}
|
||||
|
||||
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed
|
||||
})
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否可用
|
||||
async _isAccountAvailable(accountId, accountType, requestedModel = null) {
|
||||
try {
|
||||
@@ -978,7 +954,7 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 Account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -1029,7 +1005,7 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -1093,7 +1069,7 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(accountResult.data.schedulable)) {
|
||||
if (!isSchedulable(accountResult.data.schedulable)) {
|
||||
logger.info(`🚫 Bedrock account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -1113,7 +1089,7 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 CCR account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -1544,7 +1520,7 @@ class UnifiedClaudeScheduler {
|
||||
? account.status === 'active'
|
||||
: account.status === 'active'
|
||||
|
||||
if (isActive && status && this._isSchedulable(account.schedulable)) {
|
||||
if (isActive && status && isSchedulable(account.schedulable)) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) {
|
||||
continue
|
||||
@@ -1594,7 +1570,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 使用现有的优先级排序逻辑
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -1664,7 +1640,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 3. 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableCcrAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableCcrAccounts)
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
// 4. 建立会话映射
|
||||
@@ -1710,7 +1686,7 @@ class UnifiedClaudeScheduler {
|
||||
account.isActive === true &&
|
||||
account.status === 'active' &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
|
||||
|
||||
@@ -3,28 +3,13 @@ const geminiApiAccountService = require('./geminiApiAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { isSchedulable, isActive, sortAccountsByPriority } = require('../utils/commonHelper')
|
||||
|
||||
class UnifiedGeminiScheduler {
|
||||
constructor() {
|
||||
this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:'
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||
_isSchedulable(schedulable) {
|
||||
// 如果是 undefined 或 null,默认为可调度
|
||||
if (schedulable === undefined || schedulable === null) {
|
||||
return true
|
||||
}
|
||||
// 明确设置为 false(布尔值)或 'false'(字符串)时不可调度
|
||||
return schedulable !== false && schedulable !== 'false'
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否激活(兼容字符串和布尔值)
|
||||
_isActive(isActive) {
|
||||
// 兼容布尔值 true 和字符串 'true'
|
||||
return isActive === true || isActive === 'true'
|
||||
}
|
||||
|
||||
// 🎯 统一调度Gemini账号
|
||||
async selectAccountForApiKey(
|
||||
apiKeyData,
|
||||
@@ -43,7 +28,7 @@ class UnifiedGeminiScheduler {
|
||||
const boundAccount = await geminiApiAccountService.getAccount(accountId)
|
||||
if (
|
||||
boundAccount &&
|
||||
this._isActive(boundAccount.isActive) &&
|
||||
isActive(boundAccount.isActive) &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
logger.info(
|
||||
@@ -80,7 +65,7 @@ class UnifiedGeminiScheduler {
|
||||
const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId)
|
||||
if (
|
||||
boundAccount &&
|
||||
this._isActive(boundAccount.isActive) &&
|
||||
isActive(boundAccount.isActive) &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
logger.info(
|
||||
@@ -150,7 +135,7 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -200,7 +185,7 @@ class UnifiedGeminiScheduler {
|
||||
const boundAccount = await geminiApiAccountService.getAccount(accountId)
|
||||
if (
|
||||
boundAccount &&
|
||||
this._isActive(boundAccount.isActive) &&
|
||||
isActive(boundAccount.isActive) &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
const isRateLimited = await this.isAccountRateLimited(accountId)
|
||||
@@ -251,7 +236,7 @@ class UnifiedGeminiScheduler {
|
||||
const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId)
|
||||
if (
|
||||
boundAccount &&
|
||||
this._isActive(boundAccount.isActive) &&
|
||||
isActive(boundAccount.isActive) &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
||||
@@ -298,10 +283,10 @@ class UnifiedGeminiScheduler {
|
||||
const geminiAccounts = await geminiAccountService.getAllAccounts()
|
||||
for (const account of geminiAccounts) {
|
||||
if (
|
||||
this._isActive(account.isActive) &&
|
||||
isActive(account.isActive) &&
|
||||
account.status !== 'error' &&
|
||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查是否可调度
|
||||
|
||||
@@ -348,10 +333,10 @@ class UnifiedGeminiScheduler {
|
||||
const geminiApiAccounts = await geminiApiAccountService.getAllAccounts()
|
||||
for (const account of geminiApiAccounts) {
|
||||
if (
|
||||
this._isActive(account.isActive) &&
|
||||
isActive(account.isActive) &&
|
||||
account.status !== 'error' &&
|
||||
(account.accountType === 'shared' || !account.accountType) &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
|
||||
@@ -388,42 +373,27 @@ class UnifiedGeminiScheduler {
|
||||
return availableAccounts
|
||||
}
|
||||
|
||||
// 🔢 按优先级和最后使用时间排序账户
|
||||
_sortAccountsByPriority(accounts) {
|
||||
return accounts.sort((a, b) => {
|
||||
// 首先按优先级排序(数字越小优先级越高)
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority
|
||||
}
|
||||
|
||||
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed
|
||||
})
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否可用
|
||||
async _isAccountAvailable(accountId, accountType) {
|
||||
try {
|
||||
if (accountType === 'gemini') {
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
if (!account || !this._isActive(account.isActive) || account.status === 'error') {
|
||||
if (!account || !isActive(account.isActive) || account.status === 'error') {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 Gemini account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
return !(await this.isAccountRateLimited(accountId))
|
||||
} else if (accountType === 'gemini-api') {
|
||||
const account = await geminiApiAccountService.getAccount(accountId)
|
||||
if (!account || !this._isActive(account.isActive) || account.status === 'error') {
|
||||
if (!account || !isActive(account.isActive) || account.status === 'error') {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 Gemini-API account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -665,9 +635,9 @@ class UnifiedGeminiScheduler {
|
||||
|
||||
// 检查账户是否可用
|
||||
if (
|
||||
this._isActive(account.isActive) &&
|
||||
isActive(account.isActive) &&
|
||||
account.status !== 'error' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 对于 Gemini OAuth 账户,检查 token 是否过期
|
||||
if (accountType === 'gemini') {
|
||||
@@ -714,7 +684,7 @@ class UnifiedGeminiScheduler {
|
||||
}
|
||||
|
||||
// 使用现有的优先级排序逻辑
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
@@ -3,42 +3,13 @@ const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { isSchedulable, sortAccountsByPriority } = require('../utils/commonHelper')
|
||||
|
||||
class UnifiedOpenAIScheduler {
|
||||
constructor() {
|
||||
this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:'
|
||||
}
|
||||
|
||||
// 🔢 按优先级和最后使用时间排序账户(与 Claude/Gemini 调度保持一致)
|
||||
_sortAccountsByPriority(accounts) {
|
||||
return accounts.sort((a, b) => {
|
||||
const aPriority = Number.parseInt(a.priority, 10)
|
||||
const bPriority = Number.parseInt(b.priority, 10)
|
||||
const normalizedAPriority = Number.isFinite(aPriority) ? aPriority : 50
|
||||
const normalizedBPriority = Number.isFinite(bPriority) ? bPriority : 50
|
||||
|
||||
// 首先按优先级排序(数字越小优先级越高)
|
||||
if (normalizedAPriority !== normalizedBPriority) {
|
||||
return normalizedAPriority - normalizedBPriority
|
||||
}
|
||||
|
||||
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed
|
||||
})
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||
_isSchedulable(schedulable) {
|
||||
// 如果是 undefined 或 null,默认为可调度
|
||||
if (schedulable === undefined || schedulable === null) {
|
||||
return true
|
||||
}
|
||||
// 明确设置为 false(布尔值)或 'false'(字符串)时不可调度
|
||||
return schedulable !== false && schedulable !== 'false'
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否被限流(兼容字符串和对象格式)
|
||||
_isRateLimited(rateLimitStatus) {
|
||||
if (!rateLimitStatus) {
|
||||
@@ -85,7 +56,7 @@ class UnifiedOpenAIScheduler {
|
||||
let rateLimitChecked = false
|
||||
let stillLimited = false
|
||||
|
||||
let isSchedulable = this._isSchedulable(account.schedulable)
|
||||
let isSchedulable = isSchedulable(account.schedulable)
|
||||
|
||||
if (!isSchedulable) {
|
||||
if (!hasRateLimitFlag) {
|
||||
@@ -224,7 +195,7 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._isSchedulable(boundAccount.schedulable)) {
|
||||
if (!isSchedulable(boundAccount.schedulable)) {
|
||||
const errorMsg = `Dedicated account ${boundAccount.name} is not schedulable`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
const error = new Error(errorMsg)
|
||||
@@ -336,7 +307,7 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
@@ -451,11 +422,12 @@ class UnifiedOpenAIScheduler {
|
||||
if (
|
||||
(account.isActive === true || account.isActive === 'true') &&
|
||||
account.status !== 'error' &&
|
||||
account.status !== 'rateLimited' &&
|
||||
(account.accountType === 'shared' || !account.accountType)
|
||||
) {
|
||||
const hasRateLimitFlag = this._hasRateLimitFlag(account.rateLimitStatus)
|
||||
const schedulable = this._isSchedulable(account.schedulable)
|
||||
// 检查 rateLimitStatus 或 status === 'rateLimited'
|
||||
const hasRateLimitFlag =
|
||||
this._hasRateLimitFlag(account.rateLimitStatus) || account.status === 'rateLimited'
|
||||
const schedulable = isSchedulable(account.schedulable)
|
||||
|
||||
if (!schedulable && !hasRateLimitFlag) {
|
||||
logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - not schedulable`)
|
||||
@@ -464,9 +436,23 @@ class UnifiedOpenAIScheduler {
|
||||
|
||||
let isRateLimitCleared = false
|
||||
if (hasRateLimitFlag) {
|
||||
isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
|
||||
account.id
|
||||
)
|
||||
// 区分正常限流和历史遗留数据
|
||||
if (this._hasRateLimitFlag(account.rateLimitStatus)) {
|
||||
// 有 rateLimitStatus,走正常清理逻辑
|
||||
isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
|
||||
account.id
|
||||
)
|
||||
} else {
|
||||
// 只有 status=rateLimited 但没有 rateLimitStatus,是历史遗留数据,直接清除
|
||||
await openaiResponsesAccountService.updateAccount(account.id, {
|
||||
status: 'active',
|
||||
schedulable: 'true'
|
||||
})
|
||||
isRateLimitCleared = true
|
||||
logger.info(
|
||||
`✅ OpenAI-Responses账号 ${account.name} 清除历史遗留限流状态(status=rateLimited 但无 rateLimitStatus)`
|
||||
)
|
||||
}
|
||||
|
||||
if (!isRateLimitCleared) {
|
||||
logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - rate limited`)
|
||||
@@ -544,7 +530,7 @@ class UnifiedOpenAIScheduler {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
if (!isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
@@ -905,7 +891,7 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致)
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
const sortedAccounts = sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
@@ -10,6 +10,7 @@ const { v4: uuidv4 } = require('uuid')
|
||||
const redis = require('../models/redis')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { getCachedConfig, setCachedConfig } = require('../utils/performanceOptimizer')
|
||||
|
||||
// 清理任务间隔
|
||||
const CLEANUP_INTERVAL_MS = 60000 // 1分钟
|
||||
@@ -19,6 +20,9 @@ const POLL_INTERVAL_BASE_MS = 50 // 基础轮询间隔
|
||||
const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔
|
||||
const POLL_BACKOFF_FACTOR = 1.5 // 退避因子
|
||||
|
||||
// 配置缓存 key
|
||||
const CONFIG_CACHE_KEY = 'user_message_queue_config'
|
||||
|
||||
class UserMessageQueueService {
|
||||
constructor() {
|
||||
this.cleanupTimer = null
|
||||
@@ -64,18 +68,23 @@ class UserMessageQueueService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置(支持 Web 界面配置优先)
|
||||
* 获取当前配置(支持 Web 界面配置优先,带短 TTL 缓存)
|
||||
* @returns {Promise<Object>} 配置对象
|
||||
*/
|
||||
async getConfig() {
|
||||
// 检查缓存
|
||||
const cached = getCachedConfig(CONFIG_CACHE_KEY)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 默认配置(防止 config.userMessageQueue 未定义)
|
||||
// 注意:优化后的默认值 - 锁持有时间从分钟级降到毫秒级,无需长等待
|
||||
const queueConfig = config.userMessageQueue || {}
|
||||
const defaults = {
|
||||
enabled: queueConfig.enabled ?? false,
|
||||
delayMs: queueConfig.delayMs ?? 200,
|
||||
timeoutMs: queueConfig.timeoutMs ?? 5000, // 从 60000 降到 5000,因为锁持有时间短
|
||||
lockTtlMs: queueConfig.lockTtlMs ?? 5000 // 从 120000 降到 5000,5秒足以覆盖请求发送
|
||||
timeoutMs: queueConfig.timeoutMs ?? 60000,
|
||||
lockTtlMs: queueConfig.lockTtlMs ?? 120000
|
||||
}
|
||||
|
||||
// 尝试从 claudeRelayConfigService 获取 Web 界面配置
|
||||
@@ -83,7 +92,7 @@ class UserMessageQueueService {
|
||||
const claudeRelayConfigService = require('./claudeRelayConfigService')
|
||||
const webConfig = await claudeRelayConfigService.getConfig()
|
||||
|
||||
return {
|
||||
const result = {
|
||||
enabled:
|
||||
webConfig.userMessageQueueEnabled !== undefined
|
||||
? webConfig.userMessageQueueEnabled
|
||||
@@ -101,8 +110,13 @@ class UserMessageQueueService {
|
||||
? webConfig.userMessageQueueLockTtlMs
|
||||
: defaults.lockTtlMs
|
||||
}
|
||||
|
||||
// 缓存配置 30 秒
|
||||
setCachedConfig(CONFIG_CACHE_KEY, result, 30000)
|
||||
return result
|
||||
} catch {
|
||||
// 回退到环境变量配置
|
||||
// 回退到环境变量配置,也缓存
|
||||
setCachedConfig(CONFIG_CACHE_KEY, defaults, 30000)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ class UserService {
|
||||
// 保存用户信息
|
||||
await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user))
|
||||
await redis.set(`${this.usernamePrefix}${username}`, user.id)
|
||||
await redis.addToIndex('user:index', user.id)
|
||||
|
||||
// 如果是新用户,尝试转移匹配的API Keys
|
||||
if (isNewUser) {
|
||||
@@ -167,8 +168,8 @@ class UserService {
|
||||
`📊 Calculated user ${userId} usage: ${totalUsage.requests} requests, ${totalUsage.inputTokens} input tokens, $${totalUsage.totalCost.toFixed(4)} total cost from ${userApiKeys.length} API keys`
|
||||
)
|
||||
|
||||
// Count only non-deleted API keys for the user's active count
|
||||
const activeApiKeyCount = userApiKeys.filter((key) => key.isDeleted !== 'true').length
|
||||
// Count only non-deleted API keys for the user's active count(布尔值比较)
|
||||
const activeApiKeyCount = userApiKeys.filter((key) => !key.isDeleted).length
|
||||
|
||||
return {
|
||||
totalUsage,
|
||||
@@ -191,14 +192,18 @@ class UserService {
|
||||
// 📋 获取所有用户列表(管理员功能)
|
||||
async getAllUsers(options = {}) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const { page = 1, limit = 20, role, isActive } = options
|
||||
const pattern = `${this.userPrefix}*`
|
||||
const keys = await client.keys(pattern)
|
||||
const userIds = await redis.getAllIdsByIndex(
|
||||
'user:index',
|
||||
`${this.userPrefix}*`,
|
||||
/^user:(.+)$/
|
||||
)
|
||||
const keys = userIds.map((id) => `${this.userPrefix}${id}`)
|
||||
const dataList = await redis.batchGetChunked(keys)
|
||||
|
||||
const users = []
|
||||
for (const key of keys) {
|
||||
const userData = await client.get(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const userData = dataList[i]
|
||||
if (userData) {
|
||||
const user = JSON.parse(userData)
|
||||
|
||||
@@ -398,14 +403,15 @@ class UserService {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const pattern = `${this.userSessionPrefix}*`
|
||||
const keys = await client.keys(pattern)
|
||||
const keys = await redis.scanKeys(pattern)
|
||||
const dataList = await redis.batchGetChunked(keys)
|
||||
|
||||
for (const key of keys) {
|
||||
const sessionData = await client.get(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const sessionData = dataList[i]
|
||||
if (sessionData) {
|
||||
const session = JSON.parse(sessionData)
|
||||
if (session.userId === userId) {
|
||||
await client.del(key)
|
||||
await client.del(keys[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,9 +460,13 @@ class UserService {
|
||||
// 📊 获取用户统计信息
|
||||
async getUserStats() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const pattern = `${this.userPrefix}*`
|
||||
const keys = await client.keys(pattern)
|
||||
const userIds = await redis.getAllIdsByIndex(
|
||||
'user:index',
|
||||
`${this.userPrefix}*`,
|
||||
/^user:(.+)$/
|
||||
)
|
||||
const keys = userIds.map((id) => `${this.userPrefix}${id}`)
|
||||
const dataList = await redis.batchGetChunked(keys)
|
||||
|
||||
const stats = {
|
||||
totalUsers: 0,
|
||||
@@ -472,8 +482,8 @@ class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const userData = await client.get(key)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const userData = dataList[i]
|
||||
if (userData) {
|
||||
const user = JSON.parse(userData)
|
||||
stats.totalUsers++
|
||||
@@ -522,7 +532,7 @@ class UserService {
|
||||
const { displayName, username, email } = user
|
||||
|
||||
// 获取所有API Keys
|
||||
const allApiKeys = await apiKeyService.getAllApiKeys()
|
||||
const allApiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
|
||||
// 找到没有用户ID的API Keys(即由Admin创建的)
|
||||
const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '')
|
||||
|
||||
Reference in New Issue
Block a user