Java微信小程序上门维修系统:全栈开发与订单状态机实战

📅 发布时间:2026/9/29 18:43:10
Java微信小程序上门维修系统:全栈开发与订单状态机实战
简介这是一份基于Java Spring Boot与微信小程序的上门维修系统完整源码面向计算机相关专业学生、Java后端与小程序开发者适合用于课程设计、毕业设计或项目实战学习。系统涵盖用户注册登录、维修信息浏览、维修订单管理、服务评价、广告展示与后台管理等功能后端采用JDK1.8与MySQL5.7前端通过微信小程序交互可帮助读者理解前后端分离架构下的业务流程。资源包共1248个文件大小20.07MB含123个Java后端源文件、145个Vue组件、226个JS脚本、49个WXML与49个WXSS小程序文件以及278张PNG图片、1个SQL数据库脚本和3个BAT部署脚本等Vue与JS用于管理后台前端WXML/WXSS与JS构成小程序端Java负责业务接口SQL可快速初始化数据库BAT脚本辅助本地部署目录结构清晰便于分模块学习与二次开发。已有119人学习适合需要完整可运行项目进行二次开发或学习调试的读者。1. 上门维修系统源码在学什么从“接单靠吼”到“派单上小程序”的 Java 全栈入口用户在小程序里传一张漏水照片、填上门地址维修工在“待接单”列表里抢单管理员在后台看着每一单走到哪一步。这样一个闭环就是“(源码)基于Java和微信小程序的上门维修系统”这类项目最常被选作课程设计的原因它不大但五脏俱全。拆开看是三条线——微信小程序做用户端和维修工端Java 后端用 Spring Boot 提供下单、接单、状态更新的接口MySQL 存用户、维修工、订单三张核心表。适合刚学完 java 基础、想拿一个完整项目串一遍全栈的在校生也适合准备 java 面试题、想把并发和状态机讲出真实场景的新手。搞懂它怎么从零跑通后面换任何业务系统你都有参照物。2. 技术选型与表结构Spring Boot 原生小程序怎么搭最顺手2.1 技术栈拆解后端框架、ORM、前端框架各选什么上门维修系统这种体量的项目常见的做法是后端 Spring Boot MyBatis-Plus MySQL小程序端用微信原生框架管理后台如果实在需要再套一个若依之类的脚手架。技术选型不需要追求新要追求“一周内能跑通、三个月后还能看懂”。Spring Boot 的优势是内嵌 Tomcat打包成 jar 直接java -jar就能启动省掉了单独装 Tomcat、配 server.xml 这一整条链路。MyBatis-Plus 解决的是单表 CRUD 的重复劳动内置分页插件写一个selectPage就能搞定订单列表不需要为每个查询手写 XML。有人纠结要不要用 MyBatis 原生写我的建议是课程设计阶段你一定会有改表结构的冲动MyBatis-Plus 的 LambdaQueryWrapper 改起来最快后悔药好找。前端原生小程序不引入 uni-app是因为这套系统没有跨端需求加上 uni-app 等于多了一层编译链排查问题时要多绕一个弯。网上常聊的 uniapp 开发微信小程序 vs android/ios/鸿蒙 到底选谁在这个项目里答案很明确只有微信小程序一个端原生的学习成本最低。为什么不建议上 Spring Cloud、Redis、MQ 那一套上门维修的业务量级就是一个校园或者一个小区的报修量单机 Spring Boot 完全扛得住。引入微服务不是技术进阶是给自己挖坑。Nacos、Feign、Sentinel 任何一个组件出问题排错时间都够你再写一个系统了。这份源码的价值在于把业务闭环讲清楚不在于技术栈有多花哨。2.2 数据库设计五张表把上门维修的订单状态机装下来上门维修系统的核心表一般就五张用户表、维修工表、订单表、图片表、评价表。用户和维修工可以用一张表加role字段区分也可以在用户表上加一个worker_profile扩展表存技能标签。为了少踩坑我建议用户和维修工拆成两张表因为维修工有技能分类、好评率、接单数这些额外字段硬塞进用户表会让表结构变得很奇怪。订单表是整张业务图的中心。下面这段建表 SQL 是这类项目最常用的结构直接照着建就能用CREATE TABLE repair_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号展示给用户看, user_id bigint(20) NOT NULL COMMENT 下单用户ID, worker_id bigint(20) DEFAULT NULL COMMENT 接单维修工ID未接单时为空, category varchar(32) NOT NULL COMMENT 维修类别水电/家电/门窗/防水等, description varchar(500) DEFAULT NULL COMMENT 故障描述, address varchar(200) NOT NULL COMMENT 上门地址, contact_name varchar(32) NOT NULL COMMENT 联系人, contact_phone varchar(20) NOT NULL COMMENT 联系电话, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待接单 1已接单 2维修中 3待验收 4已完成 5已取消, expect_time datetime DEFAULT NULL COMMENT 期望上门时间, finish_time datetime DEFAULT NULL COMMENT 实际完成时间, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status (status), KEY idx_user_id (user_id), KEY idx_worker_id (worker_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT维修订单表;参数说明里最关键的是status字段。用tinyint存数字状态含义写在 COMMENT 里不要用 varchar 存中文否则排序、索引、统计全都别扭。0 到 5 六个状态对应完整生命周期用户下单进 0维修工接单变 1上门开始维修变 2维修完等待用户确认变 3用户点确认变 4用户取消或者超时未接单变 5。worker_id允许为空就是为了承载“已下单但还没有人接”的中间状态。finish_time单独拎出来是因为“维修中”和“已完成”之间隔着一个用户验收动作完成时间不等于接单时间。地址字段直接存字符串不拆省市区表这是课程设计和商用系统的重要区别。商用系统要按城市分派维修工必须拆地区维度课程设计阶段拆了反而增加联表复杂度一个address字段存全文查询时用LIKE匹配就够了。索引建了三个status用于后台按状态筛选user_id用于用户查自己的订单worker_id用于维修工查自己接过的单。这三条索引就是最常见的查询路径别贪多每条索引都是写操作的负担。图片表单独建不要用逗号分隔的 URL 塞在订单表的一个字段里。一张订单可能有三张故障照片拆成子表才能支持后续的“上传图片”接口独立于“创建订单”接口存在CREATE TABLE order_image ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL COMMENT 所属订单, image_url varchar(500) NOT NULL COMMENT 图片访问地址, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单图片表;评价表字段更简单订单ID、评分、评价内容、创建时间。唯一要注意的是order_id要加唯一约束保证一个订单只能评价一次这条约束在代码里也要做一层判断双保险。2.3 表结构设计的三个常见误区第一个误区是用户表里存 openid 就直接当主键。openid 是微信生态的标识长度 28 位做主键占空间、不好维护自增关系老老实实用自增id做主键openid 加唯一索引。第二个误区是所有时间字段都用varchar排序时按字符串排等到做“按月份统计订单量”的时候就傻眼了数据库原生datetime配合DATE_FORMAT就能做报表。第三个误区是忽略order_no这个业务单号。有人觉得反正有自增 id为什么还要一个面向用户的订单号因为用户报修时是打电话报单号的你不能让用户对着 18 位的自增 id 念号码用一个时间戳加随机数的 12 位短单号更好用。3. 小程序端跑通“下单-接单-看进度”登录态与数据流的三个关键点3.1 微信登录code 换 openidopenid 不能当 token 用小程序的登录链路对新手来说是个黑匣子很多人以为拿到 openid 就完事了。实际流程是这样的小程序前端调wx.login拿到一个临时 code把 code 发给后端后端拿 code 去微信的接口换 openid然后自己签发一个 token 返回给小程序。openid 是用户的唯一标识但绝不能直接拿它当登录凭证否则一旦被泄露用户身份就被冒用了。先看小程序这端的标准写法// pages/login/login.js const app getApp(); Page({ onLoad() { this.login(); }, async login() { // 先取本地缓存有 token 就不重复走 wx.login const token wx.getStorageSync(token); if (token) { this.checkToken(token); return; } // 1. wx.login 拿到的 code 只能用一次5 分钟内有效 const { code } await wx.login(); // 2. 把 code 发给后端换 token wx.request({ url: app.globalData.baseUrl /api/auth/login, method: POST, data: { code: code }, success: (res) { if (res.data.code 200) { const { token, userInfo } res.data.data; // 3. token 存本地后续请求统一带 wx.setStorageSync(token, token); wx.setStorageSync(userInfo, userInfo); wx.switchTab({ url: /pages/index/index }); } else { wx.showToast({ title: 登录失败, icon: none }); } } }); } });逻辑说明wx.login返回的 code 是一次性的后端换完 openid 之后这个 code 就作废了所以不要在小程序端做缓存。token存到本地 storage 之后每次请求从 storage 取出来放到 header 里这才是后续接口鉴权的依据。checkToken的作用是启动时用 token 调一次“获取用户信息”接口后端如果返回 401就清掉本地 token 重新走登录。后端对应这一段接口核心逻辑就三步换 openid、查用户、签发 tokenPostMapping(/api/auth/login) public Result login(RequestBody LoginRequest req) { // 1. 用 code 向微信接口换取 openid String openid wxService.code2Session(req.getCode()).getOpenid(); // 2. 查用户表不存在则自动注册默认角色是 user User user userMapper.selectOne(new LambdaQueryWrapperUser() .eq(User::getOpenid, openid)); if (user null) { user new User(); user.setOpenid(openid); user.setRole(user); userMapper.insert(user); } // 3. 签发 token过期时间建议 7 天payload 里带上 id 和 role String token JwtUtil.createToken(user.getId(), user.getRole()); return Result.success(token); }参数说明code2Session是微信官方接口需要在后端配置 appid 和 secret这两个配置绝对不能出现在小程序前端代码里否则任何人通过反编译小程序包就能拿到你的 secret。JWT 的 payload 里至少要有userId和role后续拦截器从 token 里取这两个值做权限判断。3.2 先传图再传订单提交报修单的完整时序创建订单最容易被忽略的是图片上传的时序。如果你先提交订单、再上传图片订单已经落库了图片上传失败就要处理“订单存在但没图片”的中间状态很麻烦。正确做法是先传图、拿返回的图片 id 或 url再和订单信息一起提交。这样订单创建的接口一次性把所有数据写入不存在半成品。前端核心代码拆成两步// pages/order/submit.js async submitOrder() { const form this.data.form; if (!form.address || !form.category) { wx.showToast({ title: 请填写完整信息, icon: none }); return; } // 第一步先上传图片如果有的话 let imageUrls []; if (this.data.images.length 0) { for (let i 0; i this.data.images.length; i) { const uploadRes await this.uploadImage(this.data.images[i]); if (uploadRes) { imageUrls.push(uploadRes); } } } // 第二步带着图片列表创建订单 const token wx.getStorageSync(token); wx.request({ url: app.globalData.baseUrl /api/order/create, method: POST, header: { Authorization: Bearer token }, data: { category: form.category, description: form.description, address: form.address, contactName: form.contactName, contactPhone: form.contactPhone, expectTime: form.expectTime, imageUrls: imageUrls }, success: (res) { if (res.data.code 200) { wx.navigateTo({ url: /pages/order/detail?orderId res.data.data.id }); } else { wx.showToast({ title: res.data.msg, icon: none }); } } }); }, uploadImage(filePath) { const token wx.getStorageSync(token); return new Promise((resolve, reject) { wx.uploadFile({ url: app.globalData.baseUrl /api/file/upload, filePath: filePath, name: file, header: { Authorization: Bearer token }, success: (res) { const data JSON.parse(res.data); if (data.code 200) { resolve(data.data.url); } else { reject(new Error(data.msg)); } }, fail: reject }); }); }这段代码里有两个细节值得注意。第一wx.uploadFile返回的res.data是字符串需要手动JSON.parse很多人直接拿来用结果拿到undefined这是小程序的一个经典陷阱。第二上传接口和业务接口都带Authorizationheader拦截器在处理上传请求时不能只校验业务接口文件上传同样要鉴权否则任何人都能往你的服务器扔文件。这里隐含了一个问题为什么要有imageUrls字段而不是上传完后端直接返回订单 id因为后端要维护一个“上传文件先落库、订单创建时再关联”的关系前端把 URL 列表传过来后端插入order_image表就完成了关联逻辑最清晰。3.3 订单进度轮询10 秒一次够用别急着上 WebSocket订单详情页要实时显示状态变化最朴素的方案是轮询。每隔 10 秒调一次查询接口拿到最新状态刷新页面。有人一上来就想用 WebSocket觉得实时推送才够高级但在这个项目里WebSocket 要处理连接维护、断线重连、心跳包复杂度翻一倍收益却很小。维修订单的状态是低频变化用户能接受 10 秒以内的延迟轮询是完全够用的。// pages/order/detail.js Page({ data: { orderId: null, order: {}, statusText: 待接单 }, onLoad(options) { this.setData({ orderId: options.orderId }); this.fetchDetail(); this.startPolling(); }, onUnload() { // 页面销毁时必须清理定时器 if (this.timer) { clearInterval(this.timer); } }, fetchDetail() { const token wx.getStorageSync(token); wx.request({ url: app.globalData.baseUrl /api/order/ this.data.orderId, method: GET, header: { Authorization: Bearer token }, success: (res) { if (res.data.code 200) { this.setData({ order: res.data.data }); this.updateStatusText(); } } }); }, startPolling() { // 10 秒轮询一次页面不可见时考虑停掉 this.timer setInterval(() { this.fetchDetail(); }, 10000); } });轮询要注意一个问题小程序页面切到后台后定时器仍在运行会持续发起网络请求浪费流量。改进方案是在onHide里清除定时器、onShow里重新启动同时拉一次最新数据。这个细节虽然小但很多时候课程设计答辩老师就问这个。4. Java 后端接口与状态机把业务规则写在 Service 层而不是 Controller4.1 条件更新接单接口的一行 SQL 是怎么防住并发抢单的上门维修系统里最容易出并发问题的场景是“抢单”。一个订单发出后多个维修工同时点接单如果你的代码是“先查订单状态再 update 状态”那必然出问题两个请求都查到 status0然后各自把自己设为 worker最后订单就有两个接单人这就是经典的“先查后写”竞态。解决办法是用条件更新。把状态判断写进 update 语句的 where 条件里数据库行锁会保证只有一个请求能命中Override Transactional(rollbackFor Exception.class) public boolean acceptOrder(Long orderId, Long workerId) { // 只有 status 0待接单的订单才允许接单 int rows orderMapper.update(null, new LambdaUpdateWrapperRepairOrder() .eq(RepairOrder::getId, orderId) .eq(RepairOrder::getStatus, 0) .set(RepairOrder::getWorkerId, workerId) .set(RepairOrder::getStatus, 1) .set(RepairOrder::getAcceptTime, LocalDateTime.now()) ); // rows 1 表示接单成功rows 0 说明订单已经被别人接走或已取消 if (rows 0) { throw new BizException(订单已被其他维修工接走); } return true; }逻辑说明LambdaUpdateWrapper同时承担了条件判断和字段更新的职责。where 里的status 0是核心两个并发请求同时执行这条 SQLInnoDB 的行锁会让第二个请求等待第一个请求提交后第二个请求再执行时发现 status 已经是 1影响行数为 0。这里不需要显式加SELECT ... FOR UPDATE条件更新本身就完成了乐观锁要做的事。Transactional保证 workerId 和 status 的变更作为一个原子事务提交不会出现“worker 写了但 status 没变”的中间状态。同样的技巧用在订单完结上// 用户确认完成要求订单处于待验收状态 public void finishOrder(Long orderId, Long userId) { int rows orderMapper.update(null, new LambdaUpdateWrapperRepairOrder() .eq(RepairOrder::getId, orderId) .eq(RepairOrder::getStatus, 3) .eq(RepairOrder::getUserId, userId) .set(RepairOrder::getStatus, 4) .set(RepairOrder::getFinishTime, LocalDateTime.now()) ); if (rows 0) { throw new BizException(订单状态已变化请刷新后再试); } }这里比接单多了一个条件getUserId也要等于当前登录用户。这不仅是数据权限也是并发控制的一部分——不是你的订单你连改状态的资格都没有。状态机流转的每一个入口都应该是这种“条件更新 影响行数判断”的模式而不是先 select 再 update。这个思路在 java 面试里聊到“怎么保证数据一致性”时是个非常能打的回答点。4.2 用户、维修工、管理员的接口边界数据权限在查询层卡住三种角色的接口边界可以用一张表说清楚角色可操作动作数据可见范围用户创建订单、取消待接单订单、查看自己的订单列表、确认完成仅user_id 当前用户的订单维修工查看待接单列表、接单、开始维修、提交维修结果待接单全量可见历史订单仅worker_id 当前用户管理员查看所有订单、强制改状态、分配维修工、查看统计全量订单实现上不要在每个 Controller 方法里手动判断角色用拦截器统一处理。先解析 token把用户信息放到 ThreadLocal 里后续代码随时取public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } try { // 解析 JWT拿到 userId 和 role Claims claims JwtUtil.parseToken(token); Long userId claims.get(userId, Long.class); String role claims.get(role, String.class); // 存到 ThreadLocal请求结束时必须清理 UserContext.set(userId, role); return true; } catch (Exception e) { response.setStatus(401); return false; } } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 防止线程池复用导致用户数据串号 UserContext.clear(); } }拦截器是黑色的骨架真正的数据权限在查询层。比如用户查订单列表Service 里必须强制拼接user_id条件不能允许传入一个 userId 参数来指定查谁的订单——那等于把越权漏洞留给测试人员去发现。维修工的“待接单列表”和“我的订单列表”是两个接口前者查status 0的订单后者查worker_id 当前用户。切不可用一个接口加参数区分那样很容易漏掉某个分支的权限判断。至于管理员强制改状态这是个危险操作。我一般在管理端加一个状态变更日志表每次改都记录“谁、在什么时间、把订单从哪个状态改到哪个状态、原因是什么”。不是为了应付答辩而是线上系统一定会遇到“订单状态被改乱了但没人承认改过”的事故有一张日志表就是后悔药。5. 避坑真机联调与订单状态的五个经典坑5.1 开发工具里能跑真机一打开就白屏现象小程序在微信开发者工具里一切正常后端接口能通页面能跳转。一发到手机上预览所有请求全部失败页面空白。原因开发者工具默认勾选了“不校验合法域名”所以本地用http://localhost:8080也能调通。真机上小程序运行时强制校验请求域名必须使用 HTTPS 且在小程序后台配置了 request 合法域名。你没有配置自然全挂。解决开发阶段临时在两个地方配置一下。第一微信开发者工具的“详情-本地设置”里勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”真机预览时同样要在预览页勾选。第二后端启动时不要监听localhost改成0.0.0.0同时让手机和电脑连同一个 WiFi小程序里的baseUrl写成电脑的局域网 IP比如http://192.168.1.101:8080。上线前再去小程序后台配置正式的 HTTPS 域名这是发布前绝对不能忘的一步。5.2 两个维修工同时点接单结果两个人都接上了现象一个订单同时被两个维修工接单用户后台看到两个维修工的信息状态却是“已接单”。原因接单接口写成了“先查再改”Order order orderMapper.selectById(orderId); if (order.getStatus() ! 0) { throw new BizException(已被接走); } order.setWorkerId(workerId); order.setStatus(1); orderMapper.updateById(order);两个请求同时读到了status 0都通过了 if 判断然后先后执行 update后执行的把先执行的覆盖掉了。数据库层面没有任何并发保护。解决把判断和修改合并成一条条件更新 SQL就是 4.1 里写的update ... where id ? and status 0。这条 SQL 在数据库层面是原子操作两个并发请求只有一条能成功。代码里用影响行数是否大于 0 来判断是否接单成功就能稳稳挡住并发。5.3 数据库时间比北京时间少了 8 小时现象订单创建时间显示比实际时间晚 8 小时凌晨 0 点下单显示成前一天下午 4 点。原因MySQL 连接串里没有指定时区数据库会话使用的时区是 UTC。而 Java 后端默认取的是系统时区北京时间两边差值 8 小时。解决三步全做。第一步数据库连接串加参数jdbc:mysql://localhost:3306/repair?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4第二步MySQL 全局时区也改掉在my.cnf的[mysqld]段加default-time-zone 08:00。第三步如果后端返回值给前端时日期显示还是不对检查 JSON 序列化配置把 LocalDateTime 序列化为yyyy-MM-dd HH:mm:ss字符串并带上时区。这三步缺一不可只改连接串的话手动连数据库看到的还是 UTC 时间排查起来更迷惑。5.4 用户隔三差五就要重新登录一次现象用户用着用着突然跳回登录页重新授权才能继续操作。频率没规律有时一天两次有时几天一次。原因大概率是 token 过期时间太短或者每次启动小程序都会重新走一遍wx.login覆盖了旧 token。常见误设置是 JWT 过期时间写成 2 小时对一个小程序应用来说太短了。另一个坑是前端启动时无条件调登录接口后端签发新 token 覆盖了本地 storage旧 token 虽然没过期但被顶掉了。解决token 有效期设置 7 天小程序不是高安全场景7 天是合理折中。前端登录逻辑改成“先取本地 token有就带 token 调一次校验接口校验通过直接用校验失败才重新wx.login”就是 3.1 里那段代码的处理思路。后端签发 token 时不要每次生成一个全新 token可以考虑旧 token 在有效期内不重新签发。5.5 图片上传成功但订单详情页永远打不开图片现象前端提示图片上传成功订单详情页也拿到了图片 URL但image组件显示出来是空白。更诡异的是开发工具里能看到图片真机上看不到。原因上传功能返回的是后端本地存储路径比如/upload/20240512/xxx.jpg前端访问时拼的是http://localhost:8080/upload/xxx.jpg开发工具里 localhost 指向开发机所以能显示真机上 localhost 指向手机自己自然加载失败。这是图片存储路径用手写localhost的经典翻车。解决第一后端配置静态资源映射让/upload/**可以访问本地文件。第二前端拼 URL 时用app.globalData.baseUrl imageUrl不要硬编码。第三上线后图片必须走 HTTPS 域名如果图片量大考虑上对象存储。最小可用的开发方案是后端加一个配置类Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 把 /upload/** 映射到本机的 /data/repair/upload/ 目录 registry.addResourceHandler(/upload/**) .addResourceLocations(file:/data/repair/upload/); } }addResourceLocations末尾的斜杠不能丢丢了这个映射直接失效这也是个常见的隐藏坑。6. 进阶从课程设计到能商用的维修系统只差这三步课程设计做到能跑通、能答辩其实已经达标了。但如果你真想拿这套系统去接点真实的维修生意或者面试时想聊出一点超出课程设计的思考下面这三步是性价比最高的升级。第一步补一个“超时未接单自动取消”的定时任务。真实业务里用户下了单 30 分钟没人接心里就开始发慌系统应该自动取消这张单并给用户一个通知。用 Spring 的Scheduled每 60 秒扫一次即可不需要上延迟队列Scheduled(fixedDelay 60000) public void autoCancelExpiredOrders() { // 只处理待接单且超过30分钟的单条件更新防止重复取消 int rows orderMapper.update(null, new LambdaUpdateWrapperRepairOrder() .eq(RepairOrder::getStatus, 0) .lt(RepairOrder::getCreateTime, LocalDateTime.now().minusMinutes(30)) .set(RepairOrder::getStatus, 5) ); // 这里可以记录取消数量用于运营统计 }第二步接入微信订阅消息。维修工接单后给用户推一条“已有人接单预计 x 点上门”的模板消息维修完成后再推一条“请确认服务质量”。小程序订阅消息的机制是一次订阅推送一次用户点击“允许”后只能收到一条所以要引导用户对“接单通知”和“完成通知”分别订阅。第三步把派单从“抢单”改成“推荐抢单”。给维修工表加skills字段存“水电、家电、防水”这类标签。用户下单时优先推送category匹配的维修工同时按距离排序。这个距离不一定要用经纬度计算小范围业务直接用地址字符串匹配小区名称也能解决等单量大了再上地图 API 也不迟。我自己当年做这类系统时在并发接单那一步翻过车线上测试被两个同事同时点接单瞬间造出一单双接的数据后来才明白“条件更新”这四个字的重量。这些坑你不需要重新踩一遍希望帮到你。本文还有配套的精品资源点击获取