feat: MC Report System - MySQL + Express + Vanilla JS SPA

This commit is contained in:
2026-07-12 01:23:19 +08:00
commit 247a4e851d
52 changed files with 5710 additions and 0 deletions

203
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,203 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { v4: uuid } = require('uuid');
const { query, getRow } = require('../db');
const { generateToken, authenticate } = require('../middleware/auth');
const { sendEmail } = require('../mailer');
const { verify: verifyCaptcha } = require('./captcha');
const { validateLengths } = require('../middleware/security');
const router = express.Router();
async function getSiteUrl() {
const row = await getRow("SELECT v FROM settings WHERE k = 'site_url'");
return row?.v || 'http://localhost:3100';
}
router.post('/register', validateLengths({
username: 20, password: 100, email: 100, game_name: 50, game_uid: 50,
}), async (req, res) => {
try {
const { username, password, email, game_name, game_uid, captcha_id, captcha_answer } = req.body;
if (!username || !password || !email || !game_name || !game_uid) {
return res.status(400).json({ error: '所有字段均为必填' });
}
if (username.length < 2 || username.length > 20) {
return res.status(400).json({ error: '用户名需2-20个字符' });
}
if (password.length < 6) {
return res.status(400).json({ error: '密码至少6位' });
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ error: '邮箱格式不正确' });
}
if (captcha_id && captcha_answer) {
const captchaOk = await verifyCaptcha(captcha_id, captcha_answer);
if (!captchaOk) {
return res.status(400).json({ error: '人机验证失败' });
}
}
const existingUser = await getRow('SELECT id FROM users WHERE username = ?', [username]);
if (existingUser) {
return res.status(400).json({ error: '用户名已存在' });
}
const existingEmail = await getRow('SELECT id FROM users WHERE email = ?', [email]);
if (existingEmail) {
return res.status(400).json({ error: '邮箱已被注册' });
}
const hashed = await bcrypt.hash(password, 10);
const verifyToken = uuid();
const verifyExpires = new Date(Date.now() + 24 * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19);
await query(
'INSERT INTO users(username, password, email, game_name, game_uid, active, email_verified, verify_token, verify_expires) VALUES (?,?,?,?,?,?,?,?,?)',
[username, hashed, email, game_name, game_uid, 0, 0, verifyToken, verifyExpires]
);
const siteUrl = await getSiteUrl();
const sent = await sendEmail(email, 'verify_email', {
username, game_name, game_uid,
verify_link: `${siteUrl}#/verify?token=${verifyToken}`,
});
if (!sent) {
await query('UPDATE users SET email_verified = 1, active = 1, verify_token = NULL, verify_expires = NULL WHERE username = ?', [username]);
return res.status(201).json({ message: '注册成功SMTP未配置账号已自动激活请直接登录' });
}
res.status(201).json({ message: '注册成功,请查收验证邮件' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/verify-email', async (req, res) => {
try {
const { token } = req.query;
if (!token) return res.status(400).json({ error: '无效的验证链接' });
const user = await getRow('SELECT * FROM users WHERE verify_token = ? AND verify_expires > NOW()', [token]);
if (!user) return res.status(400).json({ error: '验证链接无效或已过期' });
await query('UPDATE users SET email_verified = 1, active = 1, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
await query(
'INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)',
[user.id, user.username, 'verify_email', 'user', user.id, '邮箱验证通过']
);
res.json({ message: '邮箱验证成功,现在可以登录了' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: '请输入用户名和密码' });
const user = await getRow('SELECT * FROM users WHERE username = ?', [username]);
if (!user) return res.status(401).json({ error: '用户名或密码错误' });
const valid = await bcrypt.compare(password, user.password);
if (!valid) return res.status(401).json({ error: '用户名或密码错误' });
if (!user.active) return res.status(403).json({ error: '账号未激活,请先验证邮箱' });
const token = generateToken(user);
await query(
'INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)',
[user.id, user.username, 'login', 'user', user.id, '登录系统']
);
res.json({
token,
user: {
id: user.id,
username: user.username,
email: user.email,
game_name: user.game_name,
game_uid: user.game_uid,
role: user.role,
},
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/me', authenticate, async (req, res) => {
try {
const user = await getRow(
'SELECT id, username, email, game_name, game_uid, role, email_verified, created_at FROM users WHERE id = ?',
[req.user.id]
);
if (!user) return res.status(404).json({ error: '用户不存在' });
res.json(user);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.put('/profile', authenticate, async (req, res) => {
try {
const fields = {};
const values = [];
if (req.body.game_name) {
fields.game_name = req.body.game_name;
}
if (req.body.game_uid) {
fields.game_uid = req.body.game_uid;
}
if (req.body.email) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(req.body.email)) {
return res.status(400).json({ error: '邮箱格式不正确' });
}
fields.email = req.body.email;
}
if (Object.keys(fields).length === 0) {
return res.status(400).json({ error: '无更新内容' });
}
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
await query(`UPDATE users SET ${sets} WHERE id = ?`, [...Object.values(fields), req.user.id]);
const updated = await getRow(
'SELECT id, username, email, game_name, game_uid, role FROM users WHERE id = ?',
[req.user.id]
);
const token = generateToken(updated);
res.json({ message: '更新成功', token, user: updated });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.put('/password', authenticate, async (req, res) => {
try {
const { oldPassword, newPassword } = req.body;
if (!oldPassword || !newPassword) {
return res.status(400).json({ error: '请提供新旧密码' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: '新密码至少6位' });
}
const user = await getRow('SELECT * FROM users WHERE id = ?', [req.user.id]);
const valid = await bcrypt.compare(oldPassword, user.password);
if (!valid) return res.status(400).json({ error: '旧密码错误' });
const hashed = await bcrypt.hash(newPassword, 10);
await query('UPDATE users SET password = ? WHERE id = ?', [hashed, req.user.id]);
res.json({ message: '密码修改成功' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

31
backend/routes/captcha.js Normal file
View File

@@ -0,0 +1,31 @@
const express = require('express');
const crypto = require('crypto');
const { query } = require('../db');
const router = express.Router();
router.get('/', async (req, res) => {
try {
await query("DELETE FROM captchas WHERE created_at < NOW() - INTERVAL 5 MINUTE");
const id = crypto.randomUUID();
const ops = ['+', '-', '\u00d7'];
const op = ops[Math.floor(Math.random() * ops.length)];
let a, b;
if (op === '\u00d7') { a = Math.floor(Math.random() * 9) + 1; b = Math.floor(Math.random() * 9) + 1; }
else { a = Math.floor(Math.random() * 20) + 1; b = Math.floor(Math.random() * 10) + 1; if (op === '-' && a < b) [a, b] = [b, a]; }
const answer = op === '+' ? a + b : op === '-' ? a - b : a * b;
const question = `${a} ${op} ${b} = ?`;
await query('INSERT INTO captchas(id, question, answer) VALUES (?,?,?)', [id, question, String(answer)]);
res.json({ id, question });
} catch (e) { res.status(500).json({ error: e.message }); }
});
async function verify(id, answer) {
const rows = await query("SELECT * FROM captchas WHERE id = ? AND created_at > NOW() - INTERVAL 5 MINUTE", [id]);
if (!rows.length) return false;
await query('DELETE FROM captchas WHERE id = ?', [id]);
return String(answer).trim() === rows[0].answer;
}
module.exports = router;
module.exports.verify = verify;

View File

@@ -0,0 +1,208 @@
const express = require('express');
const { getDb } = require('../db');
const { authenticate, requireRole, optionalAuth } = require('../middleware/auth');
const router = express.Router();
router.get('/', optionalAuth, (req, res) => {
const db = getDb();
let query = `SELECT c.*,
p.display_name as planner_name, i.display_name as implementer_name, r.display_name as reviewer_name
FROM complaints c
LEFT JOIN users p ON c.assigned_planner_id = p.id
LEFT JOIN users i ON c.assigned_implementer_id = i.id
LEFT JOIN users r ON c.assigned_reviewer_id = r.id`;
let conditions = [];
let params = [];
const { status, priority, category, search } = req.query;
if (!req.user) {
conditions.push("c.status != 'pending'");
}
if (status) {
conditions.push('c.status = ?');
params.push(status);
}
if (priority) {
conditions.push('c.priority = ?');
params.push(priority);
}
if (category) {
conditions.push('c.category = ?');
params.push(category);
}
if (search) {
conditions.push("(c.title LIKE ? OR c.description LIKE ? OR c.player_name LIKE ?)");
const s = `%${search}%`;
params.push(s, s, s);
}
if (req.user && req.user.role === 'planner') {
conditions.push('(c.assigned_planner_id = ? OR c.assigned_planner_id IS NULL)');
params.push(req.user.id);
}
if (req.user && req.user.role === 'implementer') {
conditions.push('c.assigned_implementer_id = ?');
params.push(req.user.id);
}
if (req.user && req.user.role === 'reviewer') {
conditions.push('c.assigned_reviewer_id = ?');
params.push(req.user.id);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY c.updated_at DESC';
const complaints = db.prepare(query).all(...params);
res.json(complaints);
});
router.get('/stats', authenticate, (req, res) => {
const db = getDb();
const total = db.prepare('SELECT COUNT(*) as count FROM complaints').get();
const byStatus = db.prepare('SELECT status, COUNT(*) as count FROM complaints GROUP BY status').all();
const byPriority = db.prepare('SELECT priority, COUNT(*) as count FROM complaints GROUP BY priority').all();
const today = db.prepare("SELECT COUNT(*) as count FROM complaints WHERE date(created_at) = date('now','localtime')").get();
let myStats = {};
if (['planner', 'implementer', 'reviewer'].includes(req.user.role)) {
const roleMap = { planner: 'assigned_planner_id', implementer: 'assigned_implementer_id', reviewer: 'assigned_reviewer_id' };
const col = roleMap[req.user.role];
myStats.total = db.prepare(`SELECT COUNT(*) as count FROM complaints WHERE ${col} = ?`).get(req.user.id).count;
myStats.pending = db.prepare(`SELECT COUNT(*) as count FROM complaints WHERE ${col} = ? AND status NOT IN ('resolved','closed','rejected')`).get(req.user.id).count;
myStats.completed = db.prepare(`SELECT COUNT(*) as count FROM complaints WHERE ${col} = ? AND status IN ('resolved','closed')`).get(req.user.id).count;
}
res.json({ total: total.count, byStatus, byPriority, today: today.count, myStats });
});
router.get('/:id', optionalAuth, (req, res) => {
const db = getDb();
const complaint = db.prepare(`SELECT c.*,
p.display_name as planner_name, i.display_name as implementer_name, r.display_name as reviewer_name
FROM complaints c
LEFT JOIN users p ON c.assigned_planner_id = p.id
LEFT JOIN users i ON c.assigned_implementer_id = i.id
LEFT JOIN users r ON c.assigned_reviewer_id = r.id
WHERE c.id = ?`).get(req.params.id);
if (!complaint) return res.status(404).json({ error: '投诉不存在' });
const responses = db.prepare(`SELECT r.*, u.display_name as user_name, u.role as user_role
FROM responses r LEFT JOIN users u ON r.user_id = u.id
WHERE r.complaint_id = ? ORDER BY r.created_at ASC`).all(req.params.id);
complaint.responses = responses;
if (complaint.plan_id) {
const plan = db.prepare(`SELECT p.*, u.display_name as planner_name FROM plans p
LEFT JOIN users u ON p.planner_id = u.id WHERE p.id = ?`).get(complaint.plan_id);
complaint.plan = plan;
}
if (complaint.implementation_id) {
const impl = db.prepare(`SELECT i.*, u.display_name as implementer_name FROM implementations i
LEFT JOIN users u ON i.implementer_id = u.id WHERE i.id = ?`).get(complaint.implementation_id);
complaint.implementation = impl;
}
if (complaint.review_id) {
const review = db.prepare(`SELECT r.*, u.display_name as reviewer_name FROM reviews r
LEFT JOIN users u ON r.reviewer_id = u.id WHERE r.id = ?`).get(complaint.review_id);
complaint.review = review;
}
res.json(complaint);
});
router.post('/', optionalAuth, (req, res) => {
const { title, description, category, priority, player_name, player_email, player_contact } = req.body;
if (!title || !description || !player_name) {
return res.status(400).json({ error: '标题、描述和玩家名称为必填项' });
}
const db = getDb();
const result = db.prepare(`INSERT INTO complaints(title, description, category, priority, player_name, player_email, player_contact, status)
VALUES (?,?,?,?,?,?,?,'pending')`).run(
title, description, category || 'other', priority || 'medium', player_name, player_email || '', player_contact || ''
);
const complaintId = result.lastInsertRowid;
db.prepare(`INSERT INTO responses(complaint_id, user_id, response_type, content) VALUES (?,?,?,?)`).run(
complaintId, req.user?.id || null, 'system', '投诉已提交,等待处理'
);
db.prepare(`INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)`).run(
req.user?.id || null, req.user?.username || player_name, 'create', 'complaint', complaintId,
`投诉已创建: ${title}`
);
res.status(201).json({ id: complaintId, message: '投诉提交成功' });
});
router.put('/:id', authenticate, (req, res) => {
const db = getDb();
const complaint = db.prepare('SELECT * FROM complaints WHERE id = ?').get(req.params.id);
if (!complaint) return res.status(404).json({ error: '投诉不存在' });
const allowedFields = {};
if (req.body.title) allowedFields.title = req.body.title;
if (req.body.description) allowedFields.description = req.body.description;
if (req.body.category) allowedFields.category = req.body.category;
if (req.body.priority) allowedFields.priority = req.body.priority;
if (req.body.status) allowedFields.status = req.body.status;
if (Object.keys(allowedFields).length === 0) {
return res.status(400).json({ error: '没有可更新的字段' });
}
const assignments = {};
if (req.body.assigned_planner_id !== undefined) assignments.assigned_planner_id = req.body.assigned_planner_id;
if (req.body.assigned_implementer_id !== undefined) assignments.assigned_implementer_id = req.body.assigned_implementer_id;
if (req.body.assigned_reviewer_id !== undefined) assignments.assigned_reviewer_id = req.body.assigned_reviewer_id;
const allFields = { ...allowedFields, ...assignments };
const setClauses = Object.keys(allFields).map(k => `${k} = ?`).join(', ');
const values = Object.values(allFields);
db.prepare(`UPDATE complaints SET ${setClauses}, updated_at = datetime('now','localtime') WHERE id = ?`).run(...values, req.params.id);
if (req.body.status && req.body.status !== complaint.status) {
db.prepare(`INSERT INTO responses(complaint_id, user_id, response_type, content) VALUES (?,?,?,?)`).run(
req.params.id, req.user.id, 'system', `状态变更为: ${getStatusLabel(req.body.status)}`
);
db.prepare(`INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)`).run(
req.user.id, req.user.username, 'update_status', 'complaint', req.params.id,
`状态从 ${complaint.status} 变更为 ${req.body.status}`
);
}
db.prepare(`INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)`).run(
req.user.id, req.user.username, 'update', 'complaint', req.params.id, '更新投诉信息'
);
res.json({ message: '更新成功' });
});
router.post('/:id/response', authenticate, (req, res) => {
const { content, response_type } = req.body;
if (!content) return res.status(400).json({ error: '内容不能为空' });
const db = getDb();
const complaint = db.prepare('SELECT * FROM complaints WHERE id = ?').get(req.params.id);
if (!complaint) return res.status(404).json({ error: '投诉不存在' });
db.prepare(`INSERT INTO responses(complaint_id, user_id, response_type, content) VALUES (?,?,?,?)`).run(
req.params.id, req.user.id, response_type || 'comment', content
);
db.prepare(`UPDATE complaints SET updated_at = datetime('now','localtime') WHERE id = ?`).run(req.params.id);
res.status(201).json({ message: '回复添加成功' });
});
function getStatusLabel(status) {
const labels = {
pending: '待处理', under_review: '审查中', planned: '已计划',
in_progress: '实施中', in_review: '审核中', resolved: '已解决',
rejected: '已驳回', closed: '已关闭'
};
return labels[status] || status;
}
module.exports = router;

77
backend/routes/export.js Normal file
View File

@@ -0,0 +1,77 @@
const express = require('express');
const { query } = require('../db');
const { authenticate, requireRole } = require('../middleware/auth');
const router = express.Router();
const SL = { pending:'待处理', processing:'处理中', awaiting_info:'待补充', resolved:'已解决', rejected:'已驳回', closed:'已关闭' };
const TL = { report:'举报', suggestion:'建议', appeal:'申诉' };
function toCSV(headers, rows, filename) {
let csv = '\uFEFF' + headers.join(',') + '\r\n';
for (const r of rows) {
csv += headers.map(h => { const v = String(r[h] ?? ''); return /[,"\n\r]/.test(v) ? '"' + v.replace(/"/g,'""') + '"' : v; }).join(',') + '\r\n';
}
return { csv, filename };
}
router.get('/tickets', authenticate, requireRole('owner'), async (req, res) => {
const { type, status, start_date, end_date } = req.query;
let q = `SELECT id as '编号', type as '类型', title as '标题', reporter_game_name as '提交者游戏名', reporter_game_uid as '提交者UID', target_game_name as '对象游戏名', target_game_uid as '对象UID', reason as '原因', description as '描述', status as '状态', priority as '优先级', created_at as '创建时间', updated_at as '更新时间' FROM tickets WHERE 1=1`;
const p = [];
if (type) { q += ' AND type = ?'; p.push(type); }
if (status) { q += ' AND status = ?'; p.push(status); }
if (start_date) { q += ' AND DATE(created_at) >= ?'; p.push(start_date); }
if (end_date) { q += ' AND DATE(created_at) <= ?'; p.push(end_date); }
q += ' ORDER BY created_at DESC';
const rows = await query(q, p);
if (req.query.format === 'csv') {
for (const r of rows) { r['类型'] = TL[r['类型']]||r['类型']; r['状态'] = SL[r['状态']]||r['状态']; }
const { csv, filename } = toCSV(['编号','类型','标题','提交者游戏名','提交者UID','对象游戏名','对象UID','原因','描述','状态','优先级','创建时间','更新时间'], rows, `tickets_${new Date().toISOString().slice(0,10)}.csv`);
res.setHeader('Content-Type','text/csv; charset=utf-8');
res.setHeader('Content-Disposition','attachment; filename='+filename);
return res.send(csv);
}
res.json(rows);
});
router.get('/reported-players', authenticate, requireRole('owner'), async (req, res) => {
const rows = await query(`SELECT target_game_name as '被举报人游戏名', target_game_uid as '被举报人UID', COUNT(*) as '被举报次数', SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as '已处理', SUM(CASE WHEN status IN ('pending','processing','awaiting_info') THEN 1 ELSE 0 END) as '处理中', SUM(CASE WHEN status='rejected' THEN 1 ELSE 0 END) as '已驳回', MAX(created_at) as '最近举报时间', GROUP_CONCAT(reason SEPARATOR ' | ') as '常见原因' FROM tickets WHERE type='report' AND target_game_name IS NOT NULL AND target_game_name != '' GROUP BY target_game_name, target_game_uid ORDER BY COUNT(*) DESC`);
if (req.query.format === 'csv') {
const { csv, filename } = toCSV(['被举报人游戏名','被举报人UID','被举报次数','已处理','处理中','已驳回','最近举报时间','常见原因'], rows, `reported_players_${new Date().toISOString().slice(0,10)}.csv`);
res.setHeader('Content-Type','text/csv; charset=utf-8');
res.setHeader('Content-Disposition','attachment; filename='+filename);
return res.send(csv);
}
res.json(rows);
});
router.get('/audit-logs', authenticate, requireRole('owner'), async (req, res) => {
const { start_date, end_date } = req.query;
let q = `SELECT id as '编号', username as '用户', action as '操作', entity_type as '对象', entity_id as '对象ID', details as '详情', created_at as '时间' FROM audit_logs WHERE 1=1`;
const p = [];
if (start_date) { q += ' AND DATE(created_at) >= ?'; p.push(start_date); }
if (end_date) { q += ' AND DATE(created_at) <= ?'; p.push(end_date); }
q += ' ORDER BY created_at DESC LIMIT 5000';
const rows = await query(q, p);
if (req.query.format === 'csv') {
const { csv, filename } = toCSV(['编号','用户','操作','对象','对象ID','详情','时间'], rows, `audit_${new Date().toISOString().slice(0,10)}.csv`);
res.setHeader('Content-Type','text/csv; charset=utf-8');
res.setHeader('Content-Disposition','attachment; filename='+filename);
return res.send(csv);
}
res.json(rows);
});
router.get('/dashboard', authenticate, requireRole('owner'), async (req, res) => {
const [[{c:total}], byType, byStatus, [{c:today}], [{c:usersTotal}], monthly] = await Promise.all([
query('SELECT COUNT(*) as c FROM tickets'),
query('SELECT type, COUNT(*) as c FROM tickets GROUP BY type'),
query('SELECT status, COUNT(*) as c FROM tickets GROUP BY status'),
query("SELECT COUNT(*) as c FROM tickets WHERE DATE(created_at) = CURDATE()"),
query('SELECT COUNT(*) as c FROM users'),
query("SELECT DATE_FORMAT(created_at,'%Y-%m') as m, COUNT(*) as c FROM tickets GROUP BY m ORDER BY m DESC LIMIT 12"),
]);
res.json({ total, byType, byStatus, today, usersTotal, monthly });
});
module.exports = router;

95
backend/routes/install.js Normal file
View File

@@ -0,0 +1,95 @@
const express = require('express');
const mysql = require('mysql2/promise');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const { isInstalled, saveConfig, getConfig, initSchema } = require('../db');
const router = express.Router();
router.get('/status', (req, res) => {
res.json({ installed: isInstalled() });
});
router.post('/check-db', async (req, res) => {
const { host, port, user, password, database } = req.body;
try {
const conn = await mysql.createConnection({
host: host || 'localhost',
port: parseInt(port) || 3306,
user,
password,
charset: 'utf8mb4',
multipleStatements: true,
});
const [rows] = await conn.query(`SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?`, [database]);
if (rows.length === 0) {
await conn.query(`CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
}
await conn.end();
res.json({ ok: true });
} catch (e) {
res.status(400).json({ error: '数据库连接失败: ' + e.message });
}
});
router.post('/complete', async (req, res) => {
if (isInstalled()) return res.status(400).json({ error: '系统已安装' });
const { db_host, db_port, db_user, db_pass, db_name,
site_name, site_url, admin_username, admin_password, admin_email,
admin_game_name, admin_game_uid } = req.body;
if (!db_host || !db_user || !db_name || !admin_username || !admin_password || !admin_email) {
return res.status(400).json({ error: '必填字段不完整' });
}
try {
const conn = await mysql.createConnection({
host: db_host,
port: parseInt(db_port) || 3306,
user: db_user,
password: db_pass || '',
charset: 'utf8mb4',
multipleStatements: true,
});
const [dbs] = await conn.query(`SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?`, [db_name]);
if (dbs.length === 0) {
await conn.query(`CREATE DATABASE \`${db_name}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
}
await conn.query(`USE \`${db_name}\``);
await initSchema(conn);
const hashed = bcrypt.hashSync(admin_password, 10);
await conn.query(
'INSERT INTO users(username, password, email, game_name, game_uid, role, active, email_verified) VALUES (?,?,?,?,?,?,1,1)',
[admin_username, hashed, admin_email, admin_game_name || admin_username, admin_game_uid || 'OWNER001', 'owner']
);
const jwtSecret = crypto.randomBytes(32).toString('hex');
const apiKey = crypto.randomBytes(24).toString('hex');
for (const [k, v, l] of [
['site_name', site_name || 'MC举报系统', '站点名称'],
['site_url', site_url || 'http://localhost:3100', '站点地址'],
]) {
await conn.query("INSERT INTO settings(k, v, label) VALUES (?,?,?) ON DUPLICATE KEY UPDATE v=VALUES(v)", [k, v, l]);
}
await conn.end();
saveConfig({
db_host, db_port: parseInt(db_port) || 3306,
db_user, db_pass: db_pass || '', db_name,
jwt_secret: jwtSecret,
api_key: apiKey,
});
res.json({ ok: true, message: '安装完成,请重新启动服务器' });
} catch (e) {
res.status(500).json({ error: '安装失败: ' + e.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,46 @@
const express = require('express');
const { query, getRow } = require('../db');
const { authenticate, requireRole } = require('../middleware/auth');
const { sendWebhook } = require('../webhook');
const router = express.Router();
router.get('/', authenticate, requireRole('owner','admin'), async (req, res) => {
res.json(await query('SELECT * FROM notification_configs ORDER BY id'));
});
router.post('/', authenticate, requireRole('owner','admin'), async (req, res) => {
const { name, type, webhook_url, events } = req.body;
if (!name||!type) return res.status(400).json({ error: '名称和类型为必填' });
if (type==='webhook' && !webhook_url) return res.status(400).json({ error: 'Webhook地址必填' });
const r = await query('INSERT INTO notification_configs(name, type, webhook_url, events) VALUES (?,?,?,?)', [name, type, webhook_url||'', events||'all']);
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'create_notification', 'notification_config', r.insertId, `创建通知: ${name}`]);
res.status(201).json({ id: r.insertId, message: '创建成功' });
});
router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
const cfg = await getRow('SELECT * FROM notification_configs WHERE id = ?', [req.params.id]);
if (!cfg) return res.status(404).json({ error: '配置不存在' });
const fields = {};
if (req.body.name) fields.name = req.body.name;
if (req.body.webhook_url !== undefined) fields.webhook_url = req.body.webhook_url;
if (req.body.events) fields.events = req.body.events;
if (req.body.active !== undefined) fields.active = req.body.active?1:0;
if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' });
const sets = Object.keys(fields).map(k=>`${k}=?`).join(',');
await query(`UPDATE notification_configs SET ${sets} WHERE id = ?`, [...Object.values(fields), req.params.id]);
res.json({ message: '更新成功' });
});
router.delete('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
await query('DELETE FROM notification_configs WHERE id = ?', [req.params.id]);
res.json({ message: '已删除' });
});
router.post('/:id/test', authenticate, requireRole('owner','admin'), async (req, res) => {
const cfg = await getRow('SELECT * FROM notification_configs WHERE id = ?', [req.params.id]);
if (!cfg||!cfg.webhook_url) return res.status(404).json({ error: '配置不存在' });
sendWebhook('ticket_created', { ticket_id:'TEST', ticket_title:'测试通知', ticket_type:'测试', reporter_game_name:'TestPlayer', reporter_game_uid:'TEST001', ticket_reason:'这是一条测试消息' }).then(() => res.json({ message:'测试消息已发送' })).catch(e => res.status(500).json({ error:e.message }));
});
module.exports = router;

View File

@@ -0,0 +1,46 @@
const express = require('express');
const { query, getRow } = require('../db');
const { authenticate, requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/email-templates', authenticate, requireRole('owner','admin'), async (req, res) => {
res.json(await query('SELECT * FROM email_templates ORDER BY id'));
});
router.get('/email-templates/:code', authenticate, requireRole('owner','admin'), async (req, res) => {
const t = await getRow('SELECT * FROM email_templates WHERE code = ?', [req.params.code]);
if (!t) return res.status(404).json({ error: '模板不存在' });
res.json(t);
});
router.put('/email-templates/:code', authenticate, requireRole('owner','admin'), async (req, res) => {
const { subject, body } = req.body;
if (!subject||!body) return res.status(400).json({ error: '标题和内容不能为空' });
const t = await getRow('SELECT * FROM email_templates WHERE code = ?', [req.params.code]);
if (!t) return res.status(404).json({ error: '模板不存在' });
await query('UPDATE email_templates SET subject = ?, body = ? WHERE code = ?', [subject, body, req.params.code]);
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'update_email_template', 'email_template', t.id, `更新模板: ${t.name}`]);
res.json({ message: '模板更新成功' });
});
router.get('/settings', authenticate, requireRole('owner','admin'), async (req, res) => {
const rows = await query('SELECT k, v, label FROM settings ORDER BY k');
const map = {};
for (const r of rows) map[r.k] = { value: r.v, label: r.label };
res.json(map);
});
router.put('/settings', authenticate, requireRole('owner','admin'), async (req, res) => {
const updates = req.body;
if (!updates||typeof updates!=='object') return res.status(400).json({ error: '无效数据' });
for (const [key, val] of Object.entries(updates)) {
const v = typeof val === 'object' ? val.value : String(val);
const l = typeof val === 'object' ? val.label : null;
await query("INSERT INTO settings(k, v, label) VALUES (?,?,?) ON DUPLICATE KEY UPDATE v = VALUES(v)", [key, v, l]);
}
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'update_settings', 'settings', 0, '更新系统设置']);
res.json({ message: '设置已保存' });
});
module.exports = router;

262
backend/routes/tickets.js Normal file
View File

@@ -0,0 +1,262 @@
const express = require('express');
const { v4: uuid } = require('uuid');
const { getPool, query, getRow } = require('../db');
const { authenticate, requireRole, optionalAuth } = require('../middleware/auth');
const { upload, finalizeUpload } = require('../middleware/upload');
const { sendEmail } = require('../mailer');
const { sendWebhook } = require('../webhook');
const { validateLengths } = require('../middleware/security');
const router = express.Router();
const SL = { pending:'待处理', processing:'处理中', awaiting_info:'待补充', resolved:'已解决', rejected:'已驳回', closed:'已关闭' };
const TL = { report:'举报', suggestion:'建议', appeal:'申诉' };
const SC = { pending:'#f59e0b', processing:'#7c3aed', awaiting_info:'#d97706', resolved:'#059669', rejected:'#dc2626', closed:'#6b7280' };
const SO = { processing:1, awaiting_info:2, pending:3, resolved:4, rejected:5, closed:6 };
async function siteUrl() { const r = await getRow("SELECT v FROM settings WHERE k='site_url'"); return r?.v || 'http://localhost:3100'; }
router.get('/', authenticate, async (req, res) => {
let q = `SELECT t.*, u.game_name as user_game_name, u.username as submitter, a.game_name as assignee_name
FROM tickets t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN users a ON t.assigned_to = a.id`;
const conds = [], params = [];
if (req.user.role === 'player') { conds.push('t.user_id = ?'); params.push(req.user.id); }
else if (req.user.role === 'admin') { conds.push("(t.assigned_to IS NULL OR t.assigned_to = ?) AND t.type != 'suggestion'"); params.push(req.user.id); }
if (req.query.type) { conds.push('t.type = ?'); params.push(req.query.type); }
if (req.query.status) { conds.push('t.status = ?'); params.push(req.query.status); }
if (req.query.priority) { conds.push('t.priority = ?'); params.push(req.query.priority); }
if (req.query.claimed === '1') conds.push('t.assigned_to IS NOT NULL');
if (req.query.claimed === '0') conds.push('t.assigned_to IS NULL');
if (req.query.search) { const s = `%${req.query.search}%`; conds.push("(t.title LIKE ? OR t.reporter_game_name LIKE ? OR t.reporter_game_uid LIKE ? OR t.target_game_name LIKE ? OR t.target_game_uid LIKE ?)"); params.push(s,s,s,s,s); }
if (conds.length) q += ' WHERE ' + conds.join(' AND ');
q += ` ORDER BY CASE t.status ${Object.entries(SO).map(([k,v])=>`WHEN '${k}' THEN ${v}`).join(' ')} ELSE 9 END, t.updated_at DESC LIMIT 100`;
try { res.json(await query(q, params)); } catch(e) { res.status(500).json({ error: e.message }); }
});
router.get('/stats', authenticate, async (req, res) => {
const role = req.user.role;
const sql = role === 'player' ? 'SELECT type, status, COUNT(*) as count FROM tickets WHERE user_id = ? GROUP BY type, status' : 'SELECT type, status, COUNT(*) as count FROM tickets GROUP BY type, status';
const params = role === 'player' ? [req.user.id] : [];
const rows = await query(sql, params);
const bs = {}; let total = 0;
for (const r of rows) { bs[r.status] = (bs[r.status]||0) + r.count; total += r.count; }
res.json({ total, byStatus: bs });
});
router.get('/unclaimed', authenticate, requireRole('owner','admin'), async (req, res) => {
let q = `SELECT t.*, u.game_name as user_game_name, u.username as submitter, a.game_name as assignee_name
FROM tickets t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN users a ON t.assigned_to = a.id WHERE t.assigned_to IS NULL`;
const params = [];
if (req.user.role === 'admin') { q += " AND t.type != 'suggestion'"; }
if (req.query.type) { q += ' AND t.type = ?'; params.push(req.query.type); }
q += ` ORDER BY CASE t.status ${Object.entries(SO).map(([k,v])=>`WHEN '${k}' THEN ${v}`).join(' ')} ELSE 9 END, t.created_at ASC LIMIT 200`;
res.json(await query(q, params));
});
router.get('/reported-players', authenticate, requireRole('owner','admin'), async (req, res) => {
const rows = await query(`SELECT target_game_name as name, target_game_uid as uid,
COUNT(*) as total_reports,
SUM(CASE WHEN status IN ('pending','processing','awaiting_info') THEN 1 ELSE 0 END) as active_count,
SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as resolved_count,
SUM(CASE WHEN status='rejected' THEN 1 ELSE 0 END) as rejected_count,
MAX(created_at) as last_report,
GROUP_CONCAT(reason SEPARATOR ' | ') as reasons
FROM tickets WHERE type='report' AND target_game_name IS NOT NULL AND target_game_name != ''
GROUP BY target_game_name, target_game_uid ORDER BY total_reports DESC, last_report DESC`);
res.json(rows);
});
router.get('/tracking', async (req, res) => {
const token = req.query.token;
if (!token) return res.status(400).json({ error: '缺少追踪标识' });
const rows = await query(`SELECT t.*, a.game_name as assignee_name FROM tickets t LEFT JOIN users a ON t.assigned_to = a.id WHERE t.tracking_token = ? ORDER BY t.created_at DESC`, [token]);
if (!rows.length) return res.status(404).json({ error: '未找到相关工单' });
res.json(rows);
});
router.get('/:id', authenticate, async (req, res) => {
const t = await getRow(`SELECT t.*, u.game_name as user_game_name, u.username as submitter, a.game_name as assignee_name, a.username as assignee_username
FROM tickets t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN users a ON t.assigned_to = a.id WHERE t.id = ?`, [req.params.id]);
if (!t) return res.status(404).json({ error: '工单不存在' });
if (req.user.role === 'player' && t.user_id !== req.user.id) return res.status(403).json({ error: '无权查看' });
t.responses = await query(`SELECT r.*, u.username, u.role FROM responses r LEFT JOIN users u ON r.user_id = u.id WHERE r.ticket_id = ? ORDER BY r.created_at ASC`, [req.params.id]);
t.attachments = await query('SELECT * FROM attachments WHERE ticket_id = ?', [req.params.id]);
t.transfers = await query(`SELECT tf.*, fu.username as from_username, fu.game_name as from_game_name, tu.username as to_username, tu.game_name as to_game_name
FROM ticket_transfers tf LEFT JOIN users fu ON tf.from_user_id = fu.id LEFT JOIN users tu ON tf.to_user_id = tu.id WHERE tf.ticket_id = ? ORDER BY tf.created_at ASC`, [req.params.id]);
res.json(t);
});
router.post('/', optionalAuth, upload.array('files', 5), finalizeUpload, validateLengths({
title:100, reporter_game_name:50, reporter_game_uid:50, target_game_name:50, target_game_uid:50, reason:300, description:5000,
}), async (req, res) => {
const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description } = req.body;
if (!type || !['report','suggestion','appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' });
if (!title) return res.status(400).json({ error: '标题不能为空' });
const rgn = req.user?.game_name || reporter_game_name;
const rgu = req.user?.game_uid || reporter_game_uid;
if (!rgn || !rgu) return res.status(400).json({ error: '请填写游戏名称和UID' });
if (type === 'report') { if (!target_game_name && !target_game_uid) return res.status(400).json({ error: '举报需至少填写对方游戏名或UID之一' }); if (!reason) return res.status(400).json({ error: '请填写举报原因' }); }
if (type === 'suggestion' && !description) return res.status(400).json({ error: '建议内容不能为空' });
if (type === 'appeal') { if (!reason) return res.status(400).json({ error: '请填写申诉理由' }); if (!description) return res.status(400).json({ error: '请填写详细申诉内容' }); }
const countRow = await getRow("SELECT COUNT(*) as c FROM tickets WHERE (reporter_game_name = ? OR reporter_game_uid = ?) AND status IN ('pending','processing','awaiting_info')", [rgn, rgu]);
if (countRow.c >= 5) return res.status(400).json({ error: '您的待处理/处理中工单已达上限(5个),请等待已有工单处理完毕后再提交' });
const trackingToken = uuid();
const pool = await getPool();
const conn = await pool.getConnection();
let ticketId;
try {
await conn.beginTransaction();
const [r] = await conn.execute(`INSERT INTO tickets(type,title,user_id,reporter_game_name,reporter_game_uid,
target_game_name,target_game_uid,reason,description,tracking_token) VALUES (?,?,?,?,?,?,?,?,?,?)`,
[type, title, req.user?.id||null, rgn, rgu,
type==='report'?(target_game_name||''):null, type==='report'?(target_game_uid||''):null,
(type==='report'||type==='appeal')?reason:null, (type==='suggestion'||type==='appeal')?description:'', trackingToken]);
ticketId = r.insertId;
if (req.files?.length) {
for (const f of req.files) {
await conn.execute('INSERT INTO attachments(ticket_id, original_name, stored_name, mime_type, size) VALUES (?,?,?,?,?)', [ticketId, f.originalname, f.filename, f.mimetype, f.size]);
}
}
const respContent = type === 'report'
? `举报已提交\n举报人: ${rgn} (UID: ${rgu})\n被举报人: ${target_game_name||'无'} (UID: ${target_game_uid||'无'})\n原因: ${reason}${description?'\n详情: '+description:''}`
: type === 'appeal'
? `申诉已提交\n申诉人: ${rgn} (UID: ${rgu})\n标题: ${title}\n申诉理由: ${reason}\n详细内容: ${description}`
: `建议已提交\n提交人: ${rgn} (UID: ${rgu})\n标题: ${title}\n内容: ${description}`;
await conn.execute("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,0)", [ticketId, req.user?.id||null, respContent]);
await conn.execute("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)",
[req.user?.id||null, req.user?.username||rgn, `create_${type}`, 'ticket', ticketId, `提交${TL[type]}: ${title}`]);
await conn.commit();
} catch (e) { await conn.rollback(); conn.release(); return res.status(500).json({ error: e.message }); }
conn.release();
const emailVars = { reporter_game_name: rgn, reporter_game_uid: rgu, ticket_type: TL[type], ticket_id: ticketId, ticket_title: title, ticket_reason: reason||'', ticket_description: description||'', tracking_link: `${await siteUrl()}#/ticket/${ticketId}`, date: new Date().toLocaleString('zh-CN') };
if (req.user?.email) sendEmail(req.user.email, 'ticket_created', emailVars).catch(()=>{});
sendWebhook('ticket_created', { ...emailVars, type }).catch(()=>{});
res.setHeader('Set-Cookie', `tracking_token=${trackingToken}; Path=/; SameSite=Lax; Max-Age=${365*24*3600}`);
res.status(201).json({ id: ticketId, tracking_token: trackingToken, message: '提交成功' });
});
router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
if (!t) return res.status(404).json({ error: '工单不存在' });
const fields = {};
if (req.body.status) fields.status = req.body.status;
if (req.body.priority) fields.priority = req.body.priority;
if (req.body.claim_note !== undefined) fields.claim_note = req.body.claim_note;
if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' });
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
await query(`UPDATE tickets SET ${sets} WHERE id = ?`, [...Object.values(fields), req.params.id]);
if (req.body.status && req.body.status !== t.status) {
await query("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,1)", [req.params.id, req.user.id, `状态变更为: ${SL[req.body.status]||req.body.status}`]);
await notifyStatusChange(t, req.body.status);
}
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'update_ticket', 'ticket', req.params.id, '更新工单']);
res.json({ message: '更新成功' });
});
router.post('/:id/claim', authenticate, requireRole('owner','admin'), async (req, res) => {
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
if (!t) return res.status(404).json({ error: '工单不存在' });
if (t.assigned_to && t.assigned_to !== req.user.id && t.status !== 'pending') return res.status(400).json({ error: '该工单已被他人认领' });
if (t.type === 'suggestion' && req.user.role !== 'owner') return res.status(403).json({ error: '建议工单仅服主可处理' });
const claimNote = req.body.claim_note || '';
const initialReply = req.body.initial_reply || '';
await query("UPDATE tickets SET assigned_to = ?, status = 'processing', claim_note = ?, claimed_at = NOW() WHERE id = ?", [req.user.id, claimNote, req.params.id]);
const claimMsg = initialReply ? `【接单】${initialReply}${claimNote?'\n\n处理备注: '+claimNote:''}` : `【接单】已认领此工单${claimNote?'\n处理备注: '+claimNote:''}`;
await query("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,1)", [req.params.id, req.user.id, claimMsg]);
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'claim_ticket', 'ticket', req.params.id, '认领工单']);
const user = await getRow('SELECT username, game_name FROM users WHERE id = ?', [req.user.id]);
await sendEmailForTicket(t, 'ticket_claimed', { assigned_to: user?.game_name||req.user.username, claim_note: claimNote });
sendWebhook('ticket_claimed', { ticket_id: t.id, ticket_title: t.title, assigned_to: user?.game_name||req.user.username, claim_note: claimNote }).catch(()=>{});
res.json({ message: '认领成功' });
});
router.post('/:id/transfer', authenticate, requireRole('owner','admin'), async (req, res) => {
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
if (!t) return res.status(404).json({ error: '工单不存在' });
if (t.assigned_to !== req.user.id && req.user.role !== 'owner') return res.status(403).json({ error: '只能转交自己负责的工单' });
const { to_user_id, reason } = req.body;
if (!to_user_id) return res.status(400).json({ error: '请选择转交目标' });
const toUser = await getRow("SELECT * FROM users WHERE id = ? AND role IN ('owner','admin')", [to_user_id]);
if (!toUser) return res.status(400).json({ error: '目标用户无效' });
if (to_user_id === req.user.id) return res.status(400).json({ error: '不能转交给自己' });
const pool = await getPool();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.execute('INSERT INTO ticket_transfers(ticket_id, from_user_id, to_user_id, reason) VALUES (?,?,?,?)', [req.params.id, req.user.id, to_user_id, reason||'']);
await conn.execute('UPDATE tickets SET assigned_to = ? WHERE id = ?', [to_user_id, req.params.id]);
await conn.execute("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,1)", [req.params.id, req.user.id, `【转单】工单已转交给 ${toUser.game_name||toUser.username}${reason?'\n原因: '+reason:''}`]);
await conn.execute("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'transfer_ticket', 'ticket', req.params.id, `转交工单给 ${toUser.username}`]);
await conn.commit();
} catch (e) { await conn.rollback(); conn.release(); return res.status(500).json({ error: e.message }); }
conn.release();
const fromUser = await getRow('SELECT game_name FROM users WHERE id = ?', [req.user.id]);
await sendEmailForTicket(t, 'ticket_transferred', { from_user: fromUser?.game_name||req.user.username, to_user: toUser.game_name||toUser.username, transfer_reason: reason||'', claim_note: t.claim_note||'无' });
sendWebhook('ticket_transferred', { ticket_id: t.id, ticket_title: t.title, from_user: fromUser?.game_name, to_user: toUser.game_name, reason: reason||'' }).catch(()=>{});
res.json({ message: '转交成功' });
});
router.post('/:id/response', authenticate, async (req, res) => {
const { content } = req.body;
if (!content) return res.status(400).json({ error: '内容不能为空' });
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
if (!t) return res.status(404).json({ error: '工单不存在' });
if (req.user.role === 'player' && t.user_id !== req.user.id) return res.status(403).json({ error: '无权限' });
const isStaff = req.user.role === 'owner' || req.user.role === 'admin';
await query("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,?)", [req.params.id, req.user.id, content, isStaff?1:0]);
await query('UPDATE tickets SET updated_at = NOW() WHERE id = ?', [req.params.id]);
if (isStaff && t.user_id) {
await sendEmailForTicket(t, 'ticket_updated', { update_note: content });
sendWebhook('ticket_updated', { ticket_id: t.id, ticket_title: t.title, responder: req.user.username, note: content }).catch(()=>{});
}
res.status(201).json({ message: '回复成功' });
});
async function notifyStatusChange(ticket, newStatus) {
if (ticket.user_id) {
await sendEmailForTicket(ticket, 'ticket_updated', { ticket_status: SL[newStatus]||newStatus, ticket_status_color: SC[newStatus]||'#6b7280' });
}
sendWebhook('ticket_updated', { ticket_id: ticket.id, ticket_title: ticket.title, new_status: SL[newStatus]||newStatus, type: ticket.type }).catch(()=>{});
}
async function sendEmailForTicket(ticket, templateCode, extraVars = {}) {
const assignee = ticket.assigned_to ? await getRow('SELECT game_name, username FROM users WHERE id = ?', [ticket.assigned_to]) : null;
const vars = {
ticket_id: ticket.id, ticket_title: ticket.title, ticket_type: TL[ticket.type]||ticket.type,
ticket_status: SL[ticket.status]||ticket.status, ticket_status_color: SC[ticket.status]||'#6b7280',
reporter_game_name: ticket.reporter_game_name, reporter_game_uid: ticket.reporter_game_uid,
target_game_name: ticket.target_game_name||'', target_game_uid: ticket.target_game_uid||'',
ticket_reason: ticket.reason||'', ticket_description: ticket.description||'',
assigned_to: assignee?.game_name||assignee?.username||'未分配', claim_note: ticket.claim_note||'',
tracking_link: `${await siteUrl()}#/ticket/${ticket.id}`, date: new Date().toLocaleString('zh-CN'), ...extraVars,
};
if (ticket.user_id) {
const user = await getRow('SELECT email, username, game_name, game_uid FROM users WHERE id = ?', [ticket.user_id]);
if (user?.email) { vars.username = user.username; vars.game_name = user.game_name; vars.game_uid = user.game_uid; sendEmail(user.email, templateCode, vars).catch(()=>{}); }
}
if (ticket.assigned_to && templateCode !== 'ticket_created') {
const a = await getRow('SELECT email FROM users WHERE id = ?', [ticket.assigned_to]);
if (a?.email) sendEmail(a.email, templateCode, vars).catch(()=>{});
}
}
module.exports = router;

23
backend/routes/uploads.js Normal file
View File

@@ -0,0 +1,23 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const { getRow } = require('../db');
const { authenticate } = require('../middleware/auth');
const { UPLOAD_DIR } = require('../middleware/upload');
const router = express.Router();
router.get('/:storedName', authenticate, async (req, res) => {
const att = await getRow('SELECT * FROM attachments WHERE stored_name = ?', [req.params.storedName]);
if (!att) return res.status(404).json({ error: '文件不存在' });
const ticket = await getRow('SELECT * FROM tickets WHERE id = ?', [att.ticket_id]);
if (req.user.role === 'player' && ticket?.user_id !== req.user.id) return res.status(403).json({ error: '无权限' });
const filePath = path.join(UPLOAD_DIR, att.stored_name);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: '文件不存在' });
res.setHeader('Content-Type', att.mime_type);
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(att.original_name)}"`);
res.setHeader('Cache-Control', 'private, max-age=86400');
fs.createReadStream(filePath).pipe(res);
});
module.exports = router;

48
backend/routes/users.js Normal file
View File

@@ -0,0 +1,48 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { query, getRow } = require('../db');
const { authenticate, requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/', authenticate, requireRole('owner','admin'), async (req, res) => {
res.json(await query('SELECT id, username, email, game_name, game_uid, role, active, email_verified, created_at FROM users ORDER BY created_at DESC'));
});
router.get('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
const u = await getRow('SELECT id, username, email, game_name, game_uid, role, active, email_verified, created_at FROM users WHERE id = ?', [req.params.id]);
if (!u) return res.status(404).json({ error: '用户不存在' });
res.json(u);
});
router.post('/', authenticate, requireRole('owner','admin'), async (req, res) => {
const { username, password, email, game_name, game_uid, role } = req.body;
if (!username||!password||!email||!game_name||!game_uid||!role) return res.status(400).json({ error: '所有字段必填' });
if (!['owner','admin','player'].includes(role)) return res.status(400).json({ error: '无效角色' });
if (password.length < 6) return res.status(400).json({ error: '密码至少6位' });
if (await getRow('SELECT id FROM users WHERE username = ?', [username])) return res.status(400).json({ error: '用户名已存在' });
const hashed = bcrypt.hashSync(password, 10);
const r = await query('INSERT INTO users(username, password, email, game_name, game_uid, role, active, email_verified) VALUES (?,?,?,?,?,?,1,1)', [username, hashed, email, game_name, game_uid, role]);
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'create_user', 'user', r.insertId, `创建用户 ${username}`]);
res.status(201).json({ id: r.insertId, message: '创建成功' });
});
router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
const u = await getRow('SELECT * FROM users WHERE id = ?', [req.params.id]);
if (!u) return res.status(404).json({ error: '用户不存在' });
if (u.role === 'owner' && req.user.role !== 'owner') return res.status(403).json({ error: '无权修改服主' });
const fields = {};
if (req.body.email) fields.email = req.body.email;
if (req.body.game_name) fields.game_name = req.body.game_name;
if (req.body.game_uid) fields.game_uid = req.body.game_uid;
if (req.body.role) { if (!['owner','admin','player'].includes(req.body.role)) return res.status(400).json({ error: '无效角色' }); fields.role = req.body.role; }
if (req.body.active !== undefined) fields.active = req.body.active ? 1 : 0;
if (req.body.password) { if (req.body.password.length < 6) return res.status(400).json({ error: '密码至少6位' }); fields.password = bcrypt.hashSync(req.body.password, 10); }
if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' });
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
await query(`UPDATE users SET ${sets} WHERE id = ?`, [...Object.values(fields), req.params.id]);
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'update_user', 'user', req.params.id, '更新用户']);
res.json({ message: '更新成功' });
});
module.exports = router;