| 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') |
|
|
| |
| 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') |
|
|
| |
| 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 { |
| |
| 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` |
| ) |
| } |
|
|
| |
| logger.info('🕐 Initializing Claude account session windows...') |
| const claudeAccountService = require('./services/claudeAccountService') |
| await claudeAccountService.initializeSessionWindows() |
|
|
| |
| 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 |
| }) |
| ) |
|
|
| |
| if (config.web.enableCors) { |
| this.app.use(cors()) |
| } else { |
| this.app.use(corsMiddleware) |
| } |
|
|
| |
| this.app.use(browserFallbackMiddleware) |
|
|
| |
| this.app.use( |
| compression({ |
| filter: (req, res) => { |
| |
| 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) |
|
|
| |
| this.app.use(requestLogger) |
|
|
| |
| 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) => { |
| |
| 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) |
| } |
|
|
| |
| 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/') |
| }) |
|
|
| |
| 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')) |
| }) |
|
|
| |
| 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') |
| } |
|
|
| |
| 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) |
| this.app.use('/claude', apiRoutes) |
| this.app.use('/admin', adminRoutes) |
| this.app.use('/users', userRoutes) |
| |
| this.app.use('/web', webRoutes) |
| this.app.use('/apiStats', apiStatsRoutes) |
| |
| this.app.use('/gemini', standardGeminiRoutes) |
| this.app.use('/gemini', geminiRoutes) |
| this.app.use('/openai/gemini', openaiGeminiRoutes) |
| this.app.use('/openai/claude', openaiClaudeRoutes) |
| this.app.use('/openai', unifiedRoutes) |
| this.app.use('/openai', openaiRoutes) |
| |
| this.app.use('/droid', droidRoutes) |
| 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() |
|
|
| |
| 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' }) |
| } |
| }) |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) |
|
|
| |
| const saltRounds = 10 |
| const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds) |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| 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 |
| } |
| } |
| } |
|
|
| |
| 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 |
| this.server.timeout = serverTimeout |
| this.server.keepAliveTimeout = serverTimeout + 5000 |
| 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` |
| ) |
|
|
| |
| |
| const rateLimitCleanupService = require('./services/rateLimitCleanupService') |
| const cleanupIntervalMinutes = config.system.rateLimitCleanupInterval || 5 |
| rateLimitCleanupService.start(cleanupIntervalMinutes) |
| logger.info( |
| `🚨 Rate limit cleanup service started (checking every ${cleanupIntervalMinutes} minutes)` |
| ) |
|
|
| |
| |
| setInterval(async () => { |
| try { |
| const keys = await redis.keys('concurrency:*') |
| if (keys.length === 0) { |
| return |
| } |
|
|
| const now = Date.now() |
| let totalCleaned = 0 |
|
|
| |
| 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') |
|
|
| |
| try { |
| pricingService.cleanup() |
| logger.info('💰 Pricing service cleaned up') |
| } catch (error) { |
| logger.error('❌ Error cleaning up pricing service:', error) |
| } |
|
|
| |
| 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 { |
| 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 |
|
|