1. 项目概述与业务背景
大学校园里报修这件事,说出来都是泪。宿舍灯管坏了,厕所漏水,空调半夜不转,风扇嗡嗡响到怀疑人生,传统的报修方式是找宿管阿姨登记、打电话给后勤处,或者跑一趟行政楼填纸质单子。流程繁琐不说,最致命的是“状态不可知”——单子交上去之后,学生不知道师傅什么时候来、修到什么程度了,后勤处也不知道工单积压了多少、哪些师傅有空、哪些楼栋是报修重灾区。
我做的这套大学校园后勤移动报修系统,就是把后勤报修这条链路整体搬到线上。学生在小程序里拍照上传、定位楼栋房间、提交报修单;后勤调度员在后台审核派单;维修师傅通过App接收工单、查看故障描述、上传维修结果;学生最后还能对维修质量打分评价。整个过程形成闭环,所有节点都有时间戳和数据记录。
技术架构上,采用了PHP生态里两套主流框架——ThinkPHP和Laravel,后端负责业务逻辑和接口输出;前端用uni-app开发微信小程序,同时打包成Android和iOS的App。这样做的好处是:小程序承担学生高频使用入口,App作为维修师傅移动办公工具,后台管理系统则是管理员和调度员的操作中枢。
文章面向的读者有两类:一类是在校学生或者想做类似校园系统的开发者,另一类是负责学校后勤信息化的老师或技术负责人。如果你正在考虑用PHP自研一套后勤报修平台,这篇内容从需求分析到数据库设计、从接口开发到跨端适配、从部署上线到踩坑记录都有涉及。小程序报修端我做了完整的“提交报修”“进度跟踪”“历史记录”功能,App端实现了“工单抢单”“扫码签到”“完工回传”等真实工作场景,后台管理端则覆盖了工单流转、人员管理、数据统计。整套系统不是一个demo,是能直接上线的生产级方案。
2. 系统整体设计与技术选型
2.1 为什么是ThinkPHP + Laravel双框架组合
很多人看到这个标题会有一个疑问:同一个项目里同时出现ThinkPHP和Laravel,是技术选型失误还是有意为之?我在设计的时候其实是有明确考虑的。
先看两个框架各自的定位。ThinkPHP在国内中小型项目中使用非常广泛,它的上手门槛低,文档全中文,MVC目录结构直观,尤其适合做接口服务这类模式固定的开发。Laravel则胜在生态完善,Eloquent ORM的表达能力更强,队列、事件、任务调度这些高级特性开箱即用,更适合承载复杂的业务逻辑。
在这个项目里,我让ThinkPHP承担小程序端的接口服务,因为学生报修请求的特点是高频、轻量、查询多,ThinkPHP的简单直接正好匹配;Laravel承担管理后台和App端的接口服务,因为工单调度、师傅排班、数据统计的逻辑复杂度明显更高,用Laravel的ORM和集合操作写起来更顺手。
两套框架共用一个MySQL数据库,通过共同的数据表结构关联起来。这里需要特别说明一点:双框架共存的前提是数据层统一,不能在两个框架里各自建一套表结构,否则后期维护会变成灾难。我在项目初始化时就把所有数据表的字段定义、索引规则、时间字段格式统一好,两边都遵守同一套约定。
2.2 三端一体的系统架构
整个系统从用户视角分三个端:学生小程序端、师傅App端、后台管理端。小程序端采用微信原生语法开发,App端使用uni-app跨端框架打包,后台管理端使用基于Laravel的Blade模板加前端UI框架搭建。
这里有一个选型经验:为什么不把小程序也用uni-app写?原因很简单——微信小程序的开放能力是uni-app无法完全覆盖的,尤其是一些涉及地理位置、蓝牙打印、订阅消息的场景,原生写法更容易对接微信官方API。而App端因为要兼容Android和iOS两套系统,用uni-app一套代码编译双端能节省大量时间,这是典型的“按端选型”思路。
接口层面我统一走RESTful风格,返回JSON格式数据。小程序端和App端分别有独立的控制器入口,但底层调用的Service层逻辑是共享的。比如生成一个报修单号、计算工单超时时长、处理图片上传,这些公共业务都在Service层封装成静态方法,两套框架都能调用。
3. 核心数据库设计与关键接口
3.1 六张核心数据表
数据库设计是整个项目的基石,我前后迭代过三个版本,最终沉淀出六张核心表:
第一张是用户表,存放学生、师傅、管理员三类账号,用role字段区分身份。学生通过微信授权登录,记录openid;师傅账号由后台手动创建,关联手机号和姓名;管理员则绑定后勤处的不同科室。
第二张是报修单主表,这是整个系统的核心。字段包括报修单号、用户ID、报修类型(水电、门窗、空调、网络、其他)、故障描述、图片URL、楼栋、房间号、紧急程度、状态、派单师傅ID、预计完成时间、实际完成时间、评分、评价内容。
第三张是报修图片表,因为一条报修单可能上传多张现场照片,所以单独拆表,通过报修单号关联。
第四张是工单流转日志表,记录每一步操作的时间、操作人、操作动作。这是后面做进度追踪和时间统计的数据基础。
第五张是维修类型表,用于后台配置报修分类和各类别的预计工时、默认优先级。
第六张是通知消息表,存系统内所有站内信、微信订阅消息的发送记录,方便排查消息丢失问题。
用一句话总结设计原则:报修单主表承担业务状态流转,日志表承担审计和追溯,类型表承担配置化,图片表和消息表承担附属信息存储。
3.2 一个核心查询:Laravel 按分组取最新一条
在开发后台工单列表时遇到一个经典需求:显示每个学生的“最近一次报修记录”,并且按学生去重。如果用MySQL原生语法,可以用子查询先按用户分组取最大ID,再关联主表。但既然后台是Laravel,我优先用查询构造器来实现,代码长这样:
use Illuminate\Support\Facades\DB; $latestOrders = DB::table('repair_orders as r') ->join(DB::raw('(SELECT user_id, MAX(id) as max_id FROM repair_orders GROUP BY user_id) as sub'), function ($join) { $join->on('r.user_id', '=', 'sub.user_id') ->on('r.id', '=', 'sub.max_id'); }) ->where('r.status', '!=', 0) ->orderBy('r.created_at', 'desc') ->paginate(15);这里用到了Laravel中DB::raw和闭包连接的结合写法。核心思路是:先在子查询里按user_id分组拿到最大ID,然后通过双条件JOIN(同时匹配用户ID和最大ID)把完整的报单数据查出来。
很多同学在Laravel里一看到“分组取最新”就想着用groupBy('user_id')配合orderBy('created_at', 'desc')一把梭,实际执行后就会发现MySQL的ONLY_FULL_GROUP_BY模式直接报错,即使不报错取到的也不是真正的那一条最新记录,而是分组后第一行数据,这个顺序主要由索引决定,并不等于我们要的“最新”。
在上面这段SQL的基础上,如果要在ThinkPHP里实现同样的逻辑,写法也很接近:
$subQuery = Db::name('repair_orders') ->field('user_id, MAX(id) as max_id') ->group('user_id') ->buildSql(); $list = Db::name('repair_orders') ->alias('r') ->join($subQuery . ' sub', 'r.user_id = sub.user_id AND r.id = sub.max_id') ->where('r.status', '<>', 0) ->order('r.created_at', 'desc') ->paginate(15);两个框架遵循的是同一套SQL逻辑,只是API风格略有差异。掌握这个思路后,无论遇到“分组的用户取最新订单”还是“分组的设备取最新温度记录”都能直接套用。
3.3 核心接口清单
后端接口我按模块梳理了一遍,共约三十个,这里列出最核心的八个:
| 接口名称 | 请求方式 | 功能说明 | 使用端 |
|---|---|---|---|
| 微信登录 | POST /api/auth/login | 通过code换openid和token | 小程序 |
| 提交报修 | POST /api/order/create | 创建报修单,上传图片 | 小程序 |
| 报修列表 | GET /api/order/list | 按状态查询我的报修单 | 小程序 |
| 取消报修 | POST /api/order/cancel | 未接单前可取消 | 小程序 |
| 待接工单 | GET /api/app/order/pending | 师傅查看最新工单池 | App |
| 工单接单 | POST /api/app/order/accept | 师傅抢单 | App |
| 上传完工 | POST /api/app/order/complete | 提交维修结果和耗时 | App |
| 工单评价 | POST /api/order/review | 学生对本次服务打分 | 小程序 |
每个接口都要做好权限校验,小程序端用token,App端用JWT,后台管理端用session。这里有一个我在实际开发中踩过坑的地方:微信小程序登录时拿到的openid在正式环境是稳定的,但开发工具的本地环境会和正式环境产生不同的openid,所以联调时一定用真机预览,不要用开发者工具模拟登录。
4. 小程序端与App端的实现细节
4.1 微信小程序端的四个关键功能
小程序端的核心是报修流程,我把它拆成四个关键功能模块。
表单提交是整个入口。学生选择楼栋和房间号时,我用了一个picker组件联动楼栋和房间,数据从后台接口动态获取,而不是写死在页面里。因为不同校区的楼栋命名规则不一样,有的用“梅苑A栋”,有的用“9号楼”,动态获取以后换校区部署也方便。报修类型也做成动态加载,管理员在后台可以随时增删类别,前端不需要发布新版本。
图片上传用wx.uploadFile实现。这里需要特别注意:用户拍照的图片在iPhone上可能较大,有实拍图会达到3MB甚至更大,如果原图直接上传,后端PHP的post_max_size默认值很容易超限。我的做法是在前端用wx.compressImage压缩到80%质量,同时后端把upload_max_filesize调整到10MB。压缩后单张图大概控制在200KB以内,上传速度快,也避免服务器空间被图片撑爆。
进度查询模块调用接口后展示工单状态流,核心是一个“时间轴样式”,简单来说就是按时间倒序把流转日志的status和created_at渲染成纵向列表,当前状态高亮。数据来自工单日志表,前端按create_time降序循环输出。
消息通知模块是容易被忽略但实际很重要的部分。学生提交报修后,我们希望他能收到一条“你的报修单已受理”的微信订阅消息。微信官方现在用subscribeMessage.send接口做一次性订阅消息推动。实现时的正确姿势是:在用户提交报修成功后调起wx.requestSubscribeMessage授权弹窗,用户同意后再在后台发送订阅消息。很多新手直接在用户进入小程序时就弹授权,这种体验反而会被用户拒绝,授权率很低。
4.2 App端的工单管理核心逻辑
App端是给维修师傅用的,核心场景是:出工路上看工单、到了现场扫码签到、修完拍照回传。三个功能看起来简单,但每个都有值得聊的细节。
工单接单采用“抢单池”模式。师傅打开App进入待接单页面,看到当前空闲的工单列表,显示紧急程度、楼栋、故障类型和描述。点击接单后工单状态从“待接单”变为“处理中”,其他师傅就看不到这条了。这里涉及并发问题:两个师傅同时抢同一单怎么办?我的做法是在数据库操作时加上条件更新——UPDATE repair_orders SET status=2, worker_id=xxx WHERE id=? AND status=1,受影响行数为1表示抢到单,为0说明被抢走。这种乐观锁思路比先查后更简单可靠,不需要加事务。
扫码签到功能,我在工单详情页生成一个二维码,二维码内容是一串加密参数base64_encode(工单号 + 时间戳),师傅到达现场后用App里的扫码功能扫描二维码,后端解密后校验参数时效性和合法性,校验通过则记录签到时间和地理位置。这样能约束师傅必须到现场,防止远程打卡。扫码用的插件是uni-app内置的uni.scanCode,兼容性没有问题。
完工回传界面除了上传维修前和维修后的照片,还需要填写维修方式、更换配件名称和数量、实际工时。数据提交后工单状态变成“待评价”,学生端会收到一条可评价的推送提醒。照片上传用的是uni-app的uni.uploadFile,后端接口接收文件后保存到OSS或者本地目录,我项目里用的是本地目录加上按日期分目录存储,便于后期迁移到云存储。
4.3 微信小程序登录踩坑记录
在做微信登录时,遇到一个高频问题:“小程序获取登录后的微信用户失败”。这个报错信息在开发者工具里经常出现,实际原因通常不是代码逻辑错误,而是下面几个细节之一:
- 开发者工具里没有勾选“不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书”选项;
- 后端返回的数据结构没有包含
openid和session_key,前端解析失败; - 小程序的AppID和Secret配置不是同一个账号下的,前后配置串了;
- 微信服务器偶尔返回
errcode: 40029(code无效),大概率是因为同一个code被使用了两次。
排查的方法是加日志:后端把微信接口返回的原始数据记录下来,前端把wx.login拿到的code以及请求后端的参数记录下来,两端日志对照,很快就能定位到问题。我自己调整这个接口时发现,最常犯的错误是后端把js_code传成了code,微信接口返回报错,提示语又不直观,排查耗费了不少时间。
4.4 小程序顶部导航栏高度适配
小程序页面在不同机型上顶部导航栏高度不一样,刘海屏和普通屏差异较大。如果用固定导航栏高度,在iPhone X以上机型会出现状态栏遮挡内容的问题。
我踩过这个坑后,统一用官方API动态计算导航栏高度:
const systemInfo = uni.getSystemInfoSync(); const statusBarHeight = systemInfo.statusBarHeight; const customBarHeight = systemInfo.platform === 'android' ? 48 : 44; this.navHeight = statusBarHeight + customBarHeight;在这个基础上给页面容器设置padding-top: navHeight + 'px',就能保证不同机型上页面头部都不被状态栏遮挡。这套逻辑小程序和App通用,uni-app里封装一次,两端直接复用。
5. 扫码报修与消息通知的亮点功能
5.1 扫码报修:直接定位房间
传统报修流程中,学生填写宿舍楼栋和房间号经常出错,尤其是不同校区存在重名楼栋时。我的方案是给每个房间生成专属二维码,贴在门背后或卫生间墙上。学生发现设施损坏时,只需打开小程序扫一扫,系统就能自动识别楼栋和房间号,不需要手动选择。
扫码后页面自动填充楼栋和房间信息,学生只需要选报修类型、填故障描述、拍照上传,表单提交效率提升明显。这里的二维码生成采用的是ThinkPHP后端调用一个简单的字符串拼接规则,将房间ID加密后返回给前端,前端用wx.generateCode插件显示二维码。整个扫码流程本质上就是一个“参数传递+自动填充”的设计,落地简单但真实可用。
5.2 微信订阅消息的全链路方案
校园后勤报修场景里,消息触达是提升学生满意度的重要渠道。系统中共有三个关键节点需要推送:
- 报修单受理后,告诉学生“我们已收到你的报修单,师傅预计X小时内到达”;
- 维修完成后,提醒学生“你的报修已完成,请对本次服务进行评价”;
- 工单超时未处理,提醒学生“抱歉让你久等了,你的工单已升级处理优先级”。
实现订阅消息时需要注意一次性订阅的特性:用户每授权一次,后台只能发送一条消息。所以我在前端调起授权成功后,本地记录一次授权状态,后台发送后立即清除。对于超时提醒这种可能跨多天的场景,我在生成工单时调用了一次授权,但需要用户在报修成功返回时再授权一次,这样才有足够配额覆盖完整流程。
5.3 微信手机号获取的替代方案
很多校园系统都想通过手机号绑定学生身份,但微信官方对小程序的手机号快速验证组件有资质要求,普通个人开发者用不了。我的解决方案是采用“学号+密码”登录作为备选方案。学生首次打开小程序,系统展示两个选项:微信一键登录(新用户跳转绑定学号页面),或学号密码登录。如果微信授权因为用户取消等原因失败,学生完全可以通过学号密码完成登录和报修,不阻塞核心流程。
6. ThinkPHP与Laravel开发中的关键问题
6.1 ThinkPHP扩展安装与PHP环境依赖
在ThinkPHP 6的开发环境中,安装扩展时遇到ext-json缺失的问题。很多入门者用集成环境(比如phpStudy)时,PHP版本切换后没有启用对应的扩展,安装依赖时就会报错。
处理方法分两步:第一步在php.ini中启用extension=json,大部分PHP 7.2以上版本默认内置JSON扩展,如果没有就是被注释掉了;第二步用Composer安装依赖时加上--ignore-platform-req=ext-json跳过平台检测,但这种情况只适合临时开发环境,生产环境必须确保扩展真实可用。
更省事的做法是直接在项目根目录创建一个composer.json片段,声明项目对PHP扩展的依赖:
{ "require": { "php": ">=7.2.5", "ext-json": "*", "ext-pdo": "*" } }这样在一台新服务器上部署时,Composer会在安装依赖前检查扩展是否存在,提前暴露环境问题,而不是等到运行时报500错误。
6.2 Laravel分页与查询的联动
Laravel的分页器在处理复杂查询时有一个容易忽略的细节。如果你在查询中使用了->groupBy(),然后链式调用->paginate(),Laravel会自动生成一条SELECT COUNT(*)的统计SQL。但如果分组字段中含有表达式或子查询,这个统计SQL会报错。
解决办法是给分页手动指定统计逻辑:
$query = DB::table('repair_orders')->groupBy('user_id'); $paginator = $query->paginate(15, ['*'], 'page', 1);更稳妥的方案是在分页前用->get()拿到集合,再在集合层面做分页。数据量不大时性能完全够用,逻辑上也更清晰。
6.3 双框架并发操作同一张表的数据一致性
因为ThinkPHP和Laravel同时连接同一张报修单表,它们在抢单操作上存在数据竞争的可能。我在设计时统一使用“条件更新”策略,不管是哪个框架发出的更新语句,都遵守UPDATE ... WHERE id=? AND status=旧值的约定,受影响行数为0则视为操作失败。
如果未来需要更复杂的并发控制(比如同一用户不能同时提交两条未完成的报修单),可以引入Redis分布式锁或者在数据库层加唯一索引。但就当前业务规模而言,条件更新已经够用,而且没有额外引入中间件,部署成本更低。
7. 常见问题排查与部署避坑实录
7.1 八大高频问题速查表
我在开发、联调和线上运行中遇到的最有价值的八个问题,整理成速查表,方便后来者直接对照:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 小程序请求后端接口报“域名不合法” | 未配置request合法域名或未开启HTTPS | 在微信公众平台配置服务器域名,开发环境勾选“不校验合法域名” |
| 上传图片失败,返回413 | 请求体太大,超出Nginx和PHP限制 | 调整client_max_body_size和upload_max_filesize |
| 微信登录成功但获取不到用户信息 | 前端没有正确传递code或后端解密失败 | 后端打印微信原始响应,前端确认code只使用一次 |
| 报修单创建成功但图片列表为空 | 图片上传成功和报修单创建不是原子操作 | 先创建报修单,再逐个关联图片,失败时记录日志 |
| 师傅接单后发现报修单已取消 | 学生取消报修和师傅抢单同时发生 | 抢单时检查状态必须为“待接单” |
| 订阅消息发送失败,错误码43101 | 用户取消了授权或授权额度用完 | 在关键节点主动调起授权弹窗,记录剩余配额 |
| App在部分安卓机型上扫码提示“无法识别” | 扫码框没有对准二维码或者二维码破损 | 增加手动输入工单号备选方案 |
| 后台管理页加载缓慢 | 查询语句没有走索引,数据量大 | 给报修单表的status、user_id、worker_id加联合索引 |
7.2 部署方面的避坑清单
部署环境是Linux云服务器加Nginx加PHP 7.4加MySQL 5.7。整套系统上线前,有几个容易遗漏但影响很大的配置文件必须核对。
ThinkPHP项目运行在Nginx监听端口上时,如果配置了pathinfo模式,需要在Nginx配置里添加一个伪静态规则,将请求全部转发到index.php。很多初学者在本地用Apache能跑起来,部署到Nginx就返回404,原因就在这里。核心配置如下:
location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } }Laravel的部署配置稍微不同,它默认指向public目录作为web根目录。在Nginx里设置:
location / { try_files $uri $uri/ /index.php?$query_string; }两个框架混布在同一台服务器上时,建议通过不同子域名区分,比如api.student.example.com指向ThinkPHP入口,admin.example.com指向Laravel入口,避免统一入口下的路由冲突。我实际部署时就是把小程序端和App端接口拆成两个域名,管理后台再用一个独立域名,这样调试和维护的隔离性都更好。
最后,缓存和服务器的配置也需要处理一下。小程序端接口响应速度要求高一些,我给接口加了文件缓存,缓存时间控制在60秒以内,避免学生重复看到过期数据。App端的工单池因为需要实时性,不设缓存,直接读写MySQL。
8. 写在最后
这套系统从前端到后端、从数据库到部署,从头到尾走完一遍之后,最深的体会是:校园业务系统真正难的不是某个技术点,而是流程梳理和跨角色协作。学生群体的需求是“快”,维修师傅的需求是“清晰”,后勤管理层的需求是“数据”,三个角色的诉求不完全一致,系统设计时必须统筹考虑。
如果你正在规划类似的校园系统,我的建议是先画一张业务流程图,把状态机的跳转条件定义清楚,再动手写代码。报修系统本质是一个状态机驱动的业务系统,状态定义得合理,前后端开发都会顺畅很多。
最后分享一个小技巧:在本地开发时,将ThinkPHP和Laravel两套框架的日志文件分别用不同的文件名前缀,比如think-2024-01.log和laravel-2024-01.log,排查问题时用命令同时监控两个日志文件,一眼就能看出请求在两套框架之间的流转情况。这个习惯帮我节省了大量排查时间。