- 新增隐私政策页面,满足小程序审核要求 - 新增意见反馈功能(用户提交 + 管理员查看) - 后端新增 feedbacks 表和反馈 API - 管理后台新增反馈管理入口 - 我的页面添加意见反馈、隐私政策入口
201 lines
6.7 KiB
TypeScript
201 lines
6.7 KiB
TypeScript
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 feedbackRoutes from './routes/feedback'
|
||
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<string, string> = {
|
||
'.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<string, string> = {
|
||
'.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.use('/api/feedback', apiLimiter, feedbackRoutes)
|
||
|
||
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'))
|