feat: admin complaint (owner only) + result appeal (one per ticket, owner only)
This commit is contained in:
@@ -84,7 +84,7 @@ async function initSchema(connection) {
|
||||
await createIfNotExists('tickets', `CREATE TABLE tickets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT,
|
||||
type ENUM('report','suggestion','appeal') NOT NULL,
|
||||
type ENUM('report','suggestion','appeal','result_appeal') NOT NULL,
|
||||
title VARCHAR(100) NOT NULL,
|
||||
status ENUM('pending','processing','awaiting_info','resolved','rejected','closed') NOT NULL DEFAULT 'pending',
|
||||
priority ENUM('low','medium','high','urgent') NOT NULL DEFAULT 'medium',
|
||||
@@ -98,6 +98,8 @@ async function initSchema(connection) {
|
||||
claim_note TEXT,
|
||||
claimed_at DATETIME,
|
||||
tracking_token VARCHAR(255) UNIQUE,
|
||||
is_admin_complaint TINYINT(1) NOT NULL DEFAULT 0,
|
||||
parent_ticket_id INT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_type (type),
|
||||
@@ -185,6 +187,13 @@ async function initSchema(connection) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
|
||||
await seedTemplates(db);
|
||||
await migrateAdditions(db);
|
||||
}
|
||||
|
||||
async function migrateAdditions(db) {
|
||||
try { await db.execute("ALTER TABLE tickets ADD COLUMN is_admin_complaint TINYINT(1) NOT NULL DEFAULT 0"); } catch {}
|
||||
try { await db.execute("ALTER TABLE tickets ADD COLUMN parent_ticket_id INT"); } catch {}
|
||||
try { await db.execute("ALTER TABLE tickets MODIFY type ENUM('report','suggestion','appeal','result_appeal') NOT NULL"); } catch {}
|
||||
}
|
||||
|
||||
async function seedTemplates(db) {
|
||||
|
||||
@@ -10,7 +10,7 @@ const { validateLengths } = require('../middleware/security');
|
||||
const router = express.Router();
|
||||
|
||||
const SL = { pending:'待处理', processing:'处理中', awaiting_info:'待补充', resolved:'已解决', rejected:'已驳回', closed:'已关闭' };
|
||||
const TL = { report:'举报', suggestion:'建议', appeal:'申诉' };
|
||||
const TL = { report:'举报', suggestion:'建议', appeal:'申诉', result_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 };
|
||||
|
||||
@@ -22,7 +22,7 @@ router.get('/', authenticate, async (req, res) => {
|
||||
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); }
|
||||
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); }
|
||||
@@ -50,7 +50,7 @@ router.get('/unclaimed', authenticate, requireRole('owner','admin'), async (req,
|
||||
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.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));
|
||||
@@ -86,14 +86,17 @@ router.get('/:id', authenticate, async (req, res) => {
|
||||
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 } = req.body;
|
||||
if (!type || !['report','suggestion','appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' });
|
||||
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;
|
||||
@@ -101,6 +104,15 @@ router.post('/', optionalAuth, upload.array('files', 5), finalizeUpload, validat
|
||||
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: '原工单不存在或状态不允许申诉' });
|
||||
const existing = await getRow("SELECT id FROM tickets WHERE type = 'result_appeal' AND parent_ticket_id = ?", [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')", [rgn, rgu]);
|
||||
if (countRow.c >= 5) return res.status(400).json({ error: '您的待处理/处理中工单已达上限(5个),请等待已有工单处理完毕后再提交' });
|
||||
@@ -112,10 +124,14 @@ router.post('/', optionalAuth, upload.array('files', 5), finalizeUpload, validat
|
||||
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 (?,?,?,?,?,?,?,?,?,?)`,
|
||||
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')?reason:null, (type==='suggestion'||type==='appeal')?description:'', trackingToken]);
|
||||
(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) {
|
||||
@@ -169,6 +185,7 @@ router.post('/:id/claim', authenticate, requireRole('owner','admin'), async (req
|
||||
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: '建议工单仅服主可处理' });
|
||||
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 || '';
|
||||
|
||||
Reference in New Issue
Block a user