This commit is contained in:
SunSeekerX
2026-01-04 12:05:53 +08:00
parent 90023d1551
commit f5e982632d
28 changed files with 481 additions and 213 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

View File

@@ -41,7 +41,9 @@ async function migrate() {
stats.dailyIndex++ stats.dailyIndex++
} }
} }
if (keys.length > 0) await pipeline.exec() if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0') } while (cursor !== '0')
console.log(` 已处理 ${stats.dailyIndex}`) console.log(` 已处理 ${stats.dailyIndex}`)
@@ -63,7 +65,9 @@ async function migrate() {
stats.hourlyIndex++ stats.hourlyIndex++
} }
} }
if (keys.length > 0) await pipeline.exec() if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0') } while (cursor !== '0')
console.log(` 已处理 ${stats.hourlyIndex}`) console.log(` 已处理 ${stats.hourlyIndex}`)
@@ -85,7 +89,9 @@ async function migrate() {
stats.modelDailyIndex++ stats.modelDailyIndex++
} }
} }
if (keys.length > 0) await pipeline.exec() if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0') } while (cursor !== '0')
console.log(` 已处理 ${stats.modelDailyIndex}`) console.log(` 已处理 ${stats.modelDailyIndex}`)
@@ -93,7 +99,13 @@ async function migrate() {
console.log('\n4. 迁移 usage:model:hourly 索引...') console.log('\n4. 迁移 usage:model:hourly 索引...')
cursor = '0' cursor = '0'
do { do {
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:model:hourly:*', 'COUNT', 500) const [newCursor, keys] = await redis.scan(
cursor,
'MATCH',
'usage:model:hourly:*',
'COUNT',
500
)
cursor = newCursor cursor = newCursor
const pipeline = redis.pipeline() const pipeline = redis.pipeline()
@@ -107,7 +119,9 @@ async function migrate() {
stats.modelHourlyIndex++ stats.modelHourlyIndex++
} }
} }
if (keys.length > 0) await pipeline.exec() if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0') } while (cursor !== '0')
console.log(` 已处理 ${stats.modelHourlyIndex}`) console.log(` 已处理 ${stats.modelHourlyIndex}`)

View File

@@ -155,7 +155,9 @@ class RedisClient {
stats.daily++ stats.daily++
} }
} }
if (keys.length > 0) await pipeline.exec() if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0') } while (cursor !== '0')
// 迁移 usage:hourly // 迁移 usage:hourly
@@ -178,7 +180,9 @@ class RedisClient {
stats.hourly++ stats.hourly++
} }
} }
if (keys.length > 0) await pipeline.exec() if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0') } while (cursor !== '0')
// 迁移 usage:model:daily // 迁移 usage:model:daily
@@ -201,7 +205,9 @@ class RedisClient {
stats.modelDaily++ stats.modelDaily++
} }
} }
if (keys.length > 0) await pipeline.exec() if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0') } while (cursor !== '0')
// 迁移 usage:model:hourly // 迁移 usage:model:hourly
@@ -224,7 +230,9 @@ class RedisClient {
stats.modelHourly++ stats.modelHourly++
} }
} }
if (keys.length > 0) await pipeline.exec() if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0') } while (cursor !== '0')
// 迁移 usage:keymodel:daily (usage:{keyId}:model:daily:{model}:{date}) // 迁移 usage:keymodel:daily (usage:{keyId}:model:daily:{model}:{date})
@@ -249,7 +257,9 @@ class RedisClient {
stats.keymodelDaily = (stats.keymodelDaily || 0) + 1 stats.keymodelDaily = (stats.keymodelDaily || 0) + 1
} }
} }
if (keys.length > 0) await pipeline.exec() if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0') } while (cursor !== '0')
// 迁移 usage:keymodel:hourly (usage:{keyId}:model:hourly:{model}:{hour}) // 迁移 usage:keymodel:hourly (usage:{keyId}:model:hourly:{model}:{hour})
@@ -274,7 +284,9 @@ class RedisClient {
stats.keymodelHourly = (stats.keymodelHourly || 0) + 1 stats.keymodelHourly = (stats.keymodelHourly || 0) + 1
} }
} }
if (keys.length > 0) await pipeline.exec() if (keys.length > 0) {
await pipeline.exec()
}
} while (cursor !== '0') } while (cursor !== '0')
// 标记迁移完成 // 标记迁移完成
@@ -386,9 +398,13 @@ class RedisClient {
for (const key of keys) { for (const key of keys) {
// 只接受 apikey:<uuid> 形态,排除索引 key // 只接受 apikey:<uuid> 形态,排除索引 key
if (excludePrefixes.some((prefix) => key.startsWith(prefix))) continue if (excludePrefixes.some((prefix) => key.startsWith(prefix))) {
continue
}
// 确保是 apikey:<id> 格式(只有一个冒号) // 确保是 apikey:<id> 格式(只有一个冒号)
if (key.split(':').length !== 2) continue if (key.split(':').length !== 2) {
continue
}
keyIds.add(key.replace('apikey:', '')) keyIds.add(key.replace('apikey:', ''))
} }
} while (cursor !== '0') } while (cursor !== '0')
@@ -448,14 +464,22 @@ class RedisClient {
} }
const results = await pipeline.exec() const results = await pipeline.exec()
if (!results) return [] if (!results) {
return []
}
for (const result of results) { for (const result of results) {
if (!result) continue if (!result) {
continue
}
const [err, values] = result const [err, values] = result
if (err || !values) continue if (err || !values) {
continue
}
const [tags, isDeleted] = values const [tags, isDeleted] = values
if (isDeleted === 'true' || !tags) continue if (isDeleted === 'true' || !tags) {
continue
}
try { try {
const parsed = JSON.parse(tags) const parsed = JSON.parse(tags)
@@ -482,7 +506,9 @@ class RedisClient {
cursor = newCursor cursor = newCursor
const validKeys = keys.filter((k) => k !== 'apikey:hash_map' && k.split(':').length === 2) const validKeys = keys.filter((k) => k !== 'apikey:hash_map' && k.split(':').length === 2)
if (validKeys.length === 0) continue if (validKeys.length === 0) {
continue
}
const pipeline = this.client.pipeline() const pipeline = this.client.pipeline()
for (const key of validKeys) { for (const key of validKeys) {
@@ -490,14 +516,22 @@ class RedisClient {
} }
const results = await pipeline.exec() const results = await pipeline.exec()
if (!results) continue if (!results) {
continue
}
for (const result of results) { for (const result of results) {
if (!result) continue if (!result) {
continue
}
const [err, values] = result const [err, values] = result
if (err || !values) continue if (err || !values) {
continue
}
const [tags, isDeleted] = values const [tags, isDeleted] = values
if (isDeleted === 'true' || !tags) continue if (isDeleted === 'true' || !tags) {
continue
}
try { try {
const parsed = JSON.parse(tags) const parsed = JSON.parse(tags)
@@ -1572,11 +1606,15 @@ class RedisClient {
const entriesByAccount = new Map() const entriesByAccount = new Map()
for (const entry of allEntries) { for (const entry of allEntries) {
const colonIndex = entry.indexOf(':') const colonIndex = entry.indexOf(':')
if (colonIndex === -1) continue if (colonIndex === -1) {
continue
}
const accountId = entry.substring(0, colonIndex) const accountId = entry.substring(0, colonIndex)
const model = entry.substring(colonIndex + 1) const model = entry.substring(colonIndex + 1)
if (accountIdSet.has(accountId)) { if (accountIdSet.has(accountId)) {
if (!entriesByAccount.has(accountId)) entriesByAccount.set(accountId, []) if (!entriesByAccount.has(accountId)) {
entriesByAccount.set(accountId, [])
}
entriesByAccount.get(accountId).push(model) entriesByAccount.get(accountId).push(model)
} }
} }
@@ -1652,7 +1690,9 @@ class RedisClient {
for (let i = 0; i < modelKeys.length; i++) { for (let i = 0; i < modelKeys.length; i++) {
const key = modelKeys[i] const key = modelKeys[i]
const [err, modelUsage] = results[i] const [err, modelUsage] = results[i]
if (err || !modelUsage) continue if (err || !modelUsage) {
continue
}
const parts = key.split(':') const parts = key.split(':')
const model = parts[4] const model = parts[4]
@@ -1897,7 +1937,9 @@ class RedisClient {
'claude:account:*', 'claude:account:*',
/^claude:account:(.+)$/ /^claude:account:(.+)$/
) )
if (accountIds.length === 0) return [] if (accountIds.length === 0) {
return []
}
const keys = accountIds.map((id) => `claude:account:${id}`) const keys = accountIds.map((id) => `claude:account:${id}`)
const pipeline = this.client.pipeline() const pipeline = this.client.pipeline()
@@ -1938,7 +1980,9 @@ class RedisClient {
'droid:account:*', 'droid:account:*',
/^droid:account:(.+)$/ /^droid:account:(.+)$/
) )
if (accountIds.length === 0) return [] if (accountIds.length === 0) {
return []
}
const keys = accountIds.map((id) => `droid:account:${id}`) const keys = accountIds.map((id) => `droid:account:${id}`)
const pipeline = this.client.pipeline() const pipeline = this.client.pipeline()
@@ -1983,7 +2027,9 @@ class RedisClient {
'openai:account:*', 'openai:account:*',
/^openai:account:(.+)$/ /^openai:account:(.+)$/
) )
if (accountIds.length === 0) return [] if (accountIds.length === 0) {
return []
}
const keys = accountIds.map((id) => `openai:account:${id}`) const keys = accountIds.map((id) => `openai:account:${id}`)
const pipeline = this.client.pipeline() const pipeline = this.client.pipeline()
@@ -2102,14 +2148,18 @@ class RedisClient {
// 🔍 通过索引获取 key 列表(替代 SCAN // 🔍 通过索引获取 key 列表(替代 SCAN
async getKeysByIndex(indexKey, keyPattern) { async getKeysByIndex(indexKey, keyPattern) {
const members = await this.client.smembers(indexKey) const members = await this.client.smembers(indexKey)
if (!members || members.length === 0) return [] if (!members || members.length === 0) {
return []
}
return members.map((id) => keyPattern.replace('{id}', id)) return members.map((id) => keyPattern.replace('{id}', id))
} }
// 🔍 批量通过索引获取数据 // 🔍 批量通过索引获取数据
async getDataByIndex(indexKey, keyPattern) { async getDataByIndex(indexKey, keyPattern) {
const keys = await this.getKeysByIndex(indexKey, keyPattern) const keys = await this.getKeysByIndex(indexKey, keyPattern)
if (keys.length === 0) return [] if (keys.length === 0) {
return []
}
return await this.batchHgetallChunked(keys) return await this.batchHgetallChunked(keys)
} }
@@ -4258,8 +4308,12 @@ redisClient.batchGetApiKeyStats = async function (keyIds) {
* @returns {Promise<Object[]>} 每个 key 对应的数据,失败的返回 null * @returns {Promise<Object[]>} 每个 key 对应的数据,失败的返回 null
*/ */
redisClient.batchHgetallChunked = async function (keys, chunkSize = 500) { redisClient.batchHgetallChunked = async function (keys, chunkSize = 500) {
if (!keys || keys.length === 0) return [] if (!keys || keys.length === 0) {
if (keys.length <= chunkSize) return this.batchHgetall(keys) return []
}
if (keys.length <= chunkSize) {
return this.batchHgetall(keys)
}
const results = [] const results = []
for (let i = 0; i < keys.length; i += chunkSize) { for (let i = 0; i < keys.length; i += chunkSize) {
@@ -4277,7 +4331,9 @@ redisClient.batchHgetallChunked = async function (keys, chunkSize = 500) {
* @returns {Promise<(string|null)[]>} 每个 key 对应的值 * @returns {Promise<(string|null)[]>} 每个 key 对应的值
*/ */
redisClient.batchGetChunked = async function (keys, chunkSize = 500) { redisClient.batchGetChunked = async function (keys, chunkSize = 500) {
if (!keys || keys.length === 0) return [] if (!keys || keys.length === 0) {
return []
}
const client = this.getClientSafe() const client = this.getClientSafe()
if (keys.length <= chunkSize) { if (keys.length <= chunkSize) {
@@ -4316,11 +4372,15 @@ redisClient.scanAndProcess = async function (pattern, processor, options = {}) {
const processedKeys = new Set() // 全程去重 const processedKeys = new Set() // 全程去重
const processBatch = async (keys) => { const processBatch = async (keys) => {
if (keys.length === 0) return if (keys.length === 0) {
return
}
// 过滤已处理的 key // 过滤已处理的 key
const uniqueKeys = keys.filter((k) => !processedKeys.has(k)) const uniqueKeys = keys.filter((k) => !processedKeys.has(k))
if (uniqueKeys.length === 0) return if (uniqueKeys.length === 0) {
return
}
uniqueKeys.forEach((k) => processedKeys.add(k)) uniqueKeys.forEach((k) => processedKeys.add(k))
@@ -4387,7 +4447,9 @@ redisClient.scanAndGetAllChunked = async function (pattern, options = {}) {
* @returns {Promise<number>} 删除的 key 数量 * @returns {Promise<number>} 删除的 key 数量
*/ */
redisClient.batchDelChunked = async function (keys, chunkSize = 500) { redisClient.batchDelChunked = async function (keys, chunkSize = 500) {
if (!keys || keys.length === 0) return 0 if (!keys || keys.length === 0) {
return 0
}
const client = this.getClientSafe() const client = this.getClientSafe()
let deleted = 0 let deleted = 0

View File

@@ -79,7 +79,6 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
const costStats = await redis.getCostStats(keyId) const costStats = await redis.getCostStats(keyId)
const dailyCost = await redis.getDailyCost(keyId) const dailyCost = await redis.getDailyCost(keyId)
const today = redis.getDateStringInTimezone() const today = redis.getDateStringInTimezone()
const client = redis.getClientSafe()
// 获取所有相关的Redis键 // 获取所有相关的Redis键
const costKeys = await redis.scanKeys(`usage:cost:*:${keyId}:*`) const costKeys = await redis.scanKeys(`usage:cost:*:${keyId}:*`)
@@ -289,20 +288,18 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
} }
// 为每个API Key添加owner的displayName批量获取优化 // 为每个API Key添加owner的displayName批量获取优化
const userIdsToFetch = [ const userIdsToFetch = [...new Set(result.items.filter((k) => k.userId).map((k) => k.userId))]
...new Set(result.items.filter((k) => k.userId).map((k) => k.userId))
]
const userMap = new Map() const userMap = new Map()
if (userIdsToFetch.length > 0) { if (userIdsToFetch.length > 0) {
// 批量获取用户信息 // 批量获取用户信息
const users = await Promise.all( const users = await Promise.all(
userIdsToFetch.map((id) => userIdsToFetch.map((id) => userService.getUserById(id, false).catch(() => null))
userService.getUserById(id, false).catch(() => null)
)
) )
userIdsToFetch.forEach((id, i) => { userIdsToFetch.forEach((id, i) => {
if (users[i]) userMap.set(id, users[i]) if (users[i]) {
userMap.set(id, users[i])
}
}) })
} }

View File

@@ -6,13 +6,11 @@ const bedrockAccountService = require('../../services/bedrockAccountService')
const ccrAccountService = require('../../services/ccrAccountService') const ccrAccountService = require('../../services/ccrAccountService')
const geminiAccountService = require('../../services/geminiAccountService') const geminiAccountService = require('../../services/geminiAccountService')
const droidAccountService = require('../../services/droidAccountService') const droidAccountService = require('../../services/droidAccountService')
const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService') const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const redis = require('../../models/redis') const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth') const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger') const logger = require('../../utils/logger')
const CostCalculator = require('../../utils/costCalculator') const CostCalculator = require('../../utils/costCalculator')
const pricingService = require('../../services/pricingService')
const config = require('../../../config/config') const config = require('../../../config/config')
const router = express.Router() const router = express.Router()
@@ -144,7 +142,9 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
totalCacheReadTokensUsed += usage.cacheReadTokens || 0 totalCacheReadTokensUsed += usage.cacheReadTokens || 0
totalAllTokensUsed += usage.allTokens || 0 totalAllTokensUsed += usage.allTokens || 0
} }
if (key.isActive) activeApiKeys++ if (key.isActive) {
activeApiKeys++
}
} }
// 各平台账户统计(单次遍历) // 各平台账户统计(单次遍历)

View File

@@ -157,7 +157,9 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
const groupToAccountIds = new Map() const groupToAccountIds = new Map()
for (const [accountId, groups] of allGroupInfosMap) { for (const [accountId, groups] of allGroupInfosMap) {
for (const group of groups) { for (const group of groups) {
if (!groupToAccountIds.has(group.id)) groupToAccountIds.set(group.id, []) if (!groupToAccountIds.has(group.id)) {
groupToAccountIds.set(group.id, [])
}
groupToAccountIds.get(group.id).push(accountId) groupToAccountIds.get(group.id).push(accountId)
} }
} }
@@ -167,7 +169,9 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
const groupBindingCount = new Map() const groupBindingCount = new Map()
for (const key of allApiKeys) { for (const key of allApiKeys) {
const binding = key.droidAccountId const binding = key.droidAccountId
if (!binding) continue if (!binding) {
continue
}
if (binding.startsWith('group:')) { if (binding.startsWith('group:')) {
const groupId = binding.substring('group:'.length) const groupId = binding.substring('group:'.length)
groupBindingCount.set(groupId, (groupBindingCount.get(groupId) || 0) + 1) groupBindingCount.set(groupId, (groupBindingCount.get(groupId) || 0) + 1)

View File

@@ -46,7 +46,9 @@ router.get('/gemini-api-accounts', authenticateAdmin, async (req, res) => {
const bindingCountMap = new Map() const bindingCountMap = new Map()
for (const key of allApiKeys) { for (const key of allApiKeys) {
const binding = key.geminiAccountId const binding = key.geminiAccountId
if (!binding) continue if (!binding) {
continue
}
// 处理 api: 前缀 // 处理 api: 前缀
const accountId = binding.startsWith('api:') ? binding.substring(4) : binding const accountId = binding.startsWith('api:') ? binding.substring(4) : binding
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1) bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)

View File

@@ -54,7 +54,9 @@ router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) =>
const bindingCountMap = new Map() const bindingCountMap = new Map()
for (const key of allApiKeys) { for (const key of allApiKeys) {
const binding = key.openaiAccountId const binding = key.openaiAccountId
if (!binding) continue if (!binding) {
continue
}
// 处理 responses: 前缀 // 处理 responses: 前缀
const accountId = binding.startsWith('responses:') ? binding.substring(10) : binding const accountId = binding.startsWith('responses:') ? binding.substring(10) : binding
bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1) bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1)

View File

@@ -64,14 +64,18 @@ async function getUsageDataByIndex(indexKey, keyPattern, scanPattern) {
const match = const match =
k.match(/usage:([^:]+):model:daily:(.+):\d{4}-\d{2}-\d{2}$/) || k.match(/usage:([^:]+):model:daily:(.+):\d{4}-\d{2}-\d{2}$/) ||
k.match(/usage:([^:]+):model:hourly:(.+):\d{4}-\d{2}-\d{2}:\d{2}$/) k.match(/usage:([^:]+):model:hourly:(.+):\d{4}-\d{2}-\d{2}:\d{2}$/)
if (match) return `${match[1]}:${match[2]}` if (match) {
return `${match[1]}:${match[2]}`
}
} }
if (keyPattern.includes('{accountId}') && keyPattern.includes('{model}')) { if (keyPattern.includes('{accountId}') && keyPattern.includes('{model}')) {
// account_usage:model:daily 或 hourly // account_usage:model:daily 或 hourly
const match = const match =
k.match(/account_usage:model:daily:([^:]+):(.+):\d{4}-\d{2}-\d{2}$/) || k.match(/account_usage:model:daily:([^:]+):(.+):\d{4}-\d{2}-\d{2}$/) ||
k.match(/account_usage:model:hourly:([^:]+):(.+):\d{4}-\d{2}-\d{2}:\d{2}$/) k.match(/account_usage:model:hourly:([^:]+):(.+):\d{4}-\d{2}-\d{2}:\d{2}$/)
if (match) return `${match[1]}:${match[2]}` if (match) {
return `${match[1]}:${match[2]}`
}
} }
// 通用格式:提取最后一个 : 前的 id // 通用格式:提取最后一个 : 前的 id
const parts = k.split(':') const parts = k.split(':')
@@ -299,7 +303,6 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
logger.warn(`Failed to get account data for avgDailyCost calculation: ${error.message}`) logger.warn(`Failed to get account data for avgDailyCost calculation: ${error.message}`)
} }
const client = redis.getClientSafe()
const fallbackModel = fallbackModelMap[platform] || 'unknown' const fallbackModel = fallbackModelMap[platform] || 'unknown'
const daysCount = Math.min(Math.max(parseInt(days, 10) || 30, 1), 60) const daysCount = Math.min(Math.max(parseInt(days, 10) || 30, 1), 60)
@@ -359,6 +362,7 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
const dayLabel = String(tzDate.getUTCDate()).padStart(2, '0') const dayLabel = String(tzDate.getUTCDate()).padStart(2, '0')
const label = `${monthLabel}/${dayLabel}` const label = `${monthLabel}/${dayLabel}`
const client = redis.getClientSafe()
const dailyKey = `account_usage:daily:${accountId}:${dateKey}` const dailyKey = `account_usage:daily:${accountId}:${dateKey}`
const dailyData = await client.hgetall(dailyKey) const dailyData = await client.hgetall(dailyKey)
@@ -537,7 +541,6 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
} }
// 使用索引获取数据,按小时批量查询 // 使用索引获取数据,按小时批量查询
const dates = [...dateSet]
const modelDataMap = new Map() const modelDataMap = new Map()
const usageDataMap = new Map() const usageDataMap = new Map()
@@ -571,7 +574,9 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
const match = key.match(/usage:model:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/) const match = key.match(/usage:model:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/)
if (match) { if (match) {
const hourKey = match[1] const hourKey = match[1]
if (!modelKeysByHour.has(hourKey)) modelKeysByHour.set(hourKey, []) if (!modelKeysByHour.has(hourKey)) {
modelKeysByHour.set(hourKey, [])
}
modelKeysByHour.get(hourKey).push(key) modelKeysByHour.get(hourKey).push(key)
} }
} }
@@ -579,7 +584,9 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
const match = key.match(/usage:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/) const match = key.match(/usage:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/)
if (match) { if (match) {
const hourKey = match[1] const hourKey = match[1]
if (!usageKeysByHour.has(hourKey)) usageKeysByHour.set(hourKey, []) if (!usageKeysByHour.has(hourKey)) {
usageKeysByHour.set(hourKey, [])
}
usageKeysByHour.get(hourKey).push(key) usageKeysByHour.get(hourKey).push(key)
} }
} }
@@ -599,11 +606,15 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
// 处理模型级别数据 // 处理模型级别数据
for (const modelKey of modelKeys) { for (const modelKey of modelKeys) {
const modelMatch = modelKey.match(/usage:model:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) const modelMatch = modelKey.match(/usage:model:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/)
if (!modelMatch) continue if (!modelMatch) {
continue
}
const model = modelMatch[1] const model = modelMatch[1]
const data = modelDataMap.get(modelKey) const data = modelDataMap.get(modelKey)
if (!data || Object.keys(data).length === 0) continue if (!data || Object.keys(data).length === 0) {
continue
}
const modelInputTokens = parseInt(data.inputTokens) || 0 const modelInputTokens = parseInt(data.inputTokens) || 0
const modelOutputTokens = parseInt(data.outputTokens) || 0 const modelOutputTokens = parseInt(data.outputTokens) || 0
@@ -710,7 +721,9 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
const match = key.match(/usage:model:daily:.+?:(\d{4}-\d{2}-\d{2})/) const match = key.match(/usage:model:daily:.+?:(\d{4}-\d{2}-\d{2})/)
if (match) { if (match) {
const dateStr = match[1] const dateStr = match[1]
if (!modelKeysByDate.has(dateStr)) modelKeysByDate.set(dateStr, []) if (!modelKeysByDate.has(dateStr)) {
modelKeysByDate.set(dateStr, [])
}
modelKeysByDate.get(dateStr).push(key) modelKeysByDate.get(dateStr).push(key)
} }
} }
@@ -718,7 +731,9 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
const match = key.match(/usage:daily:.+?:(\d{4}-\d{2}-\d{2})/) const match = key.match(/usage:daily:.+?:(\d{4}-\d{2}-\d{2})/)
if (match) { if (match) {
const dateStr = match[1] const dateStr = match[1]
if (!usageKeysByDate.has(dateStr)) usageKeysByDate.set(dateStr, []) if (!usageKeysByDate.has(dateStr)) {
usageKeysByDate.set(dateStr, [])
}
usageKeysByDate.get(dateStr).push(key) usageKeysByDate.get(dateStr).push(key)
} }
} }
@@ -738,11 +753,15 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
// 处理模型级别数据 // 处理模型级别数据
for (const modelKey of modelKeys) { for (const modelKey of modelKeys) {
const modelMatch = modelKey.match(/usage:model:daily:(.+?):\d{4}-\d{2}-\d{2}/) const modelMatch = modelKey.match(/usage:model:daily:(.+?):\d{4}-\d{2}-\d{2}/)
if (!modelMatch) continue if (!modelMatch) {
continue
}
const model = modelMatch[1] const model = modelMatch[1]
const data = modelDataMap.get(modelKey) const data = modelDataMap.get(modelKey)
if (!data || Object.keys(data).length === 0) continue if (!data || Object.keys(data).length === 0) {
continue
}
const modelInputTokens = parseInt(data.inputTokens) || 0 const modelInputTokens = parseInt(data.inputTokens) || 0
const modelOutputTokens = parseInt(data.outputTokens) || 0 const modelOutputTokens = parseInt(data.outputTokens) || 0
@@ -827,7 +846,7 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) =
`📊 Getting model stats for API key: ${keyId}, period: ${period}, startDate: ${startDate}, endDate: ${endDate}` `📊 Getting model stats for API key: ${keyId}, period: ${period}, startDate: ${startDate}, endDate: ${endDate}`
) )
const client = redis.getClientSafe() const _client = redis.getClientSafe()
const today = redis.getDateStringInTimezone() const today = redis.getDateStringInTimezone()
const tzDate = redis.getDateInTimezone() const tzDate = redis.getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
@@ -895,9 +914,13 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) =
for (const results of allResults) { for (const results of allResults) {
for (const { key, data } of results) { for (const { key, data } of results) {
// 过滤出属于该 keyId 的记录 // 过滤出属于该 keyId 的记录
if (!key.startsWith(`usage:${keyId}:model:`)) continue if (!key.startsWith(`usage:${keyId}:model:`)) {
continue
}
const match = key.match(/usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) const match = key.match(/usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
if (!match) continue if (!match) {
continue
}
const model = match[1] const model = match[1]
if (!modelStatsMap.has(model)) { if (!modelStatsMap.has(model)) {
modelStatsMap.set(model, { modelStatsMap.set(model, {
@@ -933,11 +956,15 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) =
results = await redis.scanAndGetAllChunked(pattern) results = await redis.scanAndGetAllChunked(pattern)
} }
for (const { key, data } of results) { for (const { key, data } of results) {
if (!key.startsWith(`usage:${keyId}:model:`)) continue if (!key.startsWith(`usage:${keyId}:model:`)) {
continue
}
const match = const match =
key.match(/usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) || key.match(/usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) ||
key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/) key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/)
if (!match) continue if (!match) {
continue
}
const model = match[1] const model = match[1]
if (!modelStatsMap.has(model)) { if (!modelStatsMap.has(model)) {
modelStatsMap.set(model, { modelStatsMap.set(model, {
@@ -1255,7 +1282,7 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
} }
// 按小时获取 account_usage 数据(避免全库扫描) // 按小时获取 account_usage 数据(避免全库扫描)
const dates = [...dateSet] const _dates = [...dateSet]
const usageDataMap = new Map() const usageDataMap = new Map()
const modelDataMap = new Map() const modelDataMap = new Map()
@@ -1289,7 +1316,9 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
const match = key.match(/account_usage:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/) const match = key.match(/account_usage:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/)
if (match) { if (match) {
const hourKey = match[1] const hourKey = match[1]
if (!usageKeysByHour.has(hourKey)) usageKeysByHour.set(hourKey, []) if (!usageKeysByHour.has(hourKey)) {
usageKeysByHour.set(hourKey, [])
}
usageKeysByHour.get(hourKey).push(key) usageKeysByHour.get(hourKey).push(key)
} }
} }
@@ -1299,7 +1328,9 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
const accountId = match[1] const accountId = match[1]
const hourKey = match[2] const hourKey = match[2]
const mapKey = `${accountId}:${hourKey}` const mapKey = `${accountId}:${hourKey}`
if (!modelKeysByHour.has(mapKey)) modelKeysByHour.set(mapKey, []) if (!modelKeysByHour.has(mapKey)) {
modelKeysByHour.set(mapKey, [])
}
modelKeysByHour.get(mapKey).push(key) modelKeysByHour.get(mapKey).push(key)
} }
} }
@@ -1316,13 +1347,19 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
for (const key of usageKeys) { for (const key of usageKeys) {
const match = key.match(/account_usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) const match = key.match(/account_usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/)
if (!match) continue if (!match) {
continue
}
const accountId = match[1] const accountId = match[1]
if (!accountIdSet.has(accountId)) continue if (!accountIdSet.has(accountId)) {
continue
}
const data = usageDataMap.get(key) const data = usageDataMap.get(key)
if (!data) continue if (!data) {
continue
}
const inputTokens = parseInt(data.inputTokens) || 0 const inputTokens = parseInt(data.inputTokens) || 0
const outputTokens = parseInt(data.outputTokens) || 0 const outputTokens = parseInt(data.outputTokens) || 0
@@ -1338,10 +1375,14 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
const modelKeys = modelKeysByHour.get(`${accountId}:${hourInfo.hourKey}`) || [] const modelKeys = modelKeysByHour.get(`${accountId}:${hourInfo.hourKey}`) || []
for (const modelKey of modelKeys) { for (const modelKey of modelKeys) {
const modelData = modelDataMap.get(modelKey) const modelData = modelDataMap.get(modelKey)
if (!modelData) continue if (!modelData) {
continue
}
const parts = modelKey.split(':') const parts = modelKey.split(':')
if (parts.length < 5) continue if (parts.length < 5) {
continue
}
const modelName = parts[4] const modelName = parts[4]
const usage = { const usage = {
@@ -1434,7 +1475,9 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
const match = key.match(/account_usage:daily:.+?:(\d{4}-\d{2}-\d{2})/) const match = key.match(/account_usage:daily:.+?:(\d{4}-\d{2}-\d{2})/)
if (match) { if (match) {
const dateStr = match[1] const dateStr = match[1]
if (!usageKeysByDate.has(dateStr)) usageKeysByDate.set(dateStr, []) if (!usageKeysByDate.has(dateStr)) {
usageKeysByDate.set(dateStr, [])
}
usageKeysByDate.get(dateStr).push(key) usageKeysByDate.get(dateStr).push(key)
} }
} }
@@ -1444,7 +1487,9 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
const accountId = match[1] const accountId = match[1]
const dateStr = match[2] const dateStr = match[2]
const mapKey = `${accountId}:${dateStr}` const mapKey = `${accountId}:${dateStr}`
if (!modelKeysByDate.has(mapKey)) modelKeysByDate.set(mapKey, []) if (!modelKeysByDate.has(mapKey)) {
modelKeysByDate.set(mapKey, [])
}
modelKeysByDate.get(mapKey).push(key) modelKeysByDate.get(mapKey).push(key)
} }
} }
@@ -1460,13 +1505,19 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
for (const key of usageKeys) { for (const key of usageKeys) {
const match = key.match(/account_usage:daily:(.+?):\d{4}-\d{2}-\d{2}/) const match = key.match(/account_usage:daily:(.+?):\d{4}-\d{2}-\d{2}/)
if (!match) continue if (!match) {
continue
}
const accountId = match[1] const accountId = match[1]
if (!accountIdSet.has(accountId)) continue if (!accountIdSet.has(accountId)) {
continue
}
const data = usageDataMap.get(key) const data = usageDataMap.get(key)
if (!data) continue if (!data) {
continue
}
const inputTokens = parseInt(data.inputTokens) || 0 const inputTokens = parseInt(data.inputTokens) || 0
const outputTokens = parseInt(data.outputTokens) || 0 const outputTokens = parseInt(data.outputTokens) || 0
@@ -1482,10 +1533,14 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
const modelKeys = modelKeysByDate.get(`${accountId}:${dayInfo.dateStr}`) || [] const modelKeys = modelKeysByDate.get(`${accountId}:${dayInfo.dateStr}`) || []
for (const modelKey of modelKeys) { for (const modelKey of modelKeys) {
const modelData = modelDataMap.get(modelKey) const modelData = modelDataMap.get(modelKey)
if (!modelData) continue if (!modelData) {
continue
}
const parts = modelKey.split(':') const parts = modelKey.split(':')
if (parts.length < 5) continue if (parts.length < 5) {
continue
}
const modelName = parts[4] const modelName = parts[4]
const usage = { const usage = {
@@ -1613,7 +1668,7 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
} }
// 使用索引获取数据,按小时批量查询 // 使用索引获取数据,按小时批量查询
const dates = [...dateSet] const _dates = [...dateSet]
const usageDataMap = new Map() const usageDataMap = new Map()
const modelDataMap = new Map() const modelDataMap = new Map()
@@ -1646,7 +1701,9 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
const match = key.match(/usage:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/) const match = key.match(/usage:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/)
if (match) { if (match) {
const hourKey = match[1] const hourKey = match[1]
if (!usageKeysByHour.has(hourKey)) usageKeysByHour.set(hourKey, []) if (!usageKeysByHour.has(hourKey)) {
usageKeysByHour.set(hourKey, [])
}
usageKeysByHour.get(hourKey).push(key) usageKeysByHour.get(hourKey).push(key)
} }
} }
@@ -1654,7 +1711,9 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
const match = key.match(/usage:.+?:model:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/) const match = key.match(/usage:.+?:model:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/)
if (match) { if (match) {
const hourKey = match[1] const hourKey = match[1]
if (!modelKeysByHour.has(hourKey)) modelKeysByHour.set(hourKey, []) if (!modelKeysByHour.has(hourKey)) {
modelKeysByHour.set(hourKey, [])
}
modelKeysByHour.get(hourKey).push(key) modelKeysByHour.get(hourKey).push(key)
} }
} }
@@ -1674,11 +1733,15 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
const apiKeyDataMap = new Map() const apiKeyDataMap = new Map()
for (const key of hourUsageKeys) { for (const key of hourUsageKeys) {
const match = key.match(/usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) const match = key.match(/usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/)
if (!match) continue if (!match) {
continue
}
const apiKeyId = match[1] const apiKeyId = match[1]
const data = usageDataMap.get(key) const data = usageDataMap.get(key)
if (!data || !apiKeyMap.has(apiKeyId)) continue if (!data || !apiKeyMap.has(apiKeyId)) {
continue
}
const inputTokens = parseInt(data.inputTokens) || 0 const inputTokens = parseInt(data.inputTokens) || 0
const outputTokens = parseInt(data.outputTokens) || 0 const outputTokens = parseInt(data.outputTokens) || 0
@@ -1700,12 +1763,16 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
const apiKeyCostMap = new Map() const apiKeyCostMap = new Map()
for (const modelKey of hourModelKeys) { for (const modelKey of hourModelKeys) {
const match = modelKey.match(/usage:(.+?):model:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) const match = modelKey.match(/usage:(.+?):model:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/)
if (!match) continue if (!match) {
continue
}
const apiKeyId = match[1] const apiKeyId = match[1]
const model = match[2] const model = match[2]
const modelData = modelDataMap.get(modelKey) const modelData = modelDataMap.get(modelKey)
if (!modelData || !apiKeyDataMap.has(apiKeyId)) continue if (!modelData || !apiKeyDataMap.has(apiKeyId)) {
continue
}
const usage = { const usage = {
input_tokens: parseInt(modelData.inputTokens) || 0, input_tokens: parseInt(modelData.inputTokens) || 0,
@@ -1795,7 +1862,9 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
const match = key.match(/usage:daily:.+?:(\d{4}-\d{2}-\d{2})/) const match = key.match(/usage:daily:.+?:(\d{4}-\d{2}-\d{2})/)
if (match) { if (match) {
const dateStr = match[1] const dateStr = match[1]
if (!usageKeysByDate.has(dateStr)) usageKeysByDate.set(dateStr, []) if (!usageKeysByDate.has(dateStr)) {
usageKeysByDate.set(dateStr, [])
}
usageKeysByDate.get(dateStr).push(key) usageKeysByDate.get(dateStr).push(key)
} }
} }
@@ -1803,7 +1872,9 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
const match = key.match(/usage:.+?:model:daily:.+?:(\d{4}-\d{2}-\d{2})/) const match = key.match(/usage:.+?:model:daily:.+?:(\d{4}-\d{2}-\d{2})/)
if (match) { if (match) {
const dateStr = match[1] const dateStr = match[1]
if (!modelKeysByDate.has(dateStr)) modelKeysByDate.set(dateStr, []) if (!modelKeysByDate.has(dateStr)) {
modelKeysByDate.set(dateStr, [])
}
modelKeysByDate.get(dateStr).push(key) modelKeysByDate.get(dateStr).push(key)
} }
} }
@@ -1822,11 +1893,15 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
const apiKeyDataMap = new Map() const apiKeyDataMap = new Map()
for (const key of dayUsageKeys) { for (const key of dayUsageKeys) {
const match = key.match(/usage:daily:(.+?):\d{4}-\d{2}-\d{2}/) const match = key.match(/usage:daily:(.+?):\d{4}-\d{2}-\d{2}/)
if (!match) continue if (!match) {
continue
}
const apiKeyId = match[1] const apiKeyId = match[1]
const data = usageDataMap.get(key) const data = usageDataMap.get(key)
if (!data || !apiKeyMap.has(apiKeyId)) continue if (!data || !apiKeyMap.has(apiKeyId)) {
continue
}
const inputTokens = parseInt(data.inputTokens) || 0 const inputTokens = parseInt(data.inputTokens) || 0
const outputTokens = parseInt(data.outputTokens) || 0 const outputTokens = parseInt(data.outputTokens) || 0
@@ -1848,12 +1923,16 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
const apiKeyCostMap = new Map() const apiKeyCostMap = new Map()
for (const modelKey of dayModelKeys) { for (const modelKey of dayModelKeys) {
const match = modelKey.match(/usage:(.+?):model:daily:(.+?):\d{4}-\d{2}-\d{2}/) const match = modelKey.match(/usage:(.+?):model:daily:(.+?):\d{4}-\d{2}-\d{2}/)
if (!match) continue if (!match) {
continue
}
const apiKeyId = match[1] const apiKeyId = match[1]
const model = match[2] const model = match[2]
const modelData = modelDataMap.get(modelKey) const modelData = modelDataMap.get(modelKey)
if (!modelData || !apiKeyDataMap.has(apiKeyId)) continue if (!modelData || !apiKeyDataMap.has(apiKeyId)) {
continue
}
const usage = { const usage = {
input_tokens: parseInt(modelData.inputTokens) || 0, input_tokens: parseInt(modelData.inputTokens) || 0,
@@ -1972,7 +2051,7 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
const modelCosts = {} const modelCosts = {}
// 按模型统计费用 // 按模型统计费用
const client = redis.getClientSafe() const _client = redis.getClientSafe()
const today = redis.getDateStringInTimezone() const today = redis.getDateStringInTimezone()
const tzDate = redis.getDateInTimezone() const tzDate = redis.getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
@@ -1980,11 +2059,11 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
'0' '0'
)}` )}`
let pattern let _pattern
if (period === 'today') { if (period === 'today') {
pattern = `usage:model:daily:*:${today}` _pattern = `usage:model:daily:*:${today}`
} else if (period === 'monthly') { } else if (period === 'monthly') {
pattern = `usage:model:monthly:*:${currentMonth}` _pattern = `usage:model:monthly:*:${currentMonth}`
} else if (period === '7days') { } else if (period === '7days') {
// 最近7天汇总daily数据使用 SCAN + Pipeline 优化) // 最近7天汇总daily数据使用 SCAN + Pipeline 优化)
const modelUsageMap = new Map() const modelUsageMap = new Map()
@@ -2014,10 +2093,14 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
// 处理数据 // 处理数据
for (const { key, data } of allData) { for (const { key, data } of allData) {
if (!data) continue if (!data) {
continue
}
const modelMatch = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) const modelMatch = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
if (!modelMatch) continue if (!modelMatch) {
continue
}
const rawModel = modelMatch[1] const rawModel = modelMatch[1]
const normalizedModel = normalizeModelName(rawModel) const normalizedModel = normalizeModelName(rawModel)
@@ -2112,10 +2195,14 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
const modelUsageMap = new Map() const modelUsageMap = new Map()
for (const { key, data } of allData) { for (const { key, data } of allData) {
if (!data) continue if (!data) {
continue
}
const modelMatch = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/) const modelMatch = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/)
if (!modelMatch) continue if (!modelMatch) {
continue
}
const model = modelMatch[1] const model = modelMatch[1]
@@ -2241,10 +2328,14 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
: /usage:model:monthly:(.+):\d{4}-\d{2}$/ : /usage:model:monthly:(.+):\d{4}-\d{2}$/
for (const { key, data } of allData) { for (const { key, data } of allData) {
if (!data) continue if (!data) {
continue
}
const match = key.match(regex) const match = key.match(regex)
if (!match) continue if (!match) {
continue
}
const model = match[1] const model = match[1]
const usage = { const usage = {

View File

@@ -701,7 +701,7 @@ router.post('/api/batch-model-stats', async (req, res) => {
}) })
} }
const client = redis.getClientSafe() const _client = redis.getClientSafe()
const tzDate = redis.getDateInTimezone() const tzDate = redis.getDateInTimezone()
const today = redis.getDateStringInTimezone() const today = redis.getDateStringInTimezone()
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
@@ -940,7 +940,7 @@ router.post('/api/user-model-stats', async (req, res) => {
) )
// 重用管理后台的模型统计逻辑但只返回该API Key的数据 // 重用管理后台的模型统计逻辑但只返回该API Key的数据
const client = redis.getClientSafe() const _client = redis.getClientSafe()
// 使用与管理页面相同的时区处理逻辑 // 使用与管理页面相同的时区处理逻辑
const tzDate = redis.getDateInTimezone() const tzDate = redis.getDateInTimezone()
const today = redis.getDateStringInTimezone() const today = redis.getDateStringInTimezone()

View File

@@ -18,7 +18,9 @@ class AccountGroupService {
async ensureReverseIndexes() { async ensureReverseIndexes() {
try { try {
const client = redis.getClientSafe() const client = redis.getClientSafe()
if (!client) return if (!client) {
return
}
// 检查是否已迁移 // 检查是否已迁移
const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY) const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY)
@@ -39,10 +41,14 @@ class AccountGroupService {
for (const groupId of allGroupIds) { for (const groupId of allGroupIds) {
const group = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`) const group = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`)
if (!group || !group.platform) continue if (!group || !group.platform) {
continue
}
const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`) const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`)
if (members.length === 0) continue if (members.length === 0) {
continue
}
const pipeline = client.pipeline() const pipeline = client.pipeline()
for (const accountId of members) { for (const accountId of members) {

View File

@@ -71,7 +71,9 @@ class ApiKeyIndexService {
* 扫描所有 API Key确保 hash -> keyId 映射存在 * 扫描所有 API Key确保 hash -> keyId 映射存在
*/ */
async rebuildHashMap() { async rebuildHashMap() {
if (!this.redis) return if (!this.redis) {
return
}
try { try {
const client = this.redis.getClientSafe() const client = this.redis.getClientSafe()
@@ -187,7 +189,9 @@ class ApiKeyIndexService {
const pipeline = client.pipeline() const pipeline = client.pipeline()
for (const apiKey of apiKeys) { for (const apiKey of apiKeys) {
if (!apiKey || !apiKey.id) continue if (!apiKey || !apiKey.id) {
continue
}
const keyId = apiKey.id const keyId = apiKey.id
const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : 0 const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : 0
@@ -249,7 +253,9 @@ class ApiKeyIndexService {
* 添加单个 API Key 到索引 * 添加单个 API Key 到索引
*/ */
async addToIndex(apiKey) { async addToIndex(apiKey) {
if (!this.redis || !apiKey || !apiKey.id) return if (!this.redis || !apiKey || !apiKey.id) {
return
}
try { try {
const client = this.redis.getClientSafe() const client = this.redis.getClientSafe()
@@ -297,7 +303,9 @@ class ApiKeyIndexService {
* 更新索引(状态、名称、标签变化时调用) * 更新索引(状态、名称、标签变化时调用)
*/ */
async updateIndex(keyId, updates, oldData = {}) { async updateIndex(keyId, updates, oldData = {}) {
if (!this.redis || !keyId) return if (!this.redis || !keyId) {
return
}
try { try {
const client = this.redis.getClientSafe() const client = this.redis.getClientSafe()
@@ -376,7 +384,9 @@ class ApiKeyIndexService {
* 从索引中移除 API Key * 从索引中移除 API Key
*/ */
async removeFromIndex(keyId, oldData = {}) { async removeFromIndex(keyId, oldData = {}) {
if (!this.redis || !keyId) return if (!this.redis || !keyId) {
return
}
try { try {
const client = this.redis.getClientSafe() const client = this.redis.getClientSafe()
@@ -598,7 +608,9 @@ class ApiKeyIndexService {
* 更新 lastUsedAt 索引(供 recordUsage 调用) * 更新 lastUsedAt 索引(供 recordUsage 调用)
*/ */
async updateLastUsedAt(keyId, lastUsedAt) { async updateLastUsedAt(keyId, lastUsedAt) {
if (!this.redis || !keyId) return if (!this.redis || !keyId) {
return
}
try { try {
const client = this.redis.getClientSafe() const client = this.redis.getClientSafe()

View File

@@ -921,7 +921,9 @@ class ApiKeyService {
return keyIds return keyIds
.map((id, i) => { .map((id, i) => {
const [err, fields] = results[i] const [err, fields] = results[i]
if (err) return null if (err) {
return null
}
return { return {
id, id,
claudeAccountId: fields[0] || null, claudeAccountId: fields[0] || null,

View File

@@ -127,7 +127,7 @@ class BedrockAccountService {
// 📋 获取所有账户列表 // 📋 获取所有账户列表
async getAllAccounts() { async getAllAccounts() {
try { try {
const client = redis.getClientSafe() const _client = redis.getClientSafe()
const accountIds = await redis.getAllIdsByIndex( const accountIds = await redis.getAllIdsByIndex(
'bedrock_account:index', 'bedrock_account:index',
'bedrock_account:*', 'bedrock_account:*',

View File

@@ -2,7 +2,6 @@ const { v4: uuidv4 } = require('uuid')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../utils/proxyHelper')
const redis = require('../models/redis') const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const config = require('../../config/config')
const { createEncryptor } = require('../utils/commonHelper') const { createEncryptor } = require('../utils/commonHelper')
class CcrAccountService { class CcrAccountService {

View File

@@ -5,7 +5,11 @@
const redis = require('../models/redis') const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { getCachedConfig, setCachedConfig, deleteCachedConfig } = require('../utils/performanceOptimizer') const {
getCachedConfig,
setCachedConfig,
deleteCachedConfig
} = require('../utils/performanceOptimizer')
class ClaudeCodeHeadersService { class ClaudeCodeHeadersService {
constructor() { constructor() {

View File

@@ -24,9 +24,7 @@ const {
// structuredClone polyfill for Node < 17 // structuredClone polyfill for Node < 17
const safeClone = const safeClone =
typeof structuredClone === 'function' typeof structuredClone === 'function' ? structuredClone : (obj) => JSON.parse(JSON.stringify(obj))
? structuredClone
: (obj) => JSON.parse(JSON.stringify(obj))
class ClaudeRelayService { class ClaudeRelayService {
constructor() { constructor() {

View File

@@ -94,7 +94,9 @@ class CostInitService {
} }
} }
logger.info(`💰 Found ${apiKeyIds.length} active API Keys to process (filtered ${allKeyIds.length - apiKeyIds.length} deleted)`) logger.info(
`💰 Found ${apiKeyIds.length} active API Keys to process (filtered ${allKeyIds.length - apiKeyIds.length} deleted)`
)
let processedCount = 0 let processedCount = 0
let errorCount = 0 let errorCount = 0
@@ -155,7 +157,9 @@ class CostInitService {
for (let j = 0; j < results.length; j++) { for (let j = 0; j < results.length; j++) {
const [err, values] = results[j] const [err, values] = results[j]
if (err) continue if (err) {
continue
}
// 将数组转换为对象 // 将数组转换为对象
const data = {} const data = {}
@@ -182,7 +186,9 @@ class CostInitService {
const match = key.match( const match = key.match(
/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/ /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 [, , period, model, dateStr] = match
@@ -301,7 +307,9 @@ class CostInitService {
cursor = newCursor cursor = newCursor
for (const usageKey of usageKeys) { for (const usageKey of usageKeys) {
if (samplesChecked >= maxSamples) break if (samplesChecked >= maxSamples) {
break
}
const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
if (match) { if (match) {
@@ -319,7 +327,9 @@ class CostInitService {
} }
} }
if (samplesChecked >= maxSamples) break if (samplesChecked >= maxSamples) {
break
}
} while (cursor !== '0') } while (cursor !== '0')
logger.info('💰 Cost data appears to be up to date') logger.info('💰 Cost data appears to be up to date')

View File

@@ -2,7 +2,6 @@ const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const axios = require('axios') const axios = require('axios')
const redis = require('../models/redis') const redis = require('../models/redis')
const config = require('../../config/config')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { maskToken } = require('../utils/tokenMask') const { maskToken } = require('../utils/tokenMask')
const ProxyHelper = require('../utils/proxyHelper') const ProxyHelper = require('../utils/proxyHelper')
@@ -30,10 +29,13 @@ class DroidAccountService {
this._encryptor = createEncryptor('droid-account-salt') this._encryptor = createEncryptor('droid-account-salt')
// 🧹 定期清理缓存每10分钟 // 🧹 定期清理缓存每10分钟
setInterval(() => { setInterval(
this._encryptor.clearCache() () => {
logger.info('🧹 Droid decrypt cache cleanup completed', this._encryptor.getStats()) this._encryptor.clearCache()
}, 10 * 60 * 1000) logger.info('🧹 Droid decrypt cache cleanup completed', this._encryptor.getStats())
},
10 * 60 * 1000
)
this.supportedEndpointTypes = new Set(['anthropic', 'openai', 'comm']) this.supportedEndpointTypes = new Set(['anthropic', 'openai', 'comm'])
} }

View File

@@ -2,7 +2,12 @@ const droidAccountService = require('./droidAccountService')
const accountGroupService = require('./accountGroupService') const accountGroupService = require('./accountGroupService')
const redis = require('../models/redis') const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { isTruthy, isAccountHealthy, sortAccountsByPriority, normalizeEndpointType } = require('../utils/commonHelper') const {
isTruthy,
isAccountHealthy,
sortAccountsByPriority,
normalizeEndpointType
} = require('../utils/commonHelper')
class DroidScheduler { class DroidScheduler {
constructor() { constructor() {
@@ -16,8 +21,12 @@ class DroidScheduler {
_matchesEndpoint(account, endpointType) { _matchesEndpoint(account, endpointType) {
const normalizedEndpoint = normalizeEndpointType(endpointType) const normalizedEndpoint = normalizeEndpointType(endpointType)
const accountEndpoint = normalizeEndpointType(account?.endpointType) const accountEndpoint = normalizeEndpointType(account?.endpointType)
if (normalizedEndpoint === accountEndpoint) return true if (normalizedEndpoint === accountEndpoint) {
if (normalizedEndpoint === 'comm') return true return true
}
if (normalizedEndpoint === 'comm') {
return true
}
const sharedEndpoints = new Set(['anthropic', 'openai']) const sharedEndpoints = new Set(['anthropic', 'openai'])
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint) return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
} }

View File

@@ -1,7 +1,6 @@
const redisClient = require('../models/redis') const redisClient = require('../models/redis')
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const https = require('https') const https = require('https')
const config = require('../../config/config')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { OAuth2Client } = require('google-auth-library') const { OAuth2Client } = require('google-auth-library')
const { maskToken } = require('../utils/tokenMask') const { maskToken } = require('../utils/tokenMask')
@@ -18,6 +17,8 @@ const { createEncryptor } = require('../utils/commonHelper')
// Gemini 账户键前缀 // Gemini 账户键前缀
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:' const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:'
const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts'
const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:'
// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据 // Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com' const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
@@ -572,7 +573,7 @@ async function deleteAccount(accountId) {
// 获取所有账户 // 获取所有账户
async function getAllAccounts() { async function getAllAccounts() {
const client = redisClient.getClientSafe() const _client = redisClient.getClientSafe()
const accountIds = await redisClient.getAllIdsByIndex( const accountIds = await redisClient.getAllIdsByIndex(
'gemini_account:index', 'gemini_account:index',
`${GEMINI_ACCOUNT_KEY_PREFIX}*`, `${GEMINI_ACCOUNT_KEY_PREFIX}*`,

View File

@@ -666,7 +666,7 @@ async function deleteAccount(accountId) {
// 获取所有账户 // 获取所有账户
async function getAllAccounts() { async function getAllAccounts() {
const client = redisClient.getClientSafe() const _client = redisClient.getClientSafe()
const accountIds = await redisClient.getAllIdsByIndex( const accountIds = await redisClient.getAllIdsByIndex(
'openai:account:index', 'openai:account:index',
`${OPENAI_ACCOUNT_KEY_PREFIX}*`, `${OPENAI_ACCOUNT_KEY_PREFIX}*`,

View File

@@ -201,7 +201,9 @@ class OpenAIResponsesAccountService {
`${this.ACCOUNT_KEY_PREFIX}*`, `${this.ACCOUNT_KEY_PREFIX}*`,
/^openai_responses_account:(.+)$/ /^openai_responses_account:(.+)$/
) )
if (accountIds.length === 0) return [] if (accountIds.length === 0) {
return []
}
const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`) const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`)
// Pipeline 批量查询所有账户数据 // Pipeline 批量查询所有账户数据
@@ -210,11 +212,15 @@ class OpenAIResponsesAccountService {
const results = await pipeline.exec() const results = await pipeline.exec()
const accounts = [] const accounts = []
results.forEach(([err, accountData], index) => { results.forEach(([err, accountData]) => {
if (err || !accountData || !accountData.id) return if (err || !accountData || !accountData.id) {
return
}
// 过滤非活跃账户 // 过滤非活跃账户
if (!includeInactive && accountData.isActive !== 'true') return if (!includeInactive && accountData.isActive !== 'true') {
return
}
// 隐藏敏感信息 // 隐藏敏感信息
accountData.apiKey = '***' accountData.apiKey = '***'

View File

@@ -26,11 +26,7 @@ class UnifiedGeminiScheduler {
if (apiKeyData.geminiAccountId.startsWith('api:')) { if (apiKeyData.geminiAccountId.startsWith('api:')) {
const accountId = apiKeyData.geminiAccountId.replace('api:', '') const accountId = apiKeyData.geminiAccountId.replace('api:', '')
const boundAccount = await geminiApiAccountService.getAccount(accountId) const boundAccount = await geminiApiAccountService.getAccount(accountId)
if ( if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') {
boundAccount &&
isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
logger.info( logger.info(
`🎯 Using bound Gemini-API account: ${boundAccount.name} (${accountId}) for API key ${apiKeyData.name}` `🎯 Using bound Gemini-API account: ${boundAccount.name} (${accountId}) for API key ${apiKeyData.name}`
) )
@@ -63,11 +59,7 @@ class UnifiedGeminiScheduler {
// 普通 Gemini OAuth 专属账户 // 普通 Gemini OAuth 专属账户
else { else {
const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId) const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId)
if ( if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') {
boundAccount &&
isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
logger.info( logger.info(
`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}` `🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`
) )
@@ -183,11 +175,7 @@ class UnifiedGeminiScheduler {
if (apiKeyData.geminiAccountId.startsWith('api:')) { if (apiKeyData.geminiAccountId.startsWith('api:')) {
const accountId = apiKeyData.geminiAccountId.replace('api:', '') const accountId = apiKeyData.geminiAccountId.replace('api:', '')
const boundAccount = await geminiApiAccountService.getAccount(accountId) const boundAccount = await geminiApiAccountService.getAccount(accountId)
if ( if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') {
boundAccount &&
isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
const isRateLimited = await this.isAccountRateLimited(accountId) const isRateLimited = await this.isAccountRateLimited(accountId)
if (!isRateLimited) { if (!isRateLimited) {
// 检查模型支持 // 检查模型支持
@@ -234,11 +222,7 @@ class UnifiedGeminiScheduler {
// 普通 Gemini OAuth 账户 // 普通 Gemini OAuth 账户
else if (!apiKeyData.geminiAccountId.startsWith('group:')) { else if (!apiKeyData.geminiAccountId.startsWith('group:')) {
const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId) const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId)
if ( if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') {
boundAccount &&
isActive(boundAccount.isActive) &&
boundAccount.status !== 'error'
) {
const isRateLimited = await this.isAccountRateLimited(boundAccount.id) const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
if (!isRateLimited) { if (!isRateLimited) {
// 检查模型支持 // 检查模型支持

View File

@@ -56,9 +56,9 @@ class UnifiedOpenAIScheduler {
let rateLimitChecked = false let rateLimitChecked = false
let stillLimited = false let stillLimited = false
let isSchedulable = isSchedulable(account.schedulable) const accountSchedulable = isSchedulable(account.schedulable)
if (!isSchedulable) { if (!accountSchedulable) {
if (!hasRateLimitFlag) { if (!hasRateLimitFlag) {
return { canUse: false, reason: 'not_schedulable' } return { canUse: false, reason: 'not_schedulable' }
} }
@@ -75,7 +75,6 @@ class UnifiedOpenAIScheduler {
} else { } else {
account.schedulable = 'true' account.schedulable = 'true'
} }
isSchedulable = true
logger.info(`✅ OpenAI账号 ${account.name || accountId} 已解除限流,恢复调度权限`) logger.info(`✅ OpenAI账号 ${account.name || accountId} 已解除限流,恢复调度权限`)
} }

View File

@@ -17,33 +17,45 @@ const _encryptorCache = new Map()
// 创建加密器实例(每个 salt 独立缓存) // 创建加密器实例(每个 salt 独立缓存)
const createEncryptor = (salt) => { const createEncryptor = (salt) => {
if (_encryptorCache.has(salt)) return _encryptorCache.get(salt) if (_encryptorCache.has(salt)) {
return _encryptorCache.get(salt)
}
let keyCache = null let keyCache = null
const decryptCache = new LRUCache(500) const decryptCache = new LRUCache(500)
const getKey = () => { const getKey = () => {
if (!keyCache) keyCache = crypto.scryptSync(config.security.encryptionKey, salt, 32) if (!keyCache) {
keyCache = crypto.scryptSync(config.security.encryptionKey, salt, 32)
}
return keyCache return keyCache
} }
const encrypt = (text) => { const encrypt = (text) => {
if (!text) return '' if (!text) {
return ''
}
const key = getKey() const key = getKey()
const iv = crypto.randomBytes(IV_LENGTH) const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv) const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
let encrypted = cipher.update(text, 'utf8', 'hex') let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex') encrypted += cipher.final('hex')
return iv.toString('hex') + ':' + encrypted return `${iv.toString('hex')}:${encrypted}`
} }
const decrypt = (text, useCache = true) => { const decrypt = (text, useCache = true) => {
if (!text) return '' if (!text) {
if (!text.includes(':')) return text return ''
}
if (!text.includes(':')) {
return text
}
const cacheKey = crypto.createHash('sha256').update(text).digest('hex') const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
if (useCache) { if (useCache) {
const cached = decryptCache.get(cacheKey) const cached = decryptCache.get(cacheKey)
if (cached !== undefined) return cached if (cached !== undefined) {
return cached
}
} }
try { try {
const key = getKey() const key = getKey()
@@ -52,7 +64,9 @@ const createEncryptor = (salt) => {
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
let decrypted = decipher.update(encrypted, 'hex', 'utf8') let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8') decrypted += decipher.final('utf8')
if (useCache) decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) if (useCache) {
decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
}
return decrypted return decrypted
} catch (e) { } catch (e) {
return text return text
@@ -73,8 +87,8 @@ const createEncryptor = (salt) => {
// 默认加密器(向后兼容) // 默认加密器(向后兼容)
const defaultEncryptor = createEncryptor('claude-relay-salt') const defaultEncryptor = createEncryptor('claude-relay-salt')
const encrypt = defaultEncryptor.encrypt const { encrypt } = defaultEncryptor
const decrypt = defaultEncryptor.decrypt const { decrypt } = defaultEncryptor
const getEncryptionKey = defaultEncryptor.getKey const getEncryptionKey = defaultEncryptor.getKey
const clearDecryptCache = defaultEncryptor.clearCache const clearDecryptCache = defaultEncryptor.clearCache
const getDecryptCacheStats = defaultEncryptor.getStats const getDecryptCacheStats = defaultEncryptor.getStats
@@ -84,10 +98,13 @@ const getDecryptCacheStats = defaultEncryptor.getStats
// ============================================ // ============================================
// 转换为布尔值(宽松模式) // 转换为布尔值(宽松模式)
const toBoolean = (value) => value === true || value === 'true' || (typeof value === 'string' && value.toLowerCase() === 'true') const toBoolean = (value) =>
value === true ||
value === 'true' ||
(typeof value === 'string' && value.toLowerCase() === 'true')
// 检查是否为真值null/undefined 返回 false // 检查是否为真值null/undefined 返回 false
const isTruthy = (value) => value != null && toBoolean(value) const isTruthy = (value) => value !== null && value !== undefined && toBoolean(value)
// 检查是否可调度(默认 true只有明确 false 才返回 false // 检查是否可调度(默认 true只有明确 false 才返回 false
const isSchedulable = (value) => value !== false && value !== 'false' const isSchedulable = (value) => value !== false && value !== 'false'
@@ -97,8 +114,12 @@ const isActive = (value) => value === true || value === 'true'
// 检查账户是否健康(激活且状态正常) // 检查账户是否健康(激活且状态正常)
const isAccountHealthy = (account) => { const isAccountHealthy = (account) => {
if (!account) return false if (!account) {
if (!isTruthy(account.isActive)) return false return false
}
if (!isTruthy(account.isActive)) {
return false
}
const status = (account.status || 'active').toLowerCase() const status = (account.status || 'active').toLowerCase()
return !['error', 'unauthorized', 'blocked', 'temp_error'].includes(status) return !['error', 'unauthorized', 'blocked', 'temp_error'].includes(status)
} }
@@ -109,8 +130,14 @@ const isAccountHealthy = (account) => {
// 安全解析 JSON // 安全解析 JSON
const safeParseJson = (value, fallback = null) => { const safeParseJson = (value, fallback = null) => {
if (!value || typeof value !== 'string') return fallback if (!value || typeof value !== 'string') {
try { return JSON.parse(value) } catch { return fallback } return fallback
}
try {
return JSON.parse(value)
} catch {
return fallback
}
} }
// 安全解析 JSON 为对象 // 安全解析 JSON 为对象
@@ -131,36 +158,53 @@ const safeParseJsonArray = (value, fallback = []) => {
// 规范化模型名称(用于统计聚合) // 规范化模型名称(用于统计聚合)
const normalizeModelName = (model) => { const normalizeModelName = (model) => {
if (!model || model === 'unknown') return model if (!model || model === 'unknown') {
return model
}
// Bedrock 模型: us-east-1.anthropic.claude-3-5-sonnet-v1:0 // Bedrock 模型: us-east-1.anthropic.claude-3-5-sonnet-v1:0
if (model.includes('.anthropic.') || model.includes('.claude')) { if (model.includes('.anthropic.') || model.includes('.claude')) {
return model.replace(/^[a-z0-9-]+\./, '').replace('anthropic.', '').replace(/-v\d+:\d+$/, '') return model
.replace(/^[a-z0-9-]+\./, '')
.replace('anthropic.', '')
.replace(/-v\d+:\d+$/, '')
} }
return model.replace(/-v\d+:\d+$|:latest$/, '') return model.replace(/-v\d+:\d+$|:latest$/, '')
} }
// 规范化端点类型 // 规范化端点类型
const normalizeEndpointType = (endpointType) => { const normalizeEndpointType = (endpointType) => {
if (!endpointType) return 'anthropic' if (!endpointType) {
return 'anthropic'
}
const normalized = String(endpointType).toLowerCase() const normalized = String(endpointType).toLowerCase()
return ['openai', 'comm', 'anthropic'].includes(normalized) ? normalized : 'anthropic' return ['openai', 'comm', 'anthropic'].includes(normalized) ? normalized : 'anthropic'
} }
// 检查模型是否在映射表中 // 检查模型是否在映射表中
const isModelInMapping = (modelMapping, requestedModel) => { const isModelInMapping = (modelMapping, requestedModel) => {
if (!modelMapping || Object.keys(modelMapping).length === 0) return true if (!modelMapping || Object.keys(modelMapping).length === 0) {
if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) return true return true
}
if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) {
return true
}
const lower = requestedModel.toLowerCase() const lower = requestedModel.toLowerCase()
return Object.keys(modelMapping).some(k => k.toLowerCase() === lower) return Object.keys(modelMapping).some((k) => k.toLowerCase() === lower)
} }
// 获取映射后的模型名称 // 获取映射后的模型名称
const getMappedModelName = (modelMapping, requestedModel) => { const getMappedModelName = (modelMapping, requestedModel) => {
if (!modelMapping || Object.keys(modelMapping).length === 0) return requestedModel if (!modelMapping || Object.keys(modelMapping).length === 0) {
if (modelMapping[requestedModel]) return modelMapping[requestedModel] return requestedModel
}
if (modelMapping[requestedModel]) {
return modelMapping[requestedModel]
}
const lower = requestedModel.toLowerCase() const lower = requestedModel.toLowerCase()
for (const [key, value] of Object.entries(modelMapping)) { for (const [key, value] of Object.entries(modelMapping)) {
if (key.toLowerCase() === lower) return value if (key.toLowerCase() === lower) {
return value
}
} }
return requestedModel return requestedModel
} }
@@ -170,30 +214,34 @@ const getMappedModelName = (modelMapping, requestedModel) => {
// ============================================ // ============================================
// 按优先级和最后使用时间排序账户 // 按优先级和最后使用时间排序账户
const sortAccountsByPriority = (accounts) => { const sortAccountsByPriority = (accounts) =>
return [...accounts].sort((a, b) => { [...accounts].sort((a, b) => {
const priorityA = parseInt(a.priority, 10) || 50 const priorityA = parseInt(a.priority, 10) || 50
const priorityB = parseInt(b.priority, 10) || 50 const priorityB = parseInt(b.priority, 10) || 50
if (priorityA !== priorityB) return priorityA - priorityB if (priorityA !== priorityB) {
return priorityA - priorityB
}
const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0 const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0
const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0 const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0
if (lastUsedA !== lastUsedB) return lastUsedA - lastUsedB if (lastUsedA !== lastUsedB) {
return lastUsedA - lastUsedB
}
const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0 const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0
const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0 const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0
return createdA - createdB return createdA - createdB
}) })
}
// 生成粘性会话 Key // 生成粘性会话 Key
const composeStickySessionKey = (prefix, sessionHash, apiKeyId = null) => { const composeStickySessionKey = (prefix, sessionHash, apiKeyId = null) => {
if (!sessionHash) return null if (!sessionHash) {
return null
}
return `sticky:${prefix}:${apiKeyId || 'default'}:${sessionHash}` return `sticky:${prefix}:${apiKeyId || 'default'}:${sessionHash}`
} }
// 过滤可用账户(激活 + 健康 + 可调度) // 过滤可用账户(激活 + 健康 + 可调度)
const filterAvailableAccounts = (accounts) => { const filterAvailableAccounts = (accounts) =>
return accounts.filter(acc => acc && isAccountHealthy(acc) && isSchedulable(acc.schedulable)) accounts.filter((acc) => acc && isAccountHealthy(acc) && isSchedulable(acc.schedulable))
}
// ============================================ // ============================================
// 字符串处理 // 字符串处理
@@ -201,13 +249,17 @@ const filterAvailableAccounts = (accounts) => {
// 截断字符串 // 截断字符串
const truncate = (str, maxLen = 100, suffix = '...') => { const truncate = (str, maxLen = 100, suffix = '...') => {
if (!str || str.length <= maxLen) return str if (!str || str.length <= maxLen) {
return str
}
return str.slice(0, maxLen - suffix.length) + suffix return str.slice(0, maxLen - suffix.length) + suffix
} }
// 掩码敏感信息(保留前后几位) // 掩码敏感信息(保留前后几位)
const maskSensitive = (str, keepStart = 4, keepEnd = 4, maskChar = '*') => { const maskSensitive = (str, keepStart = 4, keepEnd = 4, maskChar = '*') => {
if (!str || str.length <= keepStart + keepEnd) return str if (!str || str.length <= keepStart + keepEnd) {
return str
}
const maskLen = Math.min(str.length - keepStart - keepEnd, 8) const maskLen = Math.min(str.length - keepStart - keepEnd, 8)
return str.slice(0, keepStart) + maskChar.repeat(maskLen) + str.slice(-keepEnd) return str.slice(0, keepStart) + maskChar.repeat(maskLen) + str.slice(-keepEnd)
} }
@@ -236,9 +288,8 @@ const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
// ============================================ // ============================================
// 获取时区偏移后的日期 // 获取时区偏移后的日期
const getDateInTimezone = (date = new Date(), offset = config.system?.timezoneOffset || 8) => { const getDateInTimezone = (date = new Date(), offset = config.system?.timezoneOffset || 8) =>
return new Date(date.getTime() + offset * 3600000) new Date(date.getTime() + offset * 3600000)
}
// 获取时区日期字符串 YYYY-MM-DD // 获取时区日期字符串 YYYY-MM-DD
const getDateStringInTimezone = (date = new Date()) => { const getDateStringInTimezone = (date = new Date()) => {
@@ -248,13 +299,17 @@ const getDateStringInTimezone = (date = new Date()) => {
// 检查是否过期 // 检查是否过期
const isExpired = (expiresAt) => { const isExpired = (expiresAt) => {
if (!expiresAt) return false if (!expiresAt) {
return false
}
return new Date(expiresAt).getTime() < Date.now() return new Date(expiresAt).getTime() < Date.now()
} }
// 计算剩余时间(秒) // 计算剩余时间(秒)
const getTimeRemaining = (expiresAt) => { const getTimeRemaining = (expiresAt) => {
if (!expiresAt) return Infinity if (!expiresAt) {
return Infinity
}
return Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000)) return Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000))
} }

View File

@@ -125,8 +125,12 @@ class CodexCliValidator {
const part1 = parts1[i] || 0 const part1 = parts1[i] || 0
const part2 = parts2[i] || 0 const part2 = parts2[i] || 0
if (part1 < part2) return -1 if (part1 < part2) {
if (part1 > part2) return 1 return -1
}
if (part1 > part2) {
return 1
}
} }
return 0 return 0

View File

@@ -53,7 +53,7 @@ class GeminiCliValidator {
// 2. 对于 /gemini 路径,检查是否包含 generateContent // 2. 对于 /gemini 路径,检查是否包含 generateContent
if (path.includes('generateContent')) { if (path.includes('generateContent')) {
// 包含 generateContent 的路径需要验证 User-Agent // 包含 generateContent 的路径需要验证 User-Agent
const geminiCliPattern = /^GeminiCLI\/v?[\d\.]+/i const geminiCliPattern = /^GeminiCLI\/v?[\d.]+/i
if (!geminiCliPattern.test(userAgent)) { if (!geminiCliPattern.test(userAgent)) {
logger.debug( logger.debug(
`Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}` `Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}`
@@ -84,8 +84,12 @@ class GeminiCliValidator {
const part1 = parts1[i] || 0 const part1 = parts1[i] || 0
const part2 = parts2[i] || 0 const part2 = parts2[i] || 0
if (part1 < part2) return -1 if (part1 < part2) {
if (part1 > part2) return 1 return -1
}
if (part1 > part2) {
return 1
}
} }
return 0 return 0