
简介一套基于微信小程序与SSM框架SpringSpring MVCMyBatis完成的设备故障报修管理系统项目源码面向计算机相关专业毕业设计、课程设计以及需要快速搭建内部报修平台的技术开发者。系统覆盖设备台账管理、故障单提交、维修任务分派、进度实时跟踪、历史记录统计、消息提醒和角色权限控制等核心业务采用微信小程序作为员工端、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框架是因为大量存量系统的实际代码就是SpringSpringMVCMyBatis这套框架在“表单流程统计”项目里非常好写Spring管业务对象SpringMVC收请求MyBatis绑定SQL。下面围绕报修单从用户提交、维修工接单到消息通知的完整链路把图片上传、状态机和分页操作写具体。适合做微信小程序毕业设计的人也适合企业后勤想把维修流程线上化的工程师参考。2. SSM工程结构、报修数据表与小程序端目录设计2.1 为什么报修系统要拆成SpringSpringMVCMyBatis三层我一般会把后端按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 报修单核心字段字段类型说明idbigint报修单主键order_novarchar(32)业务编号展示给用户device_idbigint设备iddevice_namevarchar(64)设备名称快照reporter_idbigint报修人idassignee_idbigint接单维修工iddescriptiontext故障描述imagesvarchar(1000)图片URL逗号分隔prioritytinyint1低、2中、3高statustinyint0待接单、1处理中、2已完成、3已评价create_timedatetime报修时间finish_timedatetime完成时间建表SQL中要把order_no设置成唯一索引status和reporter_id建普通索引。order_no生成方式常见是“BXyyyyMMdd三位序号”在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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里使用utf8mb4是为了兼容小程序端提交的表情符号尤其是报修备注里有人会打emoji。如果用了utf8插入时报错“Incorrect string value”那多半就是字符集问题。MyBatis的Mapper.xml里在插入时要注意useGeneratedKeystrue keyPropertyid这样插入后就能直接拿到自增id不用再查一次。2.3 小程序端目录与后端包结构对齐避免逻辑散落小程序端如果直接用原生微信小程序开发目录可以分成pages/login、pages/repair、pages/list、pages/detail再单独用utils/request.js封装wx.request。如果用uni-app开发页面结构也是一样的区别是wx.uploadFile要换成uni.uploadFilewx.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和mapperspring-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更直观。我在管理端列表里一般会用动态SQLselect idselectPage resultTypecom.example.repair.vo.RepairOrderVO SELECT * FROM repair_order where if teststatus ! null AND status #{status} /if if testreporterId ! null AND reporter_id #{reporterId} /if /where ORDER BY create_time DESC /select这里用where标签自动处理AND前缀不需要在每条SQL前面手写11。查询参数用状态、报修人、时间范围分别对应小程序端“我的报修”和管理端“按状态筛选”。如果参数再复杂再考虑用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 上传参数建议值参数建议值说明count9 - imageList.length剩余可选照片数mediaType[image]只选图片不选视频sizeType[compressed]用压缩图减少上传时长maxUploadSize5242880单张不超过5MB3.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 idmultipartResolver classorg.springframework.web.multipart.commons.CommonsMultipartResolver property namemaxUploadSize value5242880/ property namedefaultEncoding valueUTF-8/ /beanmaxUploadSize这里是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要做的事很少真正的逻辑在ServiceService 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那两个人都会查到status0最后都会执行成功单被接两次。常见做法是把update语句本身作为校验条件update idaccept UPDATE repair_order SET status 1, assignee_id #{assigneeId} WHERE id #{id} AND status 0 /updateService里这样调用int rows repairOrderMapper.accept(orderId, operatorId); if (rows 0) { throw new BizException(该报修单已被接单); }update影响行数为0时说明当前状态已经不是0直接拒绝。这种CAS思路比给表加锁更轻量因为在Spring的Transactional里你select到的旧值可能被其他事务隔离而update的where条件能保证最终一致性。评价操作同理把where改成status2防止用户重复评价。还有一点接单时要把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 idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nameplugins array bean classcom.github.pagehelper.PageInterceptor property nameproperties props prop keyhelperDialectmysql/prop prop keyreasonabletrue/prop /props /property /bean /array /property /beanService里这样写PageHelper.startPage(pageNum, pageSize); ListRepairOrderVO list repairOrderMapper.selectPage(query); PageInfoRepairOrderVO pageInfo new PageInfo(list);特别要注意startPage之后如果紧跟着的不是你要分页的查询而是另一个不同Mapper的查询那分页就作用到错误查询上了。所以startPage和查询之间不要有任何别的MyBatis操作。reasonabletrue这个参数建议打开pageNum小于1时自动按1处理pageNum超过总页数时按最后一页处理能避免管理端手动输入page999时返回空页面还报错。表5-1 PageHelper参数调整建议参数推荐值说明helperDialectmysql指定数据库方言reasonabletrue页码越界自动纠正pageSizeZerofalse设为true会让pageSize0查全部除非有导出需求5.2 导出Excel时先用GROUP BY做统计再交给POI管理端首页和导出报表都需要报修单统计。按状态统计可以用一条SQLSELECT 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语句一定要用useGeneratedKeystrue keyPropertyid这样插入报修单后order对象里就有id后续要关联repair_record时不用再查一次。配置好这些SSM这套结构在几千条报修单量级下完全够用不需要折腾分布式事务。本文还有配套的精品资源点击获取