Files
MC_Report/backend/routes/tickets.js

346 lines
25 KiB
JavaScript

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:'待补充', 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('/', 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 } = 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: '标题不能为空' });
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: '请填写详细申诉内容' }); }
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) 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]);
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) 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;