diff --git a/assets/css/admin/tickets.css b/assets/css/admin/tickets.css index 4525c061..bb7613dd 100644 --- a/assets/css/admin/tickets.css +++ b/assets/css/admin/tickets.css @@ -156,6 +156,28 @@ .aksrt-ticket-detail__reply { margin: 0; } .aksrt-ticket-detail__reply.is-admin { margin-left: 32px; background: #f0f6fc; border-color: #c5d9ed; } .aksrt-ticket-detail__reply.is-user { margin-right: 32px; } +/* 系统记录(催单等):居中细条,与对话气泡区分。 */ +.aksrt-ticket-detail__system { + display: flex; + justify-content: center; + gap: 8px; + margin: 0; + color: var(--aksrt-tk-muted); + font-size: 12px; +} +.aksrt-ticket-detail__system span { color: #b32d2e; } +/* 催单徽标:列表标题旁 + 详情信息卡。 */ +.aksrt-ticket-urge { + display: inline-block; + padding: 0 6px; + color: #b32d2e; + font-size: 12px; + line-height: 18px; + background: #fcf0f1; + border: 1px solid #f0c3c5; + border-radius: 9px; + vertical-align: middle; +} .aksrt-ticket-detail__quote { margin: 0 0 8px; padding: 6px 10px; diff --git a/assets/css/routes/tickets.css b/assets/css/routes/tickets.css index 82e8cf83..6617ee6f 100644 --- a/assets/css/routes/tickets.css +++ b/assets/css/routes/tickets.css @@ -545,6 +545,19 @@ .aksrt-ticket-msg { display: flex; gap: 10px; align-items: flex-start; } .aksrt-ticket-msg.is-user { flex-direction: row-reverse; } +/* 系统记录(如催单):居中灰色提示条。 */ +.aksrt-ticket-msg.is-system { justify-content: center; } +.aksrt-ticket-msg__system { + display: inline-flex; + gap: 6px; + align-items: center; + padding: 3px 12px; + border-radius: 999px; + background: color-mix(in srgb, var(--tickets-ink) 6%, transparent); + color: var(--tickets-muted); + font-size: 12px; +} + .aksrt-ticket-msg__avatar { display: inline-grid; flex: 0 0 32px; diff --git a/assets/js/ticket-center.js b/assets/js/ticket-center.js index 4c65e762..2c4b70af 100644 --- a/assets/js/ticket-center.js +++ b/assets/js/ticket-center.js @@ -406,6 +406,11 @@ } function messageHtml(msg, canQuote) { + if (msg.author_type === 'system') { + // 系统记录(如催单):居中提示,不可引用。 + return '
  • ' + + icon('fa-bell') + escapeHtml(msg.content) + timeTag(msg.created_at) + '
  • '; + } var admin = msg.author_type === 'admin'; var name = msg.author_name || (admin ? '客服' : '我'); var avatar = admin ? icon('fa-headset') : escapeHtml(Array.from(name)[0] || '我'); @@ -550,6 +555,8 @@ sendUrge(ctx.ticket).then(function (result) { ctx.urgeUntil = Date.now() + (parseInt(result.data && result.data.urge_wait, 10) || 0) * 1000; notyf('已提醒客服优先处理,请耐心等待', 'success'); + // 重新拉取详情,让时间线出现本次的「用户催单」系统记录。 + return fetchDetail(shell, ctx.ticket.id, { scrollEnd: true }); }).catch(function (error) { var wait = parseInt(error && error.data && error.data.urge_wait, 10) || 0; if (wait) ctx.urgeUntil = Date.now() + wait * 1000; diff --git a/src/Admin/Native/UsersMenusTicketsDetail.php b/src/Admin/Native/UsersMenusTicketsDetail.php index 774511d0..7e939bb7 100644 --- a/src/Admin/Native/UsersMenusTicketsDetail.php +++ b/src/Admin/Native/UsersMenusTicketsDetail.php @@ -49,6 +49,12 @@ trait UsersMenusTicketsDetail { } else { $html .= '
      '; foreach ($replies as $reply) { + if ((string) $reply['author_type'] === 'system') { + // 系统记录(如催单):居中提示行,无头像 / 引用 / 附件。 + $html .= '
    1. ' . esc_html((string) $reply['content']) + . '
    2. '; + continue; + } $is_admin = (string) $reply['author_type'] === 'admin'; $html .= '
    3. '; $html .= $this->ticket_message_head( @@ -159,6 +165,17 @@ trait UsersMenusTicketsDetail { } $rows[__('提交时间', 'aksrt')] = esc_html((string) $ticket['post_time']); $rows[__('最后回复', 'aksrt')] = esc_html((string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : '—'); + if ((int) $ticket['urge_count'] > 0) { + $rows[__('催单', 'aksrt')] = '' . esc_html(sprintf( + /* translators: %d urge count */ + __('%d 次', 'aksrt'), + (int) $ticket['urge_count'] + )) . ' ' . esc_html(sprintf( + /* translators: %s last urge time */ + __('最近 %s', 'aksrt'), + (string) $ticket['last_urge_time'] + )) . ''; + } $html = '
      '; foreach ($rows as $label => $value) { diff --git a/src/Admin/Native/UsersMenusTicketsList.php b/src/Admin/Native/UsersMenusTicketsList.php index 193dcf7b..2df01744 100644 --- a/src/Admin/Native/UsersMenusTicketsList.php +++ b/src/Admin/Native/UsersMenusTicketsList.php @@ -217,7 +217,7 @@ trait UsersMenusTicketsList { printf( '' - . '%5$s' + . '%5$s%15$s' . '
      %6$s%7$s
      ' . '%9$s' . '%10$s' @@ -245,7 +245,8 @@ trait UsersMenusTicketsList { /* translators: %s submit time */ __('提交于 %s', 'aksrt'), (string) $ticket['post_time'] - )) + )), + $this->ticket_urge_badge($ticket) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ); if ($is_open) { @@ -253,6 +254,23 @@ trait UsersMenusTicketsList { } } + /** 催单徽标:次数 + 悬停显示最近催单时间;已完结或未催过不输出。 */ + private function ticket_urge_badge(array $ticket): string { + $count = (int) ($ticket['urge_count'] ?? 0); + if ($count <= 0 || !empty($ticket['is_closed'])) { + return ''; + } + return ' ' . esc_html(sprintf( + /* translators: %d urge count */ + __('催单 ×%d', 'aksrt'), + $count + )) . ''; + } + /** 头像 + 名称(链接到用户编辑页)。后台不走前台懒加载,直接输出 src。 */ private function ticket_user_chip(int $user_id, string $name): string { $avatar = function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user_id, 48) : ''; diff --git a/src/Core/DBMigrator.php b/src/Core/DBMigrator.php index e284de89..61f5a49b 100644 --- a/src/Core/DBMigrator.php +++ b/src/Core/DBMigrator.php @@ -24,7 +24,9 @@ class DBMigrator { // 3.0.0: 售后(order_aftersale)整体替换为工单系统(6 张 aksrt_ticket* 表)。 // 工单不再绑定订单退款流程:标题 + 部门 + 描述为必填,关联订单为可选项; // 沟通记录从工单上的 JSON 日志升级为独立的 ticket_reply 行(支持引用回复)。 - const SCHEMA_VERSION = '3.0.0'; + // 3.1.0: aksrt_ticket 新增 urge_count / last_urge_time(催单次数与最近催单时间), + // 催单冷却改由 last_urge_time 判定,不再依赖 transient;催单同时写入 system 类型的 ticket_reply。 + const SCHEMA_VERSION = '3.1.0'; const OPT_KEY = 'aksrt_db_schema_version'; diff --git a/src/Core/DBMigratorTicket.php b/src/Core/DBMigratorTicket.php index bb99ddc6..5179a99b 100644 --- a/src/Core/DBMigratorTicket.php +++ b/src/Core/DBMigratorTicket.php @@ -31,6 +31,8 @@ trait DBMigratorTicket { admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, last_reply_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, post_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + urge_count INT UNSIGNED NOT NULL DEFAULT 0, + last_urge_time DATETIME DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, PRIMARY KEY (id), diff --git a/src/Settings/MailNotifications.php b/src/Settings/MailNotifications.php index 1dbc3d78..21913ee9 100644 --- a/src/Settings/MailNotifications.php +++ b/src/Settings/MailNotifications.php @@ -26,6 +26,28 @@ final class MailNotifications { add_action('aksrt_friend_link_submitted', [self::class, 'friend_link_submitted'], 20, 1); add_action('aksrt_user_phone_changed', [self::class, 'phone_changed'], 20, 2); add_action('aksrt_user_restriction_changed', [self::class, 'restriction_changed'], 20, 4); + add_action('aksrt_ticket_urged', [self::class, 'ticket_urged'], 20, 1); + } + + /** + * 催单邮件:已指派跟进人时只发给跟进人,否则发给全部管理员(与站内通知同一路由)。 + * + * @param array $ticket + */ + public static function ticket_urged(array $ticket): void { + $vars = [ + 'username' => (string) ($ticket['user_name'] ?? ''), + 'count' => (int) ($ticket['urge_count'] ?? 0), + 'ticket_num' => (string) ($ticket['ticket_num'] ?? ''), + 'title' => (string) ($ticket['title'] ?? ''), + 'url' => admin_url('users.php?page=aksrt_tickets&id=' . (int) ($ticket['id'] ?? 0)), + ]; + $assignee = get_userdata((int) ($ticket['admin_id'] ?? 0)); + if ($assignee instanceof \WP_User && is_email($assignee->user_email)) { + self::send('ticket_urge_admin', $assignee->user_email, $vars); + return; + } + self::send_admin('ticket_urge_admin', $vars); } public static function restriction_changed(int $user_id, string $type, string $action, string $reason): void { diff --git a/src/Settings/MailTemplates.php b/src/Settings/MailTemplates.php index ada19732..fec5552b 100644 --- a/src/Settings/MailTemplates.php +++ b/src/Settings/MailTemplates.php @@ -20,6 +20,7 @@ final class MailTemplates { 'auth_apply_admin' => self::event('身份认证申请通知', '用户 {username} 提交了身份认证申请,请登录后台审核。\n查看地址:{url}', ['username', 'url']), 'ban_appeal_admin' => self::event('账号申诉通知', '用户 {username} 提交了账号申诉。\n查看地址:{url}', ['username', 'url']), 'report_admin' => self::event('举报提交通知', '收到新的举报。\n举报人:{username}\n对象:{title}\n查看地址:{url}', ['username', 'title', 'url']), + 'ticket_urge_admin' => self::event('工单催单通知', '用户 {username} 催促处理工单(第 {count} 次)。\n工单:#{ticket_num} {title}\n查看地址:{url}', ['username', 'count', 'ticket_num', 'title', 'url']), 'reg_verify_code' => self::event('注册验证码', '您好 {username},\n\n您正在注册 {site_name},验证码为:\n\n【{code}】\n\n验证码 10 分钟内有效。如非本人操作,请忽略此邮件。', ['username', 'site_name', 'code']), 'reg_success' => self::event('注册成功', '您好 {username},\n\n欢迎加入 {site_name}!您的账号已注册成功。\n\n登录地址:{url}', ['username', 'site_name', 'url']), @@ -95,7 +96,7 @@ final class MailTemplates { ['key' => 'mail_more_content', 'label' => '邮件附加内容(支持基础 HTML)', 'type' => 'textarea', 'rows' => 3, 'sanitize_type' => 'html', 'default' => $defaults['mail_more_content']], ]], ]; - $admin = ['links_submit_admin', 'order_paid_admin', 'withdraw_submitted_admin', 'post_pending_admin', 'auth_apply_admin', 'ban_appeal_admin', 'report_admin']; + $admin = ['links_submit_admin', 'order_paid_admin', 'withdraw_submitted_admin', 'post_pending_admin', 'auth_apply_admin', 'ban_appeal_admin', 'report_admin', 'ticket_urge_admin']; $user = array_values(array_diff(array_keys($definitions), $admin)); $groups[] = self::event_group('admin', '管理员邮件推送', $admin, $definitions, $defaults); $groups[] = self::event_group('user', '用户邮件推送', $user, $definitions, $defaults); diff --git a/src/Ticket/TicketRepository.php b/src/Ticket/TicketRepository.php index fa702b66..5a9ce88f 100644 --- a/src/Ticket/TicketRepository.php +++ b/src/Ticket/TicketRepository.php @@ -206,6 +206,37 @@ final class TicketRepository { $wpdb->update($table, $fields, ['id' => $id], $formats, ['%d']); } + /** + * 催单:冷却期内拒绝,否则次数 +1 并记录时间。 + * + * 条件更新保证并发请求只会有一条命中(冷却以 last_urge_time 为准,不依赖对象缓存)。 + * + * @return int 新的催单次数;0 = 仍在冷却期或写入失败 + */ + public function record_urge(int $id, int $cooldown_seconds): int { + global $wpdb; + $table = $this->table(); + if ($table === '' || $id <= 0) { + return 0; + } + $now = current_time('mysql'); + $threshold = wp_date('Y-m-d H:i:s', time() - max(0, $cooldown_seconds)); + $affected = $wpdb->query($wpdb->prepare( + "UPDATE {$table} SET urge_count = urge_count + 1, last_urge_time = %s, updated_at = %s WHERE id = %d AND (last_urge_time IS NULL OR last_urge_time <= %s)", // phpcs:ignore WordPress.DB.PreparedSQL + $now, + $now, + $id, + $threshold + )); + if ((int) $affected !== 1) { + return 0; + } + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT urge_count FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL + $id + )); + } + /* ------------------------------------------------------------------ * * 沟通记录 * ------------------------------------------------------------------ */ @@ -323,6 +354,8 @@ final class TicketRepository { 'admin_name' => $this->user_name((int) ($row['admin_id'] ?? 0)), 'last_reply_admin_id' => (int) ($row['last_reply_admin_id'] ?? 0), 'post_admin_id' => (int) ($row['post_admin_id'] ?? 0), + 'urge_count' => (int) ($row['urge_count'] ?? 0), + 'last_urge_time' => (string) ($row['last_urge_time'] ?? ''), 'user_name' => $this->user_name((int) ($row['user_id'] ?? 0)), 'created_at' => (string) ($row['created_at'] ?? ''), 'updated_at' => (string) ($row['updated_at'] ?? ''), @@ -334,7 +367,11 @@ final class TicketRepository { * @return array */ private function normalize_reply(array $row): array { - $author_type = (string) ($row['author_type'] ?? '') === 'admin' ? 'admin' : 'user'; + // system = 系统记录(如催单),界面渲染为居中提示,不可引用。 + $author_type = (string) ($row['author_type'] ?? ''); + if (!in_array($author_type, ['admin', 'system'], true)) { + $author_type = 'user'; + } $author_id = (int) ($row['author_id'] ?? 0); $reply_id = (int) ($row['id'] ?? 0); $quote_id = (int) ($row['quote_reply_id'] ?? 0); diff --git a/src/Ticket/TicketRest.php b/src/Ticket/TicketRest.php index d05f51dd..97614a20 100644 --- a/src/Ticket/TicketRest.php +++ b/src/Ticket/TicketRest.php @@ -234,7 +234,7 @@ final class TicketRest { } $ticket['replies'] = $repo->replies($id); $ticket['notes'] = []; - $ticket['urge_wait'] = empty($ticket['is_closed']) ? $this->urge_wait($id) : 0; + $ticket['urge_wait'] = $this->urge_wait($ticket); return rest_ensure_response(['success' => true, 'data' => $ticket]); } @@ -314,7 +314,7 @@ final class TicketRest { $quote_id = absint($request->get_param('quote_reply_id')); if ($quote_id > 0) { $quoted = $repo->find_reply($quote_id); - if ($quoted === null || (int) $quoted['ticket_id'] !== $id) { + if ($quoted === null || (int) $quoted['ticket_id'] !== $id || $quoted['author_type'] === 'system') { $quote_id = 0; } } @@ -345,16 +345,19 @@ final class TicketRest { if (!empty($ticket['is_closed'])) { return new \WP_Error('aksrt_ticket_closed', __('工单已关闭,无法催单', 'aksrt'), ['status' => 409]); } - $last = get_transient('aksrt_ticket_urge_' . $id); - if ($last !== false && (int) $last + TicketSettings::urge_interval() * MINUTE_IN_SECONDS > time()) { + $count = $repo->record_urge($id, TicketSettings::urge_interval() * MINUTE_IN_SECONDS); + if ($count <= 0) { return new \WP_Error('aksrt_ticket_urge_limit', __('刚刚已催单,请稍后再试', 'aksrt'), [ 'status' => 429, - 'urge_wait' => $this->urge_wait($id), + 'urge_wait' => $this->urge_wait($repo->find($id) ?? $ticket), ]); } - set_transient('aksrt_ticket_urge_' . $id, time(), TicketSettings::urge_interval() * MINUTE_IN_SECONDS + 60); + /* translators: %d: urge count */ + $repo->create_reply($id, 'system', get_current_user_id(), sprintf(__('用户催单(第 %d 次)', 'aksrt'), $count), []); + $ticket = $repo->find($id) ?? $ticket; TicketNotifier::admin_activity($ticket, 'urge'); - return rest_ensure_response(['success' => true, 'data' => ['urge_wait' => $this->urge_wait($id)]]); + do_action('aksrt_ticket_urged', $ticket); + return rest_ensure_response(['success' => true, 'data' => ['urge_wait' => $this->urge_wait($ticket)]]); } public function close(\WP_REST_Request $request) { @@ -378,12 +381,18 @@ final class TicketRest { * ------------------------------------------------------------------ */ /** 距离下次可催单的剩余秒数(0 = 可立即催单)。 */ - private function urge_wait(int $id): int { - $last = get_transient('aksrt_ticket_urge_' . $id); - if ($last === false) { + private function urge_wait(array $ticket): int { + $last = (string) ($ticket['last_urge_time'] ?? ''); + if ($last === '' || !empty($ticket['is_closed'])) { return 0; } - return max(0, (int) $last + TicketSettings::urge_interval() * MINUTE_IN_SECONDS - time()); + try { + // last_urge_time 以站点时区写入(current_time('mysql')),按站点时区还原为时间戳。 + $ts = (new \DateTimeImmutable($last, wp_timezone()))->getTimestamp(); + } catch (\Exception $e) { + return 0; + } + return max(0, $ts + TicketSettings::urge_interval() * MINUTE_IN_SECONDS - time()); } private function ensure_enabled(): bool { diff --git a/src/Ticket/TicketRestAdmin.php b/src/Ticket/TicketRestAdmin.php index b99a3df1..3cd3269f 100644 --- a/src/Ticket/TicketRestAdmin.php +++ b/src/Ticket/TicketRestAdmin.php @@ -362,7 +362,7 @@ final class TicketRestAdmin { $quote_id = absint($request->get_param('quote_reply_id')); if ($quote_id > 0) { $quoted = $repo->find_reply($quote_id); - if ($quoted === null || (int) $quoted['ticket_id'] !== $id) { + if ($quoted === null || (int) $quoted['ticket_id'] !== $id || $quoted['author_type'] === 'system') { $quote_id = 0; } } diff --git a/tests/widget-system/ticket-permission-contract.test.cjs b/tests/widget-system/ticket-permission-contract.test.cjs index bc24eebf..87d2ad81 100644 --- a/tests/widget-system/ticket-permission-contract.test.cjs +++ b/tests/widget-system/ticket-permission-contract.test.cjs @@ -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'); diff --git a/tests/widget-system/ticket-schema-version.test.cjs b/tests/widget-system/ticket-schema-version.test.cjs index f7ad13f8..4e42d84d 100644 --- a/tests/widget-system/ticket-schema-version.test.cjs +++ b/tests/widget-system/ticket-schema-version.test.cjs @@ -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', () => {