ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

企业出差团建管理系统:ThinkPHP+Laravel双框架实战与踩坑总结

企业出差团建管理系统:ThinkPHP+Laravel双框架实战与踩坑总结 1. 先说清楚这套出差团建系统到底管哪些事前阵子公司行政和财务联合找我说现在员工出差、旅行团建、部门聚餐这些事全靠 Excel 和微信群在管每月对账能对到怀疑人生。行政发一个团建报名表有人填两次有人填了没交钱财务那边报销单和实际审批单经常对不上领导想看一眼这个季度各部门差旅花了多少得等行政手动拉三天表。老板一句话搞个系统。标题里的企业员工出差旅团建服务信息管理系统说白了就是把这堆线下流程搬到线上。这里核心的关键词是服务信息管理不是做个简单的报名页面而是要把申请、审批、行程确认、费用归集、报销对账、统计分析串成一条完整的链路。1.1 我接手时的业务痛点与系统的真实边界先泼一盆冷水这种企业内部系统最容易做砸的地方不是技术是边界不清。业务方恨不得什么都往里塞——考勤也想放进来、绩效也想关联、连企业滴滴的叫车记录都想对接。我接手第一件事就是和行政、财务、管理层各聊了一轮把需求收敛到四个核心模块出差管理员工提交出差申请选时间、填事由、预估费用直属领导和财务依次审批出差结束后关联报销单。旅行团建管理行政发布团建活动员工在线报名后台统计人数、费用分摊、保险信息和出行安排。审批中心把出差申请、团建方案审批、费用报销审批统一收口所有环节留痕。统计报表按部门、按月份、按费用类型汇总出差和团建支出给财务和老板看。边界一旦定了后面所有表结构、状态机、权限设计才有讨论的基础。这也是我踩过的第一个坑——如果一开始就把系统设计成万能 OA光需求沟通就能拖两个月。1.2 模块划分与数据流转从申请到报销的一条完整链路模块划分清楚了接下来就是数据怎么流转。我用一个实际场景串一遍员工提交出差申请审批通过后出行回来填报销单行政归档财务月底统计。这张核心链路图决定了数据库表的设计和状态字段的规划。随手整理一下主要模块和核心数据表的关系模块核心数据表关键状态字段业务归属出差管理出差申请表、出差行程表、出差费用明细表草稿、待审批、已通过、已驳回、已归档人事/行政团建管理团建活动表、报名表、活动费用表报名中、已截止、进行中、已结束行政审批中心审批实例表、审批记录表审批中、同意、驳回、撤回全部门报销管理报销单表、报销明细表、发票附件表待审核、财务审核中、已打款、已驳回财务统计报表汇总视图或定时任务生成的报表缓存表按月/按季度汇总管理层数据流的主线是员工提交申请 - 系统生成审批实例 - 审批人逐级处理 - 审批通过后业务单进入生效状态 - 费用数据写入报销环节 - 财务完成打款 - 单据归档 - 统计模块读取归档数据生成报表。这套链路里最容易出问题的是“审批状态”和“费用归集”这两个环节后面我会专门讲状态机的设计。现在先把技术选型的账算清楚。2. 为什么是ThinkPHP Laravel双框架而不是二选一很多人看到标题第一反应是你为什么要在一个系统里同时用 ThinkPHP 和 Laravel是不是又是个炫技的微服务真不是。选型这件事本质上是存量现实和增量需求之间的妥协。2.1 存量系统的现实约束ThinkPHP 3.2 代码迁移成本这系统不是从零开始的。公司之前有一套用 ThinkPHP 3.2 写的老版出差审批模块跑了两三年虽然 UI 老旧、代码结构混乱但里面沉淀了不少业务规则——比如差旅补贴按城市等级自动计算、不同部门审批链路的差异逻辑。这些规则散落在各种 Model 和 Service 类里经手过的开发已经走了两拨文档几乎没有。如果全盘用 Laravel 重写光是把这些隐性业务规则梳理清楚就要投入至少两个月的梳理加测试。而它的真实用户量——一个三百多人的公司一天最多几百个请求——决定了老代码再烂也还能跑。所以我的判断是老模块继续留在 ThinkPHP 里但要做两件事——升级到 PHP 8 兼容以及把对外接口统一改造成 JSON API。这也是为什么搜索热词里会出现thinkphp 3.2 版本兼容 php8——这不是个例是大量老项目共同的痛点后面我在踩坑部分详细讲。2.2 Laravel 负责新业务模块的核心理由新模块——团建管理、审批中心、统计报表——我毫不犹豫选了 Laravel。理由很实在审批中心这种强流程应用天然需要中间件来做权限拦截和状态流转控制Laravel 的中间件机制比 ThinkPHP 3.2 的行为扩展要清晰得多。统计报表需要队列异步生成 ExcelLaravel 的队列系统一套搞定老框架写这个得自己拼轮子。Eloquent 的关联模型对出差单 - 费用明细 - 附件这种嵌套关系处理起来非常顺手尤其是后面要讲的关联删除和软删除。Laravel 的 API Resource 层能让我们把后端 JSON 结构统一前端不管接 ThinkPHP 还是 Laravel 的接口拿到的数据格式都是一样的。说白了Laravel 不是万能的但在这个场景下用它开发新业务的效率确实比在老框架里继续叠代码高得多。2.3 双框架共存的集成策略统一认证与数据边界两个框架共存最怕的就是各自为政。我见过最离谱的做法是ThinkPHP 管一套用户登录态Laravel 再管一套用户在系统里要登两次。我的方案是三层拆分共享数据库但严格分库分表归属。老模块的表继续由 ThinkPHP 维护新模块的表归 Laravel 维护两边不直接操作对方的表。业务需要跨模块数据时走 API 调用。统一认证。所有请求先经过 Laravel 侧的统一 API 网关网关校验 JWT Token校验通过后在请求头里注入用户 ID 和角色信息再转发到对应的后端服务ThinkPHP 或 Laravel 自己的路由。数据边界。ThinkPHP 老模块只暴露只读或指定写接口给新模块不开放数据库直连。比如统计报表模块需要读出差数据就走 ThinkPHP 提供的一个/api/v1/travel/list接口而不是去查老模块的表。这套方案的核心思路不是让两个框架完美融合而是让它们各自待在舒适区里通过统一的 API 层和认证层做隔离。后面中间件那章我会把统一鉴权的具体实现细节展开讲。3. 核心业务链路的实现审批状态机、关联删除与 REST API业务系统最见功力的地方不是 CRUD 写得多溜而是核心业务链路设计得稳不稳。这一章我挑三个最容易翻车的点来讲审批状态机、关联删除、API 结构设计。3.1 出差审批状态机从草稿到归档的流转设计出差申请不是一条线走到底的中间会被人撤回、被驳回、被作废。如果不在数据库层面把状态管住就会出现申请被驳回了但费用明细表里还挂着一笔待报销的钱这种脏数据。我的状态机设计如下当前状态可触发动作目标状态操作人草稿提交审批待审批申请人草稿编辑草稿申请人待审批审批通过审批中或有下一级审批人直属领导待审批驳回已驳回直属领导待审批撤回草稿申请人审批中审批通过已通过上级/财务审批中驳回已驳回上级/财务已通过关联报销报销中申请人/财务报销中打款完成已归档财务已驳回重新提交待审批申请人已通过取消出差已取消申请人/管理员这里有两个容易被忽略的细节状态流转必须记日志。我在审批记录表里单独存了操作人、操作动作、原状态、新状态、操作时间、备注。否则两个月后有人问这张单子为什么被驳回了你根本说不清。状态变更要用事务。从待审批改为已通过的同时要生成一条审批记录、可能还要触发费用明细表的初始化工单这三步必须在一个事务里。Laravel 里用DB::transaction()包一层ThinkPHP 老模块用事务闭包谁都不许裸写update。3.2 关联删除的正确打开方式从热词ThinkPHP 关联删除说起搜索热词里有个thinkphp 关联删除这背后其实是一个血泪教训。我们在测试阶段发现一个 bug管理员在后台误删了一张团建活动主表记录结果这个活动下的所有报名记录、费用明细、甚至关联生成的审批实例全都跟着没了。为什么因为老代码里用了物理删除而且外键没有做约束全靠 PHP 代码手动关联删。某个同事写删除逻辑时漏了删费用明细表数据就成孤儿了。正确的做法分两层第一层能用软删除就不要物理删除。Laravel 的 Eloquent 自带SoftDeletestraitThinkPHP 3.2 里可以自己在模型里加一个delete_time字段的判断。业务单据尤其是审批、报销这类有审计需求的数据物理删除就不该存在。第二层如果确实需要物理删除比如清空测试数据一定要用数据库外键约束或模型事件兜底。Laravel 里最稳妥的是在模型事件里挂deleting// app/Models/TravelOrder.php protected static function booted(): void { static::deleting(function (TravelOrder $order) { // 级联删除关联费用明细保证不产生孤儿数据 $order-expenseItems()-delete(); $order-approvalRecords()-delete(); }); }ThinkPHP 3.2 老模型里也有对应的before_delete钩子但那个版本的行为扩展触发机制比较绕我直接在删除方法里手动调用了关联表删除逻辑然后包在事务里面跑。提示设计软删除时务必统一约定所有需要保留审计轨迹的表都加delete_time字段查询用模型全局作用域自动过滤避免出现有的表删了能找到有的表删了彻底没影的情况。3.3 REST API 设计给前后端分离留一条干净的出路新模块既然定了前后端分离API 结构就不能拍脑袋乱写。参考热词里的laravel restapi架构我把新模块的接口按 REST 风格收敛了一遍资源路径用复数名词/api/v1/travel-orders、/api/v1/team-activities、/api/v1/approvals用 HTTP 方法表达动作GET 读取、POST 创建、PUT/PATCH 更新、DELETE 删除。比如审批通过不是 POST/api/v1/approvals/123/pass而是 PATCH/api/v1/approvals/123body 里带actionapproved和审批意见。统一响应结构。前后端约定好所有接口返回格式一致{ code: 0, message: ok, data: { id: 1001, applicant_name: 张三, trip_type: business_trip, status: approved } }错误时code不为 0message给用户可读的提示data为 null 或错误详情。这样前端 axios 拦截器只需要处理一套格式后端 Laravel 用 API Resource 统一包一层ThinkPHP 老接口在控制器里也套同一个响应助手函数两边输出格式就对齐了。这套设计跑下来前后端联调时几乎没有因为字段名不一致或返回结构不统一吵过架省下的沟通成本比写代码的成本高多了。4. 中间件与统一鉴权两套框架下的权限控制落地热词里出现了两拨高频搜索laravel中间件实现原理和laravel 中间件统一。刚好这一章就是整个系统的主动脉——所有请求进来第一件事就是身份认证和权限判断。4.1 Laravel 中间件的执行链路从请求进来到响应出去很多人用 Laravel 中间件写多了觉得就是handle($request, $next)里写几行判断其实没搞懂它执行的完整链路。Laravel 的中间件本质是洋葱模型。一个请求进到应用会依次穿过全局中间件、路由中间件组、指定中间件然后在最内层执行真正的控制器逻辑响应再一层层往外穿出去。每一层中间件都可以在$next($request)之前做前置处理比如校验 Token也可以在$next($request)之后拿到$response做后置处理比如给响应头注入跨域标识。我设计了一个CheckPermission中间件做统一权限校验?php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; class CheckPermission { public function handle(Request $request, Closure $next, string $permission) { $user Auth::user(); if (!$user) { return response()-json([code 401, message 请先登录], 401); } if (!$user-can($permission)) { return response()-json([code 403, message 没有操作权限], 403); } return $next($request); } }路由里挂权限点的时候可以这样用Route::middleware(auth:api)-group(function () { Route::patch(/travel-orders/{id}, [TravelOrderController::class, approve]) -middleware(permission:travel.approve); Route::get(/statistics/reports, [ReportController::class, index]) -middleware(permission:report.view); });4.2 ThinkPHP 侧的鉴权拦截行为扩展与公共基类ThinkPHP 3.2 没有 Laravel 那种标准的中间件但可以在入口处用类似机制拦一道。我在老模块里做了两层公共控制器基类。所有需要登录的控制器继承一个BaseController在构造函数里统一校验 Token。虽然 TP3.2 的控制器构造函数和_initialize()方法在执行顺序上有些绕但_initialize()每次请求都会先跑用它做统一校验最省事。行为扩展做接口签名校验。老模块的接口全走 /api 开头的路由我在入口处挂了一个全局行为校验请求头里的 Token 和签名避免直接裸调内部方法。核心代码思路是从请求头取Authorization解析出 JWT验证签名和过期时间然后把用户信息挂到当前上下文。如果 Token 无效直接返回 JSON 401连控制器都不进。这套方案虽然不如 Laravel 中间件优雅但胜在改动小。老模块几百个方法不需要挨个加权限判断只在基类里统一处理侵入性降到最低。4.3 JWT 无状态认证的具体落地两边如何共用同一套 Token双框架最容易踩的坑是Laravel 签发的 TokenThinkPHP 验不了或者两边各自维护 session用户状态不同步。我采用的方案是 JWT 无状态认证两边共用同一套 secret 和校验逻辑。Token 生成统一放在 Laravel 侧登录接口payload 里包含用户 ID、姓名、角色、部门 ID、过期时间。ThinkPHP 老模块不自己去签 Token只负责校验——因为校验只需要同一个 secret 和同一个签名算法两边代码逻辑一致即可。Laravel 侧用tymon/jwt-auth这个包签发ThinkPHP 侧我写了一个轻量 JWT 校验类?php // Application/Common/Util/JwtUtil.class.php class JwtUtil { private static $secret your_secret_key_here; public static function verify($token) { [$header, $payload, $signature] explode(., $token); $check self::signature($header, $payload); if (!hash_equals($check, $signature)) { return null; // 签名不匹配 } $payload json_decode(base64_decode($payload), true); if ($payload[exp] time()) { return null; // 过期 } return $payload; } private static function signature($header, $payload) { return hash_hmac(sha256, $header.$payload, self::$secret); } }两边共用同一套 secret 是底线一旦泄露等于所有接口裸奔。我这边是把 secret 放到两边各自的配置环境变量里不走代码仓库线上和测试环境分开。无状态认证的好处是横向扩展容易——将来 ThinkPHP 老模块和 Laravel 新模块分别部署在两个实例上只要验签逻辑一致前端拿着同一个 Token 请求两边都没问题。这也是这套双框架架构能持续跑下去的基础。5. 上线前后实测踩坑清单PHP 8 兼容、PDF 跨域与框架安全这一章是全文最有价值的部分。再漂亮的设计上线前也得被真实环境毒打一轮。我把实测中踩到的最典型的三个坑完整复盘出来。5.1 ThinkPHP 3.2 老模块在 PHP 8 下的兼容改造热词里那个thinkphp 3.2 版本兼容 php8不是段子是真的难啃。老环境跑在 PHP 5.6 上被安全扫描扫出来一堆中高危漏洞运维要求必须升到 PHP 8.0。ThinkPHP 3.2 是 2013 年前后的框架按原生代码是没法平滑跑在 PHP 8 上的但也不是完全无解。我整理一份改造清单照着一条条来就不会乱问题点原因解决办法each()函数被移除PHP 8.0 移除了each()全局搜索each(改成foreachkey()/current()组合mysql_*系列函数没了PHP 7.0 起移除mysql_*扩展检查数据库连接配置换成mysqli或PDO驱动魔术引号相关代码老代码里可能有addslashes处理链统一移除输入过滤改成think\Input的过滤器mCrypt扩展移除PHP 7.2 起移除检查有没有用到加解密有的话换openssl_encrypt错误级别变化老代码大量 notice/warning 级报错开发期开启全部报错逐个修线上把 log 级别调到 errorcurl扩展相关参数变化CURLOPT_TIMEOUT 等常量在 PHP 8 有调整逐个接口回归测试重点关注上传和外部 API 调用改造完别急着上线先把所有老接口用自动化脚本跑一遍回归。我当时是写了一个脚本把出差申请、审批、报销三条主链路的每个接口按固定参数请求一遍比对返回码和数据花了三天时间把所有报错清干净。5.2 Laravel Storage 下载 PDF 报 CORS 错误的完整排查链路热词里laravel storage pdf cors 错误这个问题我们真真实实遇到了。现象是前端页面里点击下载出差报告 PDF浏览器控制台报 CORS 错误请求直接失败但用 Postman 直连同一个地址返回却是正常的。当时的排查链路是这样的第一步先确定请求是不是跨域。前端在app.example.comPDF 文件存储在storage.example.comLaravel Storage 的公开磁盘绑定的独立域名跨域是肯定的。问题在于为什么图片能加载PDF 就报错。第二步看响应头。打开 Network 面板点击那个失败的 PDF 请求查看响应头发现Access-Control-Allow-Origin压根没出现。但同域下 Storage 请求的图片却带了 CORS 头。说明 CORS 头不是 Laravel 应用加的是 nginx 或 CDN 配置只在图片扩展名上加了。第三步看响应 Content-Type。PDF 请求返回的Content-Type是application/octet-stream浏览器对这类 MIME 的跨域请求校验更严格。解决办法是在 Laravel 的公开磁盘绑定域名所在的反向代理配置文件里加上针对.pdf的 CORS 响应头location ~* \.(pdf)$ { add_header Access-Control-Allow-Origin https://app.example.com always; add_header Access-Control-Allow-Methods GET, POST, OPTIONS; add_header Access-Control-Allow-Headers Content-Type, Authorization; if ($request_method OPTIONS) { return 204; } }或者更彻底一点不在前端直接拼 Storage 域名下载而是走 Laravel 的接口返回文件流。我最终选的是后者因为接口可以顺带加权限校验——不是所有员工都有权限下载全部出差报告。public function download(Report $report) { $this-authorize(view, $report); return response()-download(storage_path(app/reports/{$report-file_path})); }这个坑的核心教训是CORS 问题不一定出在应用代码里要先检查静态资源服务的反代配置。Postman 不受 CORS 限制所以Postman 通、浏览器挂了基本可以锁定是响应头的问题。5.3 框架安全自查漏洞修复与基础加固热词里有thinkphp漏洞这确实是老框架绕不开的话题。ThinkPHP 3.2 在 2018 年前后爆出过多个 RCE 漏洞大多数是路由解析和参数过滤不严导致的。我在上线前做了一轮安全加固核心动作如下框架版本升级到 3.2.4 以上的修复版本老代码里的公开漏洞基本被补了一圈。关闭 debug 模式整个线上环境APP_DEBUGfalse避免任何异常堆栈直接暴露给用户。限制路由关闭老框架默认的路由调度模式改成严格显式路由避免通过路由参数控制模块和控制器。目录权限收敛runtime 目录只给写权限禁止上传目录执行 PHP 脚本nginx 里对/Uploads/目录禁用 php 解析。统一参数过滤所有新写的接口都强制走白名单校验不再依赖全局转义。Laravel 侧相对省心但如果APP_KEY泄露或者没有开启 CSRF 校验照样会出事。上线前我检查了几个容易漏的配置检查项默认状态修复建议APP_KEY必须重新生成php artisan key:generate禁止使用官方文档示例 keyCSRF 中间件默认开启确认所有 POST/PUT/DELETE 请求都带_token或X-CSRF-TOKEN头用户密码哈希bcrypt确认 Auth 注册逻辑用的是Hash::make()队列任务权限默认无鉴权超管功能必须校验管理员角色敏感配置.env在仓库外确认.env未提交到 Git安全这块没有一劳永逸只能把基础面铺开。好在最后等保测评和安全扫描都过了不然这套系统连内网都上不了。6. 如果让我重新选一次我会把重心放在哪项目上线跑了三个月整体稳定行政和财务那边反馈也不错。但如果让我重来一次有些决策我会调整。第一不会轻易引入双框架。双框架共享数据库和 Token 的方案虽然跑通了但运维成本摆在那——两套日志、两套部署、两套框架的安全更新。如果当初时间允许我更倾向直接把老模块的数据模型摸清一步到位迁到 Laravel。只能说在两个月内上线这个硬约束下双框架是当时的最优解。第二中间件和统一鉴权应该放在项目第一天就设计而不是快联调了才补。我前期把大量时间花在了业务表结构上结果是 Laravel 侧和 ThinkPHP 侧的鉴权逻辑各写各的后来花了三天才统一成 JWT 方案。第三权限模型设计要往前多想一步。当时只设计了员工、部门领导、财务、管理员四种角色结果上线第二周行政就要求按分公司隔离数据。幸好当初在部门表里预留了层级关系不然权限模块要推倒重来。最后再说一个小技巧这种内部管理系统的状态字段和关联关系最复杂建议在数据库设计阶段就往每张业务表加created_by、updated_by、delete_time三个字段不管将来做审计、做软删除还是做统计报表都会省很多事。这是我在多个项目里反复验证过的经验。
返回列表