简介:在临床研究中,纸质数据采集方式常伴随效率低、易出错与核查困难等问题,而EDC系统正是解决这一系列难题的核心工具。这套EDC系统完整源代码,面向临床试验软件开发者、数据管理人员及希望构建自有数据采集平台的技术团队,提供了一套可运行的Java实现。压缩包共6个文件,包含4个Java源文件与2个scc辅助文件,整体仅17KB,代码轻量、结构清晰。源码涵盖临床试验项目管理、CRF(病例报告表)在线设计、数据录入、疑问管理、逻辑自动校验、CRF与DCF导出PDF、统计报表以及自定义查询等核心环节,尤其便于研究者自定义实验流程与数据验证规则。目前已有1729人学习下载,既能帮助入门者理解EDC系统的分层架构与业务流转,也为有二次开发需求的人员提供了直接可参考的代码基底。
1. EDC系统完整源代码:先说清它到底解决临床研究里的什么问题
做过临床数据管理的人都明白,EDC系统最难的不是把表单做成网页,而是让每一格数据经得起稽查。临床试验里的数据采集,过去靠纸质CRF,填写随意、修改无痕、多中心汇总全靠人工录入,一个数字写错可能要等统计分析阶段才能暴露。EDC把整个过程变成一条可校验、可追踪、可锁定的数据流:填写的每一格都有校验规则,每一次修改都有记录,每一个质疑都有流转状态。拿到这套完整源代码,核心价值不在“有哪些页面”,而在它能不能撑起数据完整性、审计追踪和多中心权限隔离这三件事。适合做医疗信息化的开发者、初创CRO的技术负责人,以及想自建EDC平台的某临床研究团队——读懂并改造这套源码,比从零开始省下至少半年的建模和踩坑时间。
2. 拆解EDC系统核心模块:数据模型和表单引擎决定代码量的半壁江山
2.1 从纸质CRF到eCRF:数据模型怎么设计才能撑起多中心研究
纸质CRF的层级是:研究方案→中心→受试者→访视→表单→填写项。EDC系统要复现这套结构,数据库设计必须按同样的层级拆。最常见的做法是用六张核心表分别对应研究、中心、受试者、访视、表单定义和填写值,而不是把所有填写内容塞进一张大宽表。
研究(study)和中心(site)必须分离。一个研究可能同时有七八个中心在入组,同一个表单在A中心和B中心可能启用不同字段。把研究属性和中心属性拆开,后续做中心级权限、中心级表单配置才有落点。受试者表挂在中心下面,用研究+中心+受试者编号做唯一约束,避免多中心编号重复导致的数据串组。
字段值不建议直接存“男/女”这类显示文本,推荐存编码值加一份字典表。这个我们放到2.3节展开。先看整体表清单,四张核心表的设计能覆盖绝大多数EDC应用场景:
| 表名 | 作用域 | 说明 |
|---|---|---|
| study | 研究级 | 研究方案、分期、状态、开始结束时间 |
| site | 中心级 | 中心编号、机构名称、启用状态 |
| subject | 受试者级 | 随机号、入组日期、生命状态 |
| form_def | 访视+表单级 | 存储eCRF的JSON定义,含字段、校验、跳题逻辑 |
| record_value | 数据实例 | 每个受试者每个访视每个表单的一条填写记录,值存JSONB |
| audit_log | 数据实例 | 字段级修改前值与修改后值,审计追踪只认这张表 |
受试者表要冗余存一个当前访视序号,业务上叫visit_seq。为什么冗余?因为EDC的访视跳转必须受控:受试者没完成V2访视的必填表单,系统要禁止直接跳到V3。这个状态如果每次查询时现算,访问量和计算开销都高,冗余字段配合状态机更稳。
2.2 动态表单引擎:用JSON Schema存表单定义,避免为每个访视写死页面
每个临床研究的CRF都不一样,同一个研究的访视2和访视3表单也可能完全不同。如果前端为每个表单写一个页面组件,新研究一启动就要改代码,这是所有EDC源码里最拖后腿的写法。可靠的方案是做动态表单引擎:表单结构存JSON,后端负责解析和校验,前端负责按JSON递归渲染。
一份eCRF定义用JSON描述,结构是form加fields数组,每个field声明字段编码、显示名、数据类型、是否必填、候选值和校验规则。下面是最小可用示例:
{ "form_code": "VS_ECG", "form_name": "十二导联心电图", "version": "1.0", "visit_code": "V2", "fields": [ { "field_code": "ECG_DONE", "field_label": "是否完成心电图检查", "data_type": "radio", "required": true, "options": [ { "code": "1", "label": "是" }, { "code": "0", "label": "否" } ] }, { "field_code": "ECG_HR", "field_label": "心率(次/分)", "data_type": "number", "required": true, "unit": "bpm", "validation": { "range": [30, 250] } }, { "field_code": "ECG_NOTE", "field_label": "异常描述", "data_type": "text", "required": false, "skip_logic": { "condition": "ECG_DONE == 1", "explain": "仅当完成检查且结果为异常时填写" } } ] }这个JSON是form_def表的payload字段内容。解释一下关键设计:field_code是全表单唯一编码,后续的跳题逻辑、质疑生成、审计日志都引用它;data_type决定前端渲染控件和后端存值类型;validation里的range只是字段级校验的入口,真正复杂的跨字段校验在2.4节的规则引擎里。
表单版本必须跟着存。表单定义一旦发布,填写中的数据不能直接用新定义渲染,否则字段编码变了、取值范围变了,旧数据对不上。常见做法是form_def表带version字段,发布后生成新版本,受试者已录入的版本保持不变,新受试者用新版本。这条后面避坑章节还会细讲。
2.3 数据字典与编码值:为什么不直接存“男/女”而要存“1/2”
如果直接在数据库里存“男/女”这种中文文本,统计阶段就等着翻车。EDC的填充分两种:一种来自标准化字典,比如性别、种族、是否吸烟;另一种来自自由文本,比如不良事件描述、合并用药名称。字典类字段的标准做法是存编码值,显示文本通过字典表在渲染时映射。
设计一套data_dict表,结构是dict_type加code加label加sort_order。性别字典就是dict_type='GENDER',code='1'对应label='男'。表单定义里radio/select的options直接引用dict_type,不写死label。这样换成英文界面、或者统计端要求label按某标准术语集输出时,只改字典表,不用动已填写的业务数据。
统计分析阶段SAS程序员最怕的一件事:A中心的“是”存成了"Y",B中心的“是”存成了"1",合并数据集时全部要手工清洗。用统一编码值就是为了规避这个问题。凡是带options的字段,所有中心必须拉同一份字典;如果研究允许中心扩展某些选项,扩展部分必须走申请流程,由数据管理员在系统里开放,不能让中心自己随便加字段值。
2.4 校验规则引擎:SAS风格校验表达式的设计与实现
字段级校验比较简单,范围、正则、必填。真正难的是跨字段和跨表单校验,比如“心电图结果选异常时,异常描述必填”,或者“不良事件开始日期不能晚于研究中心关闭日期”。这类规则不能写死在Java代码里,必须支持配置化。
常见做法是定义一套轻量表达式语法,字段引用用字段编码,操作用==、!=、>、<、&&、||,后端用表达式解析器在保存时执行校验。下面是一段表达式执行的核心逻辑的Java简化实现:
public class EvalRule { // 用简单递归下降解析表达式,避免引入重规则引擎 public boolean eval(String expr, Map<String, Object> values) { // expr示例: "ECG_DONE == 1 && ECG_HR >= 30" expr = expr.replaceAll("\\s+", ""); if (expr.contains("&&")) { String[] parts = expr.split("&&"); for (String p : parts) { if (!evalSingle(p, values)) return false; } return true; } return evalSingle(expr, values); } private boolean evalSingle(String expr, Map<String, Object> values) { String[] parts = expr.split("=="); if (parts.length == 2) { Object actual = values.get(parts[0]); Object expected = typeConvert(parts[1]); return actual != null && actual.equals(expected); } parts = expr.split(">"); if (parts.length == 2) { double actual = Double.parseDouble(values.get(parts[0]).toString()); double expected = Double.parseDouble(parts[1]); return actual > expected; } throw new IllegalArgumentException("不支持的表达式: " + expr); } }逻辑说明:这段代码处理&&组合和单条件两类场景,单条件支持==和>,实际工程还要补充!=、<、>=、<=以及字段与字段比较。typeConvert负责把表达式右值转成和字段值一致的类型,比如"1"转Integer,日期转LocalDate。注意只支持同一层级字段的引用,跨表单引用需要靠form_code前缀,例如"VS_ECG.ECG_HR > 100",解析时先按前缀取对应表单的值。
参数方面,校验规则的每一条都要配message文案,因为保存失败时前端要展示给录入员看;还要配rule_level,分“报错”和“警示”两级,报错阻止保存,警示允许保存但弹提示。临床数据管理里有很多非硬性校验,比如“体重和上次访视差超过20%”,用警示级最合适,硬报错反而拖慢入组速度。
3. 用Spring Boot落地EDC后端:完整源代码的最小闭环
3.1 搭建工程骨架与数据库表结构:PostgreSQL JSONB就是为EDC准备的
EDC的填写数据天然是半结构化:同一张表单,有的中心可能额外填了备注字段,有的字段在方案修订后会被弃用。用关系表为每个字段建列,改一次方案就要迁移一次表,维护成本极高。实际项目里我见过两种路线:一种是MySQL存大文本序列化内容,另一种是PostgreSQL JSONB。后者有两个优势,JSONB支持字段内索引,而且更新时能够只改部分键,配合GIN索引可以按表单内容检索。
建表SQL至少要覆盖研究、表单定义、填写数据和审计日志这四条链路。下面是精简后的核心建表语句:
CREATE TABLE study ( study_id BIGSERIAL PRIMARY KEY, study_code VARCHAR(40) NOT NULL UNIQUE, study_status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE subject ( subject_id BIGSERIAL PRIMARY KEY, study_id BIGINT NOT NULL REFERENCES study(study_id), site_id BIGINT NOT NULL, subject_no VARCHAR(30) NOT NULL, current_visit_seq INT NOT NULL DEFAULT 1, status VARCHAR(20) NOT NULL DEFAULT 'SCREENING', UNIQUE (study_id, site_id, subject_no) ); CREATE TABLE form_def ( form_def_id BIGSERIAL PRIMARY KEY, study_id BIGINT NOT NULL REFERENCES study(study_id), form_code VARCHAR(50) NOT NULL, version INT NOT NULL DEFAULT 1, payload JSONB NOT NULL, publish_status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', UNIQUE (study_id, form_code, version) ); CREATE TABLE record_value ( record_id BIGSERIAL PRIMARY KEY, subject_id BIGINT NOT NULL REFERENCES subject(subject_id), form_def_id BIGINT NOT NULL REFERENCES form_def(form_def_id), visit_seq INT NOT NULL, visit_date DATE, values_json JSONB NOT NULL, record_status VARCHAR(20) NOT NULL DEFAULT 'SAVED', updated_by VARCHAR(50), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE audit_log ( audit_id BIGSERIAL PRIMARY KEY, record_id BIGINT NOT NULL, field_code VARCHAR(50) NOT NULL, old_value JSONB, new_value JSONB, action VARCHAR(20) NOT NULL, operator VARCHAR(50) NOT NULL, operated_at TIMESTAMPTZ NOT NULL DEFAULT now() );逻辑说明:form_def以JSONB存表单定义,每次改版生成新version,这样一个研究下同一表单就有多个版本。record_value表的values_json直接存填写内容的键值对,比如{"ECG_DONE":"1","ECG_HR":"75"}。为什么要单独存updated_by而不从session里现取?审计日志的operator必须记录操作时的身份,之后即使账号被禁用,历史的操作者信息依然可查。
参数说明:subject_id上用JSONB字段内值查询,需要为高频检索字段建表达式索引,例如对"values_json->>'ECG_HR'"建index,否则后期按字段筛选会全表扫。record_status建议按临床数据管理习惯分:SAVED已保存、SUBMITTED已提交、LOCKED已锁定,锁定的记录只能由数据管理员解锁。
3.2 实现eCRF渲染接口:把form_def JSON和已填数据拼成视图返回前端
前端要渲染一个表单页面,需要两方面内容:表单定义(字段、类型、选项、跳题逻辑)和这个受试者当前已填写的值。后端接口做的事就是把form_def的payload和record_value的values_json合并返回。
@RestController @RequestMapping("/api/edc") public class EcrfController { @GetMapping("/subjects/{subjectId}/forms/{formDefId}") public ResponseEntity<Map<String, Object>> renderForm( @PathVariable Long subjectId, @PathVariable Long formDefId) { FormDef formDef = formDefService.getById(formDefId); RecordValue record = recordValueService .findBySubjectAndForm(subjectId, formDefId) .orElse(null); Map<String, Object> result = new HashMap<>(); result.put("form_definition", formDef.getPayload()); // 已有值合并到定义里,前端按 field_code 取默认值 result.put("values", record == null ? new HashMap<>() : record.getValuesJson()); // 记录当前填写的record_id,前端保存时回传 result.put("record_id", record == null ? null : record.getRecordId()); // 表单级锁定状态,锁定后前端所有控件只读 result.put("locked", record != null && "LOCKED".equals(record.getRecordStatus())); return ResponseEntity.ok(result); } }逻辑说明:这个接口的重点不在代码量,而在约定——渲染时后端只负责给数据和状态,前端拿到两个JSON自己做合并。locked字段必须后端判断,不能只靠前端隐藏按钮;临床数据管理里锁定状态是强约束,后端不做校验,用户绕过前端直接调保存接口就废了。
参数说明:渲染接口要考虑空表单场景。受试者还没填过这个表单时,record是null,values返回空Map,record_id返回null。前端据此把保存按钮从“更新”变成“初次保存”,存储走两条不同SQL。另外visit_seq要在渲染时从subject表取,禁止前端传,防止用户跨访视写数据。
3.3 数据保存与稽查轨迹:一个AOP切面解决“谁在什么时间改了什么”
EDC的审计追踪是整个系统的合规生命线。审核时审计人员会抽查某条记录的某个字段,要求看到完整的修改历史:原始值、修改后的值、操作人、操作时间。实现方式是在保存数据的方法上切入AOP,保存前读取更新前的记录,保存后计算字段级差异写入audit_log。
@Aspect @Component public class AuditAspect { private final AuditLogRepository auditRepo; @Around("@annotation(com.example.edc.core.AuditTrail)") public Object auditRecord(ProceedingJoinPoint pjp) throws Throwable { Object[] args = pjp.getArgs(); SaveRecordRequest req = (SaveRecordRequest) args[0]; // 保存前查旧值 RecordValue oldRecord = recordValueRepository.findById(req.getRecordId()).orElse(null); Object result = pjp.proceed(); // 保存后查新值 RecordValue newRecord = recordValueRepository.findById(req.getRecordId()).orElse(null); if (oldRecord == null) { // 首次保存:所有值算新增 newRecord.getValuesJson().forEach((k, v) -> auditRepo.save(buildAudit(k, null, v, "CREATE", req.getOperator()))); } else { Map<String, Object> oldValues = oldRecord.getValuesJson(); Map<String, Object> newValues = newRecord.getValuesJson(); newValues.forEach((k, newVal) -> { Object oldVal = oldValues.get(k); if (!Objects.equals(oldVal, newVal)) { auditRepo.save(buildAudit(k, oldVal, newVal, "UPDATE", req.getOperator())); } }); // 处理被清空的字段 oldValues.forEach((k, oldVal) -> { if (!newValues.containsKey(k)) { auditRepo.save(buildAudit(k, oldVal, null, "UPDATE", req.getOperator())); } }); } return result; } }逻辑说明:切面拦截带@AuditTrail注解的保存方法,保存前后各查一次记录。比较时用键逐个比对,旧值存在而新值缺失的字段视为清空处理。这里有一个关键边界:审计日志只记录业务字段的变更,不记录updated_at这类系统字段,否则里面全是“系统修改了自己”的噪音。
参数说明:字段级比较建议用Jackson把JSONB转成Map再比较,注意数字类型。比如前端把75.0传回来,Jackson可能解析成Double,而库里存的是Integer,Objects.equals会认为不一致,产生无效审计。解决办法是统一在入库前把数值型JSON值转成BigDecimal再存,比较也要基于BigDecimal。这个属于典型的“代码看着没问题,审计日志里全是假差异”的坑。
3.4 病例状态机与质疑管理:数据清洗的第一道闸门
EDC里数据不是填完就完事了。一条记录要经历已保存→已提交→已锁定三个阶段,锁定后任何修改都要先走解锁流程。状态机控制在后端实现,前端只根据状态渲染可操作按钮。
质疑(Query)是EDC特有的数据清洗工具。所有临床机构都会回答“为什么要改这个数据”问一下数据管理员:某字段值和原始病历不一致,数据管理员发起质疑,中心医生答复或修改,质疑关闭。质疑必须有独立表:query_id、record_id、field_code、question、answer、status、query_type。
质疑状态流转:OPEN(待答复)→ANSWERED(已答复)→CLOSED(已关闭);如果答复不通过,从ANSWERED重新回到OPEN。后端要拦截一个动作:数据已锁定的记录被提出质疑后,中心医生填写答复,此时系统自动解锁该字段,医生修改后重新提交,质疑状态置为CLOSED。这个流程用状态枚举加一张流转记录表做,不要把状态字段存成字符串散在各处。后续表结构要控制好并发——同一时刻只有一条质疑处于待答复状态,可以在queried字段上加唯一索引约束,防止数据管理员重复发同字段质疑。
4. 前端与电子签名:从动态渲染到合规填写的最后一公里
4.1 用Vue实现动态表单渲染器:组件递归渲染与字段联动
后端返回的form_def是一个嵌套JSON,前端渲染器的核心是写一个自定义组件RenderField,根据data_type分发到不同控件:radio渲染单选框、number渲染数字输入框、text渲染文本框。这个组件要递归渲染,因为复杂表单里可能有“重复组”结构,比如合并用药列表,一个组里包含药品名称、给药途径、剂量几个字段,组本身是数组。碰到group类型,组件循环渲染子字段数组,并维护每个子项的索引。
Vue实现的核心逻辑如下:
<template> <div> <template v-for="item in fields" :key="item.field_code"> <component :is="resolveComponent(item.data_type)" :field="item" :model-value="modelValue[item.field_code]" :disabled="locked" v-bind="getComponentProps(item)" @update:model-value="updateField(item.field_code, $event)" /> </template> </div> </template>逻辑说明:model-value是当前表单的所有值对象,updateField回调负责更新这个对象并触发联动检查。组件分发时注意日期控件和下拉控件的事件签名差异,统一在updateField内部做类型适配。锁定时disabled要传给所有子组件,防止只读状态被绕过。
参数说明:Vue的动态组件resolveComponent需要注册所有可能用到的控件类型,新增自定义控件时必须在注册表里声明,否则后端JSON多一个data_type,前端直接白屏。实际工程中建议在渲染器里做未知类型的fallback,渲染成警告条而不是让整个页面崩掉。
4.2 联动与跳题逻辑:隐藏字段不能只靠前端做
跳题逻辑常被做成前端行为:ECG_DONE选“否”时隐藏ECG_HR输入框,看起来省事。但合规的EDC必须遵守一个原则——凡是跳题逻辑,前端隐藏只是为了录入体验,后端必须用同一份规则执行判定,否则编辑时被隐藏的字段值会被随意丢弃。
实现上,前端在updateField触发时解析form_def里的skip_logic条件,匹配则把对应字段的值从待提交数据里剔除并重置为空。后端在保存接口收到values后,用同一个表达式引擎重新评估所有带skip_logic的字段,把“隐藏却仍有值”的字段清空并计入审计。双向校验的目的只有一个:无论用户怎么操作前端,最终落库的数据集必须满足逻辑一致性。
计算字段也要往前端放一份。比如BMI=体重/身高²,输入身高体重后前端实时算,但真正的BMI存库值必须以后端计算结果为准,前端算的值只做展示,计算结果在保存时由后端重新算好覆盖前端传值。
4.3 保存策略与电子签名:何时自动保存,何时必须提交
临床录入场景的网络很不稳定,尤其在医院病区里,Wi-Fi断连是常态。最常见的保存策略是“自动保存加手动提交”:录入员每30秒或字段失焦时防抖保存一次,保存的数据不入正式库,只update到record_value并标记record_status=SAVED;录入员确认无误后点“提交并签名”,此时系统弹出电子签名框,输入密码后调用签名验证接口,验证通过记录才置为SUBMITTED。
电子签名逻辑不能是前端弹个框确认就算完成。后端签名接口要校验三件事:密码是否正确、当前记录是否处于可提交状态、是否在有效签名时间段内。签名信息落库信号量记录:签名人、签名时间、签名动作(提交/修改/解锁)、关联记录ID。提交后任何值变更都必须生成一条新的质疑或走解锁申请,签名接口本身也纳入审计轨迹。
前端的离线缓存要克制。不要在localStorage里缓存一整个表单的长期数据,只缓存当前未提交的临时编辑。缓存时要带上record_id和表单版本号,重新联网后先校验版本号是否一致,不一致则提示刷新页面,防止老版本表单的数据覆盖新版本定义。
5. EDC系统完整源代码落地避坑:5个临床现场跑出来的血泪经验
5.1 坑一:多中心时区不一致,SAE时间记录全部错乱
现象:某中心录入的严重不良事件开始时间,比实际病历记录的时间早了8小时,审计阶段差一点被判定为数据不真实。原因排查发现,该中心位于不同时区,前端用本地时间生成时间戳,后端按服务器默认时区解析,时间被强制转换了一次。解决:系统内所有时间统一存UTC,前端时间控件一律传UTC字符串,显示时按浏览器时区转换;受试者相关的访视日期属于业务日期,必须由录入员手动选择年月日,禁止用系统时间自动带入。这条在遇到跨国多中心研究时尤其致命。
5.2 坑二:表单版本升级后,已录入数据回填失败
现象:某研究中期修订了CRF,心电图表单新增了一个字段“QTc间期”,版本从1.0升到1.1;结果老版本已经填好的数据在重新打开时,新字段显示空白,保存时还被校验规则拦截,说必填项缺失。原因:前端用新版本定义渲染旧数据,旧记录里没有新字段值,但新版本的必填校验对全记录生效了。解决:表单定义和填写值必须绑定版本——record_value要冗余存form_version,保存时以该版本定义执行校验,渲染时加载该版本的payload,升级版本只影响新记录。核心原则是“一个记录对应一个版本”,既不能拿新版本校验旧数据,也不能用旧定义渲染新数据。
5.3 坑三:稽查日志只记了“改了什么”没记“改前改后”
现象:某次内部模拟稽查,审计人员随机抽查了一条记录,日志显示字段ECG_HR被修改过,但操作时间和修改人都有,唯独看不到修改前的值,结果没法确认这次修改是否合规。原因:早期版本审计日志只存new_value,为了省存储,后来需要回溯时才发现没有old_value,数据完整性链条断了一截。解决:审计日志必须存旧值和新值两列,且值序列化要保留原始类型。补充一个细节:清空字段也要记一条审计,很多实现直接把空值忽略,导致“值被清掉”这个动作在历史里消失。
5.4 坑四:数据字典想当然,统计口径在统计分析阶段崩掉
现象:一个多中心研究,A中心“是否吸烟”下拉框是“是/否/不吸烟”,B中心是“吸烟/不吸烟/未记录”,C中心直接文本输入。统计分析阶段SAS程序员拼数据集时发现,三家的“不吸烟”对应了三种code,只能手工重新映射,花了两个星期清洗。原因:表单定义里的options绕过了数据字典,各中心按自己的习惯配置。解决:字段设计阶段先拉一个枚举清单,radio和select必须引用统一字典配置,禁止在表单JSON里直接写死options;如果有中心特殊需求,走字典扩展流程,扩展的code由数据管理员统一分配,任何中心不得自定义。
5.5 坑五:权限控制只做了角色没做数据隔离
现象:某中心的研究者登录系统后,理论上只能看到本中心受试者数据,结果通过直接拼接URL传subject_id,就能打开其他中心的受试者记录。原因:后端接口只做了登录认证和角色校验,没有做数据权限隔离。解决:所有查询接口强制带site_id范围,数据权限在Service层统一追加过滤条件,不能依赖前端传参。建议写一个基类方法:查询时必须显式调用withScopeData(),由当前登录用户的研究角色决定能访问的中心列表,再和查询条件取交集。这个要做到接口层,不只在菜单层。
6. 把源代码变成可交付的系统:验证链路与二次开发技巧
6.1 用模拟数据跑一遍完整业务链路,验证状态机是否闭环
拿到一套EDC源码,先不要急着加功能,把基础链路跑通再谈扩展。我习惯用SQL造一套最小模拟数据,覆盖“中心创建→受试者入组→表单填写→提交→质疑→修改→重新提交→锁定”的全过程。下面是验证用的核心脚本片段:
-- 模拟验证:确认记录状态流转符合预期 INSERT INTO record_value (record_id, values_json, record_status) SELECT 1, '{"ECG_HR": 78}', 'SAVED' WHERE NOT EXISTS (SELECT 1 FROM record_value WHERE record_id = 1); -- 尝试提交已锁定记录,预期被拦截 UPDATE record_value SET record_status = 'LOCKED' WHERE record_id = 1; -- 断言:锁定后提交操作应拒绝 DO $$ BEGIN IF EXISTS (SELECT 1 FROM record_value WHERE record_id = 1 AND record_status IN ('SAVED', 'SUBMITTED')) THEN RAISE NOTICE '状态异常,记录仍处于可提交状态'; END IF; END $$;这条脚本验证三类约束:初始状态能否正确创建、状态流转是否受控、锁定后是否被接口层拦截。凡是状态机相关改动,都应该用这类SQL脚本在CI里做回归,防止某次改分支逻辑把锁定校验漏掉。
6.2 二次开发优先级和需要预留的扩展点
临床数据管理的标准化输出是EDC后端绕不开的一环。建议把导出功能做成模板化:先支持常用的数据集格式CSV、SAS传输文件,再支持CDISC标准的ODM XML导出。另外两件事值得优先考虑:一是与外部系统对接的中心化随机化接口,二是不良事件编码的自动映射。前者影响入组效率,后者直接影响药物警戒报告时效。这两块可以在源码基础上做插件式扩展:定义好接口文档,把外部系统对接参数放在配置表里,不要写死在代码里。
我现在的习惯是,任何EDC项目里先跑一遍“录入→质疑→锁定”的干路流程,确认状态机和审计链路没问题,再看字段级权限和报表。一套源码最贵的地方就是这些看不见的边界规则,功能界面再多,状态机有漏洞一样不敢过审计。希望这些经验能帮到你。
本文还有配套的精品资源,点击获取