mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Merge branch 'dev' into main
This commit is contained in:
@@ -483,6 +483,7 @@ model_provider = "crs"
|
|||||||
model = "gpt-5"
|
model = "gpt-5"
|
||||||
model_reasoning_effort = "high"
|
model_reasoning_effort = "high"
|
||||||
disable_response_storage = true
|
disable_response_storage = true
|
||||||
|
preferred_auth_method = "apikey"
|
||||||
|
|
||||||
[model_providers.crs]
|
[model_providers.crs]
|
||||||
name = "crs"
|
name = "crs"
|
||||||
|
|||||||
18
src/app.js
18
src/app.js
@@ -537,6 +537,15 @@ class Application {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes`
|
`🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 🚨 启动限流状态自动清理服务
|
||||||
|
// 每5分钟检查一次过期的限流状态,确保账号能及时恢复调度
|
||||||
|
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
|
||||||
|
const cleanupIntervalMinutes = config.system.rateLimitCleanupInterval || 5 // 默认5分钟
|
||||||
|
rateLimitCleanupService.start(cleanupIntervalMinutes)
|
||||||
|
logger.info(
|
||||||
|
`🚨 Rate limit cleanup service started (checking every ${cleanupIntervalMinutes} minutes)`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
setupGracefulShutdown() {
|
setupGracefulShutdown() {
|
||||||
@@ -555,6 +564,15 @@ class Application {
|
|||||||
logger.error('❌ Error cleaning up pricing service:', error)
|
logger.error('❌ Error cleaning up pricing service:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 停止限流清理服务
|
||||||
|
try {
|
||||||
|
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
|
||||||
|
rateLimitCleanupService.stop()
|
||||||
|
logger.info('🚨 Rate limit cleanup service stopped')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error stopping rate limit cleanup service:', error)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await redis.disconnect()
|
await redis.disconnect()
|
||||||
logger.info('👋 Redis disconnected')
|
logger.info('👋 Redis disconnected')
|
||||||
|
|||||||
@@ -1092,7 +1092,7 @@ const globalRateLimit = async (req, res, next) =>
|
|||||||
|
|
||||||
// 📊 请求大小限制中间件
|
// 📊 请求大小限制中间件
|
||||||
const requestSizeLimit = (req, res, next) => {
|
const requestSizeLimit = (req, res, next) => {
|
||||||
const maxSize = 10 * 1024 * 1024 // 10MB
|
const maxSize = 60 * 1024 * 1024 // 60MB
|
||||||
const contentLength = parseInt(req.headers['content-length'] || '0')
|
const contentLength = parseInt(req.headers['content-length'] || '0')
|
||||||
|
|
||||||
if (contentLength > maxSize) {
|
if (contentLength > maxSize) {
|
||||||
|
|||||||
@@ -2059,7 +2059,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
groupId,
|
groupId,
|
||||||
groupIds,
|
groupIds,
|
||||||
autoStopOnWarning,
|
autoStopOnWarning,
|
||||||
useUnifiedUserAgent
|
useUnifiedUserAgent,
|
||||||
|
useUnifiedClientId,
|
||||||
|
unifiedClientId
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -2100,7 +2102,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
platform,
|
platform,
|
||||||
priority: priority || 50, // 默认优先级为50
|
priority: priority || 50, // 默认优先级为50
|
||||||
autoStopOnWarning: autoStopOnWarning === true, // 默认为false
|
autoStopOnWarning: autoStopOnWarning === true, // 默认为false
|
||||||
useUnifiedUserAgent: useUnifiedUserAgent === true // 默认为false
|
useUnifiedUserAgent: useUnifiedUserAgent === true, // 默认为false
|
||||||
|
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
|
||||||
|
unifiedClientId: unifiedClientId || '' // 统一的客户端标识
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是分组类型,将账户添加到分组
|
// 如果是分组类型,将账户添加到分组
|
||||||
@@ -2703,6 +2707,23 @@ router.post(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 重置Claude Console账户状态(清除所有异常状态)
|
||||||
|
router.post(
|
||||||
|
'/claude-console-accounts/:accountId/reset-status',
|
||||||
|
authenticateAdmin,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const result = await claudeConsoleAccountService.resetAccountStatus(accountId)
|
||||||
|
logger.success(`✅ Admin reset status for Claude Console account: ${accountId}`)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset Claude Console account status:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 手动重置所有Claude Console账户的每日使用量
|
// 手动重置所有Claude Console账户的每日使用量
|
||||||
router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => {
|
router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -5419,6 +5440,7 @@ router.get('/oem-settings', async (req, res) => {
|
|||||||
siteName: 'Claude Relay Service',
|
siteName: 'Claude Relay Service',
|
||||||
siteIcon: '',
|
siteIcon: '',
|
||||||
siteIconData: '', // Base64编码的图标数据
|
siteIconData: '', // Base64编码的图标数据
|
||||||
|
showAdminButton: true, // 是否显示管理后台按钮
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5448,7 +5470,7 @@ router.get('/oem-settings', async (req, res) => {
|
|||||||
// 更新OEM设置
|
// 更新OEM设置
|
||||||
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { siteName, siteIcon, siteIconData } = req.body
|
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
|
||||||
|
|
||||||
// 验证输入
|
// 验证输入
|
||||||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||||
@@ -5479,6 +5501,7 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
|||||||
siteName: siteName.trim(),
|
siteName: siteName.trim(),
|
||||||
siteIcon: (siteIcon || '').trim(),
|
siteIcon: (siteIcon || '').trim(),
|
||||||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||||
|
showAdminButton: showAdminButton !== false, // 默认为true
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6190,6 +6213,21 @@ router.put('/openai-accounts/:id/toggle', authenticateAdmin, async (req, res) =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 重置 OpenAI 账户状态(清除所有异常状态)
|
||||||
|
router.post('/openai-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params
|
||||||
|
|
||||||
|
const result = await openaiAccountService.resetAccountStatus(accountId)
|
||||||
|
|
||||||
|
logger.success(`✅ Admin reset status for OpenAI account: ${accountId}`)
|
||||||
|
return res.json({ success: true, data: result })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset OpenAI account status:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 切换 OpenAI 账户调度状态
|
// 切换 OpenAI 账户调度状态
|
||||||
router.put(
|
router.put(
|
||||||
'/openai-accounts/:accountId/toggle-schedulable',
|
'/openai-accounts/:accountId/toggle-schedulable',
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ router.post('/api/get-key-id', async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证API Key
|
// 验证API Key(使用不触发激活的验证方法)
|
||||||
const validation = await apiKeyService.validateApiKey(apiKey)
|
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||||
|
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||||
@@ -146,6 +146,11 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||||
allowedClients,
|
allowedClients,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions || 'all',
|
||||||
|
// 添加激活相关字段
|
||||||
|
expirationMode: keyData.expirationMode || 'fixed',
|
||||||
|
isActivated: keyData.isActivated === 'true',
|
||||||
|
activationDays: parseInt(keyData.activationDays || 0),
|
||||||
|
activatedAt: keyData.activatedAt || null,
|
||||||
usage // 使用完整的 usage 数据,而不是只有 total
|
usage // 使用完整的 usage 数据,而不是只有 total
|
||||||
}
|
}
|
||||||
} else if (apiKey) {
|
} else if (apiKey) {
|
||||||
@@ -158,8 +163,8 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证API Key(重用现有的验证逻辑)
|
// 验证API Key(使用不触发激活的验证方法)
|
||||||
const validation = await apiKeyService.validateApiKey(apiKey)
|
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||||
|
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||||
@@ -335,6 +340,11 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的
|
isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的
|
||||||
createdAt: keyData.createdAt,
|
createdAt: keyData.createdAt,
|
||||||
expiresAt: keyData.expiresAt,
|
expiresAt: keyData.expiresAt,
|
||||||
|
// 添加激活相关字段
|
||||||
|
expirationMode: keyData.expirationMode || 'fixed',
|
||||||
|
isActivated: keyData.isActivated === 'true',
|
||||||
|
activationDays: parseInt(keyData.activationDays || 0),
|
||||||
|
activatedAt: keyData.activatedAt || null,
|
||||||
permissions: fullKeyData.permissions,
|
permissions: fullKeyData.permissions,
|
||||||
|
|
||||||
// 使用统计(使用验证结果中的完整数据)
|
// 使用统计(使用验证结果中的完整数据)
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post('/responses', authenticateApiKey, async (req, res) => {
|
// 主处理函数,供两个路由共享
|
||||||
|
const handleResponses = async (req, res) => {
|
||||||
let upstream = null
|
let upstream = null
|
||||||
try {
|
try {
|
||||||
// 从中间件获取 API Key 数据
|
// 从中间件获取 API Key 数据
|
||||||
@@ -205,6 +206,96 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
|||||||
axiosConfig
|
axiosConfig
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 429 限流错误
|
||||||
|
if (upstream.status === 429) {
|
||||||
|
logger.warn(`🚫 Rate limit detected for OpenAI account ${accountId} (Codex API)`)
|
||||||
|
|
||||||
|
// 解析响应体中的限流信息
|
||||||
|
let resetsInSeconds = null
|
||||||
|
let errorData = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 对于429错误,无论是否是流式请求,响应都会是完整的JSON错误对象
|
||||||
|
if (isStream && upstream.data) {
|
||||||
|
// 流式响应需要先收集数据
|
||||||
|
const chunks = []
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
upstream.data.on('data', (chunk) => chunks.push(chunk))
|
||||||
|
upstream.data.on('end', resolve)
|
||||||
|
upstream.data.on('error', reject)
|
||||||
|
// 设置超时防止无限等待
|
||||||
|
setTimeout(resolve, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fullResponse = Buffer.concat(chunks).toString()
|
||||||
|
try {
|
||||||
|
errorData = JSON.parse(fullResponse)
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to parse 429 error response:', e)
|
||||||
|
logger.debug('Raw response:', fullResponse)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非流式响应直接使用data
|
||||||
|
errorData = upstream.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取重置时间
|
||||||
|
if (errorData && errorData.error && errorData.error.resets_in_seconds) {
|
||||||
|
resetsInSeconds = errorData.error.resets_in_seconds
|
||||||
|
logger.info(
|
||||||
|
`🕐 Codex rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
'⚠️ Could not extract resets_in_seconds from 429 response, using default 60 minutes'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('⚠️ Failed to parse rate limit error:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记账户为限流状态
|
||||||
|
await unifiedOpenAIScheduler.markAccountRateLimited(
|
||||||
|
accountId,
|
||||||
|
'openai',
|
||||||
|
sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null,
|
||||||
|
resetsInSeconds
|
||||||
|
)
|
||||||
|
|
||||||
|
// 返回错误响应给客户端
|
||||||
|
const errorResponse = errorData || {
|
||||||
|
error: {
|
||||||
|
type: 'usage_limit_reached',
|
||||||
|
message: 'The usage limit has been reached',
|
||||||
|
resets_in_seconds: resetsInSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStream) {
|
||||||
|
// 流式响应也需要设置正确的状态码
|
||||||
|
res.status(429)
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
res.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||||
|
res.end()
|
||||||
|
} else {
|
||||||
|
res.status(429).json(errorResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
} else if (upstream.status === 200 || upstream.status === 201) {
|
||||||
|
// 请求成功,检查并移除限流状态
|
||||||
|
const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId)
|
||||||
|
if (isRateLimited) {
|
||||||
|
logger.info(
|
||||||
|
`✅ Removing rate limit for OpenAI account ${accountId} after successful request`
|
||||||
|
)
|
||||||
|
await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.status(upstream.status)
|
res.status(upstream.status)
|
||||||
|
|
||||||
if (isStream) {
|
if (isStream) {
|
||||||
@@ -239,6 +330,8 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
|||||||
let usageData = null
|
let usageData = null
|
||||||
let actualModel = null
|
let actualModel = null
|
||||||
let usageReported = false
|
let usageReported = false
|
||||||
|
let rateLimitDetected = false
|
||||||
|
let rateLimitResetsInSeconds = null
|
||||||
|
|
||||||
if (!isStream) {
|
if (!isStream) {
|
||||||
// 非流式响应处理
|
// 非流式响应处理
|
||||||
@@ -317,6 +410,17 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
|||||||
logger.debug('📊 Captured OpenAI usage data:', usageData)
|
logger.debug('📊 Captured OpenAI usage data:', usageData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否有限流错误
|
||||||
|
if (eventData.error && eventData.error.type === 'usage_limit_reached') {
|
||||||
|
rateLimitDetected = true
|
||||||
|
if (eventData.error.resets_in_seconds) {
|
||||||
|
rateLimitResetsInSeconds = eventData.error.resets_in_seconds
|
||||||
|
logger.warn(
|
||||||
|
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 忽略解析错误
|
// 忽略解析错误
|
||||||
}
|
}
|
||||||
@@ -388,6 +492,26 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果在流式响应中检测到限流
|
||||||
|
if (rateLimitDetected) {
|
||||||
|
logger.warn(`🚫 Processing rate limit for OpenAI account ${accountId} from stream`)
|
||||||
|
await unifiedOpenAIScheduler.markAccountRateLimited(
|
||||||
|
accountId,
|
||||||
|
'openai',
|
||||||
|
sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null,
|
||||||
|
rateLimitResetsInSeconds
|
||||||
|
)
|
||||||
|
} else if (upstream.status === 200) {
|
||||||
|
// 流式请求成功,检查并移除限流状态
|
||||||
|
const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId)
|
||||||
|
if (isRateLimited) {
|
||||||
|
logger.info(
|
||||||
|
`✅ Removing rate limit for OpenAI account ${accountId} after successful stream`
|
||||||
|
)
|
||||||
|
await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.end()
|
res.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -419,7 +543,11 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
|||||||
res.status(status).json({ error: { message } })
|
res.status(status).json({ error: { message } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// 注册两个路由路径,都使用相同的处理函数
|
||||||
|
router.post('/responses', authenticateApiKey, handleResponses)
|
||||||
|
router.post('/v1/responses', authenticateApiKey, handleResponses)
|
||||||
|
|
||||||
// 使用情况统计端点
|
// 使用情况统计端点
|
||||||
router.get('/usage', authenticateApiKey, async (req, res) => {
|
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||||
|
|||||||
@@ -258,6 +258,126 @@ class ApiKeyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔍 验证API Key(仅用于统计查询,不触发激活)
|
||||||
|
async validateApiKeyForStats(apiKey) {
|
||||||
|
try {
|
||||||
|
if (!apiKey || !apiKey.startsWith(this.prefix)) {
|
||||||
|
return { valid: false, error: 'Invalid API key format' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算API Key的哈希值
|
||||||
|
const hashedKey = this._hashApiKey(apiKey)
|
||||||
|
|
||||||
|
// 通过哈希值直接查找API Key(性能优化)
|
||||||
|
const keyData = await redis.findApiKeyByHash(hashedKey)
|
||||||
|
|
||||||
|
if (!keyData) {
|
||||||
|
return { valid: false, error: 'API key not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否激活
|
||||||
|
if (keyData.isActive !== 'true') {
|
||||||
|
return { valid: false, error: 'API key is disabled' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:这里不处理激活逻辑,保持 API Key 的未激活状态
|
||||||
|
|
||||||
|
// 检查是否过期(仅对已激活的 Key 检查)
|
||||||
|
if (
|
||||||
|
keyData.isActivated === 'true' &&
|
||||||
|
keyData.expiresAt &&
|
||||||
|
new Date() > new Date(keyData.expiresAt)
|
||||||
|
) {
|
||||||
|
return { valid: false, error: 'API key has expired' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果API Key属于某个用户,检查用户是否被禁用
|
||||||
|
if (keyData.userId) {
|
||||||
|
try {
|
||||||
|
const userService = require('./userService')
|
||||||
|
const user = await userService.getUserById(keyData.userId, false)
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
return { valid: false, error: 'User account is disabled' }
|
||||||
|
}
|
||||||
|
} catch (userError) {
|
||||||
|
// 如果用户服务出错,记录但不影响API Key验证
|
||||||
|
logger.warn(`Failed to check user status for API key ${keyData.id}:`, userError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当日费用
|
||||||
|
const dailyCost = (await redis.getDailyCost(keyData.id)) || 0
|
||||||
|
|
||||||
|
// 获取使用统计
|
||||||
|
const usage = await redis.getUsageStats(keyData.id)
|
||||||
|
|
||||||
|
// 解析限制模型数据
|
||||||
|
let restrictedModels = []
|
||||||
|
try {
|
||||||
|
restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : []
|
||||||
|
} catch (e) {
|
||||||
|
restrictedModels = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析允许的客户端
|
||||||
|
let allowedClients = []
|
||||||
|
try {
|
||||||
|
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []
|
||||||
|
} catch (e) {
|
||||||
|
allowedClients = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析标签
|
||||||
|
let tags = []
|
||||||
|
try {
|
||||||
|
tags = keyData.tags ? JSON.parse(keyData.tags) : []
|
||||||
|
} catch (e) {
|
||||||
|
tags = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
keyData: {
|
||||||
|
id: keyData.id,
|
||||||
|
name: keyData.name,
|
||||||
|
description: keyData.description,
|
||||||
|
createdAt: keyData.createdAt,
|
||||||
|
expiresAt: keyData.expiresAt,
|
||||||
|
// 添加激活相关字段
|
||||||
|
expirationMode: keyData.expirationMode || 'fixed',
|
||||||
|
isActivated: keyData.isActivated === 'true',
|
||||||
|
activationDays: parseInt(keyData.activationDays || 0),
|
||||||
|
activatedAt: keyData.activatedAt || null,
|
||||||
|
claudeAccountId: keyData.claudeAccountId,
|
||||||
|
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||||
|
geminiAccountId: keyData.geminiAccountId,
|
||||||
|
openaiAccountId: keyData.openaiAccountId,
|
||||||
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
|
bedrockAccountId: keyData.bedrockAccountId,
|
||||||
|
permissions: keyData.permissions || 'all',
|
||||||
|
tokenLimit: parseInt(keyData.tokenLimit),
|
||||||
|
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||||
|
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||||
|
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
|
||||||
|
rateLimitCost: parseFloat(keyData.rateLimitCost || 0),
|
||||||
|
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||||
|
restrictedModels,
|
||||||
|
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||||
|
allowedClients,
|
||||||
|
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||||
|
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
||||||
|
dailyCost: dailyCost || 0,
|
||||||
|
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
|
||||||
|
tags,
|
||||||
|
usage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ API key validation error (stats):', error)
|
||||||
|
return { valid: false, error: 'Internal validation error' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 📋 获取所有API Keys
|
// 📋 获取所有API Keys
|
||||||
async getAllApiKeys(includeDeleted = false) {
|
async getAllApiKeys(includeDeleted = false) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ class ClaudeAccountService {
|
|||||||
schedulable = true, // 是否可被调度
|
schedulable = true, // 是否可被调度
|
||||||
subscriptionInfo = null, // 手动设置的订阅信息
|
subscriptionInfo = null, // 手动设置的订阅信息
|
||||||
autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度
|
autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度
|
||||||
useUnifiedUserAgent = false // 是否使用统一Claude Code版本的User-Agent
|
useUnifiedUserAgent = false, // 是否使用统一Claude Code版本的User-Agent
|
||||||
|
useUnifiedClientId = false, // 是否使用统一的客户端标识
|
||||||
|
unifiedClientId = '' // 统一的客户端标识
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const accountId = uuidv4()
|
const accountId = uuidv4()
|
||||||
@@ -93,6 +95,8 @@ class ClaudeAccountService {
|
|||||||
schedulable: schedulable.toString(), // 是否可被调度
|
schedulable: schedulable.toString(), // 是否可被调度
|
||||||
autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度
|
autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度
|
||||||
useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent
|
useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent
|
||||||
|
useUnifiedClientId: useUnifiedClientId.toString(), // 是否使用统一的客户端标识
|
||||||
|
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
|
||||||
// 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空
|
// 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空
|
||||||
subscriptionInfo: subscriptionInfo
|
subscriptionInfo: subscriptionInfo
|
||||||
? JSON.stringify(subscriptionInfo)
|
? JSON.stringify(subscriptionInfo)
|
||||||
@@ -166,7 +170,10 @@ class ClaudeAccountService {
|
|||||||
createdAt: accountData.createdAt,
|
createdAt: accountData.createdAt,
|
||||||
expiresAt: accountData.expiresAt,
|
expiresAt: accountData.expiresAt,
|
||||||
scopes: claudeAiOauth ? claudeAiOauth.scopes : [],
|
scopes: claudeAiOauth ? claudeAiOauth.scopes : [],
|
||||||
autoStopOnWarning
|
autoStopOnWarning,
|
||||||
|
useUnifiedUserAgent,
|
||||||
|
useUnifiedClientId,
|
||||||
|
unifiedClientId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,6 +499,9 @@ class ClaudeAccountService {
|
|||||||
autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false
|
autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false
|
||||||
// 添加统一User-Agent设置
|
// 添加统一User-Agent设置
|
||||||
useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false
|
useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false
|
||||||
|
// 添加统一客户端标识设置
|
||||||
|
useUnifiedClientId: account.useUnifiedClientId === 'true', // 默认为false
|
||||||
|
unifiedClientId: account.unifiedClientId || '', // 统一的客户端标识
|
||||||
// 添加停止原因
|
// 添加停止原因
|
||||||
stoppedReason: account.stoppedReason || null
|
stoppedReason: account.stoppedReason || null
|
||||||
}
|
}
|
||||||
@@ -528,7 +538,9 @@ class ClaudeAccountService {
|
|||||||
'schedulable',
|
'schedulable',
|
||||||
'subscriptionInfo',
|
'subscriptionInfo',
|
||||||
'autoStopOnWarning',
|
'autoStopOnWarning',
|
||||||
'useUnifiedUserAgent'
|
'useUnifiedUserAgent',
|
||||||
|
'useUnifiedClientId',
|
||||||
|
'unifiedClientId'
|
||||||
]
|
]
|
||||||
const updatedData = { ...accountData }
|
const updatedData = { ...accountData }
|
||||||
|
|
||||||
@@ -1075,6 +1087,8 @@ class ClaudeAccountService {
|
|||||||
const updatedAccountData = { ...accountData }
|
const updatedAccountData = { ...accountData }
|
||||||
updatedAccountData.rateLimitedAt = new Date().toISOString()
|
updatedAccountData.rateLimitedAt = new Date().toISOString()
|
||||||
updatedAccountData.rateLimitStatus = 'limited'
|
updatedAccountData.rateLimitStatus = 'limited'
|
||||||
|
// 限流时停止调度,与 OpenAI 账号保持一致
|
||||||
|
updatedAccountData.schedulable = false
|
||||||
|
|
||||||
// 如果提供了准确的限流重置时间戳(来自API响应头)
|
// 如果提供了准确的限流重置时间戳(来自API响应头)
|
||||||
if (rateLimitResetTimestamp) {
|
if (rateLimitResetTimestamp) {
|
||||||
@@ -1159,9 +1173,33 @@ class ClaudeAccountService {
|
|||||||
delete accountData.rateLimitedAt
|
delete accountData.rateLimitedAt
|
||||||
delete accountData.rateLimitStatus
|
delete accountData.rateLimitStatus
|
||||||
delete accountData.rateLimitEndAt // 清除限流结束时间
|
delete accountData.rateLimitEndAt // 清除限流结束时间
|
||||||
|
// 恢复可调度状态,与 OpenAI 账号保持一致
|
||||||
|
accountData.schedulable = true
|
||||||
await redis.setClaudeAccount(accountId, accountData)
|
await redis.setClaudeAccount(accountId, accountData)
|
||||||
|
|
||||||
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
|
logger.success(
|
||||||
|
`✅ Rate limit removed for account: ${accountData.name} (${accountId}), schedulable restored`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 发送 Webhook 通知限流已解除
|
||||||
|
try {
|
||||||
|
const webhookNotifier = require('../utils/webhookNotifier')
|
||||||
|
await webhookNotifier.sendAccountAnomalyNotification({
|
||||||
|
accountId,
|
||||||
|
accountName: accountData.name || 'Claude Account',
|
||||||
|
platform: 'claude-oauth',
|
||||||
|
status: 'recovered',
|
||||||
|
errorCode: 'CLAUDE_OAUTH_RATE_LIMIT_CLEARED',
|
||||||
|
reason: 'Rate limit has been cleared and account is now schedulable',
|
||||||
|
timestamp: getISOStringWithTimezone(new Date())
|
||||||
|
})
|
||||||
|
logger.info(
|
||||||
|
`📢 Webhook notification sent for Claude account ${accountData.name} rate limit cleared`
|
||||||
|
)
|
||||||
|
} catch (webhookError) {
|
||||||
|
logger.error('Failed to send rate limit cleared webhook notification:', webhookError)
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error)
|
logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error)
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ class ClaudeConsoleAccountService {
|
|||||||
rateLimitedAt: new Date().toISOString(),
|
rateLimitedAt: new Date().toISOString(),
|
||||||
rateLimitStatus: 'limited',
|
rateLimitStatus: 'limited',
|
||||||
isActive: 'false', // 禁用账户
|
isActive: 'false', // 禁用账户
|
||||||
|
schedulable: 'false', // 停止调度,与其他平台保持一致
|
||||||
errorMessage: `Rate limited at ${new Date().toISOString()}`
|
errorMessage: `Rate limited at ${new Date().toISOString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +469,7 @@ class ClaudeConsoleAccountService {
|
|||||||
// 没有额度限制,完全恢复
|
// 没有额度限制,完全恢复
|
||||||
await client.hset(accountKey, {
|
await client.hset(accountKey, {
|
||||||
isActive: 'true',
|
isActive: 'true',
|
||||||
|
schedulable: 'true', // 恢复调度,与其他平台保持一致
|
||||||
status: 'active',
|
status: 'active',
|
||||||
errorMessage: ''
|
errorMessage: ''
|
||||||
})
|
})
|
||||||
@@ -1131,6 +1133,66 @@ class ClaudeConsoleAccountService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔄 重置账户所有异常状态
|
||||||
|
async resetAccountStatus(accountId) {
|
||||||
|
try {
|
||||||
|
const accountData = await this.getAccount(accountId)
|
||||||
|
if (!accountData) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||||
|
|
||||||
|
// 准备要更新的字段
|
||||||
|
const updates = {
|
||||||
|
status: 'active',
|
||||||
|
errorMessage: '',
|
||||||
|
schedulable: 'true',
|
||||||
|
isActive: 'true' // 重要:必须恢复isActive状态
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除所有异常状态相关的字段
|
||||||
|
const fieldsToDelete = [
|
||||||
|
'rateLimitedAt',
|
||||||
|
'rateLimitStatus',
|
||||||
|
'unauthorizedAt',
|
||||||
|
'unauthorizedCount',
|
||||||
|
'overloadedAt',
|
||||||
|
'overloadStatus',
|
||||||
|
'blockedAt',
|
||||||
|
'quotaStoppedAt'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 执行更新
|
||||||
|
await client.hset(accountKey, updates)
|
||||||
|
await client.hdel(accountKey, ...fieldsToDelete)
|
||||||
|
|
||||||
|
logger.success(`✅ Reset all error status for Claude Console account ${accountId}`)
|
||||||
|
|
||||||
|
// 发送 Webhook 通知
|
||||||
|
try {
|
||||||
|
const webhookNotifier = require('../utils/webhookNotifier')
|
||||||
|
await webhookNotifier.sendAccountAnomalyNotification({
|
||||||
|
accountId,
|
||||||
|
accountName: accountData.name || accountId,
|
||||||
|
platform: 'claude-console',
|
||||||
|
status: 'recovered',
|
||||||
|
errorCode: 'STATUS_RESET',
|
||||||
|
reason: 'Account status manually reset',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
} catch (webhookError) {
|
||||||
|
logger.warn('Failed to send webhook notification:', webhookError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, accountId }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to reset Claude Console account status: ${accountId}`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new ClaudeConsoleAccountService()
|
module.exports = new ClaudeConsoleAccountService()
|
||||||
|
|||||||
@@ -126,8 +126,11 @@ class ClaudeRelayService {
|
|||||||
// 获取有效的访问token
|
// 获取有效的访问token
|
||||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||||
|
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
|
||||||
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
|
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
|
||||||
const processedBody = this._processRequestBody(requestBody, clientHeaders)
|
const processedBody = this._processRequestBody(requestBody, clientHeaders, account)
|
||||||
|
|
||||||
// 获取代理配置
|
// 获取代理配置
|
||||||
const proxyAgent = await this._getProxyAgent(accountId)
|
const proxyAgent = await this._getProxyAgent(accountId)
|
||||||
@@ -344,7 +347,7 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🔄 处理请求体
|
// 🔄 处理请求体
|
||||||
_processRequestBody(body, clientHeaders = {}) {
|
_processRequestBody(body, clientHeaders = {}, account = null) {
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
@@ -446,9 +449,31 @@ class ClaudeRelayService {
|
|||||||
delete processedBody.top_p
|
delete processedBody.top_p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理统一的客户端标识
|
||||||
|
if (account && account.useUnifiedClientId && account.unifiedClientId) {
|
||||||
|
this._replaceClientId(processedBody, account.unifiedClientId)
|
||||||
|
}
|
||||||
|
|
||||||
return processedBody
|
return processedBody
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔄 替换请求中的客户端标识
|
||||||
|
_replaceClientId(body, unifiedClientId) {
|
||||||
|
if (!body || !body.metadata || !body.metadata.user_id || !unifiedClientId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = body.metadata.user_id
|
||||||
|
// user_id格式:user_{64位十六进制}_account__session_{uuid}
|
||||||
|
// 只替换第一个下划线后到_account之前的部分(客户端标识)
|
||||||
|
const match = userId.match(/^user_[a-f0-9]{64}(_account__session_[a-f0-9-]{36})$/)
|
||||||
|
if (match && match[1]) {
|
||||||
|
// 替换客户端标识部分
|
||||||
|
body.metadata.user_id = `user_${unifiedClientId}${match[1]}`
|
||||||
|
logger.info(`🔄 Replaced client ID with unified ID: ${body.metadata.user_id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔢 验证并限制max_tokens参数
|
// 🔢 验证并限制max_tokens参数
|
||||||
_validateAndLimitMaxTokens(body) {
|
_validateAndLimitMaxTokens(body) {
|
||||||
if (!body || !body.max_tokens) {
|
if (!body || !body.max_tokens) {
|
||||||
@@ -660,16 +685,13 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
||||||
if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
|
if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
|
||||||
const userAgent =
|
const userAgent = unifiedUA || 'claude-cli/1.0.57 (external, cli)'
|
||||||
unifiedUA ||
|
|
||||||
clientHeaders?.['user-agent'] ||
|
|
||||||
clientHeaders?.['User-Agent'] ||
|
|
||||||
'claude-cli/1.0.102 (external, cli)'
|
|
||||||
options.headers['User-Agent'] = userAgent
|
options.headers['User-Agent'] = userAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`🔗 指纹是这个: ${options.headers['User-Agent']}`)
|
logger.info(
|
||||||
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
`🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}`
|
||||||
|
)
|
||||||
|
|
||||||
// 使用自定义的 betaHeader 或默认值
|
// 使用自定义的 betaHeader 或默认值
|
||||||
const betaHeader =
|
const betaHeader =
|
||||||
@@ -840,8 +862,11 @@ class ClaudeRelayService {
|
|||||||
// 获取有效的访问token
|
// 获取有效的访问token
|
||||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||||
|
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
|
||||||
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
|
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
|
||||||
const processedBody = this._processRequestBody(requestBody, clientHeaders)
|
const processedBody = this._processRequestBody(requestBody, clientHeaders, account)
|
||||||
|
|
||||||
// 获取代理配置
|
// 获取代理配置
|
||||||
const proxyAgent = await this._getProxyAgent(accountId)
|
const proxyAgent = await this._getProxyAgent(accountId)
|
||||||
@@ -931,14 +956,13 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
||||||
if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
|
if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
|
||||||
const userAgent =
|
const userAgent = unifiedUA || 'claude-cli/1.0.57 (external, cli)'
|
||||||
unifiedUA ||
|
|
||||||
clientHeaders?.['user-agent'] ||
|
|
||||||
clientHeaders?.['User-Agent'] ||
|
|
||||||
'claude-cli/1.0.102 (external, cli)'
|
|
||||||
options.headers['User-Agent'] = userAgent
|
options.headers['User-Agent'] = userAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}`
|
||||||
|
)
|
||||||
// 使用自定义的 betaHeader 或默认值
|
// 使用自定义的 betaHeader 或默认值
|
||||||
const betaHeader =
|
const betaHeader =
|
||||||
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
|
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
|
||||||
|
|||||||
@@ -814,14 +814,37 @@ function isRateLimited(account) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 设置账户限流状态
|
// 设置账户限流状态
|
||||||
async function setAccountRateLimited(accountId, isLimited) {
|
async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = null) {
|
||||||
const updates = {
|
const updates = {
|
||||||
rateLimitStatus: isLimited ? 'limited' : 'normal',
|
rateLimitStatus: isLimited ? 'limited' : 'normal',
|
||||||
rateLimitedAt: isLimited ? new Date().toISOString() : null
|
rateLimitedAt: isLimited ? new Date().toISOString() : null,
|
||||||
|
// 限流时停止调度,解除限流时恢复调度
|
||||||
|
schedulable: isLimited ? 'false' : 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果提供了重置时间(秒数),计算重置时间戳
|
||||||
|
if (isLimited && resetsInSeconds !== null && resetsInSeconds > 0) {
|
||||||
|
const resetTime = new Date(Date.now() + resetsInSeconds * 1000).toISOString()
|
||||||
|
updates.rateLimitResetAt = resetTime
|
||||||
|
logger.info(
|
||||||
|
`🕐 Account ${accountId} will be reset at ${resetTime} (in ${resetsInSeconds} seconds / ${Math.ceil(resetsInSeconds / 60)} minutes)`
|
||||||
|
)
|
||||||
|
} else if (isLimited) {
|
||||||
|
// 如果没有提供重置时间,使用默认的60分钟
|
||||||
|
const defaultResetSeconds = 60 * 60 // 1小时
|
||||||
|
const resetTime = new Date(Date.now() + defaultResetSeconds * 1000).toISOString()
|
||||||
|
updates.rateLimitResetAt = resetTime
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ No reset time provided for account ${accountId}, using default 60 minutes. Reset at ${resetTime}`
|
||||||
|
)
|
||||||
|
} else if (!isLimited) {
|
||||||
|
updates.rateLimitResetAt = null
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateAccount(accountId, updates)
|
await updateAccount(accountId, updates)
|
||||||
logger.info(`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}`)
|
logger.info(
|
||||||
|
`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}, schedulable: ${updates.schedulable}`
|
||||||
|
)
|
||||||
|
|
||||||
// 如果被限流,发送 Webhook 通知
|
// 如果被限流,发送 Webhook 通知
|
||||||
if (isLimited) {
|
if (isLimited) {
|
||||||
@@ -834,7 +857,9 @@ async function setAccountRateLimited(accountId, isLimited) {
|
|||||||
platform: 'openai',
|
platform: 'openai',
|
||||||
status: 'blocked',
|
status: 'blocked',
|
||||||
errorCode: 'OPENAI_RATE_LIMITED',
|
errorCode: 'OPENAI_RATE_LIMITED',
|
||||||
reason: 'Account rate limited (429 error). Estimated reset in 1 hour',
|
reason: resetsInSeconds
|
||||||
|
? `Account rate limited (429 error). Reset in ${Math.ceil(resetsInSeconds / 60)} minutes`
|
||||||
|
: 'Account rate limited (429 error). Estimated reset in 1 hour',
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
})
|
})
|
||||||
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`)
|
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`)
|
||||||
@@ -844,6 +869,48 @@ async function setAccountRateLimited(accountId, isLimited) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔄 重置账户所有异常状态
|
||||||
|
async function resetAccountStatus(accountId) {
|
||||||
|
const account = await getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
// 根据是否有有效的 accessToken 来设置 status
|
||||||
|
status: account.accessToken ? 'active' : 'created',
|
||||||
|
// 恢复可调度状态
|
||||||
|
schedulable: 'true',
|
||||||
|
// 清除错误相关字段
|
||||||
|
errorMessage: null,
|
||||||
|
rateLimitedAt: null,
|
||||||
|
rateLimitStatus: 'normal',
|
||||||
|
rateLimitResetAt: null
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAccount(accountId, updates)
|
||||||
|
logger.info(`✅ Reset all error status for OpenAI account ${accountId}`)
|
||||||
|
|
||||||
|
// 发送 Webhook 通知
|
||||||
|
try {
|
||||||
|
const webhookNotifier = require('../utils/webhookNotifier')
|
||||||
|
await webhookNotifier.sendAccountAnomalyNotification({
|
||||||
|
accountId,
|
||||||
|
accountName: account.name || accountId,
|
||||||
|
platform: 'openai',
|
||||||
|
status: 'recovered',
|
||||||
|
errorCode: 'STATUS_RESET',
|
||||||
|
reason: 'Account status manually reset',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} status reset`)
|
||||||
|
} catch (webhookError) {
|
||||||
|
logger.error('Failed to send status reset webhook notification:', webhookError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'Account status reset successfully' }
|
||||||
|
}
|
||||||
|
|
||||||
// 切换账户调度状态
|
// 切换账户调度状态
|
||||||
async function toggleSchedulable(accountId) {
|
async function toggleSchedulable(accountId) {
|
||||||
const account = await getAccount(accountId)
|
const account = await getAccount(accountId)
|
||||||
@@ -873,15 +940,26 @@ async function getAccountRateLimitInfo(accountId) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
if (account.rateLimitStatus === 'limited') {
|
||||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const limitDuration = 60 * 60 * 1000 // 1小时
|
let remainingTime = 0
|
||||||
const remainingTime = Math.max(0, limitedAt + limitDuration - now)
|
|
||||||
|
// 优先使用 rateLimitResetAt 字段(精确的重置时间)
|
||||||
|
if (account.rateLimitResetAt) {
|
||||||
|
const resetAt = new Date(account.rateLimitResetAt).getTime()
|
||||||
|
remainingTime = Math.max(0, resetAt - now)
|
||||||
|
}
|
||||||
|
// 回退到使用 rateLimitedAt + 默认1小时
|
||||||
|
else if (account.rateLimitedAt) {
|
||||||
|
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||||
|
const limitDuration = 60 * 60 * 1000 // 默认1小时
|
||||||
|
remainingTime = Math.max(0, limitedAt + limitDuration - now)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isRateLimited: remainingTime > 0,
|
isRateLimited: remainingTime > 0,
|
||||||
rateLimitedAt: account.rateLimitedAt,
|
rateLimitedAt: account.rateLimitedAt,
|
||||||
|
rateLimitResetAt: account.rateLimitResetAt,
|
||||||
minutesRemaining: Math.ceil(remainingTime / (60 * 1000))
|
minutesRemaining: Math.ceil(remainingTime / (60 * 1000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -889,6 +967,7 @@ async function getAccountRateLimitInfo(accountId) {
|
|||||||
return {
|
return {
|
||||||
isRateLimited: false,
|
isRateLimited: false,
|
||||||
rateLimitedAt: null,
|
rateLimitedAt: null,
|
||||||
|
rateLimitResetAt: null,
|
||||||
minutesRemaining: 0
|
minutesRemaining: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -926,6 +1005,7 @@ module.exports = {
|
|||||||
refreshAccountToken,
|
refreshAccountToken,
|
||||||
isTokenExpired,
|
isTokenExpired,
|
||||||
setAccountRateLimited,
|
setAccountRateLimited,
|
||||||
|
resetAccountStatus,
|
||||||
toggleSchedulable,
|
toggleSchedulable,
|
||||||
getAccountRateLimitInfo,
|
getAccountRateLimitInfo,
|
||||||
updateAccountUsage,
|
updateAccountUsage,
|
||||||
|
|||||||
351
src/services/rateLimitCleanupService.js
Normal file
351
src/services/rateLimitCleanupService.js
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* 限流状态自动清理服务
|
||||||
|
* 定期检查并清理所有类型账号的过期限流状态
|
||||||
|
*/
|
||||||
|
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const openaiAccountService = require('./openaiAccountService')
|
||||||
|
const claudeAccountService = require('./claudeAccountService')
|
||||||
|
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||||||
|
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||||
|
const webhookService = require('./webhookService')
|
||||||
|
|
||||||
|
class RateLimitCleanupService {
|
||||||
|
constructor() {
|
||||||
|
this.cleanupInterval = null
|
||||||
|
this.isRunning = false
|
||||||
|
// 默认每5分钟检查一次
|
||||||
|
this.intervalMs = 5 * 60 * 1000
|
||||||
|
// 存储已清理的账户信息,用于发送恢复通知
|
||||||
|
this.clearedAccounts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动自动清理服务
|
||||||
|
* @param {number} intervalMinutes - 检查间隔(分钟),默认5分钟
|
||||||
|
*/
|
||||||
|
start(intervalMinutes = 5) {
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
logger.warn('⚠️ Rate limit cleanup service is already running')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.intervalMs = intervalMinutes * 60 * 1000
|
||||||
|
|
||||||
|
logger.info(`🧹 Starting rate limit cleanup service (interval: ${intervalMinutes} minutes)`)
|
||||||
|
|
||||||
|
// 立即执行一次清理
|
||||||
|
this.performCleanup()
|
||||||
|
|
||||||
|
// 设置定期执行
|
||||||
|
this.cleanupInterval = setInterval(() => {
|
||||||
|
this.performCleanup()
|
||||||
|
}, this.intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止自动清理服务
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval)
|
||||||
|
this.cleanupInterval = null
|
||||||
|
logger.info('🛑 Rate limit cleanup service stopped')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行一次清理检查
|
||||||
|
*/
|
||||||
|
async performCleanup() {
|
||||||
|
if (this.isRunning) {
|
||||||
|
logger.debug('⏭️ Cleanup already in progress, skipping this cycle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = true
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug('🔍 Starting rate limit cleanup check...')
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
openai: { checked: 0, cleared: 0, errors: [] },
|
||||||
|
claude: { checked: 0, cleared: 0, errors: [] },
|
||||||
|
claudeConsole: { checked: 0, cleared: 0, errors: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理 OpenAI 账号
|
||||||
|
await this.cleanupOpenAIAccounts(results.openai)
|
||||||
|
|
||||||
|
// 清理 Claude 账号
|
||||||
|
await this.cleanupClaudeAccounts(results.claude)
|
||||||
|
|
||||||
|
// 清理 Claude Console 账号
|
||||||
|
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
||||||
|
|
||||||
|
const totalChecked =
|
||||||
|
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
||||||
|
const totalCleared =
|
||||||
|
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
|
if (totalCleared > 0) {
|
||||||
|
logger.info(
|
||||||
|
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
|
||||||
|
)
|
||||||
|
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
|
||||||
|
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
|
||||||
|
logger.info(
|
||||||
|
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 发送 webhook 恢复通知
|
||||||
|
if (this.clearedAccounts.length > 0) {
|
||||||
|
await this.sendRecoveryNotifications()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`🔍 Rate limit cleanup check completed: no expired limits found (${duration}ms)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空已清理账户列表
|
||||||
|
this.clearedAccounts = []
|
||||||
|
|
||||||
|
// 记录错误
|
||||||
|
const allErrors = [
|
||||||
|
...results.openai.errors,
|
||||||
|
...results.claude.errors,
|
||||||
|
...results.claudeConsole.errors
|
||||||
|
]
|
||||||
|
if (allErrors.length > 0) {
|
||||||
|
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Rate limit cleanup failed:', error)
|
||||||
|
} finally {
|
||||||
|
this.isRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 OpenAI 账号的过期限流
|
||||||
|
*/
|
||||||
|
async cleanupOpenAIAccounts(result) {
|
||||||
|
try {
|
||||||
|
const accounts = await openaiAccountService.getAllAccounts()
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
// 只检查标记为限流的账号
|
||||||
|
if (account.rateLimitStatus === 'limited') {
|
||||||
|
result.checked++
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 unifiedOpenAIScheduler 的检查方法,它会自动清除过期的限流
|
||||||
|
const isStillLimited = await unifiedOpenAIScheduler.isAccountRateLimited(account.id)
|
||||||
|
|
||||||
|
if (!isStillLimited) {
|
||||||
|
result.cleared++
|
||||||
|
logger.info(
|
||||||
|
`🧹 Auto-cleared expired rate limit for OpenAI account: ${account.name} (${account.id})`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 记录已清理的账户信息
|
||||||
|
this.clearedAccounts.push({
|
||||||
|
platform: 'OpenAI',
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name,
|
||||||
|
previousStatus: 'rate_limited',
|
||||||
|
currentStatus: 'active'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push({
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to cleanup OpenAI accounts:', error)
|
||||||
|
result.errors.push({ error: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 Claude 账号的过期限流
|
||||||
|
*/
|
||||||
|
async cleanupClaudeAccounts(result) {
|
||||||
|
try {
|
||||||
|
const accounts = await claudeAccountService.getAllAccounts()
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
// 只检查标记为限流的账号
|
||||||
|
if (account.rateLimitStatus === 'limited' || account.rateLimitedAt) {
|
||||||
|
result.checked++
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 claudeAccountService 的检查方法,它会自动清除过期的限流
|
||||||
|
const isStillLimited = await claudeAccountService.isAccountRateLimited(account.id)
|
||||||
|
|
||||||
|
if (!isStillLimited) {
|
||||||
|
result.cleared++
|
||||||
|
logger.info(
|
||||||
|
`🧹 Auto-cleared expired rate limit for Claude account: ${account.name} (${account.id})`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 记录已清理的账户信息
|
||||||
|
this.clearedAccounts.push({
|
||||||
|
platform: 'Claude',
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name,
|
||||||
|
previousStatus: 'rate_limited',
|
||||||
|
currentStatus: 'active'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push({
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to cleanup Claude accounts:', error)
|
||||||
|
result.errors.push({ error: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 Claude Console 账号的过期限流
|
||||||
|
*/
|
||||||
|
async cleanupClaudeConsoleAccounts(result) {
|
||||||
|
try {
|
||||||
|
const accounts = await claudeConsoleAccountService.getAllAccounts()
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
// 检查两种状态字段:rateLimitStatus 和 status
|
||||||
|
const hasRateLimitStatus = account.rateLimitStatus === 'limited'
|
||||||
|
const hasStatusRateLimited = account.status === 'rate_limited'
|
||||||
|
|
||||||
|
if (hasRateLimitStatus || hasStatusRateLimited) {
|
||||||
|
result.checked++
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 claudeConsoleAccountService 的检查方法,它会自动清除过期的限流
|
||||||
|
const isStillLimited = await claudeConsoleAccountService.isAccountRateLimited(
|
||||||
|
account.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isStillLimited) {
|
||||||
|
result.cleared++
|
||||||
|
|
||||||
|
// 如果 status 字段是 rate_limited,需要额外清理
|
||||||
|
if (hasStatusRateLimited && !hasRateLimitStatus) {
|
||||||
|
await claudeConsoleAccountService.updateAccount(account.id, {
|
||||||
|
status: 'active'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🧹 Auto-cleared expired rate limit for Claude Console account: ${account.name} (${account.id})`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 记录已清理的账户信息
|
||||||
|
this.clearedAccounts.push({
|
||||||
|
platform: 'Claude Console',
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name,
|
||||||
|
previousStatus: 'rate_limited',
|
||||||
|
currentStatus: 'active'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push({
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to cleanup Claude Console accounts:', error)
|
||||||
|
result.errors.push({ error: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发一次清理(供 API 或 CLI 调用)
|
||||||
|
*/
|
||||||
|
async manualCleanup() {
|
||||||
|
logger.info('🧹 Manual rate limit cleanup triggered')
|
||||||
|
await this.performCleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送限流恢复通知
|
||||||
|
*/
|
||||||
|
async sendRecoveryNotifications() {
|
||||||
|
try {
|
||||||
|
// 按平台分组账户
|
||||||
|
const groupedAccounts = {}
|
||||||
|
for (const account of this.clearedAccounts) {
|
||||||
|
if (!groupedAccounts[account.platform]) {
|
||||||
|
groupedAccounts[account.platform] = []
|
||||||
|
}
|
||||||
|
groupedAccounts[account.platform].push(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建通知消息
|
||||||
|
const platforms = Object.keys(groupedAccounts)
|
||||||
|
const totalAccounts = this.clearedAccounts.length
|
||||||
|
|
||||||
|
let message = `🎉 共有 ${totalAccounts} 个账户的限流状态已恢复\n\n`
|
||||||
|
|
||||||
|
for (const platform of platforms) {
|
||||||
|
const accounts = groupedAccounts[platform]
|
||||||
|
message += `**${platform}** (${accounts.length} 个):\n`
|
||||||
|
for (const account of accounts) {
|
||||||
|
message += `• ${account.accountName} (ID: ${account.accountId})\n`
|
||||||
|
}
|
||||||
|
message += '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送 webhook 通知
|
||||||
|
await webhookService.sendNotification('rateLimitRecovery', {
|
||||||
|
title: '限流恢复通知',
|
||||||
|
message,
|
||||||
|
totalAccounts,
|
||||||
|
platforms: Object.keys(groupedAccounts),
|
||||||
|
accounts: this.clearedAccounts,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`📢 已发送限流恢复通知,涉及 ${totalAccounts} 个账户`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ 发送限流恢复通知失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取服务状态
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
running: !!this.cleanupInterval,
|
||||||
|
intervalMinutes: this.intervalMs / (60 * 1000),
|
||||||
|
isProcessing: this.isRunning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单例实例
|
||||||
|
const rateLimitCleanupService = new RateLimitCleanupService()
|
||||||
|
|
||||||
|
module.exports = rateLimitCleanupService
|
||||||
@@ -303,10 +303,10 @@ class UnifiedOpenAIScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🚫 标记账户为限流状态
|
// 🚫 标记账户为限流状态
|
||||||
async markAccountRateLimited(accountId, accountType, sessionHash = null) {
|
async markAccountRateLimited(accountId, accountType, sessionHash = null, resetsInSeconds = null) {
|
||||||
try {
|
try {
|
||||||
if (accountType === 'openai') {
|
if (accountType === 'openai') {
|
||||||
await openaiAccountService.setAccountRateLimited(accountId, true)
|
await openaiAccountService.setAccountRateLimited(accountId, true, resetsInSeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除会话映射
|
// 删除会话映射
|
||||||
@@ -349,13 +349,31 @@ class UnifiedOpenAIScheduler {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
if (account.rateLimitStatus === 'limited') {
|
||||||
|
// 如果有具体的重置时间,使用它
|
||||||
|
if (account.rateLimitResetAt) {
|
||||||
|
const resetTime = new Date(account.rateLimitResetAt).getTime()
|
||||||
|
const now = Date.now()
|
||||||
|
const isStillLimited = now < resetTime
|
||||||
|
|
||||||
|
// 如果已经过了重置时间,自动清除限流状态
|
||||||
|
if (!isStillLimited) {
|
||||||
|
logger.info(`✅ Auto-clearing rate limit for account ${accountId} (reset time reached)`)
|
||||||
|
await openaiAccountService.setAccountRateLimited(accountId, false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return isStillLimited
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有具体的重置时间,使用默认的1小时
|
||||||
|
if (account.rateLimitedAt) {
|
||||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const limitDuration = 60 * 60 * 1000 // 1小时
|
const limitDuration = 60 * 60 * 1000 // 1小时
|
||||||
|
|
||||||
return now < limitedAt + limitDuration
|
return now < limitedAt + limitDuration
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Failed to check rate limit status: ${accountId}`, error)
|
logger.error(`❌ Failed to check rate limit status: ${accountId}`, error)
|
||||||
|
|||||||
@@ -375,6 +375,7 @@ class WebhookService {
|
|||||||
quotaWarning: '📊 配额警告',
|
quotaWarning: '📊 配额警告',
|
||||||
systemError: '❌ 系统错误',
|
systemError: '❌ 系统错误',
|
||||||
securityAlert: '🔒 安全警报',
|
securityAlert: '🔒 安全警报',
|
||||||
|
rateLimitRecovery: '🎉 限流恢复通知',
|
||||||
test: '🧪 测试通知'
|
test: '🧪 测试通知'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +391,7 @@ class WebhookService {
|
|||||||
quotaWarning: 'active',
|
quotaWarning: 'active',
|
||||||
systemError: 'critical',
|
systemError: 'critical',
|
||||||
securityAlert: 'critical',
|
securityAlert: 'critical',
|
||||||
|
rateLimitRecovery: 'active',
|
||||||
test: 'passive'
|
test: 'passive'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +407,7 @@ class WebhookService {
|
|||||||
quotaWarning: 'bell',
|
quotaWarning: 'bell',
|
||||||
systemError: 'alert',
|
systemError: 'alert',
|
||||||
securityAlert: 'alarm',
|
securityAlert: 'alarm',
|
||||||
|
rateLimitRecovery: 'success',
|
||||||
test: 'default'
|
test: 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +473,14 @@ class WebhookService {
|
|||||||
lines.push(`**平台**: ${data.platform}`)
|
lines.push(`**平台**: ${data.platform}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.platforms) {
|
||||||
|
lines.push(`**涉及平台**: ${data.platforms.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.totalAccounts) {
|
||||||
|
lines.push(`**恢复账户数**: ${data.totalAccounts}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (data.status) {
|
if (data.status) {
|
||||||
lines.push(`**状态**: ${data.status}`)
|
lines.push(`**状态**: ${data.status}`)
|
||||||
}
|
}
|
||||||
@@ -539,6 +550,7 @@ class WebhookService {
|
|||||||
quotaWarning: 'yellow',
|
quotaWarning: 'yellow',
|
||||||
systemError: 'red',
|
systemError: 'red',
|
||||||
securityAlert: 'red',
|
securityAlert: 'red',
|
||||||
|
rateLimitRecovery: 'green',
|
||||||
test: 'blue'
|
test: 'blue'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,6 +566,7 @@ class WebhookService {
|
|||||||
quotaWarning: ':chart_with_downwards_trend:',
|
quotaWarning: ':chart_with_downwards_trend:',
|
||||||
systemError: ':x:',
|
systemError: ':x:',
|
||||||
securityAlert: ':lock:',
|
securityAlert: ':lock:',
|
||||||
|
rateLimitRecovery: ':tada:',
|
||||||
test: ':test_tube:'
|
test: ':test_tube:'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,6 +582,7 @@ class WebhookService {
|
|||||||
quotaWarning: 0xffeb3b, // 黄色
|
quotaWarning: 0xffeb3b, // 黄色
|
||||||
systemError: 0xf44336, // 红色
|
systemError: 0xf44336, // 红色
|
||||||
securityAlert: 0xf44336, // 红色
|
securityAlert: 0xf44336, // 红色
|
||||||
|
rateLimitRecovery: 0x4caf50, // 绿色
|
||||||
test: 0x2196f3 // 蓝色
|
test: 0x2196f3 // 蓝色
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ const securityLogger = winston.createLogger({
|
|||||||
const authDetailLogger = winston.createLogger({
|
const authDetailLogger = winston.createLogger({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }),
|
||||||
winston.format.printf(({ level, message, timestamp, data }) => {
|
winston.format.printf(({ level, message, timestamp, data }) => {
|
||||||
// 使用更深的深度和格式化的JSON输出
|
// 使用更深的深度和格式化的JSON输出
|
||||||
const jsonData = data ? JSON.stringify(data, null, 2) : '{}'
|
const jsonData = data ? JSON.stringify(data, null, 2) : '{}'
|
||||||
|
|||||||
@@ -934,6 +934,64 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude 统一客户端标识配置 -->
|
||||||
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
|
<label class="flex items-start">
|
||||||
|
<input
|
||||||
|
v-model="form.useUnifiedClientId"
|
||||||
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
@change="handleUnifiedClientIdChange"
|
||||||
|
/>
|
||||||
|
<div class="ml-3 flex-1">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
使用统一的客户端标识
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
开启后将使用固定的客户端标识,使所有请求看起来来自同一个客户端,减少特征
|
||||||
|
</p>
|
||||||
|
<div v-if="form.useUnifiedClientId" class="mt-3">
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>客户端标识 ID</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
|
||||||
|
type="button"
|
||||||
|
@click="regenerateClientId"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sync-alt mr-1" />
|
||||||
|
重新生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code
|
||||||
|
class="block w-full select-all break-all rounded bg-gray-100 px-3 py-2 font-mono text-xs text-gray-700 dark:bg-gray-900 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400">{{
|
||||||
|
form.unifiedClientId.substring(0, 8)
|
||||||
|
}}</span
|
||||||
|
><span class="text-gray-500 dark:text-gray-500">{{
|
||||||
|
form.unifiedClientId.substring(8, 56)
|
||||||
|
}}</span
|
||||||
|
><span class="text-blue-600 dark:text-blue-400">{{
|
||||||
|
form.unifiedClientId.substring(56)
|
||||||
|
}}</span>
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-info-circle mr-1 text-blue-500" />
|
||||||
|
此ID将替换请求中的user_id客户端部分,保留session部分用于粘性会话
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 所有平台的优先级设置 -->
|
<!-- 所有平台的优先级设置 -->
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
@@ -1553,6 +1611,64 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude 统一客户端标识配置(编辑模式) -->
|
||||||
|
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||||
|
<label class="flex items-start">
|
||||||
|
<input
|
||||||
|
v-model="form.useUnifiedClientId"
|
||||||
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
@change="handleUnifiedClientIdChange"
|
||||||
|
/>
|
||||||
|
<div class="ml-3 flex-1">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
使用统一的客户端标识
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
开启后将使用固定的客户端标识,使所有请求看起来来自同一个客户端,减少特征
|
||||||
|
</p>
|
||||||
|
<div v-if="form.useUnifiedClientId" class="mt-3">
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||||
|
>客户端标识 ID</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
|
||||||
|
type="button"
|
||||||
|
@click="regenerateClientId"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sync-alt mr-1" />
|
||||||
|
重新生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code
|
||||||
|
class="block w-full select-all break-all rounded bg-gray-100 px-3 py-2 font-mono text-xs text-gray-700 dark:bg-gray-900 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400">{{
|
||||||
|
form.unifiedClientId.substring(0, 8)
|
||||||
|
}}</span
|
||||||
|
><span class="text-gray-500 dark:text-gray-500">{{
|
||||||
|
form.unifiedClientId.substring(8, 56)
|
||||||
|
}}</span
|
||||||
|
><span class="text-blue-600 dark:text-blue-400">{{
|
||||||
|
form.unifiedClientId.substring(56)
|
||||||
|
}}</span>
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-info-circle mr-1 text-blue-500" />
|
||||||
|
此ID将替换请求中的user_id客户端部分,保留session部分用于粘性会话
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 所有平台的优先级设置(编辑模式) -->
|
<!-- 所有平台的优先级设置(编辑模式) -->
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
@@ -2155,6 +2271,8 @@ const setupTokenSessionId = ref('')
|
|||||||
// Claude Code 统一 User-Agent 信息
|
// Claude Code 统一 User-Agent 信息
|
||||||
const unifiedUserAgent = ref('')
|
const unifiedUserAgent = ref('')
|
||||||
const clearingCache = ref(false)
|
const clearingCache = ref(false)
|
||||||
|
// 客户端标识编辑状态(已废弃,不再需要编辑功能)
|
||||||
|
// const editingClientId = ref(false)
|
||||||
|
|
||||||
// 初始化代理配置
|
// 初始化代理配置
|
||||||
const initProxyConfig = () => {
|
const initProxyConfig = () => {
|
||||||
@@ -2193,6 +2311,8 @@ const form = ref({
|
|||||||
subscriptionType: 'claude_max', // 默认为 Claude Max,兼容旧数据
|
subscriptionType: 'claude_max', // 默认为 Claude Max,兼容旧数据
|
||||||
autoStopOnWarning: props.account?.autoStopOnWarning || false, // 5小时限制自动停止调度
|
autoStopOnWarning: props.account?.autoStopOnWarning || false, // 5小时限制自动停止调度
|
||||||
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
|
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
|
||||||
|
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
|
||||||
|
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
|
||||||
groupId: '',
|
groupId: '',
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
projectId: props.account?.projectId || '',
|
projectId: props.account?.projectId || '',
|
||||||
@@ -2477,6 +2597,11 @@ const exchangeSetupTokenCode = async () => {
|
|||||||
|
|
||||||
const tokenInfo = await accountsStore.exchangeClaudeSetupTokenCode(data)
|
const tokenInfo = await accountsStore.exchangeClaudeSetupTokenCode(data)
|
||||||
|
|
||||||
|
// Setup Token模式也需要确保生成客户端ID
|
||||||
|
if (form.value.useUnifiedClientId && !form.value.unifiedClientId) {
|
||||||
|
form.value.unifiedClientId = generateClientId()
|
||||||
|
}
|
||||||
|
|
||||||
// 调用相同的成功处理函数
|
// 调用相同的成功处理函数
|
||||||
await handleOAuthSuccess(tokenInfo)
|
await handleOAuthSuccess(tokenInfo)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2490,6 +2615,15 @@ const exchangeSetupTokenCode = async () => {
|
|||||||
const handleOAuthSuccess = async (tokenInfo) => {
|
const handleOAuthSuccess = async (tokenInfo) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
// OAuth模式也需要确保生成客户端ID
|
||||||
|
if (
|
||||||
|
form.value.platform === 'claude' &&
|
||||||
|
form.value.useUnifiedClientId &&
|
||||||
|
!form.value.unifiedClientId
|
||||||
|
) {
|
||||||
|
form.value.unifiedClientId = generateClientId()
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
description: form.value.description,
|
description: form.value.description,
|
||||||
@@ -2513,6 +2647,8 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
|||||||
data.priority = form.value.priority || 50
|
data.priority = form.value.priority || 50
|
||||||
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
||||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||||
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||||
|
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||||
// 添加订阅类型信息
|
// 添加订阅类型信息
|
||||||
data.subscriptionInfo = {
|
data.subscriptionInfo = {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
@@ -2697,6 +2833,11 @@ const createAccount = async () => {
|
|||||||
? 10 * 60 * 1000 // 10分钟
|
? 10 * 60 * 1000 // 10分钟
|
||||||
: 365 * 24 * 60 * 60 * 1000 // 1年
|
: 365 * 24 * 60 * 60 * 1000 // 1年
|
||||||
|
|
||||||
|
// 手动模式也需要确保生成客户端ID
|
||||||
|
if (form.value.useUnifiedClientId && !form.value.unifiedClientId) {
|
||||||
|
form.value.unifiedClientId = generateClientId()
|
||||||
|
}
|
||||||
|
|
||||||
data.claudeAiOauth = {
|
data.claudeAiOauth = {
|
||||||
accessToken: form.value.accessToken,
|
accessToken: form.value.accessToken,
|
||||||
refreshToken: form.value.refreshToken || '',
|
refreshToken: form.value.refreshToken || '',
|
||||||
@@ -2706,6 +2847,8 @@ const createAccount = async () => {
|
|||||||
data.priority = form.value.priority || 50
|
data.priority = form.value.priority || 50
|
||||||
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
||||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||||
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||||
|
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||||
// 添加订阅类型信息
|
// 添加订阅类型信息
|
||||||
data.subscriptionInfo = {
|
data.subscriptionInfo = {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
@@ -2972,9 +3115,16 @@ const updateAccount = async () => {
|
|||||||
|
|
||||||
// Claude 官方账号优先级和订阅类型更新
|
// Claude 官方账号优先级和订阅类型更新
|
||||||
if (props.account.platform === 'claude') {
|
if (props.account.platform === 'claude') {
|
||||||
|
// 更新模式也需要确保生成客户端ID
|
||||||
|
if (form.value.useUnifiedClientId && !form.value.unifiedClientId) {
|
||||||
|
form.value.unifiedClientId = generateClientId()
|
||||||
|
}
|
||||||
|
|
||||||
data.priority = form.value.priority || 50
|
data.priority = form.value.priority || 50
|
||||||
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
||||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||||
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||||
|
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||||
// 更新订阅类型信息
|
// 更新订阅类型信息
|
||||||
data.subscriptionInfo = {
|
data.subscriptionInfo = {
|
||||||
accountType: form.value.subscriptionType || 'claude_max',
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
@@ -3408,6 +3558,8 @@ watch(
|
|||||||
subscriptionType: subscriptionType,
|
subscriptionType: subscriptionType,
|
||||||
autoStopOnWarning: newAccount.autoStopOnWarning || false,
|
autoStopOnWarning: newAccount.autoStopOnWarning || false,
|
||||||
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
|
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
|
||||||
|
useUnifiedClientId: newAccount.useUnifiedClientId || false,
|
||||||
|
unifiedClientId: newAccount.unifiedClientId || '',
|
||||||
groupId: groupId,
|
groupId: groupId,
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
projectId: newAccount.projectId || '',
|
projectId: newAccount.projectId || '',
|
||||||
@@ -3530,6 +3682,32 @@ const clearUnifiedCache = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成客户端标识
|
||||||
|
const generateClientId = () => {
|
||||||
|
// 生成64位十六进制字符串(32字节)
|
||||||
|
const bytes = new Uint8Array(32)
|
||||||
|
crypto.getRandomValues(bytes)
|
||||||
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新生成客户端标识
|
||||||
|
const regenerateClientId = () => {
|
||||||
|
form.value.unifiedClientId = generateClientId()
|
||||||
|
showToast('已生成新的客户端标识', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理统一客户端标识复选框变化
|
||||||
|
const handleUnifiedClientIdChange = () => {
|
||||||
|
if (form.value.useUnifiedClientId) {
|
||||||
|
// 如果启用了统一客户端标识,自动启用统一User-Agent
|
||||||
|
form.value.useUnifiedUserAgent = true
|
||||||
|
// 如果没有客户端标识,自动生成一个
|
||||||
|
if (!form.value.unifiedClientId) {
|
||||||
|
form.value.unifiedClientId = generateClientId()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 组件挂载时获取统一 User-Agent 信息
|
// 组件挂载时获取统一 User-Agent 信息
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 获取Claude Code统一User-Agent信息
|
// 获取Claude Code统一User-Agent信息
|
||||||
|
|||||||
@@ -115,7 +115,19 @@
|
|||||||
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base"
|
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base"
|
||||||
>过期时间</span
|
>过期时间</span
|
||||||
>
|
>
|
||||||
<div v-if="statsData.expiresAt" class="text-right">
|
<!-- 未激活状态 -->
|
||||||
|
<div
|
||||||
|
v-if="statsData.expirationMode === 'activation' && !statsData.isActivated"
|
||||||
|
class="text-sm font-medium text-amber-600 dark:text-amber-500 md:text-base"
|
||||||
|
>
|
||||||
|
<i class="fas fa-pause-circle mr-1 text-xs md:text-sm" />
|
||||||
|
未激活
|
||||||
|
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>(首次使用后{{ statsData.activationDays || 30 }}天过期)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- 已设置过期时间 -->
|
||||||
|
<div v-else-if="statsData.expiresAt" class="text-right">
|
||||||
<div
|
<div
|
||||||
v-if="isApiKeyExpired(statsData.expiresAt)"
|
v-if="isApiKeyExpired(statsData.expiresAt)"
|
||||||
class="text-sm font-medium text-red-600 md:text-base"
|
class="text-sm font-medium text-red-600 md:text-base"
|
||||||
@@ -137,6 +149,7 @@
|
|||||||
{{ formatExpireDate(statsData.expiresAt) }}
|
{{ formatExpireDate(statsData.expiresAt) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 永不过期 -->
|
||||||
<div v-else class="text-sm font-medium text-gray-400 dark:text-gray-500 md:text-base">
|
<div v-else class="text-sm font-medium text-gray-400 dark:text-gray-500 md:text-base">
|
||||||
<i class="fas fa-infinity mr-1 text-xs md:text-sm" />
|
<i class="fas fa-infinity mr-1 text-xs md:text-sm" />
|
||||||
永不过期
|
永不过期
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
siteName: 'Claude Relay Service',
|
siteName: 'Claude Relay Service',
|
||||||
siteIcon: '',
|
siteIcon: '',
|
||||||
siteIconData: '',
|
siteIconData: '',
|
||||||
|
showAdminButton: true, // 控制管理后台按钮的显示
|
||||||
updatedAt: null
|
updatedAt: null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
siteName: 'Claude Relay Service',
|
siteName: 'Claude Relay Service',
|
||||||
siteIcon: '',
|
siteIcon: '',
|
||||||
siteIconData: '',
|
siteIconData: '',
|
||||||
|
showAdminButton: true,
|
||||||
updatedAt: null
|
updatedAt: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -272,7 +272,12 @@
|
|||||||
>
|
>
|
||||||
<i class="fas fa-share-alt mr-1" />共享
|
<i class="fas fa-share-alt mr-1" />共享
|
||||||
</span>
|
</span>
|
||||||
<!-- 显示所有分组 -->
|
</div>
|
||||||
|
<!-- 显示所有分组 - 换行显示 -->
|
||||||
|
<div
|
||||||
|
v-if="account.groupInfos && account.groupInfos.length > 0"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
v-for="group in account.groupInfos"
|
v-for="group in account.groupInfos"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
@@ -424,7 +429,7 @@
|
|||||||
typeof account.rateLimitStatus === 'object' &&
|
typeof account.rateLimitStatus === 'object' &&
|
||||||
account.rateLimitStatus.minutesRemaining > 0
|
account.rateLimitStatus.minutesRemaining > 0
|
||||||
"
|
"
|
||||||
>({{ account.rateLimitStatus.minutesRemaining }}分钟)</span
|
>({{ formatRateLimitTime(account.rateLimitStatus.minutesRemaining) }})</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -636,7 +641,9 @@
|
|||||||
<div class="flex flex-wrap items-center gap-1">
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
<button
|
<button
|
||||||
v-if="
|
v-if="
|
||||||
account.platform === 'claude' &&
|
(account.platform === 'claude' ||
|
||||||
|
account.platform === 'claude-console' ||
|
||||||
|
account.platform === 'openai') &&
|
||||||
(account.status === 'unauthorized' ||
|
(account.status === 'unauthorized' ||
|
||||||
account.status !== 'active' ||
|
account.status !== 'active' ||
|
||||||
account.rateLimitStatus?.isRateLimited ||
|
account.rateLimitStatus?.isRateLimited ||
|
||||||
@@ -1336,7 +1343,7 @@ const loadApiKeys = async (forceReload = false) => {
|
|||||||
apiKeysLoaded.value = true
|
apiKeysLoaded.value = true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load API keys:', error)
|
// 静默处理错误
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1353,7 +1360,7 @@ const loadAccountGroups = async (forceReload = false) => {
|
|||||||
groupsLoaded.value = true
|
groupsLoaded.value = true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load account groups:', error)
|
// 静默处理错误
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1426,6 +1433,38 @@ const formatRemainingTime = (minutes) => {
|
|||||||
return `${mins}分钟`
|
return `${mins}分钟`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式化限流时间(支持显示天数)
|
||||||
|
const formatRateLimitTime = (minutes) => {
|
||||||
|
if (!minutes || minutes <= 0) return ''
|
||||||
|
|
||||||
|
// 转换为整数,避免小数
|
||||||
|
minutes = Math.floor(minutes)
|
||||||
|
|
||||||
|
// 计算天数、小时和分钟
|
||||||
|
const days = Math.floor(minutes / 1440) // 1天 = 1440分钟
|
||||||
|
const remainingAfterDays = minutes % 1440
|
||||||
|
const hours = Math.floor(remainingAfterDays / 60)
|
||||||
|
const mins = remainingAfterDays % 60
|
||||||
|
|
||||||
|
// 根据时间长度返回不同格式
|
||||||
|
if (days > 0) {
|
||||||
|
// 超过1天,显示天数和小时
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${days}天${hours}小时`
|
||||||
|
}
|
||||||
|
return `${days}天`
|
||||||
|
} else if (hours > 0) {
|
||||||
|
// 超过1小时但不到1天,显示小时和分钟
|
||||||
|
if (mins > 0) {
|
||||||
|
return `${hours}小时${mins}分钟`
|
||||||
|
}
|
||||||
|
return `${hours}小时`
|
||||||
|
} else {
|
||||||
|
// 不到1小时,只显示分钟
|
||||||
|
return `${mins}分钟`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 打开创建账户模态框
|
// 打开创建账户模态框
|
||||||
const openCreateAccountModal = () => {
|
const openCreateAccountModal = () => {
|
||||||
showCreateAccountModal.value = true
|
showCreateAccountModal.value = true
|
||||||
@@ -1515,7 +1554,22 @@ const resetAccountStatus = async (account) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
account.isResetting = true
|
account.isResetting = true
|
||||||
const data = await apiClient.post(`/admin/claude-accounts/${account.id}/reset-status`)
|
|
||||||
|
// 根据账户平台选择不同的 API 端点
|
||||||
|
let endpoint = ''
|
||||||
|
if (account.platform === 'openai') {
|
||||||
|
endpoint = `/admin/openai-accounts/${account.id}/reset-status`
|
||||||
|
} else if (account.platform === 'claude') {
|
||||||
|
endpoint = `/admin/claude-accounts/${account.id}/reset-status`
|
||||||
|
} else if (account.platform === 'claude-console') {
|
||||||
|
endpoint = `/admin/claude-console-accounts/${account.id}/reset-status`
|
||||||
|
} else {
|
||||||
|
showToast('不支持的账户类型', 'error')
|
||||||
|
account.isResetting = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await apiClient.post(endpoint)
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('账户状态已重置', 'success')
|
showToast('账户状态已重置', 'success')
|
||||||
@@ -1621,13 +1675,7 @@ const getClaudeAccountType = (account) => {
|
|||||||
? JSON.parse(account.subscriptionInfo)
|
? JSON.parse(account.subscriptionInfo)
|
||||||
: account.subscriptionInfo
|
: account.subscriptionInfo
|
||||||
|
|
||||||
// 添加调试日志
|
// 订阅信息已解析
|
||||||
console.log('Account subscription info:', {
|
|
||||||
accountName: account.name,
|
|
||||||
subscriptionInfo: info,
|
|
||||||
hasClaudeMax: info.hasClaudeMax,
|
|
||||||
hasClaudePro: info.hasClaudePro
|
|
||||||
})
|
|
||||||
|
|
||||||
// 根据 has_claude_max 和 has_claude_pro 判断
|
// 根据 has_claude_max 和 has_claude_pro 判断
|
||||||
if (info.hasClaudeMax === true) {
|
if (info.hasClaudeMax === true) {
|
||||||
@@ -1639,13 +1687,11 @@ const getClaudeAccountType = (account) => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 解析失败,返回默认值
|
// 解析失败,返回默认值
|
||||||
console.error('Failed to parse subscription info:', e)
|
|
||||||
return 'Claude'
|
return 'Claude'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 没有订阅信息,保持原有显示
|
// 没有订阅信息,保持原有显示
|
||||||
console.log('No subscription info for account:', account.name)
|
|
||||||
return 'Claude'
|
return 'Claude'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
<!-- 分隔线 -->
|
<!-- 分隔线 -->
|
||||||
<div
|
<div
|
||||||
|
v-if="oemSettings.ldapEnabled || oemSettings.showAdminButton !== false"
|
||||||
class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600"
|
class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
<!-- 管理后台按钮 -->
|
<!-- 管理后台按钮 -->
|
||||||
<router-link
|
<router-link
|
||||||
|
v-if="oemSettings.showAdminButton !== false"
|
||||||
class="admin-button-refined flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
|
class="admin-button-refined flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
|
||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -147,6 +147,41 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<!-- 管理后台按钮显示控制 -->
|
||||||
|
<tr class="table-row">
|
||||||
|
<td class="w-48 whitespace-nowrap px-6 py-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="mr-3 flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600"
|
||||||
|
>
|
||||||
|
<i class="fas fa-eye-slash text-xs text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
管理入口
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">登录按钮显示</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<label class="inline-flex cursor-pointer items-center">
|
||||||
|
<input v-model="hideAdminButton" class="peer sr-only" type="checkbox" />
|
||||||
|
<div
|
||||||
|
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
|
||||||
|
></div>
|
||||||
|
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{
|
||||||
|
hideAdminButton ? '隐藏登录按钮' : '显示登录按钮'
|
||||||
|
}}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
隐藏后,用户需要直接访问 /admin/login 页面登录
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-6" colspan="2">
|
<td class="px-6 py-6" colspan="2">
|
||||||
@@ -189,7 +224,148 @@
|
|||||||
|
|
||||||
<!-- 移动端卡片视图 -->
|
<!-- 移动端卡片视图 -->
|
||||||
<div class="space-y-4 sm:hidden">
|
<div class="space-y-4 sm:hidden">
|
||||||
<!-- 省略移动端视图代码... -->
|
<!-- 站点名称卡片 -->
|
||||||
|
<div class="glass-card p-4">
|
||||||
|
<div class="mb-3 flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-cyan-600 text-white shadow-md"
|
||||||
|
>
|
||||||
|
<i class="fas fa-tag"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">站点名称</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">自定义您的站点品牌名称</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="oemSettings.siteName"
|
||||||
|
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
maxlength="100"
|
||||||
|
placeholder="Claude Relay Service"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 站点图标卡片 -->
|
||||||
|
<div class="glass-card p-4">
|
||||||
|
<div class="mb-3 flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500 to-pink-600 text-white shadow-md"
|
||||||
|
>
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">站点图标</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
上传自定义图标或输入图标URL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- 图标预览 -->
|
||||||
|
<div
|
||||||
|
v-if="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||||
|
class="inline-flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="图标预览"
|
||||||
|
class="h-8 w-8"
|
||||||
|
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||||
|
@error="handleIconError"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">当前图标</span>
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-1 font-medium text-red-600 transition-colors hover:bg-red-50 hover:text-red-900"
|
||||||
|
@click="removeIcon"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 上传按钮 -->
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref="iconFileInputMobile"
|
||||||
|
accept=".ico,.png,.jpg,.jpeg,.svg"
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
@change="handleIconUpload"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-success px-4 py-2"
|
||||||
|
@click="$refs.iconFileInputMobile.click()"
|
||||||
|
>
|
||||||
|
<i class="fas fa-upload mr-2" />
|
||||||
|
上传图标
|
||||||
|
</button>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
支持 .ico, .png, .jpg, .svg 格式,最大 350KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 管理后台按钮显示控制卡片 -->
|
||||||
|
<div class="glass-card p-4">
|
||||||
|
<div class="mb-3 flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 text-white shadow-md"
|
||||||
|
>
|
||||||
|
<i class="fas fa-eye-slash"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">管理入口</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">控制登录按钮在首页的显示</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="inline-flex cursor-pointer items-center">
|
||||||
|
<input v-model="hideAdminButton" class="peer sr-only" type="checkbox" />
|
||||||
|
<div
|
||||||
|
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
|
||||||
|
></div>
|
||||||
|
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{
|
||||||
|
hideAdminButton ? '隐藏登录按钮' : '显示登录按钮'
|
||||||
|
}}</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
隐藏后,用户需要直接访问 /admin/login 页面登录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮卡片 -->
|
||||||
|
<div class="glass-card p-4">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-full px-6 py-3"
|
||||||
|
:class="{ 'cursor-not-allowed opacity-50': saving }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="saveOemSettings"
|
||||||
|
>
|
||||||
|
<div v-if="saving" class="loading-spinner mr-2"></div>
|
||||||
|
<i v-else class="fas fa-save mr-2" />
|
||||||
|
{{ saving ? '保存中...' : '保存设置' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn w-full bg-gray-100 px-6 py-3 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="resetOemSettings"
|
||||||
|
>
|
||||||
|
<i class="fas fa-undo mr-2" />
|
||||||
|
重置为默认
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="oemSettings.updatedAt"
|
||||||
|
class="text-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-clock mr-1" />
|
||||||
|
上次更新: {{ formatDateTime(oemSettings.updatedAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -791,6 +967,16 @@ const isMounted = ref(true)
|
|||||||
// API请求取消控制器
|
// API请求取消控制器
|
||||||
const abortController = ref(new AbortController())
|
const abortController = ref(new AbortController())
|
||||||
|
|
||||||
|
// 计算属性:隐藏管理后台按钮(反转 showAdminButton 的值)
|
||||||
|
const hideAdminButton = computed({
|
||||||
|
get() {
|
||||||
|
return !oemSettings.value.showAdminButton
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
oemSettings.value.showAdminButton = !value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// URL 验证状态
|
// URL 验证状态
|
||||||
const urlError = ref(false)
|
const urlError = ref(false)
|
||||||
const urlValid = ref(false)
|
const urlValid = ref(false)
|
||||||
@@ -1286,7 +1472,8 @@ const saveOemSettings = async () => {
|
|||||||
const settings = {
|
const settings = {
|
||||||
siteName: oemSettings.value.siteName,
|
siteName: oemSettings.value.siteName,
|
||||||
siteIcon: oemSettings.value.siteIcon,
|
siteIcon: oemSettings.value.siteIcon,
|
||||||
siteIconData: oemSettings.value.siteIconData
|
siteIconData: oemSettings.value.siteIconData,
|
||||||
|
showAdminButton: oemSettings.value.showAdminButton
|
||||||
}
|
}
|
||||||
const result = await settingsStore.saveOemSettings(settings)
|
const result = await settingsStore.saveOemSettings(settings)
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
|
|||||||
@@ -434,6 +434,7 @@
|
|||||||
<div class="whitespace-nowrap text-gray-300">model = "gpt-5"</div>
|
<div class="whitespace-nowrap text-gray-300">model = "gpt-5"</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">model_reasoning_effort = "high"</div>
|
<div class="whitespace-nowrap text-gray-300">model_reasoning_effort = "high"</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">disable_response_storage = true</div>
|
<div class="whitespace-nowrap text-gray-300">disable_response_storage = true</div>
|
||||||
|
<div class="whitespace-nowrap text-gray-300">preferred_auth_method = "apikey"</div>
|
||||||
<div class="mt-2"></div>
|
<div class="mt-2"></div>
|
||||||
<div class="whitespace-nowrap text-gray-300">[model_providers.crs]</div>
|
<div class="whitespace-nowrap text-gray-300">[model_providers.crs]</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">name = "crs"</div>
|
<div class="whitespace-nowrap text-gray-300">name = "crs"</div>
|
||||||
@@ -920,6 +921,7 @@
|
|||||||
<div class="whitespace-nowrap text-gray-300">model = "gpt-5"</div>
|
<div class="whitespace-nowrap text-gray-300">model = "gpt-5"</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">model_reasoning_effort = "high"</div>
|
<div class="whitespace-nowrap text-gray-300">model_reasoning_effort = "high"</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">disable_response_storage = true</div>
|
<div class="whitespace-nowrap text-gray-300">disable_response_storage = true</div>
|
||||||
|
<div class="whitespace-nowrap text-gray-300">preferred_auth_method = "apikey"</div>
|
||||||
<div class="mt-2"></div>
|
<div class="mt-2"></div>
|
||||||
<div class="whitespace-nowrap text-gray-300">[model_providers.crs]</div>
|
<div class="whitespace-nowrap text-gray-300">[model_providers.crs]</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">name = "crs"</div>
|
<div class="whitespace-nowrap text-gray-300">name = "crs"</div>
|
||||||
@@ -1397,6 +1399,7 @@
|
|||||||
<div class="whitespace-nowrap text-gray-300">model = "gpt-5"</div>
|
<div class="whitespace-nowrap text-gray-300">model = "gpt-5"</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">model_reasoning_effort = "high"</div>
|
<div class="whitespace-nowrap text-gray-300">model_reasoning_effort = "high"</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">disable_response_storage = true</div>
|
<div class="whitespace-nowrap text-gray-300">disable_response_storage = true</div>
|
||||||
|
<div class="whitespace-nowrap text-gray-300">preferred_auth_method = "apikey"</div>
|
||||||
<div class="mt-2"></div>
|
<div class="mt-2"></div>
|
||||||
<div class="whitespace-nowrap text-gray-300">[model_providers.crs]</div>
|
<div class="whitespace-nowrap text-gray-300">[model_providers.crs]</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">name = "crs"</div>
|
<div class="whitespace-nowrap text-gray-300">name = "crs"</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user