1. 项目整体设计思路
1.1 核心需求拆解与功能边界
设备报修这事,但凡在稍微正规一点的公司、学校、厂区里待过就知道,传统报修方式有多折腾。打电话报修说不清位置,微信群里报修消息被刷掉,报修完不知道进度,维修人员跑错地方。这套系统要解决的就是这些真实痛点,核心需求拆开看就是四个闭环:
- 报修发起端:员工或用户通过微信小程序扫码或手动选择设备,上传故障描述、图片,提交报修单,实时看到处理进度。
- 处理端:管理员或维修人员在后台(PC端Vue页面)看到工单流,进行接单、派单、处理、完结操作。
- 通知端:工单状态变化时,通过微信订阅消息或WebSocket实时推送给相关人,不用反复刷新。
- 统计端:按设备类型、故障类型、处理时长等维度做基础统计,给管理者决策参考。
这四个闭环彼此独立又相互依赖。报修发起端解决"怎么报修"的问题,处理端解决"谁来修、修没修"的问题,通知端解决"信息同步"的问题,统计端解决"提升效率"的问题。项目名里列出了PHP和Node.js两套后端技术,实际就是这个系统里不同环节的职责分配,后面细说。
1.2 为什么选这套技术栈
首先说微信小程序,这是报修系统的天然入口,不需要用户装App,微信里扫码就能打开,权限体系和登录体系都是现成的。小程序端我用Uniapp而不是原生小程序,核心原因是一套代码可以同时编译到微信小程序、H5、App。说实话,很多报修后台的管理人员偶尔需要在手机上处理工单,用H5版就能解决,没必要再维护一套原生代码。
后端我选了PHP负责主要业务接口,Node.js做辅助服务。很多人问为什么不干脆只用一种,我的理解是:PHP处理常规的增删改查、文件上传、权限校验非常成熟,生态里现成的东西多,部署也简单,不管是宝塔还是Docker,跑起来都不费劲。而Node.js在实时通信方面有天然优势,WebSocket推送、定时任务扫描、消息队列处理,这些用Node.js写起来比PHP顺手得多。两个服务之间通过HTTP接口内部调用,各干各擅长的活,这是这套架构的核心逻辑。
管理后台用Vue,配合Element UI或类似组件库,开发效率非常高。Vue的双向绑定和组件化机制特别适合工单列表、状态流转这类交互密集的后台页面。整个技术栈选型的核心原则就一句话:用最顺手的技术处理最擅长的环节,而不是追求全栈统一使用同一种语言。
2. 前后端架构与关键模块设计
2.1 数据库表结构设计
报修系统的核心数据表我拆成了六张,不多不少,业务恰恰够用。闯过坑之后发现,表结构设计千万别一开始就整一堆冗余字段,后面改动成本太高。
用户表(user):存储微信用户的openid、unionid、昵称、头像、手机号、角色(普通用户/维修工/管理员)。openid是微信生态的唯一标识,登录环节的核心凭证。注意手机号字段建议独立存储,因为微信手机号获取是一次性加密数据,需要后端配合解密。
设备表(device):设备编号、名称、型号、位置、所属区域、状态(正常/报修中/停用)、二维码标识。这里有个细节,位置字段要存两级以上,比如"3号楼-2层-会议室",因为报修时要按位置快速定位和筛选。
报修工单表(repair_order):工单号、报修人ID、设备ID、故障类型、故障描述、图片URL、状态(待接单/处理中/已完成/已取消)、紧急程度、报修时间、完成时间。这张表是整个系统的核心,状态流转逻辑都在这一张表上。
工单日志表(order_log):工单ID、操作人ID、操作类型、操作内容、操作时间。每次状态变更都记录一条日志,方便后续排查问题和管理审计。
通知记录表(notification):接收人ID、通知类型、内容、是否已读、创建时间。WebSocket推送的消息和微信订阅消息统一在这张表登记。
评价表(evaluation):工单ID、报修人ID、评分、评价内容、评价时间。工单完结后报修人可以评价,反向督促维修质量。
这套表结构看着简单,但每张表都有存在的必要。核心原则是:宁可多一张日志表,也不要把状态变化埋在代码里。没有日志表时曾经出过一次问题:一个工单从"处理中"直接变成"已完成",用户投诉维修工没来,我们查了半天没有记录可查。加了日志表之后,每次状态变更都有迹可循,类似纠纷再没发生过。
2.2 小程序端模块拆解
Uniapp开发微信小程序,我按页面和功能模块拆成这几块:
登录模块:微信小程序最关键的环节。流程是wx.login获取code,传给后端调用微信的code2Session接口换openid和session_key。注意手机号获取方式在微信新版API里改了,现在是用户点击button触发getPhoneNumber,后端拿code解密获取手机号,不能直接用旧接口了。这个坑后面详细说。
首页与设备选择:首页展示报修入口,扫码识别设备、手动搜索设备、常用设备列表三种方式。扫码功能用uni.scanCode接口,扫到的二维码内容就是设备编号。手动搜索设备支持模糊匹配设备名称和设备编号。
报修工单填写页:核心交互页面。选择故障类型(下拉选择)、填写故障描述(textarea)、上传故障照片(uni.chooseImage配合uni.uploadFile)。这里有一个交互细节:故障描述默认给几个常用语模板让用户快速选择,比如"设备无法开机"、"设备运行时异响"、"设备屏幕显示异常",有效减少用户打字负担。
工单列表与详情页:我的报修列表页,分"进行中"和"已完成"两个Tab。需要做下拉刷新和上拉加载,用Uniapp的onPullDownRefresh和onReachBottom。详情页展示工单状态流转时间线,状态变化有记录。实时状态更新通过WebSocket推送后前端刷新,如果没有WebSocket连接,就做一个手动下拉刷新兜底。
个人中心页:展示用户信息、我的设备(收藏的常用设备)、我的评价、联系客服入口。
小程序端的核心逻辑其实不复杂,但处理好在弱网环境下的体验不容易。比如上传图片失败要能续传,工单提交失败要在本地草稿箱保存,重新联网后提示用户重试。这些措施能显著降低用户流失率。
2.3 PHP与Node.js的职责划分
这两个后端服务的分工,我画一个简单的请求路径图来理解:
微信小程序(Uniapp) → PHP API(业务逻辑) → MySQL ↓ 内部调用 Node.js(WebSocket推送 / 定时任务 / 导出)PHP负责的模块:
- 用户认证接口:登录、手机号绑定、角色鉴权。
- 工单管理接口:创建工单、查询工单、更新工单状态、取消工单。
- 设备管理接口:设备列表、设备详情、设备绑定、二维码生成。
- 评价接口:提交评价、查看评价。
- 管理后台接口:用户管理、设备管理、工单分配、统计报表。
PHP的技术选型我建议用ThinkPHP或Laravel。ThinkPHP上手快,中文文档友好,中小型项目够用;Laravel生态更完整,但学习曲线稍陡,适合对代码规范和架构要求高的团队。我这个项目用的ThinkPHP6,理由很简单:业务复杂度不需要Laravel的重型功能,ThinkPHP6的部署和维护成本更低,路由、ORM、中间件都有,够了。
Node.js负责的模块:
- WebSocket推送服务:连接管理、事件广播、断线重连。
- 定时任务:超时工单检测(比如超过2小时未接单自动提醒)、每日数据汇总。
- 文件导出服务:工单Excel导出、月度统计报表导出。
Node.js我用的Express框架,代码量不大,核心就是维护一个WebSocket连接池。PHP处理完业务后,如果需要通知用户,就请求Node.js的推送接口,Node.js把消息转发给对应的小程序WebSocket连接。实测下来这套流程很稳定,延迟在毫秒级。
为什么不让PHP直接做推送?PHP实现WebSocket不是不行,但常驻内存的进程管理和长连接维护不如Node.js顺手。PHP更适合"请求-响应"模式,Node.js更适合"长连接-事件"模式。让两种技术做各自擅长的事,这是架构设计的核心思想。当然也有一个现实原因:小程序端和Web端需要实时收到工单状态变化,用轮询会大量浪费服务器资源和用户流量,WebSocket是一次连接、随时推送,两者体验差得很远。
3. 核心功能实操实现
3.1 微信登录与手机号授权实操
这一步是新手上路第一个大坑。微信小程序登录流程拆开看只有三步,但每一步的细节都容易踩坑。
第一步:前端获取code
uni.login({ provider: 'weixin', success: (loginRes) => { // 获取到临时code const code = loginRes.code; // 把这个code发给后端 this.$http.post('/api/auth/login', { code: code }); } });第二步:后端换openid
PHP后端拿code请求微信接口:
$url = "https://api.weixin.qq.com/sns/jscode2session?appid={$appid}&secret={$secret}&js_code={$code}&grant_type=authorization_code"; $result = file_get_contents($url); $data = json_decode($result, true); // $data['openid'] 用户唯一标识 // $data['session_key'] 会话密钥,用于解密手机号注意appid和secret放在服务器端配置文件中,绝对不能写在小程序前端代码里。之前见过有人把secret写死在js里,结果被安全扫描工具直接扫出来,整个接口裸奔,这是最严重的低级错误。
第三步:获取手机号
新版微信手机号是加密的,流程是前端点击button触发,用户授权后拿到code,把code传给后端,后端调接口解密:
<button open-type="getPhoneNumber" @click="getPhoneNum">授权手机号</button> // 前端逻辑 getPhoneNum(e) { if (e.detail.code) { this.$http.post('/api/auth/phone', { code: e.detail.code }); } }// 后端解密手机号 $url = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={$access_token}"; $data = json_encode(['code' => $code]); // 返回数据包含 phone_info.phoneNumber 和 purePhoneNumber这里有个容易忽略的点:获取手机号需要access_token,而access_token需要通过appid和secret换取,并且有效期为7200秒。所以封装一个token管理方法,用缓存存token,过期自动刷新,不然每次请求都去换取会有限流风险。
实操心得:先做静默登录(code2Session),再按需授权手机号,不要让用户一打开小程序就被迫授权手机号,授权通过率会大幅下降。
3.2 报修工单状态机与创建流程
工单状态是整个系统的核心逻辑。我设计了五个状态,流转关系必须清晰:
待接单(0) -> 处理中(1) -> 已完成(2) | | v v 已取消(3) 已取消(3)状态机说明:
- 待接单:用户提交报修单后进入此状态,维修工或管理员看到待接单列表。
- 处理中:维修工接单,标记开始处理。此时报修人能看到是谁在处理。
- 已完成:维修工提交完成,填写处理结果,报修人可评价。
- 已取消:用户主动取消或超时未处理系统自动取消。
- 状态变更不可逆跳,比如"待接单"不能直接变成"已完成",必须经过"处理中",否则维修记录就失真了。
创建工单的后端代码(PHP):
public function createOrder($userId, $deviceId, $faultType, $description, $images) { // 事务控制,保证数据一致性 Db::startTrans(); try { // 生成唯一工单号 $orderNo = 'WO' . date('YmdHis') . rand(1000, 9999); // 插入工单主表 $orderId = Db::name('repair_order')->insertGetId([ 'order_no' => $orderNo, 'user_id' => $userId, 'device_id' => $deviceId, 'fault_type' => $faultType, 'description' => $description, 'images' => json_encode($images), 'status' => 0, 'create_time' => time() ]); // 写入操作日志 Db::name('order_log')->insert([ 'order_id' => $orderId, 'operator_id' => $userId, 'action' => 'create_order', 'content' => '用户提交报修工单', 'create_time' => time() ]); // 通知管理员有新工单 $this->notifyAdmins($orderId); Db::commit(); return $orderId; } catch (\Exception $e) { Db::rollback(); throw $e; } }事务必须用上。因为工单创建涉及主表插入、日志表插入、通知记录表插入三步操作,中间任何一步失败都会导致数据不一致。用事务包起来,要么全部成功,要么全部回滚。
创建工单时还需要同时触发Node.js推送:
private function notifyAdmins($orderId) { // 调用Node.js推送服务 $nodeUrl = "http://127.0.0.1:3000/api/push/order"; $postData = json_encode([ 'type' => 'new_order', 'order_id' => $orderId ]); $ch = curl_init($nodeUrl); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $postData); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_exec($ch); curl_close($ch); }这里有个性能隐患:每次创建工单都同步调用Node.js推送接口,如果Node.js服务挂了,PHP端会一直等待。解决方法是设置curl超时时间为2秒,并且推送失败不影响主流程,写在日志里即可。更稳妥的方案是引入消息队列,但项目初期没必要,2秒超时足够兜底。
3.3 Vue管理后台实操
管理后台是给管理员和维修工用的,核心页面四个:工作台(数据总览)、工单管理、设备管理、用户管理。
工作台:放几个统计卡片,今日新报修数、待处理工单数、处理中工单数、本月完成率。用一个折线图展示近7天的报修趋势。图表库用ECharts,Vue2配合vue-echarts,Vue3可以用echarts官方的新写法。
工单管理是核心页面。列表筛选条件包括状态、紧急程度、故障类型、时间段。列表操作按钮按状态显示:待接单的显示"接单",处理中的显示"完成",已完成的不显示操作按钮。Vue的组件化在这里特别舒服,工单卡片可以复用于列表和详情。
接单操作的核心逻辑:
async handleAccept(orderId) { const res = await api.updateOrderStatus({ order_id: orderId, status: 1, operator_id: this.userInfo.id }); if (res.code === 0) { this.$message.success('接单成功'); this.loadOrderList(); // 刷新列表 } }Vue后台这里有几个细节值得注意:
- 状态筛选用Tabs而不是下拉框:工单管理页顶部用Tab切换(全部/待接单/处理中/已完成/已取消),比下拉框直观,操作路径短。
- 列表必须有分页:工单量一大,一次性加载几百条会卡顿。统一用分页组件,每页20条,配合后端LIMIT分页。
- 权限控制:普通维修工只能看到分配给自己的工单,管理员能看到全部工单。后端接口返回数据时就要过滤,不能只靠前端隐藏按钮,前端永远不可信。
3.4 环境搭建与部署实操
整套系统的部署环境,我用的是Linux服务器 + Nginx + PHP 7.4 + MySQL 5.7 + Node.js 14。
PHP环境配置:
PHP安装这里踩过不少坑。最典型的就是用源码编译安装时提示no package 'libzip' found,这是因为编译安装PHP 7.4以上版本需要libzip库,而系统自带的版本太老。解决办法是先把libzip编译安装好:
# 下载编译libzip wget https://libzip.org/download/libzip-1.7.3.tar.gz tar -zxvf libzip-1.7.3.tar.gz cd libzip-1.7.3 mkdir build && cd build cmake .. make && make install装好之后再编译PHP,--with-zip参数就能正常识别了。如果是在CentOS上用宝塔面板的,面板自带PHP不用这么折腾,直接可视化安装。
Node.js环境配置:
Node.js的坑主要在Windows开发机上。很多人第一次用npm就报这个错:
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本这个错误是PowerShell的执行策略禁止运行脚本导致的。解决办法有两种:
# 方法一:以管理员身份运行PowerShell,修改执行策略 Set-ExecutionPolicy RemoteSigned -Scope CurrentUser # 方法二:使用CMD而不是PowerShell npm -v方法一更彻底,改完之后PowerShell和VS Code的终端都能正常用npm命令。
Nginx配置:
PHP项目配置一个server块,Node.js项目配置一个server块做反向代理。关键是跨域问题,管理后台Vue运行在http://admin.example.com,PHP接口运行在http://api.example.com,必然产生跨域请求。PHP端加响应头解决:
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization');如果携带了自定义header(比如token),必须写Access-Control-Allow-Headers,否则前端请求被浏览器拦截,这个问题排查起来特别隐蔽。
4. 常见问题与排查方法
4.1 微信小程序端问题实录
问题一:导航栏高度在不同机型上错位。
微信小程序的顶部导航栏在不同机型上高度不一样,尤其是刘海屏和普通屏差距明显。Uniapp里可以用uni.getSystemInfoSync()获取状态栏高度,然后动态设置自定义导航栏高度:
const systemInfo = uni.getSystemInfoSync(); this.statusBarHeight = systemInfo.statusBarHeight; this.navBarHeight = systemInfo.statusBarHeight + 44; // 44是导航栏标准高度注意:statusBarHeight在Android和iOS上返回的值单位不同,建议都转成px处理,用uni.upx2px进行单位换算。
问题二:小程序开发工具真机预览,二维码扫了打不开。
多半原因是开发版小程序的域名白名单没配好或没开启调试模式。真机预览时要在小程序开发者工具的"详情-本地设置"里勾选"不校验合法域名",否则所有接口请求都会被拦截。注意这只是开发阶段的手段,上线前必须配置合法域名并把微信服务器域名白名单配好。
问题三:uniapp打包微信小程序后,样式错乱。
原因是Uniapp编译后的rpx转换和组件的样式隔离。排查方法:开发者工具里打开"样式隔离"选项,或者检查是不是用了非标准的CSS属性。rpx的动态计算在不同机型上最容易出问题,建议固定宽度尽量用百分比,高度用rpx。
4.2 PHP端问题实录
问题一:跨域请求,前端能请求通但带不了cookie/header。
前面说了要在PHP端加响应头。但还有个容易忽略的点:如果前端需要携带自定义header(比如Authorization),浏览器会先发一个OPTIONS预检请求。PHP接口必须对OPTIONS请求直接返回200,否则真正的GET/POST请求会被拦截。ThinkPHP中可以这样处理:
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') { header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With'); exit; }问题二:PHP 8环境下老项目报错vcruntime140.dll不兼容。
这是Windows下的历史遗留问题。调试时建议用php -v检查当前PHP版本,如果项目代码用的是PHP 7语法,直接切换到PHP 7.4版本最省事。配置PHPStorm时,要在Settings-PHP里把Interpreters指向正确的PHP可执行文件,否则IDE的语法检查和实际运行环境不一致,会误判很多语法错误。
问题三:文件上传报错upload_tmp_dir不可写。
上传故障图片时遇到File upload error - unable to create a temporary directory,排查思路:确认php.ini里的upload_tmp_dir目录存在且有写权限,upload_max_filesize和post_max_size也要调整,建议都设为20M以上。部署在Nginx上时,还要同步检查Nginx的client_max_body_size,默认是1M,不改的话大图直接传不上去。
4.3 Node.js端问题实录
问题一:npm安装依赖慢或安装失败。
国内直连npm官方源经常超时,配置淘宝镜像:
npm config set registry https://registry.npmmirror.com问题二:Node.js进程挂了,WebSocket断开。
这是生产环境最严重的问题。比如服务器内存不足时Node.js进程被系统杀掉,所有小程序的WebSocket连接全部断开。解决方案有两个层面:
代码层面:前端检测到WebSocket断开后自动重连,设置重连间隔,指数退避。
运维层面:用PM2管理Node.js进程:
pm2 start app.js --name repair-push pm2 save pm2 startupPM2的daemon模式可以让Node.js应用开机自启、崩溃自动重启,这是必须的基础配置,不能省。
问题三:Node.js监听端口被封或冲突。
线上部署时Node.js监听3000端口,但很多服务器只放行80和443端口。解决方案是Nginx反向代理:外部请求wss://example.com/ws转发到http://127.0.0.1:3000,外部直接访问不到3000端口,安全性也好一些。
location /ws { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; }这一层反向代理配置很重要。WebSocket需要Upgrade和Connection头部,Nginx默认不会转发这两个头,不配置的话WebSocket握手必然失败。
4.4 联调问题实录
问题一:小程序无法请求本机开发的API。
Uniapp开发阶段在手机预览时,手机和小程序开发者工具连接的是同一个局域网,请求地址要写电脑的局域网IP,不能写localhost。比如电脑IP是192.168.1.100,接口地址就是http://192.168.1.100:8080/api。另外,HTTP明文请求在iOS的App环境下默认会被拦截,微信小程序也有体验版的域名校验,开发阶段使用"不校验合法域名"选项解决。
问题二:接口调试时请求参数被URL编码,后端取不到值。
排查方法:先在后端打印收到的原始数据file_get_contents('php://input'),看看实际传过来的是什么格式。如果是JSON格式,用json_decode解析,不要用$_POST去取。
问题三:联调过程中工单状态不同步。
典型案例:后台改了工单状态,小程序端的列表数据还是旧的。原因有两个可能:一是小程序列表页面做了缓存,二是WebSocket消息没到达或前端没处理。排查思路:先在浏览器手动请求接口确认数据源正确,再看WebSocket连接状态,最后看小程序的onShow有没有做下拉刷新。我给小程序详情页加了两个机制:页面onShow时调用一次uni.request刷新详情,同时通过WebSocket收到状态变更事件时主动更新页面数据。双保险效果很好,基本不会出现数据滞后超过5秒的情况。
5. 实操心得与改进方向
整套系统从立项到上线,前后用了不到三周。给我的感受是:技术选型不是越新越好,而是越顺手越好。微信小程序解决了入口和用户身份问题,Uniapp解决多端复用问题,PHP解决了业务快速交付问题,Node.js解决了实时性要求,Vue解决了后台开发效率问题。每一层都是"够用就行、各司其职"。
我实际操作下来有几个体会想分享:
第一个体会是,状态机设计一定要先想清楚再写代码。工单状态流转是整个系统的灵魂,如果状态设计不清晰,后面的接单、派单、统计、通知全都会跟着乱。建议在白板上先把状态图画出来,所有可能的状态转换都列出来,再开始编码。
第二个体会是,日志记录比想象中重要得多。不只是工单日志,PHP和Node.js的运行日志、接口调用日志,都要有。生产环境出了问题,日志是唯一的线索。我遇到过Node.js推送偶发失败的问题,就是通过日志发现是服务器内存不足导致进程被系统杀掉,定时任务连不上。没有日志,排查这个问题可能要浪费一下午。
第三个体会是,前端要时刻考虑弱网和异常场景。小程序端在食堂、地下车库、电梯里使用频率很高,信号不佳是常态。所以上传图片要有失败重试,提交工单要有草稿保存,列表加载要有加载中状态和加载失败重试按钮。这些细节决定了用户对系统的整体评价。
这套系统后续还可以扩展的方向不少。比如引入故障自动分类,根据报修描述自动打标签;或者给维修工加一个类似"抢单"的模式,待接单工单推送出来后先到先得;再或者把评价体系和维修工的绩效考核挂钩,形成正向激励循环。整体架构不变,在这些模块上叠加即可。
最后再说一个小技巧:报修系统这种业务,二维码是入口的关键。给每台设备生成专属二维码,打印出来贴在设备上,用户扫一下就能进入报修页面,设备信息自动带出,这一步体验做好,整个报修流程就顺畅了一大半。二维码生成用PHP简单处理就行,编码格式选QRCode,把设备编号作为内容,前端用uni.scanCode扫码后解析,整个链路没有技术门槛,但实际使用体验会好非常多。