feat: 给key增加总用量限制

This commit is contained in:
itzhan
2025-09-19 21:57:24 +08:00
parent 4d78471891
commit ec28b66e7f
11 changed files with 604 additions and 8 deletions

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env node
const path = require('path')
const Module = require('module')
const originalResolveFilename = Module._resolveFilename
Module._resolveFilename = function resolveConfig(request, parent, isMain, options) {
if (request.endsWith('/config/config')) {
return path.resolve(__dirname, '../config/config.example.js')
}
return originalResolveFilename.call(this, request, parent, isMain, options)
}
const redis = require('../src/models/redis')
const apiKeyService = require('../src/services/apiKeyService')
const { authenticateApiKey } = require('../src/middleware/auth')
Module._resolveFilename = originalResolveFilename
function createMockReq(apiKey) {
return {
headers: {
'x-api-key': apiKey,
'user-agent': 'total-usage-limit-test'
},
query: {},
body: {},
ip: '127.0.0.1',
connection: {},
originalUrl: '/test-total-usage-limit',
once: () => {},
on: () => {},
get(header) {
return this.headers[header.toLowerCase()] || ''
}
}
}
function createMockRes() {
const state = {
status: 200,
body: null
}
return {
once: () => {},
on: () => {},
status(code) {
state.status = code
return this
},
json(payload) {
state.body = payload
return this
},
getState() {
return state
}
}
}
async function runAuth(apiKey) {
const req = createMockReq(apiKey)
const res = createMockRes()
let nextCalled = false
await authenticateApiKey(req, res, () => {
nextCalled = true
})
const result = res.getState()
if (nextCalled && result.status === 200) {
return { status: 200, body: null }
}
return result
}
async function cleanupKey(keyId) {
const client = redis.getClient()
if (!client) {
return
}
try {
await redis.deleteApiKey(keyId)
const usageKeys = await client.keys(`usage:*:${keyId}*`)
if (usageKeys.length > 0) {
await client.del(...usageKeys)
}
const costKeys = await client.keys(`usage:cost:*:${keyId}*`)
if (costKeys.length > 0) {
await client.del(...costKeys)
}
await client.del(`usage:${keyId}`)
await client.del(`usage:records:${keyId}`)
await client.del(`usage:cost:total:${keyId}`)
} catch (error) {
console.warn(`Failed to cleanup test key ${keyId}:`, error.message)
}
}
async function main() {
await redis.connect()
const testName = `TotalUsageLimitTest-${Date.now()}`
const totalLimit = 1.0
const newKey = await apiKeyService.generateApiKey({
name: testName,
permissions: 'all',
totalUsageLimit: totalLimit
})
const keyId = newKey.id
const apiKey = newKey.apiKey
console.log(` Created test API key ${keyId} with total usage limit $${totalLimit}`)
let authResult = await runAuth(apiKey)
if (authResult.status !== 200) {
throw new Error(`Expected success before any usage, got status ${authResult.status}`)
}
console.log('✅ Authentication succeeds before consuming quota')
await redis.incrementDailyCost(keyId, 0.6)
authResult = await runAuth(apiKey)
if (authResult.status !== 200) {
throw new Error(`Expected success under quota, got status ${authResult.status}`)
}
console.log('✅ Authentication succeeds while still under quota ($0.60)')
await redis.incrementDailyCost(keyId, 0.5)
authResult = await runAuth(apiKey)
if (authResult.status !== 429) {
throw new Error(`Expected 429 after exceeding quota, got status ${authResult.status}`)
}
console.log('✅ Authentication returns 429 after exceeding total usage limit ($1.10)')
await cleanupKey(keyId)
await redis.disconnect()
console.log('🎉 Total usage limit test completed successfully')
}
main().catch(async (error) => {
console.error('❌ Total usage limit test failed:', error)
try {
await redis.disconnect()
} catch (_) {}
process.exitCode = 1
})

View File

@@ -308,6 +308,29 @@ const authenticateApiKey = async (req, res, next) => {
} }
} }
// 检查总额度限制(基于累计费用)
const totalUsageLimit = Number(validation.keyData.totalUsageLimit || 0)
if (totalUsageLimit > 0) {
const totalCost = Number(validation.keyData.totalCost || 0)
if (totalCost >= totalUsageLimit) {
logger.security(
`📉 Total usage limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${totalCost.toFixed(2)}/$${totalUsageLimit.toFixed(2)}`
)
return res.status(429).json({
error: 'Total usage limit exceeded',
message: `已达到总额度限制 ($${totalUsageLimit.toFixed(2)})`,
currentCost: totalCost,
costLimit: totalUsageLimit
})
}
logger.api(
`📉 Total usage for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${totalCost.toFixed(2)}/$${totalUsageLimit.toFixed(2)}`
)
}
// 检查每日费用限制 // 检查每日费用限制
const dailyCostLimit = validation.keyData.dailyCostLimit || 0 const dailyCostLimit = validation.keyData.dailyCostLimit || 0
if (dailyCostLimit > 0) { if (dailyCostLimit > 0) {
@@ -333,6 +356,29 @@ const authenticateApiKey = async (req, res, next) => {
) )
} }
// 检查总费用限制
const totalCostLimit = validation.keyData.totalCostLimit || 0
if (totalCostLimit > 0) {
const totalCost = validation.keyData.totalCost || 0
if (totalCost >= totalCostLimit) {
logger.security(
`💰 Total cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}`
)
return res.status(429).json({
error: 'Total cost limit exceeded',
message: `已达到总费用限制 ($${totalCostLimit})`,
currentCost: totalCost,
costLimit: totalCostLimit
})
}
logger.api(
`💰 Total cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${totalCost.toFixed(2)}/$${totalCostLimit}`
)
}
// 检查 Opus 周费用限制(仅对 Opus 模型生效) // 检查 Opus 周费用限制(仅对 Opus 模型生效)
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0 const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
if (weeklyOpusCostLimit > 0) { if (weeklyOpusCostLimit > 0) {
@@ -394,6 +440,9 @@ const authenticateApiKey = async (req, res, next) => {
allowedClients: validation.keyData.allowedClients, allowedClients: validation.keyData.allowedClients,
dailyCostLimit: validation.keyData.dailyCostLimit, dailyCostLimit: validation.keyData.dailyCostLimit,
dailyCost: validation.keyData.dailyCost, dailyCost: validation.keyData.dailyCost,
totalUsageLimit: validation.keyData.totalUsageLimit,
totalCostLimit: validation.keyData.totalCostLimit,
totalCost: validation.keyData.totalCost,
usage: validation.keyData.usage usage: validation.keyData.usage
} }
req.usage = validation.keyData.usage req.usage = validation.keyData.usage

View File

@@ -636,6 +636,48 @@ class RedisClient {
} }
} }
async addUsageRecord(keyId, record, maxRecords = 200) {
const listKey = `usage:records:${keyId}`
const client = this.getClientSafe()
try {
await client
.multi()
.lpush(listKey, JSON.stringify(record))
.ltrim(listKey, 0, Math.max(0, maxRecords - 1))
.expire(listKey, 86400 * 90) // 默认保留90天
.exec()
} catch (error) {
logger.error(`❌ Failed to append usage record for key ${keyId}:`, error)
}
}
async getUsageRecords(keyId, limit = 50) {
const listKey = `usage:records:${keyId}`
const client = this.getClient()
if (!client) {
return []
}
try {
const rawRecords = await client.lrange(listKey, 0, Math.max(0, limit - 1))
return rawRecords
.map((entry) => {
try {
return JSON.parse(entry)
} catch (error) {
logger.warn('⚠️ Failed to parse usage record entry:', error)
return null
}
})
.filter(Boolean)
} catch (error) {
logger.error(`❌ Failed to load usage records for key ${keyId}:`, error)
return []
}
}
// 💰 获取当日费用 // 💰 获取当日费用
async getDailyCost(keyId) { async getDailyCost(keyId) {
const today = getDateStringInTimezone() const today = getDateStringInTimezone()

View File

@@ -533,6 +533,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
enableClientRestriction, enableClientRestriction,
allowedClients, allowedClients,
dailyCostLimit, dailyCostLimit,
totalUsageLimit,
totalCostLimit,
weeklyOpusCostLimit, weeklyOpusCostLimit,
tags, tags,
activationDays, // 新增:激活后有效天数 activationDays, // 新增:激活后有效天数
@@ -615,6 +617,35 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
return res.status(400).json({ error: 'All tags must be non-empty strings' }) return res.status(400).json({ error: 'All tags must be non-empty strings' })
} }
if (
totalUsageLimit !== undefined &&
totalUsageLimit !== null &&
totalUsageLimit !== ''
) {
const usageLimit = Number(totalUsageLimit)
if (Number.isNaN(usageLimit) || usageLimit < 0) {
return res.status(400).json({ error: 'Total usage limit must be a non-negative number' })
}
}
if (
totalCostLimit !== undefined &&
totalCostLimit !== null &&
totalCostLimit !== '' &&
(Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0)
) {
return res.status(400).json({ error: 'Total cost limit must be a non-negative number' })
}
if (
totalCostLimit !== undefined &&
totalCostLimit !== null &&
totalCostLimit !== '' &&
(Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0)
) {
return res.status(400).json({ error: 'Total cost limit must be a non-negative number' })
}
// 验证激活相关字段 // 验证激活相关字段
if (expirationMode && !['fixed', 'activation'].includes(expirationMode)) { if (expirationMode && !['fixed', 'activation'].includes(expirationMode)) {
return res return res
@@ -660,6 +691,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
enableClientRestriction, enableClientRestriction,
allowedClients, allowedClients,
dailyCostLimit, dailyCostLimit,
totalUsageLimit,
totalCostLimit,
weeklyOpusCostLimit, weeklyOpusCostLimit,
tags, tags,
activationDays, activationDays,
@@ -699,6 +732,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
enableClientRestriction, enableClientRestriction,
allowedClients, allowedClients,
dailyCostLimit, dailyCostLimit,
totalUsageLimit,
totalCostLimit,
weeklyOpusCostLimit, weeklyOpusCostLimit,
tags, tags,
activationDays, activationDays,
@@ -748,6 +783,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
enableClientRestriction, enableClientRestriction,
allowedClients, allowedClients,
dailyCostLimit, dailyCostLimit,
totalUsageLimit,
totalCostLimit,
weeklyOpusCostLimit, weeklyOpusCostLimit,
tags, tags,
activationDays, activationDays,
@@ -865,6 +902,12 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
if (updates.dailyCostLimit !== undefined) { if (updates.dailyCostLimit !== undefined) {
finalUpdates.dailyCostLimit = updates.dailyCostLimit finalUpdates.dailyCostLimit = updates.dailyCostLimit
} }
if (updates.totalUsageLimit !== undefined) {
finalUpdates.totalUsageLimit = updates.totalUsageLimit
}
if (updates.totalCostLimit !== undefined) {
finalUpdates.totalCostLimit = updates.totalCostLimit
}
if (updates.weeklyOpusCostLimit !== undefined) { if (updates.weeklyOpusCostLimit !== undefined) {
finalUpdates.weeklyOpusCostLimit = updates.weeklyOpusCostLimit finalUpdates.weeklyOpusCostLimit = updates.weeklyOpusCostLimit
} }
@@ -993,6 +1036,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
allowedClients, allowedClients,
expiresAt, expiresAt,
dailyCostLimit, dailyCostLimit,
totalUsageLimit,
totalCostLimit,
weeklyOpusCostLimit, weeklyOpusCostLimit,
tags, tags,
ownerId // 新增所有者ID字段 ownerId // 新增所有者ID字段
@@ -1142,6 +1187,22 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.dailyCostLimit = costLimit updates.dailyCostLimit = costLimit
} }
if (totalCostLimit !== undefined && totalCostLimit !== null && totalCostLimit !== '') {
const costLimit = Number(totalCostLimit)
if (isNaN(costLimit) || costLimit < 0) {
return res.status(400).json({ error: 'Total cost limit must be a non-negative number' })
}
updates.totalCostLimit = costLimit
}
if (totalUsageLimit !== undefined && totalUsageLimit !== null && totalUsageLimit !== '') {
const usageLimit = Number(totalUsageLimit)
if (Number.isNaN(usageLimit) || usageLimit < 0) {
return res.status(400).json({ error: 'Total usage limit must be a non-negative number' })
}
updates.totalUsageLimit = usageLimit
}
// 处理 Opus 周费用限制 // 处理 Opus 周费用限制
if ( if (
weeklyOpusCostLimit !== undefined && weeklyOpusCostLimit !== undefined &&

View File

@@ -114,6 +114,7 @@ router.post('/api/user-stats', async (req, res) => {
// 获取当日费用统计 // 获取当日费用统计
const dailyCost = await redis.getDailyCost(keyId) const dailyCost = await redis.getDailyCost(keyId)
const costStats = await redis.getCostStats(keyId)
// 处理数据格式,与 validateApiKey 返回的格式保持一致 // 处理数据格式,与 validateApiKey 返回的格式保持一致
// 解析限制模型数据 // 解析限制模型数据
@@ -140,7 +141,10 @@ router.post('/api/user-stats', async (req, res) => {
rateLimitWindow: parseInt(keyData.rateLimitWindow) || 0, rateLimitWindow: parseInt(keyData.rateLimitWindow) || 0,
rateLimitRequests: parseInt(keyData.rateLimitRequests) || 0, rateLimitRequests: parseInt(keyData.rateLimitRequests) || 0,
dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0, dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0,
totalUsageLimit: parseFloat(keyData.totalUsageLimit) || 0,
totalCostLimit: parseFloat(keyData.totalCostLimit) || 0,
dailyCost: dailyCost || 0, dailyCost: dailyCost || 0,
totalCost: costStats.total || 0,
enableModelRestriction: keyData.enableModelRestriction === 'true', enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels, restrictedModels,
enableClientRestriction: keyData.enableClientRestriction === 'true', enableClientRestriction: keyData.enableClientRestriction === 'true',
@@ -372,11 +376,14 @@ router.post('/api/user-stats', async (req, res) => {
rateLimitRequests: fullKeyData.rateLimitRequests || 0, rateLimitRequests: fullKeyData.rateLimitRequests || 0,
rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制 rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制
dailyCostLimit: fullKeyData.dailyCostLimit || 0, dailyCostLimit: fullKeyData.dailyCostLimit || 0,
totalUsageLimit: fullKeyData.totalUsageLimit || 0,
totalCostLimit: fullKeyData.totalCostLimit || 0,
// 当前使用量 // 当前使用量
currentWindowRequests, currentWindowRequests,
currentWindowTokens, currentWindowTokens,
currentWindowCost, // 新增:当前窗口费用 currentWindowCost, // 新增:当前窗口费用
currentDailyCost, currentDailyCost,
currentTotalCost: totalCost,
// 时间窗口信息 // 时间窗口信息
windowStartTime, windowStartTime,
windowEndTime, windowEndTime,

View File

@@ -258,6 +258,9 @@ router.get('/api-keys', authenticateUser, async (req, res) => {
usage: flatUsage, usage: flatUsage,
dailyCost: key.dailyCost, dailyCost: key.dailyCost,
dailyCostLimit: key.dailyCostLimit, dailyCostLimit: key.dailyCostLimit,
totalUsageLimit: key.totalUsageLimit,
totalCost: key.totalCost,
totalCostLimit: key.totalCostLimit,
// 不返回实际的key值只返回前缀和后几位 // 不返回实际的key值只返回前缀和后几位
keyPreview: key.key keyPreview: key.key
? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}` ? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}`
@@ -287,7 +290,15 @@ router.get('/api-keys', authenticateUser, async (req, res) => {
// 🔑 创建新的API Key // 🔑 创建新的API Key
router.post('/api-keys', authenticateUser, async (req, res) => { router.post('/api-keys', authenticateUser, async (req, res) => {
try { try {
const { name, description, tokenLimit, expiresAt, dailyCostLimit } = req.body const {
name,
description,
tokenLimit,
expiresAt,
dailyCostLimit,
totalUsageLimit,
totalCostLimit
} = req.body
if (!name || !name.trim()) { if (!name || !name.trim()) {
return res.status(400).json({ return res.status(400).json({
@@ -296,6 +307,32 @@ router.post('/api-keys', authenticateUser, async (req, res) => {
}) })
} }
if (
totalCostLimit !== undefined &&
totalCostLimit !== null &&
totalCostLimit !== '' &&
(Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0)
) {
return res.status(400).json({
error: 'Invalid total cost limit',
message: 'Total cost limit must be a non-negative number'
})
}
if (
totalUsageLimit !== undefined &&
totalUsageLimit !== null &&
totalUsageLimit !== ''
) {
const usageLimit = Number(totalUsageLimit)
if (Number.isNaN(usageLimit) || usageLimit < 0) {
return res.status(400).json({
error: 'Invalid total usage limit',
message: 'Total usage limit must be a non-negative number'
})
}
}
// 检查用户API Key数量限制 // 检查用户API Key数量限制
const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id) const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id)
if (userApiKeys.length >= config.userManagement.maxApiKeysPerUser) { if (userApiKeys.length >= config.userManagement.maxApiKeysPerUser) {
@@ -314,6 +351,8 @@ router.post('/api-keys', authenticateUser, async (req, res) => {
tokenLimit: tokenLimit || null, tokenLimit: tokenLimit || null,
expiresAt: expiresAt || null, expiresAt: expiresAt || null,
dailyCostLimit: dailyCostLimit || null, dailyCostLimit: dailyCostLimit || null,
totalUsageLimit: totalUsageLimit || null,
totalCostLimit: totalCostLimit || null,
createdBy: 'user', createdBy: 'user',
// 设置服务权限为全部服务,确保前端显示“服务权限”为“全部服务”且具备完整访问权限 // 设置服务权限为全部服务,确保前端显示“服务权限”为“全部服务”且具备完整访问权限
permissions: 'all' permissions: 'all'
@@ -337,6 +376,8 @@ router.post('/api-keys', authenticateUser, async (req, res) => {
tokenLimit: newApiKey.tokenLimit, tokenLimit: newApiKey.tokenLimit,
expiresAt: newApiKey.expiresAt, expiresAt: newApiKey.expiresAt,
dailyCostLimit: newApiKey.dailyCostLimit, dailyCostLimit: newApiKey.dailyCostLimit,
totalUsageLimit: newApiKey.totalUsageLimit,
totalCostLimit: newApiKey.totalCostLimit,
createdAt: newApiKey.createdAt createdAt: newApiKey.createdAt
} }
}) })

View File

@@ -33,6 +33,8 @@ class ApiKeyService {
enableClientRestriction = false, enableClientRestriction = false,
allowedClients = [], allowedClients = [],
dailyCostLimit = 0, dailyCostLimit = 0,
totalUsageLimit = 0,
totalCostLimit = 0,
weeklyOpusCostLimit = 0, weeklyOpusCostLimit = 0,
tags = [], tags = [],
activationDays = 0, // 新增激活后有效天数0表示不使用此功能 activationDays = 0, // 新增激活后有效天数0表示不使用此功能
@@ -68,6 +70,8 @@ class ApiKeyService {
enableClientRestriction: String(enableClientRestriction || false), enableClientRestriction: String(enableClientRestriction || false),
allowedClients: JSON.stringify(allowedClients || []), allowedClients: JSON.stringify(allowedClients || []),
dailyCostLimit: String(dailyCostLimit || 0), dailyCostLimit: String(dailyCostLimit || 0),
totalUsageLimit: String(totalUsageLimit || 0),
totalCostLimit: String(totalCostLimit || 0),
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0), weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
tags: JSON.stringify(tags || []), tags: JSON.stringify(tags || []),
activationDays: String(activationDays || 0), // 新增:激活后有效天数 activationDays: String(activationDays || 0), // 新增:激活后有效天数
@@ -111,6 +115,8 @@ class ApiKeyService {
enableClientRestriction: keyData.enableClientRestriction === 'true', enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients: JSON.parse(keyData.allowedClients || '[]'), allowedClients: JSON.parse(keyData.allowedClients || '[]'),
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
totalUsageLimit: parseFloat(keyData.totalUsageLimit || 0),
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
tags: JSON.parse(keyData.tags || '[]'), tags: JSON.parse(keyData.tags || '[]'),
activationDays: parseInt(keyData.activationDays || 0), activationDays: parseInt(keyData.activationDays || 0),
@@ -188,8 +194,12 @@ class ApiKeyService {
// 获取使用统计(供返回数据使用) // 获取使用统计(供返回数据使用)
const usage = await redis.getUsageStats(keyData.id) const usage = await redis.getUsageStats(keyData.id)
// 获取当日费用统计 // 获取费用统计
const dailyCost = await redis.getDailyCost(keyData.id) const [dailyCost, costStats] = await Promise.all([
redis.getDailyCost(keyData.id),
redis.getCostStats(keyData.id)
])
const totalCost = costStats?.total || 0
// 更新最后使用时间优化只在实际API调用时更新而不是验证时 // 更新最后使用时间优化只在实际API调用时更新而不是验证时
// 注意lastUsedAt的更新已移至recordUsage方法中 // 注意lastUsedAt的更新已移至recordUsage方法中
@@ -245,8 +255,11 @@ class ApiKeyService {
enableClientRestriction: keyData.enableClientRestriction === 'true', enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients, allowedClients,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
totalUsageLimit: parseFloat(keyData.totalUsageLimit || 0),
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
dailyCost: dailyCost || 0, dailyCost: dailyCost || 0,
totalCost,
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0, weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
tags, tags,
usage usage
@@ -306,7 +319,10 @@ class ApiKeyService {
} }
// 获取当日费用 // 获取当日费用
const dailyCost = (await redis.getDailyCost(keyData.id)) || 0 const [dailyCost, costStats] = await Promise.all([
redis.getDailyCost(keyData.id),
redis.getCostStats(keyData.id)
])
// 获取使用统计 // 获取使用统计
const usage = await redis.getUsageStats(keyData.id) const usage = await redis.getUsageStats(keyData.id)
@@ -365,8 +381,11 @@ class ApiKeyService {
enableClientRestriction: keyData.enableClientRestriction === 'true', enableClientRestriction: keyData.enableClientRestriction === 'true',
allowedClients, allowedClients,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
totalUsageLimit: parseFloat(keyData.totalUsageLimit || 0),
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
dailyCost: dailyCost || 0, dailyCost: dailyCost || 0,
totalCost: costStats?.total || 0,
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0, weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
tags, tags,
usage usage
@@ -405,12 +424,17 @@ class ApiKeyService {
key.rateLimitWindow = parseInt(key.rateLimitWindow || 0) key.rateLimitWindow = parseInt(key.rateLimitWindow || 0)
key.rateLimitRequests = parseInt(key.rateLimitRequests || 0) key.rateLimitRequests = parseInt(key.rateLimitRequests || 0)
key.rateLimitCost = parseFloat(key.rateLimitCost || 0) // 新增:速率限制费用字段 key.rateLimitCost = parseFloat(key.rateLimitCost || 0) // 新增:速率限制费用字段
key.totalUsageLimit = parseFloat(key.totalUsageLimit || 0)
if (Number.isNaN(key.totalUsageLimit)) {
key.totalUsageLimit = 0
}
key.currentConcurrency = await redis.getConcurrency(key.id) key.currentConcurrency = await redis.getConcurrency(key.id)
key.isActive = key.isActive === 'true' key.isActive = key.isActive === 'true'
key.enableModelRestriction = key.enableModelRestriction === 'true' key.enableModelRestriction = key.enableModelRestriction === 'true'
key.enableClientRestriction = key.enableClientRestriction === 'true' key.enableClientRestriction = key.enableClientRestriction === 'true'
key.permissions = key.permissions || 'all' // 兼容旧数据 key.permissions = key.permissions || 'all' // 兼容旧数据
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
key.totalCostLimit = parseFloat(key.totalCostLimit || 0)
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0) key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
key.dailyCost = (await redis.getDailyCost(key.id)) || 0 key.dailyCost = (await redis.getDailyCost(key.id)) || 0
key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0 key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0
@@ -532,6 +556,8 @@ class ApiKeyService {
'enableClientRestriction', 'enableClientRestriction',
'allowedClients', 'allowedClients',
'dailyCostLimit', 'dailyCostLimit',
'totalUsageLimit',
'totalCostLimit',
'weeklyOpusCostLimit', 'weeklyOpusCostLimit',
'tags', 'tags',
'userId', // 新增用户ID所有者变更 'userId', // 新增用户ID所有者变更
@@ -827,6 +853,21 @@ class ApiKeyService {
} }
} }
// 记录单次请求的使用详情
const usageCost = costInfo && costInfo.costs ? costInfo.costs.total || 0 : 0
await redis.addUsageRecord(keyId, {
timestamp: new Date().toISOString(),
model,
accountId: accountId || null,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens,
cost: Number(usageCost.toFixed(6)),
costBreakdown: costInfo && costInfo.costs ? costInfo.costs : undefined
})
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`] const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]
if (cacheCreateTokens > 0) { if (cacheCreateTokens > 0) {
logParts.push(`Cache Create: ${cacheCreateTokens}`) logParts.push(`Cache Create: ${cacheCreateTokens}`)
@@ -973,6 +1014,32 @@ class ApiKeyService {
} }
} }
const usageRecord = {
timestamp: new Date().toISOString(),
model,
accountId: accountId || null,
accountType: accountType || null,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
ephemeral5mTokens,
ephemeral1hTokens,
totalTokens,
cost: Number((costInfo.totalCost || 0).toFixed(6)),
costBreakdown: {
input: costInfo.inputCost || 0,
output: costInfo.outputCost || 0,
cacheCreate: costInfo.cacheCreateCost || 0,
cacheRead: costInfo.cacheReadCost || 0,
ephemeral5m: costInfo.ephemeral5mCost || 0,
ephemeral1h: costInfo.ephemeral1hCost || 0
},
isLongContext: costInfo.isLongContextRequest || false
}
await redis.addUsageRecord(keyId, usageRecord)
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`] const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]
if (cacheCreateTokens > 0) { if (cacheCreateTokens > 0) {
logParts.push(`Cache Create: ${cacheCreateTokens}`) logParts.push(`Cache Create: ${cacheCreateTokens}`)
@@ -1014,8 +1081,24 @@ class ApiKeyService {
} }
// 📈 获取使用统计 // 📈 获取使用统计
async getUsageStats(keyId) { async getUsageStats(keyId, options = {}) {
return await redis.getUsageStats(keyId) const usageStats = await redis.getUsageStats(keyId)
// options 可能是字符串(兼容旧接口),仅当为对象时才解析
const optionObject =
options && typeof options === 'object' && !Array.isArray(options) ? options : {}
if (optionObject.includeRecords === false) {
return usageStats
}
const recordLimit = optionObject.recordLimit || 20
const recentRecords = await redis.getUsageRecords(keyId, recordLimit)
return {
...usageStats,
recentRecords
}
} }
// 📊 获取账户使用统计 // 📊 获取账户使用统计
@@ -1067,6 +1150,8 @@ class ApiKeyService {
dailyCost, dailyCost,
totalCost: costStats.total, totalCost: costStats.total,
dailyCostLimit: parseFloat(key.dailyCostLimit || 0), dailyCostLimit: parseFloat(key.dailyCostLimit || 0),
totalUsageLimit: parseFloat(key.totalUsageLimit || 0),
totalCostLimit: parseFloat(key.totalCostLimit || 0),
userId: key.userId, userId: key.userId,
userUsername: key.userUsername, userUsername: key.userUsername,
createdBy: key.createdBy, createdBy: key.createdBy,
@@ -1112,7 +1197,9 @@ class ApiKeyService {
userUsername: keyData.userUsername, userUsername: keyData.userUsername,
createdBy: keyData.createdBy, createdBy: keyData.createdBy,
permissions: keyData.permissions, permissions: keyData.permissions,
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0) dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
totalUsageLimit: parseFloat(keyData.totalUsageLimit || 0),
totalCostLimit: parseFloat(keyData.totalCostLimit || 0)
} }
} catch (error) { } catch (error) {
logger.error('❌ Failed to get API key by ID:', error) logger.error('❌ Failed to get API key by ID:', error)

View File

@@ -218,6 +218,20 @@
/> />
</div> </div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
总费用限制 (美元)
</label>
<input
v-model="form.totalUsageLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0"
placeholder="不修改 (0 表示无限制)"
step="0.01"
type="number"
/>
</div>
<!-- Opus 模型周费用限制 --> <!-- Opus 模型周费用限制 -->
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
@@ -521,6 +535,7 @@ const form = reactive({
rateLimitRequests: '', rateLimitRequests: '',
concurrencyLimit: '', concurrencyLimit: '',
dailyCostLimit: '', dailyCostLimit: '',
totalUsageLimit: '',
weeklyOpusCostLimit: '', // 新增Opus周费用限制 weeklyOpusCostLimit: '', // 新增Opus周费用限制
permissions: '', // 空字符串表示不修改 permissions: '', // 空字符串表示不修改
claudeAccountId: '', claudeAccountId: '',
@@ -652,6 +667,9 @@ const batchUpdateApiKeys = async () => {
if (form.dailyCostLimit !== '' && form.dailyCostLimit !== null) { if (form.dailyCostLimit !== '' && form.dailyCostLimit !== null) {
updates.dailyCostLimit = parseFloat(form.dailyCostLimit) updates.dailyCostLimit = parseFloat(form.dailyCostLimit)
} }
if (form.totalUsageLimit !== '' && form.totalUsageLimit !== null) {
updates.totalUsageLimit = parseFloat(form.totalUsageLimit)
}
if (form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null) { if (form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null) {
updates.weeklyOpusCostLimit = parseFloat(form.weeklyOpusCostLimit) updates.weeklyOpusCostLimit = parseFloat(form.weeklyOpusCostLimit)
} }

View File

@@ -329,12 +329,61 @@
step="0.01" step="0.01"
type="number" type="number"
/> />
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="dark:text灰-400 text-xs text-gray-500">
设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制 设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制
</p> </p>
</div> </div>
</div> </div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>总费用限制 (美元)</label
>
<div class="space-y-2">
<div class="flex gap-2">
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.totalUsageLimit = '100'"
>
$100
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.totalUsageLimit = '500'"
>
$500
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.totalUsageLimit = '1000'"
>
$1000
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.totalUsageLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.totalUsageLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置此 API Key 的累计总费用限制达到限制后将拒绝所有后续请求0 或留空表示无限制
</p>
</div>
</div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label >Opus 模型周费用限制 (美元)</label
@@ -861,6 +910,7 @@ const form = reactive({
rateLimitCost: '', // 新增:费用限制 rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '', concurrencyLimit: '',
dailyCostLimit: '', dailyCostLimit: '',
totalUsageLimit: '',
weeklyOpusCostLimit: '', weeklyOpusCostLimit: '',
expireDuration: '', expireDuration: '',
customExpireDate: '', customExpireDate: '',
@@ -1199,6 +1249,10 @@ const createApiKey = async () => {
form.dailyCostLimit !== '' && form.dailyCostLimit !== null form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit) ? parseFloat(form.dailyCostLimit)
: 0, : 0,
totalUsageLimit:
form.totalUsageLimit !== '' && form.totalUsageLimit !== null
? parseFloat(form.totalUsageLimit)
: 0,
weeklyOpusCostLimit: weeklyOpusCostLimit:
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit) ? parseFloat(form.weeklyOpusCostLimit)

View File

@@ -273,6 +273,55 @@
</div> </div>
</div> </div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>总费用限制 (美元)</label
>
<div class="space-y-3">
<div class="flex gap-2">
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.totalUsageLimit = '100'"
>
$100
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.totalUsageLimit = '500'"
>
$500
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.totalUsageLimit = '1000'"
>
$1000
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.totalUsageLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.totalUsageLimit"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置此 API Key 的累计总费用限制达到限制后将拒绝所有后续请求0 或留空表示无限制
</p>
</div>
</div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label >Opus 模型周费用限制 (美元)</label
@@ -713,6 +762,7 @@ const form = reactive({
rateLimitCost: '', // 新增:费用限制 rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '', concurrencyLimit: '',
dailyCostLimit: '', dailyCostLimit: '',
totalUsageLimit: '',
weeklyOpusCostLimit: '', weeklyOpusCostLimit: '',
permissions: 'all', permissions: 'all',
claudeAccountId: '', claudeAccountId: '',
@@ -826,6 +876,10 @@ const updateApiKey = async () => {
form.dailyCostLimit !== '' && form.dailyCostLimit !== null form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit) ? parseFloat(form.dailyCostLimit)
: 0, : 0,
totalUsageLimit:
form.totalUsageLimit !== '' && form.totalUsageLimit !== null
? parseFloat(form.totalUsageLimit)
: 0,
weeklyOpusCostLimit: weeklyOpusCostLimit:
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit) ? parseFloat(form.weeklyOpusCostLimit)
@@ -1100,6 +1154,7 @@ onMounted(async () => {
form.rateLimitRequests = props.apiKey.rateLimitRequests || '' form.rateLimitRequests = props.apiKey.rateLimitRequests || ''
form.concurrencyLimit = props.apiKey.concurrencyLimit || '' form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
form.dailyCostLimit = props.apiKey.dailyCostLimit || '' form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.totalUsageLimit = props.apiKey.totalUsageLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || '' form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
form.permissions = props.apiKey.permissions || 'all' form.permissions = props.apiKey.permissions || 'all'
// 处理 Claude 账号(区分 OAuth 和 Console // 处理 Claude 账号(区分 OAuth 和 Console

View File

@@ -190,6 +190,31 @@
</span> </span>
</div> </div>
<div v-if="apiKey.totalUsageLimit > 0" class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">总费用限制</span>
<span class="font-semibold text-gray-900 dark:text-gray-100">
${{ apiKey.totalUsageLimit.toFixed(2) }}
</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-600">
<div
class="h-2 rounded-full transition-all duration-300"
:class="
totalUsagePercentage >= 100
? 'bg-red-500'
: totalUsagePercentage >= 80
? 'bg-yellow-500'
: 'bg-indigo-500'
"
:style="{ width: Math.min(totalUsagePercentage, 100) + '%' }"
/>
</div>
<div class="text-right text-xs text-gray-500 dark:text-gray-400">
已使用 {{ totalUsagePercentage.toFixed(1) }}%
</div>
</div>
<div v-if="apiKey.rateLimitWindow > 0" class="space-y-2"> <div v-if="apiKey.rateLimitWindow > 0" class="space-y-2">
<h5 class="text-sm font-medium text-gray-700 dark:text-gray-300"> <h5 class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-clock mr-1 text-blue-500" /> <i class="fas fa-clock mr-1 text-blue-500" />
@@ -250,6 +275,7 @@ const totalTokens = computed(() => props.apiKey.usage?.total?.tokens || 0)
const dailyTokens = computed(() => props.apiKey.usage?.daily?.tokens || 0) const dailyTokens = computed(() => props.apiKey.usage?.daily?.tokens || 0)
const totalCost = computed(() => props.apiKey.usage?.total?.cost || 0) const totalCost = computed(() => props.apiKey.usage?.total?.cost || 0)
const dailyCost = computed(() => props.apiKey.dailyCost || 0) const dailyCost = computed(() => props.apiKey.dailyCost || 0)
const totalUsageLimit = computed(() => props.apiKey.totalUsageLimit || 0)
const inputTokens = computed(() => props.apiKey.usage?.total?.inputTokens || 0) const inputTokens = computed(() => props.apiKey.usage?.total?.inputTokens || 0)
const outputTokens = computed(() => props.apiKey.usage?.total?.outputTokens || 0) const outputTokens = computed(() => props.apiKey.usage?.total?.outputTokens || 0)
const cacheCreateTokens = computed(() => props.apiKey.usage?.total?.cacheCreateTokens || 0) const cacheCreateTokens = computed(() => props.apiKey.usage?.total?.cacheCreateTokens || 0)
@@ -260,6 +286,7 @@ const tpm = computed(() => props.apiKey.usage?.averages?.tpm || 0)
const hasLimits = computed(() => { const hasLimits = computed(() => {
return ( return (
props.apiKey.dailyCostLimit > 0 || props.apiKey.dailyCostLimit > 0 ||
props.apiKey.totalUsageLimit > 0 ||
props.apiKey.concurrencyLimit > 0 || props.apiKey.concurrencyLimit > 0 ||
props.apiKey.rateLimitWindow > 0 || props.apiKey.rateLimitWindow > 0 ||
props.apiKey.tokenLimit > 0 props.apiKey.tokenLimit > 0
@@ -271,6 +298,11 @@ const dailyCostPercentage = computed(() => {
return (dailyCost.value / props.apiKey.dailyCostLimit) * 100 return (dailyCost.value / props.apiKey.dailyCostLimit) * 100
}) })
const totalUsagePercentage = computed(() => {
if (!totalUsageLimit.value || totalUsageLimit.value === 0) return 0
return (totalCost.value / totalUsageLimit.value) * 100
})
// 方法 // 方法
const formatNumber = (num) => { const formatNumber = (num) => {
if (!num && num !== 0) return '0' if (!num && num !== 0) return '0'