微信小程序+SSM+MySQL报修系统联调避坑指南

发布时间:2026/10/29 1:41:01
微信小程序+SSM+MySQL报修系统联调避坑指南

简介:这是一套面向计算机专业本科生的毕业设计完整实践资源,聚焦设备故障报修场景,解决高校实验室、企业办公场所等实体单位中传统报修流程低效、信息不透明、响应滞后等问题。资源包含微信小程序前端(Vue+原生WXML/WXSS)、SSM框架Java后台(127个Java类,含Controller/Service/DAO分层实现)、MySQL数据库(含建表SQL与ER设计说明)、完整毕业论文(含可行性分析、系统设计、功能模块详述)及操作演示视频,覆盖从需求分析到部署上线的全流程。压缩包共1255个文件,49.98MB,以JS/Vue/WXML/WXSS(前端交互与视图)、Java(后端逻辑)、PNG/SVG(界面图标与截图)、JSON(配置与接口数据)为主,辅以BAT脚本(一键安装/运行)、SQL(数据库初始化)、MP4(全流程演示),结构清晰、开箱即用。目前已有131人学习下载,适合需快速掌握小程序+Java全栈开发、理解多角色权限系统(管理员/用户/维修员)与真实业务闭环的学生与初学者。

1. 这不是“又一个毕业设计”:微信小程序+SSM+MySQL 报修系统,为什么能跑通、能交付、还能真用?

你手头这份「设备故障报修小程序」毕业设计材料,表面看是套模板:微信小程序前端 + SSM(Spring + SpringMVC + MyBatis)后端 + MySQL 数据库。但真正拉开差距的,不是它用了什么技术栈,而是它必须同时扛住三类真实压力:微信生态的运行约束(比如真机扫码调试失败、登录态失效、页面白屏)、SSM 层的事务一致性(比如报修提交成功但工单没进数据库、状态更新丢失)、MySQL 的并发写入安全(比如同一台打印机被多人同时报修,重复派单)。我带过 12 届毕设,翻车最多的不是代码写不出来,而是把“能编译”当成“能交付”——小程序开发者工具里点开正常,一扫真机就 404;SSM 接口 Postman 调通了,小程序 wx.request 却拿不到数据;MySQL 建表时没设外键和索引,50 人同时报修时工单列表卡死 3 秒。这篇笔记不讲“SSM 是什么”,只拆解:怎么让这套组合在毕业答辩前一周稳定跑起来、数据不丢、接口不崩、演示视频不黑屏。适合正在赶毕设、手里有源码但卡在联调、部署或论文描述环节的本科生,也适合想快速复用该架构做轻量运维系统的校企合作工程师。


2. 小程序端:不是写页面,是驯服微信的运行时环境

微信小程序不是网页,它有自己的生命周期、网络模型和权限沙箱。直接把 Vue 项目逻辑搬过来必翻车。本项目小程序端核心任务就三个:用户扫码报修、实时查看工单状态、管理员审核派单。所有功能都得绕开微信的限制边界来设计。

2.1 页面结构与导航:顶部导航栏高度不是固定 44px,而是动态计算

微信小程序顶部导航栏(navbar)高度在不同机型、不同微信版本下差异极大。iPhone X 及以上有刘海区,安卓部分全面屏机型状态栏高度也不同。硬写height: 44px会导致内容被遮挡或留白过多。正确做法是用wx.getSystemInfoSync()动态获取:

// utils/system.js export function getSafeAreaTop() { const systemInfo = wx.getSystemInfoSync(); // 微信基础库 2.7.0+ 支持 safeArea,但需兼容旧版 if (systemInfo.safeArea && systemInfo.safeArea.top) { return systemInfo.safeArea.top; } // 降级方案:iOS 默认 44,安卓默认 20,但实际取 statusBarHeight + 44 return systemInfo.statusBarHeight ? systemInfo.statusBarHeight + 44 : 44; }

提示:getSafeAreaTop()必须在onLoad或onShow中调用,不能在data初始化时直接赋值,否则可能取到旧值。我在app.js全局挂载globalData.safeTop = getSafeAreaTop(),所有页面通过this.app.globalData.safeTop读取,避免重复计算。

2.2 网络请求:wx.request 不是 fetch,必须处理 code=0 和 code=200 的双重校验

SSM 后端返回 JSON 时,SpringMVC 默认状态码是 200,但微信小程序wx.request的res.statusCode是 HTTP 状态码,而业务状态码(如{code: 200, msg: "success", data: {...}})藏在res.data.code里。新手常犯错误:只判res.statusCode === 200就认为成功,结果后端抛异常返回500,小程序却显示“提交成功”。

// utils/request.js export function request(url, data = {}, method = 'POST') { return new Promise((resolve, reject) => { wx.request({ url: 'https://your-api-domain.com' + url, method, data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') || '' // 登录态透传 }, success: (res) => { // 第一层:HTTP 状态码校验 if (res.statusCode !== 200) { wx.showToast({ title: `网络错误 ${res.statusCode}`, icon: 'none' }); return reject(new Error(`HTTP ${res.statusCode}`)); } // 第二层:业务状态码校验(SSM 统一返回格式) const { code, msg, data: responseData } = res.data; if (code !== 200) { wx.showToast({ title: msg || '操作失败', icon: 'none' }); return reject(new Error(msg)); } resolve(responseData); }, fail: (err) => { wx.showToast({ title: '请求失败,请检查网络', icon: 'none' }); reject(err); } }); }); }

参数说明:url传/api/repair/submit这类相对路径,域名由utils/request.js统一管理,避免在每个页面硬编码;header.Authorization从本地缓存读 token,这是 SSM 后端@Authentication拦截器校验的依据;code !== 200判定逻辑必须和后端ResultUtil.success()返回结构严格对齐——这是联调第一道生死线。

2.3 列表加载更多:不是简单 append,要防重复请求和状态错乱

报修工单列表页(pages/list/list.wxml)需支持下拉刷新和上拉加载。常见翻车点:用户快速滚动触发多次onReachBottom,导致重复请求;刷新时新数据覆盖旧数据但 loading 状态未重置;分页参数pageNo和pageSize传错。

// pages/list/list.js Page({ data: { list: [], pageNo: 1, pageSize: 10, hasMore: true, isLoading: false, isRefreshing: false }, onLoad() { this.loadList(true); // 初始化加载 }, loadList(isRefresh) { if (this.data.isLoading || (!isRefresh && !this.data.hasMore)) return; this.setData({ isLoading: true, isRefreshing: isRefresh }); request('/api/repair/list', { pageNo: isRefresh ? 1 : this.data.pageNo, pageSize: this.data.pageSize }).then(res => { const { list, total } = res; const newData = isRefresh ? list : [...this.data.list, ...list]; this.setData({ list: newData, pageNo: isRefresh ? 1 : this.data.pageNo + 1, hasMore: newData.length < total, isLoading: false, isRefreshing: false }); }).catch(() => { this.setData({ isLoading: false, isRefreshing: false }); }); }, onPullDownRefresh() { this.loadList(true); }, onReachBottom() { this.loadList(false); } });

关键细节:isRefresh参数控制数据合并逻辑;hasMore判断依据是当前列表长度< total,而非list.length < pageSize(后者在最后一页可能刚好等于 pageSize);setData必须在then和catch中都调用,否则 loading 状态会卡死。


3. SSM 后端:不是搭框架,是构建事务闭环与接口契约

SSM 在这里不是炫技,而是解决三个硬问题:用户登录态鉴权、报修单创建与分配的原子性、多角色(用户/维修员/管理员)数据隔离。Spring Boot 虽热,但毕业设计用原生 SSM 更易溯源、答辩时老师更熟悉。

3.1 登录拦截器:用 Session 还是 JWT?毕业设计选 Session 更稳

微信小程序登录流程是:前端调wx.login()→ 拿code→ 传给后端 → 后端用code换openid→ 生成 sessionKey。很多同学用 JWT,结果调试时 token 过期、签名密钥泄露、刷新逻辑复杂。毕业设计阶段,用 HttpSession + Redis 存储最稳妥——Spring Session 自动集成 Redis,@Controller方法里直接HttpSession session注入即可。

// config/WebConfig.java @Configuration public class WebConfig implements WebMvcConfigurer { @Bean public CookieSameSiteSupplier sameSiteSupplier() { return request -> "Strict"; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .excludePathPatterns("/api/user/login", "/api/user/wxlogin"); } } // interceptor/LoginInterceptor.java public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(false); if (session == null || session.getAttribute("openid") == null) { response.setStatus(401); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); return false; } return true; } }

注意:excludePathPatterns必须排除/api/user/wxlogin(微信登录接口),否则拦截器会拦死首次登录;response.getWriter().write(...)直接输出 JSON,避免 Spring MVC 的@ResponseBody干预,确保小程序端能拿到标准错误结构。

3.2 报修单创建:MyBatis 的<selectKey>是事务安全的最后防线

用户提交报修单时,需同时插入repair_order表和关联的repair_image表(图片路径)。若用两条INSERT语句,中间出错会导致数据不一致。SSM 标准解法是:在repair_order插入时用<selectKey>获取自增主键,再用该主键插入子表。

<!-- mapper/RepairOrderMapper.xml --> <insert id="insertWithImages" parameterType="com.example.entity.RepairOrder"> <selectKey keyProperty="id" resultType="java.lang.Long" order="AFTER"> SELECT LAST_INSERT_ID() </selectKey> INSERT INTO repair_order (device_id, description, user_openid, status, create_time) VALUES (#{deviceId}, #{description}, #{userOpenid}, 0, NOW()) </insert>
// service/RepairOrderService.java @Transactional public void submitRepair(RepairOrder order, List<String> imageUrls) { // 1. 插入主单,获取 id repairOrderMapper.insertWithImages(order); // 2. 批量插入图片(order.getId() 已被 selectKey 赋值) if (!imageUrls.isEmpty()) { List<RepairImage> images = imageUrls.stream() .map(url -> { RepairImage img = new RepairImage(); img.setOrderId(order.getId()); img.setUrl(url); return img; }) .collect(Collectors.toList()); repairImageMapper.insertBatch(images); } }

血泪经验:<selectKey>的order="AFTER"表示在 INSERT 之后执行SELECT LAST_INSERT_ID(),这是 MySQL 特性,Oracle 需用BEFORE+ 序列;@Transactional必须加在 Service 方法上,且该方法不能是 private 或 final,否则 Spring AOP 失效;insertBatch是 MyBatis-Plus 提供的批量插入,比循环insert快 5 倍以上。

3.3 角色权限控制:用@PreAuthorize还是自定义注解?毕业设计选后者更可控

SSM 项目中,管理员可审核所有工单,维修员只能处理自己名下的,普通用户只能查自己的。Spring Security 的@PreAuthorize配置复杂,毕业答辩时容易说不清 SpEL 表达式。更推荐自定义@RequireRole注解 + AOP 切面:

// annotation/RequireRole.java @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value() default {}; } // aspect/RoleCheckAspect.java @Aspect @Component public class RoleCheckAspect { @Around("@annotation(requireRole)") public Object checkRole(ProceedingJoinPoint joinPoint, RequireRole requireRole) throws Throwable { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); String openid = (String) request.getSession().getAttribute("openid"); // 查询用户角色(从数据库或缓存) String role = userService.getRoleByOpenid(openid); if (Arrays.asList(requireRole.value()).contains(role)) { return joinPoint.proceed(); } throw new RuntimeException("权限不足"); } }
// controller/RepairOrderController.java @RestController @RequestMapping("/api/repair") public class RepairOrderController { @PostMapping("/assign") @RequireRole({"ADMIN", "MANAGER"}) public Result assign(@RequestBody AssignRequest req) { // 只有 ADMIN 和 MANAGER 能调用 repairOrderService.assignToWorker(req.getOrderId(), req.getWorkerId()); return Result.success(); } }

参数说明:@RequireRole({"ADMIN", "MANAGER"})显式声明角色,比@PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')")更直观;切面中userService.getRoleByOpenid()是从 MySQL 查user_role关联表,避免把角色硬编码在注解里;throw new RuntimeException会被全局异常处理器捕获,统一返回{"code":403,"msg":"权限不足"}。


4. MySQL 数据库:不是建表就行,是设计防并发、防误删、防查询慢的三层防护

毕业设计的 MySQL 往往被当成“存数据的地方”,但报修系统的真实瓶颈就在数据库:同一设备被多人同时报修、管理员批量修改状态、历史工单按时间范围查询。没做防护,演示时点两下就卡死。

4.1 表结构设计:外键不是可选项,是数据一致性的物理保障

很多同学为省事,repair_order表里直接存device_id字符串,不建外键。结果设备表被误删,报修单里全是脏数据。必须用外键约束 + 级联更新:

-- 设备表 CREATE TABLE `device` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '设备名称', `location` varchar(200) DEFAULT NULL COMMENT '位置', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 报修单表(带外键) CREATE TABLE `repair_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `device_id` bigint(20) NOT NULL COMMENT '设备ID', `description` text COMMENT '故障描述', `user_openid` varchar(64) NOT NULL COMMENT '用户openid', `status` tinyint(4) DEFAULT '0' COMMENT '状态:0待处理,1处理中,2已完成', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_device_id` (`device_id`), KEY `idx_user_openid` (`user_openid`), CONSTRAINT `fk_repair_device` FOREIGN KEY (`device_id`) REFERENCES `device` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

关键点:ON DELETE RESTRICT防止设备被删导致工单孤儿;ON UPDATE CASCADE当设备 ID 更新时自动同步(虽极少发生,但契约完整);KEY idx_device_id和idx_user_openid是后续查询的命脉,没索引的WHERE device_id = ?会全表扫描。

4.2 并发写入防护:UPDATE 语句必须带 WHERE 条件,且用行锁而非表锁

维修员点击“开始处理”时,后端执行UPDATE repair_order SET status = 1 WHERE id = ? AND status = 0。漏掉AND status = 0是致命错误——可能导致已处理的单被重复设置为“处理中”。

// mapper/RepairOrderMapper.java @Update("UPDATE repair_order SET status = 1, worker_openid = #{workerOpenid}, update_time = NOW() " + "WHERE id = #{id} AND status = 0") int updateStatusToProcessing(@Param("id") Long id, @Param("workerOpenid") String workerOpenid);
// service/RepairOrderService.java public boolean startProcessing(Long orderId, String workerOpenid) { int updated = repairOrderMapper.updateStatusToProcessing(orderId, workerOpenid); if (updated == 0) { // 返回 0 表示原状态不是 0,已被他人处理 throw new BusinessException("该工单已被处理,请刷新页面"); } return true; }

玄学提醒:MySQL 的UPDATE ... WHERE默认加行锁(Record Lock),但必须确保id是主键或唯一索引,否则会升级为间隙锁(Gap Lock)甚至临键锁(Next-Key Lock),引发死锁。毕业设计用id主键绝对安全。

4.3 查询性能优化:分页不是 limit M,N,是用游标分页防深分页

当工单数超 1 万条,SELECT * FROM repair_order ORDER BY create_time DESC LIMIT 10000, 10会极慢——MySQL 要先跳过 10000 行。毕业设计必须用游标分页(Cursor-based Pagination):

-- 第一页(最新10条) SELECT * FROM repair_order WHERE status IN (0,1,2) ORDER BY create_time DESC, id DESC LIMIT 10; -- 下一页(假设上一页最后一条 create_time='2024-05-20 10:00:00', id=1005) SELECT * FROM repair_order WHERE status IN (0,1,2) AND (create_time < '2024-05-20 10:00:00' OR (create_time = '2024-05-20 10:00:00' AND id < 1005)) ORDER BY create_time DESC, id DESC LIMIT 10;
// mapper/RepairOrderMapper.java @Select("<script>" + "SELECT * FROM repair_order " + "WHERE status IN " + "<foreach item='status' collection='statuses' open='(' separator=',' close=')'>" + "#{status}" + "</foreach> " + "<if test='cursorTime != null and cursorId != null'>" + "AND (create_time &lt; #{cursorTime} OR (create_time = #{cursorTime} AND id &lt; #{cursorId}))" + "</if>" + "ORDER BY create_time DESC, id DESC " + "LIMIT #{pageSize}" + "</script>") List<RepairOrder> selectByCursor(@Param("statuses") List<Integer> statuses, @Param("cursorTime") String cursorTime, @Param("cursorId") Long cursorId, @Param("pageSize") Integer pageSize);

为什么有效:游标分页利用索引(create_time, id联合索引),每次查询都从上一页末尾位置继续,时间复杂度 O(log n),而非深分页的 O(n);<foreach>动态拼接status IN,避免硬编码;&lt;是 XML 实体,防止解析错误。


5. 联调与避坑:90% 的毕设翻车,都发生在这一环

再完美的代码,联调不通就是废纸。本章直击微信小程序 + SSM + MySQL 三端联调的 5 个高频雷区,每一条都是我帮学生 debug 时记下的血泪记录。

5.1 小程序真机扫码 404:不是域名没配,是 Nginx 转发丢了 path

现象:开发者工具里wx.request正常,真机扫码打开小程序后,所有接口返回 404。
原因:Nginx 配置了proxy_pass http://localhost:8080;,但没加结尾/,导致https://your.com/api/repair/submit被转发成http://localhost:8080api/repair/submit(少了个/)。
解决:Nginx 配置必须写成proxy_pass http://localhost:8080/;(注意末尾/),或用rewrite显式重写:

location /api/ { proxy_pass http://localhost:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

5.2 SSM 接口返回中文乱码:不是 IDEA 编码,是 Tomcat 的 URI 编码没设

现象:Postman 调用http://localhost:8080/api/repair/list?deviceName=打印机正常,但小程序wx.request传deviceName=打印机,后端 Controller 参数接收为??。
原因:Tomcat 8.5+ 默认 URI 编码是 UTF-8,但微信小程序发送的 URL 编码是application/x-www-form-urlencoded,需显式指定URIEncoding="UTF-8"。
解决:修改conf/server.xml,在<Connector>标签里加URIEncoding="UTF-8":

<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" />

5.3 MySQL 插入中文变问号:不是数据库字符集,是 JDBC URL 缺少参数

现象:SSM 插入description="打印机卡纸",MySQL 里存成??????。
原因:MySQL 服务端字符集是utf8mb4,但 JDBC 连接 URL 没指定useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai。
解决:application.properties中 JDBC URL 必须完整:

spring.datasource.url=jdbc:mysql://localhost:3306/repair_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false

注意:allowPublicKeyRetrieval=true是 MySQL 8.0+ 必需参数,否则连接失败;useSSL=false在本地开发可关闭,生产环境应配 SSL。

5.4 小程序wx.login()换不到openid:不是 AppID 错,是js_code用了两次

现象:小程序调wx.login()拿到code,传给后端,后端用https://api.weixin.qq.com/sns/jscode2session换openid,返回{"errcode":40029,"errmsg":"invalid code"}。
原因:微信code一次性有效,5 分钟过期。调试时在开发者工具里点多次“重新登录”,code被重复使用。
解决:每次wx.login()成功后立即传给后端,不要缓存code;后端换openid后,必须用openid作为 Session Key,而不是code。

5.5 毕业论文里“系统测试”章节写不实:不是编数据,是用 JMeter 做真实压测

现象:论文写“系统支持 100 并发用户”,但没任何数据支撑,答辩被问“怎么测的?”当场哑火。
解决:用 JMeter 做最小化压测——不用学全功能,只跑通POST /api/repair/submit接口:

  1. 下载 JMeter,新建线程组(Number of Threads = 100,Ramp-up = 10,Loop Count = 1);
  2. 添加 HTTP 请求,路径/api/repair/submit,Body Data 写 JSON({"deviceId":1,"description":"test"});
  3. 添加 HTTP Header Manager,加Content-Type: application/json;
  4. 添加 View Results Tree 和 Summary Report;
  5. 运行,截图 Summary Report 中的Average Response Time < 500ms、90% Line < 800ms、Error % = 0。

技巧:JMeter 测试前,先用ab -n 100 -c 10 http://localhost:8080/api/repair/list做快速验证,ab(Apache Bench)比 JMeter 轻量,适合毕业设计速测。


6. 演示视频与答辩技巧:让老师一眼看懂你的工作量

毕业答辩不是代码朗诵会,是价值呈现。老师平均听每人 8 分钟,你要在 3 分钟内让他记住:你解决了什么真问题、怎么解决的、为什么这个解法合理。演示视频和论文描述必须服务于这个目标。

6.1 演示视频脚本:按“场景-操作-结果”三段式,拒绝功能罗列

别拍:“点击首页→点击报修→填写表单→提交→跳转成功页”。这叫操作录像,不是演示。改成:

  • 场景(5秒):实验室投影仪突然黑屏,学生掏出手机。
  • 操作(15秒):打开小程序 → 扫描投影仪旁的二维码(特写二维码)→ 自动带入设备 ID → 拍照上传故障图 → 提交。
  • 结果(10秒):后台管理页弹出新工单(红点提醒)→ 管理员点击“分配” → 维修员手机收到微信服务通知(展示通知栏)→ 维修员确认接单 → 工单状态变为“处理中”。

关键点:全程用真机录屏,禁用模拟器;二维码用真实设备生成(https://your.com/device/123);后台管理页用 Chrome 打开 SSM 的/admin页面,不要截图;微信服务通知必须是真机收到,证明消息推送链路通。

6.2 论文“系统实现”章节:用对比表格代替文字堆砌

老师不会逐行读代码,但会看表格。把核心模块实现方式做成对比表,一目了然:

模块技术选型为什么选它替代方案及弃用原因
用户登录微信wx.login()+ SSM Session免密码、免注册,符合校园场景;Session 比 JWT 更易调试JWT:需维护密钥、刷新逻辑复杂,毕设周期不够
图片上传小程序wx.uploadFile()+ 七牛云 SDK七牛云免费额度够用,CDN 加速快;避免自建文件服务器本地存储:需配置 Nginx 静态资源,跨域麻烦
工单分配SSM@Transactional+ MySQL 行锁保证“分配”操作原子性;行锁性能优于 Redis 分布式锁Redis 锁:引入新组件,增加部署复杂度

写作技巧:每行只写一个决策点,用“为什么选它”体现思考深度,“替代方案”展示技术权衡能力——这正是答辩时老师最想听的。

6.3 答辩话术:把“我做了”变成“我解决了”

别讲:“我用 Vue 写了小程序页面”。要说:“我发现微信小程序真机环境下 navbar 高度不一致,导致内容被遮挡。我通过wx.getSystemInfoSync()动态计算 safeAreaTop,并在全局注入,解决了 98% 机型的适配问题。”
别讲:“我用了 MyBatis”。要说:“为防止报修单创建时主子表数据不一致,我用<selectKey>获取自增 ID,并在 Service 层用@Transactional保证原子性,经 JMeter 100 并发测试,数据一致率达 100%。”

我的习惯:答辩前,把每个功能点写成“问题-解法-验证”三句话,贴在笔记本第一页。老师问到哪,就翻到哪,不背稿、不卡壳。
希望帮到你。

本文还有配套的精品资源,点击获取