ThinkPHP虽不提供工单系统,但可通过合理模型设计、状态机控制和权限隔离快速搭建最小可用闭环:主表t_ticket仅存生命周期字段,关联表分存内容与日志;状态流转须经TicketStatusService校验;RBAC按操作粒度配置权限并做数据级校验;附件上传对接云存储并确保删库同步删文件。

ThinkPHP 本身不提供工单系统,但能快速搭出可用的最小闭环——关键在模型设计、状态机控制和权限隔离,而不是堆功能。
工单核心模型怎么建才不翻车
工单不是简单的一张 t_ticket 表。硬塞字段会很快失控,必须拆成主表 + 关联表:
-
t_ticket:只存生命周期字段(status、creator_id、assignee_id、updated_at),status用整型(1=待处理,2=处理中,3=已解决,4=已关闭),别用字符串枚举 -
t_ticket_content:存标题、描述、附件路径(JSON数组)、图片 URL 列表——避免主表 bloated -
t_ticket_log:每次状态变更、评论、指派都写一条日志,含operator_id、action("assign"/"resolve"/"comment")、before_status/after_status
TP6 的 hasMany 和 belongsTo 要配好关联,但别在控制器里用 with 一次性加载全部日志——查列表页时只取最新 3 条,详情页再查全量。
状态流转必须用服务层兜底,不能靠前端传值
用户提交 status=3 并不意味着能直接“解决”,必须校验当前状态是否允许跳转。ThinkPHP 没内置状态机,得自己写:
立即学习“PHP免费学习笔记(深入)”;
- 定义一个
TicketStatusService类,方法canTransition($from, $to)返回布尔值,比如从1可到2或4,但从3只能到4 - 所有更新
status的地方(包括后台管理、API、定时任务)都必须调用该服务,禁止直接$ticket->status = 3 - 在
TicketModel的beforeWrite钩子中做二次校验,防止漏掉某处绕过服务层
否则会出现“用户把已关闭工单又改成待处理”这种逻辑漏洞,审计时直接被毙。
RBAC 权限要按操作粒度切,不是简单分角色
ThinkPHP 的 think-auth 扩展够用,但默认角色权限太粗——客服能看全部工单,但只能改自己创建的;技术组能改任意工单状态,但不能删日志。得这样配:
- 权限项用
ticket:assign、ticket:resolve、ticket:close这种动词+资源格式,不用ticket_edit - 给客服组分配
ticket:view:own和ticket:comment:own,技术组加ticket:view:all和ticket:assign - 控制器里用
$this->auth->check('ticket:resolve', $uid)校验,且传入工单 ID 做数据级校验(比如ticket:resolve要检查当前用户是否是处理人或管理员)
漏掉数据级校验,就等于把工单 ID 放在 URL 里任人篡改,/ticket/123/resolve 可能被改成 /ticket/456/resolve。
附件上传别用 TP 自带 upload,直接对接七牛或腾讯云 COS
本地存储在企业环境里是定时炸弹:磁盘爆满、备份困难、CDN 不友好。ThinkPHP 的 Filesystem 驱动支持 S3 兼容对象存储:
- 配置
filesystems.disks.qiniu,用qiniu/qiniu-php-sdk扩展包 - 上传时生成唯一 key:
"ticket/{$ticketId}/".md5_file($file)."_{$file->getOriginalName()}",避免同名覆盖 - 数据库只存相对路径(如
ticket/123/abc123_filename.pdf),前端拼 CDN 域名,别存完整 URL
附件删除必须同步删云存储文件,TP 的 deleteFile 方法要配合钩子,在 TicketModel::afterDelete 里触发,否则产生脏数据。



















