require('dotenv').config() const express = require('express') const cors = require('cors') const helmet = require('helmet') const compression = require('compression') const morgan = require('morgan') const rateLimit = require('express-rate-limit') const connectDB = require('./config/database') const { VisitLog } = require('./models') const initRoutes = require('./routes') const seoRoutes = require('./routes/seo') const registerWxMsgRoutes = require('./routes/wxmsg') const registerVpayRoutes = require('./routes/vpay') const logger = require('./utils/logger') // 创建 Express 应用 const app = express() // 信任反向代理(nginx/宝塔),使 req.ip 取到真实客户端 IP app.set('trust proxy', 1) // 数据库连接 connectDB() // SEO/GEO 路由(robots/sitemap/llms/about) app.use('/', seoRoutes) // 中间件配置 app.use(helmet({ contentSecurityPolicy: false, // H5 与小程序客户端各自管理 CSP;API 服务无需开启 crossOriginResourcePolicy: { policy: 'cross-origin' } })) // 安全头部 app.use(cors()) // 跨域支持 app.use(compression()) // Gzip 压缩 app.use(express.json({ limit: '1mb' })) // JSON 解析(普通接口无需 10mb 大包) app.use(express.urlencoded({ extended: true, limit: '1mb' })) // URL 编码解析 // 微信消息推送为 text/xml 体,透传为字符串供 /api/wx/msg 解析 app.use((req, res, next) => { if (req.method === 'POST' && (req.headers['content-type'] || '').includes('xml')) { let data = '' req.setEncoding('utf8') req.on('data', (c) => (data += c)) req.on('end', () => { req.body = data; next() }) return } next() }) // 日志中间件 if (process.env.NODE_ENV === 'development') { app.use(morgan('dev')) } else { app.use(morgan('combined')) } // 速率限制 const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 分钟 max: 1000, // 每个 IP 最多 1000 次请求 message: { success: false, error: '请求过于频繁,请稍后再试' } }) app.use('/api/', limiter) // 访问埋点:记录页面级 GET 请求(排除管理后台/认证/健康检查/高频轮询接口),小程序与 H5 访问均可统计 // PV 口径:只记"页面首屏"接口(每打开一页只计1次),轮询/统计类接口不计——避免一次访问刷出6-8条 const TRACKABLE_PAGES = [/^\/?api\/knowledge(\?|$)/, /^\/?api\/trend(\?|$)/, /^\/?api\/knowledge\/stats/, /^\/?api\/ai-chat\/quota/, /^\/?api\/user\/leaderboard/, /^\/?api\/feedback/] app.use('/api', (req, res, next) => { try { if (req.method === 'GET' && !req.path.startsWith('/admin') && !req.path.startsWith('/auth')) { const fullPath = String(req.originalUrl || req.path).split('?')[0] const isPage = TRACKABLE_PAGES.some((re) => re.test(fullPath)) || TRACKABLE_PAGES.some((re) => re.test(fullPath.replace(/^\/api/, ''))) if (isPage) { const ua = String(req.headers['user-agent'] || '') const referer = String(req.headers.referer || '') let source = '直接访问' if (referer.includes('servicewechat.com')) source = '小程序' else if (referer.includes('/h5') || referer.includes('wdkj.yuzhiran.com.cn')) source = 'H5' const os = /windows/i.test(ua) ? 'Windows' : /iphone|ipad|ios/i.test(ua) ? 'iOS' : /android/i.test(ua) ? 'Android' : /mac os/i.test(ua) ? 'MacOS' : '' const browser = /MicroMessenger/i.test(ua) ? '微信' : /Mobile Safari/i.test(ua) ? 'Safari' : /Edge\//i.test(ua) ? 'Edge' : /Chrome/i.test(ua) ? 'Chrome' : /Firefox/i.test(ua) ? 'Firefox' : '' const device = /Mobile|iPhone/i.test(ua) || (/Android/i.test(ua) && /Mobile/i.test(ua)) ? '移动端' : '桌面端' // 解析登录用户(不强制,游客为空) let userId = null const auth = String(req.headers.authorization || '') if (auth.startsWith('Bearer ')) { try { const decoded = require('jsonwebtoken').verify(auth.slice(7), process.env.JWT_SECRET) userId = decoded.userId || null } catch { /* token 无效视为游客 */ } } VisitLog.create({ path: String(req.originalUrl || req.path).slice(0, 120), referer: String(req.headers.referer || '').slice(0, 200), source, ip: req.ip || '', userId, os, browser, device, }).catch(() => {}) } } } catch (e) { /* 埋点失败不影响业务 */ } next() }) // 健康检查 app.get('/health', (req, res) => { res.status(200).json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() }) }) // API 路由 registerWxMsgRoutes(app) registerVpayRoutes(app) initRoutes(app) // 404 处理 app.use((req, res) => { res.status(404).json({ success: false, error: '接口不存在', path: req.path }) }) // 全局错误处理(不向客户端泄露内部错误细节) app.use((err, req, res, next) => { logger.error('服务器错误:', err) // JSON 解析错误等客户端问题返回 400 if (err.type === 'entity.parse.failed' || err.type === 'entity.too.large') { return res.status(400).json({ success: false, error: '请求体格式错误' }) } res.status(err.status || 500).json({ success: false, error: process.env.NODE_ENV === 'production' ? '服务器内部错误' : err.message }) }) // 启动服务器(仅在直接运行时启动,被测试 import 时不启动) const PORT = process.env.PORT || 3000 let server = null if (require.main === module) { server = app.listen(PORT, () => { logger.info(`🚀 服务器启动成功`) logger.info(`📍 端口: ${PORT}`) logger.info(`🌐 环境: ${process.env.NODE_ENV || 'development'}`) logger.info(`📊 健康检查: http://localhost:${PORT}/health`) }) } // 优雅关闭 process.on('SIGTERM', () => { logger.info('📴 接收到 SIGTERM 信号,正在关闭服务器...') if (server) { server.close(() => { logger.info('✅ 服务器已关闭') process.exit(0) }) } else { process.exit(0) } }) module.exports = app