mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
735 lines
25 KiB
JavaScript
735 lines
25 KiB
JavaScript
const express = require('express')
|
||
const cors = require('cors')
|
||
const helmet = require('helmet')
|
||
const compression = require('compression')
|
||
const path = require('path')
|
||
const fs = require('fs')
|
||
const bcrypt = require('bcryptjs')
|
||
|
||
const config = require('../config/config')
|
||
const logger = require('./utils/logger')
|
||
const redis = require('./models/redis')
|
||
const pricingService = require('./services/pricingService')
|
||
const cacheMonitor = require('./utils/cacheMonitor')
|
||
|
||
// Import routes
|
||
const apiRoutes = require('./routes/api')
|
||
const unifiedRoutes = require('./routes/unified')
|
||
const adminRoutes = require('./routes/admin')
|
||
const webRoutes = require('./routes/web')
|
||
const apiStatsRoutes = require('./routes/apiStats')
|
||
const geminiRoutes = require('./routes/geminiRoutes')
|
||
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
|
||
const standardGeminiRoutes = require('./routes/standardGeminiRoutes')
|
||
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
|
||
const openaiRoutes = require('./routes/openaiRoutes')
|
||
const droidRoutes = require('./routes/droidRoutes')
|
||
const userRoutes = require('./routes/userRoutes')
|
||
const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes')
|
||
const webhookRoutes = require('./routes/webhook')
|
||
|
||
// Import middleware
|
||
const {
|
||
corsMiddleware,
|
||
requestLogger,
|
||
securityMiddleware,
|
||
errorHandler,
|
||
globalRateLimit,
|
||
requestSizeLimit
|
||
} = require('./middleware/auth')
|
||
const { browserFallbackMiddleware } = require('./middleware/browserFallback')
|
||
|
||
class Application {
|
||
constructor() {
|
||
this.app = express()
|
||
this.server = null
|
||
}
|
||
|
||
async initialize() {
|
||
try {
|
||
// 🔗 连接Redis
|
||
logger.info('🔄 Connecting to Redis...')
|
||
await redis.connect()
|
||
logger.success('✅ Redis connected successfully')
|
||
|
||
// 💰 初始化价格服务
|
||
logger.info('🔄 Initializing pricing service...')
|
||
await pricingService.initialize()
|
||
|
||
// 📋 初始化模型服务
|
||
logger.info('🔄 Initializing model service...')
|
||
const modelService = require('./services/modelService')
|
||
await modelService.initialize()
|
||
|
||
// 📊 初始化缓存监控
|
||
await this.initializeCacheMonitoring()
|
||
|
||
// 🔧 初始化管理员凭据
|
||
logger.info('🔄 Initializing admin credentials...')
|
||
await this.initializeAdmin()
|
||
|
||
// 💰 初始化费用数据
|
||
logger.info('💰 Checking cost data initialization...')
|
||
const costInitService = require('./services/costInitService')
|
||
const needsInit = await costInitService.needsInitialization()
|
||
if (needsInit) {
|
||
logger.info('💰 Initializing cost data for all API Keys...')
|
||
const result = await costInitService.initializeAllCosts()
|
||
logger.info(
|
||
`💰 Cost initialization completed: ${result.processed} processed, ${result.errors} errors`
|
||
)
|
||
}
|
||
|
||
// 🕐 初始化Claude账户会话窗口
|
||
logger.info('🕐 Initializing Claude account session windows...')
|
||
const claudeAccountService = require('./services/claudeAccountService')
|
||
await claudeAccountService.initializeSessionWindows()
|
||
|
||
// 📊 初始化费用排序索引服务
|
||
logger.info('📊 Initializing cost rank service...')
|
||
const costRankService = require('./services/costRankService')
|
||
await costRankService.initialize()
|
||
|
||
// 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
|
||
this.app.use((req, res, next) => {
|
||
if (req.path === '/admin-next/' && req.method === 'GET') {
|
||
logger.warn('🚨 INTERCEPTING /admin-next/ request at the very beginning!')
|
||
const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist')
|
||
const indexPath = path.join(adminSpaPath, 'index.html')
|
||
|
||
if (fs.existsSync(indexPath)) {
|
||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||
return res.sendFile(indexPath)
|
||
} else {
|
||
logger.error('❌ index.html not found at:', indexPath)
|
||
return res.status(404).send('index.html not found')
|
||
}
|
||
}
|
||
next()
|
||
})
|
||
|
||
// 🛡️ 安全中间件
|
||
this.app.use(
|
||
helmet({
|
||
contentSecurityPolicy: false, // 允许内联样式和脚本
|
||
crossOriginEmbedderPolicy: false
|
||
})
|
||
)
|
||
|
||
// 🌐 CORS
|
||
if (config.web.enableCors) {
|
||
this.app.use(cors())
|
||
} else {
|
||
this.app.use(corsMiddleware)
|
||
}
|
||
|
||
// 🆕 兜底中间件:处理Chrome插件兼容性(必须在认证之前)
|
||
this.app.use(browserFallbackMiddleware)
|
||
|
||
// 📦 压缩 - 排除流式响应(SSE)
|
||
this.app.use(
|
||
compression({
|
||
filter: (req, res) => {
|
||
// 不压缩 Server-Sent Events
|
||
if (res.getHeader('Content-Type') === 'text/event-stream') {
|
||
return false
|
||
}
|
||
// 使用默认的压缩判断
|
||
return compression.filter(req, res)
|
||
}
|
||
})
|
||
)
|
||
|
||
// 🚦 全局速率限制(仅在生产环境启用)
|
||
if (process.env.NODE_ENV === 'production') {
|
||
this.app.use(globalRateLimit)
|
||
}
|
||
|
||
// 📏 请求大小限制
|
||
this.app.use(requestSizeLimit)
|
||
|
||
// 📝 请求日志(使用自定义logger而不是morgan)
|
||
this.app.use(requestLogger)
|
||
|
||
// 🐛 HTTP调试拦截器(仅在启用调试时生效)
|
||
if (process.env.DEBUG_HTTP_TRAFFIC === 'true') {
|
||
try {
|
||
const { debugInterceptor } = require('./middleware/debugInterceptor')
|
||
this.app.use(debugInterceptor)
|
||
logger.info('🐛 HTTP调试拦截器已启用 - 日志输出到 logs/http-debug-*.log')
|
||
} catch (error) {
|
||
logger.warn('⚠️ 无法加载HTTP调试拦截器:', error.message)
|
||
}
|
||
}
|
||
|
||
// 🔧 基础中间件
|
||
this.app.use(
|
||
express.json({
|
||
limit: '10mb',
|
||
verify: (req, res, buf, encoding) => {
|
||
// 验证JSON格式
|
||
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
|
||
throw new Error('Invalid JSON: empty body')
|
||
}
|
||
}
|
||
})
|
||
)
|
||
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
||
this.app.use(securityMiddleware)
|
||
|
||
// 🎯 信任代理
|
||
if (config.server.trustProxy) {
|
||
this.app.set('trust proxy', 1)
|
||
}
|
||
|
||
// 调试中间件 - 拦截所有 /admin-next 请求
|
||
this.app.use((req, res, next) => {
|
||
if (req.path.startsWith('/admin-next')) {
|
||
logger.info(
|
||
`🔍 DEBUG: Incoming request - method: ${req.method}, path: ${req.path}, originalUrl: ${req.originalUrl}`
|
||
)
|
||
}
|
||
next()
|
||
})
|
||
|
||
// 🎨 新版管理界面静态文件服务(必须在其他路由之前)
|
||
const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist')
|
||
if (fs.existsSync(adminSpaPath)) {
|
||
// 处理不带斜杠的路径,重定向到带斜杠的路径
|
||
this.app.get('/admin-next', (req, res) => {
|
||
res.redirect(301, '/admin-next/')
|
||
})
|
||
|
||
// 使用 all 方法确保捕获所有 HTTP 方法
|
||
this.app.all('/admin-next/', (req, res) => {
|
||
logger.info('🎯 HIT: /admin-next/ route handler triggered!')
|
||
logger.info(`Method: ${req.method}, Path: ${req.path}, URL: ${req.url}`)
|
||
|
||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||
return res.status(405).send('Method Not Allowed')
|
||
}
|
||
|
||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||
res.sendFile(path.join(adminSpaPath, 'index.html'))
|
||
})
|
||
|
||
// 处理所有其他 /admin-next/* 路径(但排除根路径)
|
||
this.app.get('/admin-next/*', (req, res) => {
|
||
// 如果是根路径,跳过(应该由上面的路由处理)
|
||
if (req.path === '/admin-next/') {
|
||
logger.error('❌ ERROR: /admin-next/ should not reach here!')
|
||
return res.status(500).send('Route configuration error')
|
||
}
|
||
|
||
const requestPath = req.path.replace('/admin-next/', '')
|
||
|
||
// 安全检查
|
||
if (
|
||
requestPath.includes('..') ||
|
||
requestPath.includes('//') ||
|
||
requestPath.includes('\\')
|
||
) {
|
||
return res.status(400).json({ error: 'Invalid path' })
|
||
}
|
||
|
||
// 检查是否为静态资源
|
||
const filePath = path.join(adminSpaPath, requestPath)
|
||
|
||
// 如果文件存在且是静态资源
|
||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||
// 设置缓存头
|
||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||
} else if (filePath.endsWith('.html')) {
|
||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||
}
|
||
return res.sendFile(filePath)
|
||
}
|
||
|
||
// 如果是静态资源但文件不存在
|
||
if (requestPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf)$/i)) {
|
||
return res.status(404).send('Not found')
|
||
}
|
||
|
||
// 其他所有路径返回 index.html(SPA 路由)
|
||
res.sendFile(path.join(adminSpaPath, 'index.html'))
|
||
})
|
||
|
||
logger.info('✅ Admin SPA (next) static files mounted at /admin-next/')
|
||
} else {
|
||
logger.warn('⚠️ Admin SPA dist directory not found, skipping /admin-next route')
|
||
}
|
||
|
||
// 🛣️ 路由
|
||
this.app.use('/api', apiRoutes)
|
||
this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等)
|
||
this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同
|
||
this.app.use('/admin', adminRoutes)
|
||
this.app.use('/users', userRoutes)
|
||
// 使用 web 路由(包含 auth 和页面重定向)
|
||
this.app.use('/web', webRoutes)
|
||
this.app.use('/apiStats', apiStatsRoutes)
|
||
// Gemini 路由:同时支持标准格式和原有格式
|
||
this.app.use('/gemini', standardGeminiRoutes) // 标准 Gemini API 格式路由
|
||
this.app.use('/gemini', geminiRoutes) // 保留原有路径以保持向后兼容
|
||
this.app.use('/openai/gemini', openaiGeminiRoutes)
|
||
this.app.use('/openai/claude', openaiClaudeRoutes)
|
||
this.app.use('/openai', unifiedRoutes) // 复用统一智能路由,支持 /openai/v1/chat/completions
|
||
this.app.use('/openai', openaiRoutes) // Codex API 路由(/openai/responses, /openai/v1/responses)
|
||
// Droid 路由:支持多种 Factory.ai 端点
|
||
this.app.use('/droid', droidRoutes) // Droid (Factory.ai) API 转发
|
||
this.app.use('/azure', azureOpenaiRoutes)
|
||
this.app.use('/admin/webhook', webhookRoutes)
|
||
|
||
// 🏠 根路径重定向到新版管理界面
|
||
this.app.get('/', (req, res) => {
|
||
res.redirect('/admin-next/api-stats')
|
||
})
|
||
|
||
// 🏥 增强的健康检查端点
|
||
this.app.get('/health', async (req, res) => {
|
||
try {
|
||
const timer = logger.timer('health-check')
|
||
|
||
// 检查各个组件健康状态
|
||
const [redisHealth, loggerHealth] = await Promise.all([
|
||
this.checkRedisHealth(),
|
||
this.checkLoggerHealth()
|
||
])
|
||
|
||
const memory = process.memoryUsage()
|
||
|
||
// 获取版本号:优先使用环境变量,其次VERSION文件,再次package.json,最后使用默认值
|
||
let version = process.env.APP_VERSION || process.env.VERSION
|
||
if (!version) {
|
||
try {
|
||
const versionFile = path.join(__dirname, '..', 'VERSION')
|
||
if (fs.existsSync(versionFile)) {
|
||
version = fs.readFileSync(versionFile, 'utf8').trim()
|
||
}
|
||
} catch (error) {
|
||
// 忽略错误,继续尝试其他方式
|
||
}
|
||
}
|
||
if (!version) {
|
||
try {
|
||
const { version: pkgVersion } = require('../package.json')
|
||
version = pkgVersion
|
||
} catch (error) {
|
||
version = '1.0.0'
|
||
}
|
||
}
|
||
|
||
const health = {
|
||
status: 'healthy',
|
||
service: 'claude-relay-service',
|
||
version,
|
||
timestamp: new Date().toISOString(),
|
||
uptime: process.uptime(),
|
||
memory: {
|
||
used: `${Math.round(memory.heapUsed / 1024 / 1024)}MB`,
|
||
total: `${Math.round(memory.heapTotal / 1024 / 1024)}MB`,
|
||
external: `${Math.round(memory.external / 1024 / 1024)}MB`
|
||
},
|
||
components: {
|
||
redis: redisHealth,
|
||
logger: loggerHealth
|
||
},
|
||
stats: logger.getStats()
|
||
}
|
||
|
||
timer.end('completed')
|
||
res.json(health)
|
||
} catch (error) {
|
||
logger.error('❌ Health check failed:', { error: error.message, stack: error.stack })
|
||
res.status(503).json({
|
||
status: 'unhealthy',
|
||
error: error.message,
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
}
|
||
})
|
||
|
||
// 📊 指标端点
|
||
this.app.get('/metrics', async (req, res) => {
|
||
try {
|
||
const stats = await redis.getSystemStats()
|
||
const metrics = {
|
||
...stats,
|
||
uptime: process.uptime(),
|
||
memory: process.memoryUsage(),
|
||
timestamp: new Date().toISOString()
|
||
}
|
||
|
||
res.json(metrics)
|
||
} catch (error) {
|
||
logger.error('❌ Metrics collection failed:', error)
|
||
res.status(500).json({ error: 'Failed to collect metrics' })
|
||
}
|
||
})
|
||
|
||
// 🚫 404 处理
|
||
this.app.use('*', (req, res) => {
|
||
res.status(404).json({
|
||
error: 'Not Found',
|
||
message: `Route ${req.originalUrl} not found`,
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
})
|
||
|
||
// 🚨 错误处理
|
||
this.app.use(errorHandler)
|
||
|
||
logger.success('✅ Application initialized successfully')
|
||
} catch (error) {
|
||
logger.error('💥 Application initialization failed:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🔧 初始化管理员凭据(总是从 init.json 加载,确保数据一致性)
|
||
async initializeAdmin() {
|
||
try {
|
||
const initFilePath = path.join(__dirname, '..', 'data', 'init.json')
|
||
|
||
if (!fs.existsSync(initFilePath)) {
|
||
logger.warn('⚠️ No admin credentials found. Please run npm run setup first.')
|
||
return
|
||
}
|
||
|
||
// 从 init.json 读取管理员凭据(作为唯一真实数据源)
|
||
const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8'))
|
||
|
||
// 将明文密码哈希化
|
||
const saltRounds = 10
|
||
const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds)
|
||
|
||
// 存储到Redis(每次启动都覆盖,确保与 init.json 同步)
|
||
const adminCredentials = {
|
||
username: initData.adminUsername,
|
||
passwordHash,
|
||
createdAt: initData.initializedAt || new Date().toISOString(),
|
||
lastLogin: null,
|
||
updatedAt: initData.updatedAt || null
|
||
}
|
||
|
||
await redis.setSession('admin_credentials', adminCredentials)
|
||
|
||
logger.success('✅ Admin credentials loaded from init.json (single source of truth)')
|
||
logger.info(`📋 Admin username: ${adminCredentials.username}`)
|
||
} catch (error) {
|
||
logger.error('❌ Failed to initialize admin credentials:', {
|
||
error: error.message,
|
||
stack: error.stack
|
||
})
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 🔍 Redis健康检查
|
||
async checkRedisHealth() {
|
||
try {
|
||
const start = Date.now()
|
||
await redis.getClient().ping()
|
||
const latency = Date.now() - start
|
||
|
||
return {
|
||
status: 'healthy',
|
||
connected: redis.isConnected,
|
||
latency: `${latency}ms`
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
status: 'unhealthy',
|
||
connected: false,
|
||
error: error.message
|
||
}
|
||
}
|
||
}
|
||
|
||
// 📝 Logger健康检查
|
||
async checkLoggerHealth() {
|
||
try {
|
||
const health = logger.healthCheck()
|
||
return {
|
||
status: health.healthy ? 'healthy' : 'unhealthy',
|
||
...health
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
status: 'unhealthy',
|
||
error: error.message
|
||
}
|
||
}
|
||
}
|
||
|
||
async start() {
|
||
try {
|
||
await this.initialize()
|
||
|
||
this.server = this.app.listen(config.server.port, config.server.host, () => {
|
||
logger.start(
|
||
`🚀 Claude Relay Service started on ${config.server.host}:${config.server.port}`
|
||
)
|
||
logger.info(
|
||
`🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats`
|
||
)
|
||
logger.info(
|
||
`🔗 API endpoint: http://${config.server.host}:${config.server.port}/api/v1/messages`
|
||
)
|
||
logger.info(`⚙️ Admin API: http://${config.server.host}:${config.server.port}/admin`)
|
||
logger.info(`🏥 Health check: http://${config.server.host}:${config.server.port}/health`)
|
||
logger.info(`📊 Metrics: http://${config.server.host}:${config.server.port}/metrics`)
|
||
})
|
||
|
||
const serverTimeout = 600000 // 默认10分钟
|
||
this.server.timeout = serverTimeout
|
||
this.server.keepAliveTimeout = serverTimeout + 5000 // keepAlive 稍长一点
|
||
logger.info(`⏱️ Server timeout set to ${serverTimeout}ms (${serverTimeout / 1000}s)`)
|
||
|
||
// 🔄 定期清理任务
|
||
this.startCleanupTasks()
|
||
|
||
// 🛑 优雅关闭
|
||
this.setupGracefulShutdown()
|
||
} catch (error) {
|
||
logger.error('💥 Failed to start server:', error)
|
||
process.exit(1)
|
||
}
|
||
}
|
||
|
||
// 📊 初始化缓存监控
|
||
async initializeCacheMonitoring() {
|
||
try {
|
||
logger.info('🔄 Initializing cache monitoring...')
|
||
|
||
// 注册各个服务的缓存实例
|
||
const services = [
|
||
{ name: 'claudeAccount', service: require('./services/claudeAccountService') },
|
||
{ name: 'claudeConsole', service: require('./services/claudeConsoleAccountService') },
|
||
{ name: 'bedrockAccount', service: require('./services/bedrockAccountService') }
|
||
]
|
||
|
||
// 注册已加载的服务缓存
|
||
for (const { name, service } of services) {
|
||
if (service && (service._decryptCache || service.decryptCache)) {
|
||
const cache = service._decryptCache || service.decryptCache
|
||
cacheMonitor.registerCache(`${name}_decrypt`, cache)
|
||
logger.info(`✅ Registered ${name} decrypt cache for monitoring`)
|
||
}
|
||
}
|
||
|
||
// 初始化时打印一次统计
|
||
setTimeout(() => {
|
||
const stats = cacheMonitor.getGlobalStats()
|
||
logger.info(`📊 Cache System - Registered: ${stats.cacheCount} caches`)
|
||
}, 5000)
|
||
|
||
logger.success('✅ Cache monitoring initialized')
|
||
} catch (error) {
|
||
logger.error('❌ Failed to initialize cache monitoring:', error)
|
||
// 不阻止应用启动
|
||
}
|
||
}
|
||
|
||
startCleanupTasks() {
|
||
// 🧹 每小时清理一次过期数据
|
||
setInterval(async () => {
|
||
try {
|
||
logger.info('🧹 Starting scheduled cleanup...')
|
||
|
||
const apiKeyService = require('./services/apiKeyService')
|
||
const claudeAccountService = require('./services/claudeAccountService')
|
||
|
||
const [expiredKeys, errorAccounts] = await Promise.all([
|
||
apiKeyService.cleanupExpiredKeys(),
|
||
claudeAccountService.cleanupErrorAccounts(),
|
||
claudeAccountService.cleanupTempErrorAccounts() // 新增:清理临时错误账户
|
||
])
|
||
|
||
await redis.cleanup()
|
||
|
||
logger.success(
|
||
`🧹 Cleanup completed: ${expiredKeys} expired keys, ${errorAccounts} error accounts reset`
|
||
)
|
||
} catch (error) {
|
||
logger.error('❌ Cleanup task failed:', error)
|
||
}
|
||
}, config.system.cleanupInterval)
|
||
|
||
logger.info(
|
||
`🔄 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)`
|
||
)
|
||
|
||
// 🔢 启动并发计数自动清理任务(Phase 1 修复:解决并发泄漏问题)
|
||
// 每分钟主动清理所有过期的并发项,不依赖请求触发
|
||
setInterval(async () => {
|
||
try {
|
||
const keys = await redis.keys('concurrency:*')
|
||
if (keys.length === 0) {
|
||
return
|
||
}
|
||
|
||
const now = Date.now()
|
||
let totalCleaned = 0
|
||
|
||
// 使用 Lua 脚本批量清理所有过期项
|
||
for (const key of keys) {
|
||
try {
|
||
const cleaned = await redis.client.eval(
|
||
`
|
||
local key = KEYS[1]
|
||
local now = tonumber(ARGV[1])
|
||
|
||
-- 清理过期项
|
||
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
||
|
||
-- 获取剩余计数
|
||
local count = redis.call('ZCARD', key)
|
||
|
||
-- 如果计数为0,删除键
|
||
if count <= 0 then
|
||
redis.call('DEL', key)
|
||
return 1
|
||
end
|
||
|
||
return 0
|
||
`,
|
||
1,
|
||
key,
|
||
now
|
||
)
|
||
if (cleaned === 1) {
|
||
totalCleaned++
|
||
}
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
|
||
}
|
||
}
|
||
|
||
if (totalCleaned > 0) {
|
||
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
|
||
}
|
||
} catch (error) {
|
||
logger.error('❌ Concurrency cleanup task failed:', error)
|
||
}
|
||
}, 60000) // 每分钟执行一次
|
||
|
||
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
|
||
}
|
||
|
||
setupGracefulShutdown() {
|
||
const shutdown = async (signal) => {
|
||
logger.info(`🛑 Received ${signal}, starting graceful shutdown...`)
|
||
|
||
if (this.server) {
|
||
this.server.close(async () => {
|
||
logger.info('🚪 HTTP server closed')
|
||
|
||
// 清理 pricing service 的文件监听器
|
||
try {
|
||
pricingService.cleanup()
|
||
logger.info('💰 Pricing service cleaned up')
|
||
} catch (error) {
|
||
logger.error('❌ Error cleaning up pricing service:', error)
|
||
}
|
||
|
||
// 清理 model service 的文件监听器
|
||
try {
|
||
const modelService = require('./services/modelService')
|
||
modelService.cleanup()
|
||
logger.info('📋 Model service cleaned up')
|
||
} catch (error) {
|
||
logger.error('❌ Error cleaning up model 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 {
|
||
const costRankService = require('./services/costRankService')
|
||
costRankService.shutdown()
|
||
logger.info('📊 Cost rank service stopped')
|
||
} catch (error) {
|
||
logger.error('❌ Error stopping cost rank service:', error)
|
||
}
|
||
|
||
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
|
||
try {
|
||
logger.info('🔢 Cleaning up all concurrency counters...')
|
||
const keys = await redis.keys('concurrency:*')
|
||
if (keys.length > 0) {
|
||
await redis.client.del(...keys)
|
||
logger.info(`✅ Cleaned ${keys.length} concurrency keys`)
|
||
} else {
|
||
logger.info('✅ No concurrency keys to clean')
|
||
}
|
||
} catch (error) {
|
||
logger.error('❌ Error cleaning up concurrency counters:', error)
|
||
// 不阻止退出流程
|
||
}
|
||
|
||
try {
|
||
await redis.disconnect()
|
||
logger.info('👋 Redis disconnected')
|
||
} catch (error) {
|
||
logger.error('❌ Error disconnecting Redis:', error)
|
||
}
|
||
|
||
logger.success('✅ Graceful shutdown completed')
|
||
process.exit(0)
|
||
})
|
||
|
||
// 强制关闭超时
|
||
setTimeout(() => {
|
||
logger.warn('⚠️ Forced shutdown due to timeout')
|
||
process.exit(1)
|
||
}, 10000)
|
||
} else {
|
||
process.exit(0)
|
||
}
|
||
}
|
||
|
||
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||
process.on('SIGINT', () => shutdown('SIGINT'))
|
||
|
||
// 处理未捕获异常
|
||
process.on('uncaughtException', (error) => {
|
||
logger.error('💥 Uncaught exception:', error)
|
||
shutdown('uncaughtException')
|
||
})
|
||
|
||
process.on('unhandledRejection', (reason, promise) => {
|
||
logger.error('💥 Unhandled rejection at:', promise, 'reason:', reason)
|
||
shutdown('unhandledRejection')
|
||
})
|
||
}
|
||
}
|
||
|
||
// 启动应用
|
||
if (require.main === module) {
|
||
const app = new Application()
|
||
app.start().catch((error) => {
|
||
logger.error('💥 Application startup failed:', error)
|
||
process.exit(1)
|
||
})
|
||
}
|
||
|
||
module.exports = Application
|