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

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)');