
去年接了一套设备报修系统的活儿甲方明确要求做成微信小程序版用户扫码报修、管理员后台派单、维修工手机接单还特意指定了技术栈后端PHP为主、nodejs为辅管理后台用vue小程序端走uniapp。说实话刚看到这个组合我也愣了一下——一个报修系统而已有必要同时上两门后端语言吗等真正把项目从原型做到上线部署我才明白这套选型并不是拍脑袋它恰好覆盖了一条从“快速出演示”到“接生产环境”的完整路径。这篇文章我会把整套系统的业务设计、数据表划分、关键接口逻辑以及我在开发过程中真实踩过的环境坑全部写清楚。不管你是准备做毕业设计、接外包小项目还是公司内部想自建一套报修工具都可以直接拿去参考。1. 设备报修系统的核心业务闭环角色、流程与模块边界1.1 三个角色和一条完整的报修闭环大部分刚接触这类项目的人第一反应就是“做增删改查嘛用户传个表单管理员看一眼列表”。真要这么想后面写代码的时候就会到处打补丁。设备报修系统表面上是个表单系统本质上是一套工单流转系统。它的核心不是“提交”这个动作而是提交之后单子怎么一步步走到“完结”。我在做需求梳理时把角色固定在三个用户通过小程序提交报修单能看自己报修单的实时进度维修完成后做验收和评价。维修工在小程序端或后台端查看被分派给自己的工单填写维修结果、上传维修照片。管理员拥有最大权限负责受理报修单、派单给维修工、监督整个维修流程、处理超时未处理的单子。一条完整的报修闭环是这样走的用户扫描设备二维码或手动选择设备填写故障描述并上传现场照片提交报修单。管理员在后台看到新单确认故障描述有效后“受理”此时单子从“待受理”变成“待派单”无效单可以直接驳回或取消。管理员把单子指派给某个维修工维修工在小程序端收到新工单提醒单子进入“维修中”。维修工处理完毕填写维修过程、更换配件说明上传维修后照片单子变成“待验收”。用户在小程序端查看维修结果确认没问题后点击“验收”单子变成“已完成”不满意可以打回或者提交评价。任意环节如果用户撤销、管理员驳回单子进入“已取消”同时记录取消原因。这个闭环看起来简单但如果不在后端用状态机来约束前端很容易出现“用户点了确认维修工还能改单子”“同一张单子被两个维修工同时处理”这类混乱状态。后面我会专门讲状态机的写法这是整项目最值得花时间的地方。1.2 系统模块拆开看小程序端、管理后台、服务端各管什么按我的习惯动手写第一行代码之前会先把系统切成几个边界清晰的模块避免写着写着前后端职责混在一起小程序用户端登录、设备选择、提交报修、报修记录列表、单子详情、验收评价、扫码绑定设备。小程序维修工端我的待办、接单/开始维修、提交维修结果、查看历史工单。注意用户端和维修工端可以做在同一个uniapp项目里用角色字段控制入口和TabBar显示不需要单独维护两套小程序代码。vue管理后台登录、数据看板今日报修量、待派单数、超时单数、工单管理、设备管理、用户管理、维修工管理、报修类型配置。后端服务PHP负责业务APInodejs负责定时任务、消息推送、WebSocket实时通知这类异步场景。模块边界一旦定了前端组件怎么拆、后端接口怎么分、数据表怎么建基本都有数了。很多时候项目做烂不是因为功能复杂而是没在一开始把“谁负责什么”说清楚。2. PHP和nodejs双后端并存的分工逻辑以及数据表怎么划2.1 PHP为主、nodejs为辅双后端并存不是炫技先说结论如果只是为了过验收一个PHP后端完全够用。那为什么还要加nodejs原因是两类技术栈擅长的场景不一样而且这个项目的真实需求里恰好同时出现了两类场景。PHP处理业务API的优势非常明显部署简单phpstudy或宝塔面板一键就能跑主流的虚拟主机基本都支持生态成熟写个登录鉴权、CRUD接口半天就能搞定对中小团队来说后续维护成本低。我的建议是把用户、设备、工单、评价这些核心业务接口全部放在PHP里数据一致性通过MySQL事务保证这是整个系统的“主干”。nodejs在这个项目里定位是“辅助服务”只做三件事定时任务扫库每天凌晨检查有哪些报修单超过24小时没人受理自动标记超时并推送提醒给管理员。消息推送通道调用微信订阅消息接口在工单状态变化时通知用户或维修工。WebSocket实时看板管理后台的待办数量不用手动刷新管理员一打开页面就能实时看到新单提醒。所以我的分工原则是凡是同步的、强一致性的写操作走PHP凡是异步的、需要长连接或定时执行的走nodejs。这套组合在演示阶段可能看不出差别但一旦单量上来你就能明显感觉到PHP接口响应稳定nodejs那边推送不阻塞业务请求。2.2 数据表划分六张核心表搞定百分之八十的业务数据表设计我在第一个版本就定了下来后面基本没大改。核心就六张表表名关键字段作用usersid, openid, phone, name, role, avatar, status用户/维修工/管理员统一放这张表用role区分devicesid, name, code, location, category, qr_code, status设备档案报修从选设备开始repair_orderid, order_no, device_id, user_id, worker_id, status, fault_desc, images, created_at, accepted_at, completed_at报修单主表状态流转核心order_logid, order_id, action, operator_id, remark, created_at工单操作日志记录每一步动作evaluationid, order_id, user_id, score, content, created_at验收评价表noticeid, user_id, content, type, is_read, created_at站内消息通知users表用role字段区分三种角色值分别是user、worker、admin这样一个uniapp项目里可以通过角色动态渲染不同首页也方便后台统一管理账号。repair_order表里加order_no流水号字段格式类似WO202506121530001好处是打印纸质单据或者用户报修时念单号都方便一张单在系统里的每次状态变化都写入order_log这样出了问题能追溯不会被“我明明点了提交”这种扯皮问题困扰。我特意把evaluation独立成表而不是在repair_order里加两个数字字段因为后续可能要扩展多维度评分独立表更灵活。设备表里带location和category字段方便后台按区域、按类型统计故障率。3. 工单状态机与权限校验后端开发中最容易写乱的地方3.1 工单状态机把流程写进配置而不是塞进if else很多新手写工单流转习惯在接口里写一大串if else判断“如果当前状态是2、当前角色是维修工就允许改成3”这样写二十个接口就要重复二十遍而且改需求时容易漏改。我的做法是定义一张状态迁移表用统一函数去校验// 状态机定义每个动作允许从哪些状态来由哪些角色执行 $flow [ submit [from [0], to 1, roles [user]], accept [from [1], to 2, roles [admin]], assign [from [2], to 2, roles [admin]], finish [from [2], to 3, roles [worker]], confirm [from [3], to 4, roles [user]], cancel [from [0, 1, 2, 3], to 5, roles [user, admin]], ]; function canTransit(int $current, string $action, string $role): bool { global $flow; $rule $flow[$action] ?? null; if (!$rule) { return false; } return in_array($current, $rule[from], true) in_array($role, $rule[roles], true); }这里的核心思路是把“状态如何流转”变成数据而不是逻辑。submit动作只能由user角色把0状态的单子变成1admin受理后变成2维修工完成维修后变成3用户确认后变成4。这样无论前端哪个页面调用后端只认这张配置表天然拦截了非法跳转。有人问assign把状态停在2而不是新开一个状态原因是“已派单但未开始维修”和“维修中”可以靠order_log里的动作区分核心业务状态没必要分太细。实际开发时我还在状态机里加了一个细节cancel动作允许从0、1、2、3四个状态发起但取消的权限不同——用户只能取消自己提交的单管理员可以取消任何单。这个归属校验单靠状态机还不够需要配合数据权限一起做。3.2 三次权限校验登录态、角色、数据归属缺一不可我在交付代码评审时最强调的一点后端接口不能只做前端按钮隐藏必须在服务端完整做三次校验。第一层是登录态校验。PHP接口用JWT方式实现前端每次请求在Authorization头带上Bearer token后端在进入业务逻辑前先解析token拿不到或过期直接返回401。uniapp端写个统一的request封装检测到401就自动跳转登录页// utils/request.js const BASE_URL https://api.example.com; export function request(options) { const token uni.getStorageSync(token); return new Promise((resolve, reject) { uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json, Authorization: Bearer token, }, success: (res) { if (res.statusCode 401) { uni.navigateTo({ url: /pages/login/login }); reject(res); return; } resolve(res.data); }, fail: (err) reject(err), }); }); }第二层是角色校验。比如“派单”接口只能admin角色调用“提交维修结果”只能worker角色调用。这个校验和状态机里的roles字段配合形成第一道业务闸门。第三层是数据归属校验。用户只能在查询时带上user_id 当前登录用户ID维修工只能看到分配给自己的工单管理员不受限。我第一次做demo时只做了前两层校验结果测试时发现用户把订单号改成别人的数字就能看到别人的维修记录这个漏铜很严重改成SQL强制带归属条件后问题才解决。3.3 接口实现片段PHP主链路与nodejs辅助服务报修单提交接口完整逻辑如下// api/repair_order/submit.php require_once ../core/Db.php; require_once ../core/Auth.php; $user Auth::verify(); if (!$user || $user[role] ! user) { exit(json_encode([code 403, msg 无权限])); } $deviceId intval($_POST[device_id] ?? 0); $faultDesc trim($_POST[fault_desc] ?? ); if (!$deviceId || !$faultDesc) { exit(json_encode([code 400, msg 参数不完整])); } $db Db::connect(); $orderNo WO . date(YmdHis) . rand(1000, 9999); $stmt $db-prepare( INSERT INTO repair_order (order_no, device_id, user_id, fault_desc, images, status, created_at) VALUES (?, ?, ?, ?, ?, 0, NOW()) ); $stmt-execute([ $orderNo, $deviceId, $user[id], $faultDesc, json_encode($_POST[images] ?? []), ]); // 写入操作日志 $log $db-prepare( INSERT INTO order_log (order_id, action, operator_id, remark, created_at) VALUES (LAST_INSERT_ID(), ?, ?, ?, NOW()) ); $log-execute([submit, $user[id], 用户提交报修单]); echo json_encode([code 0, msg 提交成功, order_no $orderNo]);注意图片我传的是数组JSON字符串前端上传图片时先走统一上传接口拿到URL列表提交报修时直接把URL数组带过来这样提交接口不用处理文件流简单很多。nodejs那边我做了一个定时扫库任务处理超时未受理的工单// services/timeout-checker.js const cron require(node-cron); const db require(./db); cron.schedule(*/10 * * * *, async () { const [rows] await db.query( SELECT id, order_no FROM repair_order WHERE status 1 AND created_at NOW() - INTERVAL 30 MINUTE ); for (const row of rows) { await db.query( UPDATE repair_order SET status 5, cancel_reason 超时未受理自动取消 WHERE id ? AND status 1, [row.id] ); await db.query( INSERT INTO order_log (order_id, action, operator_id, remark, created_at) VALUES (?, auto_cancel, 0, 系统超时自动取消, NOW()), [row.id] ); // 推送提醒给管理员 await pushToAdmin(报修单 ${row.order_no} 因超时未受理已自动取消); } });这段代码的价值在于用条件AND status 1防止重复执行哪怕两个定时任务同时跑也不会把单子取消两次。中间还插了一条order_log方便后台看到是系统自动取消而不是人工操作。4. uniapp小程序端与vue后台端的联动细节登录、上传、导航适配4.1 uniapp端登录与手机号获取别再走getUserInfo老路现在微信小程序的登录逻辑早就改了十年前那种uni.getUserInfo直接拿手机号的方案已经不能用了。最新的做法是分两步第一步通过uni.login静默获取code后端用code换openid和session_key建立基础会话。第二步用户需要绑定手机号时页面放一个按钮使用open-typegetPhoneNumber用户点击授权后前端拿到e.detail.code这是动态令牌不能直接解密出手机号必须把code传给后端后端再用code调用微信接口换取真实手机号。button open-typegetPhoneNumber getphonenumberhandlePhone 微信手机号快捷登录 /button// 页面methods async handlePhone(e) { if (!e.detail.code) { uni.showToast({ title: 已取消授权, icon: none }); return; } const res await request({ url: /api/auth/phone, method: POST, data: { code: e.detail.code }, }); uni.setStorageSync(token, res.data.token); uni.setStorageSync(userInfo, res.data.user); uni.switchTab({ url: /pages/index/index }); }后端接收code后用微信接口wxa/business/getuserphonenumber换取手机号再查users表做登录或绑定。这一步有个需要注意的地方这个接口要求小程序必须是企业主体个人主体的微信小程序拿不到这个权限会直接报错所以做demo前先确认账号类型。4.2 自定义导航栏高度适配状态栏和胶囊按钮的基准算法很多人做uniapp自定义导航栏时习惯写死height: 44px结果iPhone X上导航栏直接和状态栏重叠页面标题都看不清。微信小程序的顶部导航栏高度不是一个固定值它的组成是“系统状态栏高度 胶囊按钮垂直居中区域的高度”不同机型差异很大。我封装了一个自适应方法在自定义导航栏页面的onLoad里调用getNavBarInfo() { const sysInfo uni.getSystemInfoSync(); const menuBtn uni.getMenuButtonBoundingClientRect(); // 导航栏内容高度胶囊高度 胶囊上方间距 胶囊下方间距 const navContentHeight menuBtn.height (menuBtn.top - sysInfo.statusBarHeight) * 2; this.statusBarHeight sysInfo.statusBarHeight; this.navBarHeight sysInfo.statusBarHeight navContentHeight; }拿到这两个值后页面顶部占位view高度设为statusBarHeight navBarHeight或整体用navBarHeight再配合flex布局把标题垂直居中基本就能适配市面上绝大多数机型。核心原则是永远不要用死值全部基于uni.getMenuButtonBoundingClientRect()动态计算。这个函数只在微信小程序端存在H5端要写个fallback否则运行到浏览器调试时会报错。4.3 vue后台与小程序端的联动细节上传、路由守护和跨域处理管理后台我用的vue3加vite加element-plus代码就不贴整页了重点说三个我在联调时反复强调的细节上传接口统一。后端单独做一个/api/upload接口小程序端和后台上传图片都走它返回统一的{ url }结构。图片存储路径必须返回绝对地址不能返回相对路径否则小程序端onload图片时会出现路径拼接错误。前端路由守卫。后台所有页面除了登录页都应该有token校验我在router.beforeEach里加了一段简单判断router.beforeEach((to, from, next) { const token localStorage.getItem(admin_token); if (to.meta.requiresAuth !token) { next(/login); } else { next(); } });跨域问题。小程序端的uni.request不受浏览器同源策略限制所以PHP接口给小程序端调用时不用处理CORS。但vue后台跑在浏览器里接口跨域就必须处理。最简单的方法是在vue项目的vite.config里配开发代理server: { proxy: { /api: { target: http://localhost:8080, // 本地PHP服务地址 changeOrigin: true, }, }, }打包部署后让Nginx把/api路径反向代理到PHP服务前端代码里直接用相对路径写法这样就不用在PHP里写一大串CORS头。如果接口必须跨域调用PHP端记得设置正确响应头不要图省事直接写Access-Control-Allow-Origin: *生产环境会有安全隐患header(Access-Control-Allow-Origin: https://admin.example.com); header(Access-Control-Allow-Headers: Content-Type, Authorization); header(Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS); if ($_SERVER[REQUEST_METHOD] OPTIONS) { http_response_code(204); exit; }我之前遇到过一个典型报错后台接口一直请求不通F12看Network发现预检OPTIONS请求返回404。原因就是PHP没处理预检请求加上REQUEST_METHOD OPTIONS直接返回后就好了。5. 本地联调到真机部署我在这套项目上真实踩过的环境坑5.1 PowerShell里npm命令直接报错执行策略的坑第一次在Windows环境用VS Code跑vue项目时终端输入npm -v直接弹出来一段红色报错npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本这不是nodejs或者npm装坏了是PowerShell的脚本执行策略默认是Restricted不允许运行.ps1脚本。解决方式很简单以管理员身份打开PowerShell执行Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser执行完输入Get-ExecutionPolicy验证一下显示RemoteSigned就可以了。我一直建议团队里新配环境的同事直接用这个命令而不是网上说的删npm.ps1文件因为那是治标不治本下次装其他全局包还会遇到同样问题。5.2 tsconfig.web.json找不到版本不一致导致的连锁反应还有一次用npm create vuelatest创建项目按提示配置了TypeScript结果npm run dev直接报Failed to load tsconfig vue/tsconfig/tsconfig.web.json原因是新版create-vue生成的tsconfig文件里 extends 指向vue/tsconfig这个包但项目依赖里没装。正常按脚手架提示依赖装完后一般不会有问题出问题多数是用旧模板或手动改了package.json。解决方式npm install -D vue/tsconfig装完如果还报错检查tsconfig.app.json里的extends路径是否写成了绝对路径。这个坑不算深但遇到时容易卡住因为报错信息没有直接说“缺包”而是说“文件不存在”新手容易把方向带偏。5.3 真机调试与线上部署合法域名、HTTPS和CORS三座大山本地联调阶段微信开发者工具有一个“不校验合法域名”的开关勾上以后可以随意请求http://localhost或局域网IP非常方便。但这份方便只属于开发阶段一旦你拿真机扫码预览或者把体验版发给客户测试问题就来了。真机预览时小程序不能访问http://192.168.x.x:8080这种地址除非你在微信公众平台把该地址配置成合法域名并且域名必须支持HTTPS。注意这里说的是正式环境必须HTTPS加备案域名本地开发可以靠关闭域名校验绕过去。我在交付前吃过一次亏后端接口全部部署在内网机器微信公众平台的服务器域名里填了内网IP结果审核直接被驳回。后来换成了外网HTTPS域名配置好SSL证书才通过。部署链路我整理成一套标准动作PHP接口目录挂到Nginx的fastcgi配置好伪静态和HTTPS。vue后台执行npm run build产物放到Nginx另一个server块。nodejs服务用pm2守护执行pm2 start app.js --name report-push-server。uniapp项目在HBuilderX里点击“发行-小程序-微信”上传后到微信公众平台提交审核。还有一个容易忽略的点微信公众平台的“服务器域名”配置里request合法域名、uploadFile合法域名、downloadFile合法域名是分开的图片上传接口和普通业务接口如果域名不同需要分别配置少配一个都会导致真机上功能异常。6. 从演示版走向可上线推送、统计、自动派单的补充方案6.1 订阅消息推送让“工单有进展”主动触达用户报修系统最有感知度的功能其实是“单子有进展时用户能立刻知道”。我在第一版demo里只有站内消息后来发现用户根本不会主动打开小程序看进度必须借助微信订阅消息做主动触达。实现思路是用户提交报修单时弹出订阅消息授权请求用户同意后记录一次性订阅的模板ID和openid当工单状态流转到关键节点时后台调用订阅消息接口推送。这里要提前说清楚微信订阅消息是有次数限制的用户每授权一次只能推送一条所以千万别做“提交报修就连续推三条”的设计否则第二次推送会静默失败用户还以为是bug。我在nodejs里写了个简单的推送函数const axios require(axios); async function sendSubscribeMessage(openid, templateId, data, page) { const token await getAccessToken(); await axios.post( https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token token, { touser: openid, template_id: templateId, page, data, } ); }模板消息普遍建议做到“一状态一通知”只在“派单成功”“维修完成”“验收通过”这三个用户最关心的节点推送效果最好也不容易触发用户反感。6.2 统计看板、设备二维码与自动派单规则项目交付后管理员用得最多的不是派单操作而是看板。没有统计看板的报修系统管理员只能翻列表数单量效率低。我在vue后台首页加了四张卡片今日报修量、待派单量、维修中超时量、本月完成率下面再放一个按设备类型统计的柱状图。实现层面就是几个聚合查询接口配合ECharts渲染成本不高但现场演示效果好。设备二维码是我后来补的一个实用功能管理后台的“设备管理”页面可以按设备生成二维码打印后贴在设备上。用户扫一扫直接跳到该设备的报修页面设备ID通过场景值参数带入省去手动搜索设备的过程。这个功能用uniapp的onLoad(options)接收参数就行注意二维码内容要使用pages/repair/submit?device_idxxx这种完整路径格式。自动派单规则是基于状态机扩展的管理员不再手动指派维修工而是按维修工的技能标签和当前待办数自动分配。我在repair_order表加了一个worker_id字段nodejs定时任务每分钟扫描一次处于待派单状态且超过了时间阈值的单子按“当前待办最少的维修工优先、再按技能匹配度”算法自动分配。这样白天高峰期管理员甚至可以不管派单只需要处理驳回和异常单。6.3 个人经验这类系统最容易做砸的地方做完整套系统我最大的体会是设备报修类项目最怕的不是功能多而是把状态流转写得乱七八糟。很多初学朋友一上来就在接口里if else写着写着就出现“这个状态谁能改、改了之后谁能看”的混乱最终测试时怎么都理不清。先把状态机配置表和权限矩阵用表格画出来再开始写代码后端开发时间能省一半以上。第二个经验是“图片上传一定要统一处理”。我一开始让前端直接传base64字符串到PHP接口结果一张大图就把请求体撑到几MB接口响应慢不说数据库也快被撑爆。改成先传文件再传地址的做法后数据表里存的都是URL字符串查询列表时的响应体小了一个量级。第三个经验是关于验收的一定要给用户的验收操作留一个“打回”入口。维修工提交完成之后用户点确认之前应该有“仍有问题”的选项把单子打回给维修工重新处理否则线下实际场景里“维修了但没修好”的矛盾全堆到管理员那里人工处理系统用着用着就会被抛弃。我这个项目就是在验收节点加了打回状态工单闭环才算真正完整。如果你也是第一次独立做这种全栈项目我建议不要被PHP、nodejs、vue、uniapp四件套吓到。先像我这样把业务边界和状态机理清楚再按“PHP核心接口、nodejs异步服务、uniapp客户端、vue后台”四条线逐个推进每个模块单独测试通过了再联调整体节奏会比想象中顺畅很多。这套技术栈在真实的小型业务系统里确实能打值得花时间吃透。