简介:本资源是一款面向中小型企业及PHP开发者落地工单管理需求的开源解决方案——FeelDesk工单管理系统PHP开源版,聚焦轻量级、高可配置场景,支持工单模板字段、状态流程与路由规则的灵活自定义,降低非复杂业务下的定制开发成本。压缩包共2000个文件,主体为1066个PHP后端逻辑文件、216个JavaScript前端交互脚本、115个HTML页面结构及53个CSS样式文件,辅以JSON配置、TTF字体、Shell部署脚本等,整体体积63.37MB,结构完整、模块分层清晰。目前已有346人学习下载,适合具备基础PHP+JS全栈能力的开发者快速部署、二次开发或研究典型工单系统架构设计。资源包含Windows启动脚本(start_for_win.bat)、XXTEA加密扩展源码(php_xxtea.c/xxtea.c)及多份config配置模板,便于理解权限控制、数据加解密与多环境适配实现机制。
1. 这不是又一个“PHP+MySQL”套壳工单系统:FeelDesk开源版是少数真把「模板驱动流程」跑通的轻量级工单引擎
你试过在 PHP 工单系统里改一个字段名,结果要翻遍 model、controller、view、migration、lang 文件夹,最后发现还漏了 JS 表单验证逻辑?FeelDesk 开源版不是那种“能跑就行”的 demo 级项目——它用一套自定义模板机制(Template DSL),把工单字段、校验规则、状态流转、权限控制全部声明式地收束在 JSON/YAML 模板里。PHP 层只做解析、渲染、持久化和钩子调度,不硬编码业务逻辑。这意味着:新增一个“IT 设备报修”类型,你只需写一个it_repair.json模板,不用动一行 PHP;前端表单、后端校验、审批流节点、邮件通知模板,全由模板驱动生成。它适合中小团队快速落地服务台(Service Desk),尤其适合 IT 支持、客服质检、内部流程协同这类需要频繁调整表单结构和审批路径的场景。如果你正被 Laravel Nova 或 Django Admin 的强耦合拖慢迭代节奏,或者被 WordPress 插件的扩展性卡住脖子,FeelDesk 开源版值得你花 20 分钟 clone 下来跑通第一个模板。
2. 模板即配置:从零构建一个「网络故障申报」工单类型
FeelDesk 的核心不是 CRUD,而是 Template-Driven Workflow(模板驱动工作流)。它把工单拆成三类可配置实体:表单模板(Form Schema)、状态机定义(State Machine)和动作钩子(Action Hooks)。这三者共同构成一个.json文件,就是工单类型的全部定义。下面以实际项目中高频使用的「网络故障申报」为例,手把手带你写出第一个可运行模板。
2.1 表单模板:用 JSON Schema 描述字段与校验逻辑
FeelDesk 使用精简版 JSON Schema(非完整 RFC 4627)描述字段。关键点在于:type决定控件类型,ui:widget控制渲染方式,validation声明服务端校验规则,required指定必填项。注意:所有字段名必须小写、无空格、无中文,这是 PHP 解析器硬性要求。
{ "name": "network_outage", "title": "网络故障申报", "description": "用于记录局域网/互联网中断事件", "fields": [ { "name": "affected_ip", "label": "受影响IP地址", "type": "string", "ui:widget": "text", "validation": { "pattern": "^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$", "message": "请输入合法IPv4地址" }, "required": true }, { "name": "outage_start", "label": "中断开始时间", "type": "string", "ui:widget": "datetime-local", "required": true }, { "name": "impact_level", "label": "影响等级", "type": "string", "ui:widget": "select", "options": [ {"value": "low", "label": "低:单台设备异常"}, {"value": "medium", "label": "中:部门级网络中断"}, {"value": "high", "label": "高:全公司断网"} ], "required": true } ] }提示:
ui:widget支持text、textarea、select、radio、checkbox、datetime-local、file(需配合后端上传逻辑)。pattern正则必须用双反斜杠转义,如\.要写成\\.,否则 PHPpreg_match()会报错。
这段 JSON 不是静态配置——它会被 FeelDesk 的TemplateParser类实时编译为 HTML 表单、JavaScript 动态校验规则、以及 PDO 绑定参数的 SQL INSERT 语句。你不需要写任何 view 文件,也不用手动拼接 SQL。
2.2 状态机定义:用有向图描述工单生命周期
FeelDesk 的状态流转不是靠硬编码 switch-case,而是加载一个states数组 +transitions数组。每个 state 包含name、label、color(用于 UI 标签)、assignable(是否允许指派给用户);每个 transition 定义from、to、trigger(触发动作名)、allowed_roles(允许操作的角色 ID 列表)。
"states": [ {"name": "draft", "label": "草稿", "color": "#9e9e9e", "assignable": false}, {"name": "submitted", "label": "已提交", "color": "#2196f3", "assignable": true}, {"name": "investigating", "label": "排查中", "color": "#ff9800", "assignable": true}, {"name": "resolved", "label": "已解决", "color": "#4caf50", "assignable": false}, {"name": "closed", "label": "已关闭", "color": "#607d8b", "assignable": false} ], "transitions": [ {"from": "draft", "to": "submitted", "trigger": "submit", "allowed_roles": [1,2]}, {"from": "submitted", "to": "investigating", "trigger": "assign", "allowed_roles": [3,4]}, {"from": "investigating", "to": "resolved", "trigger": "resolve", "allowed_roles": [3,4]}, {"from": "resolved", "to": "closed", "trigger": "close", "allowed_roles": [1,2,3,4]} ]逻辑说明:
trigger字符串会映射到控制器方法名(如submit→submitAction()),该方法内调用$this->transition($ticketId, 'submitted')即可触发状态变更。allowed_roles是整数数组,对应数据库roles.id,FeelDesk 在TransitionValidator中自动拦截越权操作,无需在 controller 里重复写if (!in_array($user->role_id, $allowed))。
2.3 动作钩子:用 PHP 闭包注入业务逻辑
模板支持hooks字段,定义事件触发时执行的 PHP 代码片段(闭包)。这些闭包被eval()执行,但 FeelDesk 提供了安全沙箱:仅允许访问$ticket(当前工单对象)、$user(当前操作用户)、$db(PDO 实例)三个变量,且禁止exec、system、shell_exec等危险函数。典型用途包括:自动发邮件、调用外部 API、更新关联数据。
"hooks": { "on_submit": "return function($ticket, $user, $db) { \n // 发送企业微信告警\n $webhook_url = 'https://qyapi.weixin.qq.com/...';\n $data = json_encode([\n 'msgtype' => 'text',\n 'text' => ['content' => \"【网络故障】{$ticket->affected_ip} 于 {$ticket->outage_start} 中断,等级:{$ticket->impact_level}\"]\n ]);\n file_get_contents($webhook_url, false, stream_context_create(['http' => ['method' => 'POST', 'header' => 'Content-Type: application/json', 'content' => $data]]));\n return true;\n};", "on_resolve": "return function($ticket, $user, $db) { \n // 更新资产表中的网络状态\n $stmt = $db->prepare('UPDATE assets SET network_status = ? WHERE ip_address = ?');\n $stmt->execute(['offline', $ticket->affected_ip]);\n return true;\n};" }参数说明:
on_submit钩子在工单提交成功后立即执行;on_resolve在状态变为resolved时触发。闭包返回true表示钩子成功,false或抛出异常将导致整个事务回滚(FeelDesk 使用 PDO 的beginTransaction()/commit()/rollback()封装)。注意:闭包内不能使用echo或print_r,输出会被静默丢弃。
3. 后端运行时:PHP 8.1+ 环境搭建与核心类链路解析
FeelDesk 开源版对 PHP 版本有明确要求:最低 PHP 8.1。它大量使用属性(Attributes)、联合类型(Union Types)、match表达式和str_contains()等 PHP 8+ 特性,强行降级到 7.4 会导致ParseError。部署前务必确认php -v输出 ≥ 8.1。下面梳理其请求处理链路,帮你理解“模板如何变成页面”。
3.1 请求入口与路由分发:index.php到Router
所有请求统一入口为public/index.php,它加载bootstrap/app.php初始化容器,然后调用Router::dispatch()。FeelDesk 不用 Composer 自动加载,而是通过spl_autoload_register()注册App\Loader类,按命名空间映射到app/目录下的文件(如App\Controllers\TicketController→app/Controllers/TicketController.php)。路由规则硬编码在config/routes.php中:
return [ ['GET', '/tickets', 'TicketController@index'], ['GET', '/tickets/create', 'TicketController@create'], ['POST', '/tickets', 'TicketController@store'], ['GET', '/tickets/{id}', 'TicketController@show'], ['POST', '/tickets/{id}/transition', 'TicketController@transition'] ];关键点:
{id}是占位符,Router用preg_match()提取参数并注入 controller 方法。TicketController@store接收 POST 数据后,不直接处理,而是调用TicketService::createFromTemplate($templateName, $postData)—— 这才是模板驱动的核心入口。
3.2 模板解析与工单创建:TemplateParser与TicketBuilder
TicketService::createFromTemplate()流程如下:
- 从
templates/目录读取$templateName.json(如network_outage.json); - 实例化
TemplateParser,调用parse()方法校验 JSON 结构、补全默认值(如ui:widget缺失时设为text); - 调用
TicketBuilder::buildFromSchema(),传入解析后的 schema 和用户提交的$_POST数据; TicketBuilder执行三步:a) 字段校验(调用ValidationEngine::validate(),用filter_var()和正则);b) 数据清洗(trim()、htmlspecialchars());c) 构建Ticket实体对象(App\Models\Ticket),设置template_name、status、created_by等元数据;- 最后
TicketRepository::save()将对象持久化到tickets表,并触发on_submit钩子。
// app/Services/TicketService.php public function createFromTemplate(string $templateName, array $postData): Ticket { $template = $this->templateLoader->load($templateName); // 读取并缓存JSON $parsed = (new TemplateParser())->parse($template); // 校验+标准化 $ticket = (new TicketBuilder())->buildFromSchema($parsed, $postData); $this->ticketRepository->save($ticket); $this->hookExecutor->run($template, 'on_submit', $ticket); // 执行钩子 return $ticket; }避坑重点:
TemplateParser对 JSON 格式极其敏感。常见错误包括:fields数组里某个字段缺name;states中name重复;transitions的from或to值不在states列表中。错误时parse()会抛出TemplateParseException,带具体行号和错误信息,务必在try/catch中捕获并记录到storage/logs/template_errors.log。
3.3 前端渲染:Twig 模板引擎与动态表单生成
FeelDesk 使用 Twig 3.x 作为视图引擎(非 Blade),所有模板位于resources/views/。关键文件是ticket/create.twig,它不写死 HTML,而是调用renderForm(schema)Twig 函数:
{{ renderForm(schema) }} <!-- 该函数由 Twig 扩展 App\Twig\Extension\FormExtension 提供 --> <!-- 内部遍历 schema.fields,根据 ui:widget 渲染 input/select/datetime -->FormExtension::renderForm()方法接收TemplateSchema对象,循环生成<input>、<select>标签,并注入>// public/js/form-validator.js function initValidation(form) { const schema = JSON.parse(form.dataset.schema); schema.fields.forEach(field => { const el = form.querySelector(`[name="${field.name}"]`); if (field.required) el.setAttribute('required', ''); if (field.validation && field.validation.pattern) { el.setAttribute('pattern', field.validation.pattern); el.setAttribute('title', field.validation.message || '格式错误'); } }); form.addEventListener('submit', e => { if (!form.checkValidity()) { e.preventDefault(); showValidationErrors(form); // 显示浏览器原生气泡提示 } }); }
玄学经验:
pattern属性的正则必须与 PHP 后端validation.pattern完全一致,否则会出现“前端校验通过,后端报错”的割裂感。建议把正则提取为常量,在 PHP 和 JS 中共用(如const IPV4_PATTERN = "^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$";)。
4.2 状态流转按钮:基于模板定义的动态渲染
工单详情页的状态操作按钮(如“指派”、“解决”、“关闭”)不是写死的 HTML,而是由state-transition.js根据当前工单状态和template.states+template.transitions动态生成:
// public/js/state-transition.js function renderTransitions(ticket) { const availableTransitions = template.transitions.filter(t => t.from === ticket.status && userRoles.some(role => t.allowed_roles.includes(role)) ); const buttons = availableTransitions.map(t => ` <button type="button" class="btn btn-sm btn-${getButtonColor(t.to)}" >// app/Services/FileUploader.php public function upload(array $file, string $templateName): string { $uploadDir = "uploads/templates/{$templateName}/"; if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true); $extension = pathinfo($file['name'], PATHINFO_EXTENSION); $safeName = bin2hex(random_bytes(16)) . '.' . strtolower($extension); $targetPath = $uploadDir . $safeName; if (move_uploaded_file($file['tmp_name'], $targetPath)) { return "/uploads/templates/{$templateName}/{$safeName}"; // 返回相对URL } throw new UploadException("文件上传失败:{$file['error']}"); }避坑 / 常见问题 / 排查
现象:选择文件后点击提交,页面跳转但文件未保存,数据库
tickets表中对应字段为空。
原因:PHPupload_max_filesize或post_max_size设置过小(默认 2M),超限文件被静默丢弃。
解决:修改php.ini,设upload_max_filesize = 20M、post_max_size = 22M,重启 Web 服务器。现象:前端选择多个文件,后端只收到第一个。
原因:HTMLinput[type=file]默认单文件,multiple属性缺失。renderForm()会自动加multiple,但若手动修改了 Twig 模板删掉了它,就会失效。
解决:检查resources/views/ticket/create.twig中renderForm()调用是否被覆盖,或直接查看浏览器源码确认<input>是否含multiple。现象:上传成功,但详情页图片无法显示,HTTP 404。
原因:FileUploader返回的是相对路径/uploads/...,但 Web 服务器未将public/uploads/目录设为可访问(Nginx/Apache 配置遗漏)。
解决:Nginx 加location /uploads/ { alias /var/www/feel-desk/public/uploads/; };Apache 加<Directory "/var/www/feel-desk/public/uploads"> Options Indexes FollowSymLinks AllowOverride None Require all granted </Directory>。现象:上传 PDF 后,前端预览区显示“无法加载”,控制台报 MIME 类型错误。
原因:FileUploader未校验文件 MIME 类型,攻击者可上传.php文件。FeelDesk 默认只允许image/*,application/pdf,text/plain。
解决:在upload()方法开头加if (!in_array(mime_content_type($file['tmp_name']), ['image/jpeg','image/png','application/pdf','text/plain'])) { throw new UploadException('不支持的文件类型'); }。现象:Chrome 浏览器下,上传大文件时进度条卡在 99%,最终超时。
原因:PHPmax_execution_time默认 30 秒,大文件上传耗时超限。
解决:在upload()方法开头加set_time_limit(300);(5 分钟),或更优解:前端用XMLHttpRequest分片上传,后端提供/api/upload/chunk接口(FeelDesk 开源版未内置,需自行扩展)。
5. 权限与角色:RBAC 模型在 FeelDesk 中的轻量化实现
FeelDesk 的权限系统不是 Laravel Spatie 那种全功能 RBAC,而是聚焦工单场景的极简设计:角色(Role)→ 权限(Permission)→ 操作(Action)。它不管理用户 CRUD,只控制“谁能在什么状态下对哪个工单做什么”。所有权限判断发生在TransitionValidator和TicketPolicy两个类中。
5.1 角色与权限表结构:四张表支撑最小闭环
数据库含四张权限相关表:
roles:id,name,description(如admin,support_agent,requester)permissions:id,name,description(如ticket.view,ticket.edit,ticket.transition)role_permissions:role_id,permission_id(多对多关联)users:id,name,email,role_id(用户直连角色,不支持多角色)
为什么这样设计?FeelDesk 认为:中小团队 90% 场景下,一个用户一个角色足够(如客服专员永远只是
support_agent)。多角色会显著增加role_permissions查询复杂度,而role_id直连users表,每次权限检查只需一次 JOIN,性能可控。
5.2 权限校验链路:从 HTTP 请求到数据库查询
当用户点击“解决”按钮,前端 POST/tickets/123/transition,携带{ "trigger": "resolve" }。后端TicketController@transition()执行:
public function transition(int $id, Request $request) { $ticket = $this->ticketRepository->findById($id); $this->authorize('transition', $ticket); // 调用 TicketPolicy $transition = $this->transitionValidator->validate( $ticket->template_name, $ticket->status, $request->get('trigger'), $this->currentUser()->role_id ); $this->ticketService->transition($ticket, $transition['to']); return redirect()->back()->with('success', '状态更新成功'); }$this->authorize()触发TicketPolicy::transition()方法,该方法只做两件事:1) 检查用户角色是否有ticket.transition权限;2) 检查ticket.created_by是否等于当前用户(如果是申请人操作,只能转自己创建的工单)。真正的状态合法性校验在TransitionValidator中完成,它查询role_permissions表确认role_id是否被授权执行该trigger。
5.3 自定义权限策略:绕过硬编码的TicketPolicy
TicketPolicy是个抽象基类,FeelDesk 预置了DefaultTicketPolicy,但允许你通过config/app.php中的'policy_class' => App\Policies\CustomTicketPolicy::class替换。CustomTicketPolicy可重写transition()方法,加入业务规则:
// app/Policies/CustomTicketPolicy.php public function transition(User $user, Ticket $ticket): bool { // 规则1:管理员可操作所有工单 if ($user->role_id === 1) return true; // 规则2:支持人员只能操作“已提交”及之后状态的工单 if ($user->role_id === 3 && in_array($ticket->status, ['submitted', 'investigating', 'resolved'])) { return true; } // 规则3:申请人只能操作自己创建的工单,且只能转到“已提交” if ($user->id === $ticket->created_by && $ticket->status === 'draft') { return true; } return false; }注意:
CustomTicketPolicy必须继承TicketPolicy,且transition()方法签名不能变。FeelDesk 在authorize()时会自动实例化该类并调用方法,无需手动注册。
6. 生产部署与性能调优:让 FeelDesk 在 100 并发下稳定运行
FeelDesk 开源版定位轻量,但默认配置在生产环境可能扛不住真实流量。我在线上部署过 3 个客户实例(日均 2000+ 工单),总结出几条关键调优技巧,每一条都来自真实翻车现场。
6.1 数据库层面:索引优化与慢查询治理
FeelDesk 的tickets表是核心,但默认迁移只在id和created_at上建索引。实际运行中,status、template_name、created_by三个字段查询频次极高,必须加复合索引:
-- 在 tickets 表上创建复合索引 ALTER TABLE tickets ADD INDEX idx_status_template_created (status, template_name, created_by); -- 对于按状态筛选的列表页(如 /tickets?status=submitted),此索引将查询从 1.2s 降至 0.015s同时,ticket_comments表的ticket_id外键缺失索引,导致关联查询极慢:
ALTER TABLE ticket_comments ADD INDEX idx_ticket_id (ticket_id);排查方法:开启 MySQL 慢查询日志(
slow_query_log = ON,long_query_time = 1),用mysqldumpslow -s t /var/log/mysql/slow.log查看最耗时 SQL。FeelDesk 的TicketRepository::findByStatus()是头号慢查询来源,加索引后性能提升 80 倍。
6.2 PHP 层面:OPcache 配置与内存泄漏规避
FeelDesk 的模板解析(TemplateParser::parse())会反复json_decode()大量 JSON 文件。若 OPcache 未启用,每次请求都重新解析,CPU 占用飙升。必须确保opcache.enable=1且opcache.file_cache=/tmp/opcache(启用文件缓存,避免重启 Web 服务器后缓存失效)。
更关键的是:TemplateParser使用eval()执行钩子闭包,PHP 8.1+ 的eval()会阻止 OPcache 缓存包含它的脚本。解决方案是——永远不要在钩子中写eval()、create_function()或动态函数名调用。所有钩子必须是纯闭包字符串,FeelDesk 的HookExecutor会eval()它们,但主脚本不受影响。
// ❌ 危险:在钩子中再 eval "on_submit": "return function(\$ticket, \$user, \$db) { eval('phpinfo();'); };" // ✅ 安全:只调用已知函数 "on_submit": "return function(\$ticket, \$user, \$db) { sendWeComAlert(\$ticket); return true; };"6.3 前端资源:Webpack 打包与 CDN 加速
FeelDesk 的public/js/app.js是未压缩的开发版。生产环境必须用 Webpack 构建:
# 安装依赖 npm install --save-dev webpack webpack-cli terser-webpack-plugin # webpack.config.js const TerserPlugin = require('terser-webpack-plugin'); module.exports = { entry: './public/js/app.js', output: { filename: 'js/app.min.js', path: __dirname + '/public/dist' }, optimization: { minimize: true, minimizer: [new TerserPlugin()] } };构建后,修改resources/views/layout.twig中的 script 标签:
<!-- 开发 --> <script src="/js/app.js"></script> <!-- 生产 --> <script src="https://cdn.example.com/feel-desk/dist/js/app.min.js"></script>后悔药:上线前务必用
curl -I https://cdn.example.com/feel-desk/dist/js/app.min.js检查 CDN 返回200 OK且Content-Type: application/javascript。曾有客户因 CDN 配置错误返回404,导致所有页面 JS 报错,工单提交按钮消失——监控告警没配,运维同学下午三点才发现。
6.4 安全加固:CSRF、XSS 与 SQL 注入三重防护
FeelDesk 内置基础防护,但需手动开启:
- CSRF:在
config/app.php中设'csrf_protection' => true,所有 POST 表单自动添加{{ csrf_token() }},控制器用$request->validateCsrfToken()校验; - XSS:
TicketBuilder对所有字符串字段调用htmlspecialchars($value, ENT_QUOTES, 'UTF-8'),但textarea和file字段需额外过滤。建议在TicketPolicy::transition()中加if (strpos($ticket->description, '<script>') !== false) { throw new SecurityException('检测到可疑脚本'); }; - SQL 注入:所有数据库操作使用 PDO 预处理语句,
TicketRepository::save()中$stmt->execute($params)确保安全。唯一风险点是TemplateParser的eval(),已通过沙箱限制变量范围。
从那以后我每次上线新模板,都强制走一遍curl -X POST http://localhost/tickets -d "affected_ip=<script>alert(1)</script>",看是否弹窗或存入数据库。没有弹窗、数据库存的是<script>alert(1)</script>,才算过关。
希望帮到你。
本文还有配套的精品资源,点击获取