ARTICLE DETAIL

资讯详情

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

基于RuoYi框架的工单管理模块开发:状态机与数据权限实战

基于RuoYi框架的工单管理模块开发:状态机与数据权限实战 1. 需求拆解工单管理模块到底要解决什么问题接到“帝可得”项目的时候我的第一反应是这名字挺有意思像是对外运营的品牌项目。落到技术上它其实是一个基于RuoYi框架二次开发的业务管理系统整体包含设备巡检、运维报修、任务分派等模块而我负责的是其中编号为3的工单管理模块。这里说的“3”不是指第三个页面而是这个模块在整体业务流中的第3个核心环节——从用户提交诉求到派单处理再到结果回执和归档。RuoYi框架在Java后端开发圈子里几乎是“管理后台标配”它的RBAC权限、代码生成、定时任务、多数据源这些基础能力非常成熟。但正因为框架本身太成熟很多人容易忽略一件更重要的事业务模块的业务流程设计尤其是工单这种强状态流的模块不能仅仅依赖框架的CRUD能力必须把流程控制、状态机、数据权限、消息通知串起来才能算一个“能真正跑起来”的模块而不是一个花架子页面。1.1 业务场景与角色划分帝可得项目的使用场景是园区运营方的日常管理。园区里有物业维修人员、设备维护人员、巡检人员还有运营管理者。工单来源大致有几类用户在App端或者管理后台手动提交的报修单、设备故障自动触发生成的告警工单、巡检人员发现问题后手动补录的整改工单。这些来源决定了工单模块必须有类型字段来区分不能一刀切处理。比如用户报修工单关注的是响应时效设备告警工单关注的是故障严重级别和影响范围整改工单则要绑定到具体的巡检任务上。角色划分也很明确提交人用户/巡检员/系统创建工单填写位置、问题描述、期望处理时间。调度员运营中心人员审核工单分派给对应的处理人或者驳回。处理人维修工/设备维护人员接单、处理、回填处理结果和耗材使用情况。审核员班组负责人验收处理结果确认工单关闭。管理员系统负责人全局查看统计分析处理僵死工单。实际开发中调度员和处理人往往是同一个角色在系统里通过按钮权限来控制操作范围。RuoYi的PreAuthorize注解在这一层非常好用可以直接限定谁能看到“派单”按钮谁能看到“回执”按钮。1.2 状态流转与操作闭环工单的生命周期我最初设计时只设了4个状态待处理、处理中、已完成、已关闭。但开发到一半就发现问题工单处理过程中如果处理人发现不是自己能力范围内的问题需要转单处理结果不合格审核员要退回用户提交后信息填错了得允许撤回。所以我们最终把状态定成了7个待审核CREATED提交人刚创建工单等待调度员审核。已派单DISPATCHED调度员已指定处理人。处理中PROCESSING处理人接单并开始处理。待验收FINISHED处理人已回填结果等待审核人验收。已退回REJECTED审核不通过退回处理人重新处理。已关闭CLOSED验收通过工单正式关闭。已撤回CANCELED提交人撤回或调度员驳回作废。这7个状态不是拍脑袋定的而是把实际操作流程中每一个“需要停顿、需要确认”的节点都拆出来了。状态太多会让人操作烦躁但状态太少会让追溯变得模糊。对工单来说追溯能力是灵魂所以宁可多一些流转节点也不能省。2. 数据库设计先把表结构立住状态流转想清楚了表结构才能定下来。这个模块我设计了3张核心业务表工单主表、工单流转日志表、工单附件表。外加2张依赖配置表工单类型字典、工单紧急级别字典。为什么强调“先设计好表再写代码”因为RuoYi的代码生成器非常强大但如果你直接用它基于一张设计不合理的表去生成代码后面改起来会非常痛苦。尤其工单表这种强业务表字段多、关联多一次没设计好后期要么加字段堆接口要么就得数据迁移。2.1 工单主表字段设计与状态冗余工单主表我命名为work_order关键字段如下字段名类型说明idBIGINT主键work_order_noVARCHAR(32)工单编号唯一titleVARCHAR(200)工单标题简要描述问题order_typeVARCHAR(2)工单类型01报修、02告警、03整改priority_levelCHAR(1)紧急级别A紧急、B高、C中、D低sourceVARCHAR(2)来源01用户提交、02系统告警、03巡检补录descriptionTEXT问题详细描述locationVARCHAR(200)位置信息冗余存储statusVARCHAR(2)当前状态creator_idBIGINT提交人IDcreator_nameVARCHAR(50)提交人姓名冗余dispatcher_idBIGINT调度员IDhandler_idBIGINT处理人IDhandler_nameVARCHAR(50)处理人姓名冗余plan_timeDATETIME期望处理截止时间begin_timeDATETIME实际开始处理时间end_timeDATETIME实际完成时间handle_resultTEXT处理结果说明audit_commentVARCHAR(500)验收意见del_flagCHAR(1)逻辑删除标记create_timeDATETIME创建时间update_timeDATETIME更新时间audit_timeDATETIME验收时间close_timeDATETIME关闭时间这里有几个字段是刻意加了冗余的比如creator_name、handler_name。如果在工单列表页每展示一条数据都要去关联用户表查一次名字数据量上到几十万条之后那条关联查询会拖慢整体列表响应。RuoYi的列表页通常使用分页查询但如果每条数据都去二次连表性能一定顶上不去。而且工单模块的数据是“历史快照型”的——我希望保存的是处理人当时的姓名而不是随着他改名或离职而被覆盖这是很重要的审计考虑。再说一个细节状态字段为什么用VARCHAR而不生用INT或枚举因为框架解析字典时RuoYi的Dict注解默认处理的是字符串类型的字典值用VARCHAR存数字字符串是最省事的做法后台字典配置也直观。虽然有些人崇尚用枚举类做强类型约束但配合RuoYi的基础配置方式VARCHAR在运维维护时成本最低。2.2 流转日志表与附件表日志表work_order_logs是整个工单追溯的核心。字段包括id、work_order_id、from_status、to_status、operator_id、operator_name、op_type操作类型创建、派单、接单、回执、退回、关闭、撤回、op_comment操作备注、create_time。我遇到过的最大教训是日志表一定不要只记录状态变化还要记录操作时的关键业务数据。比如派单时不仅要记录“从待审核变成已派单”还要记录派给了谁。所以handler_id和handler_name这两个字段在日志表里也做了冗余。这样后期出问题可以还原每次操作的完整上下文。附件表work_order_files相对简单id、work_order_id、file_name、file_url、file_size、uploader_id、upload_time。但要注意工单附件不能只存文件路径还必须存原始文件名。不然前端下载的时候只能拿到一串无法理解的/profile/upload/2025/04/12/uuid.jpg这种名字用户体验非常糟糕。3. RuoYi框架下的代码实现模块设计完成后我采用RuoYi的代码生成功能来生成基础CRUD代码。但这里必须强调生成器生成的代码只是“地基”业务状态流转逻辑必须自己手写。因为生成器生成的Service层方法是标准的增删改查它不知道你的工单什么时候该走派单逻辑什么时候又能被撤回。3.1 实体类与MyBatis映射生成的实体类WorkOrder对应数据库表这个没什么特殊之处。但我在写Mapper的时候对列表查询做了两处优化第一查询条件动态拼装时增加了状态组过滤。比如首页待办要求显示“状态为待审核和已派单的工单”如果每次都在Service层分别查出两批数据再合并既笨重又容易分页错乱。正确的做法是Mapper里用IN查询。select idselectWorkOrderList parameterTypeWorkOrder resultMapWorkOrderResult SELECT * FROM work_order where del_flag 0 if testtitle ! null and title ! AND title LIKE CONCAT(%, #{title}, %)/if if testorderType ! null and orderType ! AND order_type #{orderType}/if if teststatus ! null and status ! AND status #{status}/if if teststatusList ! null and statusList.size() 0 AND status IN foreach collectionstatusList itemitem open( separator, close) #{item} /foreach /if /where /selectstatusList在Service层根据当前用户的角色和要展示的Tab标签组动态传入这比每个状态写死一个查询方法要灵活得多。第二主表查询和日志表查询分开。不要在一条SQL里做嵌套子查询查询日志那样会把列表接口拖得非常慢。工单详情页才需要查日志列表页只查主表详情加载时再异步请求日志接口。3.2 Service层核心逻辑创建、派单、处理、关闭Service层是这段开发中最值得聊的地方。我把核心操作全部收敛成独立方法不允许Controller直接调用Mapper去改状态。先看创建工单。创建逻辑比较关键的是生成work_order_no。这个编号格式我定成了WO 日期 序列例如WO20250412001。日期用LocalDate.now()格式化序列从work_order_seq表读取。这里为什么要单独建一张序列表因为如果直接用MAX(id)1去生成序列在高并发情况下会产生重复编号。用数据库的UPDATE ... RETURNING方式或者专门建一张带FOR UPDATE的序列表才能保证编号唯一。RuoYi默认没有这种编号生成器需要自己写。Transactional(rollbackFor Exception.class) public WorkOrder createOrder(WorkOrderBo bo) { // 1. 生成编号 String orderNo generateOrderNo(); // 2. 填充创建信息 WorkOrder order new WorkOrder(); BeanUtils.copyProperties(bo, order); order.setWorkOrderNo(orderNo); order.setStatus(WorkOrderStatus.CREATED.getCode()); order.setCreatorId(SecurityUtils.getUserId()); order.setCreatorName(SecurityUtils.getUsername()); order.setDelFlag(0); // 3. 插入 workOrderMapper.insertWorkOrder(order); // 4. 记录日志 insertLog(order.getId(), null, WorkOrderStatus.CREATED.getCode(), WorkOrderLogOp.CREATE, 提交工单); return order; }注意Transactional不能少。工单创建时主表插入和日志记录必须属于同一事务。有人会为了“日志不能丢”把日志写成独立事务但那样创建的工单和日志就可能出现不一致后期排查时反而更混乱。派单逻辑要处理的细节更多。调度员在派单时可以选处理人也可以直接选择“指定班组”让班组调度自行分配。我们是简化为直接指定处理人所以派单时要校验处理人状态是否是“在职”状态、是否具备处理该类工单的技能标签。RuoYi用户表本身没有技能标签字段我们在sys_user上扩展了业务字段或者用user_id去关联一张user_skill表。我这里采用的做法是直接读取处理人的角色编码比如角色是“维修工”就允许派维修类工单是“设备工程师”就允许派告警类工单。我推荐一个简洁的校验方式在SysUser基础上增加方法通过DataScope查询sys_user_role相关角色再判断角色key是否在允许的集合内。如果角色不匹配直接抛异常。不要想着“先派出去再说”因为工单派给错误的人后面整个闭环都会被卡住。处理人接单并回填处理结果时也要补充耗时记录。比如begin_time在接单时写入end_time在回执时写入可以计算处理时长。有些系统喜欢在处理完后再往前补填接单时间但那些数据往往不准。一定不要允许处理人修改begin_time字段的该字段由系统自动写入。3.3 控制器与前端交互Controller层我尽量保持“薄”只做参数校验、权限断言、调用Service、封装返回结果PreAuthorize(ss.hasPermi(work:order:dispatch)) PutMapping(/dispatch) public AjaxResult dispatch(RequestBody WorkOrderDispatchBo bo) { workOrderService.dispatchOrder(bo.getOrderId(), bo.getHandlerId()); return success(派单成功); }路径规划上我采用的风格是RuoYi默认的RESTful加动词路径/work/order/list、/work/order/{id}、/work/order/dispatch、/work/order/finish。动词路径在工单这种状态流转明确的业务里比纯REST风格更好理解而且前端调用时不容易搞混。前端我用的是RuoYi-Vue自带的Vue2加Element UI。列表页采用“Tab 表格”的组合顶部Tab是根据当前用户角色过滤好的待办、已办、全部、退回等视图。每个Tab页绑定的查询请求会把statusList参数传给后端。前端有一个小坑Element UI的el-tabs切换时会自动重新渲染表格但如果你在el-tab-pane里直接绑定了v-loading指令切换Tab可能因为加载状态未重置导致表格一直转圈。我的解决方案是在切换Tab时显式重置表格组件的loading状态并重新请求数据。4. 权限、数据隔离与状态机控制RuoYi框架自带的权限体系很强但工单模块除了按钮权限还要做数据行级隔离。什么是数据行级隔离就是不同角色查询工单列表时看到的范围不同。调度员能看到全园区的工单处理人只能看到分派给自己的工单提交人只能看到自己提交的工单。4.1 RuoYi数据权限注解的用法RuoYi的DataScope注解是基于MyBatis拦截器实现的。它会根据当前用户的角色和数据权限范围自动在SQL上拼接过滤条件。我在工单列表查询方法上加了DataScope(deptAlias d, userAlias u) public ListWorkOrder selectWorkOrderList(WorkOrder workOrder) { return workOrderMapper.selectWorkOrderList(workOrder); }但这张表不是部门表它是业务表。直接在work_order表上加DataScope是不好用的因为数据权限默认按sys_dept的创建部门过滤而工单表没有dept_id字段。我处理的方法是工单表上增加dept_id字段派单时自动获取handler所属部门写入该字段。这样DataScope才能正确地过滤出“本部门处理人负责的工单”。对处理人自己的数据隔离不能依赖DataScope需要传参。查询列表时处理人登录后我们在Service层判断角色如果是普通处理人角色强制在workOrder.setHandlerId(SecurityUtils.getUserId())并在Mapper里加判断。还有一个容易被忽略的点工单状态查询不能越权查看退回记录。比如处理人只能看到“已派单给自己”的工单不能看到“派给别人的”工单。这不仅是业务逻辑也是数据安全问题。4.2 状态机与操作校验状态机我用了一个轻量的封装没有引入复杂的StateMachine框架。因为工单的状态数量不多流转规则清晰自己写一个校验方法更直接private static final MapString, SetString TRANSITIONS new HashMap(); static { TRANSITIONS.put(WorkOrderStatus.CREATED.getCode(), new HashSet(Arrays.asList(WorkOrderStatus.DISPATCHED.getCode()))); TRANSITIONS.put(WorkOrderStatus.DISPATCHED.getCode(), new HashSet(Arrays.asList(WorkOrderStatus.PROCESSING.getCode()))); TRANSITIONS.put(WorkOrderStatus.PROCESSING.getCode(), new HashSet(Arrays.asList(WorkOrderStatus.FINISHED.getCode()))); TRANSITIONS.put(WorkOrderStatus.FINISHED.getCode(), new HashSet(Arrays.asList(WorkOrderStatus.CLOSED.getCode(), WorkOrderStatus.REJECTED.getCode()))); } private void checkTransition(String fromStatus, String toStatus) { SetString allowed TRANSITIONS.get(fromStatus); if (allowed null || !allowed.contains(toStatus)) { throw new ServiceException(非法状态流转 fromStatus - toStatus); } }每次状态变更前先调用checkTransition比在每一个Service方法里手写一堆if (status.equals(01) !toStatus.equals(02))要清晰得多。将来如果要增加状态只要改这张Map表和一个方法即可。然后每个Service方法内部还要校验操作者的身份。比如接单时只有当前登录用户是工单的handlerId才能调用接单接口。验收时只有审核员角色才能调用验收接口。RuoYi的SecurityUtils.getUserId()可以很方便地拿到当前登录用户ID。4.3 消息通知与异步处理工单状态变化后需要通知相关人员。最初我没做通知后来发现业务方总是需要人工刷新页面才知道有新的工单体验很差。RuoYi自带的消息模块比较基础我是基于AsyncTask异步发送的站内信加企业微信机器人通知。这里一定要用异步处理。因为如果在派单操作的事务里直接发送HTTP请求到企业微信机器人一旦机器人接口响应慢或超时派单接口也会跟着变慢前端会一直转圈。我把通知逻辑放到了事务提交后的异步任务里用Spring的Async注解Async(notificationExecutor) public void sendNotify(WorkOrder order, String action) { // 查收件人 // 发站内信 // 调企业微信机器人webhook }为了达到这个效果我配置了一个独立的线程池避免异步任务占用主业务线程池。线程池的核心参数经验值是corePoolSize4、maxPoolSize8、queueCapacity200。通知任务都是轻量级的IO操作这个规模足够用了。5. 实战中遇到的坑与排查记录这部分应该是大家最感兴趣的。工单模块开发过程中我踩了不少坑有些是RuoYi特有的有些是通用Java开发中容易忽略的问题。我整理成一份速查表附上排查思路。5.1 事务失效与主从延迟现象创建工单后立即去列表查询结果查不到刚创建的工单。排查后发现不是插入失败而是因为数据库主从架构中写操作在主库读操作走了从库主从复制有延迟。解决方案有两个层面一是缩短主从延迟时间间隔配置秒级同步二是在关键业务链路上强制走主库。RuoYi默认的连接池配置是HikariCP我可以为读操作单独配置一个从库数据源。但对于工单这种强一致性要求的模块我建议直接对创建后的立即查询场景走主库不要让业务去容忍主从延迟。另一个更隐蔽的问题是事务失效。我在WorkOrderService里写了一个dispatchOrder方法它内部先更新主表状态然后调用insertLog。因为两个方法在同一个类中如果insertLog也加了Transactional这个注解根本不会生效——Spring的事务注解默认只对public方法且是通过代理对象调用时生效类内部自调用时事务不生效。解决办法有两种拆分成两个ServiceWorkOrderService和WorkOrderLogService或者只在入口方法上标注Transactional让日志方法作为普通方法参与同一事务。5.2 时间字段与前端格式化前端展示工单时间时出现了时间差8小时的问题。排查后确定是JSON序列化时区导致的。RuoYi默认使用Jacksonapplication.yml里配置了时区spring: jackson: time-zone: GMT8 date-format: yyyy-MM-dd HH:mm:ss但我在创建工单时使用的是LocalDateTime.now()然后前端接收到的时间字符串是正常的。问题出在另一个接口上查询工单详情时我返回的是MapString, Object其中时间字段是通过DateUtil.format()手动格式化的而这里我传的Date对象其实是带了时区的Timestamp转成字符串后跟前端默认显示格式不一致。最终我在所有涉及时间返回的地方统一用DateUtil格式化后返回字符串不让前端自行格式化数据库原始时间值。这样虽然牺牲了一点灵活性但避免了时区切换和时间格式混乱这种低级别问题。5.3 字典翻译失效工单的状态、类型字段我都配置了RuoYi字典列表页期望显示的是“待审核”“已派单”等中文标签而不是“01”“02”这种code。用RuoYi的Excel和Dict注解后列表页正常但导出Excel时出现了字典未翻译的问题。排查方式先看导出工具类ExcelUtil是否读到了Dict(dictType work_order_status)。后来发现我在VO类上漏写了Excel(name 状态, dictType work_order_status)这一注解导出代码只识别注解里的字典配置。补上之后导出正常。另外要注意RuoYi的字典数据是缓存在JVM内存中的如果修改了sys_dict_data表的数据需要清除缓存。后台的字典管理页面里“刷新缓存”按钮是给管理员用的开发阶段测试时可以直接调用/system/dict/data/refreshCache接口刷新不然改字典值不会生效会出现页面上看到“待审核”但导出出来是“01”的情况。5.4 分页插件冲突还有一次排查分页问题工单列表在前端点击下一页时返回的数据还是第一页的内容。排查后发现是Mapper里的selectWorkOrderList用了自定义的if动态SQL而RuoYi的PageHelper在解析时把ORDER BY子句放在了错误的拼接位置导致分页SQL的LIMIT没有被正确带上。其实这个问题的根本原因是PageHelper对多表关联和动态SQL支持不太好。工单主表的查询不算复杂但确实存在多表LEFT JOIN的情况。我建议对于有JOIN的复杂查询不使用PageHelper自动分页改用手动拼接LIMIT或者将主查询拆成子查询再去分页。这样最稳妥也方便控制SQL执行计划。举个例子如果我要查询“工单处理人姓名类型名称”可以直接写子查询SELECT * FROM ( SELECT o.*, u.nick_name AS handler_nick FROM work_order o LEFT JOIN sys_user u ON o.handler_id u.user_id WHERE o.del_flag 0 ) t ORDER BY t.create_time DESC LIMIT #{offset}, #{pageSize}手动分页虽然多写一点代码但可控性明显提升。6. 测试与上线部署模块开发完成后测试环节也不能掉以轻心。工单模块涉及的流程长、角色多、状态多变必须要准备一套完整的测试用例。6.1 单元测试重点我在Service层重点测试了以下场景创建工单时编号生成是否唯一。并发创建100条工单检查work_order_no是否有重复。派单时传入不存在的handlerId是否抛出正常异常信息。接单时非当前处理人调用接口是否被拦截。退回后重新流转到处理中流程是否通畅。所有状态跳跃的非法流转如从“待审核”直接变成“已关闭”是否被状态机拦截。测试框架用的是Spring Boot Test Mockito。关键点不要在测试用例里直接操作生产数据库一定要用独立测试库并且测试完成后清理数据保证测试重复执行稳定。6.2 生产环境配置上线前有几处配置需要根据环境调整第一server.servlet.encoding.force要设置为true确保请求和响应都使用UTF-8。工单描述里经常有用户输入的换行符、特殊符号编码不正确会导致数据乱码或截断。第二上传文件大小限制要调整。RuoYi默认的spring.servlet.multipart.max-file-size是10MB对于工单附件可能不够。我给到50MB单个100MB单次请求并配置了白名单后缀防止用户上传非法文件类型。第三操作日志保留策略。工单日志表会随着时间不断膨胀虽然RuoYi本身有sys_oper_log操作日志表但工单业务日志是核心审计数据不能随便清理。我设置了一个定时任务每季度将超过一年的日志归档到历史表保持主表查询效率。我自己在实际开发中最大的体会就是业务模块开发要先通业务流程再写代码不要一上来就对着页面画原型。工单管理模块看起来只是一个状态流转的管理界面但它背后的角色权限、数据隔离、日志追溯、异常处理每一项都比CRUD本身复杂得多。如果你也正在用RuoYi框架做类似的项目建议从状态机设计开始入手把流程走通了代码实现就会顺畅很多。最后分享一个小技巧开发阶段保存一份完整的工单状态流转表上面标注每个状态的进入条件、操作角色、退出动作并把它贴到项目文档里。这个表既是后端开发的实现依据也是前端设计页面的逻辑参考同时还是后期和业务方沟通的统一语言。项目上线后这份文档的价值甚至比代码注释还要大。如果你在RuoYi工单模块开发中遇到了别的问题或者有什么更好的方案欢迎交流互相补坑。
返回列表