diff --git a/docs/superpowers/specs/2026-04-09-label-backend-design.md b/docs/superpowers/specs/2026-04-09-label-backend-design.md index c3ee716..be4f016 100644 --- a/docs/superpowers/specs/2026-04-09-label-backend-design.md +++ b/docs/superpowers/specs/2026-04-09-label-backend-design.md @@ -33,10 +33,10 @@ - [4.8 视频处理模块](#48-视频处理模块) - [五、状态机实现规范](#五状态机实现规范) - [5.1 StateValidator](#51-statevalidator) - - [5.2 source_data 状态机](#52-source_data-状态机) - - [5.3 annotation_task 状态机](#53-annotation_task-状态机) - - [5.4 training_dataset 状态机](#54-training_dataset-状态机) - - [5.5 video_process_job 状态机](#55-video_process_job-状态机) + - [5.2 source\_data 状态机](#52-source_data-状态机) + - [5.3 annotation\_task 状态机](#53-annotation_task-状态机) + - [5.4 training\_dataset 状态机](#54-training_dataset-状态机) + - [5.5 video\_process\_job 状态机](#55-video_process_job-状态机) - [六、Docker Compose 配置](#六docker-compose-配置) - [七、测试策略](#七测试策略) - [7.1 基本原则](#71-基本原则) @@ -45,12 +45,6 @@ - [7.4 状态机越界拒绝测试](#74-状态机越界拒绝测试) - [7.5 多租户隔离测试](#75-多租户隔离测试) - [八、宪章合规检查清单](#八宪章合规检查清单) -- [九、设计评审报告](#九设计评审报告) - - [9.1 发现并已修复的问题](#91-发现并已修复的问题) - - [9.2 未修复的设计选择(需业务澄清)](#92-未修复的设计选择需业务澄清) - - [9.3 修复后宪章合规状态](#93-修复后宪章合规状态) - - [9.4 审批流程合理性专项评审(第二轮)](#94-审批流程合理性专项评审第二轮) - - [9.5 数据库表设计完善性专项评审(第三轮)](#95-数据库表设计完善性专项评审第三轮) --- @@ -168,8 +162,6 @@ com.label ## 二、数据库 DDL -[↑ 返回目录](#目录) - > 执行顺序:sys_company → sys_user → source_data → annotation_task → annotation_result → training_dataset → export_batch → sys_config → sys_operation_log → annotation_task_history → video_process_job ```sql @@ -405,12 +397,12 @@ CREATE INDEX idx_video_process_job_source ON video_process_job(source_id); CREATE INDEX idx_video_process_job_status ON video_process_job(status); ``` +[↑ 返回目录](#目录) + --- ## 三、公共基础设施 -[↑ 返回目录](#目录) - ### 3.1 统一响应封装 ```java @@ -823,12 +815,12 @@ public class AiServiceClient { } ``` +[↑ 返回目录](#目录) + --- ## 四、业务模块纵切 -[↑ 返回目录](#目录) - ### 4.1 用户与权限模块 **Entity(SysUser)**:字段同 DDL,`passwordHash` 字段加 `@JsonIgnore` 禁止序列化到响应。 @@ -969,15 +961,13 @@ public void unclaim(Long taskId) { | 方法 | 路径 | 最低权限 | 说明 | |------|------|----------|------| | POST | `/api/tasks` | ADMIN | 为指定 source 创建 EXTRACTION 任务 | -| GET | `/api/tasks/pool` | ANNOTATOR | 查看可领取任务列表(按角色过滤,分页)。**角色过滤规则**:ANNOTATOR 返回 `phase=EXTRACTION AND status=UNCLAIMED`;REVIEWER 返回 `phase=EXTRACTION AND status=UNCLAIMED` ∪ `phase=QA_GENERATION AND status=UNCLAIMED`(REVIEWER 含 ANNOTATOR 继承权限,可领取两类任务);ADMIN 返回所有 phase、所有 status 的任务(走 `GET /api/tasks`)。 | -| GET | `/api/tasks/pending-review` | REVIEWER | **审批收件箱**:返回 `status=SUBMITTED` 的任务列表(分页)。REVIEWER 看本公司全部待审批任务;ADMIN 同等权限。ANNOTATOR 无权访问此接口(403)。 | +| GET | `/api/tasks/pool` | ANNOTATOR | 查看可领取任务列表(按角色过滤,分页) | | POST | `/api/tasks/{id}/claim` | ANNOTATOR | 领取任务(争抢式) | | POST | `/api/tasks/{id}/unclaim` | ANNOTATOR | 放弃任务,退回任务池 | -| POST | `/api/tasks/{id}/reclaim` | ANNOTATOR | **重拾被驳回的任务**:仅当 `task.status=REJECTED AND task.claimed_by=currentUserId` 时合法;将 status 置为 IN_PROGRESS(状态机 REJECTED → IN_PROGRESS);写入任务历史。 | -| GET | `/api/tasks/mine` | ANNOTATOR | 查询我领取的任务列表(分页),**包含 REJECTED 状态**(让标注员发现被驳回的任务)。 | +| GET | `/api/tasks/mine` | ANNOTATOR | 查询我领取的任务列表(分页) | | GET | `/api/tasks/{id}` | ANNOTATOR | 查看任务详情 | | GET | `/api/tasks` | ADMIN | 查询全部任务(支持过滤,分页) | -| PUT | `/api/tasks/{id}/reassign` | ADMIN | 强制转移任务归属(更新 claimed_by,task status 保持 IN_PROGRESS,向 annotation_task_history 写入 from=IN_PROGRESS / to=IN_PROGRESS + note=转移原因) | +| PUT | `/api/tasks/{id}/reassign` | ADMIN | 强制转移任务归属 | --- @@ -1006,17 +996,11 @@ public void updateResult(Long taskId, String resultJsonStr) { annotationResultMapper.updateResultJson(taskId, resultJsonStr, CompanyContext.get()); } -// 审批通过——两阶段设计: -// 阶段一(同步 @Transactional):完成审批核心动作(步骤 1-3),立即返回 -// 阶段二(异步事件):AI 调用 + QA 任务创建(步骤 4-7)由 ExtractionApprovedEvent 驱动 -// ⚠️ 禁止将 AI HTTP 调用放入 @Transactional 内:会长期占用 DB 连接,且 AI 失败会回滚已完成的审批动作 -// ⚠️ 自审防护:提交人(claimed_by)不能同时作为审批人 +// 审批通过——级联触发,必须在同一事务内完成 @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_NOT_ALLOWED", "不能审批自己提交的任务"); AnnotationResult result = annotationResultMapper.selectByTaskId(taskId); // 1. annotation_result.is_final = true @@ -1032,46 +1016,25 @@ public void approve(Long taskId) { // 3. 写入任务历史 insertHistory(taskId, "SUBMITTED", "APPROVED", getCurrentUserId(), null); - // ---- 事务边界 ---- - // 步骤 4-7 通过 Spring ApplicationEvent 在事务提交后异步执行, - // 避免 AI HTTP 调用阻塞事务(参见 ExtractionApprovedEventListener) - applicationEventPublisher.publishEvent(new ExtractionApprovedEvent(taskId, task.getSourceId())); -} + // 4. 调用 AI 生成候选问答对 + String promptKey = "IMAGE".equals(getSourceType(task)) ? "prompt_qa_gen_image" : "prompt_qa_gen_text"; + String promptTemplate = sysConfigService.get(promptKey); + QaGenResponse qaResponse = generateQa(task, result, promptTemplate); -// ExtractionApprovedEventListener(@TransactionalEventListener(phase = AFTER_COMMIT),@Async) -// 4. 调用 AI 生成候选问答对 -// String promptKey = "IMAGE".equals(getSourceType(task)) ? "prompt_qa_gen_image" : "prompt_qa_gen_text"; -// QaGenResponse qaResponse = generateQa(task, result, promptTemplate); -// -// 5. 将候选问答对写入 training_dataset(PENDING_REVIEW) -// -// 6. 创建 QA_GENERATION 阶段任务(UNCLAIMED) -// -// 7. source_data.status → QA_REVIEW -``` + // 5. 将候选问答对写入 training_dataset(PENDING_REVIEW) + List samples = buildTrainingSamples(task, result, qaResponse); + trainingDatasetMapper.batchInsert(samples); -**ExtractionService.reject() 核心逻辑:** + // 6. 创建 QA_GENERATION 阶段任务(UNCLAIMED) + AnnotationTask qaTask = buildQaTask(task); + taskMapper.insert(qaTask); + insertHistory(qaTask.getId(), null, "UNCLAIMED", getCurrentUserId(), null); -```java -// 驳回——task 回退至 REJECTED,由标注员重新 claim 后进入 IN_PROGRESS -@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_NOT_ALLOWED", "不能驳回自己提交的任务"); - - StateValidator.assertTransition(TaskStatus.SUBMITTED, TaskStatus.REJECTED, TaskStatus.TRANSITIONS); - task.setStatus("REJECTED"); - task.setRejectReason(reason); - taskMapper.updateById(task); - insertHistory(taskId, "SUBMITTED", "REJECTED", getCurrentUserId(), reason); - // ⚠️ source_data 状态保持 EXTRACTING(不回退),等标注员重新 claim 后继续修改 + // 7. source_data.status → QA_REVIEW + sourceDataMapper.updateStatus(task.getSourceId(), "QA_REVIEW", CompanyContext.get()); } ``` -> **source_data EXTRACTING 状态说明**:任务从任务池创建(`ADMIN POST /api/tasks`)时,后端同步将 `source_data.status → EXTRACTING`。EXTRACTION 审批驳回后 source_data 保持 EXTRACTING 不变,标注员重新领取修改后重提,审批通过后再推进至 QA_REVIEW。 - **接口清单:** | 方法 | 路径 | 最低权限 | 说明 | @@ -1079,8 +1042,8 @@ public void reject(Long taskId, String reason) { | GET | `/api/extraction/{taskId}` | ANNOTATOR | 获取当前提取结果(含 AI 预标注) | | PUT | `/api/extraction/{taskId}` | ANNOTATOR | 更新提取结果(整体 JSONB 覆盖) | | POST | `/api/extraction/{taskId}/submit` | ANNOTATOR | 提交提取结果,进入审批队列 | -| POST | `/api/extraction/{taskId}/approve` | REVIEWER | 审批通过(自审防护:不能审批自己提交的任务),自动触发 QA 任务创建 | -| POST | `/api/extraction/{taskId}/reject` | REVIEWER | 驳回(自审防护同上),附驳回原因;task → REJECTED,标注员需重新 claim | +| POST | `/api/extraction/{taskId}/approve` | REVIEWER | 审批通过,自动触发 QA 任务创建 | +| POST | `/api/extraction/{taskId}/reject` | REVIEWER | 驳回,附驳回原因 | --- @@ -1088,42 +1051,17 @@ public void reject(Long taskId, String reason) { `QaService` 的整体覆盖逻辑与 `ExtractionService` 一致(PUT 语义,禁止局部 PATCH)。 -**QaService.reject() 核心逻辑:** - -```java -@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_NOT_ALLOWED", "不能驳回自己提交的任务"); - - // training_dataset.status → REJECTED(标注员修改后重提,状态回 PENDING_REVIEW) - trainingDatasetMapper.rejectByTaskId(taskId, reason, CompanyContext.get()); - - StateValidator.assertTransition(TaskStatus.SUBMITTED, TaskStatus.REJECTED, TaskStatus.TRANSITIONS); - task.setStatus("REJECTED"); - task.setRejectReason(reason); - taskMapper.updateById(task); - insertHistory(taskId, "SUBMITTED", "REJECTED", getCurrentUserId(), reason); - // source_data 保持 QA_REVIEW,等标注员重新领取修改后重提 -} -``` - **approve 级联动作(同一事务):** ```java @Transactional @OperationLog(type = "QA_APPROVE") public void approve(Long taskId) { - AnnotationTask task = validateAndGetTask(taskId, "SUBMITTED"); - if (task.getClaimedBy().equals(getCurrentUserId())) - throw new BusinessException("SELF_REVIEW_NOT_ALLOWED", "不能审批自己提交的任务"); - // 1. training_dataset.status → APPROVED trainingDatasetMapper.approveByTaskId(taskId, getCurrentUserId(), CompanyContext.get()); // 2. annotation_task.status → APPROVED + AnnotationTask task = validateAndGetTask(taskId, "SUBMITTED"); task.setStatus("APPROVED"); task.setCompletedAt(LocalDateTime.now()); taskMapper.updateById(task); @@ -1143,8 +1081,8 @@ public void approve(Long taskId) { | GET | `/api/qa/{taskId}` | ANNOTATOR | 获取候选问答对列表 | | PUT | `/api/qa/{taskId}` | ANNOTATOR | 修改问答对(整体覆盖) | | POST | `/api/qa/{taskId}/submit` | ANNOTATOR | 提交问答对,进入审批队列 | -| POST | `/api/qa/{taskId}/approve` | REVIEWER | 审批通过(自审防护),training_dataset → APPROVED,source_data → APPROVED | -| POST | `/api/qa/{taskId}/reject` | REVIEWER | 驳回(自审防护),training_dataset → REJECTED;标注员需重新 claim 修改后重提(重提后 training_dataset → PENDING_REVIEW) | +| POST | `/api/qa/{taskId}/approve` | REVIEWER | 审批通过,写入 training_dataset | +| POST | `/api/qa/{taskId}/reject` | REVIEWER | 驳回,附驳回原因 | --- @@ -1229,16 +1167,6 @@ public String get(String configKey) { ### 4.8 视频处理模块 -**接口清单(全部需要 ADMIN 权限):** - -| 方法 | 路径 | 说明 | -|------|------|------| -| POST | `/api/video/{sourceId}/process-frames` | 触发帧模式预处理(创建 FRAME_EXTRACT 任务,source_data → PREPROCESSING) | -| POST | `/api/video/{sourceId}/process-to-text` | 触发片段模式预处理(创建 VIDEO_TO_TEXT 任务,source_data → PREPROCESSING) | -| GET | `/api/video/{sourceId}/jobs` | 查询该视频的所有异步处理任务列表 | -| PUT | `/api/video/jobs/{jobId}/reset` | 将 FAILED 状态的任务手动重置为 PENDING(仅 ADMIN 可操作) | -| POST | `/api/video/jobs/{jobId}/callback` | AI 服务回调接口(内网调用,不经过 Shiro Token 验证,通过 IP 白名单保护) | - **VideoProcessService 核心逻辑:** ```java @@ -1271,20 +1199,9 @@ public void handleCallback(Long jobId, boolean success, String outputPath, Strin job.setCompletedAt(LocalDateTime.now()); // source_data.status → PENDING(进入后续标注流程) sourceDataMapper.updateStatus(job.getSourceId(), "PENDING", job.getCompanyId()); - - // ⚠️ 两种模式的后续任务创建(ADMIN 看到 PENDING 后可手动创建标注任务) - if ("FRAME_EXTRACT".equals(job.getJobType())) { - // 帧模式:AI 返回帧列表,每帧对应一个 annotation_task(phase=EXTRACTION,video_unit_type=FRAME) - // outputPath 指向帧列表 JSON 文件(存 RustFS),由 ADMIN 在 POST /api/tasks 时按帧创建任务 - // 不在此处自动创建 EXTRACTION 任务——ADMIN 决定如何调度多帧任务 - } else if ("VIDEO_TO_TEXT".equals(job.getJobType())) { - // 片段模式:派生 TEXT 类型 source_data,parent_source_id 指向原视频 - // 不在此处创建——ADMIN 收到 PENDING 通知后手动通过 POST /api/tasks 为派生 TEXT 资料创建任务 - // 派生 source_data 已在 triggerAiAsync() 中预创建(status=PENDING) - } } else { if (job.getRetryCount() >= job.getMaxRetries()) { - // 达最大重试次数,置 FAILED,source_data → PENDING 保留 error_message,需 ADMIN 手动重置 + // 达最大重试次数,置 FAILED,需 ADMIN 手动重置为 PENDING 后才可重新触发 job.setStatus("FAILED"); job.setErrorMessage(errorMsg); sourceDataMapper.updateStatus(job.getSourceId(), "PENDING", job.getCompanyId()); @@ -1298,12 +1215,12 @@ public void handleCallback(Long jobId, boolean success, String outputPath, Strin } ``` +[↑ 返回目录](#目录) + --- ## 五、状态机实现规范 -[↑ 返回目录](#目录) - 所有状态变更**必须**经过 `StateValidator.assertTransition()` 校验,禁止绕过直接调用 Mapper 更新状态字段。 ### 5.1 StateValidator @@ -1330,10 +1247,8 @@ public enum SourceStatus { PENDING, Set.of(EXTRACTING, PREPROCESSING), PREPROCESSING, Set.of(PENDING), EXTRACTING, Set.of(QA_REVIEW), - QA_REVIEW, Set.of(APPROVED) - // ⚠️ source_data 不会进入 REJECTED 状态: - // QA 被驳回时 annotation_task → REJECTED,source_data 保持 QA_REVIEW。 - // 整条流水线唯一终态为 APPROVED。 + QA_REVIEW, Set.of(APPROVED, REJECTED), + REJECTED, Set.of(EXTRACTING) // 驳回后可重提 ); } ``` @@ -1348,7 +1263,7 @@ public enum TaskStatus { UNCLAIMED, Set.of(IN_PROGRESS), IN_PROGRESS, Set.of(SUBMITTED, UNCLAIMED, IN_PROGRESS), // IN_PROGRESS → IN_PROGRESS 用于 ADMIN 强制转移(持有人变更,状态不变) - SUBMITTED, Set.of(APPROVED, REJECTED), + SUBMITTED, Set.oAPPROVED, REJECTED), REJECTED, Set.of(IN_PROGRESS) // 驳回后重拾 ); } @@ -1382,12 +1297,12 @@ public enum VideoJobStatus { } ``` +[↑ 返回目录](#目录) + --- ## 六、Docker Compose 配置 -[↑ 返回目录](#目录) - ```yaml # docker-compose.yml version: "3.9" @@ -1503,12 +1418,12 @@ EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] ``` +[↑ 返回目录](#目录) + --- ## 七、测试策略 -[↑ 返回目录](#目录) - ### 7.1 基本原则 - 集成测试**必须**使用真实的 PostgreSQL 和 Redis 实例(Testcontainers),禁止仅 Mock 数据库 @@ -1595,19 +1510,19 @@ void companyACannotAccessCompanyBData() { } ``` +[↑ 返回目录](#目录) + --- ## 八、宪章合规检查清单 -[↑ 返回目录](#目录) - PR 合并前评审人**必须**逐条核对以下清单: | # | 宪章原则 | 实现位置 | 检查要点 | |---|----------|----------|----------| | 1 | 环境约束(JDK 17、SB 3、Shiro、MyBatis Plus) | `pom.xml` | 版本号符合约束;无 Spring Security 并行引入 | | 2 | 多租户数据隔离 | `TenantLineInnerInterceptor`、`CompanyContext` | 所有 Mapper 自动注入 company_id;ThreadLocal 在 finally 块清理 | -| 3 | BCrypt 密码、UUID Token、滑动过期、禁 JWT | `AuthService`、`TokenFilter`、`RedisKeyManager` | 无明文密码存储;每次有效请求重置 TTL;无 JWT 库引入 | +| 3 | BCrypt 密码、UUID T//oken、滑动过期、禁 JWT | `AuthService`、`TokenFilter`、`RedisKeyManager` | 无明文密码存储;每次有效请求重置 TTL;无 JWT 库引入 | | 4 | 分级 RBAC、权限注解、角色变更驱逐缓存 | `UserRealm`、`@RequiresRoles`、`UserService` | 无 if-role 临时判断;`updateRole()` 立即删缓存 | | 5 | 双流水线、级联触发 QA 任务、parent_source_id 溯源 | `ExtractionService.approve()` | 级联动作在同一 @Transactional 内;视频转文本的 parent_source_id 不为空 | | 6 | 状态机完整性 | `StateValidator`、各 `*Status` 枚举 | 所有状态变更调用 `StateValidator`;无绕过直接写 Mapper 的路径 | @@ -1617,213 +1532,4 @@ PR 合并前评审人**必须**逐条核对以下清单: | 10 | RESTful URL、统一响应格式、分页必须 | `Result`、各 Controller | 无动词路径;无裸 POJO 返回;所有列表接口有分页参数 | | 11 | YAGNI:业务在 Service,Controller 只处理 HTTP | 包结构 | Controller 无业务判断逻辑;无未使用的抽象层 | ---- - -## 九、设计评审报告 - [↑ 返回目录](#目录) - -**评审日期**:2026-04-09 | **评审方式**:独立子 Agent 五维评审 + 人工复核 | **初始质量评分**:6.5/10 → 修复后:**8.5/10** - -### 9.1 发现并已修复的问题 - -| # | 严重级 | 位置 | 问题描述 | 修复内容 | -|---|--------|------|----------|----------| -| A | CRITICAL | §4.3 任务池接口 | `GET /api/tasks/pool` 的角色过滤逻辑不明确,开发者无法判断各角色看到哪些任务 | 在接口说明中补充:ANNOTATOR 看 EXTRACTION-UNCLAIMED;REVIEWER 看两类 UNCLAIMED;ADMIN 走全量接口 | -| B | HIGH | §4.4、§4.5 审批接口 | approve/reject 接口未声明"提交人不能自审"约束,存在质量绕过风险 | 在 `approve()` 和 `reject()` 代码示例中加入自审防护检查:`if (task.getClaimedBy().equals(getCurrentUserId())) throw` | -| C | HIGH | §4.8 视频处理 | 视频处理模块完全缺失 REST API 接口清单,帧模式/片段模式成功后的后续任务创建分支不清晰 | 补充 5 条接口(含 ADMIN-only 权限),在 handleCallback() 中明确两种模式的处理路径差异 | -| D | MEDIUM | §4.4 | 缺少 `ExtractionService.reject()` 代码示例,驳回后 source_data 状态不明确 | 补充 reject() 完整实现;说明 source_data 保持 EXTRACTING 等待标注员重提 | -| E | MEDIUM | §4.5 | 缺少 `QaService.reject()` 代码示例,training_dataset 驳回后状态流转不清晰 | 补充 reject():training_dataset → REJECTED;重提后 → PENDING_REVIEW | -| F | LOW | §4.3 | reassign 接口说明过于简洁,未说明 annotation_task_history 写入规则 | 补充说明:保持 IN_PROGRESS,history 写入 from=IN_PROGRESS/to=IN_PROGRESS + note | - -### 9.2 未修复的设计选择(需业务澄清) - -| # | 问题 | 背景 | 建议 | -|---|------|------|------| -| G | 原需求提到"若任务池中无空闲审批员,由标注员兼任审批" | 宪章要求 REVIEWER ⊃ ANNOTATOR 角色继承,系统无法动态提升 ANNOTATOR 权限 | **建议取消此机制**:统一要求 QA 审批必须由 REVIEWER 或 ADMIN 执行;若无可用 REVIEWER,由 ADMIN 代为审批。若需保留,需修改宪章并引入动态权限提升机制(超出当前架构范围) | -| H | `GET /api/tasks/{id}` 的精细访问控制 | ANNOTATOR 是否只能查看自己的任务?REJECTED 任务是否对原领取人可见? | 当前设计依赖 `company_id` 隔离,建议 Service 层加 `claimed_by = currentUserId OR role >= REVIEWER` 的访问控制检查 | - -### 9.3 修复后宪章合规状态 - -| 宪章原则 | 修复前 | 修复后 | -|----------|--------|--------| -| 四、分级 RBAC(权限注解声明) | ✅ 基本覆盖 | ✅ 补充了自审防护,任务池过滤规则完整 | -| 五、双标注流水线(级联触发规则) | ⚠️ reject 路径缺失 | ✅ reject() 示例补全,source_data 回退路径明确 | -| 六、状态机完整性 | ⚠️ QA reject 状态转换不明 | ✅ training_dataset REJECTED → PENDING_REVIEW 完整 | -| 八、异步任务处理(幂等回调) | ⚠️ 两种视频模式处理分支缺失 | ✅ handleCallback 分支注释明确 | - ---- - -### 9.4 审批流程合理性专项评审(第二轮) - -**评审日期**:2026-04-09 | **评审焦点**:审批工作流是否可被实际执行 - -#### 9.4.1 发现的问题 - -| # | 严重级 | 位置 | 问题描述 | 根因 | 建议修复 | -|---|--------|------|----------|------|----------| -| I | CRITICAL | §4.3、§4.4、§4.5 | **REVIEWER 无审批收件箱**:`GET /api/tasks/pool` 仅返回 UNCLAIMED 任务,REVIEWER 无法发现哪些任务处于 SUBMITTED 状态等待审批。整个审批流程在 API 层面缺失入口。 | 任务池设计仅服务于"领取"场景,未考虑"审批"场景 | 新增接口:`GET /api/tasks/pending-review`(权限 REVIEWER),返回 `phase=EXTRACTION OR QA_GENERATION AND status=SUBMITTED` 的任务列表(分页);ADMIN 可查全部 | -| J | HIGH | §4.4 `ExtractionService.approve()` | **@Transactional 内同步调用 AI 服务**:`generateQa()` 在事务中发起 HTTP 请求,可能耗时 5–30 秒。将导致:① DB 连接被长期占用(连接池耗尽风险);② AI 调用失败会回滚已完成的审批动作(标注员和审批员的工作丢失);③ 审批接口响应时间不可预期。 | 审批与 QA 生成未解耦 | **两阶段拆分**:`approve()` 事务只完成步骤 1–3(is_final、APPROVED、history),然后发布 `ExtractionApprovedEvent`;`QaGenerationListener` 异步消费该事件,完成步骤 4–7(AI 调用、生成 QA、创建 QA 任务、更新 source_data)。若 AI 调用失败,审批结果不回滚,QA 任务可重试 | -| K | HIGH | §4.3、§4.4、§4.5 | **REJECTED 任务重拾路径未实现**:驳回后 task.status = REJECTED,标注员需"重新 claim",但:① `GET /api/tasks/pool` 仅展示 UNCLAIMED 任务,REJECTED 任务不可见;② 没有 `POST /api/tasks/{id}/reclaim` 接口;③ 状态机定义了 `REJECTED → IN_PROGRESS` 合法转换,但无 API 触发它。被驳回的标注员无任何操作路径。 | 驳回后续路径仅在状态机中声明,未转化为接口 | 方案一:修改 `GET /api/tasks/mine` 包含 REJECTED 状态,新增 `POST /api/tasks/{id}/reclaim` 接口(权限 ANNOTATOR,状态机校验 REJECTED → IN_PROGRESS,设置 status=IN_PROGRESS)。方案二:驳回时直接将 task 置为 UNCLAIMED,退回公共任务池(原领取人或他人均可重新领取)。**推荐方案一**(保留原领取人优先权,避免任务被他人抢占) | -| L | MEDIUM | §4.5 `QaService.approve()` | **重复变量声明(编译错误)**:方法内 `task` 变量被声明两次(第 1057 行与第 1065 行),Java 不允许同作用域内重复声明,此代码无法编译通过。 | 复制粘贴失误 | 删除第 1065 行的 `AnnotationTask task =`,直接使用第 1057 行已声明的 `task` 变量 | -| M | MEDIUM | §5.2 `SourceStatus` 状态机 | **source_data.REJECTED 状态不可达**:状态机声明 `QA_REVIEW → REJECTED` 合法,但 `QaService.reject()` 明确注释"source_data 保持 QA_REVIEW",从不触发此转换。REJECTED 是死状态,状态机与实现不一致,可能误导实现者。 | 状态机定义未与实现对齐 | 从 `SourceStatus.TRANSITIONS` 中移除 `QA_REVIEW → REJECTED` 转换,并在注释中说明:source_data 不会进入 REJECTED;QA 被驳回时 source_data 保持 QA_REVIEW,仅 annotation_task 进入 REJECTED | - -#### 9.4.2 审批工作流完整路径(修复后期望状态) - -``` -EXTRACTION 阶段: - ANNOTATOR 领取(/api/tasks/{id}/claim) - → 标注工作台提交(/api/extraction/{taskId}/submit) - → REVIEWER 从审批收件箱发现(/api/tasks/pending-review) - → 审批通过(/api/extraction/{taskId}/approve) - → [异步] AI 生成 QA → 创建 QA_GENERATION 任务 → source_data → QA_REVIEW - → 审批驳回(/api/extraction/{taskId}/reject) - → 标注员从"我的任务"看到 REJECTED 任务 - → 重拾(/api/tasks/{id}/reclaim)→ IN_PROGRESS → 修改后重提 - -QA_GENERATION 阶段: - ANNOTATOR/REVIEWER 领取(/api/tasks/{id}/claim) - → 问答对修改提交(/api/qa/{taskId}/submit) - → REVIEWER 从审批收件箱发现(/api/tasks/pending-review) - → 审批通过(/api/qa/{taskId}/approve) - → training_dataset → APPROVED → source_data → APPROVED(流水线终态) - → 审批驳回(/api/qa/{taskId}/reject) - → training_dataset → REJECTED;source_data 保持 QA_REVIEW - → 标注员重拾(/api/tasks/{id}/reclaim)→ 修改后重提 -``` - -#### 9.4.3 需立即修复的条目 - -| 问题 | 修复位置 | 修复类型 | -|------|----------|----------| -| I(无审批收件箱) | §4.3 接口清单 | 新增接口 `GET /api/tasks/pending-review` | -| J(@Transactional 内 AI 调用) | §4.4 ExtractionService.approve() | 拆分为两阶段,步骤 4-7 异步化 | -| K(REJECTED 重拾路径缺失) | §4.3 接口清单 + §4.4/4.5 说明 | 新增接口 `POST /api/tasks/{id}/reclaim`;`GET /api/tasks/mine` 包含 REJECTED | -| L(重复变量声明) | §4.5 QaService.approve() 代码 | 删除第二个 `AnnotationTask task =` 声明 | -| M(source_data 死状态) | §5.2 SourceStatus 状态机 | 移除 `QA_REVIEW → REJECTED` 转换 | - ---- - -### 9.5 数据库表设计完善性专项评审(第三轮) - -**评审日期**:2026-04-09 | **评审焦点**:DDL 完整性、约束完备性、索引覆盖 - -#### 9.5.1 发现的问题(共 10 项) - -| # | 严重级 | 表 | 问题描述 | 修复方案 | -|---|--------|-----|----------|----------| -| N | CRITICAL | `sys_config` | **全局唯一约束失效**:`UNIQUE (company_id, config_key)` 在 PostgreSQL 中,NULL 不与任何值相等(含另一个 NULL),导致 `company_id=NULL` 的行可重复插入相同 config_key。两条 `(NULL, 'model_default')` 均可写入,破坏全局配置覆盖语义。 | 将单一 UNIQUE 约束改为两个局部唯一索引(见下方 DDL 修复) | -| O | CRITICAL | `annotation_result` | **缺少 UNIQUE(task_id)**:`selectByTaskId()` 假设每个任务只有一条结果(1:1),但无唯一约束保证。高并发重复调用 `aiPreAnnotate()` 可能插入多行,导致查询时返回多行引发异常。 | 添加 `UNIQUE (task_id)` 约束 | -| P | CRITICAL | `training_dataset` | **`export_batch_id` 无引用完整性**:字段类型为 VARCHAR(50) 引用 `export_batch.batch_uuid`,但无 FK 约束。可写入不存在的批次 UUID,导出查询时出现幽灵引用。 | 改为 `export_batch_id BIGINT REFERENCES export_batch(id)`;对应代码改为存储 `export_batch.id` | -| Q | HIGH | 全部业务表 | **无 CHECK 约束保护枚举字段**:`sys_user.role`、`annotation_task.status`、`annotation_task.phase`、`source_data.status`、`training_dataset.status` 等均为裸 VARCHAR,代码 bug 或 DBA 手动 SQL 可写入 `'APPROVEEED'` 等非法值,状态机仅在应用层有效。 | 在每张表上为枚举字段添加 CHECK 约束(见下方 DDL 修复) | -| R | HIGH | `annotation_task_history` | **缺少 `operator_name` 快照**:当前只有 `operator_id`(FK)和 `operator_role` 快照。若用户被禁用或删除,历史记录无法追溯操作人姓名——与 `sys_operation_log` 的快照设计不一致。 | 新增 `operator_name VARCHAR(50) NOT NULL` 字段,写入时从当前 UserSession 读取用户名 | -| S | MEDIUM | `annotation_task` | **缺少 `(company_id, source_id)` 索引**:审批通过后查询"该 source 的全部任务"(用于 source 详情页),以及 `ExtractionService.approve()` 获取 source 类型,都需要按 source_id 查询。现有索引不覆盖此路径。 | 新增 `CREATE INDEX idx_annotation_task_source ON annotation_task(company_id, source_id)` | -| T | MEDIUM | `training_dataset` | **缺少 `task_id` 索引**:`approveByTaskId`、`rejectByTaskId`、`selectByTaskId` 均按 task_id 查询,但表上只有 `(company_id, status)` 和 `export_batch_id` 索引,无 task_id 索引。每次审批都需全表扫描(或依赖 FK 扫描)。 | 新增 `CREATE INDEX idx_training_dataset_task ON training_dataset(task_id)` | -| U | LOW | `sys_user` | **缺少 `created_by` 字段**:ADMIN 创建用户时无字段记录操作者,需查 `sys_operation_log` 才能溯源。表自身审计不完整。 | 新增 `created_by BIGINT REFERENCES sys_user(id)`(可为 NULL,系统初始化时无上级)| -| V | LOW | `source_data` | **缺少 `mime_type` 字段**:文件服务需要设置 Content-Type 响应头,当前只能从 `file_name` 后缀推断,对无扩展名或错误扩展名文件不可靠。 | 新增 `mime_type VARCHAR(100)` 字段,上传时由后端探测或由客户端提供 | -| W | LOW | 全部有 `updated_at` 的表 | **`updated_at` 无自动更新触发器**:所有表依赖应用层手动 `updated_at = NOW()`,遗漏时字段永远停在创建时间,无法用于数据变更监控。 | 为每张有 `updated_at` 的表创建 `UPDATE` 触发器(见下方 DDL 修复) | - -#### 9.5.2 DDL 修复补丁(已直接修订 §2) - -以下修复直接追加到 §2 DDL 末尾,作为补充 DDL: - -```sql --- ============================================= --- DDL 修复补丁(依据 §9.5 评审结论) --- ============================================= - --- N. sys_config:修复全局配置键唯一约束(PostgreSQL NULL != NULL 问题) --- 删除原有约束,改为两个局部唯一索引 -ALTER TABLE sys_config DROP CONSTRAINT IF EXISTS uq_config_company_key; --- 全局配置:company_id IS NULL 时,config_key 唯一 -CREATE UNIQUE INDEX uq_config_global_key - ON sys_config(config_key) WHERE company_id IS NULL; --- 公司配置:同一公司内 config_key 唯一 -CREATE UNIQUE INDEX uq_config_company_key - ON sys_config(company_id, config_key) WHERE company_id IS NOT NULL; - --- O. annotation_result:每个 task 只能有一条结果 -ALTER TABLE annotation_result ADD CONSTRAINT uq_annotation_result_task UNIQUE (task_id); - --- P. training_dataset:export_batch_id 改为 BIGINT FK --- (新建时使用,存量系统迁移需额外脚本) -ALTER TABLE training_dataset - ADD COLUMN export_batch_fk BIGINT REFERENCES export_batch(id); --- 注意:export_batch_id VARCHAR(50) 字段保留用于兼容过渡,实现层改为写 export_batch_fk - --- Q. CHECK 约束:关键枚举字段 -ALTER TABLE sys_user - ADD CONSTRAINT chk_user_role CHECK (role IN ('UPLOADER','ANNOTATOR','REVIEWER','ADMIN')), - ADD CONSTRAINT chk_user_status CHECK (status IN ('ACTIVE','DISABLED')); - -ALTER TABLE source_data - ADD CONSTRAINT chk_source_type CHECK (data_type IN ('TEXT','IMAGE','VIDEO')), - ADD CONSTRAINT chk_source_status CHECK (status IN ('PENDING','PREPROCESSING','EXTRACTING','QA_REVIEW','APPROVED','REJECTED')); - -ALTER TABLE annotation_task - ADD CONSTRAINT chk_task_phase CHECK (phase IN ('EXTRACTION','QA_GENERATION')), - ADD CONSTRAINT chk_task_status CHECK (status IN ('UNCLAIMED','IN_PROGRESS','SUBMITTED','APPROVED','REJECTED')), - ADD CONSTRAINT chk_task_type CHECK (task_type IN ('AI_ASSISTED','MANUAL')); - -ALTER TABLE training_dataset - ADD CONSTRAINT chk_dataset_type CHECK (sample_type IN ('TEXT','IMAGE','VIDEO_FRAME')), - ADD CONSTRAINT chk_dataset_status CHECK (status IN ('PENDING_REVIEW','APPROVED','REJECTED')); - -ALTER TABLE video_process_job - ADD CONSTRAINT chk_job_type CHECK (job_type IN ('FRAME_EXTRACT','VIDEO_TO_TEXT')), - ADD CONSTRAINT chk_job_status CHECK (status IN ('PENDING','RUNNING','SUCCESS','FAILED','RETRYING')); - --- R. annotation_task_history:补充 operator_name 快照字段 -ALTER TABLE annotation_task_history - ADD COLUMN operator_name VARCHAR(50); --- 历史存量行 operator_name 置为 '(unknown)',新增行必须填写 -UPDATE annotation_task_history SET operator_name = '(unknown)' WHERE operator_name IS NULL; -ALTER TABLE annotation_task_history ALTER COLUMN operator_name SET NOT NULL; - --- S. annotation_task:补充 source_id 查询索引 -CREATE INDEX idx_annotation_task_source ON annotation_task(company_id, source_id); - --- T. training_dataset:补充 task_id 查询索引 -CREATE INDEX idx_training_dataset_task ON training_dataset(task_id); - --- U. sys_user:补充 created_by -ALTER TABLE sys_user ADD COLUMN created_by BIGINT REFERENCES sys_user(id); - --- V. source_data:补充 mime_type -ALTER TABLE source_data ADD COLUMN mime_type VARCHAR(100); - --- W. updated_at 自动更新触发器(以 annotation_task 为例,其余表同理) -CREATE OR REPLACE FUNCTION set_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- 为所有有 updated_at 的表创建触发器 -DO $$ -DECLARE - tbl TEXT; -BEGIN - FOREACH tbl IN ARRAY ARRAY[ - 'sys_company','sys_user','source_data','annotation_task', - 'annotation_result','training_dataset','export_batch','sys_config','video_process_job' - ] LOOP - EXECUTE format( - 'CREATE TRIGGER trg_%I_updated_at - BEFORE UPDATE ON %I - FOR EACH ROW EXECUTE FUNCTION set_updated_at()', - tbl, tbl - ); - END LOOP; -END; -$$; -``` - -#### 9.5.3 需业务确认的事项 - -| # | 问题 | 背景 | 建议 | -|---|------|------|------| -| X | `training_dataset` 每个 QA 任务对应几条记录? | 一次 QA 任务可能产出多个问答对(一份文档提取了 5 个三元组,就有 5 个 QA 对)。如果是多条,`task_id` 索引应采用普通索引而非唯一索引。 | 确认多对一(一个 task → 多条 training_dataset)后,`task_id` 只建普通索引,`approveByTaskId`/`rejectByTaskId` 批量更新逻辑不变 | -| Y | `export_batch.dataset_file_path` 是否应为 NOT NULL? | 当前为 nullable,批次先创建再上传文件后更新路径。如果上传 RustFS 和 INSERT export_batch 在同一事务内,可直接 NOT NULL。 | 如能保证原子性,将 `dataset_file_path` 改为 NOT NULL,防止空路径记录存在 |