This commit is contained in:
SunSeekerX
2026-01-19 20:24:47 +08:00
parent 12fd5e1cb4
commit 76ecbe18a5
98 changed files with 8182 additions and 1896 deletions

View File

@@ -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) {

View File

@@ -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
}
}
}
// 导出实例和单独的方法

View 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()

View 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()