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