Files
xiaocai/server/src/index.ts
wangxiaogang 29614fdb70 feat: 隐私政策 + 意见反馈系统
- 新增隐私政策页面,满足小程序审核要求
- 新增意见反馈功能(用户提交 + 管理员查看)
- 后端新增 feedbacks 表和反馈 API
- 管理后台新增反馈管理入口
- 我的页面添加意见反馈、隐私政策入口
2026-06-08 10:16:28 +08:00

201 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'))