简介:一套基于微信小程序与SSM框架(Spring+Spring MVC+MyBatis)完成的设备故障报修管理系统项目源码,面向计算机相关专业毕业设计、课程设计以及需要快速搭建内部报修平台的技术开发者。系统覆盖设备台账管理、故障单提交、维修任务分派、进度实时跟踪、历史记录统计、消息提醒和角色权限控制等核心业务,采用微信小程序作为员工端、Vue后台作为管理端、SSM提供数据接口,从报修到维修再到结果反馈形成完整闭环。资源包共1286个文件,核心包括127个Java后端类、138个Vue后台页面、181个JS逻辑文件、100个JSON配置,以及WXML/WXSS小程序页面、SQL数据库脚本和PNG/JPG图片素材,压缩包约20.64MB,目录按后端、管理端、小程序、数据库与文档分隔,便于按模块阅读和二次开发。已有145人学习或下载。包内还包含可执行启动脚本与Eclipse工程配置,能帮助使用者快速复现项目,并深入理解SSM接口设计、小程序前后端联调、权限控制等关键知识点。
1. 基于微信小程序的设备故障报修管理系统为什么还要用SSM框架
设备坏了,报修信息如果不留痕,后勤管理员连“哪个设备在修、修了多久”都说不清。微信小程序适合做这种低频但刚需的内部工具:不用装App,扫码就能提交故障,还能看处理进度。后端选SSM框架,是因为大量存量系统的实际代码就是Spring+SpringMVC+MyBatis,这套框架在“表单+流程+统计”项目里非常好写:Spring管业务对象,SpringMVC收请求,MyBatis绑定SQL。下面围绕报修单从用户提交、维修工接单到消息通知的完整链路,把图片上传、状态机和分页操作写具体。适合做微信小程序毕业设计的人,也适合企业后勤想把维修流程线上化的工程师参考。
2. SSM工程结构、报修数据表与小程序端目录设计
2.1 为什么报修系统要拆成Spring+SpringMVC+MyBatis三层
我一般会把后端按controller、service、mapper三层分包,算是SSM框架最标准的写法。Controller负责接收小程序请求和做参数校验,Service负责报修单的创建、状态变更、通知发送,Mapper只写SQL和对象映射。拿“提交报修”这个功能来说,Controller收到POST /api/repair之后,要完成图片保存、订单号生成、报修单插入三个动作,这三个动作之间没有互相依赖的就不该在Controller里串行写,而是放到Service的一个事务方法里。Spring这里解决的是对象创建和事务管理,SpringMVC解决的是URL到Java方法的映射,MyBatis解决的是Java对象和数据库字段的映射。很多人在SSM项目里把SQL写在Service里,这是要避免的:报修单列表查询、按状态统计这类SQL,放在对应的Mapper.xml里更好维护。
2.2 报修订单表设计:设备、报修人、维修工、状态都要留快照
数据库设计决定了后面状态流转好不好写。我一般会建五张表:用户表、设备表、报修单表、处理记录表、附件表。其中核心是repair_order,字段设计如下表。注意要把device_name冗余到报修单里,否则设备改了名字或者被删除,历史报修单再查询时就要做表关联。status用tinyint不用varchar,因为后面要按状态做GROUP BY统计。
表2-1 报修单核心字段
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 报修单主键 |
| order_no | varchar(32) | 业务编号,展示给用户 |
| device_id | bigint | 设备id |
| device_name | varchar(64) | 设备名称快照 |
| reporter_id | bigint | 报修人id |
| assignee_id | bigint | 接单维修工id |
| description | text | 故障描述 |
| images | varchar(1000) | 图片URL,逗号分隔 |
| priority | tinyint | 1低、2中、3高 |
| status | tinyint | 0待接单、1处理中、2已完成、3已评价 |
| create_time | datetime | 报修时间 |
| finish_time | datetime | 完成时间 |
建表SQL中要把order_no设置成唯一索引,status和reporter_id建普通索引。order_no生成方式常见是“BX+yyyyMMdd+三位序号”,在Service里用数据库查询当天最大编号再加一,注意加锁或者加上唯一索引防重。建表SQL如下:
CREATE TABLE repair_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, device_id BIGINT NOT NULL, device_name VARCHAR(64), reporter_id BIGINT NOT NULL, assignee_id BIGINT, description TEXT, images VARCHAR(1000), priority TINYINT DEFAULT 2, status TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME, KEY idx_status (status), KEY idx_reporter (reporter_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里使用utf8mb4,是为了兼容小程序端提交的表情符号,尤其是报修备注里有人会打emoji。如果用了utf8,插入时报错“Incorrect string value”,那多半就是字符集问题。MyBatis的Mapper.xml里在插入时要注意useGeneratedKeys="true" keyProperty="id",这样插入后就能直接拿到自增id,不用再查一次。
2.3 小程序端目录与后端包结构对齐,避免逻辑散落
小程序端如果直接用原生微信小程序开发,目录可以分成pages/login、pages/repair、pages/list、pages/detail,再单独用utils/request.js封装wx.request。如果用uni-app开发,页面结构也是一样的,区别是wx.uploadFile要换成uni.uploadFile,wx.request换成uni.request。但无论哪种,业务校验都不能放到小程序端。有人为了省事,在提交前判断状态下拉框,在js里拼状态名,后端不受这一套约束,最后抓包一看数据早就发到后端了。正确做法是小程序端只做展示,状态流转必须由后端Service完成。Backend端包结构建议这样做:
repair-backend/ src/main/java/com/example/repair/ controller/ RepairController.java FileUploadController.java service/ RepairService.java OrderNumberGenerator.java mapper/ RepairOrderMapper.java RepairOrderMapper.xml src/main/resources/ spring-context.xml spring-mvc.xml mybatis-config.xmlSSM看似繁琐,但这个结构的好处是查错路径清楚。如果修改报修单分页条件,去RepairOrderMapper.xml找select;如果改接单后的通知逻辑,去RepairService找。Spring的配置文件要分清:spring-context.xml扫描service和mapper,spring-mvc.xml只扫描controller。如果两个文件都扫到了service,就会出现同一个Service被两个容器代理,事务注解有的时候生效,有的时候失效,非常难查。
2.4 MyBatis的Mapper绑定失败问题可以这样排查
“Invalid bound statement (not found)”是SSM项目最容易遇到的新手错误。常见原因有三种:Mapper接口的方法名和XML的id不一致;接口类被Spring扫描了但XML没有被打包;命名空间namespace写错。第一次部署时,先检查target/classes目录下有没有RepairOrderMapper.class对应路径的RepairOrderMapper.xml。还有一些项目使用MyBatis的@Select注解,这个在报修系统里不建议滥用,多表关联和动态条件写XML更直观。我在管理端列表里一般会用动态SQL:
<select id="selectPage" resultType="com.example.repair.vo.RepairOrderVO"> SELECT * FROM repair_order <where> <if test="status != null"> AND status = #{status} </if> <if test="reporterId != null"> AND reporter_id = #{reporterId} </if> </where> ORDER BY create_time DESC </select>这里用where标签自动处理AND前缀,不需要在每条SQL前面手写1=1。查询参数用状态、报修人、时间范围,分别对应小程序端“我的报修”和管理端“按状态筛选”。如果参数再复杂,再考虑用condition对象接收。
3. 用户提交报修:小程序图片上传与SSM后端入库
3.1 用wx.chooseMedia选图并用wx.uploadFile一张一张传
微信小程序从基础库2.21.0开始推荐使用wx.chooseMedia替代wx.chooseImage,它在安卓和iOS上返回的tempFiles都包含tempFilePath和size,报修照片一般限制9张,每张压缩后再传。小程序端代码可以这么写:
chooseAndUpload() { wx.chooseMedia({ count: 9 - this.data.imageList.length, mediaType: ['image'], sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (res) => { const tasks = res.tempFiles.map(f => this.uploadOne(f.tempFilePath)); Promise.all(tasks).then(urls => { this.setData({ imageList: this.data.imageList.concat(urls) }); }); } }); } uploadOne(filePath) { return new Promise((resolve, reject) => { wx.uploadFile({ url: `${getApp().globalData.baseUrl}/api/file/upload`, filePath, name: 'file', success: (res) => { const data = JSON.parse(res.data); if (data.code === 0) { resolve(data.data.url); } else { reject(new Error(data.msg)); } }, fail: reject }); }); }这段代码里count限制的是剩余可选数量,sizeType用compressed,既省流量也让上传更快。Promise.all并发上传不能超过微信的10个并发,9张是安全值。name字段“file”必须和后端@RequestParam("file")一致。选择报修类型要是用radio单选框,直接放在提交表单里,和后端字段对应即可,不要单独上传。上传成功返回的图片URL会展示在页面上,供用户确认再提交报修。
表3-1 上传参数建议值
| 参数 | 建议值 | 说明 |
|---|---|---|
| count | 9 - imageList.length | 剩余可选照片数 |
| mediaType | ['image'] | 只选图片,不选视频 |
| sizeType | ['compressed'] | 用压缩图,减少上传时长 |
| maxUploadSize | 5242880 | 单张不超过5MB |
3.2 后端FileUploadController接收文件并生成访问链接
后端用SpringMVC的MultipartFile接收上传文件,需要先在spring-mvc.xml里配置multipartResolver。我一般限制单张5MB,避免有大图把小程序内存拖垮。代码如下:
@RestController @RequestMapping("/api/file") public class FileUploadController { @Value("${upload.dir}") private String uploadDir; @Value("${upload.url-prefix}") private String urlPrefix; @PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file) throws IOException { if (file.isEmpty()) { return Result.error("文件为空"); } String original = file.getOriginalFilename(); String ext = ""; int dotIndex = original.lastIndexOf('.'); if (dotIndex >= 0) { ext = original.substring(dotIndex); } String filename = UUID.randomUUID().toString().replace("-", "") + ext; File dir = new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, filename)); return Result.ok(urlPrefix + "/uploads/" + filename); } }对应的multipartResolver配置:
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <property name="maxUploadSize" value="5242880"/> <property name="defaultEncoding" value="UTF-8"/> </bean>maxUploadSize这里是5MB。注意:CommonsMultipartResolver的bean id必须是multipartResolver,不能改名字,否则SpringMVC不认。如果出现“Required request part 'file' is not present”,优先检查name是否对应,再检查是否引入了commons-fileupload依赖。文件保存路径不要放在WEB-INF目录下面,否则通过URL访问不到,建议配一个独立的uploads目录,再用静态资源映射把/uploads/映射到磁盘目录。图片地址返回给小程序后还需要拼接完整域名,这就是urlPrefix的作用。
3.3 创建报修单的Service方法要为一个事务
图片上传成功只是第一步,点“提交报修”时,小程序会POST一个包含设备名称、故障描述、priority、images的JSON到后端。Controller要做的事很少,真正的逻辑在Service:
@Service public class RepairService { @Autowired private RepairOrderMapper repairOrderMapper; @Autowired private OrderNumberGenerator orderNumberGenerator; @Transactional(rollbackFor = Exception.class) public Long createRepairOrder(RepairCreateDTO dto, Long reporterId) { RepairOrder order = new RepairOrder(); order.setOrderNo(orderNumberGenerator.next()); order.setDeviceId(dto.getDeviceId()); order.setDeviceName(dto.getDeviceName()); order.setReporterId(reporterId); order.setDescription(dto.getDescription()); order.setImages(String.join(",", dto.getImages())); order.setPriority(dto.getPriority()); order.setStatus(0); repairOrderMapper.insert(order); return order.getId(); } }这里把@Transactional放在Service层,不能放在Controller,原因是SpringMVC的Controller是单例包装的,事务代理不同,容易出现“没生效”。生成订单号和插入报修单在一个事务里,保证不会出现order_no一样的两条数据。如果服务器异常,订单号生成器里的计数和数据库插入都回滚,下次重新生成就好。还有,reporterId不要信前端传的,应该从拦截器设置在ThreadLocal或request attribute里的登录用户id。登录态用微信登录后自己颁发的token,每次请求带在header里。
3.4 真机预览和抓包时的请求排查方法
小程序开发者工具里能直接看到请求,但真机上出问题就要抓包。常见排查路径是先看“开发设置-服务器域名”是否把后端地址加入request合法域名和uploadFile合法域名。有人改了域名后不生效,就要清理缓存重新编译;也可以用charles抓包微信小程序,观察HTTP请求是否发出、响应状态码是多少。如果遇到上传图片时返回401,多半是token没有加到uploadFile的header里,wx.uploadFile不能用默认header,需要这样写:
header: { 'Authorization': getApp().globalData.token }如果返回的是JSON但小程序的data字段解析失败,检查后端是否返回了纯HTML错误页,比如404或500页面,JSON.parse自然失败。这个时侯先把后端日志打开,看有没有NullPointerException或MapperException,比反复改小程序代码更有用。在SSM里给所有异常加一个全局@ControllerAdvice,统一返回{code: 500, msg: "系统异常"},小程序端拿到code非0就提示用户,避免白屏。
4. 工单状态机与微信订阅消息通知设计
4.1 报修单状态机:哪些角色可以把单子变成什么状态
如果报修系统只有一个上报和查看列表,那和问卷调查没区别。真正的核心在状态流转:用户报修后维修工接单,处理完改成已完成,用户再评价。把状态机定义清楚,后面不会因为需求加一个“加急撤回”就把代码改乱。以最常见的流程定义为例:
| 当前状态 | 动作 | 目标状态 | 允许角色 |
|---|---|---|---|
| 0 待接单 | 接单 | 1 处理中 | 维修工、管理员 |
| 1 处理中 | 完成 | 2 已完成 | 维修工 |
| 2 已完成 | 评价 | 3 已评价 | 报修人 |
| 0 待接单 | 撤回 | -1 已取消 | 报修人 |
我一般会在Service里提供一个统一的状态变更方法,先判断当前状态能不能执行目标动作,再执行update。禁止在各个Controller里直接写update set status,否则有人绕过校验把状态从0改成2。实现上可以用一个枚举类,只做校验用:
public enum StatusTransition { ACCEPT(0, 1), FINISH(1, 2), EVALUATE(2, 3), CANCEL(0, -1); private final int from; private final int to; StatusTransition(int from, int to) { this.from = from; this.to = to; } }然后在Service里写changeStatus方法时,先遍历枚举看是否有匹配的from/to组合,再检查操作人角色,最后执行update。这样即使以后加了一个“重新打开”状态,也只需要在枚举里加一项,不需要在所有if分支里补逻辑。
4.2 用状态CAS替换“先查再做”,防住并发抢单
维修工列表页在待接单状态下会有多个维修工同时看到一条记录。如果Service里先select,判断status == 0,再执行update,那两个人都会查到status==0,最后都会执行成功,单被接两次。常见做法是把update语句本身作为校验条件:
<update id="accept"> UPDATE repair_order SET status = 1, assignee_id = #{assigneeId} WHERE id = #{id} AND status = 0 </update>Service里这样调用:
int rows = repairOrderMapper.accept(orderId, operatorId); if (rows == 0) { throw new BizException("该报修单已被接单"); }update影响行数为0时,说明当前状态已经不是0,直接拒绝。这种CAS思路比给表加锁更轻量,因为在Spring的Transactional里,你select到的旧值可能被其他事务隔离,而update的where条件能保证最终一致性。评价操作同理,把where改成status=2,防止用户重复评价。还有一点,接单时要把assignee_id和status一起更新,不要在Service里分两次update,否则两条SQL之间会有窗口期。
4.3 微信订阅消息:用什么模板、在哪里触发
进度提醒是用微信订阅消息,不是模板消息。用户在提交报修后,需要在小程序里主动调用wx.requestSubscribeMessage,用户同意一次,后端才有权限发送一条订阅消息。我一般把订阅动作放在提交报修成功后的回调里,这样用户刚完成操作,转换率比较高。
wx.requestSubscribeMessage({ tmplIds: ['模板ID'], success(res) { if (res['模板ID'] === 'accept') { console.log('订阅成功'); } } });后端在状态变为“处理中”和“已完成”时调用subscribeMessage.send接口。这里需要注意:access_token要从微信接口获取,缓存7200秒,不能每次发送都去拿;推送接口的数据格式对字段长度非常敏感,特别是thing类型的value不能超过20个字。报修的device_name如果太长要截断,不然接口会报invalid message data。
4.4 处理记录表:状态变化的审计是详情页的底牌
光有报修单状态还不够,如果用户问“为什么修了这么久”,需要有人能回答单子什么时候被接走、维修工有没有更新过备注。所以状态变化一定要写repair_record表,字段包括id、order_id、from_status、to_status、operator_id、remark、create_time。Service里的统一变更方法这样实现:
@Transactional public void changeStatus(Long orderId, Long operatorId, int fromStatus, int toStatus, String remark) { int rows = repairOrderMapper.updateStatus(orderId, fromStatus, toStatus); if (rows == 0) { throw new BizException("状态变更失败"); } RepairRecord record = new RepairRecord(); record.setOrderId(orderId); record.setOperatorId(operatorId); record.setFromStatus(fromStatus); record.setToStatus(toStatus); record.setRemark(remark); repairRecordMapper.insert(record); }这样状态变更记录和报修单更新在同一个事务里,不会出现库里的状态和日志对不上的情况。小程序详情页只要查repair_record列表,就能按时间正序显示“提交报修/维修工接单/维修完成/用户评价”。同时,管理端可以基于这张表做“平均接单时长、平均维修时长”的统计,比用报修单表更准。唯一的代价是多一次insert,对报修这种低频系统来说完全可以接受。
5. 管理端分页查询与SSM配置的几个必调参数
5.1 PageHelper的startPage只能作用于下一条SQL
管理端报修单列表要分页,SSM项目最常见的是用PageHelper插件。配置好依赖后,在spring的SqlSessionFactoryBean里加拦截器:
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="plugins"> <array> <bean class="com.github.pagehelper.PageInterceptor"> <property name="properties"> <props> <prop key="helperDialect">mysql</prop> <prop key="reasonable">true</prop> </props> </property> </bean> </array> </property> </bean>Service里这样写:
PageHelper.startPage(pageNum, pageSize); List<RepairOrderVO> list = repairOrderMapper.selectPage(query); PageInfo<RepairOrderVO> pageInfo = new PageInfo<>(list);特别要注意:startPage之后如果紧跟着的不是你要分页的查询,而是另一个不同Mapper的查询,那分页就作用到错误查询上了。所以startPage和查询之间不要有任何别的MyBatis操作。reasonable=true这个参数建议打开:pageNum小于1时自动按1处理,pageNum超过总页数时按最后一页处理,能避免管理端手动输入page=999时返回空页面还报错。
表5-1 PageHelper参数调整建议
| 参数 | 推荐值 | 说明 |
|---|---|---|
| helperDialect | mysql | 指定数据库方言 |
| reasonable | true | 页码越界自动纠正 |
| pageSizeZero | false | 设为true会让pageSize=0查全部,除非有导出需求 |
5.2 导出Excel时先用GROUP BY做统计,再交给POI
管理端首页和导出报表都需要报修单统计。按状态统计可以用一条SQL:
SELECT status, COUNT(*) cnt FROM repair_order WHERE create_time BETWEEN #{start} AND #{end} GROUP BY status;按天统计近7天报修趋势:
SELECT DATE_FORMAT(create_time, '%Y-%m-%d') as day, COUNT(*) cnt FROM repair_order WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY day ORDER BY day;这两条SQL结果可以直接填充到前端图表,或者导出Excel。导出Excel建议后端用Apache POI生成xlsx,小程序端不要前端导出,因为微信环境生成文件再打开链路太绕。
5.3 分页慢时先看explain,再看索引
分页查询在前台是越查越慢,主要原因是MySQL在大offset下会扫描大量不需要的行。先跑一次EXPLAIN确认索引情况:
EXPLAIN SELECT * FROM repair_order WHERE status = 0 ORDER BY create_time DESC LIMIT 10, 10;如果type是ALL,给(status, create_time)加联合索引。如果查询里还有设备ID过滤,就把device_id放到联合索引第一列。对于报修系统这种数据量,不需要做复杂的分页优化,但千万别在status字段上建一个单独的索引,再用status IN (0,1,2)查询,那索引基本不会走。另外insert语句一定要用useGeneratedKeys="true" keyProperty="id",这样插入报修单后order对象里就有id,后续要关联repair_record时不用再查一次。配置好这些,SSM这套结构在几千条报修单量级下完全够用,不需要折腾分布式事务。
本文还有配套的精品资源,点击获取