feat(ticket): record urges in thread, show urge count in admin, mail admins

- aksrt_ticket gains urge_count / last_urge_time (schema 3.1.0)
- cooldown moves from transient to an atomic conditional UPDATE
- urge inserts a system reply rendered as a centered notice (not quotable)
- admin list badge + info card row for urge count / last time
- new ticket_urge_admin mail event via aksrt_ticket_urged

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-27 22:17:40 +08:00
1 parent ac3ed9c956
commit 5febdb35c8
14 files changed
+198 -25

No files matched your search

@@ -126,16 +126,35 @@ test('urge has a cooldown driven by settings', () => {
const rest = read('src/Ticket/TicketRest.php');
const body = rest.slice(rest.indexOf('public function urge('));
assert.match(body, /get_transient\('aksrt_ticket_urge_' \. \$id\)/);
assert.match(body, /set_transient\('aksrt_ticket_urge_' \. \$id, time\(\)/);
assert.match(body, /TicketSettings::urge_interval\(\) \* MINUTE_IN_SECONDS/);
// 冷却以工单行 last_urge_time 为准(条件 UPDATE 防并发),不再依赖 transient。
assert.ok(!/_transient\('aksrt_ticket_urge_/.test(rest), 'urge cooldown must not use transients');
assert.match(body, /\$count = \$repo->record_urge\(\$id, TicketSettings::urge_interval\(\) \* MINUTE_IN_SECONDS\);/);
assert.match(body, /if \(\$count <= 0\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_urge_limit'/);
const repo = read('src/Ticket/TicketRepository.php');
assert.match(repo, /SET urge_count = urge_count \+ 1, last_urge_time = %s/);
assert.match(repo, /AND \(last_urge_time IS NULL OR last_urge_time <= %s\)/);
});
test('urge writes a system record, notifies admins and fires the mail hook', () => {
const rest = read('src/Ticket/TicketRest.php');
const body = rest.slice(rest.indexOf('public function urge('), rest.indexOf('public function close('));
assert.match(body, /\$repo->create_reply\(\$id, 'system', get_current_user_id\(\)/);
assert.match(body, /TicketNotifier::admin_activity\(\$ticket, 'urge'\);/);
assert.match(body, /do_action\('aksrt_ticket_urged', \$ticket\);/);
const repo = read('src/Ticket/TicketRepository.php');
assert.match(repo, /if \(!in_array\(\$author_type, \['admin', 'system'\], true\)\) \{/);
assert.match(repo, /'urge_count' => \(int\) \(\$row\['urge_count'\] \?\? 0\),/);
});
test('quoted replies must belong to the same ticket', () => {
for (const file of ['src/Ticket/TicketRest.php', 'src/Ticket/TicketRestAdmin.php']) {
const src = read(file);
assert.match(src, /if \(\$quoted === null \|\| \(int\) \$quoted\['ticket_id'\] !== \$id\) \{\s*\n\s*\$quote_id = 0;/,
`${file} must drop cross-ticket quotes`);
// 跨工单引用与系统记录(催单提示)一律丢弃。
assert.match(src, /if \(\$quoted === null \|\| \(int\) \$quoted\['ticket_id'\] !== \$id \|\| \$quoted\['author_type'\] === 'system'\) \{\s*\n\s*\$quote_id = 0;/,
`${file} must drop cross-ticket and system quotes`);
}
const repo = read('src/Ticket/TicketRepository.php');