Build hệ thống Helpdesk & Feedback: Quản lý "tiếng nói" khách hàng chuẩn Enterprise
để hệ thống Proptech (Bất động sản) hay bất kỳ dự án SaaS nào trở nên chuyên nghiệp, module Quản lý Phản hồi & Hỗ trợ (Feedback & Ticket System) chính là cầu nối giữ chân khách hàng. Nhiều anh em thường làm phần này rất đơn giản: Tạo một bảng contacts rồi lưu email và nội dung vào đó. Nhưng thực tế, một hệ thống hỗ trợ chuẩn Enterprise cần giải quyết được bài toán Luồng công việc (Workflow): Khi nào thì một yêu cầu được tiếp nhận? Ai là người xử lý? Yêu cầu đó có độ ưu tiên (Priority) ra sao? Và quan trọng nhất là lịch sử trao đổi (Thread) giữa khách hàng và Admin.
Hôm nay, chúng ta sẽ xây dựng một hệ thống Helpdesk mini tích hợp cả Contact Form và Support Ticket, sử dụng cơ chế State Machine đơn giản để quản lý vòng đời của một Ticket.
Lời mở đầu: Sự khác biệt giữa Feedback và Ticket
- Feedback/Review: Thường là dữ liệu một chiều (User gửi đánh giá 5 sao, Admin duyệt để hiển thị).
- Support Ticket: Là dữ liệu hai chiều. Nó có trạng thái (
Mở,Đang xử lý,Đã giải quyết) và có một chuỗi hội thoại (Chat/Reply) đi kèm.
Chúng ta sẽ thiết kế một hệ thống linh hoạt, cho phép một khách hàng gửi yêu cầu và Admin có thể phản hồi trực tiếp trên đó.
Bước 1: Khởi tạo và Thiết kế Database (Ticket & Conversation)
Tạo dự án mới:
laravel new enterprise-support
cd enterprise-support
Chúng ta cần 2 bảng chính: tickets (Thông tin chung của yêu cầu) và ticket_replies (Nội dung trao đổi).
Chúng ta cần 2 bảng chính: tickets (Thông tin chung của yêu cầu) và ticket_replies (Nội dung trao đổi).
1. File Migration cho Tickets:
// database/migrations/xxxx_create_tickets_table.php
public function up(): void
{
Schema::create('tickets', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); // Null nếu là khách vãng lai gửi Contact Form
$table->string('subject');
$table->text('content');
$table->string('priority')->default('medium'); // low, medium, high, urgent
$table->string('status')->default('open'); // open, pending, resolved, closed
$table->string('category'); // support, billing, feedback, bug
$table->timestamps();
});
}
2. File Migration cho Ticket Replies:
// database/migrations/xxxx_create_ticket_replies_table.php
public function up(): void
{
Schema::create('ticket_replies', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained(); // Người trả lời (Admin hoặc User)
$table->text('message');
$table->timestamps();
});
}
Bước 2: Chuẩn hóa Trạng thái và Quan hệ Model
Sử dụng Enums để quản lý trạng thái giúp code của chúng ta "chống đạn".
// app/Enums/TicketStatus.php
namespace App\Enums;
enum TicketStatus: string {
case OPEN = 'open';
case PENDING = 'pending';
case RESOLVED = 'resolved';
case CLOSED = 'closed';
}
Model Ticket.php:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Enums\TicketStatus;
class Ticket extends Model
{
protected $fillable = ['user_id', 'subject', 'content', 'priority', 'status', 'category'];
protected $casts = [
'status' => TicketStatus::class,
];
public function replies()
{
return $this->hasMany(TicketReply::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
Bước 3: Action Pattern - Xử lý Phản hồi Ticket
Khi Admin trả lời một Ticket, trạng thái của Ticket đó thường sẽ tự động chuyển sang pending (Đang chờ khách hàng phản hồi ngược lại). Ta sẽ đóng gói logic này vào một Action.
// app/Actions/ReplyTicketAction.php
namespace App\Actions;
use App\Models\Ticket;
use App\Models\TicketReply;
use App\Enums\TicketStatus;
use Illuminate\Support\Facades\DB;
class ReplyTicketAction
{
public function execute(Ticket $ticket, int $userId, string $message): TicketReply
{
return DB::transaction(function () use ($ticket, $userId, $message) {
// 1. Tạo câu trả lời
$reply = $ticket->replies()->create([
'user_id' => $userId,
'message' => $message,
]);
// 2. Cập nhật trạng thái Ticket sang Pending (nếu Admin trả lời)
$ticket->update(['status' => TicketStatus::PENDING]);
return $reply;
});
}
}
Bước 4: Controller Điều phối API Hỗ trợ
Chúng ta cần API cho khách hàng gửi yêu cầu và API cho Admin xem/trả lời.
// app/Http/Controllers/Api/TicketController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Ticket;
use App\Actions\ReplyTicketAction;
use App\Http\Resources\TicketResource;
class TicketController extends Controller
{
/**
* Khách hàng gửi yêu cầu hỗ trợ (hoặc Contact Form)
*/
public function store(Request $request)
{
$request->validate([
'subject' => 'required|string|max:255',
'content' => 'required|string',
'category' => 'required|string',
'priority' => 'nullable|string'
]);
$ticket = Ticket::create([
'user_id' => auth()->id(), // Có thể null nếu cho phép gửi ẩn danh
'subject' => $request->subject,
'content' => $request->content,
'category' => $request->category,
'priority' => $request->priority ?? 'medium',
]);
return response()->json(['success' => true, 'data' => $ticket], 201);
}
/**
* Admin phản hồi Ticket
*/
public function reply($id, Request $request, ReplyTicketAction $action)
{
$request->validate(['message' => 'required|string']);
$ticket = Ticket::findOrFail($id);
$reply = $action->execute($ticket, auth()->id(), $request->message);
return response()->json([
'success' => true,
'message' => 'Đã gửi phản hồi thành công.',
'data' => $reply
]);
}
/**
* Lấy danh sách Ticket (Dành cho Admin)
*/
public function index()
{
$tickets = Ticket::with('user')->latest()->paginate(10);
return response()->json(['success' => true, 'data' => $tickets]);
}
}
Routes (routes/api.php):
use App\Http\Controllers\Api\TicketController;
Route::middleware('auth:sanctum')->group(function () {
Route::get('/admin/tickets', [TicketController::class, 'index']); // Xem danh sách
Route::post('/tickets', [TicketController::class, 'store']); // Tạo mới
Route::post('/tickets/{id}/reply', [TicketController::class, 'reply']); // Trả lời
});
Bước 5: Thử lửa với Postman
Bật server: php artisan serve
Kịch bản 1: Khách hàng gửi yêu cầu hỗ trợ (Submit Ticket)
- Method:
POST - URL:
http://127.0.0.1:8000/api/tickets - Body (JSON):
{
"subject": "Lỗi không thể thanh toán qua ZaloPay",
"content": "Tôi đã thử thanh toán đơn hàng #123 nhưng hệ thống báo lỗi 500.",
"category": "billing",
"priority": "high"
}
- Kết quả: Hệ thống tạo Ticket với trạng thái mặc định là
open.
Kịch bản 2: Admin kiểm tra và trả lời (Admin Reply)
- Method:
POST - URL:
http://127.0.0.1:8000/api/tickets/1/reply - Body (JSON):
{
"message": "Chào bạn, chúng tôi đã tiếp nhận lỗi này và đang làm việc với phía ZaloPay. Vui lòng đợi trong giây lát."
}
Kết quả: Một bản ghi mới được tạo trong ticket_replies. Quan trọng nhất: Ticket ID 1 sẽ tự động chuyển trạng thái từ open sang pending nhờ logic trong ReplyTicketAction
Tổng kết
Xây dựng hệ thống Phản hồi & Hỗ trợ chuẩn Enterprise đòi hỏi sự chỉn chu trong việc quản lý trạng thái:
- Workflow rõ ràng: Dùng Enums và State logic để đảm bảo Ticket luôn đi đúng lộ trình.
- Lịch sử hội thoại (Thread): Tách biệt Ticket và Replies để dễ dàng theo dõi toàn bộ quá trình hỗ trợ khách hàng.
- Tập trung hóa: Quy mọi luồng từ Contact Form, Bug Report về một mối giúp Admin quản lý tập trung, không bỏ sót bất kỳ phản hồi nào.
Đây chính là nền tảng để bạn xây dựng các tính năng cao cấp hơn như SLA (Thời gian cam kết phản hồi) hoặc Tự động hóa gửi Email thông báo cho khách hàng khi Admin vừa trả lời. Chúc anh em áp dụng thành công!
All rights reserved