Files
tech-achievement-management…/server/index.js
T
2026-04-19 14:05:40 +08:00

184 lines
6.6 KiB
JavaScript

const express = require('express');
const https = require('https');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const logger = require('./utils/logger');
const auditLogger = require('./middleware/auditLogger');
const { checkDiskSpace } = require('./utils/diskMonitor');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3000;
// 信任反向代理 (Nginx)
// 必须设置此项,否则在 Nginx 后端运行时,req.ip 永远是 127.0.0.1,导致限流策略对所有用户生效(误伤)
app.set('trust proxy', 1);
// 中间件
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true
}));
app.use(express.json());
// 定义脱敏的 URL token
morgan.token('safe-url', (req) => {
let url = req.originalUrl || req.url;
try {
// 隐藏 token, password, code 等敏感参数
url = url.replace(/([?&])(token|password|code)=([^&]+)/gi, '$1$2=***');
} catch (e) {
// 忽略错误
}
return url;
});
// HTTP 请求日志 (使用自定义格式,替换 :url 为 :safe-url)
app.use(morgan(':remote-addr - :remote-user [:date[clf]] ":method :safe-url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"', { stream: logger.stream }));
// 全局审计日志
app.use(auditLogger);
// 限流策略配置
// 1. 全局限流:每个IP每分钟最多100次请求
const globalLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
max: 100, // 限制每个IP 100次请求
standardHeaders: true, // 返回 `RateLimit-*` 头信息
legacyHeaders: false, // 禁用 `X-RateLimit-*` 头信息
message: { message: '请求过于频繁,请稍后再试' }
});
// 2. 认证接口限流:每个IP每分钟最多10次请求 (防暴力破解)
const authLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
max: 10, // 限制每个IP 10次请求
standardHeaders: true,
legacyHeaders: false,
message: { message: '登录/注册尝试过于频繁,请1分钟后再试' }
});
// 应用全局限流
app.use('/api/', globalLimiter);
// 安全策略:禁止移动端访问
app.use((req, res, next) => {
const userAgent = req.headers['user-agent'] || '';
// 匹配常见的移动设备标识
const isMobile = /mobile|android|iphone|ipad|phone/i.test(userAgent);
if (isMobile) {
res.status(403).send(`
<html>
<head><title>访问被拒绝</title></head>
<body style="display:flex;justify-content:center;align-items:center;height:100vh;font-family:sans-serif;background:#f0f2f5;">
<div style="text-align:center;padding:40px;background:white;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.15);">
<h1 style="color:#ff4d4f;">🚫 访问被拒绝</h1>
<p style="font-size:18px;color:#333;">根据本单位安全规定,本系统<strong>禁止使用移动设备</strong>访问。</p>
<p style="color:#666;">请使用办公电脑访问。</p>
</div>
</body>
</html>
`);
return;
}
next();
});
// 确保上传目录存在
const uploadDir = process.env.UPLOAD_DIR || 'uploads';
let absoluteUploadDir;
if (path.isAbsolute(uploadDir)) {
absoluteUploadDir = uploadDir;
} else {
absoluteUploadDir = path.join(__dirname, uploadDir);
}
if (!fs.existsSync(absoluteUploadDir)) {
fs.mkdirSync(absoluteUploadDir, { recursive: true });
}
const DISK_MONITOR_ENABLED = String(process.env.DISK_MONITOR_ENABLED || 'true').toLowerCase() !== 'false';
const DISK_CHECK_INTERVAL_MINUTES = Number(process.env.DISK_CHECK_INTERVAL_MINUTES || 60);
const DISK_CHECK_INTERVAL_MS = Number.isFinite(DISK_CHECK_INTERVAL_MINUTES) && DISK_CHECK_INTERVAL_MINUTES > 0
? DISK_CHECK_INTERVAL_MINUTES * 60 * 1000
: 60 * 60 * 1000;
// 启动磁盘监控(默认每 60 分钟检查一次)
if (DISK_MONITOR_ENABLED) {
setInterval(() => {
checkDiskSpace();
}, DISK_CHECK_INTERVAL_MS);
// 启动时立即检查一次
checkDiskSpace();
}
// 导入路由
const authRoutes = require('./routes/auth');
const achievementRoutes = require('./routes/achievements');
const userRoutes = require('./routes/users');
const statisticsRoutes = require('./routes/statistics');
const dictionaryRoutes = require('./routes/dictionaries');
const logRoutes = require('./routes/logs');
const notificationRoutes = require('./routes/notifications');
const aiAnalysisRoutes = require('./routes/aiAnalysis');
// 对认证路由应用更严格的限流
app.use('/api/auth', authLimiter, authRoutes);
app.use('/api/achievements', achievementRoutes);
app.use('/api/users', userRoutes);
app.use('/api/statistics', statisticsRoutes);
app.use('/api/dictionaries', dictionaryRoutes);
app.use('/api/logs', logRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/ai-analysis', aiAnalysisRoutes);
// 静态文件服务 (用于开发环境查看附件,生产环境建议用 Nginx)
app.use('/uploads', express.static(absoluteUploadDir, {
setHeaders: (res, path) => {
// 强制下载,防止浏览器直接预览 HTML/SVG 等可能包含脚本的文件
res.setHeader('Content-Disposition', 'attachment');
}
}));
// 托管前端静态文件 (生产环境)
const clientBuildPath = path.join(__dirname, '../client/build');
if (fs.existsSync(clientBuildPath)) {
app.use(express.static(clientBuildPath));
// 使用正则表达式排除以 /api/ 开头的路径
app.get(/^(?!\/api\/).*$/, (req, res) => {
res.sendFile(path.join(clientBuildPath, 'index.html'));
});
}
// 尝试读取 SSL 证书 (用于 HTTPS)
const keyPath = path.join(__dirname, 'server.key');
const certPath = path.join(__dirname, 'server.cert');
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
// 如果存在证书,启动 HTTPS 服务器
const options = {
key: fs.readFileSync(keyPath),
cert: fs.readFileSync(certPath)
};
https.createServer(options, app).listen(PORT, () => {
const msg = `HTTPS 安全服务器运行在 https://localhost:${PORT}`;
console.log(msg);
logger.info(msg);
console.log(`局域网访问请使用: https://<你的IP>:${PORT}`);
});
} else {
// 否则启动标准 HTTP 服务器
app.listen(PORT, () => {
const msg = `HTTP 服务器运行在 http://localhost:${PORT}`;
console.log(msg);
logger.info(msg);
console.log(`提示: 未检测到 server.key 和 server.cert,已降级为 HTTP 模式。`);
});
}