mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 21:17:30 +00:00
1
This commit is contained in:
21
src/app.js
21
src/app.js
@@ -52,11 +52,32 @@ class Application {
|
||||
await redis.connect()
|
||||
logger.success('Redis connected successfully')
|
||||
|
||||
// 📊 检查数据迁移(版本 > 1.1.250 时执行)
|
||||
const { getAppVersion, versionGt } = require('./utils/commonHelper')
|
||||
const currentVersion = getAppVersion()
|
||||
const migratedVersion = await redis.getMigratedVersion()
|
||||
if (versionGt(currentVersion, '1.1.250') && versionGt(currentVersion, migratedVersion)) {
|
||||
logger.info(`🔄 检测到新版本 ${currentVersion},检查数据迁移...`)
|
||||
try {
|
||||
if (await redis.needsGlobalStatsMigration()) {
|
||||
await redis.migrateGlobalStats()
|
||||
}
|
||||
await redis.cleanupSystemMetrics() // 清理过期的系统分钟统计
|
||||
} catch (err) {
|
||||
logger.error('⚠️ 数据迁移出错,但不影响启动:', err.message)
|
||||
}
|
||||
await redis.setMigratedVersion(currentVersion)
|
||||
logger.success(`✅ 数据迁移完成,版本: ${currentVersion}`)
|
||||
}
|
||||
|
||||
// 📊 后台异步迁移 usage 索引(不阻塞启动)
|
||||
redis.migrateUsageIndex().catch((err) => {
|
||||
logger.error('📊 Background usage index migration failed:', err)
|
||||
})
|
||||
|
||||
// 📊 迁移 alltime 模型统计(阻塞式,确保数据完整)
|
||||
await redis.migrateAlltimeModelStats()
|
||||
|
||||
// 💰 初始化价格服务
|
||||
logger.info('🔄 Initializing pricing service...')
|
||||
await pricingService.initialize()
|
||||
|
||||
@@ -13,6 +13,7 @@ const crypto = require('crypto')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const redis = require('../models/redis')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const { parseSSELine } = require('../utils/sseParser')
|
||||
const axios = require('axios')
|
||||
@@ -805,16 +806,18 @@ function handleModelDetails(req, res) {
|
||||
*/
|
||||
async function handleUsage(req, res) {
|
||||
try {
|
||||
const { usage } = req.apiKey
|
||||
const keyData = req.apiKey
|
||||
// 按需查询 usage 数据
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
|
||||
res.json({
|
||||
object: 'usage',
|
||||
total_tokens: usage.total.tokens,
|
||||
total_requests: usage.total.requests,
|
||||
daily_tokens: usage.daily.tokens,
|
||||
daily_requests: usage.daily.requests,
|
||||
monthly_tokens: usage.monthly.tokens,
|
||||
monthly_requests: usage.monthly.requests
|
||||
total_tokens: usage?.total?.tokens || 0,
|
||||
total_requests: usage?.total?.requests || 0,
|
||||
daily_tokens: usage?.daily?.tokens || 0,
|
||||
daily_requests: usage?.daily?.requests || 0,
|
||||
monthly_tokens: usage?.monthly?.tokens || 0,
|
||||
monthly_requests: usage?.monthly?.requests || 0
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage stats:', error)
|
||||
@@ -833,17 +836,18 @@ async function handleUsage(req, res) {
|
||||
async function handleKeyInfo(req, res) {
|
||||
try {
|
||||
const keyData = req.apiKey
|
||||
// 按需查询 usage 数据(仅 key-info 端点需要)
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
const tokensUsed = usage?.total?.tokens || 0
|
||||
|
||||
res.json({
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
permissions: keyData.permissions || 'all',
|
||||
token_limit: keyData.tokenLimit,
|
||||
tokens_used: keyData.usage.total.tokens,
|
||||
tokens_used: tokensUsed,
|
||||
tokens_remaining:
|
||||
keyData.tokenLimit > 0
|
||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
||||
: null,
|
||||
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
|
||||
rate_limit: {
|
||||
window: keyData.rateLimitWindow,
|
||||
requests: keyData.rateLimitRequests
|
||||
|
||||
@@ -1306,10 +1306,8 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
dailyCostLimit: validation.keyData.dailyCostLimit,
|
||||
dailyCost: validation.keyData.dailyCost,
|
||||
totalCostLimit: validation.keyData.totalCostLimit,
|
||||
totalCost: validation.keyData.totalCost,
|
||||
usage: validation.keyData.usage
|
||||
totalCost: validation.keyData.totalCost
|
||||
}
|
||||
req.usage = validation.keyData.usage
|
||||
|
||||
const authDuration = Date.now() - startTime
|
||||
const userAgent = req.headers['user-agent'] || 'No User-Agent'
|
||||
|
||||
@@ -299,6 +299,96 @@ class RedisClient {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 自动迁移 alltime 模型统计(启动时调用)
|
||||
async migrateAlltimeModelStats() {
|
||||
const migrationKey = 'system:migration:alltime_model_stats_v1'
|
||||
const migrated = await this.client.get(migrationKey)
|
||||
if (migrated) {
|
||||
logger.debug('📊 Alltime model stats migration already completed')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('📊 Starting alltime model stats migration...')
|
||||
const stats = { keys: 0, models: 0 }
|
||||
|
||||
try {
|
||||
// 扫描所有月度模型统计数据并聚合到 alltime
|
||||
// 格式: usage:{keyId}:model:monthly:{model}:{month}
|
||||
let cursor = '0'
|
||||
const aggregatedData = new Map() // keyId:model -> {inputTokens, outputTokens, ...}
|
||||
|
||||
do {
|
||||
const [newCursor, keys] = await this.client.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
'usage:*:model:monthly:*:*',
|
||||
'COUNT',
|
||||
500
|
||||
)
|
||||
cursor = newCursor
|
||||
|
||||
for (const key of keys) {
|
||||
// usage:{keyId}:model:monthly:{model}:{month}
|
||||
const match = key.match(/^usage:([^:]+):model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||
if (match) {
|
||||
const [, keyId, model] = match
|
||||
const aggregateKey = `${keyId}:${model}`
|
||||
|
||||
// 获取该月的数据
|
||||
const data = await this.client.hgetall(key)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
if (!aggregatedData.has(aggregateKey)) {
|
||||
aggregatedData.set(aggregateKey, {
|
||||
keyId,
|
||||
model,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
requests: 0
|
||||
})
|
||||
}
|
||||
|
||||
const agg = aggregatedData.get(aggregateKey)
|
||||
agg.inputTokens += parseInt(data.inputTokens) || 0
|
||||
agg.outputTokens += parseInt(data.outputTokens) || 0
|
||||
agg.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||
agg.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||
agg.requests += parseInt(data.requests) || 0
|
||||
stats.keys++
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
// 写入聚合后的 alltime 数据
|
||||
const pipeline = this.client.pipeline()
|
||||
for (const [, agg] of aggregatedData) {
|
||||
const alltimeKey = `usage:${agg.keyId}:model:alltime:${agg.model}`
|
||||
pipeline.hset(alltimeKey, {
|
||||
inputTokens: agg.inputTokens.toString(),
|
||||
outputTokens: agg.outputTokens.toString(),
|
||||
cacheCreateTokens: agg.cacheCreateTokens.toString(),
|
||||
cacheReadTokens: agg.cacheReadTokens.toString(),
|
||||
requests: agg.requests.toString()
|
||||
})
|
||||
stats.models++
|
||||
}
|
||||
|
||||
if (stats.models > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
// 标记迁移完成
|
||||
await this.client.set(migrationKey, Date.now().toString())
|
||||
logger.info(
|
||||
`📊 Alltime model stats migration completed: scanned ${stats.keys} monthly keys, created ${stats.models} alltime keys`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('📊 Alltime model stats migration failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.client) {
|
||||
await this.client.quit()
|
||||
@@ -996,6 +1086,14 @@ class RedisClient {
|
||||
pipeline.hincrby(keyModelMonthly, 'ephemeral5mTokens', ephemeral5mTokens)
|
||||
pipeline.hincrby(keyModelMonthly, 'ephemeral1hTokens', ephemeral1hTokens)
|
||||
|
||||
// API Key级别的模型统计 - 所有时间(无 TTL)
|
||||
const keyModelAlltime = `usage:${keyId}:model:alltime:${normalizedModel}`
|
||||
pipeline.hincrby(keyModelAlltime, 'inputTokens', finalInputTokens)
|
||||
pipeline.hincrby(keyModelAlltime, 'outputTokens', finalOutputTokens)
|
||||
pipeline.hincrby(keyModelAlltime, 'cacheCreateTokens', finalCacheCreateTokens)
|
||||
pipeline.hincrby(keyModelAlltime, 'cacheReadTokens', finalCacheReadTokens)
|
||||
pipeline.hincrby(keyModelAlltime, 'requests', 1)
|
||||
|
||||
// 小时级别统计
|
||||
pipeline.hincrby(hourly, 'tokens', coreTokens)
|
||||
pipeline.hincrby(hourly, 'inputTokens', finalInputTokens)
|
||||
@@ -1040,9 +1138,9 @@ class RedisClient {
|
||||
pipeline.expire(keyModelMonthly, 86400 * 365) // API Key模型每月统计1年过期
|
||||
pipeline.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期
|
||||
|
||||
// 系统级分钟统计的过期时间(窗口时间的2倍)
|
||||
// 系统级分钟统计的过期时间(窗口时间的2倍,默认5分钟)
|
||||
const configLocal = require('../../config/config')
|
||||
const { metricsWindow } = configLocal.system
|
||||
const metricsWindow = configLocal.system?.metricsWindow || 5
|
||||
pipeline.expire(systemMinuteKey, metricsWindow * 60 * 2)
|
||||
|
||||
// 添加索引(用于快速查询,避免 SCAN)
|
||||
@@ -1071,6 +1169,30 @@ class RedisClient {
|
||||
pipeline.expire(`usage:keymodel:daily:index:${today}`, 86400 * 32)
|
||||
pipeline.expire(`usage:keymodel:hourly:index:${currentHour}`, 86400 * 7)
|
||||
|
||||
// 全局预聚合统计
|
||||
const globalDaily = `usage:global:daily:${today}`
|
||||
const globalMonthly = `usage:global:monthly:${currentMonth}`
|
||||
pipeline.hincrby('usage:global:total', 'requests', 1)
|
||||
pipeline.hincrby('usage:global:total', 'inputTokens', finalInputTokens)
|
||||
pipeline.hincrby('usage:global:total', 'outputTokens', finalOutputTokens)
|
||||
pipeline.hincrby('usage:global:total', 'cacheCreateTokens', finalCacheCreateTokens)
|
||||
pipeline.hincrby('usage:global:total', 'cacheReadTokens', finalCacheReadTokens)
|
||||
pipeline.hincrby('usage:global:total', 'allTokens', totalTokens)
|
||||
pipeline.hincrby(globalDaily, 'requests', 1)
|
||||
pipeline.hincrby(globalDaily, 'inputTokens', finalInputTokens)
|
||||
pipeline.hincrby(globalDaily, 'outputTokens', finalOutputTokens)
|
||||
pipeline.hincrby(globalDaily, 'cacheCreateTokens', finalCacheCreateTokens)
|
||||
pipeline.hincrby(globalDaily, 'cacheReadTokens', finalCacheReadTokens)
|
||||
pipeline.hincrby(globalDaily, 'allTokens', totalTokens)
|
||||
pipeline.hincrby(globalMonthly, 'requests', 1)
|
||||
pipeline.hincrby(globalMonthly, 'inputTokens', finalInputTokens)
|
||||
pipeline.hincrby(globalMonthly, 'outputTokens', finalOutputTokens)
|
||||
pipeline.hincrby(globalMonthly, 'cacheCreateTokens', finalCacheCreateTokens)
|
||||
pipeline.hincrby(globalMonthly, 'cacheReadTokens', finalCacheReadTokens)
|
||||
pipeline.hincrby(globalMonthly, 'allTokens', totalTokens)
|
||||
pipeline.expire(globalDaily, 86400 * 32)
|
||||
pipeline.expire(globalMonthly, 86400 * 365)
|
||||
|
||||
// 执行Pipeline
|
||||
await pipeline.exec()
|
||||
}
|
||||
@@ -4521,4 +4643,151 @@ redisClient.removeFromIndex = async function (indexKey, id) {
|
||||
await client.srem(indexKey, id)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 数据迁移相关
|
||||
// ============================================
|
||||
|
||||
// 迁移全局统计数据(从 API Key 数据聚合)
|
||||
redisClient.migrateGlobalStats = async function () {
|
||||
const logger = require('../utils/logger')
|
||||
logger.info('🔄 开始迁移全局统计数据...')
|
||||
|
||||
const keyIds = await this.scanApiKeyIds()
|
||||
if (!keyIds || keyIds.length === 0) {
|
||||
logger.info('📊 没有 API Key 数据需要迁移')
|
||||
return { success: true, migrated: 0 }
|
||||
}
|
||||
|
||||
const total = {
|
||||
requests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
allTokens: 0
|
||||
}
|
||||
|
||||
// 批量获取所有 usage 数据
|
||||
const pipeline = this.client.pipeline()
|
||||
keyIds.forEach((id) => pipeline.hgetall(`usage:${id}`))
|
||||
const results = await pipeline.exec()
|
||||
|
||||
results.forEach(([err, usage]) => {
|
||||
if (err || !usage) {
|
||||
return
|
||||
}
|
||||
// 兼容新旧字段格式(带 total 前缀和不带的)
|
||||
total.requests += parseInt(usage.totalRequests || usage.requests) || 0
|
||||
total.inputTokens += parseInt(usage.totalInputTokens || usage.inputTokens) || 0
|
||||
total.outputTokens += parseInt(usage.totalOutputTokens || usage.outputTokens) || 0
|
||||
total.cacheCreateTokens +=
|
||||
parseInt(usage.totalCacheCreateTokens || usage.cacheCreateTokens) || 0
|
||||
total.cacheReadTokens += parseInt(usage.totalCacheReadTokens || usage.cacheReadTokens) || 0
|
||||
total.allTokens += parseInt(usage.totalAllTokens || usage.allTokens || usage.totalTokens) || 0
|
||||
})
|
||||
|
||||
// 写入全局统计
|
||||
await this.client.hset('usage:global:total', total)
|
||||
logger.success(
|
||||
`✅ 迁移完成: ${keyIds.length} 个 API Key, ${total.requests} 请求, ${total.allTokens} tokens`
|
||||
)
|
||||
return { success: true, migrated: keyIds.length, total }
|
||||
}
|
||||
|
||||
// 检查是否需要迁移
|
||||
redisClient.needsGlobalStatsMigration = async function () {
|
||||
const exists = await this.client.exists('usage:global:total')
|
||||
return exists === 0
|
||||
}
|
||||
|
||||
// 获取已迁移版本
|
||||
redisClient.getMigratedVersion = async function () {
|
||||
return (await this.client.get('system:migrated:version')) || '0.0.0'
|
||||
}
|
||||
|
||||
// 设置已迁移版本
|
||||
redisClient.setMigratedVersion = async function (version) {
|
||||
await this.client.set('system:migrated:version', version)
|
||||
}
|
||||
|
||||
// 获取全局统计(用于 dashboard 快速查询)
|
||||
redisClient.getGlobalStats = async function () {
|
||||
const stats = await this.client.hgetall('usage:global:total')
|
||||
if (!stats || !stats.requests) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
requests: parseInt(stats.requests) || 0,
|
||||
inputTokens: parseInt(stats.inputTokens) || 0,
|
||||
outputTokens: parseInt(stats.outputTokens) || 0,
|
||||
cacheCreateTokens: parseInt(stats.cacheCreateTokens) || 0,
|
||||
cacheReadTokens: parseInt(stats.cacheReadTokens) || 0,
|
||||
allTokens: parseInt(stats.allTokens) || 0
|
||||
}
|
||||
}
|
||||
|
||||
// 快速获取 API Key 计数(不拉全量数据)
|
||||
redisClient.getApiKeyCount = async function () {
|
||||
const keyIds = await this.scanApiKeyIds()
|
||||
if (!keyIds || keyIds.length === 0) {
|
||||
return { total: 0, active: 0 }
|
||||
}
|
||||
|
||||
// 批量获取 isActive 字段
|
||||
const pipeline = this.client.pipeline()
|
||||
keyIds.forEach((id) => pipeline.hget(`apikey:${id}`, 'isActive'))
|
||||
const results = await pipeline.exec()
|
||||
|
||||
let active = 0
|
||||
results.forEach(([err, val]) => {
|
||||
if (!err && (val === 'true' || val === true)) {
|
||||
active++
|
||||
}
|
||||
})
|
||||
return { total: keyIds.length, active }
|
||||
}
|
||||
|
||||
// 清理过期的系统分钟统计数据(启动时调用)
|
||||
redisClient.cleanupSystemMetrics = async function () {
|
||||
const logger = require('../utils/logger')
|
||||
logger.info('🧹 清理过期的系统分钟统计数据...')
|
||||
|
||||
const keys = await this.scanKeys('system:metrics:minute:*')
|
||||
if (!keys || keys.length === 0) {
|
||||
logger.info('📊 没有需要清理的系统分钟统计数据')
|
||||
return { cleaned: 0 }
|
||||
}
|
||||
|
||||
// 计算当前分钟时间戳和保留窗口
|
||||
const config = require('../../config/config')
|
||||
const metricsWindow = config.system?.metricsWindow || 5
|
||||
const currentMinute = Math.floor(Date.now() / 60000)
|
||||
const keepAfter = currentMinute - metricsWindow * 2 // 保留窗口的2倍
|
||||
|
||||
// 筛选需要删除的 key
|
||||
const toDelete = keys.filter((key) => {
|
||||
const match = key.match(/system:metrics:minute:(\d+)/)
|
||||
if (!match) {
|
||||
return false
|
||||
}
|
||||
const minute = parseInt(match[1])
|
||||
return minute < keepAfter
|
||||
})
|
||||
|
||||
if (toDelete.length === 0) {
|
||||
logger.info('📊 没有过期的系统分钟统计数据')
|
||||
return { cleaned: 0 }
|
||||
}
|
||||
|
||||
// 分批删除
|
||||
const batchSize = 1000
|
||||
for (let i = 0; i < toDelete.length; i += batchSize) {
|
||||
const batch = toDelete.slice(i, i + batchSize)
|
||||
await this.client.del(...batch)
|
||||
}
|
||||
|
||||
logger.success(`✅ 清理完成: 删除 ${toDelete.length} 个过期的系统分钟统计 key`)
|
||||
return { cleaned: toDelete.length }
|
||||
}
|
||||
|
||||
module.exports = redisClient
|
||||
|
||||
@@ -8,6 +8,17 @@ const config = require('../../../config/config')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 有效的服务权限值
|
||||
const VALID_SERVICES = ['claude', 'gemini', 'openai', 'droid']
|
||||
|
||||
// 验证 permissions 值(支持单选和多选)
|
||||
const isValidPermissions = (permissions) => {
|
||||
if (!permissions || permissions === 'all') return true
|
||||
// 支持逗号分隔的多选格式
|
||||
const services = permissions.split(',')
|
||||
return services.every((s) => VALID_SERVICES.includes(s.trim()))
|
||||
}
|
||||
|
||||
// 👥 用户管理 (用于API Key分配)
|
||||
|
||||
// 获取所有用户列表(用于API Key分配)
|
||||
@@ -1430,10 +1441,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
permissions !== undefined &&
|
||||
permissions !== null &&
|
||||
permissions !== '' &&
|
||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
||||
!isValidPermissions(permissions)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1528,10 +1539,10 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
permissions !== undefined &&
|
||||
permissions !== null &&
|
||||
permissions !== '' &&
|
||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
||||
!isValidPermissions(permissions)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1637,10 +1648,10 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
|
||||
if (
|
||||
updates.permissions !== undefined &&
|
||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions)
|
||||
!isValidPermissions(updates.permissions)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1917,9 +1928,9 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
|
||||
if (permissions !== undefined) {
|
||||
// 验证权限值
|
||||
if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) {
|
||||
if (!isValidPermissions(permissions)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination'
|
||||
})
|
||||
}
|
||||
updates.permissions = permissions
|
||||
|
||||
@@ -414,4 +414,84 @@ router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 Azure OpenAI 账户连通性
|
||||
router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await azureOpenaiAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 获取解密后的 API Key
|
||||
const apiKey = await azureOpenaiAccountService.getDecryptedApiKey(accountId)
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ error: 'API Key not found or decryption failed' })
|
||||
}
|
||||
|
||||
// 构造测试请求
|
||||
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const deploymentName = account.deploymentName || 'gpt-4o-mini'
|
||||
const apiVersion = account.apiVersion || '2024-02-15-preview'
|
||||
const apiUrl = `${account.endpoint}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
|
||||
const payload = createOpenAITestPayload(deploymentName)
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': apiKey
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.choices?.[0]?.message?.content) {
|
||||
responseText = response.data.choices[0].message.content
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Azure OpenAI account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model: deploymentName,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ Azure OpenAI account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -413,4 +413,89 @@ router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 CCR 账户连通性
|
||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { model = 'claude-sonnet-4-20250514' } = req.body
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 获取解密后的凭据
|
||||
const credentials = await ccrAccountService.getDecryptedCredentials(accountId)
|
||||
if (!credentials) {
|
||||
return res.status(401).json({ error: 'Credentials not found or decryption failed' })
|
||||
}
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const baseUrl = account.baseUrl || 'https://api.anthropic.com'
|
||||
const apiUrl = `${baseUrl}/v1/messages`
|
||||
const payload = {
|
||||
model,
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Say "Hello" in one word.' }]
|
||||
}
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': credentials.apiKey,
|
||||
'anthropic-version': '2023-06-01'
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.content?.[0]?.text) {
|
||||
responseText = response.data.content[0].text
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ CCR account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ CCR account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -20,8 +20,14 @@ const router = express.Router()
|
||||
// 获取系统概览
|
||||
router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
// 先检查是否有全局预聚合数据
|
||||
const globalStats = await redis.getGlobalStats()
|
||||
|
||||
// 根据是否有全局统计决定查询策略
|
||||
let apiKeys = null
|
||||
let apiKeyCount = null
|
||||
|
||||
const [
|
||||
apiKeys,
|
||||
claudeAccounts,
|
||||
claudeConsoleAccounts,
|
||||
geminiAccounts,
|
||||
@@ -34,7 +40,6 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
systemAverages,
|
||||
realtimeMetrics
|
||||
] = await Promise.all([
|
||||
apiKeyService.getAllApiKeysFast(),
|
||||
claudeAccountService.getAllAccounts(),
|
||||
claudeConsoleAccountService.getAllAccounts(),
|
||||
geminiAccountService.getAllAccounts(),
|
||||
@@ -48,6 +53,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
redis.getRealtimeSystemMetrics()
|
||||
])
|
||||
|
||||
// 有全局统计时只获取计数,否则拉全量
|
||||
if (globalStats) {
|
||||
apiKeyCount = await redis.getApiKeyCount()
|
||||
} else {
|
||||
apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
}
|
||||
|
||||
// 处理Bedrock账户数据
|
||||
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
||||
const normalizeBoolean = (value) => value === true || value === 'true'
|
||||
@@ -122,7 +134,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 计算使用统计(单次遍历)
|
||||
// 计算使用统计
|
||||
let totalTokensUsed = 0,
|
||||
totalRequestsUsed = 0,
|
||||
totalInputTokensUsed = 0,
|
||||
@@ -130,20 +142,37 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
totalCacheCreateTokensUsed = 0,
|
||||
totalCacheReadTokensUsed = 0,
|
||||
totalAllTokensUsed = 0,
|
||||
activeApiKeys = 0
|
||||
for (const key of apiKeys) {
|
||||
const usage = key.usage?.total
|
||||
if (usage) {
|
||||
totalTokensUsed += usage.allTokens || 0
|
||||
totalRequestsUsed += usage.requests || 0
|
||||
totalInputTokensUsed += usage.inputTokens || 0
|
||||
totalOutputTokensUsed += usage.outputTokens || 0
|
||||
totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0
|
||||
totalCacheReadTokensUsed += usage.cacheReadTokens || 0
|
||||
totalAllTokensUsed += usage.allTokens || 0
|
||||
}
|
||||
if (key.isActive) {
|
||||
activeApiKeys++
|
||||
activeApiKeys = 0,
|
||||
totalApiKeys = 0
|
||||
|
||||
if (globalStats) {
|
||||
// 使用预聚合数据(快速路径)
|
||||
totalRequestsUsed = globalStats.requests
|
||||
totalInputTokensUsed = globalStats.inputTokens
|
||||
totalOutputTokensUsed = globalStats.outputTokens
|
||||
totalCacheCreateTokensUsed = globalStats.cacheCreateTokens
|
||||
totalCacheReadTokensUsed = globalStats.cacheReadTokens
|
||||
totalAllTokensUsed = globalStats.allTokens
|
||||
totalTokensUsed = totalAllTokensUsed
|
||||
totalApiKeys = apiKeyCount.total
|
||||
activeApiKeys = apiKeyCount.active
|
||||
} else {
|
||||
// 回退到遍历(兼容旧数据)
|
||||
totalApiKeys = apiKeys.length
|
||||
for (const key of apiKeys) {
|
||||
const usage = key.usage?.total
|
||||
if (usage) {
|
||||
totalTokensUsed += usage.allTokens || 0
|
||||
totalRequestsUsed += usage.requests || 0
|
||||
totalInputTokensUsed += usage.inputTokens || 0
|
||||
totalOutputTokensUsed += usage.outputTokens || 0
|
||||
totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0
|
||||
totalCacheReadTokensUsed += usage.cacheReadTokens || 0
|
||||
totalAllTokensUsed += usage.allTokens || 0
|
||||
}
|
||||
if (key.isActive) {
|
||||
activeApiKeys++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +187,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
|
||||
const dashboard = {
|
||||
overview: {
|
||||
totalApiKeys: apiKeys.length,
|
||||
totalApiKeys,
|
||||
activeApiKeys,
|
||||
// 总账户统计(所有平台)
|
||||
totalAccounts:
|
||||
|
||||
@@ -196,31 +196,56 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||
|
||||
// 处理统计数据
|
||||
const allUsageStatsMap = new Map()
|
||||
const parseUsage = (data) => ({
|
||||
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
||||
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
||||
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
||||
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
||||
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
||||
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
||||
allTokens:
|
||||
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
||||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
||||
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
||||
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
||||
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
||||
})
|
||||
|
||||
// 构建 accountId -> createdAt 映射用于计算 averages
|
||||
const accountCreatedAtMap = new Map()
|
||||
for (const account of accounts) {
|
||||
accountCreatedAtMap.set(
|
||||
account.id,
|
||||
account.createdAt ? new Date(account.createdAt) : new Date()
|
||||
)
|
||||
}
|
||||
|
||||
for (let i = 0; i < accountIds.length; i++) {
|
||||
const accountId = accountIds[i]
|
||||
const [errTotal, total] = statsResults[i * 3]
|
||||
const [errDaily, daily] = statsResults[i * 3 + 1]
|
||||
const [errMonthly, monthly] = statsResults[i * 3 + 2]
|
||||
|
||||
const parseUsage = (data) => ({
|
||||
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
||||
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
||||
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
||||
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
||||
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
||||
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
||||
allTokens:
|
||||
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
||||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
||||
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
||||
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
||||
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
||||
})
|
||||
const totalData = errTotal ? {} : parseUsage(total)
|
||||
const totalTokens = totalData.tokens || 0
|
||||
const totalRequests = totalData.requests || 0
|
||||
|
||||
// 计算 averages
|
||||
const createdAt = accountCreatedAtMap.get(accountId)
|
||||
const now = new Date()
|
||||
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)))
|
||||
const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60)
|
||||
|
||||
allUsageStatsMap.set(accountId, {
|
||||
total: errTotal ? {} : parseUsage(total),
|
||||
total: totalData,
|
||||
daily: errDaily ? {} : parseUsage(daily),
|
||||
monthly: errMonthly ? {} : parseUsage(monthly)
|
||||
monthly: errMonthly ? {} : parseUsage(monthly),
|
||||
averages: {
|
||||
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
|
||||
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100,
|
||||
dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100,
|
||||
dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -230,7 +255,8 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||
const usageStats = allUsageStatsMap.get(account.id) || {
|
||||
daily: { tokens: 0, requests: 0 },
|
||||
total: { tokens: 0, requests: 0 },
|
||||
monthly: { tokens: 0, requests: 0 }
|
||||
monthly: { tokens: 0, requests: 0 },
|
||||
averages: { rpm: 0, tpm: 0, dailyRequests: 0, dailyTokens: 0 }
|
||||
}
|
||||
const dailyCost = dailyCostMap.get(account.id) || 0
|
||||
|
||||
@@ -249,7 +275,8 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
||||
usage: {
|
||||
daily: { ...usageStats.daily, cost: dailyCost },
|
||||
total: usageStats.total,
|
||||
monthly: usageStats.monthly
|
||||
monthly: usageStats.monthly,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -574,4 +601,92 @@ router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req,
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 Droid 账户连通性
|
||||
router.post('/droid-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { model = 'claude-sonnet-4-20250514' } = req.body
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await droidAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 确保 token 有效
|
||||
const tokenResult = await droidAccountService.ensureValidToken(accountId)
|
||||
if (!tokenResult.success) {
|
||||
return res.status(401).json({
|
||||
error: 'Token refresh failed',
|
||||
message: tokenResult.error
|
||||
})
|
||||
}
|
||||
|
||||
const accessToken = tokenResult.accessToken
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const apiUrl = 'https://api.factory.ai/v1/messages'
|
||||
const payload = {
|
||||
model,
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Say "Hello" in one word.' }]
|
||||
}
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.content?.[0]?.text) {
|
||||
responseText = response.data.content[0].text
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Droid account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ Droid account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -491,4 +491,89 @@ router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 Gemini 账户连通性
|
||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { model = 'gemini-2.5-flash' } = req.body
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 确保 token 有效
|
||||
const tokenResult = await geminiAccountService.ensureValidToken(accountId)
|
||||
if (!tokenResult.success) {
|
||||
return res.status(401).json({
|
||||
error: 'Token refresh failed',
|
||||
message: tokenResult.error
|
||||
})
|
||||
}
|
||||
|
||||
const accessToken = tokenResult.accessToken
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { createGeminiTestPayload } = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`
|
||||
const payload = createGeminiTestPayload(model)
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.candidates?.[0]?.content?.parts?.[0]?.text) {
|
||||
responseText = response.data.candidates[0].content.parts[0].text
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Gemini account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ Gemini account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -25,6 +25,8 @@ const systemRoutes = require('./system')
|
||||
const concurrencyRoutes = require('./concurrency')
|
||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||
const syncRoutes = require('./sync')
|
||||
const serviceRatesRoutes = require('./serviceRates')
|
||||
const quotaCardsRoutes = require('./quotaCards')
|
||||
|
||||
// 挂载所有子路由
|
||||
// 使用完整路径的模块(直接挂载到根路径)
|
||||
@@ -41,6 +43,8 @@ router.use('/', systemRoutes)
|
||||
router.use('/', concurrencyRoutes)
|
||||
router.use('/', claudeRelayConfigRoutes)
|
||||
router.use('/', syncRoutes)
|
||||
router.use('/', serviceRatesRoutes)
|
||||
router.use('/', quotaCardsRoutes)
|
||||
|
||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||
router.use('/account-groups', accountGroupsRoutes)
|
||||
|
||||
@@ -452,4 +452,85 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 OpenAI-Responses 账户连通性
|
||||
router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { model = 'gpt-4o-mini' } = req.body
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await openaiResponsesAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 获取解密后的 API Key
|
||||
const apiKey = await openaiResponsesAccountService.getDecryptedApiKey(accountId)
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ error: 'API Key not found or decryption failed' })
|
||||
}
|
||||
|
||||
// 构造测试请求
|
||||
const axios = require('axios')
|
||||
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
|
||||
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||
|
||||
const baseUrl = account.baseUrl || 'https://api.openai.com'
|
||||
const apiUrl = `${baseUrl}/v1/chat/completions`
|
||||
const payload = createOpenAITestPayload(model)
|
||||
|
||||
const requestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
|
||||
// 配置代理
|
||||
if (account.proxy) {
|
||||
const agent = getProxyAgent(account.proxy)
|
||||
if (agent) {
|
||||
requestConfig.httpsAgent = agent
|
||||
requestConfig.httpAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
// 提取响应文本
|
||||
let responseText = ''
|
||||
if (response.data?.choices?.[0]?.message?.content) {
|
||||
responseText = response.data.choices[0].message.content
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ OpenAI-Responses account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||
)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
accountName: account.name,
|
||||
model,
|
||||
latency,
|
||||
responseText: responseText.substring(0, 200)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
logger.error(`❌ OpenAI-Responses account test failed: ${accountId}`, error.message)
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Test failed',
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
latency
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
329
src/routes/admin/quotaCards.js
Normal file
329
src/routes/admin/quotaCards.js
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* 额度卡/时间卡管理路由
|
||||
*/
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const quotaCardService = require('../../services/quotaCardService')
|
||||
const apiKeyService = require('../../services/apiKeyService')
|
||||
const logger = require('../../utils/logger')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 额度卡管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 获取额度卡列表
|
||||
router.get('/quota-cards', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { status, limit = 100, offset = 0 } = req.query
|
||||
const result = await quotaCardService.getAllCards({
|
||||
status,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get quota cards:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取额度卡统计
|
||||
router.get('/quota-cards/stats', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const stats = await quotaCardService.getCardStats()
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get quota card stats:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取单个额度卡详情
|
||||
router.get('/quota-cards/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const card = await quotaCardService.getCardById(req.params.id)
|
||||
if (!card) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Card not found'
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: card
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get quota card:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 创建额度卡
|
||||
router.post('/quota-cards', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { type, quotaAmount, timeAmount, timeUnit, expiresAt, note, count = 1 } = req.body
|
||||
|
||||
if (!type) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'type is required'
|
||||
})
|
||||
}
|
||||
|
||||
const createdBy = req.session?.username || 'admin'
|
||||
const options = {
|
||||
type,
|
||||
quotaAmount: parseFloat(quotaAmount || 0),
|
||||
timeAmount: parseInt(timeAmount || 0),
|
||||
timeUnit: timeUnit || 'days',
|
||||
expiresAt,
|
||||
note,
|
||||
createdBy
|
||||
}
|
||||
|
||||
let result
|
||||
if (count > 1) {
|
||||
result = await quotaCardService.createCardsBatch(options, Math.min(count, 100))
|
||||
} else {
|
||||
result = await quotaCardService.createCard(options)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create quota card:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 删除未使用的额度卡
|
||||
router.delete('/quota-cards/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const result = await quotaCardService.deleteCard(req.params.id)
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete quota card:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 核销记录管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 获取核销记录列表
|
||||
router.get('/redemptions', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { userId, apiKeyId, limit = 100, offset = 0 } = req.query
|
||||
const result = await quotaCardService.getRedemptions({
|
||||
userId,
|
||||
apiKeyId,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get redemptions:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 撤销核销
|
||||
router.post('/redemptions/:id/revoke', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { reason } = req.body
|
||||
const revokedBy = req.session?.username || 'admin'
|
||||
|
||||
const result = await quotaCardService.revokeRedemption(req.params.id, revokedBy, reason)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to revoke redemption:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// API Key 聚合类型转换
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 获取转换预览
|
||||
router.get('/api-keys/:id/convert-preview', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const preview = await apiKeyService.getConvertToAggregatedPreview(req.params.id)
|
||||
res.json({
|
||||
success: true,
|
||||
data: preview
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get convert preview:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 执行转换
|
||||
router.post('/api-keys/:id/convert-to-aggregated', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { quotaLimit, permissions, serviceQuotaLimits, quotaUsed } = req.body
|
||||
|
||||
if (quotaLimit === undefined || quotaLimit === null) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'quotaLimit is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (!permissions || !Array.isArray(permissions) || permissions.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'permissions must be a non-empty array'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await apiKeyService.convertToAggregated(req.params.id, {
|
||||
quotaLimit: parseFloat(quotaLimit),
|
||||
permissions,
|
||||
serviceQuotaLimits: serviceQuotaLimits || {},
|
||||
quotaUsed: quotaUsed !== undefined ? parseFloat(quotaUsed) : null
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to convert to aggregated:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 手动增加额度
|
||||
router.post('/api-keys/:id/add-quota', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { quotaAmount } = req.body
|
||||
|
||||
if (!quotaAmount || quotaAmount <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'quotaAmount must be a positive number'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await apiKeyService.addQuota(req.params.id, parseFloat(quotaAmount))
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to add quota:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 手动减少额度
|
||||
router.post('/api-keys/:id/deduct-quota', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { quotaAmount } = req.body
|
||||
|
||||
if (!quotaAmount || quotaAmount <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'quotaAmount must be a positive number'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await apiKeyService.deductQuotaLimit(req.params.id, parseFloat(quotaAmount))
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to deduct quota:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 延长有效期
|
||||
router.post('/api-keys/:id/extend-expiry', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { amount, unit = 'days' } = req.body
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'amount must be a positive number'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await apiKeyService.extendExpiry(req.params.id, parseInt(amount), unit)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to extend expiry:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
72
src/routes/admin/serviceRates.js
Normal file
72
src/routes/admin/serviceRates.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 服务倍率配置管理路由
|
||||
*/
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const serviceRatesService = require('../../services/serviceRatesService')
|
||||
const logger = require('../../utils/logger')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
|
||||
// 获取服务倍率配置
|
||||
router.get('/service-rates', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const rates = await serviceRatesService.getRates()
|
||||
res.json({
|
||||
success: true,
|
||||
data: rates
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get service rates:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 更新服务倍率配置
|
||||
router.put('/service-rates', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { rates, baseService } = req.body
|
||||
|
||||
if (!rates || typeof rates !== 'object') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'rates is required and must be an object'
|
||||
})
|
||||
}
|
||||
|
||||
const updatedBy = req.session?.username || 'admin'
|
||||
const result = await serviceRatesService.saveRates({ rates, baseService }, updatedBy)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update service rates:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 获取可用服务列表
|
||||
router.get('/service-rates/services', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const services = await serviceRatesService.getAvailableServices()
|
||||
res.json({
|
||||
success: true,
|
||||
data: services
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get available services:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -267,6 +267,11 @@ router.get('/oem-settings', async (req, res) => {
|
||||
siteIcon: '',
|
||||
siteIconData: '', // Base64编码的图标数据
|
||||
showAdminButton: true, // 是否显示管理后台按钮
|
||||
apiStatsNotice: {
|
||||
enabled: false,
|
||||
title: '',
|
||||
content: ''
|
||||
},
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -296,7 +301,7 @@ router.get('/oem-settings', async (req, res) => {
|
||||
// 更新OEM设置
|
||||
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
|
||||
const { siteName, siteIcon, siteIconData, showAdminButton, apiStatsNotice } = req.body
|
||||
|
||||
// 验证输入
|
||||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||
@@ -328,6 +333,11 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
siteIcon: (siteIcon || '').trim(),
|
||||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||
showAdminButton: showAdminButton !== false, // 默认为true
|
||||
apiStatsNotice: {
|
||||
enabled: apiStatsNotice?.enabled === true,
|
||||
title: (apiStatsNotice?.title || '').trim().slice(0, 100),
|
||||
content: (apiStatsNotice?.content || '').trim().slice(0, 2000)
|
||||
},
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,14 @@ async function getUsageDataByIndex(indexKey, keyPattern, scanPattern) {
|
||||
return `${match[1]}:${match[2]}`
|
||||
}
|
||||
}
|
||||
// 通用格式:提取最后一个 : 前的 id
|
||||
// 通用格式:根据 keyPattern 中 {id} 的位置提取 id
|
||||
const patternParts = keyPattern.split(':')
|
||||
const idIndex = patternParts.findIndex((p) => p === '{id}')
|
||||
if (idIndex !== -1) {
|
||||
const parts = k.split(':')
|
||||
return parts[idIndex]
|
||||
}
|
||||
// 回退:提取最后一个 : 前的 id
|
||||
const parts = k.split(':')
|
||||
return parts[parts.length - 2]
|
||||
})
|
||||
|
||||
@@ -5,10 +5,38 @@ const apiKeyService = require('../services/apiKeyService')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const serviceRatesService = require('../services/serviceRatesService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
const modelsConfig = require('../../config/models')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// 📋 获取可用模型列表(公开接口)
|
||||
router.get('/models', (req, res) => {
|
||||
const { service } = req.query
|
||||
|
||||
if (service) {
|
||||
// 返回指定服务的模型
|
||||
const models = modelsConfig.getModelsByService(service)
|
||||
return res.json({
|
||||
success: true,
|
||||
data: models
|
||||
})
|
||||
}
|
||||
|
||||
// 返回所有模型(按服务分组)
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
claude: modelsConfig.CLAUDE_MODELS,
|
||||
gemini: modelsConfig.GEMINI_MODELS,
|
||||
openai: modelsConfig.OPENAI_MODELS,
|
||||
other: modelsConfig.OTHER_MODELS,
|
||||
all: modelsConfig.getAllModels()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 🏠 重定向页面请求到新版 admin-spa
|
||||
router.get('/', (req, res) => {
|
||||
res.redirect(301, '/admin-next/api-stats')
|
||||
@@ -800,13 +828,19 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// maxTokens 白名单
|
||||
const ALLOWED_MAX_TOKENS = [100, 500, 1000, 2000, 4096]
|
||||
const sanitizeMaxTokens = (value) =>
|
||||
ALLOWED_MAX_TOKENS.includes(Number(value)) ? Number(value) : 1000
|
||||
|
||||
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
|
||||
router.post('/api-key/test', async (req, res) => {
|
||||
const config = require('../../config/config')
|
||||
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
|
||||
const { apiKey, model = 'claude-sonnet-4-5-20250929', prompt = 'hi' } = req.body
|
||||
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({
|
||||
@@ -839,7 +873,7 @@ router.post('/api-key/test', async (req, res) => {
|
||||
apiUrl,
|
||||
authorization: apiKey,
|
||||
responseStream: res,
|
||||
payload: createClaudeTestPayload(model, { stream: true }),
|
||||
payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }),
|
||||
timeout: 60000,
|
||||
extraHeaders: { 'x-api-key': apiKey }
|
||||
})
|
||||
@@ -860,6 +894,318 @@ router.post('/api-key/test', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 🧪 Gemini API Key 端点测试接口
|
||||
router.post('/api-key/test-gemini', async (req, res) => {
|
||||
const config = require('../../config/config')
|
||||
const { createGeminiTestPayload } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const { apiKey, model = 'gemini-2.5-pro', prompt = 'hi' } = req.body
|
||||
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({
|
||||
error: 'API Key is required',
|
||||
message: 'Please provide your API Key'
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key format',
|
||||
message: 'API key format is invalid'
|
||||
})
|
||||
}
|
||||
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
if (!validation.valid) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: validation.error
|
||||
})
|
||||
}
|
||||
|
||||
// 检查 Gemini 权限
|
||||
const permissions = validation.keyData.permissions || 'all'
|
||||
if (permissions !== 'all' && !permissions.includes('gemini')) {
|
||||
return res.status(403).json({
|
||||
error: 'Permission denied',
|
||||
message: 'This API key does not have Gemini permission'
|
||||
})
|
||||
}
|
||||
|
||||
logger.api(
|
||||
`🧪 Gemini API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`
|
||||
)
|
||||
|
||||
const port = config.server.port || 3000
|
||||
const apiUrl = `http://127.0.0.1:${port}/gemini/v1/models/${model}:streamGenerateContent?alt=sse`
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
})
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
||||
|
||||
const axios = require('axios')
|
||||
const payload = createGeminiTestPayload(model, { prompt, maxTokens })
|
||||
|
||||
try {
|
||||
const response = await axios.post(apiUrl, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey
|
||||
},
|
||||
timeout: 60000,
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
const chunks = []
|
||||
response.data.on('data', (chunk) => chunks.push(chunk))
|
||||
response.data.on('end', () => {
|
||||
const errorData = Buffer.concat(chunks).toString()
|
||||
let errorMsg = `API Error: ${response.status}`
|
||||
try {
|
||||
const json = JSON.parse(errorData)
|
||||
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||
} catch {
|
||||
if (errorData.length < 200) {
|
||||
errorMsg = errorData || errorMsg
|
||||
}
|
||||
}
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
response.data.on('data', (chunk) => {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) {
|
||||
continue
|
||||
}
|
||||
const jsonStr = line.substring(5).trim()
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
// Gemini 格式: candidates[0].content.parts[0].text
|
||||
const text = data.candidates?.[0]?.content?.parts?.[0]?.text
|
||||
if (text) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||
res.end()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: err.message })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
} catch (axiosError) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: axiosError.message })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Gemini API Key test failed:', error)
|
||||
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: 'Test failed',
|
||||
message: error.message || 'Internal server error'
|
||||
})
|
||||
}
|
||||
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
// 🧪 OpenAI/Codex API Key 端点测试接口
|
||||
router.post('/api-key/test-openai', async (req, res) => {
|
||||
const config = require('../../config/config')
|
||||
const { createOpenAITestPayload } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const { apiKey, model = 'gpt-5', prompt = 'hi' } = req.body
|
||||
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({
|
||||
error: 'API Key is required',
|
||||
message: 'Please provide your API Key'
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key format',
|
||||
message: 'API key format is invalid'
|
||||
})
|
||||
}
|
||||
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
if (!validation.valid) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: validation.error
|
||||
})
|
||||
}
|
||||
|
||||
// 检查 OpenAI 权限
|
||||
const permissions = validation.keyData.permissions || 'all'
|
||||
if (permissions !== 'all' && !permissions.includes('openai')) {
|
||||
return res.status(403).json({
|
||||
error: 'Permission denied',
|
||||
message: 'This API key does not have OpenAI permission'
|
||||
})
|
||||
}
|
||||
|
||||
logger.api(
|
||||
`🧪 OpenAI API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`
|
||||
)
|
||||
|
||||
const port = config.server.port || 3000
|
||||
const apiUrl = `http://127.0.0.1:${port}/openai/responses`
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
})
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
||||
|
||||
const axios = require('axios')
|
||||
const payload = createOpenAITestPayload(model, { prompt, maxTokens })
|
||||
|
||||
try {
|
||||
const response = await axios.post(apiUrl, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'User-Agent': 'codex_cli_rs/1.0.0'
|
||||
},
|
||||
timeout: 60000,
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
const chunks = []
|
||||
response.data.on('data', (chunk) => chunks.push(chunk))
|
||||
response.data.on('end', () => {
|
||||
const errorData = Buffer.concat(chunks).toString()
|
||||
let errorMsg = `API Error: ${response.status}`
|
||||
try {
|
||||
const json = JSON.parse(errorData)
|
||||
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||
} catch {
|
||||
if (errorData.length < 200) {
|
||||
errorMsg = errorData || errorMsg
|
||||
}
|
||||
}
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
response.data.on('data', (chunk) => {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) {
|
||||
continue
|
||||
}
|
||||
const jsonStr = line.substring(5).trim()
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
// OpenAI Responses 格式: output[].content[].text 或 delta
|
||||
if (data.type === 'response.output_text.delta' && data.delta) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta })}\n\n`)
|
||||
} else if (data.type === 'response.content_part.delta' && data.delta?.text) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||
res.end()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: err.message })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
})
|
||||
} catch (axiosError) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: axiosError.message })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ OpenAI API Key test failed:', error)
|
||||
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: 'Test failed',
|
||||
message: error.message || 'Internal server error'
|
||||
})
|
||||
}
|
||||
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 用户模型统计查询接口 - 安全的自查询接口
|
||||
router.post('/api/user-model-stats', async (req, res) => {
|
||||
try {
|
||||
@@ -946,20 +1292,25 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
const today = redis.getDateStringInTimezone()
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
const pattern =
|
||||
period === 'daily'
|
||||
? `usage:${keyId}:model:daily:*:${today}`
|
||||
: `usage:${keyId}:model:monthly:*:${currentMonth}`
|
||||
let pattern
|
||||
let matchRegex
|
||||
if (period === 'daily') {
|
||||
pattern = `usage:${keyId}:model:daily:*:${today}`
|
||||
matchRegex = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||
} else if (period === 'alltime') {
|
||||
pattern = `usage:${keyId}:model:alltime:*`
|
||||
matchRegex = /usage:.+:model:alltime:(.+)$/
|
||||
} else {
|
||||
// monthly
|
||||
pattern = `usage:${keyId}:model:monthly:*:${currentMonth}`
|
||||
matchRegex = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||||
}
|
||||
|
||||
const results = await redis.scanAndGetAllChunked(pattern)
|
||||
const modelStats = []
|
||||
|
||||
for (const { key, data } of results) {
|
||||
const match = key.match(
|
||||
period === 'daily'
|
||||
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||
: /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||||
)
|
||||
const match = key.match(matchRegex)
|
||||
|
||||
if (!match) {
|
||||
continue
|
||||
@@ -977,6 +1328,15 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
|
||||
const costData = CostCalculator.calculateCost(usage, model)
|
||||
|
||||
// alltime 键不存储 allTokens,需要计算
|
||||
const allTokens =
|
||||
period === 'alltime'
|
||||
? usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_creation_input_tokens +
|
||||
usage.cache_read_input_tokens
|
||||
: parseInt(data.allTokens) || 0
|
||||
|
||||
modelStats.push({
|
||||
model,
|
||||
requests: parseInt(data.requests) || 0,
|
||||
@@ -984,7 +1344,7 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||
cacheReadTokens: usage.cache_read_input_tokens,
|
||||
allTokens: parseInt(data.allTokens) || 0,
|
||||
allTokens,
|
||||
costs: costData.costs,
|
||||
formatted: costData.formatted,
|
||||
pricing: costData.pricing
|
||||
@@ -1015,4 +1375,21 @@ router.post('/api/user-model-stats', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 获取服务倍率配置(公开接口)
|
||||
router.get('/service-rates', async (req, res) => {
|
||||
try {
|
||||
const rates = await serviceRatesService.getRates()
|
||||
res.json({
|
||||
success: true,
|
||||
data: rates
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get service rates:', error)
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve service rates'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -9,6 +9,7 @@ const openaiAccountService = require('../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
|
||||
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const redis = require('../models/redis')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
@@ -857,16 +858,18 @@ router.post('/v1/responses/compact', authenticateApiKey, handleResponses)
|
||||
// 使用情况统计端点
|
||||
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const { usage } = req.apiKey
|
||||
const keyData = req.apiKey
|
||||
// 按需查询 usage 数据
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
|
||||
res.json({
|
||||
object: 'usage',
|
||||
total_tokens: usage.total.tokens,
|
||||
total_requests: usage.total.requests,
|
||||
daily_tokens: usage.daily.tokens,
|
||||
daily_requests: usage.daily.requests,
|
||||
monthly_tokens: usage.monthly.tokens,
|
||||
monthly_requests: usage.monthly.requests
|
||||
total_tokens: usage?.total?.tokens || 0,
|
||||
total_requests: usage?.total?.requests || 0,
|
||||
daily_tokens: usage?.daily?.tokens || 0,
|
||||
daily_requests: usage?.daily?.requests || 0,
|
||||
monthly_tokens: usage?.monthly?.tokens || 0,
|
||||
monthly_requests: usage?.monthly?.requests || 0
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage stats:', error)
|
||||
@@ -883,25 +886,26 @@ router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||
router.get('/key-info', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const keyData = req.apiKey
|
||||
// 按需查询 usage 数据(仅 key-info 端点需要)
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
const tokensUsed = usage?.total?.tokens || 0
|
||||
res.json({
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
description: keyData.description,
|
||||
permissions: keyData.permissions || 'all',
|
||||
token_limit: keyData.tokenLimit,
|
||||
tokens_used: keyData.usage.total.tokens,
|
||||
tokens_used: tokensUsed,
|
||||
tokens_remaining:
|
||||
keyData.tokenLimit > 0
|
||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
||||
: null,
|
||||
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
|
||||
rate_limit: {
|
||||
window: keyData.rateLimitWindow,
|
||||
requests: keyData.rateLimitRequests
|
||||
},
|
||||
usage: {
|
||||
total: keyData.usage.total,
|
||||
daily: keyData.usage.daily,
|
||||
monthly: keyData.usage.monthly
|
||||
total: usage?.total || {},
|
||||
daily: usage?.daily || {},
|
||||
monthly: usage?.monthly || {}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -761,4 +761,166 @@ router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req
|
||||
}
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 额度卡核销相关路由
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const quotaCardService = require('../services/quotaCardService')
|
||||
|
||||
// 🎫 核销额度卡
|
||||
router.post('/redeem-card', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { code, apiKeyId } = req.body
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing card code',
|
||||
message: 'Card code is required'
|
||||
})
|
||||
}
|
||||
|
||||
if (!apiKeyId) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing API key ID',
|
||||
message: 'API key ID is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 API Key 属于当前用户
|
||||
const keyData = await redis.getApiKey(apiKeyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'The specified API key does not exist'
|
||||
})
|
||||
}
|
||||
|
||||
if (keyData.userId !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: 'You can only redeem cards to your own API keys'
|
||||
})
|
||||
}
|
||||
|
||||
// 执行核销
|
||||
const result = await quotaCardService.redeemCard(code, apiKeyId, req.user.id, req.user.username)
|
||||
|
||||
logger.success(`🎫 User ${req.user.username} redeemed card ${code} to key ${apiKeyId}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Redeem card error:', error)
|
||||
res.status(400).json({
|
||||
error: 'Redeem failed',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📋 获取用户的核销历史
|
||||
router.get('/redemption-history', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, offset = 0 } = req.query
|
||||
|
||||
const result = await quotaCardService.getRedemptions({
|
||||
userId: req.user.id,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get redemption history error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Failed to get redemption history',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 📊 获取用户的额度信息
|
||||
router.get('/quota-info', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { apiKeyId } = req.query
|
||||
|
||||
if (!apiKeyId) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing API key ID',
|
||||
message: 'API key ID is required'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 API Key 属于当前用户
|
||||
const keyData = await redis.getApiKey(apiKeyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'The specified API key does not exist'
|
||||
})
|
||||
}
|
||||
|
||||
if (keyData.userId !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: 'You can only view your own API key quota'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否为聚合 Key
|
||||
if (keyData.isAggregated !== 'true') {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isAggregated: false,
|
||||
message: 'This is a traditional API key, not using quota system'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 解析聚合 Key 数据
|
||||
let permissions = []
|
||||
let serviceQuotaLimits = {}
|
||||
let serviceQuotaUsed = {}
|
||||
|
||||
try {
|
||||
permissions = JSON.parse(keyData.permissions || '[]')
|
||||
} catch (e) {
|
||||
permissions = [keyData.permissions]
|
||||
}
|
||||
|
||||
try {
|
||||
serviceQuotaLimits = JSON.parse(keyData.serviceQuotaLimits || '{}')
|
||||
serviceQuotaUsed = JSON.parse(keyData.serviceQuotaUsed || '{}')
|
||||
} catch (e) {
|
||||
// 解析失败使用默认值
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isAggregated: true,
|
||||
quotaLimit: parseFloat(keyData.quotaLimit || 0),
|
||||
quotaUsed: parseFloat(keyData.quotaUsed || 0),
|
||||
quotaRemaining: parseFloat(keyData.quotaLimit || 0) - parseFloat(keyData.quotaUsed || 0),
|
||||
permissions,
|
||||
serviceQuotaLimits,
|
||||
serviceQuotaUsed,
|
||||
expiresAt: keyData.expiresAt
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Get quota info error:', error)
|
||||
res.status(500).json({
|
||||
error: 'Failed to get quota info',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -567,6 +567,34 @@ class AccountGroupService {
|
||||
}
|
||||
}
|
||||
|
||||
// 对于反向索引为空的账户,单独查询并补建索引(处理部分缺失情况)
|
||||
const emptyIndexAccountIds = []
|
||||
for (const accountId of accountIds) {
|
||||
const ids = accountGroupIdsMap.get(accountId) || []
|
||||
if (ids.length === 0) {
|
||||
emptyIndexAccountIds.push(accountId)
|
||||
}
|
||||
}
|
||||
if (emptyIndexAccountIds.length > 0 && emptyIndexAccountIds.length < accountIds.length) {
|
||||
// 部分账户索引缺失,逐个查询并补建
|
||||
for (const accountId of emptyIndexAccountIds) {
|
||||
try {
|
||||
const groups = await this.getAccountGroups(accountId)
|
||||
if (groups.length > 0) {
|
||||
const groupIds = groups.map((g) => g.id)
|
||||
accountGroupIdsMap.set(accountId, groupIds)
|
||||
groupIds.forEach((id) => uniqueGroupIds.add(id))
|
||||
// 异步补建反向索引
|
||||
client
|
||||
.sadd(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`, ...groupIds)
|
||||
.catch(() => {})
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误,保持空数组
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量获取分组详情
|
||||
const groupDetailsMap = new Map()
|
||||
if (uniqueGroupIds.size > 0) {
|
||||
|
||||
@@ -3,6 +3,7 @@ const { v4: uuidv4 } = require('uuid')
|
||||
const config = require('../../config/config')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const serviceRatesService = require('./serviceRatesService')
|
||||
|
||||
const ACCOUNT_TYPE_CONFIG = {
|
||||
claude: { prefix: 'claude:account:' },
|
||||
@@ -89,7 +90,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId = null,
|
||||
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
||||
droidAccountId = null,
|
||||
permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all'
|
||||
permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all',聚合Key为数组
|
||||
isActive = true,
|
||||
concurrencyLimit = 0,
|
||||
rateLimitWindow = null,
|
||||
@@ -106,7 +107,13 @@ class ApiKeyService {
|
||||
activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
|
||||
activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days'
|
||||
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
||||
icon = '' // 新增:图标(base64编码)
|
||||
icon = '', // 新增:图标(base64编码)
|
||||
// 聚合 Key 相关字段
|
||||
isAggregated = false, // 是否为聚合 Key
|
||||
quotaLimit = 0, // CC 额度上限(聚合 Key 使用)
|
||||
quotaUsed = 0, // 已消耗 CC 额度
|
||||
serviceQuotaLimits = {}, // 分服务额度限制 { claude: 50, codex: 30 }
|
||||
serviceQuotaUsed = {} // 分服务已消耗额度
|
||||
} = options
|
||||
|
||||
// 生成简单的API Key (64字符十六进制)
|
||||
@@ -114,6 +121,16 @@ class ApiKeyService {
|
||||
const keyId = uuidv4()
|
||||
const hashedKey = this._hashApiKey(apiKey)
|
||||
|
||||
// 处理 permissions:聚合 Key 使用数组,传统 Key 使用字符串
|
||||
let permissionsValue = permissions
|
||||
if (isAggregated && !Array.isArray(permissions)) {
|
||||
// 聚合 Key 但 permissions 不是数组,转换为数组
|
||||
permissionsValue =
|
||||
permissions === 'all'
|
||||
? ['claude', 'codex', 'gemini', 'droid', 'bedrock', 'azure', 'ccr']
|
||||
: [permissions]
|
||||
}
|
||||
|
||||
const keyData = {
|
||||
id: keyId,
|
||||
name,
|
||||
@@ -132,7 +149,9 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
||||
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
||||
droidAccountId: droidAccountId || '',
|
||||
permissions: permissions || 'all',
|
||||
permissions: Array.isArray(permissionsValue)
|
||||
? JSON.stringify(permissionsValue)
|
||||
: permissionsValue || 'all',
|
||||
enableModelRestriction: String(enableModelRestriction),
|
||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||
enableClientRestriction: String(enableClientRestriction || false),
|
||||
@@ -152,7 +171,13 @@ class ApiKeyService {
|
||||
createdBy: options.createdBy || 'admin',
|
||||
userId: options.userId || '',
|
||||
userUsername: options.userUsername || '',
|
||||
icon: icon || '' // 新增:图标(base64编码)
|
||||
icon: icon || '', // 新增:图标(base64编码)
|
||||
// 聚合 Key 相关字段
|
||||
isAggregated: String(isAggregated),
|
||||
quotaLimit: String(quotaLimit || 0),
|
||||
quotaUsed: String(quotaUsed || 0),
|
||||
serviceQuotaLimits: JSON.stringify(serviceQuotaLimits || {}),
|
||||
serviceQuotaUsed: JSON.stringify(serviceQuotaUsed || {})
|
||||
}
|
||||
|
||||
// 保存API Key数据并建立哈希映射
|
||||
@@ -182,7 +207,17 @@ class ApiKeyService {
|
||||
logger.warn(`Failed to add key ${keyId} to API Key index:`, err.message)
|
||||
}
|
||||
|
||||
logger.success(`🔑 Generated new API key: ${name} (${keyId})`)
|
||||
logger.success(
|
||||
`🔑 Generated new API key: ${name} (${keyId})${isAggregated ? ' [Aggregated]' : ''}`
|
||||
)
|
||||
|
||||
// 解析 permissions 用于返回
|
||||
let parsedPermissions = keyData.permissions
|
||||
try {
|
||||
parsedPermissions = JSON.parse(keyData.permissions)
|
||||
} catch (e) {
|
||||
// 不是 JSON,保持原值(传统 Key 的字符串格式)
|
||||
}
|
||||
|
||||
return {
|
||||
id: keyId,
|
||||
@@ -202,7 +237,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||
droidAccountId: keyData.droidAccountId,
|
||||
permissions: keyData.permissions,
|
||||
permissions: parsedPermissions,
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
@@ -218,7 +253,13 @@ class ApiKeyService {
|
||||
activatedAt: keyData.activatedAt,
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
createdBy: keyData.createdBy
|
||||
createdBy: keyData.createdBy,
|
||||
// 聚合 Key 相关字段
|
||||
isAggregated: keyData.isAggregated === 'true',
|
||||
quotaLimit: parseFloat(keyData.quotaLimit || 0),
|
||||
quotaUsed: parseFloat(keyData.quotaUsed || 0),
|
||||
serviceQuotaLimits: JSON.parse(keyData.serviceQuotaLimits || '{}'),
|
||||
serviceQuotaUsed: JSON.parse(keyData.serviceQuotaUsed || '{}')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,15 +341,26 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取使用统计(供返回数据使用)
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
// 按需获取费用统计(仅在有限制时查询,减少 Redis 调用)
|
||||
const dailyCostLimit = parseFloat(keyData.dailyCostLimit || 0)
|
||||
const totalCostLimit = parseFloat(keyData.totalCostLimit || 0)
|
||||
const weeklyOpusCostLimit = parseFloat(keyData.weeklyOpusCostLimit || 0)
|
||||
|
||||
// 获取费用统计
|
||||
const [dailyCost, costStats] = await Promise.all([
|
||||
redis.getDailyCost(keyData.id),
|
||||
redis.getCostStats(keyData.id)
|
||||
])
|
||||
const totalCost = costStats?.total || 0
|
||||
const costQueries = []
|
||||
if (dailyCostLimit > 0) {
|
||||
costQueries.push(redis.getDailyCost(keyData.id).then((v) => ({ dailyCost: v || 0 })))
|
||||
}
|
||||
if (totalCostLimit > 0) {
|
||||
costQueries.push(redis.getCostStats(keyData.id).then((v) => ({ totalCost: v?.total || 0 })))
|
||||
}
|
||||
if (weeklyOpusCostLimit > 0) {
|
||||
costQueries.push(
|
||||
redis.getWeeklyOpusCost(keyData.id).then((v) => ({ weeklyOpusCost: v || 0 }))
|
||||
)
|
||||
}
|
||||
|
||||
const costData =
|
||||
costQueries.length > 0 ? Object.assign({}, ...(await Promise.all(costQueries))) : {}
|
||||
|
||||
// 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时)
|
||||
// 注意:lastUsedAt的更新已移至recordUsage方法中
|
||||
@@ -339,6 +391,26 @@ class ApiKeyService {
|
||||
tags = []
|
||||
}
|
||||
|
||||
// 解析 permissions(聚合 Key 为数组,传统 Key 为字符串)
|
||||
let permissions = keyData.permissions || 'all'
|
||||
try {
|
||||
permissions = JSON.parse(keyData.permissions)
|
||||
} catch (e) {
|
||||
// 不是 JSON,保持原值
|
||||
}
|
||||
|
||||
// 解析聚合 Key 相关字段
|
||||
let serviceQuotaLimits = {}
|
||||
let serviceQuotaUsed = {}
|
||||
try {
|
||||
serviceQuotaLimits = keyData.serviceQuotaLimits
|
||||
? JSON.parse(keyData.serviceQuotaLimits)
|
||||
: {}
|
||||
serviceQuotaUsed = keyData.serviceQuotaUsed ? JSON.parse(keyData.serviceQuotaUsed) : {}
|
||||
} catch (e) {
|
||||
// 解析失败使用默认值
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
keyData: {
|
||||
@@ -354,7 +426,7 @@ class ApiKeyService {
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||
droidAccountId: keyData.droidAccountId,
|
||||
permissions: keyData.permissions || 'all',
|
||||
permissions,
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||
@@ -364,14 +436,19 @@ class ApiKeyService {
|
||||
restrictedModels,
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
||||
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
||||
dailyCost: dailyCost || 0,
|
||||
totalCost,
|
||||
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
|
||||
dailyCostLimit,
|
||||
totalCostLimit,
|
||||
weeklyOpusCostLimit,
|
||||
dailyCost: costData.dailyCost || 0,
|
||||
totalCost: costData.totalCost || 0,
|
||||
weeklyOpusCost: costData.weeklyOpusCost || 0,
|
||||
tags,
|
||||
usage
|
||||
// 聚合 Key 相关字段
|
||||
isAggregated: keyData.isAggregated === 'true',
|
||||
quotaLimit: parseFloat(keyData.quotaLimit || 0),
|
||||
quotaUsed: parseFloat(keyData.quotaUsed || 0),
|
||||
serviceQuotaLimits,
|
||||
serviceQuotaUsed
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -982,19 +1059,37 @@ class ApiKeyService {
|
||||
'tags',
|
||||
'userId', // 新增:用户ID(所有者变更)
|
||||
'userUsername', // 新增:用户名(所有者变更)
|
||||
'createdBy' // 新增:创建者(所有者变更)
|
||||
'createdBy', // 新增:创建者(所有者变更)
|
||||
// 聚合 Key 相关字段
|
||||
'isAggregated',
|
||||
'quotaLimit',
|
||||
'quotaUsed',
|
||||
'serviceQuotaLimits',
|
||||
'serviceQuotaUsed'
|
||||
]
|
||||
const updatedData = { ...keyData }
|
||||
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
if (allowedUpdates.includes(field)) {
|
||||
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
|
||||
// 特殊处理数组字段
|
||||
updatedData[field] = JSON.stringify(value || [])
|
||||
if (
|
||||
field === 'restrictedModels' ||
|
||||
field === 'allowedClients' ||
|
||||
field === 'tags' ||
|
||||
field === 'serviceQuotaLimits' ||
|
||||
field === 'serviceQuotaUsed'
|
||||
) {
|
||||
// 特殊处理数组/对象字段
|
||||
updatedData[field] = JSON.stringify(
|
||||
value || (field === 'serviceQuotaLimits' || field === 'serviceQuotaUsed' ? {} : [])
|
||||
)
|
||||
} else if (field === 'permissions') {
|
||||
// permissions 可能是数组(聚合 Key)或字符串(传统 Key)
|
||||
updatedData[field] = Array.isArray(value) ? JSON.stringify(value) : value || 'all'
|
||||
} else if (
|
||||
field === 'enableModelRestriction' ||
|
||||
field === 'enableClientRestriction' ||
|
||||
field === 'isActivated'
|
||||
field === 'isActivated' ||
|
||||
field === 'isAggregated'
|
||||
) {
|
||||
// 布尔值转字符串
|
||||
updatedData[field] = String(value)
|
||||
@@ -2171,6 +2266,375 @@ class ApiKeyService {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 聚合 Key 相关方法
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 判断是否为聚合 Key
|
||||
*/
|
||||
isAggregatedKey(keyData) {
|
||||
return keyData && keyData.isAggregated === 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取转换为聚合 Key 的预览信息
|
||||
* @param {string} keyId - API Key ID
|
||||
* @returns {Object} 转换预览信息
|
||||
*/
|
||||
async getConvertToAggregatedPreview(keyId) {
|
||||
try {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
throw new Error('API key not found')
|
||||
}
|
||||
|
||||
if (keyData.isAggregated === 'true') {
|
||||
throw new Error('API key is already aggregated')
|
||||
}
|
||||
|
||||
// 获取当前服务倍率
|
||||
const ratesConfig = await serviceRatesService.getRates()
|
||||
|
||||
// 确定原服务类型
|
||||
let originalService = keyData.permissions || 'all'
|
||||
try {
|
||||
const parsed = JSON.parse(originalService)
|
||||
if (Array.isArray(parsed)) {
|
||||
originalService = parsed[0] || 'claude'
|
||||
}
|
||||
} catch (e) {
|
||||
// 不是 JSON,保持原值
|
||||
}
|
||||
|
||||
// 映射 permissions 到服务类型
|
||||
const serviceMap = {
|
||||
all: 'claude',
|
||||
claude: 'claude',
|
||||
gemini: 'gemini',
|
||||
openai: 'codex',
|
||||
droid: 'droid',
|
||||
bedrock: 'bedrock',
|
||||
azure: 'azure',
|
||||
ccr: 'ccr'
|
||||
}
|
||||
const mappedService = serviceMap[originalService] || 'claude'
|
||||
const serviceRate = ratesConfig.rates[mappedService] || 1.0
|
||||
|
||||
// 获取已消耗的真实成本
|
||||
const costStats = await redis.getCostStats(keyId)
|
||||
const totalRealCost = costStats?.total || 0
|
||||
|
||||
// 获取原限额
|
||||
const originalLimit = parseFloat(keyData.totalCostLimit || 0)
|
||||
|
||||
// 计算建议的 CC 额度
|
||||
const suggestedQuotaLimit = originalLimit * serviceRate
|
||||
const suggestedQuotaUsed = totalRealCost * serviceRate
|
||||
|
||||
// 获取可用服务列表
|
||||
const availableServices = Object.keys(ratesConfig.rates)
|
||||
|
||||
return {
|
||||
keyId,
|
||||
keyName: keyData.name,
|
||||
originalService: mappedService,
|
||||
originalPermissions: originalService,
|
||||
originalLimit,
|
||||
originalUsed: totalRealCost,
|
||||
serviceRate,
|
||||
suggestedQuotaLimit: Math.round(suggestedQuotaLimit * 1000) / 1000,
|
||||
suggestedQuotaUsed: Math.round(suggestedQuotaUsed * 1000) / 1000,
|
||||
availableServices,
|
||||
ratesConfig: ratesConfig.rates
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get convert preview:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将传统 Key 转换为聚合 Key
|
||||
* @param {string} keyId - API Key ID
|
||||
* @param {Object} options - 转换选项
|
||||
* @param {number} options.quotaLimit - CC 额度上限
|
||||
* @param {Array<string>} options.permissions - 允许的服务列表
|
||||
* @param {Object} options.serviceQuotaLimits - 分服务额度限制(可选)
|
||||
* @returns {Object} 转换结果
|
||||
*/
|
||||
async convertToAggregated(keyId, options = {}) {
|
||||
try {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
throw new Error('API key not found')
|
||||
}
|
||||
|
||||
if (keyData.isAggregated === 'true') {
|
||||
throw new Error('API key is already aggregated')
|
||||
}
|
||||
|
||||
const {
|
||||
quotaLimit,
|
||||
permissions,
|
||||
serviceQuotaLimits = {},
|
||||
quotaUsed = null // 如果不传,自动计算
|
||||
} = options
|
||||
|
||||
if (quotaLimit === undefined || quotaLimit === null) {
|
||||
throw new Error('quotaLimit is required')
|
||||
}
|
||||
|
||||
if (!permissions || !Array.isArray(permissions) || permissions.length === 0) {
|
||||
throw new Error('permissions must be a non-empty array')
|
||||
}
|
||||
|
||||
// 计算已消耗的 CC 额度
|
||||
let calculatedQuotaUsed = quotaUsed
|
||||
if (calculatedQuotaUsed === null) {
|
||||
// 自动计算:获取原服务的倍率,按倍率换算已消耗成本
|
||||
const preview = await this.getConvertToAggregatedPreview(keyId)
|
||||
calculatedQuotaUsed = preview.suggestedQuotaUsed
|
||||
}
|
||||
|
||||
// 更新 Key 数据
|
||||
const updates = {
|
||||
isAggregated: true,
|
||||
quotaLimit,
|
||||
quotaUsed: calculatedQuotaUsed,
|
||||
permissions,
|
||||
serviceQuotaLimits,
|
||||
serviceQuotaUsed: {} // 转换时重置分服务统计
|
||||
}
|
||||
|
||||
await this.updateApiKey(keyId, updates)
|
||||
|
||||
logger.success(`🔄 Converted API key ${keyId} to aggregated key with ${quotaLimit} CC quota`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
keyId,
|
||||
quotaLimit,
|
||||
quotaUsed: calculatedQuotaUsed,
|
||||
permissions
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to convert to aggregated key:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速检查聚合 Key 额度(用于请求前检查)
|
||||
* @param {string} keyId - API Key ID
|
||||
* @returns {Object} { allowed: boolean, reason?: string, quotaRemaining?: number }
|
||||
*/
|
||||
async quickQuotaCheck(keyId) {
|
||||
try {
|
||||
const [quotaUsed, quotaLimit, isAggregated, isActive] = await redis.client.hmget(
|
||||
`api_key:${keyId}`,
|
||||
'quotaUsed',
|
||||
'quotaLimit',
|
||||
'isAggregated',
|
||||
'isActive'
|
||||
)
|
||||
|
||||
// 非聚合 Key 不检查额度
|
||||
if (isAggregated !== 'true') {
|
||||
return { allowed: true, isAggregated: false }
|
||||
}
|
||||
|
||||
if (isActive !== 'true') {
|
||||
return { allowed: false, reason: 'inactive', isAggregated: true }
|
||||
}
|
||||
|
||||
const used = parseFloat(quotaUsed || 0)
|
||||
const limit = parseFloat(quotaLimit || 0)
|
||||
|
||||
// 额度为 0 表示不限制
|
||||
if (limit === 0) {
|
||||
return { allowed: true, isAggregated: true, quotaRemaining: Infinity }
|
||||
}
|
||||
|
||||
if (used >= limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'quota_exceeded',
|
||||
isAggregated: true,
|
||||
quotaUsed: used,
|
||||
quotaLimit: limit
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, isAggregated: true, quotaRemaining: limit - used }
|
||||
} catch (error) {
|
||||
logger.error('❌ Quick quota check failed:', error)
|
||||
// 出错时允许通过,避免阻塞请求
|
||||
return { allowed: true, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步扣减聚合 Key 额度(请求完成后调用)
|
||||
* @param {string} keyId - API Key ID
|
||||
* @param {number} costUSD - 真实成本(USD)
|
||||
* @param {string} service - 服务类型
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deductQuotaAsync(keyId, costUSD, service) {
|
||||
// 使用 setImmediate 确保不阻塞响应
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
// 获取服务倍率
|
||||
const rate = await serviceRatesService.getServiceRate(service)
|
||||
const quotaToDeduct = costUSD * rate
|
||||
|
||||
// 原子更新总额度
|
||||
await redis.client.hincrbyfloat(`api_key:${keyId}`, 'quotaUsed', quotaToDeduct)
|
||||
|
||||
// 更新分服务额度
|
||||
const serviceQuotaUsedStr = await redis.client.hget(`api_key:${keyId}`, 'serviceQuotaUsed')
|
||||
let serviceQuotaUsed = {}
|
||||
try {
|
||||
serviceQuotaUsed = JSON.parse(serviceQuotaUsedStr || '{}')
|
||||
} catch (e) {
|
||||
serviceQuotaUsed = {}
|
||||
}
|
||||
serviceQuotaUsed[service] = (serviceQuotaUsed[service] || 0) + quotaToDeduct
|
||||
await redis.client.hset(
|
||||
`api_key:${keyId}`,
|
||||
'serviceQuotaUsed',
|
||||
JSON.stringify(serviceQuotaUsed)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
`📊 Deducted ${quotaToDeduct.toFixed(4)} CC quota from key ${keyId} (service: ${service}, rate: ${rate})`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to deduct quota for key ${keyId}:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加聚合 Key 额度(用于核销额度卡)
|
||||
* @param {string} keyId - API Key ID
|
||||
* @param {number} quotaAmount - 要增加的 CC 额度
|
||||
* @returns {Promise<Object>} { success: boolean, newQuotaLimit: number }
|
||||
*/
|
||||
async addQuota(keyId, quotaAmount) {
|
||||
try {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
throw new Error('API key not found')
|
||||
}
|
||||
|
||||
if (keyData.isAggregated !== 'true') {
|
||||
throw new Error('Only aggregated keys can add quota')
|
||||
}
|
||||
|
||||
const currentLimit = parseFloat(keyData.quotaLimit || 0)
|
||||
const newLimit = currentLimit + quotaAmount
|
||||
|
||||
await redis.client.hset(`api_key:${keyId}`, 'quotaLimit', String(newLimit))
|
||||
|
||||
logger.success(`💰 Added ${quotaAmount} CC quota to key ${keyId}, new limit: ${newLimit}`)
|
||||
|
||||
return { success: true, previousLimit: currentLimit, newQuotaLimit: newLimit }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to add quota:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少聚合 Key 额度(用于撤销核销)
|
||||
* @param {string} keyId - API Key ID
|
||||
* @param {number} quotaAmount - 要减少的 CC 额度
|
||||
* @returns {Promise<Object>} { success: boolean, newQuotaLimit: number, actualDeducted: number }
|
||||
*/
|
||||
async deductQuotaLimit(keyId, quotaAmount) {
|
||||
try {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
throw new Error('API key not found')
|
||||
}
|
||||
|
||||
if (keyData.isAggregated !== 'true') {
|
||||
throw new Error('Only aggregated keys can deduct quota')
|
||||
}
|
||||
|
||||
const currentLimit = parseFloat(keyData.quotaLimit || 0)
|
||||
const currentUsed = parseFloat(keyData.quotaUsed || 0)
|
||||
|
||||
// 不能扣到比已使用的还少
|
||||
const minLimit = currentUsed
|
||||
const actualDeducted = Math.min(quotaAmount, currentLimit - minLimit)
|
||||
const newLimit = Math.max(currentLimit - quotaAmount, minLimit)
|
||||
|
||||
await redis.client.hset(`api_key:${keyId}`, 'quotaLimit', String(newLimit))
|
||||
|
||||
logger.success(
|
||||
`💸 Deducted ${actualDeducted} CC quota from key ${keyId}, new limit: ${newLimit}`
|
||||
)
|
||||
|
||||
return { success: true, previousLimit: currentLimit, newQuotaLimit: newLimit, actualDeducted }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to deduct quota limit:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 延长 API Key 有效期(用于核销时间卡)
|
||||
* @param {string} keyId - API Key ID
|
||||
* @param {number} amount - 时间数量
|
||||
* @param {string} unit - 时间单位 'hours' | 'days' | 'months'
|
||||
* @returns {Promise<Object>} { success: boolean, newExpiresAt: string }
|
||||
*/
|
||||
async extendExpiry(keyId, amount, unit = 'days') {
|
||||
try {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
throw new Error('API key not found')
|
||||
}
|
||||
|
||||
// 计算新的过期时间
|
||||
let baseDate = keyData.expiresAt ? new Date(keyData.expiresAt) : new Date()
|
||||
// 如果已过期,从当前时间开始计算
|
||||
if (baseDate < new Date()) {
|
||||
baseDate = new Date()
|
||||
}
|
||||
|
||||
let milliseconds
|
||||
switch (unit) {
|
||||
case 'hours':
|
||||
milliseconds = amount * 60 * 60 * 1000
|
||||
break
|
||||
case 'months':
|
||||
// 简化处理:1个月 = 30天
|
||||
milliseconds = amount * 30 * 24 * 60 * 60 * 1000
|
||||
break
|
||||
case 'days':
|
||||
default:
|
||||
milliseconds = amount * 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
const newExpiresAt = new Date(baseDate.getTime() + milliseconds).toISOString()
|
||||
|
||||
await this.updateApiKey(keyId, { expiresAt: newExpiresAt })
|
||||
|
||||
logger.success(
|
||||
`⏰ Extended key ${keyId} expiry by ${amount} ${unit}, new expiry: ${newExpiresAt}`
|
||||
)
|
||||
|
||||
return { success: true, previousExpiresAt: keyData.expiresAt, newExpiresAt }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to extend expiry:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出实例和单独的方法
|
||||
|
||||
579
src/services/quotaCardService.js
Normal file
579
src/services/quotaCardService.js
Normal file
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* 额度卡/时间卡服务
|
||||
* 管理员生成卡,用户核销,管理员可撤销
|
||||
*/
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
|
||||
class QuotaCardService {
|
||||
constructor() {
|
||||
this.CARD_PREFIX = 'quota_card:'
|
||||
this.REDEMPTION_PREFIX = 'redemption:'
|
||||
this.CARD_CODE_PREFIX = 'CC' // 卡号前缀
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成卡号(16位,格式:CC_XXXX_XXXX_XXXX)
|
||||
*/
|
||||
_generateCardCode() {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 排除容易混淆的字符
|
||||
let code = ''
|
||||
for (let i = 0; i < 12; i++) {
|
||||
code += chars.charAt(crypto.randomInt(chars.length))
|
||||
}
|
||||
return `${this.CARD_CODE_PREFIX}_${code.slice(0, 4)}_${code.slice(4, 8)}_${code.slice(8, 12)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建额度卡/时间卡
|
||||
* @param {Object} options - 卡配置
|
||||
* @param {string} options.type - 卡类型:'quota' | 'time' | 'combo'
|
||||
* @param {number} options.quotaAmount - CC 额度数量(quota/combo 类型必填)
|
||||
* @param {number} options.timeAmount - 时间数量(time/combo 类型必填)
|
||||
* @param {string} options.timeUnit - 时间单位:'hours' | 'days' | 'months'
|
||||
* @param {string} options.expiresAt - 卡本身的有效期(可选)
|
||||
* @param {string} options.note - 备注
|
||||
* @param {string} options.createdBy - 创建者 ID
|
||||
* @returns {Object} 创建的卡信息
|
||||
*/
|
||||
async createCard(options = {}) {
|
||||
try {
|
||||
const {
|
||||
type = 'quota',
|
||||
quotaAmount = 0,
|
||||
timeAmount = 0,
|
||||
timeUnit = 'days',
|
||||
expiresAt = null,
|
||||
note = '',
|
||||
createdBy = 'admin'
|
||||
} = options
|
||||
|
||||
// 验证
|
||||
if (!['quota', 'time', 'combo'].includes(type)) {
|
||||
throw new Error('Invalid card type')
|
||||
}
|
||||
|
||||
if ((type === 'quota' || type === 'combo') && (!quotaAmount || quotaAmount <= 0)) {
|
||||
throw new Error('quotaAmount is required for quota/combo cards')
|
||||
}
|
||||
|
||||
if ((type === 'time' || type === 'combo') && (!timeAmount || timeAmount <= 0)) {
|
||||
throw new Error('timeAmount is required for time/combo cards')
|
||||
}
|
||||
|
||||
const cardId = uuidv4()
|
||||
const cardCode = this._generateCardCode()
|
||||
|
||||
const cardData = {
|
||||
id: cardId,
|
||||
code: cardCode,
|
||||
type,
|
||||
quotaAmount: String(quotaAmount || 0),
|
||||
timeAmount: String(timeAmount || 0),
|
||||
timeUnit: timeUnit || 'days',
|
||||
status: 'unused', // unused | redeemed | revoked | expired
|
||||
createdBy,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: expiresAt || '',
|
||||
note: note || '',
|
||||
// 核销信息
|
||||
redeemedBy: '',
|
||||
redeemedByUsername: '',
|
||||
redeemedApiKeyId: '',
|
||||
redeemedApiKeyName: '',
|
||||
redeemedAt: '',
|
||||
// 撤销信息
|
||||
revokedAt: '',
|
||||
revokedBy: '',
|
||||
revokeReason: ''
|
||||
}
|
||||
|
||||
// 保存卡数据
|
||||
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, cardData)
|
||||
|
||||
// 建立卡号到 ID 的映射(用于快速查找)
|
||||
await redis.client.set(`quota_card_code:${cardCode}`, cardId)
|
||||
|
||||
// 添加到卡列表索引
|
||||
await redis.client.sadd('quota_cards:all', cardId)
|
||||
await redis.client.sadd(`quota_cards:status:${cardData.status}`, cardId)
|
||||
|
||||
logger.success(`🎫 Created ${type} card: ${cardCode} (${cardId})`)
|
||||
|
||||
return {
|
||||
id: cardId,
|
||||
code: cardCode,
|
||||
type,
|
||||
quotaAmount: parseFloat(quotaAmount || 0),
|
||||
timeAmount: parseInt(timeAmount || 0),
|
||||
timeUnit,
|
||||
status: 'unused',
|
||||
createdBy,
|
||||
createdAt: cardData.createdAt,
|
||||
expiresAt: cardData.expiresAt,
|
||||
note
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create card:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建卡
|
||||
* @param {Object} options - 卡配置
|
||||
* @param {number} count - 创建数量
|
||||
* @returns {Array} 创建的卡列表
|
||||
*/
|
||||
async createCardsBatch(options = {}, count = 1) {
|
||||
const cards = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const card = await this.createCard(options)
|
||||
cards.push(card)
|
||||
}
|
||||
logger.success(`🎫 Batch created ${count} cards`)
|
||||
return cards
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过卡号获取卡信息
|
||||
*/
|
||||
async getCardByCode(code) {
|
||||
try {
|
||||
const cardId = await redis.client.get(`quota_card_code:${code}`)
|
||||
if (!cardId) {
|
||||
return null
|
||||
}
|
||||
return await this.getCardById(cardId)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get card by code:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 获取卡信息
|
||||
*/
|
||||
async getCardById(cardId) {
|
||||
try {
|
||||
const cardData = await redis.client.hgetall(`${this.CARD_PREFIX}${cardId}`)
|
||||
if (!cardData || Object.keys(cardData).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: cardData.id,
|
||||
code: cardData.code,
|
||||
type: cardData.type,
|
||||
quotaAmount: parseFloat(cardData.quotaAmount || 0),
|
||||
timeAmount: parseInt(cardData.timeAmount || 0),
|
||||
timeUnit: cardData.timeUnit,
|
||||
status: cardData.status,
|
||||
createdBy: cardData.createdBy,
|
||||
createdAt: cardData.createdAt,
|
||||
expiresAt: cardData.expiresAt,
|
||||
note: cardData.note,
|
||||
redeemedBy: cardData.redeemedBy,
|
||||
redeemedByUsername: cardData.redeemedByUsername,
|
||||
redeemedApiKeyId: cardData.redeemedApiKeyId,
|
||||
redeemedApiKeyName: cardData.redeemedApiKeyName,
|
||||
redeemedAt: cardData.redeemedAt,
|
||||
revokedAt: cardData.revokedAt,
|
||||
revokedBy: cardData.revokedBy,
|
||||
revokeReason: cardData.revokeReason
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get card:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有卡列表
|
||||
* @param {Object} options - 查询选项
|
||||
* @param {string} options.status - 按状态筛选
|
||||
* @param {number} options.limit - 限制数量
|
||||
* @param {number} options.offset - 偏移量
|
||||
*/
|
||||
async getAllCards(options = {}) {
|
||||
try {
|
||||
const { status, limit = 100, offset = 0 } = options
|
||||
|
||||
let cardIds
|
||||
if (status) {
|
||||
cardIds = await redis.client.smembers(`quota_cards:status:${status}`)
|
||||
} else {
|
||||
cardIds = await redis.client.smembers('quota_cards:all')
|
||||
}
|
||||
|
||||
// 排序(按创建时间倒序)
|
||||
const cards = []
|
||||
for (const cardId of cardIds) {
|
||||
const card = await this.getCardById(cardId)
|
||||
if (card) {
|
||||
cards.push(card)
|
||||
}
|
||||
}
|
||||
|
||||
cards.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
|
||||
// 分页
|
||||
const total = cards.length
|
||||
const paginatedCards = cards.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
cards: paginatedCards,
|
||||
total,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get all cards:', error)
|
||||
return { cards: [], total: 0, limit: 100, offset: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销卡
|
||||
* @param {string} code - 卡号
|
||||
* @param {string} apiKeyId - 目标 API Key ID
|
||||
* @param {string} userId - 核销用户 ID
|
||||
* @param {string} username - 核销用户名
|
||||
* @returns {Object} 核销结果
|
||||
*/
|
||||
async redeemCard(code, apiKeyId, userId, username = '') {
|
||||
try {
|
||||
// 获取卡信息
|
||||
const card = await this.getCardByCode(code)
|
||||
if (!card) {
|
||||
throw new Error('Card not found')
|
||||
}
|
||||
|
||||
// 检查卡状态
|
||||
if (card.status !== 'unused') {
|
||||
throw new Error(`Card is ${card.status}, cannot redeem`)
|
||||
}
|
||||
|
||||
// 检查卡是否过期
|
||||
if (card.expiresAt && new Date(card.expiresAt) < new Date()) {
|
||||
// 更新卡状态为过期
|
||||
await this._updateCardStatus(card.id, 'expired')
|
||||
throw new Error('Card has expired')
|
||||
}
|
||||
|
||||
// 获取 API Key 信息
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const keyData = await redis.getApiKey(apiKeyId)
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
throw new Error('API key not found')
|
||||
}
|
||||
|
||||
// 检查 API Key 是否为聚合类型(只有聚合 Key 才能核销额度卡)
|
||||
if (card.type !== 'time' && keyData.isAggregated !== 'true') {
|
||||
throw new Error('Only aggregated keys can redeem quota cards')
|
||||
}
|
||||
|
||||
// 执行核销
|
||||
const redemptionId = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// 记录核销前状态
|
||||
const beforeQuota = parseFloat(keyData.quotaLimit || 0)
|
||||
const beforeExpiry = keyData.expiresAt || ''
|
||||
|
||||
// 应用卡效果
|
||||
let afterQuota = beforeQuota
|
||||
let afterExpiry = beforeExpiry
|
||||
|
||||
if (card.type === 'quota' || card.type === 'combo') {
|
||||
const result = await apiKeyService.addQuota(apiKeyId, card.quotaAmount)
|
||||
afterQuota = result.newQuotaLimit
|
||||
}
|
||||
|
||||
if (card.type === 'time' || card.type === 'combo') {
|
||||
const result = await apiKeyService.extendExpiry(apiKeyId, card.timeAmount, card.timeUnit)
|
||||
afterExpiry = result.newExpiresAt
|
||||
}
|
||||
|
||||
// 更新卡状态
|
||||
await redis.client.hset(`${this.CARD_PREFIX}${card.id}`, {
|
||||
status: 'redeemed',
|
||||
redeemedBy: userId,
|
||||
redeemedByUsername: username,
|
||||
redeemedApiKeyId: apiKeyId,
|
||||
redeemedApiKeyName: keyData.name || '',
|
||||
redeemedAt: now
|
||||
})
|
||||
|
||||
// 更新状态索引
|
||||
await redis.client.srem(`quota_cards:status:unused`, card.id)
|
||||
await redis.client.sadd(`quota_cards:status:redeemed`, card.id)
|
||||
|
||||
// 创建核销记录
|
||||
const redemptionData = {
|
||||
id: redemptionId,
|
||||
cardId: card.id,
|
||||
cardCode: card.code,
|
||||
cardType: card.type,
|
||||
userId,
|
||||
username,
|
||||
apiKeyId,
|
||||
apiKeyName: keyData.name || '',
|
||||
quotaAdded: String(card.type === 'time' ? 0 : card.quotaAmount),
|
||||
timeAdded: String(card.type === 'quota' ? 0 : card.timeAmount),
|
||||
timeUnit: card.timeUnit,
|
||||
beforeQuota: String(beforeQuota),
|
||||
afterQuota: String(afterQuota),
|
||||
beforeExpiry,
|
||||
afterExpiry,
|
||||
timestamp: now,
|
||||
status: 'active' // active | revoked
|
||||
}
|
||||
|
||||
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, redemptionData)
|
||||
|
||||
// 添加到核销记录索引
|
||||
await redis.client.sadd('redemptions:all', redemptionId)
|
||||
await redis.client.sadd(`redemptions:user:${userId}`, redemptionId)
|
||||
await redis.client.sadd(`redemptions:apikey:${apiKeyId}`, redemptionId)
|
||||
|
||||
logger.success(`✅ Card ${card.code} redeemed by ${username || userId} to key ${apiKeyId}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
redemptionId,
|
||||
cardCode: card.code,
|
||||
cardType: card.type,
|
||||
quotaAdded: card.type === 'time' ? 0 : card.quotaAmount,
|
||||
timeAdded: card.type === 'quota' ? 0 : card.timeAmount,
|
||||
timeUnit: card.timeUnit,
|
||||
beforeQuota,
|
||||
afterQuota,
|
||||
beforeExpiry,
|
||||
afterExpiry
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to redeem card:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销核销
|
||||
* @param {string} redemptionId - 核销记录 ID
|
||||
* @param {string} revokedBy - 撤销者 ID
|
||||
* @param {string} reason - 撤销原因
|
||||
* @returns {Object} 撤销结果
|
||||
*/
|
||||
async revokeRedemption(redemptionId, revokedBy, reason = '') {
|
||||
try {
|
||||
// 获取核销记录
|
||||
const redemptionData = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${redemptionId}`)
|
||||
if (!redemptionData || Object.keys(redemptionData).length === 0) {
|
||||
throw new Error('Redemption record not found')
|
||||
}
|
||||
|
||||
if (redemptionData.status !== 'active') {
|
||||
throw new Error('Redemption is already revoked')
|
||||
}
|
||||
|
||||
const apiKeyService = require('./apiKeyService')
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// 撤销效果
|
||||
let actualQuotaDeducted = 0
|
||||
if (parseFloat(redemptionData.quotaAdded) > 0) {
|
||||
const result = await apiKeyService.deductQuotaLimit(
|
||||
redemptionData.apiKeyId,
|
||||
parseFloat(redemptionData.quotaAdded)
|
||||
)
|
||||
actualQuotaDeducted = result.actualDeducted
|
||||
}
|
||||
|
||||
// 注意:时间卡撤销比较复杂,这里简化处理,不回退时间
|
||||
// 如果需要回退时间,可以在这里添加逻辑
|
||||
|
||||
// 更新核销记录状态
|
||||
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, {
|
||||
status: 'revoked',
|
||||
revokedAt: now,
|
||||
revokedBy,
|
||||
revokeReason: reason,
|
||||
actualQuotaDeducted: String(actualQuotaDeducted)
|
||||
})
|
||||
|
||||
// 更新卡状态
|
||||
const { cardId } = redemptionData
|
||||
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, {
|
||||
status: 'revoked',
|
||||
revokedAt: now,
|
||||
revokedBy,
|
||||
revokeReason: reason
|
||||
})
|
||||
|
||||
// 更新状态索引
|
||||
await redis.client.srem(`quota_cards:status:redeemed`, cardId)
|
||||
await redis.client.sadd(`quota_cards:status:revoked`, cardId)
|
||||
|
||||
logger.success(`🔄 Revoked redemption ${redemptionId} by ${revokedBy}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
redemptionId,
|
||||
cardCode: redemptionData.cardCode,
|
||||
actualQuotaDeducted,
|
||||
reason
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to revoke redemption:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取核销记录
|
||||
* @param {Object} options - 查询选项
|
||||
* @param {string} options.userId - 按用户筛选
|
||||
* @param {string} options.apiKeyId - 按 API Key 筛选
|
||||
* @param {number} options.limit - 限制数量
|
||||
* @param {number} options.offset - 偏移量
|
||||
*/
|
||||
async getRedemptions(options = {}) {
|
||||
try {
|
||||
const { userId, apiKeyId, limit = 100, offset = 0 } = options
|
||||
|
||||
let redemptionIds
|
||||
if (userId) {
|
||||
redemptionIds = await redis.client.smembers(`redemptions:user:${userId}`)
|
||||
} else if (apiKeyId) {
|
||||
redemptionIds = await redis.client.smembers(`redemptions:apikey:${apiKeyId}`)
|
||||
} else {
|
||||
redemptionIds = await redis.client.smembers('redemptions:all')
|
||||
}
|
||||
|
||||
const redemptions = []
|
||||
for (const id of redemptionIds) {
|
||||
const data = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${id}`)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
redemptions.push({
|
||||
id: data.id,
|
||||
cardId: data.cardId,
|
||||
cardCode: data.cardCode,
|
||||
cardType: data.cardType,
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
apiKeyId: data.apiKeyId,
|
||||
apiKeyName: data.apiKeyName,
|
||||
quotaAdded: parseFloat(data.quotaAdded || 0),
|
||||
timeAdded: parseInt(data.timeAdded || 0),
|
||||
timeUnit: data.timeUnit,
|
||||
beforeQuota: parseFloat(data.beforeQuota || 0),
|
||||
afterQuota: parseFloat(data.afterQuota || 0),
|
||||
beforeExpiry: data.beforeExpiry,
|
||||
afterExpiry: data.afterExpiry,
|
||||
timestamp: data.timestamp,
|
||||
status: data.status,
|
||||
revokedAt: data.revokedAt,
|
||||
revokedBy: data.revokedBy,
|
||||
revokeReason: data.revokeReason,
|
||||
actualQuotaDeducted: parseFloat(data.actualQuotaDeducted || 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 排序(按时间倒序)
|
||||
redemptions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
|
||||
// 分页
|
||||
const total = redemptions.length
|
||||
const paginatedRedemptions = redemptions.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
redemptions: paginatedRedemptions,
|
||||
total,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get redemptions:', error)
|
||||
return { redemptions: [], total: 0, limit: 100, offset: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除未使用的卡
|
||||
*/
|
||||
async deleteCard(cardId) {
|
||||
try {
|
||||
const card = await this.getCardById(cardId)
|
||||
if (!card) {
|
||||
throw new Error('Card not found')
|
||||
}
|
||||
|
||||
if (card.status !== 'unused') {
|
||||
throw new Error('Only unused cards can be deleted')
|
||||
}
|
||||
|
||||
// 删除卡数据
|
||||
await redis.client.del(`${this.CARD_PREFIX}${cardId}`)
|
||||
await redis.client.del(`quota_card_code:${card.code}`)
|
||||
|
||||
// 从索引中移除
|
||||
await redis.client.srem('quota_cards:all', cardId)
|
||||
await redis.client.srem(`quota_cards:status:unused`, cardId)
|
||||
|
||||
logger.success(`🗑️ Deleted card ${card.code}`)
|
||||
|
||||
return { success: true, cardCode: card.code }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete card:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新卡状态(内部方法)
|
||||
*/
|
||||
async _updateCardStatus(cardId, newStatus) {
|
||||
const card = await this.getCardById(cardId)
|
||||
if (!card) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldStatus = card.status
|
||||
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, 'status', newStatus)
|
||||
|
||||
// 更新状态索引
|
||||
await redis.client.srem(`quota_cards:status:${oldStatus}`, cardId)
|
||||
await redis.client.sadd(`quota_cards:status:${newStatus}`, cardId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取卡统计信息
|
||||
*/
|
||||
async getCardStats() {
|
||||
try {
|
||||
const [unused, redeemed, revoked, expired] = await Promise.all([
|
||||
redis.client.scard('quota_cards:status:unused'),
|
||||
redis.client.scard('quota_cards:status:redeemed'),
|
||||
redis.client.scard('quota_cards:status:revoked'),
|
||||
redis.client.scard('quota_cards:status:expired')
|
||||
])
|
||||
|
||||
return {
|
||||
total: unused + redeemed + revoked + expired,
|
||||
unused,
|
||||
redeemed,
|
||||
revoked,
|
||||
expired
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get card stats:', error)
|
||||
return { total: 0, unused: 0, redeemed: 0, revoked: 0, expired: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new QuotaCardService()
|
||||
227
src/services/serviceRatesService.js
Normal file
227
src/services/serviceRatesService.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 服务倍率配置服务
|
||||
* 管理不同服务的消费倍率,以 Claude 为基准(倍率 1.0)
|
||||
* 用于聚合 Key 的虚拟额度计算
|
||||
*/
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
class ServiceRatesService {
|
||||
constructor() {
|
||||
this.CONFIG_KEY = 'system:service_rates'
|
||||
this.cachedRates = null
|
||||
this.cacheExpiry = 0
|
||||
this.CACHE_TTL = 60 * 1000 // 1分钟缓存
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认倍率配置
|
||||
*/
|
||||
getDefaultRates() {
|
||||
return {
|
||||
baseService: 'claude',
|
||||
rates: {
|
||||
claude: 1.0, // 基准:1 USD = 1 CC额度
|
||||
codex: 1.0,
|
||||
gemini: 1.0,
|
||||
droid: 1.0,
|
||||
bedrock: 1.0,
|
||||
azure: 1.0,
|
||||
ccr: 1.0
|
||||
},
|
||||
updatedAt: null,
|
||||
updatedBy: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取倍率配置(带缓存)
|
||||
*/
|
||||
async getRates() {
|
||||
try {
|
||||
// 检查缓存
|
||||
if (this.cachedRates && Date.now() < this.cacheExpiry) {
|
||||
return this.cachedRates
|
||||
}
|
||||
|
||||
const configStr = await redis.client.get(this.CONFIG_KEY)
|
||||
if (!configStr) {
|
||||
const defaultRates = this.getDefaultRates()
|
||||
this.cachedRates = defaultRates
|
||||
this.cacheExpiry = Date.now() + this.CACHE_TTL
|
||||
return defaultRates
|
||||
}
|
||||
|
||||
const storedConfig = JSON.parse(configStr)
|
||||
// 合并默认值,确保新增服务有默认倍率
|
||||
const defaultRates = this.getDefaultRates()
|
||||
storedConfig.rates = {
|
||||
...defaultRates.rates,
|
||||
...storedConfig.rates
|
||||
}
|
||||
|
||||
this.cachedRates = storedConfig
|
||||
this.cacheExpiry = Date.now() + this.CACHE_TTL
|
||||
return storedConfig
|
||||
} catch (error) {
|
||||
logger.error('获取服务倍率配置失败:', error)
|
||||
return this.getDefaultRates()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存倍率配置
|
||||
*/
|
||||
async saveRates(config, updatedBy = 'admin') {
|
||||
try {
|
||||
const defaultRates = this.getDefaultRates()
|
||||
|
||||
// 验证配置
|
||||
this.validateRates(config)
|
||||
|
||||
const newConfig = {
|
||||
baseService: config.baseService || defaultRates.baseService,
|
||||
rates: {
|
||||
...defaultRates.rates,
|
||||
...config.rates
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy
|
||||
}
|
||||
|
||||
await redis.client.set(this.CONFIG_KEY, JSON.stringify(newConfig))
|
||||
|
||||
// 清除缓存
|
||||
this.cachedRates = null
|
||||
this.cacheExpiry = 0
|
||||
|
||||
logger.info(`✅ 服务倍率配置已更新 by ${updatedBy}`)
|
||||
return newConfig
|
||||
} catch (error) {
|
||||
logger.error('保存服务倍率配置失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证倍率配置
|
||||
*/
|
||||
validateRates(config) {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('无效的配置格式')
|
||||
}
|
||||
|
||||
if (config.rates) {
|
||||
for (const [service, rate] of Object.entries(config.rates)) {
|
||||
if (typeof rate !== 'number' || rate <= 0) {
|
||||
throw new Error(`服务 ${service} 的倍率必须是正数`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个服务的倍率
|
||||
*/
|
||||
async getServiceRate(service) {
|
||||
const config = await this.getRates()
|
||||
return config.rates[service] || 1.0
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算消费的 CC 额度
|
||||
* @param {number} costUSD - 真实成本(USD)
|
||||
* @param {string} service - 服务类型
|
||||
* @returns {number} CC 额度消耗
|
||||
*/
|
||||
async calculateQuotaConsumption(costUSD, service) {
|
||||
const rate = await this.getServiceRate(service)
|
||||
return costUSD * rate
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模型名称获取服务类型
|
||||
*/
|
||||
getServiceFromModel(model) {
|
||||
if (!model) {
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
const modelLower = model.toLowerCase()
|
||||
|
||||
// Claude 系列
|
||||
if (
|
||||
modelLower.includes('claude') ||
|
||||
modelLower.includes('anthropic') ||
|
||||
modelLower.includes('opus') ||
|
||||
modelLower.includes('sonnet') ||
|
||||
modelLower.includes('haiku')
|
||||
) {
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
// OpenAI / Codex 系列
|
||||
if (
|
||||
modelLower.includes('gpt') ||
|
||||
modelLower.includes('o1') ||
|
||||
modelLower.includes('o3') ||
|
||||
modelLower.includes('o4') ||
|
||||
modelLower.includes('codex') ||
|
||||
modelLower.includes('davinci') ||
|
||||
modelLower.includes('curie') ||
|
||||
modelLower.includes('babbage') ||
|
||||
modelLower.includes('ada')
|
||||
) {
|
||||
return 'codex'
|
||||
}
|
||||
|
||||
// Gemini 系列
|
||||
if (
|
||||
modelLower.includes('gemini') ||
|
||||
modelLower.includes('palm') ||
|
||||
modelLower.includes('bard')
|
||||
) {
|
||||
return 'gemini'
|
||||
}
|
||||
|
||||
// Droid 系列
|
||||
if (modelLower.includes('droid') || modelLower.includes('factory')) {
|
||||
return 'droid'
|
||||
}
|
||||
|
||||
// Bedrock 系列(通常带有 aws 或特定前缀)
|
||||
if (
|
||||
modelLower.includes('bedrock') ||
|
||||
modelLower.includes('amazon') ||
|
||||
modelLower.includes('titan')
|
||||
) {
|
||||
return 'bedrock'
|
||||
}
|
||||
|
||||
// Azure 系列
|
||||
if (modelLower.includes('azure')) {
|
||||
return 'azure'
|
||||
}
|
||||
|
||||
// 默认返回 claude
|
||||
return 'claude'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的服务列表
|
||||
*/
|
||||
async getAvailableServices() {
|
||||
const config = await this.getRates()
|
||||
return Object.keys(config.rates)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存(用于测试或强制刷新)
|
||||
*/
|
||||
clearCache() {
|
||||
this.cachedRates = null
|
||||
this.cacheExpiry = 0
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ServiceRatesService()
|
||||
@@ -313,6 +313,51 @@ const getTimeRemaining = (expiresAt) => {
|
||||
return Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000))
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 版本处理
|
||||
// ============================================
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// 获取应用版本号
|
||||
const getAppVersion = () => {
|
||||
if (process.env.APP_VERSION) {
|
||||
return process.env.APP_VERSION
|
||||
}
|
||||
if (process.env.VERSION) {
|
||||
return process.env.VERSION
|
||||
}
|
||||
try {
|
||||
const versionFile = path.join(__dirname, '..', '..', 'VERSION')
|
||||
if (fs.existsSync(versionFile)) {
|
||||
return fs.readFileSync(versionFile, 'utf8').trim()
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
return require('../../package.json').version
|
||||
} catch {}
|
||||
return '1.0.0'
|
||||
}
|
||||
|
||||
// 版本比较: a > b
|
||||
const versionGt = (a, b) => {
|
||||
const pa = a.split('.').map(Number)
|
||||
const pb = b.split('.').map(Number)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] || 0) > (pb[i] || 0)) {
|
||||
return true
|
||||
}
|
||||
if ((pa[i] || 0) < (pb[i] || 0)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 版本比较: a >= b
|
||||
const versionGte = (a, b) => a === b || versionGt(a, b)
|
||||
|
||||
module.exports = {
|
||||
// 加密
|
||||
createEncryptor,
|
||||
@@ -351,5 +396,9 @@ module.exports = {
|
||||
getDateInTimezone,
|
||||
getDateStringInTimezone,
|
||||
isExpired,
|
||||
getTimeRemaining
|
||||
getTimeRemaining,
|
||||
// 版本
|
||||
getAppVersion,
|
||||
versionGt,
|
||||
versionGte
|
||||
}
|
||||
|
||||
@@ -24,9 +24,12 @@ function generateSessionString() {
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} options - 可选配置
|
||||
* @param {boolean} options.stream - 是否流式(默认false)
|
||||
* @param {string} options.prompt - 自定义提示词(默认 'hi')
|
||||
* @param {number} options.maxTokens - 最大输出 token(默认 1000)
|
||||
* @returns {object} 测试请求体
|
||||
*/
|
||||
function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) {
|
||||
const { stream, prompt = 'hi', maxTokens = 1000 } = options
|
||||
const payload = {
|
||||
model,
|
||||
messages: [
|
||||
@@ -35,7 +38,7 @@ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options =
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
text: prompt,
|
||||
cache_control: {
|
||||
type: 'ephemeral'
|
||||
}
|
||||
@@ -55,11 +58,11 @@ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options =
|
||||
metadata: {
|
||||
user_id: generateSessionString()
|
||||
},
|
||||
max_tokens: 21333,
|
||||
max_tokens: maxTokens,
|
||||
temperature: 1
|
||||
}
|
||||
|
||||
if (options.stream) {
|
||||
if (stream) {
|
||||
payload.stream = true
|
||||
}
|
||||
|
||||
@@ -234,9 +237,58 @@ async function sendStreamTestRequest(options) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Gemini 测试请求体
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} options - 可选配置
|
||||
* @param {string} options.prompt - 自定义提示词(默认 'hi')
|
||||
* @param {number} options.maxTokens - 最大输出 token(默认 100)
|
||||
* @returns {object} 测试请求体
|
||||
*/
|
||||
function createGeminiTestPayload(model = 'gemini-2.5-pro', options = {}) {
|
||||
const { prompt = 'hi', maxTokens = 100 } = options
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }]
|
||||
}
|
||||
],
|
||||
generationConfig: {
|
||||
maxOutputTokens: maxTokens,
|
||||
temperature: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OpenAI Responses 测试请求体
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} options - 可选配置
|
||||
* @param {string} options.prompt - 自定义提示词(默认 'hi')
|
||||
* @param {number} options.maxTokens - 最大输出 token(默认 100)
|
||||
* @returns {object} 测试请求体
|
||||
*/
|
||||
function createOpenAITestPayload(model = 'gpt-5', options = {}) {
|
||||
const { prompt = 'hi', maxTokens = 100 } = options
|
||||
return {
|
||||
model,
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
max_output_tokens: maxTokens,
|
||||
stream: true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
randomHex,
|
||||
generateSessionString,
|
||||
createClaudeTestPayload,
|
||||
createGeminiTestPayload,
|
||||
createOpenAITestPayload,
|
||||
sendStreamTestRequest
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user