十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP工单系统实战:多用户、多客服与短信邮件通知实现

PHP工单系统实战:多用户、多客服与短信邮件通知实现 简介一套基于ThinkPHP框架开发的在线工单管理系统源码主要面向企业信息化人员、PHP开发学习者以及需要快速搭建售后服务平台的团队。系统支持多用户、多客服协同处理内置短信与邮件通知作业流程覆盖客户与客服管理、问题分类、工单查看、待处理与已处理工单等核心环节。资源包内文件总数1266个核心由376个PHP文件构成业务逻辑225个JS文件与75个CSS文件负责前端交互和界面样式47个HTML文件提供页面模板另含较多图片、字体及少量数据库与日志文件整体压缩包大小18.62MB目录划分清晰。当前已有492人在线学习或下载。通过这套源码可掌握工单状态流转、多角色权限分配以及邮件/短信通道配置等常见功能实现思路也可直接部署到支持PHP5.6-7.1与MySQL的空间中使用适合作为生产项目或课设、毕设的参考基础。1. 用 PHP 重做工单系统为什么还要关心“多用户 多客服 短信邮件”你在一家一百多人的公司做内部开发领导说“上个工单系统”一提需求就是用户能提交、客服能接单、处理完要有通知。你打开服务器一看跑的是一台 2 核 4G 的 ECS装了宝塔面板和 PHP 8.1已经跑了两个其他项目。这时候摆在你面前的不是“要不要学 Laravel”而是怎么用现有的 PHP 环境把工单流转做出来并且保证多用户登录、多客服分配、短信邮件通知这几件事不串线、不丢消息。“2024 新版 PHP 程序开发在线工单管理系统源码多用户 多客服 短信 邮件通知”这个标题拆开看其实就四个模块一套多角色的用户体系、一套能按状态流转的工单数据模型、一套客服分配和超时回退规则、一套异步的短信邮件通知队列。它们不是临时拼出来的功能而是任何一个能用于生产环境的工单系统都必须处理好的基础结构。这篇文章就按这个顺序往下讲每一步都会给你能直接抄改的代码和表结构。适合看这篇的人是已经在写 PHP、又不想在这类内部系统上引入整个微服务架构的工程师。新手能跟着把表建出来、把流程跑通熟手可以重点看后面关于通知队列和分配并发控制的部分那里常见的坑比较多。2. 多用户体系与数据隔离工单系统的权限设计2.1 从用户表到角色表工单系统的账号模型长什么样工单系统里“多用户”不等于“能登录”它至少包含三层含义用户表存账号、角色表存身份、角色与权限点关联后决定能看哪些页面和按钮。一个常见的做法是 RBAC基于角色的访问控制用四张表实现用户表、角色表、用户角色关联表、权限节点表。CREATE TABLE users ( id int(11) unsigned NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录名, password_hash varchar(255) NOT NULL COMMENT password_hash 生成的密码散列, real_name varchar(50) NOT NULL COMMENT 姓名用于工单显示, mobile varchar(20) DEFAULT COMMENT 手机号短信通知用, email varchar(100) DEFAULT COMMENT 邮箱邮件通知用, department_id int(11) unsigned NOT NULL DEFAULT 0 COMMENT 所属部门, is_active tinyint(1) NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, created_at datetime NOT NULL, updated_at datetime NOT NULL, PRIMARY KEY (id), KEY idx_department (department_id), KEY idx_mobile (mobile) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE roles ( id int(11) unsigned NOT NULL AUTO_INCREMENT, role_name varchar(30) NOT NULL COMMENT 角色名称, role_key varchar(30) NOT NULL COMMENT 角色标识如 staff/admin/customer, PRIMARY KEY (id), UNIQUE KEY uk_role_key (role_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT角色表; CREATE TABLE user_roles ( user_id int(11) unsigned NOT NULL, role_id int(11) unsigned NOT NULL, PRIMARY KEY (user_id, role_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户角色关联表; CREATE TABLE permissions ( id int(11) unsigned NOT NULL AUTO_INCREMENT, perm_key varchar(50) NOT NULL COMMENT 权限标识如 ticket.assign, perm_name varchar(50) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_perm_key (perm_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT权限节点表;角色表的role_key建议固定三到四个customer是普通用户只能提交工单和追加回复staff是客服能接单和处理admin是管理员能分配客服、关闭工单、查看所有数据。权限节点可以按模块前缀来写比如ticket.view、ticket.create、ticket.assign这样后边做菜单和接口鉴权时只查一次缓存。在用户与角色之间加一层关联表而不是直接给用户表加role_id字段是因为一个用户可能在多个部门兼职比如既做一线客服又做工单审核。如果只支持单角色后面改需求会很痛苦。2.2 工单表的数据隔离怎么设计工单表是整个系统的核心数据表。多用户场景最容易出的问题是一条工单被无权限的用户看到了。要避免这个需要在表结构层面就把隔离条件设计进去而不是靠业务代码里到处写 if 判断。CREATE TABLE tickets ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, ticket_no varchar(32) NOT NULL COMMENT 工单编号如 TK202412010001, user_id int(11) unsigned NOT NULL COMMENT 提交人客户, department_id int(11) unsigned NOT NULL DEFAULT 0 COMMENT 工单所属部门用于客服筛选, assignee_id int(11) unsigned NOT NULL DEFAULT 0 COMMENT 当前处理人客服, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0待分配 1待响应 2处理中 3已解决 4已关闭, priority tinyint(1) NOT NULL DEFAULT 1 COMMENT 1低 2中 3高, title varchar(200) NOT NULL, content text NOT NULL, channel tinyint(1) NOT NULL DEFAULT 1 COMMENT 1系统提交 2邮件 3短信, created_at datetime NOT NULL, updated_at datetime NOT NULL, closed_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_ticket_no (ticket_no), KEY idx_user_status (user_id, status), KEY idx_assignee_status (assignee_id, status), KEY idx_department_status (department_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT工单表;查询工单列表的 SQL 必须把角色条件写死。普通用户只能查user_id 当前登录用户 id客服能查assignee_id 当前用户 id OR assignee_id 0也就是“我接的和我可以抢的”管理员可以直接查全部。这三个条件不要答应做成一个动态拼接的 WHERE否则哪天逻辑出问题就会出现数据越权。// 工单列表查询PDO 预处理方式 $roleKey $_SESSION[role_key] ?? customer; $where 11; $params []; if ($roleKey customer) { $where . AND t.user_id :uid; $params[:uid] $currentUserId; } elseif ($roleKey staff) { // 客服能看到未分配工单和自己名下工单 $where . AND (t.assignee_id :uid OR t.assignee_id 0); $params[:uid] $currentUserId; } elseif ($roleKey admin) { // 管理员不限制但可以按部门过滤 if (isset($deptId) $deptId 0) { $where . AND t.department_id :dept; $params[:dept] $deptId; } } $sql SELECT t.id, t.ticket_no, t.title, t.status, t.created_at FROM tickets t WHERE {$where} ORDER BY t.id DESC LIMIT 20;这段代码的关键在于把 WHERE 条件的拼接放在服务端客户端传来的任何参数都只参与$params绑定不直接拼接进 SQL。比如$_GET[status]要过滤也要先校验取值范围再绑定成一个:status参数。2.3 登录态的密码校验与会话安全PHP 登录别再用 md5 加盐那套了。password_hash()默认使用 bcrypt生成的散列包含盐和算法信息长度 60 位即使数据库泄露也不能轻易反推出原密码。public function authenticate(string $username, string $password): bool { $stmt $this-db-prepare(SELECT * FROM users WHERE username :uname AND is_active 1); $stmt-execute([:uname $username]); $user $stmt-fetch(); if (!$user || !password_verify($password, $user[password_hash])) { return false; } // 登录成功重建 session 防会话固定攻击 session_regenerate_id(true); $_SESSION[user_id] $user[id]; $_SESSION[real_name] $user[real_name]; $_SESSION[role_key] $this-getUserRoleKey($user[id]); return true; }这里有一个容易被忽略的安全配置Session 的 Cookie 要设置 HttpOnly 和 SameSite。在 php.ini 里把session.cookie_httponly 1打开可以防止 XSS 脚本读取登录凭证。如果你部署的是 HTTPS 环境session.cookie_secure 1也要打开否则登录 Cookie 可能被抓包泄露。配置项推荐值作用session.cookie_httponly1禁止 JavaScript 读取 Cookiesession.cookie_secure1HTTPS 时Cookie 只走加密连接session.use_strict_mode1拒绝未初始化的 Session IDsession.gc_maxlifetime1440登录态过期时间按业务调整3. 多客服工单流转分配策略、状态机与超时回退3.1 工单状态流转图的三种牙槽设计工单状态的时候最怕的就是把事情想复杂。刚上线时的状态定义越花哨每个人理解的偏差就越大。常规做法是先定义一个最小状态集合跑通之后再增加扩展状态。第一版我建议用四个状态待分配、待响应、处理中、已关闭。待分配表示还没有客服接手待响应表示客服已接手但还没有回复处理中表示已与用户做过交互已关闭表示用户确认解决或管理员强制关闭。如果业务需要统计“回复时长”这类指标可以加一个“已解决待确认”状态放在处理中和已关闭之间。提交工单 → 待分配 → 客服领取 → 待响应 → 客服第一次回复 → 处理中 → 用户确认 / 管理员关闭 → 已关闭状态变更不能只靠页面按钮来驱动需要在代码层做一个统一的状态变更入口。这样每次状态变化都可以记录日志、触发通知。下面是一个简单的状态校验方法// 工单状态变更统一入口 public function changeStatus(int $ticketId, int $newStatus, int $operatorId): bool { // 状态机校验哪些旧状态允许转到新状态 $allowedTransitions [ 0 [1, 4], // 待分配 → 待响应 / 已关闭 1 [2, 4], // 待响应 → 处理中 / 已关闭 2 [3, 4], // 处理中 → 已解决 / 已关闭 3 [2, 4], // 已解决 → 处理中用户不满意/ 已关闭 4 [], // 终态不允许再改 ]; $stmt $this-db-prepare(SELECT status FROM tickets WHERE id :tid FOR UPDATE); $stmt-execute([:tid $ticketId]); $currentStatus (int)$stmt-fetchColumn(); if (!in_array($newStatus, $allowedTransitions[$currentStatus] ?? [], true)) { return false; } $update $this-db-prepare( UPDATE tickets SET status :new_status, updated_at :now WHERE id :tid ); // 省略 bind 与 execute 的具体赋值 return $update-execute([:new_status $newStatus, :now date(Y-m-d H:i:s), :tid $ticketId]); }SELECT ... FOR UPDATE是关键。在 InnoDB 引擎下这条语句会对命中行加锁防止两个客服同时点击接单都读到“未分配”状态然后各自执行更新导致同一工单被分配给两个人。加锁的代价是并发较低的内部系统完全能承受。3.2 客服分配轮询与最少负载分配策略直接决定用户等多久才有人响应。两种常见的自动分配方式轮询分配和最少负载分配。轮询分配的意思是按客服列表顺序挨个分配不管当前客服手上有多少未完成工单最少负载分配则是查每个客服当前未关闭工单数量把新工单派给数量最少的人。-- 最少负载分配找出当前未关闭工单数最少的客服 SELECT u.id, u.real_name, COUNT(t.id) AS open_count FROM users u LEFT JOIN tickets t ON t.assignee_id u.id AND t.status NOT IN (3, 4) WHERE u.is_active 1 AND EXISTS ( SELECT 1 FROM user_roles ur WHERE ur.user_id u.id AND ur.role_id 2 ) GROUP BY u.id, u.real_name ORDER BY open_count ASC LIMIT 1;这个 SQL 在客服只有几个人、工单量不大的内部系统里性能没问题。客服数量到几十人以上时COUNT聚合加子查询会产生临时表可以考虑把“当前未关闭工单数”冗余到users表的一个字段里分配时直接查这个字段成功后加一工单关闭后减一。冗余字段为的是速度代价是每次状态变更都要多更新一个字段。轮询分配实现起来更简单适合客服水平差不多的场景。把在线客服 id 存到 Redis 的 List 里每次分配时RPOPLPUSH从列表头部取一个客服放到尾部实现轮流接单。// 轮询分配Redis 实现 $redis new Redis(); $redis-connect(127.0.0.1, 6379); $key ticket:staff_round_robin; $staffId $redis-rpoplpush($key, $key); // 原列表弹出再插入尾部 if ($staffId false) { // 列表为空先初始化在线客服列表 $staffs $this-getOnlineStaffIds(); if (empty($staffs)) { return null; // 当前无客服在线保持待分配状态 } $redis-rpush($key, ...$staffs); $staffId $redis-rpoplpush($key, $key); }用rpoplpush而不是rpop再lpush是因为这个操作是原子的两个客服同时触发分配时不会出现把同一个客服派给两单的情况。3.3 客服超时未响应的回退机制工单分配给客服后对方可能请假、开会、忘处理。如果系统不干预工单就会卡在“待响应”里。回退机制的常见做法是超过预设时长未回复工单自动变成“待分配”状态并给客服发送一条预警通知。# crontab 中每五分钟执行一次检测超时未响应工单 */5 * * * * /usr/bin/php /data/www/ticket/scripts/timeout_release.php /data/www/ticket/logs/timeout.log 21// timeout_release.php $deadline date(Y-m-d H:i:s, strtotime(-30 minutes)); $sql UPDATE tickets SET status 0, assignee_id 0 WHERE status 1 AND updated_at :deadline AND assignee_id ! 0; $stmt $pdo-prepare($sql); $stmt-execute([:deadline $deadline]); $releasedCount $stmt-rowCount(); if ($releasedCount 0) { // 触发短信通知原处理人工单已经重新进入分配池 // 异步发送代码见下一章 }超时时长按工单优先级来区分会更合理高优先级 15 分钟、中优先级 30 分钟、低优先级 60 分钟。实现也不复杂把 WHERE 条件改成按优先级分三次执行对应时间就能实现页面里只需要在工单详情处加一个“重新分配”按钮。4. 短信与邮件通知异步队列、模板和多渠道兜底4.1 通知事件与渠道映射工单系统里通知不能无脑全发。用户提交工单后给客服发客服接单后给用户发用户追加回复后给客服发提醒施工进度变化时给用户发。每种事件要通知谁、通过什么渠道应该用一张表维护起来而不是散落在业务代码里。CREATE TABLE notification_settings ( id int(11) unsigned NOT NULL AUTO_INCREMENT, event varchar(30) NOT NULL COMMENT 事件如 ticket.created / ticket.assigned, target_role varchar(20) NOT NULL COMMENT 通知对象角色, channel varchar(10) NOT NULL COMMENT sms / email, template_key varchar(50) NOT NULL COMMENT 模板标识, is_enabled tinyint(1) NOT NULL DEFAULT 1, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;事件标识要和业务代码里的常量对应例如工单创建ticket.created、工单分配ticket.assigned、客服回复ticket.replied、工单超时ticket.timeout。模板分开维护的好处是运营人员可以直接改模板文字不用碰代码。事件通知对象渠道模板说明ticket.created客服sms email新工单编号和标题ticket.assigned用户sms email已分配客服姓名和联系方式ticket.replied客服email用户追加了回复ticket.closed用户email工单已关闭可重新打开4.2 用 Redis 队列把通知发送变成异步任务同步发送通知的后果很多人踩过用户提交工单时代码里先写数据库再 curl 调短信网关短信服务超时 3 秒用户提交按钮就转圈 3 秒。如果再做邮件附件可能等更久。正确做法是把通知发送放进队列业务接口立即返回“提交成功”。// 工单创建后把短信通知任务推入 Redis 队列 public function pushNotification(string $event, array $ticket, array $recipient): bool { $payload json_encode([ event $event, channel sms, mobile $recipient[mobile], template ticket_created, params [ ticket_no $ticket[ticket_no], title $ticket[title], ], retry_count 0, created_at time(), ]); $redis new Redis(); $redis-connect(127.0.0.1, 6379); return $redis-rpush(queue:notification, $payload) 0; }消费者脚本单独跑用BLPOP阻塞式读取队列避免轮询空转浪费 CPU// worker_notification.php $redis new Redis(); $redis-connect(127.0.0.1, 6379); $redis-setOption(Redis::OPT_READ_TIMEOUT, -1); // 阻塞等待不超时 while (true) { $msg $redis-blpop([queue:notification], 5); if (!$msg) { continue; } $data json_decode($msg[1], true); $sent $this-sendSms($data[mobile], $data[template], $data[params]); if (!$sent $data[retry_count] 3) { $data[retry_count]; $redis-rpush(queue:notification, json_encode($data)); // 放回队列 $redis-rpush(log:notification_failed, json_encode($data)); } }队列方案最需要注意的问题是消息丢失。BLPOP消费到消息后如果进程此时被杀掉这条消息就没了。生产环境建议加一个“待确认”机制先把消息从 Redis 取走并写入处理日志表发送成功后更新日志状态如果发现日志表里一直处于“处理中”状态就由监控脚本捞出来重新入队。内部系统可以先不做这么重但要把日志表留好。4.3 邮件 SMTP 配置的最佳实践PHP 发邮件不要用mail()函数它依赖服务器上的 sendmail 组件配置灵活性和退信处理都很差。用 PHPMailer 或 Symfony Mailer 这类库可以直接指定 SMTP 服务器、端口、加密方式和认证信息。以下是 PHPMailer 配合 QQ 邮箱 SMTP 发信的一个常规配置写法use PHPMailer\PHPMailer\PHPMailer; $mail new PHPMailer(true); try { $mail-isSMTP(); $mail-Host smtp.example.com; $mail-SMTPAuth true; $mail-Username noreplyexample.com; $mail-Password your_smtp_password; $mail-SMTPSecure PHPMailer::ENCRYPTION_SMTPS; // SSL $mail-Port 465; $mail-CharSet UTF-8; $mail-Encoding base64; $mail-setFrom(noreplyexample.com, 工单中心); $mail-addAddress($userEmail, $userName); $mail-isHTML(true); $mail-Subject 服务工单更新: . $ticketNo; $mail-Body $mailContentHtml; $mail-AltBody strip_tags($mailContentHtml); $mail-send(); } catch (Exception $e) { // 记录错误$mail-ErrorInfo }容易被忽略的是发件域名要配置 SPF 和 DKIM 记录。没有这两个 DNS 记录发出的邮件很可能进收件人垃圾箱。SPF 记录在 DNS 里解析结果大概长这样vspf1 include:spf.example.com ~allDKIM 则需要邮件服务商提供私钥在 DNS 里加一条 TXT 记录。排错时可以先用dig txt example.com命令查看 SPF 是否存在再发一封测试邮件看邮件头里的Authentication-Results字段。4.4 短信通道的失败重试与手动补发短信网关大部分是 HTTP API 形式返回码 0 表示发送成功非 0 表示失败。失败原因常见的有手机号格式错误、通道欠费、内容触发敏感词屏蔽。前三类失败重试没有意义但网络超时导致的状态未知应该重试。因此队列里的retry_count不是无脑重试要区分失败码。返回码场景是否重试原因HTTP 连接超时重试不确定是否送达网关返回“欠费”不重试重试无意义手机号格式错误不重试用户数据本身有问题网关返回“发送成功”不重试已成功重试不建议写成代码里的sleep(5)这会让 worker 进程卡住后续通知都不发。正确做法是把消息重新推回队列利用下一次消费。考虑到业务场景通常只需要延迟一两分钟可以在消息体里加一个available_at字段消费时比较当前时间和该时间未到就再推回去。这个方案虽然笨但能避免引入额外的延迟队列组件。5. 把系统跑起来的检查清单和排错入口工单系统的代码写得再顺配置环境时也容易出问题。这里列一组上线前的检查动作每一项都能在十分钟内验证是否通过。5.1 多用户权限验证用客服账号登录后试着直接访问工单详情页。这个操作能让你确认权限判断是否写在每一个入口方法上而不只写在列表页。推荐用 curl 带 Session Cookie 模拟# 先用手机号密码登录拿到 cookie 保存到本地文件 curl -c /tmp/cookie_agent.txt -X POST \ -d usernameagent01passwordtest1234 \ http://your-domain.com/api/login.php # 再访问一个不属于该客服的工单详情 curl -b /tmp/cookie_agent.txt -s \ -w \nHTTP_CODE:%{http_code}\n \ http://your-domain.com/ticket/detail.php?id10023正常的响应应该是页面提示“无权限访问”或直接返回 403。如果返回了工单内容说明详情方法里没有做归属校验需要补上assignee_id 当前用户 id这个条件。5.2 通知队列消费状态启动 worker 后要确认它能持续运行。nohup启动方式在服务器重启后不会自动拉起入口脚本里要判断进程是否存在# 启动通知 worker日志写入独立文件 nohup php /data/www/ticket/scripts/worker_notification.php \ /data/www/ticket/logs/worker.log 21 # 查看 worker 是否存活 ps aux | grep worker_notification # 实时观察消费日志 tail -f /data/www/ticket/logs/worker.log如果发现worker.log里有 Redis connection refused 报错先排查 Redis 是否在运行再用redis-cli ping查看服务端返回。很多环境是 Redis 绑定了 127.0.0.1 而 PHP 用了内网 IP改成配置文件中对应的 host 即可。5.3 工单流转的自动化压力检查写一个简单的 PHP 脚本循环创建 10 个测试工单然后以客服身份接单最后检查每个工单的assignee_id是否为同一个客服。如果 10 个都分给一个人说明轮询的RPOPLPUSH初始化和消费之间存在数据不一致或者 Redis 列表里的 id 重复了。另外一个高频踩坑点是客服把工单“关闭”后无法重新打开。正常情况下用户发起回复时状态为“已关闭”的工单应该自动变成“处理中”同时把closed_at置空。这个逻辑要写在用户回复的入口方法里而不是写在客服操作里否则用户就永远只能重新提交新工单。最后的建议把工单编号生成规则定义清楚。常见格式是TK 年月日 四位流水号比如TK202412010001。生成时要注意并发不要用SELECT MAX(id)加一而应该用数据库自增主键拿到id再格式化补位或者单独建一张ticket_seq表用UPDATE ... RETURNING获取自增值这样无论以后接短信通知还是客服侧检索工单号都不会乱。本文还有配套的精品资源点击获取
返回列表