mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-05-06 12:11:36 +00:00
Merge pull request #375 from Edric-Li/main
增强API KEYS 页面、增强粘性回话的续期、增强错误处理
This commit is contained in:
@@ -22,6 +22,12 @@ REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
REDIS_ENABLE_TLS=
|
||||
|
||||
# 🔗 会话管理配置
|
||||
# 粘性会话TTL配置(小时),默认1小时
|
||||
STICKY_SESSION_TTL_HOURS=1
|
||||
# 续期阈值(分钟),默认0分钟(不续期)
|
||||
STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=0
|
||||
|
||||
# 🎯 Claude API 配置
|
||||
CLAUDE_API_URL=https://api.anthropic.com/v1/messages
|
||||
CLAUDE_API_VERSION=2023-06-01
|
||||
|
||||
@@ -32,6 +32,14 @@ const config = {
|
||||
enableTLS: process.env.REDIS_ENABLE_TLS === 'true'
|
||||
},
|
||||
|
||||
// 🔗 会话管理配置
|
||||
session: {
|
||||
// 粘性会话TTL配置(小时),默认1小时
|
||||
stickyTtlHours: parseFloat(process.env.STICKY_SESSION_TTL_HOURS) || 1,
|
||||
// 续期阈值(分钟),默认0分钟(不续期)
|
||||
renewalThresholdMinutes: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES) || 0
|
||||
},
|
||||
|
||||
// 🎯 Claude API配置
|
||||
claude: {
|
||||
apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages',
|
||||
|
||||
@@ -1356,9 +1356,12 @@ class RedisClient {
|
||||
}
|
||||
|
||||
// 🔗 会话sticky映射管理
|
||||
async setSessionAccountMapping(sessionHash, accountId, ttl = 3600) {
|
||||
async setSessionAccountMapping(sessionHash, accountId, ttl = null) {
|
||||
const appConfig = require('../../config/config')
|
||||
// 从配置读取TTL(小时),转换为秒,默认1小时
|
||||
const defaultTTL = ttl !== null ? ttl : (appConfig.session?.stickyTtlHours || 1) * 60 * 60
|
||||
const key = `sticky_session:${sessionHash}`
|
||||
await this.client.set(key, accountId, 'EX', ttl)
|
||||
await this.client.set(key, accountId, 'EX', defaultTTL)
|
||||
}
|
||||
|
||||
async getSessionAccountMapping(sessionHash) {
|
||||
@@ -1366,6 +1369,57 @@ class RedisClient {
|
||||
return await this.client.get(key)
|
||||
}
|
||||
|
||||
// 🚀 智能会话TTL续期:剩余时间少于阈值时自动续期
|
||||
async extendSessionAccountMappingTTL(sessionHash) {
|
||||
const appConfig = require('../../config/config')
|
||||
const key = `sticky_session:${sessionHash}`
|
||||
|
||||
// 📊 从配置获取参数
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1 // 小时,默认1小时
|
||||
const thresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 // 分钟,默认0(不续期)
|
||||
|
||||
// 如果阈值为0,不执行续期
|
||||
if (thresholdMinutes === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fullTTL = ttlHours * 60 * 60 // 转换为秒
|
||||
const renewalThreshold = thresholdMinutes * 60 // 转换为秒
|
||||
|
||||
try {
|
||||
// 获取当前剩余TTL(秒)
|
||||
const remainingTTL = await this.client.ttl(key)
|
||||
|
||||
// 键不存在或已过期
|
||||
if (remainingTTL === -2) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 键存在但没有TTL(永不过期,不需要处理)
|
||||
if (remainingTTL === -1) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 🎯 智能续期策略:仅在剩余时间少于阈值时才续期
|
||||
if (remainingTTL < renewalThreshold) {
|
||||
await this.client.expire(key, fullTTL)
|
||||
logger.debug(
|
||||
`🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}min, renewed to ${ttlHours}h)`
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
// 剩余时间充足,无需续期
|
||||
logger.debug(
|
||||
`✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}min)`
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to extend session TTL:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSessionAccountMapping(sessionHash) {
|
||||
const key = `sticky_session:${sessionHash}`
|
||||
return await this.client.del(key)
|
||||
|
||||
@@ -122,7 +122,7 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
|
||||
// 获取所有API Keys
|
||||
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { timeRange = 'all' } = req.query // all, 7days, monthly
|
||||
const { timeRange = 'all', startDate, endDate } = req.query // all, 7days, monthly, custom
|
||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||
|
||||
// 获取用户服务来补充owner信息
|
||||
@@ -132,7 +132,32 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
const now = new Date()
|
||||
const searchPatterns = []
|
||||
|
||||
if (timeRange === 'today') {
|
||||
if (timeRange === 'custom' && startDate && endDate) {
|
||||
// 自定义日期范围
|
||||
const redisClient = require('../models/redis')
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
|
||||
// 确保日期范围有效
|
||||
if (start > end) {
|
||||
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
||||
}
|
||||
|
||||
// 限制最大范围为365天
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||
if (daysDiff > 365) {
|
||||
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
||||
}
|
||||
|
||||
// 生成日期范围内每天的搜索模式
|
||||
const currentDate = new Date(start)
|
||||
while (currentDate <= end) {
|
||||
const tzDate = redisClient.getDateInTimezone(currentDate)
|
||||
const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}`
|
||||
searchPatterns.push(`usage:daily:*:${dateStr}`)
|
||||
currentDate.setDate(currentDate.getDate() + 1)
|
||||
}
|
||||
} else if (timeRange === 'today') {
|
||||
// 今日 - 使用时区日期
|
||||
const redisClient = require('../models/redis')
|
||||
const tzDate = redisClient.getDateInTimezone(now)
|
||||
@@ -233,7 +258,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost)
|
||||
}
|
||||
} else {
|
||||
// 7天或本月:重新计算统计数据
|
||||
// 7天、本月或自定义日期范围:重新计算统计数据
|
||||
const tempUsage = {
|
||||
requests: 0,
|
||||
tokens: 0,
|
||||
@@ -274,12 +299,28 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
const tzDate = redisClient.getDateInTimezone(now)
|
||||
const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
const modelKeys =
|
||||
timeRange === 'today'
|
||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
|
||||
: timeRange === '7days'
|
||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
|
||||
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`)
|
||||
let modelKeys = []
|
||||
if (timeRange === 'custom' && startDate && endDate) {
|
||||
// 自定义日期范围:获取范围内所有日期的模型统计
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
const currentDate = new Date(start)
|
||||
|
||||
while (currentDate <= end) {
|
||||
const tzDateForKey = redisClient.getDateInTimezone(currentDate)
|
||||
const dateStr = `${tzDateForKey.getUTCFullYear()}-${String(tzDateForKey.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDateForKey.getUTCDate()).padStart(2, '0')}`
|
||||
const dayKeys = await client.keys(`usage:${apiKey.id}:model:daily:*:${dateStr}`)
|
||||
modelKeys = modelKeys.concat(dayKeys)
|
||||
currentDate.setDate(currentDate.getDate() + 1)
|
||||
}
|
||||
} else {
|
||||
modelKeys =
|
||||
timeRange === 'today'
|
||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
|
||||
: timeRange === '7days'
|
||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
|
||||
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`)
|
||||
}
|
||||
|
||||
const modelStatsMap = new Map()
|
||||
|
||||
@@ -295,8 +336,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else if (timeRange === 'today') {
|
||||
// today选项已经在查询时过滤了,不需要额外处理
|
||||
} else if (timeRange === 'today' || timeRange === 'custom') {
|
||||
// today和custom选项已经在查询时过滤了,不需要额外处理
|
||||
}
|
||||
|
||||
const modelMatch = key.match(
|
||||
@@ -619,7 +660,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
weeklyOpusCostLimit,
|
||||
tags,
|
||||
activationDays,
|
||||
expirationMode
|
||||
expirationMode,
|
||||
icon
|
||||
})
|
||||
|
||||
logger.success(`🔑 Admin created new API key: ${name}`)
|
||||
@@ -655,7 +697,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
weeklyOpusCostLimit,
|
||||
tags,
|
||||
activationDays,
|
||||
expirationMode
|
||||
expirationMode,
|
||||
icon
|
||||
} = req.body
|
||||
|
||||
// 输入验证
|
||||
@@ -701,7 +744,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
weeklyOpusCostLimit,
|
||||
tags,
|
||||
activationDays,
|
||||
expirationMode
|
||||
expirationMode,
|
||||
icon
|
||||
})
|
||||
|
||||
// 保留原始 API Key 供返回
|
||||
@@ -3878,10 +3922,10 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
||||
}
|
||||
|
||||
// 限制最大范围为31天
|
||||
// 限制最大范围为365天
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||
if (daysDiff > 31) {
|
||||
return res.status(400).json({ error: 'Date range cannot exceed 31 days' })
|
||||
if (daysDiff > 365) {
|
||||
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
||||
}
|
||||
|
||||
// 生成日期范围内所有日期的搜索模式
|
||||
@@ -4342,10 +4386,10 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) =
|
||||
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
||||
}
|
||||
|
||||
// 限制最大范围为31天
|
||||
// 限制最大范围为365天
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||
if (daysDiff > 31) {
|
||||
return res.status(400).json({ error: 'Date range cannot exceed 31 days' })
|
||||
if (daysDiff > 365) {
|
||||
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
||||
}
|
||||
|
||||
// 生成日期范围内所有日期的搜索模式
|
||||
|
||||
@@ -36,7 +36,8 @@ class ApiKeyService {
|
||||
weeklyOpusCostLimit = 0,
|
||||
tags = [],
|
||||
activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
|
||||
expirationMode = 'fixed' // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
||||
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
||||
icon = '' // 新增:图标(base64编码)
|
||||
} = options
|
||||
|
||||
// 生成简单的API Key (64字符十六进制)
|
||||
@@ -78,7 +79,8 @@ class ApiKeyService {
|
||||
expiresAt: expirationMode === 'fixed' ? expiresAt || '' : '', // 固定模式才设置过期时间
|
||||
createdBy: options.createdBy || 'admin',
|
||||
userId: options.userId || '',
|
||||
userUsername: options.userUsername || ''
|
||||
userUsername: options.userUsername || '',
|
||||
icon: icon || '' // 新增:图标(base64编码)
|
||||
}
|
||||
|
||||
// 保存API Key数据并建立哈希映射
|
||||
|
||||
@@ -3,8 +3,8 @@ const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const axios = require('axios')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { maskToken } = require('../utils/tokenMask')
|
||||
const {
|
||||
logRefreshStart,
|
||||
@@ -707,6 +707,8 @@ class ClaudeAccountService {
|
||||
// 验证映射的账户是否仍然可用
|
||||
const mappedAccount = activeAccounts.find((acc) => acc.id === mappedAccountId)
|
||||
if (mappedAccount) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -733,7 +735,9 @@ class ClaudeAccountService {
|
||||
|
||||
// 如果有会话哈希,建立新的映射
|
||||
if (sessionHash) {
|
||||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期
|
||||
// 从配置获取TTL(小时),转换为秒
|
||||
const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60
|
||||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds)
|
||||
logger.info(
|
||||
`🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -827,6 +831,8 @@ class ClaudeAccountService {
|
||||
)
|
||||
await redis.deleteSessionAccountMapping(sessionHash)
|
||||
} else {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -885,7 +891,9 @@ class ClaudeAccountService {
|
||||
|
||||
// 如果有会话哈希,建立新的映射
|
||||
if (sessionHash) {
|
||||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期
|
||||
// 从配置获取TTL(小时),转换为秒
|
||||
const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60
|
||||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds)
|
||||
logger.info(
|
||||
`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`
|
||||
)
|
||||
|
||||
@@ -211,19 +211,7 @@ class ClaudeRelayService {
|
||||
// 检查是否为5xx状态码
|
||||
else if (response.statusCode >= 500 && response.statusCode < 600) {
|
||||
logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`)
|
||||
// 记录5xx错误
|
||||
await claudeAccountService.recordServerError(accountId, response.statusCode)
|
||||
// 检查是否需要标记为临时错误状态(连续3次500)
|
||||
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
|
||||
logger.info(
|
||||
`🔥 Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes`
|
||||
)
|
||||
if (errorCount > 10) {
|
||||
logger.error(
|
||||
`❌ Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error`
|
||||
)
|
||||
await claudeAccountService.markAccountTempError(accountId, sessionHash)
|
||||
}
|
||||
await this._handleServerError(accountId, response.statusCode, sessionHash)
|
||||
}
|
||||
// 检查是否为429状态码
|
||||
else if (response.statusCode === 429) {
|
||||
@@ -764,7 +752,7 @@ class ClaudeRelayService {
|
||||
onRequest(req)
|
||||
}
|
||||
|
||||
req.on('error', (error) => {
|
||||
req.on('error', async (error) => {
|
||||
console.error(': ❌ ', error)
|
||||
logger.error('❌ Claude API request error:', error.message, {
|
||||
code: error.code,
|
||||
@@ -784,14 +772,19 @@ class ClaudeRelayService {
|
||||
errorMessage = 'Connection refused by Claude API server'
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
errorMessage = 'Connection timed out to Claude API server'
|
||||
|
||||
await this._handleServerError(accountId, 504, null, 'Network')
|
||||
}
|
||||
|
||||
reject(new Error(errorMessage))
|
||||
})
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.on('timeout', async () => {
|
||||
req.destroy()
|
||||
logger.error('❌ Claude API request timeout')
|
||||
|
||||
await this._handleServerError(accountId, 504, null, 'Request')
|
||||
|
||||
reject(new Error('Request timeout'))
|
||||
})
|
||||
|
||||
@@ -1013,19 +1006,7 @@ class ClaudeRelayService {
|
||||
logger.warn(
|
||||
`🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}`
|
||||
)
|
||||
// 记录5xx错误
|
||||
await claudeAccountService.recordServerError(accountId, res.statusCode)
|
||||
// 检查是否需要标记为临时错误状态(连续3次500)
|
||||
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
|
||||
logger.info(
|
||||
`🔥 [Stream] Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes`
|
||||
)
|
||||
if (errorCount > 10) {
|
||||
logger.error(
|
||||
`❌ [Stream] Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error`
|
||||
)
|
||||
await claudeAccountService.markAccountTempError(accountId, sessionHash)
|
||||
}
|
||||
await this._handleServerError(accountId, res.statusCode, sessionHash, '[Stream]')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1361,7 +1342,7 @@ class ClaudeRelayService {
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', (error) => {
|
||||
req.on('error', async (error) => {
|
||||
logger.error('❌ Claude stream request error:', error.message, {
|
||||
code: error.code,
|
||||
errno: error.errno,
|
||||
@@ -1408,9 +1389,10 @@ class ClaudeRelayService {
|
||||
reject(error)
|
||||
})
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.on('timeout', async () => {
|
||||
req.destroy()
|
||||
logger.error('❌ Claude stream request timeout')
|
||||
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(504, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -1510,7 +1492,7 @@ class ClaudeRelayService {
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', (error) => {
|
||||
req.on('error', async (error) => {
|
||||
logger.error('❌ Claude stream request error:', error.message, {
|
||||
code: error.code,
|
||||
errno: error.errno,
|
||||
@@ -1557,9 +1539,10 @@ class ClaudeRelayService {
|
||||
reject(error)
|
||||
})
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.on('timeout', async () => {
|
||||
req.destroy()
|
||||
logger.error('❌ Claude stream request timeout')
|
||||
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(504, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -1596,6 +1579,33 @@ class ClaudeRelayService {
|
||||
})
|
||||
}
|
||||
|
||||
// 🛠️ 统一的错误处理方法
|
||||
async _handleServerError(accountId, statusCode, sessionHash = null, context = '') {
|
||||
try {
|
||||
await claudeAccountService.recordServerError(accountId, statusCode)
|
||||
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
|
||||
|
||||
// 根据错误类型设置不同的阈值和日志前缀
|
||||
const isTimeout = statusCode === 504
|
||||
const threshold = 3 // 统一使用3次阈值
|
||||
const prefix = context ? `${context} ` : ''
|
||||
|
||||
logger.warn(
|
||||
`⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}`
|
||||
)
|
||||
|
||||
if (errorCount > threshold) {
|
||||
const errorTypeLabel = isTimeout ? 'timeout' : '5xx'
|
||||
logger.error(
|
||||
`❌ ${prefix}Account ${accountId} exceeded ${errorTypeLabel} error threshold (${errorCount} errors), marking as temp_error`
|
||||
)
|
||||
await claudeAccountService.markAccountTempError(accountId, sessionHash)
|
||||
}
|
||||
} catch (handlingError) {
|
||||
logger.error(`❌ Failed to handle ${context} server error:`, handlingError)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重试逻辑
|
||||
async _retryRequest(requestFunc, maxRetries = 3) {
|
||||
let lastError
|
||||
|
||||
@@ -177,6 +177,8 @@ class UnifiedClaudeScheduler {
|
||||
requestedModel
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -789,6 +791,8 @@ class UnifiedClaudeScheduler {
|
||||
requestedModel
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
|
||||
@@ -61,6 +61,8 @@ class UnifiedGeminiScheduler {
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -382,6 +384,8 @@ class UnifiedGeminiScheduler {
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
|
||||
@@ -90,6 +90,8 @@ class UnifiedOpenAIScheduler {
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -406,6 +408,8 @@ class UnifiedOpenAIScheduler {
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
|
||||
)
|
||||
|
||||
202
web/admin-spa/package-lock.json
generated
202
web/admin-spa/package-lock.json
generated
@@ -15,7 +15,9 @@
|
||||
"element-plus": "^2.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.5"
|
||||
"vue-router": "^4.2.5",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx-js-style": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
@@ -1366,6 +1368,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -1643,6 +1654,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -1710,6 +1734,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1766,6 +1799,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2304,6 +2349,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exit-on-epipe": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
||||
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz",
|
||||
@@ -2379,6 +2433,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz",
|
||||
"integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -2497,6 +2557,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@@ -3804,6 +3873,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/printj": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz",
|
||||
"integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"printj": "bin/printj.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -4083,6 +4164,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz",
|
||||
@@ -5126,6 +5219,24 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -5244,6 +5355,95 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx-js-style": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz",
|
||||
"integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.2.0",
|
||||
"cfb": "^1.1.4",
|
||||
"codepage": "~1.14.0",
|
||||
"commander": "~2.17.1",
|
||||
"crc-32": "~1.2.0",
|
||||
"exit-on-epipe": "~1.0.1",
|
||||
"fflate": "^0.3.8",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx-js-style/node_modules/adler-32": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz",
|
||||
"integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"exit-on-epipe": "~1.0.1",
|
||||
"printj": "~1.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"adler32": "bin/adler32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx-js-style/node_modules/codepage": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz",
|
||||
"integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"commander": "~2.14.1",
|
||||
"exit-on-epipe": "~1.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"codepage": "bin/codepage.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx-js-style/node_modules/codepage/node_modules/commander": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz",
|
||||
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xlsx-js-style/node_modules/commander": {
|
||||
"version": "2.17.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
|
||||
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
"element-plus": "^2.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.5"
|
||||
"vue-router": "^4.2.5",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx-js-style": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
|
||||
@@ -110,19 +110,21 @@
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm"
|
||||
>名称 <span class="text-red-500">*</span></label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': errors.name }"
|
||||
:placeholder="
|
||||
form.createType === 'batch'
|
||||
? '输入基础名称(将自动添加序号)'
|
||||
: '为您的 API Key 取一个名称'
|
||||
"
|
||||
required
|
||||
type="text"
|
||||
@input="errors.name = ''"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
:class="{ 'border-red-500': errors.name }"
|
||||
:placeholder="
|
||||
form.createType === 'batch'
|
||||
? '输入基础名称(将自动添加序号)'
|
||||
: '为您的 API Key 取一个名称'
|
||||
"
|
||||
required
|
||||
type="text"
|
||||
@input="errors.name = ''"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
|
||||
{{ errors.name }}
|
||||
</p>
|
||||
|
||||
@@ -32,14 +32,16 @@
|
||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||
>名称</label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
maxlength="100"
|
||||
placeholder="请输入API Key名称"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
maxlength="100"
|
||||
placeholder="请输入API Key名称"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
|
||||
用于识别此 API Key 的用途
|
||||
</p>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<!-- 模态框 -->
|
||||
<div
|
||||
class="modal-content relative mx-auto flex max-h-[90vh] w-[95%] max-w-2xl flex-col p-4 sm:w-full sm:max-w-3xl sm:p-6 md:p-8"
|
||||
class="modal-content relative mx-auto flex max-h-[90vh] w-[95%] max-w-5xl flex-col p-4 sm:w-full sm:p-6 md:p-8"
|
||||
>
|
||||
<!-- 标题栏 -->
|
||||
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user