- ticket-create.js: added 子服务器 dropdown (group/server from /polls/server-list, supports anonymous users), sends 'server' field - tickets.js POST: accept server param, resolve 'group/server' or alias/server_name from server_groups, store into tickets.server_name - polls.js: new public GET /server-list (group_name+server_name only, no auth required) for ticket create page - verified: 13 checks (frontend/backend/db/column/syntax)
396 lines
27 KiB
JavaScript
396 lines
27 KiB
JavaScript
/*
|
|
* MC Report System
|
|
* Copyright (C) 2026 Sea Network Technology Studio
|
|
* Author: CangLan <admin@sea-studio.top>
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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, ticketAnonLimiter } = require('../middleware/security');
|
|
|
|
const router = express.Router();
|
|
|
|
// 统一校验 :id 路径参数(必须是正整数)
|
|
router.param('id', (req, res, next, id) => {
|
|
if (!/^\d+$/.test(id)) return res.status(400).json({ error: '无效的ID' });
|
|
next();
|
|
});
|
|
|
|
const SL = { pending:'待处理', processing:'处理中', awaiting_info:'待补充', appealing:'申诉中', resolved:'已解决', rejected:'已驳回', closed:'已关闭' };
|
|
const TL = { report:'举报', suggestion:'建议', appeal:'申诉', result_appeal:'结果申诉' };
|
|
const SC = { pending:'#f59e0b', processing:'#7c3aed', awaiting_info:'#d97706', appealing:'#f97316', resolved:'#059669', rejected:'#dc2626', closed:'#6b7280' };
|
|
const SO = { processing:1, awaiting_info:2, appealing:3, pending:4, resolved:5, rejected:6, closed:7 };
|
|
|
|
async function siteUrl() { const r = await getRow("SELECT v FROM settings WHERE k='site_url'"); return r?.v || 'http://localhost:3100'; }
|
|
|
|
async function autoCloseExpired() {
|
|
try {
|
|
await query(`UPDATE tickets SET status = 'closed' WHERE status IN ('resolved','rejected') AND updated_at < NOW() - INTERVAL 7 DAY`);
|
|
} catch {}
|
|
}
|
|
|
|
router.get('/', authenticate, async (req, res) => {
|
|
await autoCloseExpired();
|
|
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' AND t.is_admin_complaint = 0 AND t.type != 'result_appeal'"); 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' AND t.is_admin_complaint = 0 AND t.type != 'result_appeal'"; }
|
|
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','appealing') 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) => {
|
|
await autoCloseExpired();
|
|
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: '无权查看' });
|
|
if (req.user.role === 'admin' && (t.is_admin_complaint || t.type === 'result_appeal' || t.type === 'suggestion')) 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]);
|
|
if (t.parent_ticket_id) {
|
|
t.parent_ticket = await getRow('SELECT id, title, status, reporter_game_name, reporter_game_uid FROM tickets WHERE id = ?', [t.parent_ticket_id]);
|
|
}
|
|
res.json(t);
|
|
});
|
|
|
|
router.post('/', ticketAnonLimiter, 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, is_admin_complaint, parent_ticket_id, server } = req.body;
|
|
if (!type || !['report','suggestion','appeal','result_appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' });
|
|
if (!title) return res.status(400).json({ error: '标题不能为空' });
|
|
// 子服务器解析: '分组/子服' 或 alias 或 server_name
|
|
let serverName = '';
|
|
if (server) {
|
|
let g = '', s = '';
|
|
if (String(server).includes('/')) {
|
|
const parts = String(server).split('/');
|
|
g = parts[0].trim(); s = parts[1].trim();
|
|
serverName = s;
|
|
} else {
|
|
const row = await getRow('SELECT group_name, server_name FROM server_groups WHERE alias = ? OR server_name = ? LIMIT 1', [server, server]);
|
|
if (row) { g = row.group_name; s = row.server_name; serverName = s; }
|
|
else serverName = String(server).trim();
|
|
}
|
|
}
|
|
// 身份解析: 登录用户可选用自己绑定的任一身份(网易端/皮肤站); 匿名用表单值
|
|
let rgn = reporter_game_name, rgu = reporter_game_uid;
|
|
if (req.user) {
|
|
if (reporter_game_name && reporter_game_name !== req.user.game_name) {
|
|
const ident = await getRow('SELECT id FROM user_identities WHERE user_id = ? AND game_name = ?', [req.user.id, reporter_game_name]);
|
|
if (!ident) return res.status(403).json({ error: '所选身份不属于当前账号' });
|
|
}
|
|
if (!rgn) { rgn = req.user.game_name; rgu = req.user.game_uid; }
|
|
}
|
|
if (!rgn) return res.status(400).json({ error: '请填写游戏名称' });
|
|
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: '请填写详细申诉内容' }); }
|
|
if (type === 'result_appeal') {
|
|
if (!parent_ticket_id) return res.status(400).json({ error: '缺少原工单信息' });
|
|
const orig = await getRow("SELECT * FROM tickets WHERE id = ? AND user_id = ? AND status IN ('resolved','rejected','closed')", [parent_ticket_id, req.user?.id||0]);
|
|
if (!orig) return res.status(400).json({ error: '原工单不存在或状态不允许申诉' });
|
|
if (orig.status === 'closed') return res.status(400).json({ error: '已关闭的工单无法申诉' });
|
|
const existing = await getRow("SELECT id FROM tickets WHERE type = 'result_appeal' AND parent_ticket_id = ? AND status != 'closed'", [parent_ticket_id]);
|
|
if (existing) return res.status(400).json({ error: '该工单已有结果申诉在处理中' });
|
|
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','appealing')", [rgn, rgu]);
|
|
if (countRow.c >= 5) return res.status(400).json({ error: '您的待处理/处理中工单已达上限(5个),请等待已有工单处理完毕后再提交' });
|
|
|
|
const cookieToken = req.headers.cookie?.split('; ').find(c => c.startsWith('tracking_token='))?.split('=')[1];
|
|
const trackingToken = cookieToken || 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,is_admin_complaint,parent_ticket_id,server_name) 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'||type==='result_appeal')?reason:null,
|
|
(type==='suggestion'||type==='appeal'||type==='result_appeal')?description:'',
|
|
trackingToken,
|
|
is_admin_complaint ? 1 : 0,
|
|
parent_ticket_id || null,
|
|
serverName]);
|
|
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]);
|
|
}
|
|
}
|
|
|
|
if (type === 'result_appeal' && parent_ticket_id) {
|
|
await conn.execute("UPDATE tickets SET status = 'appealing' WHERE id = ?", [parent_ticket_id]);
|
|
await conn.execute("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,0)", [parent_ticket_id, req.user?.id||null, '【系统】玩家已对处理结果提出申诉,工单状态变更为申诉中']);
|
|
}
|
|
|
|
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}`
|
|
: type === 'result_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: '工单不存在' });
|
|
if (req.user.role === 'admin' && t.assigned_to && t.assigned_to !== req.user.id) return res.status(403).json({ error: '只能修改自己负责的工单' });
|
|
if (t.status === 'closed' && req.user.role !== 'owner') return res.status(400).json({ error: '已关闭的工单仅服主可操作' });
|
|
const fields = {};
|
|
if (req.body.status) {
|
|
if (!['pending','processing','awaiting_info','appealing','resolved','rejected','closed'].includes(req.body.status)) return res.status(400).json({ error: '无效的状态值' });
|
|
fields.status = req.body.status;
|
|
}
|
|
if (req.body.priority) fields.priority = req.body.priority;
|
|
if (req.body.priority && !['low','medium','high','urgent'].includes(req.body.priority)) return res.status(400).json({ error: '无效的优先级' });
|
|
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}`]);
|
|
|
|
if (t.type === 'result_appeal' && (req.body.status === 'resolved' || req.body.status === 'rejected' || req.body.status === 'closed')) {
|
|
await query(`UPDATE tickets SET status = 'closed' WHERE id = ?`, [t.parent_ticket_id]);
|
|
await query("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,1)", [t.parent_ticket_id, req.user.id, `【系统】申诉已处理,原工单已关闭`]);
|
|
}
|
|
|
|
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.status === 'closed' && req.user.role !== 'owner') return res.status(400).json({ error: '已关闭的工单仅服主可操作' });
|
|
if (t.type === 'suggestion' && req.user.role !== 'owner') return res.status(403).json({ error: '建议工单仅服主可处理' });
|
|
if ((t.type === 'result_appeal' || t.is_admin_complaint) && 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.status === 'closed' && req.user.role !== 'owner') return res.status(400).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('/batch', authenticate, requireRole('owner','admin'), async (req, res) => {
|
|
const { ids, action, status } = req.body;
|
|
if (!ids || !Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: '请选择工单' });
|
|
if (!['delete','status','close'].includes(action)) return res.status(400).json({ error: '无效操作' });
|
|
const pool = await getPool();
|
|
const conn = await pool.getConnection();
|
|
try {
|
|
await conn.beginTransaction();
|
|
for (const id of ids) {
|
|
if (action === 'delete') {
|
|
await conn.execute('DELETE FROM responses WHERE ticket_id = ?', [id]);
|
|
await conn.execute('DELETE FROM attachments WHERE ticket_id = ?', [id]);
|
|
await conn.execute('DELETE FROM ticket_transfers WHERE ticket_id = ?', [id]);
|
|
await conn.execute('DELETE FROM tickets WHERE id = ?', [id]);
|
|
} else {
|
|
const s = action === 'close' ? 'closed' : (status || 'closed');
|
|
await conn.execute('UPDATE tickets SET status = ? WHERE id = ?', [s, id]);
|
|
await conn.execute("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,1)", [id, req.user.id, `【批量】状态变更为: ${SL[s]||s}`]);
|
|
}
|
|
}
|
|
await conn.commit();
|
|
res.json({ message: `已处理 ${ids.length} 个工单` });
|
|
} catch(e) { await conn.rollback(); conn.release(); return res.status(500).json({ error: e.message }); }
|
|
conn.release();
|
|
});
|
|
|
|
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: '回复成功' });
|
|
});
|
|
|
|
router.post('/:id/anon-response', async (req, res) => {
|
|
const { content } = req.body;
|
|
if (!content) return res.status(400).json({ error: '内容不能为空' });
|
|
const cookieToken = req.headers.cookie?.split('; ').find(c => c.startsWith('tracking_token='))?.split('=')[1];
|
|
if (!cookieToken) return res.status(403).json({ error: '无权操作,请先提交工单获取追踪标识' });
|
|
const t = await getRow('SELECT * FROM tickets WHERE id = ? AND tracking_token = ?', [req.params.id, cookieToken]);
|
|
if (!t) return res.status(404).json({ error: '工单不存在或无权操作' });
|
|
await query("INSERT INTO responses(ticket_id, content, is_staff) VALUES (?,?,0)", [req.params.id, content]);
|
|
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;
|