Files
wordpress-theme-aksrt/src/Ticket/TicketRestAdmin.php
T
kate522andClaude Opus 5 264015b7e9 feat(admin): streamline ticket management with dialogs
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>
2026-10-31 11:25:56 +08:00

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,
];
}
}