1. 图片上传以后,工作才刚开始
刚接触 Spring Boot 时,一个需求很容易被翻译成几层代码:Controller 接收请求,Service 处理逻辑,Mapper 把数据写进 MySQL。接口能返回,页面能显示,功能似乎就完成了。
但在AI 数据标注与模型训练管理平台这个场景里,我更想弄清楚另一件事:一张图片上传以后,要经过哪些步骤,才能交给后面训练模型的人?
先把范围缩小。假设要给一批动物图片标注类别,每张图片选择一个标签:猫或者狗。
只考虑保存,事情很简单:上传图片,选中标签,点击提交。但如果标注员把一张猫的图片选成了“狗”,系统也会正常返回成功。
保存成功,只说明数据写进去了。至于这份数据能不能交付,还需要另一个人检查。
于是,一张图片要走过这样的过程:
导入图片 → 分配任务 → 填写标签 → 提交审核
│
┌────────┴────────┐
↓ ↓
审核通过 驳回修改
↓ │
加入导出批次 重新提交审核这里开始出现普通文件上传没有回答的问题:谁负责这张图片?审核员看的是什么结果?被驳回后,之前的标签还留不留?
这些问题,决定了后面的表和接口应该怎么写。
2. 一张图片,为什么会牵出这么多对象
最容易想到的办法,是直接在图片表里增加几个字段:
sample
id
image_path
label
status一个人给图片填标签时,这样很直观。但加上任务分配和审核以后,sample 就要同时表达图片是什么、谁在处理、处理到哪一步、审核结论是什么。
我更容易理解的拆法,是先让每个对象回答一个具体问题:
| 对象 | 回答的问题 |
|---|---|
| Dataset | 这批图片属于哪个数据集? |
| Sample | 具体是哪张图片,文件在哪里? |
| AnnotationTask | 谁负责标注,现在进行到哪一步? |
| Annotation | 这一次提交给出了什么标签? |
| Review | 谁检查了这次提交,结论和原因是什么? |
| ExportBatch | 这次交付选中了哪些已经通过的结果? |
这里先约定,一条任务只处理一张图片;同一张图片在这个流程里只对应一条任务。真实业务可以按批次分配,但不妨先把一张图片讲清楚。
Dataset(数据集)
└─ Sample(图片)
└─ AnnotationTask(标注任务)
├─ Annotation v1 ── Review:驳回
└─ Annotation v2 ── Review:通过
↑
ExportBatch 的明细引用任务表达工作安排,一次提交表达工作结果。两者分开以后,驳回一次就不需要复制图片或新建整条任务,只需要留下旧提交,再记录新的提交。
对象数量增加,是因为需要保存的信息有了不同的生命周期。例如,任务还在继续,第一次审核的结论却已经确定,不应该跟着下一次修改一起被覆盖。
3. 保存了标签,不代表可以交付
AWS 在介绍 Ground Truth 的标签验证与修正时,把检查已有标签和调整标签作为不同的处理步骤。这让我更容易理解:填完标签之后,还需要明确的质量检查环节。
它的另一篇多阶段标注工作流文章也将初始标注与后续质检连接起来。对于这里的小型 Java 平台,可以先用任务状态表达这些环节,不必一开始就引入云端工作流服务。
下面是本文使用的简化状态:
PENDING(未分配)
│ 分配
↓
ANNOTATING(待处理或修改中)
│ 提交
↓
REVIEWING(待审核)
├─ 驳回 → ANNOTATING
└─ 通过 → APPROVED(已通过)这里把待开始和正在填写合并为 ANNOTATING。如果以后确实需要统计领取时间、开始时间,再细分也来得及。
有了这些状态,接口就可以围绕操作来组织:
POST /annotation-tasks/{id}/assign
POST /annotation-tasks/{id}/submit
POST /annotation-tasks/{id}/approve
POST /annotation-tasks/{id}/reject例如,提交要求操作者是任务的标注员,并且任务处于 ANNOTATING;审核要求操作者有当前项目的质检权限,并且任务处于 REVIEWING。
标签也不能任意填写。即使前端只有“猫”和“狗”两个选项,后端仍然需要检查提交的标签是否属于这个数据集的标签范围。
状态的作用,是让每个操作有明确的前提和结果。 隐藏前端按钮能减少误操作,真正的规则仍然要落在后端。
4. 被驳回以后,旧标签要不要留下来
继续看那张猫的图片,编号是 sample-17。
假设第一次提交的标签是“狗”,审核员留下意见:“图片主体是猫,请重新确认。”标注员修改后,又提交了一次。
如果直接执行 UPDATE sample SET label = 'cat',最后只能看到“猫”。之前为什么驳回、审核员当时看到什么,都很难从当前记录里回答。
因此,可以把每次提交保存为独立记录:
任务 #100,图片 sample-17
提交 #501:revision = 1,label = dog
审核 #801:annotation_id = 501,结论 = 驳回
提交 #502:revision = 2,label = cat
审核 #802:annotation_id = 502,结论 = 通过审核关联的是 annotation_id,这样“审核通过”就有了明确对象:通过的是提交 #502,而不是这张图片以后所有的标签。
这里的版本是同一条任务的标注修订,还不是整个数据集的版本。
规则也要跟上:提交后的标注内容不再原地修改;驳回后在编辑区修改,再提交为新记录。任务保存 current_annotation_id,指向当前等待处理的提交。task_id 和 revision 的组合应保持唯一。
这条简化流程只允许已通过结果进入导出,不开放“通过后直接改标签”。如果以后需要纠错,应该增加明确的重新打开流程,让新提交重新经过审核。
这样看历史时,修改过程才有连续的上下文。
5. 审核按钮背后需要检查什么
审核页打开的时候,任务 #100 的当前提交可能是 #502。点击通过时,请求应同时携带任务编号和提交编号,告诉后端“我要审核的是这一份”。
下面用简化代码说明,省略 Controller、DTO 和 Mapper 定义:
@Transactional(rollbackFor = Exception.class)
public void approve(long taskId, long annotationId) {
long reviewerId = currentUser.requireId();
AnnotationTask task = taskMapper.requireById(taskId);
permissions.requireProjectReviewer(
reviewerId, task.getProjectId());
Annotation submitted = annotationMapper.requireById(annotationId);
if (submitted.getTaskId() != taskId) {
throw new BusinessException("提交不属于当前任务");
}
if (submitted.getSubmitterId() == reviewerId) {
throw new BusinessException("不能审核自己的提交");
}
if (task.getStatus() != TaskStatus.REVIEWING) {
throw new BusinessException("当前任务不在待审核状态");
}
int changed = taskMapper.approveCurrent(
taskId, annotationId);
if (changed != 1) {
throw new BusinessException("任务已变化,请刷新后重试");
}
reviewMapper.insertApproved(annotationId, reviewerId);
}审核人从当前登录身份取得,不能相信请求里随便传来的 reviewerId。权限检查也要包含项目范围,否则“拥有质检员角色”可能被错误地理解为“可以审核所有项目”。
这里要求提交人与审核人不同。因此,即使同一个账号同时拥有标注和质检角色,也不能通过自己的提交;判断依据是这份不可变提交的提交人,而不是任务后来分配给了谁。
approveCurrent 对应的关键 SQL 是:
UPDATE annotation_task
SET status = 'APPROVED'
WHERE id = #{taskId}
AND status = 'REVIEWING'
AND current_annotation_id = #{annotationId};为什么 Java 已经检查过状态,SQL 还要再检查?
因为查询和更新是两个时刻。查到待审核,不代表更新时它仍然待审核。SQL 把状态和当前提交编号放进更新条件,只有仍然满足条件的任务才能改为通过。
受影响行数为 0 时,可能是任务已经被处理,也可能是页面上的提交已经过时。后端应返回冲突提示,让用户刷新,而不能继续记录一条成功审核。
状态修改和审核记录写入,还需要在同一个事务里完成。否则,前者成功、后者失败,就会留下“已经通过,但查不到审核记录”的任务。
这里假定这些 Mapper 使用同一事务数据源,并且由其他 Spring Bean 调用这个受代理的 Service 方法。Spring 的事务文档提醒,默认代理模式下,同一个对象内部直接调用自己的事务方法,不会因此开启事务。@Transactional 需要结合调用方式理解。
这段代码只展开审核通过。提交和驳回同样需要各自的状态条件;新增标注、切换当前提交以及写入驳回意见,也应与相应状态变化一同提交。
6. 导出时,到底应该拿走哪些数据
现在,sample-17 有两份标签:#501 是“狗”,被驳回;#502 是“猫”,已通过。
导出如果只按数据集查出全部标注,两个结果都会出现。即使取最新提交,也不能保证它已经审核通过。
对于这里的流程,导出的候选项应当同时满足:任务属于本次选择的数据集、任务已通过、当前提交有对应的通过记录。操作者还需要拥有该项目的数据导出权限。
接下来,需要把选中的提交固定下来:
导出批次 #900
└─ 明细:sample-17 → annotation #502 → review #802批次负责记录一次交付,明细负责记录交付内容。文件生成时按照明细读取,不能再次查询“目前最新的标签”,否则生成的文件可能偏离创建批次时选定的内容。
以 JSON 为例,一条记录可以长这样:
{
"sample_id": "sample-17",
"image": "images/sample-17.jpg",
"label": "cat",
"annotation_id": 502
}这里的 image 表示交付包内的相对路径;接收者还需要拿到对应图片文件。数据库里的本地绝对路径,或者已经过期的临时下载地址,都不能替代可读取的图片。
生成文件也有失败的可能。因此,批次先记录为生成中,文件写完并可读取后才标记为可下载;失败时保留明细,便于针对同一批内容重试。数据库事务本身不能保证文件一定写成功。
我也不准备把导出解释为任务的终结状态。同一份已通过结果可能交付多次,留在 APPROVED,再通过批次记录交付历史,更容易表达这件事。
7. 我对后端开发多了一层理解
沿着这张图片往下追,我对 Service 里该写什么,有了更具体的认识。
分配任务,需要知道谁有权安排工作;提交标签,需要检查当前负责人和标签范围;审核,需要明确正在检查哪一份结果;导出,需要固定最终交付的内容。
这些规则会决定表怎么关联、接口接收什么参数,以及什么情况下应该返回失败。Controller、Service、Mapper 仍然有用,但只有先说清楚这些问题,代码分层才有内容可放。
这里还有两个问题值得继续往下做。一个是并发审核:两个审核员几乎同时点击时,除了阻止重复更新,还要让失败的一方得到清楚的反馈。另一个是交付后的纠错:旧批次需要保留什么,错误数据是否需要撤回,新提交怎样重新审核。
保存过标签、经过审核、已经交付,分别意味着不同的事情。把这些区别落到数据和操作上,是我理解这类后端系统的起点。
相关阅读
- 为什么标注任务需要状态机,而不只是一个 status 字段
- 两个质检员同时审核一条数据,会发生什么?
- Redis 到底应该缓存什么:从标注平台的看板说起
- 从 AI 数据平台到 LeRobot:我对 Java 后端开发理解的变化