Move on-behalf ticket creation into a header dialog and load ticket handling on demand. Reuse the admin REST write path, preserve drafts and filters, and cover dialog and server boundaries with regression tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
477 lines
21 KiB
PHP
477 lines
21 KiB
PHP
<?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'],
|
|
'status_key' => ['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, [
|
|
'methods' => \WP_REST_Server::EDITABLE,
|
|
'callback' => [$this, 'update_status'],
|
|
'permission_callback' => [$this, 'require_admin'],
|
|
'args' => [
|
|
'name' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
|
'status_key' => ['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) > TicketSettings::title_max()) {
|
|
return new \WP_Error('aksrt_ticket_title', __('请填写有效的工单标题', 'aksrt'), ['status' => 400]);
|
|
}
|
|
if ($content === '' || mb_strlen($content) > TicketSettings::content_max()) {
|
|
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'));
|
|
$order_number = TicketSettings::order_link_enabled()
|
|
? sanitize_text_field((string) $request->get_param('order_number'))
|
|
: '';
|
|
$id = TicketRepository::get_instance()->create([
|
|
'ticket_num' => TicketNumber::generate(),
|
|
'user_id' => $user_id,
|
|
'title' => $title,
|
|
'ticket_type_id' => $type_id,
|
|
'content' => $content,
|
|
'order_number' => $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]);
|
|
}
|
|
\AKSRT\Attachment\PendingUploads::claim_urls($attachment, get_current_user_id());
|
|
$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) > TicketSettings::reply_max()) {
|
|
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 || $quoted['author_type'] === 'system') {
|
|
$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]);
|
|
}
|
|
\AKSRT\Attachment\PendingUploads::claim_urls($attachment, get_current_user_id());
|
|
$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];
|
|
if ($request->has_param('ticket_type_id')) {
|
|
$type_id = absint($request->get_param('ticket_type_id'));
|
|
if ($type_id <= 0 || !TicketTypeService::exists($type_id)) {
|
|
return new \WP_Error('aksrt_ticket_type', __('请选择有效的工单部门', 'aksrt'), ['status' => 400]);
|
|
}
|
|
$fields['ticket_type_id'] = $type_id;
|
|
}
|
|
if ($request->has_param('admin_id')) {
|
|
$admin_id = absint($request->get_param('admin_id'));
|
|
if ($admin_id > 0 && $admin_id !== (int) ($ticket['admin_id'] ?? 0)
|
|
&& (!get_userdata($admin_id) || !user_can($admin_id, 'manage_options'))) {
|
|
return new \WP_Error('aksrt_ticket_admin', __('请选择有效的管理员', 'aksrt'), ['status' => 400]);
|
|
}
|
|
$fields['admin_id'] = $admin_id;
|
|
}
|
|
$fields['updated_at'] = current_time('mysql');
|
|
if (!$repo->update_fields($id, $fields)) {
|
|
return new \WP_Error('aksrt_ticket_db', __('工单状态保存失败', 'aksrt'), ['status' => 500]);
|
|
}
|
|
$was_closed = TicketStatusService::is_closed((string) $ticket['status']);
|
|
$ticket = array_merge($ticket, $fields);
|
|
if ($to === TicketStatusService::KEY_CLOSED && !$was_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,
|
|
];
|
|
}
|
|
}
|