1. 一个字段,为什么需要单独讨论
给任务表加一个 status 字段并不难。未开始是一个值,进行中是一个值,完成又是一个值。难的是:谁有权、在什么条件下把它从一个值改成另一个值?
在标注平台里,我发现这才是更值得问的问题。比如任务 #100 正在标注猫狗图片。如果接口允许传入任意状态,标注员可以跳过提交,直接把任务改成“审核通过”。数据库接受了一个合法字符串,业务却少走了整个审核环节。
// 只校验状态名称合法,仍不足以保护业务流程。
task.setStatus(request.status());
taskMapper.updateById(task);问题不在于字段是字符串还是枚举,而在于接口有没有表达“这次究竟在做什么”。“把状态改成审核通过”听起来合理,但接口真正想表达的是“对这份标签执行审核通过操作”,而不是“设置字段”。
2. 先说操作,再说状态
想象管理员把任务分给小陈,小陈提交标签,小林检查标签。小林如果发现错误,就驳回给小陈修改。
把人名去掉,剩下的就是四个动作:分配、提交、通过、驳回。每个动作都有前提。
| 当前状态 | 动作 | 操作后的状态 |
|---|---|---|
| 未分配 PENDING | 分配 | 标注中 ANNOTATING |
| 标注中 ANNOTATING | 提交 | 待审核 REVIEWING |
| 待审核 REVIEWING | 驳回 | 标注中 ANNOTATING |
| 待审核 REVIEWING | 通过 | 已通过 APPROVED |
这里把等待开始和正在修改都放在 ANNOTATING,先不区分领取、开始、暂停。表里没有列出的转换,就不开放。
这张表其实已经是一台很小的状态机(state machine,由一组状态和状态间的迁移规则组成的模型):给定当前状态和一个动作,决定能否前进,以及会走到哪里。它回答的不是“状态应该叫什么”,而是“从 A 到 B 要经过哪条被允许的路”。
AWS 的多阶段标注工作流把初始标注与后续质检分阶段组织。我从中借用的是流程需要明确衔接这一点;这里的枚举和转换规则,是围绕单张图片做的简化。
3. 把规则写在能够找到的地方
如果每个 Service 都写一组自己的 if,很容易出现提交接口允许一种状态、批量提交接口又允许另一种状态的情况。同样的动作,两个入口行为不一致,排查时只能逐段翻代码。
规则很少时,可以先把转换集中起来。下面是 Java 17 及以上可用的简化写法:
enum Status { PENDING, ANNOTATING, REVIEWING, APPROVED }
enum Action { ASSIGN, SUBMIT, APPROVE, REJECT }
static Status next(Status current, Action action) {
return switch (action) {
case ASSIGN -> require(current, Status.PENDING,
Status.ANNOTATING);
case SUBMIT -> require(current, Status.ANNOTATING,
Status.REVIEWING);
case APPROVE -> require(current, Status.REVIEWING,
Status.APPROVED);
case REJECT -> require(current, Status.REVIEWING,
Status.ANNOTATING);
};
}
static Status require(Status actual, Status expected, Status target) {
if (actual != expected) {
throw new IllegalStateException("当前状态不允许此操作");
}
return target;
}这个方法只回答一个问题:状态能不能这样变。输入不能为空,异常需要在接口层转换成能理解的业务提示。
它不查询数据库,也不发送通知。我倾向于把这部分保持简单,因为以后看审核规则时,不需要先钻进缓存、消息和文件上传代码里。
但调用 next 得到一个新状态,还不等于整次操作已经成立。规则集中了,校验的人、校验的时刻、校验的对象还没回答。
4. 状态允许,不代表这个人可以操作
待审核任务可以通过,但不能让任务的标注员随意通过自己的提交。一个项目的质检员,也不能因为拥有同名角色,就处理另一个项目的数据。
所以,一次通过操作至少要依次看这些信息:
当前登录身份
↓
是否拥有这个项目的质检权限
↓
任务是否待审核,提交是否仍是当前版本
↓
更新状态,同时保存审核结论身份由登录会话取得;项目范围由任务归属确定。前端可以根据后端返回的可用操作显示按钮,但最终校验仍然在后端完成。
这种拆分让我更容易理解两个概念:状态(state) 说的是“现在能做什么”,权限(authorization) 说的是“这个人能不能做”。二者缺一不可,状态机的迁移规则负责前者,业务权限负责后者。
本系列采用提交人与审核人不同的规则。检查的是这份提交的提交人,即使账号兼有标注和质检角色,也不能绕过这个限制。
5. 驳回后回到原状态,历史会不会丢
任务 #100 第一次提交把猫标成了狗,被驳回后,又回到了 ANNOTATING。
看上去,它和第一次还没有提交时一样。实际上已经发生过一次提交和一次审核,只看当前状态当然分不出来。
当前状态:ANNOTATING
历史记录:
小陈提交 annotation #501:dog
小林审核 annotation #501:驳回,主体应为猫因此,状态负责表达当前进度,标注记录和审核记录负责保存已经发生的事情。不能因为状态回去了,就把旧记录也删掉。
这里也值得重新认识状态:当前状态只是流程坐标,不是全部事实。数据库里真正不可丢弃的,是每一步操作的记录;状态字段只是这些记录推导出的“现在在哪”。
再次提交时创建 #502,审核员需要检查 #502,而不是继续操作旧页面里的 #501。提交编号比“当前状态仍然叫待审核”更准确地指明操作对象。
导出也要另看。一次通过的结果可以交付给不同接收方,所以这里用导出批次记录交付,任务保持 APPROVED,不在这条状态机里加入一个阻止再次交付的终结状态。
6. 一次动作,可能要改好几张表
提交标签,往往同时涉及新增标注记录、更新当前提交编号和修改任务状态。
如果只写入了新标注,却没有把任务改成待审核,审核员可能找不到它;反过来,任务已经待审核,却查不到对应提交,也无法继续处理。
这些数据库修改应当在同一个事务(transaction,一组要么全部成功要么全部回滚的数据库操作) 里提交。Spring 可以通过 @Transactional管理这个边界,但需要正确的数据源、事务管理器和代理调用方式。同一个对象内部直接调用自己的事务方法,不会自动经过默认事务代理——this 调用绕过了 Spring 生成的代理对象,注解自然不生效。
还要注意,Java 里刚刚判断过状态,数据库中的状态仍可能在随后被另一个请求改变。实际更新需要把旧状态、当前提交编号等条件带进 SQL,并检查受影响行数:
UPDATE annotation_task
SET status = 'REVIEWING',
current_annotation_id = ?
WHERE id = ?
AND status = 'ANNOTATING'
AND current_annotation_id = ?;受影响行数大于 0,才说明“在这一次写入的时刻”前提依然成立。状态机负责规定合法路径;条件更新负责判断落库那一刻,是否仍满足前提。给状态机换一个框架,并不会自动补上后者。
7. 什么时候才需要更多状态
看到任务流程,很容易继续增加“领取中”“提交中”“通知中”等状态。但每增加一个状态,就要解释谁进入它、怎样离开、失败时怎么办——状态多了,规则说明的成本随之上升。
对于一次很短的数据库提交,没有必要专门加“提交中”。如果以后导出文件需要较长时间,可以让导出批次拥有自己的生成中、可下载和失败状态。
这样,标注任务和文件生成分别表达自己的进度,不需要把所有步骤挤进同一个字段。状态应该跟着业务粒度走,而不是跟着页面交互的细节走。
我理解的状态机,首先是一组能讲清楚的业务规则。等到出现分支很多、等待时间很长、需要定时恢复的流程,再考虑引入工作流引擎(workflow engine,用于编排长流程、等待事件与失败恢复的工具),会更容易判断它究竟解决什么问题——普通的状态机在规则简单时足够清晰,工作流引擎的价值在编排复杂长流程时才体现出来。
以后重新看这张状态表,我会先问每个环节有没有独立的业务意义。例如,驳回后可以立即修改,就回到 ANNOTATING;如果需要先申诉或等待重新分配,再为这个等待过程增加状态。状态数量应当跟着业务变化,而不是跟着页面按钮增加。
相关阅读
- 从 CRUD 到 AI 数据平台:一条标注数据如何走到交付
- 两个质检员同时审核一条数据,会发生什么?
- Redis 到底应该缓存什么:从标注平台的看板说起
- 为什么训练任务不应该直接塞进 Spring Boot 请求里