Files
CloudSearch/packages/backend/src/main.ts

214 lines
8.6 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
// ── 启动前配置校验 ──
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;