feat(plan): 生成 label_backend 完整实施规划文档

Phase 0:research.md(10项技术决策,无需澄清项)
Phase 1:data-model.md(11张表+Redis结构),contracts/(8个模块API契约),quickstart.md(Docker Compose启动+流水线验证)
plan.md:宪章11条全部通过,项目结构确认
This commit is contained in:
wh
2026-04-09 12:27:16 +08:00
parent 0891ae188d
commit 4054a1133b
15 changed files with 1741 additions and 22 deletions

View File

@@ -208,7 +208,7 @@ CREATE TABLE source_data (
bucket_name VARCHAR(100) NOT NULL,
parent_source_id BIGINT REFERENCES source_data(id), -- 视频转文本时指向原视频
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
-- PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED / REJECTED
-- PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED(无 REJECTED 状态QA 驳回作用于 annotation_task
reject_reason TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
@@ -961,10 +961,12 @@ public void unclaim(Long taskId) {
| 方法 | 路径 | 最低权限 | 说明 |
|------|------|----------|------|
| POST | `/api/tasks` | ADMIN | 为指定 source 创建 EXTRACTION 任务 |
| GET | `/api/tasks/pool` | ANNOTATOR | 查看可领取任务列表(按角色过滤,分页) |
| POST | `/api/tasks/{id}/claim` | ANNOTATOR | 领取任务(争抢式) |
| GET | `/api/tasks/pool` | ANNOTATOR | 查看可领取任务UNCLAIMED 状态。ANNOTATOR 只看到 EXTRACTION 类型REVIEWER 只看到 SUBMITTED 状态(即审批队列,与 pending-review 等价);两者均分页,不可无界查询 |
| POST | `/api/tasks/{id}/claim` | ANNOTATOR | 领取任务(争抢式Redis SET NX + DB 乐观锁 |
| POST | `/api/tasks/{id}/unclaim` | ANNOTATOR | 放弃任务,退回任务池 |
| GET | `/api/tasks/mine` | ANNOTATOR | 查询我领取的任务列表(分页) |
| GET | `/api/tasks/mine` | ANNOTATOR | 查询我领取的任务列表(包含 IN_PROGRESS、SUBMITTED、REJECTED 状态,分页) |
| POST | `/api/tasks/{id}/reclaim` | ANNOTATOR | 重领被驳回的任务task.status 必须为 REJECTED 且 claimedBy = 当前用户),状态流转 REJECTED → IN_PROGRESS |
| GET | `/api/tasks/pending-review` | REVIEWER | 查看待我审批的任务列表status = SUBMITTED分页REVIEWER 的专属审批入口 |
| GET | `/api/tasks/{id}` | ANNOTATOR | 查看任务详情 |
| GET | `/api/tasks` | ADMIN | 查询全部任务(支持过滤,分页) |
| PUT | `/api/tasks/{id}/reassign` | ADMIN | 强制转移任务归属 |
@@ -996,11 +998,16 @@ public void updateResult(Long taskId, String resultJsonStr) {
annotationResultMapper.updateResultJson(taskId, resultJsonStr, CompanyContext.get());
}
// 审批通过——级联触发,必须在同一事务内完
// 审批通过——两阶段:事务内完成同步步骤,事务提交后异步触发 QA 生
@Transactional
@OperationLog(type = "EXTRACTION_APPROVE")
public void approve(Long taskId) {
AnnotationTask task = validateAndGetTask(taskId, "SUBMITTED");
// 自审校验:提交者不能审批自己的任务
if (task.getClaimedBy().equals(getCurrentUserId()))
throw new BusinessException("SELF_REVIEW_FORBIDDEN", "不允许审批自己提交的任务");
AnnotationResult result = annotationResultMapper.selectByTaskId(taskId);
// 1. annotation_result.is_final = true
@@ -1016,22 +1023,55 @@ public void approve(Long taskId) {
// 3. 写入任务历史
insertHistory(taskId, "SUBMITTED", "APPROVED", getCurrentUserId(), null);
// 4. 调用 AI 生成候选问答对
String promptKey = "IMAGE".equals(getSourceType(task)) ? "prompt_qa_gen_image" : "prompt_qa_gen_text";
// 4. 发布领域事件,事务提交后异步执行 QA 生成(步骤 5-7
// 注AI HTTP 调用禁止在 @Transactional 内同步执行——会占用数据库连接直至 AI 响应,
// 且 AI 失败会错误地回滚已完成的审批。
// 使用 @TransactionalEventListener(phase = AFTER_COMMIT) 保证先提交再触发。
eventPublisher.publishEvent(new ExtractionApprovedEvent(taskId, task.getSourceId(),
getSourceType(task), CompanyContext.get()));
}
// 驳回——状态回退,标注员可重领
@Transactional
@OperationLog(type = "EXTRACTION_REJECT")
public void reject(Long taskId, String reason) {
AnnotationTask task = validateAndGetTask(taskId, "SUBMITTED");
// 自审校验
if (task.getClaimedBy().equals(getCurrentUserId()))
throw new BusinessException("SELF_REVIEW_FORBIDDEN", "不允许驳回自己提交的任务");
StateValidator.assertTransition(TaskStatus.SUBMITTED, TaskStatus.REJECTED, TaskStatus.TRANSITIONS);
task.setStatus("REJECTED");
taskMapper.updateById(task);
insertHistory(taskId, "SUBMITTED", "REJECTED", getCurrentUserId(), reason);
// source_data.status 保持 EXTRACTING 不变,待标注员重新提交后再推进
}
// ExtractionApprovedEventListener@TransactionalEventListener独立事务
// 负责 5-7 步AI 调用 → 写 training_dataset → 创建 QA 任务 → 更新 source_data
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onExtractionApproved(ExtractionApprovedEvent event) {
AnnotationTask task = taskMapper.selectById(event.getTaskId());
AnnotationResult result = annotationResultMapper.selectByTaskId(event.getTaskId());
// 5. 调用 AI 生成候选问答对(在事务外执行,失败不影响审批结果)
String promptKey = "IMAGE".equals(event.getSourceType()) ? "prompt_qa_gen_image" : "prompt_qa_gen_text";
String promptTemplate = sysConfigService.get(promptKey);
QaGenResponse qaResponse = generateQa(task, result, promptTemplate);
// 5. 将候选问答对写入 training_datasetPENDING_REVIEW
// 6. 将候选问答对写入 training_datasetPENDING_REVIEW
List<TrainingDataset> samples = buildTrainingSamples(task, result, qaResponse);
trainingDatasetMapper.batchInsert(samples);
// 6. 创建 QA_GENERATION 阶段任务UNCLAIMED
// 7. 创建 QA_GENERATION 阶段任务UNCLAIMED
AnnotationTask qaTask = buildQaTask(task);
taskMapper.insert(qaTask);
insertHistory(qaTask.getId(), null, "UNCLAIMED", getCurrentUserId(), null);
insertHistory(qaTask.getId(), null, "UNCLAIMED", task.getClaimedBy(), null);
// 7. source_data.status → QA_REVIEW
sourceDataMapper.updateStatus(task.getSourceId(), "QA_REVIEW", CompanyContext.get());
// 8. source_data.status → QA_REVIEW
sourceDataMapper.updateStatus(event.getSourceId(), "QA_REVIEW", event.getCompanyId());
}
```
@@ -1057,21 +1097,48 @@ public void approve(Long taskId) {
@Transactional
@OperationLog(type = "QA_APPROVE")
public void approve(Long taskId) {
// 1. training_dataset.status → APPROVED
// 1. 先校验任务合法性(必须在任何 DB 写入之前执行,避免校验失败时数据已被修改)
AnnotationTask task = validateAndGetTask(taskId, "SUBMITTED");
// 自审校验:提交者不能审批自己的任务
if (task.getClaimedBy().equals(getCurrentUserId()))
throw new BusinessException("SELF_REVIEW_FORBIDDEN", "不允许审批自己提交的任务");
// 2. training_dataset.status → APPROVED
trainingDatasetMapper.approveByTaskId(taskId, getCurrentUserId(), CompanyContext.get());
// 2. annotation_task.status → APPROVED
AnnotationTask task = validateAndGetTask(taskId, "SUBMITTED");
// 3. annotation_task.status → APPROVED
StateValidator.assertTransition(TaskStatus.SUBMITTED, TaskStatus.APPROVED, TaskStatus.TRANSITIONS);
task.setStatus("APPROVED");
task.setCompletedAt(LocalDateTime.now());
taskMapper.updateById(task);
// 3. source_data.status → APPROVED整条流水线完成
// 4. source_data.status → APPROVED整条流水线完成
sourceDataMapper.updateStatus(task.getSourceId(), "APPROVED", CompanyContext.get());
// 4. 写入任务历史
// 5. 写入任务历史
insertHistory(taskId, "SUBMITTED", "APPROVED", getCurrentUserId(), null);
}
// 驳回问答对——任务退回 IN_PROGRESStraining_dataset 删除候选记录
@Transactional
@OperationLog(type = "QA_REJECT")
public void reject(Long taskId, String reason) {
AnnotationTask task = validateAndGetTask(taskId, "SUBMITTED");
// 自审校验
if (task.getClaimedBy().equals(getCurrentUserId()))
throw new BusinessException("SELF_REVIEW_FORBIDDEN", "不允许驳回自己提交的任务");
// 删除本次生成的候选问答对PENDING_REVIEW 状态),待标注员修改后重新提交
trainingDatasetMapper.deleteByTaskId(taskId, CompanyContext.get());
StateValidator.assertTransition(TaskStatus.SUBMITTED, TaskStatus.REJECTED, TaskStatus.TRANSITIONS);
task.setStatus("REJECTED");
taskMapper.updateById(task);
insertHistory(taskId, "SUBMITTED", "REJECTED", getCurrentUserId(), reason);
// source_data.status 保持 QA_REVIEW 不变
}
```
**接口清单:**
@@ -1241,14 +1308,15 @@ public final class StateValidator {
```java
public enum SourceStatus {
PENDING, PREPROCESSING, EXTRACTING, QA_REVIEW, APPROVED, REJECTED;
PENDING, PREPROCESSING, EXTRACTING, QA_REVIEW, APPROVED;
// 注source_data 无 REJECTED 状态。QA 阶段驳回的是 annotation_task→ REJECTED
// 不改变 source_data.status保持 QA_REVIEW重新提交后 source_data 随任务推进。
public static final Map<SourceStatus, Set<SourceStatus>> TRANSITIONS = Map.of(
PENDING, Set.of(EXTRACTING, PREPROCESSING),
PREPROCESSING, Set.of(PENDING),
EXTRACTING, Set.of(QA_REVIEW),
QA_REVIEW, Set.of(APPROVED, REJECTED),
REJECTED, Set.of(EXTRACTING) // 驳回后可重提
QA_REVIEW, Set.of(APPROVED)
);
}
```
@@ -1263,7 +1331,7 @@ public enum TaskStatus {
UNCLAIMED, Set.of(IN_PROGRESS),
IN_PROGRESS, Set.of(SUBMITTED, UNCLAIMED, IN_PROGRESS),
// IN_PROGRESS → IN_PROGRESS 用于 ADMIN 强制转移(持有人变更,状态不变)
SUBMITTED, Set.oAPPROVED, REJECTED),
SUBMITTED, Set.of(APPROVED, REJECTED),
REJECTED, Set.of(IN_PROGRESS) // 驳回后重拾
);
}