feat(iit): Implement real-time quality control system
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>
This commit is contained in:
354
backend/src/modules/admin/iit-projects/iitBatchController.ts
Normal file
354
backend/src/modules/admin/iit-projects/iitBatchController.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* IIT 批量操作 Controller
|
||||
*
|
||||
* 功能:
|
||||
* - 一键全量质控
|
||||
* - 一键全量数据汇总
|
||||
*
|
||||
* 用途:
|
||||
* - 运营管理端手动触发
|
||||
* - 未来可作为 AI 工具暴露
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { RedcapAdapter } from '../../iit-manager/adapters/RedcapAdapter.js';
|
||||
import { createHardRuleEngine } from '../../iit-manager/engines/HardRuleEngine.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface BatchRequest {
|
||||
Params: { projectId: string };
|
||||
}
|
||||
|
||||
export class IitBatchController {
|
||||
/**
|
||||
* 一键全量质控
|
||||
*
|
||||
* POST /api/v1/admin/iit-projects/:projectId/batch-qc
|
||||
*
|
||||
* 功能:
|
||||
* 1. 获取 REDCap 中所有记录
|
||||
* 2. 对每条记录执行质控
|
||||
* 3. 存储质控日志到 iit_qc_logs
|
||||
* 4. 更新项目统计到 iit_qc_project_stats
|
||||
*/
|
||||
async batchQualityCheck(
|
||||
request: FastifyRequest<BatchRequest>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { projectId } = request.params;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
logger.info('🔄 开始全量质控', { projectId });
|
||||
|
||||
// 1. 获取项目配置
|
||||
const project = await prisma.iitProject.findUnique({
|
||||
where: { id: projectId }
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return reply.status(404).send({ error: '项目不存在' });
|
||||
}
|
||||
|
||||
// 2. 从 REDCap 获取所有记录
|
||||
const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
|
||||
const allRecords = await adapter.exportRecords({});
|
||||
|
||||
if (!allRecords || allRecords.length === 0) {
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '项目暂无记录',
|
||||
stats: { totalRecords: 0 }
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 按 record_id 分组
|
||||
const recordMap = new Map<string, any>();
|
||||
for (const record of allRecords) {
|
||||
const recordId = record.record_id || record.id;
|
||||
if (recordId) {
|
||||
// 合并同一记录的多个事件数据
|
||||
const existing = recordMap.get(recordId) || {};
|
||||
recordMap.set(recordId, { ...existing, ...record });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 执行质控
|
||||
const engine = await createHardRuleEngine(projectId);
|
||||
const ruleVersion = new Date().toISOString().split('T')[0];
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
let warningCount = 0;
|
||||
|
||||
for (const [recordId, recordData] of recordMap.entries()) {
|
||||
const qcResult = engine.execute(recordId, recordData);
|
||||
|
||||
// 存储质控日志
|
||||
const issues = [
|
||||
...qcResult.errors.map((e: any) => ({
|
||||
field: e.field,
|
||||
rule: e.ruleName,
|
||||
level: 'RED',
|
||||
message: e.message
|
||||
})),
|
||||
...qcResult.warnings.map((w: any) => ({
|
||||
field: w.field,
|
||||
rule: w.ruleName,
|
||||
level: 'YELLOW',
|
||||
message: w.message
|
||||
}))
|
||||
];
|
||||
|
||||
await prisma.iitQcLog.create({
|
||||
data: {
|
||||
projectId,
|
||||
recordId,
|
||||
qcType: 'holistic', // 全案质控
|
||||
status: qcResult.overallStatus,
|
||||
issues,
|
||||
rulesEvaluated: qcResult.summary.totalRules,
|
||||
rulesSkipped: 0,
|
||||
rulesPassed: qcResult.summary.passed,
|
||||
rulesFailed: qcResult.summary.failed,
|
||||
ruleVersion,
|
||||
triggeredBy: 'manual'
|
||||
}
|
||||
});
|
||||
|
||||
// 更新录入汇总表的质控状态
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: {
|
||||
projectId_recordId: { projectId, recordId }
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
recordId,
|
||||
lastUpdatedAt: new Date(),
|
||||
latestQcStatus: qcResult.overallStatus,
|
||||
latestQcAt: new Date(),
|
||||
formStatus: {},
|
||||
updateCount: 1
|
||||
},
|
||||
update: {
|
||||
latestQcStatus: qcResult.overallStatus,
|
||||
latestQcAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// 统计
|
||||
if (qcResult.overallStatus === 'PASS') passCount++;
|
||||
else if (qcResult.overallStatus === 'FAIL') failCount++;
|
||||
else warningCount++;
|
||||
}
|
||||
|
||||
// 5. 更新项目统计表
|
||||
await prisma.iitQcProjectStats.upsert({
|
||||
where: { projectId },
|
||||
create: {
|
||||
projectId,
|
||||
totalRecords: recordMap.size,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount
|
||||
},
|
||||
update: {
|
||||
totalRecords: recordMap.size,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount
|
||||
}
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logger.info('✅ 全量质控完成', {
|
||||
projectId,
|
||||
totalRecords: recordMap.size,
|
||||
passCount,
|
||||
failCount,
|
||||
warningCount,
|
||||
durationMs
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '全量质控完成',
|
||||
stats: {
|
||||
totalRecords: recordMap.size,
|
||||
passed: passCount,
|
||||
failed: failCount,
|
||||
warnings: warningCount,
|
||||
passRate: `${((passCount / recordMap.size) * 100).toFixed(1)}%`
|
||||
},
|
||||
durationMs
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 全量质控失败', { projectId, error: error.message });
|
||||
return reply.status(500).send({ error: `质控失败: ${error.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键全量数据汇总
|
||||
*
|
||||
* POST /api/v1/admin/iit-projects/:projectId/batch-summary
|
||||
*
|
||||
* 功能:
|
||||
* 1. 获取 REDCap 中所有记录
|
||||
* 2. 获取项目的所有表单(instruments)
|
||||
* 3. 为每条记录生成/更新录入汇总
|
||||
*/
|
||||
async batchSummary(
|
||||
request: FastifyRequest<BatchRequest>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { projectId } = request.params;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
logger.info('🔄 开始全量数据汇总', { projectId });
|
||||
|
||||
// 1. 获取项目配置
|
||||
const project = await prisma.iitProject.findUnique({
|
||||
where: { id: projectId }
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return reply.status(404).send({ error: '项目不存在' });
|
||||
}
|
||||
|
||||
// 2. 从 REDCap 获取所有记录和表单信息
|
||||
const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
|
||||
|
||||
const [allRecords, instruments] = await Promise.all([
|
||||
adapter.exportRecords({}),
|
||||
adapter.exportInstruments()
|
||||
]);
|
||||
|
||||
if (!allRecords || allRecords.length === 0) {
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '项目暂无记录',
|
||||
stats: { totalRecords: 0 }
|
||||
});
|
||||
}
|
||||
|
||||
const totalForms = instruments?.length || 10;
|
||||
|
||||
// 3. 按 record_id 分组并计算表单完成状态
|
||||
const recordMap = new Map<string, { data: any; forms: Set<string>; firstSeen: Date }>();
|
||||
|
||||
for (const record of allRecords) {
|
||||
const recordId = record.record_id || record.id;
|
||||
if (!recordId) continue;
|
||||
|
||||
const existing = recordMap.get(recordId);
|
||||
if (existing) {
|
||||
// 合并数据
|
||||
existing.data = { ...existing.data, ...record };
|
||||
// 记录表单
|
||||
if (record.redcap_repeat_instrument) {
|
||||
existing.forms.add(record.redcap_repeat_instrument);
|
||||
}
|
||||
} else {
|
||||
recordMap.set(recordId, {
|
||||
data: record,
|
||||
forms: new Set(record.redcap_repeat_instrument ? [record.redcap_repeat_instrument] : []),
|
||||
firstSeen: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 为每条记录更新汇总
|
||||
let summaryCount = 0;
|
||||
|
||||
for (const [recordId, { data, forms, firstSeen }] of recordMap.entries()) {
|
||||
// 计算表单完成状态(简化:有数据即认为完成)
|
||||
const formStatus: Record<string, number> = {};
|
||||
const completedForms = forms.size || 1; // 至少有1个表单有数据
|
||||
|
||||
for (const form of forms) {
|
||||
formStatus[form] = 2; // 2 = 完成
|
||||
}
|
||||
|
||||
const completionRate = Math.min(100, Math.round((completedForms / totalForms) * 100));
|
||||
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: {
|
||||
projectId_recordId: { projectId, recordId }
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
recordId,
|
||||
enrolledAt: firstSeen,
|
||||
lastUpdatedAt: new Date(),
|
||||
formStatus,
|
||||
totalForms,
|
||||
completedForms,
|
||||
completionRate,
|
||||
updateCount: 1
|
||||
},
|
||||
update: {
|
||||
lastUpdatedAt: new Date(),
|
||||
formStatus,
|
||||
totalForms,
|
||||
completedForms,
|
||||
completionRate,
|
||||
updateCount: { increment: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
summaryCount++;
|
||||
}
|
||||
|
||||
// 5. 更新项目统计
|
||||
const avgCompletionRate = await prisma.iitRecordSummary.aggregate({
|
||||
where: { projectId },
|
||||
_avg: { completionRate: true }
|
||||
});
|
||||
|
||||
await prisma.iitQcProjectStats.upsert({
|
||||
where: { projectId },
|
||||
create: {
|
||||
projectId,
|
||||
totalRecords: recordMap.size,
|
||||
avgCompletionRate: avgCompletionRate._avg.completionRate || 0
|
||||
},
|
||||
update: {
|
||||
totalRecords: recordMap.size,
|
||||
avgCompletionRate: avgCompletionRate._avg.completionRate || 0
|
||||
}
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logger.info('✅ 全量数据汇总完成', {
|
||||
projectId,
|
||||
totalRecords: recordMap.size,
|
||||
summaryCount,
|
||||
durationMs
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '全量数据汇总完成',
|
||||
stats: {
|
||||
totalRecords: recordMap.size,
|
||||
summariesUpdated: summaryCount,
|
||||
totalForms,
|
||||
avgCompletionRate: `${(avgCompletionRate._avg.completionRate || 0).toFixed(1)}%`
|
||||
},
|
||||
durationMs
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 全量数据汇总失败', { projectId, error: error.message });
|
||||
return reply.status(500).send({ error: `汇总失败: ${error.message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const iitBatchController = new IitBatchController();
|
||||
81
backend/src/modules/admin/iit-projects/iitBatchRoutes.ts
Normal file
81
backend/src/modules/admin/iit-projects/iitBatchRoutes.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* IIT 批量操作路由
|
||||
*
|
||||
* API:
|
||||
* - POST /api/v1/admin/iit-projects/:projectId/batch-qc 一键全量质控
|
||||
* - POST /api/v1/admin/iit-projects/:projectId/batch-summary 一键全量数据汇总
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { iitBatchController } from './iitBatchController.js';
|
||||
|
||||
export async function iitBatchRoutes(fastify: FastifyInstance) {
|
||||
// 一键全量质控
|
||||
fastify.post('/:projectId/batch-qc', {
|
||||
schema: {
|
||||
description: '一键全量质控 - 对项目中所有记录执行质控',
|
||||
tags: ['IIT Admin - 批量操作'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectId: { type: 'string', description: 'IIT 项目 ID' }
|
||||
},
|
||||
required: ['projectId']
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
stats: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalRecords: { type: 'number' },
|
||||
passed: { type: 'number' },
|
||||
failed: { type: 'number' },
|
||||
warnings: { type: 'number' },
|
||||
passRate: { type: 'string' }
|
||||
}
|
||||
},
|
||||
durationMs: { type: 'number' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, iitBatchController.batchQualityCheck.bind(iitBatchController));
|
||||
|
||||
// 一键全量数据汇总
|
||||
fastify.post('/:projectId/batch-summary', {
|
||||
schema: {
|
||||
description: '一键全量数据汇总 - 从 REDCap 同步所有记录的录入状态',
|
||||
tags: ['IIT Admin - 批量操作'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectId: { type: 'string', description: 'IIT 项目 ID' }
|
||||
},
|
||||
required: ['projectId']
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
stats: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalRecords: { type: 'number' },
|
||||
summariesUpdated: { type: 'number' },
|
||||
totalForms: { type: 'number' },
|
||||
avgCompletionRate: { type: 'string' }
|
||||
}
|
||||
},
|
||||
durationMs: { type: 'number' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, iitBatchController.batchSummary.bind(iitBatchController));
|
||||
}
|
||||
348
backend/src/modules/admin/iit-projects/iitProjectController.ts
Normal file
348
backend/src/modules/admin/iit-projects/iitProjectController.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* IIT 项目管理控制器
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { getIitProjectService, CreateProjectInput, UpdateProjectInput } from './iitProjectService.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
interface ProjectIdParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ListProjectsQuery {
|
||||
status?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
interface TestConnectionBody {
|
||||
redcapUrl: string;
|
||||
redcapApiToken: string;
|
||||
}
|
||||
|
||||
interface LinkKbBody {
|
||||
knowledgeBaseId: string;
|
||||
}
|
||||
|
||||
// ==================== 控制器函数 ====================
|
||||
|
||||
/**
|
||||
* 获取项目列表
|
||||
*/
|
||||
export async function listProjects(
|
||||
request: FastifyRequest<{ Querystring: ListProjectsQuery }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { status, search } = request.query;
|
||||
const service = getIitProjectService(prisma);
|
||||
const projects = await service.listProjects({ status, search });
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: projects,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取 IIT 项目列表失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目详情
|
||||
*/
|
||||
export async function getProject(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const service = getIitProjectService(prisma);
|
||||
const project = await service.getProject(id);
|
||||
|
||||
if (!project) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: '项目不存在',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: project,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取 IIT 项目详情失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目
|
||||
*/
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: CreateProjectInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const input = request.body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!input.name) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '项目名称为必填项',
|
||||
});
|
||||
}
|
||||
|
||||
if (!input.redcapUrl || !input.redcapProjectId || !input.redcapApiToken) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'REDCap 配置信息为必填项(URL、项目ID、API Token)',
|
||||
});
|
||||
}
|
||||
|
||||
const service = getIitProjectService(prisma);
|
||||
const project = await service.createProject(input);
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: project,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('创建 IIT 项目失败', { error: message });
|
||||
|
||||
if (message.includes('连接测试失败')) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目
|
||||
*/
|
||||
export async function updateProject(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams; Body: UpdateProjectInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const input = request.body;
|
||||
|
||||
const service = getIitProjectService(prisma);
|
||||
const project = await service.updateProject(id, input);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: project,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('更新 IIT 项目失败', { error: message });
|
||||
|
||||
if (message.includes('连接测试失败')) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目
|
||||
*/
|
||||
export async function deleteProject(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const service = getIitProjectService(prisma);
|
||||
await service.deleteProject(id);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '删除成功',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('删除 IIT 项目失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 REDCap 连接(新配置)
|
||||
*/
|
||||
export async function testConnection(
|
||||
request: FastifyRequest<{ Body: TestConnectionBody }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { redcapUrl, redcapApiToken } = request.body;
|
||||
|
||||
if (!redcapUrl || !redcapApiToken) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '请提供 REDCap URL 和 API Token',
|
||||
});
|
||||
}
|
||||
|
||||
const service = getIitProjectService(prisma);
|
||||
const result = await service.testConnection(redcapUrl, redcapApiToken);
|
||||
|
||||
return reply.send({
|
||||
success: result.success,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('测试 REDCap 连接失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试项目的 REDCap 连接
|
||||
*/
|
||||
export async function testProjectConnection(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const service = getIitProjectService(prisma);
|
||||
const result = await service.testProjectConnection(id);
|
||||
|
||||
return reply.send({
|
||||
success: result.success,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('测试项目连接失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步 REDCap 元数据
|
||||
*/
|
||||
export async function syncMetadata(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const service = getIitProjectService(prisma);
|
||||
const result = await service.syncMetadata(id);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('同步元数据失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联知识库
|
||||
*/
|
||||
export async function linkKnowledgeBase(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams; Body: LinkKbBody }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const { knowledgeBaseId } = request.body;
|
||||
|
||||
if (!knowledgeBaseId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '请提供知识库 ID',
|
||||
});
|
||||
}
|
||||
|
||||
const service = getIitProjectService(prisma);
|
||||
await service.linkKnowledgeBase(id, knowledgeBaseId);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '关联成功',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('关联知识库失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除知识库关联
|
||||
*/
|
||||
export async function unlinkKnowledgeBase(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const service = getIitProjectService(prisma);
|
||||
await service.unlinkKnowledgeBase(id);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '解除关联成功',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('解除知识库关联失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
44
backend/src/modules/admin/iit-projects/iitProjectRoutes.ts
Normal file
44
backend/src/modules/admin/iit-projects/iitProjectRoutes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* IIT 项目管理路由
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './iitProjectController.js';
|
||||
|
||||
export async function iitProjectRoutes(fastify: FastifyInstance) {
|
||||
// ==================== 项目 CRUD ====================
|
||||
|
||||
// 获取项目列表
|
||||
fastify.get('/', controller.listProjects);
|
||||
|
||||
// 获取项目详情
|
||||
fastify.get('/:id', controller.getProject);
|
||||
|
||||
// 创建项目
|
||||
fastify.post('/', controller.createProject);
|
||||
|
||||
// 更新项目
|
||||
fastify.put('/:id', controller.updateProject);
|
||||
|
||||
// 删除项目
|
||||
fastify.delete('/:id', controller.deleteProject);
|
||||
|
||||
// ==================== REDCap 连接 ====================
|
||||
|
||||
// 测试 REDCap 连接(新配置,不需要项目 ID)
|
||||
fastify.post('/test-connection', controller.testConnection);
|
||||
|
||||
// 测试指定项目的 REDCap 连接
|
||||
fastify.post('/:id/test-connection', controller.testProjectConnection);
|
||||
|
||||
// 同步 REDCap 元数据
|
||||
fastify.post('/:id/sync-metadata', controller.syncMetadata);
|
||||
|
||||
// ==================== 知识库关联 ====================
|
||||
|
||||
// 关联知识库
|
||||
fastify.post('/:id/knowledge-base', controller.linkKnowledgeBase);
|
||||
|
||||
// 解除知识库关联
|
||||
fastify.delete('/:id/knowledge-base', controller.unlinkKnowledgeBase);
|
||||
}
|
||||
372
backend/src/modules/admin/iit-projects/iitProjectService.ts
Normal file
372
backend/src/modules/admin/iit-projects/iitProjectService.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* IIT 项目管理服务
|
||||
* 提供 IIT 项目的 CRUD 操作及相关功能
|
||||
*/
|
||||
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { RedcapAdapter } from '../../iit-manager/adapters/RedcapAdapter.js';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface CreateProjectInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
redcapUrl: string;
|
||||
redcapProjectId: string;
|
||||
redcapApiToken: string;
|
||||
fieldMappings?: Record<string, unknown>;
|
||||
knowledgeBaseId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProjectInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
redcapUrl?: string;
|
||||
redcapProjectId?: string;
|
||||
redcapApiToken?: string;
|
||||
fieldMappings?: Record<string, unknown>;
|
||||
knowledgeBaseId?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
success: boolean;
|
||||
version?: string;
|
||||
projectTitle?: string;
|
||||
recordCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ProjectListFilters {
|
||||
status?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// ==================== 服务实现 ====================
|
||||
|
||||
export class IitProjectService {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
/**
|
||||
* 获取项目列表
|
||||
*/
|
||||
async listProjects(filters?: ProjectListFilters) {
|
||||
const where: Prisma.IitProjectWhereInput = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
if (filters?.status) {
|
||||
where.status = filters.status;
|
||||
}
|
||||
|
||||
if (filters?.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ description: { contains: filters.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const projects = await this.prisma.iitProject.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
redcapProjectId: true,
|
||||
redcapUrl: true,
|
||||
knowledgeBaseId: true,
|
||||
status: true,
|
||||
lastSyncAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
userMappings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return projects.map((p) => ({
|
||||
...p,
|
||||
userMappingCount: p._count.userMappings,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目详情
|
||||
*/
|
||||
async getProject(id: string) {
|
||||
const project = await this.prisma.iitProject.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: {
|
||||
userMappings: {
|
||||
select: {
|
||||
id: true,
|
||||
systemUserId: true,
|
||||
redcapUsername: true,
|
||||
wecomUserId: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取关联的质控规则数量
|
||||
const skillCount = await this.prisma.iitSkill.count({
|
||||
where: { projectId: id },
|
||||
});
|
||||
|
||||
// 获取关联的知识库信息
|
||||
let knowledgeBase = null;
|
||||
if (project.knowledgeBaseId) {
|
||||
knowledgeBase = await this.prisma.ekbKnowledgeBase.findUnique({
|
||||
where: { id: project.knowledgeBaseId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
_count: { select: { documents: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
skillCount,
|
||||
knowledgeBase: knowledgeBase
|
||||
? {
|
||||
id: knowledgeBase.id,
|
||||
name: knowledgeBase.name,
|
||||
documentCount: knowledgeBase._count.documents,
|
||||
}
|
||||
: null,
|
||||
// 返回完整 token 供编辑使用
|
||||
redcapApiToken: project.redcapApiToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目
|
||||
*/
|
||||
async createProject(input: CreateProjectInput) {
|
||||
// 验证 REDCap 连接
|
||||
const testResult = await this.testConnection(
|
||||
input.redcapUrl,
|
||||
input.redcapApiToken
|
||||
);
|
||||
|
||||
if (!testResult.success) {
|
||||
throw new Error(`REDCap 连接测试失败: ${testResult.error}`);
|
||||
}
|
||||
|
||||
const project = await this.prisma.iitProject.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
redcapUrl: input.redcapUrl,
|
||||
redcapProjectId: input.redcapProjectId,
|
||||
redcapApiToken: input.redcapApiToken,
|
||||
fieldMappings: input.fieldMappings || {},
|
||||
knowledgeBaseId: input.knowledgeBaseId,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('创建 IIT 项目成功', { projectId: project.id, name: project.name });
|
||||
return project;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目
|
||||
*/
|
||||
async updateProject(id: string, input: UpdateProjectInput) {
|
||||
// 如果更新了 REDCap 配置,需要验证连接
|
||||
if (input.redcapUrl && input.redcapApiToken) {
|
||||
const testResult = await this.testConnection(
|
||||
input.redcapUrl,
|
||||
input.redcapApiToken
|
||||
);
|
||||
|
||||
if (!testResult.success) {
|
||||
throw new Error(`REDCap 连接测试失败: ${testResult.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
const project = await this.prisma.iitProject.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
redcapUrl: input.redcapUrl,
|
||||
redcapProjectId: input.redcapProjectId,
|
||||
redcapApiToken: input.redcapApiToken,
|
||||
fieldMappings: input.fieldMappings,
|
||||
knowledgeBaseId: input.knowledgeBaseId,
|
||||
status: input.status,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('更新 IIT 项目成功', { projectId: project.id });
|
||||
return project;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目(软删除)
|
||||
*/
|
||||
async deleteProject(id: string) {
|
||||
await this.prisma.iitProject.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
status: 'deleted',
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('删除 IIT 项目成功', { projectId: id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 REDCap 连接
|
||||
*/
|
||||
async testConnection(
|
||||
redcapUrl: string,
|
||||
redcapApiToken: string
|
||||
): Promise<TestConnectionResult> {
|
||||
try {
|
||||
const adapter = new RedcapAdapter(redcapUrl, redcapApiToken);
|
||||
|
||||
// 测试连接
|
||||
const connected = await adapter.testConnection();
|
||||
if (!connected) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'REDCap API 连接失败',
|
||||
};
|
||||
}
|
||||
|
||||
// 获取记录数量
|
||||
const recordCount = await adapter.getRecordCount();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
version: 'REDCap API',
|
||||
recordCount,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('REDCap 连接测试失败', { error: message });
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试指定项目的 REDCap 连接
|
||||
*/
|
||||
async testProjectConnection(projectId: string): Promise<TestConnectionResult> {
|
||||
const project = await this.prisma.iitProject.findFirst({
|
||||
where: { id: projectId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return {
|
||||
success: false,
|
||||
error: '项目不存在',
|
||||
};
|
||||
}
|
||||
|
||||
return this.testConnection(project.redcapUrl, project.redcapApiToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步 REDCap 元数据
|
||||
*/
|
||||
async syncMetadata(projectId: string) {
|
||||
const project = await this.prisma.iitProject.findFirst({
|
||||
where: { id: projectId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error('项目不存在');
|
||||
}
|
||||
|
||||
try {
|
||||
const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
|
||||
const metadata = await adapter.exportMetadata();
|
||||
|
||||
// 更新最后同步时间
|
||||
await this.prisma.iitProject.update({
|
||||
where: { id: projectId },
|
||||
data: { lastSyncAt: new Date() },
|
||||
});
|
||||
|
||||
logger.info('同步 REDCap 元数据成功', {
|
||||
projectId,
|
||||
fieldCount: metadata.length,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fieldCount: metadata.length,
|
||||
metadata,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('同步 REDCap 元数据失败', { projectId, error: message });
|
||||
throw new Error(`同步失败: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联知识库
|
||||
*/
|
||||
async linkKnowledgeBase(projectId: string, knowledgeBaseId: string) {
|
||||
// 验证知识库存在
|
||||
const kb = await this.prisma.ekbKnowledgeBase.findUnique({
|
||||
where: { id: knowledgeBaseId },
|
||||
});
|
||||
|
||||
if (!kb) {
|
||||
throw new Error('知识库不存在');
|
||||
}
|
||||
|
||||
await this.prisma.iitProject.update({
|
||||
where: { id: projectId },
|
||||
data: { knowledgeBaseId },
|
||||
});
|
||||
|
||||
logger.info('关联知识库成功', { projectId, knowledgeBaseId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除知识库关联
|
||||
*/
|
||||
async unlinkKnowledgeBase(projectId: string) {
|
||||
await this.prisma.iitProject.update({
|
||||
where: { id: projectId },
|
||||
data: { knowledgeBaseId: null },
|
||||
});
|
||||
|
||||
logger.info('解除知识库关联成功', { projectId });
|
||||
}
|
||||
}
|
||||
|
||||
// 单例工厂函数
|
||||
let serviceInstance: IitProjectService | null = null;
|
||||
|
||||
export function getIitProjectService(prisma: PrismaClient): IitProjectService {
|
||||
if (!serviceInstance) {
|
||||
serviceInstance = new IitProjectService(prisma);
|
||||
}
|
||||
return serviceInstance;
|
||||
}
|
||||
288
backend/src/modules/admin/iit-projects/iitQcRuleController.ts
Normal file
288
backend/src/modules/admin/iit-projects/iitQcRuleController.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* IIT 质控规则管理控制器
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { getIitQcRuleService, CreateRuleInput, UpdateRuleInput, TestRuleInput } from './iitQcRuleService.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
interface ProjectIdParams {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface RuleIdParams {
|
||||
projectId: string;
|
||||
ruleId: string;
|
||||
}
|
||||
|
||||
interface ImportRulesBody {
|
||||
rules: CreateRuleInput[];
|
||||
}
|
||||
|
||||
// ==================== 控制器函数 ====================
|
||||
|
||||
/**
|
||||
* 获取项目的所有质控规则
|
||||
*/
|
||||
export async function listRules(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId } = request.params;
|
||||
const service = getIitQcRuleService(prisma);
|
||||
const rules = await service.listRules(projectId);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: rules,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取质控规则列表失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条规则
|
||||
*/
|
||||
export async function getRule(
|
||||
request: FastifyRequest<{ Params: RuleIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId, ruleId } = request.params;
|
||||
const service = getIitQcRuleService(prisma);
|
||||
const rule = await service.getRule(projectId, ruleId);
|
||||
|
||||
if (!rule) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: '规则不存在',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: rule,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取质控规则失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加规则
|
||||
*/
|
||||
export async function addRule(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams; Body: CreateRuleInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId } = request.params;
|
||||
const input = request.body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!input.name || !input.field || !input.logic || !input.message || !input.severity || !input.category) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '缺少必填字段:name, field, logic, message, severity, category',
|
||||
});
|
||||
}
|
||||
|
||||
const service = getIitQcRuleService(prisma);
|
||||
const rule = await service.addRule(projectId, input);
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: rule,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('添加质控规则失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新规则
|
||||
*/
|
||||
export async function updateRule(
|
||||
request: FastifyRequest<{ Params: RuleIdParams; Body: UpdateRuleInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId, ruleId } = request.params;
|
||||
const input = request.body;
|
||||
|
||||
const service = getIitQcRuleService(prisma);
|
||||
const rule = await service.updateRule(projectId, ruleId, input);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: rule,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('更新质控规则失败', { error: message });
|
||||
|
||||
if (message.includes('规则不存在')) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除规则
|
||||
*/
|
||||
export async function deleteRule(
|
||||
request: FastifyRequest<{ Params: RuleIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId, ruleId } = request.params;
|
||||
const service = getIitQcRuleService(prisma);
|
||||
await service.deleteRule(projectId, ruleId);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '删除成功',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('删除质控规则失败', { error: message });
|
||||
|
||||
if (message.includes('规则不存在')) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入规则
|
||||
*/
|
||||
export async function importRules(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams; Body: ImportRulesBody }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId } = request.params;
|
||||
const { rules } = request.body;
|
||||
|
||||
if (!rules || !Array.isArray(rules) || rules.length === 0) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '请提供规则数组',
|
||||
});
|
||||
}
|
||||
|
||||
const service = getIitQcRuleService(prisma);
|
||||
const importedRules = await service.importRules(projectId, rules);
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: {
|
||||
count: importedRules.length,
|
||||
rules: importedRules,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('批量导入质控规则失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试规则逻辑
|
||||
*/
|
||||
export async function testRule(
|
||||
request: FastifyRequest<{ Body: TestRuleInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const input = request.body;
|
||||
|
||||
if (!input.logic || !input.testData) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '请提供 logic 和 testData',
|
||||
});
|
||||
}
|
||||
|
||||
const service = getIitQcRuleService(prisma);
|
||||
const result = await service.testRule(input);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('测试规则失败', { error: message });
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取规则统计
|
||||
*/
|
||||
export async function getRuleStats(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId } = request.params;
|
||||
const service = getIitQcRuleService(prisma);
|
||||
const stats = await service.getRuleStats(projectId);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取规则统计失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
32
backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts
Normal file
32
backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* IIT 质控规则管理路由
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './iitQcRuleController.js';
|
||||
|
||||
export async function iitQcRuleRoutes(fastify: FastifyInstance) {
|
||||
// 获取项目的所有质控规则
|
||||
fastify.get('/:projectId/rules', controller.listRules);
|
||||
|
||||
// 获取规则统计
|
||||
fastify.get('/:projectId/rules/stats', controller.getRuleStats);
|
||||
|
||||
// 获取单条规则
|
||||
fastify.get('/:projectId/rules/:ruleId', controller.getRule);
|
||||
|
||||
// 添加规则
|
||||
fastify.post('/:projectId/rules', controller.addRule);
|
||||
|
||||
// 更新规则
|
||||
fastify.put('/:projectId/rules/:ruleId', controller.updateRule);
|
||||
|
||||
// 删除规则
|
||||
fastify.delete('/:projectId/rules/:ruleId', controller.deleteRule);
|
||||
|
||||
// 批量导入规则
|
||||
fastify.post('/:projectId/rules/import', controller.importRules);
|
||||
|
||||
// 测试规则逻辑(不需要项目 ID)
|
||||
fastify.post('/rules/test', controller.testRule);
|
||||
}
|
||||
272
backend/src/modules/admin/iit-projects/iitQcRuleService.ts
Normal file
272
backend/src/modules/admin/iit-projects/iitQcRuleService.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* IIT 质控规则管理服务
|
||||
* 管理 JSON Logic 格式的质控规则
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import jsonLogic from 'json-logic-js';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface QCRule {
|
||||
id: string;
|
||||
name: string;
|
||||
field: string | string[];
|
||||
logic: Record<string, unknown>;
|
||||
message: string;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check';
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateRuleInput {
|
||||
name: string;
|
||||
field: string | string[];
|
||||
logic: Record<string, unknown>;
|
||||
message: string;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check';
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateRuleInput {
|
||||
name?: string;
|
||||
field?: string | string[];
|
||||
logic?: Record<string, unknown>;
|
||||
message?: string;
|
||||
severity?: 'error' | 'warning' | 'info';
|
||||
category?: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check';
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QCRuleConfig {
|
||||
rules: QCRule[];
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TestRuleInput {
|
||||
logic: Record<string, unknown>;
|
||||
testData: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ==================== 服务实现 ====================
|
||||
|
||||
export class IitQcRuleService {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
/**
|
||||
* 获取项目的所有质控规则
|
||||
*/
|
||||
async listRules(projectId: string): Promise<QCRule[]> {
|
||||
const skill = await this.prisma.iitSkill.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
skillType: 'qc_process',
|
||||
},
|
||||
});
|
||||
|
||||
if (!skill || !skill.config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const config = skill.config as unknown as QCRuleConfig;
|
||||
return config.rules || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条规则
|
||||
*/
|
||||
async getRule(projectId: string, ruleId: string): Promise<QCRule | null> {
|
||||
const rules = await this.listRules(projectId);
|
||||
return rules.find((r) => r.id === ruleId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加规则
|
||||
*/
|
||||
async addRule(projectId: string, input: CreateRuleInput): Promise<QCRule> {
|
||||
const skill = await this.getOrCreateSkill(projectId);
|
||||
const config = (skill.config as unknown as QCRuleConfig) || { rules: [], version: 1, updatedAt: '' };
|
||||
|
||||
// 生成规则 ID
|
||||
const ruleId = `rule_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const newRule: QCRule = {
|
||||
id: ruleId,
|
||||
...input,
|
||||
};
|
||||
|
||||
config.rules.push(newRule);
|
||||
config.version += 1;
|
||||
config.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.prisma.iitSkill.update({
|
||||
where: { id: skill.id },
|
||||
data: { config: config as unknown as object },
|
||||
});
|
||||
|
||||
logger.info('添加质控规则成功', { projectId, ruleId, ruleName: input.name });
|
||||
return newRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新规则
|
||||
*/
|
||||
async updateRule(projectId: string, ruleId: string, input: UpdateRuleInput): Promise<QCRule> {
|
||||
const skill = await this.getOrCreateSkill(projectId);
|
||||
const config = (skill.config as unknown as QCRuleConfig) || { rules: [], version: 1, updatedAt: '' };
|
||||
|
||||
const ruleIndex = config.rules.findIndex((r) => r.id === ruleId);
|
||||
if (ruleIndex === -1) {
|
||||
throw new Error('规则不存在');
|
||||
}
|
||||
|
||||
const updatedRule: QCRule = {
|
||||
...config.rules[ruleIndex],
|
||||
...input,
|
||||
};
|
||||
|
||||
config.rules[ruleIndex] = updatedRule;
|
||||
config.version += 1;
|
||||
config.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.prisma.iitSkill.update({
|
||||
where: { id: skill.id },
|
||||
data: { config: config as unknown as object },
|
||||
});
|
||||
|
||||
logger.info('更新质控规则成功', { projectId, ruleId });
|
||||
return updatedRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除规则
|
||||
*/
|
||||
async deleteRule(projectId: string, ruleId: string): Promise<void> {
|
||||
const skill = await this.getOrCreateSkill(projectId);
|
||||
const config = (skill.config as unknown as QCRuleConfig) || { rules: [], version: 1, updatedAt: '' };
|
||||
|
||||
const ruleIndex = config.rules.findIndex((r) => r.id === ruleId);
|
||||
if (ruleIndex === -1) {
|
||||
throw new Error('规则不存在');
|
||||
}
|
||||
|
||||
config.rules.splice(ruleIndex, 1);
|
||||
config.version += 1;
|
||||
config.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.prisma.iitSkill.update({
|
||||
where: { id: skill.id },
|
||||
data: { config: config as unknown as object },
|
||||
});
|
||||
|
||||
logger.info('删除质控规则成功', { projectId, ruleId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入规则
|
||||
*/
|
||||
async importRules(projectId: string, rules: CreateRuleInput[]): Promise<QCRule[]> {
|
||||
const skill = await this.getOrCreateSkill(projectId);
|
||||
|
||||
const newRules: QCRule[] = rules.map((input, index) => ({
|
||||
id: `rule_${Date.now()}_${index}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
...input,
|
||||
}));
|
||||
|
||||
const config: QCRuleConfig = {
|
||||
rules: newRules,
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.prisma.iitSkill.update({
|
||||
where: { id: skill.id },
|
||||
data: { config: config as unknown as object },
|
||||
});
|
||||
|
||||
logger.info('批量导入质控规则成功', { projectId, ruleCount: newRules.length });
|
||||
return newRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试规则逻辑
|
||||
*/
|
||||
async testRule(input: TestRuleInput): Promise<{ passed: boolean; result: unknown }> {
|
||||
try {
|
||||
const result = jsonLogic.apply(input.logic, input.testData);
|
||||
return {
|
||||
passed: !!result,
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`规则执行失败: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取规则分类统计
|
||||
*/
|
||||
async getRuleStats(projectId: string) {
|
||||
const rules = await this.listRules(projectId);
|
||||
|
||||
const stats = {
|
||||
total: rules.length,
|
||||
byCategory: {} as Record<string, number>,
|
||||
bySeverity: {} as Record<string, number>,
|
||||
};
|
||||
|
||||
rules.forEach((rule) => {
|
||||
stats.byCategory[rule.category] = (stats.byCategory[rule.category] || 0) + 1;
|
||||
stats.bySeverity[rule.severity] = (stats.bySeverity[rule.severity] || 0) + 1;
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建 qc_process Skill
|
||||
*/
|
||||
private async getOrCreateSkill(projectId: string) {
|
||||
let skill = await this.prisma.iitSkill.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
skillType: 'qc_process',
|
||||
},
|
||||
});
|
||||
|
||||
if (!skill) {
|
||||
skill = await this.prisma.iitSkill.create({
|
||||
data: {
|
||||
projectId,
|
||||
skillType: 'qc_process',
|
||||
name: '质控规则',
|
||||
description: '数据质量控制规则集',
|
||||
config: {
|
||||
rules: [],
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('创建 qc_process Skill', { projectId, skillId: skill.id });
|
||||
}
|
||||
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
|
||||
// 单例工厂函数
|
||||
let serviceInstance: IitQcRuleService | null = null;
|
||||
|
||||
export function getIitQcRuleService(prisma: PrismaClient): IitQcRuleService {
|
||||
if (!serviceInstance) {
|
||||
serviceInstance = new IitQcRuleService(prisma);
|
||||
}
|
||||
return serviceInstance;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* IIT 用户映射管理控制器
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { getIitUserMappingService, CreateUserMappingInput, UpdateUserMappingInput } from './iitUserMappingService.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
interface ProjectIdParams {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface MappingIdParams {
|
||||
projectId: string;
|
||||
mappingId: string;
|
||||
}
|
||||
|
||||
interface ListMappingsQuery {
|
||||
role?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// ==================== 控制器函数 ====================
|
||||
|
||||
/**
|
||||
* 获取项目的用户映射列表
|
||||
*/
|
||||
export async function listUserMappings(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams; Querystring: ListMappingsQuery }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId } = request.params;
|
||||
const { role, search } = request.query;
|
||||
|
||||
const service = getIitUserMappingService(prisma);
|
||||
const mappings = await service.listUserMappings(projectId, { role, search });
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: mappings,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取用户映射列表失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个用户映射
|
||||
*/
|
||||
export async function getUserMapping(
|
||||
request: FastifyRequest<{ Params: MappingIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId, mappingId } = request.params;
|
||||
const service = getIitUserMappingService(prisma);
|
||||
const mapping = await service.getUserMapping(projectId, mappingId);
|
||||
|
||||
if (!mapping) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: '用户映射不存在',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: mapping,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取用户映射失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户映射
|
||||
*/
|
||||
export async function createUserMapping(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams; Body: CreateUserMappingInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId } = request.params;
|
||||
const input = request.body;
|
||||
|
||||
// 验证必填字段 - 只有企业微信用户 ID 是必填的
|
||||
if (!input.wecomUserId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '请输入企业微信用户 ID',
|
||||
});
|
||||
}
|
||||
|
||||
// 如果没有提供 systemUserId,使用 wecomUserId 作为默认值
|
||||
if (!input.systemUserId) {
|
||||
input.systemUserId = input.wecomUserId;
|
||||
}
|
||||
// 如果没有提供 redcapUsername,使用 wecomUserId 作为默认值
|
||||
if (!input.redcapUsername) {
|
||||
input.redcapUsername = input.wecomUserId;
|
||||
}
|
||||
// 如果没有提供 role,默认为 PI
|
||||
if (!input.role) {
|
||||
input.role = 'PI';
|
||||
}
|
||||
|
||||
const service = getIitUserMappingService(prisma);
|
||||
const mapping = await service.createUserMapping(projectId, input);
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: mapping,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('创建用户映射失败', { error: message });
|
||||
|
||||
if (message.includes('已存在')) {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户映射
|
||||
*/
|
||||
export async function updateUserMapping(
|
||||
request: FastifyRequest<{ Params: MappingIdParams; Body: UpdateUserMappingInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId, mappingId } = request.params;
|
||||
const input = request.body;
|
||||
|
||||
const service = getIitUserMappingService(prisma);
|
||||
const mapping = await service.updateUserMapping(projectId, mappingId, input);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: mapping,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('更新用户映射失败', { error: message });
|
||||
|
||||
if (message.includes('不存在')) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户映射
|
||||
*/
|
||||
export async function deleteUserMapping(
|
||||
request: FastifyRequest<{ Params: MappingIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId, mappingId } = request.params;
|
||||
const service = getIitUserMappingService(prisma);
|
||||
await service.deleteUserMapping(projectId, mappingId);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '删除成功',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('删除用户映射失败', { error: message });
|
||||
|
||||
if (message.includes('不存在')) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色选项
|
||||
*/
|
||||
export async function getRoleOptions(
|
||||
_request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const service = getIitUserMappingService(prisma);
|
||||
const options = service.getRoleOptions();
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户映射统计
|
||||
*/
|
||||
export async function getUserMappingStats(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId } = request.params;
|
||||
const service = getIitUserMappingService(prisma);
|
||||
const stats = await service.getUserMappingStats(projectId);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取用户映射统计失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* IIT 用户映射管理路由
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './iitUserMappingController.js';
|
||||
|
||||
export async function iitUserMappingRoutes(fastify: FastifyInstance) {
|
||||
// 获取角色选项(不需要项目 ID)
|
||||
fastify.get('/roles', controller.getRoleOptions);
|
||||
|
||||
// 获取项目的用户映射列表
|
||||
fastify.get('/:projectId/users', controller.listUserMappings);
|
||||
|
||||
// 获取用户映射统计
|
||||
fastify.get('/:projectId/users/stats', controller.getUserMappingStats);
|
||||
|
||||
// 获取单个用户映射
|
||||
fastify.get('/:projectId/users/:mappingId', controller.getUserMapping);
|
||||
|
||||
// 创建用户映射
|
||||
fastify.post('/:projectId/users', controller.createUserMapping);
|
||||
|
||||
// 更新用户映射
|
||||
fastify.put('/:projectId/users/:mappingId', controller.updateUserMapping);
|
||||
|
||||
// 删除用户映射
|
||||
fastify.delete('/:projectId/users/:mappingId', controller.deleteUserMapping);
|
||||
}
|
||||
206
backend/src/modules/admin/iit-projects/iitUserMappingService.ts
Normal file
206
backend/src/modules/admin/iit-projects/iitUserMappingService.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* IIT 用户映射管理服务
|
||||
* 管理企业微信用户与 REDCap 用户的映射关系
|
||||
*/
|
||||
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface CreateUserMappingInput {
|
||||
systemUserId: string;
|
||||
redcapUsername: string;
|
||||
wecomUserId?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserMappingInput {
|
||||
systemUserId?: string;
|
||||
redcapUsername?: string;
|
||||
wecomUserId?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface UserMappingListFilters {
|
||||
role?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// ==================== 服务实现 ====================
|
||||
|
||||
export class IitUserMappingService {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
/**
|
||||
* 获取项目的用户映射列表
|
||||
*/
|
||||
async listUserMappings(projectId: string, filters?: UserMappingListFilters) {
|
||||
const where: Prisma.IitUserMappingWhereInput = {
|
||||
projectId,
|
||||
};
|
||||
|
||||
if (filters?.role) {
|
||||
where.role = filters.role;
|
||||
}
|
||||
|
||||
if (filters?.search) {
|
||||
where.OR = [
|
||||
{ systemUserId: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ redcapUsername: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ wecomUserId: { contains: filters.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const mappings = await this.prisma.iitUserMapping.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个用户映射
|
||||
*/
|
||||
async getUserMapping(projectId: string, mappingId: string) {
|
||||
return this.prisma.iitUserMapping.findFirst({
|
||||
where: {
|
||||
id: mappingId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户映射
|
||||
*/
|
||||
async createUserMapping(projectId: string, input: CreateUserMappingInput) {
|
||||
// 检查项目是否存在
|
||||
const project = await this.prisma.iitProject.findFirst({
|
||||
where: { id: projectId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error('项目不存在');
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = await this.prisma.iitUserMapping.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
OR: [
|
||||
{ systemUserId: input.systemUserId },
|
||||
{ redcapUsername: input.redcapUsername },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('该用户或 REDCap 用户名已存在映射');
|
||||
}
|
||||
|
||||
const mapping = await this.prisma.iitUserMapping.create({
|
||||
data: {
|
||||
projectId,
|
||||
systemUserId: input.systemUserId,
|
||||
redcapUsername: input.redcapUsername,
|
||||
wecomUserId: input.wecomUserId,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('创建用户映射成功', { projectId, mappingId: mapping.id });
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户映射
|
||||
*/
|
||||
async updateUserMapping(projectId: string, mappingId: string, input: UpdateUserMappingInput) {
|
||||
const existing = await this.prisma.iitUserMapping.findFirst({
|
||||
where: { id: mappingId, projectId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new Error('用户映射不存在');
|
||||
}
|
||||
|
||||
const mapping = await this.prisma.iitUserMapping.update({
|
||||
where: { id: mappingId },
|
||||
data: {
|
||||
systemUserId: input.systemUserId,
|
||||
redcapUsername: input.redcapUsername,
|
||||
wecomUserId: input.wecomUserId,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('更新用户映射成功', { projectId, mappingId });
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户映射
|
||||
*/
|
||||
async deleteUserMapping(projectId: string, mappingId: string) {
|
||||
const existing = await this.prisma.iitUserMapping.findFirst({
|
||||
where: { id: mappingId, projectId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new Error('用户映射不存在');
|
||||
}
|
||||
|
||||
await this.prisma.iitUserMapping.delete({
|
||||
where: { id: mappingId },
|
||||
});
|
||||
|
||||
logger.info('删除用户映射成功', { projectId, mappingId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用角色列表
|
||||
*/
|
||||
getRoleOptions() {
|
||||
return [
|
||||
{ value: 'PI', label: '主要研究者 (PI)' },
|
||||
{ value: 'Sub-I', label: '次要研究者 (Sub-I)' },
|
||||
{ value: 'CRC', label: '临床研究协调员 (CRC)' },
|
||||
{ value: 'CRA', label: '临床监查员 (CRA)' },
|
||||
{ value: 'DM', label: '数据管理员 (DM)' },
|
||||
{ value: 'Statistician', label: '统计师' },
|
||||
{ value: 'Other', label: '其他' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按角色统计用户数量
|
||||
*/
|
||||
async getUserMappingStats(projectId: string) {
|
||||
const mappings = await this.prisma.iitUserMapping.findMany({
|
||||
where: { projectId },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
const stats: Record<string, number> = {};
|
||||
mappings.forEach((m) => {
|
||||
stats[m.role] = (stats[m.role] || 0) + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
total: mappings.length,
|
||||
byRole: stats,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 单例工厂函数
|
||||
let serviceInstance: IitUserMappingService | null = null;
|
||||
|
||||
export function getIitUserMappingService(prisma: PrismaClient): IitUserMappingService {
|
||||
if (!serviceInstance) {
|
||||
serviceInstance = new IitUserMappingService(prisma);
|
||||
}
|
||||
return serviceInstance;
|
||||
}
|
||||
15
backend/src/modules/admin/iit-projects/index.ts
Normal file
15
backend/src/modules/admin/iit-projects/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* IIT 项目管理模块导出
|
||||
*/
|
||||
|
||||
export { iitProjectRoutes } from './iitProjectRoutes.js';
|
||||
export { iitQcRuleRoutes } from './iitQcRuleRoutes.js';
|
||||
export { iitUserMappingRoutes } from './iitUserMappingRoutes.js';
|
||||
export { iitBatchRoutes } from './iitBatchRoutes.js';
|
||||
export { IitProjectService, getIitProjectService } from './iitProjectService.js';
|
||||
export { IitQcRuleService, getIitQcRuleService } from './iitQcRuleService.js';
|
||||
export { IitUserMappingService, getIitUserMappingService } from './iitUserMappingService.js';
|
||||
export * from './iitProjectController.js';
|
||||
export * from './iitQcRuleController.js';
|
||||
export * from './iitUserMappingController.js';
|
||||
export * from './iitBatchController.js';
|
||||
Reference in New Issue
Block a user