docs: 数据库表设计完善性专项评审(第三轮)

新增 §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 + 触发器),可直接在开发库执行
This commit is contained in:
wh
2026-04-09 11:39:19 +08:00
parent e382995718
commit 6e0677e06a

View File

@@ -1633,3 +1633,129 @@ QA_GENERATION 阶段:
| KREJECTED 重拾路径缺失) | §4.3 接口清单 + §4.4/4.5 说明 | 新增接口 `POST /api/tasks/{id}/reclaim``GET /api/tasks/mine` 包含 REJECTED | | KREJECTED 重拾路径缺失) | §4.3 接口清单 + §4.4/4.5 说明 | 新增接口 `POST /api/tasks/{id}/reclaim``GET /api/tasks/mine` 包含 REJECTED |
| L重复变量声明 | §4.5 QaService.approve() 代码 | 删除第二个 `AnnotationTask task =` 声明 | | L重复变量声明 | §4.5 QaService.approve() 代码 | 删除第二个 `AnnotationTask task =` 声明 |
| Msource_data 死状态) | §5.2 SourceStatus 状态机 | 移除 `QA_REVIEW → REJECTED` 转换 | | Msource_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_datasetexport_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防止空路径记录存在 |