本文目录导读:

我来为你设计一个完整的PHP客服系统工单流程方案。
整体架构设计
数据库表结构
-- 工单表 CREATE TABLE `tickets` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `ticket_no` varchar(30) NOT NULL COMMENT '工单编号', `user_id` int(11) NOT NULL COMMENT '提交用户ID', `category_id` int(11) DEFAULT NULL COMMENT '分类ID', `priority` tinyint(1) DEFAULT '2' COMMENT '优先级 1紧急 2高 3普通 4低', `subject` varchar(200) NOT NULL COMMENT '标题', `status` tinyint(1) DEFAULT '1' COMMENT '状态 1待处理 2处理中 3待确认 4已解决 5已关闭', `assignee_id` int(11) DEFAULT NULL COMMENT '处理人ID', `channel` varchar(20) DEFAULT 'web' COMMENT '渠道 web/app/wechat/email', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `closed_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_ticket_no` (`ticket_no`), KEY `idx_user_id` (`user_id`), KEY `idx_assignee` (`assignee_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 工单回复表 CREATE TABLE `ticket_replies` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `ticket_id` bigint(20) NOT NULL, `user_id` int(11) NOT NULL COMMENT '回复人ID', `content` text NOT NULL COMMENT '回复内容', `is_customer` tinyint(1) DEFAULT '0' COMMENT '是否客户回复', `attachments` text COMMENT '附件JSON', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_ticket_id` (`ticket_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 工单分类表 CREATE TABLE `ticket_categories` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `parent_id` int(11) DEFAULT '0', `sort_order` int(11) DEFAULT '0', `status` tinyint(1) DEFAULT '1', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 工单操作日志 CREATE TABLE `ticket_logs` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `ticket_id` bigint(20) NOT NULL, `user_id` int(11) DEFAULT NULL, `action` varchar(50) NOT NULL COMMENT '操作类型', `old_value` text, `new_value` text, `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_ticket_id` (`ticket_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
核心类设计
工单管理类
<?php
class TicketManager {
private $db;
private $cache;
public function __construct($db, $cache) {
$this->db = $db;
$this->cache = $cache;
}
/**
* 创建工单
*/
public function createTicket($userId, $data) {
// 生成工单编号
$ticketNo = $this->generateTicketNo();
// 数据验证
if (empty($data['subject'])) {
throw new Exception('工单主题不能为空');
}
// 创建工单
$ticketData = [
'ticket_no' => $ticketNo,
'user_id' => $userId,
'category_id' => $data['category_id'] ?? 0,
'priority' => $data['priority'] ?? 2,
'subject' => $data['subject'],
'content' => $data['content'] ?? '',
'channel' => $data['channel'] ?? 'web'
];
$ticketId = $this->db->insert('tickets', $ticketData);
// 记录日志
$this->logAction($ticketId, $userId, 'create');
// 发送通知
$this->notifyAssignedAgent($ticketId);
return $ticketId;
}
/**
* 分配工单
*/
public function assignTicket($ticketId, $agentId, $operatorId) {
$ticket = $this->getTicketById($ticketId);
if (!$ticket) {
throw new Exception('工单不存在');
}
$this->db->update('tickets', [
'assignee_id' => $agentId,
'status' => 2 // 处理中
], ['id' => $ticketId]);
// 记录日志
$this->logAction($ticketId, $operatorId, 'assign',
$ticket['assignee_id'], $agentId);
// 通知被分配人
$this->notifyAgent($agentId, '您有新工单需要处理');
return true;
}
/**
* 回复工单
*/
public function replyTicket($ticketId, $userId, $content, $isCustomer = false) {
// 检查工单状态
$ticket = $this->getTicketById($ticketId);
if (!$ticket || $ticket['status'] == 5) {
throw new Exception('工单不存在或已关闭');
}
// 添加回复
$this->db->insert('ticket_replies', [
'ticket_id' => $ticketId,
'user_id' => $userId,
'content' => $content,
'is_customer' => $isCustomer
]);
// 更新工单状态
$newStatus = $isCustomer ? 1 : 3; // 客户回复=>待处理,客服回复=>待确认
$this->updateStatus($ticketId, $newStatus);
// 通知相关方
$this->notifyRelevantParties($ticketId, $content, $isCustomer);
return true;
}
/**
* 关闭工单
*/
public function closeTicket($ticketId, $userId, $resolution) {
$this->db->update('tickets', [
'status' => 5,
'closed_at' => date('Y-m-d H:i:s')
], ['id' => $ticketId]);
$this->logAction($ticketId, $userId, 'close', null, $resolution);
// 发送满意度调查
$this->sendSatisfactionSurvey($ticketId);
return true;
}
/**
* 生成工单编号
*/
private function generateTicketNo() {
$prefix = 'TK';
$date = date('Ymd');
$random = strtoupper(substr(uniqid(), -4));
return $prefix . $date . $random;
}
/**
* 查询工单列表
*/
public function getTicketsByUser($userId, $filters = []) {
$where = ['user_id' => $userId];
if (!empty($filters['status'])) {
$where['status'] = $filters['status'];
}
return $this->db->select(
'SELECT * FROM tickets WHERE user_id = ? ORDER BY created_at DESC LIMIT 20',
[$userId]
);
}
}
控制器设计
<?php
class TicketController extends BaseController {
private $ticketManager;
private $auth;
public function __construct() {
$this->ticketManager = new TicketManager($this->db, $this->cache);
$this->auth = new AuthService();
}
/**
* 创建工单页面
*/
public function create() {
$this->requireLogin();
$categories = $this->getCategories();
$this->render('ticket/create', ['categories' => $categories]);
}
/**
* 提交工单
*/
public function store() {
$this->requireLogin();
$userId = $this->auth->getUserId();
try {
$data = $this->request->all();
$ticketId = $this->ticketManager->createTicket($userId, $data);
// 上传附件
if ($this->request->hasFile('attachments')) {
$this->uploadAttachments($ticketId);
}
$this->redirect('/tickets/' . $ticketId);
} catch (Exception $e) {
$this->setFlash('error', $e->getMessage());
$this->redirect('/tickets/create');
}
}
/**
* 工单详情
*/
public function show($ticketId) {
$this->requireLogin();
$ticket = $this->ticketManager->getTicketById($ticketId);
// 权限检查
if (!$this->canViewTicket($ticket)) {
$this->abort(403);
}
// 获取回复列表
$replies = $this->ticketManager->getTicketReplies($ticketId);
$this->render('ticket/show', [
'ticket' => $ticket,
'replies' => $replies
]);
}
/**
* 回复工单
*/
public function reply($ticketId) {
$this->requireLogin();
$userId = $this->auth->getUserId();
$content = $this->request->input('content');
$isCustomer = $this->auth->isCustomer();
try {
$this->ticketManager->replyTicket(
$ticketId,
$userId,
$content,
$isCustomer
);
$this->redirect('/tickets/' . $ticketId);
} catch (Exception $e) {
$this->setFlash('error', $e->getMessage());
$this->redirect('/tickets/' . $ticketId);
}
}
/**
* 工单列表(后台)
*/
public function adminIndex() {
$this->requireAdmin();
$filters = $this->request->all();
$tickets = $this->ticketManager->getTicketsByFilters($filters);
$this->render('ticket/admin/index', [
'tickets' => $tickets,
'filters' => $filters
]);
}
/**
* 分配工单(后台)
*/
public function assign($ticketId) {
$this->requireAgent();
$agentId = $this->request->input('agent_id');
$operatorId = $this->auth->getUserId();
try {
$this->ticketManager->assignTicket($ticketId, $agentId, $operatorId);
$this->json(['success' => true]);
} catch (Exception $e) {
$this->json(['success' => false, 'error' => $e->getMessage()]);
}
}
/**
* 关闭工单
*/
public function close($ticketId) {
$this->requireLogin();
$userId = $this->auth->getUserId();
$resolution = $this->request->input('resolution');
try {
$this->ticketManager->closeTicket($ticketId, $userId, $resolution);
$this->json(['success' => true]);
} catch (Exception $e) {
$this->json(['success' => false, 'error' => $e->getMessage()]);
}
}
/**
* 导出工单
*/
public function export() {
$this->requireAdmin();
$filters = $this->request->all();
$data = $this->ticketManager->exportTickets($filters);
// 输出CSV
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="tickets.csv"');
$output = fopen('php://output', 'w');
foreach ($data as $row) {
fputcsv($output, $row);
}
fclose($output);
}
}
视图模板示例
工单创建页面
<!-- ticket/create.php -->
<div class="container mt-4">
<h2>提交工单</h2>
<?php if ($this->hasFlash('error')): ?>
<div class="alert alert-danger">
<?= $this->getFlash('error') ?>
</div>
<?php endif; ?>
<form action="/tickets" method="POST" enctype="multipart/form-data">
<div class="form-group">
<label>工单分类</label>
<select name="category_id" class="form-control" required>
<option value="">请选择分类</option>
<?php foreach ($categories as $category): ?>
<option value="<?= $category['id'] ?>">
<?= $category['name'] ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>优先级</label>
<select name="priority" class="form-control">
<option value="2">高</option>
<option value="3" selected>普通</option>
<option value="4">低</option>
</select>
</div>
<div class="form-group">
<label>标题</label>
<input type="text" name="subject" class="form-control"
required maxlength="200" placeholder="请输入标题">
</div>
<div class="form-group">
<label>描述</label>
<textarea name="content" class="form-control" rows="6"
required placeholder="请详细描述您的问题"></textarea>
</div>
<div class="form-group">
<label>附件</label>
<input type="file" name="attachments[]" class="form-control-file" multiple>
</div>
<button type="submit" class="btn btn-primary">提交工单</button>
</form>
</div>
工单详情页
<!-- ticket/show.php -->
<div class="container mt-4">
<h2>工单详情</h2>
<div class="card mb-4">
<div class="card-header">
<div class="d-flex justify-content-between">
<span>工单号: <?= $ticket['ticket_no'] ?></span>
<span class="badge badge-<?= $this->getStatusBadge($ticket['status']) ?>">
<?= $this->getStatusText($ticket['status']) ?>
</span>
</div>
</div>
<div class="card-body">
<h5><?= $ticket['subject'] ?></h5>
<p><?= $ticket['content'] ?></p>
<div class="mt-3">
<small>创建时间: <?= $ticket['created_at'] ?></small><br>
<small>优先级: <?= $this->getPriorityText($ticket['priority']) ?></small>
</div>
</div>
</div>
<!-- 回复列表 -->
<div class="ticket-replies mb-4">
<h4>回复记录</h4>
<?php foreach ($replies as $reply): ?>
<div class="card mb-2">
<div class="card-body">
<div class="d-flex justify-content-between">
<strong>
<?= $reply['is_customer'] ? '客户' : '客服' ?>
</strong>
<small><?= $reply['created_at'] ?></small>
</div>
<p class="mt-2"><?= $reply['content'] ?></p>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- 回复表单 -->
<?php if ($ticket['status'] != 5): ?>
<form action="/tickets/<?= $ticket['id'] ?>/reply" method="POST">
<div class="form-group">
<textarea name="content" class="form-control" rows="4"
required placeholder="输入回复内容"></textarea>
</div>
<button type="submit" class="btn btn-primary">回复</button>
</form>
<?php endif; ?>
</div>
业务规则
状态流转
创建工单 → 待处理 → 分配/认领 → 处理中 → 回复客户 → 待确认
↓ ↓
客户满意 客户不满意
↓ ↓
已解决 重新处理
↓
关闭
权限控制
用户角色:
- 普通用户 - 提交工单,回复自己的工单
- 客服人员 - 查看所有工单,回复,修改状态
- 管理员 - 分配任务,查看统计,导出数据
权限规则:
- 用户只能查看自己的工单
- 客服人员可查看被分配及所有工单
- 只有管理员可分配任务
- 关闭的工单只能查看
自动化工单流程
<?php
class TicketAutomation {
private $ticketManager;
private $mailer;
/**
* 检测超时工单
*/
public function checkOverdueTickets() {
// 获取超过24小时未处理的工单
$overdueTickets = $this->db->query(
"SELECT * FROM tickets
WHERE status = 1 AND created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)"
);
foreach ($overdueTickets as $ticket) {
// 升级优先级
$this->ticketManager->updatePriority($ticket['id'], 1);
// 通知管理员
$this->notifyAdmin($ticket);
}
}
/**
* 自动分配工单
*/
public function autoAssign($ticketId) {
// 获取当前负载最少的客服
$agent = $this->db->queryOne(
"SELECT a.id, COUNT(t.id) as ticket_count
FROM agents a
LEFT JOIN tickets t ON t.assignee_id = a.id AND t.status != 5
WHERE a.status = 1
GROUP BY a.id
ORDER BY ticket_count ASC
LIMIT 1"
);
if ($agent) {
$this->ticketManager->assignTicket($ticketId, $agent['id']);
}
}
/**
* 定期发送工单更新提醒
*/
public function sendUpdates() {
// 获取所有进行中的工单
$activeTickets = $this->db->query(
"SELECT * FROM tickets WHERE status IN (1, 2, 3)"
);
foreach ($activeTickets as $ticket) {
$this->mailer->sendEmail(
getUserEmail($ticket['user_id']),
'工单更新提醒',
"您的工单 {$ticket['ticket_no']} 有新的进展"
);
}
}
}
性能优化
缓存策略
<?php
class TicketCache {
private $redis;
/**
* 获取工单缓存,减少数据库查询
*/
public function getTicketWithCache($ticketId) {
$cacheKey = "ticket:{$ticketId}";
if ($data = $this->redis->get($cacheKey)) {
return unserialize($data);
}
$ticket = $this->db->queryOne(
"SELECT * FROM tickets WHERE id = ?",
[$ticketId]
);
// 缓存10分钟
$this->redis->setex($cacheKey, 600, serialize($ticket));
return $ticket;
}
/**
* 清除缓存
*/
public function clearTicketCache($ticketId) {
$this->redis->delete("ticket:{$ticketId}");
$this->redis->delete("ticket:replies:{$ticketId}");
$this->redis->delete("ticket:logs:{$ticketId}");
}
/**
* 缓存工单统计数据
*/
public function getTicketStats() {
$cacheKey = 'ticket:stats';
if ($data = $this->redis->get($cacheKey)) {
return unserialize($data);
}
$stats = $this->db->query(
"SELECT
COUNT(*) as total,
SUM(status = 1) as pending,
SUM(status = 2) as processing,
SUM(status = 3) as waiting,
AVG(TIMESTAMPDIFF(HOUR, created_at,
COALESCE(closed_at, NOW()))) as avg_resolution
FROM tickets"
);
$this->redis->setex($cacheKey, 300, serialize($stats));
return $stats;
}
}
异步处理
<?php
// 使用队列处理耗时操作
class TicketTaskHandler {
private $queue;
public function handleCreate($ticketId) {
// 发送邮件通知
$this->queue->push('send_email', [
'ticket_id' => $ticketId,
'type' => 'create'
]);
// 生成PDF工单记录(如果不需要)
// $this->queue->push('generate_pdf', $ticketId);
}
public function handleReply($ticketId, $replyId) {
// 发送实时通知
$this->queue->push('push_notification', [
'ticket_id' => $ticketId,
'reply_id' => $replyId
]);
// 更新搜索索引
$this->queue->push('update_search_index', $ticketId);
}
}
安全考虑
CSRF保护
<form action="/ticket/reply/<?= $ticketId ?>" method="POST">
<input type="hidden" name="_token" value="<?= csrf_token() ?>">
<!-- 表单内容 -->
</form>
XSS防护
// 输出时使用 <?= htmlspecialchars($content, ENT_QUOTES, 'UTF-8') ?>
文件上传安全
public function validateUpload($file) {
$allowedTypes = ['jpg', 'png', 'pdf', 'doc', 'docx'];
$maxFileSize = 5 * 1024 * 1024; // 5MB
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($extension, $allowedTypes)) {
throw new Exception('不允许的文件类型');
}
if ($file['size'] > $maxFileSize) {
throw new Exception('文件大小超出限制');
}
return true;
}
敏感信息过滤
public function maskSensitiveInfo($content) {
// 过滤手机号
$content = preg_replace('/1[3-9]\d{9}/', '138****0000', $content);
// 过滤身份证号
$content = preg_replace('/\d{17}[\dXx]/', '***************', $content);
// 过滤邮箱
$content = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/',
'***@***.com', $content);
return $content;
}
监控与统计
工单统计报表
<form action="/tickets/report" method="GET">
<div class="row">
<div class="col-md-3">
<input type="date" name="start_date" class="form-control">
</div>
<div class="col-md-3">
<input type="date" name="end_date" class="form-control">
</div>
<div class="col-md-3">
<select name="status" class="form-control">
<option value="">全部状态</option>
<option value="1">待处理</option>
<option value="2">处理中</option>
<option value="3">待确认</option>
<option value="4">已解决</option>
<option value="5">已关闭</option>
</select>
</div>
<button type="submit" class="btn btn-primary">生成报表</button>
</div>
</form>
实时监控面板
public function dashboard() {
$stats = $this->ticketManager->getDashboardData();
$dashboard = [
'today_tickets' => $stats['today_tickets'],
'pending' => $stats['pending'],
'processing' => $stats['processing'],
'avg_response_time' => $stats['avg_response_time'],
'avg_resolution_time' => $stats['avg_resolution_time'],
'satisfaction_rate' => $stats['satisfaction_rate'],
'trend_chart' => $this->generateChartData(),
'hot_categories' => $stats['hot_categories']
];
return $this->json($dashboard);
}
部署建议
邮件配置
config/mail.php
[
'driver' => 'smtp',
'host' => 'smtp.example.com',
'port' => 587,
'encryption' => 'tls',
'username' => 'support@example.com',
'password' => 'your_pass',
'from_address' => 'support@example.com',
'from_name' => '客服中心'
]
队列配置
config/queue.php
[
'default' => 'redis',
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'ticket_events'
]
],
'workers' => 4
]
定时任务
# 每分钟检测超时工单 * * * * * php /path/to/server/scripts/check_overdue_tickets.php # 每小时自动分配积压工单 0 * * * * php /path/to/server/scripts/auto_assign.php # 每天凌晨发送统计报表 0 0 * * * php /path/to/server/scripts/send_statistics.php
性能优化建议
-
数据库优化
- 创建必要的索引
- 分区大表
- 读写分离
-
缓存策略
- 工单列表缓存10分钟
- 统计数据缓存5分钟
- 会话数据使用Redis
-
消息队列
- 发送邮件走队列
- 生成报表走队列
- 实时通知走WebSocket
-
CDN加速
- 静态资源使用CDN
- 图片自动压缩
这个PHP客服系统工单流程完整实现了从创建、分配到解决的整个流程,你可以根据实际需求调整优先级、状态流转和通知方式,有任何具体功能需要详细说明的,请告诉我。