修复工单系统竞态与接口安全问题

This commit is contained in:
2026-09-05 16:14:43 +08:00
parent a992bd841d
commit af79e5864f
8 changed files with 521 additions and 99 deletions
+93 -1
View File
@@ -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)');
+58
View File
@@ -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 FTS5https://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/
+86 -23
View File
@@ -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]) => <StatCard key={label} label={label} value={value} tone={tone} />)}
</Stack>
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }} component="form" onSubmit={(e) => { e.preventDefault(); setPage(1); loadList(); }}>
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }} component="form" onSubmit={(e) => { e.preventDefault(); if (page === 1) loadList(); else setPage(1); }}>
<Box className="ticket-filter-grid">
<TextField className="ticket-filter-search" fullWidth size="small" label="搜索工单" placeholder="编号、标题或内容" value={filters.q} onChange={(e) => updateFilter('q', e.target.value)} />
<TextField fullWidth select size="small" label="状态" value={filters.status} onChange={(e) => updateFilter('status', e.target.value)}><MenuItem value="">全部状态</MenuItem>{Object.entries(STATUS).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField>
@@ -213,11 +276,11 @@ export default function TicketManage() {
{!selectedId ? <Box sx={{ py: 10, textAlign: 'center' }}><Typography variant="h6" color="text.secondary">选择一个工单开始处理</Typography><Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>工单详情回复和状态操作会显示在这里</Typography></Box> : detailLoading ? <Box role="status" aria-label="正在加载工单详情" sx={{ py: 10, textAlign: 'center' }}><CircularProgress /></Box> : selectedTicket ? (
<Stack spacing={2}>
<Box><Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap><Typography variant="h6" component="h2" sx={{ overflowWrap: 'anywhere' }}>{selectedTicket.subject || selectedTicket.title}</Typography><StatusChip value={selectedTicket.status} /><PriorityChip value={selectedTicket.priority} /></Stack><Typography variant="caption" color="text.secondary">{selectedTicket.ticket_no || `#${selectedTicket.id}`} · {selectedTicket.requester_name || selectedTicket.requester_username || '未知用户'} · 创建于 {fmtTime(selectedTicket.created_at)}</Typography></Box>
<Box><Grid container spacing={1.25}><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="状态" helperText="管理员可更新处理阶段" value={selectedTicket.status || 'open'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedId}/status`, { status: e.target.value }, '状态已更新')}>{Object.entries(STATUS).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="优先级" helperText="用于安排处理顺序" value={selectedTicket.priority || 'normal'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedId}/priority`, { priority: e.target.value }, '优先级已更新')}>{Object.entries(PRIORITY).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="负责人" helperText="可选择管理员或设为未分配" value={selectedTicket.assignee_id == null ? '' : String(selectedTicket.assignee_id)} disabled={saving} SelectProps={{ renderValue: (value) => { const person = normalizedAssignees.find((user) => user.id === String(value)); return <Box component="span" className="ticket-select-value" title={person ? person.displayName : '未分配'}>{person ? person.displayName : '未分配'}</Box>; } }} onChange={(e) => updateTicket(`/tickets/${selectedId}/assignee`, { assignee_id: e.target.value ? Number(e.target.value) || e.target.value : null }, '负责人已更新')}><MenuItem value="">未分配</MenuItem>{normalizedAssignees.map((user) => <MenuItem key={user.id} value={user.id} sx={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>{user.displayName}</MenuItem>)}</TextField></Grid></Grid>{saveFeedback.text && <Typography className={`ticket-save-feedback ticket-save-${saveFeedback.type}`} variant="caption" role={saveFeedback.type === 'error' ? 'alert' : 'status'} sx={{ display: 'block', mt: 0.75 }}>{saveFeedback.text}</Typography>}</Box>
<Box><Grid container spacing={1.25}><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="状态" helperText="管理员可更新处理阶段" value={selectedTicket.status || 'open'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/status`, { status: e.target.value }, '状态已更新', selectedTicket.id)}>{statusOptions.map((key) => <MenuItem key={key} value={key}>{STATUS[key].label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="优先级" helperText="用于安排处理顺序" value={selectedTicket.priority || 'normal'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/priority`, { priority: e.target.value }, '优先级已更新', selectedTicket.id)}>{Object.entries(PRIORITY).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="负责人" helperText="可选择管理员或设为未分配" value={selectedTicket.assignee_id == null ? '' : String(selectedTicket.assignee_id)} disabled={saving} SelectProps={{ renderValue: (value) => { const person = normalizedAssignees.find((user) => user.id === String(value)); return <Box component="span" className="ticket-select-value" title={person ? person.displayName : '未分配'}>{person ? person.displayName : '未分配'}</Box>; } }} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/assignee`, { assignee_id: e.target.value ? Number(e.target.value) || e.target.value : null }, '负责人已更新', selectedTicket.id)}><MenuItem value="">未分配</MenuItem>{normalizedAssignees.map((user) => <MenuItem key={user.id} value={user.id} sx={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>{user.displayName}</MenuItem>)}</TextField></Grid></Grid>{saveFeedback.text && <Typography className={`ticket-save-feedback ticket-save-${saveFeedback.type}`} variant="caption" role={saveFeedback.type === 'error' ? 'alert' : 'status'} sx={{ display: 'block', mt: 0.75 }}>{saveFeedback.text}</Typography>}</Box>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'action.hover' }}><Typography variant="subtitle2" gutterBottom>问题描述</Typography><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{selectedTicket.description || '暂无描述'}</Typography>{selectedTicket.source_url && <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, overflowWrap: 'anywhere' }}>来源{selectedTicket.source_url}</Typography>}</Paper>
<Box><Typography variant="subtitle2" sx={{ mb: 1 }}>处理记录</Typography><Stack component="ol" spacing={1.25} sx={{ m: 0, pl: 2.5 }}>{messages.map((message) => <Box component="li" key={`m-${message.id}`}><Paper variant="outlined" sx={{ p: 1.25 }}><Stack direction="row" spacing={1} alignItems="center"><Avatar sx={{ width: 26, height: 26, fontSize: 12 }}>{String(message.author_name || message.author_username || '管')[0]}</Avatar><Typography variant="body2" fontWeight={600}>{message.author_name || message.author_username || '管理员'}</Typography>{message.is_internal ? <Chip size="small" label="内部备注" color="warning" /> : <Chip size="small" label="公开回复" variant="outlined" />}<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>{fmtTime(message.created_at)}</Typography></Stack><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', mt: 1 }}>{message.content}</Typography></Paper></Box>)}{events.map((event) => <Box component="li" key={`e-${event.id}`}><Typography variant="caption" color="text.secondary">{fmtTime(event.created_at)} · {event.detail || `${event.field_name || '工单'}已更新`}</Typography></Box>)}</Stack></Box>
<Box><Typography variant="subtitle2" sx={{ mb: 1 }}>处理记录</Typography><Stack component="ol" spacing={1.25} sx={{ m: 0, pl: 2.5 }}>{timeline.map((entry) => { if (entry.type === 'message') { const message = entry.item; return <Box component="li" key={`m-${message.id}`}><Paper variant="outlined" sx={{ p: 1.25 }}><Stack direction="row" spacing={1} alignItems="center"><Avatar sx={{ width: 26, height: 26, fontSize: 12 }}>{String(message.author_name || message.author_username || '管')[0]}</Avatar><Typography variant="body2" fontWeight={600}>{message.author_name || message.author_username || '管理员'}</Typography>{message.is_internal ? <Chip size="small" label="内部备注" color="warning" /> : <Chip size="small" label="公开回复" variant="outlined" />}<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>{fmtTime(message.created_at)}</Typography></Stack><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', mt: 1 }}>{message.content}</Typography></Paper></Box>; } const event = entry.item; return <Box component="li" key={`e-${event.id}`}><Typography variant="caption" color="text.secondary">{fmtTime(event.created_at)} · {event.detail || `${event.field_name || '工单'}已更新`}</Typography></Box>; })}</Stack></Box>
<Divider />
<Grid container spacing={1.5}><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="公开回复" placeholder="回复内容会发送给用户" value={reply} disabled={saving} onChange={(e) => setReply(e.target.value)} /><Button sx={{ mt: 1 }} variant="contained" startIcon={<SendIcon />} disabled={saving || !reply.trim()} onClick={() => sendMessage(false)}>发送公开回复</Button></Grid><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="内部备注" placeholder="仅管理员可见" value={internal} disabled={saving} onChange={(e) => setInternal(e.target.value)} /><Button sx={{ mt: 1 }} variant="outlined" color="warning" disabled={saving || !internal.trim()} onClick={() => sendMessage(true)}>添加内部备注</Button></Grid></Grid>
<Grid container spacing={1.5}><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="公开回复" placeholder="回复内容会发送给用户" value={reply} disabled={saving} onChange={(e) => setReply(e.target.value)} /><Button sx={{ mt: 1 }} variant="contained" startIcon={<SendIcon />} disabled={saving || !reply.trim()} onClick={() => sendMessage(false, selectedTicket.id)}>发送公开回复</Button></Grid><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="内部备注" placeholder="仅管理员可见" value={internal} disabled={saving} onChange={(e) => setInternal(e.target.value)} /><Button sx={{ mt: 1 }} variant="outlined" color="warning" disabled={saving || !internal.trim()} onClick={() => sendMessage(true, selectedTicket.id)}>添加内部备注</Button></Grid></Grid>
</Stack>
) : <Alert severity="error">工单详情不存在或加载失败</Alert>}
</Paper>
+16
View File
@@ -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));
+9 -2
View File
@@ -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('工单创建成功但未返回编号');
+47 -7
View File
@@ -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 <div className="ticket-detail-page">
<Link to="/tickets.html" className="btn btn-text btn-sm ticket-detail-back"> 返回工单列表</Link>
<div className="ticket-detail-layout">
<div className="ticket-detail-main">
<article className="card ticket-detail-header"><div className="ticket-detail-kicker">{ticket.ticket_no || `工单 #${ticket.id}`}</div><h1 className="ticket-detail-title">{ticket.subject || ticket.title || '未命名工单'}</h1><div className="ticket-detail-tags"><StatusChip status={ticket.status} /><span className="chip chip-static">{categoryLabel(ticket.category)}</span><span className="chip chip-static">{ticket.priority === 'urgent' ? '紧急' : ticket.priority === 'high' ? '重要' : ticket.priority === 'low' ? '低' : '一般'}</span></div><TicketProgress status={ticket.status} /></article>
<article className="card ticket-detail-header"><div className="ticket-detail-kicker">{ticket.ticket_no || `工单 #${ticket.id}`}</div><h1 className="ticket-detail-title">{ticket.subject || ticket.title || '未命名工单'}</h1><div className="ticket-detail-tags"><StatusChip status={ticket.status} /><span className="chip chip-static">{categoryLabel(ticket.category)}</span><span className="chip chip-static">{priorityLabel(ticket.priority)}</span></div><TicketProgress status={ticket.status} /></article>
<article className="card ticket-detail-description"><h2>问题描述</h2><p>{ticket.description}</p>{sourceUrl && <p className="ticket-detail-source"><span className="text-muted">发现页面</span><a href={sourceUrl} target="_blank" rel="noopener noreferrer">{sourceUrl}</a></p>}</article>
<section className="ticket-detail-conversation" aria-labelledby="ticket-messages-heading"><div className="ticket-detail-section-heading"><h2 id="ticket-messages-heading">沟通记录</h2><span className="text-muted">{messages.length} 条公开回复</span></div>{messages.length === 0 ? <div className="card ticket-detail-empty"><span className="material-icons" aria-hidden="true">forum</span><p>暂无回复提交后管理员会在这里跟进</p></div> : <ol className="ticket-detail-messages">{messages.map((message) => <li key={message.id} className="card ticket-detail-message"><div className="ticket-detail-message-meta"><strong>{message.author_username || message.author_name || '用户'}</strong><time className="text-muted" dateTime={message.created_at}>{formatTime(message.created_at)}</time></div><p>{message.content || message.body}</p></li>)}</ol>}</section>
{events.length > 0 && <section className="ticket-detail-events" aria-labelledby="ticket-events-heading"><h2 id="ticket-events-heading">处理记录</h2><ol>{events.map((event) => <li key={event.id}><span>{eventLabel(event)}</span><time className="text-muted" dateTime={event.created_at}>{formatTime(event.created_at)}</time></li>)}</ol></section>}
+37 -11
View File
@@ -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 <LoginGuide />;
return <div style={{ maxWidth: 960, margin: '0 auto' }}>
@@ -76,7 +97,7 @@ export default function Tickets() {
</div>
<div className="card" style={{ padding: 12, marginBottom: 16 }}>
<label htmlFor="ticket-status-filter" style={{ marginRight: 10 }}>筛选状态</label>
<select id="ticket-status-filter" value={status} onChange={(e) => setStatus(e.target.value)} style={{ minHeight: 40, padding: '0 10px', borderRadius: 8 }}>
<select id="ticket-status-filter" value={status} onChange={(e) => updateStatus(e.target.value)} style={{ minHeight: 40, padding: '0 10px', borderRadius: 8 }}>
<option value="">全部工单</option><option value="open">待处理</option><option value="processing">处理中</option><option value="waiting">等待用户</option><option value="resolved">已解决</option><option value="closed">已关闭</option>
</select>
</div>
@@ -92,5 +113,10 @@ export default function Tickets() {
</Link></li>)}
</ul>
</section>}
{!loading && !error && totalPages > 1 && <nav className="ticket-pagination" aria-label="工单分页" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, marginTop: 20 }}>
<button type="button" className="btn btn-text btn-sm" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>上一页</button>
<span className="text-muted" aria-live="polite"> {page} / {totalPages} {total} </span>
<button type="button" className="btn btn-text btn-sm" disabled={page >= totalPages} onClick={() => setPage((current) => current + 1)}>下一页</button>
</nav>}
</div>;
}
+175 -55
View File
@@ -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;