在业务系统开发里,“任务管理流程”是一个非常典型、出现频率很高的模块。它不只是在数据库里建一张任务表那么简单,更关键的是任务从创建、分配、处理、完成到取消、驳回这一套状态流转是否严谨,以及每个环节的操作权限、历史记录、并发控制是否到位。这篇文章按照一个前后端分离项目的常规做法,梳理“第 18 步:新增任务管理流程”的完整实现思路。文中会给出任务状态定义、数据库表设计、后端流转代码、前端页面交互、接口验证方式和常见故障排查路径,适合正在做 OA、项目管理系统、工单系统或者企业后台的开发者参考。
这里需要先澄清一个概念:本文讨论的“任务管理”是业务系统内部的任务、工单、待办流转,和操作系统“任务管理器”里清除启动项不是一回事。如果在查资料时搜到的是“任务管理启动项怎么清除”,说明关键词需要切到“任务模块开发”“任务流转设计”这类方向,否则很容易找偏。
1. 设计任务管理流程前,先把边界和规则定清楚
1.1 任务管理流程到底要管哪些环节
一个完整的任务管理流程,通常包含以下环节:
- 创建任务:填入标题、描述、优先级、负责人、计划时间等信息。
- 分配与确认:任务创建后进入待分配或待处理状态,负责人看到任务后开始处理。
- 执行与更新:处理人更新进度、补充说明,必要时记录任务关联的附件或子任务。
- 提交完成:处理人认为工作已完成,提交完成结果。
- 审核与驳回:由创建人或管理员确认结果,不合格则驳回。
- 取消与归档:任务因需求变更等原因取消,完成后进入归档状态。
很多项目在第一步就出错,原因不是代码复杂,而是没有把流程规则定义清楚。团队成员各自理解“完成”和“驳回”的含义,最后状态就乱了。因此设计阶段宁可多花时间讨论状态,也不要急着写代码。
1.2 角色、权限和操作边界
在设计任务模块时,至少要区分三类操作者:
- 创建人(发起人):负责建单、分配任务、审核结果。
- 处理人(执行人):接收任务后更新进度、提交完成。
- 管理员:拥有最高权限,可以调整任何任务的状态、重新分配负责人。
这三类角色并不是所有系统都一样。有的系统中创建人和审核人是同一人,有的系统中普通成员也能建任务但没有审核权。所以下面这张权限矩阵要根据实际项目调整,不能照搬。
| 操作 | 创建人 | 处理人 | 管理员 |
|---|---|---|---|
| 创建任务 | 是 | 是 | 是 |
| 修改任务基础信息 | 是 | 否 | 是 |
| 开始处理任务 | 否 | 是 | 是 |
| 更新任务进度 | 否 | 是 | 是 |
| 提交完成 | 否 | 是 | 是 |
| 驳回任务 | 是 | 否 | 是 |
| 取消任务 | 是 | 否 | 是 |
| 重新分配负责人 | 是 | 否 | 是 |
权限校验必须放在后端,不能只在前端隐藏按钮。前端隐藏按钮只是体验优化,真正的安全边界在后端接口。
1.3 需求确认阶段必须问清楚的问题
进入实现之前,建议先把下面几个问题确认掉,否则后期改状态机成本非常高:
- 任务是否可以指派给多人?如果有多人协作,是拆成多个子任务,还是只记录一个主负责人?
- 完成是否需要审核?如果不需要审核,处理人提交后直接进入已完成。
- 被驳回后回到哪个状态?是回到处理中,还是回到待处理并重新分配?
- 取消任务是否有权限限制?普通处理人能否取消自己创建的任务?
- 任务优先级变更是否需要留痕?
这些问题没有标准答案,但必须在开发前明确。下面所有示例按“创建后待处理,开始处理后处理中,提交完成后已完成,管理员可驳回回到处理中,创建人或管理员可取消”这条规则来设计。
2. 数据库建模:先定义状态和表结构,代码才有依据
2.1 状态定义与流转规则
本文采用 5 个状态,用数字存储,便于扩展和计算:
| 状态编码 | 状态名称 | 说明 |
|---|---|---|
| 0 | 待处理 | 任务刚创建或重新分配,等待负责人开始 |
| 1 | 处理中 | 负责人已领取,正在执行 |
| 2 | 已完成 | 已完成并归档,不可继续编辑 |
| 3 | 已驳回 | 审核未通过,已退回处理中 |
| 4 | 已取消 | 任务取消,不可继续操作 |
这里有一个需要注意的细节:有的设计会把“已驳回”作为单独状态,有的项目不单独存,而是复用“处理中”并附带驳回日志。单独存的好处是列表筛选和统计更直观,坏处是状态数变多、流转规则变复杂。文中采用“驳回后回到处理中,但日志中保留驳回动作”的方式。实际上驳回后的目标状态可以根据业务配置,不必拘泥于一种。
2.2 任务主表 DDL
任务表是整个模块的核心,字段设计要考虑列表查询、权限过滤、超时统计和时间线展示。
CREATE TABLE sys_task ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', task_no VARCHAR(32) NOT NULL COMMENT '任务编号', title VARCHAR(200) NOT NULL COMMENT '任务标题', description TEXT COMMENT '任务描述', priority TINYINT NOT NULL DEFAULT 1 COMMENT '优先级:1普通,2重要,3紧急', task_type VARCHAR(32) NOT NULL DEFAULT 'GENERAL' COMMENT '任务类型', parent_task_id BIGINT DEFAULT NULL COMMENT '父任务ID,用于子任务拆分', assigner_id BIGINT NOT NULL COMMENT '创建人ID', assignee_id BIGINT DEFAULT NULL COMMENT '负责人ID', dept_id BIGINT DEFAULT NULL COMMENT '责任部门ID', plan_start_time DATETIME DEFAULT NULL COMMENT '计划开始时间', plan_end_time DATETIME DEFAULT NULL COMMENT '计划完成时间', actual_finish_time DATETIME DEFAULT NULL COMMENT '实际完成时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待处理,1处理中,2已完成,3已驳回,4已取消', progress TINYINT NOT NULL DEFAULT 0 COMMENT '进度,0到100', remark VARCHAR(500) DEFAULT NULL COMMENT '备注', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', create_by BIGINT NOT NULL COMMENT '创建人', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_by BIGINT DEFAULT NULL COMMENT '更新人', update_time DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常,1删除', PRIMARY KEY (id), UNIQUE KEY uk_task_no (task_no), KEY idx_assignee_status (assignee_id, status), KEY idx_status_plan (status, plan_end_time), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务主表';设计这张表时,有几点值得说明:
task_no使用唯一索引,用于展示给用户的业务编号。不要在界面上暴露自增主键。idx_assignee_status用来支撑“我的待办列表”查询,这是任务模块最高频的查询场景。idx_status_plan用来支撑超时任务的定时扫描,例如找出“处理中但已经超过计划完成时间”的任务。progress使用 0 到 100 的整数,不要用浮点数,避免精度问题。version字段用于乐观锁,后续实现并发控制会用到。is_deleted是逻辑删除标记,业务表不建议物理删除。
2.3 任务操作日志表
状态流转过程一定要落日志,否则出了问题很难追溯“谁在什么时间把任务从哪个状态改成了哪个状态”。
CREATE TABLE sys_task_log ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, task_id BIGINT NOT NULL COMMENT '任务ID', task_no VARCHAR(32) NOT NULL COMMENT '任务编号', operator_id BIGINT NOT NULL COMMENT '操作人ID', operator_name VARCHAR(50) DEFAULT NULL COMMENT '操作人姓名', action_code VARCHAR(32) NOT NULL COMMENT '操作编码:CREATE,START,COMPLETE,REJECT,CANCEL', from_status TINYINT DEFAULT NULL COMMENT '原状态', to_status TINYINT DEFAULT NULL COMMENT '新状态', remark VARCHAR(500) DEFAULT NULL COMMENT '操作说明', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', KEY idx_task_id (task_id), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务操作日志表';操作日志表的作用不只是审计,前端还能根据日志记录渲染任务时间线,让用户看到任务从创建到完成的全过程。如果后面要接消息通知、邮件提醒,也可以基于这张日志表做事件驱动。
2.4 附表和扩展字段如何处理
如果任务需要关联附件、评论、子任务,建议拆成独立子表,不要全部塞进主表。常见做法:
sys_task_attachment:任务附件表,存储文件地址、文件名、上传人。sys_task_comment:任务评论表,存储评论内容和评论人。sys_task_item:子任务表,通过parent_task_id关联主任务。
附件表示例:
CREATE TABLE sys_task_attachment ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, task_id BIGINT NOT NULL, file_name VARCHAR(200) NOT NULL, file_url VARCHAR(500) NOT NULL, file_size BIGINT DEFAULT 0, upload_by BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_task_id (task_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务附件表';3. 后端实现:把状态流转约束在 Service 层
3.1 项目结构和依赖
后端按常见的 Spring Boot + MyBatis Plus 分层结构组织。如果项目用的是 JPA 或者 MyBatis 原生,思路一样,替换实现方式即可。
com.example.task ├── controller │ ├── TaskController.java │ └── TaskLogController.java ├── service │ ├── TaskService.java │ └── impl │ └── TaskServiceImpl.java ├── mapper │ ├── TaskMapper.java │ └── TaskLogMapper.java ├── entity │ ├── Task.java │ └── TaskLog.java ├── enums │ ├── TaskStatusEnum.java │ └── TaskActionEnum.java └── common ├── BizException.java └── Result.java核心依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>版本号只是一个常见参考,落地前要结合项目实际的 Spring Boot 版本确认兼容性,不要直接照搬。
3.2 实体类与枚举定义
任务实体使用 MyBatis Plus 注解映射:
@Data @TableName("sys_task") public class Task { @TableId(type = IdType.AUTO) private Long id; private String taskNo; private String title; private String description; private Integer priority; private String taskType; private Long parentTaskId; private Long assignerId; private Long assigneeId; private Long deptId; private LocalDateTime planStartTime; private LocalDateTime planEndTime; private LocalDateTime actualFinishTime; private Integer status; private Integer progress; private String remark; @Version private Integer version; private Long createBy; private LocalDateTime createTime; private Long updateBy; private LocalDateTime updateTime; @TableLogic private Integer isDeleted; }状态枚举:
public enum TaskStatusEnum { PENDING(0, "待处理"), PROCESSING(1, "处理中"), DONE(2, "已完成"), REJECTED(3, "已驳回"), CANCELLED(4, "已取消"); private final Integer code; private final String desc; TaskStatusEnum(Integer code, String desc) { this.code = code; this.desc = desc; } public static TaskStatusEnum of(Integer code) { for (TaskStatusEnum status : values()) { if (status.code.equals(code)) { return status; } } throw new IllegalArgumentException("未知任务状态:" + code); } }这里把状态枚举单独抽出来,是为了避免在 Service 和 Controller 里到处写魔法数字。后面还要在状态流转校验、列表筛选中复用。
3.3 流转规则与核心操作方法
状态流转是所有任务操作的核心。建议把“当前状态能否变成目标状态”的规则集中放在一个 Map 中维护,而不是散落在各个方法里。
@Service public class TaskServiceImpl implements TaskService { /** * 合法流转规则:当前状态 -> 允许的目标状态集合 */ private static final Map<Integer, Set<Integer>> TRANSITION_MAP = new HashMap<>(); static { // 待处理可以开始处理,也可以取消 TRANSITION_MAP.put(TaskStatusEnum.PENDING.getCode(), new HashSet<>(Arrays.asList( TaskStatusEnum.PROCESSING.getCode(), TaskStatusEnum.CANCELLED.getCode()))); // 处理中可以提交完成,也可以取消 TRANSITION_MAP.put(TaskStatusEnum.PROCESSING.getCode(), new HashSet<>(Arrays.asList( TaskStatusEnum.DONE.getCode(), TaskStatusEnum.CANCELLED.getCode()))); // 已完成只允许驳回回到处理中 TRANSITION_MAP.put(TaskStatusEnum.DONE.getCode(), new HashSet<>(Collections.singletonList( TaskStatusEnum.PROCESSING.getCode()))); // 已驳回状态不单独停留,实际会回到处理中,这里可保留通用规则 TRANSITION_MAP.put(TaskStatusEnum.REJECTED.getCode(), new HashSet<>(Collections.singletonList( TaskStatusEnum.PROCESSING.getCode()))); } @Override @Transactional(rollbackFor = Exception.class) public void changeStatus(Long taskId, Long operatorId, String actionCode, String remark) { Task task = getById(taskId); if (task == null) { throw new BizException("TASK_NOT_FOUND", "任务不存在"); } Integer targetStatus = TaskActionEnum.getTargetStatus(actionCode); if (!canTransition(task.getStatus(), targetStatus)) { throw new BizException("STATUS_NOT_ALLOWED", "当前状态:" + TaskStatusEnum.of(task.getStatus()).getDesc() + " 不允许执行:" + TaskActionEnum.of(actionCode).getDesc()); } checkPermission(task, operatorId, actionCode); boolean updated = updateStatusWithCheck(task, targetStatus, operatorId); if (!updated) { throw new BizException("STATUS_CHANGED", "任务状态已被其他人修改,请刷新后重试"); } saveLog(task.getId(), task.getTaskNo(), operatorId, actionCode, task.getStatus(), targetStatus, remark); } private boolean canTransition(Integer currentStatus, Integer targetStatus) { Set<Integer> allowed = TRANSITION_MAP.get(currentStatus); return allowed != null && allowed.contains(targetStatus); } }注意几个关键点:
- 方法加了
@Transactional,状态更新和日志写入要么同时成功,要么同时回滚。 - 流转校验在事务内执行,先查旧状态,再校验规则。
- 权限校验不能省略,后面会单独说。
updateStatusWithCheck是并发安全的关键,下一节展开。
3.4 并发控制:防止重复提交和状态覆盖
任务模块最常见的并发问题有两个:两个操作员同时处理同一个任务,或者用户在页面快速点击两次“提交完成”,导致状态被覆盖。只靠 Java 层 if 判断不够,必须让数据库在修改时校验旧状态。
Mapper 中声明条件更新语句:
public interface TaskMapper extends BaseMapper<Task> { int updateStatusWithCheck(@Param("id") Long id, @Param("currentStatus") Integer currentStatus, @Param("targetStatus") Integer targetStatus, @Param("actualFinishTime") LocalDateTime actualFinishTime, @Param("operatorId") Long operatorId); }XML 实现:
<update id="updateStatusWithCheck"> UPDATE sys_task SET status = #{targetStatus}, actual_finish_time = #{actualFinishTime}, version = version + 1, update_by = #{operatorId}, update_time = NOW() WHERE id = #{id} AND status = #{currentStatus} AND is_deleted = 0 </update>这段 SQL 的核心是WHERE id = #{id} AND status = #{currentStatus}。只有当当前状态和业务方法查询到的一致时,更新才生效;如果另一个请求已经先改了状态,这条 UPDATE 影响行数为 0,业务方法就能感知到并抛出友好提示。
其他任务字段更新也应该加上类似的版本判断,可以使用 MyBatis Plus 的乐观锁插件,也可以在自定义 SQL 中增加version条件。推荐在关键业务表上同时保留状态条件和版本号,双保险。
3.5 权限校验的实现位置
权限校验放在 Service 层,而不是 Controller 层。因为 Service 层是所有访问入口的汇聚点,即使以后新增接口,也绕不开这层校验。
private void checkPermission(Task task, Long operatorId, String actionCode) { boolean isCreator = task.getAssignerId().equals(operatorId); boolean isAssignee = operatorId.equals(task.getAssigneeId()); boolean isAdmin = adminService.isAdmin(operatorId); switch (TaskActionEnum.of(actionCode)) { case START: case COMPLETE: if (!isAssignee && !isAdmin) { throw new BizException("NO_PERMISSION", "只有负责人可以执行该操作"); } break; case REJECT: case CANCEL: if (!isCreator && !isAdmin) { throw new BizException("NO_PERMISSION", "只有创建人或管理员可以执行该操作"); } break; default: break; } }这个校验逻辑是示例,不同项目的角色体系差异很大。要注意的是,判断用户角色和用户身份不能直接从前端参数里取,而是从登录态中解析出来的用户信息获取。
3.6 任务编号的生成策略
任务编号要唯一且可读,常见格式是TASK加日期加序列号,例如TASK20250720001。生成方式尽可能避免依赖数据库自增主键,因为高并发下不保证连续,而且直接把主键暴露成业务编号也不安全。
简单可靠的方案:
String datePart = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); String taskNo = "TASK" + datePart + String.format("%04d", sequenceService.nextSequence(datePart));这里sequenceService可以基于每日一张序列表实现,也可以使用 Redis 的INCR命令。生产环境还要考虑多实例部署时序列不重复的问题。
4. 前端交互:按钮按状态展示,操作按流程执行
4.1 页面与接口的对应关系
前端建议拆成三个主要页面区域:任务列表、新建编辑弹窗、任务详情时间线。列表页根据状态展示不同操作按钮,新建弹窗负责录入任务基础信息,详情页展示任务日志和附件。
核心接口设计如下:
| 接口 | 方法 | 说明 |
|---|---|---|
/api/task/page | POST | 分页查询任务列表 |
/api/task/detail | GET | 查询任务详情和日志 |
/api/task/create | POST | 创建任务 |
/api/task/update | POST | 编辑任务基础信息 |
/api/task/start | POST | 开始处理 |
/api/task/complete | POST | 提交完成 |
/api/task/reject | POST | 驳回任务 |
/api/task/cancel | POST | 取消任务 |
这里把开始、完成、驳回、取消都设计成独立接口,业务语义清晰,也方便后续在状态变更时加消息通知。不用为了省代码把所有操作合并成一个通用接口,否则每个操作都要传一堆差异化参数,很难维护。
4.2 列表状态标签和操作按钮示例
以 Vue + Element UI 为例,列表页根据状态字段控制按钮显示:
<template> <el-table v-loading="loading" :data="taskList"> <el-table-column prop="taskNo" label="任务编号" width="180" /> <el-table-column prop="title" label="任务标题" min-width="200" /> <el-table-column label="状态" width="100"> <template slot-scope="{ row }"> <el-tag :type="statusTag[row.status]"> {{ statusText[row.status] }} </el-tag> </template> </el-table-column> <el-table-column label="操作" width="280"> <template slot-scope="{ row }"> <el-button v-if="row.status === 0" size="mini" type="primary" @click="handleStart(row)">开始处理</el-button> <el-button v-if="row.status === 1" size="mini" type="success" @click="handleComplete(row)">提交完成</el-button> <el-button v-if="row.status === 1" size="mini" type="warning" @click="handleCancel(row)">取消任务</el-button> <el-button v-if="row.status === 2" size="mini" type="danger" @click="handleReject(row)">驳回</el-button> <el-button size="mini" @click="handleDetail(row)">详情</el-button> </template> </el-table-column> </el-table> </template>按钮的显示条件只是用户交互的一部分。即使前端隐藏了某个按钮,也不能保证恶意用户无法直接调用后端接口,所以后端校验始终是第一道真正的防线。
4.3 提交完成和驳回的确认弹窗
状态变更操作往往是不可逆或需要填备注的,使用确认弹窗能减少误操作。提交完成时可以顺便填写完成说明,驳回时必须填写驳回原因。
<el-dialog title="提交完成" :visible.sync="completeDialogVisible" width="500px"> <el-form :model="completeForm"> <el-form-item label="完成说明" required> <el-input v-model="completeForm.remark" type="textarea" :rows="4" placeholder="请填写完成说明,便于后续追溯" /> </el-form-item> </el-form> <div slot="footer"> <el-button @click="completeDialogVisible = false">取消</el-button> <el-button type="primary" :loading="submitting" @click="submitComplete"> 确认完成 </el-button> </div> </el-dialog>提交时把按钮置为 loading 状态,可以避免用户重复点击造成重复请求。不过这只解决了一部分重复提交问题,真正的幂等还是要依靠后端的状态条件更新。
5. 从创建到完成的验证流程
5.1 准备测试数据和登录态
开发环境先准备两个测试用户:一个创建人(例如用户 1001),一个处理人(例如用户 1003)。后端接口通过登录后返回的 Token 识别当前用户,验证时把 Token 放到请求头的 Authorization 中。
5.2 接口调用验证主流程
按正常流程依次调用接口,第一步创建任务:
curl -X POST 'http://localhost:8080/api/task/create' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <token>' \ -d '{ "title": "编写部署脚本", "description": "完成测试环境一键部署脚本", "priority": 2, "assigneeId": 1003, "planStartTime": "2025-07-20 10:00:00", "planEndTime": "2025-07-25 18:00:00" }'预期返回任务 ID 和任务编号。接着用处理人账号开始处理:
curl -X POST 'http://localhost:8080/api/task/start' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <token>' \ -d '{"taskId": 1, "remark": "已领取任务,开始编写脚本"}'处理完成后,提交完成:
curl -X POST 'http://localhost:8080/api/task/complete' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <token>' \ -d '{"taskId": 1, "remark": "脚本已编写完成,测试环境验证通过"}'如果审核不通过,用创建人账号驳回:
curl -X POST 'http://localhost:8080/api/task/reject' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <token>' \ -d '{"taskId": 1, "remark": "缺少回滚脚本,驳回补充"}'此时任务状态应该从已完成回到处理中。处理人补充材料后再次提交完成,流程闭环。
5.3 验证数据库状态和日志
接口调用完成后,检查数据库中的记录:
SELECT id, task_no, status, version, actual_finish_time FROM sys_task WHERE id = 1; SELECT task_id, action_code, from_status, to_status, operator_id, remark FROM sys_task_log WHERE task_id = 1 ORDER BY id;正常情况应该能看到创建、开始、完成、驳回、再次完成等日志记录,每一条日志都有明确的前后状态。
5.4 验证异常和越权场景
流程验证不能只测正常路径,还要至少验证这些异常分支:
- 处理人直接调用驳回接口,应该返回权限不足。
- 已完成的任务再次点击开始处理,应该返回状态不允许。
- 两个请求同时提交完成,只有一条 UPDATE 生效,另一个提示“状态已被修改”。
- 删除后的任务查询详情,应该返回任务不存在。
6. 常见问题排查:现象、原因和处理路径
任务管理模块上线后,最常见的问题集中在状态错乱、权限漏洞、性能和日志缺失四个方面。下面按排查顺序整理一个速查表。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 点击开始处理提示状态不允许 | 页面上任务状态与数据库状态不一致 | 查询sys_task中该任务的实际 status | 刷新列表重新获取最新状态,不要使用过期缓存数据 |
| 操作成功后页面状态未变化 | 前端接口返回成功但列表未刷新 | 检查Promise结束后是否重新调用查询接口 | 每次状态变更成功后主动刷新当前列表数据 |
| 两条提交完成请求都提示成功 | 缺少条件更新,直接在代码中updateById | 查看接口日志和 SQL 执行记录 | 改为WHERE status = 当前状态的条件更新 |
| 普通用户能完成他人任务 | 后端没有校验assigneeId | 用两个账号交叉调用接口复测 | 在 Service 层补齐权限校验 |
| 驳回后任务状态没有回到处理中 | 驳回方法直接设置状态为已驳回,没有走流转规则 | 查看日志表to_status字段 | 统一走changeStatus方法,通过动作编码计算目标状态 |
| 超时任务没有提醒 | 定时任务只扫描了一个状态 | 检查扫描条件中的状态值 | 同时扫描待处理和处理中任务,并关联计划结束时间 |
| 列表接口响应慢 | 缺少索引或查询条件未走索引 | 用EXPLAIN查看执行计划 | 确认assignee_id、status、create_time上有合适索引 |
| 任务编号重复数据库报错 | 序列号从 0 开始每天重置或并发冲突 | 查看异常日志中的唯一键冲突 | 使用 Redis 自增或数据库序列表控制并发 |
排查这类问题时,建议先看日志表。任务模块因为有sys_task_log,每一步操作都有记录,只要日志完整,很快就能定位是谁做了操作、为什么状态变成现在这样。如果发现日志缺失,说明日志写入逻辑没放在和状态更新同一个事务里,修复后要回归验证。
7. 生产环境必须补充的工程措施
7.1 超时任务的定时提醒
任务管理不能只等用户操作,超时任务需要系统主动发现。常见做法是写一个@Scheduled定时任务,每分钟或每小时扫描一次超时任务,并给处理人发送通知。
@Component public class TaskTimeoutJob { private final TaskService taskService; public TaskTimeoutJob(TaskService taskService) { this.taskService = taskService; } @Scheduled(cron = "0 0 * * * ?") public void checkTimeoutTasks() { List<Task> timeoutTasks = taskService.findTimeoutTasks(); for (Task task : timeoutTasks) { // 发送站内消息或邮件提醒 notifyService.notifyTimeout(task); } } }这里的findTimeoutTasks要按状态和计划结束时间过滤,例如扫描状态为待处理或处理中、plan_end_time小于当前时间、且未发送过提醒的任务。注意做好去重,避免每个周期重复提醒。
7.2 日志、监控和回滚
生产环境要为任务模块补充以下能力:
- 操作日志保留足够长时间,至少覆盖业务追责和审计周期。
- 状态变更接口的关键参数、操作人、耗时写入应用日志。
- 对任务列表接口增加耗时监控,超过阈值时告警。
- 定时任务执行后要有执行记录,防止任务因代码异常而静默失败。
回滚场景也要提前规划。如果任务状态被误操作,不是简单把 status 改回来,而是要走新的状态变更操作并留日志。直接手工修改数据库状态会给后续审计带来很大麻烦。
7.3 学习和生产环境的差异
开发环境可以精简很多配置,但生产环境至少要补齐下面几项:
| 项目 | 学习环境做法 | 生产环境建议 |
|---|---|---|
| 数据库 | 使用本地 MySQL,简化字段 | 增加慢查询日志、备份策略、主从或高可用方案 |
| 权限 | 只做登录拦截 | 结合角色权限模型、数据权限过滤 |
| 消息通知 | 不接或直接打日志 | 接入站内信、短信或企业办公通知接口 |
| 任务编号生成 | 简单日期加随机数 | 使用 Redis 序列或数据库序列保证多实例唯一 |
| 定时任务 | 注释掉或手动触发 | 独立调度平台,避免多实例重复执行 |
7.4 后续扩展方向
任务管理模块做完之后,常见的扩展方向包括:
- 任务关联项目或工单,支持跨模块跳转。
- 引入看板视图,按状态分组展示任务卡片。
- 任务提醒支持自定义规则,例如提前一天、提前一小时提醒。
- 增加任务报表,统计完成率、超时率、各负责人负载。
- 引入消息队列,状态变更后异步触发通知、搜索索引更新。
如果这个模块是整套系统“第 18 步”的功能点,说明前后已经接入了用户体系、权限体系和消息体系。任务模块作为承接业务流转的载体,最适合作为串联这些基础能力的例子,做完这一个模块,后面的待办中心、审批流、工单系统都能复用这套状态机和日志设计。
实际项目里最值得投入时间的不是页面样式,而是状态流转规则和并发控制。先把这两点做好,再考虑界面优化和扩展功能,任务的可靠性自然就有了。