Files
claude-relay-service/src/services/quotaCardService.js
root 83cbaf7c3e fix: resolve all ESLint errors
- droidRelayService: add missing keyId variable declaration
- quotaCardService: use object destructuring for actualDeducted
- apiKeyService: remove unused variables and duplicate requires
- redis: remove shadowed logger/config requires
- unifiedGeminiScheduler: rename isActive param to avoid shadow
- commonHelper: add comments to empty catch blocks
- testPayloadHelper: prefix unused model param with underscore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:14:22 +08:00

699 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 额度卡/时间卡服务
* 管理员生成卡,用户核销,管理员可撤销
*/
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' // 卡号前缀
this.LIMITS_CONFIG_KEY = 'system:quota_card_limits'
}
/**
* 获取额度卡上限配置
*/
async getLimitsConfig() {
try {
const configStr = await redis.client.get(this.LIMITS_CONFIG_KEY)
if (configStr) {
return JSON.parse(configStr)
}
// 没有 Redis 配置时,使用 config.js 默认值
const config = require('../../config/config')
return (
config.quotaCardLimits || {
enabled: true,
maxExpiryDays: 90,
maxTotalCostLimit: 1000
}
)
} catch (error) {
logger.error('❌ Failed to get limits config:', error)
return { enabled: true, maxExpiryDays: 90, maxTotalCostLimit: 1000 }
}
}
/**
* 保存额度卡上限配置
*/
async saveLimitsConfig(config) {
try {
const parsedDays = parseInt(config.maxExpiryDays)
const parsedCost = parseFloat(config.maxTotalCostLimit)
const newConfig = {
enabled: config.enabled !== false,
maxExpiryDays: Number.isNaN(parsedDays) ? 90 : parsedDays,
maxTotalCostLimit: Number.isNaN(parsedCost) ? 1000 : parsedCost,
updatedAt: new Date().toISOString()
}
await redis.client.set(this.LIMITS_CONFIG_KEY, JSON.stringify(newConfig))
logger.info('✅ Quota card limits config saved')
return newConfig
} catch (error) {
logger.error('❌ Failed to save limits config:', error)
throw error
}
}
/**
* 生成卡号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('卡号不存在')
}
// 检查卡状态
if (card.status !== 'unused') {
const statusMap = { used: '已使用', expired: '已过期', revoked: '已撤销' }
throw new Error(`卡片${statusMap[card.status] || card.status},无法兑换`)
}
// 检查卡是否过期
if (card.expiresAt && new Date(card.expiresAt) < new Date()) {
// 更新卡状态为过期
await this._updateCardStatus(card.id, 'expired')
throw new Error('卡片已过期')
}
// 获取 API Key 信息
const apiKeyService = require('./apiKeyService')
const keyData = await redis.getApiKey(apiKeyId)
if (!keyData || Object.keys(keyData).length === 0) {
throw new Error('API Key 不存在')
}
// 获取上限配置
const limits = await this.getLimitsConfig()
// 执行核销
const redemptionId = uuidv4()
const now = new Date().toISOString()
// 记录核销前状态
const beforeLimit = parseFloat(keyData.totalCostLimit || 0)
const beforeExpiry = keyData.expiresAt || ''
// 应用卡效果
let afterLimit = beforeLimit
let afterExpiry = beforeExpiry
let quotaAdded = 0
let timeAdded = 0
let actualTimeUnit = card.timeUnit // 实际使用的时间单位(截断时会改为 days
const warnings = [] // 截断警告信息
if (card.type === 'quota' || card.type === 'combo') {
let amountToAdd = card.quotaAmount
// 上限保护:检查是否超过最大额度限制
if (limits.enabled && limits.maxTotalCostLimit > 0) {
const maxAllowed = limits.maxTotalCostLimit - beforeLimit
if (amountToAdd > maxAllowed) {
amountToAdd = Math.max(0, maxAllowed)
warnings.push(
`额度已达上限,本次仅增加 ${amountToAdd} CC原卡面 ${card.quotaAmount} CC`
)
logger.warn(`额度卡兑换超出上限,已截断:原 ${card.quotaAmount} -> 实际 ${amountToAdd}`)
}
}
if (amountToAdd > 0) {
const result = await apiKeyService.addTotalCostLimit(apiKeyId, amountToAdd)
afterLimit = result.newTotalCostLimit
quotaAdded = amountToAdd
}
}
if (card.type === 'time' || card.type === 'combo') {
// 计算新的过期时间
let baseDate = beforeExpiry ? new Date(beforeExpiry) : new Date()
if (baseDate < new Date()) {
baseDate = new Date()
}
let newExpiry = new Date(baseDate)
switch (card.timeUnit) {
case 'hours':
newExpiry.setTime(newExpiry.getTime() + card.timeAmount * 60 * 60 * 1000)
break
case 'days':
newExpiry.setDate(newExpiry.getDate() + card.timeAmount)
break
case 'months':
newExpiry.setMonth(newExpiry.getMonth() + card.timeAmount)
break
}
// 上限保护:检查是否超过最大有效期
if (limits.enabled && limits.maxExpiryDays > 0) {
const maxExpiry = new Date()
maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays)
if (newExpiry > maxExpiry) {
newExpiry = maxExpiry
warnings.push(`有效期已达上限(${limits.maxExpiryDays}天),时间已截断`)
logger.warn(`时间卡兑换超出上限,已截断至 ${maxExpiry.toISOString()}`)
}
}
const result = await apiKeyService.extendExpiry(apiKeyId, card.timeAmount, card.timeUnit)
// 如果有上限保护,使用截断后的时间
if (limits.enabled && limits.maxExpiryDays > 0) {
const maxExpiry = new Date()
maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays)
if (new Date(result.newExpiresAt) > maxExpiry) {
await redis.client.hset(`apikey:${apiKeyId}`, 'expiresAt', maxExpiry.toISOString())
afterExpiry = maxExpiry.toISOString()
// 计算实际增加的天数,截断时统一用天
const actualDays = Math.max(
0,
Math.ceil((maxExpiry - baseDate) / (1000 * 60 * 60 * 24))
)
timeAdded = actualDays
actualTimeUnit = 'days'
} else {
afterExpiry = result.newExpiresAt
timeAdded = card.timeAmount
}
} else {
afterExpiry = result.newExpiresAt
timeAdded = card.timeAmount
}
}
// 更新卡状态
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(quotaAdded),
timeAdded: String(timeAdded),
timeUnit: actualTimeUnit,
beforeLimit: String(beforeLimit),
afterLimit: String(afterLimit),
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,
warnings,
redemptionId,
cardCode: card.code,
cardType: card.type,
quotaAdded,
timeAdded,
timeUnit: actualTimeUnit,
beforeLimit,
afterLimit,
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 actualDeducted = 0
if (parseFloat(redemptionData.quotaAdded) > 0) {
const result = await apiKeyService.deductTotalCostLimit(
redemptionData.apiKeyId,
parseFloat(redemptionData.quotaAdded)
)
;({ actualDeducted } = result)
}
// 注意:时间卡撤销比较复杂,这里简化处理,不回退时间
// 如果需要回退时间,可以在这里添加逻辑
// 更新核销记录状态
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, {
status: 'revoked',
revokedAt: now,
revokedBy,
revokeReason: reason,
actualDeducted: String(actualDeducted)
})
// 更新卡状态
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,
actualDeducted,
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,
beforeLimit: parseFloat(data.beforeLimit || 0),
afterLimit: parseFloat(data.afterLimit || 0),
beforeExpiry: data.beforeExpiry,
afterExpiry: data.afterExpiry,
timestamp: data.timestamp,
status: data.status,
revokedAt: data.revokedAt,
revokedBy: data.revokedBy,
revokeReason: data.revokeReason,
actualDeducted: parseFloat(data.actualDeducted || 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()