Summary: - Add 4 new database tables: iit_field_metadata, iit_qc_logs, iit_record_summary, iit_qc_project_stats - Implement pg-boss debounce mechanism in WebhookController - Refactor QC Worker for dual output: QC logs + record summary - Enhance HardRuleEngine to support form-based rule filtering - Create QcService for QC data queries - Optimize ChatService with new intents: query_enrollment, query_qc_status - Add admin batch operations: one-click full QC + one-click full summary - Create IIT Admin management module: project config, QC rules, user mapping Status: Code complete, pending end-to-end testing Co-authored-by: Cursor <cursoragent@cursor.com>
32 KiB
32 KiB
IIT Manager Agent 实时质控系统开发计划
文档版本: v1.1
创建日期: 2026-02-07
最后更新: 2026-02-07
参考文档:
docs/02-通用能力层/Postgres-Only异步任务处理指南.mddocs/03-业务模块/IIT Manager Agent/05-测试文档/IIT Manager Agent V2.9 补充:业务规则与数据治理细则.md
📋 概述
背景
当前 IIT Manager Agent 的质控功能存在以下问题:
- 无持久化:每次查询需要实时调用 REDCap API 并执行质控规则
- 规则管理繁琐:需要手动配置所有质控规则
- 缺乏审计信息:不知道数据谁录入、何时录入
- AI响应慢:每次都要实时查询和计算
目标
建立一套完整的实时质控系统:
- 字段自动同步:从 REDCap 元数据自动同步字段并生成默认规则
- 实时质控存储:每次数据录入后自动执行质控并存储结果
- 录入汇总跟踪:同步记录入组时间、录入人、表单完成状态
- 分级告警:严重问题立即推送,非紧急问题汇总推送
- AI快速响应:AI 直接查询质控结果表,无需实时计算
🎯 核心设计原则
双模式质控策略(Skill 分层配置)
针对 "录了3张表" vs "录了10张表" 的不同质控需求,采用 Skill 分层配置策略,无需配置复杂的前置条件。
策略 A:单表实时质控 (Real-time / Form-based)
| 属性 | 说明 |
|---|---|
| 触发 | Webhook (DET) |
| 上下文 | instrument 参数限制了范围 |
| 逻辑 | 系统自动只加载 formName 匹配的规则,无需人工配置 preconditions |
| 场景 | 刚录完"人口学",系统只检查"年龄",绝不会报"实验室检查缺失"的错 |
// 实时质控:只加载当前表单的规则
async executeFormQc(projectId: string, recordId: string, instrument: string) {
// ✅ 根据 instrument 自动过滤规则
const rules = await getRulesByForm(projectId, instrument);
// 只质控这张表单涉及的字段
return executeRules(rules, record);
}
策略 B:全案定时质控 (Daily Batch / Holistic)
| 属性 | 说明 |
|---|---|
| 触发 | Cron Job (每日凌晨) |
| 上下文 | 全量数据 |
| 逻辑 | 加载跨表规则(Cross-Form Logic) |
| 依赖处理 | 逻辑守卫 (Logic Guard):规则逻辑内部判断,未录入则静默跳过 |
// ✅ 正确:逻辑守卫模式
// 不要在 JSON 里配 dependencies: ['lab_test']
// 而是直接在规则逻辑里写判断
const crossFormRule = {
id: 'age_lab_consistency',
name: '年龄与实验室结果一致性检查',
logic: {
"if": [
// 逻辑守卫:如果 lab_test 表单未完成,直接跳过(返回 true = 通过)
{ "!": { "var": "lab_test_complete" } },
true, // 未录入 = 跳过,不报错
// 否则执行实际检查
{ "and": [
{ ">=": [{ "var": "age" }, 18] },
{ "<=": [{ "var": "creatinine" }, 1.5] }
]}
]
}
};
质控日志记录策略:仅新增,不覆盖
原则:每次质控都 新增记录,而不是覆盖。
原因:
-
审计轨迹 (Audit Trail)
- 调出周一的 Log,发现当时
age字段是空的(规则没触发) - 周五补录了
16(触发了规则) - 这能证明系统是清白的
- 调出周一的 Log,发现当时
-
趋势分析
- P001 患者在入组初期有 10 个错误
- 经过 3 次修改后,错误降为 0
- 这展示了数据质量提升的过程
// ✅ 正确:每次质控都新增记录
await prisma.iitQcLog.create({
data: {
projectId,
recordId,
formName: instrument,
status,
issues,
ruleVersion,
triggeredBy,
createdAt: new Date(), // 每次都是新记录
}
});
// ❌ 错误:覆盖模式会丢失历史
await prisma.iitQcLog.upsert({ ... }); // 不要用 upsert
🏗️ 架构设计
整体架构
┌──────────────────────────────────────────────────────────────────────────┐
│ CRC 在 REDCap 录入数据 │
└─────────────────────────────────────┬────────────────────────────────────┘
│
▼ DET Webhook (含 instrument 参数)
┌──────────────────────────────────────────────────────────────────────────┐
│ 同步层 (WebhookController) │
│ - 接收 HTTP 请求 │
│ - 校验签名 │
│ - 防抖检查 (pg-boss singletonKey) │
│ - 推入队列(携带 instrument 参数) │
│ - 立即返回 200 OK (< 10ms) │
└─────────────────────────────────────┬────────────────────────────────────┘
│
▼ pg-boss 队列
┌──────────────────────────────────────────────────────────────────────────┐
│ 异步层 (QcWorker) - 一次处理,两个产出 │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐│
│ │ 1. 拉取记录数据 (RedcapAdapter) ││
│ └──────────────────────────────────────────────────────────────────────┘│
│ │ │
│ ┌─────────────┴─────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ 2a. 更新录入汇总表 │ │ 2b. 执行单表质控 │ │
│ │ iit_record_summary │ │ (只评估当前表单规则) │ │
│ │ - 入组时间 │ │ 根据 instrument 过滤 │ │
│ │ - 录入人 │ └─────────────────────┘ │
│ │ - 表单完成状态 │ │ │
│ └─────────────────────┘ ▼ │
│ │ ┌─────────────────────┐ │
│ │ │ 2c. 新增质控日志 │ │
│ │ │ iit_qc_logs │ │
│ │ │ (仅新增,不覆盖) │ │
│ │ └─────────────────────┘ │
│ │ │ │
│ └─────────────┬─────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ 3. 主动干预 ││
│ │ - 🔴 RED: 立即推送企业微信 ││
│ │ - 🟡 YELLOW: 存入每日摘要队列 ││
│ │ - 🟢 GREEN: 仅记录日志 ││
│ └─────────────────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────┴─────────────────────────┐
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ 每日定时任务 (Cron Job) │ │ AI Agent 查询层 │
│ - 全案定时质控(跨表规则) │ │ - 查询 iit_qc_logs(毫秒级) │
│ - 逻辑守卫模式处理依赖 │ │ - 查询 iit_record_summary │
│ - 生成每日摘要报告 │ │ - 查询 iit_qc_project_stats │
└───────────────────────────────┘ └───────────────────────────────┘
📦 数据库设计
新增表
1. iit_qc_logs(质控日志表)
质控结果存储,仅新增,不覆盖,保留完整审计轨迹。
model IitQcLog {
id String @id @default(uuid())
projectId String @map("project_id")
recordId String @map("record_id")
eventId String? @map("event_id")
// 质控类型
qcType String @map("qc_type") // 'form' | 'holistic'
formName String? @map("form_name") // 单表质控时记录表单名
// 核心结果
status String // 'PASS' | 'FAIL' | 'WARNING'
// 字段级详情 (JSONB)
// 格式: [{ field: "age", rule: "range_check", level: "RED", message: "..." }, ...]
issues Json @default("[]")
// 规则统计
rulesEvaluated Int @default(0) @map("rules_evaluated") // 实际评估的规则数
rulesSkipped Int @default(0) @map("rules_skipped") // 逻辑守卫跳过的规则数
rulesPassed Int @default(0) @map("rules_passed")
rulesFailed Int @default(0) @map("rules_failed")
// 规则版本(用于历史追溯)
ruleVersion String @map("rule_version")
// 入排标准检查(全案质控时填充)
inclusionPassed Boolean? @map("inclusion_passed")
exclusionPassed Boolean? @map("exclusion_passed")
// 审计信息
triggeredBy String @map("triggered_by") // 'webhook' | 'manual' | 'batch'
createdAt DateTime @default(now()) @map("created_at")
// 索引 - 支持历史查询和趋势分析
@@index([projectId, recordId, createdAt]) // 查询某记录的质控历史
@@index([projectId, status, createdAt]) // 查询某状态的质控记录
@@index([projectId, qcType, createdAt]) // 按质控类型查询
@@map("iit_qc_logs")
@@schema("iit_schema")
}
设计说明:
- 无唯一约束:同一
projectId + recordId可以有多条记录 qcType:区分单表实时质控 (form) 和全案定时质控 (holistic)rulesSkipped:逻辑守卫跳过的规则数,用于分析数据完整度
2. iit_record_summary(录入汇总表)
一次处理,两个产出:与质控同时更新,记录入组和录入进度。
model IitRecordSummary {
id String @id @default(uuid())
projectId String @map("project_id")
recordId String @map("record_id")
// 入组信息
enrolledAt DateTime? @map("enrolled_at") // 首次录入时间 = 入组时间
enrolledBy String? @map("enrolled_by") // 入组录入人(REDCap username)
// 最新录入信息
lastUpdatedAt DateTime @map("last_updated_at")
lastUpdatedBy String? @map("last_updated_by")
lastFormName String? @map("last_form_name") // 最后更新的表单
// 表单完成状态 (JSONB)
// 格式: { "demographics": 2, "baseline": 1, "visit1": 0 }
// 0=未开始, 1=进行中, 2=完成
formStatus Json @default("{}") @map("form_status")
// 数据完整度
totalForms Int @default(0) @map("total_forms")
completedForms Int @default(0) @map("completed_forms")
completionRate Float @default(0) @map("completion_rate") // 0-100%
// 最新质控状态(冗余存储,查询更快)
latestQcStatus String? @map("latest_qc_status") // 'PASS' | 'FAIL' | 'WARNING'
latestQcAt DateTime? @map("latest_qc_at")
// 更新次数(用于趋势分析)
updateCount Int @default(0) @map("update_count")
// 时间戳
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// 唯一约束 - 每个记录只有一条汇总
@@unique([projectId, recordId])
@@index([projectId, enrolledAt])
@@index([projectId, latestQcStatus])
@@index([projectId, completionRate])
@@map("iit_record_summary")
@@schema("iit_schema")
}
设计说明:
- 使用 upsert:与
iit_qc_logs不同,汇总表使用覆盖模式 enrolledAt:首次录入时自动设置,后续不更新(入组时间锁定)formStatus:JSONB 存储各表单完成状态,便于前端展示进度条
3. iit_qc_project_stats(项目级汇总表)
用于 Dashboard 快速展示,避免每次 COUNT。
model IitQcProjectStats {
id String @id @default(uuid())
projectId String @unique @map("project_id")
// 汇总统计
totalRecords Int @default(0) @map("total_records")
passedRecords Int @default(0) @map("passed_records")
failedRecords Int @default(0) @map("failed_records")
warningRecords Int @default(0) @map("warning_records")
// 入排标准统计
inclusionMet Int @default(0) @map("inclusion_met")
exclusionMet Int @default(0) @map("exclusion_met")
// 录入进度统计
avgCompletionRate Float @default(0) @map("avg_completion_rate")
// 更新时间
updatedAt DateTime @updatedAt @map("updated_at")
@@map("iit_qc_project_stats")
@@schema("iit_schema")
}
4. iit_field_mapping(字段映射表)- 增强
model IitFieldMapping {
id String @id @default(uuid())
projectId String @map("project_id")
// REDCap 字段信息
fieldName String @map("field_name")
fieldLabel String @map("field_label")
fieldType String @map("field_type")
formName String @map("form_name")
// 验证规则(从 REDCap 元数据提取)
validation String? @map("validation")
validationMin String? @map("validation_min")
validationMax String? @map("validation_max")
choices String? @map("choices")
required Boolean @default(false)
// 别名(LLM 友好名称)
alias String?
// 规则来源标记
ruleSource String? @map("rule_source") // 'auto' | 'manual'
// 时间戳
syncedAt DateTime @default(now()) @map("synced_at")
// 唯一约束
@@unique([projectId, fieldName])
@@index([projectId])
@@index([projectId, formName]) // 按表单查询字段
@@map("iit_field_mapping")
@@schema("iit_schema")
}
🔧 核心模块设计
1. 字段同步服务 (FieldSyncService)
// backend/src/modules/iit-manager/services/FieldSyncService.ts
export class FieldSyncService {
/**
* 从 REDCap 同步字段元数据
*
* 流程:
* 1. 调用 RedcapAdapter.exportMetadata()
* 2. 解析元数据,提取字段信息(含 formName)
* 3. 更新 iit_field_mapping 表
* 4. 自动生成默认质控规则(标记 ruleSource = 'auto')
*/
async syncFields(projectId: string): Promise<SyncResult> {
const adapter = await getRedcapAdapter(projectId);
const metadata = await adapter.exportMetadata();
for (const field of metadata) {
await prisma.iitFieldMapping.upsert({
where: { projectId_fieldName: { projectId, fieldName: field.field_name } },
create: {
projectId,
fieldName: field.field_name,
fieldLabel: field.field_label,
fieldType: field.field_type,
formName: field.form_name, // ⭐ 关键:用于单表质控规则匹配
validation: field.text_validation_type_or_show_slider_number,
validationMin: field.text_validation_min,
validationMax: field.text_validation_max,
choices: field.select_choices_or_calculations,
required: field.required_field === 'y',
},
update: { /* ... */ },
});
}
}
/**
* 自动生成默认质控规则
*
* 根据 REDCap 元数据自动生成:
* - 范围规则(from validation_min/max)
* - 必填规则(from required)
* - 枚举规则(from choices)
*
* 规则携带 formName,用于单表质控时过滤
*/
async generateDefaultRules(projectId: string): Promise<GeneratedRule[]> {
const fields = await prisma.iitFieldMapping.findMany({ where: { projectId } });
const rules: GeneratedRule[] = [];
for (const field of fields) {
// 范围规则
if (field.validationMin || field.validationMax) {
rules.push({
id: `${field.fieldName}_range`,
name: `${field.fieldLabel}范围检查`,
formName: field.formName, // ⭐ 规则绑定到表单
source: 'auto',
logic: { /* JSON Logic */ },
});
}
}
return rules;
}
}
2. 质控 Worker (QcWorker)
关键改动:根据 instrument 参数过滤规则,一次处理更新两个表。
// backend/src/modules/iit-manager/workers/qcWorker.ts
import { jobQueue } from '@/common/jobs';
import { logger } from '@/common/logging';
interface QcJob {
projectId: string;
recordId: string;
eventId?: string;
instrument?: string; // ⭐ Webhook 携带的表单名
username?: string; // ⭐ REDCap 录入人
triggeredBy: 'webhook' | 'manual' | 'batch';
}
export function registerQcWorker() {
logger.info('[QcWorker] Registering worker');
jobQueue.process<QcJob>('iit_quality_check', async (job) => {
const { projectId, recordId, eventId, instrument, username, triggeredBy } = job.data;
logger.info('[QcWorker] Processing', { jobId: job.id, recordId, instrument });
try {
// 1. 从 REDCap 获取记录数据
const adapter = await getRedcapAdapter(projectId);
const record = await adapter.getRecordById(recordId);
// ========== 产出1:更新录入汇总表 ==========
await updateRecordSummary({
projectId,
recordId,
username,
formName: instrument,
record,
});
// ========== 产出2:执行单表质控 ==========
// ⭐ 策略 A:根据 instrument 过滤规则
const rules = instrument
? await getRulesByForm(projectId, instrument) // 只加载当前表单的规则
: await getAllRules(projectId); // batch 任务加载全部规则
const ruleEngine = createHardRuleEngine(rules);
const qcResult = ruleEngine.execute(recordId, record);
// 3. 新增质控日志(仅新增,不覆盖)
await prisma.iitQcLog.create({
data: {
projectId,
recordId,
eventId,
qcType: instrument ? 'form' : 'holistic',
formName: instrument,
status: qcResult.overallStatus,
issues: qcResult.results.filter(r => r.status !== 'PASS'),
rulesEvaluated: qcResult.summary.totalRules,
rulesSkipped: qcResult.summary.skipped || 0,
rulesPassed: qcResult.summary.passed,
rulesFailed: qcResult.summary.failed,
ruleVersion: await getRuleVersion(projectId),
triggeredBy,
}
});
// 4. 更新汇总统计
await updateProjectStats(projectId);
// 5. 主动干预
await handleAlerts(projectId, recordId, qcResult);
logger.info('[QcWorker] ✅ Job completed', { jobId: job.id });
return { success: true, recordId, status: qcResult.overallStatus };
} catch (error: any) {
logger.error('[QcWorker] ❌ Job failed', {
jobId: job.id,
error: error.message
});
throw error;
}
});
logger.info('[QcWorker] ✅ Worker registered: iit_quality_check');
}
/**
* 更新录入汇总表
*/
async function updateRecordSummary(params: {
projectId: string;
recordId: string;
username?: string;
formName?: string;
record: Record<string, any>;
}) {
const { projectId, recordId, username, formName, record } = params;
// 获取现有汇总
const existing = await prisma.iitRecordSummary.findUnique({
where: { projectId_recordId: { projectId, recordId } }
});
// 计算表单完成状态
const formStatus = calculateFormStatus(record);
const totalForms = Object.keys(formStatus).length;
const completedForms = Object.values(formStatus).filter(s => s === 2).length;
await prisma.iitRecordSummary.upsert({
where: { projectId_recordId: { projectId, recordId } },
create: {
projectId,
recordId,
enrolledAt: new Date(), // 首次录入 = 入组时间
enrolledBy: username,
lastUpdatedAt: new Date(),
lastUpdatedBy: username,
lastFormName: formName,
formStatus,
totalForms,
completedForms,
completionRate: (completedForms / totalForms) * 100,
updateCount: 1,
},
update: {
// 入组时间不更新
lastUpdatedAt: new Date(),
lastUpdatedBy: username,
lastFormName: formName,
formStatus,
completedForms,
completionRate: (completedForms / totalForms) * 100,
updateCount: { increment: 1 },
},
});
}
3. 每日定时质控服务 (DailyQcService)
策略 B:全案定时质控,使用逻辑守卫处理依赖。
// backend/src/modules/iit-manager/services/DailyQcService.ts
export class DailyQcService {
/**
* 每日凌晨执行全案质控
*
* 特点:
* - 加载所有规则,包括跨表规则
* - 使用逻辑守卫处理依赖表单未完成的情况
*/
async executeHolisticQc(projectId: string) {
const records = await redcapAdapter.exportRecords();
for (const record of records) {
// 推入队列,分批处理
await jobQueue.push('iit_quality_check', {
projectId,
recordId: record.record_id,
triggeredBy: 'batch',
// 不传 instrument,加载全部规则
}, {
singletonKey: `batch-qc-${projectId}-${record.record_id}`,
});
}
}
}
// 跨表规则示例:使用逻辑守卫
const crossFormRules = [
{
id: 'age_lab_consistency',
name: '年龄与实验室结果一致性检查',
formName: null, // 跨表规则,不绑定单一表单
logic: {
"if": [
// 逻辑守卫:检查依赖表单是否完成
{ "!": { "var": "lab_test_complete" } },
{ "__skip": true }, // 返回特殊标记,表示跳过
// 实际检查逻辑
{ "and": [
{ ">=": [{ "var": "age" }, 18] },
{ "<=": [{ "var": "creatinine" }, 1.5] }
]}
]
}
}
];
4. 告警服务 (AlertService)
// backend/src/modules/iit-manager/services/AlertService.ts
export class AlertService {
/**
* 处理告警分级
*/
async handleAlerts(projectId: string, recordId: string, qcResult: QCResult) {
const redIssues = qcResult.results.filter(r => r.severity === 'error');
const yellowIssues = qcResult.results.filter(r => r.severity === 'warning');
// 🔴 RED: 立即推送
if (redIssues.length > 0) {
await this.sendImmediateAlert(projectId, recordId, redIssues);
}
// 🟡 YELLOW: 存入每日摘要队列
if (yellowIssues.length > 0) {
await this.addToDailyDigest(projectId, recordId, yellowIssues);
}
// 🟢 GREEN: 只记录日志,不推送
}
/**
* 立即推送企业微信告警
*/
private async sendImmediateAlert(
projectId: string,
recordId: string,
issues: QCRuleResult[]
) {
const message = this.formatAlertMessage(recordId, issues);
const piUserId = await this.getProjectPiUserId(projectId);
await wechatService.sendTextMessage(piUserId, message);
}
/**
* 每日摘要定时发送(17:00)
*/
async sendDailyDigest(projectId: string) {
// 由定时任务触发
}
}
---
## 📋 开发阶段
### Phase 1: 字段同步与自动规则(预计 2 天)
| 序号 | 任务 | 优先级 | 状态 |
|------|------|--------|------|
| 1.1 | 创建 `iit_field_mapping` 表迁移(含 `formName` 字段) | P0 | ⬜ |
| 1.2 | 实现 `FieldSyncService.syncFields()` | P0 | ⬜ |
| 1.3 | 实现 `FieldSyncService.generateDefaultRules()`(规则绑定 formName) | P0 | ⬜ |
| 1.4 | 在运营端添加"同步字段"按钮 | P1 | ⬜ |
| 1.5 | 规则中添加 `source` 和 `formName` 字段 | P1 | ⬜ |
### Phase 2: 实时质控与录入汇总(预计 3 天)
| 序号 | 任务 | 优先级 | 状态 |
|------|------|--------|------|
| 2.1 | 创建 `iit_qc_logs` 表迁移(支持仅新增模式) | P0 | ⬜ |
| 2.2 | 创建 `iit_record_summary` 表迁移 | P0 | ⬜ |
| 2.3 | 创建 `iit_qc_project_stats` 表迁移 | P0 | ⬜ |
| 2.4 | 重构 `WebhookController`:传递 `instrument` 和 `username` 参数 | P0 | ⬜ |
| 2.5 | 实现 `QcWorker`(一次处理两个产出) | P0 | ⬜ |
| 2.6 | 实现 `updateRecordSummary()` 函数 | P0 | ⬜ |
| 2.7 | 实现 `getRulesByForm()` 函数(根据表单过滤规则) | P0 | ⬜ |
| 2.8 | 实现 `updateProjectStats()` 函数 | P1 | ⬜ |
| 2.9 | 添加 JSONB 索引优化查询性能 | P1 | ⬜ |
### Phase 3: AI Agent 集成(预计 2 天)
| 序号 | 任务 | 优先级 | 状态 |
|------|------|--------|------|
| 3.1 | 新增 `queryQcLogs()` 方法(查询质控历史) | P0 | ⬜ |
| 3.2 | 新增 `getRecordSummary()` 方法(查询录入汇总) | P0 | ⬜ |
| 3.3 | 新增 `getProjectQcStats()` 方法(查询项目汇总) | P0 | ⬜ |
| 3.4 | 修改 `ChatService`:优先查询质控表和汇总表 | P0 | ⬜ |
| 3.5 | 支持"哪些记录有问题"等汇总查询 | P1 | ⬜ |
| 3.6 | 支持"记录10质控历史"等趋势查询 | P1 | ⬜ |
| 3.7 | 支持"最近入组的患者"等录入查询 | P1 | ⬜ |
### Phase 4: 每日定时质控(预计 2 天)
| 序号 | 任务 | 优先级 | 状态 |
|------|------|--------|------|
| 4.1 | 实现 `DailyQcService.executeHolisticQc()` | P0 | ⬜ |
| 4.2 | 实现跨表规则的逻辑守卫模式 | P0 | ⬜ |
| 4.3 | 添加定时任务(凌晨 2:00 执行全案质控) | P0 | ⬜ |
| 4.4 | 实现每日摘要报告生成 | P1 | ⬜ |
### Phase 5: 主动干预(预计 2 天)
| 序号 | 任务 | 优先级 | 状态 |
|------|------|--------|------|
| 5.1 | 实现 `AlertService.handleAlerts()` | P0 | ⬜ |
| 5.2 | 实现紧急告警立即推送 | P0 | ⬜ |
| 5.3 | 实现每日摘要队列 | P1 | ⬜ |
| 5.4 | 添加定时任务(17:00 发送每日摘要) | P1 | ⬜ |
| 5.5 | 告警级别可配置化 | P2 | ⬜ |
### Phase 6: 批量回溯(预计 1 天)
| 序号 | 任务 | 优先级 | 状态 |
|------|------|--------|------|
| 6.1 | 实现 `BatchQcJob`(批量质控任务) | P1 | ⬜ |
| 6.2 | 分片执行(每批 50 条,流控) | P1 | ⬜ |
| 6.3 | 运营端添加"重新质控"按钮 | P2 | ⬜ |
---
## 🛡️ 安全规范
### 1. 幂等性保证
**质控日志表(仅新增模式)**:通过 pg-boss 防抖保证不重复执行。
```typescript
// ✅ 质控日志:仅新增,不需要 upsert
// 幂等性由 pg-boss singletonKey 保证
await prisma.iitQcLog.create({
data: { projectId, recordId, ... }
});
录入汇总表(覆盖模式):使用 upsert 确保重试安全。
// ✅ 录入汇总:使用 upsert
await prisma.iitRecordSummary.upsert({
where: { projectId_recordId: { projectId, recordId } },
create: { enrolledAt: new Date(), ... },
update: { lastUpdatedAt: new Date(), ... },
});
2. pg-boss 防抖
// WebhookController.ts
await jobQueue.push('iit_quality_check',
{ projectId, recordId, triggeredBy: 'webhook' },
{
singletonKey: `qc-${projectId}-${recordId}`,
singletonSeconds: 300, // 5分钟防抖
}
);
3. Payload 精简
// ✅ 只存 ID,不存大数据
await jobQueue.push('iit_quality_check', {
projectId: 'xxx',
recordId: '10',
triggeredBy: 'webhook',
});
// ❌ 禁止存大数据
await jobQueue.push('iit_quality_check', {
recordData: { ... }, // 禁止!
});
4. 任务过期时间
// 质控任务 15 分钟过期
await jobQueue.push('iit_quality_check', data, {
expireInSeconds: 15 * 60,
});
// 批量任务 1 小时过期
await jobQueue.push('iit_batch_qc', data, {
expireInSeconds: 60 * 60,
});
📊 性能预期
| 指标 | 当前 | 优化后 | 改善 |
|---|---|---|---|
| Webhook 响应时间 | 2-5秒 | < 50ms | ✅ -99% |
| AI 查询响应时间 | 3-5秒(调REDCap) | < 200ms(查本地表) | ✅ -95% |
| 批量质控 1000条 | 不支持 | ~5分钟(分片) | ✅ 新增 |
| 并发 Webhook | 可能重复执行 | 防抖去重 | ✅ 修复 |
✅ 检查清单
数据库
iit_field_mapping表已创建(含formName字段)iit_qc_logs表已创建(支持仅新增模式)iit_record_summary表已创建iit_qc_project_stats表已创建- JSONB 索引已添加
双模式质控
- 单表质控:根据
instrument过滤规则 - 全案质控:逻辑守卫模式处理依赖
- 质控规则绑定
formName
异步处理
QcWorker已注册- 队列名称使用下划线(
iit_quality_check) - Worker 在
jobQueue.start()之后注册 - 防抖配置已添加
安全规范
- 质控日志:仅新增,不覆盖
- 录入汇总:使用
upsert - Payload:只存 ID
- 错误处理:直接
throw error
功能完整性
- 字段同步功能可用
- 实时质控存储正常
- 录入汇总更新正常
- AI 查询质控表正常
- AI 查询录入汇总正常
- 告警推送正常
- 每日定时质控正常
趋势分析
- 可查询记录的质控历史
- 可分析数据质量提升过程
- 审计轨迹完整
维护者: IIT Manager Agent 开发团队
最后更新: 2026-02-07
文档状态: ✅ 已完成(v1.1 更新双模式质控策略)