import express from 'express'; import path from 'path'; import cors from 'cors'; import helmet from 'helmet'; import morgan from 'morgan'; import config from './config'; import { APP_VERSION } from "./version"; import { getDb } from './database/database'; import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache'; import rateLimiter from './middleware/rate-limit'; import routes from './routes'; import { pansouWebProxy } from './proxy/pansou-web'; import { checkAndRunScheduledCleanup } from './cloud/cleanup.service'; import { refreshAllStorageInfo } from './cloud/cloud.service'; import { checkStartup } from './config/startup-validator'; const app = express(); // ============ Middleware ============ app.set('trust proxy', true); // CORS — 生产环境必须配置真实域名(空值或占位符用 * 并打警告日志) const corsOrigin = process.env.CORS_ORIGIN || ''; const isPlaceholder = !corsOrigin || corsOrigin === 'https://your-domain.com'; if (config.nodeEnv === 'production' && isPlaceholder) { console.warn('[WARN] CORS_ORIGIN 未配置或使用了占位符,生产环境建议设置真实域名,当前临时允许所有来源'); } if (config.nodeEnv === 'production' && !isPlaceholder) { app.use(cors({ origin: corsOrigin, credentials: true })); } else { app.use(cors({ origin: '*', credentials: false })); } app.use(helmet({ contentSecurityPolicy: false })); // morgan 日志格式:不记录 IP,避免隐私合规问题 app.use(morgan(':method :url :status :res[content-length] - :response-time ms')); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); app.use(rateLimiter); // ============ 前端静态文件 ============ const frontendDist = path.join(__dirname, 'frontend'); app.use(express.static(frontendDist, { maxAge: '1d', setHeaders: (res, p) => { if (p.endsWith('index.html')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); } } })); // ============ Routes ============ app.use('/api/uploads', express.static('/app/uploads')); app.use('/api', routes); // ============ Health Check(增强版:覆盖 Redis / PanSou / VideoParser 状态) ============ app.get('/health', async (_req, res) => { const dbOk = (() => { try { getDb(); return true; } catch { return false; } })(); const redisStatus = await (async () => { try { const { getRedis } = require('./middleware/cache'); const redis = getRedis(); if (!redis) return 'disconnected'; // ioredis and mock redis both support ping() if (typeof redis.ping !== 'function') return 'unknown'; const pong = await redis.ping().catch(() => null); return pong === 'PONG' ? 'connected' : 'error'; // eslint-disable-next-line no-unused-vars } catch { return 'unknown'; } })(); const pansouStatus = await (async () => { try { // Native fetch available in Node 20+ const url = (config.pansouUrl || 'http://pansou:80').replace(/\/+$/, '') + '/api/search'; const controller = new AbortController(); const t = setTimeout(() => controller.abort(), 3000); const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kw: 'health', page: 1 }), signal: controller.signal }); clearTimeout(t); return r.ok ? 'ok' : 'degraded'; // eslint-disable-next-line no-unused-vars } catch { return 'unreachable'; } })(); const videoParserStatus = await (async () => { try { // Native fetch available in Node 20+ const url = (config.videoParserUrl || 'http://video-parser:3001').replace(/\/+$/, ''); const controller = new AbortController(); const t = setTimeout(() => controller.abort(), 3000); const r = await fetch(url, { signal: controller.signal }); clearTimeout(t); return r.ok ? 'ok' : 'degraded'; // eslint-disable-next-line no-unused-vars } catch { return 'unreachable'; } })(); const overall = dbOk && pansouStatus !== 'unreachable' ? 'ok' : dbOk ? 'degraded' : 'unhealthy'; res.json({ version: APP_VERSION, status: overall, timestamp: new Date().toISOString(), uptime: Math.floor(process.uptime()), memory: process.memoryUsage().rss, components: { db: dbOk ? 'connected' : 'error', redis: redisStatus, pansou: pansouStatus, videoParser: videoParserStatus, }, }); }); // ============ PanSou Web UI Proxy ============ app.use('/pansou', pansouWebProxy); // SPA fallback app.use((req, res, next) => { if (req.path.startsWith('/api/') || req.path === '/health') return next(); res.sendFile(path.join(frontendDist, 'index.html'), (err) => { if (err) next(); }); }); // Global error handler app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error('[Error]', err); res.status(err.status || 500).json({ error: config.nodeEnv === 'production' ? 'Internal server error' : err.message, code: err.status || 500, }); }); // ============ Server Start ============ async function start(): Promise { // ── 启动前配置校验 ── if (!checkStartup()) { console.error('[Server] 配置校验失败,退出'); process.exit(1); } try { getDb(); console.log('[DB] SQLite database initialized'); try { const { getSystemConfig } = require('./admin/system-config.service'); const tz = getSystemConfig('timezone'); if (tz) { process.env.TZ = tz; console.log(`[Config] Timezone set to: ${tz}`); } } catch { console.warn('[Config] Could not set timezone, using default'); } } catch (err) { console.error('[DB] Failed to initialize database:', err); process.exit(1); } try { const { getSystemConfig } = require('./admin/system-config.service'); const redisUrl = (process.env.REDIS_URL || getSystemConfig('redis_url') || '').trim(); if (redisUrl) { const ok = await reconnectRedis(redisUrl); if (ok) console.log('[Redis] Connected to', redisUrl); else console.warn('[Redis] Connection failed, continuing without cache'); } else { console.log('[Redis] No REDIS_URL configured, running without cache'); } } catch { console.warn('[Redis] Redis not available, continuing without cache'); } // Cleanup scheduler const CLEANUP_INTERVAL = 10 * 60 * 1000; setInterval(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Scheduler error:', err.message)); }, CLEANUP_INTERVAL); setTimeout(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Initial check error:', err.message)); }, 30000); // Storage info refresh scheduler — configurable via system_configs const { getSystemConfig: getSysCfg } = require('./admin/system-config.service'); const storageRefreshEnabled = getSysCfg('cleanup_auto_refresh_storage') !== 'false'; const storageRefreshMin = parseInt(getSysCfg('storage_refresh_interval') || '60', 10) || 60; const storageRefreshMs = storageRefreshMin * 60 * 1000; const verifyCookies = getSysCfg('cleanup_verify_enabled') === 'true'; if (!storageRefreshEnabled || storageRefreshMin === 0) { console.log(`[Storage] Auto-refresh disabled (enabled=${storageRefreshEnabled}, interval=${storageRefreshMin} min)`); } else { console.log(`[Storage] Auto-refresh every ${storageRefreshMin} minutes (verifyCookies=${verifyCookies})`); setInterval(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Refresh error:', err.message)); }, storageRefreshMs); setTimeout(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Initial refresh error:', err.message)); }, 60000); } const server = app.listen(config.port, () => { console.log(`[Server] CloudSearch Backend running on port ${config.port} (${config.nodeEnv})`); }); const shutdown = async (signal: string) => { console.log(`\n[Server] Received ${signal}, shutting down gracefully...`); server.close(async () => { await disconnectRedis(); console.log('[Server] Closed'); process.exit(0); }); setTimeout(() => { console.error('[Server] Force shutdown'); process.exit(1); }, 10000); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); process.on('uncaughtException', (err) => { console.error('[FATAL] Uncaught Exception:', err); setTimeout(() => process.exit(1), 1000); }); process.on('unhandledRejection', (reason) => { const msg = reason instanceof Error ? reason.message : String(reason); console.error('[FATAL] Unhandled Rejection:', msg); }); } start().catch((err) => { console.error('[Server] Failed to start:', err); process.exit(1); }); export default app;