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');
@@ -3,7 +3,7 @@
/**
* 工单表结构契约:
* 1) 6 张 aksrt_ticket* 表全部由 DBMigratorPart2 建出,且被 migrate_ticket_tables() 调用;
* 2) SCHEMA_VERSION 已升到 3.0.0(dbDelta 只在版本变化时重跑);
* 2) SCHEMA_VERSION 已升到 3.1.0(dbDelta 只在版本变化时重跑);
* 3) aksrt_order_aftersale 已从 schema 与表名映射中彻底移除;
* 4) 状态与部门 seeds 幂等,默认状态被标记为不可改删。
*/
@@ -27,10 +27,14 @@ const TABLES = [
test('schema version is bumped so dbDelta re-runs', () => {
const migrator = read('src/Core/DBMigrator.php');
assert.match(migrator, /const SCHEMA_VERSION = '3\.0\.0';/, 'SCHEMA_VERSION must be 3.0.0');
assert.match(migrator, /const SCHEMA_VERSION = '3\.1\.0';/, 'SCHEMA_VERSION must be 3.1.0');
assert.ok(migrator.includes('use DBMigratorTicket;'), 'DBMigratorTicket trait not composed');
// 版本注释要说明 3.0.0 做了什么,否则后人看不懂这次升版的原因。
// 版本注释要说明每次升版做了什么,否则后人看不懂升版原因。
assert.match(migrator, /3\.0\.0: 售后(order_aftersale)整体替换为工单系统/);
assert.match(migrator, /3\.1\.0: aksrt_ticket 新增 urge_count \/ last_urge_time/);
const ticket = read('src/Core/DBMigratorTicket.php');
assert.match(ticket, /urge_count INT UNSIGNED NOT NULL DEFAULT 0,/);
assert.match(ticket, /last_urge_time DATETIME DEFAULT NULL,/);
});
test('order_aftersale is gone from schema and the table map', () => {