设备坏了找不到人修、报修流程靠口头传达、维修进度无法跟踪,这类问题在教学楼和实验室里其实非常常见。本文基于微信小程序 + Spring Boot 技术栈,完整实现一个教学设备报修系统,覆盖需求分析、表结构设计、后端接口开发、小程序端页面搭建、消息通知以及上线排错全流程。系统采用原生微信小程序语法开发,后端使用 Spring Boot 2.x 与 MyBatis Plus,整体结构清晰、代码可直接复用,既能作为毕业设计参考,也能作为实际项目的初始版本。
1. 教学设备报修系统的需求与整体设计
在开始写代码之前,先弄清楚系统到底要解决什么问题。只有把业务流程理清楚,后端的表结构和接口设计才会有依据。
1.1 传统设备报修方式的问题
在很多学校场景中,设备报修仍然依赖以下几种方式:
- 电话联系管理员,管理员手工登记信息。
- 填写纸质报修单,流转效率低。
- 通过微信群消息报修,消息容易被刷掉,维修结果无从追踪。
这些方式的共同问题是:报修信息不完整、维修人员响应慢、维修进度不透明、设备历史记录无归档。设备状态和使用寿命无法形成数据资产,后续做设备维保统计分析时也没有数据支撑。
1.2 系统需要覆盖的角色
一个完整的教学设备报修系统至少要包含三类角色:
| 角色 | 核心诉求 | 主要操作 |
|---|---|---|
| 学生/教师 | 快速发起报修、查看处理进度 | 选择设备、填写故障描述、提交报修单、查看本人记录 |
| 维修人员 | 及时接收任务、更新处理结果 | 查看待处理工单、接单、填写处理意见、标记完成 |
| 系统管理员 | 掌握全局情况、分配任务、统计分析 | 管理设备、管理用户、分配维修人员、查看统计报表 |
1.3 系统核心流程
整个报修流程可以用一组状态来描述:
待处理(0) -> 处理中(1) -> 已完成(2) | | | +--> 已驳回(3) +-> 已驳回(3)- 用户进入小程序,通过微信授权登录。
- 用户选择故障设备,填写故障描述和照片,提交报修单。
- 管理员或维修人员看到待处理的报修单,更新状态为“处理中”。
- 维修完成后,更新状态为“已完成”,并填写处理说明。
- 用户随时在报修记录列表查看最新状态。
1.4 技术选型说明
这里采用的技术方案在校园类项目和毕设项目中非常常见,技术成熟、参考资料多、开发效率高:
- 小程序端:原生微信小程序,使用 WXML、WXSS、JavaScript。
- 后端:Spring Boot,简化配置,内置 Tomcat,方便快速部署。
- ORM:MyBatis Plus,代码量少,分页和条件构造器都很方便。
- 数据库:MySQL,存储用户、设备、报修单、处理记录等数据。
- 工具:微信开发者工具、IDEA、Navicat、Postman。
2. 环境准备与项目结构
2.1 环境版本
版本需要根据你的实际环境调整,下面给出的是常见组合:
- JDK 1.8 或 11
- Spring Boot 2.3.x 或 2.7.x
- MySQL 5.7 或 8.0
- MyBatis Plus 3.4.x
- Maven 3.6+
- 微信开发者工具(稳定版即可)
- 一个已注册的小程序 AppID(测试阶段可用测试号)
2.2 后端项目目录结构
建议按下面的包结构组织代码,避免所有类堆在一个包下面:
src/main/java/com/example/repair/ ├── RepairApplication.java ├── common/ │ ├── Result.java │ ├── PageResult.java │ └── GlobalExceptionHandler.java ├── config/ │ └── MybatisPlusConfig.java ├── controller/ │ ├── UserController.java │ ├── DeviceController.java │ ├── RepairOrderController.java │ ├── WechatController.java │ └── UploadController.java ├── entity/ │ ├── SysUser.java │ ├── Device.java │ ├── RepairOrder.java │ └── RepairLog.java ├── mapper/ │ ├── SysUserMapper.java │ ├── DeviceMapper.java │ ├── RepairOrderMapper.java │ └── RepairLogMapper.java ├── service/ │ ├── UserService.java │ ├── DeviceService.java │ ├── RepairOrderService.java │ └── WechatService.java └── service/impl/ └── RepairOrderServiceImpl.java2.3 小程序项目目录结构
miniprogram/ ├── app.js ├── app.json ├── app.wxss ├── utils/ │ ├── request.js │ └── auth.js ├── pages/ │ ├── index/ // 首页,展示设备分类和快捷入口 │ ├── report/ // 发起报修页 │ ├── records/ // 报修记录列表 │ ├── detail/ // 报修详情页 │ └── mine/ // 个人中心3. 数据库表结构设计
数据库设计是整个系统的地基。字段和索引设计不合理,后面写接口时就会不断返工。
3.1 用户表 sys_user
用户表主要保存微信用户的 openid、昵称、头像以及角色信息。注意 openid 是微信体系下用户的唯一标识,必须加唯一索引。
CREATE TABLE `sys_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `openid` VARCHAR(64) DEFAULT NULL COMMENT '微信openid', `nickname` VARCHAR(64) DEFAULT NULL COMMENT '昵称', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像地址', `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', `role` TINYINT NOT NULL DEFAULT 1 COMMENT '角色:1-普通用户,2-维修人员,3-管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';3.2 设备表 device
设备表记录资产位置、名称、分类和当前状态。设备状态和设备报修状态是两个不同概念,需要区分开。
CREATE TABLE `device` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `device_code` VARCHAR(64) DEFAULT NULL COMMENT '设备编号', `device_name` VARCHAR(128) NOT NULL COMMENT '设备名称', `category` VARCHAR(64) DEFAULT NULL COMMENT '设备分类,如投影仪、电脑、空调', `location` VARCHAR(128) DEFAULT NULL COMMENT '存放位置,如A栋201', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '设备状态:0-正常,1-维修中,2-报废', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_category` (`category`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备表';3.3 报修单表 repair_order
报修单是系统的核心业务表。为了查询方便,这里冗余了设备名称、报修人等信息,避免每次关联多张表。
CREATE TABLE `repair_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `repair_no` VARCHAR(32) NOT NULL COMMENT '报修单号', `device_id` BIGINT NOT NULL COMMENT '设备ID', `device_name` VARCHAR(128) DEFAULT NULL COMMENT '设备名称(冗余)', `location` VARCHAR(128) DEFAULT NULL COMMENT '设备位置(冗余)', `reporter_id` BIGINT NOT NULL COMMENT '报修人ID', `reporter_name` VARCHAR(64) DEFAULT NULL COMMENT '报修人姓名(冗余)', `reporter_phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', `description` TEXT COMMENT '故障描述', `images` VARCHAR(1000) DEFAULT NULL COMMENT '图片地址,多个用逗号分隔', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待处理,1-处理中,2-已完成,3-已驳回', `priority` TINYINT NOT NULL DEFAULT 2 COMMENT '优先级:1-紧急,2-普通', `assignee_id` BIGINT DEFAULT NULL COMMENT '维修人员ID', `assignee_name` VARCHAR(64) DEFAULT NULL COMMENT '维修人员姓名', `handle_remark` VARCHAR(500) DEFAULT NULL COMMENT '处理说明', `handle_time` DATETIME DEFAULT NULL COMMENT '处理完成时间', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_repair_no` (`repair_no`), KEY `idx_reporter` (`reporter_id`), KEY `idx_status` (`status`), KEY `idx_assignee` (`assignee_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报修单表';3.4 处理记录表 repair_log
每一次状态变更都写入日志,方便后续追溯“谁在什么时间做了什么操作”。
CREATE TABLE `repair_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `order_id` BIGINT NOT NULL COMMENT '报修单ID', `operator_id` BIGINT DEFAULT NULL COMMENT '操作人ID', `operator_name` VARCHAR(64) DEFAULT NULL COMMENT '操作人姓名', `action` VARCHAR(32) DEFAULT NULL COMMENT '动作:CREATE/ASSIGN/PROCESS/FINISH/REJECT', `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报修处理日志表';4. Spring Boot 后端核心接口实现
后端的职责是提供小程序端需要的全部接口。下面重点讲解登录、报修单创建、状态流转、列表分页和统计接口。
4.1 微信登录接口获取 openid
小程序端通过wx.login获取临时 code,后端拿 code 向微信接口换取 openid。真实的 appid 和 secret 要从微信公众平台获取,生产环境不要硬编码在代码里,建议放到配置文件或环境变量中。
// 文件路径:src/main/java/com/example/repair/controller/WechatController.java @RestController @RequestMapping("/api/wechat") public class WechatController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody LoginRequest request) { // 1. 使用 code 调用微信接口,获取 openid String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId + "&secret=" + appSecret + "&js_code=" + request.getCode() + "&grant_type=authorization_code"; RestTemplate restTemplate = new RestTemplate(); String response = restTemplate.getForObject(url, String.class); JSONObject json = JSON.parseObject(response); String openid = json.getString("openid"); // 2. 根据 openid 查找用户,不存在则自动注册 SysUser user = userService.findOrCreateUser(openid); return Result.success(user); } }这里有几个注意点:
- code 是一次性的,五分钟内有效,只能使用一次。
- 换取的 session_key 在小程序端有时需要用来解密手机号等敏感信息。
- 后端返回给前端的不建议直接传 openid,更推荐的做法是返回你自己生成的 token,后续请求通过 token 识别用户。
4.2 创建报修单接口
创建报修单是整个系统中最核心的接口。需要注意的是:设备状态要同步更新为“维修中”,同时要写入一条日志记录,方便追踪整个流程。
// 文件路径:src/main/java/com/example/repair/service/impl/RepairOrderServiceImpl.java @Override @Transactional(rollbackFor = Exception.class) public RepairOrder createOrder(RepairOrderCreateRequest request, Long reporterId) { // 1. 校验设备是否存在 Device device = deviceMapper.selectById(request.getDeviceId()); if (device == null) { throw new BusinessException("设备不存在"); } // 2. 生成报修单号,规则:日期 + 随机数 String repairNo = "BX" + DateUtil.format(new Date(), "yyyyMMddHHmmss") + RandomUtil.randomNumbers(4); // 3. 组装报修单 RepairOrder order = new RepairOrder(); order.setRepairNo(repairNo); order.setDeviceId(device.getId()); order.setDeviceName(device.getDeviceName()); order.setLocation(device.getLocation()); order.setReporterId(reporterId); order.setDescription(request.getDescription()); order.setImages(request.getImages()); order.setPriority(request.getPriority()); order.setStatus(0); // 待处理 repairOrderMapper.insert(order); // 4. 同步更新设备状态为维修中 device.setStatus(1); deviceMapper.updateById(device); // 5. 写入操作日志 RepairLog log = new RepairLog(); log.setOrderId(order.getId()); log.setAction("CREATE"); log.setRemark("用户提交报修单"); repairLogMapper.insert(log); return order; }这段代码使用了@Transactional事务注解,保证报修单、设备状态、日志三步操作要么全部成功,要么全部回滚,避免出现数据不一致。
4.3 报修单状态流转接口
状态流转接口是系统权限控制的关键点。普通用户只能提交报修,不能随意修改状态;维修人员可以将待处理改成处理中或已完成;管理员可以分配维修人员,也可以驳回报修单。
// 文件路径:src/main/java/com/example/repair/service/impl/RepairOrderServiceImpl.java @Override @Transactional(rollbackFor = Exception.class) public void updateStatus(RepairOrderStatusRequest request, Long operatorId, Integer operatorRole) { RepairOrder order = repairOrderMapper.selectById(request.getOrderId()); if (order == null) { throw new BusinessException("报修单不存在"); } // 状态机校验:只允许合法的状态流转 int currentStatus = order.getStatus(); int targetStatus = request.getTargetStatus(); if (!canTransit(currentStatus, targetStatus, operatorRole)) { throw new BusinessException("当前状态不允许该操作"); } if ("FINISH".equals(request.getAction())) { order.setStatus(2); order.setHandleRemark(request.getHandleRemark()); order.setHandleTime(new Date()); // 同步恢复设备正常状态 Device device = deviceMapper.selectById(order.getDeviceId()); if (device != null) { device.setStatus(0); deviceMapper.updateById(device); } } else if ("PROCESS".equals(request.getAction())) { order.setStatus(1); order.setAssigneeId(operatorId); order.setAssigneeName(operatorName); } else if ("REJECT".equals(request.getAction())) { order.setStatus(3); order.setHandleRemark(request.getHandleRemark()); } repairOrderMapper.updateById(order); // 写入日志 RepairLog log = new RepairLog(); log.setOrderId(order.getId()); log.setOperatorId(operatorId); log.setAction(request.getAction()); log.setRemark(request.getHandleRemark()); repairLogMapper.insert(log); }状态机校验可以抽成一个独立方法,避免散落在业务代码中。下面是一个简单的状态流转表:
| 当前状态 | 允许操作 | 目标状态 | 允许角色 |
|---|---|---|---|
| 待处理(0) | PROCESS | 处理中(1) | 维修人员、管理员 |
| 待处理(0) | REJECT | 已驳回(3) | 管理员 |
| 处理中(1) | FINISH | 已完成(2) | 维修人员、管理员 |
4.4 报修单分页列表接口
列表接口需要支持按用户查询、按状态筛选和分页。MyBatis Plus 提供的分页插件非常方便。
// 文件路径:src/main/java/com/example/repair/controller/RepairOrderController.java @GetMapping("/list") public Result list( @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) Integer status, @RequestParam(required = false) Long reporterId, @RequestParam(required = false) Long assigneeId) { Page<RepairOrder> pageParam = new Page<>(page, size); LambdaQueryWrapper<RepairOrder> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(status != null, RepairOrder::getStatus, status); wrapper.eq(reporterId != null, RepairOrder::getReporterId, reporterId); wrapper.eq(assigneeId != null, RepairOrder::getAssigneeId, assigneeId); wrapper.orderByDesc(RepairOrder::getCreateTime); Page<RepairOrder> result = repairOrderMapper.selectPage(pageParam, wrapper); return Result.success(result); }分页接口在设计时要把查询条件作为可选参数,而不是每个条件都写一个接口。这样可以减少接口数量,也方便前端组合筛选。
4.5 报修统计接口
统计接口服务于管理员首页,展示待处理数量、处理中数量、已完成数量和总报修数量,帮助管理层快速掌握当前设备维修态势。
@GetMapping("/statistics") public Result statistics() { Map<String, Object> result = new HashMap<>(); result.put("pending", repairOrderMapper.selectCount( new LambdaQueryWrapper<RepairOrder>().eq(RepairOrder::getStatus, 0))); result.put("processing", repairOrderMapper.selectCount( new LambdaQueryWrapper<RepairOrder>().eq(RepairOrder::getStatus, 1))); result.put("finished", repairOrderMapper.selectCount( new LambdaQueryWrapper<RepairOrder>().eq(RepairOrder::getStatus, 2))); result.put("total", repairOrderMapper.selectCount(null)); return Result.success(result); }5. 微信小程序端完整实现
后端接口就绪后,开始编写小程序端页面。下面会给出关键页面的核心代码,完整项目可以在此基础上继续扩展。
5.1 全局配置 app.json
小程序的全局配置里,最核心的是页面路径、窗口样式和请求域名设置。开发阶段可以在开发者工具中勾选“不校验合法域名”,但真机预览和发布时必须配置合法域名。
{ "pages": [ "pages/index/index", "pages/report/report", "pages/records/records", "pages/detail/detail", "pages/mine/mine" ], "window": { "backgroundTextStyle": "light", "navigationBarBackgroundColor": "#4A90D9", "navigationBarTitleText": "教学设备报修", "navigationBarTextStyle": "white" }, "tabBar": { "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/records/records", "text": "报修记录" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] }, "style": "v2", "sitemapLocation": "sitemap.json" }5.2 封装请求工具 request.js
所有请求都应该走统一的 request 封装,统一处理 baseURL、token、错误提示和加载状态。
// 文件路径:utils/request.js const BASE_URL = 'https://your-server-domain.com/api' function request(url, method = 'GET', data = {}) { return new Promise((resolve, reject) => { const token = wx.getStorageSync('token') wx.request({ url: BASE_URL + url, method: method, data: data, header: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, success: (res) => { if (res.statusCode === 200) { resolve(res.data) } else if (res.statusCode === 401) { wx.showToast({ title: '登录已过期,请重新登录', icon: 'none' }) // 跳转登录逻辑 } else { wx.showToast({ title: res.data.message || '请求失败', icon: 'none' }) reject(res) } }, fail: (err) => { wx.showToast({ title: '网络异常,请稍后重试', icon: 'none' }) reject(err) } }) }) } module.exports = { request, BASE_URL }5.3 登录逻辑实现
微信官方已调整了用户头像昵称的获取规则,目前更推荐使用“头像昵称填写能力”,由用户主动点击填写。下面代码演示了登录流程的核心逻辑。
// 文件路径:utils/auth.js function login() { return new Promise((resolve, reject) => { wx.login({ success: async (res) => { if (res.code) { try { const response = await request('/wechat/login', 'POST', { code: res.code }) if (response.code === 200) { const user = response.data wx.setStorageSync('userInfo', user) resolve(user) } else { reject(new Error('登录失败')) } } catch (e) { reject(e) } } else { reject(new Error('wx.login 获取 code 失败')) } } }) }) } module.exports = { login }5.4 首页与设备分类展示
首页主要承担三个功能:展示设备分类、展示待处理统计、提供快捷报修入口。这里以设备分类展示为例,通过wx.cloud或后端接口获取分类数据后渲染到页面。
<!-- 文件路径:pages/index/index.wxml --> <view class="index-container"> <view class="stat-card"> <view class="stat-item" wx:for="{{statistics}}" wx:key="name"> <text class="stat-num">{{item.value}}</text> <text class="stat-label">{{item.name}}</text> </view> </view> <view class="category-section"> <view class="section-title">设备分类</view> <view class="category-grid"> <view class="category-item" wx:for="{{categories}}" wx:key="id" bindtap="selectCategory" >// 文件路径:pages/index/index.js const { request } = require('../../utils/request') const { login } = require('../../utils/auth') Page({ data: { categories: [], statistics: [] }, async onShow() { const userInfo = wx.getStorageSync('userInfo') if (!userInfo) { await login() } this.loadCategories() this.loadStatistics() }, async loadCategories() { const res = await request('/device/categories', 'GET') if (res.code === 200) { this.setData({ categories: res.data }) } }, async loadStatistics() { const res = await request('/repair/statistics', 'GET') if (res.code === 200) { const data = res.data this.setData({ statistics: [ { name: '待处理', value: data.pending }, { name: '处理中', value: data.processing }, { name: '已完成', value: data.finished }, { name: '总数', value: data.total } ] }) } }, goReport() { wx.switchTab({ url: '/pages/report/report' }) } })5.5 报修表单页面
报修表单是整个系统交互最复杂的页面,包括设备选择、故障描述、图片上传、联系方式填写。图片上传使用wx.chooseMedia,需要注意临时文件路径与正式上传路径的区别。
<!-- 文件路径:pages/report/report.wxml --> <view class="form-container"> <view class="form-item"> <text class="label">设备名称</text> <picker mode="selector" range="{{deviceNames}}" bindchange="onDeviceChange"> <view class="picker-value">{{selectedDeviceName || '请选择设备'}}</view> </picker> </view> <view class="form-item"> <text class="label">故障描述</text> <textarea placeholder="请详细描述设备故障现象" bindinput="onDescriptionInput" /> </view> <view class="form-item"> <text class="label">故障图片</text> <view class="upload-area"> <view class="upload-item" wx:for="{{images}}" wx:key="*this"> <image src="{{item}}" mode="aspectFill" bindtap="previewImage">// 文件路径:pages/report/report.js const { request, BASE_URL } = require('../../utils/request') Page({ data: { deviceList: [], deviceNames: [], selectedDeviceId: null, selectedDeviceName: '', description: '', phone: '', images: [] }, async onLoad() { const res = await request('/device/list', 'GET') const deviceList = res.data this.setData({ deviceList, deviceNames: deviceList.map(item => item.deviceName) }) }, chooseImage() { const remain = 3 - this.data.images.length wx.chooseMedia({ count: remain, mediaType: ['image'], sourceType: ['album', 'camera'], success: (res) => { const tempFiles = res.tempFiles.map(item => item.tempFilePath) this.setData({ images: this.data.images.concat(tempFiles) }) } }) }, async submitOrder() { const { selectedDeviceId, description, phone, images } = this.data if (!selectedDeviceId) { wx.showToast({ title: '请选择设备', icon: 'none' }) return } if (!description.trim()) { wx.showToast({ title: '请填写故障描述', icon: 'none' }) return } wx.showLoading({ title: '提交中...' }) // 1. 先上传图片,得到可访问的 URL const uploadTasks = images.map((filePath) => this.uploadImage(filePath)) const imageUrls = await Promise.all(uploadTasks) // 2. 提交报修单 const res = await request('/repair/create', 'POST', { deviceId: selectedDeviceId, description: description, phone: phone, images: imageUrls.join(',') }) wx.hideLoading() if (res.code === 200) { wx.showToast({ title: '提交成功', icon: 'success' }) setTimeout(() => { wx.switchTab({ url: '/pages/records/records' }) }, 1500) } }, uploadImage(filePath) { return new Promise((resolve, reject) => { wx.uploadFile({ url: BASE_URL + '/upload', filePath: filePath, name: 'file', success: (res) => { const data = JSON.parse(res.data) if (data.code === 200) { resolve(data.data.url) } else { reject(new Error('上传失败')) } }, fail: reject }) }) } })5.6 报修记录列表与会话消息
报修记录页面需要支持按状态切换查看,使用wx:for渲染列表数据,下拉刷新重新加载最新状态。
// 文件路径:pages/records/records.js const { request } = require('../../utils/request') Page({ data: { activeTab: 0, tabs: ['全部', '待处理', '处理中', '已完成', '已驳回'], statusMap: [null, 0, 1, 2, 3], list: [], page: 1, size: 10, loading: false, hasMore: true }, onShow() { this.refreshList() }, onPullDownRefresh() { this.refreshList() }, onReachBottom() { if (this.data.hasMore) { this.loadList() } }, async refreshList() { this.setData({ page: 1, list: [], hasMore: true }) await this.loadList() wx.stopPullDownRefresh() }, async loadList() { if (this.data.loading || !this.data.hasMore) return this.setData({ loading: true }) const status = this.data.statusMap[this.data.activeTab] const res = await request('/repair/list', 'GET', { page: this.data.page, size: this.data.size, status: status }) const newList = this.data.page === 1 ? res.data.records : this.data.list.concat(res.data.records) this.setData({ list: newList, page: this.data.page + 1, hasMore: newList.length < res.data.total, loading: false }) }, switchTab(e) { const index = e.currentTarget.dataset.index this.setData({ activeTab: index }) this.refreshList() } })5.7 订阅消息通知关键步骤
维修进度提醒可以通过微信订阅消息实现,整体流程如下:
- 在微信公众平台申请“报修进度通知”模板,拿到模板 ID。
- 小程序端在用户提交报修单成功后,调用
wx.requestSubscribeMessage请求用户授权订阅。 - 后端在状态流转时,调用订阅消息发送接口通知用户。
小程序端订阅授权示例:
wx.requestSubscribeMessage({ tmplIds: ['替换成你的模板ID'], success(res) { // res['替换成你的模板ID'] === 'accept' 表示用户同意 console.log('订阅结果:', res) }, fail(err) { console.log('订阅失败:', err) } })后端发送订阅消息时,需要先获取 access_token,再调用subscribeMessage.send接口。注意用户同意一次订阅,后端只能发送一条消息,无法反复通知,所以订阅消息授权必须放在关键动作之后,确保尽量触发一次有效发送。
6. 运行验证与联调排错
系统开发完成后,需要在小程序开发者工具中完成全流程联调。按照下面的验证清单逐项检查,可以快速定位问题。
6.1 功能验证清单
| 验证项 | 操作步骤 | 预期结果 |
|---|---|---|
| 登录 | 打开小程序,自动触发 wx.login | 后端返回用户信息,本地存储成功 |
| 设备列表 | 首页进入报修页 | 能正常加载设备分类和设备列表 |
| 发起报修 | 选择设备、填写描述、上传图片、点击提交 | 页面提示提交成功,记录出现在“报修记录”列表 |
| 状态流转 | 管理员/维修人员操作报修单状态 | 用户列表状态同步更新,设备状态恢复 |
| 权限控制 | 使用普通用户账号操作驳回或分配 | 接口提示无权限 |
| 消息通知 | 提交报修并订阅 | 维修完成后收到服务通知 |
6.2 常见问题表格
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| wx.login 获取 code 失败 | 小程序没有正确配置 AppID | 检查 AppID 是否填写,测试号也可以使用 |
| 请求接口报 401 | 未携带 token 或 token 过期 | 检查请求封装中的 header,统一处理 401 跳转登录 |
| 真机预览请求失败 | 未配置合法域名,或服务器未启用 HTTPS | 后端配置 HTTPS 证书,小程序后台添加 request 合法域名 |
| 报修单提交后设备状态未更新 | 事务没有生效,或 updateById 使用错误 | 检查 Service 方法是否添加 @Transactional,检查设备 ID 是否正确 |
| 上传图片失败 | 上传接口路径错误或服务器目录无写权限 | 检查 uploadFile 的 url,确认服务器上传目录存在且有写权限 |
| 订阅消息发送失败 | 模板 ID 错误,或用户未授权 | 核对模板 ID,检查 access_token 是否过期,确认用户是否已取消订阅 |
| 模拟器中正常但真机异常 | 开发者工具开启了“不校验合法域名”,真机没有 | 在小程序管理后台配置合法域名,官方要求必须 HTTPS |
6.3 后端排查建议
后端联调时遇到问题,先看日志,再看数据库,最后看接口文档。推荐在 application.yml 中开启 SQL 日志,便于定位 SQL 执行问题。
logging: level: com.example.repair.mapper: debug开启 SQL 日志后,控制台会打印 MyBatis 执行的每条 SQL 语句,能帮助快速定位查询条件写错、字段映射失败等问题。
7. 工程落地中的最佳实践与扩展建议
实际项目中,如果只是把功能跑通,离“可以上线”的标准还有距离。下面几个方向的工程化实践非常关键。
7.1 状态机与业务流程固化
报修单的状态变更如果不做校验,很容易出现“已完成又变回待处理”之类的非法操作。建议在 Service 层建立状态流转表,每次变更都校验操作角色和目标状态。可以考虑将状态机抽成独立的枚举类,后续项目复用性更好。
// 文件路径:src/main/java/com/example/repair/common/OrderStatus.java public enum OrderStatus { PENDING(0, "待处理"), PROCESSING(1, "处理中"), FINISHED(2, "已完成"), REJECTED(3, "已驳回"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }状态机能够防止业务逻辑在迭代过程中被破坏,越是多人协作的项目越需要把状态定义收敛到一处。
7.2 接口安全与权限校验
目前示例代码中只做了简单的逻辑判断。生产环境中必须有完整权限体系,推荐使用 Spring Boot 拦截器或 Spring Security 做接口级权限控制。
- 报修单操作权限:用户只能操作自己的报修单。
- 管理员权限:设备管理、用户管理、状态驳回等接口只对管理员开放。
- 登录态校验:所有
/api/**接口都校验 token 有效性。
7.3 日志与监控
报修系统虽然业务简单,但涉及线下维修,周期性长,日志尤其重要。建议记录:
- 用户登录日志:openid、登录时间、IP。
- 操作日志:谁在什么时间对哪张报修单做了什么操作。
- 异常日志:接口异常时,记录完整堆栈和请求参数。
7.4 数据库索引与性能优化
当报修单数量增长到十万级以上时,一定要关注索引设计。当前核心查询基本都围绕 reporter_id、status、assignee_id 三个字段过滤,这三个字段已经在建表 SQL 中加入了索引。如果有按时间范围查询的需求,建议增加(status, create_time)联合索引。
7.5 后续可扩展功能
- 消息通知优化:除了订阅消息外,可以增加短信或邮件通知。
- 设备二维码:为每一台设备生成独立二维码,用户扫码直接进入报修页并自动携带设备 ID。
- 备件库存管理:维修人员领用备件时自动扣减库存。
- 数据看板:在管理端增加维修耗时、设备故障率、分类维修次数等图表统计。
- 维修评价:用户确认完成后,可以对维修服务进行评价。
7.6 上线发布注意事项
- 小程序发布前必须配置合法域名,且在“小程序后台 → 开发 → 开发设置 → 服务器域名”中添加 request 合法域名和 uploadFile 合法域名。
- 版本发布要经过体验版测试,使用不同型号手机进行真机验证,重点检查图片上传和订阅消息。
- 首次发布前可在开发者工具中开启“代码质量”扫描,清理无用代码和未使用的资源文件。
- 上线后注意观察服务器日志和数据库增长,定期备份数据。
教学设备报修系统的核心不只是“报修+维修”两个动作,而是一整套设备全生命周期管理能力。把报修单状态流转设计清楚,把权限边界划分清楚,把日志记录沉淀下来,系统的价值会远远超过一个简单的表单提交工具。开发过程中如果遇到问题,优先从前端请求参数、后端日志、数据库状态三个环节去排查,大多数问题都能在这个链路里找到答案。