AI 数据平台

为什么标注任务需要状态机,而不只是一个 status 字段

从提交、驳回和重新标注出发,理解状态如何约束操作,以及它与权限、事务的关系。

1. 一个字段,为什么需要单独讨论

给任务表加一个 status 字段并不难。未开始是一个值,进行中是一个值,完成又是一个值。难的是:谁有权、在什么条件下把它从一个值改成另一个值?

在标注平台里,我发现这才是更值得问的问题。比如任务 #100 正在标注猫狗图片。如果接口允许传入任意状态,标注员可以跳过提交,直接把任务改成“审核通过”。数据库接受了一个合法字符串,业务却少走了整个审核环节。

java3 行
// 只校验状态名称合法,仍不足以保护业务流程。
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 及以上可用的简化写法:

java22 行
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. 状态允许,不代表这个人可以操作

待审核任务可以通过,但不能让任务的标注员随意通过自己的提交。一个项目的质检员,也不能因为拥有同名角色,就处理另一个项目的数据。

所以,一次通过操作至少要依次看这些信息:

text7 行
当前登录身份
      ↓
是否拥有这个项目的质检权限
      ↓
任务是否待审核,提交是否仍是当前版本
      ↓
更新状态,同时保存审核结论

身份由登录会话取得;项目范围由任务归属确定。前端可以根据后端返回的可用操作显示按钮,但最终校验仍然在后端完成。

这种拆分让我更容易理解两个概念:状态(state) 说的是“现在能做什么”,权限(authorization) 说的是“这个人能不能做”。二者缺一不可,状态机的迁移规则负责前者,业务权限负责后者。

本系列采用提交人与审核人不同的规则。检查的是这份提交的提交人,即使账号兼有标注和质检角色,也不能绕过这个限制。

5. 驳回后回到原状态,历史会不会丢

任务 #100 第一次提交把猫标成了狗,被驳回后,又回到了 ANNOTATING。

看上去,它和第一次还没有提交时一样。实际上已经发生过一次提交和一次审核,只看当前状态当然分不出来。

text5 行
当前状态:ANNOTATING

历史记录:
  小陈提交 annotation #501:dog
  小林审核 annotation #501:驳回,主体应为猫

因此,状态负责表达当前进度,标注记录和审核记录负责保存已经发生的事情。不能因为状态回去了,就把旧记录也删掉。

这里也值得重新认识状态:当前状态只是流程坐标,不是全部事实。数据库里真正不可丢弃的,是每一步操作的记录;状态字段只是这些记录推导出的“现在在哪”。

再次提交时创建 #502,审核员需要检查 #502,而不是继续操作旧页面里的 #501。提交编号比“当前状态仍然叫待审核”更准确地指明操作对象。

导出也要另看。一次通过的结果可以交付给不同接收方,所以这里用导出批次记录交付,任务保持 APPROVED,不在这条状态机里加入一个阻止再次交付的终结状态。

6. 一次动作,可能要改好几张表

提交标签,往往同时涉及新增标注记录、更新当前提交编号和修改任务状态。

如果只写入了新标注,却没有把任务改成待审核,审核员可能找不到它;反过来,任务已经待审核,却查不到对应提交,也无法继续处理。

这些数据库修改应当在同一个事务(transaction,一组要么全部成功要么全部回滚的数据库操作) 里提交。Spring 可以通过 @Transactional管理这个边界,但需要正确的数据源、事务管理器和代理调用方式。同一个对象内部直接调用自己的事务方法,不会自动经过默认事务代理——this 调用绕过了 Spring 生成的代理对象,注解自然不生效。

还要注意,Java 里刚刚判断过状态,数据库中的状态仍可能在随后被另一个请求改变。实际更新需要把旧状态、当前提交编号等条件带进 SQL,并检查受影响行数:

sql6 行
UPDATE annotation_task
SET status = 'REVIEWING',
    current_annotation_id = ?
WHERE id = ?
  AND status = 'ANNOTATING'
  AND current_annotation_id = ?;

受影响行数大于 0,才说明“在这一次写入的时刻”前提依然成立。状态机负责规定合法路径;条件更新负责判断落库那一刻,是否仍满足前提。给状态机换一个框架,并不会自动补上后者。

7. 什么时候才需要更多状态

看到任务流程,很容易继续增加“领取中”“提交中”“通知中”等状态。但每增加一个状态,就要解释谁进入它、怎样离开、失败时怎么办——状态多了,规则说明的成本随之上升。

对于一次很短的数据库提交,没有必要专门加“提交中”。如果以后导出文件需要较长时间,可以让导出批次拥有自己的生成中、可下载和失败状态。

这样,标注任务和文件生成分别表达自己的进度,不需要把所有步骤挤进同一个字段。状态应该跟着业务粒度走,而不是跟着页面交互的细节走。

我理解的状态机,首先是一组能讲清楚的业务规则。等到出现分支很多、等待时间很长、需要定时恢复的流程,再考虑引入工作流引擎(workflow engine,用于编排长流程、等待事件与失败恢复的工具),会更容易判断它究竟解决什么问题——普通的状态机在规则简单时足够清晰,工作流引擎的价值在编排复杂长流程时才体现出来。

以后重新看这张状态表,我会先问每个环节有没有独立的业务意义。例如,驳回后可以立即修改,就回到 ANNOTATING;如果需要先申诉或等待重新分配,再为这个等待过程增加状态。状态数量应当跟着业务变化,而不是跟着页面按钮增加。

相关阅读

← 返回文章