ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

微信小程序设备故障报修管理系统:状态机驱动的工单全流程设计

微信小程序设备故障报修管理系统:状态机驱动的工单全流程设计 简介一份基于微信小程序设备故障报修管理系统的设计与实现论文面向计算机相关专业的学生、软件开发人员及需要构建报修类微信小程序的开发者提供完整参考。论文从研究背景、研究目的入手完成系统可行性分析与需求分析并重点展开系统功能设计、数据库设计内容涉及管理员、用户、维修员等三类角色的业务逻辑包括实验室管理、经验分享、报修信息提交、维修报告处理、维修结果查询等关键模块。后台开发基于Java SSM框架数据库采用MySQL小程序端使用微信开发者工具体现了前后端分离与移动端应用的实际落地。资源为单个Word文档大小1.12MB包含完整论文正文已有102人学习下载。通过阅读该论文读者能够清晰理解设备故障报修管理系统的结构设计、业务流程与开发要点适合作为毕业设计、课程报告或项目开发前期的优秀参考资料。1. 微信小程序设备故障报修管理系统先把工单主线想清楚设备故障报修管理系统做出来后最直接的改变是报修人不用再在微信群里喊话而是打开微信小程序扫码提交故障从发生、派单、接单、维修到验收每一步都有状态、有记录、有人负责。这正是这类“设计与实现”项目真正要回答的核心问题也是它区别于普通表单应用的地方。这套系统通常由小程序端、服务端、管理后台三块组成真正的主线是报修单状态机谁在什么条件下能把工单从当前状态推到下一状态。我一般会先把状态流转表钉死再写页面和后端接口前后端代码只是顺着这张表长出来。标题里的 .docx 只是存档载体交付物应该是能跑的代码加一份能讲清设计理由的文档。下文按一条可复现的路径展开先给数据模型和接口设计再分别实现小程序端和服务端最后处理 SLA 超时升级、离线补单这类上线才暴露的细节。拿这个题目做毕业设计的学生或者想用小程序替换微信群报修的 IT 运维都能直接照着搭。2. 报修工单状态机、数据表与 code 换 token 鉴权链路先给报修单定义状态再谈页面这是设计部分的重心。我一般会把状态收敛成一张表后端只认状态码前端拿到状态码再映射文案和按钮。以下是常见做法里的六态设计状态码状态名触发人合法去向0待派单用户提交后自动进入1、51待接单调度员派单2、52维修中维修员接单33待验收维修员提交完工4、54已完成报修人确认无5已关闭报修人取消或驳回无这张表有两个关键约定待验收是用户侧的终态确认维修员提交完工后不能直接置已完成取消动作只在待派单和待接单两个状态允许一旦进入维修中就只能走完验收流程。这样设计是为了在验收环节给报修人一个否决权——修得好不好不能由维修员自己说了算。2.1 六态状态机用 Java 迁移表收拢所有流转判断public enum RepairOrderState { PENDING_DISPATCH(0, 待派单), PENDING_ACCEPT(1, 待接单), REPAIRING(2, 维修中), PENDING_CHECK(3, 待验收), COMPLETED(4, 已完成), CLOSED(5, 已关闭); private final int code; private final String desc; private static final MapInteger, SetInteger LEGAL_TRANSITIONS new HashMap() {{ put(0, Set.of(1, 5)); put(1, Set.of(2, 5)); put(2, Set.of(3)); put(3, Set.of(4, 5)); put(4, Set.of()); put(5, Set.of()); }}; public static boolean canTransit(int from, int to) { return LEGAL_TRANSITIONS.getOrDefault(from, Set.of()).contains(to); } }这段代码用一张迁移表和canTransit方法收拢所有合法性判断。各业务接口在处理前统一调用canTransit(current, target)非法流转直接返回 4xx而不是在 service 里层层写 if。控制点在状态码而不是角色这样以后加“管理员代录报修”之类的角色时只改迁移表不碰业务逻辑。如果状态数量继续膨胀或者每个状态都有独立的进入、退出动作再升级成设计模式中的状态模式每个状态一个类把行为内聚到类里。对报修系统这个规模枚举加迁移表已经足够过度设计反而让状态机难以维护。2.2 设备台账与报修单的建表 SQLCREATE TABLE device ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_code VARCHAR(32) NOT NULL UNIQUE COMMENT 设备编号, device_name VARCHAR(64) NOT NULL COMMENT 设备名称, category VARCHAR(32) COMMENT 设备分类, location VARCHAR(128) COMMENT 安装位置, status TINYINT DEFAULT 1 COMMENT 1在用 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE repair_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 工单号, client_no VARCHAR(64) NOT NULL COMMENT 客户端幂等号, device_id BIGINT NOT NULL, reporter_id BIGINT NOT NULL COMMENT 报修人, assignee_id BIGINT DEFAULT NULL COMMENT 接单维修员, fault_type TINYINT COMMENT 0机械 1电气 2软件 3其他, fault_desc VARCHAR(500) COMMENT 故障描述, image_urls VARCHAR(1000) COMMENT 图片URL逗号分隔, priority TINYINT DEFAULT 1 COMMENT 0紧急 1高 2中 3低, state TINYINT DEFAULT 0 COMMENT 状态码见状态机, accept_time DATETIME DEFAULT NULL, finish_time DATETIME DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_client_no (client_no), KEY idx_state (state), KEY idx_device (device_id) ); CREATE TABLE repair_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, operator_id BIGINT NOT NULL, action VARCHAR(32) COMMENT DISPATCH/ACCEPT/COMPLETE/REJECT, remark VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );几个字段的取值要提前定死前后端才能对齐字段取值说明priority0 紧急 / 1 高 / 2 中 / 3 低决定 SLA 时限和派单顺序fault_type0 机械 / 1 电气 / 2 软件 / 3 其他决定派给哪个技能组state0~5与状态机一一对应client_no前端生成的 UUID服务端唯一索引防重复提交order_no和client_no都建唯一索引但职责不同order_no是给人看的工单编号client_no是给系统防重用的幂等号。维修历史单独存repair_log所有状态流转都要写一条日志后面排查“谁把工单改错了”全靠这张表不要省。2.3 登录鉴权小程序 code 换 token 的标准链路小程序端wx.login()拿到临时 code后端拿 code 调微信的jscode2session接口换 openid 和 session_key。openid 是用户在小程序内的唯一标识服务端拿它建用户记录然后签发自己的 token后续请求都带 token不再频繁调微信接口。RestController RequestMapping(/api/auth) public class AuthController { PostMapping(/login) public Result login(RequestBody LoginReq req) { // 1. code 换 openidsession_key 留存但不下发前端 String openid wxService.code2Session(req.getCode()).getOpenid(); // 2. 查本地用户表不存在则自动建档 User user userService.findOrCreateByOpenid(openid); // 3. 签发自己的 tokenRedis 存 2 小时过期 String token tokenService.issue(user.getId(), 2 * 60 * 60); return Result.ok(new LoginResp(token, user)); } }注意 session_key 只在换取 openid 时使用不要返回前端。Token 过期后小程序要能静默重新wx.login所以前端请求层要统一处理 401收到 401 就重走登录流程再重放原请求不能让用户手动退出重进。对外暴露的接口按资源划分保持一套约定接口方法关键参数说明/api/auth/loginPOSTcode登录换 token/api/device/listGETkeyword报修时选设备/api/order/createPOSTdeviceId, faultType, faultDesc, images, clientNo提交报修/api/order/listGETstate, pageNum, pageSize分页查工单/api/order/acceptPOSTorderId维修员接单/api/order/completePOSTorderId, resultDesc提交完工/api/order/checkPOSTorderId, score报修人验收分页参数统一pageNum从 1 开始pageSize默认 10最大 20。状态筛选用state-1表示“全部”避免为了一个“全部”选项单开接口。3. 微信小程序端报修表单、图片上传与顶部导航适配小程序端承担录入和查询两个主要场景报修人填表提交维修员看单接单。这一章给出页面结构和关键交互所有片段都按原生小程序语法写不依赖第三方框架。3.1 报修表单设备 picker 与故障类型单选框view classsection text故障设备/text picker modeselector range{{deviceNames}} bindchangeonDeviceChange view classpicker-value{{deviceNames[deviceIndex] || 请选择设备}}/view /picker /view view classsection text故障类型/text radio-group bindchangeonTypeChange label wx:for{{typeList}} wx:keyvalue radio value{{item.value}} checked{{item.checked}} / {{item.name}} /label /radio-group /view view classsection textarea value{{faultDesc}} bindinputonDescInput maxlength200 placeholder描述故障现象尽量写清楚影响范围/textarea /view button bindtapsubmitOrder提交报修/buttonPage({ data: { deviceNames: [], deviceIndex: -1, typeList: [ { name: 机械, value: 0 }, { name: 电气, value: 1 }, { name: 软件, value: 2 }, { name: 其他, value: 3 } ] }, onLoad() { wx.request({ url: ${baseUrl}/api/device/list, success: (res) { this.setData({ deviceNames: res.data.data.map(d d.deviceName) }); } }); }, submitOrder() { if (this.data.deviceIndex 0) { wx.showToast({ title: 请选择设备, icon: none }); return; } // clientNo 用 uuid 前端生成服务端按它幂等 wx.request({ method: POST, url: ${baseUrl}/api/order/create, header: { Authorization: Bearer ${getToken()} }, data: { deviceId: this.data.deviceList[this.data.deviceIndex].id, faultType: this.data.faultType, faultDesc: this.data.faultDesc, clientNo: uuid() }, success: (res) { if (res.data.code 0) { wx.redirectTo({ url: /pages/order/detail?id res.data.data.id }); } } }); } });picker的range只放名称数组提交时用索引回查deviceList再取 id不要在界面上把 id 和名称混在一起展示。radio-group的单选框必须写value否则取不到值四项固定就用静态数组不需要从后端拉。表单提交前只做必填校验故障描述这类文本让用户尽量写清楚现象但不要把校验规则卡得太死。3.2 图片采集、wx.uploadFile 上传与 USER_DATA_PATH 本地落盘图片是故障描述的重要补充常见做法是限制最多 9 张采集走wx.chooseMedia它比旧接口wx.chooseImage更稳且支持sizeType在压缩图和原图之间选择。chooseImages() { const remain 9 - this.data.imageUrls.length; if (remain 0) return; wx.chooseMedia({ count: remain, mediaType: [image], sourceType: [camera, album], sizeType: [compressed], success: (res) { res.tempFiles.forEach((f) this.uploadImage(f.tempFilePath)); } }); } uploadImage(tempFilePath) { wx.uploadFile({ url: ${baseUrl}/api/file/upload, filePath: tempFilePath, name: file, formData: { bizType: repair }, header: { Authorization: Bearer ${getToken()} }, success: (res) { // res.data 是字符串要先 parse const { url } JSON.parse(res.data); this.setData({ imageUrls: this.data.imageUrls.concat(url) }); } }); }url对应后端接收文件的字段名formData放业务标记header里带 token因为文件接口同样需要鉴权。wx.uploadFile的 success 回调里res.data是字符串直接拿来当对象用会报 undefined这是最常见的坑。上报之前的图片可以先用 FileSystemManager 落一份到本地私有目录const fs wx.getFileSystemManager(); fs.copyFileSync(tempFilePath, ${wx.env.USER_DATA_PATH}/repair_images/${Date.now()}.jpg);wx.env.USER_DATA_PATH是小程序私有目录卸载小程序会一起清掉适合放报修草稿和待补传的附件不适合放需要长期保留的业务数据。弱网环境直接走“先落盘、后补传”的流程比在内存里吊着失败任务更稳。3.3 顶部导航栏高度计算与底部安全区适配用了自定义导航后首要问题是算准导航栏高度。不同机型状态栏高度不同不能写死。标准算法是把状态栏高度与胶囊按钮位置结合起来const windowInfo wx.getWindowInfo(); const menu wx.getMenuButtonBoundingClientRect(); const statusBarHeight windowInfo.statusBarHeight; const navBarHeight (menu.top - statusBarHeight) * 2 menu.height; this.setData({ statusBarHeight, navBarHeight });各变量的含义如下变量来源说明statusBarHeightwx.getWindowInfo()状态栏高度单位 pxmenu.topwx.getMenuButtonBoundingClientRect()胶囊按钮顶部离屏幕顶部距离navBarHeight计算得出自定义导航栏总高度公式的思路是menu.top - statusBarHeight是胶囊按钮与状态栏的间距导航栏上下各留一份这个间距再加上胶囊自身高度就是导航栏该占的高度。页面 json 里把navigationStyle: custom配置好再在 wxss 里给底部操作栏加安全区适配避免 iPhone 的 Home Indicator 挡住按钮.safe-bottom { padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); }3.4 报修列表下拉刷新、触底分页与状态筛选列表页是维修员的主战场常见的组合是顶部状态筛选加下拉刷新加触底分页。页面 json 里先开两个开关enablePullDownRefresh: true、onReachBottomDistance: 50。Page({ data: { orders: [], page: 1, pageSize: 10, hasMore: true, activeState: -1 }, loadOrders(reset false) { if (!reset !this.data.hasMore) return; const pageNum reset ? 1 : this.data.page 1; wx.request({ url: ${baseUrl}/api/order/list, data: { state: this.data.activeState, pageNum, pageSize: this.data.pageSize }, header: { Authorization: Bearer ${getToken()} }, success: (res) { const list res.data.data.list; this.setData({ orders: reset ? list : this.data.orders.concat(list), page: pageNum, hasMore: list.length this.data.pageSize, loading: false }); wx.stopPullDownRefresh(); } }); }, onPullDownRefresh() { this.loadOrders(true); }, onReachBottom() { this.loadOrders(false); } });hasMore的判断用“返回条数等于 pageSize”而不是后端传 total因为列表接口为了性能常常不查 count这种判断对滚动加载足够。切状态筛选时调用loadOrders(true)同时把 page 重置回 1否则会出现在第二页数据上叠加第一页的脏数据。4. 服务端实现工单分派、订阅消息与并发兜底服务端不只是 CRUD真正的难点在工单分派、消息触达和并发接单。这一章按 Spring Boot 的常见组织方式展开持久层用 MyBatis-Plus 简化样板代码。4.1 Spring Boot 分层与分页查询的参数约定RestController RequestMapping(/api/order) public class OrderController { GetMapping(/list) public ResultPageOrderVO list( RequestParam(defaultValue -1) Integer state, RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize) { // state -1 表示全部否则按状态精确过滤 return Result.ok(orderService.queryPage(state, pageNum, pageSize)); } }public PageOrderVO queryPage(Integer state, Integer pageNum, Integer pageSize) { LambdaQueryWrapperRepairOrder qw new LambdaQueryWrapper(); qw.eq(state ! null state 0, RepairOrder::getState, state) .orderByDesc(RepairOrder::getCreateTime); PageRepairOrder page orderMapper.selectPage(new Page(pageNum, pageSize), qw); return fillVO(page); // 组装设备名、维修员姓名等冗余字段 }eq的第一个条件参数为 false 时整条条件自动跳过这是 MyBatis-Plus 处理可选参数的标准姿势。分页用Page构造器不需要手写 limit 拼串天然规避字符串拼接导致的注入问题。fillVO里要避免逐条查设备表一次in查出设备信息后分组组装。4.2 派单策略技能组匹配加最少待办数派单看似简单随机分给在线维修员会出大问题。常见做法是两层过滤先按fault_type匹配技能组再在组内按待办数升序取人超过上限的跳过。public boolean dispatch(Long orderId) { RepairOrder order orderMapper.selectById(orderId); ListRepairer candidates repairerMapper.selectBySkillGroup(order.getFaultType()); candidates.sort(Comparator.comparingInt(Repairer::getPendingCount)); for (Repairer r : candidates) { if (r.getPendingCount() MAX_PENDING) continue; // 超过 5 单不再派 int rows orderMapper.dispatchWithLock(orderId, r.getId()); if (rows 1) { notifySubscribe(r.getOpenid(), 派单通知, order.getOrderNo()); return true; } } return false; // 无人可派留在待派单队列等兜底任务 }MAX_PENDING设 5 是比较保守的阈值车间规模大可以放宽到 8。排序权重可以调比如把“今天已接单数”按 0.5 加权进去但不要一开始就上复杂算法最少待办数在绝大多数场景下已经够用。dispatchWithLock是关键下一节展开它的 SQL 写法。4.3 并发接单与乐观锁更新两个维修员同时点接单必须在数据库层面保证只有一个人成功。乐观锁用一条带状态条件的 update 实现UPDATE repair_order SET state 2, assignee_id #{workerId}, accept_time NOW() WHERE id #{orderId} AND state 1 AND assignee_id IS NULL受影响行数为 1 才算抢单成功为 0 说明工单已经被别人接走前端提示“工单已接”。这里不要先查再改查改之间必然有空窗。配合一个兜底定时任务把超时未接的工单自动释放回待派单池Scheduled(fixedDelay 60_000, initialDelay 30_000) public void releaseTimeoutOrders() { // 10 分钟未接自动释放时长做成配置项 ListRepairOrder orders orderMapper.listTimeoutUnaccepted(10); for (RepairOrder order : orders) { orderMapper.releaseOrder(order.getId()); // state 回到 0assignee_id 置空 repairLogMapper.insert(Log.of(order.getId(), SYSTEM, RELEASE, 超时未接单自动释放)); reDispatch(order.getId()); } }定时任务用fixedDelay而不是fixedRate避免上一轮还没跑完下一轮就挤进来。释放动作必须写repair_log否则工单状态莫名其妙回退排查时没有依据。4.4 订阅消息一次性订阅与返回码的坑进度触达靠订阅消息。它和模板消息最大的区别是一次订阅只能推一条用户点几次授权就推几条所以按钮文案要写清楚“订阅维修进度通知”。小程序端在提交报修时顺手请求订阅wx.requestSubscribeMessage({ tmplIds: [模板管理里申请的模板 ID], success(res) { if (res.errMsg ! requestSubscribeMessage:ok) { console.warn(用户拒绝订阅后续走站内消息兜底); } } });服务端推送时统一走一个封装方法public void sendSubscribe(String openid, String templateId, Long orderId, MapString, Object data) { JSONObject body new JSONObject(); body.put(touser, openid); body.put(template_id, templateId); body.put(page, pages/order/detail?id orderId); body.put(data, data); // access_token 提前缓存在 Redis7200 秒过期过期前 5 分钟刷新 String url https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token getAccessToken(); JSONObject resp HttpUtil.postJson(url, body); if (47003.equals(resp.getString(errcode))) { // 参数与模板字段类型不匹配多数是 thing 超长或字段名写错 log.warn(subscribe send failed: {}, resp); } }模板内容区的字段有严格约束常见限制如下字段类型长度限制常见错误thing20 个字符内设备名写太长被截断phrase5 个汉字内状态文案超长time24 小时制格式不是 yyyy-MM-dd HH:mmcharacter_string32 位工单号里带了特殊符号返回 43101 表示用户取消了订阅这种不能重试要转成站内消息列表兜底保证用户不订阅也能在“消息中心”看到进度。5. 报修 SLA 超时升级、离线补单与上线清单系统跑起来之后真正决定口碑的是两类细节响应慢不慢、没网时能不能用。这一章给出 SLA 升级规则、离线补单和上线前检查清单都是可以抄走直接用的收尾技巧。5.1 四档优先级的超时升级规则SLA 建议按优先级给响应时限超时后自动升级而不是一直挂着优先级响应时限超时动作0 紧急10 分钟直接通知设备主管1 高30 分钟通知维修组长2 中2 小时系统压力提示3 低8 小时只记日志不打扰Scheduled(fixedDelay 300_000) public void slaEscalation() { ListRepairOrder expired orderMapper.listSlaExpired(); for (RepairOrder o : expired) { // 超过时限仍处于 0 或 1 状态说明没人接住 orderMapper.raisePriority(o.getId()); repairLogMapper.insert(Log.of(o.getId(), SYSTEM, ESCALATE, SLA 超时升级)); managerNotifier.notify(o.getId(), SLA 超时, o.getPriority()); } }扫描任务每 5 分钟跑一次即可SLA 本身是分钟级指标不需要秒级精确。升级动作要落日志方便统计“哪些设备经常超时”反推设备台账里的维护计划。5.2 弱网场景的离线补单与 client_no 幂等车间里经常有信号死角提交报修时网络不可达工单不能丢。做法是把报修数据先存进本地队列网络恢复后按顺序补传服务端靠client_no去重ALTER TABLE repair_order ADD UNIQUE KEY uk_client_no (client_no);flushPendingOrders() { const queue wx.getStorageSync(pendingOrders) || []; const remain []; queue.forEach((item) { wx.request({ method: POST, url: ${baseUrl}/api/order/create, data: item, // 包含 clientNo success: () { /* 补传成功留在 remain 之外 */ }, fail: () { remain.push(item); } }); }); wx.setStorageSync(pendingOrders, remain); }注意不要在遍历时直接splice数组用重新收集剩余项的方式更安全。服务端收到重复的client_no时直接返回已有工单而不是报错这样前端补传不会产生重复数据。触发补传的时机用wx.onNetworkStatusChange监听网络恢复同时onShow时也跑一次双保险。5.3 上线前走查清单检查项做法登录态过期请求层统一处理 401静默重新 login 后重放请求图片体积chooseMedia 用 compressed后端限制单张 5MB订阅消息失败43101 转站内消息47003 查字段格式状态机非法流转单元测试覆盖 12 条合法路径和 6 条非法路径时间字段数据库存 UTC接口返回本地时间并带时区日志留痕关键操作写 repair_log接口日志带 orderId 和 openid把这 6 项全部过一遍再拿第二章的状态机迁移表当评审材料把 5 条非法流转路径写进单元测试跑完再动页面。本文还有配套的精品资源点击获取
返回列表