mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
- 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>
699 lines
22 KiB
JavaScript
699 lines
22 KiB
JavaScript
/**
|
||
* 额度卡/时间卡服务
|
||
* 管理员生成卡,用户核销,管理员可撤销
|
||
*/
|
||
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()
|