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(); 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 } = 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) { 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;