[ [ 'title' => '工单总量', 'value' => (int)Db::name('tickets')->count(), 'desc' => '当前数据库内工单总数', ], [ 'title' => '待处理工单', 'value' => (int)Db::name('tickets')->whereIn('status', ['pending', 'processing'])->count(), 'desc' => '待处理与处理中工单数量', ], [ 'title' => '已解决工单', 'value' => (int)Db::name('tickets')->where('status', 'resolved')->count(), 'desc' => '当前已解决的工单数量', ], [ 'title' => '工单留言', 'value' => (int)Db::name('ticket_messages')->count(), 'desc' => '当前工单消息记录总数', ], ], ]); } public function index(Request $request) { $keyword = trim((string)$request->input('keyword', '')); $status = trim((string)$request->input('status', '')); $type = trim((string)$request->input('ticket_type', '')); $query = Db::name('tickets') ->field([ 'id', 'ticket_no', 'ticket_type', 'biz_type', 'biz_id', 'order_id', 'user_id', 'status', 'priority', 'assignee_id', 'title', 'created_at', 'updated_at', ]) ->order('id', 'desc'); if ($keyword !== '') { $query->where(function ($builder) use ($keyword) { $builder->whereLike('ticket_no', "%{$keyword}%") ->whereOrLike('title', "%{$keyword}%"); }); } if ($status !== '') { $query->where('status', $status); } if ($type !== '') { $query->where('ticket_type', $type); } $rows = $query->select()->toArray(); $list = array_map(function (array $item) { return [ 'id' => (int)$item['id'], 'ticket_no' => $item['ticket_no'], 'ticket_type' => $item['ticket_type'], 'ticket_type_text' => $this->ticketTypeText($item['ticket_type']), 'biz_type' => $item['biz_type'], 'biz_id' => (int)($item['biz_id'] ?? 0), 'order_id' => (int)($item['order_id'] ?? 0), 'user_id' => (int)($item['user_id'] ?? 0), 'status' => $item['status'], 'status_text' => $this->statusText($item['status']), 'priority' => $item['priority'], 'priority_text' => $this->priorityText($item['priority']), 'title' => $item['title'] ?: '未命名工单', 'created_at' => $item['created_at'], 'updated_at' => $item['updated_at'], ]; }, $rows); return api_success(['list' => $list]); } public function detail(Request $request) { $id = (int)$request->input('id', 0); if (!$id) { return api_error('工单 ID 不能为空', 422); } $ticket = Db::name('tickets')->where('id', $id)->find(); if (!$ticket) { return api_error('工单不存在', 404); } $messages = Db::name('ticket_messages') ->where('ticket_id', $id) ->order('id', 'asc') ->select() ->toArray(); return api_success([ 'ticket_info' => [ 'id' => (int)$ticket['id'], 'ticket_no' => $ticket['ticket_no'], 'ticket_type' => $ticket['ticket_type'], 'ticket_type_text' => $this->ticketTypeText($ticket['ticket_type']), 'biz_type' => $ticket['biz_type'], 'biz_id' => (int)($ticket['biz_id'] ?? 0), 'order_id' => (int)($ticket['order_id'] ?? 0), 'user_id' => (int)($ticket['user_id'] ?? 0), 'status' => $ticket['status'], 'status_text' => $this->statusText($ticket['status']), 'priority' => $ticket['priority'], 'priority_text' => $this->priorityText($ticket['priority']), 'title' => $ticket['title'], 'content' => $ticket['content'], 'created_at' => $ticket['created_at'], 'updated_at' => $ticket['updated_at'], ], 'messages' => array_map(function (array $item) { return [ 'sender_type' => $item['sender_type'], 'sender_type_text' => $item['sender_type'] === 'customer_service' ? '客服' : ($item['sender_type'] === 'system' ? '系统' : '用户'), 'content' => $item['content'] ?: '', 'attachments' => $this->attachmentService()->normalize($item['attachments_json'] ?? null, $request), 'created_at' => $item['created_at'], ]; }, $messages), ]); } public function save(Request $request) { $id = (int)$request->input('id', 0); if (!$id) { return api_error('工单 ID 不能为空', 422); } $ticket = Db::name('tickets')->where('id', $id)->find(); if (!$ticket) { return api_error('工单不存在', 404); } $status = trim((string)$request->input('status', $ticket['status'])); $priority = trim((string)$request->input('priority', $ticket['priority'])); $now = date('Y-m-d H:i:s'); Db::startTrans(); try { Db::name('tickets')->where('id', $id)->update([ 'status' => $status, 'priority' => $priority, 'updated_at' => $now, ]); if ($status !== $ticket['status']) { $this->notifyStatusChanged($ticket, $status, $now); } Db::commit(); } catch (\Throwable $e) { Db::rollback(); return api_error('工单更新失败', 500, [ 'detail' => $e->getMessage(), ]); } return api_success(['id' => $id], '工单已更新'); } public function reply(Request $request) { $ticketId = (int)$request->input('ticket_id', 0); $content = trim((string)$request->input('content', '')); $attachments = $this->attachmentService()->normalize($request->input('attachments', []), $request, true); if (!$ticketId) { return api_error('工单 ID 不能为空', 422); } if ($content === '' && !$attachments) { return api_error('回复内容和附件至少填写一项', 422); } $ticket = Db::name('tickets')->where('id', $ticketId)->find(); if (!$ticket) { return api_error('工单不存在', 404); } $now = date('Y-m-d H:i:s'); Db::startTrans(); try { $messageId = (int)Db::name('ticket_messages')->insertGetId([ 'ticket_id' => $ticketId, 'sender_type' => 'customer_service', 'sender_id' => 1, 'content' => $content, 'attachments_json' => $attachments ? json_encode($attachments, JSON_UNESCAPED_UNICODE) : null, 'created_at' => $now, ]); Db::name('tickets')->where('id', $ticketId)->update([ 'status' => 'processing', 'updated_at' => $now, ]); (new MessageDispatcher())->sendInboxEvent('ticket_reply', [ 'user_id' => (int)($ticket['user_id'] ?? 0), 'biz_type' => 'ticket_message', 'biz_id' => $messageId, 'ticket_id' => $ticketId, 'ticket_no' => $ticket['ticket_no'], 'ticket_title' => $ticket['title'] ?: '客服工单', 'reply_content' => $content, 'fallback_title' => '工单有新回复', 'fallback_content' => sprintf('客服已回复您的工单「%s」,点击查看详情。', $ticket['title'] ?: '客服工单'), ]); Db::commit(); return api_success(['ticket_id' => $ticketId], '回复成功'); } catch (\Throwable $e) { Db::rollback(); return api_error('回复失败', 500, [ 'detail' => $e->getMessage(), ]); } } public function uploadFile(Request $request) { try { $asset = $this->attachmentService()->upload($request); return api_success($asset); } catch (\Throwable $e) { return api_error($e->getMessage(), 422); } } public function deleteFile(Request $request) { $fileUrl = trim((string)$request->input('file_url', '')); if ($fileUrl === '') { return api_error('文件地址不能为空', 422); } $this->attachmentService()->delete($fileUrl); return api_success([ 'file_url' => $fileUrl, ], '删除成功'); } private function statusText(string $status): string { return (new ContentService())->ticketStatusText($status); } private function priorityText(string $priority): string { return match ($priority) { 'high' => '高优先级', 'normal' => '普通', 'low' => '低优先级', default => $priority, }; } private function ticketTypeText(string $type): string { return (new ContentService())->ticketTypeText($type); } private function notifyStatusChanged(array $ticket, string $status, string $now): void { $eventConfig = match ($status) { 'waiting_user' => [ 'event_code' => 'ticket_waiting_user', 'title' => '工单等待您补充反馈', 'content' => sprintf('客服正在跟进工单「%s」,当前需要您补充反馈信息。', $ticket['title'] ?: '客服工单'), 'system_message' => '工单状态已更新为待用户反馈,请等待用户补充信息。', ], 'resolved' => [ 'event_code' => 'ticket_resolved', 'title' => '工单已解决', 'content' => sprintf('您的工单「%s」已处理完成,如仍有疑问可继续留言。', $ticket['title'] ?: '客服工单'), 'system_message' => '工单状态已更新为已解决。', ], 'closed' => [ 'event_code' => 'ticket_closed', 'title' => '工单已关闭', 'content' => sprintf('您的工单「%s」已关闭,如需继续处理可重新发起工单。', $ticket['title'] ?: '客服工单'), 'system_message' => '工单状态已更新为已关闭。', ], default => null, }; if (!$eventConfig) { return; } $messageId = (int)Db::name('ticket_messages')->insertGetId([ 'ticket_id' => (int)$ticket['id'], 'sender_type' => 'system', 'sender_id' => null, 'content' => $eventConfig['system_message'], 'attachments_json' => null, 'created_at' => $now, ]); (new MessageDispatcher())->sendInboxEvent($eventConfig['event_code'], [ 'user_id' => (int)($ticket['user_id'] ?? 0), 'biz_type' => 'ticket_message', 'biz_id' => $messageId, 'ticket_id' => (int)$ticket['id'], 'ticket_no' => $ticket['ticket_no'] ?? '', 'ticket_title' => $ticket['title'] ?: '客服工单', 'fallback_title' => $eventConfig['title'], 'fallback_content' => $eventConfig['content'], ]); } private function attachmentService(): TicketAttachmentService { return new TicketAttachmentService(); } }