简介:这是一份基于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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 换 openid,openid 不能当 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、查用户、签发 token:
@PostMapping("/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 LambdaQueryWrapper<User>() .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 层而不是 Controller
4.1 条件更新:接单接口的一行 SQL 是怎么防住并发抢单的
上门维修系统里最容易出并发问题的场景是“抢单”。一个订单发出后,多个维修工同时点接单,如果你的代码是“先查订单状态,再 update 状态”,那必然出问题:两个请求都查到 status=0,然后各自把自己设为 worker,最后订单就有两个接单人,这就是经典的“先查后写”竞态。
解决办法是用条件更新。把状态判断写进 update 语句的 where 条件里,数据库行锁会保证只有一个请求能命中:
@Override @Transactional(rollbackFor = Exception.class) public boolean acceptOrder(Long orderId, Long workerId) { // 只有 status = 0(待接单)的订单才允许接单 int rows = orderMapper.update(null, new LambdaUpdateWrapper<RepairOrder>() .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是核心,两个并发请求同时执行这条 SQL,InnoDB 的行锁会让第二个请求等待,第一个请求提交后,第二个请求再执行时发现 status 已经是 1,影响行数为 0。这里不需要显式加SELECT ... FOR UPDATE,条件更新本身就完成了乐观锁要做的事。@Transactional保证 workerId 和 status 的变更作为一个原子事务提交,不会出现“worker 写了但 status 没变”的中间状态。
同样的技巧用在订单完结上:
// 用户确认完成,要求订单处于待验收状态 public void finishOrder(Long orderId, Long userId) { int rows = orderMapper.update(null, new LambdaUpdateWrapper<RepairOrder>() .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?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4第二步,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 LambdaUpdateWrapper<RepairOrder>() .eq(RepairOrder::getStatus, 0) .lt(RepairOrder::getCreateTime, LocalDateTime.now().minusMinutes(30)) .set(RepairOrder::getStatus, 5) ); // 这里可以记录取消数量,用于运营统计 }第二步,接入微信订阅消息。维修工接单后,给用户推一条“已有人接单,预计 x 点上门”的模板消息;维修完成后再推一条“请确认服务质量”。小程序订阅消息的机制是一次订阅推送一次,用户点击“允许”后只能收到一条,所以要引导用户对“接单通知”和“完成通知”分别订阅。
第三步,把派单从“抢单”改成“推荐+抢单”。给维修工表加skills字段,存“水电、家电、防水”这类标签。用户下单时,优先推送category匹配的维修工,同时按距离排序。这个距离不一定要用经纬度计算,小范围业务直接用地址字符串匹配小区名称也能解决,等单量大了再上地图 API 也不迟。
我自己当年做这类系统时,在并发接单那一步翻过车,线上测试被两个同事同时点接单,瞬间造出一单双接的数据,后来才明白“条件更新”这四个字的重量。这些坑你不需要重新踩一遍,希望帮到你。
本文还有配套的精品资源,点击获取