176 lines
6.0 KiB
JavaScript
Executable File
176 lines
6.0 KiB
JavaScript
Executable File
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
|