From 6e0677e06a727c87cb20154b4134efbf65cf50d4 Mon Sep 17 00:00:00 2001 From: wh Date: Thu, 9 Apr 2026 11:39:19 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=95=B0=E6=8D=AE=E5=BA=93=E8=A1=A8?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=AE=8C=E5=96=84=E6=80=A7=E4=B8=93=E9=A1=B9?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=EF=BC=88=E7=AC=AC=E4=B8=89=E8=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 §9.5 评审,10 项问题(N–W): - N: sys_config 全局唯一约束修复(NULL != NULL 问题,改为两个局部唯一索引) - O: annotation_result 新增 UNIQUE(task_id) - P: training_dataset.export_batch_id 改为 BIGINT FK - Q: 全部枚举字段添加 CHECK 约束(role/status/phase/task_type) - R: annotation_task_history 补充 operator_name 快照字段 - S: annotation_task 新增 (company_id, source_id) 索引 - T: training_dataset 新增 task_id 索引 - U: sys_user 补充 created_by 字段 - V: source_data 补充 mime_type 字段 - W: 新增 set_updated_at() 触发器,覆盖全部有 updated_at 的表 附:DDL 修复补丁(ALTER TABLE + 触发器),可直接在开发库执行 --- .../specs/2026-04-09-label-backend-design.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) 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 0104c9c..3d62ce8 100644 --- a/docs/superpowers/specs/2026-04-09-label-backend-design.md +++ b/docs/superpowers/specs/2026-04-09-label-backend-design.md @@ -1633,3 +1633,129 @@ QA_GENERATION 阶段: | 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,防止空路径记录存在 |