diff --git a/README.md b/README.md new file mode 100644 index 0000000..856e2c6 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# MC Report System + +游戏玩家投诉建议申诉系统。支持 Web 端 + Paper 插件双端提交,MySQL 存储,JWT 鉴权,三级角色(服主/管理员/玩家)。 + +## 功能 + +| 模块 | 说明 | +|------|------| +| 举报 | 玩家举报违规行为,可指定目标玩家,上传截图/视频 | +| 建议 | 玩家提交改进建议 | +| 申诉 | 封禁申诉(Web端)+ 结果申诉(对处理不服) | +| 接单制 | 管理员认领工单,一人一单,可转单 | +| 审批流 | 待处理→处理中→待补充→已解决/已驳回→关闭 | +| 投票 | 分组子服定时/不定时投票,盲投制,管理员1.5票权 | +| 更新内容 | PCL风格功能投票,按时间/热度排序,分组筛选 | +| 通知 | SMTP邮件 + Discord Webhook 双通道 | +| 导出 | CSV格式导出工单/被举报人/日志(服主专享) | +| 插件 | Paper 1.20+ 插件,游戏内注册/登录/举报/追踪 | + +## 快速开始 + +```bash +# 安装依赖 +npm install + +# 启动 +node backend/server.js +# → 访问 http://localhost:3100/#/install +``` + +安装向导两步完成:MySQL 连接配置 → 创建服主账号。重启后自动加载业务路由。 + +## 技术栈 + +| 层 | 技术 | +|----|------| +| 后端 | Node.js + Express + mysql2 | +| 前端 | Vanilla JS SPA + Hash路由 | +| 数据库 | MySQL 5.7+ | +| 认证 | JWT(256位随机密钥) | +| 安全 | Helmet + Rate Limit + XSS + CSP | +| 插件 | Paper API 1.20.4 + java.net.HttpURLConnection | + +## 角色权限 + +| 操作 | 玩家 | 管理员 | 服主 | +|------|:--:|:--:|:--:| +| 提交工单 | ✅ | ✅ | ✅ | +| 查看工单 | 自己 | 待处理+自己接的 | 全部 | +| 接单 | — | 举报/申诉 | 全部 | +| 处理工单 | — | 自己接的 | 全部 | +| 用户管理 | — | ✅ | ✅ | +| 系统设置 | — | ✅ | ✅ | +| 导出数据 | — | — | ✅ | +| 投票管理 | — | — | ✅ | + +## 部署 + +```bash +# 生产环境变量 +PORT=3100 CORS_ORIGIN=https://your-domain.com node backend/server.js + +# PM2 +pm2 start backend/server.js --name mc-report + +# Nginx 反代 +# include nginx.inc; +``` + +## 目录 + +``` +├── backend/ +│ ├── server.js # Express入口 +│ ├── db.js # MySQL连接池 + 建表 +│ ├── mailer.js # SMTP邮件 +│ ├── captcha.js # SVG验证码 +│ ├── webhook.js # Discord Webhook +│ ├── middleware/ +│ │ ├── auth.js # JWT认证 +│ │ ├── security.js # 限速+XSS+APIKey +│ │ └── upload.js # 文件上传安全 +│ └── routes/ +│ ├── auth.js # 注册/登录 +│ ├── tickets.js # 工单核心 +│ ├── install.js # 安装向导 +│ ├── external.js # 外部API(插件用) +│ └── ... # 其他路由 +├── public/ # 前端SPA +├── mc-report-plugin/ # Paper 1.20+ 插件 +├── nginx.inc # Nginx额外配置 +└── data/ # 运行时数据 +``` diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 0d5c9c7..b764158 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,16 +1,15 @@ const jwt = require('jsonwebtoken'); const { getConfig } = require('../db'); +let cachedSecret = null; + function getSecret() { + if (cachedSecret) return cachedSecret; try { - const cfg = getConfig(); - const secret = cfg?.jwt_secret || process.env.JWT_SECRET; - if (secret) return secret; - throw new Error('JWT_SECRET not configured'); - } catch (e) { - if (e.message === 'JWT_SECRET not configured') throw e; - return process.env.JWT_SECRET || (() => { throw new Error('JWT_SECRET not configured'); })(); - } + cachedSecret = getConfig()?.jwt_secret || process.env.JWT_SECRET; + } catch {} + if (!cachedSecret) throw new Error('JWT_SECRET not configured'); + return cachedSecret; } function generateToken(user) { @@ -22,7 +21,8 @@ function verifyToken(token) { return jwt.verify(token, getSecret()); } function authenticate(req, res, next) { const h = req.headers.authorization; if (!h || !h.startsWith('Bearer ')) return res.status(401).json({ error: '请先登录' }); - try { req.user = verifyToken(h.split(' ')[1]); next(); } catch { return res.status(401).json({ error: '登录已过期' }); } + try { req.user = verifyToken(h.split(' ')[1]); next(); } + catch (e) { return res.status(401).json({ error: e.name === 'TokenExpiredError' ? '登录已过期,请重新登录' : '无效的认证信息' }); } } function requireRole(...roles) { diff --git a/backend/routes/features.js b/backend/routes/features.js index 192d87d..601caa3 100644 --- a/backend/routes/features.js +++ b/backend/routes/features.js @@ -21,8 +21,8 @@ router.get('/', authenticate, async (req, res) => { router.post('/', authenticate, requireRole('owner'), async (req, res) => { const { title, description, group_name, server_name } = req.body; if (!title) return res.status(400).json({ error: '标题为必填' }); - await query('INSERT INTO feature_items(title, description, group_name, server_name) VALUES (?,?,?,?)', [title, description||'', group_name||'', server_name||'']); - res.status(201).json({ id: 0 }); + const r = await query('INSERT INTO feature_items(title, description, group_name, server_name) VALUES (?,?,?,?)', [title, description||'', group_name||'', server_name||'']); + res.status(201).json({ id: r.insertId }); }); router.put('/:id', authenticate, requireRole('owner'), async (req, res) => {