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:
1 parent
ac3ed9c956
commit
5febdb35c8
14 files changed
+198
-25
No files matched your search
@@ -49,6 +49,12 @@ trait UsersMenusTicketsDetail {
|
||||
} else {
|
||||
$html .= '<ol class="aksrt-ticket-detail__replies">';
|
||||
foreach ($replies as $reply) {
|
||||
if ((string) $reply['author_type'] === 'system') {
|
||||
// 系统记录(如催单):居中提示行,无头像 / 引用 / 附件。
|
||||
$html .= '<li class="aksrt-ticket-detail__system"><span>' . esc_html((string) $reply['content'])
|
||||
. '</span><time>' . esc_html((string) $reply['created_at']) . '</time></li>';
|
||||
continue;
|
||||
}
|
||||
$is_admin = (string) $reply['author_type'] === 'admin';
|
||||
$html .= '<li class="aksrt-ticket-detail__reply is-' . ($is_admin ? 'admin' : 'user') . '">';
|
||||
$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')] = '<span class="aksrt-ticket-urge">' . esc_html(sprintf(
|
||||
/* translators: %d urge count */
|
||||
__('%d 次', 'aksrt'),
|
||||
(int) $ticket['urge_count']
|
||||
)) . '</span> <span class="aksrt-tickets-admin__muted">' . esc_html(sprintf(
|
||||
/* translators: %s last urge time */
|
||||
__('最近 %s', 'aksrt'),
|
||||
(string) $ticket['last_urge_time']
|
||||
)) . '</span>';
|
||||
}
|
||||
|
||||
$html = '<section class="aksrt-ticket-detail__card"><dl class="aksrt-ticket-detail__info">';
|
||||
foreach ($rows as $label => $value) {
|
||||
|
||||
@@ -217,7 +217,7 @@ trait UsersMenusTicketsList {
|
||||
|
||||
printf(
|
||||
'<tr id="aksrt-ticket-%1$d" class="aksrt-tickets-admin__row%2$s">'
|
||||
. '<td class="column-title"><a class="row-title" href="%3$s" aria-expanded="%4$s">%5$s</a>'
|
||||
. '<td class="column-title"><a class="row-title" href="%3$s" aria-expanded="%4$s">%5$s</a>%15$s'
|
||||
. '<div class="aksrt-tickets-admin__sub"><code>%6$s</code>%7$s</div></td>'
|
||||
. '<td class="column-status"><span class="aksrt-ticket-badge" style="--aksrt-ticket-color:%8$s">%9$s</span></td>'
|
||||
. '<td class="column-type">%10$s</td>'
|
||||
@@ -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 ' <span class="aksrt-ticket-urge" title="' . esc_attr(sprintf(
|
||||
/* translators: %s last urge time */
|
||||
__('最近催单:%s', 'aksrt'),
|
||||
(string) $ticket['last_urge_time']
|
||||
)) . '">' . esc_html(sprintf(
|
||||
/* translators: %d urge count */
|
||||
__('催单 ×%d', 'aksrt'),
|
||||
$count
|
||||
)) . '</span>';
|
||||
}
|
||||
|
||||
/** 头像 + 名称(链接到用户编辑页)。后台不走前台懒加载,直接输出 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) : '';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string,mixed> $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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string,mixed>
|
||||
*/
|
||||
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);
|
||||
|
||||
+20
-11
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user