-
{ticket.ticket_no || `工单 #${ticket.id}`}
{ticket.subject || ticket.title || '未命名工单'}
{categoryLabel(ticket.category)}{ticket.priority === 'urgent' ? '紧急' : ticket.priority === 'high' ? '重要' : ticket.priority === 'low' ? '低' : '一般'}
+
{ticket.ticket_no || `工单 #${ticket.id}`}
{ticket.subject || ticket.title || '未命名工单'}
{categoryLabel(ticket.category)}{priorityLabel(ticket.priority)}
问题描述
{ticket.description}
{sourceUrl && 发现页面{sourceUrl}
}
沟通记录
{messages.length} 条公开回复{messages.length === 0 ? : {messages.map((message) => {message.author_username || message.author_name || '用户'}
{message.content || message.body}
)}
}
{events.length > 0 &&
处理记录
{events.map((event) => - {eventLabel(event)}
)}
}
diff --git a/frontend/src/pages/Tickets.jsx b/frontend/src/pages/Tickets.jsx
index b3d5ba1..b1ee239 100644
--- a/frontend/src/pages/Tickets.jsx
+++ b/frontend/src/pages/Tickets.jsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import * as ticketsApi from '../api/tickets.js';
import { me } from '../api/auth.js';
@@ -15,13 +15,19 @@ const CATEGORY = { forum_bug: '论坛 Bug', site_bug: '站内 Bug', feature: '
export function statusInfo(status) { return STATUS[status] || { label: status || '未知状态', icon: 'help', tone: 'neutral' }; }
export function categoryLabel(category) { return CATEGORY[category] || category || '其他问题'; }
+export function priorityLabel(priority) {
+ return { low: '低', normal: '普通', high: '高', urgent: '紧急' }[priority] || '普通';
+}
/** 工单来源只允许 http(s) 或本站相对路径,避免把用户可控值直接作为危险链接。 */
export function safeSourceUrl(value) {
- const raw = String(value || '').trim();
- if (!raw || raw.length > 1000) return '';
- if (raw.startsWith('/') && !raw.startsWith('//')) return raw;
+ const valueText = String(value ?? '');
+ if (!valueText || valueText.length > 1000) return '';
+ if (/[\u0000-\u001f\u007f-\u009f]/.test(valueText) || valueText.includes('\\')) return '';
+ const raw = valueText.trim();
+ if (!raw || raw.startsWith('//')) return '';
+ if (raw.startsWith('/')) return raw;
try {
- const parsed = new URL(raw, window.location.origin);
+ const parsed = new URL(raw);
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : '';
} catch { return ''; }
}
@@ -51,15 +57,28 @@ export default function Tickets() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [status, setStatus] = useState('');
+ const [page, setPage] = useState(1);
+ const [total, setTotal] = useState(0);
+ const [totalPages, setTotalPages] = useState(1);
+ const listRequestRef = useRef(0);
const load = useCallback(async () => {
+ const requestId = ++listRequestRef.current;
setLoading(true); setError('');
try {
- const data = await ticketsApi.listTickets({ page: 1, pageSize: 20, status });
- setTickets(data.tickets || []);
- } catch (e) { setError(e.message || '工单加载失败'); }
- finally { setLoading(false); }
- }, [status]);
+ const data = await ticketsApi.listTickets({ page, pageSize: 20, status });
+ if (requestId !== listRequestRef.current) return;
+ const normalized = ticketsApi.normalizeTicketListResponse(data, 20);
+ setTickets(normalized.tickets);
+ setTotal(normalized.total);
+ setTotalPages(normalized.totalPages);
+ } catch (e) {
+ if (requestId !== listRequestRef.current) return;
+ setError(e.message || '工单加载失败');
+ } finally {
+ if (requestId === listRequestRef.current) setLoading(false);
+ }
+ }, [page, status]);
useEffect(() => {
if (!getToken()) { setLoading(false); return; }
@@ -67,6 +86,8 @@ export default function Tickets() {
}, []);
useEffect(() => { if (user) load(); }, [user, load]);
+ const updateStatus = (value) => { setStatus(value); setPage(1); };
+
if (!getToken() || (!user && !loading)) return
;
return
@@ -76,7 +97,7 @@ export default function Tickets() {
-
@@ -92,5 +113,10 @@ export default function Tickets() {
)}
}
+ {!loading && !error && totalPages > 1 &&
}
;
}
diff --git a/routes/tickets.js b/routes/tickets.js
index 0bc1d0d..82e4240 100644
--- a/routes/tickets.js
+++ b/routes/tickets.js
@@ -1,9 +1,21 @@
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']);
@@ -29,7 +41,7 @@ function currentUserRole(userId) {
function validateSourceUrl(value) {
if (value === undefined || value === null || value === '') return { value: '' };
if (typeof value !== 'string') return { error: '来源地址不合法' };
- if (value.length > 1000 || /[\u0000-\u001f\u007f]/.test(value)) 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 };
@@ -52,6 +64,24 @@ function pageParams(query) {
};
}
+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;
@@ -74,7 +104,7 @@ function addEvent(insertEvent, ticketId, actorId, type, field = '', oldValue = '
insertEvent.run(ticketId, actorId || null, type, field, String(oldValue ?? ''), String(newValue ?? ''), detail);
}
-function updateStatus(ticket, status, actorId, insertEvent) {
+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)) {
@@ -82,33 +112,68 @@ function updateStatus(ticket, status, actorId, insertEvent) {
}
const now = "datetime('now')";
const values = [status];
- let sql = `UPDATE tickets SET status = ?, updated_at = ${now}`;
+ 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 = ?';
- values.push(ticket.id);
- db.getDb().prepare(sql).run(...values);
- addEvent(insertEvent, ticket.id, actorId, 'status_changed', 'status', ticket.status, status);
+ 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) {
+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 = ? ${isAdmin ? '' : 'AND tm.is_internal = 0'}
- ORDER BY tm.created_at ASC, tm.id ASC`, [ticket.id]);
- const response = { ticket, messages };
+ 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`, [ticket.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);
@@ -176,7 +241,7 @@ router.get('/', authMiddleware, (req, res) => {
res.json({ tickets: list, list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
});
-router.post('/', authMiddleware, (req, res) => {
+router.post('/', authMiddleware, createTicketUserLimiter, createTicketIpLimiter, (req, res) => {
const body = req.body || {};
const subject = text(body.subject, 120);
const description = text(body.description, 20000);
@@ -201,8 +266,8 @@ router.post('/', authMiddleware, (req, res) => {
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)
- VALUES ('PENDING-' || hex(randomblob(8)), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
+ (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')}`;
@@ -219,26 +284,36 @@ router.post('/', authMiddleware, (req, res) => {
}
});
-router.get('/:id/events', authMiddleware, adminOnly, (req, res) => {
+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: '工单不存在' });
- res.json(db.all(`SELECT e.*, u.username AS actor_name, u.nickname AS actor_nickname
+ 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`, [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, (req, res) => {
+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'));
+ res.json(ticketResponse(ticket, role === 'admin', req.query));
});
-router.post('/:id/messages', authMiddleware, (req, res) => {
+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: '工单不存在' });
@@ -251,25 +326,35 @@ router.post('/:id/messages', authMiddleware, (req, res) => {
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 = ticket.status;
- if (role !== 'admin' && ticket.status === 'waiting') nextStatus = 'processing';
- database.prepare(`UPDATE tickets SET updated_at = datetime('now'), last_reply_at = datetime('now'),
+ 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 = ? WHERE id = ?`).run(role, nextStatus, id);
+ 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 !== ticket.status) addEvent(event, id, req.user.id, 'status_changed', 'status', ticket.status, nextStatus, '用户回复后自动进入处理中');
+ 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'));
+ res.status(201).json(ticketResponse(getTicket(id), role === 'admin', req.query));
} catch (e) {
console.error('Ticket message error:', e.message);
- res.status(500).json({ error: '回复工单失败' });
+ 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, (req, res) => {
+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);
@@ -277,16 +362,19 @@ router.post('/:id/internal-messages', authMiddleware, adminOnly, (req, res) => {
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);
- database.prepare("UPDATE tickets SET updated_at = datetime('now') WHERE id = ?").run(id);
- const event = database.prepare(`INSERT INTO ticket_events
- (ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
+ 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));
+ res.status(201).json(ticketResponse(getTicket(id), true, req.query));
} catch (e) {
console.error('Ticket internal message error:', e.message);
- res.status(500).json({ error: '添加内部备注失败' });
+ const conflict = conflictResponse(e);
+ res.status(conflict ? conflict.status : 500).json(conflict ? conflict.body : { error: '添加内部备注失败' });
}
});
@@ -298,13 +386,17 @@ router.put('/:id/status', authMiddleware, adminOnly, (req, res) => {
try {
const result = db.transaction(() => {
const database = db.getDb();
- const event = database.prepare(`INSERT INTO ticket_events
- (ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
- return updateStatus(ticket, status, req.user.id, event);
+ 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); res.status(500).json({ error: '更新状态失败' }); }
+ } 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) => {
@@ -316,13 +408,19 @@ router.put('/:id/priority', authMiddleware, adminOnly, (req, res) => {
try {
db.transaction(() => {
const database = db.getDb();
- database.prepare("UPDATE tickets SET priority = ?, updated_at = datetime('now') WHERE id = ?").run(priority, id);
- const event = database.prepare(`INSERT INTO ticket_events
- (ticket_id, actor_id, event_type, field_name, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)`);
- addEvent(event, id, req.user.id, 'priority_changed', 'priority', ticket.priority, priority);
+ 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); res.status(500).json({ error: '更新优先级失败' }); }
+ } 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) => {
@@ -335,13 +433,24 @@ router.put('/:id/assignee', authMiddleware, adminOnly, (req, res) => {
try {
db.transaction(() => {
const database = db.getDb();
- database.prepare("UPDATE tickets SET assignee_id = ?, updated_at = datetime('now') WHERE id = ?").run(assigneeId, id);
- const event = database.prepare(`INSERT INTO ticket_events
- (ticket_id, actor_id, event_type, field_name, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)`);
- addEvent(event, id, req.user.id, 'assignee_changed', 'assignee_id', ticket.assignee_id, assigneeId);
+ 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); res.status(500).json({ error: '更新负责人失败' }); }
+ } 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'));
@@ -351,20 +460,31 @@ 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: '无权限操作该工单' });
- if ((status === 'closed' && ticket.status === 'closed') || (status === 'processing' && !['resolved', 'closed'].includes(ticket.status))) {
- return res.status(409).json({ error: '当前状态不支持此操作' });
- }
try {
db.transaction(() => {
const database = db.getDb();
- const event = database.prepare(`INSERT INTO ticket_events
- (ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
- const result = updateStatus(ticket, status, req.user.id, event);
+ 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) { res.status(e.message === '不允许的状态流转' ? 409 : 500).json({ error: e.message === '不允许的状态流转' ? e.message : '更新工单失败' }); }
+ } 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;