const express = require('express'); const { rateLimit } = require('express-rate-limit'); const db = require('../db'); const { authMiddleware, adminOnly } = require('../middleware/auth'); const router = express.Router(); function userKey(req) { return String(req.user && req.user.id ? req.user.id : 'anonymous'); } const createTicketUserLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 5, keyGenerator: userKey, standardHeaders: true, legacyHeaders: false, message: { error: '提交过于频繁,请稍后再试' } }); const createTicketIpLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false, message: { error: '当前网络提交过于频繁,请稍后再试' } }); const messageUserLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 30, keyGenerator: userKey, standardHeaders: true, legacyHeaders: false, message: { error: '回复过于频繁,请稍后再试' } }); const messageIpLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 120, standardHeaders: true, legacyHeaders: false, message: { error: '当前网络回复过于频繁,请稍后再试' } }); const detailUserLimiter = rateLimit({ windowMs: 60 * 1000, limit: 60, keyGenerator: userKey, standardHeaders: true, legacyHeaders: false, message: { error: '请求过于频繁,请稍后再试' } }); const detailIpLimiter = rateLimit({ windowMs: 60 * 1000, limit: 180, standardHeaders: true, legacyHeaders: false, message: { error: '当前网络请求过于频繁,请稍后再试' } }); const CATEGORIES = new Set(['forum_bug', 'site_bug', 'feature', 'account', 'other']); const PRIORITIES = new Set(['low', 'normal', 'high', 'urgent']); const STATUSES = new Set(['open', 'processing', 'waiting', 'resolved', 'closed']); const TRANSITIONS = { open: new Set(['processing', 'closed']), processing: new Set(['waiting', 'resolved', 'closed']), waiting: new Set(['processing', 'closed']), resolved: new Set(['closed', 'processing']), closed: new Set(['processing']), }; function text(value, max = 0) { if (typeof value !== 'string') return ''; const valueText = value.trim(); return max && valueText.length > max ? valueText.slice(0, max) : valueText; } function currentUserRole(userId) { const user = db.get('SELECT role FROM users WHERE id = ?', [userId]); return user ? user.role : null; } function validateSourceUrl(value) { if (value === undefined || value === null || value === '') return { value: '' }; if (typeof value !== 'string') return { error: '来源地址不合法' }; if (value.length > 1000 || /[\u0000-\u001f\u007f-\u009f]/.test(value) || value.includes('\\')) return { error: '来源地址不合法' }; const sourceUrl = value.trim(); if (!sourceUrl) return { value: '' }; if (sourceUrl.startsWith('/') && !sourceUrl.startsWith('//')) return { value: sourceUrl }; if (!/^https?:\/\//i.test(sourceUrl)) return { error: '来源地址只允许站内路径或 http/https 地址' }; try { const parsed = new URL(sourceUrl); if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) return { error: '来源地址不合法' }; } catch { return { error: '来源地址不合法' }; } return { value: sourceUrl }; } function pageParams(query) { const page = Number.parseInt(query.page, 10); const pageSize = Number.parseInt(query.pageSize, 10); return { page: Number.isInteger(page) && page >= 1 ? page : 1, pageSize: Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 50 ? pageSize : 20, }; } function detailParams(query) { const page = Number.parseInt(query.messagePage || query.page, 10); const pageSize = Number.parseInt(query.messagePageSize || query.pageSize, 10); return { page: Number.isInteger(page) && page >= 1 ? page : 1, pageSize: Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 50 ? pageSize : 20, }; } function eventParams(query) { const page = Number.parseInt(query.eventPage || query.page, 10); const pageSize = Number.parseInt(query.eventPageSize || query.pageSize, 10); return { page: Number.isInteger(page) && page >= 1 ? page : 1, pageSize: Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 50 ? pageSize : 20, }; } function idParam(value) { const id = Number.parseInt(value, 10); return Number.isInteger(id) && id > 0 && String(id) === String(value) ? id : 0; } function getTicket(id) { return db.get(`SELECT t.*, u.username AS requester_name, u.nickname AS requester_nickname, a.username AS assignee_name, a.nickname AS assignee_nickname FROM tickets t LEFT JOIN users u ON u.id = t.requester_id LEFT JOIN users a ON a.id = t.assignee_id WHERE t.id = ?`, [id]); } function canAccess(ticket, user, role) { return !!ticket && !!role && (ticket.requester_id === user.id || role === 'admin'); } function addEvent(insertEvent, ticketId, actorId, type, field = '', oldValue = '', newValue = '', detail = '') { insertEvent.run(ticketId, actorId || null, type, field, String(oldValue ?? ''), String(newValue ?? ''), detail); } function updateStatus(ticket, status, actorId, insertEvent, database) { if (!STATUSES.has(status)) return { error: '状态不合法' }; if (ticket.status === status) return { changed: false }; if (!TRANSITIONS[ticket.status] || !TRANSITIONS[ticket.status].has(status)) { return { error: '不允许的状态流转' }; } const now = "datetime('now')"; const values = [status]; let sql = `UPDATE tickets SET status = ?, updated_at = ${now}, revision = revision + 1`; if (status === 'resolved') sql += `, resolved_at = ${now}`; if (status === 'closed') sql += `, closed_at = ${now}`; if (status !== 'resolved') sql += ', resolved_at = NULL'; if (status !== 'closed') sql += ', closed_at = NULL'; sql += ' WHERE id = ? AND revision = ?'; values.push(ticket.id, ticket.revision); const result = database.prepare(sql).run(...values); if (result.changes !== 1) { const error = new Error('工单已被其他人更新,请刷新后重试'); error.code = 'TICKET_CONFLICT'; throw error; } const eventType = status === 'closed' ? 'ticket_closed' : (ticket.status === 'closed' && status === 'processing' ? 'ticket_reopened' : 'status_changed'); addEvent(insertEvent, ticket.id, actorId, eventType, 'status', ticket.status, status); return { changed: true }; } function ticketResponse(ticket, isAdmin, query = {}) { const messagePaging = detailParams(query); const eventPaging = eventParams(query); const { page: messagePage, pageSize: messagePageSize } = messagePaging; const visibility = isAdmin ? '' : 'AND tm.is_internal = 0'; const messageTotal = db.get(`SELECT COUNT(*) AS count FROM ticket_messages tm WHERE tm.ticket_id = ? ${visibility}`, [ticket.id]).count; const messages = db.all(`SELECT tm.id, tm.ticket_id, tm.author_id, tm.content, tm.is_internal, tm.created_at, u.username AS author_name, u.nickname AS author_nickname, u.role AS author_role FROM ticket_messages tm LEFT JOIN users u ON u.id = tm.author_id WHERE tm.ticket_id = ? ${visibility} ORDER BY tm.created_at ASC, tm.id ASC LIMIT ? OFFSET ?`, [ticket.id, messagePageSize, (messagePage - 1) * messagePageSize]); const response = { ticket, messages, messagePage, messagePageSize, messageTotal, messageTotalPages: Math.ceil(messageTotal / messagePageSize) }; if (isAdmin) { const eventTotal = db.get('SELECT COUNT(*) AS count FROM ticket_events WHERE ticket_id = ?', [ticket.id]).count; response.events = db.all(`SELECT e.*, u.username AS actor_name, u.nickname AS actor_nickname FROM ticket_events e LEFT JOIN users u ON u.id = e.actor_id WHERE e.ticket_id = ? ORDER BY e.created_at ASC, e.id ASC LIMIT ? OFFSET ?`, [ticket.id, eventPaging.pageSize, (eventPaging.page - 1) * eventPaging.pageSize]); response.eventPage = eventPaging.page; response.eventPageSize = eventPaging.pageSize; response.eventTotal = eventTotal; response.eventTotalPages = Math.ceil(eventTotal / eventPaging.pageSize); } return response; } function getTicketRow(database, id) { return database.prepare('SELECT * FROM tickets WHERE id = ?').get(id); } function ticketEventStatement(database) { return database.prepare(`INSERT INTO ticket_events (ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`); } function conflictError(message = '工单已被其他人更新,请刷新后重试') { const error = new Error(message); error.code = 'TICKET_CONFLICT'; return error; } function conflictResponse(error) { return error && error.code === 'TICKET_CONFLICT' ? { status: 409, body: { error: error.message } } : null; } // 管理后台列表(作为管理后台的工单子 tab 使用) router.get('/admin', authMiddleware, adminOnly, (req, res) => { const { page, pageSize } = pageParams(req.query); const conditions = []; const params = []; const filters = [ ['status', STATUSES], ['priority', PRIORITIES], ['category', CATEGORIES], ]; for (const [key, allowed] of filters) { if (req.query[key]) { if (!allowed.has(String(req.query[key]))) return res.status(400).json({ error: `${key} 不合法` }); conditions.push(`t.${key} = ?`); params.push(String(req.query[key])); } } const assigneeId = req.query.assignee_id === 'null' ? null : idParam(req.query.assignee_id || ''); if (req.query.assignee_id !== undefined) { if (req.query.assignee_id === 'null') conditions.push('t.assignee_id IS NULL'); else if (!assigneeId) return res.status(400).json({ error: '负责人不合法' }); else { conditions.push('t.assignee_id = ?'); params.push(assigneeId); } } const requesterId = req.query.requester_id ? idParam(req.query.requester_id) : 0; if (req.query.requester_id && !requesterId) return res.status(400).json({ error: '提交人不合法' }); if (requesterId) { conditions.push('t.requester_id = ?'); params.push(requesterId); } if (req.query.q) { const q = text(req.query.q, 100); conditions.push('(t.ticket_no LIKE ? OR t.subject LIKE ? OR t.description LIKE ?)'); params.push(`%${q}%`, `%${q}%`, `%${q}%`); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const total = db.get(`SELECT COUNT(*) AS count FROM tickets t ${where}`, params).count; const list = db.all(`SELECT t.id, t.ticket_no, t.subject, t.category, t.priority, t.status, t.requester_id, t.assignee_id, t.source, t.created_at, t.updated_at, t.last_reply_at, u.username AS requester_name, u.nickname AS requester_nickname, a.username AS assignee_name, a.nickname AS assignee_nickname FROM tickets t LEFT JOIN users u ON u.id = t.requester_id LEFT JOIN users a ON a.id = t.assignee_id ${where} ORDER BY CASE t.status WHEN 'open' THEN 0 WHEN 'processing' THEN 1 WHEN 'waiting' THEN 2 WHEN 'resolved' THEN 3 ELSE 4 END, t.updated_at DESC, t.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]); res.json({ tickets: list, list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }); }); router.get('/admin/stats', authMiddleware, adminOnly, (req, res) => { const row = db.get(`SELECT COUNT(*) AS total, SUM(status = 'open') AS open, SUM(status = 'processing') AS processing, SUM(status = 'waiting') AS waiting, SUM(status = 'resolved') AS resolved, SUM(status = 'closed') AS closed FROM tickets`); res.json(Object.fromEntries(Object.entries(row).map(([key, value]) => [key, Number(value) || 0]))); }); router.get('/admin/assignees', authMiddleware, adminOnly, (req, res) => { res.json(db.all("SELECT id, username, nickname FROM users WHERE role = 'admin' ORDER BY id ASC")); }); // 用户自己的工单列表 router.get('/', authMiddleware, (req, res) => { const { page, pageSize } = pageParams(req.query); if (req.query.status && !STATUSES.has(String(req.query.status))) return res.status(400).json({ error: '状态不合法' }); const params = [req.user.id]; const statusSql = req.query.status ? 'AND t.status = ?' : ''; if (req.query.status) params.push(String(req.query.status)); const total = db.get(`SELECT COUNT(*) AS count FROM tickets t WHERE t.requester_id = ? ${statusSql}`, params).count; const list = db.all(`SELECT t.id, t.ticket_no, t.subject, t.category, t.priority, t.status, t.source, t.created_at, t.updated_at, t.last_reply_at FROM tickets t WHERE t.requester_id = ? ${statusSql} ORDER BY t.created_at DESC, t.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]); res.json({ tickets: list, list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }); }); router.post('/', authMiddleware, createTicketUserLimiter, createTicketIpLimiter, (req, res) => { const body = req.body || {}; const subject = text(body.subject, 120); const description = text(body.description, 20000); const category = text(body.category, 30) || 'other'; const priority = text(body.priority, 20) || 'normal'; const source = text(body.source, 20) || 'site'; if (subject.length < 1 || description.length < 1) return res.status(400).json({ error: '标题和问题描述不能为空' }); if (!CATEGORIES.has(category)) return res.status(400).json({ error: '问题分类不合法' }); if (!PRIORITIES.has(priority)) return res.status(400).json({ error: '优先级不合法' }); if (!['forum', 'site'].includes(source)) return res.status(400).json({ error: '来源不合法' }); const sourceUrlResult = validateSourceUrl(body.source_url); if (sourceUrlResult.error) return res.status(400).json({ error: sourceUrlResult.error }); const sourceUrl = sourceUrlResult.value; const sourceType = text(body.source_type, 40); const sourceId = body.source_id === undefined || body.source_id === '' ? 0 : idParam(String(body.source_id)); if (body.source_id !== undefined && body.source_id !== '' && !sourceId) return res.status(400).json({ error: '来源编号不合法' }); if (source === 'forum' && sourceId && !db.get('SELECT id FROM forum_posts WHERE id = ?', [sourceId])) { return res.status(400).json({ error: '关联的论坛帖子不存在' }); } const browserInfo = text(body.browser_info, 1000); try { const result = db.transaction(() => { const database = db.getDb(); const insertTicket = database.prepare(`INSERT INTO tickets (ticket_no, requester_id, subject, description, category, priority, source, source_url, source_type, source_id, browser_info, last_reply_at) VALUES ('PENDING-' || hex(randomblob(8)), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`); const info = insertTicket.run(req.user.id, subject, description, category, priority, source, sourceUrl, sourceType, sourceId, browserInfo); const id = Number(info.lastInsertRowid); const ticketNo = `RW-${String(id).padStart(6, '0')}`; database.prepare('UPDATE tickets SET ticket_no = ? WHERE id = ?').run(ticketNo, id); const event = database.prepare(`INSERT INTO ticket_events (ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`); addEvent(event, id, req.user.id, 'ticket_created', '', '', 'open', '用户创建工单'); return { id, ticket_no: ticketNo }; }); res.status(201).json({ message: '工单已创建', ticket: getTicket(result.id) }); } catch (e) { console.error('Ticket create error:', e.message); res.status(500).json({ error: '创建工单失败' }); } }); router.get('/:id/events', authMiddleware, adminOnly, detailUserLimiter, detailIpLimiter, (req, res) => { const id = idParam(req.params.id); if (!id) return res.status(404).json({ error: '工单不存在' }); if (!getTicket(id)) return res.status(404).json({ error: '工单不存在' }); const { page, pageSize } = eventParams(req.query); const eventTotal = db.get('SELECT COUNT(*) AS count FROM ticket_events WHERE ticket_id = ?', [id]).count; const events = db.all(`SELECT e.*, u.username AS actor_name, u.nickname AS actor_nickname FROM ticket_events e LEFT JOIN users u ON u.id = e.actor_id WHERE e.ticket_id = ? ORDER BY e.created_at ASC, e.id ASC LIMIT ? OFFSET ?`, [id, pageSize, (page - 1) * pageSize]); // 保留旧接口的数组响应契约,分页信息通过响应头提供。 res.set({ 'X-Page': String(page), 'X-Page-Size': String(pageSize), 'X-Total-Count': String(eventTotal), 'X-Total-Pages': String(Math.ceil(eventTotal / pageSize)), }); res.json(events); }); router.get('/:id', authMiddleware, detailUserLimiter, detailIpLimiter, (req, res) => { const id = idParam(req.params.id); const ticket = id ? getTicket(id) : null; if (!ticket) return res.status(404).json({ error: '工单不存在' }); const role = currentUserRole(req.user.id); if (!role) return res.status(401).json({ error: '登录已失效' }); if (!canAccess(ticket, req.user, role)) return res.status(403).json({ error: '无权限访问该工单' }); res.json(ticketResponse(ticket, role === 'admin', req.query)); }); router.post('/:id/messages', authMiddleware, messageUserLimiter, messageIpLimiter, (req, res) => { const id = idParam(req.params.id); const ticket = id ? getTicket(id) : null; if (!ticket) return res.status(404).json({ error: '工单不存在' }); const role = currentUserRole(req.user.id); if (!role) return res.status(401).json({ error: '登录已失效' }); if (!canAccess(ticket, req.user, role)) return res.status(403).json({ error: '无权限访问该工单' }); const content = text(req.body && req.body.content, 20000); if (!content) return res.status(400).json({ error: '回复内容不能为空' }); if (ticket.status === 'closed') return res.status(409).json({ error: '工单已关闭,不能回复' }); try { db.transaction(() => { const database = db.getDb(); const freshTicket = database.prepare('SELECT * FROM tickets WHERE id = ?').get(id); if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError(); if (freshTicket.status === 'closed') { const error = new Error('工单已关闭,不能回复'); error.code = 'TICKET_CLOSED'; throw error; } database.prepare('INSERT INTO ticket_messages (ticket_id, author_id, content, is_internal) VALUES (?, ?, ?, 0)').run(id, req.user.id, content); let nextStatus = freshTicket.status; if (role !== 'admin' && freshTicket.status === 'waiting') nextStatus = 'processing'; const updateResult = database.prepare(`UPDATE tickets SET updated_at = datetime('now'), last_reply_at = datetime('now'), first_response_at = CASE WHEN first_response_at IS NULL AND ? = 'admin' THEN datetime('now') ELSE first_response_at END, status = ?, revision = revision + 1 WHERE id = ? AND revision = ?`).run(role, nextStatus, id, freshTicket.revision); if (updateResult.changes !== 1) throw conflictError(); const event = database.prepare(`INSERT INTO ticket_events (ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`); addEvent(event, id, req.user.id, 'message_added', '', '', '公开回复', ''); if (nextStatus !== freshTicket.status) addEvent(event, id, req.user.id, 'status_changed', 'status', freshTicket.status, nextStatus, '用户回复后自动进入处理中'); }); res.status(201).json(ticketResponse(getTicket(id), role === 'admin', req.query)); } catch (e) { console.error('Ticket message error:', e.message); const response = conflictResponse(e) || (e.code === 'TICKET_CLOSED' ? { status: 409, body: { error: e.message } } : null); res.status(response ? response.status : 500).json(response ? response.body : { error: '回复工单失败' }); } }); router.post('/:id/internal-messages', authMiddleware, adminOnly, messageUserLimiter, messageIpLimiter, (req, res) => { const id = idParam(req.params.id); if (!id || !getTicket(id)) return res.status(404).json({ error: '工单不存在' }); const content = text(req.body && req.body.content, 20000); if (!content) return res.status(400).json({ error: '内部备注不能为空' }); try { db.transaction(() => { const database = db.getDb(); const freshTicket = getTicketRow(database, id); if (!freshTicket) throw new Error('工单不存在'); database.prepare('INSERT INTO ticket_messages (ticket_id, author_id, content, is_internal) VALUES (?, ?, ?, 1)').run(id, req.user.id, content); const updateResult = database.prepare("UPDATE tickets SET updated_at = datetime('now'), revision = revision + 1 WHERE id = ? AND revision = ?").run(id, freshTicket.revision); if (updateResult.changes !== 1) throw conflictError(); const event = ticketEventStatement(database); addEvent(event, id, req.user.id, 'internal_note_added', '', '', '', '管理员添加内部备注'); }); res.status(201).json(ticketResponse(getTicket(id), true, req.query)); } catch (e) { console.error('Ticket internal message error:', e.message); const conflict = conflictResponse(e); res.status(conflict ? conflict.status : 500).json(conflict ? conflict.body : { error: '添加内部备注失败' }); } }); router.put('/:id/status', authMiddleware, adminOnly, (req, res) => { const id = idParam(req.params.id); const ticket = id ? getTicket(id) : null; if (!ticket) return res.status(404).json({ error: '工单不存在' }); const status = text(req.body && req.body.status, 20); try { const result = db.transaction(() => { const database = db.getDb(); const freshTicket = database.prepare('SELECT * FROM tickets WHERE id = ?').get(id); if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError(); return updateStatus(freshTicket, status, req.user.id, ticketEventStatement(database), database); }); if (result.error) return res.status(result.error === '不允许的状态流转' ? 409 : 400).json({ error: result.error }); res.json({ message: '状态已更新', ticket: getTicket(id) }); } catch (e) { console.error('Ticket status error:', e.message); const conflict = conflictResponse(e); res.status(conflict ? conflict.status : 500).json(conflict ? conflict.body : { error: '更新状态失败' }); } }); router.put('/:id/priority', authMiddleware, adminOnly, (req, res) => { const id = idParam(req.params.id); const ticket = id ? getTicket(id) : null; if (!ticket) return res.status(404).json({ error: '工单不存在' }); const priority = text(req.body && req.body.priority, 20); if (!PRIORITIES.has(priority)) return res.status(400).json({ error: '优先级不合法' }); try { db.transaction(() => { const database = db.getDb(); const freshTicket = database.prepare('SELECT * FROM tickets WHERE id = ?').get(id); if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError(); if (freshTicket.priority === priority) return; const updateResult = database.prepare("UPDATE tickets SET priority = ?, updated_at = datetime('now'), revision = revision + 1 WHERE id = ? AND revision = ?").run(priority, id, freshTicket.revision); if (updateResult.changes !== 1) throw conflictError(); addEvent(ticketEventStatement(database), id, req.user.id, 'priority_changed', 'priority', freshTicket.priority, priority); }); res.json({ message: '优先级已更新', ticket: getTicket(id) }); } catch (e) { console.error('Ticket priority error:', e.message); const conflict = conflictResponse(e); res.status(conflict ? conflict.status : 500).json(conflict ? conflict.body : { error: '更新优先级失败' }); } }); router.put('/:id/assignee', authMiddleware, adminOnly, (req, res) => { const id = idParam(req.params.id); const ticket = id ? getTicket(id) : null; if (!ticket) return res.status(404).json({ error: '工单不存在' }); const assigneeId = req.body && (req.body.assignee_id === null || req.body.assignee_id === '' ? null : idParam(String(req.body.assignee_id))); if (assigneeId !== null && !assigneeId) return res.status(400).json({ error: '负责人不合法' }); if (assigneeId !== null && !db.get("SELECT id FROM users WHERE id = ? AND role = 'admin'", [assigneeId])) return res.status(400).json({ error: '负责人必须是管理员' }); try { db.transaction(() => { const database = db.getDb(); const freshTicket = database.prepare('SELECT * FROM tickets WHERE id = ?').get(id); if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError(); if (freshTicket.assignee_id === assigneeId) return; if (assigneeId !== null && !database.prepare("SELECT id FROM users WHERE id = ? AND role = 'admin'").get(assigneeId)) { throw new Error('负责人必须是管理员'); } const updateResult = database.prepare("UPDATE tickets SET assignee_id = ?, updated_at = datetime('now'), revision = revision + 1 WHERE id = ? AND revision = ?").run(assigneeId, id, freshTicket.revision); if (updateResult.changes !== 1) throw conflictError(); addEvent(ticketEventStatement(database), id, req.user.id, 'assignee_changed', 'assignee_id', freshTicket.assignee_id, assigneeId); }); res.json({ message: '负责人已更新', ticket: getTicket(id) }); } catch (e) { console.error('Ticket assignee error:', e.message); const conflict = conflictResponse(e); const validation = e.message === '负责人必须是管理员' ? { status: 400, body: { error: e.message } } : null; const response = conflict || validation; res.status(response ? response.status : 500).json(response ? response.body : { error: '更新负责人失败' }); } }); router.post('/:id/close', authMiddleware, (req, res) => changeUserStatus(req, res, 'closed')); router.post('/:id/reopen', authMiddleware, (req, res) => changeUserStatus(req, res, 'processing')); function changeUserStatus(req, res, status) { const id = idParam(req.params.id); const ticket = id ? getTicket(id) : null; if (!ticket) return res.status(404).json({ error: '工单不存在' }); if (!currentUserRole(req.user.id)) return res.status(401).json({ error: '登录已失效' }); if (ticket.requester_id !== req.user.id) return res.status(403).json({ error: '无权限操作该工单' }); try { db.transaction(() => { const database = db.getDb(); if (!database.prepare('SELECT id FROM users WHERE id = ?').get(req.user.id)) throw new Error('登录已失效'); const freshTicket = getTicketRow(database, id); if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError(); if ((status === 'closed' && freshTicket.status === 'closed') || (status === 'processing' && !['resolved', 'closed'].includes(freshTicket.status))) { throw new Error('当前状态不支持此操作'); } if (freshTicket.requester_id !== req.user.id) throw new Error('无权限操作该工单'); const result = updateStatus(freshTicket, status, req.user.id, ticketEventStatement(database), database); if (result.error) throw new Error(result.error); }); res.json({ message: status === 'closed' ? '工单已关闭' : '工单已重新打开', ticket: getTicket(id) }); } catch (e) { const conflict = conflictResponse(e); const response = conflict || (['当前状态不支持此操作', '不允许的状态流转'].includes(e.message) ? { status: 409, body: { error: e.message } } : null) || (['登录已失效', '无权限操作该工单', '工单不存在'].includes(e.message) ? { status: e.message === '登录已失效' ? 401 : e.message === '无权限操作该工单' ? 403 : 404, body: { error: e.message } } : null); res.status(response ? response.status : 500).json(response ? response.body : { error: '更新工单失败' }); } } module.exports = router;