任务管理流程设计:状态机、表结构与Spring Boot落地实践
任务管理功能几乎是每个业务系统的“标配”但说实话大多数团队做出来的任务管理只是一个“任务登记本”能新增、能改状态、能列表查询仅此而已。业务方开始提需求时也确实只说了“记录一下就行”可一旦系统真正用起来问题立刻暴露——任务被谁处理了当前卡在哪个环节超时了为什么没人管退回重做时状态怎么记这时候再回头改流程代价已经翻了好几倍。这篇文章不打算写一个只能演示的 Demo而是讲清楚一套任务管理流程从设计到落地的完整思路状态机怎么设计、表结构怎么拆、核心代码怎么写、超时任务怎么处理、上线后有哪些坑。整套方案基于 Spring Boot 的常见技术栈实现即使你用的是其他语言或框架流程设计和表结构的部分也可以直接借鉴。读完你可以照着把一套可用的任务管理流程接入自己的项目。1. 任务管理流程真正难在哪儿很多人以为任务管理的难点在“任务的新增和编辑”实际上后端写几个 CRUD 接口前端做两个页面确实很快就能跑起来。但一个系统做完之后被业务方评价为“不好用”问题往往出在任务状态失控和流程规则缺失上。1.1 状态失控谁都可以改谁也说不清最典型的场景是开发人员打开任务详情看到“状态”字段是下拉框待处理、处理中、已完成、已关闭随便选。看起来灵活但业务上没法约束。一个已经“已完成”的任务被改回“待处理”系统根本不知道中间发生了什么一个“处理中”的任务被直接置成“已完成”中间的耗时、负责人都没有记录。等到月底统计绩效或者复盘项目延期原因时数据全是乱的。真正的任务管理流程状态不应该是自由修改的字段而应该是受规则约束的状态机。每一步状态变化必须满足前置条件必须记录变更人、变更时间和变更原因。这不是为了限制用户而是为了让任务流转过程可追溯。1.2 超时无人处理任务发出去就石沉大海再常见的场景是任务分派给某人之后对方一直不处理没有提醒、没有升级机制。业务人员只能线下催催不动就找领导最后问题又回到了“系统不好用”。超时和升级机制是任务管理流程里最容易遗漏、也最影响使用体验的部分。一套合格的任务管理流程需要能够定义任务的 SLA服务等级协议比如“普通任务 3 天内完成紧急任务 24 小时内完成”并且在任务超时前给出提醒超时后自动升级给上级或重新分派。1.3 三个关键判断在动手设计之前我有几个明确的判断供你参考第一任务管理流程的核心是状态机设计不是页面设计。页面做得再漂亮状态流转逻辑不清楚系统依然是乱的。第二任务管理流程的本质是流程引擎的轻量实现。你不需要引入 Activiti 或 Flowable 这种重量级工作流引擎大部分业务场景用一张状态表和一段状态机校验逻辑就能覆盖 90% 的需求。第三做好任务管理流程的关键不是功能多而是规则明确、动作可追溯、超时可感知。这三个点做到位系统就已经超越了大多数“任务登记本”。2. 任务管理流程的核心概念与状态设计要写代码之前先要把概念和状态定义清楚。这里有几个容易搞混的术语先做区分。2.1 三个容易混淆的概念任务Task指一件需要被完成的具体事情比如“修复订单导出超时 Bug”“完成 4 月经营数据报表”。它是任务管理的主体对象。任务状态Task Status指任务当前处于生命周期中的哪个阶段比如待处理、处理中、已完成。它是任务流转的判断依据。任务动作Task Action指对任务执行的某个操作比如提交、受理、完成、退回、取消。动作触发状态变化。如果打在比喻里状态是任务当前“停在哪一站”动作是任务“坐什么车去下一站”。设计流程时思考顺序应该是先定义状态再定义动作最后把动作对应的状态变化画成一张表。2.2 一套可复用的任务状态定义不同业务场景下的任务状态名称可能不同但通用模型可以归纳为以下几类状态含义谁可以进入可执行的动作待受理任务已创建尚未有人认领创建人受理、取消处理中任务已被受理正在处理受理人完成、退回、转派待验收任务已提交完成等待创建人确认处理人验收通过、驳回已完成任务验收通过或直接完成验收人/处理人关闭、重新打开已关闭任务生命周期结束系统或管理员无已取消任务被取消或废弃创建人或管理员无这套状态定义的特点状态数量控制在 5 到 6 个足够覆盖大多数业务场景。状态过多会加大维护成本状态过少则缺少过程跟踪。“待验收”状态不是必须的如果你是内部任务管理成员完成任务后直接置为“已完成”也可以。但如果是外包协作、跨部门任务验收环节建议保留。每个状态都有一个明确的“负责人”比如“待受理”这个状态的负责人是当前待分派的人或角色“处理中”的负责人是受理人。负责人清晰之后超时任务才能准确找到责任人。2.3 状态流转规则状态定义好之后还需要画出“状态流转表”这张表会直接指导后面的代码实现。以下是一份常见的流转配置当前状态动作目标状态前置条件待受理受理处理中当前用户是受理人待受理取消已取消当前用户是创建人或管理员处理中完成待验收任务有处理结果处理中退回待受理填写退回原因处理中转派处理中转派给其他处理人待验收通过已完成当前用户是创建人待验收驳回处理中填写驳回原因已完成重新打开处理中当前用户是创建人或管理员已完成关闭已关闭管理员设计这张表时有几条经验不跳过中间状态。比如不允许“待受理”直接变成“已完成”必须经过“处理中”。这样每一步都有记录。退回必须填原因。退回动作如果不需要理由业务上很容易被滥用日志也失去了审计价值。限制可操作人。不是任何角色都能对任意状态执行任意动作。如果系统没有完善的权限体系至少要在代码逻辑里校验当前操作人是否在“可操作人范围内”。2.4 小结论任务管理流程的骨架是状态机。把状态和动作定义清楚后面无论是写接口、做页面还是写定时任务都会非常顺。这里真正值得花时间的不是写代码而是和业务方把状态流转表确认清楚。如果业务方说“我们也不确定”你可以先按上面这套最简模型上线后续再扩展。3. 任务管理流程整体架构与模块划分确认完状态设计接下来考虑系统架构。任务管理流程不是一个独立的复杂系统但如果要在已有业务系统中新增这一块建议按模块拆分避免把所有逻辑堆在一个类里。3.1 分层架构推荐采用经典的四层结构层级职责典型组件接口层对外提供 REST APITaskController应用层流程编排事务控制TaskAppService领域层核心状态机逻辑TaskDomainService基础设施层数据库操作、消息通知、定时任务TaskRepository关键点在于领域层的状态机逻辑要独立于应用层。如果状态机校验和业务编排混在一起后续每加一个业务场景都容易改崩原有流程。3.2 核心模块划分根据功能职责任务管理流程可以拆成以下模块任务创建模块负责创建任务设置标题、描述、优先级、截止时间、处理人/角色。任务分派模块负责将任务分配给具体的人支持直接分派和按角色分派。任务处理模块负责受理、完成、退回、转派等状态流转操作。任务验收模块负责验收通过或驳回。任务查询模块负责列表查询、详情查询、状态历史查询。任务调度模块负责超时扫描、提醒通知、升级处理。任务审计模块记录所有操作日志便于追溯。对于一个团队内部使用的任务管理流程模块 1 到 5 是必须的模块 6 强烈建议做模块 7 第一次上线可以只记录日志表不强制做分析页面。3.3 一个容易犯的错误把状态逻辑写在 Controller 或 Service 里很多新手会这样写Controller 里接收一个status字段前端把状态直接传过来后端只是做一个 update。这样做的直接后果就是状态字段变成了“随便填”的普通字段前面说的状态失控问题根本没法避免。正确做法是前端只能传“动作”不能传“目标状态”。比如点击“受理”按钮前端传的是action accept而不是status PROCESSING。后端根据动作判断当前状态是否允许流转如果允许再由后端计算出目标状态并更新。这个设计是状态机落地的关键后面代码部分会演示具体写法。4. 环境准备与前置条件为了确保这篇教程可以完整运行环境准备部分说明如下。版本信息以你实际项目为准本文重点演示通用思路。4.1 技术栈Java 8 或以上Spring Boot 2.x 或 3.xMyBatis 或 Spring Data JPA任选一种本文以 MyBatis 为例MySQL 5.7 或以上也可以用 H2 内存数据库做本地演示Maven 3.6 或以上4.2 项目依赖以 Maven 项目为例核心依赖如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies如果你的项目使用的 Spring Boot 3.x注意mybatis-spring-boot-starter的版本需要与 Spring Boot 3 适配具体版本以官方说明为准。4.3 配置示例在src/main/resources/application.yml中配置数据源spring: datasource: url: jdbc:mysql://localhost:3306/task_flow?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.taskflow.entity configuration: map-underscore-to-camel-case: true关键配置说明map-underscore-to-camel-case: true可以自动将数据库字段task_name映射到 Java 属性taskName避免手写 ResultMap。mapper-locations指定 XML 文件位置如果你使用注解方式写 SQL可以省略。如果是本地演示可以使用 H2 数据库修改url和driver-class-name即可。5. 任务管理流程核心代码实现下面进入代码部分。这一节是整个文章的核心重点讲清楚三个关键代码点状态机校验逻辑、任务创建、状态流转。代码可以按文件位置直接复制进项目。5.1 任务实体文件路径src/main/java/com/example/taskflow/entity/TaskInfo.javapackage com.example.taskflow.entity; import lombok.Data; import java.time.LocalDateTime; Data public class TaskInfo { private Long id; /** 任务标题 */ private String title; /** 任务描述 */ private String description; /** 优先级LOW / MEDIUM / HIGH / URGENT */ private String priority; /** 当前状态PENDING / PROCESSING / WAITING_ACCEPT / COMPLETED / CLOSED / CANCELED */ private String status; /** 创建人用户ID */ private Long creatorUserId; /** 处理人用户ID */ private Long assigneeUserId; /** 截止时间 */ private LocalDateTime deadline; /** 实际完成时间 */ private LocalDateTime finishedTime; /** 创建时间 */ private LocalDateTime createTime; /** 更新时间 */ private LocalDateTime updateTime; }为了让状态定义和动作定义可以复用建议把常量类单独建出来文件路径src/main/java/com/example/taskflow/constant/TaskConstant.javapackage com.example.taskflow.constant; public class TaskConstant { /** 任务状态 */ public static final String STATUS_PENDING PENDING; // 待受理 public static final String STATUS_PROCESSING PROCESSING; // 处理中 public static final String STATUS_WAITING_ACCEPT WAITING_ACCEPT; // 待验收 public static final String STATUS_COMPLETED COMPLETED; // 已完成 public static final String STATUS_CLOSED CLOSED; // 已关闭 public static final String STATUS_CANCELED CANCELED; // 已取消 /** 任务动作 */ public static final String ACTION_ACCEPT ACCEPT; // 受理 public static final String ACTION_COMPLETE COMPLETE; // 完成 public static final String ACTION_SUBMIT SUBMIT; // 提交验收 public static final String ACTION_REJECT REJECT; // 驳回 public static final String ACTION_PASS PASS; // 验收通过 public static final String ACTION_RETURN RETURN; // 退回 public static final String ACTION_TRANSFER TRANSFER; // 转派 public static final String ACTION_CANCEL CANCEL; // 取消 public static final String ACTION_CLOSE CLOSE; // 关闭 }5.2 状态机核心逻辑这是任务管理流程最关键的部分。建议把状态流转规则封装到一个独立的类中这个类不依赖 Spring 容器纯 Java 实现方便单元测试。文件路径src/main/java/com/example/taskflow/domain/TaskStateMachine.javapackage com.example.taskflow.domain; import com.example.taskflow.constant.TaskConstant; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; public class TaskStateMachine { private static final MapString, MapString, String TRANSITIONS new HashMap(); static { register(TaskConstant.STATUS_PENDING, TaskConstant.ACTION_ACCEPT, TaskConstant.STATUS_PROCESSING); register(TaskConstant.STATUS_PENDING, TaskConstant.ACTION_CANCEL, TaskConstant.STATUS_CANCELED); register(TaskConstant.STATUS_PROCESSING, TaskConstant.ACTION_COMPLETE, TaskConstant.STATUS_WAITING_ACCEPT); register(TaskConstant.STATUS_PROCESSING, TaskConstant.ACTION_RETURN, TaskConstant.STATUS_PENDING); register(TaskConstant.STATUS_PROCESSING, TaskConstant.ACTION_TRANSFER, TaskConstant.STATUS_PROCESSING); register(TaskConstant.STATUS_WAITING_ACCEPT, TaskConstant.ACTION_PASS, TaskConstant.STATUS_COMPLETED); register(TaskConstant.STATUS_WAITING_ACCEPT, TaskConstant.ACTION_REJECT, TaskConstant.STATUS_PROCESSING); register(TaskConstant.STATUS_COMPLETED, TaskConstant.ACTION_CLOSE, TaskConstant.STATUS_CLOSED); } private static void register(String from, String action, String to) { TRANSITIONS .computeIfAbsent(from, k - new HashMap()) .put(action, to); } /** * 判断当前状态下是否可以执行指定动作 */ public static boolean canTransition(String currentStatus, String action) { if (currentStatus null || action null) { return false; } MapString, String actionMap TRANSITIONS.get(currentStatus); return actionMap ! null actionMap.containsKey(action); } /** * 获取目标状态如果不可流转返回 null */ public static String getTargetStatus(String currentStatus, String action) { if (currentStatus null || action null) { return null; } MapString, String actionMap TRANSITIONS.get(currentStatus); if (actionMap null) { return null; } return actionMap.get(action); } /** * 获取某个状态可执行的全部动作 */ public static SetString getAvailableActions(String currentStatus) { MapString, String actionMap TRANSITIONS.get(currentStatus); if (actionMap null) { return new HashSet(); } return actionMap.keySet(); } }这段代码的意义在于把“状态流转规则”集中管理。后续如果要新增一个状态或者调整流转关系只要改这个类再补充对应的单元测试即可不用在业务代码里到处找 if-else。5.3 任务创建服务文件路径src/main/java/com/example/taskflow/service/TaskService.javapackage com.example.taskflow.service; import com.example.taskflow.constant.TaskConstant; import com.example.taskflow.entity.TaskInfo; import com.example.taskflow.mapper.TaskInfoMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import java.time.LocalDateTime; Service public class TaskService { Resource private TaskInfoMapper taskInfoMapper; Transactional(rollbackFor Exception.class) public Long createTask(TaskInfo task) { // 1. 基础参数校验 if (task.getTitle() null || task.getTitle().trim().isEmpty()) { throw new IllegalArgumentException(任务标题不能为空); } if (task.getDeadline() ! null task.getDeadline().isBefore(LocalDateTime.now())) { throw new IllegalArgumentException(截止时间不能早于当前时间); } // 2. 初始化默认值 task.setStatus(TaskConstant.STATUS_PENDING); task.setCreateTime(LocalDateTime.now()); task.setUpdateTime(LocalDateTime.now()); // 3. 写入数据库 taskInfoMapper.insert(task); return task.getId(); } }创建任务时有几个容易被忽略的点状态字段不允许前端传入后端强制初始化为“待受理”。截止时间不能早于当前时间这一步在接口层校验一遍在 Service 层再校验一遍避免绕过接口层直接调 Service。创建任务时要记录creatorUserId后续验收、取消、重新打开操作都需要判断操作人是否为创建人。5.4 状态流转操作状态流转操作的写法与创建任务类似但多一步校验先查当前任务再判断当前状态是否允许执行动作最后更新状态。Transactional(rollbackFor Exception.class) public void handleAction(Long taskId, String action, Long operatorUserId, String remark) { // 1. 查询任务 TaskInfo task taskInfoMapper.selectById(taskId); if (task null) { throw new IllegalArgumentException(任务不存在); } // 2. 使用状态机校验 String currentStatus task.getStatus(); if (!TaskStateMachine.canTransition(currentStatus, action)) { throw new IllegalStateException(当前任务状态不支持该操作状态 currentStatus 动作 action); } // 3. 根据动作设置额外信息 if (TaskConstant.ACTION_ACCEPT.equals(action)) { task.setAssigneeUserId(operatorUserId); } if (TaskConstant.ACTION_COMPLETE.equals(action) || TaskConstant.ACTION_PASS.equals(action)) { task.setFinishedTime(LocalDateTime.now()); } // 4. 计算目标状态 String targetStatus TaskStateMachine.getTargetStatus(currentStatus, action); task.setStatus(targetStatus); task.setUpdateTime(LocalDateTime.now()); // 5. 写操作日志 taskInfoMapper.updateStatus(task); // 6. 这里可以发送通知消息 // notifyService.sendTaskNotification(task, action, remark); }这段代码是状态流转的核心写法。值得注意的两个细节先校验再更新不能先 update 再判断否则数据已经脏了。操作日志单独记录状态更新是必须做的但操作日志同样重要。建议在同一个事务里插入一条task_status_log记录保证状态变更和日志记录要么都成功、要么都失败。5.5 定时任务超时扫描与提醒超时处理是任务管理流程里最体现工程完成度的功能。这里给出一个基于 Spring Scheduled 的轻量实现思路。文件路径src/main/java/com/example/taskflow/job/TaskTimeoutJob.javapackage com.example.taskflow.job; import com.example.taskflow.constant.TaskConstant; import com.example.taskflow.entity.TaskInfo; import com.example.taskflow.mapper.TaskInfoMapper; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.time.LocalDateTime; import java.util.List; Component public class TaskTimeoutJob { Resource private TaskInfoMapper taskInfoMapper; /** * 每分钟扫描一次找到所有已超时且状态还是待受理/处理中的任务 */ Scheduled(cron 0 * * * * ?) public void scanTimeoutTasks() { ListTaskInfo timeoutTasks taskInfoMapper.selectTimeoutTasks( TaskConstant.STATUS_PENDING , TaskConstant.STATUS_PROCESSING, LocalDateTime.now()); for (TaskInfo task : timeoutTasks) { // 第一次超时给处理人发提醒 // 第二次超时升级给创建人或管理员 // 实际项目中建议通过消息队列异步发送避免在定时任务中同步发送 System.out.println(任务超时提醒taskId task.getId() , title task.getTitle() , assignee task.getAssigneeUserId()); } } }配合的 SQL 如下select idselectTimeoutTasks resultTypecom.example.taskflow.entity.TaskInfo SELECT * FROM task_info WHERE status IN foreach collectionstatusList itemitem open( separator, close) #{item} /foreach AND deadline IS NOT NULL AND deadline lt; #{now} /select注意 XML 中小于号lt;的写法直接写会导致 XML 解析报错。定时任务的实现方式很多如果你的项目已经引入了 XXL-Job 或 ElasticJob可以把scanTimeoutTasks迁移到分布式任务调度平台中。这里用 Spring Scheduled 是为了演示核心逻辑生产环境中还要考虑任务只执行一次、避免多实例重复扫描等问题。5.6 数据库设计相关说明数据库表设计是整个流程的基石。这里用一个案例来说明。假设你要为一家软件外包公司做任务管理系统。业务场景是项目经理在系统中创建任务安排给开发人员开发人员受理后开始处理完成后提交验收项目经理验收通过任务完成如果验收不过退回给开发人员重新处理。按照这套流程表中需要体现的信息包括任务基本属性标题、描述、优先级、截止时间。人员信息创建人、处理人。时间信息创建时间、更新时间、完成时间。状态信息当前状态、状态历史。任务主表可以这样设计CREATE TABLE task_info ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, title varchar(200) NOT NULL COMMENT 任务标题, description text COMMENT 任务描述, priority varchar(20) NOT NULL DEFAULT MEDIUM COMMENT 优先级LOW/MEDIUM/HIGH/URGENT, status varchar(20) NOT NULL DEFAULT PENDING COMMENT 状态PENDING/PROCESSING/WAITING_ACCEPT/COMPLETED/CLOSED/CANCELED, creator_user_id bigint(20) NOT NULL COMMENT 创建人用户ID, assignee_user_id bigint(20) DEFAULT NULL COMMENT 处理人用户ID, deadline datetime DEFAULT NULL COMMENT 截止时间, finished_time datetime DEFAULT NULL COMMENT 实际完成时间, create_time datetime NOT NULL COMMENT 创建时间, update_time datetime NOT NULL COMMENT 更新时间, PRIMARY KEY (id), KEY idx_status_deadline (status, deadline), KEY idx_assignee_status (assignee_user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT任务信息表;状态历史表用于记录每一次状态变化CREATE TABLE task_status_log ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, task_id bigint(20) NOT NULL COMMENT 任务ID, from_status varchar(20) DEFAULT NULL COMMENT 原状态, to_status varchar(20) NOT NULL COMMENT 目标状态, action varchar(50) NOT NULL COMMENT 动作, operator_user_id bigint(20) NOT NULL COMMENT 操作人用户ID, remark varchar(500) DEFAULT NULL COMMENT 备注, create_time datetime NOT NULL COMMENT 操作时间, PRIMARY KEY (id), KEY idx_task_id (task_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT任务状态流转日志表;这里有一个重要的设计判断状态字段在task_info表中保留一个“当前状态”字段是为了查询和列表展示的方便而task_status_log表记录的是“历史轨迹”。两者缺一不可。很多团队只做前者不做后者导致事后的追踪审计完全没有依据。6. 完整示例从创建到关闭的端到端验证为了让你确认自己写的流程是对的建议按下面的顺序跑一个完整流程。假设你已经成功启动项目并且数据库表已经通过脚本初始化。6.1 第一步创建任务使用 curl 调用接口curl -X POST http://localhost:8080/api/tasks \ -H Content-Type: application/json \ -d { title: 修复订单导出超时问题, description: 订单数量超过 10 万时导出接口响应时间超过 30 秒, priority: HIGH, creatorUserId: 1, assigneeUserId: 2, deadline: 2025-06-30 18:00:00 }预期响应返回新任务的 ID比如{id: 1001}。这个接口会校验“截止时间不能早于当前时间”如果你测试时把deadline写成了过去的时间接口会直接报错。6.2 第二步查询任务状态curl -X GET http://localhost:8080/api/tasks/1001预期输出中status字段为PENDING。这是创建任务时由后端自动设置的初始状态前端传进来的任何状态值都会被忽略。6.3 第三步受理任务curl -X POST http://localhost:8080/api/tasks/1001/action \ -H Content-Type: application/json \ -d { action: ACCEPT, operatorUserId: 2, remark: 开始处理 }再次查询任务status应变为PROCESSING同时assigneeUserId被设置为操作人 ID。6.4 第四步尝试执行“非法动作”curl -X POST http://localhost:8080/api/tasks/1001/action \ -H Content-Type: application/json \ -d { action: PASS, operatorUserId: 2, remark: 尝试跳过验收 }这个操作会被拒绝。因为状态机中“处理中PROCESSING”状态下没有定义PASS动作。接口会返回类似当前任务状态不支持该操作的错误。这一步能直接验证状态机规则是否生效。6.5 第五步完成 → 验收 → 关闭# 完成处理进入待验收 curl -X POST http://localhost:8080/api/tasks/1001/action \ -H Content-Type: application/json \ -d { action: COMPLETE, operatorUserId: 2, remark: 已修复并提交测试 }# 创建人验收通过 curl -X POST http://localhost:8080/api/tasks/1001/action \ -H Content-Type: application/json \ -d { action: PASS, operatorUserId: 1, remark: 验收通过 }# 管理员关闭任务 curl -X POST http://localhost:8080/api/tasks/1001/action \ -H Content-Type: application/json \ -d { action: CLOSE, operatorUserId: 1, remark: 归档关闭 }6.6 如何判断流程是否成功整体跑完后建议同时检查两类数据task_info表中的status是否最终变为CLOSED。task_status_log表中是否有完整的 5 条记录序号动作从状态到状态1ACCEPTPENDINGPROCESSING2COMPLETEPROCESSINGWAITING_ACCEPT3PASSWAITING_ACCEPTCOMPLETED4CLOSECOMPLETEDCLOSED如果日志表记录完整说明状态机设计正确事务回滚逻辑没有破坏审计记录。6.7 验证失败时的排查入口如果创建任务时报错“截止时间不能早于当前时间”先检查你传入的 deadline 格式是否正确以及系统当前时区是否配置为 Asia/Shanghai。如果受理任务时报“任务不存在”检查是否先完成了创建步骤确认返回的 ID 在数据库中真实存在。如果状态流转时报“当前任务状态不支持该操作”先查询当前任务状态再对照状态机配置表确认该状态下确实允许此动作。7. 常见问题与排查思路问题现象可能原因排查方式解决方案创建任务后状态不是“待受理”前端手动传了状态字段检查 Controller 入参是否接收了 status 字段接口不接收 status 参数Service 层强制初始化状态任务状态可以任意跳转没有使用状态机校验检查 action 接口是否调用了 canTransition统一走 handleAction 方法所有流转必须校验定时任务没有触发Cron 表达式错误或未开启 EnableScheduling检查启动类是否加了 EnableScheduling打印日志确认触发时间加上注解调整表达式配置监控日志定时任务被多实例重复执行多节点部署时每个节点都会执行检查部署架构引入分布式锁或改用分布式任务调度平台状态更新成功但日志表没数据日志插入与状态更新不在同一事务检查事务边界是否覆盖日志插入在事务内插入日志设置同样的 Transactional操作人无权限也能操作只做了登录校验没有做角色或数据权限校验检查 handleAction 中是否校验 operatorUserId在流转逻辑前增加操作人合法性校验XML 中 SQL 报语法错误小于号未转义检查 XML 文件中的大于小于号使用lt;代替使用gt;代替任务列表查询很慢状态字段无索引查看执行计划为 status、deadline、assignee_user_id 建立联合索引8. 最佳实践与工程建议任务管理流程本身逻辑不复杂但涉及状态一致性、权限边界、定时任务、审计日志等多个方面工程落地时建议遵循以下实践8.1 状态机必须是唯一修改状态的入口所有状态变更都必须经过同一个服务方法例如上面代码中的handleAction。不要在 Controller 里写task.setStatus(...)后直接updateById这样会把状态机规则完全架空。代码审查时重点审查是否有绕过状态机的写法。8.2 幂等性设计状态流转操作容易出现重复提交。比如用户点击两次“受理”按钮如果没有幂等处理第二次操作会被状态机拦截因为状态已经是“处理中”这是可以的。但更稳妥的做法是在接口层基于taskId action做短时间内的重复提交校验或者使用分布式锁保证同一任务同一时间只允许一个流转操作执行。8.3 操作日志记录要细致task_status_log表里的from_status字段非常重要。在更新状态前先从数据库查出原始状态记录到日志表再更新当前状态。不要把from_status写死也不要省略。8.4 权限控制要提前做为任务管理流程至少预留两个角色普通成员和管理员。普通成员只能操作自己创建或分派给自己的任务管理员可以查看和操作所有任务。如果流程中需要跨部门协作建议增加“任务监督员”角色负责查看进度但不直接处理任务。8.5 配置与代码分离超时阈值、提醒次数这些参数建议放到配置文件或配置中心不要硬编码在代码里。否则业务方调整 SLA 时每次都要改代码发版本。task: sla: reminder-hours-before-deadline: 24 timeout-escalation-minutes: 30 notify: enabled: true8.6 异常与回滚创建任务、状态流转、日志记录这些操作必须在同一个事务中。万一状态更新成功但日志插入失败整个事务回滚保证数据一致性。8.7 关于安全边界任务管理流程涉及用户身份与权限校验。特别注意两点不要通过前端传入的operatorUserId直接作为操作人应该从当前登录会话中获取用户身份。勿在日志中打印完整敏感信息。如果任务描述中包含业务敏感信息建议数据库字段加密存储日志只记录任务 ID 和状态变化。9. 总结与后续学习方向这篇文章的核心结论只有一句话任务管理流程不是“增删改查”而是一套受状态机约束的生命周期管理。从待受理、处理中、待验收到已完成、已关闭每一步都必须明确动作、操作人和前置条件。同时超时扫描、操作日志、权限校验是让这套流程真正可用的三个辅助能力。如果你想基于本文的流程继续深入建议按以下方向扩展将状态机规则改为配置化。当前代码中状态机是写死的 Java 代码如果业务复杂可以考虑将状态流转表放入数据库实现动态配置。引入消息通知机制。任务被分派、被退回、超时即将发生时都需要通过站内信、短信、邮件或企业微信等方式通知相关人员。建议使用消息队列异步发送不要把通知逻辑写在主流程事务中。引入分布式任务调度。如果系统是多实例部署定时扫描任务需要加分布式锁或者直接使用 XXL-Job 一类调度平台。设计任务看板。任务列表之外看板视图对业务方非常有用。按状态分组展示任务卡片拖拽卡片改变状态本质上调用的还是本文中的handleAction接口。补充任务统计报表。按状态、按人员、按时效统计任务完成率、平均处理时长这些数据是后续流程优化的依据。最后提醒一点如果系统里已经存在多个状态字段比如订单状态、审批状态不要各自孤立实现建议将这套状态机设计抽象为一个通用组件避免每个模块都重复实现一份“if-else 状态判断”。这个抽象过程本身也值得用一篇文章来展开。
