feat(ticket): replace the after-sale flow with a general ticket system

The old 售后 flow was an order-refund appendage: tickets were hard-bound to an
order number, users could only file them from 「我的订单」against a paid order,
and the admin action bottomed out at 「标记订单已退款」. It could not carry
account questions or feature requests that have nothing to do with an order.

Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket:
title + department + description are required, linking an order is optional
(user's own paid orders only). Add departments, custom statuses, preset replies
and internal admin notes as four configuration surfaces. Conversation history
moves off the ticket row's JSON log into dedicated ticket_reply rows, which
unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end
moves from 支付中心 → 售后服务 to users.php → 工单管理.

Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new
ones, delete the old code. No dual paths, no data migration. The upstream
plugin's upstream/downstream delivery and internal-ticket conversion are
deliberately left out — both are multi-provider platform concerns with no
meaning on a single WordPress site.

Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply,
ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION
2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status
「待接单」is flagged as non-editable.

Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse
UserController::rest_upload_comment_image with a ticket-specific permission
callback, mirroring the existing /report/upload-image route rather than
growing a second upload path.

Notifications go through AKSRT\Messages\Message::send() and deep-link to the
right surface; email/SMS stay out of scope since the theme's mail and SMS
stacks are separate implementations.

Refund gate opens up: marking an order refunded no longer requires an in-flight
ticket, only that the order is paid.

Also generalise a hardcoded special case in tools/audit-admin-contracts.php —
schema domain inference now reads the parent directory instead of matching
"PostList" by name, so new leaf schema files need no per-name exception.

Verification:
  php tools/audit-admin-contracts.php   exit 0, errors=0
  node --test (widget-system + admin)   664 pass / 0 fail
  php -l over all new/changed files     clean
  node --check over all JS              clean
  npm run build + build:public          clean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-10-25 23:29:51 +08:00
1 parent 761c84d70f
commit dbb7de41e9
81 files changed
+5812 -4179

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
*
* 后台列表页分页条 —— 1:1 复刻「卡密管理」页的 zibpay_admin_pagenavi 结构。
*
* 支付中心各页面(订单明细 / 售后服务 / 付费文章 / 优惠码 / 创作分成 / 提现审核 / 卡密管理)
* 支付中心各页面(订单明细 / 付费文章 / 优惠码 / 创作分成 / 提现审核 / 卡密管理)
* 原先各写一份几乎相同的翻页代码。这里统一到一处,标记沿用卡密管理那一套:
* `« ‹ 第 X 页,共 Y 页 › »` 四枚按钮 + 「每页 X 共Y」计数,没有数字页码链接。
*
-11
View File
@@ -52,17 +52,6 @@ class PayMenus {
wp_die(esc_html__('订单模块未初始化。', 'aksrt'));
},
],
'after_sales' => [
'slug' => 'aksrt_after_sales',
'title' => __('售后服务', 'aksrt'),
'callback' => static function (): void {
if (class_exists('AKSRT\Admin\Native\PayOrders')) {
PayOrders::get_instance()->render_after_sales_admin_page();
return;
}
wp_die(esc_html__('订单模块未初始化。', 'aksrt'));
},
],
'products' => [
'slug' => 'aksrt_pay_products',
'title' => __('付费文章', 'aksrt'),
+10 -396
View File
@@ -11,7 +11,6 @@ class PayOrders {
public const CAPABILITY = 'manage_options';
public const PAGE_SLUG = 'aksrt_orders';
public const PAGE_AFTER_SALES_SLUG = 'aksrt_after_sales';
private static $instance = null;
@@ -31,7 +30,6 @@ class PayOrders {
add_action('admin_post_aksrt_pay_clear_closed_orders', [$this, 'handle_clear_closed_orders']);
add_action('admin_post_aksrt_pay_delete_order', [$this, 'handle_delete_order']);
add_action('admin_post_aksrt_pay_refund_order', [$this, 'handle_refund_order']);
add_action('admin_post_aksrt_after_sale_update', [$this, 'handle_after_sale_status']);
}
public function render_orders_page(): void {
@@ -269,7 +267,7 @@ class PayOrders {
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '<br><span class="description">' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . '</span></td>';
echo '<td style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">';
echo '<a class="button button-small" href="' . esc_url($this->page_url('aksrt_orders', ['order' => $order_number])) . '">' . esc_html__('详情', 'aksrt') . '</a>';
// 已支付是已退款的唯一前置状态;退款仍需落到一条进行中的售后工单上。
// 已支付是已退款的唯一前置状态。
if ($status_key === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;" onsubmit="return confirm(\'' . esc_js(__('确认将该订单标记为已退款?不可恢复。', 'aksrt')) . '\');">';
echo '<input type="hidden" name="action" value="aksrt_pay_refund_order" />';
@@ -298,34 +296,6 @@ class PayOrders {
$this->render_footer();
}
/**
* 售后服务子菜单(aksrt_after_sales):原订单明细页「售后」Tab 独立成页。
*/
public function render_after_sales_admin_page(): void {
$this->ensure_cap();
$this->render_header(__('售后服务', 'aksrt'));
if (!class_exists('\\AKSRT\\Payment\\AfterSaleService')) {
echo '<div class="notice notice-error"><p>' . esc_html__('售后服务不可用。', 'aksrt') . '</p></div>';
$this->render_footer();
return;
}
$policy = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
$policy_bits = [];
$policy_bits[] = __('售后功能', 'aksrt') . ':' . ($policy['enabled'] ? __('开启', 'aksrt') : __('关闭', 'aksrt'));
$limit_days = (int) ($policy['time_limit'] ?? 0);
$policy_bits[] = __('申请售后时效', 'aksrt') . ':' . ($limit_days > 0 ? sprintf(__('%d 天', 'aksrt'), $limit_days) : __('不限制', 'aksrt'));
$reason_names = array_column((array) ($policy['reasons'] ?? []), 'name');
if ($reason_names) $policy_bits[] = __('预置原因', 'aksrt') . ':' . implode('、', $reason_names);
$material_names = array_values(array_filter(array_map('trim', (array) ($policy['materials'] ?? []))));
if ($material_names) $policy_bits[] = __('申请所需材料', 'aksrt') . ':' . implode('、', $material_names);
echo '<p class="description">' . esc_html(implode(' ', $policy_bits)) . '</p>';
$this->render_after_sales_page();
$this->render_footer();
}
private function render_order_detail_page(string $order_number): void {
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$order = $repo->get($order_number);
@@ -351,13 +321,6 @@ class PayOrders {
echo '<p><a class="button" href="' . esc_url($this->page_url('aksrt_orders')) . '">&larr; ' . esc_html__('返回订单列表', 'aksrt') . '</a></p>';
// 售后记录区块:工单卡片 + 处理记录列表(与售后面板共用 .aksrt-as-log 样式)。
echo '<style>'
. '.aksrt-as-ticket{padding:10px 0;border-bottom:1px solid #f0f0f1;}'
. '.aksrt-as-ticket:last-child{padding-bottom:0;border-bottom:0;}'
. '.aksrt-as-ticket__head{margin:0 0 4px;}'
. $this->after_sale_log_css()
. '</style>';
echo '<table class="widefat striped" style="max-width:960px;"><tbody>';
$rows = [
__('订单号', 'aksrt') => $order_number,
@@ -375,23 +338,6 @@ class PayOrders {
foreach ($rows as $label => $value) {
echo '<tr><th style="width:160px;">' . esc_html($label) . '</th><td>' . esc_html((string) $value) . '</td></tr>';
}
$after_sales = \AKSRT\Payment\AfterSaleService::get_instance()->list_for_order($order_number);
if ($after_sales) {
echo '<tr><th>' . esc_html__('售后记录', 'aksrt') . '</th><td>';
$status_labels = $this->after_sale_status_labels();
foreach ($after_sales as $application) {
$as_status = (string) ($application['status'] ?? '');
echo '<div class="aksrt-as-ticket is-' . esc_attr($as_status) . '">';
echo '<p class="aksrt-as-ticket__head"><strong>' . esc_html($status_labels[$as_status] ?? $as_status) . '</strong>'
. ' <span class="description">' . esc_html((string) ($application['refund_amount'] ?? '')) . '</span>'
. ' <span class="description">' . esc_html((string) ($application['created_at'] ?? '')) . '</span></p>';
echo '<p>' . nl2br(esc_html((string) ($application['reason'] ?? ''))) . '</p>';
// 处理记录:后台每一次提交都留一条(状态 + 备注 + 时间),与售后面板同款标记。
echo $this->render_after_sale_log((array) ($application['handling_log'] ?? []), $status_labels); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '</div>';
}
echo '</td></tr>';
}
if (!empty($context)) {
echo '<tr><th>' . esc_html__('附加上下文', 'aksrt') . '</th><td><pre style="white-space:pre-wrap;margin:0;">' . esc_html(wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) . '</pre></td></tr>';
}
@@ -412,322 +358,6 @@ class PayOrders {
echo '</div>';
}
private function render_after_sales_page(): void {
$service = \AKSRT\Payment\AfterSaleService::get_instance();
$status = isset($_GET['after_sale_status']) ? sanitize_key((string) wp_unslash($_GET['after_sale_status'])) : '';
// admin_list() 会按状态常量白名单过滤,count() 不会。这里先归一一次,
// 否则手改 URL 传个野状态会变成「count 为 0 而 list 返回全部」,分页条直接说谎。
if (!in_array($status, $this->after_sale_statuses(), true)) {
$status = '';
}
$date_from = isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : '';
$date_to = isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : '';
$date_from = preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_from) ? $date_from : '';
$date_to = preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_to) ? $date_to : '';
$filter_args = ['status' => $status, 'date_from' => $date_from, 'date_to' => $date_to];
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
$per_page = AdminListPagination::DEFAULT_PER_PAGE;
$items = $service->admin_list($filter_args + ['limit' => $per_page, 'offset' => ($page_num - 1) * $per_page]);
$total = $service->count($filter_args);
$pages = max(1, (int) ceil($total / $per_page));
// 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外:表格空掉
// 且分页条不输出,只能手改 URL 退出。回退到最后一页重查一次。
if ($page_num > $pages) {
$page_num = $pages;
$items = $service->admin_list($filter_args + ['limit' => $per_page, 'offset' => ($page_num - 1) * $per_page]);
}
echo '<form method="get"><input type="hidden" name="page" value="' . esc_attr(self::PAGE_AFTER_SALES_SLUG) . '" />';
echo '<select name="after_sale_status"><option value="">' . esc_html__('全部售后状态', 'aksrt') . '</option>';
foreach ($this->after_sale_status_labels() as $key => $label) echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
echo '</select> <label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> <label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form>';
echo '<style>'
. '.aksrt-as-detail-row>td{padding:0 0 6px;}'
. '.aksrt-as-detail-row[hidden]{display:none;}'
. '.aksrt-as-detail{margin:0 12px;padding:14px 18px;border:1px solid #dcdcde;background:#fff;box-shadow:0 1px 1px rgba(0,0,0,.04);}'
. '.aksrt-as-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 28px;}'
. '.aksrt-as-field .aksrt-as-label{display:block;color:#646970;font-size:12px;margin-bottom:2px;}'
. '.aksrt-as-field .aksrt-as-value{display:block;word-break:break-word;}'
. '.aksrt-as-field{min-width:0;}'
. '.aksrt-as-thumb{width:48px;height:48px;object-fit:cover;border-radius:4px;margin:0 4px 4px 0;vertical-align:middle;}'
. '.aksrt-as-detail .aksrt-as-thumb{width:80px;height:80px;}'
. '.aksrt-as-review{margin-top:14px;padding-top:12px;border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px 16px;}'
. '.aksrt-as-review-head{flex:1 1 100%;}'
. '.aksrt-as-review-main{flex:1 1 460px;min-width:0;display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px 12px;}'
. '.aksrt-as-review-main p{margin:0;}'
. '.aksrt-status-select{min-width:150px;}'
. '.aksrt-as-refund-flag{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;margin:0;}'
. '.aksrt-as-review textarea{flex:1 1 240px;min-width:0;box-sizing:border-box;margin:0;}'
. '.aksrt-as-review-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;margin-left:auto;}'
. '.aksrt-as-field--wide{grid-column:1/-1;}'
. $this->after_sale_log_css()
. '@media (min-width:1500px){.aksrt-as-detail-grid{grid-template-columns:repeat(5,minmax(0,1fr));}}'
. '@media (max-width:600px){.aksrt-as-detail-grid{grid-template-columns:minmax(0,1fr);}}'
. '</style>';
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>订单号</th><th>用户</th><th>原因</th><th>申请资料</th><th>申请说明</th><th>退款金额</th><th>状态</th><th>操作 / 处理记录</th></tr></thead><tbody>';
if (!$items) echo '<tr><td colspan="9">' . esc_html__('暂无售后申请', 'aksrt') . '</td></tr>';
$status_labels = $this->after_sale_status_labels();
foreach ($items as $item) {
$id = (int) $item['id']; $number = (string) $item['order_number']; $uid = (int) $item['user_id'];
$user = $uid > 0 ? get_userdata($uid) : null;
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($number);
$type = $order ? \AKSRT\Core\DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])) : '';
$type_label = \AKSRT\Core\DomainModel::order_product_type_labels()[$type] ?? $type;
$product_name = $order ? (string) (\AKSRT\Payment\UserOrders::get_instance()->decorate_order_view($order, false)['product_name'] ?? '') : '';
$refund_amount = (string) ($item['refund_amount'] ?? '0');
if ($order && (string) ($order['currency'] ?? '') === 'points') $refund_amount .= ' ' . __('积分', 'aksrt'); else $refund_amount = '¥' . $refund_amount;
$apply_form = is_array($item['apply_form'] ?? null) ? $item['apply_form'] : null;
if ($apply_form) {
$reason_cell = esc_html((string) ($apply_form['reason_label'] ?? ''));
$material_lines = [];
foreach ((array) ($apply_form['materials'] ?? []) as $m) {
$ml = trim((string) ($m['label'] ?? '')); $mv = trim((string) ($m['value'] ?? ''));
if ($ml === '' && $mv === '') continue;
$material_lines[] = esc_html($ml !== '' ? $ml . ':' . $mv : $mv);
}
$materials_cell = $material_lines ? implode('<br>', $material_lines) : '—';
$desc_text = trim((string) ($apply_form['description'] ?? ''));
} else {
// 老数据:融合串原样展示在原因列
$reason_cell = nl2br(esc_html((string) $item['reason']));
$materials_cell = '—';
$desc_text = '';
}
$image_urls = array_values(array_filter((array) ($item['images'] ?? []), 'is_string'));
$thumbs = [];
foreach ($image_urls as $url) {
// 不写原生 loading 懒加载属性:lazyload 契约测试全仓库禁 loading=lazy/eager,
// 且后台不加载 lazySizes 运行时,写了也不会被主题那套接管。
// 与 UsersMenusVerify 的后台缩略图保持一致(光 <img src>)。
$thumbs[] = '<a href="' . esc_url($url) . '" target="_blank" rel="noopener"><img src="' . esc_url($url) . '" alt="" class="aksrt-as-thumb" /></a>';
}
// 举证图片并入申请说明:缩略图展示在描述文字下方,不再单独成列。
$desc_cell = ($desc_text !== '' ? nl2br(esc_html($desc_text)) : '') . ($thumbs ? '<div class="aksrt-as-desc-thumbs" style="margin-top:6px;">' . implode('', $thumbs) . '</div>' : '');
if ($desc_cell === '') $desc_cell = '—';
$status_label = $status_labels[(string) $item['status']] ?? (string) $item['status'];
$order_refunded = is_array($order) && (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED;
// 摘要行 + 手风琴详情行:点击「操作」展开整条记录的完整信息与状态面板
echo '<tr class="aksrt-as-row">';
echo '<td>' . esc_html((string) $id) . '<br><small>' . esc_html((string) ($item['created_at'] ?? '')) . '</small></td>';
echo '<td><a href="' . esc_url($this->page_url(self::PAGE_SLUG, ['order' => $number])) . '"><code>' . esc_html($number) . '</code></a><br><span class="description">' . esc_html($type_label) . ($product_name !== '' ? ' · ' . esc_html($product_name) : '') . '</span></td>';
echo '<td>' . esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid) . '</td>';
echo '<td>' . $reason_cell . '</td>';
echo '<td>' . $materials_cell . '</td>';
echo '<td>' . $desc_cell . '</td>';
echo '<td>' . esc_html($refund_amount) . '</td>';
echo '<td>' . esc_html($status_label) . '</td>';
echo '<td><a href="#" class="aksrt-toggle-row-actions" data-target="aksrt-as-detail-' . esc_attr((string) $id) . '" aria-expanded="false" aria-controls="aksrt-as-detail-' . esc_attr((string) $id) . '">' . esc_html__('操作', 'aksrt') . '</a></td>';
echo '</tr>';
echo '<tr class="aksrt-as-detail-row" id="aksrt-as-detail-' . esc_attr((string) $id) . '" hidden><td colspan="9">';
$detail_fields = [
__('订单号', 'aksrt') => '<a href="' . esc_url($this->page_url(self::PAGE_SLUG, ['order' => $number])) . '"><code>' . esc_html($number) . '</code></a>',
__('商品', 'aksrt') => esc_html($type_label . ($product_name !== '' ? ' · ' . $product_name : '')),
__('用户', 'aksrt') => esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid),
__('申请时间', 'aksrt') => esc_html((string) ($item['created_at'] ?? '')),
__('退款金额', 'aksrt') => esc_html($refund_amount),
__('当前状态', 'aksrt') => esc_html($status_label),
__('售后原因', 'aksrt') => $reason_cell,
__('申请资料', 'aksrt') => $materials_cell,
];
echo '<div class="aksrt-as-detail"><div class="aksrt-as-detail-grid">';
foreach ($detail_fields as $field_label => $field_value) {
echo '<span class="aksrt-as-field"><span class="aksrt-as-label">' . esc_html($field_label) . '</span><span class="aksrt-as-value">' . $field_value . '</span></span>';
}
// 处理备注:单独占整行,放在申请说明上方;每次提交一条,含状态与时间。
echo '<span class="aksrt-as-field aksrt-as-field--wide"><span class="aksrt-as-label">' . esc_html__('处理备注', 'aksrt') . '</span><span class="aksrt-as-value">'
. $this->render_after_sale_log((array) ($item['handling_log'] ?? []), $status_labels) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
. '</span></span>';
// 举证图片并入申请说明,单独占整行展示。
echo '<span class="aksrt-as-field aksrt-as-field--wide"><span class="aksrt-as-label">' . esc_html__('申请说明', 'aksrt') . '</span><span class="aksrt-as-value">' . $desc_cell . '</span></span>';
echo '</div>';
$status_options = '';
$current_status = (string) $item['status'];
foreach ($status_labels as $key => $label) {
$status_options .= '<option value="' . esc_attr($key) . '"' . selected($current_status, $key, false) . '>' . esc_html($label) . '</option>';
}
if (!isset($status_labels[$current_status])) {
// 历史状态(如已删除的 approved)不在可选项内:如实呈现且禁用,防止误提交。
$status_options .= '<option value="' . esc_attr($current_status) . '" selected="selected" disabled>' . esc_html($current_status) . '</option>';
}
echo '<div class="aksrt-as-review">';
echo '<p class="aksrt-as-review-head" style="margin:0;"><strong>' . esc_html__('售后状态', 'aksrt') . '</strong> <span class="description">' . esc_html__('选择目标状态后点击确认;取消仅收起面板。', 'aksrt') . '</span></p>';
echo '<div class="aksrt-as-review-main">';
echo '<p><select class="aksrt-status-select">' . $status_options . '</select></p>';
echo '<textarea class="aksrt-review-note" rows="2" placeholder="' . esc_attr__('填写处理备注(可留空)', 'aksrt') . '"></textarea>';
echo '</div>';
// 勾选后在确认时把关联订单推到已退款;订单本已退款时呈现为不可改的已选状态
echo '<label class="aksrt-as-refund-flag"><input type="checkbox" class="aksrt-mark-order-refunded" value="1"'
. ($order_refunded ? ' checked="checked" disabled' : '')
. ' /> ' . esc_html__('标记订单已退款', 'aksrt') . '</label>';
echo '<div class="aksrt-as-review-actions">';
echo '<button type="button" class="button aksrt-after-sale-cancel">' . esc_html__('取消', 'aksrt') . '</button>';
echo '<button type="button" class="button button-primary aksrt-after-sale-confirm"'
. ' data-id="' . esc_attr((string) $id) . '"'
. ' data-nonce="' . esc_attr(wp_create_nonce('aksrt_after_sale_status_' . $id)) . '">'
. esc_html__('确认', 'aksrt') . '</button>';
echo '</div></div>';
echo '</div></td></tr>';
}
echo '</tbody></table>';
// 翻页链接显式带上当前筛选条件(不含 paged),口径与上方筛选表单一致。
$query_args = array_filter([
'after_sale_status' => $status !== '' ? $status : null,
'date_from' => $date_from !== '' ? $date_from : null,
'date_to' => $date_to !== '' ? $date_to : null,
], static function ($value): bool {
return $value !== null;
});
AdminListPagination::render(self::PAGE_AFTER_SALES_SLUG, $total, $page_num, $per_page, $query_args);
// 事件委托:点击「操作」切换手风琴详情;「取消」仅收起面板;「确认」以 POST 提交选中的售后状态
echo '<script>(function(){'
. 'var postUrl=' . wp_json_encode(esc_url_raw(admin_url('admin-post.php'))) . ';'
. 'var tConfirmStatus=' . wp_json_encode(__('确认将售后状态设置为', 'aksrt')) . ';'
. 'function closeAllDetails(){'
. 'document.querySelectorAll(".aksrt-as-detail-row").forEach(function(x){x.setAttribute("hidden","");});'
. 'document.querySelectorAll(".aksrt-toggle-row-actions").forEach(function(t){t.setAttribute("aria-expanded","false");});'
. '}'
. 'document.addEventListener("click",function(e){'
. 'var el=e.target;'
. 'var t=el&&el.closest?el.closest(".aksrt-toggle-row-actions"):null;'
. 'if(t){e.preventDefault();'
. 'var p=document.getElementById(t.getAttribute("data-target"));if(!p)return;'
. 'var willOpen=p.hasAttribute("hidden");closeAllDetails();'
. 'if(willOpen){p.removeAttribute("hidden");t.setAttribute("aria-expanded","true");'
. 'var s=p.querySelector(".aksrt-status-select");if(s)s.focus();}'
. 'return;}'
. 'var c=el&&el.closest?el.closest(".aksrt-after-sale-cancel"):null;'
. 'if(c){e.preventDefault();closeAllDetails();return;}'
. 'var a=el&&el.closest?el.closest(".aksrt-after-sale-confirm"):null;'
. 'if(!a)return;e.preventDefault();'
. 'var panel=a.closest(".aksrt-as-detail-row");'
. 'var sel=panel?panel.querySelector(".aksrt-status-select"):null;'
. 'var noteEl=panel?panel.querySelector(".aksrt-review-note"):null;'
. 'var refundEl=panel?panel.querySelector(".aksrt-mark-order-refunded"):null;'
. 'var markRefund=!!(refundEl&&refundEl.checked&&!refundEl.disabled);'
. 'var opt=sel?sel.options[sel.selectedIndex]:null;'
. 'if(!sel||!opt||opt.disabled){alert(' . wp_json_encode(__('请选择售后状态。', 'aksrt')) . ');return;}'
. 'var confirmText=tConfirmStatus+"「"+opt.text+"」?";'
. 'if(markRefund)confirmText+=' . wp_json_encode(__('同时会将关联订单标记为已退款。', 'aksrt')) . ';'
. 'if(!confirm(confirmText))return;'
. 'var f=document.createElement("form");f.method="post";f.action=postUrl;'
. 'var fields={action:"aksrt_after_sale_update",after_sale_id:a.getAttribute("data-id"),after_sale_status:sel.value,mark_order_refunded:markRefund?"1":"",_wpnonce:a.getAttribute("data-nonce"),admin_note:noteEl?noteEl.value:""};'
. 'Object.keys(fields).forEach(function(k){var i=document.createElement("input");i.type="hidden";i.name=k;i.value=fields[k];f.appendChild(i);});'
. 'document.body.appendChild(f);f.submit();'
. '});'
. '})();</script>';
}
/** 售后状态白名单:admin_list() 据此过滤,count() 不校验,故查询前先按它归一。 */
private function after_sale_statuses(): array {
return [
\AKSRT\Payment\AfterSaleService::STATUS_PENDING,
\AKSRT\Payment\AfterSaleService::STATUS_PROCESSING,
\AKSRT\Payment\AfterSaleService::STATUS_COMPLETED,
\AKSRT\Payment\AfterSaleService::STATUS_REJECTED,
\AKSRT\Payment\AfterSaleService::STATUS_CANCELLED,
];
}
/** 售后状态中文标签:订单明细页与售后面板(含筛选下拉)共用,取代两处内联数组。 */
/** 处理记录列表样式:两个后台页面各自输出同一套规则,不依赖对方加载。 */
private function after_sale_log_css(): string {
return '.aksrt-as-log{display:grid;gap:5px;margin:6px 0 0;padding:0;list-style:none;}'
. '.aksrt-as-log__item{display:flex;flex-wrap:wrap;gap:3px 10px;align-items:baseline;font-size:12px;line-height:1.7;}'
. '.aksrt-as-log__time{color:#646970;font-variant-numeric:tabular-nums;}'
. '.aksrt-as-log__status{font-weight:600;}'
. '.aksrt-as-log__item.is-pending .aksrt-as-log__status{color:#b26200;}'
. '.aksrt-as-log__item.is-processing .aksrt-as-log__status{color:#2271b1;}'
. '.aksrt-as-log__item.is-completed .aksrt-as-log__status{color:#007017;}'
. '.aksrt-as-log__item.is-rejected .aksrt-as-log__status,.aksrt-as-log__item.is-cancelled .aksrt-as-log__status{color:#b32d2e;}'
. '.aksrt-as-log__note{flex:1 1 100%;color:#3c434a;}';
}
private function after_sale_status_labels(): array {
return [
\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核',
\AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中',
\AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成',
\AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝',
\AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消',
];
}
/**
* 处理记录列表:每一次状态提交一条「时间 + 状态 + 备注」,备注为空也留痕。
* 后台订单明细页与售后面板共用同一套标记与样式。
*
* @param list<array<string,mixed>> $log
* @param array<string,string> $status_labels
*/
private function render_after_sale_log(array $log, array $status_labels): string {
if (!$log) {
return '<p class="description">' . esc_html__('暂无处理记录', 'aksrt') . '</p>';
}
$service = \AKSRT\Payment\AfterSaleService::class;
$html = '<ul class="aksrt-as-log">';
foreach ($log as $entry) {
$status = (string) ($entry['status'] ?? '');
$label = (string) ($entry['source'] ?? '') === $service::SOURCE_USER
? ($status === $service::STATUS_CANCELLED ? esc_html__('用户撤销售后', 'aksrt') : esc_html__('用户提交申请', 'aksrt'))
: ($status_labels[$status] ?? $status);
$note = (string) ($entry['note'] ?? '');
$html .= '<li class="aksrt-as-log__item is-' . esc_attr($status) . '">'
. '<span class="aksrt-as-log__time">' . esc_html((string) ($entry['created_at'] ?? '')) . '</span>'
. '<span class="aksrt-as-log__status">' . esc_html($label) . '</span>'
. '<span class="aksrt-as-log__note">' . ($note !== '' ? nl2br(esc_html($note)) : '—') . '</span>'
. '</li>';
}
return $html . '</ul>';
}
public function handle_after_sale_status(): void {
if (!current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt'));
$id = isset($_POST['after_sale_id']) ? absint($_POST['after_sale_id']) : 0;
check_admin_referer('aksrt_after_sale_status_' . $id);
$status = isset($_POST['after_sale_status']) ? sanitize_key((string) wp_unslash($_POST['after_sale_status'])) : '';
$note = isset($_POST['admin_note']) ? sanitize_textarea_field((string) wp_unslash($_POST['admin_note'])) : '';
$mark_refund = isset($_POST['mark_order_refunded']) && (string) wp_unslash($_POST['mark_order_refunded']) === '1';
// 勾选「标记订单已退款」时先落订单状态:售后工单一旦离开 pending/processing,
// OrderRepository 的退款闸门就不再放行,所以顺序不能反。
if ($mark_refund && $this->mark_related_order_refunded($id) === 'failed') {
wp_safe_redirect($this->page_url(self::PAGE_AFTER_SALES_SLUG, ['aksrt_pay_msg' => 'after_sale_order_refund_failed']));
exit;
}
$result = \AKSRT\Payment\AfterSaleService::get_instance()->set_status($id, $status, $note);
$args = ['aksrt_pay_msg' => is_wp_error($result) ? 'after_sale_update_failed' : 'after_sale_updated'];
if (is_wp_error($result)) $args['after_sale_error'] = substr($result->get_error_message(), 0, 240);
elseif ($mark_refund) $args['aksrt_pay_msg'] = 'after_sale_updated_order_refunded';
wp_safe_redirect($this->page_url(self::PAGE_AFTER_SALES_SLUG, $args)); exit;
}
/**
* 把售后申请关联的订单推到已退款。仅在该工单仍为待审核/处理中时放行,
* 避免对已关闭的工单或非已支付订单误标退款。
*
* @return string 'done' | 'failed'
*/
private function mark_related_order_refunded(int $after_sale_id): string {
if (!class_exists('\AKSRT\Payment\AfterSaleService') || !class_exists('\AKSRT\Payment\OrderRepository')) return 'failed';
$service = \AKSRT\Payment\AfterSaleService::get_instance();
$row = $service->get($after_sale_id);
$order_number = is_array($row) ? (string) ($row['order_number'] ?? '') : '';
if ($order_number === '' || !$service->can_finalize_order_refund($after_sale_id, $order_number)) return 'failed';
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$order = $repo->get($order_number);
if (is_array($order) && (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) return 'done';
return $repo->update_status($order_number, \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED, [
'allow_refund' => true,
'after_sale_id' => $after_sale_id,
]) ? 'done' : 'failed';
}
public function handle_update_order_status(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
@@ -753,8 +383,10 @@ class PayOrders {
}
/**
* 订单列表「退款」:把已支付订单推到已退款。退款必须落到一条进行中的售后工单上,
* 工单号由服务端按订单号反查,不经客户端传递。
* 订单列表「退款」:把已支付订单推到已退款。
*
* 退款闸门只剩「订单当前为已支付」——工单系统与订单退款已解耦,
* 不再要求先有一条进行中的工单。
*/
public function handle_refund_order(): void {
if (!current_user_can(self::CAPABILITY)) {
@@ -773,19 +405,11 @@ class PayOrders {
} elseif ((string) ($order['status'] ?? '') !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
$code = 'order_refund_not_paid';
} else {
$ticket = class_exists('\AKSRT\Payment\AfterSaleService')
? \AKSRT\Payment\AfterSaleService::get_instance()->find_active($order_number)
: null;
if (!is_array($ticket)) {
$code = 'order_refund_no_ticket';
} else {
$ok = $repo->update_status($order_number, \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED, [
'allow_refund' => true,
'after_sale_id' => (int) ($ticket['id'] ?? 0),
'note' => __('管理员在订单列表中标记退款', 'aksrt'),
]);
$code = $ok ? 'order_refunded' : 'order_refund_failed';
}
$ok = $repo->update_status($order_number, \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED, [
'allow_refund' => true,
'note' => __('管理员在订单列表中标记退款', 'aksrt'),
]);
$code = $ok ? 'order_refunded' : 'order_refund_failed';
}
}
@@ -935,7 +559,6 @@ class PayOrders {
'order_refund_failed' => __('订单标记退款失败。', 'aksrt'),
'order_refund_missing' => __('订单不存在。', 'aksrt'),
'order_refund_not_paid' => __('仅已支付订单可以标记退款。', 'aksrt'),
'order_refund_no_ticket' => __('该订单没有进行中的售后工单,无法标记退款;请先在「售后服务」中处理。', 'aksrt'),
'order_note_updated' => __('订单备注已保存。', 'aksrt'),
'order_note_failed' => __('订单备注保存失败。', 'aksrt'),
'orders_cleared' => sprintf(
@@ -950,21 +573,12 @@ class PayOrders {
'shipping_failed' => __('物流状态更新失败。', 'aksrt'),
'shipping_created' => __('发货记录已创建。', 'aksrt'),
'shipping_create_failed' => __('发货记录创建失败。', 'aksrt'),
'after_sale_created' => __('售后工单已创建。', 'aksrt'),
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
'after_sale_updated' => __('售后状态已更新。', 'aksrt'),
'after_sale_updated_order_refunded' => __('售后状态已更新,关联订单已标记为已退款。', 'aksrt'),
'after_sale_order_refund_failed' => __('关联订单标记已退款失败:需售后工单仍为待审核/处理中,且订单为已支付。', 'aksrt'),
'after_sale_update_failed' => __('售后状态更新失败,请查看申请状态和错误原因。', 'aksrt'),
];
if (!isset($map[$code])) {
return;
}
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
if ($code === 'after_sale_update_failed' && isset($_GET['after_sale_error'])) {
echo '<div class="notice notice-error"><p>' . esc_html(sanitize_text_field((string) wp_unslash($_GET['after_sale_error']))) . '</p></div>';
}
}
private function page_url(string $slug, array $args = []): string {
+24
View File
@@ -13,6 +13,7 @@ class UsersMenus {
use UsersMenusVip;
use UsersMenusInviteCode;
use UsersMenusTickets;
use UsersMenusTicketsCenter;
public const CAPABILITY = 'manage_options';
@@ -38,6 +39,7 @@ class UsersMenus {
add_action('admin_post_aksrt_users_update_report', [$this, 'handle_update_report']);
add_action('admin_post_aksrt_users_process_appeal', [$this, 'handle_process_appeal']);
add_action('admin_post_aksrt_users_mutate_vip', [$this, 'handle_mutate_vip']);
add_action('admin_post_aksrt_ticket_update', [$this, 'handle_ticket_update']);
}
public function register_menus(): void {
@@ -86,6 +88,14 @@ class UsersMenus {
'aksrt_user_ban',
[$this, 'render_ban_page']
);
add_submenu_page(
'users.php',
__('工单管理', 'aksrt'),
__('工单管理', 'aksrt'),
self::CAPABILITY,
'aksrt_tickets',
[$this, 'render_tickets_page']
);
}
public function pending_notices(): void {
@@ -136,6 +146,20 @@ class UsersMenus {
echo '<p><a class="button button-primary" href="' . esc_url($url) . '">' . esc_html__('立即处理', 'aksrt') . '</a></p></div>';
}
}
if ($page !== 'aksrt_tickets' && class_exists('\AKSRT\Ticket\TicketSettings') && \AKSRT\Ticket\TicketSettings::enabled()) {
$pending_tickets = $this->count_pending_tickets();
if ($pending_tickets > 0) {
$url = admin_url('users.php?page=aksrt_tickets&status=' . \AKSRT\Ticket\TicketStatusService::KEY_PENDING);
echo '<div class="notice notice-info is-dismissible"><h3>' . esc_html__('工单待接单', 'aksrt') . '</h3>';
echo '<p>' . esc_html(sprintf(
/* translators: %d count */
__('您有 %d 个工单待接单', 'aksrt'),
$pending_tickets
)) . '</p>';
echo '<p><a class="button button-primary" href="' . esc_url($url) . '">' . esc_html__('立即处理', 'aksrt') . '</a></p></div>';
}
}
}
private function ensure_cap(): void {
@@ -0,0 +1,512 @@
<?php
/**
* 工单管理后台页(users.php → aksrt_tickets)。
*
* 两个 Tab:
* 1. tickets — 工单列表:筛选 + 排序表头 + 行内展开详情(沟通记录 / 回复 / 备注 / 状态迁移)
* 2. config — 配置:部门 / 状态 / 预设回复
*
* 写操作走 admin_post(非 REST),与本站其它原生后台页一致;前端 JS 见
* assets/js/admin-ticket-center.js。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusTicketsCenter {
use UsersMenusTicketsList;
use UsersMenusTicketsConfig;
private const TICKET_PAGE_SLUG = 'aksrt_tickets';
private const TICKET_PER_PAGE = 20;
/** 待接单工单数(后台 admin_notices 用)。 */
private function count_pending_tickets(): int {
if (!class_exists('\AKSRT\Ticket\TicketRepository') || !class_exists('\AKSRT\Ticket\TicketStatusService')) {
return 0;
}
return \AKSRT\Ticket\TicketRepository::get_instance()->count_by_status(
\AKSRT\Ticket\TicketStatusService::KEY_PENDING
);
}
public function render_tickets_page(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : 'tickets';
$tab = $tab === 'config' ? 'config' : 'tickets';
echo '<div class="wrap">';
echo '<h1>' . esc_html__('工单管理', 'aksrt') . '</h1>';
echo '<style>' . $this->ticket_page_css() . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$this->render_ticket_notices();
echo '<h2 class="nav-tab-wrapper">';
foreach (['tickets' => __('工单列表', 'aksrt'), 'config' => __('配置', 'aksrt')] as $key => $label) {
printf(
'<a href="%s" class="nav-tab %s">%s</a>',
esc_url(admin_url('users.php?page=' . self::TICKET_PAGE_SLUG . '&tab=' . $key)),
$tab === $key ? 'nav-tab-active' : '',
esc_html($label)
);
}
echo '</h2>';
if ($tab === 'config') {
$this->render_ticket_config_tab();
} else {
$this->render_ticket_list_tab();
}
echo '</div>';
}
/** 写操作后的 redirect 提示(aksrt_pay_msg 同款模式)。 */
private function render_ticket_notices(): void {
if (empty($_GET['aksrt_ticket_msg'])) {
return;
}
$code = sanitize_key((string) wp_unslash($_GET['aksrt_ticket_msg']));
$messages = [
'updated' => __('工单状态已更新。', 'aksrt'),
'failed' => __('工单状态更新失败:当前状态不允许该操作。', 'aksrt'),
'replied' => __('回复已提交。', 'aksrt'),
'reply_failed' => __('回复提交失败。', 'aksrt'),
'type_saved' => __('部门已保存。', 'aksrt'),
'type_deleted' => __('部门已删除。', 'aksrt'),
'type_in_use' => __('该部门下还有工单,无法删除。', 'aksrt'),
'status_saved' => __('状态已保存。', 'aksrt'),
'status_deleted' => __('状态已删除。', 'aksrt'),
'status_default' => __('默认状态不可修改或删除。', 'aksrt'),
'status_in_use' => __('该状态下还有工单,无法删除。', 'aksrt'),
'prereply_saved' => __('预设回复已保存。', 'aksrt'),
'prereply_deleted' => __('预设回复已删除。', 'aksrt'),
'prereply_order_failed' => __('预设回复排序失败:需要提交全部条目。', 'aksrt'),
'note_saved' => __('备注已保存。', 'aksrt'),
'note_deleted' => __('备注已删除。', 'aksrt'),
'created' => __('工单已创建。', 'aksrt'),
'create_failed' => __('工单创建失败。', 'aksrt'),
];
if (!isset($messages[$code])) {
return;
}
printf('<div class="notice notice-success is-dismissible"><p>%s</p></div>', esc_html($messages[$code]));
}
/**
* 写操作:统一入口,按 $action 分发。nonce 以 action + id 为单位,
* 与 PayOrders 的行内处理表单同模式。
*/
public function handle_ticket_update(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$action = isset($_POST['ticket_action']) ? sanitize_key((string) wp_unslash($_POST['ticket_action'])) : '';
$id = isset($_POST['ticket_id']) ? absint($_POST['ticket_id']) : 0;
check_admin_referer('aksrt_ticket_' . $action . '_' . $id);
$code = 'failed';
switch ($action) {
case 'status':
$code = $this->ticket_action_status($id);
break;
case 'reply':
$code = $this->ticket_action_reply($id);
break;
case 'type':
$code = $this->ticket_action_type();
break;
case 'status_def':
$code = $this->ticket_action_status_def();
break;
case 'prereply':
$code = $this->ticket_action_prereply();
break;
case 'prereply_delete':
$code = $this->ticket_action_prereply_delete();
break;
case 'type_delete':
$code = $this->ticket_action_type_delete();
break;
case 'status_delete':
$code = $this->ticket_action_status_delete();
break;
case 'note':
$code = $this->ticket_action_note($id);
break;
case 'create':
$code = $this->ticket_action_create();
break;
}
$args = ['page' => self::TICKET_PAGE_SLUG, 'aksrt_ticket_msg' => $code];
if (isset($_POST['ticket_tab'])) {
$args['tab'] = sanitize_key((string) wp_unslash($_POST['ticket_tab']));
}
wp_safe_redirect(add_query_arg($args, admin_url('users.php')));
exit;
}
/** @return string 提示码 */
private function ticket_action_status(int $id): string {
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketRepository')) {
return 'failed';
}
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$ticket = $repo->find($id);
if ($ticket === null) {
return 'failed';
}
$to = isset($_POST['status']) ? sanitize_key((string) wp_unslash($_POST['status'])) : '';
if (!\AKSRT\Ticket\TicketStatusService::can_transition((string) $ticket['status'], $to)) {
return 'failed';
}
$fields = ['status' => $to];
$type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0;
if ($type_id > 0 && \AKSRT\Ticket\TicketTypeService::exists($type_id)) {
$fields['ticket_type_id'] = $type_id;
}
$admin_id = isset($_POST['admin_id']) ? absint($_POST['admin_id']) : 0;
if ($admin_id > 0 && get_userdata($admin_id)) {
$fields['admin_id'] = $admin_id;
}
if (!$repo->update_fields($id, $fields)) {
return 'failed';
}
$ticket = array_merge($ticket, $fields);
if ($to === \AKSRT\Ticket\TicketStatusService::KEY_CLOSED) {
\AKSRT\Ticket\TicketNotifier::closed($ticket, 'admin');
}
return 'updated';
}
/** @return string 提示码 */
private function ticket_action_reply(int $id): string {
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketRepository')) {
return 'reply_failed';
}
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$ticket = $repo->find($id);
if ($ticket === null || !empty($ticket['is_closed'])) {
return 'reply_failed';
}
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
$content = trim($content);
if ($content === '' || mb_strlen($content) > 3000) {
return 'reply_failed';
}
$reply_id = $repo->create_reply($id, 'admin', get_current_user_id(), $content, [], 0);
if ($reply_id <= 0) {
return 'reply_failed';
}
$repo->touch_reply($id, 'admin', get_current_user_id());
$repo->apply_reply_status($id, $ticket, 'admin');
\AKSRT\Ticket\TicketNotifier::user_replied($repo->find($id) ?? $ticket);
return 'replied';
}
/** @return string 提示码 */
private function ticket_action_type(): string {
if (!class_exists('\AKSRT\Ticket\TicketTypeService')) {
return 'failed';
}
$id = isset($_POST['type_id']) ? absint($_POST['type_id']) : 0;
$name = isset($_POST['name']) ? sanitize_text_field((string) wp_unslash($_POST['name'])) : '';
$name = trim($name);
$description = isset($_POST['description']) ? sanitize_text_field((string) wp_unslash($_POST['description'])) : '';
$admin_ids = isset($_POST['admin_ids']) ? array_map('absint', (array) $_POST['admin_ids']) : [];
if ($name === '' || \AKSRT\Ticket\TicketTypeService::name_taken($name, $id)) {
return 'failed';
}
$payload = [
'name' => $name,
'description' => $description,
'admin_ids' => \AKSRT\Ticket\TicketTypeService::sanitize_admin_ids($admin_ids),
];
if ($id > 0) {
if (!\AKSRT\Ticket\TicketTypeService::exists($id)) {
return 'failed';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
$saved = $wpdb->update($table, [
'name' => $payload['name'],
'description' => $payload['description'],
'admin_ids' => wp_json_encode($payload['admin_ids']),
'updated_at' => current_time('mysql'),
], ['id' => $id], ['%s', '%s', '%s', '%s'], ['%d']);
if ($saved === false) {
return 'failed';
}
\AKSRT\Ticket\TicketTypeService::flush();
return 'type_saved';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
if ($table === '') {
return 'failed';
}
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$inserted = $wpdb->insert($table, [
'name' => $payload['name'],
'description' => $payload['description'],
'admin_ids' => wp_json_encode($payload['admin_ids']),
'sort_order' => $max + 1,
'created_at' => current_time('mysql'),
], ['%s', '%s', '%s', '%d', '%s']);
if ($inserted !== 1) {
return 'failed';
}
\AKSRT\Ticket\TicketTypeService::flush();
return 'type_saved';
}
/** @return string 提示码 */
private function ticket_action_status_def(): string {
if (!class_exists('\AKSRT\Ticket\TicketStatusService')) {
return 'failed';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
if ($table === '') {
return 'failed';
}
$id = isset($_POST['status_id']) ? absint($_POST['status_id']) : 0;
$name = isset($_POST['name']) ? sanitize_text_field((string) wp_unslash($_POST['name'])) : '';
$name = trim($name);
$color = \AKSRT\Ticket\TicketStatusService::sanitize_color(
(string) wp_unslash($_POST['color'] ?? '')
);
$is_closed = isset($_POST['is_closed']) ? 1 : 0;
if ($name === '') {
return 'failed';
}
if ($id > 0) {
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
$id
), ARRAY_A);
if (!is_array($row)) {
return 'failed';
}
if (!empty($row['is_default'])) {
return 'status_default';
}
$saved = $wpdb->update($table, [
'name' => $name,
'color' => $color,
'is_closed' => $is_closed,
'updated_at' => current_time('mysql'),
], ['id' => $id], ['%s', '%s', '%d', '%s'], ['%d']);
if ($saved === false) {
return 'failed';
}
\AKSRT\Ticket\TicketStatusService::flush();
return 'status_saved';
}
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$inserted = $wpdb->insert($table, [
'status_key' => \AKSRT\Ticket\TicketStatusService::make_key($name),
'name' => $name,
'color' => $color,
'is_closed' => $is_closed,
'sort_order' => $max + 1,
'created_at' => current_time('mysql'),
], ['%s', '%s', '%s', '%d', '%d', '%s']);
if ($inserted !== 1) {
return 'failed';
}
\AKSRT\Ticket\TicketStatusService::flush();
return 'status_saved';
}
/** @return string 提示码 */
private function ticket_action_prereply(): string {
if (!class_exists('\AKSRT\Ticket\TicketPrereplyService')) {
return 'failed';
}
$id = isset($_POST['prereply_id']) ? absint($_POST['prereply_id']) : 0;
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
$content = trim($content);
if ($content === '') {
return 'failed';
}
if ($id > 0) {
if (\AKSRT\Ticket\TicketPrereplyService::find($id) === null) {
return 'failed';
}
return \AKSRT\Ticket\TicketPrereplyService::update($id, $content) ? 'prereply_saved' : 'failed';
}
return \AKSRT\Ticket\TicketPrereplyService::create($content) > 0 ? 'prereply_saved' : 'failed';
}
/** @return string 提示码 */
private function ticket_action_prereply_delete(): string {
$id = isset($_POST['prereply_id']) ? absint($_POST['prereply_id']) : 0;
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketPrereplyService')) {
return 'failed';
}
return \AKSRT\Ticket\TicketPrereplyService::delete($id) ? 'prereply_deleted' : 'failed';
}
/** @return string 提示码 */
private function ticket_action_type_delete(): string {
$id = isset($_POST['type_id']) ? absint($_POST['type_id']) : 0;
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketTypeService')) {
return 'failed';
}
if (\AKSRT\Ticket\TicketRepository::get_instance()->count_by_type($id) > 0) {
return 'type_in_use';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
if ($table === '') {
return 'failed';
}
$wpdb->delete($table, ['id' => $id], ['%d']);
\AKSRT\Ticket\TicketTypeService::flush();
return 'type_deleted';
}
/** @return string 提示码 */
private function ticket_action_status_delete(): string {
$id = isset($_POST['status_id']) ? absint($_POST['status_id']) : 0;
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketStatusService')) {
return 'failed';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
if ($table === '') {
return 'failed';
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
$id
), ARRAY_A);
if (!is_array($row)) {
return 'failed';
}
if (!empty($row['is_default'])) {
return 'status_default';
}
$key = sanitize_key((string) $row['status_key']);
if (\AKSRT\Ticket\TicketRepository::get_instance()->count_by_status($key) > 0) {
return 'status_in_use';
}
$wpdb->delete($table, ['id' => $id], ['%d']);
\AKSRT\Ticket\TicketStatusService::flush();
return 'status_deleted';
}
/** @return string 提示码 */
private function ticket_action_note(int $id): string { if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketNoteService')) {
return 'failed';
}
$note_id = isset($_POST['note_id']) ? absint($_POST['note_id']) : 0;
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
$content = trim($content);
if ($content === '') {
return 'failed';
}
if ($note_id > 0) {
return \AKSRT\Ticket\TicketNoteService::update($note_id, $content) ? 'note_saved' : 'failed';
}
return \AKSRT\Ticket\TicketNoteService::create($id, $content, get_current_user_id()) > 0
? 'note_saved'
: 'failed';
}
/** @return string 提示码 */
private function ticket_action_create(): string {
if (!class_exists('\AKSRT\Ticket\TicketRepository')) {
return 'create_failed';
}
$user_id = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0;
$title = isset($_POST['title']) ? sanitize_text_field((string) wp_unslash($_POST['title'])) : '';
$title = trim($title);
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
$content = trim($content);
$type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0;
if ($user_id <= 0 || !get_userdata($user_id) || $title === '' || $content === '') {
return 'create_failed';
}
if (!\AKSRT\Ticket\TicketTypeService::exists($type_id)) {
return 'create_failed';
}
$id = \AKSRT\Ticket\TicketRepository::get_instance()->create([
'ticket_num' => \AKSRT\Ticket\TicketNumber::generate(),
'user_id' => $user_id,
'title' => $title,
'ticket_type_id' => $type_id,
'content' => $content,
'order_number' => isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '',
'status' => \AKSRT\Ticket\TicketStatusService::KEY_ADMIN_REPLIED,
'post_admin_id' => get_current_user_id(),
'admin_id' => get_current_user_id(),
]);
if ($id <= 0) {
return 'create_failed';
}
$ticket = \AKSRT\Ticket\TicketRepository::get_instance()->find($id);
if (is_array($ticket)) {
\AKSRT\Ticket\TicketNotifier::user_replied($ticket);
}
return 'created';
}
private function ticket_page_url(array $extra = []): string {
return add_query_arg(
array_merge(['page' => self::TICKET_PAGE_SLUG], $extra),
admin_url('users.php')
);
}
/**
* 行内删除按钮:独立小表单 + 二次确认,nonce 以 action + id 为单位。
* 删除类操作不占用列表主表单,避免误删。
*/
private function ticket_delete_button(string $action, string $field, int $id, string $label): string {
return sprintf(
'<form method="post" action="%s" style="display:inline;" onsubmit="return confirm(\'%s\');">'
. '<input type="hidden" name="action" value="aksrt_ticket_update" />'
. '<input type="hidden" name="ticket_action" value="%s" />'
. '<input type="hidden" name="ticket_tab" value="config" />'
. '<input type="hidden" name="%s" value="%d" />%s'
. '<button type="submit" class="button-link-delete">%s</button></form>',
esc_url(admin_url('admin-post.php')),
esc_js(__('确认删除?', 'aksrt')),
esc_attr($action),
esc_attr($field),
$id,
wp_nonce_field('aksrt_ticket_' . $action . '_' . $id, '_wpnonce', true, false),
esc_html($label)
);
}
/** 工单页共享样式(徽章取状态颜色,两个 Tab 都用)。 */
private function ticket_page_css(): string {
return '.aksrt-ticket-badge{display:inline-block;padding:2px 10px;border-radius:999px;'
. 'background:color-mix(in srgb,var(--aksrt-ticket-color,#6b7280) 14%,transparent);'
. 'color:var(--aksrt-ticket-color,#6b7280);font-size:12px;font-weight:600;line-height:1.6;}'
. '.aksrt-ticket-detail{display:grid;gap:10px;padding:12px;border:1px solid #dcdcde;border-radius:6px;background:#fff;}'
. '.aksrt-ticket-detail__content,.aksrt-ticket-detail__reply-body{white-space:pre-wrap;word-break:break-word;}'
. '.aksrt-ticket-detail__replies{display:grid;gap:8px;margin:0;padding:0;list-style:none;}'
. '.aksrt-ticket-detail__replies li{padding:10px 12px;border-left:3px solid #c3c4c7;border-radius:4px;background:#f6f7f7;}'
. '.aksrt-ticket-detail__replies li.is-admin{border-left-color:#2271b1;background:#f0f6fc;}'
. '.aksrt-ticket-detail__reply-head time{color:#646970;font-size:11px;}'
. '.aksrt-ticket-detail__quote{margin:0 0 6px;padding:6px 10px;border-left:3px solid #dcdcde;background:#fff;color:#50575e;font-size:12px;}'
. '.aksrt-ticket-detail__notes{display:grid;gap:6px;margin:0;padding:0;list-style:none;}'
. '.aksrt-ticket-detail__notes time{color:#646970;font-size:11px;}'
. '.aksrt-ticket-detail__closed{margin:0;color:#8c8f94;font-size:12px;}';
}
}
@@ -0,0 +1,165 @@
<?php
/**
* 工单管理后台页 — 配置 Tab 渲染(部门 / 状态 / 预设回复)。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusTicketsConfig {
private function render_ticket_config_tab(): void {
$this->render_ticket_types_panel();
$this->render_ticket_statuses_panel();
$this->render_ticket_prereplies_panel();
}
/** 部门:表格 + 行内编辑 + 新建表单。 */
private function render_ticket_types_panel(): void {
$types = \AKSRT\Ticket\TicketTypeService::all();
$edit_id = isset($_GET['type_id']) ? absint($_GET['type_id']) : 0;
$editing = null;
foreach ($types as $row) {
if ((int) $row['id'] === $edit_id) {
$editing = $row;
break;
}
}
echo '<h3>' . esc_html__('工单部门', 'aksrt') . '</h3>';
echo '<table class="widefat fixed striped posts" style="max-width:900px;">';
echo '<thead><tr><th width="8%">ID</th><th width="24%">' . esc_html__('名称', 'aksrt') . '</th>'
. '<th width="38%">' . esc_html__('说明', 'aksrt') . '</th>'
. '<th width="20%">' . esc_html__('受理管理员', 'aksrt') . '</th><th width="10%"></th></tr></thead><tbody>';
foreach ($types as $row) {
$edit_url = add_query_arg(['type_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']));
$cells = sprintf(
'<tr><td>%d</td><td>%s</td><td>%s</td><td>%s</td><td>',
(int) $row['id'],
esc_html((string) $row['name']),
esc_html((string) $row['description'] !== '' ? (string) $row['description'] : '—'),
esc_html($row['admin_ids'] === [] ? '—' : implode(', ', $row['admin_ids']))
);
$cells .= '<a href="' . esc_url($edit_url) . '">' . esc_html__('编辑', 'aksrt') . '</a> ';
$cells .= $this->ticket_delete_button('type_delete', 'type_id', (int) $row['id'], __('删除', 'aksrt'));
$cells .= '</td></tr>';
echo $cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</tbody></table>';
echo '<h4>' . esc_html($editing === null ? __('新增部门', 'aksrt') : __('编辑部门', 'aksrt')) . '</h4>';
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
echo '<input type="hidden" name="action" value="aksrt_ticket_update" />';
echo '<input type="hidden" name="ticket_action" value="type" />';
echo '<input type="hidden" name="ticket_tab" value="config" />';
echo '<input type="hidden" name="type_id" value="' . esc_attr((string) ($editing['id'] ?? 0)) . '" />';
echo wp_nonce_field('aksrt_ticket_type_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
echo '<p><input type="text" name="name" value="' . esc_attr((string) ($editing['name'] ?? '')) . '" placeholder="' . esc_attr__('部门名称', 'aksrt') . '" /></p>';
echo '<p><input type="text" name="description" class="large-text" value="' . esc_attr((string) ($editing['description'] ?? '')) . '" placeholder="' . esc_attr__('部门说明(可选)', 'aksrt') . '" /></p>';
echo '<p><input type="text" name="admin_ids" class="large-text" value="' . esc_attr(implode(',', (array) ($editing['admin_ids'] ?? []))) . '" placeholder="' . esc_attr__('受理管理员用户 ID,逗号分隔(可选)', 'aksrt') . '" /></p>';
submit_button(__('保存部门', 'aksrt'), 'primary', '', false);
echo '</form>';
}
/** 自定义状态:默认状态拒绝改删。 */
private function render_ticket_statuses_panel(): void {
$statuses = \AKSRT\Ticket\TicketStatusService::all();
$edit_id = isset($_GET['status_id']) ? absint($_GET['status_id']) : 0;
$editing = null;
foreach ($statuses as $row) {
if ((int) $row['id'] === $edit_id) {
$editing = $row;
break;
}
}
echo '<h3>' . esc_html__('工单状态', 'aksrt') . '</h3>';
echo '<p class="description">' . esc_html__('默认状态不可修改或删除;删除非默认状态前需先清空该状态下的工单。', 'aksrt') . '</p>';
echo '<table class="widefat fixed striped posts" style="max-width:760px;">';
echo '<thead><tr><th width="8%">ID</th><th width="22%">' . esc_html__('名称', 'aksrt') . '</th>'
. '<th width="18%">' . esc_html__('颜色', 'aksrt') . '</th>'
. '<th width="16%">' . esc_html__('完结', 'aksrt') . '</th>'
. '<th width="16%">' . esc_html__('默认', 'aksrt') . '</th><th width="20%"></th></tr></thead><tbody>';
foreach ($statuses as $row) {
$status_cells = sprintf(
'<tr><td>%d</td><td>%s</td>'
. '<td><span class="aksrt-ticket-badge" style="--aksrt-ticket-color:%s">%s</span></td>'
. '<td>%s</td><td>%s</td><td>',
(int) $row['id'],
esc_html((string) $row['name']),
esc_attr((string) $row['color']),
esc_html((string) $row['color']),
esc_html(!empty($row['is_closed']) ? __('是', 'aksrt') : __('否', 'aksrt')),
esc_html(!empty($row['is_default']) ? __('是', 'aksrt') : __('否', 'aksrt'))
);
if (empty($row['is_default'])) {
$status_cells .= '<a href="' . esc_url(add_query_arg(['status_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']))) . '">' . esc_html__('编辑', 'aksrt') . '</a> ';
$status_cells .= $this->ticket_delete_button('status_delete', 'status_id', (int) $row['id'], __('删除', 'aksrt'));
} else {
$status_cells .= '—';
}
$status_cells .= '</td></tr>';
echo $status_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</tbody></table>';
echo '<h4>' . esc_html($editing === null ? __('新增状态', 'aksrt') : __('编辑状态', 'aksrt')) . '</h4>';
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
echo '<input type="hidden" name="action" value="aksrt_ticket_update" />';
echo '<input type="hidden" name="ticket_action" value="status_def" />';
echo '<input type="hidden" name="ticket_tab" value="config" />';
echo '<input type="hidden" name="status_id" value="' . esc_attr((string) ($editing['id'] ?? 0)) . '" />';
echo wp_nonce_field('aksrt_ticket_status_def_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
echo '<p><input type="text" name="name" value="' . esc_attr((string) ($editing['name'] ?? '')) . '" placeholder="' . esc_attr__('状态名称', 'aksrt') . '" /> ';
echo '<input type="text" name="color" value="' . esc_attr((string) ($editing['color'] ?? '#6b7280')) . '" placeholder="#6b7280" style="width:120px;" /> ';
echo '<label><input type="checkbox" name="is_closed" value="1"' . checked(!empty($editing['is_closed']), true, false) . ' /> ' . esc_html__('完结状态', 'aksrt') . '</label> ';
submit_button(__('保存状态', 'aksrt'), 'primary', '', false);
echo '</p></form>';
}
/** 预设回复:表格 + 行内编辑 + 新建表单。 */
private function render_ticket_prereplies_panel(): void {
$rows = \AKSRT\Ticket\TicketPrereplyService::all();
$edit_id = isset($_GET['prereply_id']) ? absint($_GET['prereply_id']) : 0;
$editing = null;
foreach ($rows as $row) {
if ((int) $row['id'] === $edit_id) {
$editing = $row;
break;
}
}
echo '<h3>' . esc_html__('预设回复', 'aksrt') . '</h3>';
echo '<table class="widefat fixed striped posts" style="max-width:900px;">';
echo '<thead><tr><th width="8%">ID</th><th width="72%">' . esc_html__('内容', 'aksrt') . '</th><th width="20%"></th></tr></thead><tbody>';
if ($rows === []) {
echo '<tr><td colspan="3">' . esc_html__('暂无预设回复', 'aksrt') . '</td></tr>';
}
foreach ($rows as $row) {
$reply_cells = sprintf(
'<tr><td>%d</td><td>%s</td><td>',
(int) $row['id'],
esc_html((string) $row['content'])
);
$reply_cells .= '<a href="' . esc_url(add_query_arg(['prereply_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']))) . '">' . esc_html__('编辑', 'aksrt') . '</a> ';
$reply_cells .= $this->ticket_delete_button('prereply_delete', 'prereply_id', (int) $row['id'], __('删除', 'aksrt'));
$reply_cells .= '</td></tr>';
echo $reply_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</tbody></table>';
echo '<h4>' . esc_html($editing === null ? __('新增预设回复', 'aksrt') : __('编辑预设回复', 'aksrt')) . '</h4>';
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
echo '<input type="hidden" name="action" value="aksrt_ticket_update" />';
echo '<input type="hidden" name="ticket_action" value="prereply" />';
echo '<input type="hidden" name="ticket_tab" value="config" />';
echo '<input type="hidden" name="prereply_id" value="' . esc_attr((string) ($editing['id'] ?? 0)) . '" />';
echo wp_nonce_field('aksrt_ticket_prereply_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
echo '<p><textarea name="content" rows="3" class="large-text" placeholder="' . esc_attr__('预设回复内容', 'aksrt') . '">' . esc_textarea((string) ($editing['content'] ?? '')) . '</textarea></p>';
submit_button(__('保存预设回复', 'aksrt'), 'primary', '', false);
echo '</form>';
}
}
+271
View File
@@ -0,0 +1,271 @@
<?php
/**
* 工单管理后台页 — 工单列表 Tab 渲染。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusTicketsList {
private function render_ticket_list_tab(): void {
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
$type_id = isset($_GET['ticket_type_id']) ? absint($_GET['ticket_type_id']) : 0;
$s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
$paged = isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1;
$statuses = \AKSRT\Ticket\TicketStatusService::all();
$types = \AKSRT\Ticket\TicketTypeService::all();
$base_url = remove_query_arg(['status', 'ticket_type_id', 's', 'paged', 'id'], $this->ticket_page_url());
// 状态 subsubsub(完结态单独放最后,默认视图不显示已关闭工单)
echo '<ul class="subsubsub">';
$links = ['' => __('全部', 'aksrt')];
foreach ($statuses as $row) {
if (!empty($row['is_closed'])) {
continue;
}
$links[(string) $row['status_key']] = (string) $row['name'];
}
$i = 0;
foreach ($links as $key => $label) {
$url = $key === '' ? $base_url : add_query_arg('status', $key, $base_url);
$sep = ++$i < count($links) ? ' |' : '';
printf(
'<li><a class="%s" href="%s">%s</a>%s</li> ',
$status === $key ? 'current' : '',
esc_url($url),
esc_html($label),
esc_html($sep)
);
}
echo '</ul>';
// 筛选 + 搜索
echo '<form method="get" class="search-form" style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:10px 0;">';
echo '<input type="hidden" name="page" value="' . esc_attr(self::TICKET_PAGE_SLUG) . '" />';
echo '<select name="status">';
echo '<option value="">' . esc_html__('全部状态', 'aksrt') . '</option>';
foreach ($statuses as $row) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr((string) $row['status_key']),
selected($status, (string) $row['status_key'], false),
esc_html((string) $row['name'])
);
}
echo '</select> ';
echo '<select name="ticket_type_id">';
echo '<option value="">' . esc_html__('全部部门', 'aksrt') . '</option>';
foreach ($types as $row) {
printf(
'<option value="%d"%s>%s</option>',
(int) $row['id'],
selected($type_id, (int) $row['id'], false),
esc_html((string) $row['name'])
);
}
echo '</select> ';
echo '<input type="search" name="s" value="' . esc_attr($s) . '" placeholder="' . esc_attr__('工单号 / 标题', 'aksrt') . '" /> ';
submit_button(__('筛选', 'aksrt'), 'primary', '', false);
echo '</form>';
// 新建工单入口
printf(
'<p><button type="button" class="button" data-aksrt-ticket-new>%s</button></p>',
esc_html__('代客提交工单', 'aksrt')
);
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$result = $repo->paginate([
'status' => $status === '' ? [] : [$status],
'ticket_type_id' => $type_id,
'keywords' => $s,
'limit' => self::TICKET_PER_PAGE,
'offset' => ($paged - 1) * self::TICKET_PER_PAGE,
], true);
$items = $result['items'];
$total = $result['total'];
$open_id = isset($_GET['id']) ? absint($_GET['id']) : 0;
echo '<div class="table-box" style="overflow-y:auto;width:100%;">';
echo '<table class="widefat fixed striped posts" style="min-width:960px;">';
echo '<thead><tr>';
echo '<th width="6%">' . esc_html__('状态', 'aksrt') . '</th>';
echo '<th width="10%">' . esc_html__('工单号', 'aksrt') . '</th>';
echo '<th width="22%">' . esc_html__('标题', 'aksrt') . '</th>';
echo '<th width="10%">' . esc_html__('部门', 'aksrt') . '</th>';
echo '<th width="10%">' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th width="10%">' . esc_html__('跟进人', 'aksrt') . '</th>';
echo '<th width="12%">' . esc_html__('提交时间', 'aksrt') . '</th>';
echo '<th width="12%">' . esc_html__('最后回复', 'aksrt') . '</th>';
echo '</tr></thead><tbody>';
if ($items === []) {
echo '<tr><td colspan="8" align="center"><strong>' . esc_html__('暂无工单', 'aksrt') . '</strong></td></tr>';
}
foreach ($items as $ticket) {
$is_open = $open_id === (int) $ticket['id'];
printf(
'<tr><td><span class="aksrt-ticket-badge" style="--aksrt-ticket-color:%s">%s</span></td>'
. '<td>%s</td><td><a href="%s"><strong>%s</strong></a></td><td>%s</td><td>%s</td><td>%s</td>'
. '<td>%s</td><td>%s</td></tr>',
esc_attr((string) $ticket['status_color']),
esc_html((string) $ticket['status_label']),
esc_html((string) $ticket['ticket_num']),
esc_url(add_query_arg('id', (int) $ticket['id'], $base_url)),
esc_html((string) $ticket['title']),
esc_html((string) $ticket['ticket_type_name']),
esc_html((string) $ticket['user_name']),
esc_html((string) $ticket['admin_name'] !== '' ? (string) $ticket['admin_name'] : '—'),
esc_html((string) $ticket['post_time']),
esc_html((string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : '—')
);
if ($is_open) {
echo '<tr><td colspan="8">' . $this->render_ticket_detail_panel($ticket) . '</td></tr>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
echo '</tbody></table></div>';
\AKSRT\Admin\Native\AdminListPagination::render(
self::TICKET_PAGE_SLUG,
$total,
$paged,
self::TICKET_PER_PAGE,
array_filter([
'status' => $status,
'ticket_type_id' => $type_id,
's' => $s,
], static fn($value): bool => $value !== '' && $value !== 0)
);
}
/**
* 行内展开的工单详情:沟通记录 + 回复框 + 状态迁移 + 后台备注。
*
* @param array<string,mixed> $ticket
*/
private function render_ticket_detail_panel(array $ticket): string {
$id = (int) $ticket['id'];
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$replies = $repo->replies($id);
$notes = \AKSRT\Ticket\TicketNoteService::for_ticket($id);
$html = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail="' . esc_attr((string) $id) . '">';
// 工单正文
$html .= '<div class="aksrt-ticket-detail__meta">';
$html .= '<p><strong>' . esc_html__('工单号', 'aksrt') . ':</strong>' . esc_html((string) $ticket['ticket_num']) . '</p>';
$html .= '<p><strong>' . esc_html__('提交用户', 'aksrt') . ':</strong>' . esc_html((string) $ticket['user_name']) . ' (#' . esc_html((string) $ticket['user_id']) . ')</p>';
if ((string) $ticket['order_number'] !== '') {
$html .= '<p><strong>' . esc_html__('关联订单', 'aksrt') . ':</strong>' . esc_html((string) $ticket['order_number']) . '</p>';
}
$html .= '<p><strong>' . esc_html__('问题描述', 'aksrt') . ':</strong></p>';
$html .= '<div class="aksrt-ticket-detail__content">' . nl2br(esc_html((string) $ticket['content'])) . '</div>';
$html .= '</div>';
// 沟通记录
$html .= '<h4>' . esc_html__('沟通记录', 'aksrt') . '</h4>';
if ($replies === []) {
$html .= '<p class="description">' . esc_html__('暂无沟通记录', 'aksrt') . '</p>';
}
$html .= '<ol class="aksrt-ticket-detail__replies">';
foreach ($replies as $reply) {
$who = (string) $reply['author_type'] === 'admin'
? sprintf(
/* translators: %s admin name */
__('管理员 %s', 'aksrt'),
(string) $reply['author_name']
)
: (string) $reply['author_name'];
$html .= '<li class="is-' . esc_attr((string) $reply['author_type']) . '">';
$html .= '<div class="aksrt-ticket-detail__reply-head"><strong>' . esc_html($who) . '</strong>'
. ' <time>' . esc_html((string) $reply['created_at']) . '</time></div>';
if (is_array($reply['quote'] ?? null)) {
$quote = $reply['quote'];
$html .= '<blockquote class="aksrt-ticket-detail__quote">'
. esc_html(!empty($quote['is_deleted']) ? __('原回复已删除', 'aksrt') : (string) $quote['content'])
. '</blockquote>';
}
$html .= '<div class="aksrt-ticket-detail__reply-body">' . nl2br(esc_html((string) $reply['content'])) . '</div>';
$html .= '</li>';
}
$html .= '</ol>';
// 管理员回复:已完结工单不再给回复框,只提示已关闭。
if (!empty($ticket['is_closed'])) {
$html .= '<p class="aksrt-ticket-detail__closed">' . esc_html__('工单已关闭,无法继续回复。', 'aksrt') . '</p>';
} else {
$html .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-ticket-detail__reply-form">';
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
$html .= '<input type="hidden" name="ticket_action" value="reply" />';
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
$html .= '<input type="hidden" name="ticket_tab" value="tickets" />';
$html .= wp_nonce_field('aksrt_ticket_reply_' . $id, '_wpnonce', true, false);
$html .= '<p><textarea name="content" rows="3" class="large-text" placeholder="' . esc_attr__('填写回复内容', 'aksrt') . '"></textarea></p>';
$html .= '<p><button type="submit" class="button button-primary">' . esc_html__('提交回复', 'aksrt') . '</button></p>';
$html .= '</form>';
}
// 状态迁移 / 部门 / 跟进人
$html .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-ticket-detail__status-form">';
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
$html .= '<input type="hidden" name="ticket_action" value="status" />';
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
$html .= '<input type="hidden" name="ticket_tab" value="tickets" />';
$html .= wp_nonce_field('aksrt_ticket_status_' . $id, '_wpnonce', true, false);
$html .= '<p><label>' . esc_html__('状态', 'aksrt') . ' <select name="status">';
foreach (\AKSRT\Ticket\TicketStatusService::all() as $row) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr((string) $row['status_key']),
selected((string) $ticket['status'], (string) $row['status_key'], false),
esc_html((string) $row['name'])
);
}
$html .= '</select></label> ';
$html .= '<label>' . esc_html__('部门', 'aksrt') . ' <select name="ticket_type_id">';
foreach (\AKSRT\Ticket\TicketTypeService::all() as $row) {
printf(
'<option value="%d"%s>%s</option>',
(int) $row['id'],
selected((int) $ticket['ticket_type_id'], (int) $row['id'], false),
esc_html((string) $row['name'])
);
}
$html .= '</select></label> ';
$html .= '<label>' . esc_html__('跟进人 ID', 'aksrt') . ' <input type="number" name="admin_id" value="' . esc_attr((string) $ticket['admin_id']) . '" min="0" style="width:80px;" /></label> ';
$html .= '<button type="submit" class="button">' . esc_html__('保存', 'aksrt') . '</button></p>';
$html .= '</form>';
// 后台备注
$html .= '<h4>' . esc_html__('后台备注', 'aksrt') . '</h4>';
if ($notes === []) {
$html .= '<p class="description">' . esc_html__('暂无备注', 'aksrt') . '</p>';
}
$html .= '<ul class="aksrt-ticket-detail__notes">';
foreach ($notes as $note) {
$html .= '<li><strong>' . esc_html((string) $note['admin_name']) . '</strong>'
. ' <time>' . esc_html((string) $note['created_at']) . '</time>'
. '<div>' . nl2br(esc_html((string) $note['content'])) . '</div></li>';
}
$html .= '</ul>';
$html .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
$html .= '<input type="hidden" name="ticket_action" value="note" />';
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
$html .= '<input type="hidden" name="ticket_tab" value="tickets" />';
$html .= wp_nonce_field('aksrt_ticket_note_' . $id, '_wpnonce', true, false);
$html .= '<p><input type="text" name="content" class="large-text" placeholder="' . esc_attr__('添加内部备注(用户不可见)', 'aksrt') . '" /> '
. '<button type="submit" class="button">' . esc_html__('添加备注', 'aksrt') . '</button></p>';
$html .= '</form>';
$html .= '</div>';
return $html;
}
}
-28
View File
@@ -571,34 +571,6 @@ class SchemaPayment {
]],
],
],
/* ------------------------------------------------------------------
* 售后策略
* ------------------------------------------------------------------ */
'after_sale' => [
'key' => 'after_sale',
'title' => '售后策略',
'version' => '1.0.0',
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'main', 'title' => '售后功能', 'icon' => 'fa fas fa-undo-alt', 'fields' => [
['key' => 'after_sale_s', 'label' => '售后功能', 'type' => 'switch', 'default' => true, 'help' => '开启后用户可以申请售后;关闭后不再处理售后,前台「我的订单」不再显示申请售后按钮'],
['key' => 'after_sale_time_limit', 'label' => '申请售后时效(天)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 3650, 'help' => '订单支付后多少天内可以申请售后,超过时效将无法申请;0 表示不限制'],
['key' => 'after_sale_expired_text', 'label' => '超过时效提示', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '<div>抱歉,您的订单已超过售后申请时效</div>', 'rows' => 3, 'help' => '订单超过售后时效后,用户点击申请售后时弹窗提示的内容(支持HTML代码,请注意代码规范)'],
]],
['key' => 'form', 'title' => '申请表单', 'icon' => 'fa fas fa-file-alt', 'fields' => [
['key' => 'after_sale_reasons', 'label' => '预置原因', 'type' => 'group', 'button_title' => '添加原因', 'help' => '用户申请售后时可选择的原因', 'default' => [['t' => '质量问题'], ['t' => '假冒品牌'], ['t' => '其他原因']], 'fields' => [
['key' => 't', 'label' => '原因', 'type' => 'input', 'default' => ''],
]],
['key' => 'after_sale_desc', 'label' => '申请售后说明', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '', 'rows' => 5, 'help' => '用户申请售后时显示的内容(支持HTML代码,请注意代码规范)'],
['key' => 'after_sale_materials', 'label' => '申请所需材料', 'type' => 'group', 'button_title' => '添加材料', 'help' => '用户提交售后申请时需要提供的资料,如联系邮箱、联系电话;此处的信息收集,仅用于售后联系与审核', 'default' => [['t' => '联系邮箱'], ['t' => '联系电话']], 'fields' => [
['key' => 't', 'label' => '资料项', 'type' => 'input', 'default' => ''],
]],
['key' => 'after_sale_reason_placeholder', 'label' => '申请说明占位文案', 'type' => 'input', 'default' => '必填,请您详细填写申请原因', 'help' => '前台申请售后弹窗中「申请说明」输入框的提示文字'],
['key' => 'after_sale_img_s', 'label' => '申请售后可上传图片', 'type' => 'switch', 'default' => true, 'help' => '开启后申请售后弹窗中出现图片上传入口(最多上传3张)'],
]],
],
],
],
];
}
+2 -2
View File
@@ -81,8 +81,8 @@ class SchemaPermissions {
['label' => '每日签到', 'value' => 'signin'],
['label' => '前台投稿', 'value' => 'front_submit'],
['label' => '打赏用户', 'value' => 'tip_user'],
['label' => '申请售后', 'value' => 'after_sale_apply'],
['label' => '撤销售后', 'value' => 'after_sale_cancel'],
['label' => '提交工单', 'value' => 'ticket_create'],
['label' => '回复工单', 'value' => 'ticket_reply'],
]],
]],
['key' => 'providers', 'title' => '服务商配置', 'dependsOn' => ['key' => 'enabled', 'value' => true], 'fields' => $provider_fields],
+1
View File
@@ -612,6 +612,7 @@ class SchemaUsers {
],
],
],
'ticket' => Users\SchemaUsersTicket::build(),
];
}
}
@@ -0,0 +1,40 @@
<?php
/**
* 工单配置分区(域 users,分区 ticket)。
*
* 字段键、顺序、类型、默认值与文案需与
* assets/admin/src/schema/sections/users/ticket.js 保持 1:1
* (tests/widget-system/ticket-schema-contract.test.cjs 守护)。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Schema\Users;
defined('ABSPATH') || exit;
class SchemaUsersTicket {
public static function build(): array {
return [
'key' => 'ticket',
'title' => '工单中心',
'version' => '1.0.0',
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'main', 'title' => '工单功能', 'icon' => 'fa fas fa-headset', 'fields' => [
['key' => 'ticket_s', 'label' => '工单功能', 'type' => 'switch', 'default' => true, 'help' => '开启后用户可在用户中心提交工单;关闭后工单中心不再开放,后台工单列表仍可查看历史工单'],
['key' => 'ticket_num_prefix', 'label' => '工单号前缀', 'type' => 'input', 'default' => 'T', 'help' => '工单号形如「前缀+年月日+6位随机数」,仅支持字母与数字'],
['key' => 'ticket_notice_open', 'label' => '提交后显示平台公告', 'type' => 'switch', 'default' => true, 'help' => '开启后用户提交工单成功时展示下方公告内容'],
['key' => 'ticket_notice_text', 'label' => '公告内容', 'type' => 'textarea', 'sanitize_type' => 'html', 'rows' => 3, 'default' => '<div>提交工单后请耐心等待处理</div>', 'help' => '用户提交工单后展示的内容(支持HTML代码,请注意代码规范)'],
]],
['key' => 'form', 'title' => '提交设置', 'icon' => 'fa fas fa-file-alt', 'fields' => [
['key' => 'ticket_attachment_s', 'label' => '允许上传图片', 'type' => 'switch', 'default' => true, 'help' => '开启后工单内容与回复可上传图片(复用评论图片上传的大小与类型校验)'],
['key' => 'ticket_attachment_max', 'label' => '单个工单图片上限', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 9, 'help' => '工单内容与每条回复各自可上传的图片数量上限'],
['key' => 'ticket_order_link_s', 'label' => '允许关联订单', 'type' => 'switch', 'default' => true, 'help' => '开启后新建工单时可选择关联本人一个已支付订单;关闭后工单与订单完全解耦'],
['key' => 'ticket_urge_interval', 'label' => '催单冷却(分钟)', 'type' => 'number', 'default' => 15, 'min' => 1, 'max' => 1440, 'help' => '同一工单两次催单之间的最小间隔'],
]],
],
];
}
}
+5 -1
View File
@@ -12,6 +12,7 @@ class DBMigrator {
use DBMigratorPart3;
use DBMigratorIdentity;
use DBMigratorVip;
use DBMigratorTicket;
/** Current schema version; changing it re-runs dbDelta. */
// 2.5.1: order_aftersale 新增 images 列(售后举证图片)。dbDelta 仅在版本变化时
@@ -20,7 +21,10 @@ class DBMigrator {
// 2.6.0: order_aftersale 新增 handling_log 列(处理记录:每次状态提交一条
// 「状态 + 备注 + 来源 + 时间」),取代单一的 admin_note / reviewed_at / completed_at,
// 并移除从未被写入的 gateway_request_id / gateway_refund_id / gateway_result。
const SCHEMA_VERSION = '2.6.0';
// 3.0.0: 售后(order_aftersale)整体替换为工单系统(6 张 aksrt_ticket* 表)。
// 工单不再绑定订单退款流程:标题 + 部门 + 描述为必填,关联订单为可选项;
// 沟通记录从工单上的 JSON 日志升级为独立的 ticket_reply 行(支持引用回复)。
const SCHEMA_VERSION = '3.0.0';
const OPT_KEY = 'aksrt_db_schema_version';
+1 -20
View File
@@ -204,26 +204,6 @@ trait DBMigratorPart2 {
) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_order);
// ---------- order after-sales ----------
$sql_order_aftersale = "CREATE TABLE {$prefix}aksrt_order_aftersale (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_number VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
aftersale_type VARCHAR(24) NOT NULL DEFAULT 'refund',
reason TEXT NOT NULL,
apply_form LONGTEXT DEFAULT NULL,
images LONGTEXT DEFAULT NULL,
handling_log LONGTEXT DEFAULT NULL,
refund_amount VARCHAR(32) NOT NULL DEFAULT '0',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_order_status (order_number, status),
KEY idx_user_created (user_id, created_at),
KEY idx_status_created (status, created_at)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_order_aftersale);
// ---------- 9. 作者收益台账 ----------
$sql_revenue = "CREATE TABLE {$prefix}aksrt_revenue (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -555,5 +535,6 @@ trait DBMigratorPart2 {
self::migrate_identity_tables($prefix, $charset_collate);
self::migrate_vip_tables($prefix, $charset_collate);
self::migrate_ticket_tables($prefix, $charset_collate);
}
}
+6 -1
View File
@@ -25,8 +25,13 @@ trait DBMigratorPart3 {
'dl_token' => 'aksrt_dl_token',
'notification' => 'aksrt_notification',
'order' => 'aksrt_order',
'order_aftersale' => 'aksrt_order_aftersale',
'order_item' => 'aksrt_order_item',
'ticket' => 'aksrt_ticket',
'ticket_reply' => 'aksrt_ticket_reply',
'ticket_status' => 'aksrt_ticket_status',
'ticket_type' => 'aksrt_ticket_type',
'ticket_prereply' => 'aksrt_ticket_prereply',
'ticket_note' => 'aksrt_ticket_note',
'revenue' => 'aksrt_revenue',
'withdrawal' => 'aksrt_withdrawal',
'report' => 'aksrt_report',
+177
View File
@@ -0,0 +1,177 @@
<?php
/**
* Ticket (工单) tables.
*
* 6 张表取代旧的 aksrt_order_aftersale:工单不再绑定订单退款流程,
* 标题 + 部门 + 描述为必填,关联订单为可选项。
*
* @package AKSRT
*/
namespace AKSRT\Core;
defined('ABSPATH') || exit;
trait DBMigratorTicket {
private static function migrate_ticket_tables(string $prefix, string $charset_collate): void {
$ticket = "CREATE TABLE {$prefix}aksrt_ticket (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ticket_num VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL,
ticket_type_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
content TEXT NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'pending',
order_number VARCHAR(32) NOT NULL DEFAULT '',
attachment LONGTEXT DEFAULT NULL,
last_reply_time DATETIME DEFAULT NULL,
post_time DATETIME NOT NULL,
notes TEXT DEFAULT NULL,
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,
created_at DATETIME NOT NULL,
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_ticket_num (ticket_num),
KEY idx_user_created (user_id, created_at),
KEY idx_status_created (status, created_at),
KEY idx_type_status (ticket_type_id, status),
KEY idx_order (order_number)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($ticket);
$reply = "CREATE TABLE {$prefix}aksrt_ticket_reply (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ticket_id BIGINT UNSIGNED NOT NULL,
author_type VARCHAR(16) NOT NULL DEFAULT 'user',
author_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
content TEXT NOT NULL,
attachment LONGTEXT DEFAULT NULL,
quote_reply_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_ticket_created (ticket_id, created_at),
KEY idx_quote (quote_reply_id)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($reply);
// status_key 是工单上 aksrt_ticket.status 的外延键:管理员改状态名只动 name,
// 存量工单的 status 不受影响。默认状态(is_default)不可改删。
$status = "CREATE TABLE {$prefix}aksrt_ticket_status (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
status_key VARCHAR(24) NOT NULL,
name VARCHAR(60) NOT NULL,
color VARCHAR(20) NOT NULL DEFAULT '#6b7280',
is_closed TINYINT(1) NOT NULL DEFAULT 0,
is_default TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_status_key (status_key)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($status);
$type = "CREATE TABLE {$prefix}aksrt_ticket_type (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(120) NOT NULL,
description TEXT DEFAULT NULL,
admin_ids LONGTEXT DEFAULT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($type);
$prereply = "CREATE TABLE {$prefix}aksrt_ticket_prereply (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
content TEXT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($prereply);
$note = "CREATE TABLE {$prefix}aksrt_ticket_note (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ticket_id BIGINT UNSIGNED NOT NULL,
admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
content TEXT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_ticket_created (ticket_id, created_at)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($note);
self::seed_ticket_statuses($prefix);
self::seed_ticket_types($prefix);
}
/**
* 工单状态 seeds:按 status_key 幂等补齐,不覆盖管理员已改过的名称与颜色。
* 「待接单」标记 is_default,后台拒绝改删。
*
* @return void
*/
private static function seed_ticket_statuses(string $prefix): void {
global $wpdb;
$table = $prefix . 'aksrt_ticket_status';
$now = current_time('mysql');
$seeds = [
['pending', '待接单', '#f59e0b', 0, 1],
['handling', '处理中', '#8b5cf6', 0, 0],
['user_replied', '待回复', '#3b82f6', 0, 0],
['admin_replied', '已回复', '#10b981', 0, 0],
['closed', '已关闭', '#6b7280', 1, 0],
];
$exists = $wpdb->get_col("SELECT status_key FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL
if (!is_array($exists)) {
$exists = [];
}
$sort = 0;
foreach ($seeds as [$key, $label, $color, $is_closed, $is_default]) {
if (in_array($key, $exists, true)) {
continue;
}
$wpdb->insert($table, [
'status_key' => $key,
'name' => $label,
'color' => $color,
'is_closed' => $is_closed,
'is_default' => $is_default,
'sort_order' => $sort++,
'created_at' => $now,
], ['%s', '%s', '%s', '%d', '%d', '%d', '%s']);
}
}
/** 工单部门 seeds:按 name 幂等补齐。 */
private static function seed_ticket_types(string $prefix): void {
global $wpdb;
$table = $prefix . 'aksrt_ticket_type';
$now = current_time('mysql');
$seeds = ['技术支持', '账号问题', '财务问题', '产品建议', '其他'];
$exists = $wpdb->get_col("SELECT name FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL
if (!is_array($exists)) {
$exists = [];
}
$sort = 0;
foreach ($seeds as $label) {
if (in_array($label, $exists, true)) {
continue;
}
$wpdb->insert($table, [
'name' => $label,
'sort_order' => $sort++,
'created_at' => $now,
], ['%s', '%d', '%s']);
}
}
}
+26 -10
View File
@@ -385,9 +385,11 @@ trait ScriptsPart1b {
// Orders is a lazy user-center panel, so its independent assets must
// be ready before client-side panel replacement can insert its markup.
$has_orders_panel = false;
$has_tickets_panel = false;
if (class_exists('\AKSRT\User\Center')) {
$dashboard_tabs = \AKSRT\User\Center::get_instance()->get_dashboard_tabs();
$has_orders_panel = isset($dashboard_tabs['orders']);
$has_tickets_panel = isset($dashboard_tabs['tickets']);
}
if ($has_orders_panel) {
$orders_css = get_template_directory() . '/assets/css/routes/orders.css';
@@ -411,16 +413,27 @@ trait ScriptsPart1b {
);
}
// 售后弹窗(申请 / 超时效提示 / 售后详细):与订单中心同面板、同条件入队。
$order_after_sale_js = get_template_directory() . '/assets/js/order-after-sale.js';
if (file_exists($order_after_sale_js)) {
wp_enqueue_script(
'aksrt-order-after-sale',
$base_uri . '/assets/js/order-after-sale.js',
['loader_js'],
(string) filemtime($order_after_sale_js),
true
);
// 工单中心(提交 / 详情 / 回复弹窗):与订单中心同面板、同条件入队。
if ($has_tickets_panel) {
$tickets_css = get_template_directory() . '/assets/css/routes/tickets.css';
if (file_exists($tickets_css)) {
wp_enqueue_style(
'aksrt-tickets',
$base_uri . '/assets/css/routes/tickets.css',
[$core_dep],
(string) filemtime($tickets_css)
);
}
$ticket_center_js = get_template_directory() . '/assets/js/ticket-center.js';
if (file_exists($ticket_center_js)) {
wp_enqueue_script(
'aksrt-ticket-center',
$base_uri . '/assets/js/ticket-center.js',
['loader_js'],
(string) filemtime($ticket_center_js),
true
);
}
}
}
@@ -440,6 +453,9 @@ trait ScriptsPart1b {
if ($has_orders_panel && wp_script_is('aksrt-order-center', 'registered')) {
$user_center_deps[] = 'aksrt-order-center';
}
if ($has_tickets_panel && wp_script_is('aksrt-ticket-center', 'registered')) {
$user_center_deps[] = 'aksrt-ticket-center';
}
wp_enqueue_script(
'aksrt-route-user-center',
$base_uri . '/assets/js/routes/user-center.js',
+12 -14
View File
@@ -498,22 +498,20 @@ trait ScriptsPart1c {
'desc' => (string) ($report['desc'] ?? ''),
];
})(),
// 售后策略数据(order-after-sale.js):预置原因、所需材料、申请说明与
// 占位文案、图片开关、超时效提示均读取后台「支付&付费 → 售后策略」。
'afterSale' => (function () {
if (!class_exists('\AKSRT\Settings\PaymentRuntime')) {
return ['enabled' => false, 'reasons' => [], 'materials' => [], 'desc' => '', 'imgEnabled' => false, 'maxImages' => 3, 'reasonPlaceholder' => '', 'expiredText' => ''];
// 工单配置(ticket-center.js):附件开关与上限、提交公告、催单冷却。
'ticket' => (function () {
if (!class_exists('\AKSRT\Ticket\TicketSettings')) {
return ['enabled' => false, 'attachmentEnabled' => false, 'attachmentMax' => 5, 'notice' => ['open' => false, 'text' => ''], 'urgeInterval' => 15];
}
$after_sale = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
return [
'enabled' => !empty($after_sale['enabled']),
'reasons' => array_values((array) ($after_sale['reasons'] ?? [])),
'materials' => array_values((array) ($after_sale['materials'] ?? [])),
'desc' => (string) ($after_sale['desc'] ?? ''),
'imgEnabled' => !empty($after_sale['img_enabled']),
'maxImages' => 3,
'reasonPlaceholder' => (string) ($after_sale['reason_placeholder'] ?? ''),
'expiredText' => (string) ($after_sale['expired_text'] ?? ''),
'enabled' => \AKSRT\Ticket\TicketSettings::enabled(),
'attachmentEnabled' => \AKSRT\Ticket\TicketSettings::attachment_enabled(),
'attachmentMax' => \AKSRT\Ticket\TicketSettings::attachment_max(),
'notice' => [
'open' => \AKSRT\Ticket\TicketSettings::notice_open(),
'text' => \AKSRT\Ticket\TicketSettings::notice_text(),
],
'urgeInterval' => \AKSRT\Ticket\TicketSettings::urge_interval(),
];
})(),
'stickyEnabled' => class_exists('\AKSRT\Settings\Settings') ? !empty(\AKSRT\Settings\Settings::get_post_list_style_settings()['sticky_s']) : true,
-427
View File
@@ -1,427 +0,0 @@
<?php
/** User applications and administrator status management for order after-sales. */
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
final class AfterSaleService {
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
/** 处理记录来源:后台提交 / 用户提交(申请与撤销)。 */
public const SOURCE_ADMIN = 'admin';
public const SOURCE_USER = 'user';
/** 处理记录上限:与 MetaAssetService::MAX_RECORDS 同口径,超出后只保留最近的一段。 */
private const MAX_LOG_ENTRIES = 50;
private static ?self $instance = null;
public static function get_instance(): self { return self::$instance ??= new self(); }
public function init(): void { add_action('rest_api_init', [$this, 'register_routes']); }
public function register_routes(): void {
$number = '(?P<order_number>[0-9]{20}|[0-9]{22})';
register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale', [
['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_submit'], 'permission_callback' => [$this, 'require_user'], 'args' => [
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'reason' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
'reason_label' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'materials' => ['required' => false, 'type' => 'array', 'validate_callback' => static function ($value): bool {
if (!is_array($value) || count($value) > 10) return false;
foreach ($value as $item) {
if (!is_array($item)) return false;
foreach (['label', 'value'] as $key) {
if (isset($item[$key]) && !is_string($item[$key])) return false;
}
}
return true;
}],
'description' => ['required' => false, 'sanitize_callback' => 'sanitize_textarea_field'],
'images' => ['required' => false, 'type' => 'array', 'validate_callback' => static function ($value): bool {
return is_array($value) && count($value) <= 3;
}],
]],
['methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'rest_list'], 'permission_callback' => [$this, 'require_user'], 'args' => [
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
]],
['methods' => 'DELETE', 'callback' => [$this, 'rest_cancel'], 'permission_callback' => [$this, 'require_user'], 'args' => [
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
]],
]);
// 售后举证图片上传:复用评论图片上传链路(大小 / 类型 / 内容审核同一套校验)。
register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale/upload-image', [
['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_upload_image'], 'permission_callback' => [$this, 'require_after_sale_image']],
]);
}
/** 售后举证图片上传权限:登录 + nonce + 售后开启 + 图片开关开启 + 订单归属本人。 */
public function require_after_sale_image(\WP_REST_Request $request) {
$user = $this->require_user($request);
if (is_wp_error($user)) {
return $user;
}
if (!class_exists('\AKSRT\Settings\PaymentRuntime')) {
return new \WP_Error('aksrt_after_sale_forbidden', __('售后功能暂未开启', 'aksrt'), ['status' => 403]);
}
$settings = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
if (empty($settings['enabled']) || empty($settings['img_enabled'])) {
return new \WP_Error('aksrt_after_sale_forbidden', __('售后图片上传未开启', 'aksrt'), ['status' => 403]);
}
$number = sanitize_text_field((string) $request->get_param('order_number'));
if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) {
return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]);
}
return true;
}
public function rest_upload_image(\WP_REST_Request $request) {
if (!class_exists('\AKSRT\Admin\UserController')) {
return new \WP_Error('aksrt_after_sale_upload', __('上传服务不可用', 'aksrt'), ['status' => 500]);
}
return \AKSRT\Admin\UserController::get_instance()->rest_upload_comment_image($request);
}
public function require_user(\WP_REST_Request $request) {
if (!is_user_logged_in()) return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
$nonce = (string) $request->get_header('X-WP-Nonce');
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest') ? true : new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
public function rest_submit(\WP_REST_Request $request) {
$captcha = $this->require_captcha($request, 'after_sale_apply');
if (is_wp_error($captcha)) {
return $captcha;
}
$images = $request->get_param('images');
$materials = $request->get_param('materials');
$result = $this->create(
get_current_user_id(),
(string) $request->get_param('order_number'),
(string) $request->get_param('reason'),
is_array($images) ? $images : [],
[
'reason_label' => (string) $request->get_param('reason_label'),
'materials' => is_array($materials) ? $materials : [],
'description' => (string) $request->get_param('description'),
]
);
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
}
public function rest_list(\WP_REST_Request $request) {
$number = sanitize_text_field((string) $request->get_param('order_number'));
if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]);
return rest_ensure_response(['success' => true, 'data' => $this->list_for_order($number)]);
}
public function rest_cancel(\WP_REST_Request $request) {
$captcha = $this->require_captcha($request, 'after_sale_cancel');
if (is_wp_error($captcha)) {
return $captcha;
}
$result = $this->cancel_for_user(get_current_user_id(), (string) $request->get_param('order_number'));
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
}
/** 人机验证(申请售后 / 撤销售后):场景未启用时 aksrt_require_captcha 直接放行。 */
private function require_captcha(\WP_REST_Request $request, string $scene) {
if (!function_exists('aksrt_require_captcha')) {
return true;
}
$params = $request->get_json_params();
return aksrt_require_captcha($scene, is_array($params) ? $params : []);
}
/** @param array<string,mixed> $apply_form */
public function create(int $user_id, string $order_number, string $reason, array $images = [], array $apply_form = []) {
$reason = sanitize_textarea_field($reason);
$images = $this->sanitize_images($images);
$apply_form = $this->sanitize_apply_form($apply_form);
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number) || $reason === '' || mb_strlen($reason) > 2000) {
return new \WP_Error('aksrt_after_sale_invalid', __('请填写有效的售后原因', 'aksrt'), ['status' => 400]);
}
global $wpdb;
$lock = 'aksrt_after_sale_' . md5($order_number);
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
$order = OrderRepository::get_instance()->get_user_order($user_id, $order_number);
if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('只有已支付订单可以申请售后', 'aksrt'), ['status' => 409]);
// 只挡「仍在流转」的工单。已完结(completed)的工单不挡——能否再售后由
// 「订单是否已退款」决定,而订单已退款时上面的 STATUS_PAID 校验已先挡掉。
$active = $this->find_active($order_number);
if ($active) {
if ((int) ($active['user_id'] ?? 0) === $user_id && hash_equals((string) ($active['reason'] ?? ''), $reason)) {
return $active;
}
return new \WP_Error('aksrt_after_sale_exists', __('该订单已有未结束售后申请', 'aksrt'), ['status' => 409]);
}
$amount = $this->refund_amount($order);
if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]);
$now = current_time('mysql');
$inserted = $wpdb->insert($this->table(), [
'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund',
'reason' => $reason,
'apply_form' => $apply_form === [] ? null : wp_json_encode($apply_form, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'images' => wp_json_encode($images), 'refund_amount' => $amount,
'status' => self::STATUS_PENDING, 'created_at' => $now,
'handling_log' => $this->encode_log([$this->log_entry(self::STATUS_PENDING, '', self::SOURCE_USER, $now)]),
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
/** 售后举证图片:最多 3 条,统一走 esc_url_raw;非字符串与空值直接丢弃。 */
private function sanitize_images(array $images): array {
$clean = [];
foreach ($images as $url) {
if (!is_string($url) || count($clean) >= 3) {
continue;
}
$url = esc_url_raw(trim($url));
if ($url !== '') {
$clean[] = $url;
}
}
return $clean;
}
/**
* 结构化申请表单(预置原因 / 申请资料 / 申请说明),供售后详细弹窗分行展示;
* 融合字符串 reason 保持原样入库,保证后台列表与历史数据兼容。
*
* @param array<string,mixed> $form
* @return array<string,mixed>
*/
private function sanitize_apply_form(array $form): array {
$reason_label = sanitize_text_field((string) ($form['reason_label'] ?? ''));
if (mb_strlen($reason_label) > 120) $reason_label = mb_substr($reason_label, 0, 120);
$materials = [];
if (is_array($form['materials'] ?? null)) {
foreach ($form['materials'] as $item) {
if (!is_array($item) || count($materials) >= 10) continue;
$label = sanitize_text_field((string) ($item['label'] ?? ''));
$value = sanitize_text_field((string) ($item['value'] ?? ''));
if ($label === '' || $value === '') continue;
if (mb_strlen($label) > 120) $label = mb_substr($label, 0, 120);
if (mb_strlen($value) > 500) $value = mb_substr($value, 0, 500);
$materials[] = ['label' => $label, 'value' => $value];
}
}
$description = sanitize_textarea_field((string) ($form['description'] ?? ''));
if (mb_strlen($description) > 2000) $description = mb_substr($description, 0, 2000);
$clean = [];
if ($reason_label !== '') $clean['reason_label'] = $reason_label;
if ($materials !== []) $clean['materials'] = $materials;
if ($description !== '') $clean['description'] = $description;
return $clean;
}
/**
* 退款金额:积分单取整,现金单经 AssetAmount 归一为两位小数;
* 金额无法解析时返回 null,由 create() 拒掉这次申请。
*/
private function refund_amount(array $order): ?string {
if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0));
try { return \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; }
}
/** 最新一条售后工单(列表视图只取最新态,避免整表拉取)。 */
public function latest_for_order(string $order_number): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC LIMIT 1", $order_number), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
public function cancel_for_user(int $user_id, string $order_number) {
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number)) return new \WP_Error('aksrt_after_sale_invalid', __('售后申请无效', 'aksrt'), ['status' => 400]);
global $wpdb;
$lock = 'aksrt_after_sale_' . md5($order_number);
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
$row = $this->find_active($order_number);
if (!$row || (int) ($row['user_id'] ?? 0) !== $user_id) return new \WP_Error('aksrt_after_sale_missing', __('没有可取消的售后申请', 'aksrt'), ['status' => 404]);
if ((string) ($row['status'] ?? '') !== self::STATUS_PENDING) return new \WP_Error('aksrt_after_sale_state', __('售后申请已进入处理阶段,无法取消', 'aksrt'), ['status' => 409]);
if (!$this->update((int) $row['id'], ['status' => self::STATUS_CANCELLED])) return new \WP_Error('aksrt_after_sale_db', __('取消售后申请失败', 'aksrt'), ['status' => 500]);
$this->append_log((int) $row['id'], $row, self::STATUS_CANCELLED, '', self::SOURCE_USER);
return $this->get((int) $row['id']);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
/** @return list<array<string,mixed>> */
public function list_for_order(string $order_number): array {
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC", $order_number), ARRAY_A);
return array_map([$this, 'normalize'], (array) $rows);
}
/** @return list<array<string,mixed>> */
public function admin_list(array $args = []): array {
global $wpdb;
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
$valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_REJECTED, self::STATUS_CANCELLED, self::STATUS_COMPLETED];
if ($status !== '' && in_array($status, $valid, true)) { $where[] = 'status = %s'; $params[] = $status; }
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$date = sanitize_text_field((string) ($args[$key] ?? ''));
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$sql = "SELECT * FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '') . ' ORDER BY id DESC LIMIT %d OFFSET %d';
$params[] = $limit; $params[] = $offset;
return array_map([$this, 'normalize'], (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A));
}
public function count(array $args = []): int {
global $wpdb;
$status = sanitize_key((string) ($args['status'] ?? ''));
$where = [];
$params = [];
if ($status !== '') { $where[] = 'status = %s'; $params[] = $status; }
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$date = sanitize_text_field((string) ($args[$key] ?? ''));
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
$sql = "SELECT COUNT(*) FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '');
return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql));
}
/** A refund status transition is valid only for its own active application. */
public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool {
if ($after_sale_id <= 0 || $order_number === '') return false;
$row = $this->get($after_sale_id);
return is_array($row)
&& hash_equals((string) ($row['order_number'] ?? ''), $order_number)
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true);
}
/**
* 管理员直接设置售后工单状态:纯状态更新,不触发订单、资产与支付渠道联动。
*
* 每次提交都往 handling_log 落一条「状态 + 备注 + 来源 + 时间」,备注为空也留痕,
* 后台与前台才能回放整条处理链路。读取-追加-写回需要与 create()/cancel_for_user()
* 同一把订单锁,否则两条并发提交会互相覆盖日志。
*/
public function set_status(int $id, string $status, string $note = '') {
if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
$status = sanitize_key($status);
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) return new \WP_Error('aksrt_after_sale_status', __('售后状态无效', 'aksrt'), ['status' => 400]);
$row = $this->get($id);
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
global $wpdb;
$lock = 'aksrt_after_sale_' . md5((string) ($row['order_number'] ?? ''));
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
// 锁内重读:追加必须基于最新快照,否则两条并发提交仍会互相覆盖日志。
$row = $this->get($id);
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
$note = sanitize_textarea_field($note);
if (!$this->update($id, ['status' => $status])) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
$this->append_log($id, $row, $status, $note, self::SOURCE_ADMIN);
return $this->get($id);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
/**
* 追加一条处理记录。日志按时间正序存放,超出 MAX_LOG_ENTRIES 条时只保留最近的一段。
*
* @param array<string,mixed> $current 追加前的工单行(含已规范化的 handling_log)。
*/
private function append_log(int $id, array $current, string $status, string $note, string $source): void {
$log = is_array($current['handling_log'] ?? null) ? $current['handling_log'] : [];
$log[] = $this->log_entry($status, $note, $source, current_time('mysql'));
if (count($log) > self::MAX_LOG_ENTRIES) {
$log = array_slice($log, -self::MAX_LOG_ENTRIES);
}
$this->update($id, ['handling_log' => $this->encode_log($log)]);
}
/** @return array<string,string> */
private function log_entry(string $status, string $note, string $source, string $created_at): array {
return [
'status' => $status,
'note' => $note,
'source' => $source,
'created_at' => $created_at,
];
}
private function encode_log(array $log): string {
return wp_json_encode($log, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/** 该订单当前仍在处理中的售后工单(待审核/处理中),退款闸门只认它。 */
public function find_active(string $number): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s AND status IN ('pending','processing') ORDER BY id DESC LIMIT 1", $number), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
/** 按 ID 读取售后申请(含关联订单号),供后台操作面板解析关联订单。 */
public function get(int $id): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $id), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
private function update(int $id, array $data): bool {
global $wpdb;
$formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($data));
return $wpdb->update($this->table(), $data, ['id' => $id], $formats, ['%d']) !== false;
}
private function table(): string { return \AKSRT\Core\DBMigrator::table('order_aftersale'); }
/** @param array<string,mixed> $row */
private function normalize(array $row): array {
$row['id'] = (int) ($row['id'] ?? 0);
$row['user_id'] = (int) ($row['user_id'] ?? 0);
$images = json_decode((string) ($row['images'] ?? ''), true);
$row['images'] = is_array($images)
? array_values(array_filter($images, static fn($url): bool => is_string($url) && $url !== ''))
: [];
$apply_form = json_decode((string) ($row['apply_form'] ?? ''), true);
$row['apply_form'] = is_array($apply_form) ? $apply_form : null;
$row['handling_log'] = $this->normalize_log(json_decode((string) ($row['handling_log'] ?? ''), true));
return $row;
}
/**
* 处理记录规范化:只保留 status 非空的条目,source 只认 user,其余归 admin;
* 非数组(含损坏 JSON)整体视为空,界面对老工单按「暂无处理记录」处理。
*
* @return list<array<string,string>>
*/
private function normalize_log($log): array {
if (!is_array($log)) return [];
$clean = [];
foreach ($log as $entry) {
if (!is_array($entry)) continue;
$status = sanitize_key((string) ($entry['status'] ?? ''));
if ($status === '') continue;
$clean[] = [
'status' => $status,
'note' => (string) ($entry['note'] ?? ''),
'source' => (string) ($entry['source'] ?? '') === self::SOURCE_USER ? self::SOURCE_USER : self::SOURCE_ADMIN,
'created_at' => (string) ($entry['created_at'] ?? ''),
];
}
return $clean;
}
}
+1 -1
View File
@@ -136,7 +136,7 @@ class BalanceGateway implements GatewayInterface {
}
public function refund(string $order_number, string $amount, string $request_id = ''): array {
return ['success' => false, 'status' => 'failed', 'message' => __('余额订单售后请通过用户中心申请并由管理员审核', 'aksrt')];
return ['success' => false, 'status' => 'failed', 'message' => __('余额退款请联系管理员处理', 'aksrt')];
}
private function balance_enabled(): bool {
+1 -8
View File
@@ -16,7 +16,6 @@ final class OrderCenterQuery {
public const STATUS_ALL = 'all';
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_AFTER_SALE = 'after_sale';
public const STATUS_REFUNDED = 'refunded';
public const STATUS_CLOSED = 'closed';
@@ -24,7 +23,6 @@ final class OrderCenterQuery {
self::STATUS_ALL,
self::STATUS_PENDING,
self::STATUS_PAID,
self::STATUS_AFTER_SALE,
self::STATUS_REFUNDED,
self::STATUS_CLOSED,
];
@@ -94,7 +92,7 @@ final class OrderCenterQuery {
/** @return string[] */
public function database_statuses(): array {
if ($this->status() === self::STATUS_ALL || $this->status() === self::STATUS_AFTER_SALE) {
if ($this->status() === self::STATUS_ALL) {
return array_merge(array_keys(DomainModel::order_status_labels()), ['refunding']);
}
if ($this->status() === self::STATUS_PAID) {
@@ -103,10 +101,6 @@ final class OrderCenterQuery {
return [$this->status()];
}
/** 售后中:订单带有「待审核 / 处理中」工单,与订单自身状态无关。 */
public function filters_open_after_sale(): bool {
return $this->status() === self::STATUS_AFTER_SALE;
}
/** @return array<string, scalar> */
public function url_values(): array {
@@ -122,7 +116,6 @@ final class OrderCenterQuery {
public function repository_args(): array {
return [
'status_in' => $this->database_statuses(),
'after_sale_open' => $this->filters_open_after_sale(),
'product_type' => $this->product_type(),
'order_number_prefix' => $this->order_number(),
'limit' => $this->per_page(),
-8
View File
@@ -511,10 +511,6 @@ trait OrderRepositoryAdmin {
'count' => 0,
'latest' => [],
],
'after_sale' => [
'count' => 0,
'latest' => [],
],
'coupon' => [],
'pricing' => [
'subtotal' => '0.00',
@@ -591,10 +587,6 @@ trait OrderRepositoryAdmin {
'count' => 0,
'latest' => [],
],
'after_sale' => [
'count' => 0,
'latest' => [],
],
'coupon' => $coupon,
'pricing' => [
'subtotal' => $subtotal,
-18
View File
@@ -361,11 +361,6 @@ trait OrderRepositoryCore {
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED && empty($extra['allow_refund'])) {
return false;
}
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
&& (!class_exists(AfterSaleService::class) || !AfterSaleService::get_instance()->can_finalize_order_refund((int) ($extra['after_sale_id'] ?? 0), $order_number))
) {
return false;
}
$preflight = $this->get($order_number);
if (!$preflight) {
@@ -762,19 +757,6 @@ trait OrderRepositoryCore {
}
}
// 售后中:只认「待审核 / 处理中」两张在流转状态,与订单自身状态正交,
// 因此作为独立谓词叠加上去(工单已完结或已撤销不算售后中)。
if (!empty($args['after_sale_open']) && class_exists(AfterSaleService::class)) {
$open = [
AfterSaleService::STATUS_PENDING,
AfterSaleService::STATUS_PROCESSING,
];
$where[] = 'order_number IN (SELECT order_number FROM '
. \AKSRT\Core\DBMigrator::table('order_aftersale')
. ' WHERE status IN (' . implode(', ', array_fill(0, count($open), '%s')) . '))';
array_push($params, ...$open);
}
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
if ($product_type !== '') {
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
-78
View File
@@ -183,9 +183,6 @@ class UserOrders {
$can_pay = $group === OrderCenterQuery::STATUS_PENDING;
$can_cancel = $group === OrderCenterQuery::STATUS_PENDING;
$order_number = (string) ($order['order_number'] ?? '');
$after_sale_service = class_exists(AfterSaleService::class) ? AfterSaleService::get_instance() : null;
$after_sales = $detail && $after_sale_service ? $after_sale_service->list_for_order($order_number) : [];
$after_sale_state = $this->after_sale_state($group, $order_number, (string) ($order['paid_at'] ?? ''), $after_sale_service, $after_sales);
$updated_at = $this->resolve_updated_at($order);
$view = [
@@ -214,7 +211,6 @@ class UserOrders {
'can_pay' => $can_pay,
'can_cancel' => $can_cancel,
],
'after_sale' => $after_sale_state,
'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status),
'updated_at' => $updated_at,
'status_version' => $this->resolve_status_version($order, $updated_at),
@@ -229,84 +225,10 @@ class UserOrders {
$view['paid_at'] = (string) ($order['paid_at'] ?? '');
$view['expires_at'] = (string) ($order['expires_at'] ?? '');
$view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false];
$view['after_sales'] = $after_sales;
}
return $view;
}
/**
* 售后入口状态——前台「申请售后 / 售后详细」按钮与三个弹窗的唯一数据源。
*
* 列表模式只拉最新一条工单(latest_for_order);详情模式直接复用已取到的
* 全量工单(list_for_order 按 id DESC,首条即最新)。
*
* @param array<int,array<string,mixed>> $records
* @return array<string,mixed>
*/
private function after_sale_state(string $group, string $order_number, string $paid_at, ?AfterSaleService $service, array $records = []): array {
$settings = class_exists('\AKSRT\Settings\PaymentRuntime')
? \AKSRT\Settings\PaymentRuntime::get_after_sale_settings()
: [];
$settings = is_array($settings) ? $settings : [];
$open_statuses = ['pending', 'processing'];
$latest = null;
$open = false;
if ($service !== null) {
if ($records) {
foreach ($records as $record) {
if ($latest === null) {
$latest = $record;
}
if (in_array((string) ($record['status'] ?? ''), $open_statuses, true)) {
$open = true;
}
}
} elseif ($order_number !== '') {
$latest = $service->latest_for_order($order_number);
$open = $latest !== null && in_array((string) ($latest['status'] ?? ''), $open_statuses, true);
}
}
// 售后只对已支付订单开放;开关关闭时整体不处理售后。
$enabled = $group === OrderCenterQuery::STATUS_PAID && !empty($settings['enabled']);
$expired = $enabled && class_exists('\AKSRT\Settings\PaymentRuntime')
&& \AKSRT\Settings\PaymentRuntime::after_sale_expired($paid_at);
$has_application = $latest !== null;
// 可再次申请售后的状态判定(按工单状态推理,不再以「不能再申请」为常态):
// - pending / processing:工单仍在流转(待审核 / 处理中),不允许重复申请;
// - rejected:审核未通过,允许补充资料后再次申请;
// - cancelled:用户自行取消,允许重新发起;
// - completed:工单已完结,但只要订单没被另标「已退款」(enabled 仍为 true),
// 依旧允许再次申请。能否再售后由「订单是否已退款」决定,不由工单状态决定:
// 订单已退款时 $group 变成 refunded、enabled 直接为 false,上面那条自然挡死。
$latest_status = (string) ($latest['status'] ?? '');
$reapplyable_statuses = [
AfterSaleService::STATUS_REJECTED,
AfterSaleService::STATUS_CANCELLED,
AfterSaleService::STATUS_COMPLETED,
];
return [
'enabled' => $enabled,
'expired' => (bool) $expired,
'has_application' => $has_application,
'open' => $open,
'can_apply' => $enabled && !$expired && !$open,
'can_reapply' => $enabled && !$expired && $has_application && in_array($latest_status, $reapplyable_statuses, true),
'latest' => $has_application ? [
'status' => (string) ($latest['status'] ?? ''),
'reason' => (string) ($latest['reason'] ?? ''),
'apply_form' => is_array($latest['apply_form'] ?? null) ? $latest['apply_form'] : null,
'images' => array_values((array) ($latest['images'] ?? [])),
'refund_amount' => (string) ($latest['refund_amount'] ?? '0'),
'created_at' => (string) ($latest['created_at'] ?? ''),
// 处理记录:每次状态提交一条(状态 + 备注 + 来源 + 时间),前台时间线的唯一数据源。
'handling_log' => array_values((array) ($latest['handling_log'] ?? [])),
] : null,
];
}
/** @return array<string, mixed> */
private function empty_view(OrderCenterQuery $query): array {
-81
View File
@@ -569,87 +569,6 @@ class PaymentRuntime {
return false;
}
/**
* 售后策略(后台「支付&付费 → 售后策略」分区)的运行时读取。
*
* read_domain 走 SchemaProvider 的带默认读取:用户从未保存过该分区时,
* 预置原因 / 所需材料 / 文案等键仍取 schema 默认值(语义对齐
* UserRuntime::ban_settings 对禁封/举报分区的读取)。
*
* @return array{
* enabled:bool,time_limit:int,expired_text:string,
* reasons:array<int,array{slug:string,name:string}>,materials:string[],
* desc:string,img_enabled:bool,reason_placeholder:string
* }
*/
public static function get_after_sale_settings(): array {
$s = class_exists('\AKSRT\Admin\SchemaProvider')
? Settings::read_domain('payment', 'after_sale')
: Settings::get_all();
if (!is_array($s)) {
$s = [];
}
$reasons = [];
foreach ((array) ($s['after_sale_reasons'] ?? []) as $row) {
$text = trim((string) (is_array($row) ? ($row['t'] ?? '') : $row));
if ($text === '') {
continue;
}
$reasons[] = ['slug' => self::after_sale_slug($text), 'name' => sanitize_text_field($text)];
}
$materials = [];
foreach ((array) ($s['after_sale_materials'] ?? []) as $row) {
$text = trim((string) (is_array($row) ? ($row['t'] ?? '') : $row));
if ($text !== '') {
$materials[] = sanitize_text_field($text);
}
}
return [
'enabled' => !isset($s['after_sale_s']) || !empty($s['after_sale_s']),
'time_limit' => max(0, min(3650, (int) ($s['after_sale_time_limit'] ?? 7))),
'expired_text' => wp_kses_post((string) ($s['after_sale_expired_text'] ?? '')),
'reasons' => $reasons,
'materials' => $materials,
'desc' => wp_kses_post((string) ($s['after_sale_desc'] ?? '')),
'img_enabled' => !empty($s['after_sale_img_s']),
'reason_placeholder' => sanitize_text_field((string) ($s['after_sale_reason_placeholder'] ?? '')),
];
}
/**
* 订单是否已超过售后申请时效(自支付时间起算;时效 <=0 或缺支付时间视为未过期)。
*
* 时区口径与 UserOrders::get_order_countdown 保持一致:均以站点本地时间
* 比较存储的 DATETIME 与当前时间。
*/
public static function after_sale_expired(string $paid_at): bool {
$limit = (int) (self::get_after_sale_settings()['time_limit'] ?? 0);
if ($limit <= 0) {
return false;
}
$paid_at = trim($paid_at);
if ($paid_at === '') {
return false;
}
$paid_timestamp = strtotime($paid_at);
if ($paid_timestamp === false) {
return false;
}
return current_time('timestamp') > $paid_timestamp + $limit * DAY_IN_SECONDS;
}
/**
* 预置原因 slug:sanitize_key 无法处理中文标签(会被清洗成空串),
* 与 UserRuntime::ban_settings 同策略——ASCII 标签取 key,其余取稳定短哈希。
*/
private static function after_sale_slug(string $text): string {
$key = sanitize_key($text);
return $key !== '' ? $key : substr(md5($text), 0, 12);
}
private static function count_structured_lines(string $raw): int {
$lines = preg_split('/[\r\n]+/', $raw, -1, PREG_SPLIT_NO_EMPTY);
$count = 0;
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* 工单附件:清洗、渲染与下载。
*
* 上传链路不重写——复用 UserController::rest_upload_comment_image(),
* 由 TicketService 注册 /ticket/upload-attachment 并挂工单专属权限回调
* (与 UserController 的 /report/upload-image 同构)。因此附件形态限定为图片。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketAttachment {
/**
* 入参清洗:只保留非空字符串 URL,按配置上限截断。
*
* @param mixed $raw
* @return list<string>
*/
public static function sanitize($raw, ?int $max = null): array {
if (!is_array($raw)) {
return [];
}
$max = $max ?? TicketSettings::attachment_max();
$clean = [];
foreach ($raw as $url) {
if (!is_string($url) || count($clean) >= $max) {
continue;
}
$url = esc_url_raw(trim($url));
if ($url !== '') {
$clean[] = $url;
}
}
return $clean;
}
/**
* 存储态 JSON → 展示态数组。
*
* @return list<string>
*/
public static function decode($stored): array {
if (is_array($stored)) {
return self::sanitize($stored, 9);
}
$decoded = json_decode((string) $stored, true);
return is_array($decoded) ? self::sanitize($decoded, 9) : [];
}
/** @return string */
public static function encode(array $urls): string {
return wp_json_encode(array_values($urls), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}
+284
View File
@@ -0,0 +1,284 @@
<?php
/**
* 工单配置 CRUD trait:自定义状态 / 部门 / 预设回复 / 后台备注。
*
* 由 TicketRestAdmin 组合,工单本身的读写留在 TicketRestAdmin。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
trait TicketConfigRest {
/* ------------------------------------------------------------------ *
* 状态
* ------------------------------------------------------------------ */
public function list_statuses(\WP_REST_Request $request) {
return rest_ensure_response(['success' => true, 'data' => TicketStatusService::all()]);
}
public function create_status(\WP_REST_Request $request) {
$name = trim((string) $request->get_param('name'));
if ($name === '' || mb_strlen($name) > 60) {
return new \WP_Error('aksrt_ticket_status_name', __('请填写有效的状态名称', 'aksrt'), ['status' => 400]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
if ($table === '') {
return new \WP_Error('aksrt_ticket_db', __('状态表不可用', 'aksrt'), ['status' => 500]);
}
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$inserted = $wpdb->insert($table, [
'status_key' => TicketStatusService::make_key($name),
'name' => $name,
'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')),
'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0,
'sort_order' => $max + 1,
'created_at' => current_time('mysql'),
], ['%s', '%s', '%s', '%d', '%d', '%s']);
if ($inserted !== 1) {
return new \WP_Error('aksrt_ticket_db', __('状态创建失败', 'aksrt'), ['status' => 500]);
}
TicketStatusService::flush();
return rest_ensure_response(['success' => true]);
}
public function update_status(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
$row = $this->status_row($id);
if ($row === null) {
return new \WP_Error('aksrt_ticket_status_missing', __('工单状态不存在', 'aksrt'), ['status' => 404]);
}
if (!empty($row['is_default'])) {
return new \WP_Error('aksrt_ticket_status_default', __('默认状态不可修改', 'aksrt'), ['status' => 409]);
}
$name = trim((string) $request->get_param('name'));
if ($name === '' || mb_strlen($name) > 60) {
return new \WP_Error('aksrt_ticket_status_name', __('请填写有效的状态名称', 'aksrt'), ['status' => 400]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
$updated = $wpdb->update($table, [
'name' => $name,
'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')),
'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0,
'updated_at' => current_time('mysql'),
], ['id' => $id], ['%s', '%s', '%d', '%s'], ['%d']);
if ($updated === false) {
return new \WP_Error('aksrt_ticket_db', __('状态保存失败', 'aksrt'), ['status' => 500]);
}
TicketStatusService::flush();
return rest_ensure_response(['success' => true]);
}
public function delete_status(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
$row = $this->status_row($id);
if ($row === null) {
return new \WP_Error('aksrt_ticket_status_missing', __('工单状态不存在', 'aksrt'), ['status' => 404]);
}
if (!empty($row['is_default'])) {
return new \WP_Error('aksrt_ticket_status_default', __('默认状态不可删除', 'aksrt'), ['status' => 409]);
}
if (TicketRepository::get_instance()->count_by_status((string) $row['status_key']) > 0) {
return new \WP_Error('aksrt_ticket_status_in_use', __('该状态下还有工单,无法删除', 'aksrt'), ['status' => 409]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
$wpdb->delete($table, ['id' => $id], ['%d']);
TicketStatusService::flush();
return rest_ensure_response(['success' => true]);
}
/** @return array<string,mixed>|null */
private function status_row(int $id): ?array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
if ($table === '' || $id <= 0) {
return null;
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
$id
), ARRAY_A);
return is_array($row) ? $row : null;
}
/* ------------------------------------------------------------------ *
* 部门
* ------------------------------------------------------------------ */
public function list_types(\WP_REST_Request $request) {
return rest_ensure_response(['success' => true, 'data' => TicketTypeService::all()]);
}
public function create_type(\WP_REST_Request $request) {
$name = trim((string) $request->get_param('name'));
if ($name === '' || mb_strlen($name) > 120) {
return new \WP_Error('aksrt_ticket_type_name', __('请填写有效的部门名称', 'aksrt'), ['status' => 400]);
}
if (TicketTypeService::name_taken($name)) {
return new \WP_Error('aksrt_ticket_type_duplicate', __('该部门已存在', 'aksrt'), ['status' => 409]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
if ($table === '') {
return new \WP_Error('aksrt_ticket_db', __('部门表不可用', 'aksrt'), ['status' => 500]);
}
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$inserted = $wpdb->insert($table, [
'name' => $name,
'description' => sanitize_text_field((string) $request->get_param('description')),
'admin_ids' => wp_json_encode(TicketTypeService::sanitize_admin_ids((array) $request->get_param('admin_ids'))),
'sort_order' => $max + 1,
'created_at' => current_time('mysql'),
], ['%s', '%s', '%s', '%d', '%s']);
if ($inserted !== 1) {
return new \WP_Error('aksrt_ticket_db', __('部门创建失败', 'aksrt'), ['status' => 500]);
}
TicketTypeService::flush();
return rest_ensure_response(['success' => true]);
}
public function update_type(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (!TicketTypeService::exists($id)) {
return new \WP_Error('aksrt_ticket_type_missing', __('工单部门不存在', 'aksrt'), ['status' => 404]);
}
$name = trim((string) $request->get_param('name'));
if ($name === '' || mb_strlen($name) > 120) {
return new \WP_Error('aksrt_ticket_type_name', __('请填写有效的部门名称', 'aksrt'), ['status' => 400]);
}
if (TicketTypeService::name_taken($name, $id)) {
return new \WP_Error('aksrt_ticket_type_duplicate', __('该部门已存在', 'aksrt'), ['status' => 409]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
$updated = $wpdb->update($table, [
'name' => $name,
'description' => sanitize_text_field((string) $request->get_param('description')),
'admin_ids' => wp_json_encode(TicketTypeService::sanitize_admin_ids((array) $request->get_param('admin_ids'))),
'updated_at' => current_time('mysql'),
], ['id' => $id], ['%s', '%s', '%s', '%s'], ['%d']);
if ($updated === false) {
return new \WP_Error('aksrt_ticket_db', __('部门保存失败', 'aksrt'), ['status' => 500]);
}
TicketTypeService::flush();
return rest_ensure_response(['success' => true]);
}
public function delete_type(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (!TicketTypeService::exists($id)) {
return new \WP_Error('aksrt_ticket_type_missing', __('工单部门不存在', 'aksrt'), ['status' => 404]);
}
if (TicketRepository::get_instance()->count_by_type($id) > 0) {
return new \WP_Error('aksrt_ticket_type_in_use', __('该部门下还有工单,无法删除', 'aksrt'), ['status' => 409]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
$wpdb->delete($table, ['id' => $id], ['%d']);
TicketTypeService::flush();
return rest_ensure_response(['success' => true]);
}
/* ------------------------------------------------------------------ *
* 预设回复
* ------------------------------------------------------------------ */
public function list_prereplies(\WP_REST_Request $request) {
return rest_ensure_response(['success' => true, 'data' => TicketPrereplyService::all()]);
}
public function create_prereply(\WP_REST_Request $request) {
$content = trim((string) $request->get_param('content'));
if ($content === '' || mb_strlen($content) > 2000) {
return new \WP_Error('aksrt_ticket_prereply_content', __('请填写有效的预设回复', 'aksrt'), ['status' => 400]);
}
if (TicketPrereplyService::create($content) <= 0) {
return new \WP_Error('aksrt_ticket_db', __('预设回复创建失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response(['success' => true]);
}
public function update_prereply(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (TicketPrereplyService::find($id) === null) {
return new \WP_Error('aksrt_ticket_prereply_missing', __('预设回复不存在', 'aksrt'), ['status' => 404]);
}
$content = trim((string) $request->get_param('content'));
if ($content === '' || mb_strlen($content) > 2000) {
return new \WP_Error('aksrt_ticket_prereply_content', __('请填写有效的预设回复', 'aksrt'), ['status' => 400]);
}
if (!TicketPrereplyService::update($id, $content)) {
return new \WP_Error('aksrt_ticket_db', __('预设回复保存失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response(['success' => true]);
}
public function delete_prereply(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (!TicketPrereplyService::delete($id)) {
return new \WP_Error('aksrt_ticket_db', __('预设回复删除失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response(['success' => true]);
}
public function reorder_prereplies(\WP_REST_Request $request) {
if (!TicketPrereplyService::reorder((array) $request->get_param('ids'))) {
return new \WP_Error('aksrt_ticket_prereply_order', __('预设回复排序失败', 'aksrt'), ['status' => 400]);
}
return rest_ensure_response(['success' => true]);
}
/* ------------------------------------------------------------------ *
* 后台备注
* ------------------------------------------------------------------ */
public function list_notes(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
return rest_ensure_response(['success' => true, 'data' => TicketNoteService::for_ticket($id)]);
}
public function create_note(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (TicketRepository::get_instance()->find($id) === null) {
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
}
$content = trim((string) $request->get_param('content'));
if ($content === '' || mb_strlen($content) > 2000) {
return new \WP_Error('aksrt_ticket_note_content', __('请填写有效的备注内容', 'aksrt'), ['status' => 400]);
}
if (TicketNoteService::create($id, $content, get_current_user_id()) <= 0) {
return new \WP_Error('aksrt_ticket_db', __('备注创建失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response(['success' => true]);
}
public function update_note(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (TicketNoteService::find($id) === null) {
return new \WP_Error('aksrt_ticket_note_missing', __('备注不存在', 'aksrt'), ['status' => 404]);
}
$content = trim((string) $request->get_param('content'));
if ($content === '' || mb_strlen($content) > 2000) {
return new \WP_Error('aksrt_ticket_note_content', __('请填写有效的备注内容', 'aksrt'), ['status' => 400]);
}
if (!TicketNoteService::update($id, $content)) {
return new \WP_Error('aksrt_ticket_db', __('备注保存失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response(['success' => true]);
}
public function delete_note(\WP_REST_Request $request) {
$id = absint($request->get_param('id'));
if (!TicketNoteService::delete($id)) {
return new \WP_Error('aksrt_ticket_db', __('备注删除失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response(['success' => true]);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
/**
* 工单后台备注:管理员内部时间线,对用户不可见。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketNoteService {
/**
* @return list<array<string,mixed>>
*/
public static function for_ticket(int $ticket_id): array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
if ($table === '' || $ticket_id <= 0) {
return [];
}
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$table} WHERE ticket_id = %d ORDER BY id ASC", // phpcs:ignore WordPress.DB.PreparedSQL
$ticket_id
), ARRAY_A);
if (!is_array($rows)) {
return [];
}
$out = [];
foreach ($rows as $row) {
$out[] = self::normalize($row);
}
return $out;
}
/** @return array<string,mixed>|null */
public static function find(int $id): ?array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
if ($table === '' || $id <= 0) {
return null;
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
$id
), ARRAY_A);
return is_array($row) ? self::normalize($row) : null;
}
public static function create(int $ticket_id, string $content, int $admin_id): int {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
if ($table === '' || $ticket_id <= 0 || $content === '') {
return 0;
}
$inserted = $wpdb->insert($table, [
'ticket_id' => $ticket_id,
'admin_id' => $admin_id,
'content' => $content,
'created_at' => current_time('mysql'),
], ['%d', '%d', '%s', '%s']);
return $inserted === 1 ? (int) $wpdb->insert_id : 0;
}
public static function update(int $id, string $content): bool {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
if ($table === '' || $id <= 0) {
return false;
}
return $wpdb->update($table, ['content' => $content], ['id' => $id], ['%s'], ['%d']) !== false;
}
public static function delete(int $id): bool {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
if ($table === '' || $id <= 0) {
return false;
}
return $wpdb->delete($table, ['id' => $id], ['%d']) !== false;
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private static function normalize(array $row): array {
return [
'id' => (int) ($row['id'] ?? 0),
'ticket_id' => (int) ($row['ticket_id'] ?? 0),
'admin_id' => (int) ($row['admin_id'] ?? 0),
'content' => (string) ($row['content'] ?? ''),
'admin_name' => self::admin_name((int) ($row['admin_id'] ?? 0)),
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
private static function admin_name(int $admin_id): string {
if ($admin_id <= 0) {
return '';
}
$user = get_userdata($admin_id);
return $user instanceof \WP_User ? $user->display_name : '';
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* 工单站内信通知。
*
* 复用 AKSRT\Messages\Message::send()(持久化到 aksrt_notification,支持 target_url 深链)。
* 邮件 / SMS 不在此范围:主题侧的邮件与短信是另一套实现,接入属于独立范围。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketNotifier {
/** 用户新建工单 → 通知管理员。 */
public static function admins_new_ticket(array $ticket): void {
$number = (string) ($ticket['ticket_num'] ?? '');
$title = (string) ($ticket['title'] ?? '');
$content = sprintf(
/* translators: 1: ticket number 2: ticket title */
__('新工单 %1$s:%2$s', 'aksrt'),
$number,
$title
);
self::to_admins('ticket_new', $content, __('新工单待处理', 'aksrt'));
}
/** 管理员回复 → 通知工单归属用户。 */
public static function user_replied(array $ticket): void {
$user_id = (int) ($ticket['user_id'] ?? 0);
if ($user_id <= 0) {
return;
}
$content = sprintf(
/* translators: 1: ticket number 2: ticket title */
__('您的工单 %1$s(%2$s)收到新回复', 'aksrt'),
(string) ($ticket['ticket_num'] ?? ''),
(string) ($ticket['title'] ?? '')
);
self::send($user_id, 'ticket_reply', $content, __('工单有新回复', 'aksrt'), self::user_url());
}
/** 用户回复 / 催单 → 通知跟进人。 */
public static function admin_activity(array $ticket, string $kind): void {
$admin_id = (int) ($ticket['admin_id'] ?? 0);
if ($admin_id <= 0) {
return;
}
if ($kind === 'urge') {
$content = sprintf(
/* translators: 1: ticket number 2: ticket title */
__('用户催单 %1$s(%2$s)', 'aksrt'),
(string) ($ticket['ticket_num'] ?? ''),
(string) ($ticket['title'] ?? '')
);
$title = __('工单被催单', 'aksrt');
} else {
$content = sprintf(
/* translators: 1: ticket number 2: ticket title */
__('用户回复了工单 %1$s(%2$s)', 'aksrt'),
(string) ($ticket['ticket_num'] ?? ''),
(string) ($ticket['title'] ?? '')
);
$title = __('工单有新回复', 'aksrt');
}
self::send($admin_id, 'ticket_' . $kind, $content, $title, self::admin_url());
}
/** 工单关闭 → 通知对方。 */
public static function closed(array $ticket, string $closer): void {
$content = sprintf(
/* translators: 1: ticket number 2: ticket title */
__('工单 %1$s(%2$s)已关闭', 'aksrt'),
(string) ($ticket['ticket_num'] ?? ''),
(string) ($ticket['title'] ?? '')
);
if ($closer === 'admin') {
$user_id = (int) ($ticket['user_id'] ?? 0);
if ($user_id > 0) {
self::send($user_id, 'ticket_closed', $content, __('工单已关闭', 'aksrt'), self::user_url());
}
return;
}
$admin_id = (int) ($ticket['admin_id'] ?? 0);
if ($admin_id > 0) {
self::send($admin_id, 'ticket_closed', $content, __('工单已关闭', 'aksrt'), self::admin_url());
}
}
/** @param list<int> $user_ids */
private static function send(int $user_id, string $type, string $content, string $title, string $url): void {
if (!class_exists('\AKSRT\Messages\Message')) {
return;
}
\AKSRT\Messages\Message::get_instance()->send($user_id, $type, $content, [
'title' => $title,
'target_url' => $url,
]);
}
private static function to_admins(string $type, string $content, string $title): void {
if (!class_exists('WP_User_Query')) {
return;
}
$admins = get_users(['role' => 'administrator', 'fields' => 'ID', 'number' => 20]);
foreach ($admins as $admin_id) {
self::send((int) $admin_id, $type, $content, $title, self::admin_url());
}
}
private static function admin_url(): string {
return admin_url('users.php?page=aksrt_tickets');
}
private static function user_url(): string {
return function_exists('aksrt_get_user_center_url')
? aksrt_get_user_center_url('tickets')
: home_url('/');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* 工单号生成:前缀 + 年月日 + 6 位随机数字,冲突时重试。
*
* 与订单号(20/22 位纯数字)区分:工单号对外可读、可口头传达,
* 形如 T20261025XXXXXX。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketNumber {
private const MAX_ATTEMPTS = 12;
public static function generate(): string {
$prefix = TicketSettings::number_prefix();
$date = current_time('Ymd');
for ($attempt = 0; $attempt < self::MAX_ATTEMPTS; $attempt++) {
$candidate = $prefix . $date . wp_rand(100000, 999999);
if (!self::exists($candidate)) {
return $candidate;
}
}
// 极端冲突下追加随机后缀,保证仍可落库(uniq_ticket_num 会兜底)。
return $prefix . $date . wp_rand(100000, 999999) . wp_rand(100, 999);
}
private static function exists(string $ticket_num): bool {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket');
if ($table === '') {
return false;
}
$found = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE ticket_num = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL
$ticket_num
));
return $found !== null;
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
/**
* 工单预设回复:管理员维护的快捷话术,用户/管理员回复框可一键插入。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketPrereplyService {
/**
* @return list<array<string,mixed>>
*/
public static function all(): array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
if ($table === '') {
return [];
}
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY sort_order ASC, id ASC", ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
if (!is_array($rows)) {
return [];
}
$out = [];
foreach ($rows as $row) {
$out[] = [
'id' => (int) ($row['id'] ?? 0),
'content' => (string) ($row['content'] ?? ''),
'sort_order' => (int) ($row['sort_order'] ?? 0),
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
return $out;
}
/**
* 前台只暴露 id + content。
*
* @return list<array<string,mixed>>
*/
public static function public_list(): array {
$out = [];
foreach (self::all() as $row) {
$out[] = ['id' => $row['id'], 'content' => $row['content']];
}
return $out;
}
/** @return array<string,mixed>|null */
public static function find(int $id): ?array {
foreach (self::all() as $row) {
if ((int) $row['id'] === $id) {
return $row;
}
}
return null;
}
/** 新建时取当前最大 sort_order + 1,追加到末尾。 */
private static function next_sort_order(): int {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
if ($table === '') {
return 0;
}
$max = $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
return (int) $max + 1;
}
public static function create(string $content): int {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
if ($table === '') {
return 0;
}
$inserted = $wpdb->insert($table, [
'content' => $content,
'sort_order' => self::next_sort_order(),
'created_at' => current_time('mysql'),
], ['%s', '%d', '%s']);
return $inserted === 1 ? (int) $wpdb->insert_id : 0;
}
public static function update(int $id, string $content): bool {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
if ($table === '' || $id <= 0) {
return false;
}
return $wpdb->update($table, ['content' => $content], ['id' => $id], ['%s'], ['%d']) !== false;
}
public static function delete(int $id): bool {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
if ($table === '' || $id <= 0) {
return false;
}
return $wpdb->delete($table, ['id' => $id], ['%d']) !== false;
}
/**
* 排序:入参必须是全量 ID(数量不符即拒绝,避免漏排导致顺序错乱)。
*
* @param list<int> $ids
*/
public static function reorder(array $ids): bool {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
if ($table === '') {
return false;
}
$ids = array_values(array_filter(array_map('intval', $ids), static fn(int $id): bool => $id > 0));
$existing = array_map(static fn(array $row): int => (int) $row['id'], self::all());
if (count($ids) !== count($existing) || array_diff($ids, $existing) !== []) {
return false;
}
foreach ($ids as $position => $id) {
$wpdb->update($table, ['sort_order' => (int) $position], ['id' => $id], ['%d'], ['%d']);
}
return true;
}
}
@@ -0,0 +1,158 @@
<?php
/**
* 工单查询 trait:列表 WHERE 拼装、分页与统计。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
trait TicketQuery {
/**
* 列表查询的 WHERE 片段与参数。
*
* 用户视角只可能看到自己的工单;管理视角可按用户 / 部门 / 状态 / 关键字过滤。
* 管理侧默认隐藏已完结状态(对齐参考实现的 whereNotIn([3,4]))。
*
* @param array<string,mixed> $args
* @return array{0:string,1:list<mixed>}
*/
private function ticket_where(array $args, bool $is_admin): array {
global $wpdb;
$where = [];
$params = [];
if (!$is_admin) {
$where[] = 'user_id = %d';
$params[] = (int) ($args['user_id'] ?? 0);
} else {
$user_id = (int) ($args['user_id'] ?? 0);
if ($user_id > 0) {
$where[] = 'user_id = %d';
$params[] = $user_id;
}
}
$statuses = [];
foreach ((array) ($args['status'] ?? []) as $status) {
$status = sanitize_key((string) $status);
if ($status !== '') {
$statuses[] = $status;
}
}
if ($statuses !== []) {
$where[] = 'status IN (' . implode(', ', array_fill(0, count($statuses), '%s')) . ')';
$params = array_merge($params, $statuses);
} elseif ($is_admin) {
$closed = TicketStatusService::is_closed(TicketStatusService::KEY_CLOSED);
if ($closed) {
$where[] = 'status <> %s';
$params[] = TicketStatusService::KEY_CLOSED;
}
}
$type_id = (int) ($args['ticket_type_id'] ?? 0);
if ($type_id > 0) {
$where[] = 'ticket_type_id = %d';
$params[] = $type_id;
}
$keywords = sanitize_text_field((string) ($args['keywords'] ?? ''));
if ($keywords !== '') {
$like = '%' . $wpdb->esc_like($keywords) . '%';
$where[] = '(ticket_num LIKE %s OR title LIKE %s)';
$params[] = $like;
$params[] = $like;
}
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$date = sanitize_text_field((string) ($args[$key] ?? ''));
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
unset($wpdb);
return [
$where === [] ? '1 = 1' : implode(' AND ', $where),
$params,
];
}
/**
* @param array<string,mixed> $args
* @return list<array<string,mixed>>
*/
private function ticket_rows(array $args, bool $is_admin): array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket');
if ($table === '') {
return [];
}
[$where, $params] = $this->ticket_where($args, $is_admin);
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY last_reply_time DESC, id DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL
return is_array($rows) ? $rows : [];
}
/**
* @param array<string,mixed> $args
*/
private function ticket_count(array $args, bool $is_admin): int {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket');
if ($table === '') {
return 0;
}
[$where, $params] = $this->ticket_where($args, $is_admin);
$sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
$count = $params === []
? $wpdb->get_var($sql) // phpcs:ignore WordPress.DB.DirectDatabaseQuery
: $wpdb->get_var($wpdb->prepare($sql, $params)); // phpcs:ignore WordPress.DB.PreparedSQL
return (int) $count;
}
/**
* 各状态计数:侧栏统计卡与后台 badge 用。
* 用户视角只统计自己的;管理视角统计全站(可按部门过滤)。
*
* @param array<string,mixed> $args
* @return array<string,int> status_key => count
*/
private function ticket_statistic(array $args, bool $is_admin): array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket');
$out = [];
foreach (array_keys(TicketStatusService::map()) as $key) {
$out[(string) $key] = 0;
}
if ($table === '') {
return $out;
}
$query_args = [
'user_id' => (int) ($args['user_id'] ?? 0),
'ticket_type_id' => $args['ticket_type_id'] ?? 0,
];
[$where, $params] = $this->ticket_where($query_args, $is_admin);
$sql = "SELECT status, COUNT(*) AS total FROM {$table} WHERE {$where} GROUP BY status";
$rows = $wpdb->get_results($params === [] ? $sql : $wpdb->prepare($sql, $params), ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL
if (is_array($rows)) {
foreach ($rows as $row) {
$key = sanitize_key((string) ($row['status'] ?? ''));
if ($key !== '') {
$out[$key] = (int) ($row['total'] ?? 0);
}
}
}
return $out;
}
}
@@ -0,0 +1,98 @@
<?php
/**
* 工单状态机 trait:迁移校验、落库与通知触发点。
*
* 方法统一加 _ticket 后缀,避免与 TicketRepository 的对外同名包装器冲突。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
trait TicketStatus {
/**
* 执行状态迁移。合法性由 TicketStatusService::can_transition() 判定。
*
* @param array<string,mixed> $ticket 迁移前的工单行(normalize 后)
*/
private function transition_ticket(int $ticket_id, array $ticket, string $to, string $actor, int $actor_id): bool {
$from = (string) ($ticket['status'] ?? '');
if ($ticket_id <= 0 || !TicketStatusService::can_transition($from, $to)) {
return false;
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket');
if ($table === '') {
return false;
}
$updated = $wpdb->update($table, [
'status' => $to,
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id], ['%s', '%s'], ['%d']);
if ($updated === false) {
return false;
}
$ticket['status'] = $to;
if ($to === TicketStatusService::KEY_CLOSED) {
TicketNotifier::closed($ticket, $actor);
}
unset($actor_id);
return true;
}
/**
* 「接收」:仅从未接单转处理中。
*
* @param array<string,mixed> $ticket
*/
private function receive_ticket(int $ticket_id, array $ticket): bool {
if ((string) ($ticket['status'] ?? '') !== TicketStatusService::KEY_PENDING) {
return false;
}
return $this->transition_ticket($ticket_id, $ticket, TicketStatusService::KEY_HANDLING, 'admin', get_current_user_id());
}
/**
* 「已解决」:直接落到已关闭。不允许从未接单一步跳(can_transition 已挡)。
*
* @param array<string,mixed> $ticket
*/
private function resolve_ticket(int $ticket_id, array $ticket): bool {
return $this->transition_ticket($ticket_id, $ticket, TicketStatusService::KEY_CLOSED, 'admin', get_current_user_id());
}
/**
* @param array<string,mixed> $ticket
*/
private function close_ticket(int $ticket_id, array $ticket, string $actor): bool {
if (TicketStatusService::is_closed((string) ($ticket['status'] ?? ''))) {
return false;
}
return $this->transition_ticket($ticket_id, $ticket, TicketStatusService::KEY_CLOSED, $actor, get_current_user_id());
}
/**
* 回复后的状态改写:管理员回复 → 已回复;用户回复 → 待回复。
*
* @param array<string,mixed> $ticket
*/
private function reply_status(int $ticket_id, array $ticket, string $author_type): bool {
$target = $author_type === 'admin'
? TicketStatusService::KEY_ADMIN_REPLIED
: TicketStatusService::KEY_USER_REPLIED;
$from = (string) ($ticket['status'] ?? '');
if ($from === $target) {
return true;
}
// 已完结工单上的回复不改变状态(回复本身由调用方拒绝)。
if (TicketStatusService::is_closed($from)) {
return false;
}
return $this->transition_ticket($ticket_id, $ticket, $target, $author_type, get_current_user_id());
}
}
+391
View File
@@ -0,0 +1,391 @@
<?php
/**
* 工单仓储:全部 SQL 集中在此,上层(REST / 后台页)不直接碰 $wpdb。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketRepository {
use TicketQuery;
use TicketStatus;
private static ?self $instance = null;
public static function get_instance(): self {
return self::$instance ??= new self();
}
private function __construct() {}
private function table(): string {
return \AKSRT\Core\DBMigrator::table('ticket');
}
private function reply_table(): string {
return \AKSRT\Core\DBMigrator::table('ticket_reply');
}
public function create(array $data): int {
global $wpdb;
$table = $this->table();
if ($table === '') {
return 0;
}
$now = current_time('mysql');
$inserted = $wpdb->insert($table, [
'ticket_num' => (string) ($data['ticket_num'] ?? ''),
'user_id' => (int) ($data['user_id'] ?? 0),
'title' => (string) ($data['title'] ?? ''),
'ticket_type_id' => (int) ($data['ticket_type_id'] ?? 0),
'content' => (string) ($data['content'] ?? ''),
'status' => (string) ($data['status'] ?? TicketStatusService::KEY_PENDING),
'order_number' => (string) ($data['order_number'] ?? ''),
'attachment' => (string) ($data['attachment'] ?? ''),
'post_time' => $now,
'notes' => (string) ($data['notes'] ?? ''),
'admin_id' => (int) ($data['admin_id'] ?? 0),
'post_admin_id' => (int) ($data['post_admin_id'] ?? 0),
'created_at' => $now,
], [
'%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s',
]);
return $inserted === 1 ? (int) $wpdb->insert_id : 0;
}
/** @return array<string,mixed>|null */
public function find(int $id): ?array {
global $wpdb;
$table = $this->table();
if ($table === '' || $id <= 0) {
return null;
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
$id
), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
/**
* 归属校验读取:用户视角必须命中本人。
*
* @return array<string,mixed>|null
*/
public function find_for_user(int $id, int $user_id): ?array {
$row = $this->find($id);
if ($row === null || (int) $row['user_id'] !== $user_id) {
return null;
}
return $row;
}
/**
* 工单号反查(后台深链 / 通知跳转)。
*
* @return array<string,mixed>|null
*/
public function find_by_number(string $ticket_num): ?array {
global $wpdb;
$table = $this->table();
if ($table === '' || $ticket_num === '') {
return null;
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE ticket_num = %s", // phpcs:ignore WordPress.DB.PreparedSQL
$ticket_num
), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
/**
* @param array<string,mixed> $args
* @return array{items:list<array<string,mixed>>,total:int}
*/
public function paginate(array $args, bool $is_admin): array {
return [
'items' => array_map([$this, 'normalize'], $this->ticket_rows($args, $is_admin)),
'total' => $this->ticket_count($args, $is_admin),
];
}
/**
* @param array<string,mixed> $args
* @return array<string,int>
*/
public function statistic(array $args, bool $is_admin): array {
return $this->ticket_statistic($args, $is_admin);
}
public function update_fields(int $id, array $fields): bool {
global $wpdb;
$table = $this->table();
if ($table === '' || $id <= 0 || $fields === []) {
return false;
}
$formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($fields));
return $wpdb->update($table, $fields, ['id' => $id], $formats, ['%d']) !== false;
}
/* ------------------------------------------------------------------ *
* 状态机(对外入口,内部规则在 TicketStatus trait)
* ------------------------------------------------------------------ */
/**
* @param array<string,mixed> $ticket
*/
public function receive(int $id, array $ticket): bool {
return $this->receive_ticket($id, $ticket);
}
/**
* @param array<string,mixed> $ticket
*/
public function resolve(int $id, array $ticket): bool {
return $this->resolve_ticket($id, $ticket);
}
/**
* @param array<string,mixed> $ticket
*/
public function close(int $id, array $ticket, string $actor): bool {
return $this->close_ticket($id, $ticket, $actor);
}
/**
* @param array<string,mixed> $ticket
*/
public function apply_reply_status(int $id, array $ticket, string $author_type): bool {
return $this->reply_status($id, $ticket, $author_type);
}
public function count_by_status(string $status_key): int {
global $wpdb;
$table = $this->table();
if ($table === '' || $status_key === '') {
return 0;
}
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE status = %s", // phpcs:ignore WordPress.DB.PreparedSQL
$status_key
));
}
public function count_by_type(int $type_id): int {
global $wpdb;
$table = $this->table();
if ($table === '' || $type_id <= 0) {
return 0;
}
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE ticket_type_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
$type_id
));
}
public function touch_reply(int $id, string $author_type, int $author_id): void {
global $wpdb;
$table = $this->table();
if ($table === '') {
return;
}
$fields = ['last_reply_time' => current_time('mysql'), 'updated_at' => current_time('mysql')];
if ($author_type === 'admin') {
$fields['last_reply_admin_id'] = $author_id;
if ($author_id > 0) {
$fields['admin_id'] = $author_id;
}
}
$formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($fields));
$wpdb->update($table, $fields, ['id' => $id], $formats, ['%d']);
}
/* ------------------------------------------------------------------ *
* 沟通记录
* ------------------------------------------------------------------ */
public function create_reply(int $ticket_id, string $author_type, int $author_id, string $content, array $attachment, int $quote_reply_id = 0): int {
global $wpdb;
$table = $this->reply_table();
if ($table === '' || $ticket_id <= 0 || $content === '') {
return 0;
}
$inserted = $wpdb->insert($table, [
'ticket_id' => $ticket_id,
'author_type' => $author_type,
'author_id' => $author_id,
'content' => $content,
'attachment' => TicketAttachment::encode($attachment),
'quote_reply_id' => max(0, $quote_reply_id),
'created_at' => current_time('mysql'),
], ['%d', '%s', '%d', '%s', '%s', '%d', '%s']);
return $inserted === 1 ? (int) $wpdb->insert_id : 0;
}
/**
* 沟通记录(按时间正序)+ 每条的作者名与引用摘要。
*
* @return list<array<string,mixed>>
*/
public function replies(int $ticket_id): array {
global $wpdb;
$table = $this->reply_table();
if ($table === '' || $ticket_id <= 0) {
return [];
}
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$table} WHERE ticket_id = %d ORDER BY created_at ASC, id ASC", // phpcs:ignore WordPress.DB.PreparedSQL
$ticket_id
), ARRAY_A);
if (!is_array($rows)) {
return [];
}
$out = [];
foreach ($rows as $row) {
$out[] = $this->normalize_reply($row);
}
return $out;
}
/** @return array<string,mixed>|null */
public function find_reply(int $reply_id): ?array {
global $wpdb;
$table = $this->reply_table();
if ($table === '' || $reply_id <= 0) {
return null;
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
$reply_id
), ARRAY_A);
return is_array($row) ? $this->normalize_reply($row) : null;
}
public function delete_reply(int $reply_id): bool {
global $wpdb;
$table = $this->reply_table();
if ($table === '' || $reply_id <= 0) {
return false;
}
return $wpdb->delete($table, ['id' => $reply_id], ['%d']) !== false;
}
/**
* 某工单下管理员最后一次回复时间(邮件/通知文案用,暂留供后续接入)。
*/
public function last_admin_reply_time(int $ticket_id): string {
global $wpdb;
$table = $this->reply_table();
if ($table === '' || $ticket_id <= 0) {
return '';
}
$value = $wpdb->get_var($wpdb->prepare(
"SELECT created_at FROM {$table} WHERE ticket_id = %d AND author_type = 'admin' ORDER BY id DESC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL
$ticket_id
));
return $value === null ? '' : (string) $value;
}
/* ------------------------------------------------------------------ *
* 规范化
* ------------------------------------------------------------------ */
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private function normalize(array $row): array {
return [
'id' => (int) ($row['id'] ?? 0),
'ticket_num' => (string) ($row['ticket_num'] ?? ''),
'user_id' => (int) ($row['user_id'] ?? 0),
'title' => (string) ($row['title'] ?? ''),
'ticket_type_id' => (int) ($row['ticket_type_id'] ?? 0),
'ticket_type_name' => TicketTypeService::name((int) ($row['ticket_type_id'] ?? 0)),
'content' => (string) ($row['content'] ?? ''),
'status' => sanitize_key((string) ($row['status'] ?? '')),
'status_label' => TicketStatusService::label(sanitize_key((string) ($row['status'] ?? ''))),
'status_color' => TicketStatusService::color(sanitize_key((string) ($row['status'] ?? ''))),
'is_closed' => TicketStatusService::is_closed(sanitize_key((string) ($row['status'] ?? ''))),
'order_number' => (string) ($row['order_number'] ?? ''),
'attachment' => TicketAttachment::decode($row['attachment'] ?? ''),
'last_reply_time' => (string) ($row['last_reply_time'] ?? ''),
'post_time' => (string) ($row['post_time'] ?? ''),
'notes' => (string) ($row['notes'] ?? ''),
'admin_id' => (int) ($row['admin_id'] ?? 0),
'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),
'user_name' => $this->user_name((int) ($row['user_id'] ?? 0)),
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private function normalize_reply(array $row): array {
$author_type = (string) ($row['author_type'] ?? '') === 'admin' ? 'admin' : 'user';
$author_id = (int) ($row['author_id'] ?? 0);
$reply_id = (int) ($row['id'] ?? 0);
$quote_id = (int) ($row['quote_reply_id'] ?? 0);
return [
'id' => $reply_id,
'ticket_id' => (int) ($row['ticket_id'] ?? 0),
'author_type' => $author_type,
'author_id' => $author_id,
'author_name' => $this->user_name($author_id),
'content' => (string) ($row['content'] ?? ''),
'attachment' => TicketAttachment::decode($row['attachment'] ?? ''),
'quote_reply_id' => $quote_id,
'quote' => $quote_id > 0 ? $this->quote_summary($quote_id, $reply_id) : null,
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
/**
* 引用摘要:被引用的回复可能已被删除,此时返回 is_deleted 标记,
* 由界面显示「原回复已删除」而不是空白块。
*
* @return array<string,mixed>|null
*/
private function quote_summary(int $quote_id, int $self_id): ?array {
if ($quote_id === $self_id) {
return null;
}
$quoted = $this->find_reply($quote_id);
if ($quoted === null) {
return [
'id' => $quote_id,
'content' => '',
'author_type' => '',
'author_name' => '',
'created_at' => '',
'is_deleted' => true,
];
}
return [
'id' => $quoted['id'],
'content' => mb_substr((string) $quoted['content'], 0, 100),
'author_type' => $quoted['author_type'],
'author_name' => $quoted['author_name'],
'created_at' => $quoted['created_at'],
'is_deleted' => false,
];
}
private function user_name(int $user_id): string {
if ($user_id <= 0) {
return '';
}
$user = get_userdata($user_id);
return $user instanceof \WP_User ? $user->display_name : '';
}
}
+427
View File
@@ -0,0 +1,427 @@
<?php
/**
* 工单 REST — 用户侧。
*
* 命名空间 aksrt/v1;每条路由都带 permission_callback,写操作过人机验证。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketRest {
private const CAPTCHA_SCENE_CREATE = 'ticket_create';
private const CAPTCHA_SCENE_REPLY = 'ticket_reply';
public function register_routes(): void {
$namespace = 'aksrt/v1';
$id = '(?P<id>[0-9]+)';
register_rest_route($namespace, '/tickets', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'list_tickets'],
'permission_callback' => [$this, 'require_user'],
'args' => $this->list_args(),
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_ticket'],
'permission_callback' => [$this, 'require_user'],
'args' => [
'title' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'ticket_type_id' => ['required' => true, 'sanitize_callback' => 'absint'],
'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
'order_number' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'attachment' => ['required' => false, 'type' => 'array'],
],
],
]);
register_rest_route($namespace, '/tickets/statistic', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'statistic'],
'permission_callback' => [$this, 'require_user'],
]);
// 静态子路径必须早于 /tickets/{id} 注册,否则 WordPress 会先用动态规则匹配。
register_rest_route($namespace, '/tickets/meta', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'meta'],
'permission_callback' => [$this, 'require_user'],
]);
register_rest_route($namespace, '/tickets/prereply', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'prereplies'],
'permission_callback' => [$this, 'require_user'],
]);
register_rest_route($namespace, '/tickets/' . $id, [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'get_ticket'],
'permission_callback' => [$this, 'require_user'],
]);
register_rest_route($namespace, '/tickets/' . $id . '/reply', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'reply'],
'permission_callback' => [$this, 'require_user'],
'args' => [
'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
'attachment' => ['required' => false, 'type' => 'array'],
'quote_reply_id' => ['required' => false, 'sanitize_callback' => 'absint'],
],
]);
register_rest_route($namespace, '/tickets/' . $id . '/urge', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'urge'],
'permission_callback' => [$this, 'require_user'],
]);
register_rest_route($namespace, '/tickets/' . $id . '/close', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'close'],
'permission_callback' => [$this, 'require_user'],
]);
register_rest_route($namespace, '/ticket/upload-attachment', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [\AKSRT\Admin\UserController::class, 'rest_upload_comment_image'],
'permission_callback' => [$this, 'require_attachment_upload'],
]);
}
/** @return array<string,mixed> */
private function list_args(): array {
return [
'keywords' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'status' => ['required' => false, 'sanitize_callback' => [$this, 'sanitize_status_list']],
'ticket_type_id' => ['required' => false, 'sanitize_callback' => 'absint'],
'page' => ['required' => false, 'sanitize_callback' => 'absint'],
'per_page' => ['required' => false, 'sanitize_callback' => 'absint'],
];
}
/**
* status 允许传字符串或数组(后台多选筛选复用同一读取器)。
*
* @return list<string>
*/
public function sanitize_status_list($value): array {
$values = is_array($value) ? $value : explode(',', (string) $value);
$out = [];
foreach ((array) $values as $item) {
$key = sanitize_key((string) $item);
if ($key !== '' && TicketStatusService::exists($key)) {
$out[] = $key;
}
}
return array_values(array_unique($out));
}
public function require_user() {
if (!is_user_logged_in()) {
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
$nonce = isset($_SERVER['HTTP_X_WP_NONCE']) ? sanitize_text_field((string) wp_unslash($_SERVER['HTTP_X_WP_NONCE'])) : '';
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest')
? true
: new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
public function require_attachment_upload() {
$user = $this->require_user();
if (is_wp_error($user)) {
return $user;
}
if (!TicketSettings::enabled() || !TicketSettings::attachment_enabled()) {
return new \WP_Error('aksrt_ticket_attachment_forbidden', __('工单附件上传未开启', 'aksrt'), ['status' => 403]);
}
return true;
}
public function list_tickets(\WP_REST_Request $request) {
if (!$this->ensure_enabled()) {
return $this->disabled();
}
$page = max(1, (int) $request->get_param('page'));
$per_page = max(1, min(50, (int) $request->get_param('per_page') ?: 20));
$result = TicketRepository::get_instance()->paginate([
'user_id' => get_current_user_id(),
'keywords' => $request->get_param('keywords'),
'status' => $request->get_param('status'),
'ticket_type_id' => $request->get_param('ticket_type_id'),
'limit' => $per_page,
'offset' => ($page - 1) * $per_page,
], false);
return rest_ensure_response([
'success' => true,
'data' => [
'items' => $result['items'],
'total' => $result['total'],
'page' => $page,
'per_page' => $per_page,
'total_pages' => max(1, (int) ceil($result['total'] / $per_page)),
],
]);
}
public function statistic(\WP_REST_Request $request) {
if (!$this->ensure_enabled()) {
return $this->disabled();
}
return rest_ensure_response([
'success' => true,
'data' => TicketRepository::get_instance()->statistic(['user_id' => get_current_user_id()], false),
]);
}
/** 下拉数据一次性下发:部门 + 状态 + 可关联订单 + 配置。 */
public function meta(\WP_REST_Request $request) {
if (!$this->ensure_enabled()) {
return $this->disabled();
}
return rest_ensure_response([
'success' => true,
'data' => [
'types' => TicketTypeService::public_list(),
'statuses' => TicketStatusService::all(),
'prereplies' => TicketPrereplyService::public_list(),
'orders' => $this->linkable_orders(),
'notice' => [
'open' => TicketSettings::notice_open(),
'text' => TicketSettings::notice_text(),
],
'attachment' => [
'enabled' => TicketSettings::attachment_enabled(),
'max' => TicketSettings::attachment_max(),
],
'urgeInterval' => TicketSettings::urge_interval(),
],
]);
}
public function prereplies(\WP_REST_Request $request) {
return rest_ensure_response([
'success' => true,
'data' => TicketPrereplyService::public_list(),
]);
}
public function get_ticket(\WP_REST_Request $request) {
if (!$this->ensure_enabled()) {
return $this->disabled();
}
$id = absint($request->get_param('id'));
$repo = TicketRepository::get_instance();
$ticket = $repo->find_for_user($id, get_current_user_id());
if ($ticket === null) {
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
}
$ticket['replies'] = $repo->replies($id);
$ticket['notes'] = [];
return rest_ensure_response(['success' => true, 'data' => $ticket]);
}
public function create_ticket(\WP_REST_Request $request) {
if (!$this->ensure_enabled()) {
return $this->disabled();
}
$captcha = $this->require_captcha($request, self::CAPTCHA_SCENE_CREATE);
if (is_wp_error($captcha)) {
return $captcha;
}
$title = trim((string) $request->get_param('title'));
$content = trim((string) $request->get_param('content'));
$type_id = absint($request->get_param('ticket_type_id'));
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
if ($title === '' || mb_strlen($title) > 200) {
return new \WP_Error('aksrt_ticket_title', __('请填写有效的工单标题', 'aksrt'), ['status' => 400]);
}
if ($content === '' || mb_strlen($content) > 5000) {
return new \WP_Error('aksrt_ticket_content', __('请填写有效的问题描述', 'aksrt'), ['status' => 400]);
}
if (!TicketTypeService::exists($type_id)) {
return new \WP_Error('aksrt_ticket_type', __('请选择工单部门', 'aksrt'), ['status' => 400]);
}
if ($order_number !== '' && !$this->owns_paid_order($order_number)) {
return new \WP_Error('aksrt_ticket_order', __('关联订单无效', 'aksrt'), ['status' => 400]);
}
$attachment = TicketAttachment::sanitize($request->get_param('attachment'));
$id = TicketRepository::get_instance()->create([
'ticket_num' => TicketNumber::generate(),
'user_id' => get_current_user_id(),
'title' => $title,
'ticket_type_id' => $type_id,
'content' => $content,
'order_number' => $order_number,
'attachment' => TicketAttachment::encode($attachment),
'status' => TicketStatusService::KEY_PENDING,
]);
if ($id <= 0) {
return new \WP_Error('aksrt_ticket_db', __('工单提交失败', 'aksrt'), ['status' => 500]);
}
$ticket = TicketRepository::get_instance()->find($id);
if (is_array($ticket)) {
TicketNotifier::admins_new_ticket($ticket);
}
return rest_ensure_response(['success' => true, 'data' => ['id' => $id]]);
}
public function reply(\WP_REST_Request $request) {
if (!$this->ensure_enabled()) {
return $this->disabled();
}
$captcha = $this->require_captcha($request, self::CAPTCHA_SCENE_REPLY);
if (is_wp_error($captcha)) {
return $captcha;
}
$repo = TicketRepository::get_instance();
$id = absint($request->get_param('id'));
$ticket = $repo->find_for_user($id, get_current_user_id());
if ($ticket === null) {
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
}
if (!empty($ticket['is_closed'])) {
return new \WP_Error('aksrt_ticket_closed', __('工单已关闭,无法回复', 'aksrt'), ['status' => 409]);
}
$content = trim((string) $request->get_param('content'));
if ($content === '' || mb_strlen($content) > 3000) {
return new \WP_Error('aksrt_ticket_reply_content', __('请填写有效的回复内容', 'aksrt'), ['status' => 400]);
}
$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) {
$quote_id = 0;
}
}
$attachment = TicketAttachment::sanitize($request->get_param('attachment'));
$reply_id = $repo->create_reply($id, 'user', get_current_user_id(), $content, $attachment, $quote_id);
if ($reply_id <= 0) {
return new \WP_Error('aksrt_ticket_db', __('回复提交失败', 'aksrt'), ['status' => 500]);
}
$repo->touch_reply($id, 'user', get_current_user_id());
$repo->apply_reply_status($id, $ticket, 'user');
TicketNotifier::admin_activity($repo->find($id) ?? $ticket, 'reply');
return rest_ensure_response(['success' => true, 'data' => ['reply_id' => $reply_id]]);
}
public function urge(\WP_REST_Request $request) {
if (!$this->ensure_enabled()) {
return $this->disabled();
}
$repo = TicketRepository::get_instance();
$id = absint($request->get_param('id'));
$ticket = $repo->find_for_user($id, get_current_user_id());
if ($ticket === null) {
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
}
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()) {
return new \WP_Error('aksrt_ticket_urge_limit', __('刚刚已催单,请稍后再试', 'aksrt'), ['status' => 429]);
}
set_transient('aksrt_ticket_urge_' . $id, time(), TicketSettings::urge_interval() * MINUTE_IN_SECONDS + 60);
TicketNotifier::admin_activity($ticket, 'urge');
return rest_ensure_response(['success' => true]);
}
public function close(\WP_REST_Request $request) {
if (!$this->ensure_enabled()) {
return $this->disabled();
}
$repo = TicketRepository::get_instance();
$id = absint($request->get_param('id'));
$ticket = $repo->find_for_user($id, get_current_user_id());
if ($ticket === null) {
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
}
if (!$repo->close($id, $ticket, 'user')) {
return new \WP_Error('aksrt_ticket_state', __('工单状态不允许关闭', 'aksrt'), ['status' => 409]);
}
return rest_ensure_response(['success' => true]);
}
/* ------------------------------------------------------------------ *
* 内部工具
* ------------------------------------------------------------------ */
private function ensure_enabled(): bool {
return TicketSettings::enabled();
}
private function disabled() {
return new \WP_Error('aksrt_ticket_disabled', __('工单功能暂未开启', 'aksrt'), ['status' => 403]);
}
/**
* 可关联订单:本人已支付订单(未退款、未关闭)。
*
* OrderRepository::get_user_orders() 返回的是归一化订单行(无 product_name),
* 标题从 meta_payload 取,取不到回落订单号。
*
* @return list<array<string,mixed>>
*/
private function linkable_orders(): array {
if (!TicketSettings::order_link_enabled() || !class_exists('\AKSRT\Payment\OrderRepository')) {
return [];
}
$orders = \AKSRT\Payment\OrderRepository::get_instance()->get_user_orders(get_current_user_id(), [
'status' => \AKSRT\Core\DomainModel::ORDER_STATUS_PAID,
'limit' => 50,
]);
if (!is_array($orders)) {
return [];
}
$out = [];
foreach ($orders as $order) {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$out[] = [
'order_number' => (string) ($order['order_number'] ?? ''),
'product_name' => (string) ($meta['product_name'] ?? $meta['title'] ?? ''),
'amount' => (string) ($order['amount'] ?? '0'),
'currency' => (string) ($order['currency'] ?? ''),
];
}
return $out;
}
private function owns_paid_order(string $order_number): bool {
if ($order_number === '' || !class_exists('\AKSRT\Payment\OrderRepository')) {
return false;
}
$order = \AKSRT\Payment\OrderRepository::get_instance()->get_user_order(get_current_user_id(), $order_number);
return is_array($order)
&& (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
}
/**
* 人机验证:场景未启用时直接放行;用户取消验证时返回可辨识的错误。
*
* @param array<string,mixed> $params
*/
private function require_captcha(\WP_REST_Request $request, string $scene) {
if (!function_exists('aksrt_require_captcha')) {
return true;
}
$params = $request->get_json_params();
return aksrt_require_captcha($scene, is_array($params) ? $params : []);
}
}
+460
View File
@@ -0,0 +1,460 @@
<?php
/**
* 工单 REST — 管理侧(后台工单页 AJAX)。
*
* 命名空间 aksrt/v1/admin;全部路由要求 manage_options。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketRestAdmin {
use TicketConfigRest;
public function register_routes(): void {
$namespace = 'aksrt/v1/admin';
$id = '(?P<id>[0-9]+)';
register_rest_route($namespace, '/tickets', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'list_tickets'],
'permission_callback' => [$this, 'require_admin'],
'args' => $this->list_args(),
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_ticket'],
'permission_callback' => [$this, 'require_admin'],
'args' => [
'user_id' => ['required' => true, 'sanitize_callback' => 'absint'],
'title' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'ticket_type_id' => ['required' => true, 'sanitize_callback' => 'absint'],
'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
'order_number' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'attachment' => ['required' => false, 'type' => 'array'],
],
],
]);
register_rest_route($namespace, '/tickets/statistic', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'statistic'],
'permission_callback' => [$this, 'require_admin'],
]);
register_rest_route($namespace, '/tickets/statuses', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'list_statuses'],
'permission_callback' => [$this, 'require_admin'],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_status'],
'permission_callback' => [$this, 'require_admin'],
'args' => [
'name' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'color' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'],
],
],
]);
register_rest_route($namespace, '/tickets/statuses/' . $id, [
'methods' => \WP_REST_Server::EDITABLE,
'callback' => [$this, 'update_status'],
'permission_callback' => [$this, 'require_admin'],
'args' => [
'name' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'color' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'],
],
]);
register_rest_route($namespace, '/tickets/statuses/' . $id . '/delete', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'delete_status'],
'permission_callback' => [$this, 'require_admin'],
]);
register_rest_route($namespace, '/tickets/types', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'list_types'],
'permission_callback' => [$this, 'require_admin'],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_type'],
'permission_callback' => [$this, 'require_admin'],
'args' => [
'name' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'description' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'admin_ids' => ['required' => false, 'type' => 'array'],
],
],
]);
register_rest_route($namespace, '/tickets/types/' . $id, [
'methods' => \WP_REST_Server::EDITABLE,
'callback' => [$this, 'update_type'],
'permission_callback' => [$this, 'require_admin'],
'args' => [
'name' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'description' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'admin_ids' => ['required' => false, 'type' => 'array'],
],
]);
register_rest_route($namespace, '/tickets/types/' . $id . '/delete', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'delete_type'],
'permission_callback' => [$this, 'require_admin'],
]);
register_rest_route($namespace, '/tickets/prereplies', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'list_prereplies'],
'permission_callback' => [$this, 'require_admin'],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_prereply'],
'permission_callback' => [$this, 'require_admin'],
'args' => ['content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field']],
],
]);
register_rest_route($namespace, '/tickets/prereplies/' . $id, [
'methods' => \WP_REST_Server::EDITABLE,
'callback' => [$this, 'update_prereply'],
'permission_callback' => [$this, 'require_admin'],
'args' => ['content' => ['required' => false, 'sanitize_callback' => 'sanitize_textarea_field']],
]);
register_rest_route($namespace, '/tickets/prereplies/' . $id . '/delete', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'delete_prereply'],
'permission_callback' => [$this, 'require_admin'],
]);
register_rest_route($namespace, '/tickets/prereplies/order', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'reorder_prereplies'],
'permission_callback' => [$this, 'require_admin'],
'args' => ['ids' => ['required' => true, 'type' => 'array']],
]);
register_rest_route($namespace, '/tickets/' . $id, [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'get_ticket'],
'permission_callback' => [$this, 'require_admin'],
]);
register_rest_route($namespace, '/tickets/' . $id . '/reply', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'reply'],
'permission_callback' => [$this, 'require_admin'],
'args' => [
'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
'attachment' => ['required' => false, 'type' => 'array'],
'quote_reply_id' => ['required' => false, 'sanitize_callback' => 'absint'],
],
]);
register_rest_route($namespace, '/tickets/' . $id . '/status', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'set_status'],
'permission_callback' => [$this, 'require_admin'],
'args' => [
'status' => ['required' => true, 'sanitize_callback' => 'sanitize_key'],
'ticket_type_id' => ['required' => false, 'sanitize_callback' => 'absint'],
'admin_id' => ['required' => false, 'sanitize_callback' => 'absint'],
],
]);
register_rest_route($namespace, '/tickets/' . $id . '/receive', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'receive'],
'permission_callback' => [$this, 'require_admin'],
]);
register_rest_route($namespace, '/tickets/' . $id . '/resolve', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'resolve'],
'permission_callback' => [$this, 'require_admin'],
]);
register_rest_route($namespace, '/tickets/' . $id . '/close', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'close'],
'permission_callback' => [$this, 'require_admin'],
]);
register_rest_route($namespace, '/tickets/' . $id . '/notes', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'list_notes'],
'permission_callback' => [$this, 'require_admin'],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_note'],
'permission_callback' => [$this, 'require_admin'],
'args' => ['content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field']],
],
]);
register_rest_route($namespace, '/tickets/notes/' . $id, [
'methods' => \WP_REST_Server::EDITABLE,
'callback' => [$this, 'update_note'],
'permission_callback' => [$this, 'require_admin'],
'args' => ['content' => ['required' => false, 'sanitize_callback' => 'sanitize_textarea_field']],
]);
register_rest_route($namespace, '/tickets/notes/' . $id . '/delete', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'delete_note'],
'permission_callback' => [$this, 'require_admin'],
]);
}
/** @return array<string,mixed> */
private function list_args(): array {
return [
'keywords' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'status' => ['required' => false, 'sanitize_callback' => [$this, 'sanitize_status_list']],
'ticket_type_id' => ['required' => false, 'sanitize_callback' => 'absint'],
'user_id' => ['required' => false, 'sanitize_callback' => 'absint'],
'admin_id' => ['required' => false, 'sanitize_callback' => 'absint'],
'page' => ['required' => false, 'sanitize_callback' => 'absint'],
'per_page' => ['required' => false, 'sanitize_callback' => 'absint'],
];
}
/**
* @return list<string>
*/
public function sanitize_status_list($value): array {
$values = is_array($value) ? $value : explode(',', (string) $value);
$out = [];
foreach ((array) $values as $item) {
$key = sanitize_key((string) $item);
if ($key !== '' && TicketStatusService::exists($key)) {
$out[] = $key;
}
}
return array_values(array_unique($out));
}
public function require_admin() {
if (!current_user_can('manage_options')) {
return new \WP_Error('aksrt_ticket_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
}
return true;
}
/* ------------------------------------------------------------------ *
* 工单
* ------------------------------------------------------------------ */
public function list_tickets(\WP_REST_Request $request) {
$page = max(1, (int) $request->get_param('page'));
$per_page = max(1, min(100, (int) $request->get_param('per_page') ?: 20));
$result = TicketRepository::get_instance()->paginate($this->read_filters($request, $per_page, $page), true);
return rest_ensure_response([
'success' => true,
'data' => [
'items' => $result['items'],
'total' => $result['total'],
'page' => $page,
'per_page' => $per_page,
'total_pages' => max(1, (int) ceil($result['total'] / $per_page)),
],
]);
}
public function statistic(\WP_REST_Request $request) {
$filters = [
'ticket_type_id' => absint($request->get_param('ticket_type_id')),
];
return rest_ensure_response([
'success' => true,
'data' => TicketRepository::get_instance()->statistic($filters, true),
]);
}
public function get_ticket(\WP_REST_Request $request) {
$repo = TicketRepository::get_instance();
$ticket = $repo->find(absint($request->get_param('id')));
if ($ticket === null) {
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
}
$ticket['replies'] = $repo->replies((int) $ticket['id']);
$ticket['notes'] = TicketNoteService::for_ticket((int) $ticket['id']);
$ticket['prereplies'] = TicketPrereplyService::public_list();
return rest_ensure_response(['success' => true, 'data' => $ticket]);
}
public function create_ticket(\WP_REST_Request $request) {
$user_id = absint($request->get_param('user_id'));
$title = trim((string) $request->get_param('title'));
$content = trim((string) $request->get_param('content'));
$type_id = absint($request->get_param('ticket_type_id'));
if ($user_id <= 0 || !get_userdata($user_id)) {
return new \WP_Error('aksrt_ticket_user', __('请选择工单所属用户', 'aksrt'), ['status' => 400]);
}
if ($title === '' || mb_strlen($title) > 200) {
return new \WP_Error('aksrt_ticket_title', __('请填写有效的工单标题', 'aksrt'), ['status' => 400]);
}
if ($content === '' || mb_strlen($content) > 5000) {
return new \WP_Error('aksrt_ticket_content', __('请填写有效的问题描述', 'aksrt'), ['status' => 400]);
}
if (!TicketTypeService::exists($type_id)) {
return new \WP_Error('aksrt_ticket_type', __('请选择工单部门', 'aksrt'), ['status' => 400]);
}
$attachment = TicketAttachment::sanitize($request->get_param('attachment'));
$id = TicketRepository::get_instance()->create([
'ticket_num' => TicketNumber::generate(),
'user_id' => $user_id,
'title' => $title,
'ticket_type_id' => $type_id,
'content' => $content,
'order_number' => sanitize_text_field((string) $request->get_param('order_number')),
'attachment' => TicketAttachment::encode($attachment),
'status' => TicketStatusService::KEY_ADMIN_REPLIED,
'post_admin_id' => get_current_user_id(),
'admin_id' => get_current_user_id(),
]);
if ($id <= 0) {
return new \WP_Error('aksrt_ticket_db', __('工单创建失败', 'aksrt'), ['status' => 500]);
}
$ticket = TicketRepository::get_instance()->find($id);
if (is_array($ticket)) {
TicketNotifier::user_replied($ticket);
}
return rest_ensure_response(['success' => true, 'data' => ['id' => $id]]);
}
public function reply(\WP_REST_Request $request) {
$repo = TicketRepository::get_instance();
$id = absint($request->get_param('id'));
$ticket = $repo->find($id);
if ($ticket === null) {
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
}
if (!empty($ticket['is_closed'])) {
return new \WP_Error('aksrt_ticket_closed', __('工单已关闭,无法回复', 'aksrt'), ['status' => 409]);
}
$content = trim((string) $request->get_param('content'));
if ($content === '' || mb_strlen($content) > 3000) {
return new \WP_Error('aksrt_ticket_reply_content', __('请填写有效的回复内容', 'aksrt'), ['status' => 400]);
}
$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) {
$quote_id = 0;
}
}
$attachment = TicketAttachment::sanitize($request->get_param('attachment'));
$reply_id = $repo->create_reply($id, 'admin', get_current_user_id(), $content, $attachment, $quote_id);
if ($reply_id <= 0) {
return new \WP_Error('aksrt_ticket_db', __('回复提交失败', 'aksrt'), ['status' => 500]);
}
$repo->touch_reply($id, 'admin', get_current_user_id());
$repo->apply_reply_status($id, $ticket, 'admin');
TicketNotifier::user_replied($repo->find($id) ?? $ticket);
return rest_ensure_response(['success' => true, 'data' => ['reply_id' => $reply_id]]);
}
public function set_status(\WP_REST_Request $request) {
$repo = TicketRepository::get_instance();
$id = absint($request->get_param('id'));
$ticket = $repo->find($id);
if ($ticket === null) {
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
}
$to = sanitize_key((string) $request->get_param('status'));
if (!TicketStatusService::can_transition((string) $ticket['status'], $to)) {
return new \WP_Error('aksrt_ticket_state', __('工单状态不允许该变更', 'aksrt'), ['status' => 409]);
}
$fields = ['status' => $to];
$type_id = absint($request->get_param('ticket_type_id'));
if ($type_id > 0 && TicketTypeService::exists($type_id)) {
$fields['ticket_type_id'] = $type_id;
}
$admin_id = absint($request->get_param('admin_id'));
if ($admin_id > 0 && get_userdata($admin_id)) {
$fields['admin_id'] = $admin_id;
}
if (!$repo->update_fields($id, $fields)) {
return new \WP_Error('aksrt_ticket_db', __('工单状态保存失败', 'aksrt'), ['status' => 500]);
}
$ticket = array_merge($ticket, $fields);
if ($to === TicketStatusService::KEY_CLOSED) {
TicketNotifier::closed($ticket, 'admin');
}
return rest_ensure_response(['success' => true, 'data' => $repo->find($id)]);
}
public function receive(\WP_REST_Request $request) {
return $this->simple_action($request, 'receive');
}
public function resolve(\WP_REST_Request $request) {
return $this->simple_action($request, 'resolve');
}
public function close(\WP_REST_Request $request) {
return $this->simple_action($request, 'close');
}
/** 接收 / 已解决 / 关闭:三个动作都只是状态迁移,共用同一外壳。 */
private function simple_action(\WP_REST_Request $request, string $action) {
$repo = TicketRepository::get_instance();
$id = absint($request->get_param('id'));
$ticket = $repo->find($id);
if ($ticket === null) {
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
}
$ok = match ($action) {
'receive' => $repo->receive($id, $ticket),
'resolve' => $repo->resolve($id, $ticket),
default => $repo->close($id, $ticket, 'admin'),
};
if (!$ok) {
return new \WP_Error('aksrt_ticket_state', __('工单状态不允许该操作', 'aksrt'), ['status' => 409]);
}
return rest_ensure_response(['success' => true, 'data' => $repo->find($id)]);
}
/* ------------------------------------------------------------------ *
* 内部
* ------------------------------------------------------------------ */
/**
* @return array<string,mixed>
*/
private function read_filters(\WP_REST_Request $request, int $per_page, int $page): array {
return [
'keywords' => $request->get_param('keywords'),
'status' => $request->get_param('status'),
'ticket_type_id' => $request->get_param('ticket_type_id'),
'user_id' => $request->get_param('user_id'),
'admin_id' => $request->get_param('admin_id'),
'limit' => $per_page,
'offset' => ($page - 1) * $per_page,
];
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* 工单系统入口(薄壳)。
*
* 只负责单例、REST 注册与配置缓存失效;业务在 TicketRepository /
* TicketRest / TicketRestAdmin / TicketConfigRest 与各 Service 中。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketService {
private static ?self $instance = null;
public static function get_instance(): self {
return self::$instance ??= new self();
}
public function init(): void {
add_action('rest_api_init', [$this, 'register_routes']);
add_action('aksrt_settings_domain_saved', static function (string $domain, string $section): void {
if ($domain === 'users' && $section === TicketSettings::SECTION) {
TicketTypeService::flush();
TicketStatusService::flush();
}
}, 10, 2);
}
public function register_routes(): void {
(new TicketRest())->register_routes();
(new TicketRestAdmin())->register_routes();
}
/** 供后台页与前台模板复用的下拉数据(不经过 REST)。 */
public static function meta(): array {
return [
'types' => TicketTypeService::all(),
'statuses' => TicketStatusService::all(),
'prereplies' => TicketPrereplyService::all(),
];
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* Ticket settings reader — 工单配置(域 users,分区 ticket)。
*
* 字段键、顺序、类型与默认值需与 assets/admin/src/schema/sections/users/ticket.js
* 及 src/Admin/Schema/Users/SchemaUsersTicket.php 保持 1:1。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketSettings {
/** 默认分区名,SchemaProvider 据此定位 schema。 */
public const SECTION = 'ticket';
/** @return array<string,mixed> */
public static function all(): array {
$s = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::read_domain('users', self::SECTION)
: [];
return is_array($s) ? $s : [];
}
public static function enabled(): bool {
$s = self::all();
return !isset($s['ticket_s']) || !empty($s['ticket_s']);
}
/** 工单号前缀(仅保留安全字符,空值回落 T)。 */
public static function number_prefix(): string {
$raw = sanitize_key((string) (self::all()['ticket_num_prefix'] ?? 'T'));
return $raw !== '' ? strtoupper($raw) : 'T';
}
public static function notice_open(): bool {
return !empty(self::all()['ticket_notice_open']);
}
public static function notice_text(): string {
return wp_kses_post((string) (self::all()['ticket_notice_text'] ?? ''));
}
public static function attachment_enabled(): bool {
return !empty(self::all()['ticket_attachment_s']);
}
/** 单个工单(主内容 + 每条回复)的附件数量上限。 */
public static function attachment_max(): int {
return max(1, min(9, (int) (self::all()['ticket_attachment_max'] ?? 5)));
}
/** 是否允许在新建工单时关联订单。 */
public static function order_link_enabled(): bool {
return !empty(self::all()['ticket_order_link_s']);
}
/** 催单冷却分钟数。 */
public static function urge_interval(): int {
return max(1, min(1440, (int) (self::all()['ticket_urge_interval'] ?? 15)));
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
/**
* 工单状态(自定义):CRUD + seeds 兜底 + 状态机迁移规则。
*
* 状态是数据驱动的:管理员可增删改,因此状态机不写死合法目标集合,
* 只校验「目标状态必须存在」以及几条业务不变量(已完结不可流转、
* 「已解决」不允许从未接单直接跳、默认状态不可改删)。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketStatusService {
/** 内置状态 key:seeds 写入后不可改删的默认态。 */
public const KEY_PENDING = 'pending';
public const KEY_HANDLING = 'handling';
public const KEY_USER_REPLIED = 'user_replied';
public const KEY_ADMIN_REPLIED = 'admin_replied';
public const KEY_CLOSED = 'closed';
/** @var array<string,array<string,mixed>>|null status_key => row */
private static ?array $cache = null;
public static function flush(): void {
self::$cache = null;
}
/**
* 全部状态,按 sort_order / id 正序。
*
* @return list<array<string,mixed>>
*/
public static function all(): array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
if ($table === '') {
return [];
}
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY sort_order ASC, id ASC", ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
if (!is_array($rows)) {
return [];
}
self::$cache = [];
$out = [];
foreach ($rows as $row) {
$normalized = self::normalize($row);
self::$cache[(string) $normalized['status_key']] = $normalized;
$out[] = $normalized;
}
return $out;
}
/** @return array<string,array<string,mixed>> status_key => row */
public static function map(): array {
if (self::$cache === null) {
self::all();
}
return self::$cache ?? [];
}
/** @return array<string,mixed>|null */
public static function find(string $status_key): ?array {
$map = self::map();
return $map[$status_key] ?? null;
}
public static function exists(string $status_key): bool {
return self::find($status_key) !== null;
}
public static function is_closed(string $status_key): bool {
$row = self::find($status_key);
return $row !== null && !empty($row['is_closed']);
}
/** 状态展示名;未知 key 回落 key 本身(工单可能引用已被删的状态)。 */
public static function label(string $status_key): string {
$row = self::find($status_key);
return $row !== null ? (string) $row['name'] : $status_key;
}
public static function color(string $status_key): string {
$row = self::find($status_key);
return $row !== null ? (string) $row['color'] : '#6b7280';
}
/**
* 状态机:从 $from 迁移到 $to 是否合法。
*
* 只挡确定非法的组合,其余交给「目标状态必须存在」:
* - 已完结状态(is_closed)不可再流转;
* - 「已解决/关闭」类终态不允许从未接单一步跳过去(必须先接收或回复),
* 避免管理员误把刚提交的工单直接关掉;
* - 同状态原地提交视为合法(幂等,便于后台重复点击)。
*/
public static function can_transition(string $from, string $to): bool {
if ($to === '' || !self::exists($to)) {
return false;
}
if ($from === $to) {
return true;
}
if (self::is_closed($from)) {
return false;
}
if ($from === self::KEY_PENDING && $to === self::KEY_CLOSED) {
return false;
}
return true;
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private static function normalize(array $row): array {
return [
'id' => (int) ($row['id'] ?? 0),
'status_key' => sanitize_key((string) ($row['status_key'] ?? '')),
'name' => sanitize_text_field((string) ($row['name'] ?? '')),
'color' => self::sanitize_color((string) ($row['color'] ?? '')),
'is_closed' => !empty($row['is_closed']),
'is_default' => !empty($row['is_default']),
'sort_order' => (int) ($row['sort_order'] ?? 0),
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
/** 颜色只接受 #rgb / #rrggbb,其余回落默认灰。 */
public static function sanitize_color(string $color): string {
$color = strtolower(trim($color));
if (preg_match('/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/', $color) !== 1) {
return '#6b7280';
}
return $color;
}
/** 新建状态时生成唯一 status_key(ASCII 标签取 key,其余取短哈希)。 */
public static function make_key(string $name): string {
$key = sanitize_key($name);
if ($key === '') {
$key = 'st' . substr(md5($name . (string) wp_rand(1, 999999)), 0, 8);
}
$base = $key;
$suffix = 1;
while (self::exists($key)) {
$key = $base . $suffix;
$suffix++;
}
return $key;
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
/**
* 工单部门:CRUD + 受理管理员(admin_ids JSON)。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketTypeService {
/** @var list<array<string,mixed>>|null */
private static ?array $cache = null;
public static function flush(): void {
self::$cache = null;
}
/**
* @return list<array<string,mixed>>
*/
public static function all(): array {
if (self::$cache !== null) {
return self::$cache;
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
if ($table === '') {
return [];
}
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY sort_order ASC, id ASC", ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
if (!is_array($rows)) {
return [];
}
$out = [];
foreach ($rows as $row) {
$out[] = self::normalize($row);
}
self::$cache = $out;
return $out;
}
/**
* 前台可选部门(不含 description / admin_ids 内部字段)。
*
* @return list<array<string,mixed>>
*/
public static function public_list(): array {
$out = [];
foreach (self::all() as $row) {
$out[] = [
'id' => $row['id'],
'name' => $row['name'],
'description' => $row['description'],
];
}
return $out;
}
/** @return array<string,mixed>|null */
public static function find(int $id): ?array {
foreach (self::all() as $row) {
if ((int) $row['id'] === $id) {
return $row;
}
}
return null;
}
public static function exists(int $id): bool {
return self::find($id) !== null;
}
public static function name(int $id): string {
$row = self::find($id);
return $row !== null ? (string) $row['name'] : '';
}
/** 部门名是否已被占用(新建/改名时查重)。 */
public static function name_taken(string $name, int $ignore_id = 0): bool {
$name = trim($name);
foreach (self::all() as $row) {
if ((int) $row['id'] !== $ignore_id && (string) $row['name'] === $name) {
return true;
}
}
return false;
}
/**
* @param array<int,mixed> $admin_ids
* @return list<int>
*/
public static function sanitize_admin_ids(array $admin_ids): array {
$clean = [];
foreach ($admin_ids as $id) {
$id = (int) $id;
if ($id > 0 && !in_array($id, $clean, true)) {
$clean[] = $id;
}
}
return $clean;
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private static function normalize(array $row): array {
$admins = json_decode((string) ($row['admin_ids'] ?? ''), true);
return [
'id' => (int) ($row['id'] ?? 0),
'name' => sanitize_text_field((string) ($row['name'] ?? '')),
'description' => sanitize_text_field((string) ($row['description'] ?? '')),
'admin_ids' => is_array($admins) ? self::sanitize_admin_ids($admins) : [],
'sort_order' => (int) ($row['sort_order'] ?? 0),
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
}
+19
View File
@@ -197,6 +197,12 @@ class Center {
'priority' => 48,
'type' => 'panel',
],
'tickets' => [
'label' => __('工单中心', 'aksrt'),
'icon' => 'life-ring',
'priority' => 49,
'type' => 'panel',
],
'credits' => [
'label' => class_exists('\AKSRT\Settings\Settings')
? sprintf(__('我的%s', 'aksrt'), \AKSRT\Settings\Settings::get_currency_name())
@@ -248,6 +254,7 @@ class Center {
&& !empty(\AKSRT\Settings\Settings::get_payment_revenue_settings()['enabled']);
$vip_on = class_exists('\AKSRT\Settings\Settings')
&& !empty(\AKSRT\Settings\Settings::get_payment_vip_settings()['enabled']);
$ticket_on = class_exists('\AKSRT\Ticket\TicketSettings') && \AKSRT\Ticket\TicketSettings::enabled();
// Follow configured order; always auto-include enabled surfaces.
$order = $configured;
@@ -307,6 +314,15 @@ class Center {
}
array_splice($order, $insert_at, 0, ['orders']);
}
// 工单中心:紧邻「我的订单」,开关关闭时整块移除。
if ($ticket_on && !in_array('tickets', $order, true)) {
$insert_at = array_search('orders', $order, true);
array_splice($order, $insert_at === false ? count($order) : (int) $insert_at + 1, 0, ['tickets']);
}
if (!$ticket_on) {
$order = array_values(array_filter($order, static fn(string $key): bool => $key !== 'tickets'));
}
if (($credits_on || $balance_on) && !in_array('credits', $order, true)) {
$order[] = 'credits';
}
@@ -354,6 +370,9 @@ class Center {
if ($key === 'orders' && !$orders_on) {
continue;
}
if ($key === 'tickets' && !$ticket_on) {
continue;
}
if ($key === 'auth' && !$auth_on) {
continue;
}