diff --git a/db.js b/db.js index b63369a..7034b12 100644 --- a/db.js +++ b/db.js @@ -274,10 +274,11 @@ function migrateSchema() { assignee_id INTEGER, created_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')), - last_reply_at DATETIME DEFAULT (datetime('now')), + last_reply_at DATETIME DEFAULT NULL, first_response_at DATETIME, resolved_at DATETIME, closed_at DATETIME, + revision INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL )`); @@ -305,6 +306,97 @@ function migrateSchema() { FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE, FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL )`); + try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)'); } catch {} + try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)'); } catch {} + try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)'); } catch {} + try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)'); } catch {} + try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)'); } catch {} + try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)'); } catch {} + } }, + // v17: 工单并发版本与回复时间修正;为已存在的部分工单表补齐字段 + { version: 17, up: () => { + db.exec(`CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_no TEXT NOT NULL UNIQUE, + requester_id INTEGER, + subject TEXT NOT NULL, + description TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'other', + priority TEXT NOT NULL DEFAULT 'normal', + status TEXT NOT NULL DEFAULT 'open', + source TEXT NOT NULL DEFAULT 'site', + source_url TEXT DEFAULT '', + source_type TEXT DEFAULT '', + source_id INTEGER DEFAULT 0, + browser_info TEXT DEFAULT '', + assignee_id INTEGER, + created_at DATETIME DEFAULT (datetime('now')), + updated_at DATETIME DEFAULT (datetime('now')), + last_reply_at DATETIME DEFAULT NULL, + first_response_at DATETIME, + resolved_at DATETIME, + closed_at DATETIME, + revision INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL + )`); + db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL, + author_id INTEGER, + content TEXT NOT NULL, + is_internal INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT (datetime('now')), + updated_at DATETIME DEFAULT (datetime('now')), + FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE, + FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL + )`); + db.exec(`CREATE TABLE IF NOT EXISTS ticket_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL, + actor_id INTEGER, + event_type TEXT NOT NULL, + field_name TEXT DEFAULT '', + old_value TEXT DEFAULT '', + new_value TEXT DEFAULT '', + detail TEXT DEFAULT '', + created_at DATETIME DEFAULT (datetime('now')), + FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE, + FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL + )`); + const ensureColumns = (table, columns) => { + const existing = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name)); + for (const [name, definition] of columns) { + if (!existing.has(name)) { + try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); } catch {} + } + } + }; + ensureColumns('tickets', [ + ['ticket_no', "TEXT NOT NULL DEFAULT ''"], ['requester_id', 'INTEGER'], + ['subject', "TEXT NOT NULL DEFAULT ''"], ['description', "TEXT NOT NULL DEFAULT ''"], + ['category', "TEXT NOT NULL DEFAULT 'other'"], ['priority', "TEXT NOT NULL DEFAULT 'normal'"], + ['status', "TEXT NOT NULL DEFAULT 'open'"], ['source', "TEXT NOT NULL DEFAULT 'site'"], + ['source_url', "TEXT DEFAULT ''"], ['source_type', "TEXT DEFAULT ''"], ['source_id', 'INTEGER DEFAULT 0'], + ['browser_info', "TEXT DEFAULT ''"], ['assignee_id', 'INTEGER'], + ['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'], + ['last_reply_at', 'DATETIME DEFAULT NULL'], ['first_response_at', 'DATETIME DEFAULT NULL'], + ['resolved_at', 'DATETIME DEFAULT NULL'], ['closed_at', 'DATETIME DEFAULT NULL'], + ['revision', 'INTEGER NOT NULL DEFAULT 0'], + ]); + ensureColumns('ticket_messages', [ + ['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['author_id', 'INTEGER'], + ['content', "TEXT NOT NULL DEFAULT ''"], ['is_internal', 'INTEGER NOT NULL DEFAULT 0'], + ['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'], + ]); + ensureColumns('ticket_events', [ + ['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['actor_id', 'INTEGER'], + ['event_type', "TEXT NOT NULL DEFAULT ''"], ['field_name', "TEXT DEFAULT ''"], + ['old_value', "TEXT DEFAULT ''"], ['new_value', "TEXT DEFAULT ''"], + ['detail', "TEXT DEFAULT ''"], ['created_at', 'DATETIME DEFAULT NULL'], + ]); + db.exec(`UPDATE tickets SET last_reply_at = NULL + WHERE NOT EXISTS (SELECT 1 FROM ticket_messages WHERE ticket_messages.ticket_id = tickets.id)`); db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)'); db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)'); db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)'); diff --git a/docs/ticket-system-roadmap.md b/docs/ticket-system-roadmap.md new file mode 100644 index 0000000..42e9c21 --- /dev/null +++ b/docs/ticket-system-roadmap.md @@ -0,0 +1,58 @@ +# RainWeb 工单系统后续完善计划 + +## 当前定位 + +RainWeb 当前已具备登录用户提交和跟踪、管理员队列处理、状态/优先级/负责人、公开回复、内部备注、基础事件时间线、筛选搜索和分页,定位为可用 MVP。 + +本计划只覆盖审查后确认的后续增强,不影响当前 Bug 修复批次。 + +## 阶段一:运营上线前(P0) + +目标:控制滥用风险,保护工单隐私,保证管理员团队可以稳定协作。 + +| 项目 | 主要内容 | 复杂度 | 依赖 | +| --- | --- | --- | --- | +| 工单反滥用 | 按 IP/用户限流、未关闭工单配额、重复提交幂等键、必要时验证码 | 中 | 现有 tickets、captcha | +| 私有附件 | 工单附件关联表、引用对象校验、私有下载鉴权、配额、孤儿清理、下载审计 | 中高 | upload、权限模型 | +| 通知闭环 | 创建确认、公开回复、状态/负责人变化通知;投递记录、失败重试和管理员告警 | 中 | email、异步任务 | +| 最小权限模型 | 客服、主管、只读审计角色;按队列/分类限制访问范围 | 中高 | 用户角色、工单分类 | +| 自动化测试 | 越权访问、内部备注隔离、状态流转、并发更新、限流、附件访问、迁移兼容 | 中 | 以上后端能力 | + +## 阶段二:处理效率(P1) + +目标:让工单处理可衡量、可搜索、可协作。 + +- SLA 策略:按优先级/分类设置首次响应和解决目标。 +- SLA 计时:支持工作时间、节假日,并在“等待用户”时暂停。 +- 超时处理:临界提醒、违约标记、自动升级和主管队列。 +- 全文搜索:搜索回复、用户、负责人、来源和日期;数据量增长后接入 SQLite FTS5。 +- 工单协作:快捷回复模板、标签、合并/关联工单、重复反馈识别。 +- 用户体验:通知偏好、未读数、满意度评价、前台完整分页。 +- 完整审计:操作者、IP、请求 ID、前后值、后台查询和导出。 + +## 阶段三:规模化与治理(P2) + +目标:支持长期运营、外部渠道和故障恢复。 + +- 邮件入站转工单、退信处理和邮件线程关联。 +- 附件真实 MIME/魔数检查、病毒扫描、图片重编码,必要时迁移对象存储。 +- 运营报表:积压、响应时长、解决时长、重开率、SLA 达成率和客服负载。 +- 健康检查、就绪检查、结构化日志、指标、告警和磁盘容量保护。 +- 数据保留、删除/匿名化、用户数据导出、备份加密、轮换、异地保存和恢复演练。 + +## 推荐实施顺序 + +1. 先完成阶段一的权限和附件边界,再接入通知,避免通知泄漏内部数据。 +2. 在限流、分页和审计基础上实现 SLA,避免统计建立在不完整事件数据上。 +3. 最后实现邮件入站、病毒扫描、对象存储和运营报表等高复杂度能力。 + +## 暂不纳入本批次 + +本批次只修复审查发现的代码 Bug:前端请求竞态、记录排序、分页/来源地址校验、状态选项和文案一致性,以及后端限流/响应放大、并发更新、用户存在性、时间字段、事件契约和迁移兼容。上述阶段功能不会在 Bug 修复批次中顺手实现。 + +## 参考资料 + +- SQLite FTS5:https://sqlite.org/fts5.html +- OWASP 文件上传安全:https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html +- OWASP 日志安全:https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html +- Google SRE 监控原则:https://sre.google/sre-book/monitoring/ diff --git a/frontend/src/admin/pages/TicketManage.jsx b/frontend/src/admin/pages/TicketManage.jsx index 398ada6..01181b2 100644 --- a/frontend/src/admin/pages/TicketManage.jsx +++ b/frontend/src/admin/pages/TicketManage.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; @@ -17,6 +17,7 @@ import RefreshIcon from '@mui/icons-material/Refresh'; import SendIcon from '@mui/icons-material/Send'; import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore'; import NavigateNextIcon from '@mui/icons-material/NavigateNext'; +import { normalizeTicketListResponse } from '../../api/tickets.js'; import { request } from '../../api/client.js'; import { showSnack } from '../snack.jsx'; @@ -28,6 +29,13 @@ const STATUS = { resolved: { label: '已解决', color: 'success' }, closed: { label: '已关闭', color: 'default' }, }; +const STATUS_TRANSITIONS = { + open: ['processing', 'closed'], + processing: ['waiting', 'resolved', 'closed'], + waiting: ['processing', 'closed'], + resolved: ['closed', 'processing'], + closed: ['processing'], +}; const PRIORITY = { low: { label: '低', color: 'default' }, normal: { label: '普通', color: 'info' }, @@ -80,32 +88,60 @@ export default function TicketManage() { const [internal, setInternal] = useState(''); const [assignees, setAssignees] = useState([]); const [saveFeedback, setSaveFeedback] = useState({ type: '', text: '' }); + const listRequestRef = useRef(0); + const statsRequestRef = useRef(0); + const detailRequestRef = useRef(0); + const selectedIdRef = useRef(null); const loadStats = useCallback(() => { - request('/tickets/admin/stats').then((data) => setStats(data || {})).catch(() => {}); + const requestId = ++statsRequestRef.current; + request('/tickets/admin/stats').then((data) => { + if (requestId === statsRequestRef.current) setStats(data || {}); + }).catch(() => {}); }, []); const loadList = useCallback(() => { + const requestId = ++listRequestRef.current; setLoading(true); setError(''); const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) }); Object.entries(filters).forEach(([key, value]) => { if (value) params.set(key, value); }); request(`/tickets/admin?${params.toString()}`) .then((data) => { - const rows = data.list || data.tickets || []; - setList(rows); setTotal(Number(data.total || 0)); - if (selectedId && !rows.some((row) => String(row.id) === String(selectedId))) setSelectedId(null); + if (requestId !== listRequestRef.current) return; + const normalized = normalizeTicketListResponse(data, PAGE_SIZE); + setList(normalized.tickets); setTotal(normalized.total); }) - .catch((e) => setError(e.message || '工单加载失败')) - .finally(() => setLoading(false)); - }, [filters, page, selectedId]); + .catch((e) => { + if (requestId === listRequestRef.current) setError(e.message || '工单加载失败'); + }) + .finally(() => { + if (requestId === listRequestRef.current) setLoading(false); + }); + }, [filters, page]); const loadDetail = useCallback((id) => { if (!id) return; + const changed = String(selectedIdRef.current) !== String(id); + selectedIdRef.current = id; + const requestId = ++detailRequestRef.current; setSelectedId(id); setDetailLoading(true); + setDetail(null); + if (changed) { + setReply(''); setInternal(''); + setSaveFeedback({ type: '', text: '' }); + } request(`/tickets/${encodeURIComponent(id)}`) - .then((data) => setDetail(data.ticket ? data : { ticket: data, messages: [], events: [] })) - .catch((e) => showSnack(e.message || '详情加载失败', 'error')) - .finally(() => setDetailLoading(false)); + .then((data) => { + if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) { + setDetail(data.ticket ? data : { ticket: data, messages: [], events: [] }); + } + }) + .catch((e) => { + if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) showSnack(e.message || '详情加载失败', 'error'); + }) + .finally(() => { + if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) setDetailLoading(false); + }); }, []); useEffect(() => { loadList(); }, [loadList]); @@ -135,33 +171,60 @@ export default function TicketManage() { const updateFilter = (key, value) => { setPage(1); setFilters((old) => ({ ...old, [key]: value })); }; const refresh = () => { loadList(); loadStats(); if (selectedId) loadDetail(selectedId); }; - const updateTicket = async (path, body, message) => { + const updateTicket = async (path, body, message, ticketId) => { + if (!ticketId || String(selectedIdRef.current) !== String(ticketId)) return; setSaving(true); setSaveFeedback({ type: 'saving', text: '正在保存…' }); try { await request(path, { method: 'PUT', body }); + if (String(selectedIdRef.current) !== String(ticketId)) return; setSaveFeedback({ type: 'success', text: '已保存' }); - showSnack(message); loadDetail(selectedId); loadList(); loadStats(); + showSnack(message); loadDetail(ticketId); loadList(); loadStats(); } catch (e) { - setSaveFeedback({ type: 'error', text: e.message || '保存失败,请重试' }); - showSnack(e.message || '保存失败', 'error'); + if (String(selectedIdRef.current) === String(ticketId)) { + setSaveFeedback({ type: 'error', text: e.message || '保存失败,请重试' }); + showSnack(e.message || '保存失败', 'error'); + } } finally { setSaving(false); } }; - const sendMessage = async (internalMessage) => { + const sendMessage = async (internalMessage, ticketId) => { + if (!ticketId || String(selectedIdRef.current) !== String(ticketId)) return; const content = (internalMessage ? internal : reply).trim(); if (!content) return; setSaving(true); try { - await request(`/tickets/${selectedId}/${internalMessage ? 'internal-messages' : 'messages'}`, { method: 'POST', body: { content } }); + await request(`/tickets/${ticketId}/${internalMessage ? 'internal-messages' : 'messages'}`, { method: 'POST', body: { content } }); + if (String(selectedIdRef.current) !== String(ticketId)) return; if (internalMessage) setInternal(''); else setReply(''); showSnack(internalMessage ? '内部备注已添加' : '公开回复已发送'); - loadDetail(selectedId); loadList(); loadStats(); - } catch (e) { showSnack(e.message || '发送失败', 'error'); } + loadDetail(ticketId); loadList(); loadStats(); + } catch (e) { + if (String(selectedIdRef.current) === String(ticketId)) showSnack(e.message || '发送失败', 'error'); + } finally { setSaving(false); } }; const messages = detail?.messages || []; const events = detail?.events || []; + const timeline = useMemo(() => [ + ...messages.map((item) => ({ type: 'message', item })), + ...events.map((item) => ({ type: 'event', item })), + ].sort((a, b) => { + const timeA = Date.parse(String(a.item.created_at || '').replace(' ', 'T')); + const timeB = Date.parse(String(b.item.created_at || '').replace(' ', 'T')); + const timeDiff = (Number.isNaN(timeA) ? Number.MAX_SAFE_INTEGER : timeA) + - (Number.isNaN(timeB) ? Number.MAX_SAFE_INTEGER : timeB); + if (timeDiff) return timeDiff; + const createdDiff = String(a.item.created_at || '').localeCompare(String(b.item.created_at || '')); + if (createdDiff) return createdDiff; + const idA = Number(a.item.id); + const idB = Number(b.item.id); + if (Number.isFinite(idA) && Number.isFinite(idB) && idA !== idB) return idA - idB; + return a.type === b.type ? 0 : a.type === 'event' ? 1 : -1; + }), [messages, events]); + const currentStatus = selectedTicket?.status || 'open'; + const statusOptions = [currentStatus, ...(STATUS_TRANSITIONS[currentStatus] || [])] + .filter((value, index, values) => STATUS[value] && values.indexOf(value) === index); const statItems = useMemo(() => [ ['全部', stats.total || total, null], ['待处理', stats.open || 0, 'warning'], ['处理中', stats.processing || 0, 'info'], ['等待用户', stats.waiting || 0, 'secondary'], @@ -179,7 +242,7 @@ export default function TicketManage() { {statItems.map(([label, value, tone]) => )} - { e.preventDefault(); setPage(1); loadList(); }}> + { e.preventDefault(); if (page === 1) loadList(); else setPage(1); }}> updateFilter('q', e.target.value)} /> updateFilter('status', e.target.value)}>全部状态{Object.entries(STATUS).map(([key, item]) => {item.label})} @@ -213,11 +276,11 @@ export default function TicketManage() { {!selectedId ? 选择一个工单开始处理工单详情、回复和状态操作会显示在这里 : detailLoading ? : selectedTicket ? ( {selectedTicket.subject || selectedTicket.title}{selectedTicket.ticket_no || `#${selectedTicket.id}`} · {selectedTicket.requester_name || selectedTicket.requester_username || '未知用户'} · 创建于 {fmtTime(selectedTicket.created_at)} - updateTicket(`/tickets/${selectedId}/status`, { status: e.target.value }, '状态已更新')}>{Object.entries(STATUS).map(([key, item]) => {item.label})} updateTicket(`/tickets/${selectedId}/priority`, { priority: e.target.value }, '优先级已更新')}>{Object.entries(PRIORITY).map(([key, item]) => {item.label})} { const person = normalizedAssignees.find((user) => user.id === String(value)); return {person ? person.displayName : '未分配'}; } }} onChange={(e) => updateTicket(`/tickets/${selectedId}/assignee`, { assignee_id: e.target.value ? Number(e.target.value) || e.target.value : null }, '负责人已更新')}>未分配{normalizedAssignees.map((user) => {user.displayName})}{saveFeedback.text && {saveFeedback.text}} + updateTicket(`/tickets/${selectedTicket.id}/status`, { status: e.target.value }, '状态已更新', selectedTicket.id)}>{statusOptions.map((key) => {STATUS[key].label})} updateTicket(`/tickets/${selectedTicket.id}/priority`, { priority: e.target.value }, '优先级已更新', selectedTicket.id)}>{Object.entries(PRIORITY).map(([key, item]) => {item.label})} { const person = normalizedAssignees.find((user) => user.id === String(value)); return {person ? person.displayName : '未分配'}; } }} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/assignee`, { assignee_id: e.target.value ? Number(e.target.value) || e.target.value : null }, '负责人已更新', selectedTicket.id)}>未分配{normalizedAssignees.map((user) => {user.displayName})}{saveFeedback.text && {saveFeedback.text}} 问题描述{selectedTicket.description || '暂无描述'}{selectedTicket.source_url && 来源:{selectedTicket.source_url}} - 处理记录{messages.map((message) => {String(message.author_name || message.author_username || '管')[0]}{message.author_name || message.author_username || '管理员'}{message.is_internal ? : }{fmtTime(message.created_at)}{message.content})}{events.map((event) => {fmtTime(event.created_at)} · {event.detail || `${event.field_name || '工单'}已更新`})} + 处理记录{timeline.map((entry) => { if (entry.type === 'message') { const message = entry.item; return {String(message.author_name || message.author_username || '管')[0]}{message.author_name || message.author_username || '管理员'}{message.is_internal ? : }{fmtTime(message.created_at)}{message.content}; } const event = entry.item; return {fmtTime(event.created_at)} · {event.detail || `${event.field_name || '工单'}已更新`}; })} - setReply(e.target.value)} /> setInternal(e.target.value)} /> + setReply(e.target.value)} /> setInternal(e.target.value)} /> ) : 工单详情不存在或加载失败。} diff --git a/frontend/src/api/tickets.js b/frontend/src/api/tickets.js index ce04261..d1ea427 100644 --- a/frontend/src/api/tickets.js +++ b/frontend/src/api/tickets.js @@ -1,5 +1,21 @@ import { request } from './client.js'; +export function normalizeTicketListResponse(data, fallbackPageSize = 20) { + const payload = Array.isArray(data) ? { tickets: data } : (data && typeof data === 'object' ? data : {}); + const tickets = Array.isArray(payload.tickets) + ? payload.tickets + : (Array.isArray(payload.list) ? payload.list : (Array.isArray(payload.items) ? payload.items : [])); + const pageSizeValue = Number(payload.pageSize ?? payload.page_size); + const pageSize = Number.isInteger(pageSizeValue) && pageSizeValue > 0 ? pageSizeValue : fallbackPageSize; + const totalValue = Number(payload.total ?? payload.count); + const total = Number.isFinite(totalValue) && totalValue >= 0 ? totalValue : tickets.length; + const totalPagesValue = Number(payload.totalPages ?? payload.total_pages ?? payload.pages); + const totalPages = Number.isInteger(totalPagesValue) && totalPagesValue > 0 + ? totalPagesValue + : Math.max(1, Math.ceil(total / pageSize)); + return { tickets, total, totalPages }; +} + export function listTickets(params = {}) { const query = new URLSearchParams(); if (params.page) query.set('page', String(params.page)); diff --git a/frontend/src/pages/TicketCreate.jsx b/frontend/src/pages/TicketCreate.jsx index f437566..fb5e083 100644 --- a/frontend/src/pages/TicketCreate.jsx +++ b/frontend/src/pages/TicketCreate.jsx @@ -5,7 +5,7 @@ import { getToken } from '../api/client.js'; import { safeSourceUrl } from './Tickets.jsx'; const categories = [['forum_bug', '论坛 Bug'], ['site_bug', '站内 Bug'], ['feature', '功能建议'], ['account', '账号问题'], ['other', '其他问题']]; -const priorities = [['low', '普通'], ['normal', '一般'], ['high', '重要'], ['urgent', '紧急']]; +const priorities = [['low', '低'], ['normal', '普通'], ['high', '高'], ['urgent', '紧急']]; export default function TicketCreate() { const navigate = useNavigate(); @@ -26,12 +26,19 @@ export default function TicketCreate() { if (description.length < 10) { setError('请详细描述问题(至少 10 个字)'); return; } setError(''); setBusy(true); try { + const sourceValue = String(form.source_url ?? ''); + const sourceUrl = safeSourceUrl(sourceValue); + const hasInvalidSource = sourceValue.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(sourceValue) || sourceValue.includes('\\'); + if (hasInvalidSource && !sourceUrl) { + setError('来源地址不合法,请填写站内路径或 http/https 地址'); + return; + } const data = await ticketsApi.createTicket({ ...form, subject, description, source: form.category === 'forum_bug' ? 'forum' : 'site', - source_url: safeSourceUrl(form.source_url), + source_url: sourceUrl, browser_info: [navigator.userAgent, `${window.innerWidth}x${window.innerHeight}`].join(' | ').slice(0, 1000), }); if (!data.ticket || !data.ticket.id) throw new Error('工单创建成功但未返回编号'); diff --git a/frontend/src/pages/TicketDetail.jsx b/frontend/src/pages/TicketDetail.jsx index 8c6ed52..a2eda74 100644 --- a/frontend/src/pages/TicketDetail.jsx +++ b/frontend/src/pages/TicketDetail.jsx @@ -1,9 +1,9 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import * as ticketsApi from '../api/tickets.js'; import { me } from '../api/auth.js'; import { getToken } from '../api/client.js'; -import { categoryLabel, formatTime, safeSourceUrl, statusInfo } from './Tickets.jsx'; +import { categoryLabel, formatTime, priorityLabel, safeSourceUrl, statusInfo } from './Tickets.jsx'; const STEPS = ['open', 'processing', 'waiting', 'resolved', 'closed']; const PUBLIC_EVENTS = new Set(['ticket_created', 'message_added', 'status_changed', 'ticket_closed', 'ticket_reopened']); @@ -43,7 +43,22 @@ export default function TicketDetail() { const [user, setUser] = useState(null); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [reply, setReply] = useState(''); const [busy, setBusy] = useState(false); - const load = useCallback(async () => { setLoading(true); setError(''); try { setData(await ticketsApi.getTicket(id)); } catch (e) { setError(e.message || '工单加载失败'); } finally { setLoading(false); } }, [id]); + const detailRequestRef = useRef(0); + const activeIdRef = useRef(id); + activeIdRef.current = id; + const load = useCallback(async () => { + if (String(activeIdRef.current) !== String(id)) return; + const requestId = ++detailRequestRef.current; + setLoading(true); setError(''); setData(null); + try { + const nextData = await ticketsApi.getTicket(id); + if (requestId === detailRequestRef.current) setData(nextData); + } catch (e) { + if (requestId === detailRequestRef.current) setError(e.message || '工单加载失败'); + } finally { + if (requestId === detailRequestRef.current) setLoading(false); + } + }, [id]); useEffect(() => { if (!getToken()) { setLoading(false); return; } me().then(setUser).catch(() => { setUser(null); setLoading(false); }); }, []); useEffect(() => { if (user) load(); }, [user, load]); @@ -53,15 +68,40 @@ export default function TicketDetail() { const { ticket } = data; const messages = Array.isArray(data.messages) ? data.messages : []; const events = (Array.isArray(data.events) ? data.events : []).filter((event) => PUBLIC_EVENTS.has(event.event_type)); - const sourceUrl = safeSourceUrl(ticket.source_url); const info = statusInfo(ticket.status); const canReply = ticket.status !== 'closed'; - const runAction = async (action, confirmation) => { if (!window.confirm(confirmation)) return; setBusy(true); setError(''); try { await action(id); await load(); } catch (e) { setError(e.message || '操作失败'); } finally { setBusy(false); } }; - const sendReply = async (e) => { e.preventDefault(); const content = reply.trim(); if (!content) { setError('回复内容不能为空'); return; } setBusy(true); setError(''); try { await ticketsApi.addMessage(id, content); setReply(''); await load(); } catch (e) { setError(e.message || '回复失败'); } finally { setBusy(false); } }; + const sourceUrl = safeSourceUrl(ticket.source_url); const canReply = ticket.status !== 'closed'; + const runAction = async (action, confirmation) => { + if (!window.confirm(confirmation)) return; + const actionId = id; + setBusy(true); setError(''); + try { + await action(actionId); + if (String(activeIdRef.current) === String(actionId)) await load(); + } catch (e) { + if (String(activeIdRef.current) === String(actionId)) setError(e.message || '操作失败'); + } finally { setBusy(false); } + }; + const sendReply = async (e) => { + e.preventDefault(); + const content = reply.trim(); + if (!content) { setError('回复内容不能为空'); return; } + const actionId = id; + setBusy(true); setError(''); + try { + await ticketsApi.addMessage(actionId, content); + if (String(activeIdRef.current) === String(actionId)) { + setReply(''); + await load(); + } + } catch (e) { + if (String(activeIdRef.current) === String(actionId)) setError(e.message || '回复失败'); + } finally { setBusy(false); } + }; return
← 返回工单列表
-
{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) =>
  1. {message.author_username || message.author_name || '用户'}

    {message.content || message.body}

  2. )}
}
{events.length > 0 &&

处理记录

    {events.map((event) =>
  1. {eventLabel(event)}
  2. )}
} 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() {
- updateStatus(e.target.value)} style={{ minHeight: 40, padding: '0 10px', borderRadius: 8 }}>
@@ -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;