import 'dotenv/config' import express from 'express' import cors from 'cors' import rateLimit from 'express-rate-limit' import path from 'path' import fs from 'fs' import { authMiddleware } from './middleware/auth' import { initDatabase } from './db/init' import pool from './db/connection' import authRoutes from './routes/auth' import transactionRoutes from './routes/transaction' import statsRoutes from './routes/stats' import budgetRoutes from './routes/budget' import categoryRoutes from './routes/category' import backupRoutes from './routes/backup' import userRoutes from './routes/user' import groupRoutes from './routes/group' import notificationRoutes from './routes/notification' import filterRoutes from './routes/filter' import adminRoutes from './routes/admin' import { backupDatabase } from './utils/backup' // Warn if token secret is using default fallback if (!process.env.TOKEN_SECRET) { console.warn('[Security] TOKEN_SECRET not set — using default fallback. Set TOKEN_SECRET in .env for production!') } const app = express() const PORT = process.env.PORT || 3000 const ALLOWED_ORIGINS = (process.env.CORS_ORIGINS || '').split(',').filter(Boolean) // 反向代理环境下信任 X-Forwarded-* 头(修复 rate-limit 警告) app.set('trust proxy', 1) app.use(cors({ origin: (origin, callback) => { // No origin = WeChat mini-program request (always allow) if (!origin) return callback(null, true) // If no CORS_ORIGINS configured, allow all (dev mode) if (ALLOWED_ORIGINS.length === 0) return callback(null, true) // Check whitelist if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true) callback(null, false) }, credentials: true, })) app.use(express.json({ limit: '10kb' })) // 限流:登录接口 — 每分钟最多 10 次 const authLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, message: { code: 42900, message: '请求过于频繁,请稍后再试' }, standardHeaders: true, legacyHeaders: false, }) // 限流:通用 API — 每分钟最多 200 次 const apiLimiter = rateLimit({ windowMs: 60 * 1000, max: 200, message: { code: 42900, message: '请求过于频繁,请稍后再试' }, standardHeaders: true, legacyHeaders: false, }) // 头像 API(公开,不经过 auth 中间件) app.get('/api/user/avatar/:filename', async (req, res) => { try { const uploadDir = path.resolve(process.env.UPLOAD_DIR || './uploads') const avatarDir = path.join(uploadDir, 'avatars') const filename = path.basename(req.params.filename) // 文件名格式校验:仅允许 数字_数字.ext if (!/^\d+_\d+\.(jpg|jpeg|png|webp)$/i.test(filename)) { return res.status(400).json({ code: 40001, message: '无效文件名' }) } const filepath = path.join(avatarDir, filename) // 路径穿越检查:确保解析后仍在 avatarDir 内 if (!path.resolve(filepath).startsWith(path.resolve(avatarDir))) { return res.status(400).json({ code: 40001, message: '无效路径' }) } if (!fs.existsSync(filepath)) { return res.status(404).json({ code: 40400, message: '头像不存在' }) } const ext = path.extname(filename).toLowerCase() const mimeMap: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' } res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream') res.setHeader('Cache-Control', 'public, max-age=86400') fs.createReadStream(filepath).pipe(res) } catch { res.status(500).json({ code: 50000, message: '服务器错误' }) } }) // 通知图片 API(公开,不经过 auth 中间件) app.get('/api/notifications/image/:filename', async (req, res) => { try { const uploadDir = path.resolve(process.env.UPLOAD_DIR || './uploads') const notifDir = path.join(uploadDir, 'notifications') const filename = path.basename(req.params.filename) // 文件名格式校验:允许 notif_用户ID_数字.ext if (!/^notif_\d+_\d+\.(jpg|jpeg|png|webp)$/i.test(filename)) { return res.status(400).json({ code: 40001, message: '无效文件名' }) } const filepath = path.join(notifDir, filename) // 路径穿越检查 if (!path.resolve(filepath).startsWith(path.resolve(notifDir))) { return res.status(400).json({ code: 40001, message: '无效路径' }) } if (!fs.existsSync(filepath)) { return res.status(404).json({ code: 40400, message: '图片不存在' }) } const ext = path.extname(filename).toLowerCase() const mimeMap: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' } res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream') res.setHeader('Cache-Control', 'public, max-age=86400') fs.createReadStream(filepath).pipe(res) } catch { res.status(500).json({ code: 50000, message: '服务器错误' }) } }) app.use(authMiddleware) app.use('/api/auth', authLimiter, authRoutes) app.use('/api/transactions', apiLimiter, transactionRoutes) app.use('/api/stats', apiLimiter, statsRoutes) app.use('/api/budget', apiLimiter, budgetRoutes) app.use('/api/categories', apiLimiter, categoryRoutes) app.use('/api/backup', apiLimiter, backupRoutes) app.use('/api/user', apiLimiter, userRoutes) app.use('/api/groups', apiLimiter, groupRoutes) app.use('/api/notifications', apiLimiter, notificationRoutes) app.use('/api/filters', apiLimiter, filterRoutes) app.use('/api/admin', apiLimiter, adminRoutes) app.get('/api/health', async (_req, res) => { try { await pool.query('SELECT 1') res.json({ status: 'ok', db: 'connected', time: new Date().toISOString() }) } catch { res.status(503).json({ status: 'error', db: 'disconnected', time: new Date().toISOString() }) } }) let server: any // 生产环境:先备份再迁移,迁移出问题可恢复 async function start() { if (process.env.NODE_ENV === 'production') { try { await backupDatabase() console.log('[Backup] 迁移前备份完成') } catch (err) { console.error('[Backup] 备份失败:', err) } } await initDatabase() server = app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) } start() // Graceful shutdown function shutdown(signal: string) { console.log(`[Server] ${signal} received, shutting down gracefully...`) server?.close(() => { pool.end().then(() => { console.log('[Server] All connections closed') process.exit(0) }) }) // Force exit after 10s setTimeout(() => process.exit(1), 10000) } process.on('SIGTERM', () => shutdown('SIGTERM')) process.on('SIGINT', () => shutdown('SIGINT'))