ARTICLE DETAIL

资讯详情

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

基于若依框架的工单管理模块设计与状态机流转实践

基于若依框架的工单管理模块设计与状态机流转实践 做若依(RuoYi)项目做到第三个模块工单管理这种活儿说难不算难说简单也真不简单。很多人拿到这种模块第一反应是“不就是一张表配个增删改查”等真正做进去才发现工作量最大的根本不是CRUD而是状态流转怎么设计、权限怎么收敛、处理记录怎么留痕、流程怎么跟业务对得上。这篇就以我自己在帝可得项目里落地工单管理模块的完整过程为例把从表设计到接口实现、从前端页面到权限控制的整套思路和踩坑记录都捋一遍给正在做若依框架、或者正在纠结工单模块怎么设计的Java开发一个可以直接“抄作业”的参考。帝可得本身是一个偏本地生活服务方向的运营平台工单管理这个模块承载的不只是客服反馈还涵盖了配送异常、商家投诉、设备报修好几类业务场景。所以这个模块的定位不是做一个简单的“意见箱”而是要能承接多来源、多类型的工单并且支持从创建、分配到处理、回访、关闭的完整闭环。1. 项目背景与工单管理模块的整体定位先说清楚为什么工单管理会作为一个独立模块被拉出来做。帝可得平台上线后用户侧、运力侧、商家侧都会产生各种各样的反馈和异常比如用户点外卖东西少了、骑手配送超时了、商家出餐有问题、甚至运维那边充电柜设备故障了。这些诉求如果散落在各个业务系统里没人跟进也没人负责最后都会变成客诉。工单模块存在的意义就是把所有“需要有人来处理”的事情统一收口用一套标准化的流程去驱动处理人跟进、记录处理过程、沉淀处理结果。它不是业务系统的替代品而是业务系统之外的一层“协同调度层”。这一点想清楚了后面很多设计决策就顺了。在若依框架里做这个模块本质上是把一个垂直业务域的增删改查嫁接到若依已经提供好的权限模型、登录体系、操作日志、代码生成这些基础设施上。你可以把若依当成一个已经装修好的毛坯房基础的水电改造权限、登录、日志、字典都到位了工单模块要做的就是把这套房子的功能分区设计好再把家具填充进去。当时我和产品过了好几轮需求最后确认了几个核心诉求多种来源的工单都可以进入统一列表不同类型的工单可以有不同的处理流程。工单必须有明确的负责人责任到人不能出现“工单飘在空中”的情况。每一个工单的处理过程必须留痕谁创建、谁接单、谁处理、谁关闭全都得有记录。操作权限要严格区分普通用户只能提交和查看自己的工单客服可以派单处理人可以处理管理员可以查看全部。状态字段不能是一个简单的字符串必须是一个可流转的状态机非法跳转要能被拦截。有了这些前提我开始动手设计表结构和状态流转方案。2. 数据模型设计与状态机方案2.1 工单主表字段设计工单表是整个模块的核心几乎所有逻辑都围绕这张表展开。在设计字段时我反复提醒自己工单表不是业务详情表它记录的是一个“待办事项”的元信息具体的业务细节应该放到扩展表或者关联业务表里去不要一股脑全塞进来。主表字段设计如下CREATE TABLE t_work_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, order_no VARCHAR(32) NOT NULL COMMENT 工单编号, order_type TINYINT NOT NULL COMMENT 工单类型(1用户反馈 2配送异常 3商家投诉 4设备报修), title VARCHAR(200) NOT NULL COMMENT 工单标题, content TEXT COMMENT 工单内容详细描述, priority TINYINT NOT NULL DEFAULT 2 COMMENT 优先级(1高 2中 3低), source TINYINT NOT NULL DEFAULT 1 COMMENT 来源(1用户端 2管理端 3系统自动), status TINYINT NOT NULL DEFAULT 0 COMMENT 状态(0待分配 1处理中 2待确认 3已关闭 4已驳回), relation_id BIGINT COMMENT 关联业务ID(如订单ID、设备ID), relation_type TINYINT COMMENT 关联业务类型, assignee_id BIGINT COMMENT 处理人ID, assignee_name VARCHAR(64) COMMENT 处理人姓名, creator_id BIGINT NOT NULL COMMENT 创建人ID, handle_start_time DATETIME COMMENT 开始处理时间, handle_end_time DATETIME COMMENT 处理完成时间, close_time DATETIME COMMENT 关闭时间, create_time DATETIME NOT NULL COMMENT 创建时间, update_time DATETIME NOT NULL COMMENT 更新时间, del_flag CHAR(1) NOT NULL DEFAULT 0 COMMENT 删除标志(0存在 2删除), PRIMARY KEY (id), KEY idx_order_no (order_no), KEY idx_status_assignee (status, assignee_id), KEY idx_creator (creator_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT工单主表;关于字段设计有几个点特别想单独说一下。工单编号我采用的是“前缀 日期 随机数”的方式格式类似WO202406130001或者WO20240613A3F2。很多新手在做这种编号时会直接走数据库自增ID但自增ID有几个问题一是容易被爬虫遍历二是对外展示不友好三是多表合并数据时容易撞。我选的是在Service层生成编号用日期(yyyyMMdd) 雪花ID的末尾几位既保证了可读性又避免了并发冲突。关联业务ID这个字段在工单场景里很关键。工单打通到具体业务靠的就是这个字段。比如用户反馈配送超时那relation_type2且relation_id订单ID处理人在工单详情里就能直接跳转看到订单详情。这个设计让工单不是一座孤岛能和业务系统联动起来。处理人ID这里我还要多说一句RuoYi自带的用户表字段是user_id和nick_name当时我直接用这两个字段存处理人和创建人。考虑到工单列表要频繁展示处理人姓名、创建人姓名如果每次都joinsys_user表在数据量上来后会有性能损耗我干脆在工单表里冗余了一个assignee_name字段虽然在数据一致性上有一些风险比如用户改名后工单里还是旧名字但工单本身是历史记录保留历史快照反而是合理的。这一点算是工单表跟普通业务表一个不太一样的思维方式普通业务表追求实时一致工单这种审计性强的表追求的是历史快照。2.2 工单处理记录表的设计工单处理记录表是整个模块的“黑匣子”每一条操作都在这张表里落一条记录。一开始产品说处理记录直接写在工单表的一个文本字段里每次追加一行就行了被我否了。文本追加的方式有三个致命问题没法分页查询、没法按操作人筛选、没法对操作类型做统计分析。拆成独立表这些问题全部解决。CREATE TABLE t_work_order_record ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, order_id BIGINT NOT NULL COMMENT 工单ID, operate_type TINYINT NOT NULL COMMENT 操作类型(1创建 2分配 3处理 4驳回 5关闭), operate_content VARCHAR(500) COMMENT 操作内容, operator_id BIGINT NOT NULL COMMENT 操作人ID, operator_name VARCHAR(64) NOT NULL COMMENT 操作人姓名, from_status TINYINT COMMENT 操作前状态, to_status TINYINT COMMENT 操作后状态, create_time DATETIME NOT NULL COMMENT 操作时间, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT工单处理记录表;处理记录表设计的精髓是from_status和to_status这两个字段。有了它们整个工单的流转路径在任何时候都能完整回溯用户提交工单时记录from_status无 to_status0客服派单时记录from_status0 to_status1处理人处理完时记录from_status1 to_status2……后面我做数据报表时就靠这两个字段统计工单的平均响应时长和处理时长完全不需要去翻业务日志。2.3 状态机设计状态与操作的对应关系工单状态机是模块里最容易被低估逻辑复杂度的部分。我见过很多项目用简单的if/else if判断状态后面需求一变就崩了。我的做法是先把状态流转矩阵画出来明确每一种状态下哪些操作是合法的然后写一个统一的状态校验逻辑放在Service层。帝可得工单的状态设计为五个阶段状态码状态名称含义允许的流转操作0待分配工单已创建还没有处理人接单分配、驳回1处理中处理人已接单正在处理完成、转派2待确认处理人已提交处理结果等待创建人确认确认关闭、驳回重开3已关闭工单处理完成且已确认重新打开4已驳回工单被驳回可能是无权限处理、信息不全等重提、关闭状态流转的核心原则是状态只能按设计好的路径走不允许跳转和回退超过一步。比如待分配状态的工单可以直接走到已关闭吗不行。处理中的工单能直接变成待分配吗也不行必须走“驳回重开”的动作。在代码层面我写了一个状态校验工具/** * 工单状态机key当前状态value允许跳转的目标状态 * 注意这个Map是只读的不允许运行期修改 */ private static final MapInteger, ListInteger STATE_MACHINE new HashMap(); static { STATE_MACHINE.put(0, Arrays.asList(1, 4)); // 待分配 - 处理中/已驳回 STATE_MACHINE.put(1, Arrays.asList(2, 4, 1)); // 处理中 - 待确认/已驳回/转派 STATE_MACHINE.put(2, Arrays.asList(3, 1, 4)); // 待确认 - 已关闭/处理中/已驳回 STATE_MACHINE.put(3, Arrays.asList(1)); // 已关闭 - 处理中(重新打开) STATE_MACHINE.put(4, Arrays.asList(0, 3)); // 已驳回 - 待分配(重提)/已关闭 } /** * 校验状态流转是否合法 * param from 当前状态 * param to 目标状态 * return true允许流转 */ public static boolean canTransit(int from, int to) { if (from to) { return true; // 同状态操作(如转派仍然处理中)允许 } ListInteger targets STATE_MACHINE.get(from); return targets ! null targets.contains(to); }这个设计的好处是以后产品想加一个“待回访”状态只需要在这个Map里加对应规则然后把处理逻辑补上不会影响其他状态的流转。状态机和业务代码分离维护成本低很多这也是我踩过一次“到处都是if判断”的坑之后才总结出来的经验。2.4 字典设计工单类型与状态枚举RuoYi本身就提供了字典管理功能但工单模块我并没有全部依赖系统字典。原因很简单工单类型是和业务强关联的比如“设备报修”只出现在运维场景“商家投诉”只出现在运营场景。分散在系统字典里不好维护而且字典变更的审计追踪能力有限。我的方案是类型用Java枚举落地状态用Java枚举落地导出到前端展示时再用RuoYi的字典型式返回。也就是后端定义好WorkOrderTypeEnum和WorkOrderStatusEnum前端负责展示对应的文案和tag颜色。这样做的好处是在Service层写业务逻辑时可以直接用枚举做switch编译期就能发现拼写错误。Getter AllArgsConstructor public enum WorkOrderStatusEnum { PENDING(0, 待分配, warning), HANDLING(1, 处理中, primary), CONFIRMING(2, 待确认, success), CLOSED(3, 已关闭, info), REJECTED(4, 已驳回, danger); private final int code; private final String desc; private final String tagType; }有一点要提醒不要为了“统一”硬把所有枚举都改成走RuoYi字典表。字典适合的是那些内容可能经常变化、且不涉及逻辑判断的数据比如区域、渠道来源。一旦数据参与到了状态流转、业务判断里就必须用代码枚举不能在数据库里随便改。这是很多若依新手容易踩的坑值得记下来。3. 后端接口实现RuoYi三层架构下的工单核心逻辑3.1 代码生成器快速搭建骨架RuoYi最实用的功能就是代码生成器。我的实际操作流程是先在数据库中建好t_work_order和t_work_order_record两张表然后在若依的“代码生成”菜单里导入这两张表配置好生成选项一键生成 controller、service、mapper、domain、前端vue页面。但这里要特别说明代码生成器生成的是一个最基础的CRUD骨架绝不等于功能完成。生成之后我做的第一件事是把Controller里的add、edit、remove接口全部按业务规则重写。直接用的是生成器给的通用接口相当于裸奔工单的创建、处理、关闭每个动作都有严格的业务校验必须自己写。代码生成阶段我调整了几处生成配置这是容易被忽略的小细节生成模板选择“vue”版本如果用的是前后端分离版若依生成后页面是真正的Vue组件不是thymeleaf模板。列表页不需要生成“新增”按钮工单创建走的是独立入口生成时把新增功能关闭菜单权限也收紧。mapper xml里生成的都是标准字段映射但之后加业务查询时不要直接改generated xml建议另建一个WorkOrderMapperCustom.xml存放复杂查询SQL避免下次重新生成代码时覆盖手写内容。3.2 工单创建接口来源分流与编号生成工单创建接口跟普通业务表的新增接口最大的不同在于它的创建可能来源于多个端用户端小程序、运营管理后台、系统定时任务。RuoYi的BaseController中通过getLoginUser()获取当前登录人但用户端是微信小程序调用走的不是若依后台的登录体系所以我在Controller里做了一层来源处理PostMapping(/create) public AjaxResult create(RequestBody WorkOrderCreateReq req) { WorkOrder order new WorkOrder(); // 基础信息赋值 BeanUtils.copyProperties(req, order); // 判断来源 if (req.getSource() null) { // 默认来源是管理端需要登录上下文 order.setSource(WorkOrderSourceEnum.MANAGE.getCode()); order.setCreatorId(getUserId()); order.setCreatorName(getUsername()); } else { // 小程序端调用时通过请求头里的userId获取创建人 order.setSource(req.getSource()); order.setCreatorId(req.getCreatorId()); order.setCreatorName(req.getCreatorName()); } // 状态初始为待分配 order.setStatus(WorkOrderStatusEnum.PENDING.getCode()); // 生成工单编号 order.setOrderNo(generateOrderNo()); // 落库 workOrderService.save(order); // 写入处理记录 saveRecord(order.getId(), OperateTypeEnum.CREATE, null, order.getStatus(), 用户提交工单 order.getTitle()); return success(order); }创建接口这里有几个细节处理值得一提。一个是创建后直接写处理记录。很多项目把“创建”看成是工单表自己的事不单独记一条处理日志。等到后面追溯“这个工单是谁在什么时间创建、创建时内容是什么”时就很被动。实际上“创建”本身就是工单生命周期里的第一个动作它应该和“分配”“处理”一样被记录。另一个是编号生成放在Service层而不是数据库层。生成规则是WO yyyyMMddHHmmss 3位随机数实测并发下重复概率很小。如果要求绝对唯一可以在数据库给order_no加唯一索引生成时万一撞了重试一次即可。我没有用UUID因为工单编号要给人看的一长串无规则字符串谁都不愿意念。3.3 派单与处理状态流转的核心校验派单是工单管理里最关键的环节。处理人的分配直接决定这个工单能不能被及时响应。我在设计时做了两个接口一个是手动派单客服指定处理人一个是自动派单根据业务类型匹配到对应的处理组比如配送异常自动分给运力调度组的成员。手动派单核心逻辑PreAuthorize(ss.hasPermi(workorder:order:assign)) PostMapping(/assign) public AjaxResult assign(RequestBody WorkOrderAssignReq req) { WorkOrder order workOrderService.getById(req.getOrderId()); if (order null) { return error(工单不存在); } // 状态机校验只有待分配/已驳回状态可以派单 if (!StateMachineUtil.canTransit(order.getStatus(), WorkOrderStatusEnum.HANDLING.getCode())) { return error(当前工单状态不可派单); } // 派单给指定人 order.setStatus(WorkOrderStatusEnum.HANDLING.getCode()); order.setAssigneeId(req.getAssigneeId()); order.setAssigneeName(req.getAssigneeName()); order.setHandleStartTime(new Date()); workOrderService.updateById(order); // 记录处理日志 saveRecord(order.getId(), OperateTypeEnum.ASSIGN, WorkOrderStatusEnum.PENDING.getCode(), WorkOrderStatusEnum.HANDLING.getCode(), 派单给 req.getAssigneeName()); return success(); }这段代码的核心是PreAuthorize(ss.hasPermi(workorder:order:assign))权限注解。RuoYi的权限控制使用的是ss.hasPermi(权限标识)这一套标识在sys_menu里配置。当时我按照RuoYi的权限规则把工单模块的按钮权限配置成workorder:order:list工单列表查看workorder:order:create工单创建workorder:order:assign工单派单workorder:order:handle工单处理workorder:order:close工单关闭workorder:order:remove工单删除这里建议每一个操作按钮在菜单里配置成独立的权限点而不是只配一个“工单管理”的大权限。原因很简单如果所有角色都拿到同一个权限那就无法区分“只读客服”和“可派单客服”了。做权限收敛时宁可多配几个权限点也好过少配导致权限失控。3.4 处理记录的落库时机与事务处理工单流转的每一个动作都要同时更新工单表和处理记录表这里必须用事务保证一致性。若依的Service实现类自带Spring事务管理但有个大坑是Transactional注解默认只回滚RuntimeException如果是自定义的受检异常比如ServiceException继承自RuntimeException则不担心如果抛的是受检异常事务不会回滚。我建议代码里统一使用若依自带的ServiceException继承自RuntimeException并且给Service实现类加上Transactional(rollbackFor Exception.class)双保险Override Transactional(rollbackFor Exception.class) public void handleOrder(WorkOrderHandleReq req) { WorkOrder order getById(req.getOrderId()); if (order null) { throw new ServiceException(工单不存在); } if (!StateMachineUtil.canTransit(order.getStatus(), WorkOrderStatusEnum.CONFIRMING.getCode())) { throw new ServiceException(当前工单状态不可处理); } // 更新工单状态 order.setStatus(WorkOrderStatusEnum.CONFIRMING.getCode()); order.setHandleEndTime(new Date()); updateById(order); // 写处理记录 WorkOrderRecord record new WorkOrderRecord(); record.setOrderId(order.getId()); record.setOperateType(OperateTypeEnum.HANDLE.getCode()); record.setFromStatus(WorkOrderStatusEnum.HANDLING.getCode()); record.setToStatus(WorkOrderStatusEnum.CONFIRMING.getCode()); record.setOperateContent(req.getContent()); record.setOperatorId(getUserId()); record.setOperatorName(getUsername()); recordMapper.insert(record); // TODO 通知创建人工单已处理完成 }事务有一个最容易被忽视的点同一个类中方法自调用时Transactional注解会失效。比如workOrderService的方法A调用了同一个类的私有方法BB上的Transactional不做任何事。所以我写工单模块时凡是需要在事务里执行的操作全部把事务注解打在public入口方法上保证从外部进入代理对象。3.5 列表查询多条件组合与分页处理工单列表是整个模块访问频率最高的接口条件组合也比较多按工单号精确查、按标题模糊查、按类型筛选、按状态筛选、按处理人筛选、按创建时间区间筛选。RuoYi的列表分页是PageHelper实现的使用方法是在Controller里先调用startPage()然后紧接着调用列表查询方法PreAuthorize(ss.hasPermi(workorder:order:list)) GetMapping(/list) public TableDataInfo list(WorkOrderQuery query) { startPage(); ListWorkOrder list workOrderService.queryOrderList(query); return getDataTable(list); }这里有几个经验想分享给新手PageHelper的坑startPage()调用后它和最近的下一条SQL语句绑定。如果你的Controller里在调用workOrderService.queryOrderList之前中间又执行了其他SQL比如先查了一遍字典、做了一次异步日志插入PageHelper会把分页SQL套到那条先执行的SQL上导致数据错乱。解决办法是startPage()之后马上执行你要分页的查询中间别插任何其他数据库操作。数据权限工单数据需要按角色做行级过滤。若依的DataScope注解可以按部门过滤但工单模块涉及的角色是客服、处理人、管理员、普通用户不是按部门划分的所以我没有直接用它而是在Service层做了手动过滤if (isAdmin()) { // 管理员查全部 } else if (isAssigner()) { // 客服查所有待分配自己处理中的 query.setStatus(Collections.singletonList(WorkOrderStatusEnum.PENDING.getCode())); } else { // 普通处理人只能看分给自己的 query.setAssigneeId(getUserId()); }业务规则中不同的人能看到的工单范围是不一样的这个手动过滤比RuoYi默认的部门数据权限更贴合实际场景。RuoYi的DataScope是通用能力但通用意味着它是按部门模型设计的工单这种“按角色按负责人”的模型直接用在它上面会水土不服。列表查询的SQL优化工单列表默认按create_time倒序查询时如果只查状态字段尽量让索引生效。当时我建了idx_status_assignee联合索引就是为了适配“处理人查自己待处理的工单”这种高频查询场景。数据量超过几十万条后再用MySQL原生分页LIMIT 100000, 20会越翻越慢等真的遇到这个性能瓶颈时再优化前期不用过早加复杂的分页方案。4. 前端页面落地与RuoYi权限控制细节4.1 Vue页面的列表与状态标签若依的前后端分离版本前端是Vue Element UI后端返回的TableDataInfo对象前端可以直接用。工单列表页面我重点处理了三块状态标签的展示、处理按钮的权限控制、搜索条件的联动。状态标签用el-tag展示type对应后端返回的枚举值el-table-column label状态 aligncenter width100 template slot-scopescope el-tag :typeworkOrderStatusTag(scope.row.status) {{ workOrderStatusText(scope.row.status) }} /el-tag /template /el-table-column对应的展示函数放在一个单独的workOrder.js工具文件里const workOrderStatusMap { 0: { text: 待分配, tag: warning }, 1: { text: 处理中, tag: primary }, 2: { text: 待确认, tag: success }, 3: { text: 已关闭, tag: info }, 4: { text: 已驳回, tag: danger } } export function workOrderStatusText(status) { return (workOrderStatusMap[status] || {}).text || 未知 } export function workOrderStatusTag(status) { return (workOrderStatusMap[status] || {}).tag || info }搞一个独立的状态Map前端文件比直接在Vue组件里写一堆if/else要清爽得多十几个页面组件引用同一个Map也不会出现状态文案不一致的问题。4.2 操作按钮的权限控制RuoYi前端控制按钮权限的标准写法是用v-hasPermi指令el-button v-ifscope.row.status 0 v-hasPermi[workorder:order:assign] typeprimary sizemini iconel-icon-user clickhandleAssign(scope.row) 派单/el-button el-button v-ifscope.row.status 1 scope.row.assigneeId userInfo.userId v-hasPermi[workorder:order:handle] typesuccess sizemini iconel-icon-s-check clickhandleHandle(scope.row) 处理/el-button注意这里的双重判断前端按钮既做了v-hasPermi权限指令控制“这个人有没有处理工单的权限”又做了状态判断控制“这个按钮在当前状态和不显示”和assigneeId userInfo.userId判断控制“这个工单是不是当前处理人的”。“有权限”和“能操作这条数据”是两码事前端展示层面缩得越紧用户误操作的概率就越低。当然后端的PreAuthorize校验不能省前端只是体验优化后端才是安全底线。::: tip 提示 按钮权限这块很多人以为只要配了v-hasPermi就够了后端Controller不加PreAuthorize。千万不行前端隐藏按钮只是“看不见摸不着”如果有人直接调后端接口恶意传参照样能修改工单状态。所有关键操作后端必须独立校验一次权限。 :::4.3 工单详情页的时间线与操作面板工单详情页我用了两个区域展示上半部分是工单的基本信息和处理表单下半部分是一个时间线组件展示该工单从创建到现在的所有处理记录。RuoYi的Vue版本没有自带时间线组件我用Element UI的el-timeline实现el-timeline el-timeline-item v-for(record, index) in recordList :keyindex :timestamprecord.createTime record.operatorName placementtop el-card p 操作类型{{ operateTypeText(record.operateType) }} span v-ifrecord.fromStatus ! null {{ workOrderStatusText(record.fromStatus) }} → {{ workOrderStatusText(record.toStatus) }} /span /p p{{ record.operateContent }}/p /el-card /el-timeline /el-timeline时间线天然适合表达工单这种“过程型业务”用户打开详情页能清楚地看到谁在什么时间做了什么操作状态怎么一步步变化的。这也是为什么处理记录表必须保留from_status和to_status没有这两个字段时间线就只是流水账有了它们时间线才是一张活的状态迁移图。5. 线上运行后的常见问题与排查实录5.1 状态跳转异常有按钮但点了报错上线第一周就遇到一个反馈客服明明看到了“关闭工单”按钮但点击后后端返回“状态不可流转”。排查后发现原因是客服在“待确认”状态下点了关闭按钮而我的STATE_MACHINE里设置的是只有待确认状态才能流转到已关闭前端页面按钮显示的条件却也写了status 2。看起来逻辑是对的但问题出在时序上——工单已经因为某些操作直接从处理中被驳回成了已驳回前端按钮因为状态判断不匹配直接展示异常。这个问题暴露的是前端状态轮询不及时的问题。工单列表页的数据不是实时刷新的客服开着的页面上显示的可能是几分钟前的状态。我给列表页加了一个30秒自动刷新并且在提交操作前用后端返回的最新状态为准前端显示状态只做参考。后端状态机校验才是硬规则这一点在处理线上问题时非常重要。5.2 分页数据重复PageHelper与自定义SQL的冲突工单列表用了一段时间后运营反馈“第二页和第一页有重复数据”而且只在按处理人筛选时出现。排查了一上午最后定位到原因我的queryOrderListSQL里带了一个复杂子查询子查询里又有ORDER BY子句PageHelper在拼分页SQL时把子查询的排序也包进去了导致LIMIT截取的数据混乱。解决方式是重写了这个查询把子查询抽出来用JOIN代替并且在SQL最外层明确只保留一个ORDER BY t.create_time DESC。顺带说一句RuoYi的PageHelper是支持分页嵌套SQL的但嵌套层级太深、子查询里带ORDER BY时生成的COUNT查询经常出问题。遇到分页数据异常第一反应不是去查业务逻辑而是把执行的SQL打印出来用Navicat手动执行一遍看分页结果对不对。5.3 权限标识配错按钮不显示但接口能调通开发环境一切正常部署到测试环境后发现客服账号登录后看不到派单按钮但用Postman调派单接口却能正常返回。对比了两个环境的差异最后发现是测试环境的sys_menu里perms字段配错了前端v-hasPermi通过菜单权限判断没有返回workorder:order:assign这个标识后端却能查到所有权限所以放行。这个问题的根因是前后端权限判断的数据来源不一致。RuoYi后端用PreAuthorize校验时会查用户所有的角色和权限集合前端v-hasPermi用的是当前登录用户下放的“菜单权限标识”。理论上这两个应该是一致的但如果菜单权限没挂到角色上就会出现“前端操作不了、后端能调通”的诡异现象。排查权限问题时先查端到端的菜单-角色-用户关联用若依自带的“角色分配用户”和“菜单权限”两个页面交叉对比。5.4 处理记录丢失事务提交时机导致的坑灰度阶段出现了几次“工单状态变了但处理记录表里没有对应记录”的情况。检查代码发现ServiceImpl里有个方法把工单状态更新和记录插入分开了两个方法调用第二个方法在调用时抛了一个空指针异常但因为外层没有统一的事务保护第一条更新语句已经提交了。这个问题的解决方案前面已经提过把所有状态流转操作封装成一个完整事务方法两个数据库操作要么同时成功要么同时失败。我后来还给工单模块加了补偿机制每天凌晨跑一个定时任务扫描t_work_order里状态发生变化但处理记录里没有对应记录的数据补一条“系统自动修复”的日志。这个方案虽然不完美但能兜底线上运行至今还没有出现过用户反馈工单状态异常但无日志可查的情况。5.5 优先级与时效线上工单自动升级的实现运营后来提了一个新需求高优先级的工单如果两小时没人接单要自动提醒到客服组长。这个用RuoYi自带的定时任务就能实现。我在若依的“定时任务”菜单里加了一个remindTimeoutOrderTask每5分钟跑一次扫描status0且priority1且create_time超过两小时的工单批量推送站内信通知。定时任务的Service代码要点Component(timeoutOrderTask) public class TimeoutOrderTask { Autowired private WorkOrderService workOrderService; public void remind() { ListWorkOrder timeoutOrders workOrderService.listTimeoutPendingOrders(120); for (WorkOrder order : timeoutOrders) { // 推送通知逻辑可以用若依的消息通知API发站内信 // 这里注意减负不能一个循环一个大事务每个工单单独处理 } } }做定时任务要学会“给自己留余地”线上数据量没法预估代码开发时就要考虑到通知失败了怎么办。我当时的做法是通知发送成功后在工单表加了一个remind_flag字段标记避免同一条工单被反复提醒积压工单给组长造成信息轰炸。6. 工单模块后续还能怎么扩展帝可得的工单模块上线运行一段时间后我从数据里看到了一些有意思的东西某些类型的工单在某个时间段的量会激增比如下雨天“配送超时”类的工单短时间内爆表。这给了我们一些新的扩展思路。一个是接入自动分类能力。工单创建时让用户选类型但实际运营中大量用户会填得不对或者干脆不填。目前这个版本还是靠人工筛选后面可以考虑对工单标题和内容做分词处理按关键词自动建议工单类型减轻客服筛选压力。这个方向Java生态里有很多现成方案不需要特别重的模型。另一个是工单服务时长统计。我们已经在处理记录表里记录了每个操作的时间点可以统计每个处理人从接单到关闭的平均耗时、各类型工单的处理时长分布、响应超时率等指标。这些数据对运营排班、人员绩效评估、服务流程优化都有直接价值。有了from_status、to_status、create_time这些字段做报表只是按维度聚合的问题不需要再改表结构。还有一个值得做的是工单与客户回访的联动。现在工单关闭即结束但“处理完”不等于“客户满意”。后续可以在工单关闭后自动生成一条回访任务过两天提醒客服回访确认。这其实还是工单模块的范畴只是给它加了后续动作整体架构不需要动。一点个人经验总结这个工单管理模块从设计到上线我自己最深的体会是工单模块的本质是“流程”不是“表单”。很多开发拿到工单需求第一反应是建表、写CRUD、做页面但真正把工单做成型的关键是状态机设计、处理留痕、权限收敛这三件事。状态机设计决定了系统的流程是否严谨处理留痕决定了问题能否追溯权限收敛决定了数据是否安全。这三件事想清楚了工单模块的骨架就立起来了剩下的只是按部就班地填肉。至于RuoYi框架本身它给的代码生成、权限管理、日志管理这些通用能力确实能在前期帮我们节省不少时间但越到后面越会发现真正决定项目质量的是你在通用框架之上如何组织业务逻辑。不要指望代码生成器能直接生成一个能上线的工单模块它生成的只是一堆可参考的半成品业务逻辑还是要靠自己的架构设计一步步打磨。最后再分享一个小技巧工单模块的业务逻辑里涉及到的状态、类型的枚举强烈建议统一维护在一个常量类或者枚举类里这个类里的每个常量加注释说明它的含义和对应场景。等到项目迭代了半年、换了几拨人手的时候你会感谢当时那个肯多写两行注释的自己。
返回列表